Compare commits

..
Author SHA1 Message Date
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
11 changed files with 140 additions and 356 deletions
+27 -28
View File
@@ -4,31 +4,6 @@ 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.47.1` UDP 1812 (the UDM on VLAN 47, same
address as the BGP peer in `kubernetes/infrastructure/post-crds/cilium-bgp.yaml`),
shared secret `UDM_RADIUS_SECRET` from OpenBao `apps/radius`. Egress is
allowed by CNP `freeradius-udm-egress` in `freeradius-cnp.yaml`.
## authentik objects
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
@@ -94,6 +69,32 @@ 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
@@ -108,8 +109,7 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
- 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)
- Anonymous identity: `anonymous`
- Password: authentik password
### Linux (NetworkManager)
@@ -119,7 +119,6 @@ inside the TLS tunnel to FreeRADIUS and is never sent in clear over the air.
- 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
-4
View File
@@ -73,10 +73,6 @@ 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.
@@ -23,26 +23,6 @@ spec:
- port: "1813"
protocol: UDP
---
# FreeRADIUS proxies UniFi Endpoint logins to the UDM RADIUS on VLAN 47.
# 10.2.47.1 is also the UDM BGP peer in
# kubernetes/infrastructure/post-crds/cilium-bgp.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: freeradius-udm-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: freeradius
egress:
- toCIDR:
- 10.2.47.1/32
toPorts:
- ports:
- port: "1812"
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
@@ -1,8 +1,6 @@
# 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.
# FreeRADIUS 3.2 config for 802.1X: EAP-TTLS terminated here, inner PAP
# proxied to the authentik outpost ak-outpost-8021x-radius. Replaces the
# image's raddb completely. Functional test: tests/radius/run.sh.
# VLAN contract: docs/radius/dot1x.md.
apiVersion: v1
kind: ConfigMap
@@ -34,10 +32,9 @@ data:
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.
# Greater than response_window (proxy.conf): never forward a NAS
# retransmit to the outpost. A TTLS inner request sent twice to the home
# server leaves a freed request in the proxy hash; radiusd segfaults.
proxy_dedup_window = 10
max_requests = 16384
hostname_lookups = no
@@ -155,41 +152,6 @@ data:
nostrip
}
# UDM RADIUS (FreeRADIUS) for UniFi Endpoint accounts; gets the whole
# outer EAP conversation. Address: UDM on VLAN 47, also the BGP peer in
# kubernetes/infrastructure/post-crds/cilium-bgp.yaml.
home_server udm {
type = auth
ipaddr = 10.2.47.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
@@ -257,19 +219,10 @@ data:
}
authorize {
# EAP only.
# EAP-TTLS 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
@@ -292,23 +245,12 @@ data:
ok
}
# UDM replies pass unchanged.
post-auth {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
update reply {
&Cisco-AVPair !* ANY
}
update reply {
&Cisco-AVPair !* ANY
}
Post-Auth-Type REJECT {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
eap
}
}
@@ -317,12 +259,7 @@ data:
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
eap
}
}
@@ -89,11 +89,6 @@ spec:
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
@@ -69,11 +69,6 @@ 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:
+7 -16
View File
@@ -7,11 +7,9 @@ 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.
in Docker against a mock authentik outpost, and drives it with `eapol_test`
(EAP-TTLS, inner PAP) as a NAS in the test client ranges `10.2.50.0/24` and
`fd02:50::/64`. The v6 cases (`v6-*`) hit the IPv6 listeners and client `test6`.
| Case | Expected |
|---|---|
@@ -19,22 +17,12 @@ noted.
| `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 |
| Accounting-Request | answered locally, nothing sent to the outpost |
Pieces:
- `mock_outpost.py`: stand-in outpost on UDP 1812, reachable under the
outpost Service FQDN through a Docker network alias.
- `mock_udm.py`: stand-in UDM RADIUS at the `udm` home server address from
`proxy.conf`, on a second internal bridge `radtest-udm` (that address's
/24, no host address). 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`,
@@ -43,3 +31,6 @@ Pieces:
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").
+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
+2 -2
View File
@@ -15,6 +15,7 @@ import socket
import struct
SECRET = os.environ["MOCK_SECRET"].encode()
PASSWORD = os.environ["MOCK_PASSWORD"].encode()
ACCESS_REQUEST, ACCESS_ACCEPT, ACCESS_REJECT = 1, 2, 3
ATTR_USER_NAME, ATTR_USER_PASSWORD, ATTR_MESSAGE_AUTHENTICATOR = 1, 2, 80
@@ -84,7 +85,6 @@ def build_reply(code, ident, req_auth, attrs, with_ma):
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)
@@ -100,7 +100,7 @@ def main():
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
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)
-120
View File
@@ -1,120 +0,0 @@
"""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()
+16 -82
View File
@@ -10,7 +10,6 @@ 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
@@ -33,10 +32,7 @@ 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
if [[ -n "$(docker network ls -q --filter name="^${net}\$")" ]]; then docker network rm "$net" >/dev/null; fi
local vols=()
mapfile -t vols < <(docker volume ls -q --filter name='^radtest-')
if ((${#vols[@]} > 0)); then docker volume rm "${vols[@]}" >/dev/null; fi
@@ -151,26 +147,14 @@ start() {
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
local bridge
bridge="br-$(docker network inspect -f '{{printf "%.12s" .Id}}' "$net")"
if [[ -n "$(ip -4 -o addr show dev "$bridge")" ]] \
|| [[ -n "$(ip -6 -o addr show dev "$bridge" scope global)" ]] \
|| [[ -n "$(ip -6 route show dev "$bridge" "$subnet6")" ]]; then
echo "bridge $bridge got a host address or route in a test range; aborting" >&2
exit 1
fi
docker run -d --name radtest-mock --network "$net" --ip "$ip_mock" \
--network-alias "$outpost_fqdn" \
@@ -199,9 +183,7 @@ start() {
exit 1
fi
docker create --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null
docker network connect "$net_udm" radtest-freeradius
docker start radtest-freeradius >/dev/null
docker run -d --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null
sleep 2
if [[ "$(docker inspect -f '{{.State.Running}}' radtest-freeradius)" != true ]]; then
docker logs radtest-freeradius
@@ -221,13 +203,13 @@ start() {
fi
}
eapol_conf() { # case user password [outer-identity]
eapol_conf() { # case user password
cat >"$work/eapol/$1.conf" <<EOF
network={
key_mgmt=WPA-EAP
eap=TTLS
identity="$2"
anonymous_identity="${4:-anonymous}"
anonymous_identity="anonymous"
password="$3"
phase2="auth=PAP"
ca_cert="/eapol/ca.pem"
@@ -251,9 +233,9 @@ 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]
check_accept() { # case user server
local c="$1"
eapol_conf "$c" "$2" "$password" "${4:-}"
eapol_conf "$c" "$2" "$password"
eapol "$c" 5 "$3"
if [[ "$(tail -n 1 "$work/$c.out")" == SUCCESS ]]; then pass "$c: SUCCESS"; else fail "$c: SUCCESS"; fi
[[ "$(attr_value "$c" 81)" =~ ^(00)?3530$ ]] && pass "$c: Tunnel-Private-Group-Id 50" \
@@ -270,9 +252,9 @@ check_accept() { # case user server [outer-identity]
fi
}
check_reject() { # case user password timeout server [outer-identity]
check_reject() { # case user password timeout server
local c="$1"
eapol_conf "$c" "$2" "$3" "${6:-}"
eapol_conf "$c" "$2" "$3"
eapol "$c" "$4" "$5"
if [[ "$(tail -n 1 "$work/$c.out")" == FAILURE ]] && grep -q "RADIUS message: code=3 (Access-Reject)" "$work/$c.out" \
&& ! grep -q "EAPOL test timed out" "$work/$c.out"; then
@@ -282,40 +264,6 @@ check_reject() { # case user password timeout server [outer-identity]
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"
@@ -390,13 +338,6 @@ main() {
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"
@@ -412,16 +353,9 @@ main() {
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