diff --git a/go.mod b/go.mod index 4e4ca5b8..cf751669 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 7e6ae78f..5555043b 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 922926bd..8a4a549a 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -12,8 +12,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 { @@ -42,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 @@ -51,7 +55,7 @@ func (service *AccessControlsService) 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 } @@ -60,11 +64,11 @@ 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") } - 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) @@ -76,11 +80,11 @@ 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 } - 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 @@ -145,7 +149,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 c5d00b7c..7c877fa3 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -20,7 +20,7 @@ func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider return &mockProvider{acls: acls, shouldError: shouldError} } -func (m *mockProvider) Lookup(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") } @@ -153,7 +153,9 @@ func TestAccessControlsService(t *testing.T) { Config: &model.Config{}, LabelProvider: mock, }) - app, err := acls.getACLs(test.domain, mock.Lookup) + app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error { + return mock.Lookup(test.domain, locator) + }) if test.errorFunc != nil { test.errorFunc(t, err) return @@ -186,11 +188,14 @@ 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, - Runtime: &runtime, - Config: &model.Config{}, + Log: log, + Runtime: &runtime, + Config: &model.Config{}, + LabelProvider: mock, + }) + _, err := acls.getACLs("example.com", func(locator func(name string, app *model.App) bool) error { + return mock.Lookup("example.com", locator) }) - _, err := acls.getACLs("example.com", mock.Lookup) assert.Error(t, err) // get acls should return an error when multiple apps with the same domain exist diff --git a/internal/service/docker_service.go b/internal/service/docker_service.go index 28b12f8e..417baf6f 100644 --- a/internal/service/docker_service.go +++ b/internal/service/docker_service.go @@ -119,7 +119,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_grpcroute_extractor.go b/internal/service/kubernetes_grpcroute_extractor.go new file mode 100644 index 00000000..299a5665 --- /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 hosts +} + +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 00000000..39cb0cc3 --- /dev/null +++ b/internal/service/kubernetes_httproute_extractor.go @@ -0,0 +1,98 @@ +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 hosts +} + +func (k *KubernetesHTTPRouteExtractor) getRuleMatchers(matchers []gateway.HTTPRouteMatch) []string { + var res []string + + for _, m := range matchers { + if m.Path == nil { + res = append(res, "/") + continue + } + + pathType := gateway.PathMatchPathPrefix + if m.Path.Type != nil { + pathType = *m.Path.Type + } + if pathType != gateway.PathMatchPathPrefix { + continue + } + + pathValue := "/" + if m.Path.Value != nil { + pathValue = *m.Path.Value + } + res = append(res, pathValue) + } + + return res +} + +func (k *KubernetesHTTPRouteExtractor) getPaths(rules []gateway.HTTPRouteRule) []string { + 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...) + } + + 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 00000000..b03882b0 --- /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 + + if rule.HTTP == nil { + return paths + } + + 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 hosts +} + +func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) *ExtractionResult { + annotations := ingress.GetAnnotations() + 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 942c5e17..610cf1df 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -12,23 +12,150 @@ 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" +) + +type watchedResource struct { + gvr schema.GroupVersionResource + typ ResourceType +} + +func (w watchedResource) pretty() string { + return w.gvr.Group + "/" + w.gvr.Version + "/" + w.gvr.Resource +} + +type ResourceType string + +const ( + ResourceTypeIngress ResourceType = "ingress" + ResourceTypeGRPCRoute ResourceType = "grpcroute" + ResourceTypeHTTPRoute ResourceType = "httproute" ) -type ingressEntry struct { +var supportedResources = []watchedResource{ + { + gvr: schema.GroupVersionResource{ + Group: "networking.k8s.io", + Version: "v1", + Resource: "ingresses", + }, + typ: ResourceTypeIngress, + }, + { + gvr: schema.GroupVersionResource{ + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "httproutes", + }, + typ: ResourceTypeHTTPRoute, + }, + { + gvr: schema.GroupVersionResource{ + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "grpcroutes", + }, + typ: ResourceTypeGRPCRoute, + }, +} + +func hostMatchesHostname(host string, hostname string) bool { + host = normalizeDomain(host) + hostname = normalizeDomain(hostname) + if suffix, ok := strings.CutPrefix(host, "*."); ok { + return strings.HasSuffix(hostname, "."+suffix) + } + return host == hostname +} + +func hostCoversName(host string, name string) bool { + host = strings.ToLower(host) + if strings.HasPrefix(host, "*.") { + return true + } + return strings.HasPrefix(host, strings.ToLower(name+".")) +} + +type ExtractionResult struct { + typ ResourceType + name string + namespace string + hosts []string + annotations map[string]string +} + +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 } -type ingressKey struct { +type routedApps struct { + hosts []string + entries []resourceEntry +} + +type resourceKey struct { + typ ResourceType namespace string name string } @@ -36,10 +163,16 @@ type ingressKey struct { type KubernetesService struct { log *logger.Logger - client dynamic.Interface - connected bool - mu sync.RWMutex - ingressEntries map[ingressKey][]ingressEntry + apps map[resourceKey]routedApps + client dynamic.Interface + mu sync.RWMutex + connected bool + + extractors struct { + ingress *KubernetesIngressExtractor + httproute *KubernetesHTTPRouteExtractor + grpc *KubernetesGRPCRouteExtractor + } } type KubernetesServiceInput struct { @@ -61,32 +194,48 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) 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: i.Log, + client: client, + apps: make(map[resourceKey]routedApps), } - accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second) - defer accessCancel() + service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{ + Log: i.Log, + }) + service.extractors.httproute = NewKubernetesHTTPRouteExtractor(KubernetesHTTPRouteExtractorInput{ + Log: i.Log, + }) + service.extractors.grpc = NewKubernetesGRPCRouteExtractor(KubernetesGRPCRouteExtractorInput{ + Log: i.Log, + }) - _, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1}) - if err != nil { - i.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) - } + watchedGVRs := make(map[string]bool) - i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher") + 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() - service := &KubernetesService{ - log: i.Log, - client: client, - ingressEntries: make(map[ingressKey][]ingressEntry), + if err != nil { + // The Gateway API CRDs are not installed on every cluster, so a + // 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("res", res.pretty()).Msg("Successfully accessed resource, starting watcher") + + i.Ding.Go(func(ctx context.Context) { + service.watchGVR(res, ctx) + }, ding.RingMajor) + + watchedGVRs[res.gvr.Resource] = true } - i.Ding.Go(func(ctx context.Context) { - service.watchGVR(gvr, ctx) - }, ding.RingMajor) + if len(watchedGVRs) == 0 { + return nil, fmt.Errorf("failed to access any supported kubernetes api (ingresses, httproutes, grpcroutes)") + } service.connected = true i.Log.App.Debug().Msg("Kubernetes label provider started successfully") @@ -94,25 +243,38 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) return service, nil } -func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) { +func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) { k.mu.Lock() defer k.mu.Unlock() - k.ingressEntries[key] = entries + k.apps[key] = routedApps{ + hosts: hosts, + entries: entries, + } } -func (k *KubernetesService) removeIngress(key ingressKey) { +func (k *KubernetesService) removeResource(key resourceKey) { k.mu.Lock() defer k.mu.Unlock() - delete(k.ingressEntries, key) + delete(k.apps, 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) { + if !ensureAscii(domain) { + k.log.App.Debug().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 ingress entries is expected to be small - for _, entries := range k.ingressEntries { - for _, entry := range entries { + // O(n^2) is not great but the number of resource entries is expected to be small + for _, app := range k.apps { + if !slices.ContainsFunc(app.hosts, func(host string) bool { + return hostMatchesHostname(host, domain) + }) { + continue + } + for _, entry := range app.entries { if ok := locator(entry.name, &entry.app); ok { return } @@ -120,156 +282,118 @@ func (k *KubernetesService) getEntry(locator func(name string, app *model.App) b } } -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 -func (k *KubernetesService) extractHosts(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 + if typedItem == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping") + return } - 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 + + switch typedItem.typ { + case ResourceTypeIngress: + if typedItem.ingress == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping") + return } - if len(paths) == 0 { - continue + 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 } - 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.httproute.Extract(typedItem.route) + case ResourceTypeGRPCRoute: + if typedItem.grpc == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("GRPCRoute is nil, skipping") + return } - } - k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules") - return hosts, nil -} - -func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { - key := ingressKey{ - namespace: item.GetNamespace(), - name: item.GetName(), + result = k.extractors.grpc.Extract(typedItem.grpc) } - annotations := item.GetAnnotations() - if annotations == nil { - k.removeIngress(key) + if result == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping") return } - hosts, err := k.extractHosts(item) - if err != nil { - k.removeIngress(key) - return + key := resourceKey{ + typ: res.typ, + namespace: result.namespace, + name: result.name, } - if len(hosts) == 0 { - k.log.App.Warn().Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in ingress, skipping") - k.removeIngress(key) + 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 ingress labels, skipping") - k.removeIngress(key) + k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode resource labels, skipping") + k.removeResource(key) return } - var entries []ingressEntry - - v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) + var entries []resourceEntry 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.Contains(hosts, hostname) { - entries = append(entries, ingressEntry{ - 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 + } } } - for _, host := range hosts { - if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) { - entries = append(entries, ingressEntry{ - name: name, - app: config, - }) - break - } + if slices.ContainsFunc(result.hosts, func(host string) bool { + return hostCoversName(host, name) + }) { + entries = append(entries, resourceEntry{ + name: name, + app: config, + }) } } if len(entries) == 0 { - k.removeIngress(key) + k.removeResource(key) return } - k.addIngressEntries(key, entries) + k.addResourceEntries(key, result.hosts, entries) } -func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error { +func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{}) + list, err := k.client.Resource(res.gvr).List(ctx, metav1.ListOptions{}) if err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).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(&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", gvr.GroupVersion().String()).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(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool { +func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool { for { select { case <-ctx.Done(): @@ -277,62 +401,68 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch. return false case event, ok := <-w.ResultChan(): if !ok { - k.log.App.Warn().Str("api", gvr.GroupVersion().String()).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", gvr.GroupVersion().String()).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(item) + k.updateFromItem(res, newTypedItem) case watch.Deleted: - k.removeIngress(ingressKey{ + k.removeResource(resourceKey{ + typ: res.typ, namespace: item.GetNamespace(), name: item.GetName(), }) } case <-resyncTicker.C: - if err := k.resyncGVR(gvr, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run") + if err := k.resyncGVR(res, ctx); err != nil { + k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Periodic resync failed during watcher run") } } } } -func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx context.Context) { +func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) { resyncTicker := time.NewTicker(5 * time.Minute) defer resyncTicker.Stop() - if err := k.resyncGVR(gvr, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry") + if err := k.resyncGVR(res, ctx); err != nil { + 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", gvr.GroupVersion().String()).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(gvr, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry") + if err := k.resyncGVR(res, ctx); err != nil { + 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(gvr).Watch(ctx, metav1.ListOptions{}) + watcher, err := k.client.Resource(res.gvr).Watch(ctx, metav1.ListOptions{}) if err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).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", gvr.GroupVersion().String()).Msg("Watcher started successfully") - if !k.runWatcher(gvr, watcher, resyncTicker, ctx) { + k.log.App.Debug().Str("res", res.pretty()).Msg("Watcher started successfully") + if !k.runWatcher(res, watcher, resyncTicker, ctx) { cancel() return } @@ -341,13 +471,13 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex } } -func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error { +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 d1b0b6bc..2d72fbca 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -4,676 +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 TestKubernetesService(t *testing.T) { - log := logger.NewLogger().WithTestConfig() - log.Init() - - type testCase struct { - description string - run func(t *testing.T, svc *KubernetesService) +func watchedResourceForTest(t *testing.T, typ ResourceType) watchedResource { + t.Helper() + for _, resource := range supportedResources { + if resource.typ == typ { + return resource + } } + t.Fatalf("unsupported resource type %q", typ) + return watchedResource{} +} - 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.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ - { - app: app, - name: "foo", - }, - }) - - var got *model.App - svc.getEntry(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) - }, - }, - { - description: "RemoveIngress clears domain and app name entries", - run: func(t *testing.T, svc *KubernetesService) { - app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} - svc.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ - { - app: app, - name: "foo", - }, - }) - - var got *model.App - svc.getEntry(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.removeIngress(ingressKey{ - namespace: "default", - name: "my-ingress", - }) +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 +} - svc.getEntry(func(name string, app *model.App) bool { - if app.Config.Domain == "foo.example.com" { - got = app - return true - } - return false - }) - assert.Nil(t, got) - }, +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}) + } + return &typedItem{ + typ: ResourceTypeIngress, + ingress: &networking.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", Annotations: annotations}, + Spec: networking.IngressSpec{Rules: rules}, }, - { - description: "AddIngressApps replaces stale entries for the same ingress", - run: func(t *testing.T, svc *KubernetesService) { - old := model.App{Config: model.AppConfig{Domain: "old.example.com"}} - svc.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ - { - app: old, - name: "foo", - }, - }) - - updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}} - svc.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ - { - app: updated, - name: "foo", - }, - }) - - var got *model.App - svc.getEntry(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(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) - }, +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: "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.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ - { - app: app, - name: "foo", - }, - }) + } +} - var got *model.App - err := svc.Lookup(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) - }, +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: "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(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 +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 +} - app := model.App{Path: model.AppPath{Allow: "/foo"}} - svc.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ - { - app: app, - name: "foo", - }, - }) +func TestKubernetesServiceUpdateFromItem(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() - 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 - }) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "/foo", got.Path.Allow) - }, - }, + tests := []struct { + name string + resource ResourceType + item *typedItem + domain string + wantConfigDomain string + allow string + }{ { - description: "GetLabels returns empty app when service not yet started", - run: func(t *testing.T, svc *KubernetesService) { - var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { - return false - }) - require.NoError(t, err) - assert.Nil(t, got) - }, + 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", }, { - 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(&item) - - var got *model.App - svc.getEntry(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) - }, + 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", }, { - 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(&item) - - var got *model.App - svc.getEntry(func(name string, app *model.App) bool { - if app.Config.Domain == "myapp.example.com" { - got = app - return true - } - return false - }) - assert.Nil(t, got) - }, + 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", }, { - 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(&item) - - var got *model.App - svc.getEntry(func(name string, app *model.App) bool { - if app.Config.Domain == "myapp.example.com" { - got = app - return true - } - return false - }) - - require.Nil(t, got) - }, + 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", }, { - 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.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ - { - app: app, - name: "foo", - }, - }) - - item := unstructured.Unstructured{} - item.SetNamespace("default") - item.SetName("my-ingress") - - svc.updateFromItem(&item) - - var got *model.App - svc.getEntry(func(name string, app *model.App) bool { - if app.Config.Domain == "todelete.example.com" { - got = app - return true - } - return false - }) - assert.Nil(t, got) - }, + 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", }, { - 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) - }, + 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: "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")) + 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) + }) + } +} - hosts, err := svc.extractHosts(&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")) +func TestKubernetesServiceUpdateFromItemRemovesStaleEntries(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() - hosts, err := svc.extractHosts(&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")) + 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")}, + } - hosts, err := svc.extractHosts(&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") + 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")) + }) + } +} - hosts, err := svc.extractHosts(&item) - require.NoError(t, err) - assert.Empty(t, hosts) - }, - }, +func TestTypedItemFromUnstructured(t *testing.T) { + tests := []struct { + name string + resource ResourceType + item unstructured.Unstructured + assert func(t *testing.T, item *typedItem) + }{ { - 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.extractHosts(&item) - require.Error(t, err) - assert.Nil(t, hosts) + 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) }, }, { - 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")) - - svc.updateFromItem(&item) - - var got *model.App - svc.getEntry(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) + 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]) }, }, { - 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")) - - svc.updateFromItem(&item) - - var got *model.App - svc.getEntry(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) + 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(&item) + } - var got *model.App - svc.getEntry(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(&item) +func TestKubernetesServiceLookup(t *testing.T) { + log := logger.NewLogger().WithTestConfig() + log.Init() - var got *model.App - svc.getEntry(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 := ingressKey{ - namespace: "default", - name: "test-ingress", - } - svc.addIngressEntries(key, []ingressEntry{ - { - 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(&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(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 := ingressKey{ - namespace: "default", - name: "test-ingress", - } - svc.addIngressEntries(key, []ingressEntry{ - { - 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(&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(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{ - ingressEntries: make(map[ingressKey][]ingressEntry), - log: log, - } - test.run(t, svc) + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, hostMatchesHostname(test.host, test.domain)) }) } }