Files
homelab/docs/radius/dot1x.md
claude-botandClaude Opus 5.5 4e61d543a8
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
feat(wlan-setup): run wlan.renner.uno on the Go service
The wlan-setup Deployment runs gitea.renner.uno/gitea_admin/wlan-setup
(Flux image policy wlan-setup) instead of nginx + njs; the ConfigMap
wlan-setup-config.yaml with nginx.conf, njs and the Windows .cmd is removed.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:56:15 +01:00

12 KiB
Raw Permalink Blame History

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), served by the Go service wlan-setup/ (kubernetes/apps/authentik/wlan-setup*.yaml). Android, Apple and Windows get Passpoint profiles with their own app password; Linux an nmcli command (NetworkManager has no Passpoint support) that carries no password.

Passpoint: /<platform>/install (android, apple or windows) signs the user in through the authentik OAuth2 application wlan-setup (scope goauthentik.io/api). On /<platform>/callback, the service exchanges the code, creates an app password (wlan-<platform>-<user>-<timestamp>-<random>) with the user's own access token, and hands out the profile:

  • Android: application/x-wifi-config in the response to that GET, as its installer requires.
  • Apple: a .mobileconfig download.
  • Windows: a CarrierProvisioning XML signed with XML-DSig (enveloped, C14N 1.0, rsa-sha1, chain in KeyInfo) by the Let's Encrypt certificate wlan-setup-signing; kept for 5 minutes under a single-use token. The browser is sent to ms-settings:wifi-provisioning?uri=https://wlan.renner.uno/windows/profile/<token>; Windows Settings fetches it (Ingress wlan-setup-provisioning, without the gate) and offers "Add".

The service stores nothing else. Deleting the app password in authentik removes that device from the WLAN. The profiles match WLANs that advertise Hotspot 2.0 with domain and NAI realm renner.uno (EAP-TTLS/PAP), i.e. "Renner UniFi". Creating the app password needs no admin rights.

App passwords expire when unused. The page creates them with 29 days (the API caps new tokens at 30 days unless the group attribute goauthentik.io/user/token-maximum-lifetime allows more). Every 802.1X login with an app password renews it to 60 days: the policy dot1x-app-password-renew on the dot1x-login binding (at most one write per day). authentik rejects expired app passwords and deletes them in its clean_expired_models task. A device that has not connected for 60 days, or a profile that was never installed, loses its app password and needs a new profile from the page.

All clients use EAP-TTLS with inner PAP. The server certificate is a public Let's Encrypt certificate for radius.renner.uno, so no CA needs to be installed. 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

Passpoint profile from the setup page ("Auf Windows installieren"). Manual setup without the page: Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:

  • Network name: Renner 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