From 419da20681f2f073c2a63484ace2bc7a84a918da Mon Sep 17 00:00:00 2001 From: Contre Date: Mon, 18 May 2026 11:44:45 +0200 Subject: [PATCH 1/6] feat(k8s): Support new Gateway api --- internal/service/kubernetes_service.go | 110 +++++++++++--------- internal/service/kubernetes_service_test.go | 78 +++++++++++--- 2 files changed, 125 insertions(+), 63 deletions(-) diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 8976cb54e..9f8980842 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -19,17 +19,17 @@ import ( "k8s.io/client-go/rest" ) -type ingressKey struct { +type resourceKey struct { namespace string name string } -type ingressAppKey struct { - ingressKey +type resourceAppKey struct { + resourceKey appName string } -type ingressApp struct { +type resourceApp struct { domain string appName string app model.App @@ -42,9 +42,22 @@ type KubernetesService struct { client dynamic.Interface started bool mu sync.RWMutex - ingressApps map[ingressKey][]ingressApp - domainIndex map[string]ingressAppKey - appNameIndex map[string]ingressAppKey + resourceApps map[resourceKey][]resourceApp + domainIndex map[string]resourceAppKey + appNameIndex map[string]resourceAppKey +} + +var watchedGVRs = []schema.GroupVersionResource{ + { + Group: "networking.k8s.io", + Version: "v1", + Resource: "ingresses", + }, + { + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "httproutes", + }, } func NewKubernetesService( @@ -62,74 +75,75 @@ func NewKubernetesService( return nil, fmt.Errorf("failed to create kubernetes client: %w", err) } - gvr := schema.GroupVersionResource{ - Group: "networking.k8s.io", - Version: "v1", - Resource: "ingresses", + service := &KubernetesService{ + log: log, + ctx: ctx, + client: client, + resourceApps: make(map[resourceKey][]resourceApp), + domainIndex: make(map[string]resourceAppKey), + appNameIndex: make(map[string]resourceAppKey), } accessCtx, accessCancel := context.WithTimeout(ctx, 5*time.Second) defer accessCancel() - _, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1}) - if err != nil { - log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access Ingress API, Kubernetes label provider will be disabled") - return nil, fmt.Errorf("failed to access ingress api: %w", err) + started := 0 + for _, gvr := range watchedGVRs { + _, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1}) + if err != nil { + log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access API, skipping watcher") + continue + } + log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed API, starting watcher") + gvrCopy := gvr + wg.Go(func() { + service.watchGVR(gvrCopy) + }) + started++ } - log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher") - - service := &KubernetesService{ - log: log, - ctx: ctx, - client: client, - ingressApps: make(map[ingressKey][]ingressApp), - domainIndex: make(map[string]ingressAppKey), - appNameIndex: make(map[string]ingressAppKey), + if started == 0 { + return nil, fmt.Errorf("failed to access any supported kubernetes API (ingresses, httproutes)") } - wg.Go(func() { - service.watchGVR(gvr) - }) - service.started = true log.App.Debug().Msg("Kubernetes label provider started successfully") return service, nil } -func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) { +func (k *KubernetesService) addResourceApps(namespace, name string, apps []resourceApp) { k.mu.Lock() defer k.mu.Unlock() - key := ingressKey{namespace, name} - // Remove existing entries for this ingress - if existing, ok := k.ingressApps[key]; ok { + key := resourceKey{namespace, name} + // Remove existing entries for this resource + if existing, ok := k.resourceApps[key]; ok { for _, app := range existing { delete(k.domainIndex, app.domain) delete(k.appNameIndex, app.appName) } } // Add new entries - k.ingressApps[key] = apps + k.resourceApps[key] = apps for _, app := range apps { - appKey := ingressAppKey{key, app.appName} + appKey := resourceAppKey{key, app.appName} k.domainIndex[app.domain] = appKey k.appNameIndex[app.appName] = appKey } } -func (k *KubernetesService) removeIngress(namespace, name string) { +func (k *KubernetesService) removeResource(namespace, name string) { k.mu.Lock() defer k.mu.Unlock() - key := ingressKey{namespace, name} - if apps, ok := k.ingressApps[key]; ok { + key := resourceKey{namespace, name} + if apps, ok := k.resourceApps[key]; ok { for _, app := range apps { delete(k.domainIndex, app.domain) delete(k.appNameIndex, app.appName) } - delete(k.ingressApps, key) + delete(k.resourceApps, key) } } @@ -138,7 +152,7 @@ func (k *KubernetesService) getByDomain(domain string) *model.App { defer k.mu.RUnlock() if appKey, ok := k.domainIndex[domain]; ok { - if apps, ok := k.ingressApps[appKey.ingressKey]; ok { + if apps, ok := k.resourceApps[appKey.resourceKey]; ok { for i := range apps { app := &apps[i] if app.domain == domain && app.appName == appKey.appName { @@ -155,7 +169,7 @@ func (k *KubernetesService) getByAppName(appName string) *model.App { defer k.mu.RUnlock() if appKey, ok := k.appNameIndex[appName]; ok { - if apps, ok := k.ingressApps[appKey.ingressKey]; ok { + if apps, ok := k.resourceApps[appKey.resourceKey]; ok { for i := range apps { app := &apps[i] if app.appName == appName { @@ -172,30 +186,30 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { name := item.GetName() annotations := item.GetAnnotations() if annotations == nil { - k.removeIngress(namespace, name) + k.removeResource(namespace, name) return } labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") if err != nil { - k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping") - k.removeIngress(namespace, name) + k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode labels, skipping") + k.removeResource(namespace, name) return } - var apps []ingressApp + var apps []resourceApp for appName, appLabels := range labels.Apps { if appLabels.Config.Domain == "" { continue } - apps = append(apps, ingressApp{ + apps = append(apps, resourceApp{ domain: appLabels.Config.Domain, appName: appName, app: appLabels, }) } if len(apps) == 0 { - k.removeIngress(namespace, name) + k.removeResource(namespace, name) } else { - k.addIngressApps(namespace, name, apps) + k.addResourceApps(namespace, name, apps) } } @@ -239,7 +253,7 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch. case watch.Added, watch.Modified: k.updateFromItem(item) case watch.Deleted: - k.removeIngress(item.GetNamespace(), item.GetName()) + k.removeResource(item.GetNamespace(), item.GetName()) } case <-resyncTicker.C: if err := k.resyncGVR(gvr); err != nil { diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index 702fe0f82..c58d28be4 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -25,7 +25,7 @@ func TestKubernetesService(t *testing.T) { description: "Cache by domain returns app and misses unknown domain", run: func(t *testing.T, svc *KubernetesService) { app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} - svc.addIngressApps("default", "my-ingress", []ingressApp{ + svc.addResourceApps("default", "my-ingress", []resourceApp{ {domain: "foo.example.com", appName: "foo", app: app}, }) @@ -41,7 +41,7 @@ func TestKubernetesService(t *testing.T) { description: "Cache by app name returns app and misses unknown name", run: func(t *testing.T, svc *KubernetesService) { app := model.App{Config: model.AppConfig{Domain: "bar.example.com"}} - svc.addIngressApps("default", "my-ingress", []ingressApp{ + svc.addResourceApps("default", "my-ingress", []resourceApp{ {domain: "bar.example.com", appName: "bar", app: app}, }) @@ -54,14 +54,14 @@ func TestKubernetesService(t *testing.T) { }, }, { - description: "RemoveIngress clears domain and app name entries", + description: "RemoveResource clears domain and app name entries", run: func(t *testing.T, svc *KubernetesService) { app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}} - svc.addIngressApps("default", "my-ingress", []ingressApp{ + svc.addResourceApps("default", "my-ingress", []resourceApp{ {domain: "baz.example.com", appName: "baz", app: app}, }) - svc.removeIngress("default", "my-ingress") + svc.removeResource("default", "my-ingress") got := svc.getByDomain("baz.example.com") assert.Nil(t, got) @@ -70,15 +70,15 @@ func TestKubernetesService(t *testing.T) { }, }, { - description: "AddIngressApps replaces stale entries for the same ingress", + description: "AddResourceApps replaces stale entries for the same resource", run: func(t *testing.T, svc *KubernetesService) { old := model.App{Config: model.AppConfig{Domain: "old.example.com"}} - svc.addIngressApps("default", "my-ingress", []ingressApp{ + svc.addResourceApps("default", "my-ingress", []resourceApp{ {domain: "old.example.com", appName: "old", app: old}, }) updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}} - svc.addIngressApps("default", "my-ingress", []ingressApp{ + svc.addResourceApps("default", "my-ingress", []resourceApp{ {domain: "new.example.com", appName: "new", app: updated}, }) @@ -96,7 +96,7 @@ func TestKubernetesService(t *testing.T) { svc.started = true app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} - svc.addIngressApps("default", "ing", []ingressApp{ + svc.addResourceApps("default", "ing", []resourceApp{ {domain: "hit.example.com", appName: "hit", app: app}, }) @@ -121,7 +121,7 @@ func TestKubernetesService(t *testing.T) { svc.started = true app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}} - svc.addIngressApps("default", "ing", []ingressApp{ + svc.addResourceApps("default", "ing", []resourceApp{ {domain: "myapp.internal.example.com", appName: "myapp", app: app}, }) @@ -139,7 +139,7 @@ func TestKubernetesService(t *testing.T) { }, }, { - description: "UpdateFromItem parses annotations and populates cache", + description: "UpdateFromItem parses annotations and populates cache from ingress", run: func(t *testing.T, svc *KubernetesService) { item := unstructured.Unstructured{} item.SetNamespace("default") @@ -157,11 +157,30 @@ func TestKubernetesService(t *testing.T) { assert.Equal(t, "alice", got.Users.Allow) }, }, + { + description: "UpdateFromItem parses annotations and populates cache from httproute", + run: func(t *testing.T, svc *KubernetesService) { + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-httproute") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", + "tinyauth.apps.gwapp.users.allow": "bob", + }) + + svc.updateFromItem(&item) + + got := svc.getByDomain("gwapp.example.com") + require.NotNil(t, got) + assert.Equal(t, "gwapp.example.com", got.Config.Domain) + assert.Equal(t, "bob", got.Users.Allow) + }, + }, { description: "UpdateFromItem with no annotations removes existing cache entries", run: func(t *testing.T, svc *KubernetesService) { app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}} - svc.addIngressApps("default", "test-ingress", []ingressApp{ + svc.addResourceApps("default", "test-ingress", []resourceApp{ {domain: "todelete.example.com", appName: "todelete", app: app}, }) @@ -175,14 +194,43 @@ func TestKubernetesService(t *testing.T) { assert.Nil(t, got) }, }, + { + description: "Ingress and HTTPRoute apps coexist in cache", + run: func(t *testing.T, svc *KubernetesService) { + ingress := unstructured.Unstructured{} + ingress.SetNamespace("default") + ingress.SetName("my-ingress") + ingress.SetAnnotations(map[string]string{ + "tinyauth.apps.ingapp.config.domain": "ingapp.example.com", + }) + + httproute := unstructured.Unstructured{} + httproute.SetNamespace("default") + httproute.SetName("my-httproute") + httproute.SetAnnotations(map[string]string{ + "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", + }) + + svc.updateFromItem(&ingress) + svc.updateFromItem(&httproute) + + got := svc.getByDomain("ingapp.example.com") + require.NotNil(t, got) + assert.Equal(t, "ingapp.example.com", got.Config.Domain) + + got = svc.getByDomain("gwapp.example.com") + require.NotNil(t, got) + assert.Equal(t, "gwapp.example.com", got.Config.Domain) + }, + }, } for _, test := range tests { t.Run(test.description, func(t *testing.T) { svc := &KubernetesService{ - ingressApps: make(map[ingressKey][]ingressApp), - domainIndex: make(map[string]ingressAppKey), - appNameIndex: make(map[string]ingressAppKey), + resourceApps: make(map[resourceKey][]resourceApp), + domainIndex: make(map[string]resourceAppKey), + appNameIndex: make(map[string]resourceAppKey), log: log, } test.run(t, svc) From 2769725775b38f65ad619f856c530d5a545d5ce1 Mon Sep 17 00:00:00 2001 From: Contre Date: Mon, 18 May 2026 14:48:01 +0200 Subject: [PATCH 2/6] feat(k8s): Support for GRPCRoute --- internal/service/kubernetes_service.go | 5 +++++ internal/service/kubernetes_service_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 9f8980842..34989c91d 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -58,6 +58,11 @@ var watchedGVRs = []schema.GroupVersionResource{ Version: "v1", Resource: "httproutes", }, + { + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "grpcroutes", + }, } func NewKubernetesService( diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index c58d28be4..8bcb58365 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -194,6 +194,25 @@ func TestKubernetesService(t *testing.T) { assert.Nil(t, got) }, }, + { + description: "UpdateFromItem parses annotations and populates cache from grpcroute", + run: func(t *testing.T, svc *KubernetesService) { + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-grpcroute") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.grpcapp.config.domain": "grpcapp.example.com", + "tinyauth.apps.grpcapp.users.allow": "carol", + }) + + svc.updateFromItem(&item) + + got := svc.getByDomain("grpcapp.example.com") + require.NotNil(t, got) + assert.Equal(t, "grpcapp.example.com", got.Config.Domain) + assert.Equal(t, "carol", got.Users.Allow) + }, + }, { description: "Ingress and HTTPRoute apps coexist in cache", run: func(t *testing.T, svc *KubernetesService) { From e1b1e722e76fa4ef840d76b091d9ae9e0d6d6e15 Mon Sep 17 00:00:00 2001 From: Contre Date: Wed, 19 Aug 2026 12:18:02 +0200 Subject: [PATCH 3/6] fix(acl): only let a label provider define ACLs for domains it routes The app name fallback matches any domain that starts with the app name, so an app named myapp served on myapp.example.com also defined the ACLs of myapp.evil.com. Behind a proxy with a catch-all route, a request can be authorized against the wrong app that way. Label providers now receive the domain being authorized. The Kubernetes provider keeps the hosts of every Ingress, HTTPRoute and GRPCRoute it watches and withholds the apps of the resources that do not route the domain, which bounds the name fallback to the hosts a resource actually serves. Wildcard hostnames keep matching as a suffix, so nested subdomains stay resolvable by app name. Container labels carry no routing information, so the Docker provider cannot narrow its results down and keeps yielding every app. Co-Authored-By: Claude Sonnet 4.6 --- internal/service/access_controls_service.go | 10 +- .../service/access_controls_service_test.go | 11 +- internal/service/docker_service.go | 5 +- internal/service/kubernetes_service.go | 60 +++-- internal/service/kubernetes_service_test.go | 240 ++++++++++++++---- 5 files changed, 255 insertions(+), 71 deletions(-) diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index f8816a1f2..c11d9fcbf 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -10,8 +10,12 @@ 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(locator func(name string, app *model.App) bool) error + Lookup(domain string, locator func(name string, app *model.App) bool) error } type AccessControlsService struct { @@ -113,7 +117,9 @@ 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, service.labelProvider.Lookup) + return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error { + return service.labelProvider.Lookup(domain, locator) + }) } // No labels diff --git a/internal/service/access_controls_service_test.go b/internal/service/access_controls_service_test.go index 30415933a..734f81815 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -18,7 +18,7 @@ func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider return &mockProvider{acls: acls, shouldError: shouldError} } -func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error { +func (m *mockProvider) Lookup(_ string, locator func(name string, app *model.App) bool) error { if m.shouldError { return errors.New("mock error") } @@ -121,7 +121,7 @@ func TestAccessControlsService(t *testing.T) { Config: &model.Config{}, LabelProvider: mock, }) - app, err := acls.getACLs(test.domain, mock.Lookup) + app, err := acls.GetAccessControls(test.domain) require.NoError(t, err) require.Equal(t, test.want, app) }) @@ -145,10 +145,11 @@ func TestAccessControlsService(t *testing.T) { // get acls should return an error when the provider fails mock := newMockProvider(map[string]model.App{}, true) acls := NewAccessControlsService(AccessControlServiceInput{ - Log: log, - Config: &model.Config{}, + Log: log, + Config: &model.Config{}, + LabelProvider: mock, }) - _, err := acls.getACLs("example.com", mock.Lookup) + _, err := acls.GetAccessControls("example.com") require.Error(t, err) // get access controls should get acls from diff --git a/internal/service/docker_service.go b/internal/service/docker_service.go index 21265a2e5..46e354e9e 100644 --- a/internal/service/docker_service.go +++ b/internal/service/docker_service.go @@ -67,7 +67,10 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins return docker.client.ContainerInspect(docker.context, containerId) } -func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error { +// 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 { if !docker.isConnected { docker.log.App.Debug().Msg("Docker service not connected, returning empty labels") return nil diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 671c62923..de292294a 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -73,6 +73,13 @@ type resourceEntry struct { app model.App } +// routedApps holds the apps annotated on a resource along with the hosts that +// resource routes, which bound the domains those apps may define ACLs for. +type routedApps struct { + hosts []string + entries []resourceEntry +} + // resourceKey identifies a watched resource. The kind is part of the key // because an Ingress and an HTTPRoute may share a name within a namespace. type resourceKey struct { @@ -84,10 +91,10 @@ type resourceKey struct { type KubernetesService struct { log *logger.Logger - client dynamic.Interface - connected bool - mu sync.RWMutex - resourceEntries map[resourceKey][]resourceEntry + client dynamic.Interface + connected bool + mu sync.RWMutex + resourceApps map[resourceKey]routedApps } type KubernetesServiceInput struct { @@ -110,9 +117,9 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) } service := &KubernetesService{ - log: i.Log, - client: client, - resourceEntries: make(map[resourceKey][]resourceEntry), + log: i.Log, + client: client, + resourceApps: make(map[resourceKey]routedApps), } watching := 0 @@ -148,25 +155,43 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) return service, nil } -func (k *KubernetesService) addResourceEntries(key resourceKey, entries []resourceEntry) { +func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) { k.mu.Lock() defer k.mu.Unlock() - k.resourceEntries[key] = entries + k.resourceApps[key] = routedApps{ + hosts: hosts, + entries: entries, + } } func (k *KubernetesService) removeResource(key resourceKey) { k.mu.Lock() defer k.mu.Unlock() - delete(k.resourceEntries, key) + delete(k.resourceApps, key) } -func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) { +func (k *KubernetesService) getEntry(domain string, locator func(name string, app *model.App) bool) { + v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) + + hostname, err := v.SafeHostname(domain) + if err != nil { + k.log.App.Debug().Err(err).Str("domain", domain).Msg("Domain is invalid, skipping lookup") + return + } + 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 _, entries := range k.resourceEntries { - for _, entry := range entries { + for _, apps := range k.resourceApps { + // Only a resource that routes the domain may define its ACLs, otherwise + // an app could claim any domain that happens to start with its name + if !slices.ContainsFunc(apps.hosts, func(host string) bool { + return hostMatches(host, hostname) + }) { + continue + } + for _, entry := range apps.entries { if ok := locator(entry.name, &entry.app); ok { return } @@ -439,7 +464,7 @@ func (k *KubernetesService) updateFromItem(res watchedResource, item *unstructur return } - k.addResourceEntries(key, entries) + k.addResourceEntries(key, hosts, entries) } func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error { @@ -533,13 +558,16 @@ func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) { } } -func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error { +// Lookup yields the apps annotated on the resources that route domain. Apps +// annotated on any other resource are withheld, since they are served +// elsewhere and must not define the ACLs of this domain. +func (k *KubernetesService) Lookup(domain string, 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(locator) + k.getEntry(domain, locator) return nil } diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index 116269a5b..90c0accf6 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -27,6 +27,22 @@ var ( testGRPCRouteResource = mustWatchedResource("grpcroutes") ) +// aclLocator mimics the way the access controls service matches apps, first on +// the configured domain and then on the app name. +func aclLocator(domain string, got **model.App) func(name string, app *model.App) bool { + return func(name string, app *model.App) bool { + if app.Config.Domain == domain { + *got = app + return true + } + if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) { + *got = app + return true + } + return false + } +} + func TestKubernetesService(t *testing.T) { log := logger.NewLogger().WithTestConfig() log.Init() @@ -45,7 +61,7 @@ func TestKubernetesService(t *testing.T) { resource: "ingresses", namespace: "default", name: "my-ingress", - }, []resourceEntry{ + }, []string{"foo.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -53,7 +69,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("foo.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "foo.example.com" { got = app return true @@ -62,6 +78,13 @@ func TestKubernetesService(t *testing.T) { }) require.NotNil(t, got) assert.Equal(t, "foo.example.com", got.Config.Domain) + + got = nil + svc.getEntry("unknown.example.com", func(name string, app *model.App) bool { + got = app + return true + }) + assert.Nil(t, got) }, }, { @@ -74,7 +97,7 @@ func TestKubernetesService(t *testing.T) { } app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} - svc.addResourceEntries(key, []resourceEntry{ + svc.addResourceEntries(key, []string{"foo.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -82,7 +105,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("foo.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "foo.example.com" { got = app return true @@ -95,7 +118,7 @@ func TestKubernetesService(t *testing.T) { got = nil svc.removeResource(key) - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("foo.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "foo.example.com" { got = app return true @@ -115,7 +138,7 @@ func TestKubernetesService(t *testing.T) { } old := model.App{Config: model.AppConfig{Domain: "old.example.com"}} - svc.addResourceEntries(key, []resourceEntry{ + svc.addResourceEntries(key, []string{"old.example.com"}, []resourceEntry{ { app: old, name: "foo", @@ -123,7 +146,7 @@ func TestKubernetesService(t *testing.T) { }) updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}} - svc.addResourceEntries(key, []resourceEntry{ + svc.addResourceEntries(key, []string{"new.example.com"}, []resourceEntry{ { app: updated, name: "foo", @@ -131,7 +154,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("old.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "old.example.com" { got = app return true @@ -140,7 +163,7 @@ func TestKubernetesService(t *testing.T) { }) assert.Nil(t, got) - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("new.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "new.example.com" { got = app return true @@ -180,7 +203,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testHTTPRouteResource, &httpRoute) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "ingapp.example.com" { got = app return true @@ -190,7 +213,7 @@ func TestKubernetesService(t *testing.T) { require.NotNil(t, got) got = nil - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "gwapp.example.com" { got = app return true @@ -210,7 +233,7 @@ func TestKubernetesService(t *testing.T) { resource: "ingresses", namespace: "default", name: "my-ingress", - }, []resourceEntry{ + }, []string{"hit.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -218,7 +241,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { + err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "hit.example.com" { got = app return true @@ -236,7 +259,7 @@ func TestKubernetesService(t *testing.T) { svc.connected = true var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { + err := svc.Lookup("notfound.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "notfound.example.com" { got = app return true @@ -257,7 +280,7 @@ func TestKubernetesService(t *testing.T) { resource: "ingresses", namespace: "default", name: "my-ingress", - }, []resourceEntry{ + }, []string{"foo.internal.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -265,13 +288,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { - if strings.HasPrefix("foo.internal.example.com", "foo.") { - got = app - return true - } - return false - }) + err := svc.Lookup("foo.internal.example.com", aclLocator("foo.internal.example.com", &got)) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, "/foo", got.Path.Allow) @@ -280,9 +297,138 @@ func TestKubernetesService(t *testing.T) { { description: "GetLabels returns empty app when service not yet started", run: func(t *testing.T, svc *KubernetesService) { + app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} + svc.addResourceEntries(resourceKey{ + resource: "ingresses", + namespace: "default", + name: "my-ingress", + }, []string{"hit.example.com"}, []resourceEntry{ + { + app: app, + name: "foo", + }, + }) + var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { - return false + err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool { + got = app + return true + }) + require.NoError(t, err) + assert.Nil(t, got) + }, + }, + { + description: "Lookup withholds apps that are served on another host", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-ingress") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.myapp.users.allow": "alice", + }) + require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ + map[string]any{ + "host": "myapp.example.com", + }, + }, "spec", "rules")) + + svc.updateFromItem(testIngressResource, &item) + + // The app is served on myapp.example.com, so it must not be + // able to define the ACLs of a look-alike domain it does not + // route just because the name happens to prefix it + var got *model.App + err := svc.Lookup("myapp.evil.com", aclLocator("myapp.evil.com", &got)) + require.NoError(t, err) + assert.Nil(t, got) + + err = svc.Lookup("myapp.example.com", aclLocator("myapp.example.com", &got)) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "alice", got.Users.Allow) + }, + }, + { + description: "Lookup yields apps for any domain covered by a wildcard host", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-httproute") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.myapp.users.allow": "alice", + }) + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "*.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testHTTPRouteResource, &item) + + // A wildcard is a suffix match, so nested subdomains stay + // resolvable by app name + var got *model.App + err := svc.Lookup("myapp.sub.example.com", aclLocator("myapp.sub.example.com", &got)) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "alice", got.Users.Allow) + + got = nil + err = svc.Lookup("myapp.example.net", aclLocator("myapp.example.net", &got)) + require.NoError(t, err) + assert.Nil(t, got) + }, + }, + { + description: "Lookup ignores the port of the domain", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}} + svc.addResourceEntries(resourceKey{ + resource: "ingresses", + namespace: "default", + name: "my-ingress", + }, []string{"myapp.example.com"}, []resourceEntry{ + { + app: app, + name: "myapp", + }, + }) + + var got *model.App + err := svc.Lookup("myapp.example.com:8443", func(name string, app *model.App) bool { + got = app + return true + }) + require.NoError(t, err) + require.NotNil(t, got) + }, + }, + { + description: "Lookup skips an invalid domain", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}} + svc.addResourceEntries(resourceKey{ + resource: "ingresses", + namespace: "default", + name: "my-ingress", + }, []string{"myapp.example.com"}, []resourceEntry{ + { + app: app, + name: "myapp", + }, + }) + + var got *model.App + err := svc.Lookup("not a domain", func(name string, app *model.App) bool { + got = app + return true }) require.NoError(t, err) assert.Nil(t, got) @@ -309,7 +455,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "myapp.example.com" { got = app return true @@ -335,7 +481,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "myapp.example.com" { got = app return true @@ -366,7 +512,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "myapp.example.com" { got = app return true @@ -385,7 +531,7 @@ func TestKubernetesService(t *testing.T) { resource: "ingresses", namespace: "default", name: "my-ingress", - }, []resourceEntry{ + }, []string{"todelete.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -399,7 +545,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("todelete.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "todelete.example.com" { got = app return true @@ -695,7 +841,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testHTTPRouteResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "gwapp.example.com" { got = app return true @@ -724,7 +870,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testGRPCRouteResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("grpcapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "grpcapp.example.com" { got = app return true @@ -749,7 +895,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testHTTPRouteResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { got = app return true }) @@ -772,7 +918,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testHTTPRouteResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("deep.gwapp.example.com", func(name string, app *model.App) bool { if name == "gwapp" { got = app return true @@ -799,7 +945,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testHTTPRouteResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { if name == "gwapp" { got = app return true @@ -865,7 +1011,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testHTTPRouteResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { if name == "gwapp" { got = app return true @@ -904,7 +1050,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testHTTPRouteResource, &httpRoute) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "ingapp.example.com" { got = app return true @@ -915,7 +1061,7 @@ func TestKubernetesService(t *testing.T) { assert.Equal(t, "ingapp.example.com", got.Config.Domain) got = nil - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "gwapp.example.com" { got = app return true @@ -944,7 +1090,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if name == "myapp" { got = app return true @@ -973,7 +1119,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if name == "myapp" { got = app return true @@ -1002,7 +1148,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("other.example.com", func(name string, app *model.App) bool { got = app return true }) @@ -1027,7 +1173,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if name == "myapp" { got = app return true @@ -1045,7 +1191,7 @@ func TestKubernetesService(t *testing.T) { namespace: "default", name: "test-ingress", } - svc.addResourceEntries(key, []resourceEntry{ + svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{ { app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}}, name: "foo", @@ -1063,7 +1209,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("stale.example.com", func(name string, app *model.App) bool { got = app return true }) @@ -1078,7 +1224,7 @@ func TestKubernetesService(t *testing.T) { namespace: "default", name: "test-ingress", } - svc.addResourceEntries(key, []resourceEntry{ + svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{ { app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}}, name: "foo", @@ -1095,7 +1241,7 @@ func TestKubernetesService(t *testing.T) { svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("stale.example.com", func(name string, app *model.App) bool { got = app return true }) @@ -1107,8 +1253,8 @@ func TestKubernetesService(t *testing.T) { for _, test := range tests { t.Run(test.description, func(t *testing.T) { svc := &KubernetesService{ - resourceEntries: make(map[resourceKey][]resourceEntry), - log: log, + resourceApps: make(map[resourceKey]routedApps), + log: log, } test.run(t, svc) }) From b43cf76d9ba4ed057cac1d821462a6ad53ef37c3 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 20 Sep 2026 21:07:06 +0300 Subject: [PATCH 4/6] refactor: use typed objects for kubernetes --- go.mod | 18 + go.sum | 12 +- internal/service/access_controls_service.go | 6 +- .../service/kubernetes_grpcroute_extractor.go | 47 ++ .../service/kubernetes_httproute_extractor.go | 91 ++++ .../service/kubernetes_ingress_extractor.go | 68 +++ internal/service/kubernetes_service.go | 492 +++++++----------- 7 files changed, 435 insertions(+), 299 deletions(-) create mode 100644 internal/service/kubernetes_grpcroute_extractor.go create mode 100644 internal/service/kubernetes_httproute_extractor.go create mode 100644 internal/service/kubernetes_ingress_extractor.go diff --git a/go.mod b/go.mod index 4e4ca5b84..cf751669b 100644 --- a/go.mod +++ b/go.mod @@ -28,9 +28,11 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/tools v0.49.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.37.0 k8s.io/apimachinery v0.37.0 k8s.io/client-go v0.37.0 modernc.org/sqlite v1.58.0 + sigs.k8s.io/gateway-api v1.6.2 ) require ( @@ -69,6 +71,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 @@ -76,11 +79,26 @@ require ( github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect github.com/go-logr/logr v1.4.3 // 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 github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 7e6ae78fd..5555043b5 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= @@ -168,8 +174,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -440,6 +446,8 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= +sigs.k8s.io/gateway-api v1.6.2 h1:vh5YzKlbdBivEaLX61+APKLGRq4tZ7Fj4XfGkv08xB4= +sigs.k8s.io/gateway-api v1.6.2/go.mod h1:FVfx3t389ybeXOqvDghLbdvJdSCfI/PReqCUI3lu3mY= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 1544ca4e5..605b2c9d9 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -46,7 +46,7 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic } } -func (service *AccessControlsService) ensureAscii(str string) bool { +func ensureAscii(str string) bool { for i := 0; i < len(str); i++ { if str[i] > unicode.MaxASCII { return false @@ -64,7 +64,7 @@ func (service *AccessControlsService) normalizeDomain(domain string) string { } func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) { - if !service.ensureAscii(domain) { + if !ensureAscii(domain) { return nil, errors.New("domain contains non-ascii characters") } @@ -80,7 +80,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator locatorFunc := func(name string, app *model.App) bool { if app.Config.Domain != "" { - if !service.ensureAscii(app.Config.Domain) { + if !ensureAscii(app.Config.Domain) { service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping") return false } diff --git a/internal/service/kubernetes_grpcroute_extractor.go b/internal/service/kubernetes_grpcroute_extractor.go new file mode 100644 index 000000000..ff205f352 --- /dev/null +++ b/internal/service/kubernetes_grpcroute_extractor.go @@ -0,0 +1,47 @@ +package service + +import ( + "github.com/tinyauthapp/tinyauth/internal/utils/logger" + gateway "sigs.k8s.io/gateway-api/apis/v1" +) + +type KubernetesGRPCRouteExtractor struct { + log *logger.Logger +} + +type KubernetesGRPCRouteExtractorInput struct { + Log *logger.Logger +} + +func NewKubernetesGRPCRouteExtractor(i KubernetesGRPCRouteExtractorInput) *KubernetesGRPCRouteExtractor { + return &KubernetesGRPCRouteExtractor{ + log: i.Log, + } +} + +func (k *KubernetesGRPCRouteExtractor) getHosts(hostnames []gateway.Hostname) []string { + var hosts []string + + for _, hostname := range hostnames { + if hostname != "" { + hosts = append(hosts, string(hostname)) + } + } + + return nil +} + +func (k *KubernetesGRPCRouteExtractor) Extract(route *gateway.GRPCRoute) *ExtractionResult { + hosts := k.getHosts(route.Spec.Hostnames) + namespace := route.GetNamespace() + name := route.GetName() + annotations := route.GetAnnotations() + + return &ExtractionResult{ + typ: ResourceTypeGRPCRoute, + name: name, + namespace: namespace, + hosts: hosts, + annotations: annotations, + } +} diff --git a/internal/service/kubernetes_httproute_extractor.go b/internal/service/kubernetes_httproute_extractor.go new file mode 100644 index 000000000..035dbba2b --- /dev/null +++ b/internal/service/kubernetes_httproute_extractor.go @@ -0,0 +1,91 @@ +package service + +import ( + "slices" + + "github.com/tinyauthapp/tinyauth/internal/utils/logger" + gateway "sigs.k8s.io/gateway-api/apis/v1" +) + +type KubernetesHTTPRouteExtractor struct { + log *logger.Logger +} + +type KubernetesHTTPRouteExtractorInput struct { + Log *logger.Logger +} + +func NewKubernetesHTTPRouteExtractor(i KubernetesHTTPRouteExtractorInput) *KubernetesHTTPRouteExtractor { + return &KubernetesHTTPRouteExtractor{ + log: i.Log, + } +} + +func (k *KubernetesHTTPRouteExtractor) getHosts(hostnames []gateway.Hostname) []string { + var hosts []string + + for _, hostname := range hostnames { + if hostname != "" { + hosts = append(hosts, string(hostname)) + } + } + + return nil +} + +func (k *KubernetesHTTPRouteExtractor) getRuleMatchers(matchers []gateway.HTTPRouteMatch) []string { + var res []string + + for _, m := range matchers { + pathType := m.Path.Type + if pathType == nil { + pathType = new(gateway.PathMatchPathPrefix) + } + + pathValue := m.Path.Value + if pathValue == nil { + pathValue = new("/") + } + + if *pathType != gateway.PathMatchPathPrefix { + continue + } + + res = append(res, *pathValue) + } + + return res +} + +func (k *KubernetesHTTPRouteExtractor) getPaths(rules []gateway.HTTPRouteRule) []string { + var paths []string + + for _, rule := range rules { + matchers := k.getRuleMatchers(rule.Matches) + paths = append(paths, matchers...) + } + + return paths +} + +func (k *KubernetesHTTPRouteExtractor) Extract(route *gateway.HTTPRoute) *ExtractionResult { + hosts := k.getHosts(route.Spec.Hostnames) + paths := k.getPaths(route.Spec.Rules) + + namespace := route.GetNamespace() + name := route.GetName() + + annotations := route.GetAnnotations() + + if !slices.Contains(paths, "/") { + k.log.App.Warn().Str("namespace", namespace).Str("name", name).Strs("paths", paths).Msg("Route does not contain a catch-all path, another route may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this route to ensure auth checks are applied to all paths for this host.") + } + + return &ExtractionResult{ + typ: ResourceTypeHTTPRoute, + name: name, + namespace: namespace, + hosts: hosts, + annotations: annotations, + } +} diff --git a/internal/service/kubernetes_ingress_extractor.go b/internal/service/kubernetes_ingress_extractor.go new file mode 100644 index 000000000..b07a222be --- /dev/null +++ b/internal/service/kubernetes_ingress_extractor.go @@ -0,0 +1,68 @@ +package service + +import ( + "slices" + + "github.com/tinyauthapp/tinyauth/internal/utils/logger" + networking "k8s.io/api/networking/v1" +) + +type KubernetesIngressExtractor struct { + log *logger.Logger +} + +type KubernetesIngressExtractorInput struct { + Log *logger.Logger +} + +func NewKubernetesIngressExtractor(i KubernetesIngressExtractorInput) *KubernetesIngressExtractor { + return &KubernetesIngressExtractor{ + log: i.Log, + } +} + +func (k *KubernetesIngressExtractor) getPaths(rule networking.IngressRule) []string { + var paths []string + + for _, path := range rule.HTTP.Paths { + paths = append(paths, path.Path) + } + + return paths +} + +func (k *KubernetesIngressExtractor) getHosts(rules []networking.IngressRule) []string { + var hosts []string + + for _, rule := range rules { + hosts = append(hosts, rule.Host) + paths := k.getPaths(rule) + + if len(paths) == 0 { + continue + } + + if !slices.Contains(paths, "/") { + k.log.App.Warn().Strs("hosts", hosts).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.") + } + } + + return nil +} + +func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) *ExtractionResult { + annotations := ingress.GetAnnotations() + if len(annotations) == 0 { + return nil + } + + hosts := k.getHosts(ingress.Spec.Rules) + + return &ExtractionResult{ + typ: ResourceTypeIngress, + name: ingress.GetName(), + namespace: ingress.GetNamespace(), + hosts: hosts, + annotations: annotations, + } +} diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index de292294a..716dcbe19 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -12,42 +12,43 @@ import ( "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/validators" "go.uber.org/dig" - + networking "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" + gateway "sigs.k8s.io/gateway-api/apis/v1" ) -// watchedResource describes a kind of resource that can carry tinyauth -// annotations, along with the specifics of extracting the hosts it routes. type watchedResource struct { gvr schema.GroupVersionResource - // gatewayAPI resources declare their hosts in spec.hostnames instead of - // spec.rules[].host and may use the wildcard label (`*.`). - gatewayAPI bool - // httpPaths marks resources that route on HTTP paths, which means another - // resource may claim the same host on a different path. - httpPaths bool + typ ResourceType } -// api returns a human readable identifier for the watched resource. -func (r watchedResource) api() string { - return r.gvr.GroupVersion().String() + "/" + r.gvr.Resource +func (w watchedResource) pretty() string { + return w.gvr.Group + "/" + w.gvr.Version + "/" + w.gvr.Resource } -var watchedResources = []watchedResource{ +type ResourceType string + +const ( + ResourceTypeIngress ResourceType = "ingress" + ResourceTypeGRPCRoute ResourceType = "grpcroute" + ResourceTypeHTTPRoute ResourceType = "httproute" +) + +var supportedResources = []watchedResource{ { gvr: schema.GroupVersionResource{ Group: "networking.k8s.io", Version: "v1", Resource: "ingresses", }, - httpPaths: true, + typ: ResourceTypeIngress, }, { gvr: schema.GroupVersionResource{ @@ -55,8 +56,7 @@ var watchedResources = []watchedResource{ Version: "v1", Resource: "httproutes", }, - gatewayAPI: true, - httpPaths: true, + typ: ResourceTypeHTTPRoute, }, { gvr: schema.GroupVersionResource{ @@ -64,26 +64,97 @@ var watchedResources = []watchedResource{ Version: "v1", Resource: "grpcroutes", }, - gatewayAPI: true, + typ: ResourceTypeGRPCRoute, }, } +func hostMatchesHostname(host string, hostname string) bool { + host = strings.ToLower(host) + 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 +} + +type typedItem struct { + typ ResourceType + ingress *networking.Ingress + route *gateway.HTTPRoute + grpc *gateway.GRPCRoute +} + +func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) { + var typed *T + 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 typed, nil +} + +func (ti *typedItem) fromUnstructured(typ ResourceType, obj *unstructured.Unstructured) (*typedItem, error) { + switch typ { + case ResourceTypeIngress: + typed, err := convertFromUnstructured[networking.Ingress](obj) + if err != nil { + return nil, err + } + return &typedItem{ + typ: ResourceTypeIngress, + ingress: typed, + }, nil + case ResourceTypeHTTPRoute: + typed, err := convertFromUnstructured[gateway.HTTPRoute](obj) + if err != nil { + return nil, err + } + return &typedItem{ + typ: ResourceTypeHTTPRoute, + route: typed, + }, nil + case ResourceTypeGRPCRoute: + typed, err := convertFromUnstructured[gateway.GRPCRoute](obj) + if err != nil { + return nil, err + } + return &typedItem{ + typ: ResourceTypeGRPCRoute, + grpc: typed, + }, nil + default: + return nil, fmt.Errorf("unknown resource type %s", typ) + } +} + type resourceEntry struct { name string app model.App } -// routedApps holds the apps annotated on a resource along with the hosts that -// resource routes, which bound the domains those apps may define ACLs for. type routedApps struct { hosts []string entries []resourceEntry } -// resourceKey identifies a watched resource. The kind is part of the key -// because an Ingress and an HTTPRoute may share a name within a namespace. type resourceKey struct { - resource string + typ ResourceType namespace string name string } @@ -91,10 +162,16 @@ type resourceKey struct { type KubernetesService struct { log *logger.Logger - client dynamic.Interface - connected bool - mu sync.RWMutex - resourceApps map[resourceKey]routedApps + apps map[resourceKey]routedApps + client dynamic.Interface + mu sync.RWMutex + connected bool + + extractors struct { + ingress *KubernetesIngressExtractor + httproute *KubernetesHTTPRouteExtractor + grpc *KubernetesGRPCRouteExtractor + } } type KubernetesServiceInput struct { @@ -117,35 +194,45 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) } service := &KubernetesService{ - log: i.Log, - client: client, - resourceApps: make(map[resourceKey]routedApps), + log: i.Log, + client: client, + apps: make(map[resourceKey]routedApps), } - watching := 0 + service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{ + Log: i.Log, + }) + service.extractors.httproute = NewKubernetesHTTPRouteExtractor(KubernetesHTTPRouteExtractorInput{ + Log: i.Log, + }) + service.extractors.grpc = NewKubernetesGRPCRouteExtractor(KubernetesGRPCRouteExtractorInput{ + Log: i.Log, + }) + + watchedGVRs := make(map[string]bool) - for _, res := range watchedResources { - accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second) - _, err := client.Resource(res.gvr).List(accessCtx, metav1.ListOptions{Limit: 1}) - accessCancel() + for _, res := range supportedResources { + ctx, cancel := context.WithTimeout(i.Ctx, 5*time.Second) + _, err := client.Resource(res.gvr).List(ctx, metav1.ListOptions{Limit: 1}) + cancel() if err != nil { // The Gateway API CRDs are not installed on every cluster, so a - // single unreachable API is not fatal - i.Log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to access API, skipping watcher") + // single unreachable resource is not fatal + i.Log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to access resource, skipping watcher") continue } - i.Log.App.Debug().Str("api", res.api()).Msg("Successfully accessed API, starting watcher") + i.Log.App.Debug().Str("res", res.pretty()).Msg("Successfully accessed resource, starting watcher") i.Ding.Go(func(ctx context.Context) { service.watchGVR(res, ctx) }, ding.RingMajor) - watching++ + watchedGVRs[res.gvr.Resource] = true } - if watching == 0 { + if len(watchedGVRs) == 0 { return nil, fmt.Errorf("failed to access any supported kubernetes api (ingresses, httproutes, grpcroutes)") } @@ -158,7 +245,7 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) { k.mu.Lock() defer k.mu.Unlock() - k.resourceApps[key] = routedApps{ + k.apps[key] = routedApps{ hosts: hosts, entries: entries, } @@ -167,15 +254,12 @@ func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, func (k *KubernetesService) removeResource(key resourceKey) { k.mu.Lock() defer k.mu.Unlock() - delete(k.resourceApps, key) + delete(k.apps, key) } func (k *KubernetesService) getEntry(domain string, locator func(name string, app *model.App) bool) { - v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) - - hostname, err := v.SafeHostname(domain) - if err != nil { - k.log.App.Debug().Err(err).Str("domain", domain).Msg("Domain is invalid, skipping lookup") + if !ensureAscii(domain) { + k.log.App.Debug().Str("domain", domain).Msg("Domain is invalid, skipping lookup") return } @@ -183,15 +267,13 @@ func (k *KubernetesService) getEntry(domain string, locator func(name string, ap defer k.mu.RUnlock() // O(n^2) is not great but the number of resource entries is expected to be small - for _, apps := range k.resourceApps { - // Only a resource that routes the domain may define its ACLs, otherwise - // an app could claim any domain that happens to start with its name - if !slices.ContainsFunc(apps.hosts, func(host string) bool { - return hostMatches(host, hostname) + for _, app := range k.apps { + if !slices.ContainsFunc(app.hosts, func(host string) bool { + return hostMatchesHostname(host, domain) }) { continue } - for _, entry := range apps.entries { + for _, entry := range app.entries { if ok := locator(entry.name, &entry.app); ok { return } @@ -199,230 +281,48 @@ func (k *KubernetesService) getEntry(domain string, locator func(name string, ap } } -// hostMatches reports whether hostname is routed by host. It honours the -// Gateway API wildcard label (`*.`), which is a suffix match, so -// `*.example.com` matches `test.example.com` and `foo.test.example.com` but -// not `example.com`. -func hostMatches(host string, hostname string) bool { - host = strings.ToLower(host) - - if suffix, ok := strings.CutPrefix(host, "*."); ok { - return strings.HasSuffix(hostname, "."+suffix) - } - - return host == hostname -} - -// hostCoversName reports whether an app name could resolve to a host routed by -// the resource. A wildcard host covers any app name since `*.example.com` -// routes `.example.com` for every name. -func hostCoversName(host string, name string) bool { - host = strings.ToLower(host) - - if strings.HasPrefix(host, "*.") { - return true - } - - return strings.HasPrefix(host, strings.ToLower(name+".")) -} - -func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) { - http, found, err := unstructured.NestedMap(rule, "http") - if err != nil { - return nil, fmt.Errorf("reading http from rule: %w", err) - } - if !found { - return nil, nil - } - paths, found, err := unstructured.NestedSlice(http, "paths") - if err != nil { - return nil, fmt.Errorf("reading http.paths: %w", err) - } - if !found { - return nil, nil - } - var result []string - for _, p := range paths { - path, ok := p.(map[string]any) - if !ok { - continue - } - if p, ok := path["path"].(string); ok && p != "" { - result = append(result, p) - } - } - return result, nil -} +func (k *KubernetesService) updateFromItem(res watchedResource, typedItem *typedItem) { + var result *ExtractionResult -// extractRoutePaths returns the paths matched by a Gateway API route rule and -// whether the rule matches every path for its hosts. -func (k *KubernetesService) extractRoutePaths(rule map[string]any) ([]string, bool, error) { - matches, found, err := unstructured.NestedSlice(rule, "matches") - if err != nil { - return nil, false, fmt.Errorf("reading matches from rule: %w", err) - } - if !found || len(matches) == 0 { - // An omitted matches list defaults to a PathPrefix match on "/" - return nil, true, nil - } - var result []string - catchAll := false - for _, m := range matches { - match, ok := m.(map[string]any) - if !ok { - continue - } - path, ok := match["path"].(map[string]any) - if !ok { - // A match without a path constrains something else, such as headers - // or a gRPC method, and leaves the path unrestricted - catchAll = true - continue - } - // Both fields are optional and default to a PathPrefix match on "/" - pathType, ok := path["type"].(string) - if !ok || pathType == "" { - pathType = "PathPrefix" - } - value, ok := path["value"].(string) - if !ok || value == "" { - value = "/" - } - result = append(result, value) - if pathType == "PathPrefix" && value == "/" { - catchAll = true - } - } - return result, catchAll, nil -} - -// warnMissingCatchAllPath warns when a Gateway API route does not match every -// path for the hosts it routes. Unlike an Ingress, a route declares its hosts -// once for all of its rules, so the rules are checked as a whole. -func (k *KubernetesService) warnMissingCatchAllPath(item *unstructured.Unstructured) { - rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules") - if err != nil { - // This is purely to warn users - // It doesn't affect our ability to extract hosts, so we won't fail the whole operation - k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from route rules") - return - } - if !found || len(rules) == 0 { - return - } - var paths []string - for _, r := range rules { - rule, ok := r.(map[string]any) - if !ok { - continue - } - rulePaths, catchAll, err := k.extractRoutePaths(rule) - if err != nil { - k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from route rule") - continue - } - if catchAll { + switch typedItem.typ { + case ResourceTypeIngress: + if typedItem.ingress != nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping") return } - paths = append(paths, rulePaths...) - } - if len(paths) == 0 { - return - } - k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Route does not contain a catch-all path, another route may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this route to ensure auth checks are applied to all paths for this host.") -} - -func (k *KubernetesService) extractIngressHosts(item *unstructured.Unstructured) ([]string, error) { - rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules") - if err != nil { - return nil, fmt.Errorf("reading spec.rules: %w", err) - } - if !found { - return nil, nil - } - var hosts []string - for _, r := range rules { - rule, ok := r.(map[string]any) - if !ok { - continue - } - if host, ok := rule["host"].(string); ok && host != "" { - hosts = append(hosts, host) - } - paths, err := k.extractPaths(rule) - if err != nil { - // This is purely to warn users - // It doesn't affect our ability to extract hosts, so we won't fail the whole operation - k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule") - continue - } - if len(paths) == 0 { - continue - } - if !slices.Contains(paths, "/") { - k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.") + result = k.extractors.ingress.Extract(typedItem.ingress) + case ResourceTypeHTTPRoute: + if typedItem.route != nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("HTTPRoute is nil, skipping") + return } - } - k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules") - return hosts, nil -} - -func (k *KubernetesService) extractRouteHosts(res watchedResource, item *unstructured.Unstructured) ([]string, error) { - hostnames, found, err := unstructured.NestedStringSlice(item.Object, "spec", "hostnames") - if err != nil { - return nil, fmt.Errorf("reading spec.hostnames: %w", err) - } - if !found { - // A route without hostnames inherits the ones of the gateway listeners - // it attaches to, which we cannot resolve from the route alone - return nil, nil - } - var hosts []string - for _, hostname := range hostnames { - if hostname != "" { - hosts = append(hosts, hostname) + result = k.extractors.httproute.Extract(typedItem.route) + case ResourceTypeGRPCRoute: + if typedItem.grpc != nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("GRPCRoute is nil, skipping") + return } + result = k.extractors.grpc.Extract(typedItem.grpc) } - if res.httpPaths { - k.warnMissingCatchAllPath(item) - } - k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from route hostnames") - return hosts, nil -} -func (k *KubernetesService) extractHosts(res watchedResource, item *unstructured.Unstructured) ([]string, error) { - if res.gatewayAPI { - return k.extractRouteHosts(res, item) - } - return k.extractIngressHosts(item) -} - -func (k *KubernetesService) updateFromItem(res watchedResource, item *unstructured.Unstructured) { - key := resourceKey{ - resource: res.gvr.Resource, - namespace: item.GetNamespace(), - name: item.GetName(), - } - - annotations := item.GetAnnotations() - if annotations == nil { - k.removeResource(key) + if result == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping") return } - hosts, err := k.extractHosts(res, item) - if err != nil { - k.removeResource(key) - return + key := resourceKey{ + typ: res.typ, + namespace: result.namespace, + name: result.name, } - if len(hosts) == 0 { - k.log.App.Warn().Str("api", res.api()).Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in resource, skipping") + 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](annotations, "apps") + 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) @@ -431,25 +331,24 @@ func (k *KubernetesService) updateFromItem(res watchedResource, item *unstructur var entries []resourceEntry - v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) - for name, config := range labels.Apps { if config.Config.Domain != "" { - hostname, err := v.SafeHostname(config.Config.Domain) - if err != nil { + 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(hosts, func(host string) bool { - return hostMatches(host, hostname) - }) { - entries = append(entries, resourceEntry{ - name: name, - app: config, - }) - continue + } 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(hosts, func(host string) bool { + if slices.ContainsFunc(result.hosts, func(host string) bool { return hostCoversName(host, name) }) { entries = append(entries, resourceEntry{ @@ -464,7 +363,7 @@ func (k *KubernetesService) updateFromItem(res watchedResource, item *unstructur return } - k.addResourceEntries(key, hosts, entries) + k.addResourceEntries(key, result.hosts, entries) } func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error { @@ -473,18 +372,21 @@ func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) list, err := k.client.Resource(res.gvr).List(ctx, metav1.ListOptions{}) if err != nil { - k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to list resources for resync") + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to list resources for resync") return err } - for i := range list.Items { - k.updateFromItem(res, &list.Items[i]) + for _, item := range list.Items { + newTypedItem, err := new(typedItem).fromUnstructured(res.typ, &item) + if err != nil { + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping") + continue + } + k.updateFromItem(res, newTypedItem) } - k.log.App.Debug().Str("api", res.api()).Int("count", len(list.Items)).Msg("Resync complete") + k.log.App.Debug().Str("res", res.pretty()).Int("count", len(list.Items)).Msg("Resync complete") return nil } -// runWatcher drains events from an active watcher until it closes or the context is done. -// Returns true if the caller should restart the watcher, false if it should exit. func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool { for { select { @@ -493,29 +395,34 @@ func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, r return false case event, ok := <-w.ResultChan(): if !ok { - k.log.App.Warn().Str("api", res.api()).Msg("Watcher channel closed, restarting watcher") + k.log.App.Warn().Str("res", res.pretty()).Msg("Watcher channel closed, restarting watcher") w.Stop() time.Sleep(5 * time.Second) return true } item, ok := event.Object.(*unstructured.Unstructured) if !ok { - k.log.App.Warn().Str("api", res.api()).Msg("Received unexpected event object, skipping") + k.log.App.Warn().Str("res", res.pretty()).Msg("Received unexpected event object, skipping") + continue + } + newTypedItem, err := new(typedItem).fromUnstructured(res.typ, item) + if err != nil { + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping") continue } switch event.Type { case watch.Added, watch.Modified: - k.updateFromItem(res, item) + k.updateFromItem(res, newTypedItem) case watch.Deleted: k.removeResource(resourceKey{ - resource: res.gvr.Resource, + typ: res.typ, namespace: item.GetNamespace(), name: item.GetName(), }) } case <-resyncTicker.C: if err := k.resyncGVR(res, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Periodic resync failed during watcher run") + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Periodic resync failed during watcher run") } } } @@ -526,29 +433,29 @@ func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) { defer resyncTicker.Stop() if err := k.resyncGVR(res, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Initial resync failed, will retry") + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Initial resync failed, will retry") time.Sleep(30 * time.Second) } for { select { case <-ctx.Done(): - k.log.App.Debug().Str("api", res.api()).Msg("Shutting down kubernetes watcher") + k.log.App.Debug().Str("res", res.pretty()).Msg("Shutting down kubernetes watcher") return case <-resyncTicker.C: if err := k.resyncGVR(res, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Periodic resync failed, will retry") + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Periodic resync failed, will retry") } default: ctx, cancel := context.WithCancel(ctx) watcher, err := k.client.Resource(res.gvr).Watch(ctx, metav1.ListOptions{}) if err != nil { - k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to start watcher, will retry") + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to start watcher, will retry") cancel() time.Sleep(10 * time.Second) continue } - k.log.App.Debug().Str("api", res.api()).Msg("Watcher started successfully") + k.log.App.Debug().Str("res", res.pretty()).Msg("Watcher started successfully") if !k.runWatcher(res, watcher, resyncTicker, ctx) { cancel() return @@ -558,9 +465,6 @@ func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) { } } -// Lookup yields the apps annotated on the resources that route domain. Apps -// annotated on any other resource are withheld, since they are served -// elsewhere and must not define the ACLs of this domain. func (k *KubernetesService) Lookup(domain string, locator func(name string, app *model.App) bool) error { if !k.connected { k.log.App.Debug().Msg("Kubernetes label provider not started, skipping") From 45e165f7425622570aca555a7714baff73077c56 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 20 Sep 2026 21:21:36 +0300 Subject: [PATCH 5/6] tests: add tests for kubernetes service and extractors Co-authored-by: Codex --- .../service/kubernetes_grpcroute_extractor.go | 2 +- .../service/kubernetes_httproute_extractor.go | 27 +- .../service/kubernetes_ingress_extractor.go | 10 +- internal/service/kubernetes_service.go | 14 +- internal/service/kubernetes_service_test.go | 1527 ++++------------- 5 files changed, 349 insertions(+), 1231 deletions(-) diff --git a/internal/service/kubernetes_grpcroute_extractor.go b/internal/service/kubernetes_grpcroute_extractor.go index ff205f352..299a56657 100644 --- a/internal/service/kubernetes_grpcroute_extractor.go +++ b/internal/service/kubernetes_grpcroute_extractor.go @@ -28,7 +28,7 @@ func (k *KubernetesGRPCRouteExtractor) getHosts(hostnames []gateway.Hostname) [] } } - return nil + return hosts } func (k *KubernetesGRPCRouteExtractor) Extract(route *gateway.GRPCRoute) *ExtractionResult { diff --git a/internal/service/kubernetes_httproute_extractor.go b/internal/service/kubernetes_httproute_extractor.go index 035dbba2b..39cb0cc3c 100644 --- a/internal/service/kubernetes_httproute_extractor.go +++ b/internal/service/kubernetes_httproute_extractor.go @@ -30,28 +30,31 @@ func (k *KubernetesHTTPRouteExtractor) getHosts(hostnames []gateway.Hostname) [] } } - return nil + return hosts } func (k *KubernetesHTTPRouteExtractor) getRuleMatchers(matchers []gateway.HTTPRouteMatch) []string { var res []string for _, m := range matchers { - pathType := m.Path.Type - if pathType == nil { - pathType = new(gateway.PathMatchPathPrefix) + if m.Path == nil { + res = append(res, "/") + continue } - pathValue := m.Path.Value - if pathValue == nil { - pathValue = new("/") + pathType := gateway.PathMatchPathPrefix + if m.Path.Type != nil { + pathType = *m.Path.Type } - - if *pathType != gateway.PathMatchPathPrefix { + if pathType != gateway.PathMatchPathPrefix { continue } - res = append(res, *pathValue) + pathValue := "/" + if m.Path.Value != nil { + pathValue = *m.Path.Value + } + res = append(res, pathValue) } return res @@ -61,6 +64,10 @@ func (k *KubernetesHTTPRouteExtractor) getPaths(rules []gateway.HTTPRouteRule) [ var paths []string for _, rule := range rules { + if len(rule.Matches) == 0 { + paths = append(paths, "/") + continue + } matchers := k.getRuleMatchers(rule.Matches) paths = append(paths, matchers...) } diff --git a/internal/service/kubernetes_ingress_extractor.go b/internal/service/kubernetes_ingress_extractor.go index b07a222be..b03882b04 100644 --- a/internal/service/kubernetes_ingress_extractor.go +++ b/internal/service/kubernetes_ingress_extractor.go @@ -24,6 +24,10 @@ func NewKubernetesIngressExtractor(i KubernetesIngressExtractorInput) *Kubernete func (k *KubernetesIngressExtractor) getPaths(rule networking.IngressRule) []string { var paths []string + if rule.HTTP == nil { + return paths + } + for _, path := range rule.HTTP.Paths { paths = append(paths, path.Path) } @@ -47,15 +51,11 @@ func (k *KubernetesIngressExtractor) getHosts(rules []networking.IngressRule) [] } } - return nil + return hosts } func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) *ExtractionResult { annotations := ingress.GetAnnotations() - if len(annotations) == 0 { - return nil - } - hosts := k.getHosts(ingress.Spec.Rules) return &ExtractionResult{ diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 716dcbe19..610cf1dfd 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -69,7 +69,8 @@ var supportedResources = []watchedResource{ } func hostMatchesHostname(host string, hostname string) bool { - host = strings.ToLower(host) + host = normalizeDomain(host) + hostname = normalizeDomain(hostname) if suffix, ok := strings.CutPrefix(host, "*."); ok { return strings.HasSuffix(hostname, "."+suffix) } @@ -284,21 +285,26 @@ func (k *KubernetesService) getEntry(domain string, locator func(name string, ap func (k *KubernetesService) updateFromItem(res watchedResource, typedItem *typedItem) { var result *ExtractionResult + if typedItem == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping") + return + } + switch typedItem.typ { case ResourceTypeIngress: - if typedItem.ingress != nil { + 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) case ResourceTypeHTTPRoute: - if typedItem.route != nil { + if typedItem.route == nil { k.log.App.Warn().Str("res", res.pretty()).Msg("HTTPRoute is nil, skipping") return } result = k.extractors.httproute.Extract(typedItem.route) case ResourceTypeGRPCRoute: - if typedItem.grpc != nil { + if typedItem.grpc == nil { k.log.App.Warn().Str("res", res.pretty()).Msg("GRPCRoute is nil, skipping") return } diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index 90c0accf6..2d72fbcac 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -4,1259 +4,364 @@ import ( "strings" "testing" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/logger" + networking "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + gateway "sigs.k8s.io/gateway-api/apis/v1" ) -func mustWatchedResource(resource string) watchedResource { - for _, res := range watchedResources { - if res.gvr.Resource == resource { - return res +func watchedResourceForTest(t *testing.T, typ ResourceType) watchedResource { + t.Helper() + for _, resource := range supportedResources { + if resource.typ == typ { + return resource } } - panic("unknown watched resource: " + resource) + t.Fatalf("unsupported resource type %q", typ) + return watchedResource{} } -var ( - testIngressResource = mustWatchedResource("ingresses") - testHTTPRouteResource = mustWatchedResource("httproutes") - testGRPCRouteResource = mustWatchedResource("grpcroutes") -) - -// aclLocator mimics the way the access controls service matches apps, first on -// the configured domain and then on the app name. -func aclLocator(domain string, got **model.App) func(name string, app *model.App) bool { - return func(name string, app *model.App) bool { - if app.Config.Domain == domain { - *got = app - return true - } - if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) { - *got = app - return true - } - return false +func newKubernetesServiceForTest(log *logger.Logger) *KubernetesService { + service := &KubernetesService{ + apps: make(map[resourceKey]routedApps), + log: log, } + service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{Log: log}) + service.extractors.httproute = NewKubernetesHTTPRouteExtractor(KubernetesHTTPRouteExtractorInput{Log: log}) + service.extractors.grpc = NewKubernetesGRPCRouteExtractor(KubernetesGRPCRouteExtractorInput{Log: log}) + return service } -func TestKubernetesService(t *testing.T) { - log := logger.NewLogger().WithTestConfig() - log.Init() - - type testCase struct { - description string - run func(t *testing.T, svc *KubernetesService) +func testIngress(name string, annotations map[string]string, hosts ...string) *typedItem { + rules := make([]networking.IngressRule, 0, len(hosts)) + for _, host := range hosts { + rules = append(rules, networking.IngressRule{Host: host}) } - - tests := []testCase{ - { - description: "Cache by domain returns app and misses unknown domain", - run: func(t *testing.T, svc *KubernetesService) { - app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} - svc.addResourceEntries(resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - }, []string{"foo.example.com"}, []resourceEntry{ - { - app: app, - name: "foo", - }, - }) - - var got *model.App - svc.getEntry("foo.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "foo.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "foo.example.com", got.Config.Domain) - - got = nil - svc.getEntry("unknown.example.com", func(name string, app *model.App) bool { - got = app - return true - }) - assert.Nil(t, got) - }, - }, - { - description: "RemoveResource clears domain and app name entries", - run: func(t *testing.T, svc *KubernetesService) { - key := resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - } - - app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} - svc.addResourceEntries(key, []string{"foo.example.com"}, []resourceEntry{ - { - app: app, - name: "foo", - }, - }) - - var got *model.App - svc.getEntry("foo.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "foo.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "foo.example.com", got.Config.Domain) - - got = nil - svc.removeResource(key) - - svc.getEntry("foo.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "foo.example.com" { - got = app - return true - } - return false - }) - assert.Nil(t, got) - }, - }, - { - description: "AddResourceEntries replaces stale entries for the same resource", - run: func(t *testing.T, svc *KubernetesService) { - key := resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - } - - old := model.App{Config: model.AppConfig{Domain: "old.example.com"}} - svc.addResourceEntries(key, []string{"old.example.com"}, []resourceEntry{ - { - app: old, - name: "foo", - }, - }) - - updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}} - svc.addResourceEntries(key, []string{"new.example.com"}, []resourceEntry{ - { - app: updated, - name: "foo", - }, - }) - - var got *model.App - svc.getEntry("old.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "old.example.com" { - got = app - return true - } - return false - }) - assert.Nil(t, got) - - svc.getEntry("new.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "new.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "new.example.com", got.Config.Domain) - }, - }, - { - description: "Resources of different kinds with the same name do not clobber each other", - run: func(t *testing.T, svc *KubernetesService) { - ingress := unstructured.Unstructured{} - ingress.SetNamespace("default") - ingress.SetName("shared") - ingress.SetAnnotations(map[string]string{ - "tinyauth.apps.ingapp.config.domain": "ingapp.example.com", - }) - require.NoError(t, unstructured.SetNestedSlice(ingress.Object, []any{ - map[string]any{ - "host": "ingapp.example.com", - }, - }, "spec", "rules")) - - httpRoute := unstructured.Unstructured{} - httpRoute.SetNamespace("default") - httpRoute.SetName("shared") - httpRoute.SetAnnotations(map[string]string{ - "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", - }) - require.NoError(t, unstructured.SetNestedStringSlice(httpRoute.Object, []string{ - "gwapp.example.com", - }, "spec", "hostnames")) - - svc.updateFromItem(testIngressResource, &ingress) - svc.updateFromItem(testHTTPRouteResource, &httpRoute) - - var got *model.App - svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "ingapp.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - - got = nil - svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "gwapp.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - }, - }, - { - description: "GetLabels returns app from cache when connected", - run: func(t *testing.T, svc *KubernetesService) { - svc.connected = true - - app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} - svc.addResourceEntries(resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - }, []string{"hit.example.com"}, []resourceEntry{ - { - app: app, - name: "foo", - }, - }) - - var got *model.App - err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "hit.example.com" { - got = app - return true - } - return false - }) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "hit.example.com", got.Config.Domain) - }, - }, - { - description: "GetLabels returns empty app on cache miss when started", - run: func(t *testing.T, svc *KubernetesService) { - svc.connected = true - - var got *model.App - err := svc.Lookup("notfound.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "notfound.example.com" { - got = app - return true - } - return false - }) - require.NoError(t, err) - require.Nil(t, got) - }, - }, - { - description: "GetLabels resolves app by app name", - run: func(t *testing.T, svc *KubernetesService) { - svc.connected = true - - app := model.App{Path: model.AppPath{Allow: "/foo"}} - svc.addResourceEntries(resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - }, []string{"foo.internal.example.com"}, []resourceEntry{ - { - app: app, - name: "foo", - }, - }) - - var got *model.App - err := svc.Lookup("foo.internal.example.com", aclLocator("foo.internal.example.com", &got)) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "/foo", got.Path.Allow) - }, + return &typedItem{ + typ: ResourceTypeIngress, + ingress: &networking.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", Annotations: annotations}, + Spec: networking.IngressSpec{Rules: rules}, }, - { - description: "GetLabels returns empty app when service not yet started", - run: func(t *testing.T, svc *KubernetesService) { - app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} - svc.addResourceEntries(resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - }, []string{"hit.example.com"}, []resourceEntry{ - { - app: app, - name: "foo", - }, - }) - - var got *model.App - err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool { - got = app - return true - }) - require.NoError(t, err) - assert.Nil(t, got) - }, - }, - { - description: "Lookup withholds apps that are served on another host", - run: func(t *testing.T, svc *KubernetesService) { - svc.connected = true - - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.users.allow": "alice", - }) - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "myapp.example.com", - }, - }, "spec", "rules")) - - svc.updateFromItem(testIngressResource, &item) - - // The app is served on myapp.example.com, so it must not be - // able to define the ACLs of a look-alike domain it does not - // route just because the name happens to prefix it - var got *model.App - err := svc.Lookup("myapp.evil.com", aclLocator("myapp.evil.com", &got)) - require.NoError(t, err) - assert.Nil(t, got) - - err = svc.Lookup("myapp.example.com", aclLocator("myapp.example.com", &got)) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "alice", got.Users.Allow) - }, - }, - { - description: "Lookup yields apps for any domain covered by a wildcard host", - run: func(t *testing.T, svc *KubernetesService) { - svc.connected = true - - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.users.allow": "alice", - }) - require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ - "*.example.com", - }, "spec", "hostnames")) - - svc.updateFromItem(testHTTPRouteResource, &item) - - // A wildcard is a suffix match, so nested subdomains stay - // resolvable by app name - var got *model.App - err := svc.Lookup("myapp.sub.example.com", aclLocator("myapp.sub.example.com", &got)) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "alice", got.Users.Allow) - - got = nil - err = svc.Lookup("myapp.example.net", aclLocator("myapp.example.net", &got)) - require.NoError(t, err) - assert.Nil(t, got) - }, - }, - { - description: "Lookup ignores the port of the domain", - run: func(t *testing.T, svc *KubernetesService) { - svc.connected = true - - app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}} - svc.addResourceEntries(resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - }, []string{"myapp.example.com"}, []resourceEntry{ - { - app: app, - name: "myapp", - }, - }) - - var got *model.App - err := svc.Lookup("myapp.example.com:8443", func(name string, app *model.App) bool { - got = app - return true - }) - require.NoError(t, err) - require.NotNil(t, got) - }, - }, - { - description: "Lookup skips an invalid domain", - run: func(t *testing.T, svc *KubernetesService) { - svc.connected = true - - app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}} - svc.addResourceEntries(resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - }, []string{"myapp.example.com"}, []resourceEntry{ - { - app: app, - name: "myapp", - }, - }) - - var got *model.App - err := svc.Lookup("not a domain", func(name string, app *model.App) bool { - got = app - return true - }) - require.NoError(t, err) - assert.Nil(t, got) - }, - }, - { - description: "UpdateFromItem parses annotations and populates cache", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.domain": "myapp.example.com", - "tinyauth.apps.myapp.users.allow": "alice", - }) - item.Object["spec"] = map[string]any{ - "rules": []any{ - map[string]any{ - "host": "myapp.example.com", - }, - }, - } - - svc.updateFromItem(testIngressResource, &item) - - var got *model.App - svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "myapp.example.com" { - got = app - return true - } - return false - }) - - require.NotNil(t, got) - assert.Equal(t, "myapp.example.com", got.Config.Domain) - assert.Equal(t, "alice", got.Users.Allow) - }, - }, - { - description: "Update from item skips annotations with no hosts", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.domain": "myapp.example.com", - }) - - svc.updateFromItem(testIngressResource, &item) - - var got *model.App - svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "myapp.example.com" { - got = app - return true - } - return false - }) - assert.Nil(t, got) - }, - }, - { - description: "UpdateFromItem fails when label parsing fails", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.domain": "myapp.example.com", - "tinyauth.apps.myapp.users.break": "i-dont-exist", - }) - item.Object["spec"] = map[string]any{ - "rules": []any{ - map[string]any{ - "host": "myapp.example.com", - }, - }, - } - - svc.updateFromItem(testIngressResource, &item) - - var got *model.App - svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "myapp.example.com" { - got = app - return true - } - return false - }) - - require.Nil(t, got) - }, - }, - { - description: "UpdateFromItem with no annotations removes existing cache entries", - run: func(t *testing.T, svc *KubernetesService) { - app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}} - svc.addResourceEntries(resourceKey{ - resource: "ingresses", - namespace: "default", - name: "my-ingress", - }, []string{"todelete.example.com"}, []resourceEntry{ - { - app: app, - name: "foo", - }, - }) - - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("my-ingress") - - svc.updateFromItem(testIngressResource, &item) - - var got *model.App - svc.getEntry("todelete.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "todelete.example.com" { - got = app - return true - } - return false - }) - assert.Nil(t, got) - }, - }, - { - description: "ExtractPaths returns all non empty paths from a rule", - run: func(t *testing.T, svc *KubernetesService) { - rule := map[string]any{ - "http": map[string]any{ - "paths": []any{ - map[string]any{"path": "/"}, - map[string]any{"path": "/api"}, - map[string]any{"path": ""}, - map[string]any{"pathType": "Prefix"}, - "not-a-map", - }, - }, - } - - paths, err := svc.extractPaths(rule) - require.NoError(t, err) - assert.Equal(t, []string{"/", "/api"}, paths) - }, - }, - { - description: "ExtractPaths returns nothing when http or paths are missing", - run: func(t *testing.T, svc *KubernetesService) { - paths, err := svc.extractPaths(map[string]any{}) - require.NoError(t, err) - assert.Empty(t, paths) - - paths, err = svc.extractPaths(map[string]any{ - "http": map[string]any{}, - }) - require.NoError(t, err) - assert.Empty(t, paths) - }, - }, - { - description: "ExtractPaths errors when http is not a map", - run: func(t *testing.T, svc *KubernetesService) { - paths, err := svc.extractPaths(map[string]any{ - "http": "invalid", - }) - require.Error(t, err) - assert.Nil(t, paths) - }, - }, - { - description: "ExtractPaths errors when paths is not a slice", - run: func(t *testing.T, svc *KubernetesService) { - paths, err := svc.extractPaths(map[string]any{ - "http": map[string]any{ - "paths": "invalid", - }, - }) - require.Error(t, err) - assert.Nil(t, paths) - }, - }, - { - description: "ExtractHosts returns hosts from all rules", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "foo.example.com", - "http": map[string]any{ - "paths": []any{ - map[string]any{"path": "/"}, - }, - }, - }, - map[string]any{ - "host": "bar.example.com", - }, - map[string]any{ - "host": "", - }, - "not-a-map", - }, "spec", "rules")) - - hosts, err := svc.extractHosts(testIngressResource, &item) - require.NoError(t, err) - assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts) - }, - }, - { - description: "ExtractHosts still returns hosts when a rule has no catch all path", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "foo.example.com", - "http": map[string]any{ - "paths": []any{ - map[string]any{"path": "/api"}, - }, - }, - }, - }, "spec", "rules")) - - hosts, err := svc.extractIngressHosts(&item) - require.NoError(t, err) - assert.Equal(t, []string{"foo.example.com"}, hosts) - }, - }, - { - description: "ExtractHosts still returns hosts when path extraction fails", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "foo.example.com", - "http": "invalid", - }, - }, "spec", "rules")) - - hosts, err := svc.extractIngressHosts(&item) - require.NoError(t, err) - assert.Equal(t, []string{"foo.example.com"}, hosts) - }, - }, - { - description: "ExtractHosts returns nothing when spec.rules is missing", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - - hosts, err := svc.extractIngressHosts(&item) - require.NoError(t, err) - assert.Empty(t, hosts) - }, - }, - { - description: "ExtractHosts errors when spec.rules is not a slice", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules")) - - hosts, err := svc.extractIngressHosts(&item) - require.Error(t, err) - assert.Nil(t, hosts) - }, - }, - { - description: "ExtractRouteHosts returns the hostnames of a route", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ - "foo.example.com", - "", - "*.bar.example.com", - }, "spec", "hostnames")) - - hosts, err := svc.extractHosts(testHTTPRouteResource, &item) - require.NoError(t, err) - assert.Equal(t, []string{"foo.example.com", "*.bar.example.com"}, hosts) - }, - }, - { - description: "ExtractRouteHosts returns nothing when spec.hostnames is missing", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - - hosts, err := svc.extractRouteHosts(testHTTPRouteResource, &item) - require.NoError(t, err) - assert.Empty(t, hosts) - }, - }, - { - description: "ExtractRouteHosts errors when spec.hostnames is not a string slice", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "hostnames")) - - hosts, err := svc.extractRouteHosts(testHTTPRouteResource, &item) - require.Error(t, err) - assert.Nil(t, hosts) - }, - }, - { - description: "ExtractRoutePaths treats omitted matches as a catch all", - run: func(t *testing.T, svc *KubernetesService) { - paths, catchAll, err := svc.extractRoutePaths(map[string]any{}) - require.NoError(t, err) - assert.True(t, catchAll) - assert.Empty(t, paths) - }, - }, - { - description: "ExtractRoutePaths applies the default path match", - run: func(t *testing.T, svc *KubernetesService) { - paths, catchAll, err := svc.extractRoutePaths(map[string]any{ - "matches": []any{ - map[string]any{ - "path": map[string]any{}, - }, - }, - }) - require.NoError(t, err) - assert.True(t, catchAll) - assert.Equal(t, []string{"/"}, paths) - }, - }, - { - description: "ExtractRoutePaths reports no catch all for scoped path matches", - run: func(t *testing.T, svc *KubernetesService) { - paths, catchAll, err := svc.extractRoutePaths(map[string]any{ - "matches": []any{ - map[string]any{ - "path": map[string]any{ - "type": "PathPrefix", - "value": "/api", - }, - }, - map[string]any{ - "path": map[string]any{ - "type": "Exact", - "value": "/", - }, - }, - "not-a-map", - }, - }) - require.NoError(t, err) - assert.False(t, catchAll) - assert.Equal(t, []string{"/api", "/"}, paths) - }, - }, - { - description: "ExtractRoutePaths treats a match without a path as a catch all", - run: func(t *testing.T, svc *KubernetesService) { - paths, catchAll, err := svc.extractRoutePaths(map[string]any{ - "matches": []any{ - map[string]any{ - "method": map[string]any{ - "service": "com.example.Service", - }, - }, - }, - }) - require.NoError(t, err) - assert.True(t, catchAll) - assert.Empty(t, paths) - }, - }, - { - description: "ExtractRoutePaths errors when matches is not a slice", - run: func(t *testing.T, svc *KubernetesService) { - paths, catchAll, err := svc.extractRoutePaths(map[string]any{ - "matches": "invalid", - }) - require.Error(t, err) - assert.False(t, catchAll) - assert.Nil(t, paths) - }, - }, - { - description: "UpdateFromItem parses annotations and populates cache from httproute", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", - "tinyauth.apps.gwapp.users.allow": "bob", - }) - require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ - "gwapp.example.com", - }, "spec", "hostnames")) - - svc.updateFromItem(testHTTPRouteResource, &item) - - var got *model.App - svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "gwapp.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "gwapp.example.com", got.Config.Domain) - assert.Equal(t, "bob", got.Users.Allow) - }, - }, - { - description: "UpdateFromItem parses annotations and populates cache from grpcroute", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-grpcroute") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.grpcapp.config.domain": "grpcapp.example.com", - "tinyauth.apps.grpcapp.users.allow": "carol", - }) - require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ - "grpcapp.example.com", - }, "spec", "hostnames")) - - svc.updateFromItem(testGRPCRouteResource, &item) - - var got *model.App - svc.getEntry("grpcapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "grpcapp.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "grpcapp.example.com", got.Config.Domain) - assert.Equal(t, "carol", got.Users.Allow) - }, - }, - { - description: "UpdateFromItem skips routes without hostnames", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", - }) - - svc.updateFromItem(testHTTPRouteResource, &item) - - var got *model.App - svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { - got = app - return true - }) - assert.Nil(t, got) - }, - }, - { - description: "UpdateFromItem registers an app whose domain is covered by a wildcard hostname", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.gwapp.config.domain": "deep.gwapp.example.com", - }) - require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ - "*.example.com", - }, "spec", "hostnames")) - - svc.updateFromItem(testHTTPRouteResource, &item) - - var got *model.App - svc.getEntry("deep.gwapp.example.com", func(name string, app *model.App) bool { - if name == "gwapp" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "deep.gwapp.example.com", got.Config.Domain) - }, - }, - { - description: "UpdateFromItem registers an app by name under a wildcard hostname", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.gwapp.users.allow": "alice", - }) - require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ - "*.example.com", - }, "spec", "hostnames")) - - svc.updateFromItem(testHTTPRouteResource, &item) + } +} - var got *model.App - svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { - if name == "gwapp" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "alice", got.Users.Allow) - }, +func testHTTPRoute(name string, annotations map[string]string, hosts ...string) *typedItem { + hostnames := make([]gateway.Hostname, 0, len(hosts)) + for _, host := range hosts { + hostnames = append(hostnames, gateway.Hostname(host)) + } + return &typedItem{ + typ: ResourceTypeHTTPRoute, + route: &gateway.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", Annotations: annotations}, + Spec: gateway.HTTPRouteSpec{Hostnames: hostnames, Rules: []gateway.HTTPRouteRule{{}}}, }, - { - description: "HostMatches honours the gateway api wildcard suffix rule", - run: func(t *testing.T, svc *KubernetesService) { - assert.True(t, hostMatches("foo.example.com", "foo.example.com")) - assert.True(t, hostMatches("Foo.Example.com", "foo.example.com")) - assert.False(t, hostMatches("bar.example.com", "foo.example.com")) + } +} - // A wildcard is a suffix match over one or more labels - assert.True(t, hostMatches("*.example.com", "foo.example.com")) - assert.True(t, hostMatches("*.example.com", "foo.test.example.com")) - assert.False(t, hostMatches("*.example.com", "example.com")) - assert.False(t, hostMatches("*.example.com", "foo.example.net")) - }, +func testGRPCRoute(name string, annotations map[string]string, hosts ...string) *typedItem { + hostnames := make([]gateway.Hostname, 0, len(hosts)) + for _, host := range hosts { + hostnames = append(hostnames, gateway.Hostname(host)) + } + return &typedItem{ + typ: ResourceTypeGRPCRoute, + grpc: &gateway.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", Annotations: annotations}, + Spec: gateway.GRPCRouteSpec{Hostnames: hostnames}, }, - { - description: "HostCoversName matches app names against a host", - run: func(t *testing.T, svc *KubernetesService) { - assert.True(t, hostCoversName("foo.example.com", "foo")) - assert.True(t, hostCoversName("Foo.example.com", "FOO")) - assert.False(t, hostCoversName("bar.example.com", "foo")) - assert.False(t, hostCoversName("example.com", "foo")) + } +} - // A wildcard routes . for every name - assert.True(t, hostCoversName("*.example.com", "foo")) - assert.True(t, hostCoversName("*.example.com", "bar")) - }, - }, - { - description: "UpdateFromItem registers a route that has no catch-all path", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-httproute") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", - }) - require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ - "gwapp.example.com", - }, "spec", "hostnames")) - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "matches": []any{ - map[string]any{ - "path": map[string]any{ - "type": "PathPrefix", - "value": "/api", - }, - }, - }, - }, - }, "spec", "rules")) +func lookupApp(service *KubernetesService, domain string) *model.App { + var app *model.App + service.getEntry(domain, func(name string, candidate *model.App) bool { + if candidate.Config.Domain == domain || strings.HasPrefix(domain, name+".") { + app = candidate + return true + } + return false + }) + return app +} - svc.updateFromItem(testHTTPRouteResource, &item) +func TestKubernetesServiceUpdateFromItem(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() - var got *model.App - svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { - if name == "gwapp" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - }, + tests := []struct { + name string + resource ResourceType + item *typedItem + domain string + wantConfigDomain string + allow string + }{ + { + name: "Ingress matches a configured domain", + resource: ResourceTypeIngress, + item: testIngress("ingress", map[string]string{ + "tinyauth.apps.dashboard.config.domain": "dashboard.example.com", + "tinyauth.apps.dashboard.users.allow": "alice", + }, "dashboard.example.com"), + domain: "dashboard.example.com", wantConfigDomain: "dashboard.example.com", allow: "alice", + }, + { + name: "Ingress matches an app name case insensitively", + resource: ResourceTypeIngress, + item: testIngress("ingress", map[string]string{ + "tinyauth.apps.dashboard.users.allow": "alice", + }, "Dashboard.example.com"), + domain: "dashboard.example.com", allow: "alice", + }, + { + name: "HTTPRoute matches a configured domain", + resource: ResourceTypeHTTPRoute, + item: testHTTPRoute("http-route", map[string]string{ + "tinyauth.apps.api.config.domain": "api.example.com", + "tinyauth.apps.api.users.allow": "bob", + }, "api.example.com"), + domain: "api.example.com", wantConfigDomain: "api.example.com", allow: "bob", + }, + { + name: "HTTPRoute wildcard matches nested subdomains", + resource: ResourceTypeHTTPRoute, + item: testHTTPRoute("http-route", map[string]string{ + "tinyauth.apps.api.config.domain": "deep.api.example.com", + "tinyauth.apps.api.users.allow": "bob", + }, "*.example.com"), + domain: "deep.api.example.com", wantConfigDomain: "deep.api.example.com", allow: "bob", + }, + { + name: "GRPCRoute matches a configured domain", + resource: ResourceTypeGRPCRoute, + item: testGRPCRoute("grpc-route", map[string]string{ + "tinyauth.apps.grpc.config.domain": "grpc.example.com", + "tinyauth.apps.grpc.users.allow": "carol", + }, "grpc.example.com"), + domain: "grpc.example.com", wantConfigDomain: "grpc.example.com", allow: "carol", + }, + { + name: "GRPCRoute matches an app name through a wildcard", + resource: ResourceTypeGRPCRoute, + item: testGRPCRoute("grpc-route", map[string]string{ + "tinyauth.apps.grpc.users.allow": "carol", + }, "*.example.com"), + domain: "grpc.example.com", allow: "carol", }, - { - description: "Ingress and HTTPRoute apps coexist in cache", - run: func(t *testing.T, svc *KubernetesService) { - ingress := unstructured.Unstructured{} - ingress.SetNamespace("default") - ingress.SetName("my-ingress") - ingress.SetAnnotations(map[string]string{ - "tinyauth.apps.ingapp.config.domain": "ingapp.example.com", - }) - require.NoError(t, unstructured.SetNestedSlice(ingress.Object, []any{ - map[string]any{ - "host": "ingapp.example.com", - }, - }, "spec", "rules")) - - httpRoute := unstructured.Unstructured{} - httpRoute.SetNamespace("default") - httpRoute.SetName("my-httproute") - httpRoute.SetAnnotations(map[string]string{ - "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", - }) - require.NoError(t, unstructured.SetNestedStringSlice(httpRoute.Object, []string{ - "gwapp.example.com", - }, "spec", "hostnames")) - - svc.updateFromItem(testIngressResource, &ingress) - svc.updateFromItem(testHTTPRouteResource, &httpRoute) - - var got *model.App - svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "ingapp.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "ingapp.example.com", got.Config.Domain) + } - got = nil - svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { - if app.Config.Domain == "gwapp.example.com" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "gwapp.example.com", got.Config.Domain) - }, - }, - { - description: "UpdateFromItem registers app when its domain matches an ingress host", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.domain": "myapp.example.com", - }) - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "myapp.example.com", - }, - }, "spec", "rules")) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := newKubernetesServiceForTest(log) + service.updateFromItem(watchedResourceForTest(t, test.resource), test.item) + + app := lookupApp(service, test.domain) + require.NotNil(t, app) + assert.Equal(t, test.allow, app.Users.Allow) + assert.Equal(t, test.wantConfigDomain, app.Config.Domain) + }) + } +} - svc.updateFromItem(testIngressResource, &item) +func TestKubernetesServiceUpdateFromItemRemovesStaleEntries(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() - var got *model.App - svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { - if name == "myapp" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "myapp.example.com", got.Config.Domain) - }, - }, - { - description: "UpdateFromItem registers app when its name matches an ingress host prefix", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.users.allow": "alice", - }) - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "MyApp.example.com", - }, - }, "spec", "rules")) + tests := []struct { + name string + resource ResourceType + item *typedItem + }{ + {"Ingress without annotations", ResourceTypeIngress, testIngress("route", nil, "app.example.com")}, + {"Ingress without hosts", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.allow": "alice"})}, + {"HTTPRoute without annotations", ResourceTypeHTTPRoute, testHTTPRoute("route", nil, "app.example.com")}, + {"HTTPRoute without hosts", ResourceTypeHTTPRoute, testHTTPRoute("route", map[string]string{"tinyauth.apps.app.users.allow": "alice"})}, + {"GRPCRoute without annotations", ResourceTypeGRPCRoute, testGRPCRoute("route", nil, "app.example.com")}, + {"GRPCRoute without hosts", ResourceTypeGRPCRoute, testGRPCRoute("route", map[string]string{"tinyauth.apps.app.users.allow": "alice"})}, + {"Ingress with invalid annotations", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.break": "invalid"}, "app.example.com")}, + {"HTTPRoute with invalid annotations", ResourceTypeHTTPRoute, testHTTPRoute("route", map[string]string{"tinyauth.apps.app.users.break": "invalid"}, "app.example.com")}, + {"GRPCRoute with invalid annotations", ResourceTypeGRPCRoute, testGRPCRoute("route", map[string]string{"tinyauth.apps.app.users.break": "invalid"}, "app.example.com")}, + } - svc.updateFromItem(testIngressResource, &item) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := newKubernetesServiceForTest(log) + key := resourceKey{typ: test.resource, namespace: "default", name: "route"} + service.addResourceEntries(key, []string{"app.example.com"}, []resourceEntry{{ + name: "app", + app: model.App{Config: model.AppConfig{Domain: "app.example.com"}}, + }}) + + service.updateFromItem(watchedResourceForTest(t, test.resource), test.item) + assert.Nil(t, lookupApp(service, "app.example.com")) + }) + } +} - var got *model.App - svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { - if name == "myapp" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - assert.Equal(t, "alice", got.Users.Allow) +func TestTypedItemFromUnstructured(t *testing.T) { + tests := []struct { + name string + resource ResourceType + item unstructured.Unstructured + assert func(t *testing.T, item *typedItem) + }{ + { + name: "Ingress", + resource: ResourceTypeIngress, + item: unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"name": "ingress", "namespace": "default"}, + "spec": map[string]any{"rules": []any{map[string]any{"host": "app.example.com"}}}, + }}, + assert: func(t *testing.T, item *typedItem) { + require.NotNil(t, item.ingress) + assert.Equal(t, "app.example.com", item.ingress.Spec.Rules[0].Host) + }, + }, + { + name: "HTTPRoute", + resource: ResourceTypeHTTPRoute, + item: unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"name": "http-route", "namespace": "default"}, + "spec": map[string]any{"hostnames": []any{"app.example.com"}}, + }}, + assert: func(t *testing.T, item *typedItem) { + require.NotNil(t, item.route) + assert.Equal(t, gateway.Hostname("app.example.com"), item.route.Spec.Hostnames[0]) + }, + }, + { + name: "GRPCRoute", + resource: ResourceTypeGRPCRoute, + item: unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"name": "grpc-route", "namespace": "default"}, + "spec": map[string]any{"hostnames": []any{"app.example.com"}}, + }}, + assert: func(t *testing.T, item *typedItem) { + require.NotNil(t, item.grpc) + assert.Equal(t, gateway.Hostname("app.example.com"), item.grpc.Spec.Hostnames[0]) }, }, - { - description: "UpdateFromItem skips apps that match neither host nor name", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.domain": "myapp.example.com", - }) - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "other.example.com", - }, - }, "spec", "rules")) - - svc.updateFromItem(testIngressResource, &item) + } - var got *model.App - svc.getEntry("other.example.com", func(name string, app *model.App) bool { - got = app - return true - }) - assert.Nil(t, got) - }, - }, - { - description: "UpdateFromItem falls back to app name when the domain is invalid", - run: func(t *testing.T, svc *KubernetesService) { - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("test-ingress") - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.domain": "not a domain", - }) - require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ - map[string]any{ - "host": "myapp.example.com", - }, - }, "spec", "rules")) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + item, err := new(typedItem).fromUnstructured(test.resource, &test.item) + require.NoError(t, err) + assert.Equal(t, test.resource, item.typ) + test.assert(t, item) + }) + } +} - svc.updateFromItem(testIngressResource, &item) +func TestKubernetesServiceLookup(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() - var got *model.App - svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { - if name == "myapp" { - got = app - return true - } - return false - }) - require.NotNil(t, got) - }, - }, - { - description: "UpdateFromItem removes entries when host extraction fails", - run: func(t *testing.T, svc *KubernetesService) { - key := resourceKey{ - resource: "ingresses", - namespace: "default", - name: "test-ingress", - } - svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{ - { - app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}}, - name: "foo", - }, - }) + tests := []struct { + name string + connected bool + domain string + wantApp bool + }{ + {"Returns a matching app when connected", true, "app.example.com", true}, + {"Skips the cache before the service is connected", false, "app.example.com", false}, + {"Skips an invalid domain", true, "app.example.com\xC3\xA9", false}, + } - item := unstructured.Unstructured{} - item.SetNamespace(key.namespace) - item.SetName(key.name) - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.domain": "myapp.example.com", - }) - require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules")) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := newKubernetesServiceForTest(log) + service.connected = test.connected + service.addResourceEntries(resourceKey{typ: ResourceTypeIngress, namespace: "default", name: "route"}, []string{"app.example.com"}, []resourceEntry{{ + name: "app", + app: model.App{Config: model.AppConfig{Domain: "app.example.com"}}, + }}) + + var app *model.App + err := service.Lookup(test.domain, func(_ string, candidate *model.App) bool { + app = candidate + return true + }) + require.NoError(t, err) + assert.Equal(t, test.wantApp, app != nil) + }) + } +} - svc.updateFromItem(testIngressResource, &item) +func TestKubernetesServiceKeepsResourceTypesSeparate(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() + service := newKubernetesServiceForTest(log) + + resources := []struct { + resource ResourceType + item *typedItem + domain string + }{ + {ResourceTypeIngress, testIngress("shared", map[string]string{"tinyauth.apps.ingress.config.domain": "ingress.example.com"}, "ingress.example.com"), "ingress.example.com"}, + {ResourceTypeHTTPRoute, testHTTPRoute("shared", map[string]string{"tinyauth.apps.http.config.domain": "http.example.com"}, "http.example.com"), "http.example.com"}, + {ResourceTypeGRPCRoute, testGRPCRoute("shared", map[string]string{"tinyauth.apps.grpc.config.domain": "grpc.example.com"}, "grpc.example.com"), "grpc.example.com"}, + } - var got *model.App - svc.getEntry("stale.example.com", func(name string, app *model.App) bool { - got = app - return true - }) - assert.Nil(t, got) - }, - }, - { - description: "UpdateFromItem removes entries when annotations are not decodable", - run: func(t *testing.T, svc *KubernetesService) { - key := resourceKey{ - resource: "ingresses", - namespace: "default", - name: "test-ingress", - } - svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{ - { - app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}}, - name: "foo", - }, - }) + for _, resource := range resources { + service.updateFromItem(watchedResourceForTest(t, resource.resource), resource.item) + } + for _, resource := range resources { + assert.NotNil(t, lookupApp(service, resource.domain)) + } +} - item := unstructured.Unstructured{} - item.SetNamespace(key.namespace) - item.SetName(key.name) - item.SetAnnotations(map[string]string{ - "tinyauth.apps.myapp.config.oauthWhitelist": "[", - }) +func TestKubernetesHTTPRouteExtractorPaths(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() + extractor := NewKubernetesHTTPRouteExtractor(KubernetesHTTPRouteExtractorInput{Log: log}) + + prefix := gateway.PathMatchPathPrefix + exact := gateway.PathMatchExact + api := "/api" + + tests := []struct { + name string + rules []gateway.HTTPRouteRule + want []string + }{ + {"Rule without matches defaults to catch-all", []gateway.HTTPRouteRule{{}}, []string{"/"}}, + {"Match without path defaults to catch-all", []gateway.HTTPRouteRule{{Matches: []gateway.HTTPRouteMatch{{}}}}, []string{"/"}}, + {"Path defaults apply independently", []gateway.HTTPRouteRule{{Matches: []gateway.HTTPRouteMatch{{Path: &gateway.HTTPPathMatch{}}}}}, []string{"/"}}, + {"Exact paths do not count as catch-all", []gateway.HTTPRouteRule{{Matches: []gateway.HTTPRouteMatch{{Path: &gateway.HTTPPathMatch{Type: &exact}}}}}, nil}, + {"Prefix paths are retained", []gateway.HTTPRouteRule{{Matches: []gateway.HTTPRouteMatch{{Path: &gateway.HTTPPathMatch{Type: &prefix, Value: &api}}}}}, []string{"/api"}}, + } - svc.updateFromItem(testIngressResource, &item) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, extractor.getPaths(test.rules)) + }) + } +} - var got *model.App - svc.getEntry("stale.example.com", func(name string, app *model.App) bool { - got = app - return true - }) - assert.Nil(t, got) - }, - }, +func TestKubernetesHostMatching(t *testing.T) { + tests := []struct { + name string + host string + domain string + want bool + }{ + {"Exact host", "app.example.com", "app.example.com", true}, + {"Case insensitive exact host", "App.Example.com", "app.example.com", true}, + {"Wildcard host", "*.example.com", "deep.app.example.com", true}, + {"Wildcard does not match its apex", "*.example.com", "example.com", false}, + {"Different host", "app.example.com", "other.example.com", false}, } for _, test := range tests { - t.Run(test.description, func(t *testing.T) { - svc := &KubernetesService{ - resourceApps: make(map[resourceKey]routedApps), - log: log, - } - test.run(t, svc) + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, hostMatchesHostname(test.host, test.domain)) }) } } From 34456b94b1f0a9e586dc6637a459c4e4ca4a4178 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 20 Sep 2026 21:22:56 +0300 Subject: [PATCH 6/6] chore: add missing acls service modification for domain normalization --- internal/service/access_controls_service.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 605b2c9d9..8a4a549aa 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -55,7 +55,7 @@ func ensureAscii(str string) bool { return true } -func (service *AccessControlsService) normalizeDomain(domain string) string { +func normalizeDomain(domain string) string { if host, _, err := net.SplitHostPort(domain); err == nil { domain = host } @@ -68,7 +68,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator return nil, errors.New("domain contains non-ascii characters") } - normalizedDomain := service.normalizeDomain(domain) + normalizedDomain := normalizeDomain(domain) if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain { return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain) @@ -84,7 +84,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping") return false } - if normalizedDomain == service.normalizeDomain(app.Config.Domain) { + if normalizedDomain == normalizeDomain(app.Config.Domain) { service.log.App.Debug().Str("name", name).Msg("Found matching container by domain") domainMatch = app return true