Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 05d2fcac71 feat(radius): proxy UniFi Endpoint logins to the UDM over VLAN 11
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The UDM RADIUS rejects the pod CIDR as an unknown client; it accepts
10.2.11.0/24. FreeRADIUS pods get net1 on VLAN 11 "(11) RADIUS" and the
udm home server moves to 10.2.11.1.

- Talos k8s-{1,2,3}: VLANConfig radius0 (VLAN 11 on uplink), no
  addresses, IPv6 disabled on the host link.
- Cilium bpf.vlanBypass gains 11: radius0 is address-less like ens19, so
  its tagged frames meet the VLAN filter on uplink (#592).
- NAD freeradius-udm: macvlan on radius0, DHCP IPAM requesting only
  subnet-mask (skipDefault), so the lease has no router and net1 holds
  only the connected /24. No sbr: it moves that route into a source
  table, and the unbound proxy socket would then reach the UDM via eth0
  from the pod IP. tuning disables IPv6 on net1 (VLAN 11 sends RAs).
- NAS listeners bound to eth0, so nothing listens on net1.
- CNP freeradius-udm-egress removed: net1 traffic bypasses Cilium.
- Harness: FreeRADIUS interfaces named eth0/net1 explicitly; Docker
  attaches networks in no fixed order, and the eth0 bind exposed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:02:37 +01:00
k.renner 697856cbf8 Merge pull request 'feat(radius): forward UniFi Endpoint logins to the UDM RADIUS' (#1559) from feat/radius-udm-forward into main
Reviewed-on: #1559
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-04 12:20:57 +00:00
k.renner 4962af5ed1 Merge pull request 'docs(radius): UniFi objects and live login check for the 802.1X test' (#1558) from docs/radius-live-test into main
Reviewed-on: #1558
2026-11-04 12:15:30 +00:00
claude-botandClaude Opus 5.5 bc698ea22d feat(radius): forward non-anonymous EAP logins to the UDM RADIUS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Outer identity anonymous[@realm] (any case) keeps the local EAP-TTLS ->
authentik path. Any other outer identity is a UniFi Endpoint account: the
outer server proxies every EAP round to new home server udm (10.2.47.1:1812,
UDM_RADIUS_SECRET) without local EAP processing; post-proxy/post-auth leave
UDM replies untouched. Endpoint account names look like ordinary usernames,
so routing keys on the anonymous identity, not a name pattern.

home_server udm: response_window 5 < proxy_dedup_window 10,
require_message_authenticator yes (RFC 3579, EAP replies), status_check
request with a user name that cannot exist.

CNP freeradius-udm-egress allows only 10.2.47.1/32 UDP 1812.

Harness: mock_udm.py on a second internal bridge at the udm address from
proxy.conf. Asserts both EAP rounds (State included) reach it, its Accept
reaches the NAS with Tunnel-* VLAN 200 and MS-MPPE keys intact, its Reject
passes, and anonymous logins and accounting never reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:13:07 +01:00
claude-botandClaude Opus 5.5 d42862e2d8 docs(radius): UniFi objects and live login check for the 802.1X test
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:05:55 +01:00
k.renner 73dd6f6362 Merge pull request 'perf(authentik): disable Postgres JIT, persistent DB connections' (#1557) from fix/authentik-pg-latency into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1557
2026-11-04 08:56:56 +00:00
claude-botandClaude Opus 5.5 7434894f14 perf(authentik): disable Postgres JIT, persistent DB connections
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:52:41 +01:00
k.renner 7798a17ba5 Merge pull request 'fix(radius): run FreeRADIUS from a non-world-writable config dir' (#1556) from fix/freeradius-confdir-mode into main
Reviewed-on: #1556
2026-11-04 08:08:25 +00:00
claude-botandClaude Opus 5.5 61fc16ff67 fix(radius): idempotent raddb copy in the init container
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:05:51 +01:00
claude-botandClaude Opus 5.5 1e785e028e fix(radius): run FreeRADIUS from a copied, non-world-writable raddb
FreeRADIUS refuses a config dir that is globally writable; the kubelet
projected volume root is. Mount it at /etc/raddb-src, copy it into an
emptyDir with an initContainer and run radiusd from the copy. The test
harness now makes the mount world-writable and runs the initContainer
from the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:04:23 +01:00
security-bot 265b745429 chore(image-automation): bump ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47
Updates managed by image-automation-controller.
2026-11-04 08:04:09 +00:00
k.renner 014fe5efc0 Merge pull request 'fix(radius): skip Flux substitution on the FreeRADIUS config' (#1555) from fix/freeradius-flux-substitute into main
Reviewed-on: #1555
2026-11-04 07:52:46 +00:00
claude-botandClaude Opus 5.5 22fd0f8e54 fix(radius): skip Flux substitution on the FreeRADIUS config
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:51:56 +01:00
security-bot c382831464 chore(image-automation): bump docker.io/freeradius/freeradius-server:3.2.10-alpine
Updates managed by image-automation-controller.
2026-11-04 07:39:51 +00:00
k.renner 49017ac5d7 Merge pull request 'feat(radius): in-cluster 802.1X RADIUS via authentik (test SSID)' (#1554) from dot1x-radius-authentik into main
Reviewed-on: #1554
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-11-04 07:39:20 +00:00
claude-botandClaude Opus 5.5 64e19b4087 fix(radius): proxy_dedup_window above response_window
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
radiusd 3.2.10 segfaulted (exit 139) after the outpost came back from a
stall. A NAS retransmit forwarded to the outpost leaves the request in the
proxy hash while post-proxy runs; eapttls_postproxy() then sets
request->proxy->dst_port = 0 (ttls.c:880), which is part of the rbtree key,
so the later removal misses, the freed request stays in proxy_list and the
next reply lookup dereferences it in fr_packet_cmp(). Still present on the
v3.2.x head; 3.2.10 is the newest image.

With proxy_dedup_window (10) > response_window (5) a retransmit is never
forwarded while a request waits, num_proxied_requests stays 1 and the
request leaves the hash before post-proxy. Lost in-cluster packets are no
longer resent; the request fails after response_window.

Verified: 12/12 consecutive tests/radius/run.sh runs green incl. recovery
(10-15 s), radiusd -XC OK; before the change 2/2 runs crashed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:18:33 +01:00
claude-botandClaude Opus 5.5 f9eb38e2da fix(radius): outpost status checks, review doc corrections
Home server: response_window 5, status_check = request with a user that
does not exist in authentik (always rejected), check_interval 6,
check_timeout 4, num_answers_to_alive 3; revive_interval dropped. With
status_check = none the blind revive gave 7-8 s in some runs and no
recovery within 120 s in others.

tests/radius/run.sh: recovery case stalls the mock outpost until the
home server is marked dead, resumes it and asserts a login succeeds
within 20 s. It fails intermittently: radiusd stops answering
(liveness probe fails) after late replies arrive for requests it already
failed. Diagnosis pending.

Docs: secret is the only guard on the FreeRADIUS -> outpost hop; outpost
Service name resolved at startup. Spec: VLAN 10 is dual-stack
(fd02:10::/64). helmrelease comment: missing !Env resolves to the
default, no KeyError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:55:08 +01:00
claude-botandClaude Opus 5.5 3ed987dd74 docs(radius): anonymous outer identity on Android
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:15:19 +01:00
claude-botandClaude Opus 5.5 da58096453 feat(radius): IPv6 clients and listeners, client cert validation docs
FreeRADIUS listens on :: for 1812/1813 and accepts aps6 fd02:10::/64 and
test6 fd02:50::/64 with the same secrets as their IPv4 entries; the NAS
CNP admits both prefixes. The harness adds IPv6 cases on an isolated
dual-stack bridge.

Windows 11 setup requires server validation for radius.renner.uno and
never Windows sign-in credentials. The Apple profile uses EncryptionType
Any and OuterIdentity anonymous. dot1x.md marks the test/test6 clients
and their CNP CIDRs as test-phase only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:14:23 +01:00
claude-botandClaude Opus 5.5 06a916ddba fix(radius): Cisco-AVPair attribute name, absolute radclient path in probes
FreeRADIUS's dictionary names the Cisco VSA Cisco-AVPair; Cisco-AV-Pair
fails the config load. The image puts /opt/bin on PATH only in its
entrypoint, so exec probes need the absolute path. The AV-pair regex
now checks for the attribute first to avoid a condition-evaluation
failure message on Accepts without it.

tests/radius/run.sh now also runs the Deployment's readiness probe
command inside the test container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:00:51 +01:00
claude-botandClaude Opus 5.5 c6467a54ef docs(radius): Loki metric queries for 802.1X latency, Apple trust prompt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:40:29 +01:00
claude-botandClaude Opus 5.5 eb7949b0f0 docs(radius): client setup and measurement for the 802.1X test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:38:53 +01:00
claude-botandClaude Opus 5.5 f1d0dde23b feat(radius): FreeRADIUS EAP-TTLS frontend for authentik
FreeRADIUS 3.2 (2 replicas, non-root, read-only root) terminates EAP-TTLS
for radius.renner.uno and proxies inner PAP to ak-outpost-8021x-radius.
Inner post-auth passes standard Tunnel-* through, translates
Cisco-AV-Pair dot1x-vlan=<id> into Tunnel-*, strips Cisco-AV-Pair and
rejects an Accept without VLAN; doing this in the inner tunnel makes
TTLS answer with EAP-Failure. Home server does not require
Message-Authenticator (outpost rejects lack it). Accounting is answered
locally.

Adds radius-lb (10.2.99.91/fd99::91), CNPs, image automation, replica
alerts and tests/radius/run.sh (Docker + eapol_test against a mock
outpost).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:36:24 +01:00
claude-botandClaude Opus 5.5 e33abd320f fix(authentik): DOT1X_OUTPOST_SECRET on worker only, optional
Server pods (SSO) no longer depend on radius-secrets. Worker reloads on
radius-secrets changes. Docs: worker-only secret, unquoted integer
dot1x_vlan, FreeRADIUS VLAN contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:18:40 +01:00
claude-botandClaude Opus 5.5 a8affe6955 feat(authentik): 802.1X RADIUS provider, VLAN mapping and outpost
Blueprint dot1x-radius.yaml: non-interactive flow dot1x-authentication-flow,
policy dot1x-has-vlan, RADIUS mapping dot1x-vlan (lowest dot1x_vlan group
attribute; RFC 3580 Tunnel-* by raw code plus Cisco-AV-Pair fallback),
provider and outpost "802.1X Radius" (2 replicas, ClusterIP), client
networks = cluster pod CIDRs. Shared secret from radius-secrets via env
DOT1X_OUTPOST_SECRET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:13:54 +01:00
claude-botandClaude Opus 5.5 6757eaf361 feat(radius): server certificate and secrets for radius.renner.uno
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:07:12 +01:00
claude-botandClaude Opus 5.5 da1dc5b00e docs: implementation plan for 802.1X RADIUS via authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:59:10 +01:00
claude-botandClaude Opus 5.5 b05c84ac20 docs: rename VLAN group attribute to dot1x_vlan (WiFi and wired)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:54:11 +01:00
claude-botandClaude Opus 5.5 378496541c docs: record FreeRADIUS-as-own-service decision and outpost Message-Authenticator risk
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:50:00 +01:00
claude-botandClaude Opus 5.5 50d862d214 docs: switch 802.1X design to EAP-TTLS/PAP via authentik RADIUS outpost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:27:38 +01:00
claude-botandClaude Opus 5.5 fae0276045 docs: design for in-cluster 802.1X RADIUS backed by authentik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:50:32 +01:00
29 changed files with 2524 additions and 11 deletions
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.wifi.managed</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test.network</string>
<key>PayloadUUID</key>
<string>c7814579-fc2b-4f38-bcc8-77e1123ab710</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi</string>
<key>PayloadOrganization</key>
<string>Renner</string>
<key>SSID_STR</key>
<string>Renner Test</string>
<key>HIDDEN_NETWORK</key>
<false/>
<key>AutoJoin</key>
<true/>
<key>EncryptionType</key>
<string>Any</string>
<key>EAPClientConfiguration</key>
<dict>
<key>OuterIdentity</key>
<string>anonymous</string>
<key>AcceptEAPTypes</key>
<array>
<integer>21</integer>
</array>
<key>TTLSInnerAuthentication</key>
<string>PAP</string>
<key>TLSTrustedServerNames</key>
<array>
<string>radius.renner.uno</string>
</array>
</dict>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>uno.renner.wifi.renner-test</string>
<key>PayloadUUID</key>
<string>b35513d4-97e5-4804-a77b-5ca1f723a408</string>
<key>PayloadDisplayName</key>
<string>Renner Test Wi-Fi (802.1X)</string>
<key>PayloadOrganization</key>
<string>Renner</string>
</dict>
</plist>
+227
View File
@@ -0,0 +1,227 @@
# 802.1X via authentik
FreeRADIUS terminates EAP-TTLS and proxies the inner PAP request to the
authentik RADIUS outpost `802.1X Radius`. authentik checks the password and
the access policy and returns the VLAN.
## Routing by outer identity
FreeRADIUS decides on the outer EAP identity (`User-Name`) of each request,
before any local EAP processing:
| Outer identity | Path |
|---|---|
| `anonymous` or `anonymous@<anything>` (any case) | EAP-TTLS terminated in FreeRADIUS, inner PAP to authentik |
| anything else | whole EAP conversation (all rounds, `State` included) proxied to the UDM RADIUS for UniFi Endpoint accounts |
UniFi Endpoint clients send their real account name as outer identity; their
names look like ordinary usernames, so the rule cannot match on name patterns.
The UDM's Access-Accept/Reject/Challenge goes to the NAS unchanged, including
its Tunnel-* VLAN and MS-MPPE keys. The "Accept without VLAN is rejected" rule
applies to the authentik path only. Accounting is answered locally on both
paths.
The anonymous outer identity is mandatory for authentik clients: without it
the login goes to the UDM and fails.
UDM target: home server `udm`, `10.2.11.1` UDP 1812 (the UDM on VLAN 11,
UniFi network "(11) RADIUS"), shared secret `UDM_RADIUS_SECRET` from OpenBao
`apps/radius`. The UDM RADIUS accepts clients from `10.2.11.0/24` only, not
from the pod CIDR.
### Path to the UDM
Each FreeRADIUS pod has a second interface `net1` on VLAN 11
(NetworkAttachmentDefinition `freeradius-udm`,
`kubernetes/apps/authentik/freeradius-network-attachment.yaml`):
- macvlan on the node link `radius0` (VLAN 11 on `uplink`, no host addresses;
VLANConfig in `kubernetes/talos/patches/k8s-{1,2,3}.yaml`). VLAN 11 is in
Cilium's `bpf.vlanBypass`, otherwise the tagged frames are dropped on
`uplink`.
- IPv4 leased from the UDM DHCP server (cni-dhcp-daemon), requested without
the routers option: `net1` holds only the connected `10.2.11.0/24`, no
default route. The proxy socket is not bound to an address; the kernel's
route lookup for `10.2.11.1` picks `net1` and its leased address as source.
- IPv6 disabled on `net1` (VLAN 11 sends router advertisements).
- The NAS listeners are bound to `eth0` (`interface = eth0`); nothing listens
on `net1`.
- Traffic on `net1` bypasses Cilium; there is no CNP for the UDM hop.
`radius0` must exist on every node before FreeRADIUS pods are scheduled
there: without it the macvlan ADD fails and the pod stays in
`ContainerCreating`.
## authentik objects
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
`dot1x-radius.yaml`:
| Object | Name | Purpose |
|---|---|---|
| Flow | `dot1x-authentication-flow` | identification, password, login; no MFA, no enrollment |
| Expression policy | `dot1x-has-vlan` | admits users with a valid `dot1x_vlan` group |
| RADIUS property mapping | `dot1x-vlan` | puts the VLAN into the Access-Accept |
| RADIUS provider | `802.1X Radius` | shared secret `DOT1X_OUTPOST_SECRET`, clients = cluster pod CIDRs |
| Application | `dot1x` | bound to `dot1x-has-vlan` |
| Outpost | `802.1X Radius` | radius, 2 replicas, ClusterIP, namespace `authentik` |
The outpost's Kubernetes objects are named from the slugified outpost name:
Service/Deployment `ak-outpost-8021x-radius`, pod label
`goauthentik.io/outpost-name: 8021x-radius`.
The shared secret is key `DOT1X_OUTPOST_SECRET` of Secret `radius-secrets`
(ExternalSecret from OpenBao `apps/radius`), passed to the authentik worker
only (it applies the blueprints) as env `DOT1X_OUTPOST_SECRET`, with
`optional: true`: a missing Secret fails only the dot1x blueprint, not
authentik. Reloader restarts the worker when `radius-secrets` changes.
On the FreeRADIUS → outpost hop the shared secret is the only guard: the
authentik namespace baseline admits all same-namespace pods, and the
provider's client_networks is the pod CIDR.
FreeRADIUS resolves the outpost Service name at startup; a re-created
outpost Service needs the FreeRADIUS pods restarted.
## VLAN convention
- A group carries the VLAN as attribute `dot1x_vlan`, an integer VLAN id
(1–4094), e.g. `dot1x_vlan: 50`. It must be an unquoted integer: a string
such as `"50"` is ignored and the user is denied.
- Membership counts through group inheritance (`User.all_groups()`).
- A user without any such group is rejected (policy `dot1x-has-vlan`).
- A user in several such groups gets the lowest `dot1x_vlan`.
- The same attribute applies to WiFi and wired 802.1X.
Groups and memberships are managed by the owner in authentik, not in
blueprints.
## Reply format
The mapping sets both:
- RFC 3580 `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"` (raw attribute codes 64, 65, 81; the
dictionary bundled with authentik does not define them).
- `Cisco-AV-Pair = "dot1x-vlan=<id>"`.
Contract for FreeRADIUS: if the outpost reply carries the standard Tunnel-*
attributes, use them; otherwise translate `Cisco-AV-Pair = "dot1x-vlan=<id>"`
into `Tunnel-Type = VLAN`, `Tunnel-Medium-Type = IEEE-802`,
`Tunnel-Private-Group-Id = "<id>"`. An Access-Accept without a VLAN is
rejected.
## Test-phase entries
The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
removed after the test.
## UniFi objects
Created through the UniFi Network API (site `default`):
| Object | ID | Settings |
|---|---|---|
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
## Live login check
`tests/radius/live.sh` runs EAP-TTLS/PAP logins with `eapol_test` against the
deployed FreeRADIUS from a test client range and prints result, duration and
the VLAN attributes of the Access-Accept (hex) per login; secrets are read from
files and never printed:
```
LIVE_PASSWORD_VAR=CLAUDE_BOT_PASSWORD tests/radius/live.sh 10.2.99.91 claude-bot \
~/.config/claude-bot/authentik ~/.config/claude-bot/radius-secret 20
```
For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
`medium=[00000006]` IEEE-802.
## Client setup (SSID "Renner 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` (mandatory; empty sends the login to the
UDM, where it fails)
- 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`
- Anonymous identity: `anonymous` (mandatory)
- 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\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
logcli instant-query --quiet \
'max_over_time({source="unifi-syslog",app="stahtd"} |~ "\"vap\": ?\"<vap>\"" | regexp "radius_auth_delta\": ?\"?(?P<d>[0-9]+)" | unwrap d [24h])'
```
| SSID | VAP(s) | Events | avg radius_auth_delta (us) | max radius_auth_delta (us) |
|---|---|---|---|---|
| Renner Test | | | | |
| Renner UniFi | | | | |
@@ -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=<id>"`.
- [ ] **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=<id>` into `Tunnel-Type := VLAN`, `Tunnel-Medium-Type := IEEE-802`, `Tunnel-Private-Group-Id := <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: '^(?P<v>3\.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`.
@@ -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/<app>`, 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 = <dot1x_vlan>`.
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).
@@ -1447,3 +1447,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
+4
View File
@@ -73,6 +73,10 @@ spec:
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# authentik's subclass joins over empty tables get planner costs far above
# jit_above_cost; LLVM compilation then takes ~420 ms for a query that
# executes in 0.1 ms (every flow start, every RADIUS/LDAP login).
jit: "off"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
@@ -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
@@ -0,0 +1,404 @@
# FreeRADIUS 3.2 config for 802.1X: outer identity anonymous[@...] -> EAP-TTLS
# terminated here, inner PAP proxied to the authentik outpost
# ak-outpost-8021x-radius; any other outer identity -> whole EAP conversation
# proxied to the UDM RADIUS (UniFi Endpoint). 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
annotations:
# FreeRADIUS uses ${...} itself; Flux postBuild substitution must skip it.
kustomize.toolkit.fluxcd.io/substitute: disabled
data:
radiusd.conf: |
prefix = /opt
exec_prefix = ${prefix}
sbindir = ${exec_prefix}/sbin
libdir = ${exec_prefix}/lib
raddbdir = /run/raddb/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 every home server's response_window (proxy.conf): never
# forward a NAS retransmit to a home server. 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
}
# UDM RADIUS (FreeRADIUS) for UniFi Endpoint accounts; gets the whole
# outer EAP conversation. Address: UDM on VLAN 11 "(11) RADIUS", reached
# over net1 (freeradius-network-attachment.yaml); the UDM accepts
# RADIUS clients from 10.2.11.0/24 only.
home_server udm {
type = auth
ipaddr = 10.2.11.1
port = 1812
proto = udp
secret = $ENV{UDM_RADIUS_SECRET}
# RFC 3579: every reply to an EAP request carries Message-Authenticator.
require_message_authenticator = yes
# Less than proxy_dedup_window (radiusd.conf).
response_window = 5
zombie_period = 20
# Probe with an Access-Request for a user that cannot exist (no
# EAP-Message, so no Endpoint login); the reject counts as an answer.
# Sent only while zombie or dead.
status_check = request
username = "udm-status-probe.invalid"
password = "not-a-secret"
check_interval = 6
check_timeout = 4
num_answers_to_alive = 3
}
home_server_pool udm {
type = fail-over
home_server = udm
}
realm udm {
auth_pool = udm
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 {
# NAS listeners on eth0 only; net1 (VLAN 11) carries the UDM proxy
# traffic and nothing else.
listen {
type = auth
ipaddr = *
port = 1812
interface = eth0
}
listen {
type = acct
ipaddr = *
port = 1813
interface = eth0
}
listen {
type = auth
ipv6addr = ::
port = 1812
interface = eth0
}
listen {
type = acct
ipv6addr = ::
port = 1813
interface = eth0
}
authorize {
# EAP only.
if (!&EAP-Message) {
reject
}
# Outer identity anonymous[@realm]: authentik clients, EAP-TTLS here.
# Any other: UniFi Endpoint account (real name as outer identity);
# every round goes to the UDM unprocessed.
if (&User-Name && &User-Name !~ /^anonymous(@.*)?$/i) {
update control {
&Proxy-To-Realm := udm
}
return
}
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
}
# UDM replies pass unchanged.
post-auth {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
update reply {
&Cisco-AVPair !* ANY
}
}
Post-Auth-Type REJECT {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
pre-proxy {
}
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
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
@@ -0,0 +1,48 @@
---
# FreeRADIUS net1: macvlan on radius0, VLAN 11 "(11) RADIUS" (VLANConfig in
# kubernetes/talos/patches/k8s-{1,2,3}.yaml). Carries only the proxy traffic
# to home server `udm` (freeradius-config.yaml): the UDM RADIUS accepts
# clients from 10.2.11.0/24, not from the pod CIDR. Bypasses Cilium policy.
# Address leased from the UDM; nothing references it.
#
# net1 holds only its connected /24, so the unbound proxy socket reaches the
# UDM via net1 with the leased source. No sbr: it moves that route into a
# source-based table, and the proxy would leave via eth0 from the pod IP.
# skipDefault + subnet-mask: the lease is requested without routers, so no
# default route lands on net1 (dnsmasq sends only requested options).
# IPv6 off on net1: VLAN 11 sends RAs (default route, SLAAC); the UDM
# RADIUS clients are IPv4 only.
#
# Requires cni-dhcp-daemon (infrastructure/multus) and VLAN 11 in Cilium's
# bpf.vlanBypass (infrastructure/cilium/helmrelease.yaml).
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: freeradius-udm
namespace: authentik
spec:
config: |
{
"cniVersion": "0.3.1",
"name": "freeradius-udm",
"plugins": [
{
"type": "macvlan",
"master": "radius0",
"mode": "bridge",
"ipam": {
"type": "dhcp",
"request": [
{ "skipDefault": true },
{ "option": "subnet-mask" }
]
}
},
{
"type": "tuning",
"sysctl": {
"net.ipv6.conf.IFNAME.disable_ipv6": "1"
}
}
]
}
+235
View File
@@ -0,0 +1,235 @@
# 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
annotations:
# net1 on VLAN 11: the path to the UDM RADIUS.
k8s.v1.cni.cncf.io/networks: freeradius-udm
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
# FreeRADIUS refuses a config dir that is world-writable; kubelet's
# volume root is, so the config is copied to a dir the pod owns.
initContainers:
- name: raddb-copy
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
command:
- /bin/sh
- -ec
args:
- mkdir -p /run/raddb/raddb && cp -rL /etc/raddb-src/. /run/raddb/raddb/ && chmod 0750 /run/raddb/raddb
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: raddb
mountPath: /etc/raddb-src
readOnly: true
- name: raddb-run
mountPath: /run/raddb
containers:
- name: freeradius
image: docker.io/freeradius/freeradius-server:3.2.10-alpine # {"$imagepolicy": "flux-system:freeradius"}
args:
- -f
- -l
- stdout
- -d
- /run/raddb/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
- name: UDM_RADIUS_SECRET
valueFrom:
secretKeyRef:
name: radius-secrets
key: UDM_RADIUS_SECRET
ports:
- name: radius
containerPort: 1812
protocol: UDP
- name: radius-acct
containerPort: 1813
protocol: UDP
readinessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/raddb/probe/status
- 127.0.0.1:18121
- status
- status-probe
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- /opt/bin/radclient
- -d
- /run/raddb/raddb
- -q
- -r
- "1"
- -t
- "2"
- -f
- /run/raddb/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-run
mountPath: /run/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
- name: raddb-run
emptyDir:
medium: Memory
sizeLimit: 4Mi
# 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
+20 -2
View File
@@ -69,6 +69,11 @@ spec:
secretKeyRef:
name: ak-pg-app
key: password
# Default 0 opens a new Postgres connection per request.
- name: AUTHENTIK_POSTGRESQL__CONN_MAX_AGE
value: "60"
- name: AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECKS
value: "true"
- name: AUTHENTIK_EMAIL__PASSWORD
valueFrom:
secretKeyRef:
@@ -80,8 +85,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:
@@ -164,6 +169,19 @@ spec:
hosts:
- 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:
@@ -10,6 +10,12 @@ resources:
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
- radius-cert.yaml
- radius-externalsecret.yaml
- freeradius-config.yaml
- freeradius-network-attachment.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
@@ -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
@@ -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
@@ -91,4 +91,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
@@ -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.
+2 -2
View File
@@ -93,7 +93,7 @@ spec:
type: RuntimeDefault
containers:
- name: lidarr
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
@@ -140,7 +140,7 @@ spec:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls47 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
@@ -95,19 +95,20 @@ spec:
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
# filter" in cilium_drop_count_total.
#
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
# carry an address, so it sees mgmt0's frames already untagged by the
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
# address-less, so its frames only meet the filter on the shared parent,
# still tagged. Adding 40 would change nothing and would touch the
# datapath carrying etcd and the API.
# Only the address-less macvlan parents need listing: VLAN 30 (ens19,
# v30iot) and VLAN 11 (radius0, freeradius-udm). Cilium attaches to
# devices that carry an address, so it sees mgmt0's frames already
# untagged by the 8021q layer — nothing for a VLAN filter to match.
# ens19 and radius0 are deliberately address-less, so their frames only
# meet the filter on the shared parent, still tagged. Adding 40 would
# change nothing and would touch the datapath carrying etcd and the API.
#
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
# "no buffer space available" (#592).
#
# Same three-copy problem as policyMapMax above. Takes an agent restart.
vlanBypass: [30]
vlanBypass: [30, 11]
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
# redirects the socket directly to the backend pod instead of sending
# traffic out to the VIP and back (which fails with L2 announcements).
@@ -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: '^(?P<v>3\.2\.\d+)-alpine$'
extract: '$v'
policy:
semver:
range: "3.2.x"
@@ -12,6 +12,7 @@ resources:
- cloudflare-lb-sync.yaml
- cni-plugins.yaml
- cnpg-barman-plugin.yaml
- freeradius.yaml
- gatus.yaml
- gitea-mcp.yaml
- gotenberg.yaml
+15
View File
@@ -8,6 +8,10 @@ machine:
# 10.2.30.0/24 route makes node replies bypass the UDM and breaks every
# DNATed flow.
net.ipv6.conf.ens19.disable_ipv6: "1"
# radius0 (VLAN 11) is LINK ONLY as well: the macvlan parent for the
# FreeRADIUS attachment. VLAN 11 announces RAs; the node must not
# autoconfigure from them.
net.ipv6.conf.radius0.disable_ipv6: "1"
# These two sit on the untagged fallback network, where nothing may run.
# Left enabled they autoconfigure a ULA and an IPv6 default route from its
# RAs, which ties with mgmt0's and costs the cluster IPv6 egress.
@@ -168,6 +172,17 @@ vlanID: 30
parent: uplink
up: true
---
# VLAN 11, UniFi "(11) RADIUS": link only, no addresses, no DHCP. Macvlan
# parent of the freeradius-udm attachment
# (kubernetes/apps/authentik/freeradius-network-attachment.yaml), which names
# it as `master`; renaming this link means editing that too.
apiVersion: v1alpha1
kind: VLANConfig
name: radius0
vlanID: 11
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only: the VXLAN overlay rides it once the
# kubelet node IP moves here, and nothing on it is routed off the switch.
#
+10
View File
@@ -7,6 +7,8 @@ machine:
# macvlan parent for the v30iot pod attachments; host addresses on that L2
# make node replies bypass the UDM and break every UDM-DNATed flow.
net.ipv6.conf.ens19.disable_ipv6: "1"
# radius0 (VLAN 11): link only — see k8s-1.yaml.
net.ipv6.conf.radius0.disable_ipv6: "1"
# Same for the physical ports on the untagged fallback network: left
# enabled they take an IPv6 default route from its RAs and cost the
# cluster IPv6 egress.
@@ -115,6 +117,14 @@ vlanID: 30
parent: uplink
up: true
---
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
apiVersion: v1alpha1
kind: VLANConfig
name: radius0
vlanID: 11
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
# and no DHCP — see k8s-1.yaml.
apiVersion: v1alpha1
+10
View File
@@ -4,6 +4,8 @@ machine:
sysctls:
# v30iot NIC carries no host addressing — see k8s-1.yaml.
net.ipv6.conf.ens19.disable_ipv6: "1"
# radius0 (VLAN 11): link only — see k8s-1.yaml.
net.ipv6.conf.radius0.disable_ipv6: "1"
# Same for the physical ports on the untagged fallback network — see
# k8s-1.yaml.
net.ipv6.conf.uplink.disable_ipv6: "1"
@@ -102,6 +104,14 @@ vlanID: 30
parent: uplink
up: true
---
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
apiVersion: v1alpha1
kind: VLANConfig
name: radius0
vlanID: 11
parent: uplink
up: true
---
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
# and no DHCP — see k8s-1.yaml.
apiVersion: v1alpha1
+51
View File
@@ -0,0 +1,51 @@
# 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 a mock UDM RADIUS, 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`. Cases use outer identity `anonymous` unless
noted.
| 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 or the UDM |
| `anon-realm` (outer identity `Anonymous@Example.Org`) | local TTLS, as `vlan-tunnel` |
| `udm-accept`, `v6-udm-accept` (outer identity = account name) | both EAP rounds reach the mock UDM (second with its `State`); its Access-Accept reaches the NAS with Tunnel-* VLAN 200 and MS-MPPE keys unchanged |
| `udm-reject` | the mock UDM's Access-Reject reaches the NAS within 1 s |
| all `anonymous` cases | never reach the mock UDM |
| `recovery` | outpost stalls until marked dead, then returns: login succeeds within 20 s |
Pieces:
- `mock_outpost.py`: stand-in outpost on UDP 1812, reachable under the
outpost Service FQDN through a Docker network alias.
- `mock_udm.py`: stand-in UDM RADIUS at the `udm` home server address from
`proxy.conf` (`10.2.11.1`), on a second internal bridge `radtest-udm` (that
address's /24, no host address). FreeRADIUS gets a second interface there,
as `net1` in the pod: only the connected /24, no default route, and the
proxy socket picks it by route lookup. The macvlan/DHCP attachment itself
is not tested. Answers round 1 with Challenge + `State` +
EAP-TTLS Start (or Reject for `endpoint-reject`), round 2 with Accept +
EAP-Success; terminates no EAP.
- `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.
`live.sh` logs in against the deployed FreeRADIUS instead; usage in
`docs/radius/dot1x.md` ("Live login check").
+4
View File
@@ -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/*
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Live EAP-TTLS/PAP login against the deployed FreeRADIUS from a test client
# (10.2.50.0/24 / fd02:50::/64, client "test"/"test6").
#
# Usage: tests/radius/live.sh <server-ip> <identity> <password-file> <secret-file> [count]
# password-file: the password on the first line, or `export NAME=value` lines
# (LIVE_PASSWORD_VAR selects the variable, default CLAUDE_BOT_TOKEN)
# secret-file: `bao kv get` output of apps/radius, or the bare
# DOT1X_TEST_SECRET on the first line
# count: number of logins (default 1)
# LIVE_SOURCE: optional local address for eapol_test -A (must be in a
# test client range)
# Prints one line per login: result, duration, VLAN. Secrets are never printed.
set -euo pipefail
server="$1"
identity="$2"
password_file="$3"
secret_file="$4"
count="${5:-1}"
password_var="${LIVE_PASSWORD_VAR:-CLAUDE_BOT_TOKEN}"
read_password() {
local line
line=$(grep -m1 -E "^export ${password_var}=" "$password_file" || true)
if [ -n "$line" ]; then
line="${line#*=}"
line="${line#\"}"; line="${line%\"}"
line="${line#\'}"; line="${line%\'}"
printf '%s' "$line"
else
head -n1 "$password_file"
fi
}
read_secret() {
local s
s=$(sed -n 's/.*DOT1X_TEST_SECRET:\([^] ]*\).*/\1/p' "$secret_file")
if [ -n "$s" ]; then printf '%s' "$s"; else head -n1 "$secret_file"; fi
}
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
chmod 700 "$work"
password=$(read_password)
secret=$(read_secret)
[ -n "$password" ] || { echo "no password found in $password_file" >&2; exit 2; }
[ -n "$secret" ] || { echo "no DOT1X_TEST_SECRET found in $secret_file" >&2; exit 2; }
umask 077
cat > "$work/eapol.conf" <<EOF
network={
ssid="Renner Test"
key_mgmt=WPA-EAP
eap=TTLS
identity="${identity}"
anonymous_identity="anonymous"
ca_cert="/etc/ssl/certs/ca-certificates.crt"
domain_suffix_match="radius.renner.uno"
phase2="auth=PAP"
password="${password}"
}
EOF
for i in $(seq 1 "$count"); do
start=$(date +%s%N)
out=$(eapol_test -c "$work/eapol.conf" -a "$server" ${LIVE_SOURCE:+-A "$LIVE_SOURCE"} -s "$secret" -t 10 2>&1 || true)
end=$(date +%s%N)
ms=$(( (end - start) / 1000000 ))
result=$(tail -n1 <<<"$out")
case "$result" in SUCCESS|FAILURE) ;; *) result="NO-RESULT" ;; esac
# Attribute lines of the Access-Accept as the NAS received it (hex values).
attrs=$(awk '/RADIUS message: code=2 /{f=1; next} f && /^ /{print; next} f{f=0}' <<<"$out")
attr() { awk -v a="Attribute $1 " 'index($0, a){getline; sub(/^ *Value: /, ""); print; exit}' <<<"$attrs"; }
echo "login $i: $result ${ms}ms tunnel-type=[$(attr 64)] medium=[$(attr 65)] group-id=[$(attr 81)]"
done
+120
View File
@@ -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()
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():
password = os.environ["MOCK_PASSWORD"].encode()
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()
+120
View File
@@ -0,0 +1,120 @@
"""Stand-in for the UDM RADIUS (UDP 1812) that UniFi Endpoint logins go to.
Terminates nothing; logs what arrives and answers just enough EAP to prove
that FreeRADIUS proxies the whole conversation:
round 1 (EAP-Response/Identity, no State)
identity endpoint-reject Access-Reject + EAP-Failure
any other identity Access-Challenge + State + EAP-Request/TTLS-Start
round 2 (State from round 1)
Access-Accept + EAP-Success, Tunnel-* VLAN 200, MS-MPPE-Recv-Key 0x11 * 32,
MS-MPPE-Send-Key 0x22 * 32
no EAP-Message (status probe), unknown State Access-Reject
All replies carry Message-Authenticator and echo Proxy-State.
"""
import hashlib
import os
import socket
import struct
from mock_outpost import ATTR_USER_NAME, attr, build_reply, parse_attrs, request_ma_ok, vsa
SECRET = os.environ["MOCK_SECRET"].encode()
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT, ACCESS_CHALLENGE = 1, 2, 3, 11
ATTR_STATE, ATTR_PROXY_STATE, ATTR_EAP_MESSAGE = 24, 33, 79
EAP_REQUEST, EAP_RESPONSE, EAP_SUCCESS, EAP_FAILURE = 1, 2, 3, 4
EAP_IDENTITY, EAP_TTLS = 1, 21
STATE = b"udm-mock-state-1"
RECV_KEY = b"\x11" * 32
SEND_KEY = b"\x22" * 32
TUNNEL_VLAN_200 = (
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"200") # Tunnel-Private-Group-Id = "200"
)
def mppe_key(key, req_auth, salt):
"""RFC 2548 2.4.2 encryption of an MS-MPPE key."""
plain = bytes([len(key)]) + key
plain += b"\x00" * (-len(plain) % 16)
out, last = b"", req_auth + salt
for i in range(0, len(plain), 16):
b = hashlib.md5(SECRET + last).digest()
chunk = bytes(p ^ k for p, k in zip(plain[i : i + 16], b))
out += chunk
last = chunk
return salt + out
def eap(code, ident, payload=b""):
return struct.pack("!BBH", code, ident, 4 + len(payload)) + payload
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 1812))
print("mock udm 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"").decode(errors="replace")
state = next((v for t, _, v in attrs if t == ATTR_STATE), None)
eap_msg = b"".join(v for t, _, v in attrs if t == ATTR_EAP_MESSAGE)
echo = b"".join(attr(ATTR_PROXY_STATE, v) for t, _, v in attrs if t == ATTR_PROXY_STATE)
ma = request_ma_ok(packet, attrs)
if len(eap_msg) < 5:
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo, True)
print(f"peer={peer[0]} probe user={user} request_ma={ma} -> reject", flush=True)
elif state is None:
eap_id = eap_msg[1]
eap_ok = eap_msg[0] == EAP_RESPONSE and eap_msg[4] == EAP_IDENTITY
identity = eap_msg[5:].decode(errors="replace")
if identity == "endpoint-reject":
body = attr(ATTR_EAP_MESSAGE, eap(EAP_FAILURE, eap_id))
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo + body, True)
result = "reject"
else:
body = attr(ATTR_STATE, STATE) + attr(
ATTR_EAP_MESSAGE, eap(EAP_REQUEST, (eap_id + 1) & 0xFF, bytes([EAP_TTLS, 0x20]))
)
reply = build_reply(ACCESS_CHALLENGE, ident, req_auth, echo + body, True)
result = "challenge"
print(
f"peer={peer[0]} round=1 user={user} eap_identity_response={eap_ok} "
f"eap_identity={identity} request_ma={ma} -> {result}",
flush=True,
)
elif state == STATE:
eap_id = eap_msg[1]
eap_type = eap_msg[4]
body = (
TUNNEL_VLAN_200
+ vsa(311, 17, mppe_key(RECV_KEY, req_auth, b"\x80\x01"))
+ vsa(311, 16, mppe_key(SEND_KEY, req_auth, b"\x80\x02"))
+ attr(ATTR_EAP_MESSAGE, eap(EAP_SUCCESS, eap_id))
)
reply = build_reply(ACCESS_ACCEPT, ident, req_auth, echo + body, True)
print(
f"peer={peer[0]} round=2 user={user} state_ok=True eap_type={eap_type} "
f"request_ma={ma} -> accept",
flush=True,
)
else:
reply = build_reply(ACCESS_REJECT, ident, req_auth, echo, True)
print(f"peer={peer[0]} user={user} state={state.hex()} unknown -> reject", flush=True)
sock.sendto(reply, peer)
if __name__ == "__main__":
main()
+436
View File
@@ -0,0 +1,436 @@
#!/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
net_udm=radtest-udm
# 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
local n
for n in "$net" "$net_udm"; do
if [[ -n "$(docker network ls -q --filter name="^${n}\$")" ]]; then docker network rm "$n" >/dev/null; fi
done
local vols=()
mapfile -t vols < <(docker volume ls -q --filter name='^radtest-')
if ((${#vols[@]} > 0)); then docker volume rm "${vols[@]}" >/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"
# kubelet creates the projected volume root world-writable
chmod 1777 "$raddb"
}
# Volume mounts of a pod container as docker -v options, as the pod declares
# them: the projected volume is the rendered config, other emptyDirs (except
# the /tmp tmpfs) are named docker volumes.
mount_opts() { # container name; sets mounts[]
local vol name path ro ro_suffix
vol="$(deployment | yq -r '.spec.template.spec.volumes[] | select(.projected) | .name')"
mounts=()
while read -r name path ro; do
ro_suffix=""
if [[ "$ro" == true ]]; then ro_suffix=":ro"; fi
if [[ "$name" == "$vol" ]]; then
mounts+=(-v "$work/raddb:$path${ro_suffix}")
elif [[ "$name" != tmp ]]; then
mounts+=(-v "radtest-$name:$path${ro_suffix}")
fi
done < <(deployment | yq -r --arg n "$1" '(.spec.template.spec.containers + (.spec.template.spec.initContainers // []))[]
| select(.name == $n) | .volumeMounts[] | "\(.name) \(.mountPath) \(.readOnly // false)"')
}
# emptyDir volumes other than /tmp, writable by the pod user like kubelet's
create_volumes() {
local name
while read -r name; do
docker volume create "radtest-$name" >/dev/null
docker run --rm --user 0:0 -v "radtest-$name:/v" "$mock_image" chmod 0777 /v >/dev/null
done < <(deployment | yq -r '.spec.template.spec.volumes[] | select(.emptyDir) | select(.name != "tmp") | .name')
}
# Pod restrictions: non-root, read-only root, no capabilities.
pod_opts=(--user 65534:65534 --read-only --tmpfs /tmp:rw,size=16m --cap-drop ALL --security-opt no-new-privileges)
# Run the Deployment's initContainers exactly as defined.
run_init() {
local names=() n image cmd=() args=()
mapfile -t names < <(deployment | yq -r '.spec.template.spec.initContainers // [] | .[].name')
for n in "${names[@]}"; do
image="$(deployment | yq -r --arg n "$n" '.spec.template.spec.initContainers[] | select(.name == $n) | .image')"
mapfile -t cmd < <(deployment | yq -r --arg n "$n" '.spec.template.spec.initContainers[] | select(.name == $n) | .command[]')
mapfile -t args < <(deployment | yq -r --arg n "$n" '.spec.template.spec.initContainers[] | select(.name == $n) | .args // [] | .[]')
mount_opts "$n"
if docker run --rm --name "radtest-init-$n" --network "$net" "${pod_opts[@]}" "${mounts[@]}" \
--entrypoint "${cmd[0]}" "$image" "${cmd[@]:1}" "${args[@]}" >"$work/init-$n.log" 2>&1; then
pass "initContainer $n"
else
cat "$work/init-$n.log"
fail "initContainer $n"
exit 1
fi
done
}
# --- 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
# UDM home server address from proxy.conf; its /24 is a second internal
# bridge (gateway address moved off the UDM's).
ip_udm="$(awk '$1 == "home_server" && $2 == "udm" {f=1} f && $1 == "ipaddr" {print $3; exit}' "$work/raddb/proxy.conf")"
docker network create --internal --subnet "${ip_udm%.*}.0/24" --gateway "${ip_udm%.*}.254" \
-o com.docker.network.bridge.inhibit_ipv4=true "$net_udm" >/dev/null
local bridge n
for n in "$net" "$net_udm"; do
bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$n")"
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
done
docker run -d --name radtest-udm --network "$net_udm" --ip "$ip_udm" \
-e MOCK_SECRET=dummy-udm_radius_secret \
-v "$here/mock_outpost.py:/mock/mock_outpost.py:ro" -v "$here/mock_udm.py:/mock/mock_udm.py:ro" \
"$mock_image" python3 -u /mock/mock_udm.py >/dev/null
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
create_volumes
run_init
# Same restrictions as the pod.
mount_opts freeradius
local run_opts=("${pod_opts[@]}" "${secret_env[@]}" "${mounts[@]}")
local confdir="" i
for i in "${!args[@]}"; do
if [[ "${args[$i]}" == -d ]]; then confdir="${args[$((i + 1))]}"; fi
done
echo "--- radiusd -XC (config check)"
if docker run --rm --network "$net" "${run_opts[@]}" "$image" radiusd -XC -d "$confdir" >"$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
# Interface names as in the pod: eth0 the NAS side (the listeners bind to
# it), net1 the UDM side. Docker attaches networks in no fixed order.
docker create --name radtest-freeradius "${run_opts[@]}" \
--network "name=$net,ip=$ip_radius,ip6=$ip6_radius,driver-opt=com.docker.network.endpoint.ifname=eth0" \
"$image" "${args[@]}" >/dev/null
docker network connect --driver-opt com.docker.network.endpoint.ifname=net1 "$net_udm" radtest-freeradius
docker start radtest-freeradius >/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 [outer-identity]
cat >"$work/eapol/$1.conf" <<EOF
network={
key_mgmt=WPA-EAP
eap=TTLS
identity="$2"
anonymous_identity="${4:-anonymous}"
password="$3"
phase2="auth=PAP"
ca_cert="/eapol/ca.pem"
domain_suffix_match="radius.renner.uno"
}
EOF
}
eapol() { # name timeout server
docker run --rm --network "$net" --ip "$ip_nas" --ip6 "$ip6_nas" -v "$work/eapol:/eapol:ro" "$eapol_image" \
eapol_test -c "/eapol/$1.conf" -a "$3" -s "dummy-dot1x_test_secret" -t "$2" \
>"$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 [outer-identity]
local c="$1"
eapol_conf "$c" "$2" "$password" "${4:-}"
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 [outer-identity]
local c="$1"
eapol_conf "$c" "$2" "$3" "${6:-}"
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
}
udm_log() { docker logs radtest-udm 2>&1; }
# Non-anonymous outer identity: the whole EAP conversation goes to the mock
# UDM, its Access-Accept reaches the NAS unchanged.
check_udm_accept() { # case user server
local c="$1" u="$2"
eapol_conf "$c" "$u" "$password" "$u"
eapol "$c" 3 "$3"
udm_log | grep -q "round=1 user=$u eap_identity_response=True eap_identity=$u request_ma=True -> challenge" \
&& pass "$c: EAP-Response/Identity forwarded to the UDM" || fail "$c: EAP-Response/Identity forwarded to the UDM"
udm_log | grep -q "round=2 user=$u state_ok=True eap_type=21 request_ma=True -> accept" \
&& pass "$c: second round forwarded with the UDM's State" || fail "$c: second round forwarded with the UDM's State"
grep -q "RADIUS message: code=2 (Access-Accept)" "$work/$c.out" \
&& pass "$c: UDM Access-Accept reaches the NAS" || fail "$c: UDM Access-Accept reaches the NAS"
[[ "$(attr_value "$c" 81)" =~ ^(00)?323030$ ]] && pass "$c: Tunnel-Private-Group-Id 200" \
|| fail "$c: Tunnel-Private-Group-Id 200 (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)')"
local k
for k in "Recv-Key (crypt)|11" "Send-Key (sign)|22"; do
grep -qF "MS-MPPE-${k%|*} - hexdump(len=32): $(printf "${k#*|} %.0s" {1..31})${k#*|}" "$work/$c.out" \
&& pass "$c: MS-MPPE-${k%% *} unchanged" || fail "$c: MS-MPPE-${k%% *} unchanged"
done
}
check_udm_reject() { # case user server
local c="$1" u="$2"
check_reject "$c" "$u" "$password" 1 "$3" "$u"
udm_log | grep -q "round=1 user=$u eap_identity_response=True eap_identity=$u request_ma=True -> reject" \
&& pass "$c: decided by the UDM" || fail "$c: decided by the UDM"
}
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"
# Outer identity routing: anonymous[@realm] (any case) -> local TTLS,
# anything else -> UDM.
check_accept anon-realm vlan-tunnel "$ip_radius" "Anonymous@Example.Org"
check_udm_accept udm-accept endpoint-accept "$ip_radius"
check_udm_reject udm-reject endpoint-reject "$ip_radius"
check_udm_accept v6-udm-accept v6-endpoint-accept "$ip6_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 udm_log | grep -Eiq "eap_identity=anonymous|user=(vlan-|novlan|bad|anonymous)|code=4"; then
fail "anonymous logins and accounting never reach the UDM"
else
pass "anonymous logins and accounting never reach the UDM"
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
echo "--- mock udm log"; udm_log
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 "$@"