From a05f1431caea881b81f808b0017a7ea923524ca6 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 5 Nov 2026 14:39:07 +0100 Subject: [PATCH] feat(wlan-setup): Go service for the WLAN setup page wlan-setup/ is a Go service that will replace the nginx + njs backend of wlan.renner.uno (switched over in a follow-up once the first image exists). - Page, /whoami, /healthz; /{android,apple,windows}/install and /callback: authentik OAuth code flow (client wlan-setup, client_secret_basic), user check against X-authentik-username, per-device app password wlan--... (29 days), profile per platform: Android Passpoint (application/x-wifi-config), Apple .mobileconfig with Passpoint, Windows CarrierProvisioning with Passpoint. - Windows: the profile is signed with XML-DSig in the form of Microsoft's samples (enveloped, C14N 1.0, rsa-sha1, chain in KeyInfo) by the key in /etc/wlan-signing, kept for 5 minutes under a single-use token, and the browser is sent to ms-settings:wifi-provisioning?uri=.../windows/profile/. - Offline tests against a fake authentik; goxmldsig verifies the signature in the tests only. - CI: wlan-setup-test (gofmt, vet, test -race) on PRs; build-ci-images builds gitea.renner.uno/gitea_admin/wlan-setup:YYYY-MM-DD-HHMM on main; Flux image automation follows the newest tag. Co-Authored-By: Claude Opus 5.5 --- .woodpecker/build-ci-images.yml | 26 + .woodpecker/wlan-setup-test.yml | 13 + .../infrastructure/image-automation/README.md | 1 + .../image-automation/kustomization.yaml | 1 + .../image-automation/wlan-setup.yaml | 23 + wlan-setup/Containerfile | 23 + wlan-setup/authentik.go | 169 +++++ wlan-setup/config.go | 51 ++ wlan-setup/go.mod | 10 + wlan-setup/go.sum | 14 + wlan-setup/index.html | 123 ++++ wlan-setup/main.go | 59 ++ wlan-setup/profiles.go | 123 ++++ wlan-setup/server.go | 239 +++++++ wlan-setup/server_test.go | 648 ++++++++++++++++++ wlan-setup/sign.go | 121 ++++ wlan-setup/store.go | 70 ++ 17 files changed, 1714 insertions(+) create mode 100644 .woodpecker/wlan-setup-test.yml create mode 100644 kubernetes/infrastructure/image-automation/wlan-setup.yaml create mode 100644 wlan-setup/Containerfile create mode 100644 wlan-setup/authentik.go create mode 100644 wlan-setup/config.go create mode 100644 wlan-setup/go.mod create mode 100644 wlan-setup/go.sum create mode 100644 wlan-setup/index.html create mode 100644 wlan-setup/main.go create mode 100644 wlan-setup/profiles.go create mode 100644 wlan-setup/server.go create mode 100644 wlan-setup/server_test.go create mode 100644 wlan-setup/sign.go create mode 100644 wlan-setup/store.go diff --git a/.woodpecker/build-ci-images.yml b/.woodpecker/build-ci-images.yml index 4855adc2..8031327a 100644 --- a/.woodpecker/build-ci-images.yml +++ b/.woodpecker/build-ci-images.yml @@ -41,6 +41,7 @@ when: - "ci/images/**" - "secret-rotator/**" - "review-bot/**" + - "wlan-setup/**" - ".woodpecker/build-ci-images.yml" - event: deployment evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "build-ci-images"' @@ -197,6 +198,31 @@ steps: --destination gitea.renner.uno/gitea_admin/review-bot:$TAG \ --destination gitea.renner.uno/gitea_admin/review-bot:latest + # wlan-setup: the Go service behind wlan.renner.uno (802.1X/Passpoint setup page with per-device + # authentik app passwords). Runs after Gitea is up, so no ghcr mirror. + - name: build-wlan-setup + image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.20-debug@sha256:28af5a3bd9c51fedb353ad91de735eda53592b5c06a34e7f5a9550334725d7f7 + directory: wlan-setup + when: + - event: deployment + - event: push + path: ["wlan-setup/**", ".woodpecker/build-ci-images.yml"] + environment: + GITEA_USER: gitea_admin + GITEA_TOKEN: + from_secret: ci_images_token + commands: + - mkdir -p /kaniko/.docker + - AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n') + - printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json + - TAG=$(date +%Y-%m-%d-%H%M) + - | + /kaniko/executor \ + --context . \ + --dockerfile Containerfile \ + --destination gitea.renner.uno/gitea_admin/wlan-setup:$TAG \ + --destination gitea.renner.uno/gitea_admin/wlan-setup:latest + # Runtime image, not CI tooling: a general-purpose alpine + jq base for # workloads that need to read JSON from a shell script. Named for what it IS, # not for who uses it — its first consumer is the zigbee2mqtt seed-data init diff --git a/.woodpecker/wlan-setup-test.yml b/.woodpecker/wlan-setup-test.yml new file mode 100644 index 00000000..16d5fae3 --- /dev/null +++ b/.woodpecker/wlan-setup-test.yml @@ -0,0 +1,13 @@ +# Tests for the wlan-setup Go service (wlan-setup/). Offline: the tests run against a fake +# authentik server, so the step needs no secret and no network beyond the module cache. +when: + - event: pull_request + path: ["wlan-setup/**", ".woodpecker/wlan-setup-test.yml"] + +steps: + - name: go-test + image: golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 + commands: + - test -z "$(gofmt -l wlan-setup)" + - go -C wlan-setup vet ./... + - go -C wlan-setup test -race ./... diff --git a/kubernetes/infrastructure/image-automation/README.md b/kubernetes/infrastructure/image-automation/README.md index 2d3cffc5..0c0822b1 100644 --- a/kubernetes/infrastructure/image-automation/README.md +++ b/kubernetes/infrastructure/image-automation/README.md @@ -46,6 +46,7 @@ existing `flux-system` GitRepository whose Secret was created by | authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins | | kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins | | cloudflare-lb-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins | +| wlan-setup | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins | | secret-rotator | `YYYY-MM-DD` | In-house (build-ci-images.yml); newest tag wins; staged: only the Home Assistant rotation CronJobs carry the marker so far | | gatus | `5.x` | v-prefix filter | | velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor | diff --git a/kubernetes/infrastructure/image-automation/kustomization.yaml b/kubernetes/infrastructure/image-automation/kustomization.yaml index c453c0ff..13cfb8af 100644 --- a/kubernetes/infrastructure/image-automation/kustomization.yaml +++ b/kubernetes/infrastructure/image-automation/kustomization.yaml @@ -42,5 +42,6 @@ resources: - velero-plugin-for-aws.yaml - vsftpd.yaml - wiki.yaml + - wlan-setup.yaml - woodpecker.yaml - wyoming.yaml diff --git a/kubernetes/infrastructure/image-automation/wlan-setup.yaml b/kubernetes/infrastructure/image-automation/wlan-setup.yaml new file mode 100644 index 00000000..cc922c8b --- /dev/null +++ b/kubernetes/infrastructure/image-automation/wlan-setup.yaml @@ -0,0 +1,23 @@ +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImageRepository +metadata: + name: wlan-setup + namespace: flux-system +spec: + image: gitea.renner.uno/gitea_admin/wlan-setup + interval: 30m +--- +# build-ci-images.yml tags each build YYYY-MM-DD-HHMM; newest wins. +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImagePolicy +metadata: + name: wlan-setup + namespace: flux-system +spec: + imageRepositoryRef: + name: wlan-setup + filterTags: + pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$' + policy: + alphabetical: + order: asc diff --git a/wlan-setup/Containerfile b/wlan-setup/Containerfile new file mode 100644 index 00000000..b10c1726 --- /dev/null +++ b/wlan-setup/Containerfile @@ -0,0 +1,23 @@ +# wlan-setup — static Go binary in a FROM-scratch image. +# +# Nothing in the final image but the binary and the CA bundle (the binary has no shell and no +# package manager to patch or to exec). +FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS build + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go index.html ./ + +RUN go vet ./... \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /wlan-setup . + +FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /wlan-setup /wlan-setup + +# Matches runAsUser in the Deployment. There is no /etc/passwd in a scratch image, so this is a +# bare uid by necessity. +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/wlan-setup"] diff --git a/wlan-setup/authentik.go b/wlan-setup/authentik.go new file mode 100644 index 00000000..4d5be60b --- /dev/null +++ b/wlan-setup/authentik.go @@ -0,0 +1,169 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +// authentikHost is the Host header of every call: authentik routes by it. +const authentikHost = "auth.renner.uno" + +// maxReply bounds what is read of an authentik reply. +const maxReply = 1 << 20 + +var credential = regexp.MustCompile(`^[A-Za-z0-9._~+/=-]+$`) + +// Authentik calls authentik's OAuth and API endpoints at its internal address. +type Authentik struct { + base string + secret string + http *http.Client +} + +// call sends one request and decodes the JSON reply into out. Errors name the method, path and +// status, never the credential or the request body. +func (a *Authentik) call(ctx context.Context, method, path, auth, contentType string, body []byte, out any) error { + var rd io.Reader + if body != nil { + rd = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, a.base+path, rd) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + req.Host = authentikHost + req.Header.Set("Authorization", auth) + req.Header.Set("Accept", "application/json") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := a.http.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, maxReply+1)) + if err != nil { + return fmt.Errorf("%s %s: reading the reply: %w", method, path, err) + } + if len(data) > maxReply { + return fmt.Errorf("%s %s -> %d: reply larger than %d bytes", method, path, resp.StatusCode, maxReply) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("%s %s -> %d %s", method, path, resp.StatusCode, snippet(data)) + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("%s %s: decoding the reply: %w", method, path, err) + } + return nil +} + +func snippet(b []byte) string { + s := strings.ToValidUTF8(string(b), "?") + if len(s) > 300 { + s = s[:300] + } + return s +} + +func bearer(token string) (string, error) { + if !credential.MatchString(token) { + return "", fmt.Errorf("no access token") + } + return "Bearer " + token, nil +} + +// Exchange trades the authorization code for an access token. +func (a *Authentik) Exchange(ctx context.Context, code, redirectURI string) (string, error) { + basic := "Basic " + base64.StdEncoding.EncodeToString([]byte(clientID+":"+a.secret)) + body := form("grant_type", "authorization_code", "code", code, "redirect_uri", redirectURI) + var grant struct { + AccessToken string `json:"access_token"` + } + if err := a.call(ctx, http.MethodPost, "/application/o/token/", basic, "application/x-www-form-urlencoded", + []byte(body), &grant); err != nil { + return "", err + } + if !credential.MatchString(grant.AccessToken) { + return "", fmt.Errorf("POST /application/o/token/: no access token in the reply") + } + return grant.AccessToken, nil +} + +// Username returns the username of the access token's user. +func (a *Authentik) Username(ctx context.Context, token string) (string, error) { + auth, err := bearer(token) + if err != nil { + return "", err + } + var me struct { + User struct { + Username string `json:"username"` + } `json:"user"` + } + if err := a.call(ctx, http.MethodGet, "/api/v3/core/users/me/", auth, "", nil, &me); err != nil { + return "", err + } + return me.User.Username, nil +} + +type tokenRequest struct { + Identifier string `json:"identifier"` + Intent string `json:"intent"` + Description string `json:"description"` + Expiring bool `json:"expiring"` + Expires string `json:"expires"` +} + +// AppPassword creates an app password as the token's user and returns its key. +func (a *Authentik) AppPassword(ctx context.Context, token string, t tokenRequest) (string, error) { + auth, err := bearer(token) + if err != nil { + return "", err + } + body, err := json.Marshal(t) + if err != nil { + return "", err + } + var created json.RawMessage + if err := a.call(ctx, http.MethodPost, "/api/v3/core/tokens/", auth, "application/json", body, &created); err != nil { + return "", err + } + var view struct { + Key string `json:"key"` + } + path := "/api/v3/core/tokens/" + url.PathEscape(t.Identifier) + "/view_key/" + if err := a.call(ctx, http.MethodGet, path, auth, "", nil, &view); err != nil { + return "", err + } + if view.Key == "" { + return "", fmt.Errorf("GET %s: no key in the reply", path) + } + return view.Key, nil +} + +// form encodes key/value pairs in the given order, like encodeURIComponent. +func form(pairs ...string) string { + var b strings.Builder + for i := 0; i+1 < len(pairs); i += 2 { + if i > 0 { + b.WriteByte('&') + } + b.WriteString(escape(pairs[i])) + b.WriteByte('=') + b.WriteString(escape(pairs[i+1])) + } + return b.String() +} + +func escape(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} diff --git a/wlan-setup/config.go b/wlan-setup/config.go new file mode 100644 index 00000000..e35b505e --- /dev/null +++ b/wlan-setup/config.go @@ -0,0 +1,51 @@ +package main + +import ( + "errors" + "net/url" + "strings" +) + +// Config is the service configuration, read from the environment. +type Config struct { + ClientSecret string + // AuthentikURL is where the calls go; they carry Host auth.renner.uno. + AuthentikURL string + AuthorizeURL string + PublicURL string + SigningKey string + SigningCert string + Listen string +} + +func loadConfig(getenv func(string) string) (Config, error) { + get := func(key, def string) string { + if v := strings.TrimSpace(getenv(key)); v != "" { + return v + } + return def + } + c := Config{ + ClientSecret: strings.TrimSpace(getenv("WLAN_SETUP_CLIENT_SECRET")), + AuthentikURL: strings.TrimRight(get("AUTHENTIK_INTERNAL_URL", "http://authentik-server.authentik.svc.cluster.local"), "/"), + AuthorizeURL: get("AUTHENTIK_AUTHORIZE_URL", "https://auth.renner.uno/application/o/authorize/"), + PublicURL: strings.TrimRight(get("PUBLIC_URL", "https://wlan.renner.uno"), "/"), + SigningKey: get("SIGNING_KEY", "/etc/wlan-signing/tls.key"), + SigningCert: get("SIGNING_CERT", "/etc/wlan-signing/tls.crt"), + Listen: get("LISTEN", ":8080"), + } + var errs []error + if c.ClientSecret == "" { + errs = append(errs, errors.New("WLAN_SETUP_CLIENT_SECRET is not set")) + } + for key, v := range map[string]string{ + "AUTHENTIK_INTERNAL_URL": c.AuthentikURL, + "AUTHENTIK_AUTHORIZE_URL": c.AuthorizeURL, + "PUBLIC_URL": c.PublicURL, + } { + if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + errs = append(errs, errors.New(key+" must be an absolute http(s) URL")) + } + } + return c, errors.Join(errs...) +} diff --git a/wlan-setup/go.mod b/wlan-setup/go.mod new file mode 100644 index 00000000..e77fe179 --- /dev/null +++ b/wlan-setup/go.mod @@ -0,0 +1,10 @@ +module gitea.renner.uno/konairius/homelab/wlan-setup + +go 1.27 + +require ( + github.com/beevik/etree v1.7.0 + github.com/russellhaering/goxmldsig v1.6.1 +) + +require github.com/jonboulle/clockwork v0.5.0 // indirect diff --git a/wlan-setup/go.sum b/wlan-setup/go.sum new file mode 100644 index 00000000..5611047e --- /dev/null +++ b/wlan-setup/go.sum @@ -0,0 +1,14 @@ +github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA= +github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+ohfNJjwyYU= +github.com/russellhaering/goxmldsig v1.6.1/go.mod h1:haZkRcLs9W/Xp989fIjP3BrTdbFQveRF0QNZSYoH09w= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/wlan-setup/index.html b/wlan-setup/index.html new file mode 100644 index 00000000..75bc286f --- /dev/null +++ b/wlan-setup/index.html @@ -0,0 +1,123 @@ + + + + + +WLAN einrichten + + + +
+

