diff --git a/docs-es/conf.py b/docs-es/conf.py
index e47eb31a..3f6722b0 100644
--- a/docs-es/conf.py
+++ b/docs-es/conf.py
@@ -16,7 +16,7 @@ author = "LibreQoE, LLC"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
-extensions = ["myst_parser"]
+extensions = ["myst_parser", "sphinxcontrib.mermaid"]
myst_enable_extensions = [
'substitution'
diff --git a/docs-es/requirements.txt b/docs-es/requirements.txt
index 17ed7e25..5838e5f0 100644
--- a/docs-es/requirements.txt
+++ b/docs-es/requirements.txt
@@ -1,3 +1,4 @@
sphinx==6.2.1
sphinx_rtd_theme
-myst-parser
\ No newline at end of file
+myst-parser
+sphinxcontrib-mermaid
diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md
index e06312e6..f538e847 100644
--- a/docs-es/v2.0/configuration-advanced-es.md
+++ b/docs-es/v2.0/configuration-advanced-es.md
@@ -62,6 +62,65 @@ netflow_version = 5
do_not_track_subnets = ["192.168.0.0/16"]
```
+### Contabilidad RADIUS (opcional)
+
+LibreQoS acepta una sección opcional `[radius_accounting]` para definir clientes NAS de confianza. Cuando está habilitada, `lqosd` inicia un servicio de contabilidad RADIUS, verifica paquetes de los clientes configurados, envía paquetes Accounting-Response para solicitudes aceptadas y mantiene el estado de sesión decodificado en memoria. Cuando `radius_accounting.dynamic_circuit_application.enabled` y la opción global `dynamic_circuits.enabled` están habilitadas, las sesiones Start e Interim-Update aptas se envían a la ruta de circuitos dinámicos.
+
+Para patrones de despliegue, configuración de un BNG PPPoE MikroTik y validación
+del ciclo de vida, consulte [Contabilidad RADIUS y circuitos dinámicos](radius-es.md).
+
+Ejemplo:
+
+```toml
+[dynamic_circuits]
+enabled = true
+
+[radius_accounting]
+enabled = true
+listen = "0.0.0.0:1813"
+default_ttl_seconds = 900
+stale_grace_seconds = 120
+
+[radius_accounting.dynamic_circuit_application]
+enabled = true
+match_shaped_devices_by_mac = true
+match_shaped_devices_by_username = true
+# Nodo padre opcional para identidades RADIUS por defecto cuando no se usan metadatos MAC.
+# fallback_parent_node = "Core PPPoE"
+# fallback_parent_node_id = "core-pppoe"
+# fallback_anchor_node_id = "radius-anchor"
+
+[radius_accounting.fallback_speed_profile]
+download_min_mbps = 5.0
+upload_min_mbps = 3.0
+download_max_mbps = 25.0
+upload_max_mbps = 10.0
+
+[[radius_accounting.clients]]
+name = "pppoe-core-1"
+source = ["192.0.2.10/32"]
+secret_file = "/etc/lqos/radius-secrets/pppoe-core-1"
+```
+
+Notas:
+- Omita la sección o configure `enabled = false` para mantener deshabilitada la contabilidad RADIUS. Puede omitir los clientes mientras está deshabilitada; cualquier entrada de cliente que configure se sigue validando.
+- Con `radius_accounting.dynamic_circuit_application.enabled = false`, los paquetes de contabilidad se aceptan y se rastrean, pero no cambian el shaping. Cuando está habilitado, LibreQoS puede resolver sesiones aptas como definiciones `ShapedDevice` en memoria. Los circuitos dinámicos solo se aplican si también está configurado `dynamic_circuits.enabled = true` en la sección global.
+- Los paquetes Accounting-Response se envían de forma independiente de la aplicación de circuitos dinámicos. Si falla una creación o actualización, `lqosd` registra los identificadores de circuito y sesión y sigue escuchando.
+- La aplicación de circuitos dinámicos crea o actualiza circuitos desde paquetes Start e Interim-Update aptos para shaping. Los paquetes Stop, la expiración por TTL y la expiración de sesiones obsoletas por reinicio NAS envían `RemoveDynamicCircuit` para el circuito activo creado por RADIUS. Los paquetes Accounting-Response se siguen enviando sin esperar esa eliminación asíncrona; los fallos se registran con los identificadores de circuito y sesión.
+- Configure `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_username = true` para comparar el valor RADIUS `User-Name` con el campo existente `MAC` de `ShapedDevices.csv`. Coloque un nombre de usuario en ese campo para usuarios PPPoE o DHCP-RADIUS; la coincidencia es literal. Una coincidencia única por nombre de usuario tiene prioridad sobre la coincidencia MAC. Los archivos anteriores con un campo MAC vacío y una columna `RADIUS Username` se leen de forma compatible, pero los archivos nuevos o reescritos usan solo el campo MAC.
+- Configure `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_mac = true` para comparar los valores RADIUS `Calling-Station-Id` con los valores MAC de `ShapedDevices.csv` cuando no haya una fila con el nombre de usuario. LibreQoS normaliza formatos con dos puntos, guiones, puntos, hexadecimal sin separadores y combinaciones de mayúsculas/minúsculas antes de comparar. Una coincidencia única aporta los campos de circuito, dispositivo, nodo padre, override SQM y velocidades de `ShapedDevices.csv`; las velocidades decodificadas del paquete tienen prioridad. Las direcciones IPv4 e IPv6 activas vienen de la sesión RADIUS. Las coincidencias duplicadas por nombre de usuario o MAC dejan la sesión pendiente.
+- El servicio RADIUS carga metadatos de identidad desde `ShapedDevices.csv` cuando inicia. Reinicie `lqosd` después de cambiar el valor MAC o nombre de usuario, nodo padre, circuito, dispositivo, SQM o velocidad que las coincidencias RADIUS deban usar.
+- Configure `fallback_parent_node` cuando las identidades RADIUS sin coincidencia deban quedar aptas para shaping mediante el perfil de velocidad de respaldo. Sin `fallback_parent_node`, las sesiones sin coincidencia quedan pendientes por falta de metadatos de nodo padre.
+- `fallback_parent_node`, `fallback_parent_node_id` y `fallback_anchor_node_id` se usan solo para identidades dinámicas sin coincidencia. LibreQoS deriva su ID de circuito estable del NAS más el RADIUS `User-Name`, o del NAS más `Calling-Station-Id` cuando no hay nombre de usuario. `Acct-Session-Id` se usa solo para el ciclo de vida, por lo que los clientes que se reconectan conservan un único ID de circuito. Los paquetes de accounting sin ninguna de esas identidades de abonado quedan pendientes. Las sesiones con coincidencia conservan los metadatos de circuito y nodo padre de su fila de `ShapedDevices.csv`.
+- Una sesión RADIUS solo queda apta para shaping cuando LibreQoS tiene una identidad estable de NAS más `Acct-Session-Id`, una identidad de dispositivo, al menos una dirección IP o prefijo recibido por RADIUS, metadatos de conexión a un nodo padre y un perfil de velocidad resuelto. Las sesiones sin metadatos de nodo padre quedan pendientes.
+- Cualquier valor configurado en `listen` debe ser una dirección de escucha IP:puerto con un puerto distinto de cero, como `0.0.0.0:1813`. Cuando `enabled = true`, configure al menos un cliente. Cada cliente configurado debe incluir al menos una entrada `source`.
+- `source` acepta una cadena IP/CIDR o una lista de cadenas IP/CIDR. Las direcciones IP sin prefijo se aceptan como hosts individuales.
+- Cada cliente configurado debe incluir un `secret_file` no vacío. `lqosd` lee este archivo cuando inicia el servicio y usa su contenido como secreto compartido. LibreQoS conserva la ruta configurada en `/etc/lqos.conf`. La salida de depuración generada a partir de este campo oculta la ruta, pero los paquetes de soporte que incluyan `/etc/lqos.conf` pueden mostrar esa ruta.
+- `default_ttl_seconds` y `stale_grace_seconds` deben ser mayores que cero.
+- Omita `[radius_accounting.fallback_speed_profile]` cuando las sesiones sin una velocidad decodificada utilizable en el paquete RADIUS ni una velocidad de coincidencia MAC en `ShapedDevices.csv` deban quedar pendientes con motivo de velocidad faltante. Si una fila coincidente de `ShapedDevices.csv` contiene velocidades inválidas, la sesión queda pendiente en lugar de usar el perfil de respaldo.
+- Cuando la aplicación de circuitos dinámicos de RADIUS está habilitada, los valores del perfil de velocidad de respaldo deben ser finitos y mayores que cero. `download_min_mbps` no debe superar `download_max_mbps`, y `upload_min_mbps` no debe superar `upload_max_mbps`.
+- Reinicie `lqosd` después de cambiar esta sección para recargar el servicio y los archivos de secreto compartido.
+
### Integraciones con CRM/NMS
Más información sobre [configuración de integraciones aquí.](integrations-es.md).
diff --git a/docs-es/v2.0/radius-es.md b/docs-es/v2.0/radius-es.md
new file mode 100644
index 00000000..0757e671
--- /dev/null
+++ b/docs-es/v2.0/radius-es.md
@@ -0,0 +1,191 @@
+# Contabilidad RADIUS y circuitos dinámicos
+
+LibreQoS puede recibir paquetes de contabilidad RADIUS de un BNG, NAS,
+concentrador PPPoE o sistema DHCP-RADIUS y crear circuitos dinámicos para
+abonados activos. Cuando un abonado recibe una dirección IP solo durante la
+conexión, el circuito aparece con Accounting-Start, se actualiza con
+Interim-Update y se elimina con Accounting-Stop.
+
+La contabilidad RADIUS no sustituye la topología normal. LibreQoS todavía
+necesita un nodo padre y un perfil de velocidad utilizable antes de poder dar
+forma a una sesión.
+
+## Colocar LibreQoS en la ruta de datos del abonado
+
+RADIUS es información del plano de control, no la ruta del tráfico. Coloque
+LibreQoS en línea donde pueda inspeccionar el tráfico dentro de la sesión PPPoE:
+el acceso de clientes pasa por LibreQoS hasta el BNG y después a Internet. Los
+paquetes Accounting indican a LibreQoS qué circuito activo de abonado es dueño
+del tráfico que ya puede observar.
+
+```{mermaid}
+flowchart LR
+ CPE[CPE del cliente] --> ACCESS[Red de acceso]
+ ACCESS --> LQOS[Shaper LibreQoS en línea]
+ LQOS --> BNG[BNG / NAS PPPoE]
+ BNG --> INTERNET[Internet]
+ BNG -. Contabilidad RADIUS
identidad y ciclo de sesión .-> LQOS
+```
+
+LibreQoS no termina PPPoE y la contabilidad RADIUS no redirige el tráfico de
+clientes a través de LibreQoS. El BNG termina PPPoE; LibreQoS usa la identidad
+de contabilidad para crear y eliminar el circuito de shaping correspondiente.
+
+```{mermaid}
+sequenceDiagram
+ participant CPE as CPE del abonado
+ participant BNG as BNG / NAS
+ participant RADIUS as Servidor RADIUS
+ participant LQOS as LibreQoS lqosd
+ participant Q as Circuito dinámico
+
+ CPE->>BNG: Sesión PPPoE o DHCP
+ BNG->>RADIUS: Access-Request
+ RADIUS-->>BNG: Access-Accept (atributos de velocidad opcionales)
+ BNG->>LQOS: Accounting-Start
+ LQOS->>Q: Crear circuito
+ BNG->>LQOS: Accounting-Interim-Update
+ LQOS->>Q: Actualizar circuito
+ CPE->>BNG: Desconexión
+ BNG->>LQOS: Accounting-Stop
+ LQOS->>Q: Eliminar circuito
+```
+
+## Elegir la fuente de velocidad e identidad
+
+LibreQoS admite los tres patrones siguientes. Se pueden habilitar juntos; las
+velocidades decodificadas del paquete tienen prioridad sobre una fila coincidente
+de `ShapedDevices.csv`, y una fila coincidente tiene prioridad sobre el perfil
+de respaldo.
+
+| Patrón | Fuente de identidad y velocidad | Uso adecuado |
+| --- | --- | --- |
+| Detalles completos de RADIUS | El BNG reenvía identidad y atributos de velocidad, como `Mikrotik-Rate-Limit`. | El sistema de abonados controla la velocidad. |
+| Coincidencia con un dispositivo | `User-Name` o `Calling-Station-Id` coincide con el campo `MAC` de `ShapedDevices.csv`; la fila aporta circuito, nodo padre, SQM y velocidades. | Abonados PPPoE o DHCP-RADIUS ya representados en LibreQoS. |
+| Valores predeterminados | Una identidad sin coincidencia usa `fallback_parent_*` y `fallback_speed_profile`. | Servicio predeterminado controlado o migración gradual. |
+
+El arnés de prueba cubre los tres casos: una velocidad del paquete de 10/25
+Mbps, una fila de usuario conocido de 60/20 Mbps y un usuario desconocido con
+respaldo de 30/12 Mbps. También verifica Start, Interim-Update y Stop en cada
+caso.
+
+## Configurar LibreQoS
+
+Habilite los circuitos dinámicos globales y defina un listener RADIUS de
+clientes confiables. Restrinja cada cliente a la dirección o CIDR que envía los
+paquetes Accounting—normalmente el proxy RADIUS en una topología AAA separada.
+No exponga el listener a una red no confiable ni guarde el secreto compartido en
+`lqos.conf`.
+
+```toml
+[dynamic_circuits]
+enabled = true
+
+[radius_accounting]
+enabled = true
+listen = "192.0.2.10:1813"
+default_ttl_seconds = 900
+stale_grace_seconds = 120
+
+[radius_accounting.dynamic_circuit_application]
+enabled = true
+match_shaped_devices_by_mac = true
+match_shaped_devices_by_username = true
+fallback_parent_node = "BNG Access"
+fallback_parent_node_id = "bng-access"
+fallback_anchor_node_id = "core"
+
+[radius_accounting.fallback_speed_profile]
+download_min_mbps = 5.0
+upload_min_mbps = 2.0
+download_max_mbps = 30.0
+upload_max_mbps = 10.0
+
+[[radius_accounting.clients]]
+name = "radius-proxy-1"
+source = ["192.0.2.20/32"]
+secret_file = "/etc/libreqos/radius-secrets/radius-proxy-1"
+```
+
+Guarde el archivo de secreto con permisos exclusivos del propietario y reinicie
+`lqosd` después de cambiar los ajustes RADIUS o la identidad, el nodo padre, el
+circuito, SQM o las velocidades en `ShapedDevices.csv`.
+
+### Coincidir abonados PPPoE o DHCP-RADIUS
+
+El campo `MAC` es un campo de identidad opcional. Con la coincidencia MAC
+habilitada, LibreQoS normaliza los formatos MAC de `Calling-Station-Id`. Con la
+coincidencia por usuario habilitada, compara `User-Name` de RADIUS literalmente
+con el mismo campo. No agregue una columna de usuario separada a archivos nuevos.
+
+```csv
+Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment
+pppoe-42,Cliente 42,pppoe-42,Cliente 42,BNG Access,bng-access,core,customer42@example.net,,,10,5,60,20,Identidad de usuario PPPoE
+```
+
+Para DHCP-RADIUS, coloque el nombre de usuario DHCP en este campo. Para un NAS
+basado en MAC, coloque allí la MAC del abonado. Una coincidencia única por
+usuario tiene prioridad sobre la coincidencia MAC. Las identidades duplicadas
+dejan la sesión pendiente en lugar de seleccionar un circuito arbitrario.
+
+## Construir un BNG PPPoE MikroTik
+
+El siguiente ejemplo de RouterOS es un esquema pequeño, no una configuración
+completa de router de producción. Sustituya las interfaces, direcciones, rango
+del pool, dirección del proxy y secreto por los de su red.
+
+RouterOS envía tanto autenticación como contabilidad a la `address` de una sola
+entrada `/radius add`. Cuando AAA y LibreQoS son hosts separados, apunte RouterOS
+a un proxy RADIUS. El proxy reenvía el tráfico Access-Request a AAA y los
+paquetes Accounting a LibreQoS. Configure LibreQoS para confiar en la dirección
+de origen estable del proxy, no en la dirección del BNG.
+
+```routeros
+/ip pool add name=pppoe-subscribers ranges=100.64.0.2-100.64.255.254
+/ppp profile add name=pppoe-radius local-address=100.64.0.1 remote-address=pppoe-subscribers use-encryption=no
+/interface pppoe-server server add interface=ether3 service-name=internet default-profile=pppoe-radius authentication=pap one-session-per-host=yes disabled=no
+
+/radius add service=ppp address=192.0.2.20 src-address=192.0.2.1 authentication-port=1812 accounting-port=1813 secret=replace-this-secret
+/ppp aaa set use-radius=yes accounting=yes interim-update=5m
+```
+
+Permita UDP 1812 y 1813 entre el BNG y el proxy, y UDP 1813 desde el proxy hasta
+LibreQoS. LibreQoS necesita paquetes de contabilidad, no paquetes Access-Request
+ni Access-Accept. El proxy gestiona la relación de secreto compartido en ambos
+tramos y es el cliente RADIUS incluido en `radius_accounting.clients`.
+
+```{mermaid}
+flowchart LR
+ CPE[CPE del abonado] -->|PPPoE| BNG[BNG MikroTik]
+ BNG -->|UDP 1812/1813| PROXY[Proxy RADIUS]
+ PROXY -->|Access-Request / Accept| AUTH[FreeRADIUS o AAA]
+ PROXY -->|Accounting Start / Interim / Stop| LQOS[LibreQoS]
+ LQOS --> DYN[Circuitos dinámicos]
+ DYN --> TREE[Topología LibreQoS]
+```
+
+Cuando use límites de velocidad MikroTik, pruebe el mapeo de direcciones con una
+captura de paquetes real o con el arnés. El arnés devuelve deliberadamente
+`25M/10M` y verifica el resultado de contabilidad como 10 Mbps de descarga y 25
+Mbps de subida.
+
+## Validar antes de producción
+
+Use el [arnés PPPoE RADIUS](../../radius-harness/README.md) incluido en un host
+libvirt/KVM para probar un checkout de LibreQoS sin instalar los artefactos Rust
+en el huésped. Crea VMs descartables de RouterOS, FreeRADIUS, LibreQoS y cliente
+PPPoE, y las elimina con `down`.
+
+Para un BNG de producción, verifique este ciclo de vida con un abonado de prueba:
+
+1. Conéctelo y confirme que LibreQoS registra un Accounting-Start aceptado y
+ crea un circuito dinámico con el nodo padre y las velocidades esperadas.
+2. Espere un Accounting-Interim-Update y confirme que el circuito conserva el
+ perfil de velocidad esperado.
+3. Desconéctelo y confirme que Accounting-Stop elimina el circuito.
+
+Si una sesión queda pendiente, revise primero la fuente del cliente confiable y
+el secreto compartido; después la identidad del abonado, la dirección IP o
+prefijo recibido, el nodo padre de topología y la fuente de velocidad. Consulte
+la [referencia avanzada de configuración](configuration-advanced-es.md#contabilidad-radius-opcional)
+para el contrato de configuración completo.
diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md
index 293bc17e..56edbb1c 100644
--- a/docs/v2.0/configuration-advanced.md
+++ b/docs/v2.0/configuration-advanced.md
@@ -195,6 +195,65 @@ Notes:
- `attach_to` is a `network.json` node name (optional; empty is allowed).
- Unknown-IP promotions are applied to Bakery asynchronously. Repeated observations for the same promoted circuit are deduplicated while the live overlay is queued or waiting for Bakery.
+#### RADIUS accounting (optional)
+
+LibreQoS accepts an optional `[radius_accounting]` section for trusted NAS client settings. When enabled, `lqosd` starts a RADIUS accounting listener, verifies packets from configured clients, sends Accounting-Response packets for accepted requests, and keeps the decoded session state in memory. When both `radius_accounting.dynamic_circuit_application.enabled` and top-level `dynamic_circuits.enabled` are true, shapeable Start and Interim-Update sessions are submitted to the dynamic-circuit path.
+
+For deployment patterns, MikroTik PPPoE BNG setup, and lifecycle validation, see
+[RADIUS accounting and dynamic circuits](radius.md).
+
+Example:
+
+```toml
+[dynamic_circuits]
+enabled = true
+
+[radius_accounting]
+enabled = true
+listen = "0.0.0.0:1813"
+default_ttl_seconds = 900
+stale_grace_seconds = 120
+
+[radius_accounting.dynamic_circuit_application]
+enabled = true
+match_shaped_devices_by_mac = true
+match_shaped_devices_by_username = true
+# Optional fallback parent for default RADIUS identities when MAC metadata is not used.
+# fallback_parent_node = "Core PPPoE"
+# fallback_parent_node_id = "core-pppoe"
+# fallback_anchor_node_id = "radius-anchor"
+
+[radius_accounting.fallback_speed_profile]
+download_min_mbps = 5.0
+upload_min_mbps = 3.0
+download_max_mbps = 25.0
+upload_max_mbps = 10.0
+
+[[radius_accounting.clients]]
+name = "pppoe-core-1"
+source = ["192.0.2.10/32"]
+secret_file = "/etc/lqos/radius-secrets/pppoe-core-1"
+```
+
+Notes:
+- Omit the section or set `enabled = false` to keep RADIUS accounting disabled. Clients may be omitted while it is disabled; any client entries you configure are still validated.
+- With `radius_accounting.dynamic_circuit_application.enabled = false`, accounting packets are accepted and tracked but do not change shaping. With it enabled, LibreQoS can resolve eligible sessions into `ShapedDevice` definitions in memory. Dynamic circuits are applied only when top-level `dynamic_circuits.enabled = true` is also configured.
+- Accounting-Response packets are sent independently from dynamic-circuit application. If a create or update fails, `lqosd` logs the circuit and session identifiers and keeps listening.
+- Dynamic-circuit application creates or updates circuits from shapeable Start and Interim-Update packets. Stop packets, TTL expiry, and stale NAS reset expiry submit `RemoveDynamicCircuit` for the active RADIUS-created circuit. Accounting-Response packets are still sent without waiting for that asynchronous removal; failures are logged with the circuit and session identifiers.
+- Set `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_username = true` to match the RADIUS `User-Name` against the existing `MAC` field in `ShapedDevices.csv`. Put a username in that field for PPPoE or DHCP-RADIUS users; matching is verbatim. A unique username match takes priority over MAC matching. Older files with an empty MAC field and a `RADIUS Username` column are read compatibly, but new or rewritten files use the MAC field only.
+- Set `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_mac = true` to match RADIUS `Calling-Station-Id` values against `ShapedDevices.csv` MAC values when no username row matches. LibreQoS normalizes colon, hyphen, dotted, plain-hex, and mixed-case MAC formats before matching. A unique match supplies the circuit, device, parent, SQM override, and ShapedDevices speed fields; packet-decoded rates still take priority. The active IPv4 and IPv6 addresses come from the RADIUS session. Duplicate username or MAC matches leave the session pending.
+- The RADIUS listener loads identity-match metadata from `ShapedDevices.csv` when it starts. Restart `lqosd` after changing a MAC or username value, parent, circuit, device, SQM, or speed fields that RADIUS matching should use.
+- Configure `fallback_parent_node` when unmatched RADIUS identities should become shapeable from the fallback speed profile. Without `fallback_parent_node`, unmatched sessions remain pending with missing parent metadata.
+- `fallback_parent_node`, `fallback_parent_node_id`, and `fallback_anchor_node_id` are used only for unmatched dynamic identities. LibreQoS derives their stable circuit ID from the NAS plus RADIUS `User-Name`, or from the NAS plus `Calling-Station-Id` when no username is supplied. `Acct-Session-Id` remains lifecycle state only, so reconnecting customers retain one circuit ID. Accounting packets without either subscriber identity remain pending. Matched sessions keep the circuit and parent metadata from their `ShapedDevices.csv` row.
+- A RADIUS session is shapeable only after LibreQoS has a stable NAS plus `Acct-Session-Id` identity, a device identity, at least one framed or delegated IP address or prefix, parent attachment metadata, and a resolved speed profile. Sessions without parent metadata remain pending.
+- Any configured `listen` value must be an IP:port listen address with a non-zero port, such as `0.0.0.0:1813`. When `enabled = true`, configure at least one client. Each configured client must include at least one `source` entry.
+- `source` accepts one IP/CIDR string or a list of IP/CIDR strings. Bare IP addresses are accepted as host sources.
+- Each configured client must include a non-empty `secret_file`. `lqosd` reads this file when the listener starts and uses its contents as the shared secret. LibreQoS preserves the configured path in `/etc/lqos.conf`. Debug output generated from this config field hides the configured path, but `/etc/lqos.conf` and support bundles that include it can still show the path.
+- `default_ttl_seconds` and `stale_grace_seconds` must be greater than zero.
+- Omit `[radius_accounting.fallback_speed_profile]` when sessions without a usable decoded packet rate or ShapedDevices MAC-match rate should stay pending with a missing-rate reason. If a matched `ShapedDevices.csv` row contains invalid speed fields, the session stays pending instead of falling back.
+- When RADIUS dynamic-circuit application is enabled, fallback speed values must be finite and greater than zero. `download_min_mbps` must not exceed `download_max_mbps`, and `upload_min_mbps` must not exceed `upload_max_mbps`.
+- Restart `lqosd` after changing this section so the listener and shared-secret files are reloaded.
+
#### CRM/NMS Integrations
Learn more about [configuring integrations here](integrations.md).
diff --git a/docs/v2.0/radius.md b/docs/v2.0/radius.md
new file mode 100644
index 00000000..f3adeb91
--- /dev/null
+++ b/docs/v2.0/radius.md
@@ -0,0 +1,184 @@
+# RADIUS accounting and dynamic circuits
+
+LibreQoS can receive RADIUS Accounting packets from a broadband network gateway
+(BNG), NAS, PPPoE concentrator, or DHCP-RADIUS system and create dynamic
+circuits for active subscribers. It is useful when subscribers receive an IP
+address only while connected: the circuit appears at Accounting-Start, is
+refreshed by Interim-Update, and is removed at Accounting-Stop.
+
+RADIUS accounting does not replace the normal topology. LibreQoS still needs a
+parent attachment and a usable speed profile before it can shape a session.
+
+## Place LibreQoS in the subscriber data path
+
+RADIUS is control-plane data, not the traffic path. Place LibreQoS inline where
+it can inspect traffic inside the PPPoE session: customer access flows through
+LibreQoS to the BNG, then to the Internet. The Accounting packets tell LibreQoS
+which active subscriber circuit owns the traffic it can already observe.
+
+```{mermaid}
+flowchart LR
+ CPE[Customer CPE] --> ACCESS[Access network]
+ ACCESS --> LQOS[LibreQoS inline shaper]
+ LQOS --> BNG[PPPoE BNG / NAS]
+ BNG --> INTERNET[Internet]
+ BNG -. RADIUS Accounting
identity and session lifecycle .-> LQOS
+```
+
+LibreQoS does not terminate PPPoE and RADIUS accounting does not redirect
+customer traffic through LibreQoS. The BNG terminates PPPoE; LibreQoS uses the
+accounting identity to create and remove the corresponding shaping circuit.
+
+```{mermaid}
+sequenceDiagram
+ participant CPE as Subscriber CPE
+ participant BNG as BNG / NAS
+ participant RADIUS as RADIUS server
+ participant LQOS as LibreQoS lqosd
+ participant Q as Dynamic circuit
+
+ CPE->>BNG: PPPoE or DHCP session
+ BNG->>RADIUS: Access-Request
+ RADIUS-->>BNG: Access-Accept (optional rate attributes)
+ BNG->>LQOS: Accounting-Start
+ LQOS->>Q: Create circuit
+ BNG->>LQOS: Accounting-Interim-Update
+ LQOS->>Q: Refresh circuit
+ CPE->>BNG: Disconnect
+ BNG->>LQOS: Accounting-Stop
+ LQOS->>Q: Remove circuit
+```
+
+## Choose a speed and identity source
+
+LibreQoS supports these three deployment patterns. They can be enabled together;
+packet-decoded rates take precedence over a matching `ShapedDevices.csv` row,
+and a matching row takes precedence over the fallback profile.
+
+| Pattern | Identity and speed source | Suitable use |
+| --- | --- | --- |
+| Full RADIUS details | The BNG forwards subscriber identity and rate attributes such as MikroTik `Mikrotik-Rate-Limit`. | The subscriber management system owns rate selection. |
+| Match a shaped device | `User-Name` or `Calling-Station-Id` matches the existing `MAC` field in `ShapedDevices.csv`; the row supplies circuit, parent, SQM, and rates. | PPPoE or DHCP-RADIUS subscribers already represented in LibreQoS. |
+| Defaults | An unmatched identity uses `fallback_parent_*` and `fallback_speed_profile`. | A controlled default service or gradual migration. |
+
+The test harness exercises all three: a packet rate of 10/25 Mbps, a known
+username row at 60/20 Mbps, and an unknown username with a 30/12 Mbps fallback.
+It also verifies Start, Interim-Update, and Stop behavior for each case.
+
+## Configure LibreQoS
+
+Enable global dynamic circuits and define a trusted RADIUS listener. Restrict
+each client to the source address or CIDR that sends Accounting packets—typically
+the RADIUS proxy in a split AAA topology. Never expose the listener to an
+untrusted network or store the shared secret directly in `lqos.conf`.
+
+```toml
+[dynamic_circuits]
+enabled = true
+
+[radius_accounting]
+enabled = true
+listen = "192.0.2.10:1813"
+default_ttl_seconds = 900
+stale_grace_seconds = 120
+
+[radius_accounting.dynamic_circuit_application]
+enabled = true
+match_shaped_devices_by_mac = true
+match_shaped_devices_by_username = true
+fallback_parent_node = "BNG Access"
+fallback_parent_node_id = "bng-access"
+fallback_anchor_node_id = "core"
+
+[radius_accounting.fallback_speed_profile]
+download_min_mbps = 5.0
+upload_min_mbps = 2.0
+download_max_mbps = 30.0
+upload_max_mbps = 10.0
+
+[[radius_accounting.clients]]
+name = "radius-proxy-1"
+source = ["192.0.2.20/32"]
+secret_file = "/etc/libreqos/radius-secrets/radius-proxy-1"
+```
+
+Create the secret file with owner-only permissions, then restart `lqosd` after
+changing RADIUS settings or the identity, parent, circuit, SQM, or rate data in
+`ShapedDevices.csv`.
+
+### Match PPPoE or DHCP-RADIUS subscribers
+
+The `MAC` field is an optional identity field. With MAC matching enabled,
+LibreQoS normalizes `Calling-Station-Id` MAC formats. With username matching
+enabled, it compares RADIUS `User-Name` to the same field verbatim. Do not add a
+separate username column to new files.
+
+```csv
+Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment
+pppoe-42,Customer 42,pppoe-42,Customer 42,BNG Access,bng-access,core,customer42@example.net,,,10,5,60,20,PPPoE username identity
+```
+
+For a DHCP-RADIUS installation, place the DHCP username in this field instead.
+For a MAC-based NAS, place the subscriber MAC there. A unique username match is
+preferred before MAC matching. Duplicate identity values leave the session
+pending rather than selecting an arbitrary circuit.
+
+## Build a MikroTik PPPoE BNG
+
+The following is a small RouterOS outline, not a complete production router
+configuration. Replace interface names, addresses, pool ranges, proxy address,
+and secret for your network.
+
+RouterOS sends both authentication and accounting to the `address` in one
+`/radius add` entry. When AAA and LibreQoS are separate hosts, point RouterOS at
+a RADIUS proxy. The proxy forwards Access-Request traffic to AAA and forwards
+Accounting packets to LibreQoS. Configure LibreQoS to trust the proxy's stable
+source address, not the BNG address.
+
+```routeros
+/ip pool add name=pppoe-subscribers ranges=100.64.0.2-100.64.255.254
+/ppp profile add name=pppoe-radius local-address=100.64.0.1 remote-address=pppoe-subscribers use-encryption=no
+/interface pppoe-server server add interface=ether3 service-name=internet default-profile=pppoe-radius authentication=pap one-session-per-host=yes disabled=no
+
+/radius add service=ppp address=192.0.2.20 src-address=192.0.2.1 authentication-port=1812 accounting-port=1813 secret=replace-this-secret
+/ppp aaa set use-radius=yes accounting=yes interim-update=5m
+```
+
+Allow UDP 1812 and 1813 between the BNG and the proxy, and UDP 1813 from the
+proxy to LibreQoS. LibreQoS needs Accounting packets, not Access-Request or
+Access-Accept packets. The proxy owns the shared-secret relationship on both
+legs and is the RADIUS client listed in `radius_accounting.clients`.
+
+```{mermaid}
+flowchart LR
+ CPE[Subscriber CPE] -->|PPPoE| BNG[MikroTik BNG]
+ BNG -->|UDP 1812/1813| PROXY[RADIUS proxy]
+ PROXY -->|Access-Request / Accept| AUTH[FreeRADIUS or AAA]
+ PROXY -->|Accounting Start / Interim / Stop| LQOS[LibreQoS]
+ LQOS --> DYN[Dynamic circuits]
+ DYN --> TREE[LibreQoS topology]
+```
+
+When using MikroTik rate limits, test the direction mapping with a real packet
+capture or the harness. The harness deliberately returns `25M/10M` and verifies
+the accounting result as 10 Mbps download and 25 Mbps upload.
+
+## Validate before production
+
+Use the committed [RADIUS PPPoE harness](../../radius-harness/README.md) on a
+libvirt/KVM host to test a LibreQoS checkout without installing Rust artifacts
+inside the guest. It creates disposable RouterOS, FreeRADIUS, LibreQoS, and
+PPPoE-client VMs, then removes them with `down`.
+
+For a production BNG, verify this lifecycle for one test subscriber:
+
+1. Connect and confirm LibreQoS logs an accepted Accounting-Start and creates a
+ dynamic circuit with the expected parent and rates.
+2. Wait for an Accounting-Interim-Update and confirm the circuit remains with
+ the expected rate profile.
+3. Disconnect and confirm Accounting-Stop removes the circuit.
+
+If a session remains pending, first check the trusted client source and shared
+secret, then the subscriber identity, received IP address or prefix, topology
+parent, and speed source. See the [advanced configuration reference](configuration-advanced.md#radius-accounting-optional)
+for the complete configuration contract.
diff --git a/radius-harness/.gitignore b/radius-harness/.gitignore
new file mode 100644
index 00000000..6aac2f89
--- /dev/null
+++ b/radius-harness/.gitignore
@@ -0,0 +1,3 @@
+/artifacts/
+/images/
+/run/
diff --git a/radius-harness/README.md b/radius-harness/README.md
new file mode 100644
index 00000000..c1a0d1c9
--- /dev/null
+++ b/radius-harness/README.md
@@ -0,0 +1,78 @@
+# LibreQoS RADIUS PPPoE harness
+
+This disposable libvirt harness verifies the RADIUS accounting lifecycle end to
+end: a RouterOS CHR NAS authenticates a PPPoE client with FreeRADIUS, sends
+accounting to a root-run LibreQoS guest, and the test verifies dynamic-circuit
+creation, profile selection, Interim-Update, and removal on disconnect.
+
+It creates `radius-*` libvirt domains and networks, sparse qcow2 overlays, and
+temporary `vbr-r*` host-side bridge interfaces for its isolated networks.
+`./radius-harness/lab down` removes them with the overlays and VMs. `purge`
+additionally removes downloaded base images and the host-built runtime bundle.
+The harness does not change existing host interfaces, LibreQoS services, or
+host shaping.
+
+The `radius-*` domain and network names are reserved for this harness. Do not
+run `down` or `purge` on a host where another workload intentionally uses one
+of those names.
+
+## Requirements
+
+Run from a LibreQoS checkout on a host with libvirt/KVM and OVMF/edk2 UEFI
+firmware. Install `virsh`, `virt-install`, `qemu-img`, `cloud-localds` (from
+`cloud-image-utils`), `curl`, `unzip`, `rsync`, `sshpass`, and a Rust toolchain.
+The invoking account must be allowed to manage the configured libvirt URI
+(default: `qemu:///system`).
+
+The harness discovers common OVMF locations. If your distribution stores its
+firmware elsewhere, set `OVMF_CODE_PATH` and `OVMF_VARS_PATH` in the environment
+before `up`.
+
+The pinned image hashes are in [lab.env](lab.env). Review and update the URL,
+version, and SHA-256 together when changing an image.
+
+## Run
+
+Set the one-time RouterOS administrator password and the lab-only RADIUS shared
+secret. They are never committed and are written only to ignored run-state and
+disposable guests.
+
+```bash
+export ROUTEROS_ADMIN_PASSWORD='a-routeros-password'
+export RADIUS_SHARED_SECRET='a-lab-radius-secret'
+
+./radius-harness/lab init
+./radius-harness/lab up
+./radius-harness/lab console # set the RouterOS admin password once; exit with Ctrl+]
+./radius-harness/lab configure
+./radius-harness/lab test
+./radius-harness/lab down
+```
+
+`init` downloads the pinned Ubuntu and RouterOS bases once and builds `lqosd`
+and `lqos_python` on the host. The guest receives only the resulting runtime
+bundle, not the Rust build tree.
+
+The test covers three cases:
+
+1. A RADIUS rate-limit attribute supplies the queue speed.
+2. A known PPPoE username in the `MAC` field of `ShapedDevices.csv` supplies
+ the circuit identity and speed when RADIUS supplies no rate.
+3. An unknown username receives the configured fallback queue.
+
+The harness uses temporary IPv4 ranges `198.18.10.0/24`, `198.18.30.0/24`, and
+`100.64.0.0/16`. If those conflict with local infrastructure, change the
+templates before running it.
+
+## Commands
+
+```text
+./radius-harness/lab init Download bases and build the runtime bundle.
+./radius-harness/lab up Create networks, overlays, and VMs.
+./radius-harness/lab status Show domains and management addresses.
+./radius-harness/lab configure Install fixtures and start services.
+./radius-harness/lab test Run the lifecycle assertions.
+./radius-harness/lab down Remove VMs, networks, overlays, and secrets.
+./radius-harness/lab purge Also remove cached images and runtime artifacts.
+./radius-harness/lab console Open the RouterOS serial console.
+```
diff --git a/radius-harness/lab b/radius-harness/lab
new file mode 100755
index 00000000..da698b58
--- /dev/null
+++ b/radius-harness/lab
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+LAB_ROOT=$(cd -- "$(dirname -- "$0")" && pwd)
+source "$LAB_ROOT/lib/common.sh"
+source "$LAB_ROOT/lab.env"
+
+readonly UBUNTU_BASE="$IMAGE_DIR/$(basename "$UBUNTU_IMAGE_URL")"
+readonly ROUTER_ARCHIVE="$IMAGE_DIR/chr-$ROUTEROS_VERSION.img.zip"
+readonly ROUTER_BASE="$IMAGE_DIR/chr-$ROUTEROS_VERSION.qcow2"
+readonly SSH_KEY="$RUN_DIR/id_ed25519"
+OVMF_CODE=
+OVMF_VARS=
+
+usage() {
+ cat <<'EOF'
+Usage: ./radius-harness/lab
+
+Commands: init up status configure test down purge console
+EOF
+}
+
+require_host_tools() {
+ for tool in virsh virt-install qemu-img cloud-localds curl unzip sha256sum rsync ssh scp; do require_command "$tool"; done
+}
+
+ensure_ssh_key() {
+ mkdir -p "$RUN_DIR"
+ [[ -f $SSH_KEY ]] || ssh-keygen -q -t ed25519 -N '' -f "$SSH_KEY"
+}
+
+resolve_ovmf() {
+ local code_path vars_path
+ if [[ -n ${OVMF_CODE_PATH:-} || -n ${OVMF_VARS_PATH:-} ]]; then
+ [[ -n ${OVMF_CODE_PATH:-} && -n ${OVMF_VARS_PATH:-} ]] || die "set both OVMF_CODE_PATH and OVMF_VARS_PATH"
+ [[ -r $OVMF_CODE_PATH && -r $OVMF_VARS_PATH ]] || die "configured OVMF firmware paths are not readable"
+ OVMF_CODE=$OVMF_CODE_PATH
+ OVMF_VARS=$OVMF_VARS_PATH
+ return
+ fi
+
+ for code_path in \
+ /usr/share/OVMF/OVMF_CODE_4M.fd \
+ /usr/share/OVMF/OVMF_CODE.fd \
+ /usr/share/edk2-ovmf/x64/OVMF_CODE.fd \
+ /usr/share/edk2/ovmf/OVMF_CODE.fd; do
+ vars_path=${code_path/CODE/VARS}
+ if [[ -r $code_path && -r $vars_path ]]; then
+ OVMF_CODE=$code_path
+ OVMF_VARS=$vars_path
+ return
+ fi
+ done
+ die "OVMF firmware was not found; install OVMF/edk2 or set OVMF_CODE_PATH and OVMF_VARS_PATH"
+}
+
+download_routeros() {
+ [[ -n ${ROUTEROS_CHR_SHA256:-} ]] || die "set ROUTEROS_CHR_SHA256 in lab.env before downloading RouterOS"
+ if [[ ! -f $ROUTER_ARCHIVE ]]; then
+ note "downloading RouterOS CHR $ROUTEROS_VERSION"
+ curl --fail --location --output "$ROUTER_ARCHIVE.partial" "$ROUTEROS_CHR_URL"
+ printf '%s %s\n' "$ROUTEROS_CHR_SHA256" "$ROUTER_ARCHIVE.partial" | sha256sum --check --status || die "RouterOS archive checksum did not match"
+ mv "$ROUTER_ARCHIVE.partial" "$ROUTER_ARCHIVE"
+ fi
+ printf '%s %s\n' "$ROUTEROS_CHR_SHA256" "$ROUTER_ARCHIVE" | sha256sum --check --status || die "cached RouterOS archive checksum did not match"
+ if [[ ! -f $ROUTER_BASE ]]; then
+ unzip -p "$ROUTER_ARCHIVE" >"$IMAGE_DIR/chr-$ROUTEROS_VERSION.img"
+ qemu-img convert -f raw -O qcow2 "$IMAGE_DIR/chr-$ROUTEROS_VERSION.img" "$ROUTER_BASE"
+ rm -f "$IMAGE_DIR/chr-$ROUTEROS_VERSION.img"
+ fi
+}
+
+download_ubuntu() {
+ [[ -n ${UBUNTU_IMAGE_SHA256:-} ]] || die "set UBUNTU_IMAGE_SHA256 in lab.env before downloading Ubuntu"
+ if [[ ! -f $UBUNTU_BASE ]]; then
+ note "downloading Ubuntu 24.04 cloud image"
+ curl --fail --location --output "$UBUNTU_BASE.partial" "$UBUNTU_IMAGE_URL"
+ printf '%s %s\n' "$UBUNTU_IMAGE_SHA256" "$UBUNTU_BASE.partial" | sha256sum --check --status || die "Ubuntu image checksum did not match"
+ mv "$UBUNTU_BASE.partial" "$UBUNTU_BASE"
+ fi
+ printf '%s %s\n' "$UBUNTU_IMAGE_SHA256" "$UBUNTU_BASE" | sha256sum --check --status || die "cached Ubuntu image checksum did not match"
+}
+
+build_runtime_bundle() {
+ [[ -d $REPO_ROOT/src/rust ]] || die "LibreQoS checkout not found: $REPO_ROOT"
+ note "building lqosd and lqos_python on the host"
+ (cd "$REPO_ROOT/src/rust" && cargo build --release -p lqosd -p lqos_python)
+ safe_remove "$RUNTIME_DIR"
+ mkdir -p "$RUNTIME_DIR/bin" "$RUNTIME_DIR/src"
+ install -m 0755 "$REPO_ROOT/src/rust/target/release/lqosd" "$RUNTIME_DIR/bin/lqosd"
+ install -m 0755 "$REPO_ROOT/src/rust/target/release/liblqos_python.so" "$RUNTIME_DIR/liblqos_python.so"
+ rsync -a --delete --exclude rust --exclude dist --exclude bin --exclude geo2.bin --exclude lts2 --exclude '*.pyc' --exclude '__pycache__' "$REPO_ROOT/src/" "$RUNTIME_DIR/src/"
+}
+
+create_network() { local name=$1 xml=$2; if ! network_exists "$name"; then virsh_lab net-define "$xml"; virsh_lab net-start "$name"; fi; }
+destroy_network() { local name=$1; if network_exists "$name"; then virsh_lab net-destroy "$name" >/dev/null 2>&1 || true; virsh_lab net-undefine "$name"; fi; }
+create_overlay() { local name=$1 backing=$2 size=${3:-}; if [[ -n $size ]]; then qemu-img create -f qcow2 -F qcow2 -b "$backing" "$RUN_DIR/$name.qcow2" "$size" >/dev/null; else qemu-img create -f qcow2 -F qcow2 -b "$backing" "$RUN_DIR/$name.qcow2" >/dev/null; fi; }
+
+render_cloud_init() {
+ local name=$1 network_config=$2
+ sed "s|__LAB_SSH_KEY__|$(<"$SSH_KEY.pub")|" "$LAB_ROOT/templates/user-data.yaml" >"$RUN_DIR/$name-user-data"
+ cloud-localds --network-config="$network_config" "$RUN_DIR/$name-seed.iso" "$RUN_DIR/$name-user-data"
+}
+
+install_linux_domain() {
+ local name=$1 memory=$2 vcpus=$3 network_args=$4
+ install -m 0644 "$OVMF_VARS" "$RUN_DIR/$name-vars.fd"
+ virt-install --connect "$LIBVIRT_URI" --name "$name" --memory "$memory" --vcpus "$vcpus" --import --boot "loader=$OVMF_CODE,loader.type=pflash,loader.readonly=yes,loader.secure=no,nvram=$RUN_DIR/$name-vars.fd" --disk "path=$RUN_DIR/$name.qcow2,format=qcow2,bus=virtio" --disk "path=$RUN_DIR/$name-seed.iso,device=cdrom" --os-variant ubuntu24.04 --graphics vnc,listen=127.0.0.1 --noautoconsole $network_args
+}
+
+create_domains() {
+ create_overlay "$LQOS_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$RADIUS_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$CLIENT_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$ROUTER_DOMAIN" "$ROUTER_BASE"
+ install_linux_domain "$LQOS_DOMAIN" 4096 2 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:10:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:10:00:11 --network network=$TRANSIT_NETWORK,model=virtio,mac=52:54:00:10:00:12"
+ install_linux_domain "$RADIUS_DOMAIN" 512 1 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:20:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:20:00:11"
+ install_linux_domain "$CLIENT_DOMAIN" 512 1 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:30:00:10 --network network=$ACCESS_NETWORK,model=virtio,mac=02:00:00:00:00:42"
+ virt-install --connect "$LIBVIRT_URI" --name "$ROUTER_DOMAIN" --memory 512 --vcpus 1 --import --disk "path=$RUN_DIR/$ROUTER_DOMAIN.qcow2,format=qcow2,bus=virtio" --os-variant generic --graphics none --noautoconsole --network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:40:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:40:00:11 --network network=$ACCESS_NETWORK,model=virtio,mac=52:54:00:40:00:12 --network network=$TRANSIT_NETWORK,model=virtio,mac=52:54:00:40:00:13
+ sleep 5
+}
+
+destroy_domain() { local name=$1; if domain_exists "$name"; then virsh_lab destroy "$name" >/dev/null 2>&1 || true; virsh_lab undefine "$name" --nvram >/dev/null 2>&1 || virsh_lab undefine "$name"; fi; }
+cmd_init() { require_host_tools; mkdir -p "$IMAGE_DIR" "$RUN_DIR"; download_ubuntu; download_routeros; build_runtime_bundle; note "lab bases and runtime bundle are ready"; }
+cmd_up() {
+ require_host_tools; resolve_ovmf; [[ -f $UBUNTU_BASE && -f $ROUTER_BASE && -x $RUNTIME_DIR/bin/lqosd ]] || die "run ./radius-harness/lab init first"; ensure_lab_stopped
+ trap 'status=$?; trap - ERR; note "lab creation failed; removing partial resources"; cmd_down || true; exit "$status"' ERR
+ safe_remove "$RUN_DIR"; mkdir -p "$RUN_DIR"; ensure_ssh_key
+ for guest in lqos radius client; do cp "$LAB_ROOT/templates/$guest-network.yaml" "$RUN_DIR/$guest-network.yaml"; done
+ render_cloud_init "$LQOS_DOMAIN" "$RUN_DIR/lqos-network.yaml"; render_cloud_init "$RADIUS_DOMAIN" "$RUN_DIR/radius-network.yaml"; render_cloud_init "$CLIENT_DOMAIN" "$RUN_DIR/client-network.yaml"
+ create_network "$CONTROL_NETWORK" "$LAB_ROOT/templates/network-control.xml"; create_network "$ACCESS_NETWORK" "$LAB_ROOT/templates/network-access.xml"; create_network "$TRANSIT_NETWORK" "$LAB_ROOT/templates/network-transit.xml"; create_domains
+ trap - ERR; note "VMs created. Use ./radius-harness/lab console once to set RouterOS admin password, then run ./radius-harness/lab configure."
+}
+cmd_status() { for item in "$LQOS_DOMAIN 52:54:00:10:00:10" "$RADIUS_DOMAIN 52:54:00:20:00:10" "$CLIENT_DOMAIN 52:54:00:30:00:10" "$ROUTER_DOMAIN 52:54:00:40:00:10"; do set -- $item; printf '%-20s %s\n' "$1" "$(management_ip "$2" || true)"; done; }
+cmd_console() { domain_exists "$ROUTER_DOMAIN" || die "RouterOS domain is not running; use ./radius-harness/lab up first"; virsh_lab console "$ROUTER_DOMAIN"; }
+cmd_configure() { "$LAB_ROOT/lib/configure.sh"; }
+cmd_test() { "$LAB_ROOT/lib/test-lifecycle.sh"; }
+cmd_down() { for domain in "$CLIENT_DOMAIN" "$RADIUS_DOMAIN" "$ROUTER_DOMAIN" "$LQOS_DOMAIN"; do destroy_domain "$domain"; done; for network in "$TRANSIT_NETWORK" "$ACCESS_NETWORK" "$CONTROL_NETWORK"; do destroy_network "$network"; done; [[ -d $RUN_DIR ]] && safe_remove "$RUN_DIR"; note "disposable lab resources removed"; }
+cmd_purge() { cmd_down; [[ -d $IMAGE_DIR ]] && safe_remove "$IMAGE_DIR"; [[ -d $RUNTIME_DIR ]] && safe_remove "$RUNTIME_DIR"; note "cached bases and runtime artifacts removed"; }
+
+case ${1:-} in init) cmd_init ;; up) cmd_up ;; status) cmd_status ;; configure) cmd_configure ;; test) cmd_test ;; down) cmd_down ;; purge) cmd_purge ;; console) cmd_console ;; *) usage; exit 2 ;; esac
diff --git a/radius-harness/lab.env b/radius-harness/lab.env
new file mode 100644
index 00000000..61c872a5
--- /dev/null
+++ b/radius-harness/lab.env
@@ -0,0 +1,18 @@
+# Pinned RouterOS long-term CHR release. Update version, URL, and SHA together.
+ROUTEROS_VERSION=7.21.5
+ROUTEROS_CHR_URL=https://download.mikrotik.com/routeros/7.21.5/chr-7.21.5.img.zip
+ROUTEROS_CHR_SHA256=dbb8f1739da73a68e5167fcbc731a5ba3d50f9d9b195731b96f96fcc58904580
+
+# Pinned Ubuntu 24.04 cloud image. Update URL and SHA together.
+UBUNTU_IMAGE_URL=https://cloud-images.ubuntu.com/releases/noble/release-20260518/ubuntu-24.04-server-cloudimg-amd64.img
+UBUNTU_IMAGE_SHA256=53fdde898feed8b027d94baa9cfe8229867f330a1d9c49dc7d84465ee7f229f7
+
+# Set these before `./radius-harness/lab configure`; they are deliberately not
+# committed here:
+# export ROUTEROS_ADMIN_PASSWORD='choose-a-password'
+# export RADIUS_SHARED_SECRET='choose-a-shared-secret'
+
+# Optional UEFI firmware overrides for distributions with non-standard OVMF
+# locations. Set both paths together when automatic discovery does not find one.
+# export OVMF_CODE_PATH=/path/to/OVMF_CODE.fd
+# export OVMF_VARS_PATH=/path/to/OVMF_VARS.fd
diff --git a/radius-harness/lib/common.sh b/radius-harness/lib/common.sh
new file mode 100755
index 00000000..af4933ed
--- /dev/null
+++ b/radius-harness/lib/common.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+LAB_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
+REPO_ROOT=${LIBREQOS_REPO_ROOT:-$(cd -- "$LAB_ROOT/.." && pwd)}
+RUNTIME_DIR="$LAB_ROOT/artifacts/runtime"
+IMAGE_DIR="$LAB_ROOT/images"
+RUN_DIR="$LAB_ROOT/run"
+LIBVIRT_URI=${LIBVIRT_URI:-qemu:///system}
+MANAGEMENT_NETWORK=default
+
+readonly LQOS_DOMAIN=radius-lqos
+readonly ROUTER_DOMAIN=radius-routeros
+readonly RADIUS_DOMAIN=radius-freeradius
+readonly CLIENT_DOMAIN=radius-client
+readonly CONTROL_NETWORK=radius-control
+readonly ACCESS_NETWORK=radius-access
+readonly TRANSIT_NETWORK=radius-transit
+
+die() { echo "error: $*" >&2; exit 1; }
+note() { echo "==> $*"; }
+require_command() { command -v "$1" >/dev/null || die "required command not found: $1"; }
+require_lab_secret() { local secret_name=$1; [[ -n ${!secret_name:-} ]] || die "set $secret_name in the environment"; }
+virsh_lab() { virsh -c "$LIBVIRT_URI" "$@"; }
+domain_exists() { virsh_lab dominfo "$1" >/dev/null 2>&1; }
+network_exists() { virsh_lab net-info "$1" >/dev/null 2>&1; }
+
+ensure_lab_stopped() {
+ for domain in "$LQOS_DOMAIN" "$ROUTER_DOMAIN" "$RADIUS_DOMAIN" "$CLIENT_DOMAIN"; do
+ domain_exists "$domain" && die "lab domain already exists: $domain (run ./radius-harness/lab down first)"
+ done
+ for network in "$CONTROL_NETWORK" "$ACCESS_NETWORK" "$TRANSIT_NETWORK"; do
+ network_exists "$network" && die "lab network already exists: $network (inspect and remove it before starting this lab)"
+ done
+}
+
+management_ip() {
+ local mac=$1
+ virsh_lab net-dhcp-leases "$MANAGEMENT_NETWORK" | awk -v wanted_mac="$mac" 'tolower($0) ~ tolower(wanted_mac) { expires = $1 " " $2; if (expires > latest) { latest = expires; split($5, address, "/"); ip = address[1] } } END { print ip }'
+}
+
+wait_for_management_ip() {
+ local mac=$1 ip
+ for _ in $(seq 1 60); do
+ ip=$(management_ip "$mac")
+ [[ -n $ip ]] && { printf '%s\n' "$ip"; return 0; }
+ sleep 2
+ done
+ die "timed out waiting for DHCP lease for $mac"
+}
+
+wait_for_ssh() {
+ local host=$1
+ for _ in $(seq 1 60); do
+ if ssh -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=2 -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new "lab@$host" true >/dev/null 2>&1; then return 0; fi
+ sleep 2
+ done
+ die "timed out waiting for SSH on $host"
+}
+
+lab_ssh() { local host=$1; shift; ssh -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new "lab@$host" "$@"; }
+lab_scp() { local source=$1 host=$2 destination=$3; scp -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -p -o BatchMode=yes -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new -r "$source" "lab@$host:$destination"; }
+
+safe_remove() {
+ local path=$1
+ [[ $path == "$LAB_ROOT"/* ]] || die "refusing to remove path outside lab root: $path"
+ rm -rf -- "$path"
+}
diff --git a/radius-harness/lib/configure.sh b/radius-harness/lib/configure.sh
new file mode 100755
index 00000000..15e5eab0
--- /dev/null
+++ b/radius-harness/lib/configure.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+LAB_ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd)
+source "$LAB_ROOT/lib/common.sh"
+source "$LAB_ROOT/lab.env"
+
+note "checking configuration prerequisites"
+require_command sshpass
+require_lab_secret RADIUS_SHARED_SECRET
+require_lab_secret ROUTEROS_ADMIN_PASSWORD
+
+note "discovering guest management addresses"
+lqos_ip=$(wait_for_management_ip 52:54:00:10:00:10)
+radius_ip=$(wait_for_management_ip 52:54:00:20:00:10)
+client_ip=$(wait_for_management_ip 52:54:00:30:00:10)
+router_ip=$(wait_for_management_ip 52:54:00:40:00:10)
+note "waiting for SSH on LibreQoS, FreeRADIUS, and PPPoE client guests"
+wait_for_ssh "$lqos_ip"; wait_for_ssh "$radius_ip"; wait_for_ssh "$client_ip"
+
+note "rendering isolated lab configuration"
+install -d -m 0700 "$RUN_DIR/config"
+umask 077
+cp "$LAB_ROOT/templates/lqos.conf" "$RUN_DIR/config/lqos.conf"
+cp "$LAB_ROOT/templates/network.json" "$RUN_DIR/config/network.json"
+cp "$LAB_ROOT/templates/ShapedDevices.csv" "$RUN_DIR/config/ShapedDevices.csv"
+escaped_radius_secret=$(printf '%s' "$RADIUS_SHARED_SECRET" | sed 's/[\\/&]/\\&/g')
+sed "s/__RADIUS_SHARED_SECRET__/$escaped_radius_secret/g" "$LAB_ROOT/templates/freeradius-client.conf" >"$RUN_DIR/config/radius-lab.conf"
+sed "s/__RADIUS_SHARED_SECRET__/$escaped_radius_secret/g" "$LAB_ROOT/templates/routeros.rsc" >"$RUN_DIR/config/radius-lab.rsc"
+
+note "uploading host-built LibreQoS runtime and fixtures"
+lab_ssh "$lqos_ip" 'rm -rf /tmp/runtime'
+lab_scp "$RUNTIME_DIR" "$lqos_ip" /tmp/
+for file in lqos.conf network.json ShapedDevices.csv; do lab_scp "$RUN_DIR/config/$file" "$lqos_ip" "/tmp/$file"; done
+lab_scp "$RUN_DIR/config/radius-lab.conf" "$radius_ip" /home/lab/radius-lab.conf
+lab_scp "$LAB_ROOT/templates/freeradius-users" "$radius_ip" /tmp/users
+
+note "installing LibreQoS fixture and RADIUS listener configuration"
+lab_ssh "$lqos_ip" 'sudo mkdir -p /opt/libreqos/src /etc/libreqos; sudo rm -rf /opt/libreqos/src/*; sudo cp -a /tmp/runtime/src/. /opt/libreqos/src/; sudo mv /tmp/runtime/bin /opt/libreqos/src/bin; sudo mv /tmp/runtime/liblqos_python.so /opt/libreqos/src/liblqos_python.so; rm -rf /tmp/runtime; sudo install -m 0644 /tmp/lqos.conf /etc/lqos.conf; sudo install -m 0644 /tmp/network.json /opt/libreqos/src/network.json; sudo install -m 0644 /tmp/ShapedDevices.csv /opt/libreqos/src/ShapedDevices.csv'
+note "creating isolated multiqueue veth interfaces for LibreQoS validation"
+lab_ssh "$lqos_ip" 'for interface in lqos-lan lqos-wan; do sudo ip link del "$interface" 2>/dev/null || true; sudo ip link add "$interface" numrxqueues 2 numtxqueues 2 type veth peer name "${interface}-p" numrxqueues 2 numtxqueues 2; sudo ip link set "$interface" up; sudo ip link set "${interface}-p" up; done'
+printf '%s\n' "$RADIUS_SHARED_SECRET" | lab_ssh "$lqos_ip" 'sudo tee /etc/libreqos/radius-shared-secret >/dev/null; sudo chmod 600 /etc/libreqos/radius-shared-secret'
+lab_ssh "$lqos_ip" "sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null; sudo nft delete table ip radius_lab 2>/dev/null || true; sudo nft add table ip radius_lab; sudo nft 'add chain ip radius_lab prerouting { type nat hook prerouting priority dstnat; }'; sudo nft 'add chain ip radius_lab postrouting { type nat hook postrouting priority srcnat; }'; sudo nft add rule ip radius_lab prerouting ip daddr 198.18.10.10 udp dport 1812 dnat to 198.18.10.20; sudo nft add rule ip radius_lab postrouting ip daddr 198.18.10.20 udp dport 1812 snat to 198.18.10.10"
+
+note "configuring and starting FreeRADIUS"
+lab_ssh "$radius_ip" "sudo install -m 0644 /tmp/users /etc/freeradius/3.0/mods-config/files/authorize; sudo grep -q 'client radius-lqos-nat' /etc/freeradius/3.0/clients.conf || sudo sh -c 'cat /home/lab/radius-lab.conf >> /etc/freeradius/3.0/clients.conf'; rm -f /home/lab/radius-lab.conf; sudo systemctl enable --now freeradius; sudo systemctl restart freeradius"
+note "starting lqosd before building the LibreQoS fixture state"
+lab_ssh "$lqos_ip" 'sudo systemctl stop radius-lqosd.service 2>/dev/null || true; sudo systemd-run --unit=radius-lqosd --collect --setenv=RUST_LOG=debug /opt/libreqos/src/bin/lqosd; sleep 1; sudo systemctl is-active --quiet radius-lqosd.service'
+note "building the LibreQoS fixture state inside the lab"
+lab_ssh "$lqos_ip" 'cd /opt/libreqos/src && sudo env PYTHONPATH=/opt/libreqos/src python3 LibreQoS.py'
+
+note "uploading RouterOS PPPoE and RADIUS configuration"
+export SSHPASS=$ROUTEROS_ADMIN_PASSWORD
+sshpass -e scp -p -o UserKnownHostsFile="$RUN_DIR/routeros_known_hosts" -o StrictHostKeyChecking=accept-new "$RUN_DIR/config/radius-lab.rsc" "admin@$router_ip:radius-lab.rsc"
+sshpass -e ssh -o UserKnownHostsFile="$RUN_DIR/routeros_known_hosts" -o StrictHostKeyChecking=accept-new "admin@$router_ip" '/import file-name=radius-lab.rsc'
+unset SSHPASS
+
+note "creating PPPoE client profile"
+printf '%s\n' 'plugin rp-pppoe.so pppoe0' 'user pppoe-lab' 'password pppoe-lab-password' 'noauth' 'persist' 'maxfail 1' | lab_ssh "$client_ip" 'sudo tee /etc/ppp/peers/radius-lab >/dev/null'
+note "guest configuration complete; run ./radius-harness/lab test"
diff --git a/radius-harness/lib/test-lifecycle.sh b/radius-harness/lib/test-lifecycle.sh
new file mode 100755
index 00000000..015b3a0f
--- /dev/null
+++ b/radius-harness/lib/test-lifecycle.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+LAB_ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd)
+source "$LAB_ROOT/lib/common.sh"
+
+journal_cursor() { local lqos_ip=$1; lab_ssh "$lqos_ip" "sudo journalctl -u radius-lqosd -n 0 --show-cursor --no-pager | sed -n 's/^-- cursor: //p'"; }
+
+wait_for_log() {
+ local lqos_ip=$1 cursor=$2 pattern=$3 description=$4
+ for _ in $(seq 1 20); do
+ lab_ssh "$lqos_ip" "sudo journalctl -u radius-lqosd --after-cursor='$cursor' --no-pager | grep -q -- '$pattern'" && return 0
+ sleep 2
+ done
+ die "$description"
+}
+
+dynamic_circuit_count_is() { local lqos_ip=$1 expected_count=$2; lab_ssh "$lqos_ip" "sudo python3 -c 'import json, os, sys; path = \"/opt/libreqos/src/dynamic_circuits.json\"; circuits = json.load(open(path)).get(\"circuits\", []) if os.path.exists(path) else []; sys.exit(0 if len(circuits) == $expected_count else 1)'"; }
+dynamic_circuit_has_profile() { local lqos_ip=$1 expected_circuit=$2 expected_download=$3 expected_upload=$4; lab_ssh "$lqos_ip" "sudo python3 -c 'import json, math, os, sys; path = \"/opt/libreqos/src/dynamic_circuits.json\"; circuits = json.load(open(path)).get(\"circuits\", []) if os.path.exists(path) else []; matches = [c.get(\"shaped\", {}) for c in circuits if (\"$expected_circuit\" == \"any\" or c.get(\"shaped\", {}).get(\"circuit_id\") == \"$expected_circuit\")]; sys.exit(0 if any(math.isclose(float(c.get(\"download_max_mbps\", -1)), $expected_download) and math.isclose(float(c.get(\"upload_max_mbps\", -1)), $expected_upload) for c in matches) else 1)'"; }
+
+wait_for_dynamic_circuit_profile() {
+ local lqos_ip=$1 expected_circuit=$2 expected_download=$3 expected_upload=$4 description=$5
+ for _ in $(seq 1 20); do dynamic_circuit_has_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" && return 0; sleep 2; done
+ die "$description"
+}
+
+run_pppoe_case() {
+ local name=$1 username=$2 password=$3 expected_circuit=$4 expected_download=$5 expected_upload=$6
+ note "checking that no dynamic circuit is present before $name"
+ dynamic_circuit_count_is "$lqos_ip" 0 || die "a dynamic circuit was already present before $name"
+ start_cursor=$(journal_cursor "$lqos_ip"); [[ -n $start_cursor ]] || die "could not establish an lqosd journal cursor"
+ note "starting PPPoE session for $name"
+ printf '%s\n' "plugin rp-pppoe.so pppoe0" "user $username" "password $password" noauth persist 'maxfail 1' | lab_ssh "$client_ip" 'sudo tee /etc/ppp/peers/radius-lab >/dev/null'
+ lab_ssh "$client_ip" 'sudo poff radius-lab || true; sudo pon radius-lab'
+ for _ in $(seq 1 30); do lab_ssh "$client_ip" "ip -4 addr show ppp0 | grep -q '100.64.0.2'" && break; sleep 2; done
+ lab_ssh "$client_ip" "ip -4 addr show ppp0 | grep -q '100.64.0.2'" || die "PPPoE client did not receive 100.64.0.2 for $name"
+ note "waiting for RADIUS Start and the expected $name queue"
+ wait_for_log "$lqos_ip" "$start_cursor" 'accepted RADIUS accounting packet.*status=Some[(]Start[)]' "lqosd did not accept a RADIUS Start packet for $name"
+ wait_for_log "$lqos_ip" "$start_cursor" 'applied RADIUS dynamic-circuit request.*intent="create"' "dynamic circuit creation was not observed for $name"
+ wait_for_dynamic_circuit_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" "the $name queue did not have the expected ${expected_download}/${expected_upload} Mbps profile"
+ interim_cursor=$(journal_cursor "$lqos_ip"); note "waiting for the RouterOS five-second RADIUS Interim-Update for $name"
+ wait_for_log "$lqos_ip" "$interim_cursor" 'accepted RADIUS accounting packet.*status=Some[(]InterimUpdate[)]' "lqosd did not accept a RADIUS Interim-Update packet for $name"
+ wait_for_dynamic_circuit_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" "the $name queue did not retain its expected profile after Interim-Update"
+ stop_cursor=$(journal_cursor "$lqos_ip"); note "disconnecting $name and waiting for RADIUS Stop plus queue removal"
+ lab_ssh "$client_ip" 'sudo poff radius-lab'
+ wait_for_log "$lqos_ip" "$stop_cursor" 'accepted RADIUS accounting packet.*status=Some[(]Stop[)]' "lqosd did not accept a RADIUS Stop packet for $name"
+ wait_for_log "$lqos_ip" "$stop_cursor" 'applied RADIUS dynamic-circuit request.*intent="remove"' "dynamic circuit removal was not observed for $name"
+ for _ in $(seq 1 20); do dynamic_circuit_count_is "$lqos_ip" 0 && return 0; sleep 2; done
+ die "the $name dynamic circuit remained after RADIUS Stop"
+}
+
+client_ip=$(wait_for_management_ip 52:54:00:30:00:10)
+lqos_ip=$(wait_for_management_ip 52:54:00:10:00:10)
+trap 'lab_ssh "$client_ip" "sudo poff radius-lab || true" >/dev/null 2>&1 || true' EXIT
+run_pppoe_case 'packet-rate fallback identity' pppoe-rate pppoe-rate-password any 10 25
+run_pppoe_case 'known username ShapedDevices identity' pppoe-known pppoe-known-password radius-known-circuit 60 20
+run_pppoe_case 'unknown username default identity' pppoe-unknown pppoe-unknown-password any 30 12
+trap - EXIT
+note "all RADIUS lifecycle and queue-profile tests passed"
diff --git a/radius-harness/templates/ShapedDevices.csv b/radius-harness/templates/ShapedDevices.csv
new file mode 100644
index 00000000..2b2e86ed
--- /dev/null
+++ b/radius-harness/templates/ShapedDevices.csv
@@ -0,0 +1,2 @@
+Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment
+radius-known-circuit,Radius Known Circuit,radius-known-device,Known RADIUS Subscriber,PPPoE Access,lab-pppoe-access,lab-core,pppoe-known,,,6,3,60,20,RADIUS username speed fixture
diff --git a/radius-harness/templates/client-network.yaml b/radius-harness/templates/client-network.yaml
new file mode 100644
index 00000000..a63c0e64
--- /dev/null
+++ b/radius-harness/templates/client-network.yaml
@@ -0,0 +1,4 @@
+version: 2
+ethernets:
+ mgmt0: {match: {macaddress: '52:54:00:30:00:10'}, set-name: mgmt0, dhcp4: true}
+ pppoe0: {match: {macaddress: '02:00:00:00:00:42'}, set-name: pppoe0, dhcp4: false}
diff --git a/radius-harness/templates/freeradius-client.conf b/radius-harness/templates/freeradius-client.conf
new file mode 100644
index 00000000..498e7144
--- /dev/null
+++ b/radius-harness/templates/freeradius-client.conf
@@ -0,0 +1,4 @@
+client radius-lqos-nat {
+ ipaddr = 198.18.10.10
+ secret = __RADIUS_SHARED_SECRET__
+}
diff --git a/radius-harness/templates/freeradius-users b/radius-harness/templates/freeradius-users
new file mode 100644
index 00000000..72eec23f
--- /dev/null
+++ b/radius-harness/templates/freeradius-users
@@ -0,0 +1,12 @@
+pppoe-rate Cleartext-Password := "pppoe-rate-password"
+ Framed-IP-Address := 100.64.0.2,
+ Framed-IP-Netmask := 255.255.255.255,
+ Mikrotik-Rate-Limit := "25M/10M"
+
+pppoe-known Cleartext-Password := "pppoe-known-password"
+ Framed-IP-Address := 100.64.0.2,
+ Framed-IP-Netmask := 255.255.255.255
+
+pppoe-unknown Cleartext-Password := "pppoe-unknown-password"
+ Framed-IP-Address := 100.64.0.2,
+ Framed-IP-Netmask := 255.255.255.255
diff --git a/radius-harness/templates/lqos-network.yaml b/radius-harness/templates/lqos-network.yaml
new file mode 100644
index 00000000..ed513879
--- /dev/null
+++ b/radius-harness/templates/lqos-network.yaml
@@ -0,0 +1,5 @@
+version: 2
+ethernets:
+ mgmt0: {match: {macaddress: '52:54:00:10:00:10'}, set-name: mgmt0, dhcp4: true}
+ control0: {match: {macaddress: '52:54:00:10:00:11'}, set-name: control0, addresses: [198.18.10.10/24]}
+ transit0: {match: {macaddress: '52:54:00:10:00:12'}, set-name: transit0, addresses: [198.18.30.10/24]}
diff --git a/radius-harness/templates/lqos.conf b/radius-harness/templates/lqos.conf
new file mode 100644
index 00000000..aa4da552
--- /dev/null
+++ b/radius-harness/templates/lqos.conf
@@ -0,0 +1,106 @@
+version = "1.5"
+lqos_directory = "/opt/libreqos/src"
+state_directory = "/opt/libreqos/state"
+node_id = "radius-lab"
+node_name = "RADIUS PPPoE Lab"
+packet_capture_time = 10
+queue_check_period_ms = 1000
+display_cobrand = false
+enable_circuit_heatmaps = true
+enable_site_heatmaps = true
+enable_asn_heatmaps = true
+
+[tuning]
+set_cpu_governor_performance = false
+stop_irq_balance = false
+netdev_budget_usecs = 8000
+netdev_budget_packets = 300
+rx_usecs = 8
+tx_usecs = 8
+disable_rxvlan = true
+disable_txvlan = true
+disable_offload = ["gso", "tso", "lro", "sg", "gro"]
+
+[bridge]
+use_xdp_bridge = false
+to_internet = "lqos-wan"
+to_network = "lqos-lan"
+
+[queues]
+default_sqm = "cake diffserv4"
+queue_mode = "shape"
+monitor_only = false
+uplink_bandwidth_mbps = 100
+downlink_bandwidth_mbps = 100
+generated_pn_download_mbps = 100
+generated_pn_upload_mbps = 100
+dry_run = true
+sudo = false
+use_binpacking = false
+
+[long_term_stats]
+gather_stats = false
+collation_period_seconds = 10
+license_key = ""
+
+[local_api]
+bearer_token = ""
+
+[ip_ranges]
+ignore_subnets = []
+allow_subnets = ["172.16.0.0/12", "10.0.0.0/8", "100.64.0.0/16", "192.168.0.0/16"]
+
+[integration_common]
+circuit_name_as_address = false
+queue_refresh_interval_mins = 30
+use_mikrotik_ipv6 = false
+ethernet_port_limits_enabled = true
+
+[netzur_integration]
+enable_netzur = false
+api_key = ""
+api_url = ""
+timeout_secs = 60
+
+[visp_integration]
+enable_visp = false
+client_id = ""
+client_secret = ""
+username = ""
+password = ""
+timeout_secs = 20
+
+[influxdb]
+enable_influxdb = false
+url = "http://localhost:8086"
+org = "libreqos"
+bucket = "RADIUS Lab"
+token = ""
+
+[dynamic_circuits]
+enabled = true
+
+[radius_accounting]
+enabled = true
+listen = "198.18.10.10:1813"
+default_ttl_seconds = 12
+stale_grace_seconds = 2
+
+[radius_accounting.dynamic_circuit_application]
+enabled = true
+match_shaped_devices_by_mac = true
+match_shaped_devices_by_username = true
+fallback_parent_node = "PPPoE Access"
+fallback_parent_node_id = "lab-pppoe-access"
+fallback_anchor_node_id = "lab-core"
+
+[radius_accounting.fallback_speed_profile]
+download_min_mbps = 4.0
+upload_min_mbps = 2.0
+download_max_mbps = 30.0
+upload_max_mbps = 12.0
+
+[[radius_accounting.clients]]
+name = "radius-routeros"
+source = ["198.18.10.1/32"]
+secret_file = "/etc/libreqos/radius-shared-secret"
diff --git a/radius-harness/templates/network-access.xml b/radius-harness/templates/network-access.xml
new file mode 100644
index 00000000..fc76fa07
--- /dev/null
+++ b/radius-harness/templates/network-access.xml
@@ -0,0 +1,5 @@
+
+ radius-access
+
+
+
diff --git a/radius-harness/templates/network-control.xml b/radius-harness/templates/network-control.xml
new file mode 100644
index 00000000..55a281a8
--- /dev/null
+++ b/radius-harness/templates/network-control.xml
@@ -0,0 +1,5 @@
+
+ radius-control
+
+
+
diff --git a/radius-harness/templates/network-transit.xml b/radius-harness/templates/network-transit.xml
new file mode 100644
index 00000000..5fc4433d
--- /dev/null
+++ b/radius-harness/templates/network-transit.xml
@@ -0,0 +1,5 @@
+
+ radius-transit
+
+
+
diff --git a/radius-harness/templates/network.json b/radius-harness/templates/network.json
new file mode 100644
index 00000000..2d8295ca
--- /dev/null
+++ b/radius-harness/templates/network.json
@@ -0,0 +1,16 @@
+{
+ "Lab Core": {
+ "id": "lab-core",
+ "downloadBandwidthMbps": 100,
+ "uploadBandwidthMbps": 100,
+ "type": "site",
+ "children": {
+ "PPPoE Access": {
+ "id": "lab-pppoe-access",
+ "downloadBandwidthMbps": 100,
+ "uploadBandwidthMbps": 100,
+ "type": "ap"
+ }
+ }
+ }
+}
diff --git a/radius-harness/templates/radius-network.yaml b/radius-harness/templates/radius-network.yaml
new file mode 100644
index 00000000..b3e46fa3
--- /dev/null
+++ b/radius-harness/templates/radius-network.yaml
@@ -0,0 +1,4 @@
+version: 2
+ethernets:
+ mgmt0: {match: {macaddress: '52:54:00:20:00:10'}, set-name: mgmt0, dhcp4: true}
+ control0: {match: {macaddress: '52:54:00:20:00:11'}, set-name: control0, addresses: [198.18.10.20/24]}
diff --git a/radius-harness/templates/routeros.rsc b/radius-harness/templates/routeros.rsc
new file mode 100644
index 00000000..29794279
--- /dev/null
+++ b/radius-harness/templates/routeros.rsc
@@ -0,0 +1,8 @@
+/ip address add address=198.18.10.1/24 interface=ether2
+/ip address add address=198.18.30.1/24 interface=ether4
+/ip pool add name=radius-pppoe ranges=100.64.0.2-100.64.0.2
+/ppp profile add name=radius-pppoe local-address=100.64.0.1 remote-address=radius-pppoe use-encryption=no
+/interface pppoe-server server add interface=ether3 service-name=radius-lab default-profile=radius-pppoe authentication=pap one-session-per-host=yes disabled=no
+/radius add service=ppp address=198.18.10.10 authentication-port=1812 accounting-port=1813 secret=__RADIUS_SHARED_SECRET__
+/ppp aaa set use-radius=yes accounting=yes interim-update=5s
+/ip service set ssh disabled=no
diff --git a/radius-harness/templates/user-data.yaml b/radius-harness/templates/user-data.yaml
new file mode 100644
index 00000000..1baa3a3a
--- /dev/null
+++ b/radius-harness/templates/user-data.yaml
@@ -0,0 +1,12 @@
+#cloud-config
+users:
+ - name: lab
+ groups: [adm, sudo]
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ ssh_authorized_keys:
+ - __LAB_SSH_KEY__
+package_update: true
+packages: [libelf1, nftables, ppp, pppoe, python3-chardet, python3-deepdiff, python3-psutil, freeradius, freeradius-utils]
+runcmd:
+ - systemctl disable --now freeradius || true
diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock
index 36e14605..28bdc3b7 100644
--- a/src/rust/Cargo.lock
+++ b/src/rust/Cargo.lock
@@ -2404,6 +2404,20 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "lqos_radius"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "hmac",
+ "ip_network",
+ "lqos_config",
+ "md-5",
+ "subtle",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
[[package]]
name = "lqos_rs"
version = "0.1.0"
@@ -2568,6 +2582,7 @@ dependencies = [
"lqos_overrides",
"lqos_probe",
"lqos_queue_tracker",
+ "lqos_radius",
"lqos_setup",
"lqos_stormguard",
"lqos_sys",
@@ -2655,6 +2670,16 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
[[package]]
name = "memalloc"
version = "0.1.0"
diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml
index 9e068f37..6ff4b947 100644
--- a/src/rust/Cargo.toml
+++ b/src/rust/Cargo.toml
@@ -39,6 +39,7 @@ members = [
"uisp", # REST support for the UISP API
"uisp_integration", # UISP Integration in Rust
"lqos_probe", # Shared active probe provider and result types.
+ "lqos_radius", # Rootless RADIUS accounting packet parsing and diagnostic UDP listener.
"lqos_stormguard", # An implementation of CAKE AutoRotate using dynamic bus information. EXPERIMENTAL.
"lqos_bakery", # The bakery makes CAKEs - controls queue creation.
"lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv
@@ -68,6 +69,8 @@ ip_network_table = "0"
ip_network = "0"
sha2 = "0.10"
hmac = "0.12"
+md-5 = "0.10"
+subtle = "2.6"
base64 = "0.22"
argon2 = "0.5"
rand_core = { version = "0.6", features = ["getrandom"] }
diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs
index bc2e90fc..21a10a05 100644
--- a/src/rust/lqos_config/src/etc/mod.rs
+++ b/src/rust/lqos_config/src/etc/mod.rs
@@ -20,10 +20,13 @@ pub mod test_data;
mod v15;
pub use v15::{
BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode,
- LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RttThresholds,
- SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy, TopologyConfig,
- TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
+ LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RadiusAccountingClient,
+ RadiusAccountingConfig, RadiusClientSource, RadiusDynamicCircuitApplicationConfig,
+ RadiusFallbackSpeedProfile, RadiusSharedSecretSource, RateProfileValidationError,
+ RttThresholds, SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy,
+ TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
TreeguardLinksConfig, TreeguardQooConfig, Tunables, normalize_external_hostname,
+ validate_rate_profile_mbps,
};
static CONFIG: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None));
diff --git a/src/rust/lqos_config/src/etc/v15/mod.rs b/src/rust/lqos_config/src/etc/v15/mod.rs
index 966faa7d..b5ab63a3 100644
--- a/src/rust/lqos_config/src/etc/v15/mod.rs
+++ b/src/rust/lqos_config/src/etc/v15/mod.rs
@@ -17,6 +17,7 @@ mod mikrotik_ipv6;
mod netzur_integration;
mod powercode_integration;
mod queues;
+mod radius_accounting;
mod sonar_integration;
mod splynx_integration;
mod stormguard;
@@ -33,6 +34,11 @@ pub use integration_common::IntegrationConfig;
pub use long_term_stats::LongTermStats;
pub use mikrotik_ipv6::MikrotikIpv6Config;
pub use queues::{LazyQueueMode, QueueMode};
+pub use radius_accounting::{
+ RadiusAccountingClient, RadiusAccountingConfig, RadiusClientSource,
+ RadiusDynamicCircuitApplicationConfig, RadiusFallbackSpeedProfile, RadiusSharedSecretSource,
+ RateProfileValidationError, validate_rate_profile_mbps,
+};
pub use stormguard::{StormguardConfig, StormguardStrategy};
pub use topology::{TopologyConfig, normalize_topology_compile_mode};
pub use treeguard::{
diff --git a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs
new file mode 100644
index 00000000..5067a2bc
--- /dev/null
+++ b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs
@@ -0,0 +1,1309 @@
+//! RADIUS accounting configuration.
+//!
+//! This module only defines and validates operator configuration. Runtime
+//! listener startup, packet authentication, and session handling live outside
+//! this config schema.
+
+use allocative::Allocative;
+use ip_network::IpNetwork;
+use serde::{Deserialize, Serialize};
+use std::fmt;
+use std::net::{IpAddr, SocketAddr};
+use thiserror::Error;
+
+fn default_ttl_seconds() -> u64 {
+ 900
+}
+
+fn default_stale_grace_seconds() -> u64 {
+ 120
+}
+
+/// Configured `secret_file` value for a RADIUS shared secret.
+///
+/// This stores the operator-configured `secret_file` string, normally a file
+/// path. Serde preserves the value for `/etc/lqos.conf` round trips, while
+/// `Debug` redacts this wrapper.
+#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)]
+#[serde(transparent)]
+pub struct RadiusSharedSecretSource(String);
+
+impl RadiusSharedSecretSource {
+ /// Creates a wrapper for an operator-configured `secret_file` value.
+ pub fn new(source: impl Into) -> Self {
+ Self(source.into())
+ }
+
+ /// Returns the configured `secret_file` value.
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ /// Returns true when the configured `secret_file` value is blank.
+ pub fn is_empty(&self) -> bool {
+ self.0.trim().is_empty()
+ }
+}
+
+impl fmt::Debug for RadiusSharedSecretSource {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("RadiusSharedSecretSource(REDACTED)")
+ }
+}
+
+impl From for RadiusSharedSecretSource {
+ fn from(source: String) -> Self {
+ Self::new(source)
+ }
+}
+
+impl From<&str> for RadiusSharedSecretSource {
+ fn from(source: &str) -> Self {
+ Self::new(source)
+ }
+}
+
+/// One trusted RADIUS client source address or network.
+#[derive(Clone, Debug, PartialEq, Allocative)]
+pub struct RadiusClientSource {
+ #[allocative(skip)]
+ network: IpNetwork,
+}
+
+impl RadiusClientSource {
+ /// Creates a RADIUS client source from a parsed IP network.
+ pub fn new(network: IpNetwork) -> Self {
+ Self { network }
+ }
+
+ /// Returns the parsed source network.
+ pub fn network(&self) -> &IpNetwork {
+ &self.network
+ }
+}
+
+impl Serialize for RadiusClientSource {
+ fn serialize(&self, serializer: S) -> Result
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.network.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for RadiusClientSource {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let raw = String::deserialize(deserializer)?;
+ parse_client_source(&raw)
+ .map(Self::new)
+ .map_err(serde::de::Error::custom)
+ }
+}
+
+/// One trusted RADIUS NAS client entry.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
+pub struct RadiusAccountingClient {
+ /// Optional operator label used for diagnostics.
+ #[serde(default)]
+ pub name: String,
+ /// UDP source allow-list for this NAS client.
+ ///
+ /// TOML may provide either one string or a list of strings. Values must be
+ /// IP addresses or CIDR networks.
+ #[serde(default, deserialize_with = "deserialize_source_allow_list")]
+ pub source: Vec,
+ /// Configured `secret_file` value for this client's shared secret.
+ #[serde(default)]
+ pub secret_file: RadiusSharedSecretSource,
+}
+
+impl RadiusAccountingClient {
+ /// Validates one trusted RADIUS client definition.
+ pub fn validate(&self, index: usize) -> Result<(), String> {
+ let label = client_label(index, &self.name);
+
+ if self.source.is_empty() {
+ return Err(format!(
+ "{label}.source must include at least one IP address or CIDR"
+ ));
+ }
+
+ if self.secret_file.is_empty() {
+ return Err(format!("{label}.secret_file must not be empty"));
+ }
+
+ Ok(())
+ }
+}
+
+/// Optional fallback speed profile for sessions without decoded packet rates or matched ShapedDevices rates.
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Allocative)]
+pub struct RadiusFallbackSpeedProfile {
+ /// Default minimum download bandwidth in Mbps.
+ pub download_min_mbps: f32,
+ /// Default minimum upload bandwidth in Mbps.
+ pub upload_min_mbps: f32,
+ /// Default maximum download bandwidth in Mbps.
+ pub download_max_mbps: f32,
+ /// Default maximum upload bandwidth in Mbps.
+ pub upload_max_mbps: f32,
+}
+
+impl RadiusFallbackSpeedProfile {
+ /// Validates all fallback speed values.
+ pub fn validate(&self) -> Result<(), String> {
+ validate_rate_profile_mbps(
+ self.download_min_mbps,
+ self.upload_min_mbps,
+ self.download_max_mbps,
+ self.upload_max_mbps,
+ )
+ .map_err(|error| error.with_config_prefix("radius_accounting.fallback_speed_profile"))
+ }
+}
+
+/// Validation error for a complete Mbps rate profile.
+#[derive(Clone, Copy, Debug, Error, PartialEq)]
+pub enum RateProfileValidationError {
+ /// A rate value was NaN or infinite.
+ #[error("{field} must be finite, got {rate_mbps} Mbps")]
+ NonFinite {
+ /// Field that failed validation.
+ field: &'static str,
+ /// Invalid rate value in Mbps.
+ rate_mbps: f32,
+ },
+ /// A rate value was zero or negative.
+ #[error("{field} must be > 0, got {rate_mbps} Mbps")]
+ NonPositive {
+ /// Field that failed validation.
+ field: &'static str,
+ /// Invalid rate value in Mbps.
+ rate_mbps: f32,
+ },
+ /// A minimum rate exceeded its matching maximum rate.
+ #[error("{min_field} ({min_mbps} Mbps) must be <= {max_field} ({max_mbps} Mbps)")]
+ MinimumExceedsMaximum {
+ /// Minimum-rate field that failed validation.
+ min_field: &'static str,
+ /// Minimum-rate value in Mbps.
+ min_mbps: f32,
+ /// Maximum-rate field that failed validation.
+ max_field: &'static str,
+ /// Maximum-rate value in Mbps.
+ max_mbps: f32,
+ },
+}
+
+impl RateProfileValidationError {
+ /// Formats this validation error with a config-path prefix.
+ #[must_use]
+ pub fn with_config_prefix(&self, prefix: &str) -> String {
+ match self {
+ Self::NonFinite { field, .. } => format!("{prefix}.{field} must be finite"),
+ Self::NonPositive { field, .. } => format!("{prefix}.{field} must be > 0"),
+ Self::MinimumExceedsMaximum {
+ min_field,
+ max_field,
+ ..
+ } => format!("{prefix}.{min_field} must be <= {prefix}.{max_field}"),
+ }
+ }
+}
+
+/// Validates a complete Mbps rate profile.
+pub fn validate_rate_profile_mbps(
+ download_min_mbps: f32,
+ upload_min_mbps: f32,
+ download_max_mbps: f32,
+ upload_max_mbps: f32,
+) -> Result<(), RateProfileValidationError> {
+ validate_rate_profile_field("download_min_mbps", download_min_mbps)?;
+ validate_rate_profile_field("upload_min_mbps", upload_min_mbps)?;
+ validate_rate_profile_field("download_max_mbps", download_max_mbps)?;
+ validate_rate_profile_field("upload_max_mbps", upload_max_mbps)?;
+ validate_rate_profile_order(
+ "download_min_mbps",
+ download_min_mbps,
+ "download_max_mbps",
+ download_max_mbps,
+ )?;
+ validate_rate_profile_order(
+ "upload_min_mbps",
+ upload_min_mbps,
+ "upload_max_mbps",
+ upload_max_mbps,
+ )
+}
+
+/// RADIUS accounting listener configuration.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
+pub struct RadiusAccountingConfig {
+ /// Persisted operator flag for RADIUS accounting listener support.
+ ///
+ /// When true, `lqosd` starts the listener after configuration validation.
+ #[serde(default)]
+ pub enabled: bool,
+ /// UDP socket address for the listener, for example `0.0.0.0:1813`.
+ #[allocative(skip)]
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub listen: Option,
+ /// Time-to-live in seconds for accounting-derived session state.
+ #[serde(default = "default_ttl_seconds")]
+ pub default_ttl_seconds: u64,
+ /// Grace period in seconds before stale accounting state is considered expired.
+ #[serde(default = "default_stale_grace_seconds")]
+ pub stale_grace_seconds: u64,
+ /// Safety gate for RADIUS dynamic-circuit application.
+ #[serde(default)]
+ pub dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig,
+ /// Optional fallback speed profile for sessions without decoded packet rates or matched ShapedDevices rates.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fallback_speed_profile: Option,
+ /// Trusted RADIUS NAS clients.
+ #[serde(default)]
+ pub clients: Vec,
+}
+
+/// RADIUS dynamic-circuit application settings.
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)]
+pub struct RadiusDynamicCircuitApplicationConfig {
+ /// Enable the RADIUS dynamic-circuit application gate.
+ #[serde(default)]
+ pub enabled: bool,
+ /// Match `Calling-Station-Id` values to `ShapedDevices.csv` MAC fields.
+ #[serde(default)]
+ pub match_shaped_devices_by_mac: bool,
+ /// Match RADIUS `User-Name` values to `ShapedDevices.csv` MAC fields verbatim.
+ #[serde(default)]
+ pub match_shaped_devices_by_username: bool,
+ /// Parent node used for default RADIUS identities when MAC matching does not supply metadata.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fallback_parent_node: Option,
+ /// Stable parent node ID used with `fallback_parent_node`, when known.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fallback_parent_node_id: Option,
+ /// Stable anchor node ID used with `fallback_parent_node`, when known.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fallback_anchor_node_id: Option,
+}
+
+impl RadiusDynamicCircuitApplicationConfig {
+ /// Validates RADIUS dynamic-circuit application settings.
+ pub fn validate(&self) -> Result<(), String> {
+ validate_optional_text(
+ "radius_accounting.dynamic_circuit_application.fallback_parent_node",
+ self.fallback_parent_node.as_deref(),
+ )?;
+ validate_optional_text(
+ "radius_accounting.dynamic_circuit_application.fallback_parent_node_id",
+ self.fallback_parent_node_id.as_deref(),
+ )?;
+ validate_optional_text(
+ "radius_accounting.dynamic_circuit_application.fallback_anchor_node_id",
+ self.fallback_anchor_node_id.as_deref(),
+ )?;
+
+ if self.fallback_parent_node.is_none()
+ && (self.fallback_parent_node_id.is_some() || self.fallback_anchor_node_id.is_some())
+ {
+ return Err(
+ "radius_accounting.dynamic_circuit_application.fallback_parent_node must be set when fallback parent IDs are configured"
+ .to_string(),
+ );
+ }
+
+ Ok(())
+ }
+}
+
+impl Default for RadiusAccountingConfig {
+ fn default() -> Self {
+ Self {
+ enabled: false,
+ listen: None,
+ default_ttl_seconds: default_ttl_seconds(),
+ stale_grace_seconds: default_stale_grace_seconds(),
+ dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig::default(),
+ fallback_speed_profile: None,
+ clients: Vec::new(),
+ }
+ }
+}
+
+impl RadiusAccountingConfig {
+ /// Validates RADIUS accounting configuration.
+ pub fn validate(&self) -> Result<(), String> {
+ if self.default_ttl_seconds == 0 {
+ return Err("radius_accounting.default_ttl_seconds must be > 0".to_string());
+ }
+ if self.stale_grace_seconds == 0 {
+ return Err("radius_accounting.stale_grace_seconds must be > 0".to_string());
+ }
+
+ if self.listen.is_some_and(|listen| listen.port() == 0) {
+ return Err("radius_accounting.listen port must be > 0".to_string());
+ }
+
+ if self.dynamic_circuit_application.enabled {
+ self.dynamic_circuit_application.validate()?;
+ if let Some(fallback_speed_profile) = &self.fallback_speed_profile {
+ fallback_speed_profile.validate()?;
+ }
+ }
+
+ for (index, client) in self.clients.iter().enumerate() {
+ client.validate(index)?;
+ }
+
+ if self.enabled {
+ if self.listen.is_none() {
+ return Err("radius_accounting.listen must be configured when enabled".to_string());
+ }
+ if self.clients.is_empty() {
+ return Err(
+ "radius_accounting.clients must include at least one client when enabled"
+ .to_string(),
+ );
+ }
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum SourceAllowList {
+ One(String),
+ Many(Vec),
+}
+
+fn deserialize_source_allow_list<'de, D>(
+ deserializer: D,
+) -> Result, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ match SourceAllowList::deserialize(deserializer)? {
+ SourceAllowList::One(source) => parse_source_allow_list(vec![source]),
+ SourceAllowList::Many(sources) => parse_source_allow_list(sources),
+ }
+}
+
+fn parse_source_allow_list(sources: Vec) -> Result, E>
+where
+ E: serde::de::Error,
+{
+ sources
+ .into_iter()
+ .map(|source| {
+ parse_client_source(&source)
+ .map(RadiusClientSource::new)
+ .map_err(serde::de::Error::custom)
+ })
+ .collect()
+}
+
+fn client_label(index: usize, name: &str) -> String {
+ let trimmed_name = name.trim();
+ if trimmed_name.is_empty() {
+ format!("radius_accounting.clients[{index}]")
+ } else {
+ format!("radius_accounting.clients[{index}] ('{trimmed_name}')")
+ }
+}
+
+fn validate_optional_text(config_path: &str, value: Option<&str>) -> Result<(), String> {
+ if value.is_some_and(|value| value.trim().is_empty()) {
+ return Err(format!("{config_path} must not be empty when configured"));
+ }
+ Ok(())
+}
+
+fn validate_rate_profile_field(
+ field: &'static str,
+ rate_mbps: f32,
+) -> Result<(), RateProfileValidationError> {
+ if !rate_mbps.is_finite() {
+ return Err(RateProfileValidationError::NonFinite { field, rate_mbps });
+ }
+ if rate_mbps <= 0.0 {
+ return Err(RateProfileValidationError::NonPositive { field, rate_mbps });
+ }
+ Ok(())
+}
+
+fn validate_rate_profile_order(
+ min_field: &'static str,
+ min_mbps: f32,
+ max_field: &'static str,
+ max_mbps: f32,
+) -> Result<(), RateProfileValidationError> {
+ if min_mbps > max_mbps {
+ return Err(RateProfileValidationError::MinimumExceedsMaximum {
+ min_field,
+ min_mbps,
+ max_field,
+ max_mbps,
+ });
+ }
+ Ok(())
+}
+
+fn parse_client_source(source: &str) -> Result {
+ let trimmed = source.trim();
+ if trimmed.is_empty() {
+ return Err("source is empty".to_string());
+ }
+
+ if let Ok(network) = trimmed.parse::() {
+ return Ok(network);
+ }
+
+ if let Ok(ip) = trimmed.parse::() {
+ return Ok(IpNetwork::from(ip));
+ }
+
+ Err(format!("'{trimmed}' is not a valid IP address or CIDR"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::etc::v15::Config;
+
+ const TEST_CLIENT_NAME: &str = "pppoe-core-1";
+ const TEST_CLIENT_NAME_LINE: &str = "name = \"pppoe-core-1\"";
+ const TEST_LISTEN: &str = "127.0.0.1:18130";
+ const TEST_LISTEN_LINE: &str = "listen = \"127.0.0.1:18130\"";
+ const TEST_SOURCE: &str = "192.0.2.10/32";
+ const TEST_SOURCE_LINE: &str = "source = \"192.0.2.10/32\"";
+ const TEST_SECRET_FILE: &str = "/etc/lqos/radius-secrets/pppoe-core-1";
+ const TEST_SECRET_FILE_LINE: &str = "secret_file = \"/etc/lqos/radius-secrets/pppoe-core-1\"";
+ const TEST_TTL_SECONDS_LINE: &str = "default_ttl_seconds = 900";
+ const TEST_STALE_GRACE_SECONDS_LINE: &str = "stale_grace_seconds = 120";
+ const TEST_FALLBACK_SECTION_LINES: &[&str] = &[
+ "[radius_accounting.fallback_speed_profile]",
+ "download_min_mbps = 5.0",
+ "upload_min_mbps = 3.0",
+ "download_max_mbps = 25.0",
+ "upload_max_mbps = 10.0",
+ ];
+ const TEST_CLIENT_LINES: &[&str] = &[TEST_SOURCE_LINE, TEST_SECRET_FILE_LINE];
+ const TEST_NAMED_CLIENT_LINES: &[&str] = &[
+ TEST_CLIENT_NAME_LINE,
+ TEST_SOURCE_LINE,
+ TEST_SECRET_FILE_LINE,
+ ];
+
+ fn source(raw: &str) -> RadiusClientSource {
+ RadiusClientSource::new(parse_client_source(raw).expect("test source should parse"))
+ }
+
+ fn radius_section(
+ enabled: bool,
+ section_lines: &[&str],
+ client_lines: Option<&[&str]>,
+ ) -> String {
+ let mut radius = format!("\n\n[radius_accounting]\nenabled = {enabled}\n");
+ for line in section_lines {
+ radius.push_str(line);
+ radius.push('\n');
+ }
+ if let Some(client_lines) = client_lines {
+ radius.push_str("\n[[radius_accounting.clients]]\n");
+ for line in client_lines {
+ radius.push_str(line);
+ radius.push('\n');
+ }
+ }
+ radius
+ }
+
+ fn enabled_radius_section(section_lines: &[&str], client_lines: Option<&[&str]>) -> String {
+ let mut lines = Vec::with_capacity(section_lines.len() + 1);
+ lines.push(TEST_LISTEN_LINE);
+ lines.extend_from_slice(section_lines);
+ radius_section(true, &lines, client_lines)
+ }
+
+ fn enabled_radius_section_with_clients(client_sections: &[&[&str]]) -> String {
+ let mut radius = format!("\n\n[radius_accounting]\nenabled = true\n{TEST_LISTEN_LINE}\n");
+ for client_lines in client_sections {
+ radius.push_str("\n[[radius_accounting.clients]]\n");
+ for line in *client_lines {
+ radius.push_str(line);
+ radius.push('\n');
+ }
+ }
+ radius
+ }
+
+ fn example_with_radius(radius: &str) -> String {
+ let mut raw = include_str!("example.toml").to_string();
+ raw.push_str(radius);
+ raw
+ }
+
+ fn load_error_contains(radius: &str, expected: &str) {
+ let raw = example_with_radius(radius);
+ let error = Config::load_from_string(&raw).expect_err("config should fail validation");
+ assert!(
+ error.contains(expected),
+ "expected error to contain '{expected}', got '{error}'"
+ );
+ }
+
+ fn first_radius_client(radius: &str) -> RadiusAccountingClient {
+ Config::load_from_string(&example_with_radius(radius))
+ .expect("radius accounting should deserialize")
+ .radius_accounting
+ .expect("radius accounting should be present")
+ .clients
+ .into_iter()
+ .next()
+ .expect("radius accounting should include a client")
+ }
+
+ fn load_error_cases(cases: impl IntoIterator- ) {
+ for (radius, expected) in cases {
+ load_error_contains(&radius, expected);
+ }
+ }
+
+ fn assert_disabled_defaults(radius: &RadiusAccountingConfig) {
+ assert!(!radius.enabled);
+ assert!(radius.listen.is_none());
+ assert!(radius.clients.is_empty());
+ assert_eq!(radius.default_ttl_seconds, default_ttl_seconds());
+ assert_eq!(radius.stale_grace_seconds, default_stale_grace_seconds());
+ assert!(!radius.dynamic_circuit_application.enabled);
+ assert!(
+ !radius
+ .dynamic_circuit_application
+ .match_shaped_devices_by_mac
+ );
+ assert!(
+ radius
+ .dynamic_circuit_application
+ .fallback_parent_node
+ .is_none()
+ );
+ assert!(
+ radius
+ .dynamic_circuit_application
+ .fallback_parent_node_id
+ .is_none()
+ );
+ assert!(
+ radius
+ .dynamic_circuit_application
+ .fallback_anchor_node_id
+ .is_none()
+ );
+ assert!(radius.fallback_speed_profile.is_none());
+ }
+
+ fn valid_client() -> RadiusAccountingClient {
+ RadiusAccountingClient {
+ name: TEST_CLIENT_NAME.to_string(),
+ source: vec![source(TEST_SOURCE), source("2001:db8::/48")],
+ secret_file: RadiusSharedSecretSource::from(TEST_SECRET_FILE),
+ }
+ }
+
+ fn valid_fallback_speed_profile() -> RadiusFallbackSpeedProfile {
+ RadiusFallbackSpeedProfile {
+ download_min_mbps: 5.0,
+ upload_min_mbps: 3.0,
+ download_max_mbps: 25.0,
+ upload_max_mbps: 10.0,
+ }
+ }
+
+ fn valid_enabled_config() -> RadiusAccountingConfig {
+ RadiusAccountingConfig {
+ enabled: true,
+ listen: Some(
+ TEST_LISTEN
+ .parse()
+ .expect("test listen address should parse"),
+ ),
+ default_ttl_seconds: default_ttl_seconds(),
+ stale_grace_seconds: default_stale_grace_seconds(),
+ dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig::default(),
+ fallback_speed_profile: None,
+ clients: vec![valid_client()],
+ }
+ }
+
+ #[test]
+ fn disabled_radius_accounting_allows_no_clients() {
+ let config = RadiusAccountingConfig::default();
+
+ config
+ .validate()
+ .expect("disabled radius accounting should not require clients");
+ }
+
+ #[test]
+ fn disabled_radius_accounting_round_trips() {
+ let config = Config {
+ radius_accounting: Some(RadiusAccountingConfig::default()),
+ ..Config::default()
+ };
+
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ let radius = parsed
+ .radius_accounting
+ .expect("radius accounting section should round trip");
+
+ assert_disabled_defaults(&radius);
+ }
+
+ #[test]
+ fn empty_radius_accounting_toml_section_uses_disabled_defaults() {
+ let config = Config::load_from_string(&example_with_radius(
+ r#"
+
+[radius_accounting]
+"#,
+ ))
+ .expect("empty radius accounting section should deserialize");
+ let radius = config
+ .radius_accounting
+ .expect("radius accounting should be present");
+
+ assert_disabled_defaults(&radius);
+ }
+
+ #[test]
+ fn enabled_radius_accounting_round_trips_and_redacts_debug_secret_source() {
+ let config = Config {
+ radius_accounting: Some(valid_enabled_config()),
+ ..Config::default()
+ };
+
+ config
+ .validate()
+ .expect("radius accounting should validate");
+ let debug = format!("{config:?}");
+ assert!(!debug.contains(TEST_SECRET_FILE));
+ assert!(debug.contains("RadiusSharedSecretSource(REDACTED)"));
+
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ assert!(raw.contains("[radius_accounting]"));
+ assert!(raw.contains(TEST_SECRET_FILE_LINE));
+
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ assert_eq!(parsed.radius_accounting, config.radius_accounting);
+
+ let toml_config = Config::load_from_string(&example_with_radius(&enabled_radius_section(
+ TEST_FALLBACK_SECTION_LINES,
+ Some(TEST_CLIENT_LINES),
+ )))
+ .expect("fallback speed profile TOML should deserialize");
+ assert_eq!(
+ toml_config
+ .radius_accounting
+ .expect("radius accounting should be present")
+ .fallback_speed_profile,
+ Some(valid_fallback_speed_profile())
+ );
+ }
+
+ #[test]
+ fn disabled_dynamic_circuit_application_round_trips() {
+ let mut radius = valid_enabled_config();
+ radius.dynamic_circuit_application.enabled = false;
+ let config = Config {
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ let radius = parsed
+ .radius_accounting
+ .expect("radius accounting section should round trip");
+
+ assert!(!radius.dynamic_circuit_application.enabled);
+ }
+
+ #[test]
+ fn enabled_dynamic_circuit_application_round_trips_without_top_level_dynamic_circuits() {
+ let mut radius = valid_enabled_config();
+ radius.dynamic_circuit_application.enabled = true;
+ let config = Config {
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+
+ config
+ .validate()
+ .expect("valid RADIUS dynamic circuit activation should pass");
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ assert!(raw.contains("[radius_accounting.dynamic_circuit_application]"));
+
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ let radius = parsed
+ .radius_accounting
+ .expect("radius accounting section should round trip");
+ assert!(radius.dynamic_circuit_application.enabled);
+ assert!(
+ !radius
+ .dynamic_circuit_application
+ .match_shaped_devices_by_mac
+ );
+ assert!(parsed.dynamic_circuits.is_none());
+ }
+
+ #[test]
+ fn fallback_speed_profile_round_trips() {
+ let mut radius = valid_enabled_config();
+ radius.fallback_speed_profile = Some(valid_fallback_speed_profile());
+ let config = Config {
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+
+ config
+ .validate()
+ .expect("valid fallback speed profile should pass");
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ assert!(raw.contains("[radius_accounting.fallback_speed_profile]"));
+ assert!(raw.contains("download_min_mbps = 5.0"));
+
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ assert_eq!(parsed.radius_accounting, config.radius_accounting);
+ }
+
+ #[test]
+ fn mac_match_dynamic_application_setting_round_trips() {
+ let mut radius = valid_enabled_config();
+ radius.dynamic_circuit_application.enabled = true;
+ radius
+ .dynamic_circuit_application
+ .match_shaped_devices_by_mac = true;
+ let config = Config {
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+
+ config
+ .validate()
+ .expect("valid RADIUS MAC matching settings should pass");
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ assert!(raw.contains("match_shaped_devices_by_mac = true"));
+
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ let radius = parsed
+ .radius_accounting
+ .expect("radius accounting section should round trip");
+ assert!(
+ radius
+ .dynamic_circuit_application
+ .match_shaped_devices_by_mac
+ );
+ }
+
+ #[test]
+ fn username_match_dynamic_application_setting_round_trips() {
+ let mut radius = valid_enabled_config();
+ radius.dynamic_circuit_application.enabled = true;
+ radius
+ .dynamic_circuit_application
+ .match_shaped_devices_by_username = true;
+ let config = Config {
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+
+ config
+ .validate()
+ .expect("valid RADIUS username matching settings should pass");
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ assert!(raw.contains("match_shaped_devices_by_username = true"));
+
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ let radius = parsed
+ .radius_accounting
+ .expect("radius accounting section should round trip");
+ assert!(
+ radius
+ .dynamic_circuit_application
+ .match_shaped_devices_by_username
+ );
+ }
+
+ #[test]
+ fn fallback_parent_settings_round_trip() {
+ let mut radius = valid_enabled_config();
+ radius.dynamic_circuit_application.enabled = true;
+ radius.dynamic_circuit_application.fallback_parent_node = Some("Core PPPoE".to_string());
+ radius.dynamic_circuit_application.fallback_parent_node_id = Some("core-pppoe".to_string());
+ radius.dynamic_circuit_application.fallback_anchor_node_id =
+ Some("radius-anchor".to_string());
+ let config = Config {
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+
+ config
+ .validate()
+ .expect("valid RADIUS fallback parent settings should pass");
+ let raw = toml::to_string_pretty(&config).expect("config should serialize");
+ assert!(raw.contains("fallback_parent_node = \"Core PPPoE\""));
+ assert!(raw.contains("fallback_parent_node_id = \"core-pppoe\""));
+ assert!(raw.contains("fallback_anchor_node_id = \"radius-anchor\""));
+
+ let parsed = Config::load_from_string(&raw).expect("config should deserialize");
+ assert_eq!(parsed.radius_accounting, config.radius_accounting);
+ }
+
+ #[test]
+ fn validation_rejects_invalid_fallback_speed_profile() {
+ for (label, profile, expected) in [
+ (
+ "non-finite download minimum",
+ RadiusFallbackSpeedProfile {
+ download_min_mbps: f32::NAN,
+ ..valid_fallback_speed_profile()
+ },
+ "download_min_mbps must be finite",
+ ),
+ (
+ "non-finite upload maximum",
+ RadiusFallbackSpeedProfile {
+ upload_max_mbps: f32::INFINITY,
+ ..valid_fallback_speed_profile()
+ },
+ "upload_max_mbps must be finite",
+ ),
+ (
+ "zero download maximum",
+ RadiusFallbackSpeedProfile {
+ download_max_mbps: 0.0,
+ ..valid_fallback_speed_profile()
+ },
+ "download_max_mbps must be > 0",
+ ),
+ (
+ "negative upload minimum",
+ RadiusFallbackSpeedProfile {
+ upload_min_mbps: -1.0,
+ ..valid_fallback_speed_profile()
+ },
+ "upload_min_mbps must be > 0",
+ ),
+ (
+ "download minimum greater than maximum",
+ RadiusFallbackSpeedProfile {
+ download_min_mbps: 30.0,
+ ..valid_fallback_speed_profile()
+ },
+ "download_min_mbps must be <= radius_accounting.fallback_speed_profile.download_max_mbps",
+ ),
+ (
+ "upload minimum greater than maximum",
+ RadiusFallbackSpeedProfile {
+ upload_min_mbps: 15.0,
+ ..valid_fallback_speed_profile()
+ },
+ "upload_min_mbps must be <= radius_accounting.fallback_speed_profile.upload_max_mbps",
+ ),
+ ] {
+ let mut config = valid_enabled_config();
+ config.dynamic_circuit_application.enabled = true;
+ config.fallback_speed_profile = Some(profile);
+ let error = config.validate().expect_err(label);
+
+ assert!(
+ error.contains(expected),
+ "expected error to contain '{expected}', got '{error}'"
+ );
+ }
+ }
+
+ #[test]
+ fn dynamic_circuit_application_does_not_require_top_level_dynamic_circuits() {
+ let mut radius = valid_enabled_config();
+ radius.dynamic_circuit_application.enabled = true;
+
+ let missing_top_level_dynamic_circuits = Config {
+ radius_accounting: Some(radius.clone()),
+ ..Config::default()
+ };
+ missing_top_level_dynamic_circuits
+ .validate()
+ .expect("RADIUS dynamic circuit activation should not require dynamic_circuits");
+
+ let disabled_top_level_dynamic_circuits = Config {
+ dynamic_circuits: Some(Default::default()),
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+ disabled_top_level_dynamic_circuits
+ .validate()
+ .expect("disabled dynamic_circuits should not block RADIUS dynamic circuit activation");
+ }
+
+ #[test]
+ fn disabled_radius_accounting_dynamic_application_does_not_require_dynamic_circuits() {
+ let radius = RadiusAccountingConfig {
+ dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig {
+ enabled: true,
+ ..RadiusDynamicCircuitApplicationConfig::default()
+ },
+ ..RadiusAccountingConfig::default()
+ };
+ let config = Config {
+ radius_accounting: Some(radius),
+ ..Config::default()
+ };
+
+ config
+ .validate()
+ .expect("disabled RADIUS accounting should not require dynamic_circuits");
+ }
+
+ #[test]
+ fn dynamic_circuit_activation_settings_deserialize_without_top_level_dynamic_circuits() {
+ let radius = format!(
+ r#"
+
+[radius_accounting]
+enabled = true
+{TEST_LISTEN_LINE}
+
+[radius_accounting.dynamic_circuit_application]
+enabled = true
+
+[[radius_accounting.clients]]
+{TEST_SOURCE_LINE}
+{TEST_SECRET_FILE_LINE}
+"#
+ );
+ let config = Config::load_from_string(&example_with_radius(&radius))
+ .expect("valid activation settings should deserialize");
+ let radius = config
+ .radius_accounting
+ .expect("radius accounting should be present");
+
+ assert!(radius.dynamic_circuit_application.enabled);
+ assert!(config.dynamic_circuits.is_none());
+ }
+
+ #[test]
+ fn source_accepts_single_string_in_toml() {
+ let radius = enabled_radius_section(
+ &[TEST_TTL_SECONDS_LINE, TEST_STALE_GRACE_SECONDS_LINE],
+ Some(TEST_NAMED_CLIENT_LINES),
+ );
+ let client = first_radius_client(&radius);
+
+ assert_eq!(client.source[0].network().to_string(), "192.0.2.10/32");
+ }
+
+ #[test]
+ fn enabled_toml_without_timing_values_uses_defaults() {
+ let radius = enabled_radius_section(&[], Some(TEST_CLIENT_LINES));
+ let config = Config::load_from_string(&example_with_radius(&radius))
+ .expect("timing defaults should deserialize");
+ let radius = config
+ .radius_accounting
+ .expect("radius accounting should be present");
+
+ assert_eq!(radius.default_ttl_seconds, default_ttl_seconds());
+ assert_eq!(radius.stale_grace_seconds, default_stale_grace_seconds());
+ }
+
+ #[test]
+ fn enabled_radius_accounting_requires_listen_and_clients() {
+ let mut config = valid_enabled_config();
+ config.listen = None;
+ let error = config
+ .validate()
+ .expect_err("enabled radius accounting should require listen");
+ assert!(error.contains("listen"));
+
+ let mut config = valid_enabled_config();
+ config.clients.clear();
+ let error = config
+ .validate()
+ .expect_err("enabled radius accounting should require clients");
+ assert!(error.contains("clients"));
+ }
+
+ #[test]
+ fn validation_rejects_invalid_listen_address() {
+ let radius = radius_section(
+ true,
+ &[r#"listen = "not-a-socket""#],
+ Some(TEST_CLIENT_LINES),
+ );
+ load_error_contains(&radius, "invalid socket address");
+ }
+
+ #[test]
+ fn validation_rejects_zero_listen_port() {
+ let mut config = valid_enabled_config();
+ config.listen = Some("0.0.0.0:0".parse().expect("test listen should parse"));
+
+ let error = config
+ .validate()
+ .expect_err("zero listen port should fail validation");
+
+ assert!(error.contains("listen port"));
+ }
+
+ #[test]
+ fn validation_rejects_invalid_client_source() {
+ let radius = enabled_radius_section(
+ &[],
+ Some(&[r#"source = "not-a-network""#, TEST_SECRET_FILE_LINE]),
+ );
+ load_error_contains(&radius, "not a valid IP address or CIDR");
+ }
+
+ #[test]
+ fn validation_rejects_missing_secret_source() {
+ let mut config = valid_enabled_config();
+ config.clients[0].secret_file = RadiusSharedSecretSource::default();
+
+ let error = config
+ .validate()
+ .expect_err("missing secret source should fail validation");
+
+ assert!(error.contains("secret_file"));
+ }
+
+ #[test]
+ fn validation_rejects_missing_client_source() {
+ load_error_cases([
+ (
+ enabled_radius_section(&[], Some(&[TEST_SECRET_FILE_LINE])),
+ "source",
+ ),
+ (
+ enabled_radius_section(&[], Some(&["source = []", TEST_SECRET_FILE_LINE])),
+ "source",
+ ),
+ ]);
+ }
+
+ #[test]
+ fn validation_checks_each_configured_client() {
+ let mut config = valid_enabled_config();
+ let mut second_client = valid_client();
+ second_client.name = "pppoe-core-2".to_string();
+ second_client.source.clear();
+ config.clients.push(second_client);
+
+ let error = config
+ .validate()
+ .expect_err("second client missing source should fail validation");
+
+ assert!(error.contains("clients[1]"));
+ assert!(error.contains("pppoe-core-2"));
+ assert!(error.contains("source"));
+
+ let mut config = valid_enabled_config();
+ let mut second_client = valid_client();
+ second_client.name = "pppoe-core-2".to_string();
+ second_client.secret_file = RadiusSharedSecretSource::default();
+ config.clients.push(second_client);
+
+ let error = config
+ .validate()
+ .expect_err("second client missing secret should fail validation");
+
+ assert!(error.contains("clients[1]"));
+ assert!(error.contains("pppoe-core-2"));
+ assert!(error.contains("secret_file"));
+ }
+
+ #[test]
+ fn multiple_clients_toml_deserializes_and_validates() {
+ let radius = enabled_radius_section_with_clients(&[
+ TEST_NAMED_CLIENT_LINES,
+ &[
+ r#"name = "pppoe-core-2""#,
+ r#"source = ["192.0.2.11/32", "2001:db8:1::/48"]"#,
+ TEST_SECRET_FILE_LINE,
+ ],
+ ]);
+ let config = Config::load_from_string(&example_with_radius(&radius))
+ .expect("multiple radius clients should deserialize");
+ let clients = config
+ .radius_accounting
+ .expect("radius accounting should be present")
+ .clients;
+
+ assert_eq!(clients.len(), 2);
+ assert_eq!(clients[1].name, "pppoe-core-2");
+ assert_eq!(clients[1].source[0].network().to_string(), "192.0.2.11/32");
+ assert_eq!(
+ clients[1].source[1].network().to_string(),
+ "2001:db8:1::/48"
+ );
+ }
+
+ #[test]
+ fn multiple_clients_toml_reports_second_client_validation_error() {
+ let radius = enabled_radius_section_with_clients(&[
+ TEST_NAMED_CLIENT_LINES,
+ &[r#"name = "pppoe-core-2""#, TEST_SECRET_FILE_LINE],
+ ]);
+ let raw = example_with_radius(&radius);
+
+ let error = Config::load_from_string(&raw)
+ .expect_err("second TOML client missing source should fail validation");
+
+ assert!(error.contains("clients[1]"));
+ assert!(error.contains("pppoe-core-2"));
+ assert!(error.contains("source"));
+ }
+
+ #[test]
+ fn validation_rejects_non_positive_timing_values() {
+ let mut config = valid_enabled_config();
+ config.default_ttl_seconds = 0;
+ let error = config
+ .validate()
+ .expect_err("zero ttl should fail validation");
+ assert!(error.contains("default_ttl_seconds"));
+
+ let mut config = valid_enabled_config();
+ config.stale_grace_seconds = 0;
+ let error = config
+ .validate()
+ .expect_err("zero stale grace should fail validation");
+ assert!(error.contains("stale_grace_seconds"));
+ }
+
+ #[test]
+ fn top_level_load_rejects_validation_failures() {
+ load_error_cases([
+ (
+ enabled_radius_section(&[], Some(&[TEST_SOURCE_LINE])),
+ "secret_file",
+ ),
+ (
+ enabled_radius_section(&["default_ttl_seconds = 0"], Some(TEST_CLIENT_LINES)),
+ "default_ttl_seconds",
+ ),
+ (
+ enabled_radius_section(&["stale_grace_seconds = 0"], Some(TEST_CLIENT_LINES)),
+ "stale_grace_seconds",
+ ),
+ (
+ enabled_radius_section(
+ &[
+ "[radius_accounting.dynamic_circuit_application]",
+ "enabled = true",
+ "[radius_accounting.fallback_speed_profile]",
+ "download_min_mbps = 0.0",
+ "upload_min_mbps = 3.0",
+ "download_max_mbps = 25.0",
+ "upload_max_mbps = 10.0",
+ ],
+ Some(TEST_CLIENT_LINES),
+ ),
+ "fallback_speed_profile.download_min_mbps",
+ ),
+ (
+ enabled_radius_section(
+ &[
+ "[radius_accounting.dynamic_circuit_application]",
+ "enabled = true",
+ "fallback_parent_node = \" \"",
+ ],
+ Some(TEST_CLIENT_LINES),
+ ),
+ "fallback_parent_node",
+ ),
+ (
+ enabled_radius_section(
+ &[
+ "[radius_accounting.dynamic_circuit_application]",
+ "enabled = true",
+ "fallback_parent_node_id = \"core-pppoe\"",
+ ],
+ Some(TEST_CLIENT_LINES),
+ ),
+ "fallback_parent_node must be set",
+ ),
+ (enabled_radius_section(&[], None), "clients"),
+ ]);
+ }
+
+ #[test]
+ fn disabled_radius_accounting_rejects_invalid_configured_values() {
+ load_error_contains(
+ r#"
+
+[radius_accounting]
+enabled = false
+default_ttl_seconds = 0
+"#,
+ "default_ttl_seconds",
+ );
+
+ load_error_contains(
+ r#"
+
+[radius_accounting]
+enabled = false
+listen = "0.0.0.0:0"
+"#,
+ "listen port",
+ );
+ }
+
+ #[test]
+ fn disabled_radius_accounting_rejects_invalid_configured_clients() {
+ load_error_cases([
+ (
+ radius_section(false, &[], Some(&["source = []", TEST_SECRET_FILE_LINE])),
+ "source",
+ ),
+ (
+ radius_section(
+ false,
+ &[],
+ Some(&[TEST_SOURCE_LINE, r#"secret_file = " ""#]),
+ ),
+ "secret_file",
+ ),
+ (
+ radius_section(
+ false,
+ &[],
+ Some(&[r#"source = " ""#, TEST_SECRET_FILE_LINE]),
+ ),
+ "source is empty",
+ ),
+ ]);
+ }
+
+ #[test]
+ fn validation_accepts_bare_ip_sources() {
+ let radius = enabled_radius_section(
+ &[],
+ Some(&[
+ r#"source = ["192.0.2.10", "2001:db8::1"]"#,
+ TEST_SECRET_FILE_LINE,
+ ]),
+ );
+ let client = first_radius_client(&radius);
+
+ assert_eq!(client.source[0].network().to_string(), "192.0.2.10/32");
+ assert_eq!(client.source[1].network().to_string(), "2001:db8::1/128");
+ }
+}
diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs
index ed6289bc..cfc98f59 100644
--- a/src/rust/lqos_config/src/etc/v15/top_config.rs
+++ b/src/rust/lqos_config/src/etc/v15/top_config.rs
@@ -181,6 +181,12 @@ pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_circuits: Option,
+ /// RADIUS accounting configuration.
+ ///
+ /// Optional so older configs without this section still deserialize cleanly.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub radius_accounting: Option,
+
/// Network flows configuration
pub flows: Option,
@@ -372,6 +378,9 @@ impl Config {
if let Some(dynamic_circuits) = &self.dynamic_circuits {
dynamic_circuits.validate()?;
}
+ if let Some(radius_accounting) = &self.radius_accounting {
+ radius_accounting.validate()?;
+ }
Ok(())
}
@@ -447,6 +456,7 @@ impl Default for Config {
local_api: super::local_api::LocalApiConfig::default(),
ip_ranges: super::ip_ranges::IpRanges::default(),
dynamic_circuits: None,
+ radius_accounting: None,
integration_common: super::integration_common::IntegrationConfig::default(),
topology: super::topology::TopologyConfig::default(),
mikrotik_ipv6: super::mikrotik_ipv6::MikrotikIpv6Config::default(),
diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs
index 07c3fc7b..d062c751 100644
--- a/src/rust/lqos_config/src/lib.rs
+++ b/src/rust/lqos_config/src/lib.rs
@@ -42,12 +42,14 @@ pub use cpu_topology::{
};
pub use etc::{
BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode,
- LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RttThresholds,
- SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy, TopologyConfig,
- TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
+ LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RadiusAccountingClient,
+ RadiusAccountingConfig, RadiusClientSource, RadiusDynamicCircuitApplicationConfig,
+ RadiusFallbackSpeedProfile, RadiusSharedSecretSource, RateProfileValidationError,
+ RttThresholds, SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy,
+ TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode,
TreeguardLinksConfig, TreeguardQooConfig, Tunables, clear_cached_config, disable_xdp_bridge,
enable_long_term_stats, load_config, normalize_external_hostname,
- treeguard_cpu_mode_migration_notice, update_config,
+ treeguard_cpu_mode_migration_notice, update_config, validate_rate_profile_mbps,
};
pub use ethernet_port_limits::{
DEFAULT_ETHERNET_PORT_LIMIT_MULTIPLIER, EthernetPortLimitPolicy, EthernetPortObservation,
diff --git a/src/rust/lqos_config/src/shaped_devices/mod.rs b/src/rust/lqos_config/src/shaped_devices/mod.rs
index 876920ed..75f3365f 100644
--- a/src/rust/lqos_config/src/shaped_devices/mod.rs
+++ b/src/rust/lqos_config/src/shaped_devices/mod.rs
@@ -2,7 +2,7 @@ mod serializable;
mod shaped_device;
use csv::{QuoteStyle, ReaderBuilder, WriterBuilder};
-use lqos_utils::{XdpIpAddress, hash_to_i64};
+use lqos_utils::XdpIpAddress;
use serializable::SerializableShapedDevice;
pub use shaped_device::ShapedDevice;
use std::net::IpAddr;
@@ -33,9 +33,7 @@ impl Default for ConfigShapedDevices {
impl ConfigShapedDevices {
fn refresh_derived_fields(devices: &mut [ShapedDevice]) {
for device in devices {
- device.circuit_hash = hash_to_i64(&device.circuit_id);
- device.device_hash = hash_to_i64(&device.device_id);
- device.parent_hash = hash_to_i64(&device.parent_node);
+ device.refresh_hashes();
}
}
@@ -340,6 +338,7 @@ pub enum ShapedDevicesError {
#[cfg(test)]
mod test {
+ use lqos_utils::hash_to_i64;
use std::net::{Ipv4Addr, Ipv6Addr};
use super::*;
diff --git a/src/rust/lqos_config/src/shaped_devices/shaped_device.rs b/src/rust/lqos_config/src/shaped_devices/shaped_device.rs
index 83082d44..353a669a 100644
--- a/src/rust/lqos_config/src/shaped_devices/shaped_device.rs
+++ b/src/rust/lqos_config/src/shaped_devices/shaped_device.rs
@@ -40,8 +40,7 @@ pub struct ShapedDevice {
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_node_id: Option,
- /// The device's MAC address. This isn't actually used, it exists for
- /// convenient mapping/seraching.
+ /// The device's MAC address or RADIUS username, used by imports and RADIUS identity matching.
pub mac: String,
/// A list of all IPv4 addresses and CIDR subnets associated with the
@@ -91,6 +90,15 @@ pub struct ShapedDevice {
}
impl ShapedDevice {
+ /// Refreshes derived hash fields from the current circuit, device, and parent identifiers.
+ ///
+ /// Side effects: mutates only `circuit_hash`, `device_hash`, and `parent_hash` on this value.
+ pub fn refresh_hashes(&mut self) {
+ self.circuit_hash = hash_to_i64(&self.circuit_id);
+ self.device_hash = hash_to_i64(&self.device_id);
+ self.parent_hash = hash_to_i64(&self.parent_node);
+ }
+
fn normalize_header(value: &str) -> String {
value
.chars()
@@ -149,6 +157,9 @@ impl ShapedDevice {
"mac" => {
layout.insert("mac", idx);
}
+ "radiususername" | "radius_username" | "username" => {
+ layout.insert("legacy_radius_username", idx);
+ }
"ipv4" => {
layout.insert("ipv4", idx);
}
@@ -252,7 +263,14 @@ impl ShapedDevice {
"" => None,
value => Some(value.to_string()),
},
- mac: Self::field(record, &layout, "mac").to_string(),
+ mac: {
+ let mac = Self::field(record, &layout, "mac");
+ if mac.trim().is_empty() {
+ Self::field(record, &layout, "legacy_radius_username").to_string()
+ } else {
+ mac.to_string()
+ }
+ },
ipv4: ShapedDevice::parse_ipv4(Self::field(record, &layout, "ipv4")),
ipv6: ShapedDevice::parse_ipv6(Self::field(record, &layout, "ipv6")),
download_min_mbps: {
@@ -321,10 +339,11 @@ impl ShapedDevice {
},
comment: Self::field(record, &layout, "comment").to_string(),
sqm_override: None,
- circuit_hash: hash_to_i64(Self::field(record, &layout, "circuit_id")),
- device_hash: hash_to_i64(Self::field(record, &layout, "device_id")),
- parent_hash: hash_to_i64(Self::field(record, &layout, "parent_node")),
+ circuit_hash: 0,
+ device_hash: 0,
+ parent_hash: 0,
};
+ device.refresh_hashes();
// Optional 14th field: per-circuit SQM override token
if let Some(raw) = layout.get("sqm").and_then(|idx| record.get(*idx)) {
@@ -578,6 +597,128 @@ mod tests {
assert_eq!(device.ipv4.len(), 1);
}
+ #[test]
+ fn test_header_aware_username_in_mac_parsing() {
+ let headers = StringRecord::from(vec![
+ "Circuit ID",
+ "Circuit Name",
+ "Device ID",
+ "Device Name",
+ "Parent Node",
+ "Parent Node ID",
+ "Anchor Node ID",
+ "MAC",
+ "IPv4",
+ "IPv6",
+ "Download Min Mbps",
+ "Upload Min Mbps",
+ "Download Max Mbps",
+ "Upload Max Mbps",
+ "Comment",
+ ]);
+ let record = StringRecord::from(vec![
+ "radius-circuit",
+ "RADIUS circuit",
+ "radius-device",
+ "RADIUS device",
+ "Tower-A",
+ "",
+ "",
+ "pppoe-known",
+ "192.0.2.10",
+ "",
+ "10",
+ "10",
+ "100",
+ "100",
+ "RADIUS username fixture",
+ ]);
+
+ let device = ShapedDevice::from_csv(&record, Some(&headers))
+ .expect("a RADIUS username should be accepted in the MAC field");
+ assert_eq!(device.mac, "pppoe-known");
+ }
+
+ #[test]
+ fn test_legacy_radius_username_populates_an_empty_mac_field() {
+ let headers = StringRecord::from(vec![
+ "Circuit ID",
+ "Circuit Name",
+ "Device ID",
+ "Device Name",
+ "Parent Node",
+ "MAC",
+ "IPv4",
+ "IPv6",
+ "Download Min Mbps",
+ "Upload Min Mbps",
+ "Download Max Mbps",
+ "Upload Max Mbps",
+ "Comment",
+ "RADIUS Username",
+ ]);
+ let record = StringRecord::from(vec![
+ "radius-circuit",
+ "RADIUS circuit",
+ "radius-device",
+ "RADIUS device",
+ "Tower-A",
+ "",
+ "192.0.2.10",
+ "",
+ "10",
+ "10",
+ "100",
+ "100",
+ "legacy RADIUS username fixture",
+ "pppoe-known",
+ ]);
+
+ let device = ShapedDevice::from_csv(&record, Some(&headers))
+ .expect("a legacy RADIUS Username should populate an empty MAC field");
+ assert_eq!(device.mac, "pppoe-known");
+ }
+
+ #[test]
+ fn test_mac_field_takes_precedence_over_legacy_radius_username() {
+ let headers = StringRecord::from(vec![
+ "Circuit ID",
+ "Circuit Name",
+ "Device ID",
+ "Device Name",
+ "Parent Node",
+ "MAC",
+ "IPv4",
+ "IPv6",
+ "Download Min Mbps",
+ "Upload Min Mbps",
+ "Download Max Mbps",
+ "Upload Max Mbps",
+ "Comment",
+ "RADIUS Username",
+ ]);
+ let record = StringRecord::from(vec![
+ "radius-circuit",
+ "RADIUS circuit",
+ "radius-device",
+ "RADIUS device",
+ "Tower-A",
+ "aa:bb:cc:dd:ee:ff",
+ "192.0.2.10",
+ "",
+ "10",
+ "10",
+ "100",
+ "100",
+ "legacy RADIUS username fixture",
+ "pppoe-known",
+ ]);
+
+ let device = ShapedDevice::from_csv(&record, Some(&headers))
+ .expect("the MAC field should take precedence over a legacy RADIUS Username");
+ assert_eq!(device.mac, "aa:bb:cc:dd:ee:ff");
+ }
+
#[test]
fn test_header_aware_id_alias_parsing() {
let headers = StringRecord::from(vec![
diff --git a/src/rust/lqos_radius/Cargo.toml b/src/rust/lqos_radius/Cargo.toml
new file mode 100644
index 00000000..33d730ca
--- /dev/null
+++ b/src/rust/lqos_radius/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "lqos_radius"
+version = "0.1.0"
+edition = "2024"
+license = "GPL-2.0-only"
+
+[dependencies]
+clap = { workspace = true }
+hmac = { workspace = true }
+ip_network = { workspace = true }
+lqos_config = { path = "../lqos_config" }
+md-5 = { workspace = true }
+subtle = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
diff --git a/src/rust/lqos_radius/src/accounting_event.rs b/src/rust/lqos_radius/src/accounting_event.rs
new file mode 100644
index 00000000..2f5c6083
--- /dev/null
+++ b/src/rust/lqos_radius/src/accounting_event.rs
@@ -0,0 +1,566 @@
+//! Typed RADIUS Accounting-Request event extraction.
+
+use crate::attribute_type::{ACCT_STATUS_TYPE, VENDOR_SPECIFIC};
+use crate::packet::split_radius_tlv;
+use crate::{AccountingRequest, RadiusAttribute, VerifiedAccountingRequest};
+use std::net::{Ipv4Addr, Ipv6Addr};
+
+const USER_NAME: u8 = 1;
+const NAS_IP_ADDRESS: u8 = 4;
+const NAS_PORT: u8 = 5;
+const FRAMED_IP_ADDRESS: u8 = 8;
+const FRAMED_IP_NETMASK: u8 = 9;
+const FRAMED_ROUTE: u8 = 22;
+const CLASS: u8 = 25;
+const CALLED_STATION_ID: u8 = 30;
+const CALLING_STATION_ID: u8 = 31;
+const NAS_IDENTIFIER: u8 = 32;
+const ACCT_SESSION_ID: u8 = 44;
+const EVENT_TIMESTAMP: u8 = 55;
+const NAS_PORT_ID: u8 = 87;
+const NAS_IPV6_ADDRESS: u8 = 95;
+const FRAMED_IPV6_PREFIX: u8 = 97;
+const DELEGATED_IPV6_PREFIX: u8 = 123;
+const FRAMED_IPV6_ADDRESS: u8 = 168;
+
+const MIKROTIK_VENDOR_ID: u32 = 14988;
+const MIKROTIK_RATE_LIMIT: u8 = 8;
+
+/// Common RADIUS accounting attributes extracted into typed fields.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct AccountingEvent {
+ /// Acct-Status-Type when present.
+ pub status_type: Option,
+ /// Acct-Session-Id when present.
+ pub acct_session_id: Option,
+ /// NAS-IP-Address when present.
+ pub nas_ip_address: Option,
+ /// NAS-IPv6-Address when present.
+ pub nas_ipv6_address: Option,
+ /// NAS-Identifier when present.
+ pub nas_identifier: Option,
+ /// NAS-Port when present.
+ pub nas_port: Option,
+ /// NAS-Port-Id when present.
+ pub nas_port_id: Option,
+ /// User-Name when present.
+ pub user_name: Option,
+ /// Calling-Station-Id when present.
+ pub calling_station_id: Option,
+ /// Called-Station-Id when present.
+ pub called_station_id: Option,
+ /// Class attributes when present. RADIUS permits more than one Class value.
+ pub class: Vec>,
+ /// Event-Timestamp as Unix seconds when present.
+ pub event_timestamp: Option,
+ /// Framed-IP-Address when present.
+ pub framed_ip_address: Option,
+ /// Framed-IP-Netmask when present.
+ pub framed_ip_netmask: Option,
+ /// Framed-Route values when present.
+ pub framed_routes: Vec,
+ /// Framed-IPv6-Prefix values when present.
+ pub framed_ipv6_prefixes: Vec,
+ /// Delegated-IPv6-Prefix values when present.
+ pub delegated_ipv6_prefixes: Vec,
+ /// Framed-IPv6-Address when present.
+ pub framed_ipv6_address: Option,
+ /// Standard attributes this extractor does not expose as typed fields or could not decode.
+ pub unknown_standard_attributes: Vec,
+ /// Vendor-Specific attributes this extractor does not understand or cannot decode.
+ pub unknown_vendor_attributes: Vec,
+ /// Decoded MikroTik-Rate-Limit attributes when present.
+ pub mikrotik_rate_limits: Vec,
+}
+
+impl AccountingEvent {
+ /// Extracts a typed accounting event from a parsed Accounting-Request.
+ ///
+ /// Side effects: none. The packet is inspected in memory only.
+ #[must_use]
+ pub fn from_request(request: &AccountingRequest) -> Self {
+ Self::from_request_with_options(request, AccountingEventOptions::default())
+ }
+
+ /// Extracts a typed accounting event from a parsed Accounting-Request.
+ ///
+ /// Side effects: none. The packet is inspected in memory only.
+ #[must_use]
+ pub fn from_request_with_options(
+ request: &AccountingRequest,
+ options: AccountingEventOptions,
+ ) -> Self {
+ let mut event = Self::default();
+
+ for attribute in request.packet().attributes() {
+ event.apply_attribute(attribute, options);
+ }
+
+ event
+ }
+
+ /// Extracts a typed accounting event from a verified Accounting-Request.
+ ///
+ /// Side effects: none. The packet is inspected in memory only.
+ #[must_use]
+ pub fn from_verified(request: &VerifiedAccountingRequest) -> Self {
+ Self::from_verified_with_options(request, AccountingEventOptions::default())
+ }
+
+ /// Extracts a typed accounting event from a verified Accounting-Request.
+ ///
+ /// Side effects: none. The packet is inspected in memory only.
+ #[must_use]
+ pub fn from_verified_with_options(
+ request: &VerifiedAccountingRequest,
+ options: AccountingEventOptions,
+ ) -> Self {
+ Self::from_request_with_options(request.request(), options)
+ }
+
+ fn apply_attribute(&mut self, attribute: &RadiusAttribute, options: AccountingEventOptions) {
+ match attribute.kind() {
+ USER_NAME => set_once(&mut self.user_name, Some(text(attribute.value()))),
+ NAS_IP_ADDRESS => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.nas_ip_address,
+ attribute,
+ ipv4,
+ ),
+ NAS_PORT => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.nas_port,
+ attribute,
+ u32_value,
+ ),
+ FRAMED_IP_ADDRESS => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.framed_ip_address,
+ attribute,
+ ipv4,
+ ),
+ FRAMED_IP_NETMASK => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.framed_ip_netmask,
+ attribute,
+ ipv4,
+ ),
+ FRAMED_ROUTE => self.framed_routes.push(text(attribute.value())),
+ CLASS => self.class.push(attribute.value().to_vec()),
+ VENDOR_SPECIFIC => self.apply_vendor_specific(attribute.value(), options),
+ CALLED_STATION_ID => {
+ set_once(&mut self.called_station_id, Some(text(attribute.value())))
+ }
+ CALLING_STATION_ID => {
+ set_once(&mut self.calling_station_id, Some(text(attribute.value())));
+ }
+ NAS_IDENTIFIER => set_once(&mut self.nas_identifier, Some(text(attribute.value()))),
+ ACCT_STATUS_TYPE => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.status_type,
+ attribute,
+ |value| u32_value(value).map(AcctStatusType::from),
+ ),
+ ACCT_SESSION_ID => set_once(&mut self.acct_session_id, Some(text(attribute.value()))),
+ EVENT_TIMESTAMP => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.event_timestamp,
+ attribute,
+ u32_value,
+ ),
+ NAS_PORT_ID => set_once(&mut self.nas_port_id, Some(text(attribute.value()))),
+ NAS_IPV6_ADDRESS => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.nas_ipv6_address,
+ attribute,
+ ipv6,
+ ),
+ FRAMED_IPV6_PREFIX => push_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.framed_ipv6_prefixes,
+ attribute,
+ ipv6_prefix,
+ ),
+ DELEGATED_IPV6_PREFIX => push_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.delegated_ipv6_prefixes,
+ attribute,
+ ipv6_prefix,
+ ),
+ FRAMED_IPV6_ADDRESS => set_optional(
+ &mut self.unknown_standard_attributes,
+ &mut self.framed_ipv6_address,
+ attribute,
+ ipv6,
+ ),
+ _ => self.unknown_standard_attributes.push(attribute.clone()),
+ }
+ }
+
+ fn apply_vendor_specific(&mut self, value: &[u8], options: AccountingEventOptions) {
+ let Some((vendor_id, mut remaining)) = vendor_id_and_payload(value) else {
+ self.unknown_vendor_attributes
+ .push(UnknownVendorAttribute::malformed(value));
+ return;
+ };
+
+ if remaining.is_empty() {
+ self.unknown_vendor_attributes
+ .push(UnknownVendorAttribute::vendor_only(vendor_id));
+ return;
+ }
+
+ if vendor_id != MIKROTIK_VENDOR_ID {
+ self.unknown_vendor_attributes
+ .push(UnknownVendorAttribute::raw_vendor_payload(
+ vendor_id, remaining,
+ ));
+ return;
+ }
+
+ while !remaining.is_empty() {
+ let Ok((vendor_type, vendor_value, next)) = split_radius_tlv(remaining) else {
+ self.unknown_vendor_attributes
+ .push(UnknownVendorAttribute::vendor_data(vendor_id, remaining));
+ return;
+ };
+
+ if vendor_type == MIKROTIK_RATE_LIMIT
+ && let Some(rate_limit) = MikrotikRateLimit::parse(vendor_value, options)
+ {
+ self.mikrotik_rate_limits.push(rate_limit);
+ remaining = next;
+ continue;
+ }
+
+ self.unknown_vendor_attributes
+ .push(UnknownVendorAttribute::subattribute(
+ vendor_id,
+ vendor_type,
+ vendor_value,
+ ));
+
+ remaining = next;
+ }
+ }
+}
+
+impl From<&AccountingRequest> for AccountingEvent {
+ fn from(request: &AccountingRequest) -> Self {
+ Self::from_request(request)
+ }
+}
+
+impl From<&VerifiedAccountingRequest> for AccountingEvent {
+ fn from(request: &VerifiedAccountingRequest) -> Self {
+ Self::from_verified(request)
+ }
+}
+
+/// Options that control typed accounting event extraction.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct AccountingEventOptions {
+ /// Mapping between MikroTik NAS RX/TX rates and LibreQoS upload/download rates.
+ pub mikrotik_rate_limit_direction: MikrotikRateLimitDirection,
+}
+
+/// Acct-Status-Type values understood by LibreQoS.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AcctStatusType {
+ /// Start, value 1.
+ Start,
+ /// Stop, value 2.
+ Stop,
+ /// Interim-Update, value 3.
+ InterimUpdate,
+ /// Accounting-On, value 7.
+ AccountingOn,
+ /// Accounting-Off, value 8.
+ AccountingOff,
+ /// A status value not enumerated by this crate.
+ Unknown(u32),
+}
+
+impl AcctStatusType {
+ /// Returns the RADIUS integer value for this Acct-Status-Type.
+ #[must_use]
+ pub const fn as_u32(self) -> u32 {
+ match self {
+ Self::Start => 1,
+ Self::Stop => 2,
+ Self::InterimUpdate => 3,
+ Self::AccountingOn => 7,
+ Self::AccountingOff => 8,
+ Self::Unknown(value) => value,
+ }
+ }
+}
+
+impl From for AcctStatusType {
+ fn from(value: u32) -> Self {
+ match value {
+ 1 => Self::Start,
+ 2 => Self::Stop,
+ 3 => Self::InterimUpdate,
+ 7 => Self::AccountingOn,
+ 8 => Self::AccountingOff,
+ other => Self::Unknown(other),
+ }
+ }
+}
+
+impl From for u32 {
+ fn from(status_type: AcctStatusType) -> Self {
+ status_type.as_u32()
+ }
+}
+
+/// IPv6 prefix decoded from RADIUS ipv6prefix attributes.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Ipv6Prefix {
+ /// The IPv6 network address bytes supplied by the NAS.
+ pub address: Ipv6Addr,
+ /// Prefix length in bits.
+ pub prefix_len: u8,
+}
+
+/// A Vendor-Specific attribute this extractor did not decode.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UnknownVendorAttribute {
+ /// Vendor ID when the Vendor-Specific attribute had enough bytes to include it.
+ pub vendor_id: Option,
+ /// Vendor type when the vendor payload had a complete subattribute header.
+ pub vendor_type: Option,
+ /// Vendor attribute value, or the remaining malformed bytes when no value could be split out.
+ pub value: Vec,
+}
+
+impl UnknownVendorAttribute {
+ fn malformed(value: &[u8]) -> Self {
+ Self {
+ vendor_id: None,
+ vendor_type: None,
+ value: value.to_vec(),
+ }
+ }
+
+ fn vendor_only(vendor_id: u32) -> Self {
+ Self {
+ vendor_id: Some(vendor_id),
+ vendor_type: None,
+ value: Vec::new(),
+ }
+ }
+
+ fn vendor_data(vendor_id: u32, value: &[u8]) -> Self {
+ Self {
+ vendor_id: Some(vendor_id),
+ vendor_type: value.first().copied(),
+ value: value.to_vec(),
+ }
+ }
+
+ fn raw_vendor_payload(vendor_id: u32, value: &[u8]) -> Self {
+ Self {
+ vendor_id: Some(vendor_id),
+ vendor_type: None,
+ value: value.to_vec(),
+ }
+ }
+
+ fn subattribute(vendor_id: u32, vendor_type: u8, value: &[u8]) -> Self {
+ Self {
+ vendor_id: Some(vendor_id),
+ vendor_type: Some(vendor_type),
+ value: value.to_vec(),
+ }
+ }
+}
+
+/// Mapping between MikroTik NAS directions and LibreQoS directions.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum MikrotikRateLimitDirection {
+ /// Map NAS RX to LibreQoS upload and NAS TX to LibreQoS download.
+ #[default]
+ Normal,
+ /// Map NAS RX to LibreQoS download and NAS TX to LibreQoS upload.
+ Swapped,
+}
+
+impl MikrotikRateLimitDirection {
+ const fn map_rates(self, nas_rx_bps: u64, nas_tx_bps: u64) -> (u64, u64) {
+ match self {
+ Self::Normal => (nas_rx_bps, nas_tx_bps),
+ Self::Swapped => (nas_tx_bps, nas_rx_bps),
+ }
+ }
+}
+
+/// MikroTik-Rate-Limit values decoded from a Vendor-Specific attribute.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MikrotikRateLimit {
+ /// Original MikroTik-Rate-Limit string.
+ pub original: String,
+ /// NAS RX rate in bits per second.
+ pub nas_rx_bps: u64,
+ /// NAS TX rate in bits per second.
+ pub nas_tx_bps: u64,
+ /// LibreQoS upload rate in bits per second after direction mapping.
+ pub upload_bps: u64,
+ /// LibreQoS download rate in bits per second after direction mapping.
+ pub download_bps: u64,
+}
+
+impl MikrotikRateLimit {
+ fn parse(value: &[u8], options: AccountingEventOptions) -> Option {
+ let original = text(value);
+ let rate_pair = original.split_whitespace().next()?;
+ let (nas_rx_bps, nas_tx_bps) = parse_mikrotik_rate_pair(rate_pair)?;
+ let (upload_bps, download_bps) = options
+ .mikrotik_rate_limit_direction
+ .map_rates(nas_rx_bps, nas_tx_bps);
+
+ Some(Self {
+ original,
+ nas_rx_bps,
+ nas_tx_bps,
+ upload_bps,
+ download_bps,
+ })
+ }
+}
+
+fn set_once(slot: &mut Option, value: Option) {
+ if slot.is_none() {
+ *slot = value;
+ }
+}
+
+fn set_optional(
+ unknown_standard_attributes: &mut Vec,
+ slot: &mut Option,
+ attribute: &RadiusAttribute,
+ decode: impl FnOnce(&[u8]) -> Option,
+) {
+ let decoded = decode(attribute.value());
+ if decoded.is_none() {
+ unknown_standard_attributes.push(attribute.clone());
+ }
+ set_once(slot, decoded);
+}
+
+fn push_optional(
+ unknown_standard_attributes: &mut Vec,
+ items: &mut Vec,
+ attribute: &RadiusAttribute,
+ decode: impl FnOnce(&[u8]) -> Option,
+) {
+ let Some(decoded) = decode(attribute.value()) else {
+ unknown_standard_attributes.push(attribute.clone());
+ return;
+ };
+ items.push(decoded);
+}
+
+fn text(value: &[u8]) -> String {
+ String::from_utf8_lossy(value).into_owned()
+}
+
+fn ipv4(value: &[u8]) -> Option {
+ let bytes: [u8; 4] = value.try_into().ok()?;
+ Some(Ipv4Addr::from(bytes))
+}
+
+fn ipv6(value: &[u8]) -> Option {
+ let bytes: [u8; 16] = value.try_into().ok()?;
+ Some(Ipv6Addr::from(bytes))
+}
+
+fn ipv6_prefix(value: &[u8]) -> Option {
+ if !(2..=18).contains(&value.len()) {
+ return None;
+ }
+ if value[0] != 0 {
+ return None;
+ }
+ let prefix_len = value[1];
+ if prefix_len > 128 {
+ return None;
+ }
+ let expected_prefix_bytes = usize::from(prefix_len).div_ceil(8);
+ let prefix_bytes = &value[2..];
+ if prefix_bytes.len() < expected_prefix_bytes {
+ return None;
+ }
+ if prefix_bytes[expected_prefix_bytes..]
+ .iter()
+ .any(|byte| *byte != 0)
+ {
+ return None;
+ }
+ let mut bytes = [0_u8; 16];
+ bytes[..expected_prefix_bytes].copy_from_slice(&prefix_bytes[..expected_prefix_bytes]);
+ if let Some(mask) = final_prefix_byte_mask(prefix_len) {
+ if bytes[expected_prefix_bytes - 1] & !mask != 0 {
+ return None;
+ }
+ bytes[expected_prefix_bytes - 1] &= mask;
+ }
+ Some(Ipv6Prefix {
+ address: Ipv6Addr::from(bytes),
+ prefix_len,
+ })
+}
+
+fn final_prefix_byte_mask(prefix_len: u8) -> Option {
+ let remainder = prefix_len % 8;
+ if remainder == 0 {
+ None
+ } else {
+ Some(u8::MAX << (8 - remainder))
+ }
+}
+
+fn u32_value(value: &[u8]) -> Option {
+ let bytes: [u8; 4] = value.try_into().ok()?;
+ Some(u32::from_be_bytes(bytes))
+}
+
+fn vendor_id_and_payload(value: &[u8]) -> Option<(u32, &[u8])> {
+ let vendor_id_bytes: [u8; 4] = value.get(..4)?.try_into().ok()?;
+ Some((u32::from_be_bytes(vendor_id_bytes), &value[4..]))
+}
+
+fn parse_mikrotik_rate_pair(value: &str) -> Option<(u64, u64)> {
+ let (nas_rx, nas_tx) = match value.split_once('/') {
+ Some(pair) => pair,
+ None => (value, value),
+ };
+ Some((parse_mikrotik_rate(nas_rx)?, parse_mikrotik_rate(nas_tx)?))
+}
+
+fn parse_mikrotik_rate(value: &str) -> Option {
+ let trimmed = value.trim();
+ let digit_count = trimmed
+ .as_bytes()
+ .iter()
+ .take_while(|byte| byte.is_ascii_digit())
+ .count();
+ if digit_count == 0 {
+ return None;
+ }
+
+ let number = trimmed[..digit_count].parse::().ok()?;
+ let multiplier = match trimmed[digit_count..].to_ascii_lowercase().as_str() {
+ "" | "bps" => 1,
+ "k" | "kb" | "kbit" | "kbps" => 1_000,
+ "m" | "mb" | "mbit" | "mbps" => 1_000_000,
+ "g" | "gb" | "gbit" | "gbps" => 1_000_000_000,
+ _ => return None,
+ };
+
+ number.checked_mul(multiplier)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/rust/lqos_radius/src/accounting_event/tests.rs b/src/rust/lqos_radius/src/accounting_event/tests.rs
new file mode 100644
index 00000000..5e8a6f64
--- /dev/null
+++ b/src/rust/lqos_radius/src/accounting_event/tests.rs
@@ -0,0 +1,625 @@
+//! Tests for typed RADIUS Accounting-Request event extraction.
+
+use super::*;
+use crate::packet::MESSAGE_AUTHENTICATOR_TYPE;
+use crate::test_support::{
+ SHARED_SECRET, accounting_request_packet, radius_attribute, radius_attributes,
+ radius_raw_vendor_attribute, radius_text_attribute, radius_u32_attribute,
+ radius_vendor_attribute, radius_vendor_attributes, radius_vendor_subattribute,
+ signed_accounting_request_packet,
+};
+use crate::{
+ MessageAuthenticatorPolicy, RadiusCode, handle_accounting_request, verify_accounting_request,
+};
+
+const RAW_ACCOUNTING_START_FIXTURE: [u8; 26] = [
+ 4,
+ 51,
+ 0,
+ 26,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ ACCT_STATUS_TYPE,
+ 6,
+ 0,
+ 0,
+ 0,
+ 1,
+];
+const RAW_ACCOUNTING_INTERIM_FIXTURE: [u8; 26] = [
+ 4,
+ 52,
+ 0,
+ 26,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ ACCT_STATUS_TYPE,
+ 6,
+ 0,
+ 0,
+ 0,
+ 3,
+];
+const RAW_ACCOUNTING_STOP_FIXTURE: [u8; 26] = [
+ 4,
+ 53,
+ 0,
+ 26,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ ACCT_STATUS_TYPE,
+ 6,
+ 0,
+ 0,
+ 0,
+ 2,
+];
+const RAW_UNKNOWN_VENDOR_IPV6_FIXTURE: [u8; 66] = [
+ 4,
+ 54,
+ 0,
+ 66,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 241,
+ 5,
+ b's',
+ b't',
+ b'd',
+ 26,
+ 12,
+ 0,
+ 0,
+ 48,
+ 57,
+ b'v',
+ b'e',
+ b'n',
+ b'd',
+ b'o',
+ b'r',
+ NAS_IPV6_ADDRESS,
+ 18,
+ 0x20,
+ 0x01,
+ 0x0d,
+ 0xb8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ FRAMED_IPV6_PREFIX,
+ 11,
+ 0,
+ 56,
+ 0x20,
+ 0x01,
+ 0x0d,
+ 0xb8,
+ 0,
+ 0x10,
+ 0,
+];
+
+#[test]
+fn extracts_acct_status_type_variants_and_unknown_values() {
+ let cases = [
+ (1, AcctStatusType::Start),
+ (2, AcctStatusType::Stop),
+ (3, AcctStatusType::InterimUpdate),
+ (7, AcctStatusType::AccountingOn),
+ (8, AcctStatusType::AccountingOff),
+ (99, AcctStatusType::Unknown(99)),
+ ];
+
+ for (raw, expected) in cases {
+ let request = request_with_attributes(&[radius_u32_attribute(ACCT_STATUS_TYPE, raw)]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(event.status_type, Some(expected));
+ assert_eq!(event.status_type.unwrap().as_u32(), raw);
+ }
+}
+
+#[test]
+fn raw_packet_fixtures_cover_accounting_statuses_and_attribute_preservation() {
+ for (raw_packet, identifier, expected_status) in [
+ (
+ RAW_ACCOUNTING_START_FIXTURE.as_slice(),
+ 51,
+ AcctStatusType::Start,
+ ),
+ (
+ RAW_ACCOUNTING_INTERIM_FIXTURE.as_slice(),
+ 52,
+ AcctStatusType::InterimUpdate,
+ ),
+ (
+ RAW_ACCOUNTING_STOP_FIXTURE.as_slice(),
+ 53,
+ AcctStatusType::Stop,
+ ),
+ ] {
+ let request = handle_accounting_request(raw_packet).unwrap();
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(request.packet().code(), RadiusCode::AccountingRequest);
+ assert_eq!(request.packet().identifier(), identifier);
+ assert_eq!(event.status_type, Some(expected_status));
+ }
+
+ let request = handle_accounting_request(&RAW_UNKNOWN_VENDOR_IPV6_FIXTURE).unwrap();
+ let event = AccountingEvent::from_request(&request);
+
+ assert_unknown_standard_attributes(&event, &[(241, b"std")]);
+ assert_eq!(
+ event.unknown_vendor_attributes,
+ vec![UnknownVendorAttribute {
+ vendor_id: Some(12_345),
+ vendor_type: None,
+ value: b"vendor".to_vec(),
+ }]
+ );
+ assert_eq!(event.nas_ipv6_address, Some("2001:db8::1".parse().unwrap()));
+ assert_eq!(
+ event.framed_ipv6_prefixes,
+ vec![Ipv6Prefix {
+ address: "2001:db8:10::".parse().unwrap(),
+ prefix_len: 56,
+ }]
+ );
+}
+
+#[test]
+fn extracts_session_nas_identity_and_subscriber_address_fields() {
+ let request = request_with_attributes(&[
+ radius_text_attribute(ACCT_SESSION_ID, "session-123"),
+ radius_attribute(NAS_IP_ADDRESS, &[192, 0, 2, 10]),
+ radius_attribute(
+ NAS_IPV6_ADDRESS,
+ &ipv6_bytes([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]),
+ ),
+ radius_text_attribute(NAS_IDENTIFIER, "nas-1"),
+ radius_u32_attribute(NAS_PORT, 17),
+ radius_text_attribute(NAS_PORT_ID, "ether5"),
+ radius_text_attribute(USER_NAME, "subscriber@example.net"),
+ radius_text_attribute(CALLING_STATION_ID, "00:11:22:33:44:55"),
+ radius_text_attribute(CALLED_STATION_ID, "service"),
+ radius_attribute(CLASS, b"class-data"),
+ radius_attribute(CLASS, b"class-data-2"),
+ radius_u32_attribute(EVENT_TIMESTAMP, 1_700_000_000),
+ radius_attribute(FRAMED_IP_ADDRESS, &[198, 51, 100, 25]),
+ radius_attribute(FRAMED_IP_NETMASK, &[255, 255, 255, 0]),
+ radius_text_attribute(FRAMED_ROUTE, "198.51.100.0/24 192.0.2.1 1"),
+ radius_text_attribute(FRAMED_ROUTE, "203.0.113.0/24 192.0.2.1 1"),
+ ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 56, 0x2001, 0xdb8, 1),
+ ipv6_prefix_attribute(DELEGATED_IPV6_PREFIX, 48, 0x2001, 0xdb8, 2),
+ radius_attribute(
+ FRAMED_IPV6_ADDRESS,
+ &ipv6_bytes([0x2001, 0xdb8, 3, 0, 0, 0, 0, 5]),
+ ),
+ ]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(event.acct_session_id.as_deref(), Some("session-123"));
+ assert_eq!(event.nas_ip_address, Some(Ipv4Addr::new(192, 0, 2, 10)));
+ assert_eq!(event.nas_ipv6_address, Some("2001:db8::1".parse().unwrap()));
+ assert_eq!(event.nas_identifier.as_deref(), Some("nas-1"));
+ assert_eq!(event.nas_port, Some(17));
+ assert_eq!(event.nas_port_id.as_deref(), Some("ether5"));
+ assert_eq!(event.user_name.as_deref(), Some("subscriber@example.net"));
+ assert_eq!(
+ event.calling_station_id.as_deref(),
+ Some("00:11:22:33:44:55")
+ );
+ assert_eq!(event.called_station_id.as_deref(), Some("service"));
+ assert_eq!(
+ event.class,
+ vec![b"class-data".to_vec(), b"class-data-2".to_vec()]
+ );
+ assert_eq!(event.event_timestamp, Some(1_700_000_000));
+ assert_eq!(
+ event.framed_ip_address,
+ Some(Ipv4Addr::new(198, 51, 100, 25))
+ );
+ assert_eq!(
+ event.framed_ip_netmask,
+ Some(Ipv4Addr::new(255, 255, 255, 0))
+ );
+ assert_eq!(
+ event.framed_routes,
+ vec![
+ "198.51.100.0/24 192.0.2.1 1".to_string(),
+ "203.0.113.0/24 192.0.2.1 1".to_string()
+ ]
+ );
+ assert_eq!(
+ event.framed_ipv6_prefixes,
+ vec![Ipv6Prefix {
+ address: "2001:db8:1::".parse().unwrap(),
+ prefix_len: 56,
+ }]
+ );
+ assert_eq!(
+ event.delegated_ipv6_prefixes,
+ vec![Ipv6Prefix {
+ address: "2001:db8:2::".parse().unwrap(),
+ prefix_len: 48,
+ }]
+ );
+ assert_eq!(
+ event.framed_ipv6_address,
+ Some("2001:db8:3::5".parse().unwrap())
+ );
+ assert_eq!(event.unknown_standard_attributes, Vec::new());
+ assert_eq!(event.unknown_vendor_attributes, Vec::new());
+}
+
+#[test]
+fn extracts_compact_ipv6_prefix_fields() {
+ let request = request_with_attributes(&[
+ compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 0, &[]),
+ compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 56, &[0x20, 0x01, 0x0d, 0xb8, 0, 1, 0]),
+ compact_ipv6_prefix_attribute(
+ DELEGATED_IPV6_PREFIX,
+ 64,
+ &[0x20, 0x01, 0x0d, 0xb8, 0, 2, 0, 0],
+ ),
+ compact_ipv6_prefix_attribute(
+ DELEGATED_IPV6_PREFIX,
+ 128,
+ &ipv6_bytes([0x2001, 0xdb8, 3, 0, 0, 0, 0, 1]),
+ ),
+ compact_ipv6_prefix_attribute(
+ FRAMED_IPV6_PREFIX,
+ 60,
+ &[0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xc0, 0],
+ ),
+ ]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(
+ event.framed_ipv6_prefixes,
+ vec![
+ Ipv6Prefix {
+ address: "::".parse().unwrap(),
+ prefix_len: 0,
+ },
+ Ipv6Prefix {
+ address: "2001:db8:1::".parse().unwrap(),
+ prefix_len: 56,
+ },
+ Ipv6Prefix {
+ address: "2001:db8:4:abc0::".parse().unwrap(),
+ prefix_len: 60,
+ },
+ ]
+ );
+ assert_eq!(
+ event.delegated_ipv6_prefixes,
+ vec![
+ Ipv6Prefix {
+ address: "2001:db8:2::".parse().unwrap(),
+ prefix_len: 64,
+ },
+ Ipv6Prefix {
+ address: "2001:db8:3::1".parse().unwrap(),
+ prefix_len: 128,
+ },
+ ]
+ );
+ assert_eq!(event.unknown_standard_attributes, Vec::new());
+}
+
+#[test]
+fn preserves_known_standard_attributes_that_fail_typed_decoding() {
+ let request = request_with_attributes(&[
+ radius_attribute(NAS_IP_ADDRESS, &[192, 0, 2]),
+ radius_attribute(NAS_IPV6_ADDRESS, &[0; 15]),
+ radius_attribute(ACCT_STATUS_TYPE, &[1, 2, 3]),
+ radius_attribute(FRAMED_IPV6_ADDRESS, &[0; 15]),
+ compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 64, &[0x20, 0x01]),
+ compact_ipv6_prefix_attribute(
+ DELEGATED_IPV6_PREFIX,
+ 60,
+ &[0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xcd],
+ ),
+ compact_ipv6_prefix_attribute(
+ FRAMED_IPV6_PREFIX,
+ 56,
+ &[0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 1],
+ ),
+ compact_ipv6_prefix_attribute_with_reserved(
+ FRAMED_IPV6_PREFIX,
+ 1,
+ 64,
+ &[0x20, 0x01, 0x0d, 0xb8, 0, 5, 0, 0],
+ ),
+ ]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(event.nas_ip_address, None);
+ assert_eq!(event.nas_ipv6_address, None);
+ assert_eq!(event.status_type, None);
+ assert_eq!(event.framed_ipv6_address, None);
+ assert_eq!(event.framed_ipv6_prefixes, Vec::new());
+ assert_eq!(event.delegated_ipv6_prefixes, Vec::new());
+ assert_unknown_standard_attributes(
+ &event,
+ &[
+ (NAS_IP_ADDRESS, &[192, 0, 2][..]),
+ (NAS_IPV6_ADDRESS, &[0; 15][..]),
+ (ACCT_STATUS_TYPE, &[1, 2, 3][..]),
+ (FRAMED_IPV6_ADDRESS, &[0; 15][..]),
+ (FRAMED_IPV6_PREFIX, &[0, 64, 0x20, 0x01][..]),
+ (
+ DELEGATED_IPV6_PREFIX,
+ &[0, 60, 0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xcd][..],
+ ),
+ (
+ FRAMED_IPV6_PREFIX,
+ &[0, 56, 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 1][..],
+ ),
+ (
+ FRAMED_IPV6_PREFIX,
+ &[1, 64, 0x20, 0x01, 0x0d, 0xb8, 0, 5, 0, 0][..],
+ ),
+ ],
+ );
+}
+
+#[test]
+fn extracts_from_verified_accounting_request() {
+ let attributes = radius_attributes(&[radius_text_attribute(ACCT_SESSION_ID, "verified")]);
+ let packet = signed_accounting_request_packet(21, &attributes, SHARED_SECRET);
+ let verified =
+ verify_accounting_request(&packet, SHARED_SECRET, MessageAuthenticatorPolicy::Optional)
+ .unwrap();
+ let event = AccountingEvent::from_verified(&verified);
+
+ assert_eq!(event.acct_session_id.as_deref(), Some("verified"));
+}
+
+#[test]
+fn preserves_unknown_standard_and_vendor_attributes() {
+ let request = request_with_attributes(&[
+ radius_attribute(241, b"standard"),
+ radius_attribute(MESSAGE_AUTHENTICATOR_TYPE, &[0_u8; 16]),
+ radius_raw_vendor_attribute(12_345, b"opaque-vendor"),
+ radius_raw_vendor_attribute(54_321, b""),
+ radius_attribute(VENDOR_SPECIFIC, &[0, 0, 0]),
+ ]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_unknown_standard_attributes(
+ &event,
+ &[
+ (241, b"standard"),
+ (MESSAGE_AUTHENTICATOR_TYPE, &[0_u8; 16][..]),
+ ],
+ );
+ assert_eq!(
+ event.unknown_vendor_attributes,
+ vec![
+ UnknownVendorAttribute {
+ vendor_id: Some(12_345),
+ vendor_type: None,
+ value: b"opaque-vendor".to_vec(),
+ },
+ UnknownVendorAttribute {
+ vendor_id: Some(54_321),
+ vendor_type: None,
+ value: Vec::new(),
+ },
+ UnknownVendorAttribute {
+ vendor_id: None,
+ vendor_type: None,
+ value: vec![0, 0, 0],
+ }
+ ]
+ );
+}
+
+#[test]
+fn decodes_mikrotik_rate_limit_with_normal_direction_mapping() {
+ let request = request_with_attributes(&[radius_vendor_attribute(
+ MIKROTIK_VENDOR_ID,
+ MIKROTIK_RATE_LIMIT,
+ b"10M/25M 12M/30M 8M/20M 10/10 8 5M/15M",
+ )]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(event.unknown_vendor_attributes, Vec::new());
+ assert_eq!(
+ event.mikrotik_rate_limits,
+ vec![MikrotikRateLimit {
+ original: "10M/25M 12M/30M 8M/20M 10/10 8 5M/15M".to_string(),
+ nas_rx_bps: 10_000_000,
+ nas_tx_bps: 25_000_000,
+ upload_bps: 10_000_000,
+ download_bps: 25_000_000,
+ }]
+ );
+}
+
+#[test]
+fn decodes_mikrotik_rate_limit_with_swapped_direction_mapping() {
+ let request = request_with_attributes(&[radius_vendor_attribute(
+ MIKROTIK_VENDOR_ID,
+ MIKROTIK_RATE_LIMIT,
+ b"512k/2M",
+ )]);
+ let options = AccountingEventOptions {
+ mikrotik_rate_limit_direction: MikrotikRateLimitDirection::Swapped,
+ };
+ let event = AccountingEvent::from_request_with_options(&request, options);
+
+ assert_eq!(event.mikrotik_rate_limits.len(), 1);
+ assert_eq!(event.mikrotik_rate_limits[0].nas_rx_bps, 512_000);
+ assert_eq!(event.mikrotik_rate_limits[0].nas_tx_bps, 2_000_000);
+ assert_eq!(event.mikrotik_rate_limits[0].upload_bps, 2_000_000);
+ assert_eq!(event.mikrotik_rate_limits[0].download_bps, 512_000);
+}
+
+#[test]
+fn preserves_unknown_and_invalid_mikrotik_subattributes() {
+ let request = request_with_attributes(&[radius_vendor_attributes(
+ MIKROTIK_VENDOR_ID,
+ &[
+ radius_vendor_subattribute(MIKROTIK_RATE_LIMIT, b"10M/25M"),
+ radius_vendor_subattribute(99, b"opaque"),
+ radius_vendor_subattribute(MIKROTIK_RATE_LIMIT, b"not-a-rate"),
+ ],
+ )]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(event.mikrotik_rate_limits.len(), 1);
+ assert_eq!(event.mikrotik_rate_limits[0].upload_bps, 10_000_000);
+ assert_eq!(event.mikrotik_rate_limits[0].download_bps, 25_000_000);
+ assert_eq!(
+ event.unknown_vendor_attributes,
+ vec![
+ UnknownVendorAttribute {
+ vendor_id: Some(MIKROTIK_VENDOR_ID),
+ vendor_type: Some(99),
+ value: b"opaque".to_vec(),
+ },
+ UnknownVendorAttribute {
+ vendor_id: Some(MIKROTIK_VENDOR_ID),
+ vendor_type: Some(MIKROTIK_RATE_LIMIT),
+ value: b"not-a-rate".to_vec(),
+ },
+ ]
+ );
+}
+
+#[test]
+fn preserves_malformed_mikrotik_subattributes() {
+ let request = request_with_attributes(&[radius_raw_vendor_attribute(
+ MIKROTIK_VENDOR_ID,
+ &[MIKROTIK_RATE_LIMIT, 1],
+ )]);
+ let event = AccountingEvent::from_request(&request);
+
+ assert_eq!(event.mikrotik_rate_limits, Vec::new());
+ assert_eq!(
+ event.unknown_vendor_attributes,
+ vec![UnknownVendorAttribute {
+ vendor_id: Some(MIKROTIK_VENDOR_ID),
+ vendor_type: Some(MIKROTIK_RATE_LIMIT),
+ value: vec![MIKROTIK_RATE_LIMIT, 1],
+ }]
+ );
+}
+
+fn assert_unknown_standard_attributes(event: &AccountingEvent, expected: &[(u8, &[u8])]) {
+ let actual: Vec<_> = event
+ .unknown_standard_attributes
+ .iter()
+ .map(|attribute| (attribute.kind(), attribute.value()))
+ .collect();
+
+ assert_eq!(actual.as_slice(), expected);
+}
+
+fn request_with_attributes(attributes: &[Vec]) -> AccountingRequest {
+ let encoded = radius_attributes(attributes);
+ handle_accounting_request(&accounting_request_packet(7, &encoded)).unwrap()
+}
+
+fn ipv6_prefix_attribute(kind: u8, prefix_len: u8, a: u16, b: u16, c: u16) -> Vec {
+ let mut value = Vec::with_capacity(18);
+ value.push(0);
+ value.push(prefix_len);
+ value.extend_from_slice(&ipv6_bytes([a, b, c, 0, 0, 0, 0, 0]));
+ radius_attribute(kind, &value)
+}
+
+fn compact_ipv6_prefix_attribute(kind: u8, prefix_len: u8, prefix_bytes: &[u8]) -> Vec {
+ compact_ipv6_prefix_attribute_with_reserved(kind, 0, prefix_len, prefix_bytes)
+}
+
+fn compact_ipv6_prefix_attribute_with_reserved(
+ kind: u8,
+ reserved: u8,
+ prefix_len: u8,
+ prefix_bytes: &[u8],
+) -> Vec {
+ let mut value = Vec::with_capacity(2 + prefix_bytes.len());
+ value.push(reserved);
+ value.push(prefix_len);
+ value.extend_from_slice(prefix_bytes);
+ radius_attribute(kind, &value)
+}
+
+fn ipv6_bytes(segments: [u16; 8]) -> [u8; 16] {
+ let mut bytes = [0_u8; 16];
+ for (index, segment) in segments.into_iter().enumerate() {
+ bytes[index * 2..index * 2 + 2].copy_from_slice(&segment.to_be_bytes());
+ }
+ bytes
+}
diff --git a/src/rust/lqos_radius/src/attribute_type.rs b/src/rust/lqos_radius/src/attribute_type.rs
new file mode 100644
index 00000000..4ae55f85
--- /dev/null
+++ b/src/rust/lqos_radius/src/attribute_type.rs
@@ -0,0 +1,4 @@
+//! Crate-private RADIUS attribute type constants shared across modules.
+
+pub(crate) const VENDOR_SPECIFIC: u8 = 26;
+pub(crate) const ACCT_STATUS_TYPE: u8 = 40;
diff --git a/src/rust/lqos_radius/src/dynamic_circuit.rs b/src/rust/lqos_radius/src/dynamic_circuit.rs
new file mode 100644
index 00000000..0550557e
--- /dev/null
+++ b/src/rust/lqos_radius/src/dynamic_circuit.rs
@@ -0,0 +1,77 @@
+//! Dynamic-circuit command intents emitted by RADIUS session handling.
+
+use crate::{AccountingEvent, AccountingSessionKey, NasResetStatus};
+use lqos_config::ShapedDevice;
+
+/// Sink boundary for dynamic-circuit intents produced by RADIUS session state.
+pub trait DynamicCircuitCommandSink {
+ /// Receives one dynamic-circuit intent.
+ ///
+ /// Side effects: depend on the sink implementation. The `lqos_radius` crate
+ /// only calls this boundary; it does not write dynamic circuit files or talk
+ /// to `lqosd` directly.
+ fn emit(&mut self, intent: DynamicCircuitIntent);
+}
+
+/// Dynamic-circuit intent that an lqosd-facing adapter can map onto daemon commands.
+#[derive(Clone, Debug, PartialEq)]
+pub enum DynamicCircuitIntent {
+ /// Create a runtime dynamic circuit. Adapters can map this to `CreateDynamicCircuit`.
+ CreateDynamicCircuit(DynamicCircuitUpsert),
+ /// Update a runtime dynamic circuit. Adapters can map this to `CreateDynamicCircuit`.
+ UpdateDynamicCircuit(DynamicCircuitUpsert),
+ /// Remove a runtime dynamic circuit. Adapters may defer this until runtime removals are enabled.
+ RemoveDynamicCircuit(DynamicCircuitRemoval),
+}
+
+impl DynamicCircuitIntent {
+ /// Returns the stable dynamic-circuit identifier carried by this intent.
+ #[must_use]
+ pub fn circuit_id(&self) -> &str {
+ match self {
+ Self::CreateDynamicCircuit(upsert) | Self::UpdateDynamicCircuit(upsert) => {
+ &upsert.circuit_id
+ }
+ Self::RemoveDynamicCircuit(removal) => &removal.circuit_id,
+ }
+ }
+}
+
+/// Data needed to create or update a dynamic circuit from a RADIUS session.
+#[derive(Clone, Debug, PartialEq)]
+pub struct DynamicCircuitUpsert {
+ /// Stable circuit identifier for the dynamic circuit overlay.
+ pub circuit_id: String,
+ /// Deterministic RADIUS session key that produced this intent.
+ pub session_key: AccountingSessionKey,
+ /// Latest decoded accounting event data for the shapeable session.
+ pub event: AccountingEvent,
+ /// Fully resolved in-memory `ShapedDevice` payload for the active session.
+ pub shaped_device: ShapedDevice,
+}
+
+/// Data needed to remove a dynamic circuit from a RADIUS session.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct DynamicCircuitRemoval {
+ /// Stable circuit identifier for the dynamic circuit overlay.
+ pub circuit_id: String,
+ /// Deterministic RADIUS session key that produced this intent.
+ pub session_key: AccountingSessionKey,
+ /// Why this removal was emitted.
+ pub reason: DynamicCircuitRemovalReason,
+}
+
+/// Cause for a dynamic-circuit removal intent.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DynamicCircuitRemovalReason {
+ /// The RADIUS session emitted Acct-Status-Type Stop.
+ Stop,
+ /// The in-memory session was expired by the caller.
+ Expired,
+ /// A previously shapeable session no longer has enough data to stay active.
+ NoLongerShapeable,
+ /// A session was promoted to a different deterministic dynamic-circuit id.
+ Rekeyed,
+ /// A NAS reset event marked the session stale.
+ NasReset(NasResetStatus),
+}
diff --git a/src/rust/lqos_radius/src/lib.rs b/src/rust/lqos_radius/src/lib.rs
new file mode 100644
index 00000000..fe9ec5e9
--- /dev/null
+++ b/src/rust/lqos_radius/src/lib.rs
@@ -0,0 +1,48 @@
+//! RADIUS accounting packet parsing and rootless listener support.
+//!
+//! The crate checks RADIUS packet framing, parses and verifies
+//! Accounting-Request packets, builds Accounting-Response packets, tracks
+//! decoded accounting sessions in memory, resolves shapeable sessions into
+//! in-memory shaped-device definitions, exposes deferred dynamic-circuit
+//! command intents for callers that opt in, and exposes a UDP listener that can
+//! bind to non-privileged loopback addresses for development and automated tests.
+
+#![warn(missing_docs)]
+
+mod accounting_event;
+mod attribute_type;
+mod dynamic_circuit;
+mod listener;
+mod mac_match;
+mod packet;
+mod session;
+#[cfg(test)]
+mod test_support;
+
+pub use accounting_event::{
+ AccountingEvent, AccountingEventOptions, AcctStatusType, Ipv6Prefix, MikrotikRateLimit,
+ MikrotikRateLimitDirection, UnknownVendorAttribute,
+};
+pub use dynamic_circuit::{
+ DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemoval,
+ DynamicCircuitRemovalReason, DynamicCircuitUpsert,
+};
+pub use listener::{
+ AccountingListenerOutcome, DEFAULT_LISTEN_ADDR, ListenerConfig, ListenerError, RadiusListener,
+ ReceivedAccountingPacket, ReceivedVerifiedAccountingPacket, TrustedClientSource,
+ TrustedClientSourceError, TrustedRadiusClient, TrustedRadiusClientError, start_listener,
+};
+pub use mac_match::{ShapedDevicesMacMatch, ShapedDevicesMacMatcher, normalize_radius_mac};
+pub use packet::{
+ AccountingRequest, MessageAuthenticatorPolicy, PacketError, RadiusAttribute, RadiusCode,
+ RadiusPacket, VerifiedAccountingRequest, build_accounting_response, handle_accounting_request,
+ parse_packet, verify_accounting_request,
+};
+pub use session::{
+ AccountingSession, AccountingSessionIgnoreReason, AccountingSessionKey, AccountingSessionState,
+ AccountingSessionStore, AccountingSessionUpdate, DynamicCircuitMapping, DynamicCircuitParent,
+ DynamicCircuitResolution, NasIdentity, NasResetStatus, PendingSessionFingerprint,
+ PendingSessionReason, RadiusActivationCounters, RadiusActivationDiagnostic,
+ RadiusActivationDiagnosticState, RadiusPacketCounters, ResolvedSessionRate, SessionRateProfile,
+ SessionRateProfileError, SessionRateSource, SessionRateSources, ShapedDevicesMatchOptions,
+};
diff --git a/src/rust/lqos_radius/src/listener.rs b/src/rust/lqos_radius/src/listener.rs
new file mode 100644
index 00000000..a6d636de
--- /dev/null
+++ b/src/rust/lqos_radius/src/listener.rs
@@ -0,0 +1,511 @@
+//! UDP listener startup for RADIUS Accounting-Request packets.
+
+use crate::VerifiedAccountingRequest;
+use crate::packet::{
+ MessageAuthenticatorPolicy, PacketError, RADIUS_MAX_PACKET_LEN, handle_accounting_request,
+ verify_accounting_request,
+};
+use ip_network::{IpNetwork, IpNetworkError};
+use std::fmt;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
+use std::str::FromStr;
+use thiserror::Error;
+use tokio::net::UdpSocket;
+
+/// Default non-privileged loopback listen address for local use and tests.
+pub const DEFAULT_LISTEN_ADDR: SocketAddr =
+ SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 18130));
+
+/// Configuration used to start a RADIUS accounting listener.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ListenerConfig {
+ /// UDP address the listener will bind.
+ pub listen_addr: SocketAddr,
+}
+
+impl Default for ListenerConfig {
+ fn default() -> Self {
+ Self {
+ listen_addr: DEFAULT_LISTEN_ADDR,
+ }
+ }
+}
+
+/// A started UDP listener for RADIUS accounting datagrams.
+pub struct RadiusListener {
+ socket: UdpSocket,
+}
+
+impl RadiusListener {
+ /// Returns the local UDP address bound by the listener.
+ ///
+ /// Side effects: queries the operating system for the socket's local
+ /// address. It does not read packets or change socket state.
+ pub fn local_addr(&self) -> Result {
+ match self.socket.local_addr() {
+ Ok(addr) => Ok(addr),
+ Err(source) => Err(ListenerError::LocalAddr { source }),
+ }
+ }
+
+ /// Waits for and parses the next RADIUS Accounting-Request datagram.
+ ///
+ /// Side effects: awaits network input on the listener's UDP socket.
+ /// Malformed or unsupported packets are returned as errors and no response
+ /// packets are sent. This diagnostic listener does not verify shared-secret
+ /// authenticators; callers that know the trusted client secret should use
+ /// `verify_accounting_request` before accepting a packet.
+ pub async fn receive_next(&self) -> Result {
+ let mut datagram = [0_u8; RADIUS_MAX_PACKET_LEN];
+ let (received_len, peer) = receive_datagram(&self.socket, &mut datagram).await?;
+
+ let request = match handle_accounting_request(&datagram[..received_len]) {
+ Ok(request) => request,
+ Err(source) => return Err(ListenerError::Packet { peer, source }),
+ };
+
+ Ok(ReceivedAccountingPacket {
+ peer,
+ received_len,
+ request,
+ })
+ }
+
+ /// Waits for the next datagram, verifies it against trusted clients, and
+ /// sends Accounting-Response packets for accepted Accounting-Requests.
+ ///
+ /// Side effects: awaits network input on the listener socket and sends one
+ /// UDP Accounting-Response when the packet source and authenticators are
+ /// trusted. Rejected packets do not produce accounting events and do not
+ /// receive responses.
+ pub async fn receive_next_verified(
+ &self,
+ clients: &[TrustedRadiusClient],
+ ) -> Result {
+ let mut datagram = [0_u8; RADIUS_MAX_PACKET_LEN];
+ let (received_len, peer) = receive_datagram(&self.socket, &mut datagram).await?;
+
+ let mut matched_client = None;
+ for client in clients {
+ if !client.source_matches(peer.ip()) {
+ continue;
+ }
+ if matched_client.replace(client).is_some() {
+ return Ok(AccountingListenerOutcome::RejectedAmbiguousSource {
+ peer,
+ received_len,
+ });
+ }
+ }
+ let Some(client) = matched_client else {
+ return Ok(AccountingListenerOutcome::RejectedSource { peer, received_len });
+ };
+
+ let request = match verify_accounting_request(
+ &datagram[..received_len],
+ client.shared_secret(),
+ client.message_authenticator_policy(),
+ ) {
+ Ok(request) => request,
+ Err(source) => {
+ return Ok(AccountingListenerOutcome::RejectedPacket {
+ peer,
+ received_len,
+ source,
+ });
+ }
+ };
+ let response = request.build_response(client.shared_secret());
+ let response_len = match self.socket.send_to(&response, peer).await {
+ Ok(response_len) => response_len,
+ Err(source) => return Err(ListenerError::Send { peer, source }),
+ };
+
+ Ok(AccountingListenerOutcome::Accepted(
+ ReceivedVerifiedAccountingPacket {
+ peer,
+ received_len,
+ response_len,
+ request,
+ },
+ ))
+ }
+}
+
+/// One parse-only Accounting-Request datagram received by the listener.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReceivedAccountingPacket {
+ /// UDP peer address that sent the datagram.
+ pub peer: SocketAddr,
+ /// Number of bytes received in the datagram.
+ pub received_len: usize,
+ /// Parsed, unverified Accounting-Request packet.
+ pub request: crate::AccountingRequest,
+}
+
+/// Runtime allow-list entry for a trusted RADIUS client source.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TrustedClientSource {
+ network: IpNetwork,
+}
+
+impl TrustedClientSource {
+ /// Creates a source matcher for one host IP address.
+ #[must_use]
+ pub fn host(address: IpAddr) -> Self {
+ Self {
+ network: IpNetwork::from(address),
+ }
+ }
+
+ /// Creates a source matcher for an IP network.
+ ///
+ /// Side effects: none. The supplied address must be the network address for
+ /// the prefix length.
+ pub fn network(address: IpAddr, prefix_len: u8) -> Result {
+ source_network(address, prefix_len).map(|network| Self { network })
+ }
+
+ /// Returns the canonical source address or network address.
+ #[must_use]
+ pub fn address(&self) -> IpAddr {
+ self.network.network_address()
+ }
+
+ /// Returns the source prefix length.
+ #[must_use]
+ pub fn prefix_len(&self) -> u8 {
+ self.network.netmask()
+ }
+
+ /// Returns true when `address` belongs to this source allow-list entry.
+ #[must_use]
+ pub fn contains(&self, address: IpAddr) -> bool {
+ self.network.contains(address)
+ }
+}
+
+impl FromStr for TrustedClientSource {
+ type Err = TrustedClientSourceError;
+
+ fn from_str(raw: &str) -> Result {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return Err(TrustedClientSourceError::Empty);
+ }
+
+ let Some((address, prefix_len)) = trimmed.split_once('/') else {
+ return trimmed.parse::().map(Self::host).map_err(|_| {
+ TrustedClientSourceError::InvalidAddress {
+ raw: trimmed.to_string(),
+ }
+ });
+ };
+
+ let address =
+ address
+ .parse::()
+ .map_err(|_| TrustedClientSourceError::InvalidAddress {
+ raw: address.to_string(),
+ })?;
+ let prefix_len = prefix_len.parse::().map_err(|_| {
+ TrustedClientSourceError::InvalidPrefixLengthText {
+ raw: prefix_len.to_string(),
+ }
+ })?;
+
+ source_network(address, prefix_len).map(|network| Self { network })
+ }
+}
+
+/// Errors returned while building trusted RADIUS client source matchers.
+#[derive(Clone, Debug, Eq, Error, PartialEq)]
+pub enum TrustedClientSourceError {
+ /// The supplied source text is empty.
+ #[error("RADIUS client source must not be empty")]
+ Empty,
+ /// The supplied source text does not contain a valid IP address.
+ #[error("RADIUS client source address '{raw}' is invalid")]
+ InvalidAddress {
+ /// Source address text supplied by the caller.
+ raw: String,
+ },
+ /// The supplied CIDR prefix is not numeric.
+ #[error("RADIUS client source prefix '{raw}' is invalid")]
+ InvalidPrefixLengthText {
+ /// Prefix text supplied by the caller.
+ raw: String,
+ },
+ /// The supplied prefix length is outside the address-family range.
+ #[error(
+ "RADIUS client source prefix length {prefix_len} is invalid for {address}; valid range is 0..={max_prefix_len}"
+ )]
+ InvalidPrefixLength {
+ /// Address supplied for the source matcher.
+ address: IpAddr,
+ /// Prefix length supplied for the source matcher.
+ prefix_len: u8,
+ /// Maximum prefix length for the address family.
+ max_prefix_len: u8,
+ },
+ /// The supplied CIDR string contains host bits in the network address.
+ #[error("RADIUS client source '{address}/{prefix_len}' has host bits set")]
+ HostBitsSet {
+ /// Address supplied for the source matcher.
+ address: IpAddr,
+ /// Prefix length supplied for the source matcher.
+ prefix_len: u8,
+ },
+}
+
+/// Runtime trusted RADIUS client with source matching and shared secret.
+#[derive(Clone, Eq, PartialEq)]
+pub struct TrustedRadiusClient {
+ sources: Vec,
+ shared_secret: Vec,
+ message_authenticator_policy: MessageAuthenticatorPolicy,
+}
+
+impl TrustedRadiusClient {
+ /// Creates a trusted RADIUS client using optional Message-Authenticator
+ /// policy.
+ ///
+ /// Side effects: none. The shared secret is copied into owned memory for
+ /// packet verification and response signing.
+ pub fn new(
+ sources: Vec,
+ shared_secret: impl Into>,
+ ) -> Result {
+ Self::with_message_authenticator_policy(
+ sources,
+ shared_secret,
+ MessageAuthenticatorPolicy::Optional,
+ )
+ }
+
+ /// Creates a trusted RADIUS client with an explicit Message-Authenticator
+ /// policy.
+ ///
+ /// Side effects: none. The shared secret is copied into owned memory for
+ /// packet verification and response signing.
+ pub fn with_message_authenticator_policy(
+ sources: Vec,
+ shared_secret: impl Into>,
+ message_authenticator_policy: MessageAuthenticatorPolicy,
+ ) -> Result {
+ if sources.is_empty() {
+ return Err(TrustedRadiusClientError::NoSources);
+ }
+
+ let shared_secret = shared_secret.into();
+ if shared_secret.is_empty() {
+ return Err(TrustedRadiusClientError::EmptySharedSecret);
+ }
+
+ Ok(Self {
+ sources,
+ shared_secret,
+ message_authenticator_policy,
+ })
+ }
+
+ /// Returns the source allow-list for this client.
+ #[must_use]
+ pub fn sources(&self) -> &[TrustedClientSource] {
+ &self.sources
+ }
+
+ /// Returns the shared secret used for packet verification and response
+ /// signing.
+ #[must_use]
+ pub fn shared_secret(&self) -> &[u8] {
+ &self.shared_secret
+ }
+
+ /// Returns the Message-Authenticator policy for this client.
+ #[must_use]
+ pub const fn message_authenticator_policy(&self) -> MessageAuthenticatorPolicy {
+ self.message_authenticator_policy
+ }
+
+ fn source_matches(&self, address: IpAddr) -> bool {
+ self.sources.iter().any(|source| source.contains(address))
+ }
+}
+
+impl fmt::Debug for TrustedRadiusClient {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("TrustedRadiusClient")
+ .field("sources", &self.sources)
+ .field("shared_secret", &"REDACTED")
+ .field(
+ "message_authenticator_policy",
+ &self.message_authenticator_policy,
+ )
+ .finish()
+ }
+}
+
+/// Errors returned while building trusted RADIUS clients.
+#[derive(Clone, Debug, Eq, Error, PartialEq)]
+pub enum TrustedRadiusClientError {
+ /// The client has no source allow-list entries.
+ #[error("trusted RADIUS client must include at least one source")]
+ NoSources,
+ /// The client has no shared secret bytes.
+ #[error("trusted RADIUS client shared secret must not be empty")]
+ EmptySharedSecret,
+}
+
+/// Result of handling one verified-listener UDP datagram.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum AccountingListenerOutcome {
+ /// The datagram was verified and received an Accounting-Response.
+ Accepted(ReceivedVerifiedAccountingPacket),
+ /// The datagram source IP did not match any trusted client.
+ RejectedSource {
+ /// UDP peer address that sent the rejected datagram.
+ peer: SocketAddr,
+ /// Number of bytes received in the rejected datagram.
+ received_len: usize,
+ },
+ /// The datagram source IP matched more than one trusted client, so the
+ /// listener rejected it instead of guessing which shared secret should apply.
+ RejectedAmbiguousSource {
+ /// UDP peer address that sent the rejected datagram.
+ peer: SocketAddr,
+ /// Number of bytes received in the rejected datagram.
+ received_len: usize,
+ },
+ /// The datagram source matched a trusted client, but packet parsing or
+ /// authenticator verification failed.
+ RejectedPacket {
+ /// UDP peer address that sent the rejected datagram.
+ peer: SocketAddr,
+ /// Number of bytes received in the rejected datagram.
+ received_len: usize,
+ /// Packet parsing or authenticator verification error.
+ source: PacketError,
+ },
+}
+
+/// One accepted and verified Accounting-Request handled by the listener.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReceivedVerifiedAccountingPacket {
+ /// UDP peer address that sent the datagram.
+ pub peer: SocketAddr,
+ /// Number of bytes received in the accepted datagram.
+ pub received_len: usize,
+ /// Number of bytes sent in the Accounting-Response datagram.
+ pub response_len: usize,
+ /// Verified Accounting-Request packet.
+ pub request: VerifiedAccountingRequest,
+}
+
+/// Errors returned while starting or receiving from the RADIUS listener.
+#[derive(Debug, Error)]
+pub enum ListenerError {
+ /// The UDP listener could not bind the requested address.
+ #[error("failed to bind RADIUS UDP listener to {addr}: {source}")]
+ Bind {
+ /// Address that failed to bind.
+ addr: SocketAddr,
+ /// Operating-system bind error.
+ #[source]
+ source: std::io::Error,
+ },
+ /// The UDP listener could not receive a datagram.
+ #[error("failed to receive RADIUS UDP datagram: {source}")]
+ Receive {
+ /// Operating-system receive error.
+ #[source]
+ source: std::io::Error,
+ },
+ /// The UDP listener could not send a response datagram.
+ #[error("failed to send RADIUS Accounting-Response to {peer}: {source}")]
+ Send {
+ /// UDP peer address that should have received the response.
+ peer: SocketAddr,
+ /// Operating-system send error.
+ #[source]
+ source: std::io::Error,
+ },
+ /// The listener's local address could not be queried.
+ #[error("failed to read RADIUS UDP listener local address: {source}")]
+ LocalAddr {
+ /// Operating-system local-address error.
+ #[source]
+ source: std::io::Error,
+ },
+ /// The listener received a malformed or unsupported packet.
+ #[error("received malformed or unsupported RADIUS accounting packet from {peer}: {source}")]
+ Packet {
+ /// UDP peer address that sent the rejected datagram.
+ peer: SocketAddr,
+ /// Packet parsing error.
+ #[source]
+ source: PacketError,
+ },
+}
+
+async fn receive_datagram(
+ socket: &UdpSocket,
+ datagram: &mut [u8; RADIUS_MAX_PACKET_LEN],
+) -> Result<(usize, SocketAddr), ListenerError> {
+ match socket.recv_from(datagram).await {
+ Ok(received) => Ok(received),
+ Err(source) => Err(ListenerError::Receive { source }),
+ }
+}
+
+const fn max_prefix_len(address: IpAddr) -> u8 {
+ match address {
+ IpAddr::V4(_) => 32,
+ IpAddr::V6(_) => 128,
+ }
+}
+
+fn network_error(
+ address: IpAddr,
+ prefix_len: u8,
+ source: IpNetworkError,
+) -> TrustedClientSourceError {
+ match source {
+ IpNetworkError::NetmaskError(_) => TrustedClientSourceError::InvalidPrefixLength {
+ address,
+ prefix_len,
+ max_prefix_len: max_prefix_len(address),
+ },
+ IpNetworkError::HostBitsSet => TrustedClientSourceError::HostBitsSet {
+ address,
+ prefix_len,
+ },
+ }
+}
+
+fn source_network(address: IpAddr, prefix_len: u8) -> Result {
+ IpNetwork::new(address, prefix_len).map_err(|source| network_error(address, prefix_len, source))
+}
+
+/// Starts a UDP listener for RADIUS accounting packets.
+///
+/// Side effects: binds a UDP socket to `config.listen_addr`. This function does
+/// not touch TC/XDP state, services, files, or privileged ports unless the caller
+/// explicitly supplies such an address.
+pub async fn start_listener(config: ListenerConfig) -> Result {
+ let socket = match UdpSocket::bind(config.listen_addr).await {
+ Ok(socket) => socket,
+ Err(source) => {
+ return Err(ListenerError::Bind {
+ addr: config.listen_addr,
+ source,
+ });
+ }
+ };
+
+ Ok(RadiusListener { socket })
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/rust/lqos_radius/src/listener/tests.rs b/src/rust/lqos_radius/src/listener/tests.rs
new file mode 100644
index 00000000..ec5edb3c
--- /dev/null
+++ b/src/rust/lqos_radius/src/listener/tests.rs
@@ -0,0 +1,550 @@
+//! Tests for RADIUS UDP listener behavior.
+
+use super::*;
+use crate::attribute_type::ACCT_STATUS_TYPE;
+use crate::packet::{RADIUS_HEADER_LEN, RADIUS_MAX_ACCOUNTING_PACKET_LEN, RADIUS_MAX_PACKET_LEN};
+use crate::test_support::{
+ SHARED_SECRET, accounting_request_packet, accounting_request_packet_with_message_authenticator,
+ max_sized_accounting_request_packet, radius_attributes, radius_packet, radius_text_attribute,
+ radius_u32_attribute, signed_accounting_request_packet,
+};
+use crate::{PacketError, RadiusCode, parse_packet};
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::time::Duration;
+use tokio::net::UdpSocket;
+use tokio::time::timeout;
+
+const ACCT_SESSION_ID: u8 = 44;
+const UDP_NO_RESPONSE_TIMEOUT: Duration = Duration::from_millis(25);
+const UDP_TEST_TIMEOUT: Duration = Duration::from_secs(1);
+
+#[test]
+fn default_listener_address_is_rootless_loopback() {
+ let config = ListenerConfig::default();
+
+ assert!(config.listen_addr.ip().is_loopback());
+ assert!(config.listen_addr.port() > 1024);
+}
+
+mod udp_listener {
+ //! Loopback UDP listener tests.
+
+ use super::*;
+
+ #[tokio::test]
+ async fn receives_accounting_request_on_loopback_ephemeral_port() {
+ let fixture = LoopbackUdpFixture::bind().await;
+
+ fixture.send(&accounting_request_packet(11, &[])).await;
+ let received = fixture.receive_next().await.unwrap();
+
+ assert_eq!(received.peer, fixture.sender_addr());
+ assert_eq!(received.received_len, RADIUS_HEADER_LEN);
+ assert_eq!(received.request.packet().identifier(), 11);
+ }
+
+ #[tokio::test]
+ async fn receives_maximum_sized_accounting_request() {
+ let fixture = LoopbackUdpFixture::bind().await;
+
+ fixture.send(&max_sized_accounting_request_packet(14)).await;
+ let received = fixture.receive_next().await.unwrap();
+
+ assert_eq!(received.received_len, RADIUS_MAX_ACCOUNTING_PACKET_LEN);
+ assert_eq!(received.request.packet().identifier(), 14);
+ assert_eq!(received.request.packet().attributes().len(), 16);
+ }
+
+ #[tokio::test]
+ async fn reports_packet_errors_with_sender_address() {
+ let fixture = LoopbackUdpFixture::bind().await;
+
+ fixture
+ .send(&radius_packet(RadiusCode::AccessRequest, 12, &[]))
+ .await;
+ let err = fixture.receive_next().await.unwrap_err();
+
+ let ListenerError::Packet { peer, source } = err else {
+ panic!("expected packet parsing error, got {err:?}");
+ };
+ assert_eq!(peer, fixture.sender_addr());
+ assert_eq!(source, PacketError::UnsupportedCode { code: 1 });
+ }
+
+ #[tokio::test]
+ async fn reports_malformed_datagrams_with_sender_address() {
+ let fixture = LoopbackUdpFixture::bind().await;
+
+ fixture
+ .send(&[RadiusCode::AccountingRequest.as_u8(), 13])
+ .await;
+ let err = fixture.receive_next().await.unwrap_err();
+
+ let ListenerError::Packet { peer, source } = err else {
+ panic!("expected packet parsing error, got {err:?}");
+ };
+ assert_eq!(peer, fixture.sender_addr());
+ assert_eq!(source, PacketError::PacketTooShort { actual: 2 });
+
+ fixture.send(&accounting_request_packet(15, &[])).await;
+ let received = fixture.receive_next().await.unwrap();
+
+ assert_eq!(received.peer, fixture.sender_addr());
+ assert_eq!(received.request.packet().identifier(), 15);
+ }
+
+ #[tokio::test]
+ async fn verified_listener_accepts_request_and_sends_response() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = trusted_loopback_clients(SHARED_SECRET);
+ let attributes = radius_attributes(&[
+ radius_u32_attribute(ACCT_STATUS_TYPE, 1),
+ radius_text_attribute(ACCT_SESSION_ID, "session-accepted"),
+ ]);
+ let request = signed_accounting_request_packet(21, &attributes, SHARED_SECRET);
+
+ let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await);
+ let response = fixture.receive_response().await;
+
+ assert_eq!(accepted.peer, fixture.sender_addr());
+ assert_eq!(accepted.received_len, request.len());
+ assert_eq!(accepted.response_len, response.len());
+ assert_eq!(accepted.request.packet().identifier(), 21);
+ let event = crate::AccountingEvent::from_verified(&accepted.request);
+ assert_eq!(event.acct_session_id.as_deref(), Some("session-accepted"));
+ assert_eq!(response, accepted.request.build_response(SHARED_SECRET));
+ assert_accounting_response(&response, 21);
+ }
+
+ #[tokio::test]
+ async fn verified_listener_accepts_later_source_in_client_allow_list() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = vec![
+ TrustedRadiusClient::new(
+ vec![alternate_loopback_source(), trusted_loopback_source()],
+ SHARED_SECRET,
+ )
+ .unwrap(),
+ ];
+ let request = signed_accounting_request_packet(31, &[], SHARED_SECRET);
+
+ let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await);
+ let response = fixture.receive_response().await;
+
+ assert_eq!(accepted.request.packet().identifier(), 31);
+ assert_eq!(response, accepted.request.build_response(SHARED_SECRET));
+ assert_accounting_response(&response, 31);
+ }
+
+ #[tokio::test]
+ async fn verified_listener_accepts_message_authenticator_when_required() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = trusted_loopback_clients_with_policy(
+ SHARED_SECRET,
+ MessageAuthenticatorPolicy::Required,
+ );
+ let request = accounting_request_packet_with_message_authenticator(28, &[], SHARED_SECRET);
+
+ let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await);
+ let response = fixture.receive_response().await;
+
+ assert_eq!(accepted.request.packet().identifier(), 28);
+ assert!(accepted.request.has_message_authenticator());
+ assert_eq!(accepted.response_len, response.len());
+ assert_eq!(response, accepted.request.build_response(SHARED_SECRET));
+ assert_accounting_response(&response, 28);
+ }
+
+ #[tokio::test]
+ async fn verified_listener_rejects_missing_message_authenticator_when_required() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = trusted_loopback_clients_with_policy(
+ SHARED_SECRET,
+ MessageAuthenticatorPolicy::Required,
+ );
+ let request = signed_accounting_request_packet(29, &[], SHARED_SECRET);
+
+ let outcome = fixture
+ .verified_outcome_without_response(&request, &clients)
+ .await;
+
+ assert_eq!(
+ outcome,
+ AccountingListenerOutcome::RejectedPacket {
+ peer: fixture.sender_addr(),
+ received_len: request.len(),
+ source: PacketError::MissingMessageAuthenticator,
+ }
+ );
+ }
+
+ #[tokio::test]
+ async fn verified_listener_rejects_ambiguous_source_before_parsing() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = vec![
+ TrustedRadiusClient::new(
+ vec![
+ TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8)
+ .unwrap(),
+ ],
+ SHARED_SECRET,
+ )
+ .unwrap(),
+ TrustedRadiusClient::new(vec![trusted_loopback_source()], SHARED_SECRET).unwrap(),
+ ];
+ let request = [RadiusCode::AccountingRequest.as_u8(), 24];
+
+ let outcome = fixture
+ .verified_outcome_without_response(&request, &clients)
+ .await;
+
+ assert_eq!(
+ outcome,
+ AccountingListenerOutcome::RejectedAmbiguousSource {
+ peer: fixture.sender_addr(),
+ received_len: request.len(),
+ }
+ );
+ }
+
+ #[tokio::test]
+ async fn verified_listener_rejects_unconfigured_source_without_event_or_response() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = alternate_loopback_clients();
+ let request = signed_accounting_request_packet(22, &[], SHARED_SECRET);
+
+ let outcome = fixture
+ .verified_outcome_without_response(&request, &clients)
+ .await;
+
+ assert_eq!(
+ outcome,
+ AccountingListenerOutcome::RejectedSource {
+ peer: fixture.sender_addr(),
+ received_len: request.len(),
+ }
+ );
+ }
+
+ #[tokio::test]
+ async fn verified_listener_rejects_malformed_untrusted_source_before_parsing() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = alternate_loopback_clients();
+ let request = [RadiusCode::AccountingRequest.as_u8(), 27];
+
+ let outcome = fixture
+ .verified_outcome_without_response(&request, &clients)
+ .await;
+
+ assert_eq!(
+ outcome,
+ AccountingListenerOutcome::RejectedSource {
+ peer: fixture.sender_addr(),
+ received_len: request.len(),
+ }
+ );
+ }
+
+ #[tokio::test]
+ async fn verified_listener_rejects_malformed_trusted_packet_without_response() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = trusted_loopback_clients(SHARED_SECRET);
+ let request = [RadiusCode::AccountingRequest.as_u8(), 26];
+
+ let outcome = fixture
+ .verified_outcome_without_response(&request, &clients)
+ .await;
+
+ assert_eq!(
+ outcome,
+ AccountingListenerOutcome::RejectedPacket {
+ peer: fixture.sender_addr(),
+ received_len: request.len(),
+ source: PacketError::PacketTooShort { actual: 2 },
+ }
+ );
+ }
+
+ #[tokio::test]
+ async fn verified_listener_rejects_unsupported_trusted_packet_without_response() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = trusted_loopback_clients(SHARED_SECRET);
+ let request = radius_packet(RadiusCode::AccessRequest, 30, &[]);
+
+ let outcome = fixture
+ .verified_outcome_without_response(&request, &clients)
+ .await;
+
+ assert_eq!(
+ outcome,
+ AccountingListenerOutcome::RejectedPacket {
+ peer: fixture.sender_addr(),
+ received_len: request.len(),
+ source: PacketError::UnsupportedCode { code: 1 },
+ }
+ );
+ }
+
+ #[tokio::test]
+ async fn verified_listener_rejects_invalid_authenticator_without_event_or_response() {
+ let fixture = LoopbackUdpFixture::bind().await;
+ let clients = trusted_loopback_clients(SHARED_SECRET);
+ let request = signed_accounting_request_packet(23, &[], b"wrong-secret");
+
+ let outcome = fixture
+ .verified_outcome_without_response(&request, &clients)
+ .await;
+
+ assert_eq!(
+ outcome,
+ AccountingListenerOutcome::RejectedPacket {
+ peer: fixture.sender_addr(),
+ received_len: request.len(),
+ source: PacketError::InvalidRequestAuthenticator,
+ }
+ );
+ }
+}
+
+#[test]
+fn trusted_client_source_matches_exact_hosts_and_networks() {
+ let host = TrustedClientSource::host(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
+ let network =
+ TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 0)), 24).unwrap();
+ let ipv6_network = TrustedClientSource::network("2001:db8::".parse().unwrap(), 64).unwrap();
+
+ assert!(host.contains(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
+ assert!(!host.contains(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))));
+ assert_eq!(network.address(), IpAddr::V4(Ipv4Addr::new(192, 0, 2, 0)));
+ assert!(network.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 25))));
+ assert!(!network.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 3, 25))));
+ assert_eq!(
+ ipv6_network.address(),
+ "2001:db8::".parse::().unwrap()
+ );
+ assert!(ipv6_network.contains("2001:db8::abcd".parse().unwrap()));
+ assert!(!ipv6_network.contains("2001:db9::abcd".parse().unwrap()));
+}
+
+#[test]
+fn trusted_client_source_parses_hosts_and_cidr_networks() {
+ assert_eq!(
+ "127.0.0.1".parse::().unwrap(),
+ trusted_loopback_source()
+ );
+ assert_eq!(
+ "2001:db8::/64".parse::().unwrap(),
+ TrustedClientSource::network("2001:db8::".parse().unwrap(), 64).unwrap()
+ );
+ assert_eq!(
+ "192.0.2.1/33".parse::(),
+ Err(TrustedClientSourceError::InvalidPrefixLength {
+ address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
+ prefix_len: 33,
+ max_prefix_len: 32,
+ })
+ );
+ assert_eq!(
+ "2001:db8::/129".parse::(),
+ Err(TrustedClientSourceError::InvalidPrefixLength {
+ address: "2001:db8::".parse().unwrap(),
+ prefix_len: 129,
+ max_prefix_len: 128,
+ })
+ );
+}
+
+#[test]
+fn trusted_client_source_rejects_invalid_text() {
+ assert_eq!(
+ "".parse::(),
+ Err(TrustedClientSourceError::Empty)
+ );
+ assert_eq!(
+ "not-an-address".parse::(),
+ Err(TrustedClientSourceError::InvalidAddress {
+ raw: "not-an-address".to_string(),
+ })
+ );
+ assert_eq!(
+ "192.0.2.1/nope".parse::(),
+ Err(TrustedClientSourceError::InvalidPrefixLengthText {
+ raw: "nope".to_string(),
+ })
+ );
+ assert_eq!(
+ "192.0.2.129/24".parse::(),
+ Err(TrustedClientSourceError::HostBitsSet {
+ address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)),
+ prefix_len: 24,
+ })
+ );
+ assert_eq!(
+ TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)), 24),
+ Err(TrustedClientSourceError::HostBitsSet {
+ address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)),
+ prefix_len: 24,
+ })
+ );
+}
+
+#[test]
+fn trusted_radius_client_rejects_missing_sources_or_secret() {
+ assert_eq!(
+ TrustedRadiusClient::with_message_authenticator_policy(
+ Vec::new(),
+ SHARED_SECRET,
+ MessageAuthenticatorPolicy::Required,
+ ),
+ Err(TrustedRadiusClientError::NoSources)
+ );
+ assert_eq!(
+ TrustedRadiusClient::with_message_authenticator_policy(
+ vec![trusted_loopback_source()],
+ Vec::new(),
+ MessageAuthenticatorPolicy::Required,
+ ),
+ Err(TrustedRadiusClientError::EmptySharedSecret)
+ );
+}
+
+struct LoopbackUdpFixture {
+ listener: RadiusListener,
+ listener_addr: SocketAddr,
+ sender: UdpSocket,
+}
+
+impl LoopbackUdpFixture {
+ async fn bind() -> Self {
+ let listener = start_listener(ListenerConfig {
+ listen_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+ })
+ .await
+ .unwrap();
+ let listener_addr = listener.local_addr().unwrap();
+ let sender = UdpSocket::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
+ .await
+ .unwrap();
+
+ Self {
+ listener,
+ listener_addr,
+ sender,
+ }
+ }
+
+ fn sender_addr(&self) -> SocketAddr {
+ self.sender.local_addr().unwrap()
+ }
+
+ async fn send(&self, packet: &[u8]) {
+ self.sender
+ .send_to(packet, self.listener_addr)
+ .await
+ .unwrap();
+ }
+
+ async fn receive_next(&self) -> Result {
+ timeout(UDP_TEST_TIMEOUT, self.listener.receive_next())
+ .await
+ .unwrap()
+ }
+
+ async fn verified_outcome(
+ &self,
+ packet: &[u8],
+ clients: &[TrustedRadiusClient],
+ ) -> AccountingListenerOutcome {
+ self.send(packet).await;
+ timeout(
+ UDP_TEST_TIMEOUT,
+ self.listener.receive_next_verified(clients),
+ )
+ .await
+ .unwrap()
+ .unwrap()
+ }
+
+ async fn verified_outcome_without_response(
+ &self,
+ packet: &[u8],
+ clients: &[TrustedRadiusClient],
+ ) -> AccountingListenerOutcome {
+ let outcome = self.verified_outcome(packet, clients).await;
+ self.assert_no_response().await;
+ outcome
+ }
+
+ async fn receive_response(&self) -> Vec {
+ let mut response = [0_u8; RADIUS_MAX_PACKET_LEN];
+ let (response_len, peer) = timeout(UDP_TEST_TIMEOUT, self.sender.recv_from(&mut response))
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert!(peer.ip().is_loopback());
+ response[..response_len].to_vec()
+ }
+
+ async fn assert_no_response(&self) {
+ let mut response = [0_u8; RADIUS_MAX_PACKET_LEN];
+
+ match timeout(
+ UDP_NO_RESPONSE_TIMEOUT,
+ self.sender.recv_from(&mut response),
+ )
+ .await
+ {
+ Err(_) => {}
+ Ok(Ok((response_len, peer))) => {
+ panic!("rejected packet received {response_len} response bytes from {peer}");
+ }
+ Ok(Err(source)) => panic!("failed to check for rejected-packet response: {source}"),
+ }
+ }
+}
+
+fn trusted_loopback_clients(shared_secret: &[u8]) -> Vec {
+ trusted_loopback_clients_with_policy(shared_secret, MessageAuthenticatorPolicy::Optional)
+}
+
+fn trusted_loopback_clients_with_policy(
+ shared_secret: &[u8],
+ policy: MessageAuthenticatorPolicy,
+) -> Vec {
+ vec![
+ TrustedRadiusClient::with_message_authenticator_policy(
+ vec![trusted_loopback_source()],
+ shared_secret,
+ policy,
+ )
+ .unwrap(),
+ ]
+}
+
+fn alternate_loopback_clients() -> Vec {
+ vec![TrustedRadiusClient::new(vec![alternate_loopback_source()], SHARED_SECRET).unwrap()]
+}
+
+fn trusted_loopback_source() -> TrustedClientSource {
+ TrustedClientSource::host(IpAddr::V4(Ipv4Addr::LOCALHOST))
+}
+
+fn alternate_loopback_source() -> TrustedClientSource {
+ TrustedClientSource::host(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))
+}
+
+fn expect_accepted(outcome: AccountingListenerOutcome) -> ReceivedVerifiedAccountingPacket {
+ match outcome {
+ AccountingListenerOutcome::Accepted(accepted) => accepted,
+ other => panic!("expected accepted packet, got {other:?}"),
+ }
+}
+
+fn assert_accounting_response(response: &[u8], identifier: u8) {
+ let parsed = parse_packet(response).unwrap();
+
+ assert_eq!(parsed.code(), RadiusCode::AccountingResponse);
+ assert_eq!(parsed.identifier(), identifier);
+ assert_eq!(parsed.attributes(), &[]);
+}
diff --git a/src/rust/lqos_radius/src/mac_match.rs b/src/rust/lqos_radius/src/mac_match.rs
new file mode 100644
index 00000000..e7970f20
--- /dev/null
+++ b/src/rust/lqos_radius/src/mac_match.rs
@@ -0,0 +1,287 @@
+//! RADIUS identity matching against `ShapedDevices.csv` MAC fields.
+
+use crate::AccountingEvent;
+use lqos_config::ShapedDevice;
+use std::collections::HashMap;
+
+/// Matches RADIUS identities against shaped-device MAC fields.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ShapedDevicesMacMatcher {
+ matches_by_mac: HashMap,
+ matches_by_username: HashMap,
+}
+
+impl ShapedDevicesMacMatcher {
+ /// Builds a RADIUS identity matcher from shaped-device rows.
+ ///
+ /// Values that are valid MAC addresses are indexed for `Calling-Station-Id`
+ /// matching. Every non-empty MAC field is also indexed verbatim for
+ /// `User-Name` matching, so integrations can use the existing field for
+ /// either identity. Duplicate identity values are retained as ambiguous matches.
+ ///
+ /// Side effects: none. The supplied rows are inspected and cloned in memory.
+ #[must_use]
+ pub fn from_devices(devices: &[ShapedDevice]) -> Self {
+ let mut matches_by_mac = HashMap::new();
+ let mut matches_by_username = HashMap::new();
+ for device in devices {
+ if let Some(normalized_mac) = normalize_radius_mac(&device.mac) {
+ matches_by_mac
+ .entry(normalized_mac)
+ .and_modify(|entry| *entry = ShapedDevicesMacEntry::Ambiguous)
+ .or_insert_with(|| ShapedDevicesMacEntry::Unique(Box::new(device.clone())));
+ }
+ let username = &device.mac;
+ if !username.is_empty() {
+ matches_by_username
+ .entry(username.to_string())
+ .and_modify(|entry| *entry = ShapedDevicesMacEntry::Ambiguous)
+ .or_insert_with(|| ShapedDevicesMacEntry::Unique(Box::new(device.clone())));
+ }
+ }
+
+ Self {
+ matches_by_mac,
+ matches_by_username,
+ }
+ }
+
+ /// Matches one accounting event by `Calling-Station-Id`.
+ ///
+ /// Side effects: none. The event and in-memory matcher are inspected only.
+ #[must_use]
+ pub fn match_event(&self, event: &AccountingEvent) -> ShapedDevicesMacMatch {
+ self.match_event_with_identities(event, false, true)
+ }
+
+ /// Matches one accounting event by configured RADIUS username and MAC identities.
+ ///
+ /// A unique username match takes precedence; MAC matching is attempted only when no
+ /// username row matches. Side effects: none.
+ #[must_use]
+ pub fn match_event_with_identities(
+ &self,
+ event: &AccountingEvent,
+ match_by_username: bool,
+ match_by_mac: bool,
+ ) -> ShapedDevicesMacMatch {
+ if match_by_username
+ && let Some(username) = event
+ .user_name
+ .as_deref()
+ && let Some(result) = Self::match_entry(&self.matches_by_username, username)
+ {
+ return result;
+ }
+ if !match_by_mac {
+ return ShapedDevicesMacMatch::NoMatch;
+ }
+ let Some(calling_station_id) = event.calling_station_id.as_deref() else {
+ return ShapedDevicesMacMatch::NoMatch;
+ };
+ let Some(normalized_mac) = normalize_radius_mac(calling_station_id) else {
+ return ShapedDevicesMacMatch::NoMatch;
+ };
+
+ Self::match_entry(&self.matches_by_mac, &normalized_mac)
+ .unwrap_or(ShapedDevicesMacMatch::NoMatch)
+ }
+
+ fn match_entry(
+ entries: &HashMap,
+ key: &str,
+ ) -> Option {
+ match entries.get(key) {
+ Some(ShapedDevicesMacEntry::Unique(device)) => {
+ Some(ShapedDevicesMacMatch::Unique(device.clone()))
+ }
+ Some(ShapedDevicesMacEntry::Ambiguous) => Some(ShapedDevicesMacMatch::Ambiguous),
+ None => None,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+enum ShapedDevicesMacEntry {
+ Unique(Box),
+ Ambiguous,
+}
+
+/// Result of matching one RADIUS identity to shaped-device rows.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ShapedDevicesMacMatch {
+ /// Exactly one shaped-device row matched the configured identity.
+ Unique(Box),
+ /// No shaped-device row matched the configured identity.
+ NoMatch,
+ /// More than one shaped-device row matched the configured identity.
+ Ambiguous,
+}
+
+/// Normalizes common RADIUS and `ShapedDevices.csv` MAC address formats.
+///
+/// Accepted inputs include colon-separated, hyphen-separated, dotted,
+/// plain-hex, and mixed-case forms. The returned value is twelve lowercase
+/// hexadecimal characters.
+#[must_use]
+pub fn normalize_radius_mac(raw_mac: &str) -> Option {
+ let raw_mac = raw_mac.trim();
+ if raw_mac.len() == 12 && raw_mac.chars().all(|ch| ch.is_ascii_hexdigit()) {
+ return Some(raw_mac.to_ascii_lowercase());
+ }
+
+ if raw_mac.contains(':') {
+ return normalize_delimited_mac(raw_mac, ':', 6, 2);
+ }
+ if raw_mac.contains('-') {
+ return normalize_delimited_mac(raw_mac, '-', 6, 2);
+ }
+ if raw_mac.contains('.') {
+ return normalize_delimited_mac(raw_mac, '.', 3, 4);
+ }
+
+ None
+}
+
+fn normalize_delimited_mac(
+ raw_mac: &str,
+ separator: char,
+ expected_groups: usize,
+ expected_group_len: usize,
+) -> Option {
+ let mut normalized_mac = String::with_capacity(12);
+ let mut group_count = 0;
+ for group in raw_mac.split(separator) {
+ group_count += 1;
+ if group.len() != expected_group_len {
+ return None;
+ }
+ for ch in group.chars() {
+ if !ch.is_ascii_hexdigit() {
+ return None;
+ }
+ normalized_mac.push(ch.to_ascii_lowercase());
+ }
+ }
+
+ (group_count == expected_groups).then_some(normalized_mac)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn normalizes_common_mac_formats() {
+ for raw_mac in [
+ "AA:BB:CC:DD:EE:FF",
+ "aa-bb-cc-dd-ee-ff",
+ "AABB.CCDD.EEFF",
+ "aabbccddeeff",
+ " AaBbCcDdEeFf ",
+ ] {
+ assert_eq!(
+ normalize_radius_mac(raw_mac).as_deref(),
+ Some("aabbccddeeff")
+ );
+ }
+
+ assert_eq!(normalize_radius_mac("aa:bb:cc"), None);
+ assert_eq!(normalize_radius_mac("aa:bbccddeeff"), None);
+ assert_eq!(normalize_radius_mac("aa-bbccddeeff"), None);
+ assert_eq!(normalize_radius_mac("aabb.ccddeeff"), None);
+ assert_eq!(normalize_radius_mac("aa-bb.cc:ddEeFf"), None);
+ assert_eq!(normalize_radius_mac("aa:bb:cc:dd:ee:zz"), None);
+ assert_eq!(normalize_radius_mac("aa bb cc dd ee ff"), None);
+ }
+
+ #[test]
+ fn matcher_ignores_empty_and_invalid_shaped_device_mac_rows() {
+ let matcher = ShapedDevicesMacMatcher::from_devices(&[
+ shaped_device("empty-mac", ""),
+ shaped_device("invalid-mac", "aa:bbccddeeff"),
+ ]);
+ let mut event = AccountingEvent {
+ calling_station_id: Some("aa:bb:cc:dd:ee:ff".to_string()),
+ ..AccountingEvent::default()
+ };
+
+ assert_eq!(matcher.match_event(&event), ShapedDevicesMacMatch::NoMatch);
+
+ let matcher = ShapedDevicesMacMatcher::from_devices(&[
+ shaped_device("valid-mac", "aa-bb-cc-dd-ee-ff"),
+ shaped_device("ignored-invalid-mac", "aa:bbccddeeff"),
+ ]);
+ let ShapedDevicesMacMatch::Unique(device) = matcher.match_event(&event) else {
+ panic!("valid shaped-device MAC should match uniquely");
+ };
+ assert_eq!(device.circuit_id, "valid-mac");
+
+ event.calling_station_id = Some("aa:bbccddeeff".to_string());
+ assert_eq!(matcher.match_event(&event), ShapedDevicesMacMatch::NoMatch);
+ }
+
+ #[test]
+ fn matcher_marks_duplicate_normalized_macs_ambiguous() {
+ let matcher = ShapedDevicesMacMatcher::from_devices(&[
+ shaped_device("first-circuit", "aa-bb-cc-dd-ee-ff"),
+ shaped_device("second-circuit", "AABB.CCDD.EEFF"),
+ ]);
+ let event = AccountingEvent {
+ calling_station_id: Some("AA:BB:CC:DD:EE:FF".to_string()),
+ ..AccountingEvent::default()
+ };
+
+ assert_eq!(
+ matcher.match_event(&event),
+ ShapedDevicesMacMatch::Ambiguous
+ );
+ }
+
+ #[test]
+ fn username_match_precedes_mac_when_mac_field_holds_a_username() {
+ let username_device = shaped_device("username-circuit", " pppoe-known ");
+ let mac_device = shaped_device("mac-circuit", "aa:bb:cc:dd:ee:ff");
+ let matcher = ShapedDevicesMacMatcher::from_devices(&[username_device, mac_device]);
+ let event = AccountingEvent {
+ user_name: Some(" pppoe-known ".to_string()),
+ calling_station_id: Some("aa:bb:cc:dd:ee:ff".to_string()),
+ ..AccountingEvent::default()
+ };
+
+ let ShapedDevicesMacMatch::Unique(device) =
+ matcher.match_event_with_identities(&event, true, true)
+ else {
+ panic!("username should match uniquely before Calling-Station-Id");
+ };
+ assert_eq!(device.circuit_id, "username-circuit");
+
+ let untrimmed_username = AccountingEvent {
+ user_name: Some("pppoe-known".to_string()),
+ ..AccountingEvent::default()
+ };
+ assert_eq!(
+ matcher.match_event_with_identities(&untrimmed_username, true, false),
+ ShapedDevicesMacMatch::NoMatch
+ );
+
+ let mac_as_username = AccountingEvent {
+ user_name: Some("aa:bb:cc:dd:ee:ff".to_string()),
+ ..AccountingEvent::default()
+ };
+ let ShapedDevicesMacMatch::Unique(device) =
+ matcher.match_event_with_identities(&mac_as_username, true, false)
+ else {
+ panic!("a username that resembles a MAC should match verbatim");
+ };
+ assert_eq!(device.circuit_id, "mac-circuit");
+ }
+
+ fn shaped_device(circuit_id: &str, mac: &str) -> ShapedDevice {
+ ShapedDevice {
+ circuit_id: circuit_id.to_string(),
+ mac: mac.to_string(),
+ ..ShapedDevice::default()
+ }
+ }
+}
diff --git a/src/rust/lqos_radius/src/main.rs b/src/rust/lqos_radius/src/main.rs
new file mode 100644
index 00000000..48c23598
--- /dev/null
+++ b/src/rust/lqos_radius/src/main.rs
@@ -0,0 +1,206 @@
+//! Rootless diagnostic executable for the LibreQoS RADIUS accounting listener.
+
+use clap::{ArgGroup, Parser};
+use lqos_radius::{
+ AccountingListenerOutcome, DEFAULT_LISTEN_ADDR, ListenerConfig, ListenerError, RadiusListener,
+ TrustedClientSource, TrustedRadiusClient, start_listener,
+};
+use std::net::SocketAddr;
+use thiserror::Error;
+
+#[derive(Parser)]
+#[command(
+ name = "lqos_radius",
+ about = "Run the LibreQoS RADIUS accounting diagnostic listener.",
+ group(
+ ArgGroup::new("run_mode")
+ .required(true)
+ .args(["parse_only", "shared_secret"])
+ )
+)]
+struct Args {
+ #[arg(long, value_name = "ADDR", default_value_t = DEFAULT_LISTEN_ADDR)]
+ listen: SocketAddr,
+ #[arg(long, conflicts_with_all = ["client_sources", "shared_secret"])]
+ parse_only: bool,
+ #[arg(
+ long = "client-source",
+ value_name = "IP_OR_CIDR",
+ requires = "shared_secret"
+ )]
+ client_sources: Vec,
+ #[arg(
+ long,
+ value_name = "SECRET",
+ requires = "client_sources",
+ value_parser = clap::builder::NonEmptyStringValueParser::new()
+ )]
+ shared_secret: Option,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), MainError> {
+ let args = Args::parse();
+ let listen = args.listen;
+ let run_mode = args.run_mode();
+ let listener = start_listener(ListenerConfig {
+ listen_addr: listen,
+ })
+ .await?;
+ let local_addr = listener.local_addr()?;
+
+ println!("Listening for RADIUS accounting packets on {local_addr}");
+ match run_mode {
+ RunMode::ParseOnly => parse_only_loop(&listener).await,
+ RunMode::Verified(clients) => verified_loop(&listener, &clients).await,
+ }
+}
+
+impl Args {
+ fn run_mode(self) -> RunMode {
+ let Self {
+ listen: _,
+ parse_only,
+ client_sources,
+ shared_secret,
+ } = self;
+
+ if parse_only {
+ return RunMode::ParseOnly;
+ }
+ let shared_secret = shared_secret.expect("clap requires --shared-secret in verified mode");
+ let client = TrustedRadiusClient::new(client_sources, shared_secret.into_bytes()).expect(
+ "clap requires at least one --client-source and a non-empty --shared-secret in verified mode",
+ );
+
+ RunMode::Verified(vec![client])
+ }
+}
+
+enum RunMode {
+ ParseOnly,
+ Verified(Vec),
+}
+
+#[derive(Debug, Error)]
+enum MainError {
+ #[error(transparent)]
+ Listener(#[from] ListenerError),
+}
+
+async fn parse_only_loop(listener: &RadiusListener) -> Result<(), MainError> {
+ loop {
+ match listener.receive_next().await {
+ Ok(received) => println!(
+ "Parsed Accounting-Request from {}: identifier={}, attributes={}",
+ received.peer,
+ received.request.packet().identifier(),
+ received.request.packet().attributes().len()
+ ),
+ Err(ListenerError::Packet { peer, source }) => {
+ eprintln!("Discarded malformed or unsupported RADIUS packet from {peer}: {source}");
+ }
+ Err(err) => return Err(err.into()),
+ }
+ }
+}
+
+async fn verified_loop(
+ listener: &RadiusListener,
+ clients: &[TrustedRadiusClient],
+) -> Result<(), MainError> {
+ loop {
+ match listener.receive_next_verified(clients).await {
+ Ok(AccountingListenerOutcome::Accepted(accepted)) => println!(
+ "Accepted Accounting-Request from {}: identifier={}, attributes={}, response_bytes={}",
+ accepted.peer,
+ accepted.request.packet().identifier(),
+ accepted.request.packet().attributes().len(),
+ accepted.response_len
+ ),
+ Ok(AccountingListenerOutcome::RejectedSource { peer, .. }) => {
+ eprintln!("Discarded RADIUS packet from unconfigured source {peer}");
+ }
+ Ok(AccountingListenerOutcome::RejectedAmbiguousSource { peer, .. }) => {
+ eprintln!("Discarded RADIUS packet from ambiguous trusted source {peer}");
+ }
+ Ok(AccountingListenerOutcome::RejectedPacket { peer, source, .. }) => {
+ eprintln!("Rejected RADIUS packet from {peer}: {source}");
+ }
+ Err(ListenerError::Send { peer, source }) => {
+ eprintln!("Failed to send RADIUS Accounting-Response to {peer}: {source}");
+ }
+ Err(err) => return Err(err.into()),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::Ipv4Addr;
+
+ #[test]
+ fn cli_parser_accepts_parse_only_mode() {
+ let args = Args::try_parse_from(["lqos_radius", "--parse-only"]).unwrap();
+
+ assert_eq!(args.listen, DEFAULT_LISTEN_ADDR);
+ assert!(matches!(args.run_mode(), RunMode::ParseOnly));
+ }
+
+ #[test]
+ fn cli_parser_accepts_verified_mode_with_multiple_sources() {
+ let args = Args::try_parse_from([
+ "lqos_radius",
+ "--listen",
+ "127.0.0.1:0",
+ "--client-source",
+ "127.0.0.1",
+ "--client-source",
+ "192.0.2.0/24",
+ "--shared-secret",
+ "radius-secret",
+ ])
+ .unwrap();
+ assert_eq!(args.listen, SocketAddr::from((Ipv4Addr::LOCALHOST, 0)));
+
+ let RunMode::Verified(clients) = args.run_mode() else {
+ panic!("expected verified run mode");
+ };
+ assert_eq!(clients.len(), 1);
+ assert_eq!(clients[0].sources().len(), 2);
+ assert!(clients[0].sources()[0].contains(Ipv4Addr::LOCALHOST.into()));
+ assert!(clients[0].sources()[1].contains(Ipv4Addr::new(192, 0, 2, 99).into()));
+ assert_eq!(clients[0].shared_secret(), b"radius-secret");
+ }
+
+ #[test]
+ fn cli_parser_rejects_missing_or_conflicting_modes() {
+ assert!(Args::try_parse_from(["lqos_radius"]).is_err());
+ assert!(
+ Args::try_parse_from(["lqos_radius", "--parse-only", "--shared-secret", "secret"])
+ .is_err()
+ );
+ assert!(
+ Args::try_parse_from([
+ "lqos_radius",
+ "--parse-only",
+ "--client-source",
+ "127.0.0.1"
+ ])
+ .is_err()
+ );
+ assert!(Args::try_parse_from(["lqos_radius", "--client-source", "127.0.0.1"]).is_err());
+ assert!(Args::try_parse_from(["lqos_radius", "--shared-secret", "secret"]).is_err());
+ assert!(
+ Args::try_parse_from([
+ "lqos_radius",
+ "--client-source",
+ "127.0.0.1",
+ "--shared-secret",
+ "",
+ ])
+ .is_err()
+ );
+ }
+}
diff --git a/src/rust/lqos_radius/src/packet.rs b/src/rust/lqos_radius/src/packet.rs
new file mode 100644
index 00000000..7b47fa8b
--- /dev/null
+++ b/src/rust/lqos_radius/src/packet.rs
@@ -0,0 +1,640 @@
+//! RADIUS packet framing, Accounting-Request verification, and response building.
+
+use hmac::{Hmac, Mac};
+use md5::{Digest, Md5};
+use subtle::ConstantTimeEq;
+use thiserror::Error;
+
+type HmacMd5 = Hmac;
+
+pub(crate) const RADIUS_HEADER_LEN: usize = 20;
+pub(crate) const RADIUS_MAX_PACKET_LEN: usize = 4096;
+// RFC 2866 section 3 caps RADIUS Accounting packet Length at 4095 bytes.
+pub(crate) const RADIUS_MAX_ACCOUNTING_PACKET_LEN: usize = 4095;
+pub(crate) const RADIUS_AUTHENTICATOR_LEN: usize = 16;
+pub(crate) const RADIUS_ATTRIBUTE_HEADER_LEN: usize = 2;
+const RADIUS_MIN_ATTRIBUTE_LEN: usize = 2;
+const ACCOUNTING_REQUEST_CODE: u8 = 4;
+const ACCOUNTING_RESPONSE_CODE: u8 = 5;
+const PROXY_STATE_TYPE: u8 = 33;
+pub(crate) const MESSAGE_AUTHENTICATOR_TYPE: u8 = 80;
+pub(crate) const MESSAGE_AUTHENTICATOR_VALUE_LEN: usize = 16;
+#[cfg(test)]
+pub(crate) const MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN: usize =
+ RADIUS_ATTRIBUTE_HEADER_LEN + MESSAGE_AUTHENTICATOR_VALUE_LEN;
+
+/// RADIUS packet code values understood by the packet parser.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RadiusCode {
+ /// Access-Request packet code.
+ AccessRequest,
+ /// Access-Accept packet code.
+ AccessAccept,
+ /// Access-Reject packet code.
+ AccessReject,
+ /// Accounting-Request packet code.
+ AccountingRequest,
+ /// Accounting-Response packet code.
+ AccountingResponse,
+ /// Any RADIUS packet code not enumerated by this crate.
+ Other(u8),
+}
+
+impl RadiusCode {
+ /// Returns the wire value for this packet code.
+ #[must_use]
+ pub const fn as_u8(self) -> u8 {
+ match self {
+ Self::AccessRequest => 1,
+ Self::AccessAccept => 2,
+ Self::AccessReject => 3,
+ Self::AccountingRequest => ACCOUNTING_REQUEST_CODE,
+ Self::AccountingResponse => ACCOUNTING_RESPONSE_CODE,
+ Self::Other(code) => code,
+ }
+ }
+}
+
+impl From for RadiusCode {
+ fn from(code: u8) -> Self {
+ match code {
+ 1 => Self::AccessRequest,
+ 2 => Self::AccessAccept,
+ 3 => Self::AccessReject,
+ ACCOUNTING_REQUEST_CODE => Self::AccountingRequest,
+ ACCOUNTING_RESPONSE_CODE => Self::AccountingResponse,
+ other => Self::Other(other),
+ }
+ }
+}
+
+impl From for u8 {
+ fn from(code: RadiusCode) -> Self {
+ code.as_u8()
+ }
+}
+
+/// One decoded RADIUS attribute.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RadiusAttribute {
+ kind: u8,
+ value: Vec,
+}
+
+impl RadiusAttribute {
+ /// Returns the raw RADIUS attribute type value.
+ #[must_use]
+ pub const fn kind(&self) -> u8 {
+ self.kind
+ }
+
+ /// Returns the raw RADIUS attribute value bytes.
+ #[must_use]
+ pub fn value(&self) -> &[u8] {
+ &self.value
+ }
+}
+
+/// A well-formed RADIUS packet.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RadiusPacket {
+ code: RadiusCode,
+ identifier: u8,
+ authenticator: [u8; RADIUS_AUTHENTICATOR_LEN],
+ attributes: Vec,
+}
+
+impl RadiusPacket {
+ /// Returns the packet code.
+ #[must_use]
+ pub const fn code(&self) -> RadiusCode {
+ self.code
+ }
+
+ /// Returns the packet identifier.
+ #[must_use]
+ pub const fn identifier(&self) -> u8 {
+ self.identifier
+ }
+
+ /// Returns the 16-byte request authenticator from the packet header.
+ #[must_use]
+ pub const fn authenticator(&self) -> &[u8; RADIUS_AUTHENTICATOR_LEN] {
+ &self.authenticator
+ }
+
+ /// Returns the decoded packet attributes.
+ #[must_use]
+ pub fn attributes(&self) -> &[RadiusAttribute] {
+ &self.attributes
+ }
+}
+
+/// A well-formed RADIUS Accounting-Request packet.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AccountingRequest {
+ packet: RadiusPacket,
+}
+
+impl AccountingRequest {
+ /// Returns the decoded RADIUS packet.
+ #[must_use]
+ pub const fn packet(&self) -> &RadiusPacket {
+ &self.packet
+ }
+
+ /// Consumes the accounting request and returns the decoded RADIUS packet.
+ #[must_use]
+ pub fn into_packet(self) -> RadiusPacket {
+ self.packet
+ }
+}
+
+/// Policy for accepting Accounting-Request packets without Message-Authenticator.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum MessageAuthenticatorPolicy {
+ /// Verify Message-Authenticator when present, but do not require it.
+ #[default]
+ Optional,
+ /// Reject Accounting-Request packets that lack Message-Authenticator.
+ Required,
+}
+
+impl MessageAuthenticatorPolicy {
+ /// Returns true when the policy requires Message-Authenticator attribute 80.
+ #[must_use]
+ pub const fn requires_message_authenticator(self) -> bool {
+ matches!(self, Self::Required)
+ }
+}
+
+/// A RADIUS Accounting-Request whose authenticators matched the shared secret.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VerifiedAccountingRequest {
+ request: AccountingRequest,
+ has_message_authenticator: bool,
+}
+
+impl VerifiedAccountingRequest {
+ /// Returns the verified Accounting-Request wrapper.
+ #[must_use]
+ pub const fn request(&self) -> &AccountingRequest {
+ &self.request
+ }
+
+ /// Returns the verified decoded RADIUS packet.
+ #[must_use]
+ pub const fn packet(&self) -> &RadiusPacket {
+ self.request.packet()
+ }
+
+ /// Returns true when the accepted packet contained Message-Authenticator.
+ #[must_use]
+ pub const fn has_message_authenticator(&self) -> bool {
+ self.has_message_authenticator
+ }
+
+ /// Consumes the verified request and returns the Accounting-Request wrapper.
+ #[must_use]
+ pub fn into_request(self) -> AccountingRequest {
+ self.request
+ }
+
+ /// Builds an Accounting-Response packet for this verified request.
+ ///
+ /// Side effects: none. The returned bytes are not sent to the network.
+ #[must_use]
+ pub fn build_response(&self, shared_secret: &[u8]) -> Vec {
+ build_accounting_response(self, shared_secret)
+ }
+}
+
+/// Errors returned while parsing RADIUS packets.
+#[derive(Clone, Debug, Eq, Error, PartialEq)]
+pub enum PacketError {
+ /// The datagram was shorter than the fixed RADIUS header.
+ #[error("RADIUS packet is {actual} bytes; minimum is 20 bytes")]
+ PacketTooShort {
+ /// Number of bytes received in the datagram.
+ actual: usize,
+ },
+ /// The packet length field was outside the RADIUS framing bounds.
+ #[error("RADIUS packet length field is {declared} bytes; valid range is 20..=4096 bytes")]
+ InvalidPacketLength {
+ /// Length declared in the packet header.
+ declared: usize,
+ },
+ /// The packet length field exceeded the RADIUS Accounting framing limit.
+ #[error(
+ "RADIUS accounting packet length field is {declared} bytes; valid range is 20..=4095 bytes"
+ )]
+ InvalidAccountingPacketLength {
+ /// Length declared in the packet header.
+ declared: usize,
+ },
+ /// The datagram ended before the declared RADIUS packet length.
+ #[error(
+ "RADIUS packet length field is {declared} bytes, but datagram contains only {actual} bytes"
+ )]
+ PacketTruncated {
+ /// Length declared in the packet header.
+ declared: usize,
+ /// Number of bytes received in the datagram.
+ actual: usize,
+ },
+ /// An attribute was missing its type and length bytes.
+ #[error("RADIUS attribute at offset {offset} is missing its type/length header")]
+ AttributeHeaderTruncated {
+ /// Byte offset of the malformed attribute.
+ offset: usize,
+ },
+ /// An attribute declared a length smaller than its own header.
+ #[error(
+ "RADIUS attribute at offset {offset} declares invalid length {declared}; minimum is 2 bytes"
+ )]
+ InvalidAttributeLength {
+ /// Byte offset of the malformed attribute.
+ offset: usize,
+ /// Length declared by the attribute.
+ declared: usize,
+ },
+ /// An attribute declared more bytes than remain in the packet.
+ #[error(
+ "RADIUS attribute at offset {offset} declares length {declared}, but only {remaining} bytes remain"
+ )]
+ AttributeTruncated {
+ /// Byte offset of the malformed attribute.
+ offset: usize,
+ /// Length declared by the attribute.
+ declared: usize,
+ /// Remaining bytes in the packet from the attribute offset.
+ remaining: usize,
+ },
+ /// The packet code is not supported by the accounting handler.
+ #[error("RADIUS code {code} is not supported by the accounting handler")]
+ UnsupportedCode {
+ /// Unsupported raw packet code.
+ code: u8,
+ },
+ /// The Accounting-Request authenticator did not match the shared secret.
+ #[error("RADIUS Accounting-Request authenticator does not match the shared secret")]
+ InvalidRequestAuthenticator,
+ /// Message-Authenticator is required by policy but absent from the packet.
+ #[error("RADIUS Message-Authenticator is required but missing")]
+ MissingMessageAuthenticator,
+ /// Message-Authenticator had an invalid value length.
+ #[error("RADIUS Message-Authenticator value is {actual} bytes; expected 16 bytes")]
+ InvalidMessageAuthenticatorLength {
+ /// Actual Message-Authenticator value length.
+ actual: usize,
+ },
+ /// More than one Message-Authenticator attribute was present.
+ #[error("RADIUS packet contains more than one Message-Authenticator attribute")]
+ MultipleMessageAuthenticators,
+ /// The Message-Authenticator HMAC did not match the shared secret.
+ #[error("RADIUS Message-Authenticator does not match the shared secret")]
+ InvalidMessageAuthenticator,
+}
+
+/// Parses the RADIUS packet framing in one UDP datagram.
+///
+/// Extra bytes beyond the declared RADIUS length are ignored as UDP padding.
+/// This function performs no shared-secret verification and does not mutate
+/// process or system state.
+pub fn parse_packet(datagram: &[u8]) -> Result {
+ if datagram.len() < RADIUS_HEADER_LEN {
+ return Err(PacketError::PacketTooShort {
+ actual: datagram.len(),
+ });
+ }
+
+ let declared_len = u16::from_be_bytes([datagram[2], datagram[3]]) as usize;
+ if !(RADIUS_HEADER_LEN..=RADIUS_MAX_PACKET_LEN).contains(&declared_len) {
+ return Err(PacketError::InvalidPacketLength {
+ declared: declared_len,
+ });
+ }
+ if datagram.len() < declared_len {
+ return Err(PacketError::PacketTruncated {
+ declared: declared_len,
+ actual: datagram.len(),
+ });
+ }
+
+ let packet_bytes = &datagram[..declared_len];
+ let mut authenticator = [0_u8; RADIUS_AUTHENTICATOR_LEN];
+ authenticator.copy_from_slice(&packet_bytes[4..RADIUS_HEADER_LEN]);
+ let attributes = parse_attributes(&packet_bytes[RADIUS_HEADER_LEN..])?;
+
+ Ok(RadiusPacket {
+ code: RadiusCode::from(packet_bytes[0]),
+ identifier: packet_bytes[1],
+ authenticator,
+ attributes,
+ })
+}
+
+/// Parses a UDP datagram and returns only RADIUS Accounting-Request packets.
+///
+/// This function performs syntactic packet checks only. It does not verify
+/// authenticators, build Accounting-Response packets, or update session state.
+pub fn handle_accounting_request(datagram: &[u8]) -> Result {
+ let packet = parse_packet(datagram)?;
+ if packet.code() != RadiusCode::AccountingRequest {
+ return Err(PacketError::UnsupportedCode {
+ code: packet.code().as_u8(),
+ });
+ }
+ let declared_len = u16::from_be_bytes([datagram[2], datagram[3]]) as usize;
+ if declared_len > RADIUS_MAX_ACCOUNTING_PACKET_LEN {
+ return Err(PacketError::InvalidAccountingPacketLength {
+ declared: declared_len,
+ });
+ }
+
+ Ok(AccountingRequest { packet })
+}
+
+/// Parses and verifies one RADIUS Accounting-Request datagram.
+///
+/// The caller is responsible for matching the UDP peer to a trusted client and
+/// passing that client's shared secret. Packet framing and attribute bounds are
+/// checked before authenticators are evaluated.
+/// When Message-Authenticator is present, it is verified with the
+/// Accounting-Request vector set to zero before the request authenticator is
+/// verified with the received Message-Authenticator value present.
+///
+/// Side effects: none. This function does not send responses, update session
+/// state, touch files, or modify host networking.
+pub fn verify_accounting_request(
+ datagram: &[u8],
+ shared_secret: &[u8],
+ message_authenticator_policy: MessageAuthenticatorPolicy,
+) -> Result {
+ let request = handle_accounting_request(datagram)?;
+ let message_authenticator_index = message_authenticator_index(request.packet())?;
+
+ if message_authenticator_policy.requires_message_authenticator()
+ && message_authenticator_index.is_none()
+ {
+ return Err(PacketError::MissingMessageAuthenticator);
+ }
+
+ if let Some(index) = message_authenticator_index {
+ verify_message_authenticator(request.packet(), shared_secret, index)?;
+ }
+ verify_request_authenticator(request.packet(), shared_secret)?;
+
+ Ok(VerifiedAccountingRequest {
+ request,
+ has_message_authenticator: message_authenticator_index.is_some(),
+ })
+}
+
+/// Builds a minimal RADIUS Accounting-Response for a verified request.
+///
+/// The response uses code 5, copies the request identifier, preserves any
+/// Proxy-State attributes from the request, and signs the response
+/// authenticator with the shared secret.
+///
+/// Side effects: none. The returned bytes are not sent to the network.
+#[must_use]
+pub fn build_accounting_response(
+ request: &VerifiedAccountingRequest,
+ shared_secret: &[u8],
+) -> Vec {
+ let mut response = Vec::with_capacity(RADIUS_HEADER_LEN + shared_secret.len());
+ response.push(ACCOUNTING_RESPONSE_CODE);
+ response.push(request.packet().identifier());
+ response.extend_from_slice(&[0, 0]);
+ response.extend_from_slice(request.packet().authenticator());
+ encode_proxy_state_attributes(request.packet(), &mut response);
+ let response_len = response.len();
+ response[2..4].copy_from_slice(&(response_len as u16).to_be_bytes());
+ response.extend_from_slice(shared_secret);
+ let authenticator = md5_digest(&response);
+
+ response.truncate(response_len);
+ response[4..RADIUS_HEADER_LEN].copy_from_slice(&authenticator);
+ response
+}
+
+fn parse_attributes(mut attribute_bytes: &[u8]) -> Result, PacketError> {
+ let mut attributes = Vec::new();
+ let mut offset = RADIUS_HEADER_LEN;
+
+ while !attribute_bytes.is_empty() {
+ let (kind, value, remaining) = split_radius_tlv(attribute_bytes)
+ .map_err(|error| packet_error_from_tlv_split(error, offset, attribute_bytes.len()))?;
+ let declared_len = attribute_bytes.len() - remaining.len();
+
+ attributes.push(RadiusAttribute {
+ kind,
+ value: value.to_vec(),
+ });
+ attribute_bytes = remaining;
+ offset += declared_len;
+ }
+
+ Ok(attributes)
+}
+
+pub(crate) fn split_radius_tlv(value: &[u8]) -> Result<(u8, &[u8], &[u8]), RadiusTlvError> {
+ if value.len() < RADIUS_ATTRIBUTE_HEADER_LEN {
+ return Err(RadiusTlvError::HeaderTruncated);
+ }
+
+ let kind = value[0];
+ let declared_len = usize::from(value[1]);
+ if declared_len < RADIUS_MIN_ATTRIBUTE_LEN {
+ return Err(RadiusTlvError::InvalidLength { declared_len });
+ }
+ if declared_len > value.len() {
+ return Err(RadiusTlvError::Truncated { declared_len });
+ }
+
+ Ok((
+ kind,
+ &value[RADIUS_ATTRIBUTE_HEADER_LEN..declared_len],
+ &value[declared_len..],
+ ))
+}
+
+pub(crate) enum RadiusTlvError {
+ HeaderTruncated,
+ InvalidLength { declared_len: usize },
+ Truncated { declared_len: usize },
+}
+
+fn packet_error_from_tlv_split(
+ error: RadiusTlvError,
+ offset: usize,
+ remaining: usize,
+) -> PacketError {
+ match error {
+ RadiusTlvError::HeaderTruncated => PacketError::AttributeHeaderTruncated { offset },
+ RadiusTlvError::InvalidLength { declared_len } => PacketError::InvalidAttributeLength {
+ offset,
+ declared: declared_len,
+ },
+ RadiusTlvError::Truncated { declared_len } => PacketError::AttributeTruncated {
+ offset,
+ declared: declared_len,
+ remaining,
+ },
+ }
+}
+
+fn verify_request_authenticator(
+ packet: &RadiusPacket,
+ shared_secret: &[u8],
+) -> Result<(), PacketError> {
+ let expected = expected_request_authenticator(packet, shared_secret);
+
+ if constant_time_eq(packet.authenticator(), &expected) {
+ Ok(())
+ } else {
+ Err(PacketError::InvalidRequestAuthenticator)
+ }
+}
+
+fn verify_message_authenticator(
+ packet: &RadiusPacket,
+ shared_secret: &[u8],
+ message_authenticator_index: usize,
+) -> Result<(), PacketError> {
+ let expected =
+ expected_message_authenticator(packet, shared_secret, message_authenticator_index);
+ let actual = packet.attributes()[message_authenticator_index].value();
+
+ if constant_time_eq(actual, &expected) {
+ Ok(())
+ } else {
+ Err(PacketError::InvalidMessageAuthenticator)
+ }
+}
+
+pub(crate) fn message_authenticator_index(
+ packet: &RadiusPacket,
+) -> Result