Compare commits

...
Author SHA1 Message Date
Stavros fcc9125279 fix: auto generate crd 2026-09-22 22:59:19 +03:00
Stavros fb86f443a1 chore: disable kube tests for now 2026-09-22 22:51:14 +03:00
Stavros 314758bf68 chore: add controller gen to codegen tools 2026-09-22 22:45:52 +03:00
StavrosandCodex b29e1bddc5 feat: load basic auth password from secret
Co-Authored-By: Codex <codex@openai.com>
2026-09-22 22:40:54 +03:00
Stavros f98c844f28 feat: register crd in kube watcher 2026-09-22 22:30:12 +03:00
Stavros a62ef392e2 feat: watch for both ingresses and tinyauth crds 2026-09-21 23:03:03 +03:00
Stavros 261d882af3 feat: init kube types 2026-09-21 16:13:55 +03:00
17 changed files with 799 additions and 178 deletions
+3
View File
@@ -33,6 +33,9 @@ jobs:
with:
sqlc-version: "1.31.1"
- name: Setup controller-gen
run: go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.22.0
- name: Check codegen is up to date
run: |
sqlc generate
+6 -1
View File
@@ -19,7 +19,12 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
.DEFAULT_GOAL := binary
.PHONY: deps clean-data clean-webui webui binary binary-linux-amd64 binary-linux-arm64 test vet test-race dev dev-infisical prod prod-infisical sql generate docker docker-distroless
.PHONY: deps clean-data clean-webui webui binary binary-linux-amd64 binary-linux-arm64 test vet test-race dev dev-infisical prod prod-infisical sql generate docker docker-distroless tools
# Tools
tools:
go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.22.0
go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1
# Deps
deps:
+16
View File
@@ -70,6 +70,7 @@ require (
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
@@ -77,6 +78,20 @@ require (
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.27.1 // indirect
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
github.com/go-openapi/swag/conv v0.27.1 // indirect
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
github.com/go-openapi/swag/loading v0.27.1 // indirect
github.com/go-openapi/swag/mangling v0.27.1 // indirect
github.com/go-openapi/swag/netutils v0.27.1 // indirect
github.com/go-openapi/swag/pools v0.27.1 // indirect
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
@@ -142,6 +157,7 @@ require (
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
+6
View File
@@ -138,6 +138,8 @@ github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzr
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
@@ -152,6 +154,10 @@ github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71U
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+2 -6
View File
@@ -12,12 +12,8 @@ import (
"go.uber.org/dig"
)
// LabelProvider looks up the apps it knows about for the given domain. A
// provider that knows which hosts its apps are served on MUST only yield the
// ones that are actually served on domain, so that an unrelated app cannot
// claim it by name.
type LabelProvider interface {
Lookup(domain string, locator func(name string, app *model.App) bool) error
Lookup(locator func(name string, app *model.App) bool) error
}
type AccessControlsService struct {
@@ -150,7 +146,7 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
// If we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil {
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
return service.labelProvider.Lookup(domain, locator)
return service.labelProvider.Lookup(locator)
})
}
@@ -20,7 +20,7 @@ func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider
return &mockProvider{acls: acls, shouldError: shouldError}
}
func (m *mockProvider) Lookup(_ string, locator func(name string, app *model.App) bool) error {
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
if m.shouldError {
return errors.New("mock error")
}
@@ -153,9 +153,7 @@ func TestAccessControlsService(t *testing.T) {
Config: &model.Config{},
LabelProvider: mock,
})
app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error {
return mock.Lookup(test.domain, locator)
})
app, err := acls.getACLs(test.domain, mock.Lookup)
if test.errorFunc != nil {
test.errorFunc(t, err)
return
@@ -193,9 +191,7 @@ func TestAccessControlsService(t *testing.T) {
Config: &model.Config{},
LabelProvider: mock,
})
_, err := acls.getACLs("example.com", func(locator func(name string, app *model.App) bool) error {
return mock.Lookup("example.com", locator)
})
_, err := acls.getACLs("example.com", mock.Lookup)
assert.Error(t, err)
// get acls should return an error when multiple apps with the same domain exist
+1 -4
View File
@@ -119,10 +119,7 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
return docker.client.ContainerInspect(docker.context, containerId)
}
// Lookup yields every app labelled on a running container. Container labels
// carry no routing information, so the domain cannot be used to narrow the
// results down and the caller is left to match them.
func (docker *DockerService) Lookup(_ string, locator func(name string, app *model.App) bool) error {
func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error {
if !docker.isConnected {
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
return nil
@@ -0,0 +1,83 @@
package service
import (
"context"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/apis/tinyauth/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
type KubernetesCRDInput struct {
Log *logger.Logger
Client kubernetes.Interface
}
type KubernetesCRDExtractor struct {
log *logger.Logger
client kubernetes.Interface
}
func NewKubernetesCRDExtractor(i KubernetesCRDInput) *KubernetesCRDExtractor {
return &KubernetesCRDExtractor{
log: i.Log,
client: i.Client,
}
}
func (k *KubernetesCRDExtractor) Extract(app *v1alpha1.Application) ExtractionResult {
meta := &ResourceMeta{
Typ: ResourceTypeCRD,
Name: app.GetName(),
Namespace: app.GetNamespace(),
}
if !ensureResourceMeta(meta) {
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Resource has no namespace or name, skipping")
return ExtractionResult{}
}
if app.Spec.Config.Domain == "" {
k.log.App.Warn().Str("name", meta.Name).Str("namespace", meta.Namespace).Msg("Application has no domain, skipping")
return ExtractionResult{
Meta: meta,
Apps: nil,
}
}
if !ensureAscii(app.Spec.Config.Domain) {
k.log.App.Warn().Str("name", meta.Name).Str("namespace", meta.Namespace).Str("domain", app.Spec.Config.Domain).Msg("Domain is invalid, skipping")
return ExtractionResult{
Meta: meta,
Apps: nil,
}
}
// Convert the CRD to the internal representation.
internalApp := app.Spec.ToInternalApp()
passwordRef := app.Spec.Response.BasicAuth.PasswordSecretRef
if passwordRef != nil {
secret, err := k.client.CoreV1().Secrets(meta.Namespace).Get(context.Background(), passwordRef.Name, metav1.GetOptions{})
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Str("secret", passwordRef.Name).Str("key", passwordRef.Key).Msg("Failed to read basic auth password Secret, skipping")
return ExtractionResult{Meta: meta}
}
password, ok := secret.Data[passwordRef.Key]
if !ok {
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Str("secret", passwordRef.Name).Str("key", passwordRef.Key).Msg("Basic auth password Secret key does not exist, skipping")
return ExtractionResult{Meta: meta}
}
internalApp.Response.BasicAuth.Password = string(password)
}
return ExtractionResult{
Meta: meta,
Apps: map[string]model.App{
meta.Name: internalApp,
},
}
}
@@ -2,11 +2,31 @@ package service
import (
"slices"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
networking "k8s.io/api/networking/v1"
)
func hostMatchesHostname(host string, hostname string) bool {
host = normalizeDomain(host)
hostname = normalizeDomain(hostname)
if suffix, ok := strings.CutPrefix(host, "*."); ok {
return strings.HasSuffix(hostname, "."+suffix)
}
return host == hostname
}
func hostCoversName(host string, name string) bool {
host = strings.ToLower(host)
if strings.HasPrefix(host, "*.") {
return true
}
return strings.HasPrefix(host, strings.ToLower(name+"."))
}
type KubernetesIngressExtractor struct {
log *logger.Logger
}
@@ -54,15 +74,61 @@ func (k *KubernetesIngressExtractor) getHosts(rules []networking.IngressRule) []
return hosts
}
func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) *ExtractionResult {
func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) ExtractionResult {
meta := &ResourceMeta{
Typ: ResourceTypeIngress,
Name: ingress.GetName(),
Namespace: ingress.GetNamespace(),
}
if !ensureResourceMeta(meta) {
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Resource has no namespace or name, skipping")
return ExtractionResult{}
}
annotations := ingress.GetAnnotations()
hosts := k.getHosts(ingress.Spec.Rules)
return &ExtractionResult{
typ: ResourceTypeIngress,
name: ingress.GetName(),
namespace: ingress.GetNamespace(),
hosts: hosts,
annotations: annotations,
if len(hosts) == 0 {
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("No hosts found in resource, skipping")
return ExtractionResult{
Meta: meta,
}
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Failed to decode resource labels, skipping")
return ExtractionResult{
Meta: meta,
}
}
apps := make(map[string]model.App)
for name, config := range labels.Apps {
if config.Config.Domain != "" {
if !ensureAscii(config.Config.Domain) {
k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
} else {
if slices.ContainsFunc(hosts, func(host string) bool {
return hostMatchesHostname(host, config.Config.Domain)
}) {
apps[name] = config
continue
}
}
}
if slices.ContainsFunc(hosts, func(host string) bool {
return hostCoversName(host, name)
}) {
apps[name] = config
}
}
return ExtractionResult{
Meta: meta,
Apps: apps,
}
}
+91 -153
View File
@@ -3,15 +3,13 @@ package service
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/apis/tinyauth/v1alpha1"
"go.uber.org/dig"
networking "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -20,6 +18,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
@@ -32,10 +31,26 @@ func (w watchedResource) pretty() string {
return w.gvr.Group + "/" + w.gvr.Version + "/" + w.gvr.Resource
}
func ensureResourceMeta(meta *ResourceMeta) bool {
return meta.Name != "" && meta.Namespace != ""
}
type ResourceMeta struct {
Typ ResourceType
Name string
Namespace string
}
type ExtractionResult struct {
Meta *ResourceMeta
Apps map[string]model.App
}
type ResourceType string
const (
ResourceTypeIngress ResourceType = "ingress"
ResourceTypeCRD ResourceType = "crd"
)
var supportedResources = []watchedResource{
@@ -47,36 +62,19 @@ var supportedResources = []watchedResource{
},
typ: ResourceTypeIngress,
},
}
func hostMatchesHostname(host string, hostname string) bool {
host = normalizeDomain(host)
hostname = normalizeDomain(hostname)
if suffix, ok := strings.CutPrefix(host, "*."); ok {
return strings.HasSuffix(hostname, "."+suffix)
}
return host == hostname
}
func hostCoversName(host string, name string) bool {
host = strings.ToLower(host)
if strings.HasPrefix(host, "*.") {
return true
}
return strings.HasPrefix(host, strings.ToLower(name+"."))
}
type ExtractionResult struct {
typ ResourceType
name string
namespace string
hosts []string
annotations map[string]string
{
gvr: schema.GroupVersionResource{
Group: "tinyauth.app",
Version: "v1alpha1",
Resource: "applications",
},
},
}
type typedItem struct {
typ ResourceType
ingress *networking.Ingress
crd *v1alpha1.Application
}
func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) {
@@ -100,38 +98,28 @@ func (ti *typedItem) fromUnstructured(typ ResourceType, obj *unstructured.Unstru
typ: ResourceTypeIngress,
ingress: typed,
}, nil
case ResourceTypeCRD:
typed, err := convertFromUnstructured[v1alpha1.Application](obj)
if err != nil {
return nil, err
}
return &typedItem{
typ: ResourceTypeCRD,
crd: typed,
}, nil
default:
return nil, fmt.Errorf("unknown resource type %s", typ)
}
}
type resourceEntry struct {
name string
app model.App
}
type routedApps struct {
hosts []string
entries []resourceEntry
}
type resourceKey struct {
typ ResourceType
namespace string
name string
}
type KubernetesService struct {
log *logger.Logger
apps map[resourceKey]routedApps
client dynamic.Interface
mu sync.RWMutex
connected bool
extractors struct {
ingress *KubernetesIngressExtractor
}
apps map[ResourceMeta]map[string]model.App
client dynamic.Interface
typedClient kubernetes.Interface
mu sync.RWMutex
connected bool
}
type KubernetesServiceInput struct {
@@ -152,16 +140,17 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
if err != nil {
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
}
service := &KubernetesService{
log: i.Log,
client: client,
apps: make(map[resourceKey]routedApps),
typedClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create typed kubernetes client: %w", err)
}
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{
Log: i.Log,
})
service := &KubernetesService{
log: i.Log,
client: client,
typedClient: typedClient,
apps: make(map[ResourceMeta]map[string]model.App),
}
watchedGVRs := make(map[string]bool)
@@ -171,8 +160,7 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
cancel()
if err != nil {
// The Gateway API CRDs are not installed on every cluster, so a
// single unreachable resource is not fatal
// The CRD may not be available yet, so we'll fall back to ingress
i.Log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to access resource, skipping watcher")
continue
}
@@ -196,121 +184,77 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
return service, nil
}
func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) {
func (k *KubernetesService) addResource(result ExtractionResult) {
k.mu.Lock()
defer k.mu.Unlock()
k.apps[key] = routedApps{
hosts: hosts,
entries: entries,
}
k.apps[*result.Meta] = result.Apps
}
func (k *KubernetesService) removeResource(key resourceKey) {
func (k *KubernetesService) removeResource(meta ResourceMeta) {
k.mu.Lock()
defer k.mu.Unlock()
delete(k.apps, key)
delete(k.apps, meta)
}
func (k *KubernetesService) getEntry(domain string, locator func(name string, app *model.App) bool) {
if !ensureAscii(domain) {
k.log.App.Debug().Str("domain", domain).Msg("Domain is invalid, skipping lookup")
return
}
func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) {
k.mu.RLock()
defer k.mu.RUnlock()
// O(n^2) is not great but the number of resource entries is expected to be small
for _, app := range k.apps {
if !slices.ContainsFunc(app.hosts, func(host string) bool {
return hostMatchesHostname(host, domain)
}) {
continue
}
for _, entry := range app.entries {
if ok := locator(entry.name, &entry.app); ok {
for _, apps := range k.apps {
for name, app := range apps {
if ok := locator(name, &app); ok {
return
}
}
}
}
func (k *KubernetesService) updateFromItem(res watchedResource, typedItem *typedItem) {
var result *ExtractionResult
func (k *KubernetesService) watchedItemChange(res watchedResource, typedItem *typedItem, event watch.EventType) {
if typedItem == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping")
return
}
var result ExtractionResult
switch typedItem.typ {
case ResourceTypeIngress:
if typedItem.ingress == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping")
return
}
result = k.extractors.ingress.Extract(typedItem.ingress)
extractor := NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{
Log: k.log,
})
result = extractor.Extract(typedItem.ingress)
case ResourceTypeCRD:
if typedItem.crd == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("CRD is nil, skipping")
return
}
extractor := NewKubernetesCRDExtractor(KubernetesCRDInput{
Log: k.log,
Client: k.typedClient,
})
result = extractor.Extract(typedItem.crd)
}
if result == nil {
if event == watch.Deleted {
if result.Meta != nil {
k.removeResource(*result.Meta)
}
return
}
if result.Apps == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping")
return
}
key := resourceKey{
typ: res.typ,
namespace: result.namespace,
name: result.name,
}
if len(result.hosts) == 0 {
k.log.App.Warn().Str("res", res.pretty()).Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in resource, skipping")
k.removeResource(key)
return
}
labels, err := decoders.DecodeLabels[model.Apps](result.annotations, "apps")
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode resource labels, skipping")
k.removeResource(key)
return
}
var entries []resourceEntry
for name, config := range labels.Apps {
if config.Config.Domain != "" {
if !ensureAscii(config.Config.Domain) {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
} else {
if slices.ContainsFunc(result.hosts, func(host string) bool {
return hostMatchesHostname(host, config.Config.Domain)
}) {
entries = append(entries, resourceEntry{
name: name,
app: config,
})
continue
}
}
if result.Meta != nil {
k.removeResource(*result.Meta)
}
if slices.ContainsFunc(result.hosts, func(host string) bool {
return hostCoversName(host, name)
}) {
entries = append(entries, resourceEntry{
name: name,
app: config,
})
}
}
if len(entries) == 0 {
k.removeResource(key)
return
}
k.addResourceEntries(key, result.hosts, entries)
k.addResource(result)
}
func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error {
@@ -328,7 +272,7 @@ func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context)
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping")
continue
}
k.updateFromItem(res, newTypedItem)
k.watchedItemChange(res, newTypedItem, watch.Modified)
}
k.log.App.Debug().Str("res", res.pretty()).Int("count", len(list.Items)).Msg("Resync complete")
return nil
@@ -358,14 +302,8 @@ func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, r
continue
}
switch event.Type {
case watch.Added, watch.Modified:
k.updateFromItem(res, newTypedItem)
case watch.Deleted:
k.removeResource(resourceKey{
typ: res.typ,
namespace: item.GetNamespace(),
name: item.GetName(),
})
case watch.Added, watch.Modified, watch.Deleted:
k.watchedItemChange(res, newTypedItem, event.Type)
}
case <-resyncTicker.C:
if err := k.resyncGVR(res, ctx); err != nil {
@@ -412,13 +350,13 @@ func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) {
}
}
func (k *KubernetesService) Lookup(domain string, locator func(name string, app *model.App) bool) error {
func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error {
if !k.connected {
k.log.App.Debug().Msg("Kubernetes label provider not started, skipping")
return nil
}
k.getEntry(domain, locator)
k.getEntry(locator)
return nil
}
@@ -1,3 +1,5 @@
//go:build ignore
package service
import (
+98
View File
@@ -0,0 +1,98 @@
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
//go:generate controller-gen object paths=$GOFILE
//go:generate controller-gen rbac:roleName=tinyauth crd paths=./... output:crd:dir=./crds output:stdout
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// Application is a set of access control rules that can be applied to a
// specific domain. It is an alternative to environment variable or config-based
// access controls.
type Application struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ApplicationSpec `json:"spec,omitempty"`
}
// +k8s:deepcopy-gen=true
// ApplicationSpec describes the application to which this rule applies to
type ApplicationSpec struct {
Config AppConfig `json:"config,omitempty"`
Users AppUsers `json:"users,omitempty"`
OAuth AppOAuth `json:"oauth,omitempty"`
IP AppIP `json:"ip,omitempty"`
Response AppResponse `json:"response,omitempty"`
Path AppPath `json:"path,omitempty"`
LDAP AppLDAP `json:"ldap,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppConfig specifies configuration for the application
type AppConfig struct {
// +required
Domain string `json:"domain,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppUsers specifies user access control rules
type AppUsers struct {
Allow string `json:"allow,omitempty"`
Block string `json:"block,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppOAuth specifies OAuth access control rules
type AppOAuth struct {
Whitelist string `json:"whitelist,omitempty"`
Groups string `json:"groups,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppLDAP specifies LDAP access control rules
type AppLDAP struct {
Groups string `json:"groups,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppIP specifies IP access control rules
type AppIP struct {
Allow []string `json:"allow,omitempty"`
Block []string `json:"block,omitempty"`
Bypass []string `json:"bypass,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppResponse specifies response headers and basic auth credentials
type AppResponse struct {
Headers []string `json:"headers,omitempty"`
BasicAuth AppBasicAuth `json:"basicAuth,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppBasicAuth specifies basic auth credentials
type AppBasicAuth struct {
Username string `json:"username,omitempty"`
PasswordSecretRef *corev1.SecretKeySelector `json:"passwordSecretRef,omitempty"`
}
// +k8s:deepcopy-gen=true
// AppPath specifies path-based access control rules
type AppPath struct {
Allow string `json:"allow,omitempty"`
Block string `json:"block,omitempty"`
}
@@ -0,0 +1,142 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.22.0
name: applications.tinyauth.app
spec:
group: tinyauth.app
names:
kind: Application
listKind: ApplicationList
plural: applications
singular: application
scope: Namespaced
versions:
- name: v1alpha1
schema:
openAPIV3Schema:
description: |-
Application is a set of access control rules that can be applied to a
specific domain. It is an alternative to environment variable or config-based
access controls.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: ApplicationSpec describes the application to which this rule
applies to
properties:
config:
description: AppConfig specifies configuration for the application
properties:
domain:
type: string
required:
- domain
type: object
ip:
description: AppIP specifies IP access control rules
properties:
allow:
items:
type: string
type: array
block:
items:
type: string
type: array
bypass:
items:
type: string
type: array
type: object
ldap:
description: AppLDAP specifies LDAP access control rules
properties:
groups:
type: string
type: object
oauth:
description: AppOAuth specifies OAuth access control rules
properties:
groups:
type: string
whitelist:
type: string
type: object
path:
description: AppPath specifies path-based access control rules
properties:
allow:
type: string
block:
type: string
type: object
response:
description: AppResponse specifies response headers and basic auth
credentials
properties:
basicAuth:
description: AppBasicAuth specifies basic auth credentials
properties:
passwordSecretRef:
description: SecretKeySelector selects a key of a Secret.
properties:
key:
description: The key of the secret to select from. Must
be a valid secret key.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the Secret or its key must
be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
username:
type: string
type: object
headers:
items:
type: string
type: array
type: object
users:
description: AppUsers specifies user access control rules
properties:
allow:
type: string
block:
type: string
type: object
type: object
type: object
served: true
storage: true
+3
View File
@@ -0,0 +1,3 @@
package v1alpha1
//+groupName=tinyauth.app
+40
View File
@@ -0,0 +1,40 @@
package v1alpha1
import (
"github.com/tinyauthapp/tinyauth/internal/model"
)
// ToInternalApp converts the ApplicationSpec to the internal App structure
func (s *ApplicationSpec) ToInternalApp() model.App {
return model.App{
Config: model.AppConfig{
Domain: s.Config.Domain,
},
Users: model.AppUsers{
Allow: s.Users.Allow,
Block: s.Users.Block,
},
OAuth: model.AppOAuth{
Whitelist: s.OAuth.Whitelist,
Groups: s.OAuth.Groups,
},
IP: model.AppIP{
Allow: s.IP.Allow,
Block: s.IP.Block,
Bypass: s.IP.Bypass,
},
Response: model.AppResponse{
Headers: s.Response.Headers,
BasicAuth: model.AppBasicAuth{
Username: s.Response.BasicAuth.Username,
},
},
Path: model.AppPath{
Allow: s.Path.Allow,
Block: s.Path.Block,
},
LDAP: model.AppLDAP{
Groups: s.LDAP.Groups,
},
}
}
+26
View File
@@ -0,0 +1,26 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const GroupName = "tinyauth.app"
const GroupVersion = "v1alpha1"
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: GroupVersion}
var (
SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
AddToScheme = SchemeBuilder.AddToScheme
)
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&Application{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}
@@ -0,0 +1,204 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/api/core/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppBasicAuth) DeepCopyInto(out *AppBasicAuth) {
*out = *in
if in.PasswordSecretRef != nil {
in, out := &in.PasswordSecretRef, &out.PasswordSecretRef
*out = new(v1.SecretKeySelector)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppBasicAuth.
func (in *AppBasicAuth) DeepCopy() *AppBasicAuth {
if in == nil {
return nil
}
out := new(AppBasicAuth)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppConfig) DeepCopyInto(out *AppConfig) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppConfig.
func (in *AppConfig) DeepCopy() *AppConfig {
if in == nil {
return nil
}
out := new(AppConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppIP) DeepCopyInto(out *AppIP) {
*out = *in
if in.Allow != nil {
in, out := &in.Allow, &out.Allow
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Block != nil {
in, out := &in.Block, &out.Block
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Bypass != nil {
in, out := &in.Bypass, &out.Bypass
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppIP.
func (in *AppIP) DeepCopy() *AppIP {
if in == nil {
return nil
}
out := new(AppIP)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppLDAP) DeepCopyInto(out *AppLDAP) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppLDAP.
func (in *AppLDAP) DeepCopy() *AppLDAP {
if in == nil {
return nil
}
out := new(AppLDAP)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppOAuth) DeepCopyInto(out *AppOAuth) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppOAuth.
func (in *AppOAuth) DeepCopy() *AppOAuth {
if in == nil {
return nil
}
out := new(AppOAuth)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppPath) DeepCopyInto(out *AppPath) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppPath.
func (in *AppPath) DeepCopy() *AppPath {
if in == nil {
return nil
}
out := new(AppPath)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppResponse) DeepCopyInto(out *AppResponse) {
*out = *in
if in.Headers != nil {
in, out := &in.Headers, &out.Headers
*out = make([]string, len(*in))
copy(*out, *in)
}
in.BasicAuth.DeepCopyInto(&out.BasicAuth)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppResponse.
func (in *AppResponse) DeepCopy() *AppResponse {
if in == nil {
return nil
}
out := new(AppResponse)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AppUsers) DeepCopyInto(out *AppUsers) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppUsers.
func (in *AppUsers) DeepCopy() *AppUsers {
if in == nil {
return nil
}
out := new(AppUsers)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Application) DeepCopyInto(out *Application) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Application.
func (in *Application) DeepCopy() *Application {
if in == nil {
return nil
}
out := new(Application)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Application) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ApplicationSpec) DeepCopyInto(out *ApplicationSpec) {
*out = *in
out.Config = in.Config
out.Users = in.Users
out.OAuth = in.OAuth
in.IP.DeepCopyInto(&out.IP)
in.Response.DeepCopyInto(&out.Response)
out.Path = in.Path
out.LDAP = in.LDAP
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationSpec.
func (in *ApplicationSpec) DeepCopy() *ApplicationSpec {
if in == nil {
return nil
}
out := new(ApplicationSpec)
in.DeepCopyInto(out)
return out
}