Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 05d2fcac71 feat(radius): proxy UniFi Endpoint logins to the UDM over VLAN 11
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
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 <noreply@anthropic.com>
2026-11-04 15:02:37 +01:00
12 changed files with 145 additions and 40 deletions
+27 -4
View File
@@ -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
@@ -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
@@ -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 {
@@ -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:
@@ -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).
+15
View File
@@ -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.
#
+10
View File
@@ -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
+10
View File
@@ -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
+5 -2
View File
@@ -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`.
+8 -4
View File
@@ -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