Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2543369c | ||
|
|
5add98190f | ||
|
|
1643b305b2 | ||
|
|
657e3d0d92 | ||
|
|
ac2d5d13a1 | ||
|
|
ed3be3156b | ||
|
|
05d2fcac71 | ||
|
|
697856cbf8 | ||
|
|
4962af5ed1 | ||
|
|
bc698ea22d | ||
|
|
d42862e2d8 | ||
|
|
73dd6f6362 |
+77
-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
|
||||
@@ -69,6 +117,32 @@ The FreeRADIUS clients `test` (10.2.50.0/24) and `test6` (fd02:50::/64) and
|
||||
their CIDRs in `freeradius-cnp.yaml` are for the test phase only and must be
|
||||
removed after the test.
|
||||
|
||||
## UniFi objects
|
||||
|
||||
Created through the UniFi Network API (site `default`):
|
||||
|
||||
| Object | ID | Settings |
|
||||
|---|---|---|
|
||||
| Firewall policy "Allow APs RADIUS" | `62c21e63-8c15-4720-adf8-b2333c090e2f` | zone Isolated `10.2.10.0/24` → zone External `10.2.99.91`, UDP `1812-1813`, ALLOW, IPv4 |
|
||||
| RADIUS profile "Cluster 802.1X" | `3e5fcfed-63dc-4e7d-ad51-17cbc074e4bb` | auth `10.2.99.91:1812`, acct `10.2.99.91:1813`, secret `DOT1X_NAS_SECRET`, VLAN assignment on (`vlan_wlan_mode: optional`) |
|
||||
| WLAN "Renner Test" | `3eba27e7-ddc2-41fb-ad3f-82add317847a` | copy of "Renner UniFi" (WPA2/WPA3 Enterprise transition, fast roaming, BSS transition, same AP group and bands), network (90) Guest as fallback, RADIUS profile "Cluster 802.1X" |
|
||||
|
||||
## Live login check
|
||||
|
||||
`tests/radius/live.sh` runs EAP-TTLS/PAP logins with `eapol_test` against the
|
||||
deployed FreeRADIUS from a test client range and prints result, duration and
|
||||
the VLAN attributes of the Access-Accept (hex) per login; secrets are read from
|
||||
files and never printed:
|
||||
|
||||
```
|
||||
LIVE_PASSWORD_VAR=CLAUDE_BOT_PASSWORD tests/radius/live.sh 10.2.99.91 claude-bot \
|
||||
~/.config/claude-bot/authentik ~/.config/claude-bot/radius-secret 20
|
||||
```
|
||||
|
||||
For IPv6 set `LIVE_SOURCE` to the workstation's `fd02:50::` address and use
|
||||
`fd99::91`. `group-id=[3530]` is VLAN `50`, `tunnel-type=[0000000d]` VLAN,
|
||||
`medium=[00000006]` IEEE-802.
|
||||
|
||||
## Client setup (SSID "Renner Test")
|
||||
|
||||
All clients use EAP-TTLS with inner PAP. The server certificate is a public
|
||||
@@ -83,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)
|
||||
@@ -93,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -128,7 +128,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.4 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.11.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: immich-machine-learning
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.5 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.3.6 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3003
|
||||
|
||||
@@ -87,7 +87,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: immich-server
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.5 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
image: ghcr.io/immich-app/immich-server:v3.3.6 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-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`,
|
||||
@@ -31,3 +46,6 @@ Pieces:
|
||||
Requires Docker, `openssl` and `yq` (the jq wrapper). Secrets, password and
|
||||
certificates are generated dummies. `RADTEST_DEBUG=1` runs radiusd with `-xx`
|
||||
and prints all logs. Containers, network and temp files are removed on exit.
|
||||
|
||||
`live.sh` logs in against the deployed FreeRADIUS instead; usage in
|
||||
`docs/radius/dot1x.md` ("Live login check").
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live EAP-TTLS/PAP login against the deployed FreeRADIUS from a test client
|
||||
# (10.2.50.0/24 / fd02:50::/64, client "test"/"test6").
|
||||
#
|
||||
# Usage: tests/radius/live.sh <server-ip> <identity> <password-file> <secret-file> [count]
|
||||
# password-file: the password on the first line, or `export NAME=value` lines
|
||||
# (LIVE_PASSWORD_VAR selects the variable, default CLAUDE_BOT_TOKEN)
|
||||
# secret-file: `bao kv get` output of apps/radius, or the bare
|
||||
# DOT1X_TEST_SECRET on the first line
|
||||
# count: number of logins (default 1)
|
||||
# LIVE_SOURCE: optional local address for eapol_test -A (must be in a
|
||||
# test client range)
|
||||
# Prints one line per login: result, duration, VLAN. Secrets are never printed.
|
||||
set -euo pipefail
|
||||
|
||||
server="$1"
|
||||
identity="$2"
|
||||
password_file="$3"
|
||||
secret_file="$4"
|
||||
count="${5:-1}"
|
||||
password_var="${LIVE_PASSWORD_VAR:-CLAUDE_BOT_TOKEN}"
|
||||
|
||||
read_password() {
|
||||
local line
|
||||
line=$(grep -m1 -E "^export ${password_var}=" "$password_file" || true)
|
||||
if [ -n "$line" ]; then
|
||||
line="${line#*=}"
|
||||
line="${line#\"}"; line="${line%\"}"
|
||||
line="${line#\'}"; line="${line%\'}"
|
||||
printf '%s' "$line"
|
||||
else
|
||||
head -n1 "$password_file"
|
||||
fi
|
||||
}
|
||||
|
||||
read_secret() {
|
||||
local s
|
||||
s=$(sed -n 's/.*DOT1X_TEST_SECRET:\([^] ]*\).*/\1/p' "$secret_file")
|
||||
if [ -n "$s" ]; then printf '%s' "$s"; else head -n1 "$secret_file"; fi
|
||||
}
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
chmod 700 "$work"
|
||||
|
||||
password=$(read_password)
|
||||
secret=$(read_secret)
|
||||
[ -n "$password" ] || { echo "no password found in $password_file" >&2; exit 2; }
|
||||
[ -n "$secret" ] || { echo "no DOT1X_TEST_SECRET found in $secret_file" >&2; exit 2; }
|
||||
|
||||
umask 077
|
||||
cat > "$work/eapol.conf" <<EOF
|
||||
network={
|
||||
ssid="Renner Test"
|
||||
key_mgmt=WPA-EAP
|
||||
eap=TTLS
|
||||
identity="${identity}"
|
||||
anonymous_identity="anonymous"
|
||||
ca_cert="/etc/ssl/certs/ca-certificates.crt"
|
||||
domain_suffix_match="radius.renner.uno"
|
||||
phase2="auth=PAP"
|
||||
password="${password}"
|
||||
}
|
||||
EOF
|
||||
|
||||
for i in $(seq 1 "$count"); do
|
||||
start=$(date +%s%N)
|
||||
out=$(eapol_test -c "$work/eapol.conf" -a "$server" ${LIVE_SOURCE:+-A "$LIVE_SOURCE"} -s "$secret" -t 10 2>&1 || true)
|
||||
end=$(date +%s%N)
|
||||
ms=$(( (end - start) / 1000000 ))
|
||||
result=$(tail -n1 <<<"$out")
|
||||
case "$result" in SUCCESS|FAILURE) ;; *) result="NO-RESULT" ;; esac
|
||||
# Attribute lines of the Access-Accept as the NAS received it (hex values).
|
||||
attrs=$(awk '/RADIUS message: code=2 /{f=1; next} f && /^ /{print; next} f{f=0}' <<<"$out")
|
||||
attr() { awk -v a="Attribute $1 " 'index($0, a){getline; sub(/^ *Value: /, ""); print; exit}' <<<"$attrs"; }
|
||||
echo "login $i: $result ${ms}ms tunnel-type=[$(attr 64)] medium=[$(attr 65)] group-id=[$(attr 81)]"
|
||||
done
|
||||
@@ -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