mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-08-27 15:53:32 +08:00
Compare commits
3
Commits
v5.1.2-beta.3
...
v5.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7077a7c84 | ||
|
|
80bc87188e | ||
|
|
0e7bdf6cd5 |
@@ -223,6 +223,11 @@ TINYAUTH_LDAP_AUTHKEY=
|
|||||||
# Cache duration for LDAP group membership in seconds.
|
# Cache duration for LDAP group membership in seconds.
|
||||||
TINYAUTH_LDAP_GROUPCACHETTL=900
|
TINYAUTH_LDAP_GROUPCACHETTL=900
|
||||||
|
|
||||||
|
# experimental config
|
||||||
|
|
||||||
|
# Enable the OAuth bridge, uses a new way to format OAuth user information.
|
||||||
|
TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false
|
||||||
|
|
||||||
# tailscale config
|
# tailscale config
|
||||||
|
|
||||||
# Enable Tailscale integration.
|
# Enable Tailscale integration.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
@@ -32,10 +33,10 @@ func main() {
|
|||||||
Resources: loaders,
|
Resources: loaders,
|
||||||
Run: func(_ []string) error {
|
Run: func(_ []string) error {
|
||||||
// enable this on experimental features
|
// enable this on experimental features
|
||||||
//if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) {
|
if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) {
|
||||||
// colors := getColors()
|
colors := getColors()
|
||||||
// fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
|
fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
|
||||||
//}
|
}
|
||||||
return runCmd(*tConfig)
|
return runCmd(*tConfig)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,35 +220,16 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var name string
|
oauthUserInfo := controller.createOAuthUserInfo(oauthUserInfo{
|
||||||
|
Username: user.PreferredUsername,
|
||||||
if strings.TrimSpace(user.Name) != "" {
|
Email: user.Email,
|
||||||
controller.log.App.Debug().Msg("Using name from OAuth provider")
|
Name: user.Name,
|
||||||
name = user.Name
|
})
|
||||||
} else {
|
|
||||||
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
|
|
||||||
parts := strings.SplitN(user.Email, "@", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
|
|
||||||
} else {
|
|
||||||
name = utils.Capitalize(user.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var username string
|
|
||||||
|
|
||||||
if strings.TrimSpace(user.PreferredUsername) != "" {
|
|
||||||
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
|
|
||||||
username = user.PreferredUsername
|
|
||||||
} else {
|
|
||||||
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
|
|
||||||
username = strings.Replace(user.Email, "@", "_", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionCookie := repository.Session{
|
sessionCookie := repository.Session{
|
||||||
Username: username,
|
Username: oauthUserInfo.Username,
|
||||||
Name: name,
|
Name: oauthUserInfo.Name,
|
||||||
Email: user.Email,
|
Email: oauthUserInfo.Email,
|
||||||
Provider: svc.ID(),
|
Provider: svc.ID(),
|
||||||
OAuthGroups: utils.CoalesceToString(user.Groups),
|
OAuthGroups: utils.CoalesceToString(user.Groups),
|
||||||
OAuthName: svc.Name(),
|
OAuthName: svc.Name(),
|
||||||
@@ -355,3 +336,59 @@ func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type oauthUserInfo struct {
|
||||||
|
Email string
|
||||||
|
Username string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (controller *OAuthController) createOAuthUserInfo(input oauthUserInfo) oauthUserInfo {
|
||||||
|
info := oauthUserInfo{
|
||||||
|
Email: input.Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
if controller.config.Experimental.OAuthBridgeEnabled {
|
||||||
|
if input.Username != "" {
|
||||||
|
info.Username = input.Username
|
||||||
|
} else {
|
||||||
|
parts := strings.SplitN(input.Email, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
|
||||||
|
} else {
|
||||||
|
info.Username = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Name != "" {
|
||||||
|
info.Name = input.Name
|
||||||
|
} else {
|
||||||
|
info.Name = utils.Capitalize(info.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Name != "" {
|
||||||
|
controller.log.App.Debug().Msg("Using name from OAuth provider")
|
||||||
|
info.Name = input.Name
|
||||||
|
} else {
|
||||||
|
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
|
||||||
|
parts := strings.SplitN(input.Email, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
|
||||||
|
} else {
|
||||||
|
info.Name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Username != "" {
|
||||||
|
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
|
||||||
|
info.Username = input.Username
|
||||||
|
} else {
|
||||||
|
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
|
||||||
|
info.Username = strings.Replace(info.Email, "@", "_", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ var (
|
|||||||
type ContextMiddleware struct {
|
type ContextMiddleware struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
runtime *model.RuntimeConfig
|
runtime *model.RuntimeConfig
|
||||||
|
config *model.Config
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
broker *service.OAuthBrokerService
|
broker *service.OAuthBrokerService
|
||||||
tailscale *service.TailscaleService
|
tailscale *service.TailscaleService
|
||||||
@@ -50,6 +51,7 @@ type ContextMiddlewareInput struct {
|
|||||||
|
|
||||||
Log *logger.Logger
|
Log *logger.Logger
|
||||||
RuntimeConfig *model.RuntimeConfig
|
RuntimeConfig *model.RuntimeConfig
|
||||||
|
StaticConfig *model.Config
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
BrokerService *service.OAuthBrokerService
|
BrokerService *service.OAuthBrokerService
|
||||||
TailscaleService *service.TailscaleService
|
TailscaleService *service.TailscaleService
|
||||||
@@ -59,6 +61,7 @@ func NewContextMiddleware(i ContextMiddlewareInput) *ContextMiddleware {
|
|||||||
return &ContextMiddleware{
|
return &ContextMiddleware{
|
||||||
log: i.Log,
|
log: i.Log,
|
||||||
runtime: i.RuntimeConfig,
|
runtime: i.RuntimeConfig,
|
||||||
|
config: i.StaticConfig,
|
||||||
auth: i.AuthService,
|
auth: i.AuthService,
|
||||||
broker: i.BrokerService,
|
broker: i.BrokerService,
|
||||||
tailscale: i.TailscaleService,
|
tailscale: i.TailscaleService,
|
||||||
@@ -332,16 +335,19 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext,
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
username := strings.Replace(whois.LoginName, "@", "_", 1)
|
|
||||||
|
|
||||||
uctx := model.TailscaleContext{
|
uctx := model.TailscaleContext{
|
||||||
BaseContext: model.BaseContext{
|
BaseContext: model.BaseContext{
|
||||||
Username: username,
|
Email: whois.LoginName,
|
||||||
Email: whois.LoginName,
|
Name: whois.DisplayName,
|
||||||
Name: whois.DisplayName,
|
|
||||||
},
|
},
|
||||||
NodeName: whois.NodeName,
|
NodeName: whois.NodeName,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Experimental.OAuthBridgeEnabled {
|
||||||
|
uctx.BaseContext.Username = strings.SplitN(whois.LoginName, "@", 2)[0]
|
||||||
|
} else {
|
||||||
|
uctx.BaseContext.Username = strings.Replace(whois.LoginName, "@", "_", 1)
|
||||||
|
}
|
||||||
|
|
||||||
return &uctx, nil
|
return &uctx, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ type Config struct {
|
|||||||
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
|
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
|
||||||
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
|
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
|
||||||
// enable the cli warning on experimental features
|
// enable the cli warning on experimental features
|
||||||
//Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
|
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
|
||||||
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
|
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
|
||||||
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
|
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -238,7 +238,9 @@ type LogStreamConfig struct {
|
|||||||
Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"`
|
Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//type ExperimentalConfig struct{}
|
type ExperimentalConfig struct {
|
||||||
|
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type TailscaleConfig struct {
|
type TailscaleConfig struct {
|
||||||
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
|
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type LabelProvider interface {
|
type LabelProvider interface {
|
||||||
GetLabels(appDomain string) (*model.App, error)
|
Lookup(locator func(name string, app *model.App) bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccessControlsService struct {
|
type AccessControlsService struct {
|
||||||
@@ -37,35 +37,74 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
|
||||||
var nameMatch *model.App
|
|
||||||
|
|
||||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||||
|
|
||||||
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
|
var domainMatch *model.App
|
||||||
for app, config := range service.config.Apps {
|
var nameMatch *model.App
|
||||||
if config.Config.Domain != "" {
|
var nameMatchedApps []string
|
||||||
err := v.Validate(config.Config.Domain, domain)
|
|
||||||
|
locatorFunc := func(name string, app *model.App) bool {
|
||||||
|
if app.Config.Domain != "" {
|
||||||
|
err := v.Validate(app.Config.Domain, domain)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
|
||||||
return &config
|
domainMatch = app
|
||||||
}
|
return true
|
||||||
if !errors.Is(err, validators.ErrHostnameMismatch) {
|
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
|
||||||
service.log.App.Debug().Str("name", app).Err(err).Msg("Domain validation failed")
|
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(app+".")) {
|
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
|
||||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
|
||||||
nameMatch = &config
|
nameMatch = app
|
||||||
|
nameMatchedApps = append(nameMatchedApps, name)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return nameMatch
|
err := lookup(locatorFunc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if domainMatch != nil {
|
||||||
|
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by domain")
|
||||||
|
return domainMatch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if nameMatch == nil {
|
||||||
|
service.log.App.Debug().Str("domain", domain).Msg("No match found for domain, skipping")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nameMatchedApps) > 1 {
|
||||||
|
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
|
||||||
|
}
|
||||||
|
|
||||||
|
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
|
||||||
|
return nameMatch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
|
||||||
|
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
|
||||||
|
for app, config := range service.config.Apps {
|
||||||
|
if ok := locator(app, &config); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
||||||
// First check in the static config
|
// First check in the static config
|
||||||
app := service.lookupStaticACLs(domain)
|
app, err := service.lookupStaticACLs(domain)
|
||||||
|
|
||||||
|
// Will never return an error here, but we need to check it
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if app != nil {
|
if app != nil {
|
||||||
service.log.App.Debug().Msg("Using static ACLs for app")
|
service.log.App.Debug().Msg("Using static ACLs for app")
|
||||||
@@ -74,9 +113,9 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
|
|||||||
|
|
||||||
// If we have a label provider configured, try to get ACLs from it
|
// If we have a label provider configured, try to get ACLs from it
|
||||||
if service.labelProvider != nil {
|
if service.labelProvider != nil {
|
||||||
return service.labelProvider.GetLabels(domain)
|
return service.getACLs(domain, service.labelProvider.Lookup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// no labels
|
// No labels
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,224 +4,184 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockLabelProvider struct {
|
type mockProvider struct {
|
||||||
getLabelsFn func(appDomain string) (*model.App, error)
|
acls map[string]model.App
|
||||||
calledWith string
|
shouldError bool
|
||||||
callCount int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockLabelProvider) GetLabels(appDomain string) (*model.App, error) {
|
func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider {
|
||||||
m.calledWith = appDomain
|
return &mockProvider{acls: acls, shouldError: shouldError}
|
||||||
m.callCount++
|
}
|
||||||
if m.getLabelsFn != nil {
|
|
||||||
return m.getLabelsFn(appDomain)
|
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
|
||||||
|
if m.shouldError {
|
||||||
|
return errors.New("mock error")
|
||||||
}
|
}
|
||||||
return nil, nil
|
for name, app := range m.acls {
|
||||||
|
if ok := locator(name, &app); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLookupStaticACLs(t *testing.T) {
|
func TestAccessControlsService(t *testing.T) {
|
||||||
log := logger.NewLogger().WithTestConfig()
|
log := logger.NewLogger().WithTestConfig()
|
||||||
log.Init()
|
log.Init()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
apps map[string]model.App
|
domain string
|
||||||
domain string
|
acls map[string]model.App
|
||||||
expectNil bool
|
want *model.App
|
||||||
expectedDomain string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "returns nil when no apps are configured",
|
name: "returns ACLs for domain",
|
||||||
apps: nil,
|
domain: "example.com",
|
||||||
domain: "foo.example.com",
|
acls: map[string]model.App{
|
||||||
expectNil: true,
|
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||||
|
},
|
||||||
|
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "returns nil when no app matches",
|
name: "returns ACLs for domain with port",
|
||||||
apps: map[string]model.App{
|
domain: "example.com:8080",
|
||||||
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
acls: map[string]model.App{
|
||||||
|
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||||
},
|
},
|
||||||
domain: "bar.example.com",
|
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||||
expectNil: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches by exact domain",
|
name: "returns ACLs for domain with trailing dot",
|
||||||
apps: map[string]model.App{
|
domain: "example.com.",
|
||||||
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
acls: map[string]model.App{
|
||||||
|
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||||
},
|
},
|
||||||
domain: "foo.example.com",
|
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||||
expectedDomain: "foo.example.com",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches by app name when domain does not match any app",
|
name: "returns ACLs for non-ascii domain",
|
||||||
apps: map[string]model.App{
|
domain: "bücher.example.com",
|
||||||
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
|
acls: map[string]model.App{
|
||||||
|
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}},
|
||||||
},
|
},
|
||||||
domain: "foo.example.com",
|
want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}},
|
||||||
expectedDomain: "configured.example.com",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matches by app name for nested subdomains",
|
name: "returns ACLs for punycode domain and non-ascii config",
|
||||||
apps: map[string]model.App{
|
domain: "bücher.example.com",
|
||||||
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
|
acls: map[string]model.App{
|
||||||
|
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
||||||
},
|
},
|
||||||
domain: "foo.sub.example.com",
|
want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
|
||||||
expectedDomain: "configured.example.com",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "selects the app matching by domain among multiple apps",
|
name: "returns ACLs with case-insensitive matching",
|
||||||
apps: map[string]model.App{
|
domain: "Example.com",
|
||||||
"unrelated": {Config: model.AppConfig{Domain: "other.example.com"}},
|
acls: map[string]model.App{
|
||||||
"target": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
"foo": {Config: model.AppConfig{Domain: "example.com"}},
|
||||||
},
|
},
|
||||||
domain: "foo.example.com",
|
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
|
||||||
expectedDomain: "foo.example.com",
|
},
|
||||||
|
{
|
||||||
|
name: "falls back to name matching when domain fails",
|
||||||
|
domain: "app.example.com",
|
||||||
|
acls: map[string]model.App{
|
||||||
|
"app": {Path: model.AppPath{Allow: "/foo"}},
|
||||||
|
},
|
||||||
|
want: &model.App{Path: model.AppPath{Allow: "/foo"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name matching is case-insensitive",
|
||||||
|
domain: "aPp.example.com",
|
||||||
|
acls: map[string]model.App{
|
||||||
|
"APP": {Path: model.AppPath{Allow: "/foo"}},
|
||||||
|
},
|
||||||
|
want: &model.App{Path: model.AppPath{Allow: "/foo"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "returns nil when no ACLs are found",
|
||||||
|
domain: "example.com",
|
||||||
|
acls: map[string]model.App{},
|
||||||
|
want: nil,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
// run once for a mock provider
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
for _, test := range tests {
|
||||||
svc := NewAccessControlsService(AccessControlServiceInput{
|
t.Run(test.name+"(getACLs)", func(t *testing.T) {
|
||||||
|
mock := newMockProvider(test.acls, false)
|
||||||
|
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||||
Log: log,
|
Log: log,
|
||||||
Config: &model.Config{Apps: tt.apps},
|
Config: &model.Config{},
|
||||||
LabelProvider: nil,
|
LabelProvider: mock,
|
||||||
})
|
})
|
||||||
got := svc.lookupStaticACLs(tt.domain)
|
app, err := acls.getACLs(test.domain, mock.Lookup)
|
||||||
if tt.expectNil {
|
require.NoError(t, err)
|
||||||
assert.Nil(t, got)
|
require.Equal(t, test.want, app)
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, tt.expectedDomain, got.Config.Domain)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetAccessControls(t *testing.T) {
|
// run again for static acls
|
||||||
log := logger.NewLogger().WithTestConfig()
|
for _, test := range tests {
|
||||||
log.Init()
|
t.Run(test.name+"(staticACLs)", func(t *testing.T) {
|
||||||
|
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||||
t.Run("returns static ACLs when domain matches", func(t *testing.T) {
|
Log: log,
|
||||||
config := model.Config{
|
Config: &model.Config{
|
||||||
Apps: map[string]model.App{
|
Apps: test.acls,
|
||||||
"foo": {
|
|
||||||
Config: model.AppConfig{Domain: "foo.example.com"},
|
|
||||||
Users: model.AppUsers{Allow: "alice"},
|
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
}
|
app, err := acls.lookupStaticACLs(test.domain)
|
||||||
svc := NewAccessControlsService(AccessControlServiceInput{
|
require.NoError(t, err)
|
||||||
Log: log,
|
require.Equal(t, test.want, app)
|
||||||
Config: &config,
|
|
||||||
LabelProvider: nil,
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
got, err := svc.GetAccessControls("foo.example.com")
|
// get acls should return an error when the provider fails
|
||||||
|
mock := newMockProvider(map[string]model.App{}, true)
|
||||||
require.NoError(t, err)
|
acls := NewAccessControlsService(AccessControlServiceInput{
|
||||||
require.NotNil(t, got)
|
Log: log,
|
||||||
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
Config: &model.Config{},
|
||||||
assert.Equal(t, "alice", got.Users.Allow)
|
|
||||||
})
|
})
|
||||||
|
_, err := acls.getACLs("example.com", mock.Lookup)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
t.Run("returns nil when no static match and no label provider", func(t *testing.T) {
|
// get access controls should get acls from
|
||||||
svc := NewAccessControlsService(AccessControlServiceInput{
|
// static when static acls are configured
|
||||||
Log: log,
|
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||||
Config: &model.Config{},
|
Log: log,
|
||||||
LabelProvider: nil,
|
Config: &model.Config{
|
||||||
})
|
|
||||||
|
|
||||||
got, err := svc.GetAccessControls("unknown.example.com")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, got)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns nil when label provider pointer wraps a nil interface", func(t *testing.T) {
|
|
||||||
var provider LabelProvider
|
|
||||||
svc := NewAccessControlsService(AccessControlServiceInput{
|
|
||||||
Log: log,
|
|
||||||
Config: &model.Config{},
|
|
||||||
LabelProvider: provider, // nil provider
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := svc.GetAccessControls("unknown.example.com")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, got)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to label provider when no static match", func(t *testing.T) {
|
|
||||||
expected := &model.App{
|
|
||||||
Config: model.AppConfig{Domain: "dynamic.example.com"},
|
|
||||||
Users: model.AppUsers{Allow: "bob"},
|
|
||||||
}
|
|
||||||
mock := &mockLabelProvider{
|
|
||||||
getLabelsFn: func(appDomain string) (*model.App, error) {
|
|
||||||
return expected, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
var provider LabelProvider = mock
|
|
||||||
svc := NewAccessControlsService(AccessControlServiceInput{
|
|
||||||
Log: log,
|
|
||||||
Config: &model.Config{},
|
|
||||||
LabelProvider: provider,
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := svc.GetAccessControls("dynamic.example.com")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Same(t, expected, got)
|
|
||||||
assert.Equal(t, "dynamic.example.com", mock.calledWith)
|
|
||||||
assert.Equal(t, 1, mock.callCount)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("does not call label provider when static match found", func(t *testing.T) {
|
|
||||||
mock := &mockLabelProvider{}
|
|
||||||
var provider LabelProvider = mock
|
|
||||||
config := model.Config{
|
|
||||||
Apps: map[string]model.App{
|
Apps: map[string]model.App{
|
||||||
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
svc := NewAccessControlsService(AccessControlServiceInput{
|
|
||||||
Log: log,
|
|
||||||
Config: &config,
|
|
||||||
LabelProvider: provider,
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := svc.GetAccessControls("foo.example.com")
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
|
||||||
assert.Equal(t, 0, mock.callCount)
|
|
||||||
})
|
})
|
||||||
|
app, err := acls.GetAccessControls("foo.example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
|
||||||
|
|
||||||
t.Run("propagates label provider errors", func(t *testing.T) {
|
// should return nil for no apps
|
||||||
providerErr := errors.New("provider boom")
|
app, err = acls.GetAccessControls("bar.example.com")
|
||||||
mock := &mockLabelProvider{
|
require.NoError(t, err)
|
||||||
getLabelsFn: func(appDomain string) (*model.App, error) {
|
require.Nil(t, app)
|
||||||
return nil, providerErr
|
|
||||||
},
|
|
||||||
}
|
|
||||||
var provider LabelProvider = mock
|
|
||||||
svc := NewAccessControlsService(AccessControlServiceInput{
|
|
||||||
Log: log,
|
|
||||||
Config: &model.Config{},
|
|
||||||
LabelProvider: provider,
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := svc.GetAccessControls("dynamic.example.com")
|
// Should use label provider if available
|
||||||
|
mock = newMockProvider(map[string]model.App{
|
||||||
assert.Nil(t, got)
|
"bar": {
|
||||||
assert.ErrorIs(t, err, providerErr)
|
Config: model.AppConfig{Domain: "bar.example.com"},
|
||||||
assert.Equal(t, 1, mock.callCount)
|
},
|
||||||
|
}, false)
|
||||||
|
acls = NewAccessControlsService(AccessControlServiceInput{
|
||||||
|
Log: log,
|
||||||
|
Config: &model.Config{},
|
||||||
|
LabelProvider: mock,
|
||||||
})
|
})
|
||||||
|
app, err = acls.GetAccessControls("bar.example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"fmt"
|
||||||
|
|
||||||
"github.com/steveiliop56/ding"
|
"github.com/steveiliop56/ding"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
@@ -31,7 +31,6 @@ type DockerServiceInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
|
||||||
|
|
||||||
client, err := client.NewClientWithOpts(client.FromEnv)
|
client, err := client.NewClientWithOpts(client.FromEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -68,49 +67,38 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
|
|||||||
return docker.client.ContainerInspect(docker.context, containerId)
|
return docker.client.ContainerInspect(docker.context, containerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
|
func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error {
|
||||||
if !docker.isConnected {
|
if !docker.isConnected {
|
||||||
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
containers, err := docker.getContainers()
|
containers, err := docker.getContainers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("failed to get containers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ctr := range containers {
|
for _, ctr := range containers {
|
||||||
inspect, err := docker.inspectContainer(ctr.ID)
|
inspect, err := docker.inspectContainer(ctr.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
docker.log.App.Error().Err(err).Msgf("Failed to inspect container %s", ctr.ID)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
|
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
docker.log.App.Warn().Err(err).Msgf("Failed to decode labels for container %s", ctr.ID)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var nameMatch *model.App
|
for app, config := range labels.Apps {
|
||||||
|
if ok := locator(app, &config); ok {
|
||||||
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
|
return nil
|
||||||
for appName, appLabels := range labels.Apps {
|
|
||||||
if appLabels.Config.Domain == appDomain {
|
|
||||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
|
||||||
return &appLabels, nil
|
|
||||||
}
|
}
|
||||||
if strings.SplitN(appDomain, ".", 2)[0] == appName {
|
|
||||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
|
||||||
nameMatch = &appLabels
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if nameMatch != nil {
|
|
||||||
return nameMatch, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
|
return nil
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (docker *DockerService) watchAndClose(ctx context.Context) {
|
func (docker *DockerService) watchAndClose(ctx context.Context) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||||
|
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||||
"go.uber.org/dig"
|
"go.uber.org/dig"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -22,31 +23,23 @@ import (
|
|||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ingressEntry struct {
|
||||||
|
name string
|
||||||
|
app model.App
|
||||||
|
}
|
||||||
|
|
||||||
type ingressKey struct {
|
type ingressKey struct {
|
||||||
namespace string
|
namespace string
|
||||||
name string
|
name string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ingressAppKey struct {
|
|
||||||
ingressKey
|
|
||||||
appName string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ingressApp struct {
|
|
||||||
domain string
|
|
||||||
appName string
|
|
||||||
app model.App
|
|
||||||
}
|
|
||||||
|
|
||||||
type KubernetesService struct {
|
type KubernetesService struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
|
|
||||||
client dynamic.Interface
|
client dynamic.Interface
|
||||||
started bool
|
connected bool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ingressApps map[ingressKey][]ingressApp
|
ingressEntries map[ingressKey][]ingressEntry
|
||||||
domainIndex map[string]ingressAppKey
|
|
||||||
appNameIndex map[string]ingressAppKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type KubernetesServiceInput struct {
|
type KubernetesServiceInput struct {
|
||||||
@@ -86,90 +79,45 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
|
|||||||
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
|
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
|
||||||
|
|
||||||
service := &KubernetesService{
|
service := &KubernetesService{
|
||||||
log: i.Log,
|
log: i.Log,
|
||||||
client: client,
|
client: client,
|
||||||
ingressApps: make(map[ingressKey][]ingressApp),
|
ingressEntries: make(map[ingressKey][]ingressEntry),
|
||||||
domainIndex: make(map[string]ingressAppKey),
|
|
||||||
appNameIndex: make(map[string]ingressAppKey),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
i.Ding.Go(func(ctx context.Context) {
|
i.Ding.Go(func(ctx context.Context) {
|
||||||
service.watchGVR(gvr, ctx)
|
service.watchGVR(gvr, ctx)
|
||||||
}, ding.RingMajor)
|
}, ding.RingMajor)
|
||||||
|
|
||||||
service.started = true
|
service.connected = true
|
||||||
i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
|
i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
|
||||||
|
|
||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
|
func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
|
k.ingressEntries[key] = entries
|
||||||
key := ingressKey{namespace, name}
|
|
||||||
// Remove existing entries for this ingress
|
|
||||||
if existing, ok := k.ingressApps[key]; ok {
|
|
||||||
for _, app := range existing {
|
|
||||||
delete(k.domainIndex, app.domain)
|
|
||||||
delete(k.appNameIndex, app.appName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add new entries
|
|
||||||
k.ingressApps[key] = apps
|
|
||||||
for _, app := range apps {
|
|
||||||
appKey := ingressAppKey{key, app.appName}
|
|
||||||
k.domainIndex[app.domain] = appKey
|
|
||||||
k.appNameIndex[app.appName] = appKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) removeIngress(namespace, name string) {
|
func (k *KubernetesService) removeIngress(key ingressKey) {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
|
delete(k.ingressEntries, key)
|
||||||
key := ingressKey{namespace, name}
|
|
||||||
if apps, ok := k.ingressApps[key]; ok {
|
|
||||||
for _, app := range apps {
|
|
||||||
delete(k.domainIndex, app.domain)
|
|
||||||
delete(k.appNameIndex, app.appName)
|
|
||||||
}
|
|
||||||
delete(k.ingressApps, key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) getByDomain(domain string) *model.App {
|
func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) {
|
||||||
k.mu.RLock()
|
k.mu.RLock()
|
||||||
defer k.mu.RUnlock()
|
defer k.mu.RUnlock()
|
||||||
|
|
||||||
if appKey, ok := k.domainIndex[domain]; ok {
|
// O(n^2) is not great but the number of ingress entries is expected to be small
|
||||||
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
|
for _, entries := range k.ingressEntries {
|
||||||
for i := range apps {
|
for _, entry := range entries {
|
||||||
app := &apps[i]
|
if ok := locator(entry.name, &entry.app); ok {
|
||||||
if app.domain == domain && app.appName == appKey.appName {
|
return
|
||||||
return &app.app
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *KubernetesService) getByAppName(appName string) *model.App {
|
|
||||||
k.mu.RLock()
|
|
||||||
defer k.mu.RUnlock()
|
|
||||||
|
|
||||||
if appKey, ok := k.appNameIndex[appName]; ok {
|
|
||||||
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
|
|
||||||
for i := range apps {
|
|
||||||
app := &apps[i]
|
|
||||||
if app.appName == appName {
|
|
||||||
return &app.app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
|
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
|
||||||
@@ -219,7 +167,8 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str
|
|||||||
}
|
}
|
||||||
paths, err := k.extractPaths(rule)
|
paths, err := k.extractPaths(rule)
|
||||||
if err != nil {
|
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
|
// 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")
|
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -235,44 +184,71 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||||
namespace := item.GetNamespace()
|
key := ingressKey{
|
||||||
name := item.GetName()
|
namespace: item.GetNamespace(),
|
||||||
|
name: item.GetName(),
|
||||||
|
}
|
||||||
|
|
||||||
annotations := item.GetAnnotations()
|
annotations := item.GetAnnotations()
|
||||||
if annotations == nil {
|
if annotations == nil {
|
||||||
k.removeIngress(namespace, name)
|
k.removeIngress(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hosts, err := k.extractHosts(item)
|
hosts, err := k.extractHosts(item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
k.removeIngress(namespace, name)
|
k.removeIngress(key)
|
||||||
return
|
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")
|
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
|
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode ingress labels, skipping")
|
||||||
k.removeIngress(namespace, name)
|
k.removeIngress(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var apps []ingressApp
|
|
||||||
for appName, appLabels := range labels.Apps {
|
var entries []ingressEntry
|
||||||
if appLabels.Config.Domain == "" {
|
|
||||||
continue
|
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||||
|
|
||||||
|
for name, config := range labels.Apps {
|
||||||
|
if config.Config.Domain != "" {
|
||||||
|
hostname, err := v.SafeHostname(config.Config.Domain)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
|
|
||||||
k.log.App.Warn().Str("namespace", namespace).Str("name", name).Str("appName", appName).Str("domain", appLabels.Config.Domain).Msg("App domain does not match any hosts defined in ingress rules, skipping")
|
for _, host := range hosts {
|
||||||
continue
|
if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) {
|
||||||
|
entries = append(entries, ingressEntry{
|
||||||
|
name: name,
|
||||||
|
app: config,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
apps = append(apps, ingressApp{
|
|
||||||
domain: appLabels.Config.Domain,
|
|
||||||
appName: appName,
|
|
||||||
app: appLabels,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if len(apps) == 0 {
|
|
||||||
k.removeIngress(namespace, name)
|
if len(entries) == 0 {
|
||||||
} else {
|
k.removeIngress(key)
|
||||||
k.addIngressApps(namespace, name, apps)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
k.addIngressEntries(key, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
|
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
|
||||||
@@ -315,7 +291,10 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
|
|||||||
case watch.Added, watch.Modified:
|
case watch.Added, watch.Modified:
|
||||||
k.updateFromItem(item)
|
k.updateFromItem(item)
|
||||||
case watch.Deleted:
|
case watch.Deleted:
|
||||||
k.removeIngress(item.GetNamespace(), item.GetName())
|
k.removeIngress(ingressKey{
|
||||||
|
namespace: item.GetNamespace(),
|
||||||
|
name: item.GetName(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
case <-resyncTicker.C:
|
case <-resyncTicker.C:
|
||||||
if err := k.resyncGVR(gvr, ctx); err != nil {
|
if err := k.resyncGVR(gvr, ctx); err != nil {
|
||||||
@@ -362,25 +341,13 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
|
func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error {
|
||||||
if !k.started {
|
if !k.connected {
|
||||||
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping")
|
k.log.App.Debug().Msg("Kubernetes label provider not started, skipping")
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check cache
|
k.getEntry(locator)
|
||||||
app := k.getByDomain(appDomain)
|
|
||||||
if app != nil {
|
|
||||||
k.log.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
|
|
||||||
return app, nil
|
|
||||||
}
|
|
||||||
appName := strings.SplitN(appDomain, ".", 2)[0]
|
|
||||||
app = k.getByAppName(appName)
|
|
||||||
if app != nil {
|
|
||||||
k.log.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
|
|
||||||
return app, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain")
|
return nil
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
@@ -25,47 +26,66 @@ func TestKubernetesService(t *testing.T) {
|
|||||||
description: "Cache by domain returns app and misses unknown domain",
|
description: "Cache by domain returns app and misses unknown domain",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
svc.addIngressEntries(ingressKey{
|
||||||
{domain: "foo.example.com", appName: "foo", app: app},
|
namespace: "default",
|
||||||
|
name: "my-ingress",
|
||||||
|
}, []ingressEntry{
|
||||||
|
{
|
||||||
|
app: app,
|
||||||
|
name: "foo",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
got := svc.getByDomain("foo.example.com")
|
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)
|
require.NotNil(t, got)
|
||||||
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
||||||
|
|
||||||
got = svc.getByDomain("notfound.example.com")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Cache by app name returns app and misses unknown name",
|
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "bar.example.com"}}
|
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
|
||||||
{domain: "bar.example.com", appName: "bar", app: app},
|
|
||||||
})
|
|
||||||
|
|
||||||
got := svc.getByAppName("bar")
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "bar.example.com", got.Config.Domain)
|
|
||||||
|
|
||||||
got = svc.getByAppName("notfound")
|
|
||||||
assert.Nil(t, got)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "RemoveIngress clears domain and app name entries",
|
description: "RemoveIngress clears domain and app name entries",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}}
|
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
svc.addIngressEntries(ingressKey{
|
||||||
{domain: "baz.example.com", appName: "baz", app: app},
|
namespace: "default",
|
||||||
|
name: "my-ingress",
|
||||||
|
}, []ingressEntry{
|
||||||
|
{
|
||||||
|
app: app,
|
||||||
|
name: "foo",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
svc.removeIngress("default", "my-ingress")
|
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 := svc.getByDomain("baz.example.com")
|
got = nil
|
||||||
assert.Nil(t, got)
|
svc.removeIngress(ingressKey{
|
||||||
got = svc.getByAppName("baz")
|
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)
|
assert.Nil(t, got)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -73,67 +93,130 @@ func TestKubernetesService(t *testing.T) {
|
|||||||
description: "AddIngressApps replaces stale entries for the same ingress",
|
description: "AddIngressApps replaces stale entries for the same ingress",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
|
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
svc.addIngressEntries(ingressKey{
|
||||||
{domain: "old.example.com", appName: "old", app: old},
|
namespace: "default",
|
||||||
|
name: "my-ingress",
|
||||||
|
}, []ingressEntry{
|
||||||
|
{
|
||||||
|
app: old,
|
||||||
|
name: "foo",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
|
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
|
||||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
svc.addIngressEntries(ingressKey{
|
||||||
{domain: "new.example.com", appName: "new", app: updated},
|
namespace: "default",
|
||||||
|
name: "my-ingress",
|
||||||
|
}, []ingressEntry{
|
||||||
|
{
|
||||||
|
app: updated,
|
||||||
|
name: "foo",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
got := svc.getByDomain("old.example.com")
|
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)
|
assert.Nil(t, got)
|
||||||
|
|
||||||
got = svc.getByDomain("new.example.com")
|
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)
|
require.NotNil(t, got)
|
||||||
assert.Equal(t, "new.example.com", got.Config.Domain)
|
assert.Equal(t, "new.example.com", got.Config.Domain)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "GetLabels returns app from cache when started",
|
description: "GetLabels returns app from cache when connected",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
svc.started = true
|
svc.connected = true
|
||||||
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
|
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
|
||||||
svc.addIngressApps("default", "ing", []ingressApp{
|
svc.addIngressEntries(ingressKey{
|
||||||
{domain: "hit.example.com", appName: "hit", app: app},
|
namespace: "default",
|
||||||
|
name: "my-ingress",
|
||||||
|
}, []ingressEntry{
|
||||||
|
{
|
||||||
|
app: app,
|
||||||
|
name: "foo",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := svc.GetLabels("hit.example.com")
|
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.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
assert.Equal(t, "hit.example.com", got.Config.Domain)
|
assert.Equal(t, "hit.example.com", got.Config.Domain)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "GetLabels returns empty app on cache miss when started",
|
description: "GetLabels returns empty app on cache miss when started",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
svc.started = true
|
svc.connected = true
|
||||||
|
|
||||||
got, err := svc.GetLabels("notfound.example.com")
|
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.NoError(t, err)
|
||||||
assert.Nil(t, got)
|
require.Nil(t, got)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "GetLabels resolves app by app name",
|
description: "GetLabels resolves app by app name",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
svc.started = true
|
svc.connected = true
|
||||||
|
|
||||||
app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}}
|
app := model.App{Path: model.AppPath{Allow: "/foo"}}
|
||||||
svc.addIngressApps("default", "ing", []ingressApp{
|
svc.addIngressEntries(ingressKey{
|
||||||
{domain: "myapp.internal.example.com", appName: "myapp", app: app},
|
namespace: "default",
|
||||||
|
name: "my-ingress",
|
||||||
|
}, []ingressEntry{
|
||||||
|
{
|
||||||
|
app: app,
|
||||||
|
name: "foo",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := svc.GetLabels("myapp.internal.example.com")
|
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.NoError(t, err)
|
||||||
assert.Equal(t, "myapp.internal.example.com", got.Config.Domain)
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, "/foo", got.Path.Allow)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "GetLabels returns empty app when service not yet started",
|
description: "GetLabels returns empty app when service not yet started",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
got, err := svc.GetLabels("anything.example.com")
|
var got *model.App
|
||||||
|
err := svc.Lookup(func(name string, app *model.App) bool {
|
||||||
|
return false
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Nil(t, got)
|
assert.Nil(t, got)
|
||||||
},
|
},
|
||||||
@@ -148,30 +231,437 @@ func TestKubernetesService(t *testing.T) {
|
|||||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
||||||
"tinyauth.apps.myapp.users.allow": "alice",
|
"tinyauth.apps.myapp.users.allow": "alice",
|
||||||
})
|
})
|
||||||
|
item.Object["spec"] = map[string]any{
|
||||||
|
"rules": []any{
|
||||||
|
map[string]any{
|
||||||
|
"host": "myapp.example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
svc.updateFromItem(&item)
|
||||||
|
|
||||||
got := svc.getByDomain("myapp.example.com")
|
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)
|
require.NotNil(t, got)
|
||||||
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
||||||
assert.Equal(t, "alice", got.Users.Allow)
|
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",
|
description: "UpdateFromItem with no annotations removes existing cache entries",
|
||||||
run: func(t *testing.T, svc *KubernetesService) {
|
run: func(t *testing.T, svc *KubernetesService) {
|
||||||
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
|
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
|
||||||
svc.addIngressApps("default", "test-ingress", []ingressApp{
|
svc.addIngressEntries(ingressKey{
|
||||||
{domain: "todelete.example.com", appName: "todelete", app: app},
|
namespace: "default",
|
||||||
|
name: "my-ingress",
|
||||||
|
}, []ingressEntry{
|
||||||
|
{
|
||||||
|
app: app,
|
||||||
|
name: "foo",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
item := unstructured.Unstructured{}
|
item := unstructured.Unstructured{}
|
||||||
item.SetNamespace("default")
|
item.SetNamespace("default")
|
||||||
item.SetName("test-ingress")
|
item.SetName("my-ingress")
|
||||||
|
|
||||||
svc.updateFromItem(&item)
|
svc.updateFromItem(&item)
|
||||||
|
|
||||||
got := svc.getByDomain("todelete.example.com")
|
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)
|
assert.Nil(t, got)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -180,10 +670,8 @@ func TestKubernetesService(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.description, func(t *testing.T) {
|
t.Run(test.description, func(t *testing.T) {
|
||||||
svc := &KubernetesService{
|
svc := &KubernetesService{
|
||||||
ingressApps: make(map[ingressKey][]ingressApp),
|
ingressEntries: make(map[ingressKey][]ingressEntry),
|
||||||
domainIndex: make(map[string]ingressAppKey),
|
log: log,
|
||||||
appNameIndex: make(map[string]ingressAppKey),
|
|
||||||
log: log,
|
|
||||||
}
|
}
|
||||||
test.run(t, svc)
|
test.run(t, svc)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user