WLAN einrichten

+

WLAN Renner UniFi für dein Konto …. Android, iPhone, iPad, Mac und Windows bekommen ein eigenes WLAN-App-Passwort; Linux meldet sich mit deinem authentik-Passwort an.

+ +
+ +
+ Andere Geräte +
+
+ + + + + + + + +
+ + + diff --git a/wlan-setup/main.go b/wlan-setup/main.go new file mode 100644 index 00000000..cc67f757 --- /dev/null +++ b/wlan-setup/main.go @@ -0,0 +1,59 @@ +// wlan-setup serves wlan.renner.uno behind the authentik gate: the setup page for the WLAN +// "Renner UniFi" and, per device, an authorization-code sign-in that creates an authentik app +// password as the user and answers with a Passpoint profile (Android, Apple) or a signed +// CarrierProvisioning document that Windows Settings fetches once. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + cfg, err := loadConfig(os.Getenv) + if err != nil { + log.Error("invalid configuration", "err", err) + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := run(ctx, cfg, log); err != nil { + log.Error("stopped", "err", err) + os.Exit(1) + } +} + +func run(ctx context.Context, cfg Config, log *slog.Logger) error { + srv := &http.Server{ + Addr: cfg.Listen, + Handler: newServer(cfg, log).routes(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + // A callback makes up to four authentik calls of at most 10 s each. + WriteTimeout: 60 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 64 << 10, + } + failed := make(chan error, 1) + go func() { failed <- srv.ListenAndServe() }() + log.Info("listening", "addr", cfg.Listen) + + select { + case <-ctx.Done(): + case err := <-failed: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + } + log.Info("shutting down") + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shutdown) +} diff --git a/wlan-setup/profiles.go b/wlan-setup/profiles.go new file mode 100644 index 00000000..1171a604 --- /dev/null +++ b/wlan-setup/profiles.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/base64" + "strings" +) + +// isrgRootX1 is the trust anchor of the radius-tls chain (DER, base64). +const isrgRootX1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=" + +// carrierID identifies the Windows provisioning document; a reinstall replaces the profile. +const carrierID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}" + +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + +func node(name, inner string) string { + return "" + name + "" + inner + "" +} + +func leaf(name, value string) string { return node(name, ""+value+"") } + +var xmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">") + +func xmlText(s string) string { return xmlEscaper.Replace(s) } + +// androidProfile is the application/x-wifi-config body: base64 of a MIME multipart with the +// Passpoint R1 PPS MO and the trust anchor. +func androidProfile(user, password string) string { + pps := `1.2` + + node("PerProviderSubscription", + "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0"+ + node("i001", + node("HomeSP", leaf("FriendlyName", "Renner")+leaf("FQDN", "renner.uno"))+ + node("Credential", + leaf("Realm", "renner.uno")+ + node("UsernamePassword", + leaf("Username", user)+ + leaf("Password", b64(password))+ + node("EAPMethod", leaf("EAPType", "21")+leaf("InnerMethod", "PAP"))))+ + node("Extension", node("Android", + node("AAAServerTrustedNames", leaf("FQDN", "radius.renner.uno")))))) + + "" + mime := "Content-Type: multipart/mixed; boundary={boundary}\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "--{boundary}\r\n" + + "Content-Type: application/x-passpoint-profile\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + b64(pps) + "\r\n" + + "--{boundary}\r\n" + + "Content-Type: application/x-x509-ca-cert\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + isrgRootX1 + "\r\n" + + "--{boundary}--\r\n" + return b64(mime) +} + +// appleProfile is the .mobileconfig: com.apple.wifi.managed with Passpoint matching. Fixed +// identifiers and UUIDs, so a reinstall replaces the previous profile. +func appleProfile(user, password string) string { + return `` + "\n" + + `` + "\n" + + `` + + `PayloadContent` + + `PayloadTypecom.apple.wifi.managed` + + `PayloadVersion1` + + `PayloadIdentifieruno.renner.wifi.renner-unifi.network` + + `PayloadUUID824959bf-c55d-4ed2-b569-c33f3759c2b6` + + `PayloadDisplayNameRenner UniFi` + + `PayloadOrganizationRenner` + + `IsHotspot` + + `DomainNamerenner.uno` + + `NAIRealmNamesrenner.uno` + + `DisplayedOperatorNameRenner` + + `ServiceProviderRoamingEnabled` + + `AutoJoin` + + `EncryptionTypeAny` + + `EAPClientConfiguration` + + `UserName` + xmlText(user) + `` + + `UserPassword` + xmlText(password) + `` + + `OuterIdentityanonymous` + + `AcceptEAPTypes21` + + `TTLSInnerAuthenticationPAP` + + `TLSTrustedServerNamesradius.renner.uno` + + `` + + `PayloadTypeConfiguration` + + `PayloadVersion1` + + `PayloadIdentifieruno.renner.wifi.renner-unifi` + + `PayloadUUID7175a2a3-ddd3-42ea-b4ad-b7e5501892f3` + + `PayloadDisplayNameWLAN Renner UniFi` + + `PayloadOrganizationRenner` + + `` + "\n" +} + +// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile, +// WPA3 Enterprise, EAP-TTLS with inner PAP, and the user's credentials. +func windowsProfile(user, password string) string { + const common = ` xmlns="http://www.microsoft.com/provisioning/EapCommon"` + return `` + + `` + carrierID + `renner-unifi` + + `` + + `Renner UniFi` + + `Renner UniFi` + + `renner.unorenner.uno` + + `` + + `WPA3ENTAEStrue` + + `user` + + `` + + `210` + + `0311` + + `` + + `radius.renner.uno` + + `ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8` + + `true` + + `` + + `trueanonymous` + + `` + + `` + + `21311` + + `` + + `` + xmlText(user) + `` + xmlText(password) + `` + + `` + + `` +} diff --git a/wlan-setup/server.go b/wlan-setup/server.go new file mode 100644 index 00000000..f75c009a --- /dev/null +++ b/wlan-setup/server.go @@ -0,0 +1,239 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + _ "embed" + "encoding/base64" + "io" + "log/slog" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +//go:embed index.html +var indexHTML []byte + +const ( + clientID = "wlan-setup" + stateCookie = "wlan_oauth_state" + lifetimeDays = 29 + restart = "Bitte die Einrichtung neu starten.\n" + failed = "Die Einrichtung ist fehlgeschlagen.\n" +) + +// userPattern admits only plain usernames into a generated file. +var userPattern = regexp.MustCompile(`^[A-Za-z0-9._@+-]{1,64}$`) + +var identifierUnsafe = regexp.MustCompile(`[^a-z0-9-]`) + +var platformLabels = map[string]string{"android": "Android", "apple": "Apple", "windows": "Windows"} + +// Server serves the setup page and the per-platform flows. +type Server struct { + cfg Config + authentik *Authentik + profiles *ProfileStore + now func() time.Time + log *slog.Logger +} + +func newServer(cfg Config, log *slog.Logger) *Server { + return &Server{ + cfg: cfg, + authentik: &Authentik{base: cfg.AuthentikURL, secret: cfg.ClientSecret, http: &http.Client{Timeout: 10 * time.Second}}, + profiles: newProfileStore(time.Now), + now: time.Now, + log: log, + } +} + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(indexHTML) + }) + mux.HandleFunc("GET /whoami", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + if u := r.Header.Get("X-authentik-username"); userPattern.MatchString(u) { + _, _ = io.WriteString(w, u) + } + }) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "ok\n") + }) + for name := range platformLabels { + mux.HandleFunc("GET /"+name+"/install", func(w http.ResponseWriter, r *http.Request) { s.install(w, r, name) }) + mux.HandleFunc("GET /"+name+"/callback", func(w http.ResponseWriter, r *http.Request) { s.callback(w, r, name) }) + } + mux.HandleFunc("GET /windows/profile/{token}", s.windowsProfile) + mux.HandleFunc("/", http.NotFound) + return noStore(mux) +} + +func noStore(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + h.ServeHTTP(w, r) + }) +} + +func random(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (s *Server) redirectURI(name string) string { return s.cfg.PublicURL + "/" + name + "/callback" } + +func setStateCookie(w http.ResponseWriter, name, value string, maxAge int) { + w.Header().Set("Set-Cookie", stateCookie+"="+value+"; Path=/"+name+"/; Max-Age="+strconv.Itoa(maxAge)+ + "; HttpOnly; Secure; SameSite=Lax") +} + +func redirect(w http.ResponseWriter, location string) { + w.Header().Set("Location", location) + w.WriteHeader(http.StatusFound) +} + +func text(w http.ResponseWriter, status int, body string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) +} + +// install starts an authorization-code sign-in at authentik; the user is already signed in there. +func (s *Server) install(w http.ResponseWriter, _ *http.Request, name string) { + state := random(24) + setStateCookie(w, name, state, 300) + redirect(w, s.cfg.AuthorizeURL+"?"+form( + "response_type", "code", + "client_id", clientID, + "redirect_uri", s.redirectURI(name), + "scope", "openid profile goauthentik.io/api", + "state", state, + )) +} + +func stateFrom(r *http.Request) string { + c, err := r.Cookie(stateCookie) + if err != nil { + return "" + } + return c.Value +} + +// callback creates a new app password as the user and answers with the platform's profile. +func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) { + q := r.URL.Query() + state, code := stateFrom(r), q.Get("code") + if state == "" || code == "" || subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 { + text(w, http.StatusBadRequest, restart) + return + } + fail := func(reason string, args ...any) { + s.log.Error("wlan-setup "+name+": "+reason, args...) + text(w, http.StatusBadGateway, failed) + } + + // Windows signs the profile: without key and chain there is nothing to hand out, so no app + // password is created. + var signer *Signer + if name == "windows" { + var err error + if signer, err = loadSigner(s.cfg.SigningKey, s.cfg.SigningCert); err != nil { + fail("cannot load the signing key", "err", err) + return + } + } + + ctx := r.Context() + access, err := s.authentik.Exchange(ctx, code, s.redirectURI(name)) + if err != nil { + fail("token exchange", "err", err) + return + } + user, err := s.authentik.Username(ctx, access) + if err != nil { + fail("user lookup", "err", err) + return + } + if !userPattern.MatchString(user) || user != r.Header.Get("X-authentik-username") { + s.log.Warn("wlan-setup "+name+": the token's user is not the signed-in user", "user", user) + w.WriteHeader(http.StatusForbidden) + return + } + + now := s.now().UTC() + suffix := strings.Map(func(c rune) rune { + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + return c + } + return 'x' + }, random(24)[:6]) + identifier := "wlan-" + name + "-" + identifierUnsafe.ReplaceAllString(strings.ToLower(user), "-") + "-" + + now.Format("20060102T150405") + "-" + suffix + key, err := s.authentik.AppPassword(ctx, access, tokenRequest{ + Identifier: identifier, + Intent: "app_password", + Description: "WLAN " + platformLabels[name] + ", " + now.Format("2006-01-02") + " (wlan.renner.uno)", + Expiring: true, + Expires: now.Add(lifetimeDays * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z07:00"), + }) + if err != nil { + fail("app password", "err", err) + return + } + + switch name { + case "android": + setStateCookie(w, name, "", 0) + w.Header().Set("Content-Type", "application/x-wifi-config") + w.Header().Set("Content-Transfer-Encoding", "base64") + _, _ = io.WriteString(w, androidProfile(user, key)) + case "apple": + setStateCookie(w, name, "", 0) + w.Header().Set("Content-Type", "application/x-apple-aspen-config") + w.Header().Set("Content-Disposition", `attachment; filename="renner-unifi.mobileconfig"`) + _, _ = io.WriteString(w, appleProfile(user, key)) + case "windows": + body, err := signer.Sign(windowsProfile(user, key)) + if err != nil { + fail("signing the profile", "err", err) + return + } + token := random(32) + if !s.profiles.Put(token, body) { + fail("too many profiles waiting to be fetched") + return + } + setStateCookie(w, name, "", 0) + redirect(w, "ms-settings:wifi-provisioning?uri="+s.cfg.PublicURL+"/windows/profile/"+token) + } +} + +// windowsProfile hands out a signed profile once. Windows Settings fetches it without the +// authentik session: the unguessable single-use token is the only gate. +func (s *Server) windowsProfile(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + if r.Method == http.MethodHead { + if !s.profiles.Has(token) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/xml") + return + } + body, ok := s.profiles.Take(token) + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/xml") + _, _ = w.Write(body) +} diff --git a/wlan-setup/server_test.go b/wlan-setup/server_test.go new file mode 100644 index 00000000..b6a546d1 --- /dev/null +++ b/wlan-setup/server_test.go @@ -0,0 +1,648 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "encoding/xml" + "io" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/beevik/etree" + dsig "github.com/russellhaering/goxmldsig" +) + +const ( + testSecret = "client-secret" + testAccess = "access-token-123" + testKey = "app-password-<&>-456" + testCode = "code-789" +) + +// fakeAuthentik answers the four calls of a callback and records what it received. +type fakeAuthentik struct { + t *testing.T + username string + fail string // path that answers 500 + + mu sync.Mutex + requests []string + created tokenRequest +} + +func (f *fakeAuthentik) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + f.mu.Unlock() + t := f.t + if r.Host != "auth.renner.uno" { + t.Errorf("%s: Host %q", r.URL.Path, r.Host) + } + if r.Header.Get("Cookie") != "" { + t.Errorf("%s: a cookie reached authentik", r.URL.Path) + } + if r.URL.Path == f.fail { + http.Error(w, `{"detail":"broken"}`, http.StatusInternalServerError) + return + } + bearer := func() bool { + if got := r.Header.Get("Authorization"); got != "Bearer "+testAccess { + t.Errorf("%s: Authorization %q", r.URL.Path, got) + return false + } + return true + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/application/o/token/": + user, pass, ok := r.BasicAuth() + if !ok || user != "wlan-setup" || pass != testSecret { + t.Errorf("token: Basic auth %q %v", user, ok) + } + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("token: Content-Type %q", ct) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != testCode { + t.Errorf("token: form %v", r.PostForm) + } + platform := strings.Split(r.PostForm.Get("redirect_uri"), "/")[3] + if want := "https://wlan.renner.uno/" + platform + "/callback"; r.PostForm.Get("redirect_uri") != want { + t.Errorf("token: redirect_uri %q", r.PostForm.Get("redirect_uri")) + } + _, _ = io.WriteString(w, `{"access_token":"`+testAccess+`","token_type":"Bearer"}`) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/core/users/me/": + if bearer() { + _ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"username": f.username}}) + } + case r.Method == http.MethodPost && r.URL.Path == "/api/v3/core/tokens/": + if !bearer() { + return + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("tokens: Content-Type %q", ct) + } + var tr tokenRequest + if err := json.NewDecoder(r.Body).Decode(&tr); err != nil { + t.Fatal(err) + } + f.mu.Lock() + f.created = tr + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"identifier":"`+tr.Identifier+`"}`) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v3/core/tokens/") && + strings.HasSuffix(r.URL.Path, "/view_key/"): + f.mu.Lock() + want := "/api/v3/core/tokens/" + f.created.Identifier + "/view_key/" + f.mu.Unlock() + if r.URL.Path != want { + t.Errorf("view_key: path %q, want %q", r.URL.Path, want) + } + if bearer() { + _ = json.NewEncoder(w).Encode(map[string]string{"key": testKey}) + } + default: + t.Errorf("unexpected call %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } +} + +type testCert struct { + cert *x509.Certificate + keyFile string + certFile string +} + +// writeSigningFiles writes a fresh RSA key (PKCS#8) and a chain of a self-signed leaf plus a +// second certificate, as cert-manager would mount them. +func writeSigningFiles(t *testing.T) testCert { + t.Helper() + dir := t.TempDir() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + mk := func(cn string, k *rsa.PrivateKey) []byte { + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &k.PublicKey, k) + if err != nil { + t.Fatal(err) + } + return der + } + leaf := mk("wlan.renner.uno", key) + other, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + intermediate := mk("intermediate", other) + pkcs8, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + tc := testCert{keyFile: filepath.Join(dir, "tls.key"), certFile: filepath.Join(dir, "tls.crt")} + if err := os.WriteFile(tc.keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil { + t.Fatal(err) + } + chain := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf}), + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intermediate})...) + if err := os.WriteFile(tc.certFile, chain, 0o600); err != nil { + t.Fatal(err) + } + tc.cert, _ = x509.ParseCertificate(leaf) + return tc +} + +type harness struct { + srv *Server + handler http.Handler + fake *fakeAuthentik + signing testCert + clock *fakeClock +} + +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *fakeClock) Add(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +func newHarness(t *testing.T) *harness { + t.Helper() + fake := &fakeAuthentik{t: t, username: "Alice.Smith"} + upstream := httptest.NewServer(fake) + t.Cleanup(upstream.Close) + signing := writeSigningFiles(t) + cfg, err := loadConfig(env(map[string]string{ + "WLAN_SETUP_CLIENT_SECRET": testSecret, + "AUTHENTIK_INTERNAL_URL": upstream.URL, + "SIGNING_KEY": signing.keyFile, + "SIGNING_CERT": signing.certFile, + })) + if err != nil { + t.Fatal(err) + } + srv := newServer(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + clock := &fakeClock{now: time.Date(2026, 11, 5, 9, 8, 7, 0, time.UTC)} + srv.now = clock.Now + srv.profiles.now = clock.Now + return &harness{srv: srv, handler: srv.routes(), fake: fake, signing: signing, clock: clock} +} + +func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + +func (h *harness) do(req *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, req) + if rec.Header().Get("Cache-Control") != "no-store" { + panic("no Cache-Control: no-store on " + req.URL.Path) + } + return rec +} + +func (h *harness) callback(platform, cookieState, queryState, user string) *httptest.ResponseRecorder { + q := url.Values{"state": {queryState}, "code": {testCode}} + req := httptest.NewRequest(http.MethodGet, "/"+platform+"/callback?"+q.Encode(), nil) + if cookieState != "" { + req.Header.Set("Cookie", "other=1; wlan_oauth_state="+cookieState) + } + req.Header.Set("X-authentik-username", user) + return h.do(req) +} + +func TestInstall(t *testing.T) { + h := newHarness(t) + for _, platform := range []string{"android", "apple", "windows"} { + rec := h.do(httptest.NewRequest(http.MethodGet, "/"+platform+"/install", nil)) + if rec.Code != http.StatusFound { + t.Fatalf("%s: status %d", platform, rec.Code) + } + cookie := rec.Header().Get("Set-Cookie") + m := regexp.MustCompile(`^wlan_oauth_state=([A-Za-z0-9_-]{32}); Path=/` + platform + + `/; Max-Age=300; HttpOnly; Secure; SameSite=Lax$`).FindStringSubmatch(cookie) + if m == nil { + t.Fatalf("%s: cookie %q", platform, cookie) + } + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, "https://auth.renner.uno/application/o/authorize/?response_type=code&") { + t.Errorf("%s: location %q", platform, loc) + } + if !strings.Contains(loc, "scope=openid%20profile%20goauthentik.io%2Fapi") { + t.Errorf("%s: scope not encoded like encodeURIComponent: %q", platform, loc) + } + u, err := url.Parse(loc) + if err != nil { + t.Fatal(err) + } + q := u.Query() + if q.Get("client_id") != "wlan-setup" || q.Get("redirect_uri") != "https://wlan.renner.uno/"+platform+"/callback" || + q.Get("scope") != "openid profile goauthentik.io/api" || q.Get("state") != m[1] || q.Get("response_type") != "code" { + t.Errorf("%s: query %v", platform, q) + } + } +} + +func TestCallbackStateMismatch(t *testing.T) { + h := newHarness(t) + for _, c := range []struct{ cookie, query string }{{"abc", "abd"}, {"", "abc"}, {"abc", ""}} { + rec := h.callback("android", c.cookie, c.query, "Alice.Smith") + if rec.Code != http.StatusBadRequest || rec.Body.String() != "Bitte die Einrichtung neu starten.\n" { + t.Errorf("%+v: %d %q", c, rec.Code, rec.Body.String()) + } + } + req := httptest.NewRequest(http.MethodGet, "/apple/callback?state=abc", nil) + req.Header.Set("Cookie", "wlan_oauth_state=abc") + if rec := h.do(req); rec.Code != http.StatusBadRequest { + t.Errorf("no code: %d", rec.Code) + } + if len(h.fake.requests) != 0 { + t.Errorf("authentik was called: %v", h.fake.requests) + } +} + +func TestCallbackUserMismatch(t *testing.T) { + h := newHarness(t) + if rec := h.callback("android", "s", "s", "bob"); rec.Code != http.StatusForbidden { + t.Errorf("other user: %d", rec.Code) + } + h.fake.username = "bad user" + if rec := h.callback("apple", "s", "s", "bad user"); rec.Code != http.StatusForbidden { + t.Errorf("unsafe username: %d", rec.Code) + } + for _, r := range h.fake.requests { + if strings.HasPrefix(r, "POST /api/v3/core/tokens/") { + t.Errorf("an app password was created for a mismatched user") + } + } +} + +func TestCallbackUpstreamError(t *testing.T) { + for _, path := range []string{"/application/o/token/", "/api/v3/core/users/me/", "/api/v3/core/tokens/"} { + h := newHarness(t) + h.fake.fail = path + rec := h.callback("android", "s", "s", "Alice.Smith") + if rec.Code != http.StatusBadGateway || rec.Body.String() != "Die Einrichtung ist fehlgeschlagen.\n" { + t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=0") { + t.Errorf("%s: the state cookie was cleared on failure", path) + } + } +} + +func TestCallbackWindowsWithoutSigningKey(t *testing.T) { + h := newHarness(t) + h.srv.cfg.SigningKey = filepath.Join(t.TempDir(), "missing.key") + if rec := h.callback("windows", "s", "s", "Alice.Smith"); rec.Code != http.StatusBadGateway { + t.Errorf("status %d", rec.Code) + } + if len(h.fake.requests) != 0 { + t.Errorf("authentik was called without a signing key: %v", h.fake.requests) + } +} + +func checkCreated(t *testing.T, h *harness, platform, label string) { + t.Helper() + got := h.fake.created + if !regexp.MustCompile(`^wlan-` + platform + `-alice-smith-20261105T090807-[A-Za-z0-9]{6}$`).MatchString(got.Identifier) { + t.Errorf("identifier %q", got.Identifier) + } + if got.Intent != "app_password" || !got.Expiring || got.Expires != "2026-12-04T09:08:07.000Z" || + got.Description != "WLAN "+label+", 2026-11-05 (wlan.renner.uno)" { + t.Errorf("token request %+v", got) + } + want := []string{"POST /application/o/token/", "GET /api/v3/core/users/me/", "POST /api/v3/core/tokens/", + "GET /api/v3/core/tokens/" + got.Identifier + "/view_key/"} + if strings.Join(h.fake.requests, "|") != strings.Join(want, "|") { + t.Errorf("calls %v", h.fake.requests) + } +} + +func checkCleared(t *testing.T, rec *httptest.ResponseRecorder, platform string) { + t.Helper() + if got := rec.Header().Get("Set-Cookie"); got != "wlan_oauth_state=; Path=/"+platform+"/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" { + t.Errorf("cookie %q", got) + } +} + +func TestCallbackAndroid(t *testing.T) { + h := newHarness(t) + rec := h.callback("android", "s", "s", "Alice.Smith") + if rec.Code != http.StatusOK { + t.Fatalf("status %d %q", rec.Code, rec.Body.String()) + } + checkCreated(t, h, "android", "Android") + checkCleared(t, rec, "android") + if rec.Header().Get("Content-Type") != "application/x-wifi-config" || + rec.Header().Get("Content-Transfer-Encoding") != "base64" || rec.Header().Get("Content-Disposition") != "" { + t.Errorf("headers %v", rec.Header()) + } + mime, err := base64.StdEncoding.DecodeString(rec.Body.String()) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(string(mime), "--{boundary}\r\n") + var pps string + for _, p := range parts { + if body, ok := strings.CutPrefix(p, "Content-Type: application/x-passpoint-profile\r\nContent-Transfer-Encoding: base64\r\n\r\n"); ok { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(body)) + if err != nil { + t.Fatal(err) + } + pps = string(raw) + } + } + if !strings.Contains(pps, "UsernameAlice.Smith") || + !strings.Contains(pps, "Password"+base64.StdEncoding.EncodeToString([]byte(testKey))+"") { + t.Errorf("pps %s", pps) + } + if !strings.Contains(string(mime), "Content-Type: application/x-x509-ca-cert\r\nContent-Transfer-Encoding: base64\r\n\r\n"+isrgRootX1+"\r\n--{boundary}--\r\n") { + t.Error("trust anchor part missing") + } +} + +// plistValue decodes one plist value: dict, array, string, integer, true, false. +func plistValue(d *xml.Decoder, start xml.StartElement) (any, error) { + switch start.Name.Local { + case "dict": + m := map[string]any{} + var key string + for { + tok, err := d.Token() + if err != nil { + return nil, err + } + switch el := tok.(type) { + case xml.StartElement: + if el.Name.Local == "key" { + if err := d.DecodeElement(&key, &el); err != nil { + return nil, err + } + continue + } + v, err := plistValue(d, el) + if err != nil { + return nil, err + } + m[key] = v + case xml.EndElement: + return m, nil + } + } + case "array": + var a []any + for { + tok, err := d.Token() + if err != nil { + return nil, err + } + switch el := tok.(type) { + case xml.StartElement: + v, err := plistValue(d, el) + if err != nil { + return nil, err + } + a = append(a, v) + case xml.EndElement: + return a, nil + } + } + case "true", "false": + return start.Name.Local == "true", d.Skip() + default: + var s string + err := d.DecodeElement(&s, &start) + return s, err + } +} + +func parsePlist(t *testing.T, body []byte) map[string]any { + t.Helper() + d := xml.NewDecoder(bytes.NewReader(body)) + for { + tok, err := d.Token() + if err != nil { + t.Fatal(err) + } + if el, ok := tok.(xml.StartElement); ok && el.Name.Local == "dict" { + v, err := plistValue(d, el) + if err != nil { + t.Fatal(err) + } + return v.(map[string]any) + } + } +} + +func TestCallbackApple(t *testing.T) { + h := newHarness(t) + rec := h.callback("apple", "s", "s", "Alice.Smith") + if rec.Code != http.StatusOK { + t.Fatalf("status %d %q", rec.Code, rec.Body.String()) + } + checkCreated(t, h, "apple", "Apple") + checkCleared(t, rec, "apple") + if rec.Header().Get("Content-Type") != "application/x-apple-aspen-config" || + rec.Header().Get("Content-Disposition") != `attachment; filename="renner-unifi.mobileconfig"` { + t.Errorf("headers %v", rec.Header()) + } + top := parsePlist(t, rec.Body.Bytes()) + if top["PayloadType"] != "Configuration" || top["PayloadUUID"] != "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3" { + t.Errorf("top %v", top) + } + wifi := top["PayloadContent"].([]any)[0].(map[string]any) + if wifi["IsHotspot"] != true || wifi["DomainName"] != "renner.uno" || + len(wifi["NAIRealmNames"].([]any)) != 1 || wifi["NAIRealmNames"].([]any)[0] != "renner.uno" || + wifi["PayloadUUID"] != "824959bf-c55d-4ed2-b569-c33f3759c2b6" { + t.Errorf("wifi %v", wifi) + } + eap := wifi["EAPClientConfiguration"].(map[string]any) + if eap["UserName"] != "Alice.Smith" || eap["UserPassword"] != testKey || eap["OuterIdentity"] != "anonymous" || + eap["TTLSInnerAuthentication"] != "PAP" { + t.Errorf("eap %v", eap) + } +} + +func (h *harness) windowsToken(t *testing.T) string { + t.Helper() + rec := h.callback("windows", "s", "s", "Alice.Smith") + if rec.Code != http.StatusFound { + t.Fatalf("status %d %q", rec.Code, rec.Body.String()) + } + checkCleared(t, rec, "windows") + m := regexp.MustCompile(`^ms-settings:wifi-provisioning\?uri=https://wlan\.renner\.uno/windows/profile/([A-Za-z0-9_-]{43})$`). + FindStringSubmatch(rec.Header().Get("Location")) + if m == nil { + t.Fatalf("location %q", rec.Header().Get("Location")) + } + return m[1] +} + +func validate(cert *x509.Certificate, body []byte) error { + doc := etree.NewDocument() + if err := doc.ReadFromBytes(body); err != nil { + return err + } + ctx := dsig.NewDefaultValidationContext(&dsig.MemoryX509CertificateStore{Roots: []*x509.Certificate{cert}}) + _, err := ctx.Validate(doc.Root()) + return err +} + +func TestCallbackWindows(t *testing.T) { + h := newHarness(t) + token := h.windowsToken(t) + checkCreated(t, h, "windows", "Windows") + + // No header or cookie: Windows Settings fetches it outside the authentik session. + head := h.do(httptest.NewRequest(http.MethodHead, "/windows/profile/"+token, nil)) + if head.Code != http.StatusOK { + t.Errorf("HEAD: %d", head.Code) + } + rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/xml" { + t.Fatalf("first fetch: %d %v", rec.Code, rec.Header()) + } + body := rec.Body.Bytes() + if again := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); again.Code != http.StatusNotFound { + t.Errorf("second fetch: %d", again.Code) + } + + if err := validate(h.signing.cert, body); err != nil { + t.Fatalf("signature does not verify: %v\n%s", err, body) + } + s := string(body) + for _, want := range []string{ + ``, + `Alice.Smithapp-password-<&>-456`, + ``, + ``, + ``, + ``, + ``, + ``, + } { + if !strings.Contains(s, want) { + t.Errorf("missing %s", want) + } + } + // The unsigned document is already canonical: its SHA-1 is the reference digest. + sum := sha1.Sum([]byte(windowsProfile("Alice.Smith", testKey))) + if !strings.Contains(s, ""+base64.StdEncoding.EncodeToString(sum[:])+"") { + t.Error("the digest is not the SHA-1 of the unsigned document") + } + if !strings.HasPrefix(s, strings.TrimSuffix(windowsProfile("Alice.Smith", testKey), "")+""); n != 2 { + t.Errorf("%d certificates in KeyInfo, want the whole chain (2)", n) + } + leaf := base64.StdEncoding.EncodeToString(h.signing.cert.Raw) + if !strings.Contains(s, ""+leaf+"") { + t.Error("the leaf is not the first certificate") + } + + tampered := bytes.Replace(body, []byte("Alice.Smith"), []byte("Alice.Smitx"), 1) + if err := validate(h.signing.cert, tampered); err == nil { + t.Error("a tampered profile verifies") + } +} + +func TestWindowsProfileExpires(t *testing.T) { + h := newHarness(t) + token := h.windowsToken(t) + h.clock.Add(profileTTL) + if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); rec.Code != http.StatusNotFound { + t.Errorf("expired: %d", rec.Code) + } + if len(h.srv.profiles.profiles) != 0 { + t.Error("the expired profile was not purged") + } + if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/unknown", nil)); rec.Code != http.StatusNotFound { + t.Errorf("unknown: %d", rec.Code) + } +} + +func TestWhoamiHealthzIndexNotFound(t *testing.T) { + h := newHarness(t) + for _, c := range []struct{ user, want string }{{"Alice.Smith", "Alice.Smith"}, {"a b", ""}, {"", ""}, + {strings.Repeat("a", 65), ""}} { + req := httptest.NewRequest(http.MethodGet, "/whoami", nil) + req.Header.Set("X-authentik-username", c.user) + rec := h.do(req) + if rec.Code != http.StatusOK || rec.Body.String() != c.want || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") { + t.Errorf("whoami %q: %d %q", c.user, rec.Code, rec.Body.String()) + } + } + if rec := h.do(httptest.NewRequest(http.MethodGet, "/healthz", nil)); rec.Code != http.StatusOK || rec.Body.String() != "ok\n" { + t.Errorf("healthz: %d %q", rec.Code, rec.Body.String()) + } + rec := h.do(httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `href="/windows/install"`) || + strings.Contains(rec.Body.String(), ".cmd") { + t.Errorf("index: %d", rec.Code) + } + for _, req := range []*http.Request{ + httptest.NewRequest(http.MethodGet, "/renner-unifi-wlan.cmd", nil), + httptest.NewRequest(http.MethodGet, "/linux/install", nil), + httptest.NewRequest(http.MethodPost, "/", nil), + httptest.NewRequest(http.MethodPost, "/android/install", nil), + httptest.NewRequest(http.MethodGet, "/index.html", nil), + } { + if rec := h.do(req); rec.Code != http.StatusNotFound { + t.Errorf("%s %s: %d", req.Method, req.URL.Path, rec.Code) + } + } +} + +func TestLoadConfig(t *testing.T) { + if _, err := loadConfig(env(nil)); err == nil || !strings.Contains(err.Error(), "WLAN_SETUP_CLIENT_SECRET is not set") { + t.Errorf("missing secret: %v", err) + } + c, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x"})) + if err != nil { + t.Fatal(err) + } + if c.AuthentikURL != "http://authentik-server.authentik.svc.cluster.local" || + c.AuthorizeURL != "https://auth.renner.uno/application/o/authorize/" || c.PublicURL != "https://wlan.renner.uno" || + c.SigningKey != "/etc/wlan-signing/tls.key" || c.SigningCert != "/etc/wlan-signing/tls.crt" || c.Listen != ":8080" { + t.Errorf("defaults %+v", c) + } + if _, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x", "PUBLIC_URL": "wlan"})); err == nil { + t.Error("relative PUBLIC_URL accepted") + } +} diff --git a/wlan-setup/sign.go b/wlan-setup/sign.go new file mode 100644 index 00000000..ca5a26ea --- /dev/null +++ b/wlan-setup/sign.go @@ -0,0 +1,121 @@ +package main + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "os" + "strings" +) + +// Signer holds the key and chain that sign the Windows provisioning document. +type Signer struct { + key *rsa.PrivateKey + chain [][]byte +} + +// loadSigner reads the PEM key (PKCS#8 RSA) and the PEM chain (leaf first). It runs per request: +// cert-manager rotates the files. +func loadSigner(keyFile, certFile string) (*Signer, error) { + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("signing key: %w", err) + } + certPEM, err := os.ReadFile(certFile) + if err != nil { + return nil, fmt.Errorf("signing certificate: %w", err) + } + return parseSigner(keyPEM, certPEM) +} + +func parseSigner(keyPEM, certPEM []byte) (*Signer, error) { + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, errors.New("signing key: no PEM block") + } + var key *rsa.PrivateKey + switch block.Type { + case "PRIVATE KEY": + k, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("signing key: %w", err) + } + rk, ok := k.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("signing key: not an RSA key") + } + key = rk + case "RSA PRIVATE KEY": + k, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("signing key: %w", err) + } + key = k + default: + return nil, fmt.Errorf("signing key: unexpected PEM block %q", block.Type) + } + var chain [][]byte + for rest := certPEM; ; { + var b *pem.Block + b, rest = pem.Decode(rest) + if b == nil { + break + } + if b.Type != "CERTIFICATE" { + continue + } + if _, err := x509.ParseCertificate(b.Bytes); err != nil { + return nil, fmt.Errorf("signing certificate: %w", err) + } + chain = append(chain, b.Bytes) + } + if len(chain) == 0 { + return nil, errors.New("signing certificate: no certificate") + } + leaf, _ := x509.ParseCertificate(chain[0]) + if pub, ok := leaf.PublicKey.(*rsa.PublicKey); !ok || !pub.Equal(&key.PublicKey) { + return nil, errors.New("signing certificate: the first certificate does not match the key") + } + return &Signer{key: key, chain: chain}, nil +} + +const dsigNS = "http://www.w3.org/2000/09/xmldsig#" + +// Sign returns doc with an enveloped XML-DSig signature as the last child of the root, in the +// form of Microsoft's CarrierProvisioning samples: Reference URI="" with only the +// enveloped-signature transform, inclusive C14N 1.0, RSA-SHA1, SHA-1 digest, and every +// certificate of the chain in KeyInfo/X509Data. doc must already be in canonical form +// (windowsProfile writes it that way), so its bytes are the digest input. +func (s *Signer) Sign(doc string) ([]byte, error) { + const end = "" + if !strings.HasSuffix(doc, end) { + return nil, errors.New("signing: not a CarrierProvisioning document") + } + digest := sha1.Sum([]byte(doc)) + signedInfo := `` + + `` + + `` + + `` + + `` + base64.StdEncoding.EncodeToString(digest[:]) + `` + + `` + // Canonical SignedInfo carries the namespace it inherits from Signature. + hashed := sha1.Sum([]byte(`` + signedInfo)) + value, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA1, hashed[:]) + if err != nil { + return nil, fmt.Errorf("signing: %w", err) + } + var certs strings.Builder + for _, c := range s.chain { + certs.WriteString("" + base64.StdEncoding.EncodeToString(c) + "") + } + signature := `` + signedInfo + + `` + base64.StdEncoding.EncodeToString(value) + `` + + `` + certs.String() + `` + return []byte(strings.TrimSuffix(doc, end) + signature + end), nil +} diff --git a/wlan-setup/store.go b/wlan-setup/store.go new file mode 100644 index 00000000..9acaad59 --- /dev/null +++ b/wlan-setup/store.go @@ -0,0 +1,70 @@ +package main + +import ( + "sync" + "time" +) + +// profileTTL is how long a signed Windows profile waits for Windows Settings to fetch it. +const profileTTL = 5 * time.Minute + +// maxProfiles bounds the profiles held at once. +const maxProfiles = 1000 + +type storedProfile struct { + body []byte + expires time.Time +} + +// ProfileStore keeps signed Windows profiles in memory, each fetchable once until it expires. +type ProfileStore struct { + mu sync.Mutex + now func() time.Time + profiles map[string]storedProfile +} + +func newProfileStore(now func() time.Time) *ProfileStore { + return &ProfileStore{now: now, profiles: map[string]storedProfile{}} +} + +func (s *ProfileStore) purge(now time.Time) { + for k, p := range s.profiles { + if !now.Before(p.expires) { + delete(s.profiles, k) + } + } +} + +// Put stores body under token; false when the store is full. +func (s *ProfileStore) Put(token string, body []byte) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.purge(now) + if len(s.profiles) >= maxProfiles { + return false + } + s.profiles[token] = storedProfile{body: body, expires: now.Add(profileTTL)} + return true +} + +// Take returns the profile stored under token and deletes it. +func (s *ProfileStore) Take(token string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.purge(s.now()) + p, ok := s.profiles[token] + if ok { + delete(s.profiles, token) + } + return p.body, ok +} + +// Has reports whether a profile is stored under token, without taking it. +func (s *ProfileStore) Has(token string) bool { + s.mu.Lock() + defer s.mu.Unlock() + s.purge(s.now()) + _, ok := s.profiles[token] + return ok +} -- 2.54.0