diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d225e7b..c90049df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Makefile b/Makefile index cc0baeff..2a628c65 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/go.mod b/go.mod index fdea3c4a..5e288a77 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -118,6 +133,7 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -142,6 +158,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 diff --git a/go.sum b/go.sum index 2a5d44ce..98da11cc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 8a4a549a..0e9cae82 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -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) }) } diff --git a/internal/service/access_controls_service_test.go b/internal/service/access_controls_service_test.go index 7c877fa3..55bf028c 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -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 diff --git a/internal/service/docker_service.go b/internal/service/docker_service.go index 417baf6f..28b12f8e 100644 --- a/internal/service/docker_service.go +++ b/internal/service/docker_service.go @@ -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 diff --git a/internal/service/kubernetes_crd_extractor.go b/internal/service/kubernetes_crd_extractor.go new file mode 100644 index 00000000..633f15e3 --- /dev/null +++ b/internal/service/kubernetes_crd_extractor.go @@ -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, + }, + } +} diff --git a/internal/service/kubernetes_ingress_extractor.go b/internal/service/kubernetes_ingress_extractor.go index b03882b0..7447e1a3 100644 --- a/internal/service/kubernetes_ingress_extractor.go +++ b/internal/service/kubernetes_ingress_extractor.go @@ -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, } } diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 3e232194..e0743757 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -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,7 +18,9 @@ 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" + "k8s.io/client-go/tools/clientcmd" ) type watchedResource struct { @@ -32,10 +32,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 +63,20 @@ 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", + }, + typ: ResourceTypeCRD, + }, } type typedItem struct { typ ResourceType ingress *networking.Ingress + crd *v1alpha1.Application } func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) { @@ -84,7 +84,7 @@ func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &typed) if err != nil { var zero *T - return zero, fmt.Errorf("failed to convert ingress to typed object: %w", err) + return zero, fmt.Errorf("failed to convert to typed object: %w", err) } return typed, nil } @@ -100,38 +100,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 { @@ -143,9 +133,14 @@ type KubernetesServiceInput struct { } func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) { - cfg, err := rest.InClusterConfig() + service := &KubernetesService{ + log: i.Log, + apps: make(map[ResourceMeta]map[string]model.App), + } + + cfg, err := service.getConfig() if err != nil { - return nil, fmt.Errorf("failed to get in-cluster kubernetes config: %w", err) + return nil, fmt.Errorf("failed to get kubernetes config: %w", err) } client, err := dynamic.NewForConfig(cfg) @@ -153,15 +148,13 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) 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.client = client + service.typedClient = typedClient watchedGVRs := make(map[string]bool) @@ -171,8 +164,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 +188,92 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) return service, nil } -func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) { - k.mu.Lock() - defer k.mu.Unlock() - k.apps[key] = routedApps{ - hosts: hosts, - entries: entries, +func (k *KubernetesService) getConfig() (*rest.Config, error) { + config, err := rest.InClusterConfig() + + if err == nil { + return config, nil } + + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + &clientcmd.ConfigOverrides{}, + ).ClientConfig() } -func (k *KubernetesService) removeResource(key resourceKey) { +func (k *KubernetesService) addResource(result ExtractionResult) { k.mu.Lock() defer k.mu.Unlock() - delete(k.apps, key) + k.apps[*result.Meta] = result.Apps } -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) removeResource(meta ResourceMeta) { + k.mu.Lock() + defer k.mu.Unlock() + delete(k.apps, meta) +} +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) - } - - if result == 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 + 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) } - 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) + if event == watch.Deleted { + if result.Meta != nil { + k.removeResource(*result.Meta) + } 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 slices.ContainsFunc(result.hosts, func(host string) bool { - return hostCoversName(host, name) - }) { - entries = append(entries, resourceEntry{ - name: name, - app: config, - }) + if result.Apps == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping") + if result.Meta != nil { + k.removeResource(*result.Meta) } - } - - 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 +291,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 +321,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 +369,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 } diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index 47481b10..1629b6a6 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package service import ( diff --git a/pkg/apis/tinyauth/v1alpha1/application.go b/pkg/apis/tinyauth/v1alpha1/application.go new file mode 100644 index 00000000..e37ee964 --- /dev/null +++ b/pkg/apis/tinyauth/v1alpha1/application.go @@ -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"` +} diff --git a/pkg/apis/tinyauth/v1alpha1/crds/tinyauth.app_applications.yaml b/pkg/apis/tinyauth/v1alpha1/crds/tinyauth.app_applications.yaml new file mode 100644 index 00000000..440d6db8 --- /dev/null +++ b/pkg/apis/tinyauth/v1alpha1/crds/tinyauth.app_applications.yaml @@ -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 diff --git a/pkg/apis/tinyauth/v1alpha1/doc.go b/pkg/apis/tinyauth/v1alpha1/doc.go new file mode 100644 index 00000000..4d9977c0 --- /dev/null +++ b/pkg/apis/tinyauth/v1alpha1/doc.go @@ -0,0 +1,3 @@ +package v1alpha1 + +//+groupName=tinyauth.app diff --git a/pkg/apis/tinyauth/v1alpha1/mapper.go b/pkg/apis/tinyauth/v1alpha1/mapper.go new file mode 100644 index 00000000..ac1c46e3 --- /dev/null +++ b/pkg/apis/tinyauth/v1alpha1/mapper.go @@ -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, + }, + } +} diff --git a/pkg/apis/tinyauth/v1alpha1/register.go b/pkg/apis/tinyauth/v1alpha1/register.go new file mode 100644 index 00000000..3ba54b0a --- /dev/null +++ b/pkg/apis/tinyauth/v1alpha1/register.go @@ -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 +} diff --git a/pkg/apis/tinyauth/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/tinyauth/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..f0c1e60e --- /dev/null +++ b/pkg/apis/tinyauth/v1alpha1/zz_generated.deepcopy.go @@ -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 +}