From 05d2fcac71bce007a96d6f5566488676db41fbaa Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Wed, 4 Nov 2026 15:02:37 +0100 Subject: [PATCH] feat(radius): proxy UniFi Endpoint logins to the UDM over VLAN 11 The UDM RADIUS rejects the pod CIDR as an unknown client; it accepts 10.2.11.0/24. FreeRADIUS pods get net1 on VLAN 11 "(11) RADIUS" and the udm home server moves to 10.2.11.1. - Talos k8s-{1,2,3}: VLANConfig radius0 (VLAN 11 on uplink), no addresses, IPv6 disabled on the host link. - Cilium bpf.vlanBypass gains 11: radius0 is address-less like ens19, so its tagged frames meet the VLAN filter on uplink (#592). - NAD freeradius-udm: macvlan on radius0, DHCP IPAM requesting only subnet-mask (skipDefault), so the lease has no router and net1 holds only the connected /24. No sbr: it moves that route into a source table, and the unbound proxy socket would then reach the UDM via eth0 from the pod IP. tuning disables IPv6 on net1 (VLAN 11 sends RAs). - NAS listeners bound to eth0, so nothing listens on net1. - CNP freeradius-udm-egress removed: net1 traffic bypasses Cilium. - Harness: FreeRADIUS interfaces named eth0/net1 explicitly; Docker attaches networks in no fixed order, and the eth0 bind exposed it. Co-Authored-By: Claude Opus 5.5 --- docs/radius/dot1x.md | 31 ++++++++++-- kubernetes/apps/authentik/freeradius-cnp.yaml | 20 -------- .../apps/authentik/freeradius-config.yaml | 13 +++-- .../freeradius-network-attachment.yaml | 48 +++++++++++++++++++ kubernetes/apps/authentik/freeradius.yaml | 3 ++ kubernetes/apps/authentik/kustomization.yaml | 1 + .../infrastructure/cilium/helmrelease.yaml | 15 +++--- kubernetes/talos/patches/k8s-1.yaml | 15 ++++++ kubernetes/talos/patches/k8s-2.yaml | 10 ++++ kubernetes/talos/patches/k8s-3.yaml | 10 ++++ tests/radius/README.md | 7 ++- tests/radius/run.sh | 12 +++-- 12 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 kubernetes/apps/authentik/freeradius-network-attachment.yaml diff --git a/docs/radius/dot1x.md b/docs/radius/dot1x.md index 8e98607e..f2b51ac6 100644 --- a/docs/radius/dot1x.md +++ b/docs/radius/dot1x.md @@ -24,10 +24,33 @@ 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`. +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 diff --git a/kubernetes/apps/authentik/freeradius-cnp.yaml b/kubernetes/apps/authentik/freeradius-cnp.yaml index d43f4549..a0c076ca 100644 --- a/kubernetes/apps/authentik/freeradius-cnp.yaml +++ b/kubernetes/apps/authentik/freeradius-cnp.yaml @@ -23,26 +23,6 @@ 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 500e9e43..851f49b8 100644 --- a/kubernetes/apps/authentik/freeradius-config.yaml +++ b/kubernetes/apps/authentik/freeradius-config.yaml @@ -156,11 +156,12 @@ data: } # 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. + # 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.47.1 + ipaddr = 10.2.11.1 port = 1812 proto = udp secret = $ENV{UDM_RADIUS_SECRET} @@ -232,28 +233,34 @@ 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 { diff --git a/kubernetes/apps/authentik/freeradius-network-attachment.yaml b/kubernetes/apps/authentik/freeradius-network-attachment.yaml new file mode 100644 index 00000000..dda47097 --- /dev/null +++ b/kubernetes/apps/authentik/freeradius-network-attachment.yaml @@ -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" + } + } + ] + } diff --git a/kubernetes/apps/authentik/freeradius.yaml b/kubernetes/apps/authentik/freeradius.yaml index c98c9115..c7fa097c 100644 --- a/kubernetes/apps/authentik/freeradius.yaml +++ b/kubernetes/apps/authentik/freeradius.yaml @@ -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: diff --git a/kubernetes/apps/authentik/kustomization.yaml b/kubernetes/apps/authentik/kustomization.yaml index faf92228..c465acbb 100644 --- a/kubernetes/apps/authentik/kustomization.yaml +++ b/kubernetes/apps/authentik/kustomization.yaml @@ -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; diff --git a/kubernetes/infrastructure/cilium/helmrelease.yaml b/kubernetes/infrastructure/cilium/helmrelease.yaml index d39508c7..273a1dd1 100644 --- a/kubernetes/infrastructure/cilium/helmrelease.yaml +++ b/kubernetes/infrastructure/cilium/helmrelease.yaml @@ -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). diff --git a/kubernetes/talos/patches/k8s-1.yaml b/kubernetes/talos/patches/k8s-1.yaml index 3f7aa0de..f7b92ce1 100644 --- a/kubernetes/talos/patches/k8s-1.yaml +++ b/kubernetes/talos/patches/k8s-1.yaml @@ -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. # diff --git a/kubernetes/talos/patches/k8s-2.yaml b/kubernetes/talos/patches/k8s-2.yaml index 32b16c79..99f0a7f6 100644 --- a/kubernetes/talos/patches/k8s-2.yaml +++ b/kubernetes/talos/patches/k8s-2.yaml @@ -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 diff --git a/kubernetes/talos/patches/k8s-3.yaml b/kubernetes/talos/patches/k8s-3.yaml index 20012676..67d9384f 100644 --- a/kubernetes/talos/patches/k8s-3.yaml +++ b/kubernetes/talos/patches/k8s-3.yaml @@ -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 diff --git a/tests/radius/README.md b/tests/radius/README.md index d66a12b7..5f18284a 100644 --- a/tests/radius/README.md +++ b/tests/radius/README.md @@ -31,8 +31,11 @@ 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` + + `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`. diff --git a/tests/radius/run.sh b/tests/radius/run.sh index 4a4d2dba..3cb8563b 100755 --- a/tests/radius/run.sh +++ b/tests/radius/run.sh @@ -183,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 @@ -199,8 +199,12 @@ start() { exit 1 fi - docker create --name radtest-freeradius --ip "$ip_radius" --ip6 "$ip6_radius" "${run_opts[@]}" "$image" "${args[@]}" >/dev/null - docker network connect "$net_udm" radtest-freeradius + # 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 -- 2.54.0