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>
121 lines
3.9 KiB
Python
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()
|