diff --git a/docs/radius/clients/renner-test.mobileconfig b/docs/radius/clients/renner-test.mobileconfig
new file mode 100644
index 00000000..1fd30025
--- /dev/null
+++ b/docs/radius/clients/renner-test.mobileconfig
@@ -0,0 +1,58 @@
+
+
+
+
+ PayloadContent
+
+
+ PayloadType
+ com.apple.wifi.managed
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ uno.renner.wifi.renner-test.network
+ PayloadUUID
+ c7814579-fc2b-4f38-bcc8-77e1123ab710
+ PayloadDisplayName
+ Renner Test Wi-Fi
+ PayloadOrganization
+ Renner
+ SSID_STR
+ Renner Test
+ HIDDEN_NETWORK
+
+ AutoJoin
+
+ EncryptionType
+ Any
+ EAPClientConfiguration
+
+ OuterIdentity
+ anonymous
+ AcceptEAPTypes
+
+ 21
+
+ TTLSInnerAuthentication
+ PAP
+ TLSTrustedServerNames
+
+ radius.renner.uno
+
+
+
+
+ PayloadType
+ Configuration
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ uno.renner.wifi.renner-test
+ PayloadUUID
+ b35513d4-97e5-4804-a77b-5ca1f723a408
+ PayloadDisplayName
+ Renner Test Wi-Fi (802.1X)
+ PayloadOrganization
+ Renner
+
+
diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md
new file mode 100644
index 00000000..464785cb
--- /dev/null
+++ b/docs/radius/dot1x.md
@@ -0,0 +1,151 @@
+# 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.
+
+## 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 = ""` (raw attribute codes 64, 65, 81; the
+ dictionary bundled with authentik does not define them).
+- `Cisco-AV-Pair = "dot1x-vlan="`.
+
+Contract for FreeRADIUS: if the outpost reply carries the standard Tunnel-*
+attributes, use them; otherwise translate `Cisco-AV-Pair = "dot1x-vlan="`
+into `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
+`Tunnel-Private-Group-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.
+
+## Client setup (SSID "Renner Test")
+
+All clients use EAP-TTLS with inner PAP. The server certificate is a public
+Let's Encrypt certificate for `radius.renner.uno`, so no CA needs to be
+installed. Username and password are the authentik credentials. PAP runs
+inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
+
+### Android
+
+- EAP method: TTLS
+- Phase 2 authentication: PAP
+- CA certificate: Use system certificates
+- Domain: `radius.renner.uno`
+- Identity: authentik username
+- Anonymous identity: `anonymous`
+- Password: authentik password
+
+### 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`
+- Username / password: authentik credentials
+
+### Windows 11
+
+Settings > Network & Internet > Wi-Fi > Manage known networks > Add network:
+
+- Network name: `Renner Test`
+- Security type: WPA2-Enterprise AES
+- 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)
+
+Apple devices need the configuration profile
+`docs/radius/clients/renner-test.mobileconfig`. Install it via AirDrop, Files
+or Settings (Profile Downloaded). The device asks for username and password on
+the first join. The profile sets security type Any (WPA2/WPA3), outer identity
+`anonymous` and trusted server name `radius.renner.uno`; it contains no
+password and no certificate. Apple
+devices may show a one-time "trust certificate" prompt for `radius.renner.uno`
+on the first join.
+
+## 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\": ?\"\"" | regexp "radius_auth_delta\": ?\"?(?P[0-9]+)" | unwrap d [24h])'
+
+logcli instant-query --quiet \
+ 'max_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"\"" | regexp "radius_auth_delta\": ?\"?(?P[0-9]+)" | unwrap d [24h])'
+```
+
+| SSID | VAP(s) | Events | avg radius_auth_delta (us) | max radius_auth_delta (us) |
+|---|---|---|---|---|
+| Renner Test | | | | |
+| Renner UniFi | | | | |
diff --git a/docs/superpowers/plans/2026-11-03-dot1x-radius-authentik.md b/docs/superpowers/plans/2026-11-03-dot1x-radius-authentik.md
new file mode 100644
index 00000000..d9a2ee2a
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-03-dot1x-radius-authentik.md
@@ -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="`.
+
+- [ ] **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=` into `Tunnel-Type := VLAN`, `Tunnel-Medium-Type := IEEE-802`, `Tunnel-Private-Group-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: '^(?P3\.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`.
diff --git a/docs/superpowers/specs/2026-11-03-wifi-radius-authentik-design.md b/docs/superpowers/specs/2026-11-03-wifi-radius-authentik-design.md
new file mode 100644
index 00000000..70fcc0db
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-03-wifi-radius-authentik-design.md
@@ -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/`, 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 = `.
+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).
diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml
index b71cec91..0a0f9f34 100644
--- a/kubernetes/apps/authentik/blueprints-configmap.yaml
+++ b/kubernetes/apps/authentik/blueprints-configmap.yaml
@@ -1316,3 +1316,180 @@ 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:
+ backends:
+ - authentik.core.auth.InbuiltBackend
+
+ - 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
+
+ - model: authentik_flows.flowstagebinding
+ identifiers:
+ target: !KeyOf dot1x-flow
+ stage: !KeyOf dot1x-login
+ order: 100
+ state: present
+
+ - 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
diff --git a/kubernetes/apps/authentik/freeradius-cnp.yaml b/kubernetes/apps/authentik/freeradius-cnp.yaml
new file mode 100644
index 00000000..a0c076ca
--- /dev/null
+++ b/kubernetes/apps/authentik/freeradius-cnp.yaml
@@ -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
diff --git a/kubernetes/apps/authentik/freeradius-config.yaml b/kubernetes/apps/authentik/freeradius-config.yaml
new file mode 100644
index 00000000..6ef47a6c
--- /dev/null
+++ b/kubernetes/apps/authentik/freeradius-config.yaml
@@ -0,0 +1,331 @@
+# FreeRADIUS 3.2 config for 802.1X: EAP-TTLS terminated here, inner PAP
+# proxied to the authentik outpost ak-outpost-8021x-radius. Replaces the
+# image's raddb completely. Functional test: tests/radius/run.sh.
+# VLAN contract: docs/radius/dot1x.md.
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: freeradius-config
+ namespace: authentik
+data:
+ radiusd.conf: |
+ prefix = /opt
+ exec_prefix = ${prefix}
+ sbindir = ${exec_prefix}/sbin
+ libdir = ${exec_prefix}/lib
+ raddbdir = /etc/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 response_window (proxy.conf): never forward a NAS
+ # retransmit to the outpost. A TTLS inner request sent twice to the home
+ # server leaves a freed request in the proxy hash; radiusd segfaults.
+ 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
+ }
+
+ 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 {
+ listen {
+ type = auth
+ ipaddr = *
+ port = 1812
+ }
+
+ listen {
+ type = acct
+ ipaddr = *
+ port = 1813
+ }
+
+ listen {
+ type = auth
+ ipv6addr = ::
+ port = 1812
+ }
+
+ listen {
+ type = acct
+ ipv6addr = ::
+ port = 1813
+ }
+
+ authorize {
+ # EAP-TTLS only.
+ if (!&EAP-Message) {
+ reject
+ }
+ 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
+ }
+
+ post-auth {
+ update reply {
+ &Cisco-AVPair !* ANY
+ }
+ Post-Auth-Type REJECT {
+ eap
+ }
+ }
+
+ pre-proxy {
+ }
+
+ # Runs the TTLS callback that hands the outpost reply back into the tunnel.
+ post-proxy {
+ 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
diff --git a/kubernetes/apps/authentik/freeradius.yaml b/kubernetes/apps/authentik/freeradius.yaml
new file mode 100644
index 00000000..530293df
--- /dev/null
+++ b/kubernetes/apps/authentik/freeradius.yaml
@@ -0,0 +1,195 @@
+# 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
+ 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
+ containers:
+ - name: freeradius
+ image: freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
+ args:
+ - -f
+ - -l
+ - stdout
+ - -d
+ - /etc/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
+ ports:
+ - name: radius
+ containerPort: 1812
+ protocol: UDP
+ - name: radius-acct
+ containerPort: 1813
+ protocol: UDP
+ readinessProbe:
+ exec:
+ command:
+ - /opt/bin/radclient
+ - -d
+ - /etc/raddb
+ - -q
+ - -r
+ - "1"
+ - -t
+ - "2"
+ - -f
+ - /etc/raddb/probe/status
+ - 127.0.0.1:18121
+ - status
+ - status-probe
+ periodSeconds: 10
+ timeoutSeconds: 5
+ livenessProbe:
+ exec:
+ command:
+ - /opt/bin/radclient
+ - -d
+ - /etc/raddb
+ - -q
+ - -r
+ - "1"
+ - -t
+ - "2"
+ - -f
+ - /etc/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
+ mountPath: /etc/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
+ # 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
diff --git a/kubernetes/apps/authentik/helmrelease.yaml b/kubernetes/apps/authentik/helmrelease.yaml
index 034bfd99..92aa63f2 100644
--- a/kubernetes/apps/authentik/helmrelease.yaml
+++ b/kubernetes/apps/authentik/helmrelease.yaml
@@ -88,8 +88,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:
@@ -174,6 +174,19 @@ spec:
- auth.renner.uno
worker:
+ # Overrides the global key; keep authentik-secrets in the list.
+ podAnnotations:
+ secret.reloader.stakater.com/reload: authentik-secrets,radius-secrets
+ # Shared secret of the 802.1X Radius provider, set via `!Env` in
+ # dot1x-radius.yaml. Worker only (blueprint applier); optional so a
+ # missing Secret fails only the dot1x blueprint entry.
+ env:
+ - name: DOT1X_OUTPOST_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: radius-secrets
+ key: DOT1X_OUTPOST_SECRET
+ optional: true
resources:
requests:
# Same reasoning as server above, and the worker is burstier still:
diff --git a/kubernetes/apps/authentik/kustomization.yaml b/kubernetes/apps/authentik/kustomization.yaml
index 5ee158fc..55b8fd73 100644
--- a/kubernetes/apps/authentik/kustomization.yaml
+++ b/kubernetes/apps/authentik/kustomization.yaml
@@ -9,6 +9,11 @@ resources:
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
+ - radius-cert.yaml
+ - radius-externalsecret.yaml
+ - freeradius-config.yaml
+ - freeradius.yaml
+ - freeradius-cnp.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
diff --git a/kubernetes/apps/authentik/radius-cert.yaml b/kubernetes/apps/authentik/radius-cert.yaml
new file mode 100644
index 00000000..b7e6b16b
--- /dev/null
+++ b/kubernetes/apps/authentik/radius-cert.yaml
@@ -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
diff --git a/kubernetes/apps/authentik/radius-externalsecret.yaml b/kubernetes/apps/authentik/radius-externalsecret.yaml
new file mode 100644
index 00000000..5b312bcd
--- /dev/null
+++ b/kubernetes/apps/authentik/radius-externalsecret.yaml
@@ -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
diff --git a/kubernetes/apps/monitoring-rules/kustomization.yaml b/kubernetes/apps/monitoring-rules/kustomization.yaml
index 96d16790..2f1ace39 100644
--- a/kubernetes/apps/monitoring-rules/kustomization.yaml
+++ b/kubernetes/apps/monitoring-rules/kustomization.yaml
@@ -84,4 +84,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
diff --git a/kubernetes/apps/monitoring-rules/prometheusrule-radius.yaml b/kubernetes/apps/monitoring-rules/prometheusrule-radius.yaml
new file mode 100644
index 00000000..56cea792
--- /dev/null
+++ b/kubernetes/apps/monitoring-rules/prometheusrule-radius.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.
diff --git a/kubernetes/infrastructure/image-automation/freeradius.yaml b/kubernetes/infrastructure/image-automation/freeradius.yaml
new file mode 100644
index 00000000..ee03b58a
--- /dev/null
+++ b/kubernetes/infrastructure/image-automation/freeradius.yaml
@@ -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: '^(?P3\.2\.\d+)-alpine$'
+ extract: '$v'
+ policy:
+ semver:
+ range: "3.2.x"
diff --git a/kubernetes/infrastructure/image-automation/kustomization.yaml b/kubernetes/infrastructure/image-automation/kustomization.yaml
index f649ceb1..e7145d34 100644
--- a/kubernetes/infrastructure/image-automation/kustomization.yaml
+++ b/kubernetes/infrastructure/image-automation/kustomization.yaml
@@ -7,6 +7,7 @@ resources:
- backup-verify.yaml
- ceph.yaml
- cnpg-barman-plugin.yaml
+ - freeradius.yaml
- gatus.yaml
- gitea-mcp.yaml
- gotenberg.yaml
diff --git a/tests/radius/README.md b/tests/radius/README.md
new file mode 100644
index 00000000..8350f947
--- /dev/null
+++ b/tests/radius/README.md
@@ -0,0 +1,33 @@
+# 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 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`.
+
+| 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 |
+
+Pieces:
+
+- `mock_outpost.py`: stand-in outpost on UDP 1812, reachable under the
+ outpost Service FQDN through a Docker network alias.
+- `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.
diff --git a/tests/radius/eapol.Dockerfile b/tests/radius/eapol.Dockerfile
new file mode 100644
index 00000000..afc8b1fa
--- /dev/null
+++ b/tests/radius/eapol.Dockerfile
@@ -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/*
diff --git a/tests/radius/mock_outpost.py b/tests/radius/mock_outpost.py
new file mode 100644
index 00000000..82f0117a
--- /dev/null
+++ b/tests/radius/mock_outpost.py
@@ -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()
+PASSWORD = os.environ["MOCK_PASSWORD"].encode()
+
+ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT = 1, 2, 3
+ATTR_USER_NAME, ATTR_USER_PASSWORD, ATTR_MESSAGE_AUTHENTICATOR = 1, 2, 80
+
+
+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():
+ 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()
diff --git a/tests/radius/run.sh b/tests/radius/run.sh
new file mode 100755
index 00000000..8877fb66
--- /dev/null
+++ b/tests/radius/run.sh
@@ -0,0 +1,304 @@
+#!/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
+# 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
+ if [[ -n "$(docker network ls -q --filter name="^${net}\$")" ]]; then docker network rm "$net" >/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"
+}
+
+# --- 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
+ local bridge
+ bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$net")"
+ if [[ -n "$(ip -4 -o addr show dev "$bridge")" ]] \
+ || [[ -n "$(ip -6 -o addr show dev "$bridge" scope global)" ]] \
+ || [[ -n "$(ip -6 route show dev "$bridge" "$subnet6")" ]]; then
+ echo "bridge $bridge got a host address or route in a test range; aborting" >&2
+ exit 1
+ fi
+
+ 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
+
+ # Same restrictions as the pod: non-root, read-only root, no capabilities.
+ local run_opts=(--network "$net" --user 65534:65534 --read-only --tmpfs /tmp:rw,size=16m
+ --cap-drop ALL --security-opt no-new-privileges "${secret_env[@]}"
+ -v "$work/raddb:/etc/raddb:ro")
+
+ echo "--- radiusd -XC (config check)"
+ if docker run --rm "${run_opts[@]}" "$image" radiusd -XC -d /etc/raddb >"$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
+
+ docker run -d --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/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
+ cat >"$work/eapol/$1.conf" <"$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
+ local c="$1"
+ eapol_conf "$c" "$2" "$password"
+ 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
+ local c="$1"
+ eapol_conf "$c" "$2" "$3"
+ 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
+}
+
+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"
+
+ # 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 ((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
+ 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 "$@"