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.