diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 8a4a549a..0e9cae82 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -12,12 +12,8 @@ import ( "go.uber.org/dig" ) -// LabelProvider looks up the apps it knows about for the given domain. A -// provider that knows which hosts its apps are served on MUST only yield the -// ones that are actually served on domain, so that an unrelated app cannot -// claim it by name. type LabelProvider interface { - Lookup(domain string, locator func(name string, app *model.App) bool) error + Lookup(locator func(name string, app *model.App) bool) error } type AccessControlsService struct { @@ -150,7 +146,7 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A // If we have a label provider configured, try to get ACLs from it if service.labelProvider != nil { return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error { - return service.labelProvider.Lookup(domain, locator) + return service.labelProvider.Lookup(locator) }) } diff --git a/internal/service/kubernetes_crd_extractor.go b/internal/service/kubernetes_crd_extractor.go new file mode 100644 index 00000000..bdc94705 --- /dev/null +++ b/internal/service/kubernetes_crd_extractor.go @@ -0,0 +1,57 @@ +package service + +import ( + "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils/logger" + "github.com/tinyauthapp/tinyauth/pkg/apis/tinyauth/v1alpha1" +) + +type KubernetesCRDInput struct { + Log *logger.Logger +} + +type KubernetesCRDExtractor struct { + log *logger.Logger +} + +func NewKubernetesCRDExtractor(i KubernetesCRDInput) *KubernetesCRDExtractor { + return &KubernetesCRDExtractor{ + log: i.Log, + } +} + +func (k *KubernetesCRDExtractor) Extract(app *v1alpha1.Application) ExtractionResult { + meta := &ResourceMeta{ + Name: app.GetName(), + Namespace: app.GetNamespace(), + } + + if !ensureResourceMeta(meta) { + k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Resource has no namespace or name, skipping") + return ExtractionResult{} + } + + if app.Spec.Config.Domain == "" { + k.log.App.Warn().Str("name", meta.Name).Str("namespace", meta.Namespace).Msg("Application has no domain, skipping") + return ExtractionResult{ + Meta: meta, + Apps: nil, + } + } + + if !ensureAscii(app.Spec.Config.Domain) { + k.log.App.Warn().Str("name", meta.Name).Str("namespace", meta.Namespace).Str("domain", app.Spec.Config.Domain).Msg("Domain is invalid, skipping") + return ExtractionResult{ + Meta: meta, + Apps: nil, + } + } + + return ExtractionResult{ + Meta: meta, + Apps: &map[string]model.App{ + // Convert the CRD to the internal representation + meta.Name: app.Spec.ToInternalApp(), + }, + } +} diff --git a/internal/service/kubernetes_ingress_extractor.go b/internal/service/kubernetes_ingress_extractor.go index b03882b0..13e4c851 100644 --- a/internal/service/kubernetes_ingress_extractor.go +++ b/internal/service/kubernetes_ingress_extractor.go @@ -2,11 +2,31 @@ package service import ( "slices" + "strings" + "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils/decoders" "github.com/tinyauthapp/tinyauth/internal/utils/logger" networking "k8s.io/api/networking/v1" ) +func hostMatchesHostname(host string, hostname string) bool { + host = normalizeDomain(host) + hostname = normalizeDomain(hostname) + if suffix, ok := strings.CutPrefix(host, "*."); ok { + return strings.HasSuffix(hostname, "."+suffix) + } + return host == hostname +} + +func hostCoversName(host string, name string) bool { + host = strings.ToLower(host) + if strings.HasPrefix(host, "*.") { + return true + } + return strings.HasPrefix(host, strings.ToLower(name+".")) +} + type KubernetesIngressExtractor struct { log *logger.Logger } @@ -54,15 +74,60 @@ func (k *KubernetesIngressExtractor) getHosts(rules []networking.IngressRule) [] return hosts } -func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) *ExtractionResult { +func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) ExtractionResult { + meta := &ResourceMeta{ + Name: ingress.GetName(), + Namespace: ingress.GetNamespace(), + } + + if !ensureResourceMeta(meta) { + k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Resource has no namespace or name, skipping") + return ExtractionResult{} + } + annotations := ingress.GetAnnotations() hosts := k.getHosts(ingress.Spec.Rules) - return &ExtractionResult{ - typ: ResourceTypeIngress, - name: ingress.GetName(), - namespace: ingress.GetNamespace(), - hosts: hosts, - annotations: annotations, + if len(hosts) == 0 { + k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("No hosts found in resource, skipping") + return ExtractionResult{ + Meta: meta, + } + } + + labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") + if err != nil { + k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Msg("Failed to decode resource labels, skipping") + return ExtractionResult{ + Meta: meta, + } + } + + apps := make(map[string]model.App) + + for name, config := range labels.Apps { + if config.Config.Domain != "" { + if !ensureAscii(config.Config.Domain) { + k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name") + } else { + if slices.ContainsFunc(hosts, func(host string) bool { + return hostMatchesHostname(host, config.Config.Domain) + }) { + apps[name] = config + continue + } + } + } + + if slices.ContainsFunc(hosts, func(host string) bool { + return hostCoversName(host, name) + }) { + apps[name] = config + } + } + + return ExtractionResult{ + Meta: meta, + Apps: &apps, } } diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 3e232194..80b123e2 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -3,15 +3,13 @@ package service import ( "context" "fmt" - "slices" - "strings" "sync" "time" "github.com/steveiliop56/ding" "github.com/tinyauthapp/tinyauth/internal/model" - "github.com/tinyauthapp/tinyauth/internal/utils/decoders" "github.com/tinyauthapp/tinyauth/internal/utils/logger" + "github.com/tinyauthapp/tinyauth/pkg/apis/tinyauth/v1alpha1" "go.uber.org/dig" networking "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -32,10 +30,25 @@ func (w watchedResource) pretty() string { return w.gvr.Group + "/" + w.gvr.Version + "/" + w.gvr.Resource } +func ensureResourceMeta(meta *ResourceMeta) bool { + return meta.Name != "" && meta.Namespace != "" +} + +type ResourceMeta struct { + Name string + Namespace string +} + +type ExtractionResult struct { + Meta *ResourceMeta + Apps *map[string]model.App +} + type ResourceType string const ( ResourceTypeIngress ResourceType = "ingress" + ResourceTypeCRD ResourceType = "crd" ) var supportedResources = []watchedResource{ @@ -49,34 +62,10 @@ var supportedResources = []watchedResource{ }, } -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 + crd *v1alpha1.Application } func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) { @@ -105,33 +94,13 @@ func (ti *typedItem) fromUnstructured(typ ResourceType, obj *unstructured.Unstru } } -type resourceEntry struct { - name string - app model.App -} - -type routedApps struct { - hosts []string - entries []resourceEntry -} - -type resourceKey struct { - typ ResourceType - namespace string - name string -} - type KubernetesService struct { log *logger.Logger - apps map[resourceKey]routedApps + apps map[ResourceMeta]map[string]model.App client dynamic.Interface mu sync.RWMutex connected bool - - extractors struct { - ingress *KubernetesIngressExtractor - } } type KubernetesServiceInput struct { @@ -156,13 +125,9 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) service := &KubernetesService{ log: i.Log, client: client, - apps: make(map[resourceKey]routedApps), + apps: make(map[ResourceMeta]map[string]model.App), } - service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{ - Log: i.Log, - }) - watchedGVRs := make(map[string]bool) for _, res := range supportedResources { @@ -171,8 +136,7 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) cancel() if err != nil { - // The Gateway API CRDs are not installed on every cluster, so a - // single unreachable resource is not fatal + // The CRD may not be available yet, so we'll fall back to ingress i.Log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to access resource, skipping watcher") continue } @@ -196,39 +160,25 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) return service, nil } -func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) { +func (k *KubernetesService) addResource(result ExtractionResult) { k.mu.Lock() defer k.mu.Unlock() - k.apps[key] = routedApps{ - hosts: hosts, - entries: entries, - } + k.apps[*result.Meta] = *result.Apps } -func (k *KubernetesService) removeResource(key resourceKey) { +func (k *KubernetesService) removeResource(meta ResourceMeta) { k.mu.Lock() defer k.mu.Unlock() - delete(k.apps, key) + delete(k.apps, meta) } -func (k *KubernetesService) getEntry(domain string, locator func(name string, app *model.App) bool) { - if !ensureAscii(domain) { - k.log.App.Debug().Str("domain", domain).Msg("Domain is invalid, skipping lookup") - return - } - +func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) { k.mu.RLock() defer k.mu.RUnlock() - // O(n^2) is not great but the number of resource entries is expected to be small - for _, app := range k.apps { - if !slices.ContainsFunc(app.hosts, func(host string) bool { - return hostMatchesHostname(host, domain) - }) { - continue - } - for _, entry := range app.entries { - if ok := locator(entry.name, &entry.app); ok { + for _, apps := range k.apps { + for name, app := range apps { + if ok := locator(name, &app); ok { return } } @@ -236,81 +186,43 @@ func (k *KubernetesService) getEntry(domain string, locator func(name string, ap } func (k *KubernetesService) updateFromItem(res watchedResource, typedItem *typedItem) { - var result *ExtractionResult - if typedItem == nil { k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping") return } + var result ExtractionResult + switch typedItem.typ { case ResourceTypeIngress: if typedItem.ingress == nil { k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping") return } - result = k.extractors.ingress.Extract(typedItem.ingress) + extractor := NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{ + Log: k.log, + }) + result = extractor.Extract(typedItem.ingress) + case ResourceTypeCRD: + if typedItem.crd == nil { + k.log.App.Warn().Str("res", res.pretty()).Msg("CRD is nil, skipping") + return + } + extractor := NewKubernetesCRDExtractor(KubernetesCRDInput{ + Log: k.log, + }) + result = extractor.Extract(typedItem.crd) } - if result == nil { + if result.Apps == nil { k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping") - return - } - - key := resourceKey{ - typ: res.typ, - namespace: result.namespace, - name: result.name, - } - - if len(result.hosts) == 0 { - k.log.App.Warn().Str("res", res.pretty()).Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in resource, skipping") - k.removeResource(key) - return - } - - labels, err := decoders.DecodeLabels[model.Apps](result.annotations, "apps") - if err != nil { - k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode resource labels, skipping") - k.removeResource(key) - return - } - - var entries []resourceEntry - - for name, config := range labels.Apps { - if config.Config.Domain != "" { - if !ensureAscii(config.Config.Domain) { - k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name") - } else { - if slices.ContainsFunc(result.hosts, func(host string) bool { - return hostMatchesHostname(host, config.Config.Domain) - }) { - entries = append(entries, resourceEntry{ - name: name, - app: config, - }) - continue - } - } + if result.Meta != nil { + k.removeResource(*result.Meta) } - - if slices.ContainsFunc(result.hosts, func(host string) bool { - return hostCoversName(host, name) - }) { - entries = append(entries, resourceEntry{ - name: name, - app: config, - }) - } - } - - if len(entries) == 0 { - k.removeResource(key) return } - k.addResourceEntries(key, result.hosts, entries) + k.addResource(result) } func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error { @@ -358,14 +270,8 @@ func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, r continue } switch event.Type { - case watch.Added, watch.Modified: + case watch.Added, watch.Modified, watch.Deleted: k.updateFromItem(res, newTypedItem) - case watch.Deleted: - k.removeResource(resourceKey{ - typ: res.typ, - namespace: item.GetNamespace(), - name: item.GetName(), - }) } case <-resyncTicker.C: if err := k.resyncGVR(res, ctx); err != nil { @@ -412,13 +318,13 @@ func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) { } } -func (k *KubernetesService) Lookup(domain string, locator func(name string, app *model.App) bool) error { +func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error { if !k.connected { k.log.App.Debug().Msg("Kubernetes label provider not started, skipping") return nil } - k.getEntry(domain, locator) + k.getEntry(locator) return nil } diff --git a/pkg/apis/tinyauth/v1alpha1/application.go b/pkg/apis/tinyauth/v1alpha1/application.go index c534f4a0..b9567d64 100644 --- a/pkg/apis/tinyauth/v1alpha1/application.go +++ b/pkg/apis/tinyauth/v1alpha1/application.go @@ -6,23 +6,11 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object -// ApplicationSet is a list of Application resources -type ApplicationSet struct { - metav1.TypeMeta `json:",inline"` - // +optional - metav1.ListMeta `json:"metadata,omitempty"` - - Items []Application `json:"items"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - // Application is a set of access control rules that can be applied to a // specific domain. It is an alternative to environment variable or config-based // access controls. type Application struct { - metav1.TypeMeta `json:",inline"` - // +optional + metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec ApplicationSpec `json:"spec,omitempty"` diff --git a/pkg/apis/tinyauth/v1alpha1/mapper.go b/pkg/apis/tinyauth/v1alpha1/mapper.go new file mode 100644 index 00000000..bf8d452f --- /dev/null +++ b/pkg/apis/tinyauth/v1alpha1/mapper.go @@ -0,0 +1,42 @@ +package v1alpha1 + +import ( + "github.com/tinyauthapp/tinyauth/internal/model" +) + +// ToInternalApp converts the ApplicationSpec to the internal App structure +func (s *ApplicationSpec) ToInternalApp() model.App { + return model.App{ + Config: model.AppConfig{ + Domain: s.Config.Domain, + }, + Users: model.AppUsers{ + Allow: s.Users.Allow, + Block: s.Users.Block, + }, + OAuth: model.AppOAuth{ + Whitelist: s.OAuth.Whitelist, + Groups: s.OAuth.Groups, + }, + IP: model.AppIP{ + Allow: s.IP.Allow, + Block: s.IP.Block, + Bypass: s.IP.Bypass, + }, + Response: model.AppResponse{ + Headers: s.Response.Headers, + BasicAuth: model.AppBasicAuth{ + Username: s.Response.BasicAuth.Username, + Password: s.Response.BasicAuth.Password, + PasswordFile: s.Response.BasicAuth.PasswordFile, + }, + }, + Path: model.AppPath{ + Allow: s.Path.Allow, + Block: s.Path.Block, + }, + LDAP: model.AppLDAP{ + Groups: s.LDAP.Groups, + }, + } +} diff --git a/pkg/apis/tinyauth/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/tinyauth/v1alpha1/zz_generated.deepcopy.go index c0a511e2..35bca166 100644 --- a/pkg/apis/tinyauth/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/tinyauth/v1alpha1/zz_generated.deepcopy.go @@ -175,38 +175,6 @@ func (in *Application) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ApplicationSet) DeepCopyInto(out *ApplicationSet) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]Application, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationSet. -func (in *ApplicationSet) DeepCopy() *ApplicationSet { - if in == nil { - return nil - } - out := new(ApplicationSet) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ApplicationSet) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApplicationSpec) DeepCopyInto(out *ApplicationSpec) { *out = *in