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>
This commit is contained in:
2026-11-04 13:13:07 +01:00
co-authored by Claude Opus 5.5
parent 73dd6f6362
commit bc698ea22d
8 changed files with 347 additions and 34 deletions
+28 -1
View File
@@ -4,6 +4,31 @@ 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
@@ -83,7 +108,8 @@ 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`
- Anonymous identity: `anonymous` (mandatory; empty sends the login to the
UDM, where it fails)
- Password: authentik password
### Linux (NetworkManager)
@@ -93,6 +119,7 @@ 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
@@ -23,6 +23,26 @@ 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,6 +1,8 @@
# 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.
# 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
@@ -32,9 +34,10 @@ data:
max_request_time = 30
cleanup_delay = 5
# Greater than response_window (proxy.conf): never forward a NAS
# retransmit to the outpost. A TTLS inner request sent twice to the home
# server leaves a freed request in the proxy hash; radiusd segfaults.
# 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
@@ -152,6 +155,41 @@ 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
@@ -219,10 +257,19 @@ data:
}
authorize {
# EAP-TTLS only.
# 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
@@ -245,12 +292,23 @@ data:
ok
}
# UDM replies pass unchanged.
post-auth {
update reply {
&Cisco-AVPair !* ANY
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
update reply {
&Cisco-AVPair !* ANY
}
}
Post-Auth-Type REJECT {
eap
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
@@ -259,7 +317,12 @@ data:
# Runs the TTLS callback that hands the outpost reply back into the tunnel.
post-proxy {
eap
if (&control:Proxy-To-Realm == "udm") {
noop
}
else {
eap
}
}
}
@@ -89,6 +89,11 @@ 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
+16 -4
View File
@@ -7,9 +7,11 @@ tests/radius/run.sh
Runs the production FreeRADIUS config from
`kubernetes/apps/authentik/freeradius-config.yaml` (rendered through the
projected volume in `freeradius.yaml`, same image, args and secret env names)
in Docker against a mock authentik outpost, and drives it with `eapol_test`
(EAP-TTLS, inner PAP) as a NAS in the test client ranges `10.2.50.0/24` and
`fd02:50::/64`. The v6 cases (`v6-*`) hit the IPv6 listeners and client `test6`.
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 |
|---|---|
@@ -17,12 +19,22 @@ in Docker against a mock authentik outpost, and drives it with `eapol_test`
| `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 |
| 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`, 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`,
+2 -2
View File
@@ -15,7 +15,6 @@ 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
@@ -85,6 +84,7 @@ 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
@@ -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()
+82 -16
View File
@@ -10,6 +10,7 @@ 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
@@ -32,7 +33,10 @@ cleanup() {
local ids=()
mapfile -t ids < <(docker ps -aq --filter name='^radtest-')
if ((${#ids[@]} > 0)); then docker rm -f "${ids[@]}" >/dev/null; fi
if [[ -n "$(docker network ls -q --filter name="^${net}\$")" ]]; then docker network rm "$net" >/dev/null; fi
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
@@ -147,14 +151,26 @@ 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
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
# 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" \
@@ -183,7 +199,9 @@ start() {
exit 1
fi
docker run -d --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null
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
sleep 2
if [[ "$(docker inspect -f '{{.State.Running}}' radtest-freeradius)" != true ]]; then
docker logs radtest-freeradius
@@ -203,13 +221,13 @@ start() {
fi
}
eapol_conf() { # case user password
eapol_conf() { # case user password [outer-identity]
cat >"$work/eapol/$1.conf" <<EOF
network={
key_mgmt=WPA-EAP
eap=TTLS
identity="$2"
anonymous_identity="anonymous"
anonymous_identity="${4:-anonymous}"
password="$3"
phase2="auth=PAP"
ca_cert="/eapol/ca.pem"
@@ -233,9 +251,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
check_accept() { # case user server [outer-identity]
local c="$1"
eapol_conf "$c" "$2" "$password"
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" \
@@ -252,9 +270,9 @@ check_accept() { # case user server
fi
}
check_reject() { # case user password timeout server
check_reject() { # case user password timeout server [outer-identity]
local c="$1"
eapol_conf "$c" "$2" "$3"
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
@@ -264,6 +282,40 @@ check_reject() { # case user password timeout server
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"
@@ -338,6 +390,13 @@ 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"
@@ -353,9 +412,16 @@ 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