Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
657e3d0d92 | ||
|
|
ac2d5d13a1 | ||
|
|
ed3be3156b | ||
|
|
05d2fcac71 | ||
|
|
697856cbf8 | ||
|
|
4962af5ed1 | ||
|
|
bc698ea22d | ||
|
|
73dd6f6362 | ||
|
|
7434894f14 |
+51
-1
@@ -4,6 +4,54 @@ 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.11.1` UDP 1812 (the UDM on VLAN 11,
|
||||
UniFi network "(11) RADIUS"), shared secret `UDM_RADIUS_SECRET` from OpenBao
|
||||
`apps/radius`. The UDM RADIUS accepts clients from `10.2.11.0/24` only, not
|
||||
from the pod CIDR.
|
||||
|
||||
### Path to the UDM
|
||||
|
||||
Each FreeRADIUS pod has a second interface `net1` on VLAN 11
|
||||
(NetworkAttachmentDefinition `freeradius-udm`,
|
||||
`kubernetes/apps/authentik/freeradius-network-attachment.yaml`):
|
||||
|
||||
- macvlan on the node link `radius0` (VLAN 11 on `uplink`, no host addresses;
|
||||
VLANConfig in `kubernetes/talos/patches/k8s-{1,2,3}.yaml`). VLAN 11 is in
|
||||
Cilium's `bpf.vlanBypass`, otherwise the tagged frames are dropped on
|
||||
`uplink`.
|
||||
- IPv4 leased from the UDM DHCP server (cni-dhcp-daemon), requested without
|
||||
the routers option: `net1` holds only the connected `10.2.11.0/24`, no
|
||||
default route. The proxy socket is not bound to an address; the kernel's
|
||||
route lookup for `10.2.11.1` picks `net1` and its leased address as source.
|
||||
- IPv6 disabled on `net1` (VLAN 11 sends router advertisements).
|
||||
- The NAS listeners are bound to `eth0` (`interface = eth0`); nothing listens
|
||||
on `net1`.
|
||||
- Traffic on `net1` bypasses Cilium; there is no CNP for the UDM hop.
|
||||
|
||||
`radius0` must exist on every node before FreeRADIUS pods are scheduled
|
||||
there: without it the macvlan ADD fails and the pod stays in
|
||||
`ContainerCreating`.
|
||||
|
||||
## authentik objects
|
||||
|
||||
Defined in `kubernetes/apps/authentik/blueprints-configmap.yaml`, key
|
||||
@@ -109,7 +157,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)
|
||||
@@ -119,6 +168,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
|
||||
|
||||
@@ -73,6 +73,10 @@ 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.
|
||||
|
||||
@@ -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,42 @@ data:
|
||||
nostrip
|
||||
}
|
||||
|
||||
# UDM RADIUS (FreeRADIUS) for UniFi Endpoint accounts; gets the whole
|
||||
# outer EAP conversation. Address: UDM on VLAN 11 "(11) RADIUS", reached
|
||||
# over net1 (freeradius-network-attachment.yaml); the UDM accepts
|
||||
# RADIUS clients from 10.2.11.0/24 only.
|
||||
home_server udm {
|
||||
type = auth
|
||||
ipaddr = 10.2.11.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
|
||||
@@ -194,35 +233,50 @@ data:
|
||||
|
||||
sites-enabled_default: |
|
||||
server default {
|
||||
# NAS listeners on eth0 only; net1 (VLAN 11) carries the UDM proxy
|
||||
# traffic and nothing else.
|
||||
listen {
|
||||
type = auth
|
||||
ipaddr = *
|
||||
port = 1812
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipaddr = *
|
||||
port = 1813
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = auth
|
||||
ipv6addr = ::
|
||||
port = 1812
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipv6addr = ::
|
||||
port = 1813
|
||||
interface = eth0
|
||||
}
|
||||
|
||||
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 +299,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 +324,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
# FreeRADIUS net1: macvlan on radius0, VLAN 11 "(11) RADIUS" (VLANConfig in
|
||||
# kubernetes/talos/patches/k8s-{1,2,3}.yaml). Carries only the proxy traffic
|
||||
# to home server `udm` (freeradius-config.yaml): the UDM RADIUS accepts
|
||||
# clients from 10.2.11.0/24, not from the pod CIDR. Bypasses Cilium policy.
|
||||
# Address leased from the UDM; nothing references it.
|
||||
#
|
||||
# net1 holds only its connected /24, so the unbound proxy socket reaches the
|
||||
# UDM via net1 with the leased source. No sbr: it moves that route into a
|
||||
# source-based table, and the proxy would leave via eth0 from the pod IP.
|
||||
# skipDefault + subnet-mask: the lease is requested without routers, so no
|
||||
# default route lands on net1 (dnsmasq sends only requested options).
|
||||
# IPv6 off on net1: VLAN 11 sends RAs (default route, SLAAC); the UDM
|
||||
# RADIUS clients are IPv4 only.
|
||||
#
|
||||
# Requires cni-dhcp-daemon (infrastructure/multus) and VLAN 11 in Cilium's
|
||||
# bpf.vlanBypass (infrastructure/cilium/helmrelease.yaml).
|
||||
apiVersion: k8s.cni.cncf.io/v1
|
||||
kind: NetworkAttachmentDefinition
|
||||
metadata:
|
||||
name: freeradius-udm
|
||||
namespace: authentik
|
||||
spec:
|
||||
config: |
|
||||
{
|
||||
"cniVersion": "0.3.1",
|
||||
"name": "freeradius-udm",
|
||||
"plugins": [
|
||||
{
|
||||
"type": "macvlan",
|
||||
"master": "radius0",
|
||||
"mode": "bridge",
|
||||
"ipam": {
|
||||
"type": "dhcp",
|
||||
"request": [
|
||||
{ "skipDefault": true },
|
||||
{ "option": "subnet-mask" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tuning",
|
||||
"sysctl": {
|
||||
"net.ipv6.conf.IFNAME.disable_ipv6": "1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,9 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: freeradius
|
||||
annotations:
|
||||
# net1 on VLAN 11: the path to the UDM RADIUS.
|
||||
k8s.v1.cni.cncf.io/networks: freeradius-udm
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
affinity:
|
||||
@@ -89,6 +92,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
|
||||
|
||||
@@ -69,6 +69,11 @@ 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:
|
||||
|
||||
@@ -13,6 +13,7 @@ resources:
|
||||
- radius-cert.yaml
|
||||
- radius-externalsecret.yaml
|
||||
- freeradius-config.yaml
|
||||
- freeradius-network-attachment.yaml
|
||||
- freeradius.yaml
|
||||
- freeradius-cnp.yaml
|
||||
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
|
||||
|
||||
@@ -95,19 +95,20 @@ spec:
|
||||
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
|
||||
# filter" in cilium_drop_count_total.
|
||||
#
|
||||
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
|
||||
# carry an address, so it sees mgmt0's frames already untagged by the
|
||||
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
|
||||
# address-less, so its frames only meet the filter on the shared parent,
|
||||
# still tagged. Adding 40 would change nothing and would touch the
|
||||
# datapath carrying etcd and the API.
|
||||
# Only the address-less macvlan parents need listing: VLAN 30 (ens19,
|
||||
# v30iot) and VLAN 11 (radius0, freeradius-udm). Cilium attaches to
|
||||
# devices that carry an address, so it sees mgmt0's frames already
|
||||
# untagged by the 8021q layer — nothing for a VLAN filter to match.
|
||||
# ens19 and radius0 are deliberately address-less, so their frames only
|
||||
# meet the filter on the shared parent, still tagged. Adding 40 would
|
||||
# change nothing and would touch the datapath carrying etcd and the API.
|
||||
#
|
||||
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
|
||||
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
|
||||
# "no buffer space available" (#592).
|
||||
#
|
||||
# Same three-copy problem as policyMapMax above. Takes an agent restart.
|
||||
vlanBypass: [30]
|
||||
vlanBypass: [30, 11]
|
||||
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
|
||||
# redirects the socket directly to the backend pod instead of sending
|
||||
# traffic out to the VIP and back (which fails with L2 announcements).
|
||||
|
||||
@@ -8,6 +8,10 @@ machine:
|
||||
# 10.2.30.0/24 route makes node replies bypass the UDM and breaks every
|
||||
# DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11) is LINK ONLY as well: the macvlan parent for the
|
||||
# FreeRADIUS attachment. VLAN 11 announces RAs; the node must not
|
||||
# autoconfigure from them.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# These two sit on the untagged fallback network, where nothing may run.
|
||||
# Left enabled they autoconfigure a ULA and an IPv6 default route from its
|
||||
# RAs, which ties with mgmt0's and costs the cluster IPv6 egress.
|
||||
@@ -168,6 +172,17 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, UniFi "(11) RADIUS": link only, no addresses, no DHCP. Macvlan
|
||||
# parent of the freeradius-udm attachment
|
||||
# (kubernetes/apps/authentik/freeradius-network-attachment.yaml), which names
|
||||
# it as `master`; renaming this link means editing that too.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only: the VXLAN overlay rides it once the
|
||||
# kubelet node IP moves here, and nothing on it is routed off the switch.
|
||||
#
|
||||
|
||||
@@ -7,6 +7,8 @@ machine:
|
||||
# macvlan parent for the v30iot pod attachments; host addresses on that L2
|
||||
# make node replies bypass the UDM and break every UDM-DNATed flow.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11): link only — see k8s-1.yaml.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# Same for the physical ports on the untagged fallback network: left
|
||||
# enabled they take an IPv6 default route from its RAs and cost the
|
||||
# cluster IPv6 egress.
|
||||
@@ -115,6 +117,14 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
|
||||
# and no DHCP — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
@@ -4,6 +4,8 @@ machine:
|
||||
sysctls:
|
||||
# v30iot NIC carries no host addressing — see k8s-1.yaml.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
# radius0 (VLAN 11): link only — see k8s-1.yaml.
|
||||
net.ipv6.conf.radius0.disable_ipv6: "1"
|
||||
# Same for the physical ports on the untagged fallback network — see
|
||||
# k8s-1.yaml.
|
||||
net.ipv6.conf.uplink.disable_ipv6: "1"
|
||||
@@ -102,6 +104,14 @@ vlanID: 30
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# VLAN 11, "(11) RADIUS": link only, no host addressing — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: radius0
|
||||
vlanID: 11
|
||||
parent: uplink
|
||||
up: true
|
||||
---
|
||||
# Pod network, VLAN 48. Node-to-node only, statically addressed, no gateway
|
||||
# and no DHCP — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
|
||||
+19
-4
@@ -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,25 @@ 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` (`10.2.11.1`), on a second internal bridge `radtest-udm` (that
|
||||
address's /24, no host address). FreeRADIUS gets a second interface there,
|
||||
as `net1` in the pod: only the connected /24, no default route, and the
|
||||
proxy socket picks it by route lookup. The macvlan/DHCP attachment itself
|
||||
is not tested. 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`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
+88
-18
@@ -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" \
|
||||
@@ -167,14 +183,14 @@ start() {
|
||||
|
||||
# Same restrictions as the pod.
|
||||
mount_opts freeradius
|
||||
local run_opts=(--network "$net" "${pod_opts[@]}" "${secret_env[@]}" "${mounts[@]}")
|
||||
local run_opts=("${pod_opts[@]}" "${secret_env[@]}" "${mounts[@]}")
|
||||
local confdir="" i
|
||||
for i in "${!args[@]}"; do
|
||||
if [[ "${args[$i]}" == -d ]]; then confdir="${args[$((i + 1))]}"; fi
|
||||
done
|
||||
|
||||
echo "--- radiusd -XC (config check)"
|
||||
if docker run --rm "${run_opts[@]}" "$image" radiusd -XC -d "$confdir" >"$work/configcheck.log" 2>&1 \
|
||||
if docker run --rm --network "$net" "${run_opts[@]}" "$image" radiusd -XC -d "$confdir" >"$work/configcheck.log" 2>&1 \
|
||||
&& grep -q "Configuration appears to be OK" "$work/configcheck.log"; then
|
||||
pass "radiusd -XC: Configuration appears to be OK"
|
||||
else
|
||||
@@ -183,7 +199,13 @@ start() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run -d --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null
|
||||
# Interface names as in the pod: eth0 the NAS side (the listeners bind to
|
||||
# it), net1 the UDM side. Docker attaches networks in no fixed order.
|
||||
docker create --name radtest-freeradius "${run_opts[@]}" \
|
||||
--network "name=$net,ip=$ip_radius,ip6=$ip6_radius,driver-opt=com.docker.network.endpoint.ifname=eth0" \
|
||||
"$image" "${args[@]}" >/dev/null
|
||||
docker network connect --driver-opt com.docker.network.endpoint.ifname=net1 "$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 +225,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 +255,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 +274,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 +286,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 +394,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 +416,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
|
||||
|
||||
Reference in New Issue
Block a user