Files
homelab/tests/radius/mock_outpost.py
T
claude-botandClaude Opus 5.5 bc698ea22d
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
feat(radius): forward non-anonymous EAP logins to the UDM RADIUS
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

121 lines
3.9 KiB
Python

"""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()