Files
homelab/tests/radius/run.sh
T
claude-botandClaude Opus 5.5 05d2fcac71
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
feat(radius): proxy UniFi Endpoint logins to the UDM over VLAN 11
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

437 lines
18 KiB
Bash
Executable File

#!/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 "$@"