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>
269 lines
12 KiB
Markdown
269 lines
12 KiB
Markdown
# 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 | | | | |
|