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 "$@"