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
claude-botandClaude Opus 5.5 7434894f14 perf(authentik): disable Postgres JIT, persistent DB connections
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-04 09:52:41 +01:00
k.renner 7798a17ba5 Merge pull request 'fix(radius): run FreeRADIUS from a non-world-writable config dir' (#1556) from fix/freeradius-confdir-mode into main
Reviewed-on: #1556
2026-11-04 08:08:25 +00:00
claude-botandClaude Opus 5.5 61fc16ff67 fix(radius): idempotent raddb copy in the init container
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-04 09:05:51 +01:00
claude-botandClaude Opus 5.5 1e785e028e fix(radius): run FreeRADIUS from a copied, non-world-writable raddb
FreeRADIUS refuses a config dir that is globally writable; the kubelet
projected volume root is. Mount it at /etc/raddb-src, copy it into an
emptyDir with an initContainer and run radiusd from the copy. The test
harness now makes the mount world-writable and runs the initContainer
from the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:04:23 +01:00
security-bot 265b745429 chore(image-automation): bump ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47
Updates managed by image-automation-controller.
2026-11-04 08:04:09 +00:00
k.renner 014fe5efc0 Merge pull request 'fix(radius): skip Flux substitution on the FreeRADIUS config' (#1555) from fix/freeradius-flux-substitute into main
Reviewed-on: #1555
2026-11-04 07:52:46 +00:00
claude-botandClaude Opus 5.5 22fd0f8e54 fix(radius): skip Flux substitution on the FreeRADIUS config
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-04 08:51:56 +01:00
security-bot c382831464 chore(image-automation): bump docker.io/freeradius/freeradius-server:3.2.10-alpine
Updates managed by image-automation-controller.
2026-11-04 07:39:51 +00:00
k.renner 49017ac5d7 Merge pull request 'feat(radius): in-cluster 802.1X RADIUS via authentik (test SSID)' (#1554) from dot1x-radius-authentik into main
Reviewed-on: #1554
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-04 07:39:20 +00:00
claude-botandClaude Opus 5.5 64e19b4087 fix(radius): proxy_dedup_window above response_window
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
radiusd 3.2.10 segfaulted (exit 139) after the outpost came back from a
stall. A NAS retransmit forwarded to the outpost leaves the request in the
proxy hash while post-proxy runs; eapttls_postproxy() then sets
request->proxy->dst_port = 0 (ttls.c:880), which is part of the rbtree key,
so the later removal misses, the freed request stays in proxy_list and the
next reply lookup dereferences it in fr_packet_cmp(). Still present on the
v3.2.x head; 3.2.10 is the newest image.

With proxy_dedup_window (10) > response_window (5) a retransmit is never
forwarded while a request waits, num_proxied_requests stays 1 and the
request leaves the hash before post-proxy. Lost in-cluster packets are no
longer resent; the request fails after response_window.

Verified: 12/12 consecutive tests/radius/run.sh runs green incl. recovery
(10-15 s), radiusd -XC OK; before the change 2/2 runs crashed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:18:33 +01:00
claude-botandClaude Opus 5.5 f9eb38e2da fix(radius): outpost status checks, review doc corrections
Home server: response_window 5, status_check = request with a user that
does not exist in authentik (always rejected), check_interval 6,
check_timeout 4, num_answers_to_alive 3; revive_interval dropped. With
status_check = none the blind revive gave 7-8 s in some runs and no
recovery within 120 s in others.

tests/radius/run.sh: recovery case stalls the mock outpost until the
home server is marked dead, resumes it and asserts a login succeeds
within 20 s. It fails intermittently: radiusd stops answering
(liveness probe fails) after late replies arrive for requests it already
failed. Diagnosis pending.

Docs: secret is the only guard on the FreeRADIUS -> outpost hop; outpost
Service name resolved at startup. Spec: VLAN 10 is dual-stack
(fd02:10::/64). helmrelease comment: missing !Env resolves to the
default, no KeyError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:55:08 +01:00
claude-botandClaude Opus 5.5 3ed987dd74 docs(radius): anonymous outer identity on Android
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-04 07:15:19 +01:00
claude-botandClaude Opus 5.5 da58096453 feat(radius): IPv6 clients and listeners, client cert validation docs
FreeRADIUS listens on :: for 1812/1813 and accepts aps6 fd02:10::/64 and
test6 fd02:50::/64 with the same secrets as their IPv4 entries; the NAS
CNP admits both prefixes. The harness adds IPv6 cases on an isolated
dual-stack bridge.

Windows 11 setup requires server validation for radius.renner.uno and
never Windows sign-in credentials. The Apple profile uses EncryptionType
Any and OuterIdentity anonymous. dot1x.md marks the test/test6 clients
and their CNP CIDRs as test-phase only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:14:23 +01:00
claude-botandClaude Opus 5.5 06a916ddba fix(radius): Cisco-AVPair attribute name, absolute radclient path in probes
FreeRADIUS's dictionary names the Cisco VSA Cisco-AVPair; Cisco-AV-Pair
fails the config load. The image puts /opt/bin on PATH only in its
entrypoint, so exec probes need the absolute path. The AV-pair regex
now checks for the attribute first to avoid a condition-evaluation
failure message on Accepts without it.

tests/radius/run.sh now also runs the Deployment's readiness probe
command inside the test container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:00:51 +01:00
claude-botandClaude Opus 5.5 c6467a54ef docs(radius): Loki metric queries for 802.1X latency, Apple trust prompt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:40:29 +01:00
claude-botandClaude Opus 5.5 eb7949b0f0 docs(radius): client setup and measurement for the 802.1X test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:38:53 +01:00
claude-botandClaude Opus 5.5 f1d0dde23b feat(radius): FreeRADIUS EAP-TTLS frontend for authentik
FreeRADIUS 3.2 (2 replicas, non-root, read-only root) terminates EAP-TTLS
for radius.renner.uno and proxies inner PAP to ak-outpost-8021x-radius.
Inner post-auth passes standard Tunnel-* through, translates
Cisco-AV-Pair dot1x-vlan=<id> into Tunnel-*, strips Cisco-AV-Pair and
rejects an Accept without VLAN; doing this in the inner tunnel makes
TTLS answer with EAP-Failure. Home server does not require
Message-Authenticator (outpost rejects lack it). Accounting is answered
locally.

Adds radius-lb (10.2.99.91/fd99::91), CNPs, image automation, replica
alerts and tests/radius/run.sh (Docker + eapol_test against a mock
outpost).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:36:24 +01:00
claude-botandClaude Opus 5.5 e33abd320f fix(authentik): DOT1X_OUTPOST_SECRET on worker only, optional
Server pods (SSO) no longer depend on radius-secrets. Worker reloads on
radius-secrets changes. Docs: worker-only secret, unquoted integer
dot1x_vlan, FreeRADIUS VLAN contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:18:40 +01:00
claude-botandClaude Opus 5.5 a8affe6955 feat(authentik): 802.1X RADIUS provider, VLAN mapping and outpost
Blueprint dot1x-radius.yaml: non-interactive flow dot1x-authentication-flow,
policy dot1x-has-vlan, RADIUS mapping dot1x-vlan (lowest dot1x_vlan group
attribute; RFC 3580 Tunnel-* by raw code plus Cisco-AV-Pair fallback),
provider and outpost "802.1X Radius" (2 replicas, ClusterIP), client
networks = cluster pod CIDRs. Shared secret from radius-secrets via env
DOT1X_OUTPOST_SECRET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:13:54 +01:00
claude-botandClaude Opus 5.5 6757eaf361 feat(radius): server certificate and secrets for radius.renner.uno
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:07:12 +01:00
claude-botandClaude Opus 5.5 da1dc5b00e docs: implementation plan for 802.1X RADIUS via authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:59:10 +01:00
claude-botandClaude Opus 5.5 b05c84ac20 docs: rename VLAN group attribute to dot1x_vlan (WiFi and wired)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:54:11 +01:00
claude-botandClaude Opus 5.5 378496541c docs: record FreeRADIUS-as-own-service decision and outpost Message-Authenticator risk
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:50:00 +01:00
claude-botandClaude Opus 5.5 50d862d214 docs: switch 802.1X design to EAP-TTLS/PAP via authentik RADIUS outpost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:27:38 +01:00
claude-botandClaude Opus 5.5 fae0276045 docs: design for in-cluster 802.1X RADIUS backed by authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:50:32 +01:00
61 changed files with 5073 additions and 44 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
+257
View File
@@ -0,0 +1,257 @@
# 802.1X via authentik
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
`dot1x-radius.yaml`:
| Object | Name | Purpose |
|---|---|---|
| Flow | `dot1x-authentication-flow` | identification, password, login; no MFA, no enrollment |
| Expression policy | `dot1x-has-vlan` | admits users with a valid `dot1x_vlan` group |
| RADIUS property mapping | `dot1x-vlan` | puts the VLAN into the Access-Accept |
| RADIUS provider | `802.1X Radius` | shared secret `DOT1X_OUTPOST_SECRET`, clients = cluster pod CIDRs |
| Application | `dot1x` | bound to `dot1x-has-vlan` |
| Outpost | `802.1X Radius` | radius, 2 replicas, ClusterIP, namespace `authentik` |
The outpost's Kubernetes objects are named from the slugified outpost name:
Service/Deployment `ak-outpost-8021x-radius`, pod label
`goauthentik.io/outpost-name: 8021x-radius`.
The shared secret is key `DOT1X_OUTPOST_SECRET` of Secret `radius-secrets`
(ExternalSecret from OpenBao `apps/radius`), passed to the authentik worker
only (it applies the blueprints) as env `DOT1X_OUTPOST_SECRET`, with
`optional: true`: a missing Secret fails only the dot1x blueprint, not
authentik. Reloader restarts the worker when `radius-secrets` changes.
On the FreeRADIUS → outpost hop the shared secret is the only guard: the
authentik namespace baseline admits all same-namespace pods, and the
provider's client_networks is the pod CIDR.
FreeRADIUS resolves the outpost Service name at startup; a re-created
outpost Service needs the FreeRADIUS pods restarted.
## VLAN convention
- A group carries the VLAN as attribute `dot1x_vlan`, an integer VLAN id
(1–4094), e.g. `dot1x_vlan: 50`. It must be an unquoted integer: a string
such as `"50"` is ignored and the user is denied.
- Membership counts through group inheritance (`User.all_groups()`).
- A user without any such group is rejected (policy `dot1x-has-vlan`).
- A user in several such groups gets the lowest `dot1x_vlan`.
- The same attribute applies to WiFi and wired 802.1X.
Groups and memberships are managed by the owner in authentik, not in
blueprints.
## Reply format
The mapping sets both:
- RFC 3580 `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"` (raw attribute codes 64, 65, 81; the
dictionary bundled with authentik does not define them).
- `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
Contract for FreeRADIUS: if the outpost reply carries the standard Tunnel-*
attributes, use them; otherwise translate `Cisco-AV-Pair = "dot1x-vlan=<id>"`
into `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"`. An Access-Accept without a VLAN is
rejected.
## Test-phase entries
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.
## 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. 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
Passpoint profile from the setup page ("Auf Android installieren").
### Linux (NetworkManager)
- Security: WPA & WPA2/3 Enterprise
- Authentication: Tunneled TLS (TTLS)
- 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 UniFi`
- Security type: WPA3-Enterprise AES
- EAP method: EAP-TTLS
- Authentication method: Unencrypted password (PAP)
- Server validation: where offered ("Verify server identity" / trusted
server name), set `radius.renner.uno`
- Do not use Windows sign-in credentials; enter the authentik credentials
On the first join, check the certificate details before connecting: it must
be issued to `radius.renner.uno` by Let's Encrypt. Otherwise cancel.
If EAP-TTLS is not offered, use Control Panel > "Manually connect to a
wireless network" instead.
### Apple (iOS, iPadOS, macOS)
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
The UniFi access points log station events (`stahtd`) to Loki. The field
`radius_auth_delta` (microseconds) is in a JSON payload after
`stahtd_dump_event(): `.
VAP names per SSID are looked up through the UniFi API: `stat/device`,
`vap_table`, map `essid` to `name`.
The JSON mixes `"key":"v"` and `"key": "v"`, so the filters tolerate an
optional space. Values are microseconds. Run once per VAP of "Renner Test" and
of "Renner UniFi" and compare avg and max:
```
logcli instant-query --quiet \
'avg_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
logcli instant-query --quiet \
'max_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
```
| SSID | VAP(s) | Events | avg radius_auth_delta (us) | max radius_auth_delta (us) |
|---|---|---|---|---|
| Renner Test | | | | |
| Renner UniFi | | | | |
@@ -0,0 +1,123 @@
# 802.1X RADIUS via authentik — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** In-cluster FreeRADIUS that terminates EAP-TTLS/PAP for 802.1X and checks the password against a dedicated authentik RADIUS outpost, live behind a test SSID "Renner Test".
**Architecture:** FreeRADIUS (own Deployment, 2 replicas, LB `10.2.99.91`/`fd99::91`, Let's Encrypt cert `radius.renner.uno`) terminates EAP-TTLS, proxies the inner PAP request to a new blueprint-managed authentik RADIUS outpost "802.1X Radius" (2 replicas) whose provider returns the VLAN from the group attribute `dot1x_vlan`. UniFi gets a RADIUS profile, a test WLAN and a firewall policy via the UDM API.
**Tech Stack:** Flux/Kustomize manifests, authentik 2026.8 blueprints, `freeradius/freeradius-server:3.2.10-alpine`, cert-manager, External Secrets (OpenBao), Cilium LB-IPAM/BGP + CNP, UniFi Network API, `eapol_test` (workstation `/usr/bin/eapol_test`).
**Spec:** `docs/superpowers/specs/2026-11-03-wifi-radius-authentik-design.md`
## Global Constraints
- Work only in this worktree; commits as `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`; never push to `main`, open a PR, never self-merge.
- Never read Kubernetes Secret data; never print secret values; credentials only via env var on the local command (`VAR="$SECRET" tool`), never inside a string another program interprets.
- authentik users/groups are created by the owner, not in blueprints.
- Group attribute name: `dot1x_vlan` (integer). Test VLAN: `50`.
- LB: `lbipam.cilium.io/ips: "10.2.99.91,fd99::91"`, label `lb-pool: bgp`, `externalTrafficPolicy: Local`, `ipFamilyPolicy: PreferDualStack`.
- Hostname/cert: `radius.renner.uno`, ClusterIssuer `letsencrypt-cluster`.
- RADIUS clients: `10.2.10.0/24` (APs/switches). Test client: workstation network `10.2.50.0/24` with its own secret (test phase only).
- Existing "Unifi Radius" provider and "Local Radius" outpost: untouched.
- Main WLANs "Renner UniFi", "Renner IoT", "Renner Gast": untouched.
- Resource convention: requests set, memory limit only, no CPU limit; `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`.
- New manifests must be listed in `kubernetes/apps/authentik/kustomization.yaml`.
## Review Focus
- User not in any `dot1x_vlan` group → must get Access-Reject, never an Accept without VLAN (would land in the Guest fallback).
- Wrong password → Access-Reject within < 1 s, no retry storm between FreeRADIUS and outpost.
- Outpost reject packets lack `Message-Authenticator` (upstream behaviour) → FreeRADIUS must still forward a Reject to the AP, not time out.
- One FreeRADIUS replica or one outpost replica down → logins keep working (Service routes to the other).
- Accounting packets from APs (UDP 1813) → answered locally, never forwarded to the outpost (which has no 1813).
---
### Task 1: Secrets and server certificate
**Files:**
- Create: `kubernetes/apps/authentik/radius-cert.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Produces: Secret `radius-tls` (keys `tls.crt`, `tls.key`); OpenBao `apps/authentik` keys `DOT1X_OUTPOST_SECRET`, `DOT1X_NAS_SECRET`, `DOT1X_TEST_SECRET` (reach the cluster as keys of the existing Secret `authentik-secrets` via `externalsecret.yaml`).
- [ ] **Step 1:** Generate three secrets (`openssl rand -base64 48`, piped straight into `bao kv patch apps/authentik DOT1X_OUTPOST_SECRET=- …` without echoing) — if claude-bot's OpenBao policy cannot write `apps/authentik`, stop and hand the exact `bao kv patch` command to the owner.
- [ ] **Step 2:** Verify key names only: `bao kv get -format=json apps/authentik | jq -r '.data.data | keys[]' | grep DOT1X` → three names.
- [ ] **Step 3:** Write `radius-cert.yaml` (Certificate `radius-tls`, secretName `radius-tls`, dnsNames `[radius.renner.uno]`, issuerRef `letsencrypt-cluster`/ClusterIssuer), add to kustomization.
- [ ] **Step 4:** `kubectl kustomize kubernetes/apps/authentik >/dev/null` → exit 0; `kubectl apply --dry-run=server -f kubernetes/apps/authentik/radius-cert.yaml` → `created (server dry run)`.
- [ ] **Step 5:** Commit `feat(radius): server certificate for radius.renner.uno`.
### Task 2: authentik provider, VLAN mapping and outpost
**Files:**
- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (new key `dot1x-radius.yaml`)
- Modify: `kubernetes/apps/authentik/helmrelease.yaml` (worker env `DOT1X_OUTPOST_SECRET` from `authentik-secrets`, only if blueprints cannot already read it via `!Env`)
- Create: `docs/radius/dot1x.md` (owner steps + conventions)
**Interfaces:**
- Consumes: `DOT1X_OUTPOST_SECRET` (Task 1).
- Produces: outpost "802.1X Radius" → operator Service `ak-outpost-802-1x-radius` (ClusterIP, UDP 1812), pod labels `goauthentik.io/outpost-name: 802-1x-radius`; provider "802.1X Radius" with `client_networks` = cluster pod CIDRs (read from `kubectl get nodes -o jsonpath` podCIDRs / Cilium config, not guessed); reply carries the VLAN either as standard `Tunnel-Type`/`Tunnel-Medium-Type`/`Tunnel-Private-Group-Id` or, fallback, as `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
- [ ] **Step 1: Blueprint objects** — expression policy `dot1x-has-vlan` (`return any(g.attributes.get("dot1x_vlan") for g in request.user.ak_groups.all())`), RADIUS property mapping `dot1x-vlan` (lowest numeric `dot1x_vlan` across the user's groups wins), provider "802.1X Radius" (`authorization_flow: ldap-authentication-flow` after Step 2 confirms it is non-interactive, `invalidation_flow: default-invalidation-flow`, `mfa_support: false`, `shared_secret: !Env DOT1X_OUTPOST_SECRET`, `property_mappings: [dot1x-vlan]`), application slug `dot1x` bound to `dot1x-has-vlan`, outpost "802.1X Radius" (type radius, `k8s-conn`, namespace `authentik`, `kubernetes_replicas: 2`, `kubernetes_service_type: ClusterIP`, providers: only "802.1X Radius").
- [ ] **Step 2:** Confirm `ldap-authentication-flow` contains only identification + password stages (authentik API/UI read); if not, add a blueprint flow `dot1x-authentication-flow` with exactly those two stages and use it.
- [ ] **Step 3:** Owner step (documented in `docs/radius/dot1x.md`, asked of the owner during execution): create group `802.1X Privat` with attribute `dot1x_vlan: 50`, add the owner and a test user `dot1x-test`.
- [ ] **Step 4:** After merge + reconcile: `kubectl -n authentik get deploy ak-outpost-802-1x-radius -o json | jq .status.readyReplicas` → `2`.
- [ ] **Step 5: VLAN attribute spike** — from a throwaway pod in `authentik` (`kubectl run radtest --rm -i --image=freeradius/freeradius-server:3.2.10-alpine --command -- radtest dot1x-test "$PW" ak-outpost-802-1x-radius 0 "$SECRET"` with values passed via `--env`, not in the string) expect `Access-Accept` containing `Tunnel-Private-Group-Id = "50"`. If the outpost cannot emit standard Tunnel attributes, switch the mapping to the `Cisco-AV-Pair` fallback and expect `Cisco-AV-Pair = "dot1x-vlan=50"`. Record which one in `docs/radius/dot1x.md`.
- [ ] **Step 6:** Same with a user outside any `dot1x_vlan` group and with a wrong password → `Access-Reject` (< 1 s).
- [ ] **Step 7:** Commit `feat(authentik): 802.1X RADIUS provider, VLAN mapping and outpost`.
### Task 3: FreeRADIUS service
**Files:**
- Create: `kubernetes/apps/authentik/freeradius-config.yaml` (ConfigMap: `radiusd.conf`, `clients.conf`, `proxy.conf`, `mods-enabled/eap`, `sites-enabled/default`, `sites-enabled/inner-tunnel`)
- Create: `kubernetes/apps/authentik/freeradius.yaml` (Deployment + Service `radius-lb`)
- Create: `kubernetes/apps/authentik/freeradius-cnp.yaml`
- Create: `kubernetes/infrastructure/image-automation/freeradius.yaml`; modify its `kustomization.yaml`
- Modify: `kubernetes/apps/authentik/kustomization.yaml`
**Interfaces:**
- Consumes: `radius-tls`, `authentik-secrets` keys `DOT1X_NAS_SECRET`, `DOT1X_TEST_SECRET`, `DOT1X_OUTPOST_SECRET`; outpost Service `ak-outpost-802-1x-radius:1812/UDP`; reply VLAN format from Task 2.
- Produces: `radius.renner.uno` → `10.2.99.91`/`fd99::91`, UDP 1812/1813.
- [ ] **Step 1: Config** — `clients.conf`: client `aps` `10.2.10.0/24` secret `$ENV{DOT1X_NAS_SECRET}`, client `test` `10.2.50.0/24` secret `$ENV{DOT1X_TEST_SECRET}`, both `require_message_authenticator = yes`. `eap`: `default_eap_type = ttls`, TLS from `/etc/raddb/certs/radius-tls/{tls.crt,tls.key}`, `ttls { virtual_server = inner-tunnel, use_tunneled_reply = yes }`; no PEAP/MSCHAP. `inner-tunnel`: PAP proxied to home server `authentik` (`ak-outpost-802-1x-radius.authentik.svc.cluster.local:1812`, secret `$ENV{DOT1X_OUTPOST_SECRET}`, `response_window = 3`, do not require Message-Authenticator on its rejects). `default` post-auth: if the Cisco-AV-Pair fallback is in use, translate `dot1x-vlan=<id>` into `Tunnel-Type := VLAN`, `Tunnel-Medium-Type := IEEE-802`, `Tunnel-Private-Group-Id := <id>`; reject an Accept that carries no VLAN. Accounting: answer locally, `detail`-free.
- [ ] **Step 2: Deployment** — image `freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}`, args `-f -l stdout`, 2 replicas, `podAntiAffinity` required on `kubernetes.io/hostname`, env from the three secret keys (`secretKeyRef`, not `envFrom`), mounts ConfigMap + `radius-tls`, readiness/liveness via `radclient` Status-Server on 127.0.0.1 or a TCP-less exec check, requests `cpu 20m / memory 64Mi`, limit `memory 256Mi`, `stakater.com/auto: "true"` like other apps. Service `radius-lb` per Global Constraints, ports 1812/1813 UDP, annotation `external-dns.kubernetes.io/hostname: radius.renner.uno`.
- [ ] **Step 3: CNPs** — ingress to FreeRADIUS pods from `10.2.10.0/24` and `10.2.50.0/24` UDP 1812/1813; ingress to pods `goauthentik.io/outpost-name: 802-1x-radius` UDP 1812 only from FreeRADIUS pods (model on `ldap-outpost-cnp.yaml`, use container ports).
- [ ] **Step 4: Image automation** — `ImageRepository freeradius` (`docker.io/freeradius/freeradius-server`), `ImagePolicy freeradius` with `filterTags.pattern: '^(?P<v>3\.2\.\d+)-alpine$'`, `extract: '$v'`, `semver.range: "3.2.x"`.
- [ ] **Step 5:** `kubectl kustomize kubernetes/apps/authentik >/dev/null` and `kubectl apply --dry-run=server` on the new files → no errors.
- [ ] **Step 6:** After merge + reconcile: `kubectl -n authentik get deploy freeradius -o json | jq .status.readyReplicas` → `2`; `kubectl -n authentik get svc radius-lb -o json | jq -r '.status.loadBalancer.ingress[].ip'` → `10.2.99.91` and `fd99::91`.
- [ ] **Step 7: End-to-end from the workstation** — `eapol_test` config (TTLS, phase2 `auth=PAP`, `ca_cert` system bundle, `domain_suffix_match="radius.renner.uno"`, identity `dot1x-test`) run as `DOT1X_TEST_SECRET=… eapol_test -c ttls.conf -a 10.2.99.91 -s "$DOT1X_TEST_SECRET"` with the password supplied from an env var into the config file in the scratchpad (file deleted afterwards) → `SUCCESS`, `Tunnel-Private-Group-Id` `50` in the output; same with wrong password → `FAILURE` in < 1 s; user without group → `FAILURE`.
- [ ] **Step 8: Redundancy check without touching live pods** — confirm both FreeRADIUS replicas and both outpost replicas serve: `kubectl -n authentik get pods -l app.kubernetes.io/name=freeradius -o wide` shows two different nodes; loop `eapol_test` 20× → 20× `SUCCESS` and both pods log requests (`kubectl logs`). No rollout restart or pod deletion (house rule).
- [ ] **Step 9: Alert** — add rules to `kubernetes/apps/monitoring-rules/` (follow the existing rule files there): `kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-802-1x-radius"} < 1` for 2m → critical; `< 2` for 15m → warning. Verify `kubectl kustomize kubernetes/apps/monitoring-rules >/dev/null`.
- [ ] **Step 10:** Commit `feat(radius): FreeRADIUS EAP-TTLS frontend for authentik`.
### Task 4: UniFi RADIUS profile, test WLAN, firewall policy
**Files:**
- Modify: `docs/radius/dot1x.md` (record created objects, IDs, settings)
**Interfaces:**
- Consumes: `radius.renner.uno`/`10.2.99.91`, `DOT1X_NAS_SECRET`.
- Produces: UniFi RADIUS profile "Cluster 802.1X" (auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, VLAN assignment enabled), WLAN "Renner Test", firewall policy "Allow APs RADIUS".
- [ ] **Step 1:** POST firewall policy (clone of "Allow APs syslog to Loki": source zone Isolated IP `10.2.10.0/24`, destination zone External IP `10.2.99.91`, UDP, ports `1812-1813`, ALLOW) via `/proxy/network/v2/api/site/default/firewall-policies`; verify it is listed.
- [ ] **Step 2:** Create RADIUS profile via `/proxy/network/api/s/default/rest/radiusprofile` (secret passed from env into a JSON file in the scratchpad, never on the command line); verify listed.
- [ ] **Step 3:** Create WLAN "Renner Test" via `/rest/wlanconf` cloned from "Renner UniFi" settings (security `wpaeap`, `wpa3_support`/`wpa3_transition` as main, `fast_roaming_enabled: true`, `bss_transition: true`, same `ap_group_ids`, `networkconf_id` (90) Guest as fallback) with the new `radiusprofile_id`; verify all APs broadcast it (`stat/device` vap_table essid).
- [ ] **Step 4:** Owner joins one Android device; verify in Loki stahtd `success` with `radius_auth_delta` and `vlan 50` in `stat/sta`.
- [ ] **Step 5:** Commit `docs(radius): document UniFi objects for the 802.1X test`.
### Task 5: Client configuration aids and measurement
**Files:**
- Create: `docs/radius/clients/renner-test.mobileconfig` (no password; EAP-TTLS, `TTLSInnerAuthentication: PAP`, `TLSTrustedServerNames: [radius.renner.uno]`)
- Modify: `docs/radius/dot1x.md` (Android/Linux/Windows steps; LogQL for `radius_auth_delta` per SSID; result table)
- [ ] **Step 1:** Write the profile; validate `plutil -lint`-equivalent: `xmllint --noout docs/radius/clients/renner-test.mobileconfig` → no output.
- [ ] **Step 2:** Document the comparison query (stahtd events, `radius_auth_delta`, grouped by VAP of "Renner Test" vs. "Renner UniFi") and run it once the owner's devices have joined; record avg/max.
- [ ] **Step 3:** Commit `docs(radius): client setup and measurement for the 802.1X test`.
### Task 6: PR
- [ ] **Step 1:** Push branch with claude-bot credentials; open PR via `tea pr create --login claude-bot` (description = the change; no session status).
- [ ] **Step 2:** Verify with `tea pr list --login claude-bot --state open`.
@@ -0,0 +1,228 @@
# 802.1X via authentik: in-cluster RADIUS (first test on a test SSID)
Date: 2026-11-03
## Intent
Full 802.1X logins on the main WLAN "Renner UniFi" spend 1.3–6 s in RADIUS
(avg ~2 s, every login >1 s; 0.23–0.43 s per RADIUS round trip). The RADIUS
server is the UDM's built-in FreeRADIUS, which runs Ubiquiti's
`/usr/sbin/pyauth_log.py` in authorize/authenticate/post-auth and spawns
`mca-ctrl` synchronously for every RADIUS packet. Every failed fast roam,
every arrival at home and every band change pays that cost. The owner has
been unhappy with the UDM RADIUS performance for a while.
This project stands up an in-cluster RADIUS whose password check and group
membership come from authentik, and puts it behind a **separate test SSID**.
The owner moves some of his own devices to it. If RADIUS time per login is
significantly better, a solution for the rest of the household follows in a
later project.
## Goals
- In-cluster RADIUS for **802.1X in general** (WiFi and wired switch ports;
owner: "802.1x ist das was relevant ist") that authenticates users with
their **authentik username and password** (central credentials; a password
change in authentik applies immediately).
- Password verification **by authentik itself** via the authentik RADIUS
outpost (owner decision).
- **VLAN per user from authentik groups.**
- Must work with **every client OS** (Android, iOS/iPadOS, macOS, Windows,
Linux).
- Client setup as automatic as possible; no client certificates, no private
CA. A device configured once should carry the credentials to new devices
through platform sync where the platform does so.
- A test SSID that points only at the new RADIUS; measurable result.
## Non-goals
- Migrating "Renner UniFi" or the UniFi Endpoint-provisioned accounts. The main
WLAN keeps using the UDM RADIUS unchanged.
- VPN authentication (OpenVPN "Renner SSL VPN" also uses the UDM RADIUS
profile; out of scope).
- Enabling 802.1X on switch ports in this project (no port has it today); the
server must be ready for it (switches as RADIUS clients); the first test is
the test SSID.
- An onboarding page that hands out Apple/Windows profiles — owner: leave it
out for the test (likely follow-up).
- RADIUS accounting (authentik supports authentication requests only).
- MFA for 802.1X.
## Anti-goals (a failure even if it technically works)
- Anything that changes behaviour of the existing "Renner UniFi", "Renner IoT"
or "Renner Gast" WLANs or breaks the UniFi Endpoint app.
- Client certificates (EAP-TLS) — owner: without central device management
unacceptable fiddling.
- NT hashes (PEAP-MSCHAPv2 via `sambaNTPassword` from authentik-samba-bridge)
— rejected by the owner: weak password-equivalent MD4 hash stored and
exposed, and dependency on the samba-bridge fork.
- A private CA that every client has to trust.
- Copying Ubiquiti's RADIUS certificate/private key off the UDM.
## Constraints and facts
- authentik RADIUS provider (outpost image `ghcr.io/goauthentik/radius:2026.8.4`)
supports **PAP** and **EAP-TLS** only (no PEAP/MSCHAPv2, no EAP-TTLS);
custom reply attributes via RADIUS property mappings (since 2024.8);
authentication requests only (docs.goauthentik.io, RADIUS provider). It
speaks plain RADIUS (no RadSec).
- APs/switches speak EAP, so an EAP server must terminate the outer method:
FreeRADIUS terminates **EAP-TTLS** and forwards the **inner PAP** request to
the authentik RADIUS outpost.
- Client OS support for EAP-TTLS/PAP (vendor docs: IronWiFi, JumpCloud,
Fortinet, Meraki; no Apple statement found):
- Android, Linux: normal join dialog (EAP method TTLS, phase 2 PAP,
identity/password, CA "system certificates", domain).
- Windows 11: no file needed, but via "add network manually": EAP-TTLS,
"Unencrypted password (PAP)" (Control Panel route if Settings lacks it).
- iOS/iPadOS/macOS: requires a configuration profile (inner auth PAP is
only selectable in a profile). For the test the profile is built and
installed by hand.
- authentik does not support EAP (maintainer, goauthentik/authentik#15498);
MSCHAPv2/PEAP will not be implemented (reversible password storage);
EAP-TLS needs the MTLS stage (enterprise-licensed per community project
wus-technik/authentik-freeradius). FreeRADIUS in front of the outpost is
the established pattern. Owner decision (2026-11-03): **FreeRADIUS as its
own service** — the alternatives (FreeRADIUS as sidecar in the outpost pod,
keeping the UDM RADIUS, another IdP) were rejected.
- Known outpost issues with `Message-Authenticator` (goauthentik/authentik
#17901, #22931: packets dropped as "invalid message authenticator"). The
builder must test the FreeRADIUS → outpost hop with Message-Authenticator
early and document the working combination.
- **The FreeRADIUS → authentik outpost hop is plain RADIUS**: the password is
only obfuscated with the shared secret (RFC 2865 MD5 hiding). Cilium has no
transparent encryption enabled (no WireGuard/IPsec in `cilium-config`).
The owner chose this path over LDAPS bind (2026-11-03). Mitigations below.
- Existing objects: RADIUS provider "Unifi Radius" (exists in the authentik
DB, only referenced by `!Find` in
`kubernetes/apps/authentik/blueprints-configmap.yaml`) and outpost
"Local Radius" (`ak-outpost-local-radius`, ClusterIP, 1 replica, namespace
`authentik`). What currently uses "Unifi Radius" is unknown; it must not
break.
- authentik users and groups are created by the owner in authentik, not in
blueprints. Designs read a convention.
- APs (and switches) send RADIUS from the management network VLAN 10
(`10.2.10.0/24`). VLAN 10 is dual-stack, `10.2.10.0/24` and
`fd02:10::/64` (verified live: all APs/switches have fd02:10:: addresses).
- LB services: pool `bgp-pool` (10.2.99.0/24, fd99::/64, label `lb-pool: bgp`,
`kubernetes/infrastructure/post-crds/cilium-bgp.yaml`), pinned IPs with the
same last byte in both families (`lbipam.cilium.io/ips`),
`externalTrafficPolicy: Local` (preserves the AP source IP),
`ipFamilyPolicy: PreferDualStack`. Live in use: .81–.90.
- UDM firewall: the LB range 10.2.99.0/24 is in the UDM "External" zone; the
existing policy "Allow APs syslog to Loki" (Isolated 10.2.10.0/24 → External
10.2.99.84 UDP 5514) is the template. Firewall policies and WLANs are not in
OpenTofu today (UI/API only; the UDM API key is available to the builder).
- House patterns: cert-manager `Certificate` with ClusterIssuer
`letsencrypt-cluster` (Cloudflare DNS-01), as `ldap-outpost-cert.yaml`;
secrets via External Secrets Operator from OpenBao (`ClusterSecretStore
openbao`, key `apps/<app>`, Reloader restarts pods); DNS via
`external-dns.kubernetes.io/hostname` on the Service (UDM split-horizon);
additive CNP for LAN ingress like `ldap-outpost-cnp.yaml`; namespace
baseline `infrastructure/network-policies/authentik-ns.yaml`.
## Design (decided)
### Authentication path
```
client --EAP-TTLS (outer TLS to FreeRADIUS, cert radius.renner.uno), inner PAP--> AP / switch
AP/switch --RADIUS UDP 1812--> FreeRADIUS (in cluster, LB 10.2.99.91)
FreeRADIUS (inner tunnel) --RADIUS PAP--> authentik RADIUS outpost (in cluster)
<-- Access-Accept + VLAN attributes (authentik property mapping) --
FreeRADIUS --Access-Accept + Tunnel attrs (copied from inner reply)--> AP/switch
```
- Owner decision: username + **authentik password** (central credentials).
- Owner decision: password check **directly by the authentik RADIUS outpost**.
- Owner decision: **separate test SSID**; test devices are the owner's own,
across all OSes, and must keep working normally.
- Owner decision: client certificates and NT hashes are out.
- Success threshold (proposed by me, accepted by the owner): RADIUS time per
full login **< 0.5 s** vs. ~2 s today — a data point for the follow-up
decision, not a hard pass/fail gate.
### Protecting the FreeRADIUS → outpost hop (my calls)
- CNP so the RADIUS outpost accepts RADIUS only from the FreeRADIUS pods.
- Long random shared secret for this hop (OpenBao).
- Require and send `Message-Authenticator` on both hops (BlastRADIUS).
### Server certificate
Public Let's Encrypt certificate for `radius.renner.uno` via cert-manager
(renewed automatically). Android/Linux validate with system certificates +
domain `radius.renner.uno`; Apple via the profile (CA/server name in the
profile); Windows shows the certificate on first join. No private CA.
(My call on the hostname.) The builder verifies and documents what clients do
after a certificate renewal (Windows may prompt again).
### VLAN from groups (convention, my call)
A group that grants 802.1X access (WiFi and wired) carries the group
attribute `dot1x_vlan` (integer VLAN ID, e.g. `50`). An authentik RADIUS
property mapping returns `Tunnel-Type = VLAN (13)`,
`Tunnel-Medium-Type = IEEE-802 (6)`, `Tunnel-Private-Group-Id = <dot1x_vlan>`.
A user without any group carrying `dot1x_vlan` is rejected. If a user has
several, the builder picks a deterministic rule and documents it. The owner
creates the groups and memberships in authentik; for the test the owner's
group maps to VLAN 50 ((50) Privat), like his current UDM account.
### authentik side (my calls)
- A dedicated RADIUS provider for 802.1X (blueprint-managed), with the VLAN
property mapping and a non-interactive authentication flow, attached to an
outpost with **2 replicas**. Whether that is the existing "Local Radius"
outpost or a new one is the builder's call, after finding out what uses
"Unifi Radius" today; nothing that already uses it may break.
- The provider's allowed client network: the FreeRADIUS pods only.
### Test SSID (my calls)
- Name: "Renner Test".
- WPA2/WPA3-Enterprise like "Renner UniFi", Fast Roaming on, BSS transition on,
same bands. Fallback network for unassigned clients: same as "Renner UniFi"
((90) Guest).
- RADIUS profile with **only** the cluster RADIUS (no UDM fallback), so the
measurement shows the real performance.
### Placement and exposure (my calls)
- FreeRADIUS in namespace `authentik`, files under `kubernetes/apps/authentik/`
(no new Flux Kustomization; egress to the outpost is already allowed by the
namespace baseline). **2 replicas on different nodes.**
- LB Service `radius-lb`, IPs `10.2.99.91,fd99::91`, UDP 1812 (+1813 so APs
don't wait on accounting), `external-dns` hostname `radius.renner.uno`.
- Additive CNP: ingress from `10.2.10.0/24` to the FreeRADIUS pods, UDP
1812/1813.
- RADIUS clients in FreeRADIUS: APs and switches (`10.2.10.0/24`).
- Secrets (AP/switch↔FreeRADIUS, FreeRADIUS↔outpost) in OpenBao `apps/radius`.
- UDM firewall policy "Allow APs RADIUS": Isolated `10.2.10.0/24` → External
`10.2.99.91` UDP 1812/1813, modelled on "Allow APs syslog to Loki".
- Accounting: FreeRADIUS accepts and discards.
### Measurement and test
- stahtd `STA_ASSOC_TRACKER` events in Loki (`{source="unifi-syslog",
app="stahtd"}`) carry `radius_auth_delta` (µs) per full login, end to end as
seen by the AP (including the authentik check). Compare the test SSID's VAPs
against "Renner UniFi" over the test period.
- Test matrix: the owner's devices — Android, iOS/iPadOS, macOS (with
hand-made profile), Windows, Linux — join "Renner Test"; each must connect,
land in VLAN 50, survive fast roaming between APs, and keep working
normally.
## Left to the builder
- FreeRADIUS image/version and configuration details (EAP-TTLS module,
inner-tunnel proxying of PAP to the outpost, copying inner reply
attributes / `use_tunneled_reply`, Message-Authenticator settings, TLS
settings), resource requests/limits.
- The authentik flow used for RADIUS password checks (must not require
interactive stages) and the exact property-mapping code.
- How the UniFi RADIUS profile, test WLAN and firewall policy are created
(UniFi API vs. OpenTofu); if not IaC, document them.
- The Apple profile for the test (`.mobileconfig` with TTLS/PAP, server name
`radius.renner.uno`), and the Windows manual steps.
- Verification steps and monitoring (metrics, alert on RADIUS unreachable).
@@ -1447,3 +1447,267 @@ data:
stage: !KeyOf sso-passkey-validation
order: 30
state: present
# 802.1X: FreeRADIUS terminates EAP-TTLS and proxies inner PAP to the
# "802.1X Radius" outpost. VLAN from group attribute dot1x_vlan; see
# docs/radius/dot1x.md.
dot1x-radius.yaml: |
version: 1
metadata:
name: 802.1X RADIUS (homelab)
entries:
# Non-interactive: identification, password, login. No MFA.
- id: dot1x-flow
model: authentik_flows.flow
identifiers:
slug: dot1x-authentication-flow
state: present
attrs:
name: 802.1X authentication
title: "802.1X"
designation: authentication
- id: dot1x-identification
model: authentik_stages_identification.identificationstage
identifiers:
name: dot1x-identification
state: present
attrs:
user_fields:
- username
- email
- id: dot1x-password
model: authentik_stages_password.passwordstage
identifiers:
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
identifiers:
name: dot1x-login
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-identification
order: 10
state: present
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf dot1x-flow
stage: !KeyOf dot1x-password
order: 20
state: present
- 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
identifiers:
name: dot1x-has-vlan
state: present
attrs:
expression: |
for g in request.user.all_groups():
v = g.attributes.get("dot1x_vlan")
if type(v) is int and 1 <= v <= 4094:
return True
return False
# Lowest dot1x_vlan wins. Tunnel-* (RFC 3580) set by raw code: the
# bundled dictionary lacks them. Cisco-AV-Pair is the fallback.
- id: dot1x-vlan-mapping
model: authentik_providers_radius.radiusproviderpropertymapping
identifiers:
name: dot1x-vlan
state: present
attrs:
expression: |
vlans = [g.attributes.get("dot1x_vlan") for g in request.user.all_groups()]
vlans = [v for v in vlans if type(v) is int and 1 <= v <= 4094]
if not vlans:
return packet
vlan = str(min(vlans))
try:
packet[64] = [b"\x00\x00\x00\x0d"] # Tunnel-Type = VLAN
packet[65] = [b"\x00\x00\x00\x06"] # Tunnel-Medium-Type = IEEE-802
packet[81] = [vlan.encode()] # Tunnel-Private-Group-Id
except Exception:
pass
define_attribute(
vendor_code=9,
vendor_name="Cisco",
attribute_name="AV-Pair",
attribute_code=1,
attribute_type="string",
)
packet["Cisco-AV-Pair"] = f"dot1x-vlan={vlan}"
return packet
# client_networks: cluster pod CIDRs (kube-controller-manager
# --cluster-cidr, talos/patches/cluster.yaml podSubnets).
- id: dot1x-provider
model: authentik_providers_radius.radiusprovider
identifiers:
name: 802.1X Radius
state: present
attrs:
authorization_flow: !KeyOf dot1x-flow
invalidation_flow: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
shared_secret: !Env DOT1X_OUTPOST_SECRET
client_networks: "10.244.0.0/16,fd10:244::/48"
mfa_support: false
certificate: null
property_mappings:
- !KeyOf dot1x-vlan-mapping
- id: dot1x-application
model: authentik_core.application
identifiers:
slug: dot1x
state: present
attrs:
name: 802.1X network access
slug: dot1x
provider: !KeyOf dot1x-provider
meta_description: "WiFi and wired 802.1X via FreeRADIUS"
- model: authentik_policies.policybinding
identifiers:
target: !KeyOf dot1x-application
policy: !KeyOf dot1x-has-vlan
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
- model: authentik_outposts.outpost
identifiers:
name: 802.1X Radius
state: present
attrs:
type: radius
service_connection: !Find [authentik_outposts.kubernetesserviceconnection, [name, "Local Kubernetes Cluster"]]
providers:
- !KeyOf dot1x-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 2
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: []
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_ingress_path_type: null
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"
+4
View File
@@ -73,6 +73,10 @@ spec:
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# authentik's subclass joins over empty tables get planner costs far above
# jit_above_cost; LLVM compilation then takes ~420 ms for a query that
# executes in 0.1 ms (every flow start, every RADIUS/LDAP login).
jit: "off"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
@@ -0,0 +1,45 @@
# RADIUS clients reach FreeRADIUS through radius-lb (externalTrafficPolicy
# Local keeps their source IP): APs 10.2.10.0/24 + fd02:10::/64, test
# clients 10.2.50.0/24 + fd02:50::/64 (test phase only).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: freeradius-nas-ingress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: freeradius
ingress:
- fromCIDR:
- 10.2.10.0/24
- fd02:10::/64
- 10.2.50.0/24
- fd02:50::/64
toPorts:
- ports:
- port: "1812"
protocol: UDP
- port: "1813"
protocol: UDP
---
# FreeRADIUS proxies inner PAP to the 802.1X outpost on its container port.
# Additive: default-deny-ingress already admits the whole namespace.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: dot1x-outpost-freeradius-ingress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: authentik-outpost-radius
goauthentik.io/outpost-name: 8021x-radius
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: freeradius
toPorts:
- ports:
- port: "1812"
protocol: UDP
@@ -0,0 +1,404 @@
# 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
metadata:
name: freeradius-config
namespace: authentik
annotations:
# FreeRADIUS uses ${...} itself; Flux postBuild substitution must skip it.
kustomize.toolkit.fluxcd.io/substitute: disabled
data:
radiusd.conf: |
prefix = /opt
exec_prefix = ${prefix}
sbindir = ${exec_prefix}/sbin
libdir = ${exec_prefix}/lib
raddbdir = /run/raddb/raddb
confdir = ${raddbdir}
modconfdir = ${confdir}/mods-config
certdir = ${confdir}/certs
cadir = ${confdir}/certs
db_dir = ${raddbdir}
# only writable path in the pod
localstatedir = /tmp
logdir = /tmp
radacctdir = /tmp
run_dir = /tmp
name = radiusd
pidfile = ${run_dir}/${name}.pid
max_request_time = 30
cleanup_delay = 5
# 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
log {
destination = stdout
colourise = no
stripped_names = no
auth = yes
auth_badpass = no
auth_goodpass = no
}
checkrad = ${sbindir}/checkrad
security {
allow_core_dumps = no
max_attributes = 200
# Rejects decided here wait 0.5 s; proxied rejects are not delayed.
reject_delay = 0.5
status_server = yes
require_message_authenticator = yes
limit_proxy_state = yes
}
proxy_requests = yes
$INCLUDE proxy.conf
$INCLUDE clients.conf
thread pool {
start_servers = 2
max_servers = 16
min_spare_servers = 2
max_spare_servers = 4
max_requests_per_server = 0
}
modules {
$INCLUDE mods-enabled/
}
instantiate {
}
policy {
}
$INCLUDE sites-enabled/
# Site-local dictionary; the image's dictionaries under /opt/share/freeradius
# are loaded first.
dictionary: |
# no local attributes
clients.conf: |
client aps {
ipaddr = 10.2.10.0/24
secret = $ENV{DOT1X_NAS_SECRET}
require_message_authenticator = yes
}
client aps6 {
ipv6addr = fd02:10::/64
secret = $ENV{DOT1X_NAS_SECRET}
require_message_authenticator = yes
}
# test, test6: test phase only (docs/radius/dot1x.md).
client test {
ipaddr = 10.2.50.0/24
secret = $ENV{DOT1X_TEST_SECRET}
require_message_authenticator = yes
}
client test6 {
ipv6addr = fd02:50::/64
secret = $ENV{DOT1X_TEST_SECRET}
require_message_authenticator = yes
}
proxy.conf: |
proxy server {
default_fallback = no
}
home_server authentik {
type = auth
ipaddr = ak-outpost-8021x-radius.authentik.svc.cluster.local
port = 1812
proto = udp
secret = $ENV{DOT1X_OUTPOST_SECRET}
# The outpost sends Message-Authenticator on Access-Accept only.
require_message_authenticator = no
# Less than proxy_dedup_window (radiusd.conf).
response_window = 5
zombie_period = 20
# The outpost does not answer Status-Server. Probe with an
# Access-Request for a user that does not exist in authentik; the
# reject counts as an answer. Sent only while zombie or dead.
status_check = request
username = "dot1x-status-probe"
password = "not-a-secret"
check_interval = 6
check_timeout = 4
num_answers_to_alive = 3
}
home_server_pool authentik {
type = fail-over
home_server = authentik
}
realm authentik {
auth_pool = authentik
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
}
always reject {
rcode = reject
}
always noop {
rcode = noop
}
mods-enabled_eap: |
eap {
default_eap_type = ttls
timer_expire = 60
ignore_unknown_eap_types = no
max_sessions = ${max_requests}
tls-config tls-common {
certificate_file = ${certdir}/radius-tls/tls.crt
private_key_file = ${certdir}/radius-tls/tls.key
# tls.crt from cert-manager carries leaf + intermediate.
auto_chain = yes
tls_min_version = "1.2"
tls_max_version = "1.2"
cipher_list = "DEFAULT"
ecdh_curve = ""
cache {
enable = no
}
}
ttls {
tls = tls-common
copy_request_to_tunnel = no
use_tunneled_reply = yes
virtual_server = inner-tunnel
}
}
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 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
}
Autz-Type Status-Server {
ok
}
}
authenticate {
Auth-Type EAP {
eap
}
}
# Answered here; the outpost has no accounting port.
preacct {
}
accounting {
ok
}
# UDM replies pass unchanged.
post-auth {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
update reply {
&Cisco-AVPair !* ANY
}
}
Post-Auth-Type REJECT {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
pre-proxy {
}
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
sites-enabled_inner-tunnel: |
server inner-tunnel {
authorize {
# PAP only.
if (!&User-Password) {
reject
}
update control {
&Proxy-To-Realm := authentik
}
}
authenticate {
}
# Runs on the outpost's Access-Accept. use_tunneled_reply copies this
# reply into the outer Access-Accept.
post-auth {
update reply {
&Message-Authenticator !* ANY
&Proxy-State !* ANY
}
if (!&reply:Tunnel-Private-Group-Id && &reply:Cisco-AVPair && &reply:Cisco-AVPair =~ /^dot1x-vlan=([0-9]+)$/) {
update reply {
&Tunnel-Type := VLAN
&Tunnel-Medium-Type := IEEE-802
&Tunnel-Private-Group-Id := "%{1}"
}
}
update reply {
&Cisco-AVPair !* ANY
}
# No VLAN would put the client in the WLAN's fallback network.
if (!&reply:Tunnel-Private-Group-Id) {
reject
}
}
pre-proxy {
}
post-proxy {
}
}
# Status-Server only, loopback only; used by the probes.
sites-enabled_status: |
server status {
listen {
type = status
ipaddr = 127.0.0.1
port = 18121
}
client probe {
ipaddr = 127.0.0.1
secret = status-probe
}
authorize {
ok
Autz-Type Status-Server {
ok
}
}
}
probe_status: |
Message-Authenticator = 0x00
@@ -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"
}
}
]
}
+235
View File
@@ -0,0 +1,235 @@
# 802.1X RADIUS front end (radius.renner.uno). Config: freeradius-config.yaml.
apiVersion: apps/v1
kind: Deployment
metadata:
name: freeradius
namespace: authentik
labels:
app.kubernetes.io/name: freeradius
annotations:
configmap.reloader.stakater.com/reload: freeradius-config
secret.reloader.stakater.com/reload: radius-secrets,radius-tls
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: freeradius
template:
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:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: freeradius
topologyKey: kubernetes.io/hostname
# The image has no radius user and its own raddb is root-only; the
# mounted raddb replaces it.
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
# FreeRADIUS refuses a config dir that is world-writable; kubelet's
# volume root is, so the config is copied to a dir the pod owns.
initContainers:
- name: raddb-copy
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
command:
- /bin/sh
- -ec
args:
- mkdir -p /run/raddb/raddb && cp -rL /etc/raddb-src/. /run/raddb/raddb/ && chmod 0750 /run/raddb/raddb
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: raddb
mountPath: /etc/raddb-src
readOnly: true
- name: raddb-run
mountPath: /run/raddb
containers:
- name: freeradius
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
args:
- -f
- -l
- stdout
- -d
- /run/raddb/raddb
env:
- name: DOT1X_NAS_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_NAS_SECRET
- name: DOT1X_TEST_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: DOT1X_TEST_SECRET
- name: DOT1X_OUTPOST_SECRET
valueFrom:
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
protocol: UDP
- name: radius-acct
containerPort: 1813
protocol: UDP
readinessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: raddb-run
mountPath: /run/raddb
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: raddb
projected:
defaultMode: 0440
sources:
- configMap:
name: freeradius-config
items:
- key: radiusd.conf
path: radiusd.conf
- key: dictionary
path: dictionary
- key: clients.conf
path: clients.conf
- key: proxy.conf
path: proxy.conf
- key: mods-enabled_always
path: mods-enabled/always
- key: mods-enabled_eap
path: mods-enabled/eap
- key: sites-enabled_default
path: sites-enabled/default
- key: sites-enabled_inner-tunnel
path: sites-enabled/inner-tunnel
- key: sites-enabled_status
path: sites-enabled/status
- key: probe_status
path: probe/status
- secret:
name: radius-tls
items:
- key: tls.crt
path: certs/radius-tls/tls.crt
- key: tls.key
path: certs/radius-tls/tls.key
- name: raddb-run
emptyDir:
medium: Memory
sizeLimit: 4Mi
# pidfile
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
metadata:
name: radius-lb
namespace: authentik
labels:
lb-pool: bgp
annotations:
lbipam.cilium.io/ips: "10.2.99.91,fd99::91"
# LAN-side DNS only (external-dns-unifi); see ldap-outpost-lb.yaml.
external-dns.kubernetes.io/hostname: radius.renner.uno
spec:
type: LoadBalancer
externalTrafficPolicy: Local
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
selector:
app.kubernetes.io/name: freeradius
ports:
- name: radius
port: 1812
targetPort: radius
protocol: UDP
- name: radius-acct
port: 1813
targetPort: radius-acct
protocol: UDP
+27 -2
View File
@@ -69,6 +69,11 @@ spec:
secretKeyRef:
name: ak-pg-app
key: password
# Default 0 opens a new Postgres connection per request.
- name: AUTHENTIK_POSTGRESQL__CONN_MAX_AGE
value: "60"
- name: AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECKS
value: "true"
- name: AUTHENTIK_EMAIL__PASSWORD
valueFrom:
secretKeyRef:
@@ -80,8 +85,8 @@ spec:
# authentik_crypto.certificatekeypair the LDAP providers attach
# via attrs.certificate. Issued by cert-manager — see
# ldap-outpost-cert.yaml. Both pods get it because the chart's
# global.env propagates to server AND worker; if only the worker
# had it, blueprint apply would crash on startup with KeyError.
# global.env propagates to server AND worker; a pod without the
# variable resolves `!Env` to None/the default (no KeyError).
- name: LDAP_OUTPOST_TLS_CRT
valueFrom:
secretKeyRef:
@@ -164,6 +169,26 @@ spec:
hosts:
- auth.renner.uno
worker:
# Overrides the global key; keep authentik-secrets in the list.
podAnnotations:
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:
secretKeyRef:
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:
@@ -10,6 +10,15 @@ resources:
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
- 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,15 @@
---
# EAP-TTLS server certificate for the FreeRADIUS service (radius.renner.uno).
# Issued via the cluster-wide Cloudflare DNS-01 ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: radius-tls
namespace: authentik
spec:
secretName: radius-tls
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- radius.renner.uno
@@ -0,0 +1,15 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: radius-secrets
namespace: authentik
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: radius-secrets
dataFrom:
- extract:
key: apps/radius
@@ -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
@@ -91,4 +91,6 @@ resources:
# Gitea's data volume. It filled twice (#336, REN-580) and the chart's
# KubePersistentVolumeFillingUp warned 33 minutes before the second (REN-583).
- prometheusrule-gitea.yaml
# 802.1X: FreeRADIUS and the authentik outpost behind it.
- prometheusrule-radius.yaml
- probes-blackbox.yaml
@@ -0,0 +1,40 @@
# 802.1X RADIUS chain: FreeRADIUS (TTLS front end) and the authentik outpost
# behind it, 2 replicas each. Either at 0 means no 802.1X logins.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: radius
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: radius.rules
rules:
- alert: RadiusDeploymentDown
expr: >-
kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-8021x-radius"}
< 1
for: 2m
labels:
severity: critical
annotations:
summary: "{{ $labels.deployment }} has no available replica"
description: >-
Deployment {{ $labels.deployment }} (ns authentik) has had no
available replica for 2m. 802.1X logins (radius.renner.uno) fail.
Check `kubectl -n authentik describe deploy {{ $labels.deployment }}`
and its pod logs.
- alert: RadiusDeploymentDegraded
expr: >-
kube_deployment_status_replicas_available{namespace="authentik",deployment=~"freeradius|ak-outpost-8021x-radius"}
< 2
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.deployment }} has {{ $value }} of 2 replicas available"
description: >-
Deployment {{ $labels.deployment }} (ns authentik) has run with
fewer than 2 available replicas for 15m. Logins still work but one
more failure stops them.
+2 -2
View File
@@ -93,7 +93,7 @@ spec:
type: RuntimeDefault
containers:
- name: lidarr
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
@@ -140,7 +140,7 @@ spec:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
+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 |
@@ -0,0 +1,23 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: freeradius
namespace: flux-system
spec:
image: docker.io/freeradius/freeradius-server
interval: 1h
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: freeradius
namespace: flux-system
spec:
imageRepositoryRef:
name: freeradius
filterTags:
pattern: '^(?P<v>3\.2\.\d+)-alpine$'
extract: '$v'
policy:
semver:
range: "3.2.x"
@@ -12,6 +12,7 @@ resources:
- cloudflare-lb-sync.yaml
- cni-plugins.yaml
- cnpg-barman-plugin.yaml
- freeradius.yaml
- gatus.yaml
- gitea-mcp.yaml
- gotenberg.yaml
@@ -41,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": [
+51
View File
@@ -0,0 +1,51 @@
# FreeRADIUS functional test
```sh
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 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 |
|---|---|
| `vlan-tunnel` (outpost sends Tunnel-* and Cisco-AV-Pair) | SUCCESS, Tunnel-* VLAN 50, no Cisco-AV-Pair |
| `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 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`,
`gateway_mode_ipv6=isolated`), so the workstation LAN routes are untouched.
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").
+4
View File
@@ -0,0 +1,4 @@
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends eapoltest \
&& rm -rf /var/lib/apt/lists/*
+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
+120
View File
@@ -0,0 +1,120 @@
"""Stand-in for the authentik RADIUS outpost `8021x-radius` (UDP 1812).
Users (password MOCK_PASSWORD):
vlan-tunnel Accept: Tunnel-Type/Medium-Type/Private-Group-Id 50 + Cisco-AV-Pair
vlan-avpair Accept: Cisco-AV-Pair "dot1x-vlan=50" only
novlan Accept without VLAN
anything else, or a wrong password: Access-Reject WITHOUT Message-Authenticator
Accepts carry Message-Authenticator; rejects do not (authentik behaviour).
"""
import hashlib
import hmac
import os
import socket
import struct
SECRET = os.environ["MOCK_SECRET"].encode()
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT = 1, 2, 3
ATTR_USER_NAME, ATTR_USER_PASSWORD, ATTR_MESSAGE_AUTHENTICATOR = 1, 2, 80
def attr(t, value):
return bytes([t, len(value) + 2]) + value
def vsa(vendor, vendor_type, value):
return attr(26, struct.pack("!I", vendor) + bytes([vendor_type, len(value) + 2]) + value)
TUNNEL_VLAN_50 = (
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"50") # Tunnel-Private-Group-Id = "50"
)
AVPAIR_VLAN_50 = vsa(9, 1, b"dot1x-vlan=50") # Cisco-AV-Pair
REPLIES = {
b"vlan-tunnel": TUNNEL_VLAN_50 + AVPAIR_VLAN_50,
b"vlan-avpair": AVPAIR_VLAN_50,
b"novlan": b"",
}
def parse_attrs(data):
out, i = [], 0
while i + 2 <= len(data):
t, length = data[i], data[i + 1]
if length < 2:
break
out.append((t, i, data[i + 2 : i + length]))
i += length
return out
def decrypt_password(enc, authenticator):
out, last = b"", authenticator
for i in range(0, len(enc), 16):
key = hashlib.md5(SECRET + last).digest()
chunk = enc[i : i + 16]
out += bytes(a ^ b for a, b in zip(chunk, key))
last = chunk
return out.rstrip(b"\x00")
def request_ma_ok(packet, attrs):
for t, off, value in attrs:
if t == ATTR_MESSAGE_AUTHENTICATOR:
start = 20 + off + 2
zeroed = packet[:start] + b"\x00" * 16 + packet[start + 16 :]
return hmac.compare_digest(hmac.new(SECRET, zeroed, hashlib.md5).digest(), value)
return None
def build_reply(code, ident, req_auth, attrs, with_ma):
if with_ma:
attrs += attr(ATTR_MESSAGE_AUTHENTICATOR, b"\x00" * 16)
hdr = struct.pack("!BBH", code, ident, 20 + len(attrs))
if with_ma:
ma = hmac.new(SECRET, hdr + req_auth + attrs, hashlib.md5).digest()
attrs = attrs[:-16] + ma
resp_auth = hashlib.md5(hdr + req_auth + attrs + SECRET).digest()
return hdr + resp_auth + attrs
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)
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"")
enc = next((v for t, _, v in attrs if t == ATTR_USER_PASSWORD), b"")
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)
result = "accept"
else:
out = build_reply(ACCESS_REJECT, ident, req_auth, b"", False)
result = "reject"
print(
f"peer={peer[0]} code={code} user={user.decode(errors='replace')} "
f"password_ok={password_ok} request_ma={ma} -> {result}",
flush=True,
)
sock.sendto(out, peer)
if __name__ == "__main__":
main()
+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()
+436
View File
@@ -0,0 +1,436 @@
#!/usr/bin/env bash
# Functional test of the FreeRADIUS config in kubernetes/apps/authentik/.
# See README.md. Needs: docker, openssl, yq (the jq wrapper, kislyuk/yq).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
app="$repo/kubernetes/apps/authentik"
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
ip_acct=10.2.50.249
ip_nas=10.2.50.250
ip_radius=10.2.50.251
ip_mock=10.2.50.252
ip6_acct=fd02:50::249
ip6_nas=fd02:50::250
ip6_radius=fd02:50::251
outpost_fqdn=ak-outpost-8021x-radius.authentik.svc.cluster.local
mock_image=python:3.13-alpine
eapol_image=radtest-eapol:local
password=dummy-correct-password
work="$(mktemp -d)"
fails=0
cleanup() {
local ids=()
mapfile -t ids < <(docker ps -aq --filter name='^radtest-')
if ((${#ids[@]} > 0)); then docker rm -f "${ids[@]}" >/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
rm -rf "$work"
}
trap cleanup EXIT
pass() { printf 'PASS %s\n' "$1"; }
fail() { printf 'FAIL %s\n' "$1"; fails=$((fails + 1)); }
deployment() { yq -r 'select(.kind == "Deployment")' "$deploy"; }
# --- render the production ConfigMap through the Deployment's projected volume
render() {
local raddb="$work/raddb" key path
mkdir -p "$raddb"
while read -r key path; do
mkdir -p "$raddb/$(dirname "$path")"
yq -r --arg k "$key" 'select(.kind == "ConfigMap") | .data[$k]' "$config" >"$raddb/$path"
done < <(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .projected.sources[]
| select(.configMap) | .configMap.items[] | "\(.key) \(.path)"')
# self-signed CA + server cert in place of the cert-manager Secret
local crt key_path
crt="$(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .projected.sources[]
| select(.secret) | .secret.items[] | select(.key == "tls.crt") | .path')"
key_path="$(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .projected.sources[]
| select(.secret) | .secret.items[] | select(.key == "tls.key") | .path')"
mkdir -p "$raddb/$(dirname "$crt")" "$work/pki" "$work/eapol"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=radtest CA" \
-keyout "$work/pki/ca.key" -out "$work/eapol/ca.pem" >"$work/pki/openssl.log" 2>&1
openssl req -newkey rsa:2048 -nodes -subj "/CN=radius.renner.uno" \
-keyout "$raddb/$key_path" -out "$work/pki/server.csr" >>"$work/pki/openssl.log" 2>&1
printf 'subjectAltName=DNS:radius.renner.uno\nextendedKeyUsage=serverAuth\n' >"$work/pki/ext"
openssl x509 -req -in "$work/pki/server.csr" -CA "$work/eapol/ca.pem" -CAkey "$work/pki/ca.key" \
-CAcreateserial -days 1 -extfile "$work/pki/ext" -out "$raddb/$crt" >>"$work/pki/openssl.log" 2>&1
chmod -R a+rX "$work"
# kubelet creates the projected volume root world-writable
chmod 1777 "$raddb"
}
# Volume mounts of a pod container as docker -v options, as the pod declares
# them: the projected volume is the rendered config, other emptyDirs (except
# the /tmp tmpfs) are named docker volumes.
mount_opts() { # container name; sets mounts[]
local vol name path ro ro_suffix
vol="$(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .name')"
mounts=()
while read -r name path ro; do
ro_suffix=""
if [[ "$ro" == true ]]; then ro_suffix=":ro"; fi
if [[ "$name" == "$vol" ]]; then
mounts+=(-v "$work/raddb:$path${ro_suffix}")
elif [[ "$name" != tmp ]]; then
mounts+=(-v "radtest-$name:$path${ro_suffix}")
fi
done < <(deployment | yq -r --arg n "$1" '(.spec.template.spec.containers + (.spec.template.spec.initContainers // []))[]
| select(.name == $n) | .volumeMounts[] | "\(.name) \(.mountPath) \(.readOnly // false)"')
}
# emptyDir volumes other than /tmp, writable by the pod user like kubelet's
create_volumes() {
local name
while read -r name; do
docker volume create "radtest-$name" >/dev/null
docker run --rm --user 0:0 -v "radtest-$name:/v" "$mock_image" chmod 0777 /v >/dev/null
done < <(deployment | yq -r '.spec.template.spec.volumes[] | select(.emptyDir) | select(.name != "tmp") | .name')
}
# Pod restrictions: non-root, read-only root, no capabilities.
pod_opts=(--user 65534:65534 --read-only --tmpfs /tmp:rw,size=16m --cap-drop ALL --security-opt no-new-privileges)
# Run the Deployment's initContainers exactly as defined.
run_init() {
local names=() n image cmd=() args=()
mapfile -t names < <(deployment | yq -r '.spec.template.spec.initContainers // [] | .[].name')
for n in "${names[@]}"; do
image="$(deployment | yq -r --arg n "$n" '.spec.template.spec.initContainers[] | select(.name == $n) | .image')"
mapfile -t cmd < <(deployment | yq -r --arg n "$n" '.spec.template.spec.initContainers[] | select(.name == $n) | .command[]')
mapfile -t args < <(deployment | yq -r --arg n "$n" '.spec.template.spec.initContainers[] | select(.name == $n) | .args // [] | .[]')
mount_opts "$n"
if docker run --rm --name "radtest-init-$n" --network "$net" "${pod_opts[@]}" "${mounts[@]}" \
--entrypoint "${cmd[0]}" "$image" "${cmd[@]:1}" "${args[@]}" >"$work/init-$n.log" 2>&1; then
pass "initContainer $n"
else
cat "$work/init-$n.log"
fail "initContainer $n"
exit 1
fi
done
}
# --- dummy secrets for every secretKeyRef env var of the Deployment
secret_env=()
load_env() {
local name
while read -r name; do
secret_env+=(-e "$name=dummy-${name,,}")
done < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius")
| .env[] | select(.valueFrom.secretKeyRef) | .name')
}
start() {
local image args=()
image="$(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius") | .image')"
mapfile -t args < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius") | .args[]')
if [[ "${RADTEST_DEBUG:-0}" == 1 ]]; then args+=(-xx); fi
radius_image="$image"
# Internal bridge without a host address: both test ranges are also the
# workstation LAN, so the host must get no address or route on them.
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
# 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" \
-e MOCK_SECRET=dummy-dot1x_outpost_secret -e MOCK_PASSWORD="$password" \
-v "$here/mock_outpost.py:/mock_outpost.py:ro" \
"$mock_image" python3 -u /mock_outpost.py >/dev/null
create_volumes
run_init
# Same restrictions as the pod.
mount_opts freeradius
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 --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
cat "$work/configcheck.log"
fail "radiusd -XC"
exit 1
fi
# 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
fail "freeradius container running"
exit 1
fi
pass "freeradius container running with production args: ${args[*]}"
local probe=()
mapfile -t probe < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius")
| .readinessProbe.exec.command[]')
if docker exec radtest-freeradius "${probe[@]}" >"$work/probe.out" 2>&1; then
pass "readiness probe command succeeds"
else
cat "$work/probe.out"
fail "readiness probe command succeeds"
fi
}
eapol_conf() { # case user password [outer-identity]
cat >"$work/eapol/$1.conf" <<EOF
network={
key_mgmt=WPA-EAP
eap=TTLS
identity="$2"
anonymous_identity="${4:-anonymous}"
password="$3"
phase2="auth=PAP"
ca_cert="/eapol/ca.pem"
domain_suffix_match="radius.renner.uno"
}
EOF
}
eapol() { # name timeout server
docker run --rm --network "$net" --ip "$ip_nas" --ip6 "$ip6_nas" -v "$work/eapol:/eapol:ro" "$eapol_image" \
eapol_test -c "/eapol/$1.conf" -a "$3" -s "dummy-dot1x_test_secret" -t "$2" \
>"$work/$1.out" 2>&1 || true
}
# Attribute lines of the Access-Accept as the NAS received it.
accept_attrs() {
awk '/RADIUS message: code=2 /{f=1; next} f && /^ /{print; next} f{f=0}' "$work/$1.out"
}
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 [outer-identity]
local c="$1"
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" \
|| fail "$c: Tunnel-Private-Group-Id 50 (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)')"
# Cisco-AV-Pair = VSA vendor 9; "dot1x-vlan" in hex
if accept_attrs "$c" | grep -Eq 'Value: 00000009|646f7431782d766c616e'; then
fail "$c: no Cisco-AV-Pair to the NAS"
else
pass "$c: no Cisco-AV-Pair to the NAS"
fi
}
check_reject() { # case user password timeout server [outer-identity]
local c="$1"
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
pass "$c: FAILURE via Access-Reject within ${4}s"
else
fail "$c: FAILURE via Access-Reject within ${4}s"
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"
docker run --rm --network "$net" --ip "$ip_acct" --ip6 "$ip6_acct" -v "$work/eapol:/eapol:ro" "$radius_image" \
radclient -x -r 1 -t 2 -f "/eapol/$1.txt" "$2:1813" acct dummy-dot1x_test_secret \
>"$work/$1.out" 2>&1 || true
if grep -q "Received Accounting-Response" "$work/$1.out"; then
pass "$1: accounting answered locally"
else
fail "$1: accounting answered locally"
fi
}
# Outpost stalls until FreeRADIUS marks it dead, then comes back: a login must
# succeed again within the bound.
check_recovery() { # bound-seconds
local bound="$1" start now elapsed n=0
eapol_conf recovery vlan-avpair "$password"
docker pause radtest-mock >/dev/null
start=$SECONDS
# Requests during the stall: timeouts mark the home server zombie, then
# dead. Unpause right after the dead marking (worst case for recovery).
until docker logs radtest-freeradius 2>&1 | grep -q "port 1812 as dead"; do
if ((SECONDS - start > 90)); then
docker unpause radtest-mock >/dev/null
fail "recovery: home server marked dead during the stall"
return
fi
eapol recovery 4 "$ip_radius"
done
echo "recovery: home server marked dead $((SECONDS - start))s into the stall"
docker unpause radtest-mock >/dev/null
start=$SECONDS
while :; do
n=$((n + 1))
eapol recovery 5 "$ip_radius"
if [[ "$(tail -n 1 "$work/recovery.out")" == SUCCESS ]]; then break; fi
now=$SECONDS
if ((now - start > 120)); then break; fi
sleep 1
done
elapsed=$((SECONDS - start))
echo "recovery: first SUCCESS ${elapsed}s after unpause (attempt $n)"
if [[ "$(tail -n 1 "$work/recovery.out")" == SUCCESS ]] && ((elapsed <= bound)); then
pass "recovery: login succeeds within ${bound}s after the outpost returns (${elapsed}s)"
else
fail "recovery: login succeeds within ${bound}s after the outpost returns (${elapsed}s)"
local probe=()
mapfile -t probe < <(deployment | yq -r '.spec.template.spec.containers[] | select(.name == "freeradius")
| .livenessProbe.exec.command[]')
if docker exec radtest-freeradius "${probe[@]}" >"$work/recovery-probe.out" 2>&1; then
echo "recovery: liveness probe still succeeds"
else
echo "recovery: liveness probe fails"
fi
fi
}
main() {
for f in "$deploy" "$config"; do
[[ -f "$f" ]] || { echo "missing $f" >&2; exit 1; }
done
docker build -q -t "$eapol_image" -f "$here/eapol.Dockerfile" "$here" >/dev/null
render
load_env
start
check_accept vlan-tunnel vlan-tunnel "$ip_radius"
check_accept vlan-avpair vlan-avpair "$ip_radius"
check_reject novlan novlan "$password" 5 "$ip_radius"
check_reject bad-password vlan-tunnel wrong-password 1 "$ip_radius"
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"
check_reject v6-bad-password vlan-tunnel wrong-password 1 "$ip6_radius"
check_accounting v6-acct "[$ip6_radius]"
# dead -> alive needs num_answers_to_alive (3) status checks, check_interval 6s
check_recovery 20
if grep -q "code=4" < <(docker logs radtest-mock 2>&1); then
fail "no accounting forwarded to the outpost"
else
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
if ((fails > 0)); then echo "$fails check(s) failed"; exit 1; fi
echo "all checks passed"
}
main "$@"
+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
}