diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md index 464785cb..747305f1 100644 --- a/docs/radius/dot1x.md +++ b/docs/radius/dot1x.md @@ -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@` (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 diff --git a/kubernetes/apps/authentik/freeradius-cnp.yaml b/kubernetes/apps/authentik/freeradius-cnp.yaml index a0c076ca..d43f4549 100644 --- a/kubernetes/apps/authentik/freeradius-cnp.yaml +++ b/kubernetes/apps/authentik/freeradius-cnp.yaml @@ -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 diff --git a/kubernetes/apps/authentik/freeradius-config.yaml b/kubernetes/apps/authentik/freeradius-config.yaml index 637d7074..500e9e43 100644 --- a/kubernetes/apps/authentik/freeradius-config.yaml +++ b/kubernetes/apps/authentik/freeradius-config.yaml @@ -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 + } } } diff --git a/kubernetes/apps/authentik/freeradius.yaml b/kubernetes/apps/authentik/freeradius.yaml index e40411ab..c98c9115 100644 --- a/kubernetes/apps/authentik/freeradius.yaml +++ b/kubernetes/apps/authentik/freeradius.yaml @@ -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 diff --git a/tests/radius/README.md b/tests/radius/README.md index 8350f947..00974fc7 100644 --- a/tests/radius/README.md +++ b/tests/radius/README.md @@ -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`, diff --git a/tests/radius/mock_outpost.py b/tests/radius/mock_outpost.py index 82f0117a..98f1684b 100644 --- a/tests/radius/mock_outpost.py +++ b/tests/radius/mock_outpost.py @@ -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) diff --git a/tests/radius/mock_udm.py b/tests/radius/mock_udm.py new file mode 100644 index 00000000..7e67182f --- /dev/null +++ b/tests/radius/mock_udm.py @@ -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() diff --git a/tests/radius/run.sh b/tests/radius/run.sh index 7ef5c55b..4a4d2dba 100755 --- a/tests/radius/run.sh +++ b/tests/radius/run.sh @@ -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" <&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