From 419da20681f2f073c2a63484ace2bc7a84a918da Mon Sep 17 00:00:00 2001 From: Contre Date: Mon, 18 May 2026 11:44:45 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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) })