Compare commits

...
Author SHA1 Message Date
security-bot cd2e7864a4 chore(image-automation): bump gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1517
Updates managed by image-automation-controller.
2026-11-05 15:19:21 +00:00
k.renner fec9f6e0e5 Merge pull request 'fix(wlan-setup): Windows profile valid against the provisioning schemas' (#1573) from fix/wlan-setup-windows-xsd into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1573
2026-11-05 15:17:16 +00:00
claude-botandClaude Opus 5.5 55fce76a2d fix(wlan-setup): Windows profile valid against the provisioning schemas
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
Windows Settings rejected the provisioning XML: Global/SubscriberId is
base:SubscriberType (\w+, at most 20 characters) in the schemas Windows
ships in %SYSTEMROOT%\schemas\provisioning, and "renner-unifi" contains a
hyphen. It is now "rennerunifi".

Those schemas also allow WPA3ENT and Hotspot2/NAIRealm, which the online
schema reference lacks: the profile uses WPA3ENT again (the network is
WPA3-Enterprise) and carries NAI realm renner.uno next to the domain.

TestWindowsProfileSchema validates the signed document with xmllint against
a copy of that schema folder (WLAN_SETUP_PROVISIONING_XSD); without it the
test is skipped, the schemas are Microsoft's files and not in the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:16:06 +01:00
security-bot 5fcd6dc5c4 chore(image-automation): bump gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1429
Updates managed by image-automation-controller.
2026-11-05 14:53:19 +00:00
security-bot 3a35e8481a chore(image-automation): bump gotenberg/gotenberg:8.40.0
Updates managed by image-automation-controller.
2026-11-05 14:53:04 +00:00
k.renner a5be653044 Merge pull request 'fix(wlan-setup): schema-valid Windows profile, profiles built with serializers' (#1572) from fix/wlan-setup-windows-schema into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1572
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 14:29:39 +00:00
security-bot 1d5a262a84 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-11-05
Updates managed by image-automation-controller.
2026-11-05 14:24:19 +00:00
security-bot ad6fc66875 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-11-05-1355
Updates managed by image-automation-controller.
2026-11-05 14:24:14 +00:00
security-bot 861e9a3193 chore(image-automation): bump gitea.renner.uno/konairius/alpine-openssl:2026-11-05-1353
Updates managed by image-automation-controller.
2026-11-05 14:23:48 +00:00
claude-bot 9a1dc89326 Merge remote-tracking branch 'origin/main' into fix/wlan-setup-windows-schema
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
2026-11-05 15:18:39 +01:00
claude-botandClaude Opus 5.5 d505ca2062 chore(wlan-setup): drop the build output from the tree
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:18:29 +01:00
claude-botandClaude Opus 5.5 28e864cfc4 refactor(wlan-setup): build profiles with XML and plist serializers
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was canceled
All three profiles were assembled from strings. They are now typed
documents:

- Android: PPS MO from encoding/xml structs; MIME multipart with
  mime/multipart (boundary renner-passpoint-profile; Android's ConfigParser
  reads part headers into a map, so their order does not matter).
- Apple: .mobileconfig marshalled with howett.net/plist.
- Windows: CarrierProvisioning built as an etree document; the signature is
  an etree subtree, digest and SignedInfo canonicalized with goxmldsig's
  C14N 1.0 canonicalizer (SignedInfo in place, so it carries the inherited
  xmldsig namespace). Same form as Microsoft's samples: one transform,
  rsa-sha1, sha1 digest, chain in KeyInfo.

Tests parse the outputs (multipart reader, encoding/xml, plist, etree) and
verify the Windows signature with goxmldsig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:18:15 +01:00
security-bot e7d45a5c31 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-11-05-1401
Updates managed by image-automation-controller.
2026-11-05 14:17:39 +00:00
security-bot 7a8791c3b0 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-11-05-1350
Updates managed by image-automation-controller.
2026-11-05 14:14:29 +00:00
claude-botandClaude Opus 5.5 b6acbc6e79 fix(wlan-setup): Windows provisioning XML within the CarrierControl schema
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
Windows Settings rejected the profile as invalid provisioning XML. The
CarrierControl WLAN schema allows authentication only up to WPA2; WPA3ENT is
not in it. The profile now uses WPA2 (Enterprise; Windows adds the PMF the
WPA3-Enterprise network requires) and follows Microsoft's signed Passpoint
samples otherwise: Hotspot2 with DomainName only, XML declaration in front
(outside the root, so not part of the digest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:07:23 +01:00
security-bot b1b58e397c chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-05-1355
Updates managed by image-automation-controller.
2026-11-05 14:07:18 +00:00
k.renner fdaf20aa3f Merge pull request 'feat(wlan-setup): run wlan.renner.uno on the Go service' (#1571) from feat/wlan-setup-go-deploy into main
Reviewed-on: #1571
2026-11-05 13:59:54 +00:00
claude-botandClaude Opus 5.5 4e61d543a8 feat(wlan-setup): run wlan.renner.uno on the Go service
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The wlan-setup Deployment runs gitea.renner.uno/gitea_admin/wlan-setup
(Flux image policy wlan-setup) instead of nginx + njs; the ConfigMap
wlan-setup-config.yaml with nginx.conf, njs and the Windows .cmd is removed.

- Certificate wlan-setup-signing for wlan.renner.uno (PKCS#8), mounted at
  /etc/wlan-signing (fsGroup 65534, mode 0440); the service reads it per
  request, so renewals need no restart.
- Ingress wlan-setup-provisioning: /windows/profile/ without the authentik
  gate, for Windows Settings fetching the signed provisioning XML.
- OAuth provider wlan-setup: redirect URI
  https://wlan.renner.uno/windows/callback.
- docs/radius/dot1x.md: Go service and the Windows Passpoint flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:56:15 +01:00
security-bot 302b177ba2 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05
Updates managed by image-automation-controller.
2026-11-05 13:54:15 +00:00
security-bot 531e192e0c chore(image-automation): bump gitea.renner.uno/konairius/cni-plugins:2026-11-05-1353
Updates managed by image-automation-controller.
2026-11-05 13:53:49 +00:00
security-bot 3416cd476d chore(image-automation): bump gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353
Updates managed by image-automation-controller.
2026-11-05 13:53:48 +00:00
k.renner 8a41b80217 Merge pull request 'feat(wlan-setup): Go service for the WLAN setup page' (#1570) from feat/wlan-setup-go into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1570
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 13:46:29 +00:00
claude-botandClaude Opus 5.5 a05f1431ca feat(wlan-setup): Go service for the WLAN setup page
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
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-<platform>-... (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/<token>.
- 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 <noreply@anthropic.com>
2026-11-05 14:39:07 +01:00
k.renner 5718c9bae2 Merge pull request 'feat(radius): Apple Passpoint profile with a per-device app password' (#1568) from feat/wlan-setup-apple-passpoint into main
Reviewed-on: #1568
2026-11-05 13:03:12 +00:00
k.renner 2c79edabea Merge pull request 'docs(radius): Renner UniFi is WPA3 Enterprise with Passpoint' (#1567) from docs/dot1x-passpoint-renner-unifi into main
Reviewed-on: #1567
2026-11-05 13:02:23 +00:00
claude-botandClaude Opus 5.5 6e12a608b3 docs(radius): Passpoint setup for Android and Apple
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Android and Apple get Passpoint profiles with their own app password from
wlan.renner.uno; the manual Android steps with the account password are
removed. Windows and Linux keep their setup files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:00:55 +01:00
claude-botandClaude Opus 5.5 5718492e2b feat(radius): Apple Passpoint profile with a per-device app password
wlan.renner.uno serves Apple devices the same way as Android: /apple/install
signs in through the wlan-setup OAuth application, /apple/callback creates
an app password (wlan-apple-...) with the user's own access token and returns
a .mobileconfig with Passpoint (IsHotspot, domain and NAI realm renner.uno),
EAP-TTLS/PAP, outer identity anonymous, trusted server name
radius.renner.uno, username and app password. The static profile without a
password is removed; its PayloadIdentifier/UUIDs are kept so the new profile
replaces it on install. The OAuth provider gets the redirect URI
https://wlan.renner.uno/apple/callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:00:16 +01:00
claude-botandClaude Opus 5.5 3bfb38ebd7 docs(radius): Renner UniFi is WPA3 Enterprise with Passpoint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
"Renner UniFi" now runs WPA3 Enterprise without transition mode and
advertises Passpoint (realm and domain renner.uno, EAP-TTLS/PAP), so the
Android profiles from wlan.renner.uno join it directly. The table row for
the deleted "Renner Test" WLAN is replaced; the Windows manual setup uses
WPA3-Enterprise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:53:12 +01:00
k.renner 1de63af07b Merge pull request 'feat(radius): Android WLAN profile with a per-device app password' (#1566) from feat/dot1x-app-passwords into main
Reviewed-on: #1566
2026-11-05 12:19:11 +00:00
claude-botandClaude Opus 5.5 6c2c278f65 feat(radius): log WLAN app password renewals
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:16:27 +01:00
claude-botandClaude Opus 5.5 21625685b4 feat(radius): WLAN app passwords expire 60 days after last use
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
New app passwords start with 29 days (below the 30-day API cap, so no
group attribute is needed). The expression policy dot1x-app-password-renew,
re-evaluated on the dot1x-login binding, renews the app password that
TokenBackend matched to 60 days on each 802.1X login (at most one write
per day; always passes, failure_result true). authentik rejects expired
app passwords and deletes them in clean_expired_models (every 5 min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:11:12 +01:00
claude-botandClaude Opus 5.5 29da21c28f docs(radius): app password lifetime cap is 30 days without the attribute
ci/woodpecker/pr/ci-scripts-test Pipeline is pending
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:05:27 +01:00
claude-botandClaude Opus 5.5 c781e263fc fix(radius): wlan-setup authentik subrequests take large replies
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
- subrequest_output_buffer_size 256k: users/me with groups, avatar and
  permissions exceeds the one-page default and was cut off.
- proxy_pass_request_headers off: browser headers (Accept-Encoding,
  User-Agent, X-authentik-*) no longer reach authentik.
- Random suffix on the app password identifier, so two installs in the
  same second do not collide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:02:10 +01:00
claude-botandClaude Opus 5.5 b1be3181ef feat(radius): Android WLAN profile with a per-device app password
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wlan.renner.uno signs the user in through the new authentik OAuth2
application wlan-setup (confidential, scope goauthentik.io/api). The
callback exchanges the code (client_secret_basic), creates an app password
with the user's own access token (10 years) and returns the Passpoint
profile carrying it in the response to the user's GET. The page asks for
no password and stores nothing; deleting the app password in authentik
removes the device.

- dot1x-password stage also accepts app passwords (TokenBackend).
- Client secret: ESO Password generator wlan-setup-oauth, read by the
  authentik worker (blueprint) and by wlan-setup.
- authentik calls go through an internal nginx location; headers come
  from $arg_* because subrequests share the parent's variable cache.
- Reloader annotations on the wlan-setup Deployment metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:52:09 +01:00
k.renner 08b087337e Merge pull request 'feat(radius): Android Passpoint profile on the WLAN setup page' (#1565) from feat/wlan-setup-passpoint into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1565
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 10:37:22 +00:00
claude-botandClaude Opus 5.5 8610853a6b feat(radius): serve the Android Passpoint profile from nginx
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Android starts its Passpoint installer only for a profile fetched with a
user-triggered GET and Content-Type application/x-wifi-config; a
browser-generated blob does not qualify. The page now POSTs the password to
/android/prepare, where njs builds the profile and stores it in a shared
dict (120 s, bound to the caller's username) under a random token; the
link /android/profile/<token> delivers it once.

AAAServerTrustedNames moves under Extension/Android, the only place
Android's PpsMoParser accepts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:32:21 +01:00
claude-botandClaude Opus 5.5 1b724ef114 feat(radius): Android Passpoint profile on the WLAN setup page
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Android section takes the password, builds a Passpoint R1 profile
(application/x-wifi-config: PPS MO with HomeSP FQDN and realm renner.uno,
EAP-TTLS with inner PAP, AAA server name radius.renner.uno, ISRG Root X1 as
trust anchor) in the browser and hands it to Android as a blob. The password
is not sent to any server.

Matches WLANs that advertise Hotspot 2.0 with domain and NAI realm
renner.uno.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:52:51 +01:00
k.renner ee1a421dc0 Merge pull request 'feat(radius): WLAN setup page wlan.renner.uno for "Renner UniFi"' (#1564) from feat/wlan-setup-page into main
Reviewed-on: #1564
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-05 09:02:04 +00:00
claude-botandClaude Opus 5.5 1e66d793d5 feat(radius): WLAN setup page wlan.renner.uno for "Renner UniFi"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
nginx behind the Authentik gate (any user, public) serving:
- Apple profile (TTLS/PAP, outer identity anonymous, trusted server name
  radius.renner.uno) with the caller's username from X-authentik-username,
- a Windows .cmd that imports the WLAN profile via netsh (TTLS/PAP, server
  name radius.renner.uno, trust anchor ISRG Root X1),
- the Linux nmcli command.
No file carries a password. Usernames outside [A-Za-z0-9._@+-] are left
blank instead of being put into a generated file.

The Renner Test profile is removed; that SSID no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:56:10 +01:00
security-bot 0996609ce4 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-11-05-0732
Updates managed by image-automation-controller.
2026-11-05 07:51:27 +00:00
k.renner 4b27a5f273 Merge pull request 'chore(ci): bump ci-claude image to 2.1.318' (#1563) from ci-image/claude-2.1.318 into main
Reviewed-on: #1563
2026-11-05 07:36:53 +00:00
38 changed files with 2506 additions and 100 deletions
+26
View File
@@ -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
+13
View File
@@ -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 ./...
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test.network</string>
<key>PayloadUUID</key>
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi</string>
<key>PayloadOrganization</key>
<string>Renner</string>
<key>SSID_STR</key>
<string>Renner Test</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>AutoJoin</key>
<true/>
<key>EncryptionType</key>
<string>Any</string>
<key>EAPClientConfiguration</key>
<dict>
<key>OuterIdentity</key>
<string>anonymous</string>
<key>AcceptEAPTypes</key>
<array>
<integer>21</integer>
</array>
<key>TTLSInnerAuthentication</key>
<string>PAP</string>
<key>TLSTrustedServerNames</key>
<array>
<string>radius.renner.uno</string>
</array>
</dict>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test</string>
<key>PayloadUUID</key>
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi (802.1X)</string>
<key>PayloadOrganization</key>
<string>Renner</string>
</dict>
</plist>
+64 -23
View File
@@ -125,7 +125,15 @@ Created through the UniFi Network API (site `default`):
|---|---|---|
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
| WLAN "Renner UniFi" | `ef4122f2-4a83-4dca-9ef3-468d29f39a6b` | WPA3 Enterprise (no transition), PMF required, fast roaming, BSS transition, client isolation off, RADIUS profile "Cluster 802.1X"; Application Hotspot, type Passpoint (set in the UniFi UI: the API format differs from the UI's) |
Passpoint on "Renner UniFi" (UniFi offers Passpoint only with WPA3 Enterprise):
venue "Renner", Residential / Private Residence, network type Private with
Guest Access, IPv4 single NATed private, IPv6 available, NAI realm
`renner.uno` with EAP-TTLS, sub-methods PAP and username/password, domain
`renner.uno`, operator friendly name "Renner", connection capability ICMP
allow. The AKMs on air are `FT/802.1X` and `802.1X/SHA-256`; plain `802.1X`
is not offered.
## Live login check
@@ -143,23 +151,54 @@ For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
`medium=[00000006]` IEEE-802.
## Client setup (SSID "Renner Test")
## Client setup (SSID "Renner UniFi")
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
authentik user), served by the Go service `wlan-setup/`
(`kubernetes/apps/authentik/wlan-setup*.yaml`). Android, Apple and Windows get
Passpoint profiles with their own app password; Linux an `nmcli` command
(NetworkManager has no Passpoint support) that carries no password.
Passpoint: `/<platform>/install` (`android`, `apple` or `windows`) signs the
user in through the authentik OAuth2 application `wlan-setup` (scope
`goauthentik.io/api`). On `/<platform>/callback`, the service exchanges the
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
with the user's own access token, and hands out the profile:
- Android: `application/x-wifi-config` in the response to that GET, as its
installer requires.
- Apple: a `.mobileconfig` download.
- Windows: a CarrierProvisioning XML signed with XML-DSig (enveloped, C14N
1.0, rsa-sha1, chain in KeyInfo) by the Let's Encrypt certificate
`wlan-setup-signing`; kept for 5 minutes under a single-use token. The
browser is sent to `ms-settings:wifi-provisioning?uri=https://wlan.renner.uno/windows/profile/<token>`;
Windows Settings fetches it (Ingress `wlan-setup-provisioning`, without the
gate) and offers "Add".
The service stores nothing else. Deleting the app password in authentik removes
that device from the WLAN. The profiles match WLANs that advertise Hotspot 2.0
with domain and NAI realm `renner.uno` (EAP-TTLS/PAP), i.e. "Renner UniFi".
Creating the app password needs no admin rights.
App passwords expire when unused. The page creates them with 29 days (the API
caps new tokens at 30 days unless the group attribute
`goauthentik.io/user/token-maximum-lifetime` allows more). Every 802.1X login
with an app password renews it to 60 days: the policy
`dot1x-app-password-renew` on the `dot1x-login` binding (at most one write per
day). authentik rejects expired app passwords and deletes them in its
`clean_expired_models` task. A device that has not connected for 60 days, or a
profile that was never installed, loses its app password and needs a new
profile from the page.
All clients use EAP-TTLS with inner PAP. The server certificate is a public
Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
installed. Username and password are the authentik credentials. PAP runs
inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
installed. The 802.1X password stage (`dot1x-password`) accepts the authentik
password and app passwords. PAP runs inside the TLS tunnel to FreeRADIUS and
is never sent in clear over the air.
### Android
- EAP method: TTLS
- Phase 2 authentication: PAP
- CA certificate: Use system certificates
- Domain: `radius.renner.uno`
- Identity: authentik username
- Anonymous identity: `anonymous` (mandatory; empty sends the login to the
UDM, where it fails)
- Password: authentik password
Passpoint profile from the setup page ("Auf Android installieren").
### Linux (NetworkManager)
@@ -173,10 +212,12 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
### Windows 11
Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
Passpoint profile from the setup page ("Auf Windows installieren"). Manual
setup without the page: Settings > Network & Internet > Wi-Fi > Manage known
networks > Add network:
- Network name: `Renner Test`
- Security type: WPA2-Enterprise AES
- Network name: `Renner UniFi`
- Security type: WPA3-Enterprise AES
- EAP method: EAP-TTLS
- Authentication method: Unencrypted password (PAP)
- Server validation: where offered ("Verify server identity" / trusted
@@ -191,14 +232,14 @@ wireless network" instead.
### Apple (iOS, iPadOS, macOS)
Apple devices need the configuration profile
`docs/radius/clients/renner-test.mobileconfig`. Install it via AirDrop, Files
or Settings (Profile Downloaded). The device asks for username and password on
the first join. The profile sets security type Any (WPA2/WPA3), outer identity
`anonymous` and trusted server name `radius.renner.uno`; it contains no
password and no certificate. Apple
devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
on the first join.
Passpoint configuration profile from the setup page ("Profil laden"),
installed via Settings (Profile Downloaded) or, on a Mac, System Settings >
General > Device Management. It sets `IsHotspot` with domain and NAI realm
`renner.uno` (no SSID), security type Any, outer identity `anonymous`,
trusted server name `radius.renner.uno`, username and app password; it
contains no certificate. Its PayloadIdentifier is fixed, so installing it
replaces an earlier profile. Apple devices may show a one-time "trust
certificate" prompt for `radius.renner.uno` on the first join.
## Measurement: authentication latency per SSID
@@ -1483,8 +1483,11 @@ data:
name: dot1x-password
state: present
attrs:
# App passwords: the WLAN credential that devices store (Android
# Passpoint profiles carry it), created by wlan.renner.uno.
backends:
- authentik.core.auth.InbuiltBackend
- authentik.core.auth.TokenBackend
- id: dot1x-login
model: authentik_stages_user_login.userloginstage
@@ -1506,12 +1509,54 @@ data:
order: 20
state: present
- model: authentik_flows.flowstagebinding
- id: binding-dot1x-login
model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-login
order: 100
state: present
attrs:
evaluate_on_plan: false
re_evaluate_policies: true
# Sliding expiry: an app password used for 802.1X expires 60 days
# after its last use; authentik rejects and deletes expired app
# passwords. auth_method_args.token is set by TokenBackend. Always
# passes; failure_result true keeps an error from blocking the login.
- id: dot1x-app-password-renew
model: authentik_policies_expression.expressionpolicy
identifiers:
name: dot1x-app-password-renew
state: present
attrs:
expression: |
from datetime import timedelta
from django.utils.timezone import now
from authentik.core.models import Token, TokenIntents
ref = (request.context.get("auth_method_args") or {}).get("token")
if request.context.get("auth_method") == "token" and isinstance(ref, dict):
renewed = now() + timedelta(days=60)
count = Token.objects.filter(
pk=ref.get("pk"),
user=request.user,
intent=TokenIntents.INTENT_APP_PASSWORD,
expiring=True,
expires__lt=renewed - timedelta(days=1),
).update(expires=renewed)
ak_logger.info("dot1x app password", token=ref.get("name"), renewed=count)
return True
- model: authentik_policies.policybinding
identifiers:
policy: !KeyOf dot1x-app-password-renew
target: !KeyOf binding-dot1x-login
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: true
- id: dot1x-has-vlan
model: authentik_policies_expression.expressionpolicy
@@ -1624,3 +1669,47 @@ data:
kubernetes_httproute_annotations: {}
kubernetes_httproute_parent_refs: []
kubernetes_json_patches: null
# wlan.renner.uno (wlan-setup.yaml): signs the user in and, with the
# user's own access token (scope goauthentik.io/api), creates the WLAN
# app password for the Android Passpoint profile. Confidential client;
# the secret comes from wlan-setup-oauth (wlan-setup-oauth.yaml). No
# policy binding: every authentik user may set up the WLAN.
- id: wlan-setup-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: WLAN setup
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: confidential
client_id: wlan-setup
client_secret: !Env WLAN_SETUP_CLIENT_SECRET
grant_types:
- authorization_code
access_token_validity: minutes=5
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-authentik_api]]
redirect_uris:
- url: https://wlan.renner.uno/android/callback
matching_mode: strict
- url: https://wlan.renner.uno/apple/callback
matching_mode: strict
- url: https://wlan.renner.uno/windows/callback
matching_mode: strict
- model: authentik_core.application
identifiers:
slug: wlan-setup
state: present
attrs:
name: WLAN setup
slug: wlan-setup
provider: !KeyOf wlan-setup-provider
meta_launch_url: https://wlan.renner.uno
meta_description: "WLAN setup page; creates WLAN app passwords"
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-01-1059 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-11-05-1355 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+11 -4
View File
@@ -171,10 +171,11 @@ spec:
worker:
# Overrides the global key; keep authentik-secrets in the list.
podAnnotations:
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
# Shared secret of the 802.1X Radius provider, set via `!Env` in
# dot1x-radius.yaml. Worker only (blueprint applier); optional so a
# missing Secret fails only the dot1x blueprint entry.
secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets,wlan-setup-oauth
# Shared secret of the 802.1X Radius provider and client secret of the
# WLAN setup provider, set via `!Env` in dot1x-radius.yaml. Worker only
# (blueprint applier); optional so a missing Secret fails only the
# blueprint entries that use it.
env:
- name: DOT1X_OUTPOST_SECRET
valueFrom:
@@ -182,6 +183,12 @@ spec:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
optional: true
- name: WLAN_SETUP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: wlan-setup-oauth
key: client_secret
optional: true
resources:
requests:
# Same reasoning as server above, and the worker is burstier still:
@@ -16,6 +16,9 @@ resources:
- freeradius-network-attachment.yaml
- freeradius.yaml
- freeradius-cnp.yaml
- wlan-setup-oauth.yaml
- wlan-setup-signing-cert.yaml
- wlan-setup.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
# each carries prune: disabled so ownership transferred without a teardown).
# authentik has no data PVCs — all state lives in ak-pg. This KS depends on
@@ -0,0 +1,32 @@
# Client secret of the "WLAN setup" OAuth2 provider (dot1x-radius blueprint).
# Generated once; the authentik worker reads it as WLAN_SETUP_CLIENT_SECRET
# for the blueprint, wlan-setup reads it for the code exchange.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: wlan-setup-oauth
namespace: authentik
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- client_secret
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: wlan-setup-oauth
namespace: authentik
spec:
refreshPolicy: CreatedOnce
target:
name: wlan-setup-oauth
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: wlan-setup-oauth
@@ -0,0 +1,21 @@
---
# Signs the Windows Wi-Fi provisioning XML of wlan.renner.uno (XML-DSig in the
# wlan-setup service). Issued via the cluster-wide Cloudflare DNS-01
# ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wlan-setup-signing
namespace: authentik
spec:
secretName: wlan-setup-signing
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- wlan.renner.uno
privateKey:
algorithm: RSA
size: 2048
encoding: PKCS8
rotationPolicy: Always
+157
View File
@@ -0,0 +1,157 @@
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi", served by the
# wlan-setup Go service (wlan-setup/). Android, Apple and Windows get Passpoint
# profiles with a per-device authentik app password; Linux an nmcli command.
# Public, so a device can reach it before it is in the WLAN (mobile data,
# Renner Gast). Any Authentik user passes the gate (no group); the service
# stores no user credentials beyond the 5-minute single-use Windows profile.
apiVersion: apps/v1
kind: Deployment
metadata:
name: wlan-setup
namespace: authentik
labels:
app.kubernetes.io/name: wlan-setup
annotations:
secret.reloader.stakater.com/reload: wlan-setup-oauth
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: wlan-setup
template:
metadata:
labels:
app.kubernetes.io/name: wlan-setup
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
# Group of the signing key files (defaultMode 0440).
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: wlan-setup
image: gitea.renner.uno/gitea_admin/wlan-setup:2026-11-05-1517 # {"$imagepolicy": "flux-system:wlan-setup"}
env:
# Client secret of the "WLAN setup" OAuth2 provider.
- name: WLAN_SETUP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: wlan-setup-oauth
key: client_secret
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: http
livenessProbe:
httpGet:
path: /healthz
port: http
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
memory: 64Mi
volumeMounts:
# Key and chain that sign the Windows provisioning XML; read per
# request, so cert-manager renewals need no restart.
- name: signing
mountPath: /etc/wlan-signing
readOnly: true
volumes:
- name: signing
secret:
secretName: wlan-setup-signing
defaultMode: 0440
items:
- key: tls.key
path: tls.key
- key: tls.crt
path: tls.crt
---
apiVersion: v1
kind: Service
metadata:
name: wlan-setup
namespace: authentik
labels:
app.kubernetes.io/name: wlan-setup
spec:
ipFamilyPolicy: PreferDualStack
selector:
app.kubernetes.io/name: wlan-setup
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wlan-setup
namespace: authentik
labels:
# Public: Cloudflare CNAME → NetBird proxy → Traefik.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- wlan.renner.uno
secretName: wlan-setup-tls
rules:
- host: wlan.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wlan-setup
port:
number: 80
---
# Without the gate: Windows Settings fetches the signed provisioning XML
# without the browser's session; the single-use token in the path is the only
# gate. Traefik reaches the pods through the CNP wlan-setup-gate that
# gate-sync creates for the Ingress above.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wlan-setup-provisioning
namespace: authentik
spec:
ingressClassName: traefik
tls:
- hosts:
- wlan.renner.uno
secretName: wlan-setup-tls
rules:
- host: wlan.renner.uno
http:
paths:
- path: /windows/profile/
pathType: Prefix
backend:
service:
name: wlan-setup
port:
number: 80
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-29-1355 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-11-05-1401 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
@@ -64,7 +64,7 @@ spec:
type: RuntimeDefault
containers:
- name: shelly-manager
image: gitea.renner.uno/konairius/shelly-manager:2026-11-02-1412 # {"$imagepolicy": "flux-system:shelly-manager"}
image: gitea.renner.uno/konairius/shelly-manager:2026-11-05-0732 # {"$imagepolicy": "flux-system:shelly-manager"}
imagePullPolicy: IfNotPresent
ports:
- name: http
@@ -66,7 +66,7 @@ spec:
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path when it falls through — so the path has to
# exist on both registries. Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
command:
- sh
- -c
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-30-1314 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-11-05-1355 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.39.0 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.40.0 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+1 -1
View File
@@ -155,7 +155,7 @@ spec:
# exist on both registries — hence konairius/, which matches the ghcr
# namespace. Steady state still pulls from Gitea and never leaves the
# network.
image: gitea.renner.uno/konairius/alpine-openssl:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-openssl"}
image: gitea.renner.uno/konairius/alpine-openssl:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-openssl"}
command:
- sh
- -c
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-02-1054 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-11-05-1350 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: GITEA_URL
value: https://gitea.renner.uno
+1 -1
View File
@@ -184,7 +184,7 @@ spec:
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path, so it has to exist on both registries.
# Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-10-29-1348 # {"$imagepolicy": "flux-system:alpine-jq"}
image: gitea.renner.uno/konairius/alpine-jq:2026-11-05-1353 # {"$imagepolicy": "flux-system:alpine-jq"}
command:
- sh
- -c
@@ -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 |
@@ -42,5 +42,6 @@ resources:
- velero-plugin-for-aws.yaml
- vsftpd.yaml
- wiki.yaml
- wlan-setup.yaml
- woodpecker.yaml
- wyoming.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
@@ -73,7 +73,7 @@ spec:
# No imagePullSecret: the ghcr namespace is private and its credential
# is in machine.registries too, because a Secret would have to come
# from OpenBao, which needs Ceph, which needs these binaries.
image: gitea.renner.uno/konairius/cni-plugins:2026-10-29-1348 # {"$imagepolicy": "flux-system:cni-plugins"}
image: gitea.renner.uno/konairius/cni-plugins:2026-11-05-1353 # {"$imagepolicy": "flux-system:cni-plugins"}
command:
- sh
- -c
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -133,7 +133,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -195,7 +195,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-02 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-11-05 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: backup-verify
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-29 # {"$imagepolicy": "flux-system:backup-verify"}
image: gitea.renner.uno/gitea_admin/ci-backup-verify:2026-11-05 # {"$imagepolicy": "flux-system:backup-verify"}
command: ["bash", "/script/backup-verify.sh"]
env:
- name: HOME
+1
View File
@@ -0,0 +1 @@
/wlan-setup
+23
View File
@@ -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"]
+169
View File
@@ -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")
}
+51
View File
@@ -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...)
}
+11
View File
@@ -0,0 +1,11 @@
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
howett.net/plist v1.0.1
)
require github.com/jonboulle/clockwork v0.5.0 // indirect
+19
View File
@@ -0,0 +1,19 @@
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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+123
View File
@@ -0,0 +1,123 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WLAN einrichten</title>
<style>
:root { --bg: #ffffff; --fg: #1d1d1f; --muted: #5f6368; --card: #f4f5f7; --accent: #0b63ce; --code: #e9ebef; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121314; --fg: #e8eaed; --muted: #9aa0a6; --card: #1e1f22; --accent: #7cacf8; --code: #2a2b2f; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h2 { font-size: 1.15rem; margin: 0 0 .5rem; }
p { margin: .5rem 0; }
.muted { color: var(--muted); }
section { background: var(--card); border-radius: 12px; padding: 1rem 1.1rem; margin: 1rem 0; }
section.current { outline: 2px solid var(--accent); }
a.button, button { display: inline-block; background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: .6rem 1rem; font: inherit; text-decoration: none; cursor: pointer; }
@media (prefers-color-scheme: dark) { a.button, button { color: #121314; } }
ol { padding-left: 1.3rem; margin: .5rem 0; }
pre { background: var(--code); border-radius: 8px; padding: .75rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-size: .85rem; margin: .5rem 0; }
details > summary { cursor: pointer; color: var(--accent); margin: 1.5rem 0 .5rem; }
</style>
</head>
<body>
<main>
<h1>WLAN einrichten</h1>
<p>WLAN <strong>Renner UniFi</strong> für dein Konto <strong id="user">…</strong>. Android, iPhone, iPad, Mac und Windows bekommen ein eigenes WLAN-App-Passwort; Linux meldet sich mit deinem authentik-Passwort an.</p>
<div id="detected"></div>
<details id="others">
<summary>Andere Geräte</summary>
<div id="rest"></div>
</details>
<template id="t-android">
<section data-os="android">
<h2>Android</h2>
<p><a class="button" href="/android/install">Auf Android installieren</a></p>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-apple">
<section data-os="apple">
<h2>iPhone, iPad, Mac</h2>
<p><a class="button" href="/apple/install">Profil laden</a></p>
<ol>
<li>iPhone/iPad: Einstellungen → „Profil geladen“ → Installieren. Mac: Systemeinstellungen → Allgemein → Geräteverwaltung → Profil doppelklicken.</li>
<li>Das Gerät verbindet sich danach selbst mit „Renner UniFi“.</li>
</ol>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-windows">
<section data-os="windows">
<h2>Windows</h2>
<p><a class="button" href="/windows/install">Auf Windows installieren</a></p>
<p>Windows öffnet die Einstellungen und fragt, ob das WLAN-Profil hinzugefügt werden soll: „Hinzufügen“ wählen.</p>
<p class="muted">Das Gerät bekommt ein eigenes WLAN-App-Passwort in deinem authentik-Konto; dort gelöscht, ist das Gerät aus dem WLAN.</p>
</section>
</template>
<template id="t-linux">
<section data-os="linux">
<h2>Linux (NetworkManager)</h2>
<p>Im Terminal ausführen, danach fragt NetworkManager nach dem Passwort:</p>
<pre id="nmcli"></pre>
<p><button type="button" id="copy-nmcli">Befehl kopieren</button></p>
</section>
</template>
</main>
<script>
(function () {
var ua = navigator.userAgent;
var os = "other";
if (/Android/i.test(ua)) os = "android";
else if (/iPhone|iPad|iPod|Macintosh/i.test(ua)) os = "apple";
else if (/Windows/i.test(ua)) os = "windows";
else if (/Linux|X11/i.test(ua)) os = "linux";
var detected = document.getElementById("detected");
var rest = document.getElementById("rest");
["android", "apple", "windows", "linux"].forEach(function (name) {
var node = document.getElementById("t-" + name).content.cloneNode(true);
if (name === os) { node.querySelector("section").classList.add("current"); detected.appendChild(node); }
else rest.appendChild(node);
});
if (os === "other") document.getElementById("others").open = true;
function quote(s) { return "'" + s.replace(/'/g, "'\\''") + "'"; }
function render(user) {
document.getElementById("user").textContent = user || "(dein Benutzername)";
document.getElementById("nmcli").textContent =
"nmcli connection add type wifi con-name 'Renner UniFi' ssid 'Renner UniFi' " +
"wifi-sec.key-mgmt wpa-eap 802-1x.eap ttls 802-1x.phase2-auth pap " +
"802-1x.anonymous-identity anonymous 802-1x.identity " + quote(user || "BENUTZERNAME") + " " +
"802-1x.domain-suffix-match radius.renner.uno 802-1x.system-ca-certs yes " +
"802-1x.password-flags 2 && nmcli --ask connection up 'Renner UniFi'";
}
render("");
fetch("/whoami", { credentials: "same-origin" })
.then(function (r) { return r.ok ? r.text() : ""; })
.then(function (t) { render(t.trim()); })
.catch(function () {});
document.getElementById("copy-nmcli").addEventListener("click", function () {
var text = document.getElementById("nmcli").textContent;
var btn = this;
navigator.clipboard.writeText(text).then(function () {
btn.textContent = "Kopiert";
setTimeout(function () { btn.textContent = "Befehl kopieren"; }, 2000);
});
});
})();
</script>
</body>
</html>
+59
View File
@@ -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)
}
+258
View File
@@ -0,0 +1,258 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/xml"
"fmt"
"mime"
"mime/multipart"
"net/textproto"
"github.com/beevik/etree"
"howett.net/plist"
)
// 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}"
// mimeBoundary separates the parts of the Android profile.
const mimeBoundary = "renner-passpoint-profile"
// ppsNode is a node of the PPS MO management tree: a leaf carries a Value, an interior node
// further nodes.
type ppsNode struct {
XMLName xml.Name `xml:"Node"`
NodeName string `xml:"NodeName"`
RTProperties *ppsRTProperties `xml:"RTProperties,omitempty"`
Value *string `xml:"Value,omitempty"`
Nodes []ppsNode `xml:"Node"`
}
type ppsRTProperties struct {
DDFName string `xml:"Type>DDFName"`
}
type ppsTree struct {
XMLName xml.Name `xml:"syncml:dmddf1.2 MgmtTree"`
VerDTD string `xml:"VerDTD"`
Nodes []ppsNode `xml:"Node"`
}
func ppsLeaf(name, value string) ppsNode { return ppsNode{NodeName: name, Value: &value} }
func ppsInterior(name string, nodes ...ppsNode) ppsNode {
return ppsNode{NodeName: name, Nodes: nodes}
}
// androidProfile is the application/x-wifi-config body: base64 of a MIME multipart with the
// Passpoint R1 PPS MO and the trust anchor, each part base64.
func androidProfile(user, password string) ([]byte, error) {
subscription := ppsInterior("PerProviderSubscription",
ppsInterior("i001",
ppsInterior("HomeSP", ppsLeaf("FriendlyName", "Renner"), ppsLeaf("FQDN", "renner.uno")),
ppsInterior("Credential",
ppsLeaf("Realm", "renner.uno"),
ppsInterior("UsernamePassword",
ppsLeaf("Username", user),
ppsLeaf("Password", base64.StdEncoding.EncodeToString([]byte(password))),
ppsInterior("EAPMethod", ppsLeaf("EAPType", "21"), ppsLeaf("InnerMethod", "PAP")))),
ppsInterior("Extension", ppsInterior("Android",
ppsInterior("AAAServerTrustedNames", ppsLeaf("FQDN", "radius.renner.uno"))))))
subscription.RTProperties = &ppsRTProperties{DDFName: "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0"}
pps, err := xml.Marshal(ppsTree{VerDTD: "1.2", Nodes: []ppsNode{subscription}})
if err != nil {
return nil, fmt.Errorf("pps: %w", err)
}
var parts bytes.Buffer
mw := multipart.NewWriter(&parts)
if err := mw.SetBoundary(mimeBoundary); err != nil {
return nil, err
}
for _, p := range []struct{ contentType, body string }{
{"application/x-passpoint-profile", base64.StdEncoding.EncodeToString(pps)},
{"application/x-x509-ca-cert", isrgRootX1},
} {
w, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {p.contentType},
"Content-Transfer-Encoding": {"base64"},
})
if err != nil {
return nil, err
}
if _, err := w.Write([]byte(p.body)); err != nil {
return nil, err
}
}
if err := mw.Close(); err != nil {
return nil, err
}
var message bytes.Buffer
fmt.Fprintf(&message, "Content-Type: %s\r\nContent-Transfer-Encoding: base64\r\n\r\n",
mime.FormatMediaType("multipart/mixed", map[string]string{"boundary": mw.Boundary()}))
message.Write(parts.Bytes())
return []byte(base64.StdEncoding.EncodeToString(message.Bytes())), nil
}
type appleEAP struct {
UserName string
UserPassword string
OuterIdentity string
AcceptEAPTypes []int
TTLSInnerAuthentication string
TLSTrustedServerNames []string
}
type appleWiFi struct {
PayloadType string
PayloadVersion int
PayloadIdentifier string
PayloadUUID string
PayloadDisplayName string
PayloadOrganization string
IsHotspot bool
DomainName string
NAIRealmNames []string
DisplayedOperatorName string
ServiceProviderRoamingEnabled bool
AutoJoin bool
EncryptionType string
EAPClientConfiguration appleEAP
}
type appleConfiguration struct {
PayloadContent []appleWiFi
PayloadType string
PayloadVersion int
PayloadIdentifier string
PayloadUUID string
PayloadDisplayName string
PayloadOrganization string
}
// 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) ([]byte, error) {
return plist.MarshalIndent(appleConfiguration{
PayloadContent: []appleWiFi{{
PayloadType: "com.apple.wifi.managed",
PayloadVersion: 1,
PayloadIdentifier: "uno.renner.wifi.renner-unifi.network",
PayloadUUID: "824959bf-c55d-4ed2-b569-c33f3759c2b6",
PayloadDisplayName: "Renner UniFi",
PayloadOrganization: "Renner",
IsHotspot: true,
DomainName: "renner.uno",
NAIRealmNames: []string{"renner.uno"},
DisplayedOperatorName: "Renner",
ServiceProviderRoamingEnabled: false,
AutoJoin: true,
EncryptionType: "Any",
EAPClientConfiguration: appleEAP{
UserName: user,
UserPassword: password,
OuterIdentity: "anonymous",
AcceptEAPTypes: []int{21},
TTLSInnerAuthentication: "PAP",
TLSTrustedServerNames: []string{"radius.renner.uno"},
},
}},
PayloadType: "Configuration",
PayloadVersion: 1,
PayloadIdentifier: "uno.renner.wifi.renner-unifi",
PayloadUUID: "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3",
PayloadDisplayName: "WLAN Renner UniFi",
PayloadOrganization: "Renner",
}, plist.XMLFormat, "\t")
}
// Namespaces of the CarrierProvisioning document.
const (
nsCarrierControl = "http://www.microsoft.com/networking/CarrierControl/v1"
nsWLAN = "http://www.microsoft.com/networking/CarrierControl/WLAN/v1"
nsOneX = "http://www.microsoft.com/networking/OneX/v1"
nsEapHostConfig = "http://www.microsoft.com/provisioning/EapHostConfig"
nsEapCommon = "http://www.microsoft.com/provisioning/EapCommon"
nsEapTtlsConn = "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1"
nsEapHostUserCred = "http://www.microsoft.com/provisioning/EapHostUserCredentials"
nsEapTtlsUser = "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1"
)
// add appends an element named tag to parent. ns, when set, becomes its default namespace.
func add(parent *etree.Element, tag, ns string) *etree.Element {
el := parent.CreateElement(tag)
if ns != "" {
el.CreateAttr("xmlns", ns)
}
return el
}
// addText appends an element named tag with text content to parent.
func addText(parent *etree.Element, tag, ns, text string) *etree.Element {
el := add(parent, tag, ns)
el.SetText(text)
return el
}
// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile
// (domain and NAI realm renner.uno), WPA3-Enterprise, EAP-TTLS with inner PAP, and the user's
// credentials. Valid against the schemas Windows ships in %SYSTEMROOT%\schemas\provisioning
// (TestWindowsProfileSchema); SubscriberId is base:SubscriberType, i.e. \w+ and at most 20
// characters.
func windowsProfile(user, password string) *etree.Document {
doc := etree.NewDocument()
doc.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true}
doc.CreateProcInst("xml", `version="1.0"`)
doc.CreateText("\n")
root := add(&doc.Element, "CarrierProvisioning", nsCarrierControl)
global := add(root, "Global", "")
addText(global, "CarrierId", "", carrierID)
addText(global, "SubscriberId", "", "rennerunifi")
wlan := add(add(root, "WLANProfiles", ""), "WLANProfile", nsWLAN)
addText(wlan, "name", "", "Renner UniFi")
addText(add(add(wlan, "SSIDConfig", ""), "SSID", ""), "name", "", "Renner UniFi")
hotspot2 := add(wlan, "Hotspot2", "")
addText(hotspot2, "DomainName", "", "renner.uno")
addText(add(hotspot2, "NAIRealm", ""), "name", "", "renner.uno")
security := add(add(wlan, "MSM", ""), "security", "")
auth := add(security, "authEncryption", "")
addText(auth, "authentication", "", "WPA3ENT")
addText(auth, "encryption", "", "AES")
addText(auth, "useOneX", "", "true")
oneX := add(security, "OneX", nsOneX)
addText(oneX, "authMode", "", "user")
eapHost := add(add(oneX, "EAPConfig", ""), "EapHostConfig", nsEapHostConfig)
method := add(eapHost, "EapMethod", "")
addText(method, "Type", nsEapCommon, "21")
addText(method, "VendorId", nsEapCommon, "0")
addText(method, "VendorType", nsEapCommon, "0")
addText(method, "AuthorId", nsEapCommon, "311")
ttls := add(add(eapHost, "Config", ""), "EapTtls", nsEapTtlsConn)
validation := add(ttls, "ServerValidation", "")
addText(validation, "ServerNames", "", "radius.renner.uno")
addText(validation, "TrustedRootCAHash", "", "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8")
addText(validation, "DisablePrompt", "", "true")
add(add(ttls, "Phase2Authentication", ""), "PAPAuthentication", "")
identity := add(ttls, "Phase1Identity", "")
addText(identity, "IdentityPrivacy", "", "true")
addText(identity, "AnonymousIdentity", "", "anonymous")
userCred := add(security, "EapHostUserCredentials", nsEapHostUserCred)
userCred.CreateAttr("xmlns:eapCommon", nsEapCommon)
userMethod := add(userCred, "EapMethod", "")
addText(userMethod, "eapCommon:Type", "", "21")
addText(userMethod, "eapCommon:AuthorId", "", "311")
credentials := add(add(userCred, "Credentials", ""), "EapTtls", nsEapTtlsUser)
addText(credentials, "Username", "", user)
addText(credentials, "Password", "", password)
return doc
}
+249
View File
@@ -0,0 +1,249 @@
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":
body, err := androidProfile(user, key)
if err != nil {
fail("building the profile", "err", err)
return
}
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-wifi-config")
w.Header().Set("Content-Transfer-Encoding", "base64")
_, _ = w.Write(body)
case "apple":
body, err := appleProfile(user, key)
if err != nil {
fail("building the profile", "err", err)
return
}
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
w.Header().Set("Content-Disposition", `attachment; filename="renner-unifi.mobileconfig"`)
_, _ = w.Write(body)
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)
}
+880
View File
@@ -0,0 +1,880 @@
package main
import (
"bufio"
"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"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/beevik/etree"
dsig "github.com/russellhaering/goxmldsig"
"howett.net/plist"
)
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())
}
raw, err := base64.StdEncoding.DecodeString(rec.Body.String())
if err != nil {
t.Fatal(err)
}
msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(raw))).ReadMIMEHeader()
if err != nil {
t.Fatal(err)
}
mediaType, params, err := mime.ParseMediaType(msg.Get("Content-Type"))
if err != nil || mediaType != "multipart/mixed" || params["boundary"] == "" ||
msg.Get("Content-Transfer-Encoding") != "base64" {
t.Fatalf("message header %v", msg)
}
body := raw[bytes.Index(raw, []byte("\r\n\r\n"))+4:]
mr := multipart.NewReader(bytes.NewReader(body), params["boundary"])
parts := map[string][]byte{}
var order []string
for {
p, err := mr.NextRawPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if p.Header.Get("Content-Transfer-Encoding") != "base64" {
t.Errorf("part %v is not base64", p.Header)
}
b, err := io.ReadAll(p)
if err != nil {
t.Fatal(err)
}
ct := p.Header.Get("Content-Type")
order = append(order, ct)
parts[ct] = b
}
if strings.Join(order, ",") != "application/x-passpoint-profile,application/x-x509-ca-cert" {
t.Errorf("parts %v", order)
}
if string(parts["application/x-x509-ca-cert"]) != isrgRootX1 {
t.Error("the trust anchor part is not ISRG Root X1")
}
pps, err := base64.StdEncoding.DecodeString(string(parts["application/x-passpoint-profile"]))
if err != nil {
t.Fatal(err)
}
type node struct {
Name string `xml:"NodeName"`
DDF string `xml:"RTProperties>Type>DDFName"`
Value *string `xml:"Value"`
Nodes []node `xml:"Node"`
}
var tree struct {
XMLName xml.Name
VerDTD string `xml:"VerDTD"`
Nodes []node `xml:"Node"`
}
if err := xml.Unmarshal(pps, &tree); err != nil {
t.Fatalf("pps: %v\n%s", err, pps)
}
if tree.XMLName != (xml.Name{Space: "syncml:dmddf1.2", Local: "MgmtTree"}) || tree.VerDTD != "1.2" ||
len(tree.Nodes) != 1 || tree.Nodes[0].Name != "PerProviderSubscription" ||
tree.Nodes[0].DDF != "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0" {
t.Fatalf("pps %s", pps)
}
// value follows NodeNames below PerProviderSubscription/i001.
value := func(path string) string {
nodes := tree.Nodes[0].Nodes
var cur *node
for _, name := range strings.Split("i001/"+path, "/") {
cur = nil
for i := range nodes {
if nodes[i].Name == name {
cur = &nodes[i]
}
}
if cur == nil {
return "<no node>"
}
nodes = cur.Nodes
}
if cur.Value == nil {
return "<no value>"
}
return *cur.Value
}
for path, want := range map[string]string{
"HomeSP/FriendlyName": "Renner",
"HomeSP/FQDN": "renner.uno",
"Credential/Realm": "renner.uno",
"Credential/UsernamePassword/Username": "Alice.Smith",
"Credential/UsernamePassword/Password": base64.StdEncoding.EncodeToString([]byte(testKey)),
"Credential/UsernamePassword/EAPMethod/EAPType": "21",
"Credential/UsernamePassword/EAPMethod/InnerMethod": "PAP",
"Extension/Android/AAAServerTrustedNames/FQDN": "radius.renner.uno",
} {
if got := value(path); got != want {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
}
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())
}
var top map[string]any
format, err := plist.Unmarshal(rec.Body.Bytes(), &top)
if err != nil || format != plist.XMLFormat {
t.Fatalf("plist (format %d): %v\n%s", format, err, rec.Body.Bytes())
}
want := map[string]any{
"PayloadContent": []any{map[string]any{
"PayloadType": "com.apple.wifi.managed",
"PayloadVersion": uint64(1),
"PayloadIdentifier": "uno.renner.wifi.renner-unifi.network",
"PayloadUUID": "824959bf-c55d-4ed2-b569-c33f3759c2b6",
"PayloadDisplayName": "Renner UniFi",
"PayloadOrganization": "Renner",
"IsHotspot": true,
"DomainName": "renner.uno",
"NAIRealmNames": []any{"renner.uno"},
"DisplayedOperatorName": "Renner",
"ServiceProviderRoamingEnabled": false,
"AutoJoin": true,
"EncryptionType": "Any",
"EAPClientConfiguration": map[string]any{
"UserName": "Alice.Smith",
"UserPassword": testKey,
"OuterIdentity": "anonymous",
"AcceptEAPTypes": []any{uint64(21)},
"TTLSInnerAuthentication": "PAP",
"TLSTrustedServerNames": []any{"radius.renner.uno"},
},
}},
"PayloadType": "Configuration",
"PayloadVersion": uint64(1),
"PayloadIdentifier": "uno.renner.wifi.renner-unifi",
"PayloadUUID": "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3",
"PayloadDisplayName": "WLAN Renner UniFi",
"PayloadOrganization": "Renner",
}
if !reflect.DeepEqual(top, want) {
t.Errorf("profile\n got %#v\nwant %#v", top, want)
}
if !bytes.Contains(rec.Body.Bytes(), []byte("<string>app-password-&lt;&amp;&gt;-456</string>")) {
t.Error("the password is not escaped as XML text")
}
}
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{
`<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">`,
`<Username>Alice.Smith</Username><Password>app-password-&lt;&amp;&gt;-456</Password>`,
`<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo>`,
`<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>`,
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod>`,
`<Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform>`,
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod>`,
`</Signature></CarrierProvisioning>`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %s", want)
}
}
// The reference digest is the SHA-1 of the canonical unsigned document; written with
// canonical settings, the unsigned root is already in that form.
unsigned := etree.NewDocument()
unsigned.SetRoot(windowsProfile("Alice.Smith", testKey).Root())
unsigned.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true}
unsignedBytes, err := unsigned.WriteToBytes()
if err != nil {
t.Fatal(err)
}
canonical, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(unsigned.Root())
if err != nil || !bytes.Equal(canonical, unsignedBytes) {
t.Fatalf("the unsigned document is not canonical: %v\n%s\n%s", err, canonical, unsignedBytes)
}
sum := sha1.Sum(canonical)
if !strings.Contains(s, "<DigestValue>"+base64.StdEncoding.EncodeToString(sum[:])+"</DigestValue>") {
t.Error("the digest is not the SHA-1 of the unsigned document")
}
signed := etree.NewDocument()
if err := signed.ReadFromBytes(body); err != nil {
t.Fatal(err)
}
if pi, ok := signed.Child[0].(*etree.ProcInst); !ok || pi.Target != "xml" || pi.Inst != `version="1.0"` {
t.Errorf("the document does not start with the XML declaration: %q", s[:min(len(s), 40)])
}
root := signed.Root()
if last := root.ChildElements()[len(root.ChildElements())-1]; last.Tag != "Signature" {
t.Errorf("the last child of the root is %s", last.Tag)
}
root.RemoveChild(root.SelectElement("Signature"))
if stripped, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(root); err != nil || !bytes.Equal(stripped, canonical) {
t.Errorf("without the signature, the document differs from the unsigned one: %v", err)
}
// Only the enveloped-signature transform, as in Microsoft's signed samples.
if n := strings.Count(s, "<Transform "); n != 1 {
t.Errorf("%d transforms, want 1", n)
}
if n := strings.Count(s, "<X509Certificate>"); 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, "<X509Data><X509Certificate>"+leaf+"</X509Certificate>") {
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")
}
}
// TestWindowsProfileSchema validates the signed document with xmllint against the schemas
// Windows ships in %SYSTEMROOT%\schemas\provisioning. They are Microsoft's files and not in the
// repo: point WLAN_SETUP_PROVISIONING_XSD at a copy of that folder to run it.
func TestWindowsProfileSchema(t *testing.T) {
dir := os.Getenv("WLAN_SETUP_PROVISIONING_XSD")
if dir == "" {
t.Skip("WLAN_SETUP_PROVISIONING_XSD not set")
}
xmllint, err := exec.LookPath("xmllint")
if err != nil {
t.Skip("xmllint not installed")
}
tc := writeSigningFiles(t)
signer, err := loadSigner(tc.keyFile, tc.certFile)
if err != nil {
t.Fatal(err)
}
body, err := signer.Sign(windowsProfile("Alice.Smith", testKey))
if err != nil {
t.Fatal(err)
}
// The schemas import each other by namespace only; a wrapper schema gives libxml2 the
// locations. Dependencies first, so the later imports find their namespaces loaded.
wrapper := etree.NewDocument()
schema := wrapper.CreateElement("xs:schema")
schema.CreateAttr("xmlns:xs", "http://www.w3.org/2001/XMLSchema")
schema.CreateAttr("targetNamespace", "urn:wlan-setup:schema-test")
for _, s := range []struct{ ns, file string }{
{"http://www.microsoft.com/networking/CarrierControl/Base/v1", "provisioning_base_v1.xsd"},
{"http://www.microsoft.com/networking/CarrierControl/Plans/v1", "provisioning_plans_v1.xsd"},
{"http://www.microsoft.com/networking/CarrierControl/WWAN/v1", "provisioning_wwan_v1.xsd"},
{"http://www.microsoft.com/networking/CarrierControl/WLAN/v1", "provisioning_wlan_v1.xsd"},
{dsig.Namespace, "provisioning_sig_v1.xsd"},
{nsCarrierControl, "provisioning_v1.xsd"},
} {
imp := schema.CreateElement("xs:import")
imp.CreateAttr("namespace", s.ns)
imp.CreateAttr("schemaLocation", "file://"+filepath.Join(dir, s.file))
}
tmp := t.TempDir()
wrapperFile := filepath.Join(tmp, "wrapper.xsd")
if err := wrapper.WriteToFile(wrapperFile); err != nil {
t.Fatal(err)
}
docFile := filepath.Join(tmp, "profile.xml")
if err := os.WriteFile(docFile, body, 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command(xmllint, "--noout", "--schema", wrapperFile, docFile).CombinedOutput()
if err != nil {
t.Fatalf("xmllint: %v\n%s", err, out)
}
}
func TestWindowsProfileDocument(t *testing.T) {
tc := writeSigningFiles(t)
signer, err := loadSigner(tc.keyFile, tc.certFile)
if err != nil {
t.Fatal(err)
}
body, err := signer.Sign(windowsProfile("Alice.Smith", testKey))
if err != nil {
t.Fatal(err)
}
if err := validate(tc.cert, body); err != nil {
t.Fatalf("signature does not verify: %v", err)
}
var wellFormed struct{}
if err := xml.Unmarshal(body, &wellFormed); err != nil {
t.Fatalf("not well-formed: %v", err)
}
doc := etree.NewDocument()
if err := doc.ReadFromBytes(body); err != nil {
t.Fatal(err)
}
root := doc.Root()
const (
wlan = "WLANProfiles/WLANProfile/"
sec = wlan + "MSM/security/"
eap = sec + "OneX/EAPConfig/EapHostConfig/"
ttls = eap + "Config/EapTtls/"
creds = sec + "EapHostUserCredentials/"
sig = "Signature/"
)
for path, want := range map[string]string{
"Global/CarrierId": carrierID,
"Global/SubscriberId": "rennerunifi",
wlan + "name": "Renner UniFi",
wlan + "SSIDConfig/SSID/name": "Renner UniFi",
wlan + "Hotspot2/DomainName": "renner.uno",
wlan + "Hotspot2/NAIRealm/name": "renner.uno",
sec + "authEncryption/authentication": "WPA3ENT",
sec + "authEncryption/encryption": "AES",
sec + "authEncryption/useOneX": "true",
sec + "OneX/authMode": "user",
eap + "EapMethod/Type": "21",
eap + "EapMethod/VendorId": "0",
eap + "EapMethod/VendorType": "0",
eap + "EapMethod/AuthorId": "311",
ttls + "ServerValidation/ServerNames": "radius.renner.uno",
ttls + "ServerValidation/TrustedRootCAHash": "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8",
ttls + "ServerValidation/DisablePrompt": "true",
ttls + "Phase2Authentication/PAPAuthentication": "",
ttls + "Phase1Identity/IdentityPrivacy": "true",
ttls + "Phase1Identity/AnonymousIdentity": "anonymous",
creds + "EapMethod/eapCommon:Type": "21",
creds + "EapMethod/eapCommon:AuthorId": "311",
creds + "Credentials/EapTtls/Username": "Alice.Smith",
creds + "Credentials/EapTtls/Password": testKey,
sig + "SignedInfo/Reference/Transforms/Transform": "",
} {
el := root.FindElement(path)
if el == nil {
t.Errorf("no element %s", path)
continue
}
if el.Text() != want {
t.Errorf("%s = %q, want %q", path, el.Text(), want)
}
}
for path, want := range map[string]string{
".": "http://www.microsoft.com/networking/CarrierControl/v1",
"Global/CarrierId": "http://www.microsoft.com/networking/CarrierControl/v1",
"WLANProfiles": "http://www.microsoft.com/networking/CarrierControl/v1",
wlan + "Hotspot2/DomainName": "http://www.microsoft.com/networking/CarrierControl/WLAN/v1",
sec + "OneX/authMode": "http://www.microsoft.com/networking/OneX/v1",
eap + "EapMethod": "http://www.microsoft.com/provisioning/EapHostConfig",
eap + "EapMethod/Type": "http://www.microsoft.com/provisioning/EapCommon",
eap + "EapMethod/AuthorId": "http://www.microsoft.com/provisioning/EapCommon",
ttls + "ServerValidation": "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1",
creds + "EapMethod": "http://www.microsoft.com/provisioning/EapHostUserCredentials",
creds + "EapMethod/eapCommon:Type": "http://www.microsoft.com/provisioning/EapCommon",
creds + "Credentials/EapTtls/Password": "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1",
"Signature": dsig.Namespace,
sig + "SignedInfo/Reference": dsig.Namespace,
} {
if el := root.FindElement(path); el == nil || el.NamespaceURI() != want {
t.Errorf("%s: namespace not %s", path, want)
}
}
if si := root.FindElement(sig + "SignedInfo"); si == nil || len(si.Attr) != 0 {
t.Error("SignedInfo carries its own attributes")
}
for path, want := range map[string]string{
sig + "SignedInfo/CanonicalizationMethod": "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
sig + "SignedInfo/SignatureMethod": "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
sig + "SignedInfo/Reference/Transforms/Transform": "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
sig + "SignedInfo/Reference/DigestMethod": "http://www.w3.org/2000/09/xmldsig#sha1",
} {
if el := root.FindElement(path); el == nil || el.SelectAttrValue("Algorithm", "") != want {
t.Errorf("%s: Algorithm not %s", path, want)
}
}
if ref := root.FindElement(sig + "SignedInfo/Reference"); ref == nil || ref.SelectAttrValue("URI", "-") != "" {
t.Error(`Reference URI is not ""`)
}
if n := len(root.FindElements(sig + "SignedInfo/Reference/Transforms/Transform")); n != 1 {
t.Errorf("%d transforms, want 1", n)
}
certs := root.FindElements(sig + "KeyInfo/X509Data/X509Certificate")
if len(certs) != 2 || certs[0].Text() != base64.StdEncoding.EncodeToString(tc.cert.Raw) {
t.Errorf("KeyInfo holds %d certificates, want the chain with the leaf first", len(certs))
}
if _, err := signer.Sign(doc); err == nil {
t.Error("an already signed document was signed again")
}
}
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")
}
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"github.com/beevik/etree"
dsig "github.com/russellhaering/goxmldsig"
)
// 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 = dsig.Namespace
// Sign appends an enveloped XML-DSig signature as the last child of doc's root and returns the
// serialized document, 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.
func (s *Signer) Sign(doc *etree.Document) ([]byte, error) {
root := doc.Root()
if root == nil || root.Tag != "CarrierProvisioning" || root.SelectElement(dsig.SignatureTag) != nil {
return nil, errors.New("signing: not an unsigned CarrierProvisioning document")
}
c14n := dsig.MakeC14N10RecCanonicalizer()
canonical, err := c14n.Canonicalize(root)
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
digest := sha1.Sum(canonical)
signature := root.CreateElement(dsig.SignatureTag)
signature.CreateAttr("xmlns", dsigNS)
signedInfo := signature.CreateElement(dsig.SignedInfoTag)
signedInfo.CreateElement(dsig.CanonicalizationMethodTag).
CreateAttr(dsig.AlgorithmAttr, c14n.Algorithm().String())
signedInfo.CreateElement(dsig.SignatureMethodTag).CreateAttr(dsig.AlgorithmAttr, dsig.RSASHA1SignatureMethod)
reference := signedInfo.CreateElement(dsig.ReferenceTag)
reference.CreateAttr(dsig.URIAttr, "")
reference.CreateElement(dsig.TransformsTag).CreateElement(dsig.TransformTag).
CreateAttr(dsig.AlgorithmAttr, dsig.EnvelopedSignatureAltorithmId.String())
reference.CreateElement(dsig.DigestMethodTag).CreateAttr(dsig.AlgorithmAttr, dsigNS+"sha1")
reference.CreateElement(dsig.DigestValueTag).SetText(base64.StdEncoding.EncodeToString(digest[:]))
// In place, the canonicalizer adds the namespace SignedInfo inherits from Signature.
canonical, err = c14n.Canonicalize(signedInfo)
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
hashed := sha1.Sum(canonical)
value, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA1, hashed[:])
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
signature.CreateElement(dsig.SignatureValueTag).SetText(base64.StdEncoding.EncodeToString(value))
x509Data := signature.CreateElement(dsig.KeyInfoTag).CreateElement(dsig.X509DataTag)
for _, c := range s.chain {
x509Data.CreateElement(dsig.X509CertificateTag).SetText(base64.StdEncoding.EncodeToString(c))
}
return doc.WriteToBytes()
}
+70
View File
@@ -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
}