mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-09-22 02:13:31 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996316b524 | ||
|
|
90e898abbf | ||
|
|
34456b94b1 | ||
|
|
45e165f742 | ||
|
|
b43cf76d9b | ||
|
|
5a685c4a6b | ||
|
|
e1b1e722e7 | ||
|
|
3a216e95e2 | ||
|
|
2769725775 | ||
|
|
419da20681 |
@@ -28,6 +28,7 @@ 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
|
||||
@@ -81,6 +82,7 @@ require (
|
||||
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
|
||||
|
||||
@@ -168,8 +168,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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -12,23 +12,111 @@ 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"
|
||||
)
|
||||
|
||||
type ingressEntry struct {
|
||||
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"
|
||||
)
|
||||
|
||||
var supportedResources = []watchedResource{
|
||||
{
|
||||
gvr: schema.GroupVersionResource{
|
||||
Group: "networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "ingresses",
|
||||
},
|
||||
typ: ResourceTypeIngress,
|
||||
},
|
||||
}
|
||||
|
||||
func hostMatchesHostname(host string, hostname string) bool {
|
||||
host = normalizeDomain(host)
|
||||
hostname = normalizeDomain(hostname)
|
||||
if suffix, ok := strings.CutPrefix(host, "*."); ok {
|
||||
return strings.HasSuffix(hostname, "."+suffix)
|
||||
}
|
||||
return host == hostname
|
||||
}
|
||||
|
||||
func hostCoversName(host string, name string) bool {
|
||||
host = strings.ToLower(host)
|
||||
if strings.HasPrefix(host, "*.") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(host, strings.ToLower(name+"."))
|
||||
}
|
||||
|
||||
type ExtractionResult struct {
|
||||
typ ResourceType
|
||||
name string
|
||||
namespace string
|
||||
hosts []string
|
||||
annotations map[string]string
|
||||
}
|
||||
|
||||
type typedItem struct {
|
||||
typ ResourceType
|
||||
ingress *networking.Ingress
|
||||
}
|
||||
|
||||
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
|
||||
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 +124,14 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
type KubernetesServiceInput struct {
|
||||
@@ -61,32 +153,42 @@ 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",
|
||||
}
|
||||
|
||||
accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second)
|
||||
defer accessCancel()
|
||||
|
||||
_, 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)
|
||||
}
|
||||
|
||||
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
|
||||
|
||||
service := &KubernetesService{
|
||||
log: i.Log,
|
||||
client: client,
|
||||
ingressEntries: make(map[ingressKey][]ingressEntry),
|
||||
log: i.Log,
|
||||
client: client,
|
||||
apps: make(map[resourceKey]routedApps),
|
||||
}
|
||||
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
service.watchGVR(gvr, ctx)
|
||||
}, ding.RingMajor)
|
||||
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{
|
||||
Log: i.Log,
|
||||
})
|
||||
|
||||
watchedGVRs := make(map[string]bool)
|
||||
|
||||
for _, res := range supportedResources {
|
||||
ctx, cancel := context.WithTimeout(i.Ctx, 5*time.Second)
|
||||
_, err := client.Resource(res.gvr).List(ctx, metav1.ListOptions{Limit: 1})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
// The Gateway API CRDs are not installed on every cluster, so a
|
||||
// single unreachable 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
|
||||
}
|
||||
|
||||
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 +196,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 +235,106 @@ 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
|
||||
}
|
||||
var hosts []string
|
||||
for _, r := range rules {
|
||||
rule, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if host, ok := rule["host"].(string); ok && host != "" {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
paths, err := k.extractPaths(rule)
|
||||
if err != nil {
|
||||
// This is purely to warn users
|
||||
// It doesn't affect our ability to extract hosts, so we won't fail the whole operation
|
||||
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
|
||||
continue
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(paths, "/") {
|
||||
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
|
||||
}
|
||||
}
|
||||
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(),
|
||||
}
|
||||
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
k.removeIngress(key)
|
||||
if typedItem == nil {
|
||||
k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
hosts, err := k.extractHosts(item)
|
||||
switch typedItem.typ {
|
||||
case ResourceTypeIngress:
|
||||
if typedItem.ingress == nil {
|
||||
k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping")
|
||||
return
|
||||
}
|
||||
result = k.extractors.ingress.Extract(typedItem.ingress)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
key := resourceKey{
|
||||
typ: res.typ,
|
||||
namespace: result.namespace,
|
||||
name: result.name,
|
||||
}
|
||||
|
||||
if len(result.hosts) == 0 {
|
||||
k.log.App.Warn().Str("res", res.pretty()).Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in resource, skipping")
|
||||
k.removeResource(key)
|
||||
return
|
||||
}
|
||||
|
||||
labels, err := decoders.DecodeLabels[model.Apps](result.annotations, "apps")
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
labels, err := decoders.DecodeLabels[model.Apps](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)
|
||||
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 +342,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 +412,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
|
||||
}
|
||||
|
||||
@@ -4,676 +4,238 @@ 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"
|
||||
)
|
||||
|
||||
func TestKubernetesService(t *testing.T) {
|
||||
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{}
|
||||
}
|
||||
|
||||
func newKubernetesServiceForTest(log *logger.Logger) *KubernetesService {
|
||||
service := &KubernetesService{
|
||||
apps: make(map[resourceKey]routedApps),
|
||||
log: log,
|
||||
}
|
||||
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{Log: log})
|
||||
return service
|
||||
}
|
||||
|
||||
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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func TestKubernetesServiceUpdateFromItem(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
run func(t *testing.T, svc *KubernetesService)
|
||||
tests := []struct {
|
||||
name string
|
||||
resource ResourceType
|
||||
item *typedItem
|
||||
domain string
|
||||
wantConfigDomain string
|
||||
allow string
|
||||
}{
|
||||
{
|
||||
name: "Ingress matches a configured domain",
|
||||
resource: ResourceTypeIngress,
|
||||
item: testIngress("ingress", map[string]string{
|
||||
"tinyauth.apps.dashboard.config.domain": "dashboard.example.com",
|
||||
"tinyauth.apps.dashboard.users.allow": "alice",
|
||||
}, "dashboard.example.com"),
|
||||
domain: "dashboard.example.com", wantConfigDomain: "dashboard.example.com", allow: "alice",
|
||||
},
|
||||
{
|
||||
name: "Ingress matches an app name case insensitively",
|
||||
resource: ResourceTypeIngress,
|
||||
item: testIngress("ingress", map[string]string{
|
||||
"tinyauth.apps.dashboard.users.allow": "alice",
|
||||
}, "Dashboard.example.com"),
|
||||
domain: "dashboard.example.com", allow: "alice",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubernetesServiceUpdateFromItemRemovesStaleEntries(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
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"})},
|
||||
{"Ingress with invalid annotations", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.break": "invalid"}, "app.example.com")},
|
||||
}
|
||||
|
||||
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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedItemFromUnstructured(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resource ResourceType
|
||||
item unstructured.Unstructured
|
||||
assert func(t *testing.T, item *typedItem)
|
||||
}{
|
||||
{
|
||||
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",
|
||||
})
|
||||
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
app := model.App{Path: model.AppPath{Allow: "/foo"}}
|
||||
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 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "ExtractPaths returns all non empty paths from a rule",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
rule := map[string]any{
|
||||
"http": map[string]any{
|
||||
"paths": []any{
|
||||
map[string]any{"path": "/"},
|
||||
map[string]any{"path": "/api"},
|
||||
map[string]any{"path": ""},
|
||||
map[string]any{"pathType": "Prefix"},
|
||||
"not-a-map",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
paths, err := svc.extractPaths(rule)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/", "/api"}, paths)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "ExtractPaths returns nothing when http or paths are missing",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
paths, err := svc.extractPaths(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, paths)
|
||||
|
||||
paths, err = svc.extractPaths(map[string]any{
|
||||
"http": map[string]any{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, paths)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "ExtractPaths errors when http is not a map",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
paths, err := svc.extractPaths(map[string]any{
|
||||
"http": "invalid",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, paths)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "ExtractPaths errors when paths is not a slice",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
paths, err := svc.extractPaths(map[string]any{
|
||||
"http": map[string]any{
|
||||
"paths": "invalid",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, paths)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "ExtractHosts returns hosts from all rules",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
item := unstructured.Unstructured{}
|
||||
item.SetNamespace("default")
|
||||
item.SetName("test-ingress")
|
||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
||||
map[string]any{
|
||||
"host": "foo.example.com",
|
||||
"http": map[string]any{
|
||||
"paths": []any{
|
||||
map[string]any{"path": "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"host": "bar.example.com",
|
||||
},
|
||||
map[string]any{
|
||||
"host": "",
|
||||
},
|
||||
"not-a-map",
|
||||
}, "spec", "rules"))
|
||||
|
||||
hosts, err := svc.extractHosts(&item)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "ExtractHosts still returns hosts when a rule has no catch all path",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
item := unstructured.Unstructured{}
|
||||
item.SetNamespace("default")
|
||||
item.SetName("test-ingress")
|
||||
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
|
||||
map[string]any{
|
||||
"host": "foo.example.com",
|
||||
"http": map[string]any{
|
||||
"paths": []any{
|
||||
map[string]any{"path": "/api"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "spec", "rules"))
|
||||
|
||||
hosts, err := svc.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"))
|
||||
|
||||
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")
|
||||
|
||||
hosts, err := svc.extractHosts(&item)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hosts)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "ExtractHosts errors when spec.rules is not a slice",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
item := unstructured.Unstructured{}
|
||||
item.SetNamespace("default")
|
||||
item.SetName("test-ingress")
|
||||
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
|
||||
|
||||
hosts, err := svc.extractHosts(&item)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, hosts)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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"))
|
||||
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
})
|
||||
|
||||
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"))
|
||||
|
||||
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 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",
|
||||
},
|
||||
})
|
||||
|
||||
item := unstructured.Unstructured{}
|
||||
item.SetNamespace(key.namespace)
|
||||
item.SetName(key.name)
|
||||
item.SetAnnotations(map[string]string{
|
||||
"tinyauth.apps.myapp.config.oauthWhitelist": "[",
|
||||
})
|
||||
|
||||
svc.updateFromItem(&item)
|
||||
|
||||
var got *model.App
|
||||
svc.getEntry(func(name string, app *model.App) bool {
|
||||
got = app
|
||||
return true
|
||||
})
|
||||
assert.Nil(t, got)
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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) {
|
||||
item, err := new(typedItem).fromUnstructured(test.resource, &test.item)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.resource, item.typ)
|
||||
test.assert(t, item)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubernetesServiceLookup(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
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},
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
service.updateFromItem(watchedResourceForTest(t, resource.resource), resource.item)
|
||||
}
|
||||
for _, resource := range resources {
|
||||
assert.NotNil(t, lookupApp(service, resource.domain))
|
||||
}
|
||||
}
|
||||
|
||||
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.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.want, hostMatchesHostname(test.host, test.domain))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user