Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
with:
sqlc-version: "1.31.1"

- name: Setup controller-gen
run: go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.22.0

- name: Check codegen is up to date
run: |
sqlc generate
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c

.DEFAULT_GOAL := binary

.PHONY: deps clean-data clean-webui webui binary binary-linux-amd64 binary-linux-arm64 test vet test-race dev dev-infisical prod prod-infisical sql generate docker docker-distroless
.PHONY: deps clean-data clean-webui webui binary binary-linux-amd64 binary-linux-arm64 test vet test-race dev dev-infisical prod prod-infisical sql generate docker docker-distroless tools

# Tools
tools:
go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.22.0
go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1

# Deps
deps:
Expand Down
17 changes: 17 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,28 @@ 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
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-logr/logr v1.4.4 // 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
Expand Down Expand Up @@ -118,6 +133,7 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
Expand All @@ -142,6 +158,7 @@ require (
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
8 changes: 2 additions & 6 deletions internal/service/access_controls_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
})
}

Expand Down
10 changes: 3 additions & 7 deletions internal/service/access_controls_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider
return &mockProvider{acls: acls, shouldError: shouldError}
}

func (m *mockProvider) Lookup(_ string, locator func(name string, app *model.App) bool) error {
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
if m.shouldError {
return errors.New("mock error")
}
Expand Down Expand Up @@ -153,9 +153,7 @@ func TestAccessControlsService(t *testing.T) {
Config: &model.Config{},
LabelProvider: mock,
})
app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error {
return mock.Lookup(test.domain, locator)
})
app, err := acls.getACLs(test.domain, mock.Lookup)
if test.errorFunc != nil {
test.errorFunc(t, err)
return
Expand Down Expand Up @@ -193,9 +191,7 @@ func TestAccessControlsService(t *testing.T) {
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
Expand Down
5 changes: 1 addition & 4 deletions internal/service/docker_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,7 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
return docker.client.ContainerInspect(docker.context, containerId)
}

// 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 {
func (docker *DockerService) Lookup(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
Expand Down
83 changes: 83 additions & 0 deletions internal/service/kubernetes_crd_extractor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package service

import (
"context"

"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/apis/tinyauth/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)

type KubernetesCRDInput struct {
Log *logger.Logger
Client kubernetes.Interface
}

type KubernetesCRDExtractor struct {
log *logger.Logger
client kubernetes.Interface
}

func NewKubernetesCRDExtractor(i KubernetesCRDInput) *KubernetesCRDExtractor {
return &KubernetesCRDExtractor{
log: i.Log,
client: i.Client,
}
}

func (k *KubernetesCRDExtractor) Extract(app *v1alpha1.Application) ExtractionResult {
meta := &ResourceMeta{
Typ: ResourceTypeCRD,
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,
}
}

// Convert the CRD to the internal representation.
internalApp := app.Spec.ToInternalApp()
passwordRef := app.Spec.Response.BasicAuth.PasswordSecretRef
if passwordRef != nil {
secret, err := k.client.CoreV1().Secrets(meta.Namespace).Get(context.Background(), passwordRef.Name, metav1.GetOptions{})
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", meta.Namespace).Str("name", meta.Name).Str("secret", passwordRef.Name).Str("key", passwordRef.Key).Msg("Failed to read basic auth password Secret, skipping")
return ExtractionResult{Meta: meta}
}

password, ok := secret.Data[passwordRef.Key]
if !ok {
k.log.App.Warn().Str("namespace", meta.Namespace).Str("name", meta.Name).Str("secret", passwordRef.Name).Str("key", passwordRef.Key).Msg("Basic auth password Secret key does not exist, skipping")
return ExtractionResult{Meta: meta}
}

internalApp.Response.BasicAuth.Password = string(password)
}

return ExtractionResult{
Meta: meta,
Apps: map[string]model.App{
meta.Name: internalApp,
},
}
}
80 changes: 73 additions & 7 deletions internal/service/kubernetes_ingress_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -54,15 +74,61 @@ 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{
Typ: ResourceTypeIngress,
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,
}
}
Loading
Loading