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>
12 KiB
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 onuplink, no host addresses; VLANConfig inkubernetes/talos/patches/k8s-{1,2,3}.yaml). VLAN 11 is in Cilium'sbpf.vlanBypass, otherwise the tagged frames are dropped onuplink. - IPv4 leased from the UDM DHCP server (cni-dhcp-daemon), requested without
the routers option:
net1holds only the connected10.2.11.0/24, no default route. The proxy socket is not bound to an address; the kernel's route lookup for10.2.11.1picksnet1and 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 onnet1. - Traffic on
net1bypasses 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-configin the response to that GET, as its installer requires. - Apple: a
.mobileconfigdownload. - 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 toms-settings:wifi-provisioning?uri=https://wlan.renner.uno/windows/profile/<token>; Windows Settings fetches it (Ingresswlan-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 |