diff --git a/internal/config/config.go b/internal/config/config.go index 82050de..fbe5aa6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -123,53 +123,53 @@ type RedirectQuery struct { RedirectURI string `url:"redirect_uri"` } -// Labels +// App config -type Labels struct { - Apps map[string]AppLabels +type AppConfigs struct { + Apps map[string]App } -type AppLabels struct { - Config ConfigLabels - Users UsersLabels - OAuth OAuthLabels - IP IPLabels - Response ResponseLabels - Path PathLabels +type App struct { + Config AppConfig + Users AppUsers + OAuth AppOAuth + IP AppIP + Response AppResponse + Path AppPath } -type ConfigLabels struct { +type AppConfig struct { Domain string } -type UsersLabels struct { +type AppUsers struct { Allow string Block string } -type OAuthLabels struct { +type AppOAuth struct { Whitelist string Groups string } -type IPLabels struct { +type AppIP struct { Allow []string Block []string Bypass []string } -type ResponseLabels struct { +type AppResponse struct { Headers []string - BasicAuth BasicAuthLabels + BasicAuth AppBasicAuth } -type BasicAuthLabels struct { +type AppBasicAuth struct { Username string Password string PasswordFile string } -type PathLabels struct { +type AppPath struct { Allow string Block string } diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index fd25076..dde799c 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -7,6 +7,7 @@ import ( "tinyauth/internal/config" "tinyauth/internal/service" "tinyauth/internal/utils" + "tinyauth/internal/utils/decoders" "github.com/gin-gonic/gin" "github.com/google/go-querystring/query" @@ -67,6 +68,16 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { proto := c.Request.Header.Get("X-Forwarded-Proto") host := c.Request.Header.Get("X-Forwarded-Host") + var app config.App + + headers, err := decoders.DecodeHeaders(utils.NormalizeHeaders(c.Request.Header)) + + if err != nil { + log.Error().Err(err).Msg("Failed to decode headers") + controller.handleError(c, req, isBrowser) + return + } + labels, err := controller.docker.GetLabels(host) if err != nil { @@ -75,10 +86,21 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } + if len(headers.Apps) > 0 { + for k, v := range headers.Apps { + log.Debug().Str("app", k).Msg("Using headers for app config instead of labels") + app = v + break + } + } else { + log.Debug().Msg("No app config found in headers, using labels") + app = labels + } + clientIP := c.ClientIP() - if controller.auth.IsBypassedIP(labels.IP, clientIP) { - controller.setHeaders(c, labels) + if controller.auth.IsBypassedIP(app.IP, clientIP) { + controller.setHeaders(c, app) c.JSON(200, gin.H{ "status": 200, "message": "Authenticated", @@ -86,7 +108,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - authEnabled, err := controller.auth.IsAuthEnabled(uri, labels.Path) + authEnabled, err := controller.auth.IsAuthEnabled(uri, app.Path) if err != nil { log.Error().Err(err).Msg("Failed to check if auth is enabled for resource") @@ -96,7 +118,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { if !authEnabled { log.Debug().Msg("Authentication disabled for resource, allowing access") - controller.setHeaders(c, labels) + controller.setHeaders(c, app) c.JSON(200, gin.H{ "status": 200, "message": "Authenticated", @@ -104,7 +126,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - if !controller.auth.CheckIP(labels.IP, clientIP) { + if !controller.auth.CheckIP(app.IP, clientIP) { if req.Proxy == "nginx" || !isBrowser { c.JSON(401, gin.H{ "status": 401, @@ -147,7 +169,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } if userContext.IsLoggedIn { - appAllowed := controller.auth.IsResourceAllowed(c, userContext, labels) + appAllowed := controller.auth.IsResourceAllowed(c, userContext, app) if !appAllowed { log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") @@ -181,7 +203,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } if userContext.OAuth { - groupOK := controller.auth.IsInOAuthGroup(c, userContext, labels.OAuth.Groups) + groupOK := controller.auth.IsInOAuthGroup(c, userContext, app.OAuth.Groups) if !groupOK { log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") @@ -221,7 +243,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) - controller.setHeaders(c, labels) + controller.setHeaders(c, app) c.JSON(200, gin.H{ "status": 200, @@ -251,7 +273,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())) } -func (controller *ProxyController) setHeaders(c *gin.Context, labels config.AppLabels) { +func (controller *ProxyController) setHeaders(c *gin.Context, labels config.App) { c.Header("Authorization", c.Request.Header.Get("Authorization")) headers := utils.ParseHeaders(labels.Response.Headers) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index cb14a7e..9739cb9 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -285,7 +285,7 @@ func (auth *AuthService) UserAuthConfigured() bool { return len(auth.config.Users) > 0 || auth.ldap != nil } -func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, labels config.AppLabels) bool { +func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, labels config.App) bool { if context.OAuth { log.Debug().Msg("Checking OAuth whitelist") return utils.CheckFilter(labels.OAuth.Whitelist, context.Email) @@ -322,7 +322,7 @@ func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserConte return false } -func (auth *AuthService) IsAuthEnabled(uri string, path config.PathLabels) (bool, error) { +func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) { // Check for block list if path.Block != "" { regex, err := regexp.Compile(path.Block) @@ -364,7 +364,7 @@ func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User { } } -func (auth *AuthService) CheckIP(labels config.IPLabels, ip string) bool { +func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool { for _, blocked := range labels.Block { res, err := utils.FilterIP(blocked, ip) if err != nil { @@ -398,7 +398,7 @@ func (auth *AuthService) CheckIP(labels config.IPLabels, ip string) bool { return true } -func (auth *AuthService) IsBypassedIP(labels config.IPLabels, ip string) bool { +func (auth *AuthService) IsBypassedIP(labels config.AppIP, ip string) bool { for _, bypassed := range labels.Bypass { res, err := utils.FilterIP(bypassed, ip) if err != nil { diff --git a/internal/service/docker_service.go b/internal/service/docker_service.go index f4ce236..d2a4cfc 100644 --- a/internal/service/docker_service.go +++ b/internal/service/docker_service.go @@ -4,7 +4,7 @@ import ( "context" "strings" "tinyauth/internal/config" - "tinyauth/internal/utils" + "tinyauth/internal/utils/decoders" container "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" @@ -55,17 +55,17 @@ func (docker *DockerService) DockerConnected() bool { return err == nil } -func (docker *DockerService) GetLabels(appDomain string) (config.AppLabels, error) { +func (docker *DockerService) GetLabels(appDomain string) (config.App, error) { isConnected := docker.DockerConnected() if !isConnected { log.Debug().Msg("Docker not connected, returning empty labels") - return config.AppLabels{}, nil + return config.App{}, nil } containers, err := docker.GetContainers() if err != nil { - return config.AppLabels{}, err + return config.App{}, err } for _, ctr := range containers { @@ -75,7 +75,7 @@ func (docker *DockerService) GetLabels(appDomain string) (config.AppLabels, erro continue } - labels, err := utils.GetLabels(inspect.Config.Labels) + labels, err := decoders.DecodeLabels(inspect.Config.Labels) if err != nil { log.Warn().Str("id", ctr.ID).Err(err).Msg("Error getting container labels, skipping") continue @@ -95,5 +95,5 @@ func (docker *DockerService) GetLabels(appDomain string) (config.AppLabels, erro } log.Debug().Msg("No matching container found, returning empty labels") - return config.AppLabels{}, nil + return config.App{}, nil } diff --git a/internal/utils/decoders/header_decoder.go b/internal/utils/decoders/header_decoder.go new file mode 100644 index 0000000..834b845 --- /dev/null +++ b/internal/utils/decoders/header_decoder.go @@ -0,0 +1,119 @@ +package decoders + +import ( + "fmt" + "sort" + "strings" + "tinyauth/internal/config" + + "github.com/traefik/paerser/parser" +) + +// Based on: https://github.com/traefik/paerser/blob/master/parser/labels_decode.go (Apache 2.0 License) + +func DecodeHeaders(headers map[string]string) (config.AppConfigs, error) { + var app config.AppConfigs + + err := decodeHeadersHelper(headers, &app, "tinyauth", "tinyauth-apps") + + if err != nil { + return config.AppConfigs{}, err + } + + return app, nil +} + +func decodeHeadersHelper(headers map[string]string, element any, rootName string, filters ...string) error { + node, err := decodeHeadersToNode(headers, rootName, filters...) + + if err != nil { + return err + } + + opts := parser.MetadataOpts{TagName: "header", AllowSliceAsStruct: true} + err = parser.AddMetadata(element, node, opts) + + if err != nil { + return err + } + + return parser.Fill(element, node, parser.FillerOpts{AllowSliceAsStruct: true}) +} + +func decodeHeadersToNode(headers map[string]string, rootName string, filters ...string) (*parser.Node, error) { + sortedKeys := sortKeys(headers, filters) + + var node *parser.Node + + for i, key := range sortedKeys { + split := strings.Split(strings.ToLower(key), "-") + + if split[0] != rootName { + return nil, fmt.Errorf("invalid header root %s", split[0]) + } + + for _, v := range split { + if v == "" { + return nil, fmt.Errorf("invalid element: %s", key) + } + } + + if i == 0 { + node = &parser.Node{} + } + + decodeHeaderToNode(node, split, headers[key]) + } + + return node, nil +} + +func decodeHeaderToNode(root *parser.Node, path []string, value string) { + if len(root.Name) == 0 { + root.Name = path[0] + } + + if len(path) > 1 { + node := containsNode(root.Children, path[1]) + + if node != nil { + decodeHeaderToNode(node, path[1:], value) + } else { + child := &parser.Node{Name: path[1]} + decodeHeaderToNode(child, path[1:], value) + root.Children = append(root.Children, child) + } + } else { + root.Value = value + } +} + +func containsNode(nodes []*parser.Node, name string) *parser.Node { + for _, node := range nodes { + if strings.EqualFold(node.Name, name) { + return node + } + } + return nil +} + +func sortKeys(headers map[string]string, filters []string) []string { + var sortedKeys []string + + for key := range headers { + if len(filters) == 0 { + sortedKeys = append(sortedKeys, key) + continue + } + + for _, filter := range filters { + if strings.HasPrefix(strings.ToLower(key), strings.ToLower(filter)) { + sortedKeys = append(sortedKeys, key) + continue + } + } + } + + sort.Strings(sortedKeys) + return sortedKeys +} diff --git a/internal/utils/decoders/header_decoder_test.go b/internal/utils/decoders/header_decoder_test.go new file mode 100644 index 0000000..02d0990 --- /dev/null +++ b/internal/utils/decoders/header_decoder_test.go @@ -0,0 +1,73 @@ +package decoders_test + +import ( + "reflect" + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" +) + +func TestDecodeHeaders(t *testing.T) { + // Variables + expected := config.AppConfigs{ + Apps: map[string]config.App{ + "foo": { + Config: config.AppConfig{ + Domain: "example.com", + }, + Users: config.AppUsers{ + Allow: "user1,user2", + Block: "user3", + }, + OAuth: config.AppOAuth{ + Whitelist: "somebody@example.com", + Groups: "group3", + }, + IP: config.AppIP{ + Allow: []string{"10.71.0.1/24", "10.71.0.2"}, + Block: []string{"10.10.10.10", "10.0.0.0/24"}, + Bypass: []string{"192.168.1.1"}, + }, + Response: config.AppResponse{ + Headers: []string{"X-Foo=Bar", "X-Baz=Qux"}, + BasicAuth: config.AppBasicAuth{ + Username: "admin", + Password: "password", + PasswordFile: "/path/to/passwordfile", + }, + }, + Path: config.AppPath{ + Allow: "/public", + Block: "/private", + }, + }, + }, + } + test := map[string]string{ + "Tinyauth-Apps-Foo-Config-Domain": "example.com", + "Tinyauth-Apps-Foo-Users-Allow": "user1,user2", + "Tinyauth-Apps-Foo-Users-Block": "user3", + "Tinyauth-Apps-Foo-OAuth-Whitelist": "somebody@example.com", + "Tinyauth-Apps-Foo-OAuth-Groups": "group3", + "Tinyauth-Apps-Foo-IP-Allow": "10.71.0.1/24,10.71.0.2", + "Tinyauth-Apps-Foo-IP-Block": "10.10.10.10,10.0.0.0/24", + "Tinyauth-Apps-Foo-IP-Bypass": "192.168.1.1", + "Tinyauth-Apps-Foo-Response-Headers": "X-Foo=Bar,X-Baz=Qux", + "Tinyauth-Apps-Foo-Response-BasicAuth-Username": "admin", + "Tinyauth-Apps-Foo-Response-BasicAuth-Password": "password", + "Tinyauth-Apps-Foo-Response-BasicAuth-PasswordFile": "/path/to/passwordfile", + "Tinyauth-Apps-Foo-Path-Allow": "/public", + "Tinyauth-Apps-Foo-Path-Block": "/private", + } + + // Test + result, err := decoders.DecodeHeaders(test) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !reflect.DeepEqual(expected, result) { + t.Fatalf("Expected %v but got %v", expected, result) + } +} diff --git a/internal/utils/decoders/label_decoder.go b/internal/utils/decoders/label_decoder.go new file mode 100644 index 0000000..fbb7ecb --- /dev/null +++ b/internal/utils/decoders/label_decoder.go @@ -0,0 +1,19 @@ +package decoders + +import ( + "tinyauth/internal/config" + + "github.com/traefik/paerser/parser" +) + +func DecodeLabels(labels map[string]string) (config.AppConfigs, error) { + var appLabels config.AppConfigs + + err := parser.Decode(labels, &appLabels, "tinyauth", "tinyauth.apps") + + if err != nil { + return config.AppConfigs{}, err + } + + return appLabels, nil +} diff --git a/internal/utils/decoders/label_decoder_test.go b/internal/utils/decoders/label_decoder_test.go new file mode 100644 index 0000000..558860a --- /dev/null +++ b/internal/utils/decoders/label_decoder_test.go @@ -0,0 +1,73 @@ +package decoders_test + +import ( + "reflect" + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" +) + +func TestDecodeLabels(t *testing.T) { + // Variables + expected := config.AppConfigs{ + Apps: map[string]config.App{ + "foo": { + Config: config.AppConfig{ + Domain: "example.com", + }, + Users: config.AppUsers{ + Allow: "user1,user2", + Block: "user3", + }, + OAuth: config.AppOAuth{ + Whitelist: "somebody@example.com", + Groups: "group3", + }, + IP: config.AppIP{ + Allow: []string{"10.71.0.1/24", "10.71.0.2"}, + Block: []string{"10.10.10.10", "10.0.0.0/24"}, + Bypass: []string{"192.168.1.1"}, + }, + Response: config.AppResponse{ + Headers: []string{"X-Foo=Bar", "X-Baz=Qux"}, + BasicAuth: config.AppBasicAuth{ + Username: "admin", + Password: "password", + PasswordFile: "/path/to/passwordfile", + }, + }, + Path: config.AppPath{ + Allow: "/public", + Block: "/private", + }, + }, + }, + } + test := map[string]string{ + "tinyauth.apps.foo.config.domain": "example.com", + "tinyauth.apps.foo.users.allow": "user1,user2", + "tinyauth.apps.foo.users.block": "user3", + "tinyauth.apps.foo.oauth.whitelist": "somebody@example.com", + "tinyauth.apps.foo.oauth.groups": "group3", + "tinyauth.apps.foo.ip.allow": "10.71.0.1/24,10.71.0.2", + "tinyauth.apps.foo.ip.block": "10.10.10.10,10.0.0.0/24", + "tinyauth.apps.foo.ip.bypass": "192.168.1.1", + "tinyauth.apps.foo.response.headers": "X-Foo=Bar,X-Baz=Qux", + "tinyauth.apps.foo.response.basicauth.username": "admin", + "tinyauth.apps.foo.response.basicauth.password": "password", + "tinyauth.apps.foo.response.basicauth.passwordfile": "/path/to/passwordfile", + "tinyauth.apps.foo.path.allow": "/public", + "tinyauth.apps.foo.path.block": "/private", + } + + // Test + result, err := decoders.DecodeLabels(test) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if reflect.DeepEqual(expected, result) == false { + t.Fatalf("Expected %v but got %v", expected, result) + } +} diff --git a/internal/utils/label_utils.go b/internal/utils/header_utils.go similarity index 73% rename from internal/utils/label_utils.go rename to internal/utils/header_utils.go index 5e423f7..79aeb33 100644 --- a/internal/utils/label_utils.go +++ b/internal/utils/header_utils.go @@ -3,22 +3,8 @@ package utils import ( "net/http" "strings" - "tinyauth/internal/config" - - "github.com/traefik/paerser/parser" ) -func GetLabels(labels map[string]string) (config.Labels, error) { - var labelsParsed config.Labels - - err := parser.Decode(labels, &labelsParsed, "tinyauth", "tinyauth.apps") - if err != nil { - return config.Labels{}, err - } - - return labelsParsed, nil -} - func ParseHeaders(headers []string) map[string]string { headerMap := make(map[string]string) for _, header := range headers { @@ -46,3 +32,13 @@ func SanitizeHeader(header string) string { return -1 }, header) } + +func NormalizeHeaders(headers http.Header) map[string]string { + var result = make(map[string]string) + + for key, values := range headers { + result[key] = strings.Join(values, ",") + } + + return result +} diff --git a/internal/utils/security_utils.go b/internal/utils/security_utils.go index b40c56c..e1f1b2e 100644 --- a/internal/utils/security_utils.go +++ b/internal/utils/security_utils.go @@ -46,6 +46,8 @@ func GetBasicAuth(username string, password string) string { } func FilterIP(filter string, ip string) (bool, error) { + filter = strings.Replace(filter, "-", "/", -1) + ipAddr := net.ParseIP(ip) if strings.Contains(filter, "/") {