From 9a3fecd5653064f9997ffc5e66f7c99f84a2176c Mon Sep 17 00:00:00 2001 From: Stavros Date: Tue, 30 Dec 2025 18:26:57 +0200 Subject: [PATCH] feat: non-docker acls (#549) * wip * feat: add paerser as submodule and apply patch for nested maps * refactor: update release workflows to include submodule and patches * chore: update contributing instructions --- .github/workflows/ci.yml | 11 +- .github/workflows/nightly.yml | 58 ++++++++- .github/workflows/release.yml | 58 ++++++++- .gitmodules | 4 + CONTRIBUTING.md | 29 ++++- Dockerfile | 6 +- Dockerfile.dev | 4 +- Dockerfile.distroless | 6 +- docker-compose.dev.yml | 1 - go.mod | 2 + go.sum | 2 - internal/bootstrap/service_bootstrap.go | 2 +- internal/config/config.go | 53 ++++---- internal/controller/proxy_controller_test.go | 2 +- internal/service/access_controls_service.go | 128 +++++-------------- paerser | 1 + patches/nested_maps.diff | 95 ++++++++++++++ 17 files changed, 314 insertions(+), 148 deletions(-) create mode 100644 .gitmodules create mode 160000 paerser create mode 100644 patches/nested_maps.diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c67bed..6e70165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,16 @@ jobs: - name: Setup go uses: actions/setup-go@v5 with: - go-version: "^1.23.2" + go-version: "^1.24.0" + + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff - name: Install frontend dependencies run: | diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5bc3e09..05563b6 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -61,7 +61,16 @@ jobs: - name: Install go uses: actions/setup-go@v5 with: - go-version: "^1.23.2" + go-version: "^1.24.0" + + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff - name: Install frontend dependencies run: | @@ -107,7 +116,16 @@ jobs: - name: Install go uses: actions/setup-go@v5 with: - go-version: "^1.23.2" + go-version: "^1.24.0" + + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff - name: Install frontend dependencies run: | @@ -147,6 +165,15 @@ jobs: with: ref: nightly + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -205,6 +232,15 @@ jobs: with: ref: nightly + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -263,6 +299,15 @@ jobs: with: ref: nightly + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -321,6 +366,15 @@ jobs: with: ref: nightly + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6896c2..1edf09d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,16 @@ jobs: - name: Install go uses: actions/setup-go@v5 with: - go-version: "^1.23.2" + go-version: "^1.24.0" + + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff - name: Install frontend dependencies run: | @@ -82,7 +91,16 @@ jobs: - name: Install go uses: actions/setup-go@v5 with: - go-version: "^1.23.2" + go-version: "^1.24.0" + + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff - name: Install frontend dependencies run: | @@ -119,6 +137,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -174,6 +201,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -229,6 +265,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -284,6 +329,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Initialize submodules + run: | + git submodule init + git submodule update + + - name: Apply patches + run: | + git apply --directory paerser/ patches/nested_maps.diff + - name: Docker meta id: meta uses: docker/metadata-action@v5 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c3cedbc --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "paerser"] + path = paerser + url = https://github.com/traefik/paerser + ignore = all diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 079d181..1440bc9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Contributing is relatively easy, you just need to follow the steps below and you ## Requirements - Bun -- Golang v1.23.2 and above +- Golang 1.24.0+ - Git - Docker @@ -18,12 +18,21 @@ git clone https://github.com/steveiliop56/tinyauth cd tinyauth ``` -## Install requirements +## Initialize submodules -Although you will not need the requirements in your machine since the development will happen in docker, I still recommend to install them because this way you will not have import errors. To install the go requirements run: +The project uses Git submodules for some dependencies, so you need to initialize them with: ```sh -go mod tidy +git submodule init +git submodule update +``` + +## Install requirements + +Although you will not need the requirements in your machine since the development will happen in Docker, I still recommend to install them because this way you will not have import errors. To install the Go requirements run: + +```sh +go mod download ``` You also need to download the frontend dependencies, this can be done like so: @@ -33,13 +42,21 @@ cd frontend/ bun install ``` +## Apply patches + +Some of the dependencies need to be patched in order to work correctly with the project, you can apply the patches by running: + +```sh +git apply --directory paerser/ patches/nested_maps.diff +``` + ## Create your `.env` file In order to configure the app you need to create an environment file, this can be done by copying the `.env.example` file to `.env` and modifying the environment variables to suit your needs. ## Developing -I have designed the development workflow to be entirely in docker, this is because it will directly work with traefik and you will not need to do any building in your host machine. The recommended development setup is to have a subdomain pointing to your machine like this: +I have designed the development workflow to be entirely in Docker, this is because it will directly work with Traefik and you will not need to do any building in your host machine. The recommended development setup is to have a subdomain pointing to your machine like this: ``` *.dev.example.com -> 127.0.0.1 @@ -49,7 +66,7 @@ dev.example.com -> 127.0.0.1 > [!TIP] > You can use [sslip.io](https://sslip.io) as a domain if you don't have one to develop with. -Then you can just make sure the domains are correct in the development docker compose file and run: +Then you can just make sure the domains are correct in the development Docker compose file and run: ```sh docker compose -f docker-compose.dev.yml up --build diff --git a/Dockerfile b/Dockerfile index 41500ed..aae8e0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ ARG BUILD_TIMESTAMP WORKDIR /tinyauth +COPY ./paerser ./paerser + COPY go.mod ./ COPY go.sum ./ @@ -38,7 +40,7 @@ COPY ./internal ./internal COPY --from=frontend-builder /frontend/dist ./internal/assets/dist RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth - + # Runner FROM alpine:3.23 AS runner @@ -62,4 +64,4 @@ ENV PATH=$PATH:/tinyauth HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"] -ENTRYPOINT ["tinyauth"] \ No newline at end of file +ENTRYPOINT ["tinyauth"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 96ea9b9..7efe722 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -2,6 +2,8 @@ FROM golang:1.25-alpine3.21 WORKDIR /tinyauth +COPY ./paerser ./paerser + COPY go.mod ./ COPY go.sum ./ @@ -20,4 +22,4 @@ ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db ENV TINYAUTH_RESOURCESDIR=/data/resources -ENTRYPOINT ["air", "-c", "air.toml"] \ No newline at end of file +ENTRYPOINT ["air", "-c", "air.toml"] diff --git a/Dockerfile.distroless b/Dockerfile.distroless index 9806bea..52fb344 100644 --- a/Dockerfile.distroless +++ b/Dockerfile.distroless @@ -28,6 +28,8 @@ ARG BUILD_TIMESTAMP WORKDIR /tinyauth +COPY ./paerser ./paerser + COPY go.mod ./ COPY go.sum ./ @@ -40,7 +42,7 @@ COPY --from=frontend-builder /frontend/dist ./internal/assets/dist RUN mkdir -p data RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth - + # Runner FROM gcr.io/distroless/static-debian12:latest AS runner @@ -65,4 +67,4 @@ ENV PATH=$PATH:/tinyauth HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"] -ENTRYPOINT ["tinyauth"] \ No newline at end of file +ENTRYPOINT ["tinyauth"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cc454f6..8ffd6fd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -42,7 +42,6 @@ services: volumes: - ./internal:/tinyauth/internal - ./cmd:/tinyauth/cmd - - ./main.go:/tinyauth/main.go - /var/run/docker.sock:/var/run/docker.sock - ./data:/data ports: diff --git a/go.mod b/go.mod index 5979f36..57f7720 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.24.0 toolchain go1.24.3 +replace github.com/traefik/paerser v0.2.2 => ./paerser + require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/huh v0.8.0 diff --git a/go.sum b/go.sum index 4a44d52..b6d649b 100644 --- a/go.sum +++ b/go.sum @@ -271,8 +271,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ= -github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index 6110aeb..e4abc8d 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -57,7 +57,7 @@ func (app *BootstrapApp) initServices() (Services, error) { services.dockerService = dockerService - accessControlsService := service.NewAccessControlsService(dockerService) + accessControlsService := service.NewAccessControlsService(dockerService, app.config.Apps) err = accessControlsService.Init() diff --git a/internal/config/config.go b/internal/config/config.go index f69c473..d3e00de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type Config struct { LogJSON bool `description:"Enable JSON formatted logs." yaml:"logJSON"` Server ServerConfig `description:"Server configuration." yaml:"server"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` + Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` UI UIConfig `description:"UI customization." yaml:"ui"` Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"` @@ -156,61 +157,55 @@ type RedirectQuery struct { RedirectURI string `url:"redirect_uri"` } -// Labels +// ACLs type Apps struct { - Apps map[string]App + Apps map[string]App `description:"App ACLs configuration." yaml:"apps"` } type App struct { - Config AppConfig - Users AppUsers - OAuth AppOAuth - IP AppIP - Response AppResponse - Path AppPath + Config AppConfig `description:"App configuration." yaml:"config"` + Users AppUsers `description:"User access configuration." yaml:"users"` + OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth"` + IP AppIP `description:"IP access configuration." yaml:"ip"` + Response AppResponse `description:"Response customization." yaml:"response"` + Path AppPath `description:"Path access configuration." yaml:"path"` } type AppConfig struct { - Domain string + Domain string `description:"The domain of the app." yaml:"domain"` } type AppUsers struct { - Allow string - Block string + Allow string `description:"Comma-separated list of allowed users." yaml:"allow"` + Block string `description:"Comma-separated list of blocked users." yaml:"block"` } type AppOAuth struct { - Whitelist string - Groups string + Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist"` + Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"` } type AppIP struct { - Allow []string - Block []string - Bypass []string + Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` + Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` + Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass"` } type AppResponse struct { - Headers []string - BasicAuth AppBasicAuth + Headers []string `description:"Custom headers to add to the response." yaml:"headers"` + BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth"` } type AppBasicAuth struct { - Username string - Password string - PasswordFile string + Username string `description:"Basic auth username." yaml:"username"` + Password string `description:"Basic auth password." yaml:"password"` + PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile"` } type AppPath struct { - Allow string - Block string -} - -// Flags - -type Providers struct { - Providers map[string]OAuthServiceConfig + Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"` + Block string `description:"Comma-separated list of blocked paths." yaml:"block"` } // API server diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index d0de555..54bb888 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -41,7 +41,7 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En assert.NilError(t, dockerService.Init()) // Access controls - accessControlsService := service.NewAccessControlsService(dockerService) + accessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{}) assert.NilError(t, accessControlsService.Init()) diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index e5af779..74117ea 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -1,122 +1,54 @@ package service import ( + "errors" + "strings" + + "github.com/rs/zerolog/log" "github.com/steveiliop56/tinyauth/internal/config" ) -/* -Environment variable/flag based ACLs are disabled until v5 due to a technical challenge -with the current parsing logic. - -The current parser works for simple OAuth provider configs like: -- PROVIDERS_MY_AMAZING_PROVIDER_CLIENT_ID - -However, it breaks down when handling nested structs required for ACLs. The custom parsing -solution that worked for v4 OAuth providers is incompatible with the ACL parsing logic, -making the codebase unmaintainable and fragile. - -A solution is being considered for v5 that would standardize the format to something like: -- TINYAUTH_PROVIDERS_GOOGLE_CLIENTSECRET -- TINYAUTH_APPS_MYAPP_CONFIG_DOMAIN - -This would allow the Traefik parser to handle everything consistently, but requires a -config migration. Until this is resolved, environment-based ACLs are disabled and only -Docker label-based ACLs are supported. - -See: https://discord.com/channels/1337450123600465984/1337459086270271538/1434986689935179838 for more information -*/ - type AccessControlsService struct { docker *DockerService - // envACLs config.Apps + static map[string]config.App } -func NewAccessControlsService(docker *DockerService) *AccessControlsService { +func NewAccessControlsService(docker *DockerService, static map[string]config.App) *AccessControlsService { return &AccessControlsService{ docker: docker, + static: static, } } func (acls *AccessControlsService) Init() error { - // acls.envACLs = config.Apps{} - // env := os.Environ() - // appEnvVars := []string{} - - // for _, e := range env { - // if strings.HasPrefix(e, "TINYAUTH_APPS_") { - // appEnvVars = append(appEnvVars, e) - // } - // } - - // err := acls.loadEnvACLs(appEnvVars) - - // if err != nil { - // return err - // } - - // return nil - - return nil - + return nil // No initialization needed } -// func (acls *AccessControlsService) loadEnvACLs(appEnvVars []string) error { -// if len(appEnvVars) == 0 { -// return nil -// } +func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) { + for app, config := range acls.static { + if config.Config.Domain == domain { + log.Debug().Str("name", app).Msg("Found matching container by domain") + return config, nil + } -// envAcls := map[string]string{} + if strings.SplitN(domain, ".", 2)[0] == app { + log.Debug().Str("name", app).Msg("Found matching container by app name") + return config, nil + } + } + return config.App{}, errors.New("no results") +} -// for _, e := range appEnvVars { -// parts := strings.SplitN(e, "=", 2) -// if len(parts) != 2 { -// continue -// } +func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) { + // First check in the static config + app, err := acls.lookupStaticACLs(domain) -// key := parts[0] -// key = strings.ToLower(key) -// key = strings.ReplaceAll(key, "_", ".") -// value := parts[1] -// envAcls[key] = value -// } - -// apps, err := decoders.DecodeLabels(envAcls) - -// if err != nil { -// return err -// } - -// acls.envACLs = apps -// return nil -// } - -// func (acls *AccessControlsService) lookupEnvACLs(appDomain string) *config.App { -// if len(acls.envACLs.Apps) == 0 { -// return nil -// } - -// for appName, appACLs := range acls.envACLs.Apps { -// if appACLs.Config.Domain == appDomain { -// return &appACLs -// } - -// if strings.SplitN(appDomain, ".", 2)[0] == appName { -// return &appACLs -// } -// } - -// return nil -// } - -func (acls *AccessControlsService) GetAccessControls(appDomain string) (config.App, error) { - // First check environment variables - // envACLs := acls.lookupEnvACLs(appDomain) - - // if envACLs != nil { - // log.Debug().Str("domain", appDomain).Msg("Found matching access controls in environment variables") - // return *envACLs, nil - // } + if err == nil { + log.Debug().Msg("Using ACls from static configuration") + return app, nil + } // Fallback to Docker labels - return acls.docker.GetLabels(appDomain) + log.Debug().Msg("Falling back to Docker labels for ACLs") + return acls.docker.GetLabels(domain) } diff --git a/paerser b/paerser new file mode 160000 index 0000000..7e1b633 --- /dev/null +++ b/paerser @@ -0,0 +1 @@ +Subproject commit 7e1b633ba9fd73f136891ddfec372b2e94d9e9c8 diff --git a/patches/nested_maps.diff b/patches/nested_maps.diff new file mode 100644 index 0000000..91c883f --- /dev/null +++ b/patches/nested_maps.diff @@ -0,0 +1,95 @@ +diff --git a/env/env_test.go b/env/env_test.go +index 7045569..365dc00 100644 +--- a/env/env_test.go ++++ b/env/env_test.go +@@ -166,6 +166,38 @@ func TestDecode(t *testing.T) { + Foo: &struct{ Field string }{}, + }, + }, ++ { ++ desc: "map under the root key", ++ environ: []string{"TRAEFIK_FOO_BAR_FOOBAR_BARFOO=foo"}, ++ element: &struct { ++ Foo map[string]struct { ++ Foobar struct { ++ Barfoo string ++ } ++ } ++ }{}, ++ expected: &struct { ++ Foo map[string]struct { ++ Foobar struct { ++ Barfoo string ++ } ++ } ++ }{ ++ Foo: map[string]struct { ++ Foobar struct { ++ Barfoo string ++ } ++ }{ ++ "bar": { ++ Foobar: struct { ++ Barfoo string ++ }{ ++ Barfoo: "foo", ++ }, ++ }, ++ }, ++ }, ++ }, + } + + for _, test := range testCases { +diff --git a/parser/nodes_metadata.go b/parser/nodes_metadata.go +index 36946c1..0279705 100644 +--- a/parser/nodes_metadata.go ++++ b/parser/nodes_metadata.go +@@ -75,8 +75,13 @@ func (m metadata) add(rootType reflect.Type, node *Node) error { + node.Kind = fType.Kind() + node.Tag = field.Tag + +- if fType.Kind() == reflect.Struct || fType.Kind() == reflect.Pointer && fType.Elem().Kind() == reflect.Struct || +- fType.Kind() == reflect.Map { ++ if node.Kind == reflect.String && len(node.Children) > 0 { ++ fType = reflect.TypeOf(struct{}{}) ++ node.Kind = reflect.Struct ++ } ++ ++ if node.Kind == reflect.Struct || node.Kind == reflect.Pointer && fType.Elem().Kind() == reflect.Struct || ++ node.Kind == reflect.Map { + if len(node.Children) == 0 && !(field.Tag.Get(m.TagName) == TagLabelAllowEmpty || field.Tag.Get(m.TagName) == "-") { + return fmt.Errorf("%s cannot be a standalone element (type %s)", node.Name, fType) + } +@@ -90,11 +95,11 @@ func (m metadata) add(rootType reflect.Type, node *Node) error { + return nil + } + +- if fType.Kind() == reflect.Struct || fType.Kind() == reflect.Pointer && fType.Elem().Kind() == reflect.Struct { ++ if node.Kind == reflect.Struct || node.Kind == reflect.Pointer && fType.Elem().Kind() == reflect.Struct { + return m.browseChildren(fType, node) + } + +- if fType.Kind() == reflect.Map { ++ if node.Kind == reflect.Map { + if fType.Elem().Kind() == reflect.Interface { + addRawValue(node) + return nil +@@ -115,7 +120,7 @@ func (m metadata) add(rootType reflect.Type, node *Node) error { + return nil + } + +- if fType.Kind() == reflect.Slice { ++ if node.Kind == reflect.Slice { + if m.AllowSliceAsStruct && field.Tag.Get(TagLabelSliceAsStruct) != "" { + return m.browseChildren(fType.Elem(), node) + } +@@ -129,7 +134,7 @@ func (m metadata) add(rootType reflect.Type, node *Node) error { + return nil + } + +- return fmt.Errorf("invalid node %s: %v", node.Name, fType.Kind()) ++ return fmt.Errorf("invalid node %s: %v", node.Name, node.Kind) + } + + func (m metadata) findTypedField(rType reflect.Type, node *Node) (reflect.StructField, error) {