Compare commits

...
Author SHA1 Message Date
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
claude-bot ea195d0e4f chore(ci): bump ci-claude image to 2.1.318
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-05 07:31:22 +00:00
k.renner 1ae259e474 Merge pull request 'chore(deps): update node.js to v26' (#1546) from renovate/node-26.x into main
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
Reviewed-on: #1546
2026-11-05 07:29:19 +00:00
k.renner 796d8459b6 Merge pull request 'fix(zwave-gateway): zwave-js-ui 11.25.2 (Server-Schema 51), Renovate für edge_*_image' (#1562) from fix/zwave-js-ui-11.25.2-renovate into main
Reviewed-on: #1562
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-04 20:54:36 +00:00
security-bot a6be8247eb chore(image-automation): bump ghcr.io/usetrmnl/terminus:0.80.0
Updates managed by image-automation-controller.
2026-11-04 20:51:49 +00:00
hermes-bot 826042f3ea fix(zwave-gateway): zwave-js-ui 11.25.2, Renovate for edge images
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Home Assistant 2026.11.0 requires Z-Wave JS Server schema 51. zwave-js-ui
11.21.1 ships @zwave-js/server 3.10 (schema 50), so the zwave_js entry is
stuck in setup_retry. 11.24.2 is the first release on @zwave-js/server
^3.11.0 (schema 51); bump to the current 11.25.2, pinned by digest.

11.25.2 is built on Alpine 3.23 (was 3.22). The role now reads the
Alpine release from the image and names the musl libeatmydata.so after
it, instead of a hand-synced edge_zwave_alpine_base: Renovate bumps the
image from now on, and the old `creates:` never refreshed the .so.
Builds for other Alpine releases are removed.

Renovate never read ansible/roles/*/defaults/main.yml: enabledManagers
had no custom.regex and there were no customManagers. Add one for
edge_<name>_image (docker datasource and versioning), and give ansible/**
updates their own PR, since the play runs from a workstation after the
merge (no Ansible pipeline since 0c1fb0a2).
2026-11-04 20:46:03 +00:00
security-bot aa2543369c chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.3.6
Updates managed by image-automation-controller.
2026-11-04 18:24:23 +00:00
security-bot 5add98190f chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.3.6
Updates managed by image-automation-controller.
2026-11-04 18:18:59 +00:00
security-bot 1643b305b2 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.11.0
Updates managed by image-automation-controller.
2026-11-04 17:55:55 +00:00
k.renner 657e3d0d92 Merge pull request 'feat(radius): proxy UniFi Endpoint logins to the UDM over VLAN 11' (#1561) from feat/radius-proxy-vlan11 into main
Reviewed-on: #1561
2026-11-04 14:10:09 +00:00
k.renner ac2d5d13a1 Merge pull request 'feat(network): VLAN 11 link radius0 on the nodes, Cilium VLAN bypass' (#1560) from feat/vlan11-radius0-base into main
Reviewed-on: #1560
2026-11-04 14:07:19 +00:00
claude-botandClaude Opus 5.5 ed3be3156b feat(network): VLAN 11 link radius0 on the nodes, Cilium VLAN bypass
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
VLAN 11 "(11) RADIUS" carries the FreeRADIUS proxy traffic to the UDM
RADIUS, which accepts clients from its own networks only.

- Talos k8s-{1,2,3}: VLANConfig radius0 (VLAN 11 on uplink), link only,
  no addresses, IPv6 disabled (VLAN 11 sends RAs).
- Cilium bpf.vlanBypass [30, 11]: radius0 is address-less like ens19, so
  its tagged frames meet the VLAN filter on uplink (#592).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:05:23 +01:00
claude-botandClaude Opus 5.5 05d2fcac71 feat(radius): proxy UniFi Endpoint logins to the UDM over VLAN 11
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The UDM RADIUS rejects the pod CIDR as an unknown client; it accepts
10.2.11.0/24. FreeRADIUS pods get net1 on VLAN 11 "(11) RADIUS" and the
udm home server moves to 10.2.11.1.

- Talos k8s-{1,2,3}: VLANConfig radius0 (VLAN 11 on uplink), no
  addresses, IPv6 disabled on the host link.
- Cilium bpf.vlanBypass gains 11: radius0 is address-less like ens19, so
  its tagged frames meet the VLAN filter on uplink (#592).
- NAD freeradius-udm: macvlan on radius0, DHCP IPAM requesting only
  subnet-mask (skipDefault), so the lease has no router and net1 holds
  only the connected /24. No sbr: it moves that route into a source
  table, and the unbound proxy socket would then reach the UDM via eth0
  from the pod IP. tuning disables IPv6 on net1 (VLAN 11 sends RAs).
- NAS listeners bound to eth0, so nothing listens on net1.
- CNP freeradius-udm-egress removed: net1 traffic bypasses Cilium.
- Harness: FreeRADIUS interfaces named eth0/net1 explicitly; Docker
  attaches networks in no fixed order, and the eth0 bind exposed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:02:37 +01:00
k.renner 697856cbf8 Merge pull request 'feat(radius): forward UniFi Endpoint logins to the UDM RADIUS' (#1559) from feat/radius-udm-forward into main
Reviewed-on: #1559
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-04 12:20:57 +00:00
k.renner 4962af5ed1 Merge pull request 'docs(radius): UniFi objects and live login check for the 802.1X test' (#1558) from docs/radius-live-test into main
Reviewed-on: #1558
2026-11-04 12:15:30 +00:00
claude-botandClaude Opus 5.5 bc698ea22d feat(radius): forward non-anonymous EAP logins to the UDM RADIUS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Outer identity anonymous[@realm] (any case) keeps the local EAP-TTLS ->
authentik path. Any other outer identity is a UniFi Endpoint account: the
outer server proxies every EAP round to new home server udm (10.2.47.1:1812,
UDM_RADIUS_SECRET) without local EAP processing; post-proxy/post-auth leave
UDM replies untouched. Endpoint account names look like ordinary usernames,
so routing keys on the anonymous identity, not a name pattern.

home_server udm: response_window 5 < proxy_dedup_window 10,
require_message_authenticator yes (RFC 3579, EAP replies), status_check
request with a user name that cannot exist.

CNP freeradius-udm-egress allows only 10.2.47.1/32 UDP 1812.

Harness: mock_udm.py on a second internal bridge at the udm address from
proxy.conf. Asserts both EAP rounds (State included) reach it, its Accept
reaches the NAS with Tunnel-* VLAN 200 and MS-MPPE keys intact, its Reject
passes, and anonymous logins and accounting never reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:13:07 +01:00
claude-botandClaude Opus 5.5 d42862e2d8 docs(radius): UniFi objects and live login check for the 802.1X test
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:05:55 +01:00
k.renner 73dd6f6362 Merge pull request 'perf(authentik): disable Postgres JIT, persistent DB connections' (#1557) from fix/authentik-pg-latency into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1557
2026-11-04 08:56:56 +00:00
renovate-bot 335c423ca6 chore(deps): update node.js to v26
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-11-04 03:05:56 +00:00
52 changed files with 3199 additions and 159 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
+4 -4
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-yaml-citations.sh
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.318
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+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 ./...
+11 -9
View File
@@ -134,11 +134,13 @@ Afterwards:
## Day-2 notes
- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in
defaults and re-run the play (no flux image-automation out here —
deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump
changes its Alpine major, also update `edge_zwave_alpine_base` and refresh
the preloaded `.so` (see "Logging & eMMC" → zwave-js).
- Image bumps: Renovate opens a PR for `edge_*_image` in
`roles/*/defaults/main.yml` (tag + digest, `customManagers` in
`renovate.json`). No pipeline deploys to edge-1, so after the merge run
`ansible-playbook plays/install-zwave-gateway.yml` from `ansible/` on a
mesh-connected workstation. The play reads the image's Alpine release
itself and rebuilds the preloaded `.so` when it changes (see
"Logging & eMMC" → zwave-js).
- open3e / E3onCAN updates: both are pinned to commits in
`roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick
up upstream changes -- that is deliberate, these drive the heat pump. The
@@ -190,7 +192,7 @@ journal commit).
- **Fix:** the `zwave-js` container preloads **`libeatmydata`**
(`LD_PRELOAD=/usr/lib/libeatmydata.so`, mounted read-only from
`zwave-js/libeatmydata.so`). This makes `fsync`/`fdatasync` no-ops *for that
`zwave-js/libeatmydata-alpine<X.Y>.so`). This makes `fsync`/`fdatasync` no-ops *for that
one process*, so the kernel batches the writes via normal writeback (~30 s)
instead of forcing each one. The WS/MQTT value stream to Home Assistant and
the on-disk cache are **unchanged** — full data resolution, no re-interview
@@ -199,9 +201,9 @@ journal commit).
batch it); the only mount-level alternative is putting the cache on tmpfs,
which makes the *whole* store volatile → re-interview on every restart.
- **The `.so` is musl** (the image is Alpine-based), extracted from
`edge_zwave_alpine_base` — keep that var in sync with the image's Alpine
version; to refresh after a major bump, `rm zwave-js/libeatmydata.so` and
re-run.
`alpine:<X.Y>`, where `X.Y` is read from the image's `/etc/alpine-release`
on every run. A new Alpine release gives a new file name, so the play
extracts it, recreates the container and deletes the old file.
- **Durability guard:** because `fsync` is neutralized, a hard power-loss
during the (rare) settings write could truncate `settings.json`, which holds
the **Z-Wave network keys**. `edge-zwave-settings-backup.timer` keeps a
@@ -13,12 +13,8 @@ zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
# survives moving the stick between machines.
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
# Alpine version the zwave-js-ui image is built on -- the source for the musl
# libeatmydata.so preloaded into that container. Keep in sync if the image
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
edge_zwave_alpine_base: alpine:3.22
# Renovate-tracked (customManagers in renovate.json).
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.25.2@sha256:ac4874a3cbea61c0c60002a8fd8ae05104cc8eb2eae91aa05bd184972aa06853
# Secret from inventory/group_vars/edge_host/vault.yml:
# edge_zwavejs_session_secret
+32 -3
View File
@@ -15,17 +15,31 @@
- "{{ zwave_base_dir }}"
- "{{ zwave_base_dir }}/store"
# The preloaded .so is musl and must come from the image's own Alpine release.
- name: Read the Alpine release of the zwave-js-ui image
ansible.builtin.command:
cmd: docker run --rm --entrypoint cat {{ edge_zwavejs_image }} /etc/alpine-release
register: zwave_alpine_release
changed_when: false
check_mode: false
- name: Name libeatmydata.so after that Alpine release
ansible.builtin.set_fact:
zwave_alpine_minor: "{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}"
zwave_libeatmydata: "libeatmydata-alpine{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}.so"
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
ansible.builtin.shell:
cmd: |
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
docker run --rm --network=host alpine:{{ zwave_alpine_minor }} \
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
creates: "{{ zwave_base_dir }}/libeatmydata.so"
install -m 0644 "$tmp" {{ zwave_base_dir }}/{{ zwave_libeatmydata }}
creates: "{{ zwave_base_dir }}/{{ zwave_libeatmydata }}"
executable: /bin/bash
notify: Restart zwave-js
# Because fsync is neutralized, a hard power-loss during the (rare) settings
@@ -93,3 +107,18 @@
zwave-js could not start -- the ZWA-2 radio is not present:
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
when: zwave_compose_up.rc != 0
- name: Find libeatmydata.so builds of other Alpine releases
ansible.builtin.find:
paths: "{{ zwave_base_dir }}"
patterns: "libeatmydata*.so"
excludes: "{{ zwave_libeatmydata }}"
register: zwave_stale_libeatmydata
- name: Remove libeatmydata.so builds of other Alpine releases
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ zwave_stale_libeatmydata.files }}"
loop_control:
label: "{{ item.path }}"
@@ -31,7 +31,7 @@ services:
- "{{ edge_zwave_device }}:/dev/zwave"
volumes:
# musl libeatmydata.so extracted from the image's Alpine base (ro)
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
- ./{{ zwave_libeatmydata }}:/usr/lib/libeatmydata.so:ro
# Persistent store: settings.json, device DB, network keys. Path is
# unchanged from the previous shared project, so nothing is migrated.
- ./store:/usr/src/app/store
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
FROM node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
@@ -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>
+126 -20
View File
@@ -4,6 +4,54 @@ FreeRADIUS terminates EAP-TTLS and proxies the inner PAP request to the
authentik RADIUS outpost `802.1X Radius`. authentik checks the password and
the access policy and returns the VLAN.
## Routing by outer identity
FreeRADIUS decides on the outer EAP identity (`User-Name`) of each request,
before any local EAP processing:
| Outer identity | Path |
|---|---|
| `anonymous` or `anonymous@<anything>` (any case) | EAP-TTLS terminated in FreeRADIUS, inner PAP to authentik |
| anything else | whole EAP conversation (all rounds, `State` included) proxied to the UDM RADIUS for UniFi Endpoint accounts |
UniFi Endpoint clients send their real account name as outer identity; their
names look like ordinary usernames, so the rule cannot match on name patterns.
The UDM's Access-Accept/Reject/Challenge goes to the NAS unchanged, including
its Tunnel-* VLAN and MS-MPPE keys. The "Accept without VLAN is rejected" rule
applies to the authentik path only. Accounting is answered locally on both
paths.
The anonymous outer identity is mandatory for authentik clients: without it
the login goes to the UDM and fails.
UDM target: home server `udm`, `10.2.11.1` UDP 1812 (the UDM on VLAN 11,
UniFi network "(11) RADIUS"), shared secret `UDM_RADIUS_SECRET` from OpenBao
`apps/radius`. The UDM RADIUS accepts clients from `10.2.11.0/24` only, not
from the pod CIDR.
### Path to the UDM
Each FreeRADIUS pod has a second interface `net1` on VLAN 11
(NetworkAttachmentDefinition `freeradius-udm`,
`kubernetes/apps/authentik/freeradius-network-attachment.yaml`):
- macvlan on the node link `radius0` (VLAN 11 on `uplink`, no host addresses;
VLANConfig in `kubernetes/talos/patches/k8s-{1,2,3}.yaml`). VLAN 11 is in
Cilium's `bpf.vlanBypass`, otherwise the tagged frames are dropped on
`uplink`.
- IPv4 leased from the UDM DHCP server (cni-dhcp-daemon), requested without
the routers option: `net1` holds only the connected `10.2.11.0/24`, no
default route. The proxy socket is not bound to an address; the kernel's
route lookup for `10.2.11.1` picks `net1` and its leased address as source.
- IPv6 disabled on `net1` (VLAN 11 sends router advertisements).
- The NAS listeners are bound to `eth0` (`interface = eth0`); nothing listens
on `net1`.
- Traffic on `net1` bypasses Cilium; there is no CNP for the UDM hop.
`radius0` must exist on every node before FreeRADIUS pods are scheduled
there: without it the macvlan ADD fails and the pod stays in
`ContainerCreating`.
## authentik objects
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
@@ -69,22 +117,79 @@ The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
removed after the test.
## Client setup (SSID "Renner Test")
## UniFi objects
Created through the UniFi Network API (site `default`):
| Object | ID | Settings |
|---|---|---|
| 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 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
`tests/radius/live.sh` runs EAP-TTLS/PAP logins with `eapol_test` against the
deployed FreeRADIUS from a test client range and prints result, duration and
the VLAN attributes of the Access-Accept (hex) per login; secrets are read from
files and never printed:
```
LIVE_PASSWORD_VAR=CLAUDE_BOT_PASSWORD tests/radius/live.sh 10.2.99.91 claude-bot \
~/.config/claude-bot/authentik ~/.config/claude-bot/radius-secret 20
```
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 UniFi")
Setup page: `https://wlan.renner.uno` (behind the Authentik gate, any
authentik user; `kubernetes/apps/authentik/wlan-setup*.yaml`). Android and
Apple get Passpoint profiles with their own app password; Windows gets a file
that imports the WLAN profile with `netsh`, Linux an `nmcli` command
(NetworkManager has no Passpoint support). The Windows and Linux setups carry
no password.
Passpoint (Android, Apple): `/<platform>/install` (`android` or `apple`) signs
the user in through the authentik OAuth2 application `wlan-setup` (scope
`goauthentik.io/api`). On `/<platform>/callback`, nginx (njs) exchanges the
code, creates an app password (`wlan-<platform>-<user>-<timestamp>-<random>`)
with the user's own access token, and returns the profile in the response to
that GET: Android `application/x-wifi-config` (as its installer requires),
Apple a `.mobileconfig`. The page stores nothing. 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`
- Password: authentik password
Passpoint profile from the setup page ("Auf Android installieren").
### Linux (NetworkManager)
@@ -93,14 +198,15 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
- Inner authentication: PAP
- CA certificate: system bundle (e.g. `/etc/ssl/certs/ca-certificates.crt`)
- Domain: `radius.renner.uno`
- Anonymous identity: `anonymous` (mandatory)
- Username / password: authentik credentials
### Windows 11
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
@@ -115,14 +221,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,45 @@ 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
- 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,6 +1,8 @@
# FreeRADIUS 3.2 config for 802.1X: EAP-TTLS terminated here, inner PAP
# proxied to the authentik outpost ak-outpost-8021x-radius. Replaces the
# image's raddb completely. Functional test: tests/radius/run.sh.
# FreeRADIUS 3.2 config for 802.1X: outer identity anonymous[@...] -> EAP-TTLS
# terminated here, inner PAP proxied to the authentik outpost
# ak-outpost-8021x-radius; any other outer identity -> whole EAP conversation
# proxied to the UDM RADIUS (UniFi Endpoint). Replaces the image's raddb
# completely. Functional test: tests/radius/run.sh.
# VLAN contract: docs/radius/dot1x.md.
apiVersion: v1
kind: ConfigMap
@@ -32,9 +34,10 @@ data:
max_request_time = 30
cleanup_delay = 5
# Greater than response_window (proxy.conf): never forward a NAS
# retransmit to the outpost. A TTLS inner request sent twice to the home
# server leaves a freed request in the proxy hash; radiusd segfaults.
# Greater than every home server's response_window (proxy.conf): never
# forward a NAS retransmit to a home server. A TTLS inner request sent
# twice to the home server leaves a freed request in the proxy hash;
# radiusd segfaults.
proxy_dedup_window = 10
max_requests = 16384
hostname_lookups = no
@@ -152,6 +155,42 @@ data:
nostrip
}
# UDM RADIUS (FreeRADIUS) for UniFi Endpoint accounts; gets the whole
# outer EAP conversation. Address: UDM on VLAN 11 "(11) RADIUS", reached
# over net1 (freeradius-network-attachment.yaml); the UDM accepts
# RADIUS clients from 10.2.11.0/24 only.
home_server udm {
type = auth
ipaddr = 10.2.11.1
port = 1812
proto = udp
secret = $ENV{UDM_RADIUS_SECRET}
# RFC 3579: every reply to an EAP request carries Message-Authenticator.
require_message_authenticator = yes
# Less than proxy_dedup_window (radiusd.conf).
response_window = 5
zombie_period = 20
# Probe with an Access-Request for a user that cannot exist (no
# EAP-Message, so no Endpoint login); the reject counts as an answer.
# Sent only while zombie or dead.
status_check = request
username = "udm-status-probe.invalid"
password = "not-a-secret"
check_interval = 6
check_timeout = 4
num_answers_to_alive = 3
}
home_server_pool udm {
type = fail-over
home_server = udm
}
realm udm {
auth_pool = udm
nostrip
}
mods-enabled_always: |
always ok {
rcode = ok
@@ -194,35 +233,50 @@ data:
sites-enabled_default: |
server default {
# NAS listeners on eth0 only; net1 (VLAN 11) carries the UDM proxy
# traffic and nothing else.
listen {
type = auth
ipaddr = *
port = 1812
interface = eth0
}
listen {
type = acct
ipaddr = *
port = 1813
interface = eth0
}
listen {
type = auth
ipv6addr = ::
port = 1812
interface = eth0
}
listen {
type = acct
ipv6addr = ::
port = 1813
interface = eth0
}
authorize {
# EAP-TTLS only.
# EAP only.
if (!&EAP-Message) {
reject
}
# Outer identity anonymous[@realm]: authentik clients, EAP-TTLS here.
# Any other: UniFi Endpoint account (real name as outer identity);
# every round goes to the UDM unprocessed.
if (&User-Name && &User-Name !~ /^anonymous(@.*)?$/i) {
update control {
&Proxy-To-Realm := udm
}
return
}
eap {
ok = return
updated = return
@@ -245,12 +299,23 @@ data:
ok
}
# UDM replies pass unchanged.
post-auth {
update reply {
&Cisco-AVPair !* ANY
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
update reply {
&Cisco-AVPair !* ANY
}
}
Post-Auth-Type REJECT {
eap
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
@@ -259,7 +324,12 @@ data:
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
eap
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
@@ -0,0 +1,48 @@
---
# FreeRADIUS net1: macvlan on radius0, VLAN 11 "(11) RADIUS" (VLANConfig in
# kubernetes/talos/patches/k8s-{1,2,3}.yaml). Carries only the proxy traffic
# to home server `udm` (freeradius-config.yaml): the UDM RADIUS accepts
# clients from 10.2.11.0/24, not from the pod CIDR. Bypasses Cilium policy.
# Address leased from the UDM; nothing references it.
#
# net1 holds only its connected /24, so the unbound proxy socket reaches the
# UDM via net1 with the leased source. No sbr: it moves that route into a
# source-based table, and the proxy would leave via eth0 from the pod IP.
# skipDefault + subnet-mask: the lease is requested without routers, so no
# default route lands on net1 (dnsmasq sends only requested options).
# IPv6 off on net1: VLAN 11 sends RAs (default route, SLAAC); the UDM
# RADIUS clients are IPv4 only.
#
# Requires cni-dhcp-daemon (infrastructure/multus) and VLAN 11 in Cilium's
# bpf.vlanBypass (infrastructure/cilium/helmrelease.yaml).
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: freeradius-udm
namespace: authentik
spec:
config: |
{
"cniVersion": "0.3.1",
"name": "freeradius-udm",
"plugins": [
{
"type": "macvlan",
"master": "radius0",
"mode": "bridge",
"ipam": {
"type": "dhcp",
"request": [
{ "skipDefault": true },
{ "option": "subnet-mask" }
]
}
},
{
"type": "tuning",
"sysctl": {
"net.ipv6.conf.IFNAME.disable_ipv6": "1"
}
}
]
}
@@ -18,6 +18,9 @@ spec:
metadata:
labels:
app.kubernetes.io/name: freeradius
annotations:
# net1 on VLAN 11: the path to the UDM RADIUS.
k8s.v1.cni.cncf.io/networks: freeradius-udm
spec:
enableServiceLinks: false
affinity:
@@ -89,6 +92,11 @@ spec:
secretKeyRef:
name: radius-secrets
key: DOT1X_OUTPOST_SECRET
- name: UDM_RADIUS_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: UDM_RADIUS_SECRET
ports:
- name: radius
containerPort: 1812
+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:
@@ -13,8 +13,12 @@ resources:
- radius-cert.yaml
- radius-externalsecret.yaml
- freeradius-config.yaml
- freeradius-network-attachment.yaml
- freeradius.yaml
- freeradius-cnp.yaml
- wlan-setup-oauth.yaml
- wlan-setup-config.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,482 @@
# wlan.renner.uno: setup files for "Renner UniFi" (802.1X, EAP-TTLS/PAP
# against freeradius, outer identity anonymous, server name radius.renner.uno).
# Served by wlan-setup (wlan-setup.yaml) behind the Authentik gate; the
# username comes from X-authentik-username. The Android and Apple Passpoint
# profiles carry a credential: a per-device authentik app password that the
# page creates with the user's own OAuth access token.
apiVersion: v1
kind: ConfigMap
metadata:
name: wlan-setup
namespace: authentik
data:
# The image's nginx.conf plus the njs module and the OAuth client secret
# passpoint.js reads from the environment.
nginx.conf: |
load_module modules/ngx_http_js_module.so;
env WLAN_SETUP_CLIENT_SECRET;
worker_processes 1;
error_log /var/log/nginx/error.log notice;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
proxy_temp_path /tmp/proxy_temp;
client_body_temp_path /tmp/client_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - [$time_local] "$request" $status $body_bytes_sent "$http_user_agent"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
default.conf: |
# Only plain usernames reach a generated file; anything else leaves the
# field empty and the device asks for it.
map $http_x_authentik_username $wifi_user {
"~^[A-Za-z0-9._@+-]{1,64}$" $http_x_authentik_username;
default "";
}
js_path /etc/nginx/njs/;
js_import passpoint from passpoint.js;
# authentik-server, resolved once at start; reached only by passpoint.js
# subrequests (/_ak/...).
upstream authentik {
server authentik-server.authentik.svc.cluster.local:80;
}
server {
listen 8080;
listen [::]:8080;
server_tokens off;
root /usr/share/nginx/html;
add_header Cache-Control "no-store" always;
location = / {
try_files /index.html =404;
}
location = /whoami {
default_type text/plain;
return 200 $wifi_user;
}
location = /renner-unifi-wlan.cmd {
default_type application/octet-stream;
add_header Cache-Control "no-store" always;
add_header Content-Disposition 'attachment; filename="renner-unifi-wlan.cmd"' always;
try_files /renner-unifi-wlan.cmd =404;
}
# Android and Apple: a tap on /<platform>/install starts an
# authorization-code sign-in at authentik (the user is already signed
# in there); the callback creates the app password as the user and
# answers the same navigation with the Passpoint profile. Android:
# application/x-wifi-config, base64, no Content-Disposition, as its
# installer requires. Apple: a .mobileconfig download.
location ~ ^/(android|apple)/install$ {
js_content passpoint.install;
}
location ~ ^/(android|apple)/callback$ {
# The query carries the one-time authorization code.
access_log off;
js_content passpoint.callback;
}
# Subrequests from passpoint.js only. Credential and content type
# arrive as arguments and leave as headers; proxy_pass with a variable
# URI does not forward the arguments. $arg_* only: subrequests share
# the parent's variable cache, so a map would keep its first value.
location ~ ^/_ak(/.*)$ {
internal;
proxy_pass http://authentik$1;
# njs reads the whole reply from this buffer (default one page).
subrequest_output_buffer_size 256k;
# Only the headers set here reach authentik.
proxy_pass_request_headers off;
proxy_set_header Host auth.renner.uno;
proxy_set_header Cookie "";
proxy_set_header Authorization "$arg_scheme $arg_cred";
proxy_set_header Content-Type $arg_ct;
proxy_set_header Accept "application/json";
}
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location / {
return 404;
}
}
# Windows: extracts the WLAN profile (everything after line 8 of itself)
# into %TEMP% and imports it for the current user with netsh. EAP-TTLS with
# PAPAuthentication, outer identity anonymous, server name
# radius.renner.uno, trust anchor ISRG Root X1 (SHA-1 thumbprint; the
# Let's Encrypt chain of radius-tls ends there). Windows asks for username
# and password on connect. CRLF line endings: cmd.exe needs them.
renner-unifi-wlan.cmd: "@echo off\r\nrem WLAN Renner UniFi einrichten (802.1X, Anmeldung mit dem authentik-Konto)\r\nset \"WLANXML=%TEMP%\\renner-unifi-wlan.xml\"\r\nmore +8 \"%~f0\" > \"%WLANXML%\"\r\nnetsh wlan add profile filename=\"%WLANXML%\" user=current\r\ndel \"%WLANXML%\"\r\nnetsh wlan connect name=\"Renner UniFi\"\r\npause & exit /b\r\n<?xml version=\"1.0\"?><WLANProfile xmlns=\"http://www.microsoft.com/networking/WLAN/profile/v1\"><name>Renner UniFi</name><SSIDConfig><SSID><hex>52656E6E657220556E694669</hex><name>Renner UniFi</name></SSID><nonBroadcast>false</nonBroadcast></SSIDConfig><connectionType>ESS</connectionType><connectionMode>auto</connectionMode><MSM><security><authEncryption><authentication>WPA2</authentication><encryption>AES</encryption><useOneX>true</useOneX></authEncryption><OneX xmlns=\"http://www.microsoft.com/networking/OneX/v1\"><cacheUserData>true</cacheUserData><authMode>user</authMode><EAPConfig><EapHostConfig xmlns=\"http://www.microsoft.com/provisioning/EapHostConfig\"><EapMethod><Type xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">21</Type><VendorId xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">0</VendorId><VendorType xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">0</VendorType><AuthorId xmlns=\"http://www.microsoft.com/provisioning/EapCommon\">311</AuthorId></EapMethod><Config xmlns=\"http://www.microsoft.com/provisioning/EapHostConfig\"><EapTtls xmlns=\"http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1\"><ServerValidation><ServerNames>radius.renner.uno</ServerNames><TrustedRootCAHash>ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8</TrustedRootCAHash><DisablePrompt>false</DisablePrompt></ServerValidation><Phase2Authentication><PAPAuthentication/></Phase2Authentication><Phase1Identity><IdentityPrivacy>true</IdentityPrivacy><AnonymousIdentity>anonymous</AnonymousIdentity></Phase1Identity></EapTtls></Config></EapHostConfig></EAPConfig></OneX></security></MSM></WLANProfile>\r\n"
# Android Passpoint R1 provisioning (source.android.com, Passpoint): the
# body is base64 of a MIME multipart with the PPS MO and the trust anchor.
# HomeSP FQDN and realm renner.uno match the Hotspot 2.0 domain and NAI realm
# the Passpoint WLAN announces; EAP-TTLS (21) with inner PAP. Trust anchor
# ISRG Root X1 (the radius-tls chain ends there), AAA server name
# radius.renner.uno (Extension/Android/AAAServerTrustedNames, as Android's
# PpsMoParser expects it). The credential is a new app password per install,
# created as the user with the access token of the "WLAN setup" OAuth client
# (authorization code, scope goauthentik.io/api). It starts with 29 days
# (below the 30-day token cap); each 802.1X login renews it to 60 days
# (policy dot1x-app-password-renew).
# Apple: com.apple.wifi.managed with IsHotspot, DomainName and NAIRealmNames
# renner.uno (no SSID: Passpoint matching), TTLS (21) with inner PAP, outer
# identity anonymous, trusted server name radius.renner.uno, username and
# app password. Fixed PayloadIdentifier/UUIDs so a reinstall replaces the
# previous profile.
passpoint.js: |
const ISRG_ROOT_X1 = "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=";
const USER = /^[A-Za-z0-9._@+-]{1,64}$/;
function b64(s) { return Buffer.from(s).toString("base64"); }
function node(name, inner) { return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"; }
function leaf(name, value) { return node(name, "<Value>" + value + "</Value>"); }
function profile(user, password) {
const pps = '<MgmtTree xmlns="syncml:dmddf1.2"><VerDTD>1.2</VerDTD>' +
node("PerProviderSubscription",
"<RTProperties><Type><DDFName>urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0</DDFName></Type></RTProperties>" +
node("i001",
node("HomeSP", leaf("FriendlyName", "Renner") + leaf("FQDN", "renner.uno")) +
node("Credential",
leaf("Realm", "renner.uno") +
node("UsernamePassword",
leaf("Username", user) +
leaf("Password", b64(password)) +
node("EAPMethod", leaf("EAPType", "21") + leaf("InnerMethod", "PAP")))) +
node("Extension", node("Android",
node("AAAServerTrustedNames", leaf("FQDN", "radius.renner.uno")))))) +
"</MgmtTree>";
const mime = "Content-Type: multipart/mixed; boundary={boundary}\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-passpoint-profile\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
b64(pps) + "\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-x509-ca-cert\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
ISRG_ROOT_X1 + "\r\n" +
"--{boundary}--\r\n";
return b64(mime);
}
function xml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function mobileconfig(user, password) {
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
'<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-unifi.network</string>' +
'<key>PayloadUUID</key><string>824959bf-c55d-4ed2-b569-c33f3759c2b6</string>' +
'<key>PayloadDisplayName</key><string>Renner UniFi</string>' +
'<key>PayloadOrganization</key><string>Renner</string>' +
'<key>IsHotspot</key><true/>' +
'<key>DomainName</key><string>renner.uno</string>' +
'<key>NAIRealmNames</key><array><string>renner.uno</string></array>' +
'<key>DisplayedOperatorName</key><string>Renner</string>' +
'<key>ServiceProviderRoamingEnabled</key><false/>' +
'<key>AutoJoin</key><true/>' +
'<key>EncryptionType</key><string>Any</string>' +
'<key>EAPClientConfiguration</key><dict>' +
'<key>UserName</key><string>' + xml(user) + '</string>' +
'<key>UserPassword</key><string>' + xml(password) + '</string>' +
'<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-unifi</string>' +
'<key>PayloadUUID</key><string>7175a2a3-ddd3-42ea-b4ad-b7e5501892f3</string>' +
'<key>PayloadDisplayName</key><string>WLAN Renner UniFi</string>' +
'<key>PayloadOrganization</key><string>Renner</string>' +
'</dict></plist>\n';
}
const PLATFORMS = {
android: { label: "Android" },
apple: { label: "Apple" },
};
// "android" or "apple" from /<platform>/install or /<platform>/callback.
function platform(r) {
return r.uri.split("/")[1];
}
function redirectUri(name) {
return "https://wlan.renner.uno/" + name + "/callback";
}
const CLIENT_ID = "wlan-setup";
const AUTHORIZE = "https://auth.renner.uno/application/o/authorize/";
const STATE_COOKIE = "wlan_oauth_state";
const LIFETIME_DAYS = 29;
function random() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return Buffer.from(bytes).toString("base64url");
}
function cookie(r, name) {
const all = r.headersIn["Cookie"] || "";
const parts = all.split(";");
for (let n = 0; n < parts.length; n++) {
const part = parts[n];
const i = part.indexOf("=");
if (i > 0 && part.substring(0, i).trim() === name) return part.substring(i + 1).trim();
}
return "";
}
function form(fields) {
return Object.keys(fields).map(k => encodeURIComponent(k) + "=" + encodeURIComponent(fields[k])).join("&");
}
const FORM = "application/x-www-form-urlencoded";
const JSON_CT = "application/json";
const CRED = /^[A-Za-z0-9._~+\/=-]+$/;
// authentik call through the internal /_ak location; the arguments
// become the Authorization and Content-Type headers there (raw, nginx
// does not decode $arg_*).
async function ak(r, method, path, scheme, cred, ct, body) {
if (!CRED.test(cred)) throw new Error(method + " " + path + ": no credential");
const args = "scheme=" + scheme + "&cred=" + cred + "&ct=" + ct;
const opts = { method: method, args: args };
if (body !== undefined) opts.body = body;
const reply = await r.subrequest("/_ak" + path, opts);
if (reply.status < 200 || reply.status > 299) {
throw new Error(method + " " + path + " -> " + reply.status + " " + reply.responseText.substring(0, 300));
}
return JSON.parse(reply.responseText);
}
function install(r) {
const name = platform(r);
const state = random();
r.headersOut["Set-Cookie"] = STATE_COOKIE + "=" + state +
"; Path=/" + name + "/; Max-Age=300; HttpOnly; Secure; SameSite=Lax";
r.return(302, AUTHORIZE + "?" + form({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri(name),
scope: "openid profile goauthentik.io/api",
state: state,
}));
}
async function callback(r) {
const name = platform(r);
try {
const state = cookie(r, STATE_COOKIE);
if (!state || r.args.state !== state || !r.args.code) { r.return(400, "Bitte die Einrichtung neu starten.\n"); return; }
const client = Buffer.from(CLIENT_ID + ":" + process.env.WLAN_SETUP_CLIENT_SECRET).toString("base64");
const grant = await ak(r, "POST", "/application/o/token/", "Basic", client, FORM, form({
grant_type: "authorization_code",
code: r.args.code,
redirect_uri: redirectUri(name),
}));
const access = grant.access_token;
const me = await ak(r, "GET", "/api/v3/core/users/me/", "Bearer", access, "");
const user = me.user.username;
if (!USER.test(user) || user !== r.headersIn["X-authentik-username"]) { r.return(403); return; }
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "");
const identifier = "wlan-" + name + "-" + user.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase() + "-" + stamp + "-" + random().substring(0, 6).replace(/[^A-Za-z0-9]/g, "x");
const expires = new Date(now.getTime() + LIFETIME_DAYS * 86400000).toISOString();
await ak(r, "POST", "/api/v3/core/tokens/", "Bearer", access, JSON_CT, JSON.stringify({
identifier: identifier,
intent: "app_password",
description: "WLAN " + PLATFORMS[name].label + ", " + now.toISOString().substring(0, 10) + " (wlan.renner.uno)",
expiring: true,
expires: expires,
}));
const key = (await ak(r, "GET", "/api/v3/core/tokens/" + identifier + "/view_key/", "Bearer", access, "")).key;
r.headersOut["Set-Cookie"] = STATE_COOKIE + "=; Path=/" + name + "/; Max-Age=0; HttpOnly; Secure; SameSite=Lax";
if (name === "apple") {
r.headersOut["Content-Type"] = "application/x-apple-aspen-config";
r.headersOut["Content-Disposition"] = 'attachment; filename="renner-unifi.mobileconfig"';
r.return(200, mobileconfig(user, key));
} else {
r.headersOut["Content-Type"] = "application/x-wifi-config";
r.headersOut["Content-Transfer-Encoding"] = "base64";
r.return(200, profile(user, key));
}
} catch (e) {
r.error("wlan-setup " + name + ": " + e.message);
r.return(502, "Die Einrichtung ist fehlgeschlagen.\n");
}
}
export default { install, callback };
index.html: |
<!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 und Mac bekommen ein eigenes WLAN-App-Passwort; Windows und Linux melden 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="/renner-unifi-wlan.cmd">Einrichtungsdatei laden</a></p>
<ol>
<li>Die geladene Datei doppelklicken. Fragt Windows nach („Der Computer wurde durch Windows geschützt“): „Weitere Informationen“ → „Trotzdem ausführen“.</li>
<li>Beim Verbinden Benutzername <strong class="u">…</strong> und dein Passwort eingeben.</li>
</ol>
</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;
var currentUser = "";
function quote(s) { return "'" + s.replace(/'/g, "'\\''") + "'"; }
function render(user) {
currentUser = user;
var shown = user || "(dein Benutzername)";
document.getElementById("user").textContent = shown;
document.querySelectorAll(".u").forEach(function (el) { el.textContent = shown; });
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>
@@ -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
+163
View File
@@ -0,0 +1,163 @@
# wlan.renner.uno: setup page for the 802.1X WLAN "Renner UniFi" (Apple
# profile, Windows import file, Linux nmcli command, Android Passpoint profile
# with its own app password; config and files in wlan-setup-config.yaml).
# 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 page stores
# no user credentials.
apiVersion: apps/v1
kind: Deployment
metadata:
name: wlan-setup
namespace: authentik
labels:
app.kubernetes.io/name: wlan-setup
annotations:
configmap.reloader.stakater.com/reload: wlan-setup
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: 101
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b5047b9b76845eac124358373c6852377c52e4b05834a47647e955dd91cb8f1e
env:
# Client secret of the "WLAN setup" OAuth2 provider (passpoint.js).
- 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:
# subPath: replaced on rollout, which the reloader annotation
# triggers on every ConfigMap change.
- name: main
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
- name: config
mountPath: /etc/nginx/conf.d
readOnly: true
- name: njs
mountPath: /etc/nginx/njs
readOnly: true
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
# pid file and temp paths of the unprivileged image.
- name: tmp
mountPath: /tmp
volumes:
- name: main
configMap:
name: wlan-setup
items:
- key: nginx.conf
path: nginx.conf
- name: config
configMap:
name: wlan-setup
items:
- key: default.conf
path: default.conf
- name: njs
configMap:
name: wlan-setup
items:
- key: passpoint.js
path: passpoint.js
- name: html
configMap:
name: wlan-setup
items:
- key: index.html
path: index.html
- key: renner-unifi-wlan.cmd
path: renner-unifi-wlan.cmd
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
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
@@ -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
@@ -128,7 +128,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.11.0 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:v3.3.5 # {"$imagepolicy": "flux-system:immich-machine-learning"}
image: ghcr.io/immich-app/immich-machine-learning:v3.3.6 # {"$imagepolicy": "flux-system:immich-machine-learning"}
ports:
- name: http
containerPort: 3003
+1 -1
View File
@@ -87,7 +87,7 @@ spec:
mountPath: /ca
containers:
- name: immich-server
image: ghcr.io/immich-app/immich-server:v3.3.5 # {"$imagepolicy": "flux-system:immich-server"}
image: ghcr.io/immich-app/immich-server:v3.3.6 # {"$imagepolicy": "flux-system:immich-server"}
ports:
- name: http
containerPort: 3001
+2 -2
View File
@@ -134,7 +134,7 @@ spec:
# is a device-bricking class of risk. Bumps are a reviewed, manual
# act — see kubernetes/infrastructure/image-automation/README.md.
- name: terminus
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
ports:
- name: http
containerPort: 2345
@@ -197,7 +197,7 @@ spec:
capabilities:
drop: ["ALL"]
- name: sidekiq
image: ghcr.io/usetrmnl/terminus:0.79.0 # {"$imagepolicy": "flux-system:terminus"}
image: ghcr.io/usetrmnl/terminus:0.80.0 # {"$imagepolicy": "flux-system:terminus"}
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
envFrom:
- configMapRef:
@@ -95,19 +95,20 @@ spec:
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
# filter" in cilium_drop_count_total.
#
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
# carry an address, so it sees mgmt0's frames already untagged by the
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
# address-less, so its frames only meet the filter on the shared parent,
# still tagged. Adding 40 would change nothing and would touch the
# datapath carrying etcd and the API.
# Only the address-less macvlan parents need listing: VLAN 30 (ens19,
# v30iot) and VLAN 11 (radius0, freeradius-udm). Cilium attaches to
# devices that carry an address, so it sees mgmt0's frames already
# untagged by the 8021q layer — nothing for a VLAN filter to match.
# ens19 and radius0 are deliberately address-less, so their frames only
# meet the filter on the shared parent, still tagged. Adding 40 would
# change nothing and would touch the datapath carrying etcd and the API.
#
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
# "no buffer space available" (#592).
#
# Same three-copy problem as policyMapMax above. Takes an agent restart.
vlanBypass: [30]
vlanBypass: [30, 11]
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
# redirects the socket directly to the backend pod instead of sending
# traffic out to the VIP and back (which fails with L2 announcements).
@@ -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
+15
View File
@@ -8,6 +8,10 @@ machine:
# 10.2.30.0/24 route makes node replies bypass the UDM and breaks every
# DNATed flow.
net.ipv6.conf.ens19.disable_ipv6: "1"
# radius0 (VLAN 11) is LINK ONLY as well: the macvlan parent for the
# FreeRADIUS attachment. VLAN 11 announces RAs; the node must not
# autoconfigure from them.
net.ipv6.conf.radius0.disable_ipv6: "1"
# These two sit on the untagged fallback network, where nothing may run.
# Left enabled they autoconfigure a ULA and an IPv6 default route from its
# RAs, which ties with mgmt0's and costs the cluster IPv6 egress.
@@ -168,6 +172,17 @@ vlanID: 30
parent: uplink
up: true
---
# VLAN 11, UniFi "(11) RADIUS": link only, no addresses, no DHCP. Macvlan
# parent of the freeradius-udm attachment
# (kubernetes/apps/authentik/freeradius-network-attachment.yaml), which names
# it as `master`; renaming this link means editing that too.
apiVersion: v1alpha1
kind: VLANConfig
name: radius0
vlanID: 11
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only: the VXLAN overlay rides it once the
# kubelet node IP moves here, and nothing on it is routed off the switch.
#
+10
View File
@@ -7,6 +7,8 @@ machine:
# macvlan parent for the v30iot pod attachments; host addresses on that L2
# make node replies bypass the UDM and break every UDM-DNATed flow.
net.ipv6.conf.ens19.disable_ipv6: "1"
# radius0 (VLAN 11): link only — see k8s-1.yaml.
net.ipv6.conf.radius0.disable_ipv6: "1"
# Same for the physical ports on the untagged fallback network: left
# enabled they take an IPv6 default route from its RAs and cost the
# cluster IPv6 egress.
@@ -115,6 +117,14 @@ vlanID: 30
parent: uplink
up: true
---
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
apiVersion: v1alpha1
kind: VLANConfig
name: radius0
vlanID: 11
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
# and no DHCP — see k8s-1.yaml.
apiVersion: v1alpha1
+10
View File
@@ -4,6 +4,8 @@ machine:
sysctls:
# v30iot NIC carries no host addressing — see k8s-1.yaml.
net.ipv6.conf.ens19.disable_ipv6: "1"
# radius0 (VLAN 11): link only — see k8s-1.yaml.
net.ipv6.conf.radius0.disable_ipv6: "1"
# Same for the physical ports on the untagged fallback network — see
# k8s-1.yaml.
net.ipv6.conf.uplink.disable_ipv6: "1"
@@ -102,6 +104,14 @@ vlanID: 30
parent: uplink
up: true
---
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
apiVersion: v1alpha1
kind: VLANConfig
name: radius0
vlanID: 11
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
# and no DHCP — see k8s-1.yaml.
apiVersion: v1alpha1
+30 -1
View File
@@ -7,7 +7,8 @@
"flux",
"kubernetes",
"woodpecker",
"dockerfile"
"dockerfile",
"custom.regex"
],
"ignorePaths": [
"kubernetes/clusters/homelab/flux-system/**",
@@ -23,6 +24,20 @@
"/^kubernetes/.+\\.ya?ml$/"
]
},
"customManagers": [
{
"customType": "regex",
"description": "Container images of the Ansible roles: `edge_<name>_image: <image>:<tag>@<digest>` in ansible/roles/*/defaults/main.yml. No other manager reads these files.",
"managerFilePatterns": [
"/^ansible/roles/[^/]+/defaults/main\\.ya?ml$/"
],
"matchStrings": [
"(?:^|\\n)edge_[a-z0-9_]+_image:[ \\t]*[\"']?(?<depName>[^\\s:@\"']+):(?<currentValue>[^\\s@\"']+)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
],
"datasourceTemplate": "docker",
"versioningTemplate": "docker"
}
],
"packageRules": [
{
"description": "Flux image automation (kubernetes/infrastructure/image-automation) already bumps these images. Renovate must not open a second, competing update. Keep this list equal to the ImageRepository images there.",
@@ -134,6 +149,20 @@
"groupName": "CI non-major",
"groupSlug": "ci-minor-patch"
},
{
"description": "Edge-host images (ansible/): no pipeline deploys them, the play runs from a workstation after the merge. One PR of its own, so the merge and the play run go together.",
"matchFileNames": [
"ansible/**"
],
"matchUpdateTypes": [
"patch",
"minor",
"digest",
"pin"
],
"groupName": "edge hosts (ansible)",
"groupSlug": "edge-hosts-ansible"
},
{
"description": "Stateful apps and the CNI: one PR each, never in a group (REN-315). A git revert does not undo a DB migration or a CNI change, so each needs its own review and, where it can disrupt the cluster, a window.",
"matchUpdateTypes": [
+22 -4
View File
@@ -7,9 +7,11 @@ tests/radius/run.sh
Runs the production FreeRADIUS config from
`kubernetes/apps/authentik/freeradius-config.yaml` (rendered through the
projected volume in `freeradius.yaml`, same image, args and secret env names)
in Docker against a mock authentik outpost, and drives it with `eapol_test`
(EAP-TTLS, inner PAP) as a NAS in the test client ranges `10.2.50.0/24` and
`fd02:50::/64`. The v6 cases (`v6-*`) hit the IPv6 listeners and client `test6`.
in Docker against a mock authentik outpost and a mock UDM RADIUS, and drives
it with `eapol_test` (EAP-TTLS, inner PAP) as a NAS in the test client ranges
`10.2.50.0/24` and `fd02:50::/64`. The v6 cases (`v6-*`) hit the IPv6
listeners and client `test6`. Cases use outer identity `anonymous` unless
noted.
| Case | Expected |
|---|---|
@@ -17,12 +19,25 @@ in Docker against a mock authentik outpost, and drives it with `eapol_test`
| `vlan-avpair` (outpost sends only `Cisco-AV-Pair = "dot1x-vlan=50"`) | SUCCESS, Tunnel-* VLAN 50, no Cisco-AV-Pair |
| `novlan` (Accept without VLAN) | FAILURE (Access-Reject) |
| wrong password / unknown user (Reject without Message-Authenticator) | FAILURE (Access-Reject) within 1 s |
| Accounting-Request | answered locally, nothing sent to the outpost |
| Accounting-Request | answered locally, nothing sent to the outpost or the UDM |
| `anon-realm` (outer identity `Anonymous@Example.Org`) | local TTLS, as `vlan-tunnel` |
| `udm-accept`, `v6-udm-accept` (outer identity = account name) | both EAP rounds reach the mock UDM (second with its `State`); its Access-Accept reaches the NAS with Tunnel-* VLAN 200 and MS-MPPE keys unchanged |
| `udm-reject` | the mock UDM's Access-Reject reaches the NAS within 1 s |
| all `anonymous` cases | never reach the mock UDM |
| `recovery` | outpost stalls until marked dead, then returns: login succeeds within 20 s |
Pieces:
- `mock_outpost.py`: stand-in outpost on UDP 1812, reachable under the
outpost Service FQDN through a Docker network alias.
- `mock_udm.py`: stand-in UDM RADIUS at the `udm` home server address from
`proxy.conf` (`10.2.11.1`), on a second internal bridge `radtest-udm` (that
address's /24, no host address). FreeRADIUS gets a second interface there,
as `net1` in the pod: only the connected /24, no default route, and the
proxy socket picks it by route lookup. The macvlan/DHCP attachment itself
is not tested. Answers round 1 with Challenge + `State` +
EAP-TTLS Start (or Reject for `endpoint-reject`), round 2 with Accept +
EAP-Success; terminates no EAP.
- `eapol.Dockerfile`: `eapol_test` from Debian `eapoltest`.
- Docker network `radtest`: internal bridge on `10.2.50.0/24` and
`fd02:50::/64` with no host address (`inhibit_ipv4`,
@@ -31,3 +46,6 @@ Pieces:
Requires Docker, `openssl` and `yq` (the jq wrapper). Secrets, password and
certificates are generated dummies. `RADTEST_DEBUG=1` runs radiusd with `-xx`
and prints all logs. Containers, network and temp files are removed on exit.
`live.sh` logs in against the deployed FreeRADIUS instead; usage in
`docs/radius/dot1x.md` ("Live login check").
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Live EAP-TTLS/PAP login against the deployed FreeRADIUS from a test client
# (10.2.50.0/24 / fd02:50::/64, client "test"/"test6").
#
# Usage: tests/radius/live.sh <server-ip> <identity> <password-file> <secret-file> [count]
# password-file: the password on the first line, or `export NAME=value` lines
# (LIVE_PASSWORD_VAR selects the variable, default CLAUDE_BOT_TOKEN)
# secret-file: `bao kv get` output of apps/radius, or the bare
# DOT1X_TEST_SECRET on the first line
# count: number of logins (default 1)
# LIVE_SOURCE: optional local address for eapol_test -A (must be in a
# test client range)
# Prints one line per login: result, duration, VLAN. Secrets are never printed.
set -euo pipefail
server="$1"
identity="$2"
password_file="$3"
secret_file="$4"
count="${5:-1}"
password_var="${LIVE_PASSWORD_VAR:-CLAUDE_BOT_TOKEN}"
read_password() {
local line
line=$(grep -m1 -E "^export ${password_var}=" "$password_file" || true)
if [ -n "$line" ]; then
line="${line#*=}"
line="${line#\"}"; line="${line%\"}"
line="${line#\'}"; line="${line%\'}"
printf '%s' "$line"
else
head -n1 "$password_file"
fi
}
read_secret() {
local s
s=$(sed -n 's/.*DOT1X_TEST_SECRET:\([^] ]*\).*/\1/p' "$secret_file")
if [ -n "$s" ]; then printf '%s' "$s"; else head -n1 "$secret_file"; fi
}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
chmod 700 "$work"
password=$(read_password)
secret=$(read_secret)
[ -n "$password" ] || { echo "no password found in $password_file" >&2; exit 2; }
[ -n "$secret" ] || { echo "no DOT1X_TEST_SECRET found in $secret_file" >&2; exit 2; }
umask 077
cat > "$work/eapol.conf" <<EOF
network={
ssid="Renner Test"
key_mgmt=WPA-EAP
eap=TTLS
identity="${identity}"
anonymous_identity="anonymous"
ca_cert="/etc/ssl/certs/ca-certificates.crt"
domain_suffix_match="radius.renner.uno"
phase2="auth=PAP"
password="${password}"
}
EOF
for i in $(seq 1 "$count"); do
start=$(date +%s%N)
out=$(eapol_test -c "$work/eapol.conf" -a "$server" ${LIVE_SOURCE:+-A "$LIVE_SOURCE"} -s "$secret" -t 10 2>&1 || true)
end=$(date +%s%N)
ms=$(( (end - start) / 1000000 ))
result=$(tail -n1 <<<"$out")
case "$result" in SUCCESS|FAILURE) ;; *) result="NO-RESULT" ;; esac
# Attribute lines of the Access-Accept as the NAS received it (hex values).
attrs=$(awk '/RADIUS message: code=2 /{f=1; next} f && /^ /{print; next} f{f=0}' <<<"$out")
attr() { awk -v a="Attribute $1 " 'index($0, a){getline; sub(/^ *Value: /, ""); print; exit}' <<<"$attrs"; }
echo "login $i: $result ${ms}ms tunnel-type=[$(attr 64)] medium=[$(attr 65)] group-id=[$(attr 81)]"
done
+2 -2
View File
@@ -15,7 +15,6 @@ import socket
import struct
SECRET = os.environ["MOCK_SECRET"].encode()
PASSWORD = os.environ["MOCK_PASSWORD"].encode()
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT = 1, 2, 3
ATTR_USER_NAME, ATTR_USER_PASSWORD, ATTR_MESSAGE_AUTHENTICATOR = 1, 2, 80
@@ -85,6 +84,7 @@ def build_reply(code, ident, req_auth, attrs, with_ma):
def main():
password = os.environ["MOCK_PASSWORD"].encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 1812))
print("mock outpost listening on udp/1812", flush=True)
@@ -100,7 +100,7 @@ def main():
attrs = parse_attrs(packet[20:])
user = next((v for t, _, v in attrs if t == ATTR_USER_NAME), b"")
enc = next((v for t, _, v in attrs if t == ATTR_USER_PASSWORD), b"")
password_ok = decrypt_password(enc, req_auth) == PASSWORD
password_ok = decrypt_password(enc, req_auth) == password
ma = request_ma_ok(packet, attrs)
if password_ok and user in REPLIES:
out = build_reply(ACCESS_ACCEPT, ident, req_auth, REPLIES[user], True)
+120
View File
@@ -0,0 +1,120 @@
"""Stand-in for the UDM RADIUS (UDP 1812) that UniFi Endpoint logins go to.
Terminates nothing; logs what arrives and answers just enough EAP to prove
that FreeRADIUS proxies the whole conversation:
round 1 (EAP-Response/Identity, no State)
identity endpoint-reject Access-Reject + EAP-Failure
any other identity Access-Challenge + State + EAP-Request/TTLS-Start
round 2 (State from round 1)
Access-Accept + EAP-Success, Tunnel-* VLAN 200, MS-MPPE-Recv-Key 0x11 * 32,
MS-MPPE-Send-Key 0x22 * 32
no EAP-Message (status probe), unknown State Access-Reject
All replies carry Message-Authenticator and echo Proxy-State.
"""
import hashlib
import os
import socket
import struct
from mock_outpost import ATTR_USER_NAME, attr, build_reply, parse_attrs, request_ma_ok, vsa
SECRET = os.environ["MOCK_SECRET"].encode()
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT, ACCESS_CHALLENGE = 1, 2, 3, 11
ATTR_STATE, ATTR_PROXY_STATE, ATTR_EAP_MESSAGE = 24, 33, 79
EAP_REQUEST, EAP_RESPONSE, EAP_SUCCESS, EAP_FAILURE = 1, 2, 3, 4
EAP_IDENTITY, EAP_TTLS = 1, 21
STATE = b"udm-mock-state-1"
RECV_KEY = b"\x11" * 32
SEND_KEY = b"\x22" * 32
TUNNEL_VLAN_200 = (
attr(64, b"\x00\x00\x00\x0d") # Tunnel-Type = VLAN
+ attr(65, b"\x00\x00\x00\x06") # Tunnel-Medium-Type = IEEE-802
+ attr(81, b"200") # Tunnel-Private-Group-Id = "200"
)
def mppe_key(key, req_auth, salt):
"""RFC 2548 2.4.2 encryption of an MS-MPPE key."""
plain = bytes([len(key)]) + key
plain += b"\x00" * (-len(plain) % 16)
out, last = b"", req_auth + salt
for i in range(0, len(plain), 16):
b = hashlib.md5(SECRET + last).digest()
chunk = bytes(p ^ k for p, k in zip(plain[i : i + 16], b))
out += chunk
last = chunk
return salt + out
def eap(code, ident, payload=b""):
return struct.pack("!BBH", code, ident, 4 + len(payload)) + payload
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 1812))
print("mock udm listening on udp/1812", flush=True)
while True:
packet, peer = sock.recvfrom(4096)
if len(packet) < 20:
continue
code, ident, _length = struct.unpack("!BBH", packet[:4])
req_auth = packet[4:20]
if code != ACCESS_REQUEST:
print(f"peer={peer[0]} code={code} ignored", flush=True)
continue
attrs = parse_attrs(packet[20:])
user = next((v for t, _, v in attrs if t == ATTR_USER_NAME), b"").decode(errors="replace")
state = next((v for t, _, v in attrs if t == ATTR_STATE), None)
eap_msg = b"".join(v for t, _, v in attrs if t == ATTR_EAP_MESSAGE)
echo = b"".join(attr(ATTR_PROXY_STATE, v) for t, _, v in attrs if t == ATTR_PROXY_STATE)
ma = request_ma_ok(packet, attrs)
if len(eap_msg) < 5:
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo, True)
print(f"peer={peer[0]} probe user={user} request_ma={ma} -> reject", flush=True)
elif state is None:
eap_id = eap_msg[1]
eap_ok = eap_msg[0] == EAP_RESPONSE and eap_msg[4] == EAP_IDENTITY
identity = eap_msg[5:].decode(errors="replace")
if identity == "endpoint-reject":
body = attr(ATTR_EAP_MESSAGE, eap(EAP_FAILURE, eap_id))
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo + body, True)
result = "reject"
else:
body = attr(ATTR_STATE, STATE) + attr(
ATTR_EAP_MESSAGE, eap(EAP_REQUEST, (eap_id + 1) & 0xFF, bytes([EAP_TTLS, 0x20]))
)
reply = build_reply(ACCESS_CHALLENGE, ident, req_auth, echo + body, True)
result = "challenge"
print(
f"peer={peer[0]} round=1 user={user} eap_identity_response={eap_ok} "
f"eap_identity={identity} request_ma={ma} -> {result}",
flush=True,
)
elif state == STATE:
eap_id = eap_msg[1]
eap_type = eap_msg[4]
body = (
TUNNEL_VLAN_200
+ vsa(311, 17, mppe_key(RECV_KEY, req_auth, b"\x80\x01"))
+ vsa(311, 16, mppe_key(SEND_KEY, req_auth, b"\x80\x02"))
+ attr(ATTR_EAP_MESSAGE, eap(EAP_SUCCESS, eap_id))
)
reply = build_reply(ACCESS_ACCEPT, ident, req_auth, echo + body, True)
print(
f"peer={peer[0]} round=2 user={user} state_ok=True eap_type={eap_type} "
f"request_ma={ma} -> accept",
flush=True,
)
else:
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo, True)
print(f"peer={peer[0]} user={user} state={state.hex()} unknown -> reject", flush=True)
sock.sendto(reply, peer)
if __name__ == "__main__":
main()
+88 -18
View File
@@ -10,6 +10,7 @@ deploy="$app/freeradius.yaml"
config="$app/freeradius-config.yaml"
net=radtest
net_udm=radtest-udm
# test client ranges from clients.conf
subnet=10.2.50.0/24
subnet6=fd02:50::/64
@@ -32,7 +33,10 @@ cleanup() {
local ids=()
mapfile -t ids < <(docker ps -aq --filter name='^radtest-')
if ((${#ids[@]} > 0)); then docker rm -f "${ids[@]}" >/dev/null; fi
if [[ -n "$(docker network ls -q --filter name="^${net}\$")" ]]; then docker network rm "$net" >/dev/null; fi
local n
for n in "$net" "$net_udm"; do
if [[ -n "$(docker network ls -q --filter name="^${n}\$")" ]]; then docker network rm "$n" >/dev/null; fi
done
local vols=()
mapfile -t vols < <(docker volume ls -q --filter name='^radtest-')
if ((${#vols[@]} > 0)); then docker volume rm "${vols[@]}" >/dev/null; fi
@@ -147,14 +151,26 @@ start() {
docker network create --internal --subnet "$subnet" --ipv6 --subnet "$subnet6" \
-o com.docker.network.bridge.inhibit_ipv4=true \
-o com.docker.network.bridge.gateway_mode_ipv6=isolated "$net" >/dev/null
local bridge
bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$net")"
if [[ -n "$(ip -4 -o addr show dev "$bridge")" ]] \
|| [[ -n "$(ip -6 -o addr show dev "$bridge" scope global)" ]] \
|| [[ -n "$(ip -6 route show dev "$bridge" "$subnet6")" ]]; then
echo "bridge $bridge got a host address or route in a test range; aborting" >&2
exit 1
fi
# UDM home server address from proxy.conf; its /24 is a second internal
# bridge (gateway address moved off the UDM's).
ip_udm="$(awk '$1 == "home_server" && $2 == "udm" {f=1} f && $1 == "ipaddr" {print $3; exit}' "$work/raddb/proxy.conf")"
docker network create --internal --subnet "${ip_udm%.*}.0/24" --gateway "${ip_udm%.*}.254" \
-o com.docker.network.bridge.inhibit_ipv4=true "$net_udm" >/dev/null
local bridge n
for n in "$net" "$net_udm"; do
bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$n")"
if [[ -n "$(ip -4 -o addr show dev "$bridge")" ]] \
|| [[ -n "$(ip -6 -o addr show dev "$bridge" scope global)" ]] \
|| [[ -n "$(ip -6 route show dev "$bridge" "$subnet6")" ]]; then
echo "bridge $bridge got a host address or route in a test range; aborting" >&2
exit 1
fi
done
docker run -d --name radtest-udm --network "$net_udm" --ip "$ip_udm" \
-e MOCK_SECRET=dummy-udm_radius_secret \
-v "$here/mock_outpost.py:/mock/mock_outpost.py:ro" -v "$here/mock_udm.py:/mock/mock_udm.py:ro" \
"$mock_image" python3 -u /mock/mock_udm.py >/dev/null
docker run -d --name radtest-mock --network "$net" --ip "$ip_mock" \
--network-alias "$outpost_fqdn" \
@@ -167,14 +183,14 @@ start() {
# Same restrictions as the pod.
mount_opts freeradius
local run_opts=(--network "$net" "${pod_opts[@]}" "${secret_env[@]}" "${mounts[@]}")
local run_opts=("${pod_opts[@]}" "${secret_env[@]}" "${mounts[@]}")
local confdir="" i
for i in "${!args[@]}"; do
if [[ "${args[$i]}" == -d ]]; then confdir="${args[$((i + 1))]}"; fi
done
echo "--- radiusd -XC (config check)"
if docker run --rm "${run_opts[@]}" "$image" radiusd -XC -d "$confdir" >"$work/configcheck.log" 2>&1 \
if docker run --rm --network "$net" "${run_opts[@]}" "$image" radiusd -XC -d "$confdir" >"$work/configcheck.log" 2>&1 \
&& grep -q "Configuration appears to be OK" "$work/configcheck.log"; then
pass "radiusd -XC: Configuration appears to be OK"
else
@@ -183,7 +199,13 @@ start() {
exit 1
fi
docker run -d --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null
# Interface names as in the pod: eth0 the NAS side (the listeners bind to
# it), net1 the UDM side. Docker attaches networks in no fixed order.
docker create --name radtest-freeradius "${run_opts[@]}" \
--network "name=$net,ip=$ip_radius,ip6=$ip6_radius,driver-opt=com.docker.network.endpoint.ifname=eth0" \
"$image" "${args[@]}" >/dev/null
docker network connect --driver-opt com.docker.network.endpoint.ifname=net1 "$net_udm" radtest-freeradius
docker start radtest-freeradius >/dev/null
sleep 2
if [[ "$(docker inspect -f '{{.State.Running}}' radtest-freeradius)" != true ]]; then
docker logs radtest-freeradius
@@ -203,13 +225,13 @@ start() {
fi
}
eapol_conf() { # case user password
eapol_conf() { # case user password [outer-identity]
cat >"$work/eapol/$1.conf" <<EOF
network={
key_mgmt=WPA-EAP
eap=TTLS
identity="$2"
anonymous_identity="anonymous"
anonymous_identity="${4:-anonymous}"
password="$3"
phase2="auth=PAP"
ca_cert="/eapol/ca.pem"
@@ -233,9 +255,9 @@ attr_value() { # case attr-number
accept_attrs "$1" | awk -v a="Attribute $2 " 'index($0, a){getline; sub(/^ *Value: /, ""); print; exit}'
}
check_accept() { # case user server
check_accept() { # case user server [outer-identity]
local c="$1"
eapol_conf "$c" "$2" "$password"
eapol_conf "$c" "$2" "$password" "${4:-}"
eapol "$c" 5 "$3"
if [[ "$(tail -n 1 "$work/$c.out")" == SUCCESS ]]; then pass "$c: SUCCESS"; else fail "$c: SUCCESS"; fi
[[ "$(attr_value "$c" 81)" =~ ^(00)?3530$ ]] && pass "$c: Tunnel-Private-Group-Id 50" \
@@ -252,9 +274,9 @@ check_accept() { # case user server
fi
}
check_reject() { # case user password timeout server
check_reject() { # case user password timeout server [outer-identity]
local c="$1"
eapol_conf "$c" "$2" "$3"
eapol_conf "$c" "$2" "$3" "${6:-}"
eapol "$c" "$4" "$5"
if [[ "$(tail -n 1 "$work/$c.out")" == FAILURE ]] && grep -q "RADIUS message: code=3 (Access-Reject)" "$work/$c.out" \
&& ! grep -q "EAPOL test timed out" "$work/$c.out"; then
@@ -264,6 +286,40 @@ check_reject() { # case user password timeout server
fi
}
udm_log() { docker logs radtest-udm 2>&1; }
# Non-anonymous outer identity: the whole EAP conversation goes to the mock
# UDM, its Access-Accept reaches the NAS unchanged.
check_udm_accept() { # case user server
local c="$1" u="$2"
eapol_conf "$c" "$u" "$password" "$u"
eapol "$c" 3 "$3"
udm_log | grep -q "round=1 user=$u eap_identity_response=True eap_identity=$u request_ma=True -> challenge" \
&& pass "$c: EAP-Response/Identity forwarded to the UDM" || fail "$c: EAP-Response/Identity forwarded to the UDM"
udm_log | grep -q "round=2 user=$u state_ok=True eap_type=21 request_ma=True -> accept" \
&& pass "$c: second round forwarded with the UDM's State" || fail "$c: second round forwarded with the UDM's State"
grep -q "RADIUS message: code=2 (Access-Accept)" "$work/$c.out" \
&& pass "$c: UDM Access-Accept reaches the NAS" || fail "$c: UDM Access-Accept reaches the NAS"
[[ "$(attr_value "$c" 81)" =~ ^(00)?323030$ ]] && pass "$c: Tunnel-Private-Group-Id 200" \
|| fail "$c: Tunnel-Private-Group-Id 200 (got '$(attr_value "$c" 81)')"
[[ "$(attr_value "$c" 64)" == 0000000d ]] && pass "$c: Tunnel-Type VLAN" \
|| fail "$c: Tunnel-Type VLAN (got '$(attr_value "$c" 64)')"
[[ "$(attr_value "$c" 65)" == 00000006 ]] && pass "$c: Tunnel-Medium-Type IEEE-802" \
|| fail "$c: Tunnel-Medium-Type IEEE-802 (got '$(attr_value "$c" 65)')"
local k
for k in "Recv-Key (crypt)|11" "Send-Key (sign)|22"; do
grep -qF "MS-MPPE-${k%|*} - hexdump(len=32): $(printf "${k#*|} %.0s" {1..31})${k#*|}" "$work/$c.out" \
&& pass "$c: MS-MPPE-${k%% *} unchanged" || fail "$c: MS-MPPE-${k%% *} unchanged"
done
}
check_udm_reject() { # case user server
local c="$1" u="$2"
check_reject "$c" "$u" "$password" 1 "$3" "$u"
udm_log | grep -q "round=1 user=$u eap_identity_response=True eap_identity=$u request_ma=True -> reject" \
&& pass "$c: decided by the UDM" || fail "$c: decided by the UDM"
}
check_accounting() { # case server
printf 'Acct-Status-Type = Start\nUser-Name = "acct-test"\nAcct-Session-Id = "%s"\n' \
"$1" >"$work/eapol/$1.txt"
@@ -338,6 +394,13 @@ main() {
check_reject unknown-user bad "$password" 1 "$ip_radius"
check_accounting acct "$ip_radius"
# Outer identity routing: anonymous[@realm] (any case) -> local TTLS,
# anything else -> UDM.
check_accept anon-realm vlan-tunnel "$ip_radius" "Anonymous@Example.Org"
check_udm_accept udm-accept endpoint-accept "$ip_radius"
check_udm_reject udm-reject endpoint-reject "$ip_radius"
check_udm_accept v6-udm-accept v6-endpoint-accept "$ip6_radius"
# IPv6 listeners and clients
check_accept v6-vlan-avpair vlan-avpair "$ip6_radius"
check_reject v6-novlan novlan "$password" 5 "$ip6_radius"
@@ -353,9 +416,16 @@ main() {
pass "no accounting forwarded to the outpost"
fi
if udm_log | grep -Eiq "eap_identity=anonymous|user=(vlan-|novlan|bad|anonymous)|code=4"; then
fail "anonymous logins and accounting never reach the UDM"
else
pass "anonymous logins and accounting never reach the UDM"
fi
if ((fails > 0)) || [[ "${RADTEST_DEBUG:-0}" == 1 ]]; then
echo "--- freeradius log"; docker logs radtest-freeradius 2>&1
echo "--- mock outpost log"; docker logs radtest-mock 2>&1
echo "--- mock udm log"; udm_log
for o in "$work"/*.out; do echo "--- $(basename "$o")"; cat "$o"; done
fi
echo
+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...)
}
+10
View File
@@ -0,0 +1,10 @@
module gitea.renner.uno/konairius/homelab/wlan-setup
go 1.27
require (
github.com/beevik/etree v1.7.0
github.com/russellhaering/goxmldsig v1.6.1
)
require github.com/jonboulle/clockwork v0.5.0 // indirect
+14
View File
@@ -0,0 +1,14 @@
github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA=
github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+ohfNJjwyYU=
github.com/russellhaering/goxmldsig v1.6.1/go.mod h1:haZkRcLs9W/Xp989fIjP3BrTdbFQveRF0QNZSYoH09w=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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)
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"encoding/base64"
"strings"
)
// isrgRootX1 is the trust anchor of the radius-tls chain (DER, base64).
const isrgRootX1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc="
// carrierID identifies the Windows provisioning document; a reinstall replaces the profile.
const carrierID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}"
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
func node(name, inner string) string {
return "<Node><NodeName>" + name + "</NodeName>" + inner + "</Node>"
}
func leaf(name, value string) string { return node(name, "<Value>"+value+"</Value>") }
var xmlEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
func xmlText(s string) string { return xmlEscaper.Replace(s) }
// androidProfile is the application/x-wifi-config body: base64 of a MIME multipart with the
// Passpoint R1 PPS MO and the trust anchor.
func androidProfile(user, password string) string {
pps := `<MgmtTree xmlns="syncml:dmddf1.2"><VerDTD>1.2</VerDTD>` +
node("PerProviderSubscription",
"<RTProperties><Type><DDFName>urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0</DDFName></Type></RTProperties>"+
node("i001",
node("HomeSP", leaf("FriendlyName", "Renner")+leaf("FQDN", "renner.uno"))+
node("Credential",
leaf("Realm", "renner.uno")+
node("UsernamePassword",
leaf("Username", user)+
leaf("Password", b64(password))+
node("EAPMethod", leaf("EAPType", "21")+leaf("InnerMethod", "PAP"))))+
node("Extension", node("Android",
node("AAAServerTrustedNames", leaf("FQDN", "radius.renner.uno")))))) +
"</MgmtTree>"
mime := "Content-Type: multipart/mixed; boundary={boundary}\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-passpoint-profile\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
b64(pps) + "\r\n" +
"--{boundary}\r\n" +
"Content-Type: application/x-x509-ca-cert\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
isrgRootX1 + "\r\n" +
"--{boundary}--\r\n"
return b64(mime)
}
// appleProfile is the .mobileconfig: com.apple.wifi.managed with Passpoint matching. Fixed
// identifiers and UUIDs, so a reinstall replaces the previous profile.
func appleProfile(user, password string) string {
return `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n" +
`<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-unifi.network</string>` +
`<key>PayloadUUID</key><string>824959bf-c55d-4ed2-b569-c33f3759c2b6</string>` +
`<key>PayloadDisplayName</key><string>Renner UniFi</string>` +
`<key>PayloadOrganization</key><string>Renner</string>` +
`<key>IsHotspot</key><true/>` +
`<key>DomainName</key><string>renner.uno</string>` +
`<key>NAIRealmNames</key><array><string>renner.uno</string></array>` +
`<key>DisplayedOperatorName</key><string>Renner</string>` +
`<key>ServiceProviderRoamingEnabled</key><false/>` +
`<key>AutoJoin</key><true/>` +
`<key>EncryptionType</key><string>Any</string>` +
`<key>EAPClientConfiguration</key><dict>` +
`<key>UserName</key><string>` + xmlText(user) + `</string>` +
`<key>UserPassword</key><string>` + xmlText(password) + `</string>` +
`<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-unifi</string>` +
`<key>PayloadUUID</key><string>7175a2a3-ddd3-42ea-b4ad-b7e5501892f3</string>` +
`<key>PayloadDisplayName</key><string>WLAN Renner UniFi</string>` +
`<key>PayloadOrganization</key><string>Renner</string>` +
`</dict></plist>` + "\n"
}
// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile,
// WPA3 Enterprise, EAP-TTLS with inner PAP, and the user's credentials.
func windowsProfile(user, password string) string {
const common = ` xmlns="http://www.microsoft.com/provisioning/EapCommon"`
return `<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">` +
`<Global><CarrierId>` + carrierID + `</CarrierId><SubscriberId>renner-unifi</SubscriberId></Global>` +
`<WLANProfiles><WLANProfile xmlns="http://www.microsoft.com/networking/CarrierControl/WLAN/v1">` +
`<name>Renner UniFi</name>` +
`<SSIDConfig><SSID><name>Renner UniFi</name></SSID></SSIDConfig>` +
`<Hotspot2><DomainName>renner.uno</DomainName><NAIRealm><name>renner.uno</name></NAIRealm></Hotspot2>` +
`<MSM><security>` +
`<authEncryption><authentication>WPA3ENT</authentication><encryption>AES</encryption><useOneX>true</useOneX></authEncryption>` +
`<OneX xmlns="http://www.microsoft.com/networking/OneX/v1"><authMode>user</authMode><EAPConfig>` +
`<EapHostConfig xmlns="http://www.microsoft.com/provisioning/EapHostConfig">` +
`<EapMethod><Type` + common + `>21</Type><VendorId` + common + `>0</VendorId>` +
`<VendorType` + common + `>0</VendorType><AuthorId` + common + `>311</AuthorId></EapMethod>` +
`<Config><EapTtls xmlns="http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1">` +
`<ServerValidation><ServerNames>radius.renner.uno</ServerNames>` +
`<TrustedRootCAHash>ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8</TrustedRootCAHash>` +
`<DisablePrompt>true</DisablePrompt></ServerValidation>` +
`<Phase2Authentication><PAPAuthentication></PAPAuthentication></Phase2Authentication>` +
`<Phase1Identity><IdentityPrivacy>true</IdentityPrivacy><AnonymousIdentity>anonymous</AnonymousIdentity></Phase1Identity>` +
`</EapTtls></Config></EapHostConfig></EAPConfig></OneX>` +
`<EapHostUserCredentials xmlns="http://www.microsoft.com/provisioning/EapHostUserCredentials" xmlns:eapCommon="http://www.microsoft.com/provisioning/EapCommon">` +
`<EapMethod><eapCommon:Type>21</eapCommon:Type><eapCommon:AuthorId>311</eapCommon:AuthorId></EapMethod>` +
`<Credentials><EapTtls xmlns="http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1">` +
`<Username>` + xmlText(user) + `</Username><Password>` + xmlText(password) + `</Password>` +
`</EapTtls></Credentials></EapHostUserCredentials>` +
`</security></MSM></WLANProfile></WLANProfiles></CarrierProvisioning>`
}
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"crypto/rand"
"crypto/subtle"
_ "embed"
"encoding/base64"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
//go:embed index.html
var indexHTML []byte
const (
clientID = "wlan-setup"
stateCookie = "wlan_oauth_state"
lifetimeDays = 29
restart = "Bitte die Einrichtung neu starten.\n"
failed = "Die Einrichtung ist fehlgeschlagen.\n"
)
// userPattern admits only plain usernames into a generated file.
var userPattern = regexp.MustCompile(`^[A-Za-z0-9._@+-]{1,64}$`)
var identifierUnsafe = regexp.MustCompile(`[^a-z0-9-]`)
var platformLabels = map[string]string{"android": "Android", "apple": "Apple", "windows": "Windows"}
// Server serves the setup page and the per-platform flows.
type Server struct {
cfg Config
authentik *Authentik
profiles *ProfileStore
now func() time.Time
log *slog.Logger
}
func newServer(cfg Config, log *slog.Logger) *Server {
return &Server{
cfg: cfg,
authentik: &Authentik{base: cfg.AuthentikURL, secret: cfg.ClientSecret, http: &http.Client{Timeout: 10 * time.Second}},
profiles: newProfileStore(time.Now),
now: time.Now,
log: log,
}
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexHTML)
})
mux.HandleFunc("GET /whoami", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if u := r.Header.Get("X-authentik-username"); userPattern.MatchString(u) {
_, _ = io.WriteString(w, u)
}
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = io.WriteString(w, "ok\n")
})
for name := range platformLabels {
mux.HandleFunc("GET /"+name+"/install", func(w http.ResponseWriter, r *http.Request) { s.install(w, r, name) })
mux.HandleFunc("GET /"+name+"/callback", func(w http.ResponseWriter, r *http.Request) { s.callback(w, r, name) })
}
mux.HandleFunc("GET /windows/profile/{token}", s.windowsProfile)
mux.HandleFunc("/", http.NotFound)
return noStore(mux)
}
func noStore(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
h.ServeHTTP(w, r)
})
}
func random(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) redirectURI(name string) string { return s.cfg.PublicURL + "/" + name + "/callback" }
func setStateCookie(w http.ResponseWriter, name, value string, maxAge int) {
w.Header().Set("Set-Cookie", stateCookie+"="+value+"; Path=/"+name+"/; Max-Age="+strconv.Itoa(maxAge)+
"; HttpOnly; Secure; SameSite=Lax")
}
func redirect(w http.ResponseWriter, location string) {
w.Header().Set("Location", location)
w.WriteHeader(http.StatusFound)
}
func text(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}
// install starts an authorization-code sign-in at authentik; the user is already signed in there.
func (s *Server) install(w http.ResponseWriter, _ *http.Request, name string) {
state := random(24)
setStateCookie(w, name, state, 300)
redirect(w, s.cfg.AuthorizeURL+"?"+form(
"response_type", "code",
"client_id", clientID,
"redirect_uri", s.redirectURI(name),
"scope", "openid profile goauthentik.io/api",
"state", state,
))
}
func stateFrom(r *http.Request) string {
c, err := r.Cookie(stateCookie)
if err != nil {
return ""
}
return c.Value
}
// callback creates a new app password as the user and answers with the platform's profile.
func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) {
q := r.URL.Query()
state, code := stateFrom(r), q.Get("code")
if state == "" || code == "" || subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 {
text(w, http.StatusBadRequest, restart)
return
}
fail := func(reason string, args ...any) {
s.log.Error("wlan-setup "+name+": "+reason, args...)
text(w, http.StatusBadGateway, failed)
}
// Windows signs the profile: without key and chain there is nothing to hand out, so no app
// password is created.
var signer *Signer
if name == "windows" {
var err error
if signer, err = loadSigner(s.cfg.SigningKey, s.cfg.SigningCert); err != nil {
fail("cannot load the signing key", "err", err)
return
}
}
ctx := r.Context()
access, err := s.authentik.Exchange(ctx, code, s.redirectURI(name))
if err != nil {
fail("token exchange", "err", err)
return
}
user, err := s.authentik.Username(ctx, access)
if err != nil {
fail("user lookup", "err", err)
return
}
if !userPattern.MatchString(user) || user != r.Header.Get("X-authentik-username") {
s.log.Warn("wlan-setup "+name+": the token's user is not the signed-in user", "user", user)
w.WriteHeader(http.StatusForbidden)
return
}
now := s.now().UTC()
suffix := strings.Map(func(c rune) rune {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
return c
}
return 'x'
}, random(24)[:6])
identifier := "wlan-" + name + "-" + identifierUnsafe.ReplaceAllString(strings.ToLower(user), "-") + "-" +
now.Format("20060102T150405") + "-" + suffix
key, err := s.authentik.AppPassword(ctx, access, tokenRequest{
Identifier: identifier,
Intent: "app_password",
Description: "WLAN " + platformLabels[name] + ", " + now.Format("2006-01-02") + " (wlan.renner.uno)",
Expiring: true,
Expires: now.Add(lifetimeDays * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z07:00"),
})
if err != nil {
fail("app password", "err", err)
return
}
switch name {
case "android":
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-wifi-config")
w.Header().Set("Content-Transfer-Encoding", "base64")
_, _ = io.WriteString(w, androidProfile(user, key))
case "apple":
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
w.Header().Set("Content-Disposition", `attachment; filename="renner-unifi.mobileconfig"`)
_, _ = io.WriteString(w, appleProfile(user, key))
case "windows":
body, err := signer.Sign(windowsProfile(user, key))
if err != nil {
fail("signing the profile", "err", err)
return
}
token := random(32)
if !s.profiles.Put(token, body) {
fail("too many profiles waiting to be fetched")
return
}
setStateCookie(w, name, "", 0)
redirect(w, "ms-settings:wifi-provisioning?uri="+s.cfg.PublicURL+"/windows/profile/"+token)
}
}
// windowsProfile hands out a signed profile once. Windows Settings fetches it without the
// authentik session: the unguessable single-use token is the only gate.
func (s *Server) windowsProfile(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if r.Method == http.MethodHead {
if !s.profiles.Has(token) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/xml")
return
}
body, ok := s.profiles.Take(token)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write(body)
}
+648
View File
@@ -0,0 +1,648 @@
package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"encoding/xml"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/beevik/etree"
dsig "github.com/russellhaering/goxmldsig"
)
const (
testSecret = "client-secret"
testAccess = "access-token-123"
testKey = "app-password-<&>-456"
testCode = "code-789"
)
// fakeAuthentik answers the four calls of a callback and records what it received.
type fakeAuthentik struct {
t *testing.T
username string
fail string // path that answers 500
mu sync.Mutex
requests []string
created tokenRequest
}
func (f *fakeAuthentik) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
f.mu.Unlock()
t := f.t
if r.Host != "auth.renner.uno" {
t.Errorf("%s: Host %q", r.URL.Path, r.Host)
}
if r.Header.Get("Cookie") != "" {
t.Errorf("%s: a cookie reached authentik", r.URL.Path)
}
if r.URL.Path == f.fail {
http.Error(w, `{"detail":"broken"}`, http.StatusInternalServerError)
return
}
bearer := func() bool {
if got := r.Header.Get("Authorization"); got != "Bearer "+testAccess {
t.Errorf("%s: Authorization %q", r.URL.Path, got)
return false
}
return true
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/application/o/token/":
user, pass, ok := r.BasicAuth()
if !ok || user != "wlan-setup" || pass != testSecret {
t.Errorf("token: Basic auth %q %v", user, ok)
}
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("token: Content-Type %q", ct)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != testCode {
t.Errorf("token: form %v", r.PostForm)
}
platform := strings.Split(r.PostForm.Get("redirect_uri"), "/")[3]
if want := "https://wlan.renner.uno/" + platform + "/callback"; r.PostForm.Get("redirect_uri") != want {
t.Errorf("token: redirect_uri %q", r.PostForm.Get("redirect_uri"))
}
_, _ = io.WriteString(w, `{"access_token":"`+testAccess+`","token_type":"Bearer"}`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/core/users/me/":
if bearer() {
_ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"username": f.username}})
}
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/core/tokens/":
if !bearer() {
return
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("tokens: Content-Type %q", ct)
}
var tr tokenRequest
if err := json.NewDecoder(r.Body).Decode(&tr); err != nil {
t.Fatal(err)
}
f.mu.Lock()
f.created = tr
f.mu.Unlock()
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"identifier":"`+tr.Identifier+`"}`)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v3/core/tokens/") &&
strings.HasSuffix(r.URL.Path, "/view_key/"):
f.mu.Lock()
want := "/api/v3/core/tokens/" + f.created.Identifier + "/view_key/"
f.mu.Unlock()
if r.URL.Path != want {
t.Errorf("view_key: path %q, want %q", r.URL.Path, want)
}
if bearer() {
_ = json.NewEncoder(w).Encode(map[string]string{"key": testKey})
}
default:
t.Errorf("unexpected call %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}
type testCert struct {
cert *x509.Certificate
keyFile string
certFile string
}
// writeSigningFiles writes a fresh RSA key (PKCS#8) and a chain of a self-signed leaf plus a
// second certificate, as cert-manager would mount them.
func writeSigningFiles(t *testing.T) testCert {
t.Helper()
dir := t.TempDir()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
mk := func(cn string, k *rsa.PrivateKey) []byte {
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &k.PublicKey, k)
if err != nil {
t.Fatal(err)
}
return der
}
leaf := mk("wlan.renner.uno", key)
other, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
intermediate := mk("intermediate", other)
pkcs8, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
tc := testCert{keyFile: filepath.Join(dir, "tls.key"), certFile: filepath.Join(dir, "tls.crt")}
if err := os.WriteFile(tc.keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil {
t.Fatal(err)
}
chain := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf}),
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intermediate})...)
if err := os.WriteFile(tc.certFile, chain, 0o600); err != nil {
t.Fatal(err)
}
tc.cert, _ = x509.ParseCertificate(leaf)
return tc
}
type harness struct {
srv *Server
handler http.Handler
fake *fakeAuthentik
signing testCert
clock *fakeClock
}
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func (c *fakeClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now }
func (c *fakeClock) Add(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
func newHarness(t *testing.T) *harness {
t.Helper()
fake := &fakeAuthentik{t: t, username: "Alice.Smith"}
upstream := httptest.NewServer(fake)
t.Cleanup(upstream.Close)
signing := writeSigningFiles(t)
cfg, err := loadConfig(env(map[string]string{
"WLAN_SETUP_CLIENT_SECRET": testSecret,
"AUTHENTIK_INTERNAL_URL": upstream.URL,
"SIGNING_KEY": signing.keyFile,
"SIGNING_CERT": signing.certFile,
}))
if err != nil {
t.Fatal(err)
}
srv := newServer(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
clock := &fakeClock{now: time.Date(2026, 11, 5, 9, 8, 7, 0, time.UTC)}
srv.now = clock.Now
srv.profiles.now = clock.Now
return &harness{srv: srv, handler: srv.routes(), fake: fake, signing: signing, clock: clock}
}
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
func (h *harness) do(req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, req)
if rec.Header().Get("Cache-Control") != "no-store" {
panic("no Cache-Control: no-store on " + req.URL.Path)
}
return rec
}
func (h *harness) callback(platform, cookieState, queryState, user string) *httptest.ResponseRecorder {
q := url.Values{"state": {queryState}, "code": {testCode}}
req := httptest.NewRequest(http.MethodGet, "/"+platform+"/callback?"+q.Encode(), nil)
if cookieState != "" {
req.Header.Set("Cookie", "other=1; wlan_oauth_state="+cookieState)
}
req.Header.Set("X-authentik-username", user)
return h.do(req)
}
func TestInstall(t *testing.T) {
h := newHarness(t)
for _, platform := range []string{"android", "apple", "windows"} {
rec := h.do(httptest.NewRequest(http.MethodGet, "/"+platform+"/install", nil))
if rec.Code != http.StatusFound {
t.Fatalf("%s: status %d", platform, rec.Code)
}
cookie := rec.Header().Get("Set-Cookie")
m := regexp.MustCompile(`^wlan_oauth_state=([A-Za-z0-9_-]{32}); Path=/` + platform +
`/; Max-Age=300; HttpOnly; Secure; SameSite=Lax$`).FindStringSubmatch(cookie)
if m == nil {
t.Fatalf("%s: cookie %q", platform, cookie)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://auth.renner.uno/application/o/authorize/?response_type=code&") {
t.Errorf("%s: location %q", platform, loc)
}
if !strings.Contains(loc, "scope=openid%20profile%20goauthentik.io%2Fapi") {
t.Errorf("%s: scope not encoded like encodeURIComponent: %q", platform, loc)
}
u, err := url.Parse(loc)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if q.Get("client_id") != "wlan-setup" || q.Get("redirect_uri") != "https://wlan.renner.uno/"+platform+"/callback" ||
q.Get("scope") != "openid profile goauthentik.io/api" || q.Get("state") != m[1] || q.Get("response_type") != "code" {
t.Errorf("%s: query %v", platform, q)
}
}
}
func TestCallbackStateMismatch(t *testing.T) {
h := newHarness(t)
for _, c := range []struct{ cookie, query string }{{"abc", "abd"}, {"", "abc"}, {"abc", ""}} {
rec := h.callback("android", c.cookie, c.query, "Alice.Smith")
if rec.Code != http.StatusBadRequest || rec.Body.String() != "Bitte die Einrichtung neu starten.\n" {
t.Errorf("%+v: %d %q", c, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/apple/callback?state=abc", nil)
req.Header.Set("Cookie", "wlan_oauth_state=abc")
if rec := h.do(req); rec.Code != http.StatusBadRequest {
t.Errorf("no code: %d", rec.Code)
}
if len(h.fake.requests) != 0 {
t.Errorf("authentik was called: %v", h.fake.requests)
}
}
func TestCallbackUserMismatch(t *testing.T) {
h := newHarness(t)
if rec := h.callback("android", "s", "s", "bob"); rec.Code != http.StatusForbidden {
t.Errorf("other user: %d", rec.Code)
}
h.fake.username = "bad user"
if rec := h.callback("apple", "s", "s", "bad user"); rec.Code != http.StatusForbidden {
t.Errorf("unsafe username: %d", rec.Code)
}
for _, r := range h.fake.requests {
if strings.HasPrefix(r, "POST /api/v3/core/tokens/") {
t.Errorf("an app password was created for a mismatched user")
}
}
}
func TestCallbackUpstreamError(t *testing.T) {
for _, path := range []string{"/application/o/token/", "/api/v3/core/users/me/", "/api/v3/core/tokens/"} {
h := newHarness(t)
h.fake.fail = path
rec := h.callback("android", "s", "s", "Alice.Smith")
if rec.Code != http.StatusBadGateway || rec.Body.String() != "Die Einrichtung ist fehlgeschlagen.\n" {
t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=0") {
t.Errorf("%s: the state cookie was cleared on failure", path)
}
}
}
func TestCallbackWindowsWithoutSigningKey(t *testing.T) {
h := newHarness(t)
h.srv.cfg.SigningKey = filepath.Join(t.TempDir(), "missing.key")
if rec := h.callback("windows", "s", "s", "Alice.Smith"); rec.Code != http.StatusBadGateway {
t.Errorf("status %d", rec.Code)
}
if len(h.fake.requests) != 0 {
t.Errorf("authentik was called without a signing key: %v", h.fake.requests)
}
}
func checkCreated(t *testing.T, h *harness, platform, label string) {
t.Helper()
got := h.fake.created
if !regexp.MustCompile(`^wlan-` + platform + `-alice-smith-20261105T090807-[A-Za-z0-9]{6}$`).MatchString(got.Identifier) {
t.Errorf("identifier %q", got.Identifier)
}
if got.Intent != "app_password" || !got.Expiring || got.Expires != "2026-12-04T09:08:07.000Z" ||
got.Description != "WLAN "+label+", 2026-11-05 (wlan.renner.uno)" {
t.Errorf("token request %+v", got)
}
want := []string{"POST /application/o/token/", "GET /api/v3/core/users/me/", "POST /api/v3/core/tokens/",
"GET /api/v3/core/tokens/" + got.Identifier + "/view_key/"}
if strings.Join(h.fake.requests, "|") != strings.Join(want, "|") {
t.Errorf("calls %v", h.fake.requests)
}
}
func checkCleared(t *testing.T, rec *httptest.ResponseRecorder, platform string) {
t.Helper()
if got := rec.Header().Get("Set-Cookie"); got != "wlan_oauth_state=; Path=/"+platform+"/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" {
t.Errorf("cookie %q", got)
}
}
func TestCallbackAndroid(t *testing.T) {
h := newHarness(t)
rec := h.callback("android", "s", "s", "Alice.Smith")
if rec.Code != http.StatusOK {
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
}
checkCreated(t, h, "android", "Android")
checkCleared(t, rec, "android")
if rec.Header().Get("Content-Type") != "application/x-wifi-config" ||
rec.Header().Get("Content-Transfer-Encoding") != "base64" || rec.Header().Get("Content-Disposition") != "" {
t.Errorf("headers %v", rec.Header())
}
mime, err := base64.StdEncoding.DecodeString(rec.Body.String())
if err != nil {
t.Fatal(err)
}
parts := strings.Split(string(mime), "--{boundary}\r\n")
var pps string
for _, p := range parts {
if body, ok := strings.CutPrefix(p, "Content-Type: application/x-passpoint-profile\r\nContent-Transfer-Encoding: base64\r\n\r\n"); ok {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(body))
if err != nil {
t.Fatal(err)
}
pps = string(raw)
}
}
if !strings.Contains(pps, "<Node><NodeName>Username</NodeName><Value>Alice.Smith</Value></Node>") ||
!strings.Contains(pps, "<Node><NodeName>Password</NodeName><Value>"+base64.StdEncoding.EncodeToString([]byte(testKey))+"</Value></Node>") {
t.Errorf("pps %s", pps)
}
if !strings.Contains(string(mime), "Content-Type: application/x-x509-ca-cert\r\nContent-Transfer-Encoding: base64\r\n\r\n"+isrgRootX1+"\r\n--{boundary}--\r\n") {
t.Error("trust anchor part missing")
}
}
// plistValue decodes one plist value: dict, array, string, integer, true, false.
func plistValue(d *xml.Decoder, start xml.StartElement) (any, error) {
switch start.Name.Local {
case "dict":
m := map[string]any{}
var key string
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch el := tok.(type) {
case xml.StartElement:
if el.Name.Local == "key" {
if err := d.DecodeElement(&key, &el); err != nil {
return nil, err
}
continue
}
v, err := plistValue(d, el)
if err != nil {
return nil, err
}
m[key] = v
case xml.EndElement:
return m, nil
}
}
case "array":
var a []any
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch el := tok.(type) {
case xml.StartElement:
v, err := plistValue(d, el)
if err != nil {
return nil, err
}
a = append(a, v)
case xml.EndElement:
return a, nil
}
}
case "true", "false":
return start.Name.Local == "true", d.Skip()
default:
var s string
err := d.DecodeElement(&s, &start)
return s, err
}
}
func parsePlist(t *testing.T, body []byte) map[string]any {
t.Helper()
d := xml.NewDecoder(bytes.NewReader(body))
for {
tok, err := d.Token()
if err != nil {
t.Fatal(err)
}
if el, ok := tok.(xml.StartElement); ok && el.Name.Local == "dict" {
v, err := plistValue(d, el)
if err != nil {
t.Fatal(err)
}
return v.(map[string]any)
}
}
}
func TestCallbackApple(t *testing.T) {
h := newHarness(t)
rec := h.callback("apple", "s", "s", "Alice.Smith")
if rec.Code != http.StatusOK {
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
}
checkCreated(t, h, "apple", "Apple")
checkCleared(t, rec, "apple")
if rec.Header().Get("Content-Type") != "application/x-apple-aspen-config" ||
rec.Header().Get("Content-Disposition") != `attachment; filename="renner-unifi.mobileconfig"` {
t.Errorf("headers %v", rec.Header())
}
top := parsePlist(t, rec.Body.Bytes())
if top["PayloadType"] != "Configuration" || top["PayloadUUID"] != "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3" {
t.Errorf("top %v", top)
}
wifi := top["PayloadContent"].([]any)[0].(map[string]any)
if wifi["IsHotspot"] != true || wifi["DomainName"] != "renner.uno" ||
len(wifi["NAIRealmNames"].([]any)) != 1 || wifi["NAIRealmNames"].([]any)[0] != "renner.uno" ||
wifi["PayloadUUID"] != "824959bf-c55d-4ed2-b569-c33f3759c2b6" {
t.Errorf("wifi %v", wifi)
}
eap := wifi["EAPClientConfiguration"].(map[string]any)
if eap["UserName"] != "Alice.Smith" || eap["UserPassword"] != testKey || eap["OuterIdentity"] != "anonymous" ||
eap["TTLSInnerAuthentication"] != "PAP" {
t.Errorf("eap %v", eap)
}
}
func (h *harness) windowsToken(t *testing.T) string {
t.Helper()
rec := h.callback("windows", "s", "s", "Alice.Smith")
if rec.Code != http.StatusFound {
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
}
checkCleared(t, rec, "windows")
m := regexp.MustCompile(`^ms-settings:wifi-provisioning\?uri=https://wlan\.renner\.uno/windows/profile/([A-Za-z0-9_-]{43})$`).
FindStringSubmatch(rec.Header().Get("Location"))
if m == nil {
t.Fatalf("location %q", rec.Header().Get("Location"))
}
return m[1]
}
func validate(cert *x509.Certificate, body []byte) error {
doc := etree.NewDocument()
if err := doc.ReadFromBytes(body); err != nil {
return err
}
ctx := dsig.NewDefaultValidationContext(&dsig.MemoryX509CertificateStore{Roots: []*x509.Certificate{cert}})
_, err := ctx.Validate(doc.Root())
return err
}
func TestCallbackWindows(t *testing.T) {
h := newHarness(t)
token := h.windowsToken(t)
checkCreated(t, h, "windows", "Windows")
// No header or cookie: Windows Settings fetches it outside the authentik session.
head := h.do(httptest.NewRequest(http.MethodHead, "/windows/profile/"+token, nil))
if head.Code != http.StatusOK {
t.Errorf("HEAD: %d", head.Code)
}
rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil))
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/xml" {
t.Fatalf("first fetch: %d %v", rec.Code, rec.Header())
}
body := rec.Body.Bytes()
if again := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); again.Code != http.StatusNotFound {
t.Errorf("second fetch: %d", again.Code)
}
if err := validate(h.signing.cert, body); err != nil {
t.Fatalf("signature does not verify: %v\n%s", err, body)
}
s := string(body)
for _, want := range []string{
`<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 unsigned document is already canonical: its SHA-1 is the reference digest.
sum := sha1.Sum([]byte(windowsProfile("Alice.Smith", testKey)))
if !strings.Contains(s, "<DigestValue>"+base64.StdEncoding.EncodeToString(sum[:])+"</DigestValue>") {
t.Error("the digest is not the SHA-1 of the unsigned document")
}
if !strings.HasPrefix(s, strings.TrimSuffix(windowsProfile("Alice.Smith", testKey), "</CarrierProvisioning>")+"<Signature ") {
t.Error("the signed document does not start with the unsigned one")
}
// 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")
}
}
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")
}
}
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"strings"
)
// Signer holds the key and chain that sign the Windows provisioning document.
type Signer struct {
key *rsa.PrivateKey
chain [][]byte
}
// loadSigner reads the PEM key (PKCS#8 RSA) and the PEM chain (leaf first). It runs per request:
// cert-manager rotates the files.
func loadSigner(keyFile, certFile string) (*Signer, error) {
keyPEM, err := os.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("signing key: %w", err)
}
certPEM, err := os.ReadFile(certFile)
if err != nil {
return nil, fmt.Errorf("signing certificate: %w", err)
}
return parseSigner(keyPEM, certPEM)
}
func parseSigner(keyPEM, certPEM []byte) (*Signer, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("signing key: no PEM block")
}
var key *rsa.PrivateKey
switch block.Type {
case "PRIVATE KEY":
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing key: %w", err)
}
rk, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("signing key: not an RSA key")
}
key = rk
case "RSA PRIVATE KEY":
k, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing key: %w", err)
}
key = k
default:
return nil, fmt.Errorf("signing key: unexpected PEM block %q", block.Type)
}
var chain [][]byte
for rest := certPEM; ; {
var b *pem.Block
b, rest = pem.Decode(rest)
if b == nil {
break
}
if b.Type != "CERTIFICATE" {
continue
}
if _, err := x509.ParseCertificate(b.Bytes); err != nil {
return nil, fmt.Errorf("signing certificate: %w", err)
}
chain = append(chain, b.Bytes)
}
if len(chain) == 0 {
return nil, errors.New("signing certificate: no certificate")
}
leaf, _ := x509.ParseCertificate(chain[0])
if pub, ok := leaf.PublicKey.(*rsa.PublicKey); !ok || !pub.Equal(&key.PublicKey) {
return nil, errors.New("signing certificate: the first certificate does not match the key")
}
return &Signer{key: key, chain: chain}, nil
}
const dsigNS = "http://www.w3.org/2000/09/xmldsig#"
// Sign returns doc with an enveloped XML-DSig signature as the last child of the root, in the
// form of Microsoft's CarrierProvisioning samples: Reference URI="" with only the
// enveloped-signature transform, inclusive C14N 1.0, RSA-SHA1, SHA-1 digest, and every
// certificate of the chain in KeyInfo/X509Data. doc must already be in canonical form
// (windowsProfile writes it that way), so its bytes are the digest input.
func (s *Signer) Sign(doc string) ([]byte, error) {
const end = "</CarrierProvisioning>"
if !strings.HasSuffix(doc, end) {
return nil, errors.New("signing: not a CarrierProvisioning document")
}
digest := sha1.Sum([]byte(doc))
signedInfo := `<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>` +
`<SignatureMethod Algorithm="` + dsigNS + `rsa-sha1"></SignatureMethod>` +
`<Reference URI=""><Transforms><Transform Algorithm="` + dsigNS + `enveloped-signature"></Transform></Transforms>` +
`<DigestMethod Algorithm="` + dsigNS + `sha1"></DigestMethod>` +
`<DigestValue>` + base64.StdEncoding.EncodeToString(digest[:]) + `</DigestValue></Reference>` +
`</SignedInfo>`
// Canonical SignedInfo carries the namespace it inherits from Signature.
hashed := sha1.Sum([]byte(`<SignedInfo xmlns="` + dsigNS + `">` + signedInfo))
value, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA1, hashed[:])
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
var certs strings.Builder
for _, c := range s.chain {
certs.WriteString("<X509Certificate>" + base64.StdEncoding.EncodeToString(c) + "</X509Certificate>")
}
signature := `<Signature xmlns="` + dsigNS + `"><SignedInfo>` + signedInfo +
`<SignatureValue>` + base64.StdEncoding.EncodeToString(value) + `</SignatureValue>` +
`<KeyInfo><X509Data>` + certs.String() + `</X509Data></KeyInfo></Signature>`
return []byte(strings.TrimSuffix(doc, end) + signature + end), nil
}
+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
}