Merge pull request #1132 from LibreQoE/feat/radius_accounting

Add RADIUS accounting dynamic circuits
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-07-21 15:02:57 -06:00
commit e1d05dbd17
72 files changed
+19327 -68

No files matched your search

+1 -1
View File
@@ -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'
+2 -1
View File
@@ -1,3 +1,4 @@
sphinx==6.2.1
sphinx_rtd_theme
myst-parser
myst-parser
sphinxcontrib-mermaid
+59
View File
@@ -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).
+191
View File
@@ -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<br>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.
+59
View File
@@ -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).
+184
View File
@@ -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<br>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.
+3
View File
@@ -0,0 +1,3 @@
/artifacts/
/images/
/run/
+78
View File
@@ -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.
```
+138
View File
@@ -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 <command>
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
+18
View File
@@ -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
+68
View File
@@ -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"
}
+60
View File
@@ -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"
+59
View File
@@ -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"
@@ -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
1 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
2 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
@@ -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}
@@ -0,0 +1,4 @@
client radius-lqos-nat {
ipaddr = 198.18.10.10
secret = __RADIUS_SHARED_SECRET__
}
+12
View File
@@ -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
@@ -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]}
+106
View File
@@ -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"
@@ -0,0 +1,5 @@
<network>
<name>radius-access</name>
<bridge name='vbr-raccess'/>
<forward mode='none'/>
</network>
@@ -0,0 +1,5 @@
<network>
<name>radius-control</name>
<bridge name='vbr-rcontrol'/>
<forward mode='none'/>
</network>
@@ -0,0 +1,5 @@
<network>
<name>radius-transit</name>
<bridge name='vbr-rtransit'/>
<forward mode='none'/>
</network>
+16
View File
@@ -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"
}
}
}
}
@@ -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]}
+8
View File
@@ -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
+12
View File
@@ -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
+25
View File
@@ -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"
+3
View File
@@ -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"] }
+6 -3
View File
@@ -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<ArcSwap<Option<Arc<Config>>>> = Lazy::new(|| ArcSwap::from_pointee(None));
+6
View File
@@ -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::{
File diff suppressed because it is too large. Load diff
@@ -181,6 +181,12 @@ pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_circuits: Option<super::dynamic_circuits::DynamicCircuitsConfig>,
/// 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<super::radius_accounting::RadiusAccountingConfig>,
/// Network flows configuration
pub flows: Option<super::flows::FlowConfig>,
@@ -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(),
+6 -4
View File
@@ -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,
@@ -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::*;
@@ -40,8 +40,7 @@ pub struct ShapedDevice {
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_node_id: Option<String>,
/// 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![
+15
View File
@@ -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 }
@@ -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<AcctStatusType>,
/// Acct-Session-Id when present.
pub acct_session_id: Option<String>,
/// NAS-IP-Address when present.
pub nas_ip_address: Option<Ipv4Addr>,
/// NAS-IPv6-Address when present.
pub nas_ipv6_address: Option<Ipv6Addr>,
/// NAS-Identifier when present.
pub nas_identifier: Option<String>,
/// NAS-Port when present.
pub nas_port: Option<u32>,
/// NAS-Port-Id when present.
pub nas_port_id: Option<String>,
/// User-Name when present.
pub user_name: Option<String>,
/// Calling-Station-Id when present.
pub calling_station_id: Option<String>,
/// Called-Station-Id when present.
pub called_station_id: Option<String>,
/// Class attributes when present. RADIUS permits more than one Class value.
pub class: Vec<Vec<u8>>,
/// Event-Timestamp as Unix seconds when present.
pub event_timestamp: Option<u32>,
/// Framed-IP-Address when present.
pub framed_ip_address: Option<Ipv4Addr>,
/// Framed-IP-Netmask when present.
pub framed_ip_netmask: Option<Ipv4Addr>,
/// Framed-Route values when present.
pub framed_routes: Vec<String>,
/// Framed-IPv6-Prefix values when present.
pub framed_ipv6_prefixes: Vec<Ipv6Prefix>,
/// Delegated-IPv6-Prefix values when present.
pub delegated_ipv6_prefixes: Vec<Ipv6Prefix>,
/// Framed-IPv6-Address when present.
pub framed_ipv6_address: Option<Ipv6Addr>,
/// Standard attributes this extractor does not expose as typed fields or could not decode.
pub unknown_standard_attributes: Vec<RadiusAttribute>,
/// Vendor-Specific attributes this extractor does not understand or cannot decode.
pub unknown_vendor_attributes: Vec<UnknownVendorAttribute>,
/// Decoded MikroTik-Rate-Limit attributes when present.
pub mikrotik_rate_limits: Vec<MikrotikRateLimit>,
}
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<u32> 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<AcctStatusType> 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<u32>,
/// Vendor type when the vendor payload had a complete subattribute header.
pub vendor_type: Option<u8>,
/// Vendor attribute value, or the remaining malformed bytes when no value could be split out.
pub value: Vec<u8>,
}
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<Self> {
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<T>(slot: &mut Option<T>, value: Option<T>) {
if slot.is_none() {
*slot = value;
}
}
fn set_optional<T>(
unknown_standard_attributes: &mut Vec<RadiusAttribute>,
slot: &mut Option<T>,
attribute: &RadiusAttribute,
decode: impl FnOnce(&[u8]) -> Option<T>,
) {
let decoded = decode(attribute.value());
if decoded.is_none() {
unknown_standard_attributes.push(attribute.clone());
}
set_once(slot, decoded);
}
fn push_optional<T>(
unknown_standard_attributes: &mut Vec<RadiusAttribute>,
items: &mut Vec<T>,
attribute: &RadiusAttribute,
decode: impl FnOnce(&[u8]) -> Option<T>,
) {
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<Ipv4Addr> {
let bytes: [u8; 4] = value.try_into().ok()?;
Some(Ipv4Addr::from(bytes))
}
fn ipv6(value: &[u8]) -> Option<Ipv6Addr> {
let bytes: [u8; 16] = value.try_into().ok()?;
Some(Ipv6Addr::from(bytes))
}
fn ipv6_prefix(value: &[u8]) -> Option<Ipv6Prefix> {
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<u8> {
let remainder = prefix_len % 8;
if remainder == 0 {
None
} else {
Some(u8::MAX << (8 - remainder))
}
}
fn u32_value(value: &[u8]) -> Option<u32> {
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<u64> {
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::<u64>().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;
@@ -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<u8>]) -> 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<u8> {
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<u8> {
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<u8> {
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
}
@@ -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;
@@ -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),
}
+48
View File
@@ -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,
};
+511
View File
@@ -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<SocketAddr, ListenerError> {
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<ReceivedAccountingPacket, ListenerError> {
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<AccountingListenerOutcome, ListenerError> {
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<Self, TrustedClientSourceError> {
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<Self, Self::Err> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(TrustedClientSourceError::Empty);
}
let Some((address, prefix_len)) = trimmed.split_once('/') else {
return trimmed.parse::<IpAddr>().map(Self::host).map_err(|_| {
TrustedClientSourceError::InvalidAddress {
raw: trimmed.to_string(),
}
});
};
let address =
address
.parse::<IpAddr>()
.map_err(|_| TrustedClientSourceError::InvalidAddress {
raw: address.to_string(),
})?;
let prefix_len = prefix_len.parse::<u8>().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<TrustedClientSource>,
shared_secret: Vec<u8>,
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<TrustedClientSource>,
shared_secret: impl Into<Vec<u8>>,
) -> Result<Self, TrustedRadiusClientError> {
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<TrustedClientSource>,
shared_secret: impl Into<Vec<u8>>,
message_authenticator_policy: MessageAuthenticatorPolicy,
) -> Result<Self, TrustedRadiusClientError> {
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, TrustedClientSourceError> {
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<RadiusListener, ListenerError> {
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;
+550
View File
@@ -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::<IpAddr>().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::<TrustedClientSource>().unwrap(),
trusted_loopback_source()
);
assert_eq!(
"2001:db8::/64".parse::<TrustedClientSource>().unwrap(),
TrustedClientSource::network("2001:db8::".parse().unwrap(), 64).unwrap()
);
assert_eq!(
"192.0.2.1/33".parse::<TrustedClientSource>(),
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::<TrustedClientSource>(),
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::<TrustedClientSource>(),
Err(TrustedClientSourceError::Empty)
);
assert_eq!(
"not-an-address".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::InvalidAddress {
raw: "not-an-address".to_string(),
})
);
assert_eq!(
"192.0.2.1/nope".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::InvalidPrefixLengthText {
raw: "nope".to_string(),
})
);
assert_eq!(
"192.0.2.129/24".parse::<TrustedClientSource>(),
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<ReceivedAccountingPacket, ListenerError> {
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<u8> {
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<TrustedRadiusClient> {
trusted_loopback_clients_with_policy(shared_secret, MessageAuthenticatorPolicy::Optional)
}
fn trusted_loopback_clients_with_policy(
shared_secret: &[u8],
policy: MessageAuthenticatorPolicy,
) -> Vec<TrustedRadiusClient> {
vec![
TrustedRadiusClient::with_message_authenticator_policy(
vec![trusted_loopback_source()],
shared_secret,
policy,
)
.unwrap(),
]
}
fn alternate_loopback_clients() -> Vec<TrustedRadiusClient> {
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(), &[]);
}
+287
View File
@@ -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<String, ShapedDevicesMacEntry>,
matches_by_username: HashMap<String, ShapedDevicesMacEntry>,
}
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<String, ShapedDevicesMacEntry>,
key: &str,
) -> Option<ShapedDevicesMacMatch> {
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<ShapedDevice>),
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<ShapedDevice>),
/// 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<String> {
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<String> {
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()
}
}
}
+206
View File
@@ -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<TrustedClientSource>,
#[arg(
long,
value_name = "SECRET",
requires = "client_sources",
value_parser = clap::builder::NonEmptyStringValueParser::new()
)]
shared_secret: Option<String>,
}
#[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<TrustedRadiusClient>),
}
#[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()
);
}
}
+640
View File
@@ -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<Md5>;
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<u8> 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<RadiusCode> 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<u8>,
}
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<RadiusAttribute>,
}
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<u8> {
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<RadiusPacket, PacketError> {
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<AccountingRequest, PacketError> {
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<VerifiedAccountingRequest, PacketError> {
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<u8> {
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<Vec<RadiusAttribute>, 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<Option<usize>, PacketError> {
let mut found = None;
for (index, attribute) in packet.attributes().iter().enumerate() {
if attribute.kind() != MESSAGE_AUTHENTICATOR_TYPE {
continue;
}
if attribute.value().len() != MESSAGE_AUTHENTICATOR_VALUE_LEN {
return Err(PacketError::InvalidMessageAuthenticatorLength {
actual: attribute.value().len(),
});
}
if found.replace(index).is_some() {
return Err(PacketError::MultipleMessageAuthenticators);
}
}
Ok(found)
}
pub(crate) fn expected_request_authenticator(
packet: &RadiusPacket,
shared_secret: &[u8],
) -> [u8; RADIUS_AUTHENTICATOR_LEN] {
let mut signed_packet = encode_packet(packet, [0_u8; RADIUS_AUTHENTICATOR_LEN], None);
signed_packet.extend_from_slice(shared_secret);
md5_digest(&signed_packet)
}
pub(crate) fn expected_message_authenticator(
packet: &RadiusPacket,
shared_secret: &[u8],
message_authenticator_index: usize,
) -> [u8; MESSAGE_AUTHENTICATOR_VALUE_LEN] {
let signed_packet = encode_packet(
packet,
[0_u8; RADIUS_AUTHENTICATOR_LEN],
Some(message_authenticator_index),
);
hmac_md5(shared_secret, &signed_packet)
}
fn encode_packet(
packet: &RadiusPacket,
authenticator: [u8; RADIUS_AUTHENTICATOR_LEN],
zero_message_authenticator_index: Option<usize>,
) -> Vec<u8> {
let packet_len = RADIUS_HEADER_LEN
+ packet
.attributes()
.iter()
.map(encoded_attribute_len)
.sum::<usize>();
let mut encoded = Vec::with_capacity(packet_len);
encoded.push(packet.code().as_u8());
encoded.push(packet.identifier());
encoded.extend_from_slice(&(packet_len as u16).to_be_bytes());
encoded.extend_from_slice(&authenticator);
for (index, attribute) in packet.attributes().iter().enumerate() {
if Some(index) == zero_message_authenticator_index {
encode_attribute_value(
attribute.kind(),
&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN],
&mut encoded,
);
} else {
encode_attribute(attribute, &mut encoded);
}
}
encoded
}
fn encode_proxy_state_attributes(packet: &RadiusPacket, encoded: &mut Vec<u8>) {
for attribute in packet.attributes() {
if attribute.kind() != PROXY_STATE_TYPE {
continue;
}
encode_attribute(attribute, encoded);
}
}
fn encode_attribute(attribute: &RadiusAttribute, encoded: &mut Vec<u8>) {
encode_attribute_value(attribute.kind(), attribute.value(), encoded);
}
#[cfg(test)]
pub(crate) fn encode_radius_tlv(kind: u8, value: &[u8]) -> Vec<u8> {
let encoded_len = RADIUS_ATTRIBUTE_HEADER_LEN + value.len();
assert!(encoded_len <= u8::MAX as usize);
let mut encoded = Vec::with_capacity(encoded_len);
encode_attribute_value(kind, value, &mut encoded);
encoded
}
fn encode_attribute_value(kind: u8, value: &[u8], encoded: &mut Vec<u8>) {
encoded.push(kind);
encoded.push((RADIUS_ATTRIBUTE_HEADER_LEN + value.len()) as u8);
encoded.extend_from_slice(value);
}
fn encoded_attribute_len(attribute: &RadiusAttribute) -> usize {
RADIUS_ATTRIBUTE_HEADER_LEN + attribute.value().len()
}
pub(crate) fn md5_digest(bytes: &[u8]) -> [u8; RADIUS_AUTHENTICATOR_LEN] {
let mut hasher = Md5::new();
hasher.update(bytes);
hasher.finalize().into()
}
pub(crate) fn hmac_md5(key: &[u8], bytes: &[u8]) -> [u8; MESSAGE_AUTHENTICATOR_VALUE_LEN] {
let mut mac = HmacMd5::new_from_slice(key).expect("HMAC-MD5 accepts any key length");
mac.update(bytes);
mac.finalize().into_bytes().into()
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
left.ct_eq(right).into()
}
#[cfg(test)]
mod tests;
+580
View File
@@ -0,0 +1,580 @@
//! Packet tests for RADIUS accounting framing and authentication.
use super::*;
use crate::attribute_type::ACCT_STATUS_TYPE;
use crate::test_support::{
SHARED_SECRET, accounting_request_packet, accounting_request_packet_with_message_authenticator,
accounting_request_packet_with_message_authenticator_at,
accounting_request_packet_with_total_len, max_sized_accounting_request_packet, radius_packet,
signed_accounting_request_packet, signed_radius_packet,
};
const ACCOUNTING_START_ATTRIBUTE: [u8; 6] = [ACCT_STATUS_TYPE, 6, 0, 0, 0, 1];
const FIXED_ACCOUNTING_REQUEST: [u8; 26] = [
4, 7, 0, 26, 234, 109, 208, 193, 96, 89, 23, 174, 213, 177, 203, 9, 123, 217, 127, 22, 40, 6,
0, 0, 0, 1,
];
const FIXED_ACCOUNTING_RESPONSE: [u8; 20] = [
5, 7, 0, 20, 35, 106, 121, 65, 4, 237, 106, 10, 15, 20, 216, 122, 222, 158, 144, 160,
];
const FIXED_PROXY_ACCOUNTING_REQUEST: [u8; 33] = [
4, 9, 0, 33, 96, 121, 47, 229, 56, 17, 23, 167, 140, 77, 87, 100, 57, 14, 218, 128, 40, 6, 0,
0, 0, 1, 33, 7, 112, 114, 111, 120, 121,
];
const FIXED_PROXY_ACCOUNTING_RESPONSE: [u8; 27] = [
5, 9, 0, 27, 125, 39, 136, 19, 21, 211, 192, 196, 215, 106, 80, 129, 89, 194, 251, 134, 33, 7,
112, 114, 111, 120, 121,
];
// Matches FreeRADIUS v3.2.x Accounting-Request handling: HMAC with the request
// vector zeroed, then Accounting MD5 with the Message-Authenticator value present.
const FIXED_MESSAGE_AUTHENTICATOR_REQUEST: [u8; 44] = [
4, 11, 0, 44, 17, 225, 44, 198, 149, 240, 255, 183, 213, 29, 93, 75, 88, 244, 33, 62, 40, 6, 0,
0, 0, 1, 80, 18, 190, 213, 77, 117, 156, 25, 155, 188, 192, 113, 189, 123, 238, 165, 229, 7,
];
const FIXED_MESSAGE_AUTHENTICATOR_RESPONSE: [u8; 20] = [
5, 11, 0, 20, 120, 245, 78, 218, 247, 24, 76, 17, 208, 88, 100, 153, 63, 124, 107, 242,
];
const FIXED_MULTI_PROXY_ACCOUNTING_REQUEST: [u8; 39] = [
4, 12, 0, 39, 158, 213, 142, 15, 167, 123, 197, 221, 162, 68, 20, 166, 78, 6, 157, 152, 40, 6,
0, 0, 0, 1, 33, 5, 111, 110, 101, 241, 3, 9, 33, 5, 116, 119, 111,
];
const FIXED_MULTI_PROXY_ACCOUNTING_RESPONSE: [u8; 30] = [
5, 12, 0, 30, 178, 201, 10, 40, 135, 29, 40, 136, 69, 8, 142, 127, 30, 126, 130, 165, 33, 5,
111, 110, 101, 33, 5, 116, 119, 111,
];
const RAW_INVALID_PACKET_LENGTH_FIXTURE: [u8; 20] = [
ACCOUNTING_REQUEST_CODE,
13,
0,
(RADIUS_HEADER_LEN - 1) as u8,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
];
const UNKNOWN_ATTRIBUTE_TYPE: u8 = 241;
#[test]
fn parses_accounting_request_with_attributes() {
let request =
handle_accounting_request(&accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE))
.unwrap();
let packet = request.packet();
assert_eq!(packet.code(), RadiusCode::AccountingRequest);
assert_eq!(packet.identifier(), 7);
assert_eq!(packet.authenticator(), &[1_u8; RADIUS_AUTHENTICATOR_LEN]);
assert_eq!(packet.attributes().len(), 1);
assert_eq!(packet.attributes()[0].kind(), ACCT_STATUS_TYPE);
assert_eq!(packet.attributes()[0].value(), &[0, 0, 0, 1]);
}
#[test]
fn parses_minimal_accounting_request_raw_fixture() {
let datagram = [
ACCOUNTING_REQUEST_CODE,
42,
0,
RADIUS_HEADER_LEN as u8,
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
];
let parsed = parse_packet(&datagram).unwrap();
assert_eq!(parsed.code(), RadiusCode::AccountingRequest);
assert_eq!(parsed.identifier(), 42);
assert_eq!(
parsed.authenticator(),
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
);
assert_eq!(parsed.attributes(), &[]);
}
#[test]
fn rejects_short_packet_header() {
assert_eq!(
parse_packet(&[ACCOUNTING_REQUEST_CODE, 1]),
Err(PacketError::PacketTooShort { actual: 2 })
);
}
#[test]
fn rejects_declared_packet_length_below_header() {
assert_eq!(
parse_packet(&RAW_INVALID_PACKET_LENGTH_FIXTURE),
Err(PacketError::InvalidPacketLength {
declared: RADIUS_HEADER_LEN - 1,
})
);
}
#[test]
fn rejects_declared_packet_length_above_maximum() {
assert_eq!(
parse_packet(&packet_with_declared_len(
(RADIUS_MAX_PACKET_LEN + 1) as u16
)),
Err(PacketError::InvalidPacketLength {
declared: RADIUS_MAX_PACKET_LEN + 1,
})
);
}
#[test]
fn parses_declared_packet_length_at_radius_maximum() {
let packet = accounting_request_packet_with_total_len(7, RADIUS_MAX_PACKET_LEN);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(packet.len(), RADIUS_MAX_PACKET_LEN);
assert_eq!(parsed.attributes().len(), 16);
assert_eq!(parsed.attributes()[15].kind(), 16);
assert_eq!(parsed.attributes()[15].value().len(), 249);
}
#[test]
fn parses_generated_packet_length_that_would_leave_one_byte_chunk() {
let packet = accounting_request_packet_with_total_len(7, RADIUS_HEADER_LEN + 256);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(packet.len(), RADIUS_HEADER_LEN + 256);
assert_eq!(parsed.attributes().len(), 2);
assert_eq!(parsed.attributes()[0].value().len(), 252);
assert_eq!(parsed.attributes()[1].value().len(), 0);
}
#[test]
fn accounting_handler_accepts_declared_packet_length_at_accounting_maximum() {
let packet = max_sized_accounting_request_packet(7);
let request = handle_accounting_request(&packet).unwrap();
assert_eq!(packet.len(), RADIUS_MAX_ACCOUNTING_PACKET_LEN);
assert_eq!(request.packet().attributes().len(), 16);
assert_eq!(request.packet().attributes()[15].kind(), 16);
assert_eq!(request.packet().attributes()[15].value().len(), 248);
}
#[test]
fn accounting_handler_rejects_declared_packet_length_above_accounting_maximum() {
let packet = accounting_request_packet_with_total_len(7, RADIUS_MAX_PACKET_LEN);
assert_eq!(
handle_accounting_request(&packet),
Err(PacketError::InvalidAccountingPacketLength {
declared: RADIUS_MAX_PACKET_LEN,
})
);
}
#[test]
fn rejects_truncated_declared_packet_length() {
assert_eq!(
parse_packet(&packet_with_declared_len((RADIUS_HEADER_LEN + 1) as u16)),
Err(PacketError::PacketTruncated {
declared: RADIUS_HEADER_LEN + 1,
actual: RADIUS_HEADER_LEN,
})
);
}
#[test]
fn parses_empty_attribute_value_and_continues() {
let mut attributes = vec![UNKNOWN_ATTRIBUTE_TYPE, 2];
attributes.extend_from_slice(&ACCOUNTING_START_ATTRIBUTE);
let packet = accounting_request_packet(7, &attributes);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(parsed.attributes().len(), 2);
assert_eq!(parsed.attributes()[0].kind(), UNKNOWN_ATTRIBUTE_TYPE);
assert_eq!(parsed.attributes()[0].value(), &[] as &[u8]);
assert_eq!(parsed.attributes()[1].kind(), ACCT_STATUS_TYPE);
assert_eq!(parsed.attributes()[1].value(), &[0, 0, 0, 1]);
}
#[test]
fn rejects_truncated_attribute_header() {
let packet = accounting_request_packet(7, &[ACCT_STATUS_TYPE]);
assert_eq!(
parse_packet(&packet),
Err(PacketError::AttributeHeaderTruncated {
offset: RADIUS_HEADER_LEN,
})
);
}
#[test]
fn rejects_attribute_length_smaller_than_header() {
let packet = accounting_request_packet(7, &[ACCT_STATUS_TYPE, 1]);
assert_eq!(
parse_packet(&packet),
Err(PacketError::InvalidAttributeLength {
offset: RADIUS_HEADER_LEN,
declared: 1,
})
);
}
#[test]
fn rejects_attribute_length_overrun() {
let mut packet = vec![ACCOUNTING_REQUEST_CODE, 7, 0, 24];
packet.extend_from_slice(&[1_u8; RADIUS_AUTHENTICATOR_LEN]);
packet.extend_from_slice(&[ACCT_STATUS_TYPE, 6, 0, 0]);
assert_eq!(
parse_packet(&packet),
Err(PacketError::AttributeTruncated {
offset: RADIUS_HEADER_LEN,
declared: 6,
remaining: 4,
})
);
}
#[test]
fn ignores_padding_after_declared_length() {
let mut packet = accounting_request_packet(7, &[]);
packet.extend_from_slice(&[ACCT_STATUS_TYPE, 1]);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(parsed.attributes(), &[]);
}
#[test]
fn accounting_handler_rejects_non_accounting_code() {
let packet = radius_packet(RadiusCode::AccessRequest, 7, &ACCOUNTING_START_ATTRIBUTE);
assert_eq!(
handle_accounting_request(&packet),
Err(PacketError::UnsupportedCode { code: 1 })
);
}
#[test]
fn verifies_fixed_accounting_request_authenticator() {
let verified = verify_optional(&FIXED_ACCOUNTING_REQUEST).unwrap();
assert_eq!(verified.packet().identifier(), 7);
assert!(!verified.has_message_authenticator());
assert_eq!(verified.packet().attributes()[0].kind(), ACCT_STATUS_TYPE);
assert_eq!(verified.packet().attributes()[0].value(), &[0, 0, 0, 1]);
}
#[test]
fn rejects_accounting_request_with_wrong_secret() {
assert_eq!(
verify_accounting_request(
&FIXED_ACCOUNTING_REQUEST,
b"wrong-secret",
MessageAuthenticatorPolicy::Optional
),
Err(PacketError::InvalidRequestAuthenticator)
);
}
#[test]
fn rejects_accounting_request_with_modified_attributes() {
let mut packet =
signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
let final_attribute_byte = packet.len() - 1;
packet[final_attribute_byte] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidRequestAuthenticator)
);
}
#[test]
fn verification_rejects_unsupported_code() {
let packet = signed_radius_packet(
RadiusCode::AccessRequest,
7,
&ACCOUNTING_START_ATTRIBUTE,
SHARED_SECRET,
);
assert_eq!(
verify_optional(&packet),
Err(PacketError::UnsupportedCode { code: 1 })
);
}
#[test]
fn verification_rejects_truncated_packet_before_authentication() {
let mut packet =
signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
packet.truncate(RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len() - 1);
assert_eq!(
verify_optional(&packet),
Err(PacketError::PacketTruncated {
declared: RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len(),
actual: RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len() - 1,
})
);
}
#[test]
fn verifies_fixed_message_authenticator_when_optional() {
let verified = verify_optional(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST).unwrap();
assert_eq!(verified.packet().identifier(), 11);
assert!(verified.has_message_authenticator());
}
#[test]
fn verifies_fixed_message_authenticator_when_required() {
let verified = verify_required(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST).unwrap();
assert_eq!(verified.packet().identifier(), 11);
assert!(verified.has_message_authenticator());
}
#[test]
fn fixed_message_authenticator_uses_accounting_interop_order() {
let value_start =
RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len() + RADIUS_ATTRIBUTE_HEADER_LEN;
let value_end = value_start + MESSAGE_AUTHENTICATOR_VALUE_LEN;
let mut received_message_authenticator = [0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN];
received_message_authenticator
.copy_from_slice(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST[value_start..value_end]);
let mut received_request_authenticator = [0_u8; RADIUS_AUTHENTICATOR_LEN];
received_request_authenticator
.copy_from_slice(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST[4..RADIUS_HEADER_LEN]);
let mut hmac_input = FIXED_MESSAGE_AUTHENTICATOR_REQUEST.to_vec();
hmac_input[4..RADIUS_HEADER_LEN].fill(0);
hmac_input[value_start..value_end].fill(0);
assert_eq!(
hmac_md5(SHARED_SECRET, &hmac_input),
received_message_authenticator
);
let mut md5_input = FIXED_MESSAGE_AUTHENTICATOR_REQUEST.to_vec();
md5_input[4..RADIUS_HEADER_LEN].fill(0);
md5_input.extend_from_slice(SHARED_SECRET);
assert_eq!(md5_digest(&md5_input), received_request_authenticator);
let mut old_md5_input = FIXED_MESSAGE_AUTHENTICATOR_REQUEST.to_vec();
old_md5_input[4..RADIUS_HEADER_LEN].fill(0);
old_md5_input[value_start..value_end].fill(0);
old_md5_input.extend_from_slice(SHARED_SECRET);
assert_ne!(md5_digest(&old_md5_input), received_request_authenticator);
}
#[test]
fn rejects_message_authenticator_packet_with_invalid_request_authenticator() {
let mut packet = FIXED_MESSAGE_AUTHENTICATOR_REQUEST;
packet[4] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidRequestAuthenticator)
);
}
#[test]
fn verifies_message_authenticator_before_other_attributes() {
let mut attributes = vec![
MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8,
];
attributes.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
attributes.extend_from_slice(&ACCOUNTING_START_ATTRIBUTE);
let packet = accounting_request_packet_with_message_authenticator_at(
7,
&attributes,
RADIUS_ATTRIBUTE_HEADER_LEN,
SHARED_SECRET,
);
let verified = verify_required(&packet).unwrap();
assert_eq!(verified.packet().attributes().len(), 2);
assert_eq!(verified.packet().attributes()[1].kind(), ACCT_STATUS_TYPE);
}
#[test]
fn rejects_invalid_message_authenticator() {
let mut packet = accounting_request_packet_with_message_authenticator(
7,
&ACCOUNTING_START_ATTRIBUTE,
SHARED_SECRET,
);
let message_authenticator_value = packet.len() - MESSAGE_AUTHENTICATOR_VALUE_LEN;
packet[message_authenticator_value] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidMessageAuthenticator)
);
}
#[test]
fn rejects_modified_accounting_attribute_with_message_authenticator() {
let mut packet = FIXED_MESSAGE_AUTHENTICATOR_REQUEST;
let accounting_status_value = RADIUS_HEADER_LEN + RADIUS_ATTRIBUTE_HEADER_LEN + 3;
packet[accounting_status_value] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidMessageAuthenticator)
);
}
#[test]
fn rejects_multiple_message_authenticators() {
let mut attributes = ACCOUNTING_START_ATTRIBUTE.to_vec();
attributes.extend_from_slice(&[
MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8,
]);
attributes.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
attributes.extend_from_slice(&[
MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8,
]);
attributes.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
let packet = signed_accounting_request_packet(7, &attributes, SHARED_SECRET);
assert_eq!(
verify_optional(&packet),
Err(PacketError::MultipleMessageAuthenticators)
);
}
#[test]
fn rejects_missing_message_authenticator_when_required() {
let packet = signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
assert_eq!(
verify_required(&packet),
Err(PacketError::MissingMessageAuthenticator)
);
}
#[test]
fn rejects_malformed_message_authenticator_length() {
let mut attributes = ACCOUNTING_START_ATTRIBUTE.to_vec();
attributes.extend_from_slice(&[MESSAGE_AUTHENTICATOR_TYPE, 3, 1]);
let packet = signed_accounting_request_packet(7, &attributes, SHARED_SECRET);
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidMessageAuthenticatorLength { actual: 1 })
);
}
#[test]
fn builds_accounting_response_code_5() {
let packet = signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
let verified = verify_optional(&packet).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response.len(), RADIUS_HEADER_LEN);
assert_eq!(response[0], RadiusCode::AccountingResponse.as_u8());
assert_eq!(u16::from_be_bytes([response[2], response[3]]), 20);
assert_eq!(parsed_response.code(), RadiusCode::AccountingResponse);
assert_eq!(parsed_response.identifier(), 7);
assert_eq!(parsed_response.attributes(), &[]);
}
#[test]
fn builds_fixed_accounting_response_fixture() {
let verified = verify_optional(&FIXED_ACCOUNTING_REQUEST).unwrap();
assert_eq!(
build_accounting_response(&verified, SHARED_SECRET),
FIXED_ACCOUNTING_RESPONSE
);
}
#[test]
fn builds_accounting_response_for_message_authenticator_request() {
let verified = verify_required(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response.len(), RADIUS_HEADER_LEN);
assert_eq!(response, FIXED_MESSAGE_AUTHENTICATOR_RESPONSE);
assert_eq!(parsed_response.code(), RadiusCode::AccountingResponse);
assert_eq!(parsed_response.identifier(), 11);
assert_eq!(parsed_response.attributes(), &[]);
}
#[test]
fn accounting_response_preserves_proxy_state_attributes() {
let verified = verify_optional(&FIXED_PROXY_ACCOUNTING_REQUEST).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response, FIXED_PROXY_ACCOUNTING_RESPONSE);
assert_eq!(parsed_response.attributes().len(), 1);
assert_eq!(parsed_response.attributes()[0].kind(), PROXY_STATE_TYPE);
assert_eq!(parsed_response.attributes()[0].value(), b"proxy");
}
#[test]
fn accounting_response_preserves_all_proxy_state_attributes_only() {
let verified = verify_optional(&FIXED_MULTI_PROXY_ACCOUNTING_REQUEST).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response, FIXED_MULTI_PROXY_ACCOUNTING_RESPONSE);
assert_eq!(parsed_response.attributes().len(), 2);
assert_eq!(parsed_response.attributes()[0].kind(), PROXY_STATE_TYPE);
assert_eq!(parsed_response.attributes()[0].value(), b"one");
assert_eq!(parsed_response.attributes()[1].kind(), PROXY_STATE_TYPE);
assert_eq!(parsed_response.attributes()[1].value(), b"two");
}
fn verify_optional(datagram: &[u8]) -> Result<VerifiedAccountingRequest, PacketError> {
verify_accounting_request(
datagram,
SHARED_SECRET,
MessageAuthenticatorPolicy::Optional,
)
}
fn verify_required(datagram: &[u8]) -> Result<VerifiedAccountingRequest, PacketError> {
verify_accounting_request(
datagram,
SHARED_SECRET,
MessageAuthenticatorPolicy::Required,
)
}
fn packet_with_declared_len(declared_len: u16) -> Vec<u8> {
let mut packet = vec![ACCOUNTING_REQUEST_CODE, 1];
packet.extend_from_slice(&declared_len.to_be_bytes());
packet.extend_from_slice(&[1_u8; RADIUS_AUTHENTICATOR_LEN]);
packet
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+188
View File
@@ -0,0 +1,188 @@
//! Test helpers for constructing minimal RADIUS packets.
use crate::RadiusCode;
use crate::attribute_type::VENDOR_SPECIFIC;
use crate::packet::{
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN, MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_VALUE_LEN, RADIUS_ATTRIBUTE_HEADER_LEN, RADIUS_AUTHENTICATOR_LEN,
RADIUS_HEADER_LEN, RADIUS_MAX_ACCOUNTING_PACKET_LEN, RADIUS_MAX_PACKET_LEN, encode_radius_tlv,
expected_message_authenticator, expected_request_authenticator, message_authenticator_index,
parse_packet,
};
pub(crate) const SHARED_SECRET: &[u8] = b"radius-secret";
pub(crate) fn accounting_request_packet(identifier: u8, attributes: &[u8]) -> Vec<u8> {
radius_packet(RadiusCode::AccountingRequest, identifier, attributes)
}
pub(crate) fn radius_attributes(attributes: &[Vec<u8>]) -> Vec<u8> {
attributes.concat()
}
pub(crate) fn radius_attribute(kind: u8, value: &[u8]) -> Vec<u8> {
encode_radius_tlv(kind, value)
}
pub(crate) fn radius_text_attribute(kind: u8, value: &str) -> Vec<u8> {
radius_attribute(kind, value.as_bytes())
}
pub(crate) fn radius_u32_attribute(kind: u8, value: u32) -> Vec<u8> {
radius_attribute(kind, &value.to_be_bytes())
}
pub(crate) fn radius_vendor_attribute(vendor_id: u32, vendor_type: u8, value: &[u8]) -> Vec<u8> {
let subattribute = radius_vendor_subattribute(vendor_type, value);
radius_vendor_attributes(vendor_id, &[subattribute])
}
pub(crate) fn radius_vendor_attributes(vendor_id: u32, subattributes: &[Vec<u8>]) -> Vec<u8> {
let vendor_payload_len = subattributes.iter().map(Vec::len).sum::<usize>();
let mut vendor_value = Vec::with_capacity(4 + vendor_payload_len);
vendor_value.extend_from_slice(&vendor_id.to_be_bytes());
for subattribute in subattributes {
vendor_value.extend_from_slice(subattribute);
}
radius_attribute(VENDOR_SPECIFIC, &vendor_value)
}
pub(crate) fn radius_vendor_subattribute(vendor_type: u8, value: &[u8]) -> Vec<u8> {
radius_attribute(vendor_type, value)
}
pub(crate) fn radius_raw_vendor_attribute(vendor_id: u32, value: &[u8]) -> Vec<u8> {
let mut vendor_value = Vec::with_capacity(4 + value.len());
vendor_value.extend_from_slice(&vendor_id.to_be_bytes());
vendor_value.extend_from_slice(value);
radius_attribute(VENDOR_SPECIFIC, &vendor_value)
}
pub(crate) fn max_sized_accounting_request_packet(identifier: u8) -> Vec<u8> {
accounting_request_packet_with_total_len(identifier, RADIUS_MAX_ACCOUNTING_PACKET_LEN)
}
pub(crate) fn accounting_request_packet_with_total_len(
identifier: u8,
packet_len: usize,
) -> Vec<u8> {
assert!(
(RADIUS_HEADER_LEN + RADIUS_ATTRIBUTE_HEADER_LEN..=RADIUS_MAX_PACKET_LEN)
.contains(&packet_len)
);
let mut remaining_attribute_bytes = packet_len - RADIUS_HEADER_LEN;
let mut attributes = Vec::with_capacity(remaining_attribute_bytes);
let mut kind = 1_u8;
while remaining_attribute_bytes > u8::MAX as usize {
let attribute_len = if remaining_attribute_bytes - u8::MAX as usize == 1 {
u8::MAX as usize - 1
} else {
u8::MAX as usize
};
attributes.push(kind);
attributes.push(attribute_len as u8);
attributes.resize(
attributes.len() + attribute_len - RADIUS_ATTRIBUTE_HEADER_LEN,
kind,
);
remaining_attribute_bytes -= attribute_len;
kind = kind.wrapping_add(1);
}
let final_attribute_len = remaining_attribute_bytes;
attributes.push(kind);
attributes.push(final_attribute_len as u8);
attributes.resize(
attributes.len() + final_attribute_len - RADIUS_ATTRIBUTE_HEADER_LEN,
0,
);
accounting_request_packet(identifier, &attributes)
}
pub(crate) fn radius_packet(code: RadiusCode, identifier: u8, attributes: &[u8]) -> Vec<u8> {
let packet_len = RADIUS_HEADER_LEN + attributes.len();
assert!(packet_len <= RADIUS_MAX_PACKET_LEN);
let mut packet = Vec::with_capacity(packet_len);
packet.push(code.as_u8());
packet.push(identifier);
packet.extend_from_slice(&(packet_len as u16).to_be_bytes());
packet.extend_from_slice(&[1_u8; RADIUS_AUTHENTICATOR_LEN]);
packet.extend_from_slice(attributes);
packet
}
pub(crate) fn signed_accounting_request_packet(
identifier: u8,
attributes: &[u8],
shared_secret: &[u8],
) -> Vec<u8> {
signed_radius_packet(
RadiusCode::AccountingRequest,
identifier,
attributes,
shared_secret,
)
}
pub(crate) fn signed_radius_packet(
code: RadiusCode,
identifier: u8,
attributes: &[u8],
shared_secret: &[u8],
) -> Vec<u8> {
let mut packet = radius_packet(code, identifier, attributes);
let parsed = parse_packet(&packet).unwrap();
let request_authenticator = expected_request_authenticator(&parsed, shared_secret);
packet[4..RADIUS_HEADER_LEN].copy_from_slice(&request_authenticator);
packet
}
pub(crate) fn accounting_request_packet_with_message_authenticator(
identifier: u8,
attributes: &[u8],
shared_secret: &[u8],
) -> Vec<u8> {
let mut attributes_with_message_authenticator =
Vec::with_capacity(attributes.len() + 2 + MESSAGE_AUTHENTICATOR_VALUE_LEN);
attributes_with_message_authenticator.extend_from_slice(attributes);
attributes_with_message_authenticator.push(MESSAGE_AUTHENTICATOR_TYPE);
attributes_with_message_authenticator.push(MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8);
attributes_with_message_authenticator
.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
let message_authenticator_value_offset = attributes.len() + RADIUS_ATTRIBUTE_HEADER_LEN;
accounting_request_packet_with_message_authenticator_at(
identifier,
&attributes_with_message_authenticator,
message_authenticator_value_offset,
shared_secret,
)
}
pub(crate) fn accounting_request_packet_with_message_authenticator_at(
identifier: u8,
attributes_with_message_authenticator: &[u8],
message_authenticator_value_offset: usize,
shared_secret: &[u8],
) -> Vec<u8> {
assert!(
message_authenticator_value_offset + MESSAGE_AUTHENTICATOR_VALUE_LEN
<= attributes_with_message_authenticator.len()
);
let mut packet = accounting_request_packet(identifier, attributes_with_message_authenticator);
let value_start = RADIUS_HEADER_LEN + message_authenticator_value_offset;
let value_end = value_start + MESSAGE_AUTHENTICATOR_VALUE_LEN;
let parsed = parse_packet(&packet).unwrap();
let message_authenticator_index = message_authenticator_index(&parsed).unwrap().unwrap();
let message_authenticator =
expected_message_authenticator(&parsed, shared_secret, message_authenticator_index);
packet[value_start..value_end].copy_from_slice(&message_authenticator);
let parsed = parse_packet(&packet).unwrap();
let request_authenticator = expected_request_authenticator(&parsed, shared_secret);
packet[4..RADIUS_HEADER_LEN].copy_from_slice(&request_authenticator);
packet
}
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: test-radclient-harness.sh
Builds the lqos_radius diagnostic listener, starts it on loopback, and sends
RADIUS Accounting-Request packets with radclient.
Environment overrides:
RADIUS_HARNESS_PORT=18130
RADIUS_HARNESS_SECRET=testing123
RADIUS_HARNESS_CLIENT_SOURCE=127.0.0.1
RADIUS_HARNESS_TIMEOUT=2
RADIUS_HARNESS_READY_ATTEMPTS=10
KEEP_RADIUS_HARNESS_TMP=1
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
RUST_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
PORT="${RADIUS_HARNESS_PORT:-18130}"
SECRET="${RADIUS_HARNESS_SECRET:-testing123}"
CLIENT_SOURCE="${RADIUS_HARNESS_CLIENT_SOURCE:-127.0.0.1}"
RADCLIENT_TIMEOUT="${RADIUS_HARNESS_TIMEOUT:-2}"
READY_ATTEMPTS="${RADIUS_HARNESS_READY_ATTEMPTS:-10}"
LISTEN_ADDR="127.0.0.1:${PORT}"
if ! command -v radclient >/dev/null 2>&1; then
echo "radclient is required but was not found in PATH" >&2
exit 1
fi
case "${PORT}" in
''|*[!0-9]*)
echo "RADIUS_HARNESS_PORT must be numeric, got '${PORT}'" >&2
exit 1
;;
esac
if (( PORT < 1024 || PORT > 65535 )); then
echo "RADIUS_HARNESS_PORT must be in the unprivileged range 1024..65535, got '${PORT}'" >&2
exit 1
fi
case "${READY_ATTEMPTS}" in
''|*[!0-9]*)
echo "RADIUS_HARNESS_READY_ATTEMPTS must be numeric, got '${READY_ATTEMPTS}'" >&2
exit 1
;;
esac
if (( READY_ATTEMPTS < 1 )); then
echo "RADIUS_HARNESS_READY_ATTEMPTS must be greater than zero" >&2
exit 1
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lqos-radius-radclient.XXXXXX")"
LISTENER_LOG="${WORK_DIR}/listener.log"
LISTENER_PID=""
cleanup() {
if [[ -n "${LISTENER_PID}" ]] && kill -0 "${LISTENER_PID}" >/dev/null 2>&1; then
kill "${LISTENER_PID}" >/dev/null 2>&1 || true
wait "${LISTENER_PID}" >/dev/null 2>&1 || true
fi
if [[ "${KEEP_RADIUS_HARNESS_TMP:-0}" == "1" ]]; then
echo "Kept harness files in ${WORK_DIR}"
else
rm -rf "${WORK_DIR}"
fi
}
trap cleanup EXIT
require_listener_log() {
if [[ -s "${LISTENER_LOG}" ]]; then
cat "${LISTENER_LOG}" >&2
else
echo "listener produced no log output" >&2
fi
}
wait_for_listener() {
local output_file="${WORK_DIR}/ready.radclient.out"
for _ in $(seq 1 "${READY_ATTEMPTS}"); do
if radclient -x -r 1 -t "${RADCLIENT_TIMEOUT}" "${LISTEN_ADDR}" acct "${SECRET}" \
<"${WORK_DIR}/acct-probe.rad" >"${output_file}" 2>&1
then
return 0
fi
if ! kill -0 "${LISTENER_PID}" >/dev/null 2>&1; then
echo "lqos_radius listener exited before becoming ready" >&2
cat "${output_file}" >&2
require_listener_log
exit 1
fi
sleep 0.1
done
echo "timed out waiting for lqos_radius listener on ${LISTEN_ADDR}" >&2
cat "${output_file}" >&2
require_listener_log
exit 1
}
write_common_session_attributes() {
local framed_ip="$1"
cat <<EOF
User-Name = "customer-123"
Acct-Session-Id = "sess-0001"
NAS-IP-Address = 192.0.2.10
NAS-Identifier = "pppoe-core-1"
NAS-Port = 501
NAS-Port-Type = Virtual
Calling-Station-Id = "AA-BB-CC-DD-EE-FF"
Called-Station-Id = "pppoe-access"
Service-Type = Framed-User
Framed-Protocol = PPP
Framed-IP-Address = ${framed_ip}
EOF
}
write_accounting_fixture() {
local path="$1"
local status="$2"
local framed_ip="$3"
shift 3
{
echo "Acct-Status-Type = ${status}"
write_common_session_attributes "${framed_ip}"
for attribute in "$@"; do
printf '%s\n' "${attribute}"
done
} >"${path}"
}
write_radius_fixtures() {
cat >"${WORK_DIR}/acct-probe.rad" <<'EOF'
Acct-Status-Type = Accounting-On
NAS-IP-Address = 192.0.2.10
NAS-Identifier = "pppoe-core-1"
EOF
write_accounting_fixture \
"${WORK_DIR}/acct-start.rad" \
"Start" \
"198.51.100.55"
write_accounting_fixture \
"${WORK_DIR}/acct-interim.rad" \
"Interim-Update" \
"198.51.100.56" \
"Acct-Input-Octets = 123456" \
"Acct-Output-Octets = 654321" \
"Acct-Session-Time = 60"
write_accounting_fixture \
"${WORK_DIR}/acct-stop.rad" \
"Stop" \
"198.51.100.56" \
"Acct-Input-Octets = 223456" \
"Acct-Output-Octets = 754321" \
"Acct-Session-Time = 120" \
"Acct-Terminate-Cause = User-Request"
}
send_accounting_packet() {
local label="$1"
local packet_file="$2"
local output_file="${WORK_DIR}/${label}.radclient.out"
if ! radclient -x -r 1 -t "${RADCLIENT_TIMEOUT}" "${LISTEN_ADDR}" acct "${SECRET}" \
<"${packet_file}" >"${output_file}" 2>&1
then
echo "radclient ${label} request failed" >&2
cat "${output_file}" >&2
require_listener_log
exit 1
fi
}
send_rejected_packet() {
local output_file="${WORK_DIR}/wrong-secret.radclient.out"
if radclient -x -r 1 -t "${RADCLIENT_TIMEOUT}" "${LISTEN_ADDR}" acct "wrong-${SECRET}" \
<"${WORK_DIR}/acct-start.rad" >"${output_file}" 2>&1
then
echo "radclient unexpectedly received a response for a wrong-secret request" >&2
cat "${output_file}" >&2
require_listener_log
exit 1
fi
}
echo "Building lqos_radius diagnostic listener..."
cargo build --manifest-path "${RUST_DIR}/Cargo.toml" -p lqos_radius
write_radius_fixtures
"${RUST_DIR}/target/debug/lqos_radius" \
--listen "${LISTEN_ADDR}" \
--client-source "${CLIENT_SOURCE}" \
--shared-secret "${SECRET}" \
>"${LISTENER_LOG}" 2>&1 &
LISTENER_PID="$!"
wait_for_listener
echo "Sending accepted Accounting-Start, Interim-Update, and Stop packets..."
send_accounting_packet "start" "${WORK_DIR}/acct-start.rad"
send_accounting_packet "interim" "${WORK_DIR}/acct-interim.rad"
send_accounting_packet "stop" "${WORK_DIR}/acct-stop.rad"
echo "Sending wrong-secret rejection probe..."
send_rejected_packet
echo "RADIUS radclient loopback harness passed on ${LISTEN_ADDR}"
+3 -2
View File
@@ -673,9 +673,10 @@ fn build_shaping_inputs(
}
let conflicts = duplicate_circuit_shape_conflicts(circuit, device);
if !conflicts.is_empty() {
errors.push(format!(
"Circuit '{}' had conflicting circuit-level fields across ShapedDevices.csv rows while building shaping_inputs.json: {}.",
warnings.push(format!(
"Circuit '{}' had conflicting circuit-level fields across ShapedDevices.csv rows; using the first row's values and ignoring the duplicate row's circuit-level values for device '{}': {}.",
device.circuit_id,
device.device_id,
conflicts.join(", ")
));
}
+15 -4
View File
@@ -228,7 +228,7 @@
#[test]
fn shaping_inputs_reject_duplicate_circuit_shape_conflicts() {
fn shaping_inputs_warn_and_keep_first_duplicate_circuit_shape() {
let lqos_directory = unique_temp_dir("lqos-topology-duplicate-circuit-shape");
let config = Config {
lqos_directory: lqos_directory.to_string_lossy().to_string(),
@@ -269,17 +269,28 @@
})),
};
let err = build_shaping_inputs(&config, &artifacts)
.expect_err("duplicate circuit conflicts should fail shaping inputs");
let message = err.to_string();
let shaping_inputs = build_shaping_inputs(&config, &artifacts)
.expect("duplicate circuit conflicts should keep the first row")
.expect("ShapedDevices.csv should produce shaping inputs");
let message = shaping_inputs.warnings.join("\n");
assert!(message.contains("conflicting circuit-level fields"));
assert!(message.contains("ignoring the duplicate row's circuit-level values"));
assert!(message.contains("Download Min Mbps"));
assert!(message.contains("Upload Min Mbps"));
assert!(message.contains("Download Max Mbps"));
assert!(message.contains("Upload Max Mbps"));
assert!(message.contains("Comment"));
assert!(message.contains("sqm"));
assert_eq!(shaping_inputs.circuits.len(), 1);
let circuit = &shaping_inputs.circuits[0];
assert_eq!(circuit.download_min_mbps, 10.0);
assert_eq!(circuit.upload_min_mbps, 10.0);
assert_eq!(circuit.download_max_mbps, 100.0);
assert_eq!(circuit.upload_max_mbps, 100.0);
assert_eq!(circuit.comment, "first");
assert_eq!(circuit.sqm_override.as_deref(), Some("cake"));
assert_eq!(circuit.devices.len(), 2);
}
+97 -7
View File
@@ -172,18 +172,20 @@ struct LockMetadata {
process: Option<String>,
operation: Option<String>,
created_unix: Option<u64>,
process_start_ticks: Option<u64>,
}
impl LockMetadata {
fn current(operation: &str) -> Self {
Self {
pid: unsafe { getpid() },
pid: std::process::id() as i32,
process: current_process_name(),
operation: Some(sanitize_lock_field(operation)),
created_unix: SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs()),
process_start_ticks: process_start_ticks_for_pid(unsafe { getpid() }),
}
}
@@ -196,6 +198,7 @@ impl LockMetadata {
process: None,
operation: None,
created_unix: None,
process_start_ticks: None,
});
}
@@ -203,6 +206,7 @@ impl LockMetadata {
let mut process = None;
let mut operation = None;
let mut created_unix = None;
let mut process_start_ticks = None;
for line in contents.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
@@ -230,6 +234,14 @@ impl LockMetadata {
}
})?)
}
"process_start_ticks" => {
process_start_ticks = Some(value.trim().parse::<u64>().map_err(|source| {
ProcessLockError::InvalidMetadata {
path: path.to_path_buf(),
source,
}
})?)
}
_ => {}
}
}
@@ -245,6 +257,7 @@ impl LockMetadata {
process,
operation,
created_unix,
process_start_ticks,
})
}
@@ -255,10 +268,14 @@ impl LockMetadata {
.created_unix
.map(|seconds| seconds.to_string())
.unwrap_or_else(|| "unknown".to_string());
format!(
let mut serialized = format!(
"pid={}\nprocess={}\noperation={}\ncreated_unix={}\n",
self.pid, process, operation, created_unix
)
);
if let Some(process_start_ticks) = self.process_start_ticks {
serialized.push_str(&format!("process_start_ticks={process_start_ticks}\n"));
}
serialized
}
fn describe_holder(&self) -> String {
@@ -272,6 +289,9 @@ impl LockMetadata {
if let Some(created_unix) = self.created_unix {
parts.push(format!("created_unix={created_unix}"));
}
if let Some(process_start_ticks) = self.process_start_ticks {
parts.push(format!("process_start_ticks={process_start_ticks}"));
}
parts.join(", ")
}
}
@@ -384,6 +404,26 @@ impl ProcessFileLock {
return Errno::last() != Errno::ESRCH;
}
if let Some(expected_start_ticks) = metadata.process_start_ticks {
let Some(actual_start_ticks) = process_start_ticks_for_pid(metadata.pid) else {
return true;
};
if actual_start_ticks != expected_start_ticks {
return false;
}
} else if let Some(recorded_process) = metadata
.process
.as_deref()
.filter(|process| *process != "unknown")
{
let Some(current_process) = process_comm_for_pid(metadata.pid) else {
return true;
};
if current_process != recorded_process {
return false;
}
}
let Some(needle) = process_name_contains else {
return true;
};
@@ -543,6 +583,12 @@ fn process_comm_for_pid(pid: i32) -> Option<String> {
.and_then(|name| non_empty_sanitized(name.trim()))
}
fn process_start_ticks_for_pid(pid: i32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (_, fields) = stat.rsplit_once(')')?;
fields.split_whitespace().nth(19)?.parse().ok()
}
fn process_argv0_from_cmdline_bytes(raw: &[u8]) -> Option<String> {
raw.split(|byte| *byte == 0)
.next()
@@ -582,7 +628,8 @@ fn sanitize_lock_field(value: &str) -> String {
mod tests {
use super::{
LockMetadata, ProcessFileLock, ProcessLockConfig, ProcessLockError,
process_argv0_from_cmdline_bytes, process_name_matches,
process_argv0_from_cmdline_bytes, process_comm_for_pid, process_name_matches,
process_start_ticks_for_pid,
};
use std::{
fs::{create_dir_all, read_to_string, remove_dir_all, write},
@@ -678,6 +725,7 @@ mod tests {
Some("load effective overrides")
);
assert_eq!(metadata.created_unix, Some(1_800_000_000));
assert_eq!(metadata.process_start_ticks, None);
}
#[test]
@@ -709,12 +757,52 @@ mod tests {
assert!(contents.contains("process="));
assert!(contents.contains("operation=load effective overrides"));
assert!(contents.contains("created_unix="));
assert!(contents.contains("process_start_ticks="));
}
assert!(!path.exists());
remove_dir_all(&dir).expect("failed to clean up temp test dir");
}
#[test]
fn start_ticks_distinguish_reused_pids() {
let mut metadata = LockMetadata::current("save operator overrides");
let current_start_ticks = process_start_ticks_for_pid(metadata.pid)
.expect("current process should expose Linux start ticks");
assert!(ProcessFileLock::is_lock_valid(&metadata, None));
metadata.process_start_ticks = Some(current_start_ticks.saturating_add(1));
assert!(!ProcessFileLock::is_lock_valid(&metadata, None));
}
#[test]
fn legacy_metadata_rejects_a_reused_pid_with_a_different_process_name() {
let metadata = LockMetadata {
pid: std::process::id() as i32,
process: Some("previous-lock-holder".to_string()),
operation: Some("save operator overrides".to_string()),
created_unix: Some(1_800_000_000),
process_start_ticks: None,
};
assert!(!ProcessFileLock::is_lock_valid(&metadata, None));
}
#[test]
fn metadata_without_start_ticks_round_trips() {
let metadata = LockMetadata {
pid: 12345,
process: Some("lqos_overrides".to_string()),
operation: Some("save operator overrides".to_string()),
created_unix: Some(1_800_000_000),
process_start_ticks: None,
};
let parsed = LockMetadata::parse(&metadata.serialize(), std::path::Path::new("test.lock"))
.expect("metadata without start ticks should remain readable");
assert_eq!(parsed, metadata);
}
#[test]
fn pid_only_lock_file_preserves_legacy_format() {
let dir = unique_test_dir();
@@ -756,11 +844,13 @@ mod tests {
let dir = unique_test_dir();
create_dir_all(&dir).expect("failed to create temp test dir");
let path = dir.join("test.lock");
let process = process_comm_for_pid(std::process::id() as i32)
.expect("current process should expose its command name");
write(
&path,
format!(
"pid={}\nprocess=test-holder\noperation=save overrides\ncreated_unix=1800000000\n",
std::process::id()
"pid={}\nprocess={process}\noperation=save overrides\ncreated_unix=1800000000\n",
std::process::id(),
),
)
.expect("failed to write temp lock");
@@ -773,7 +863,7 @@ mod tests {
assert_eq!(code, "TEST_LOCKED");
assert!(message.contains("pid="));
assert!(message.contains("process=test-holder"));
assert!(message.contains(&format!("process={process}")));
assert!(message.contains("operation=save overrides"));
assert!(message.contains("created_unix=1800000000"));
+2
View File
@@ -22,6 +22,7 @@ lqos_topology_compile = { path = "../lqos_topology_compile" }
lqos_heimdall = { path = "../lqos_heimdall" }
lqos_stormguard = { path = "../lqos_stormguard" }
lqos_probe = { path = "../lqos_probe" }
lqos_radius = { path = "../lqos_radius" }
lqos_netplan_helper = { path = "../lqos_netplan_helper" }
lqos_setup = { path = "../lqos_setup" }
tokio = { version = "1", features = [ "full" ] }
@@ -81,4 +82,5 @@ allocative_derive.workspace = true
futures-util = "0.3.31"
[dev-dependencies]
tokio = { version = "1", features = [ "full", "test-util" ] }
toml = { workspace = true }
+17 -9
View File
@@ -1,11 +1,19 @@
#!/bin/bash
set -e
echo "Copying static"
mkdir -p ../../bin/static2/
cp -v -R src/node_manager/static2/* ../../bin/static2/
echo "Done"
pushd src/node_manager/js_build || exit
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
STATIC_SOURCE_DIR="${SCRIPT_DIR}/src/node_manager/static2"
JS_BUILD_DIR="${SCRIPT_DIR}/src/node_manager/js_build"
STATIC_TARGET_DIR="${SCRIPT_DIR}/../../bin/static2"
pushd "${JS_BUILD_DIR}" >/dev/null
./esbuild.sh
./test-build-contract.sh
popd || exit
cp -R src/node_manager/js_build/out/* ../../bin/static2/
popd >/dev/null
echo "Copying static"
rm -rf "${STATIC_TARGET_DIR:?}"
mkdir -p "${STATIC_TARGET_DIR}"
cp -v -R "${STATIC_SOURCE_DIR}/." "${STATIC_TARGET_DIR}/"
cp -R "${JS_BUILD_DIR}/out/." "${STATIC_TARGET_DIR}/"
echo "Done"
+12
View File
@@ -17,6 +17,7 @@ mod override_writer;
mod preflight_checks;
mod probe_provider;
mod program_control;
mod radius_accounting;
mod remote_commands;
mod rtt_exclusions;
mod scheduler_control;
@@ -200,6 +201,7 @@ fn main() -> Result<()> {
// Load config
let config = lqos_config::load_config()?;
let web_config = config.clone();
let radius_accounting_config = config.radius_accounting.clone();
shaping_runtime::mark_starting("LibreQoS is starting shaping services.");
ensure_rustls_crypto_provider()?;
@@ -413,6 +415,16 @@ fn main() -> Result<()> {
BusRequest,
)>(100);
if let Err(err) = radius_accounting::start_configured_radius_accounting(
radius_accounting_config,
&web_config,
bus_tx.clone(),
)
.await
{
error!("RADIUS accounting listener was not started: {err}");
}
lqos_topology::start_topology_thread(bus_tx.clone());
let webserver_disabled = web_config.disable_webserver.unwrap_or(false);
+1 -1
View File
@@ -291,7 +291,7 @@ mod tests {
);
assert_eq!(
read_required_kb(&parsed, "MemTotal").unwrap(),
read_required_kb(&parsed, "MemTotal").expect("MemTotal should parse"),
15_458_992 * 1024
);
assert_eq!(read_optional_raw(&parsed, "Threads"), 44);
@@ -1,4 +1,4 @@
# Site_build
# js_build
This assembles the JavaScript for the node_manager site using `esbuild`.
@@ -13,14 +13,17 @@ This assembles the JavaScript for the node_manager site using `esbuild`.
- `./esbuild.sh`
- `./test-build-contract.sh`
`esbuild.sh` prefers a locally installed `esbuild` binary when available. If none is found, it falls back to downloading a standalone binary into `/tmp/esbuild`.
`esbuild.sh` uses `ESBUILD_BIN` when set and matching `ESBUILD_VERSION`. Explicit `ESBUILD_BIN` overrides must already report the expected version. Otherwise it uses an `esbuild` on `PATH` only when its version matches `ESBUILD_VERSION`; if no matching binary is available, it downloads a standalone binary into the default managed cache at `src/rust/target/esbuild`. Set `ESBUILD_INSTALL_DIR` to relocate that cache.
Defaults:
- fallback esbuild version: `0.25.3`
- browser targets: `chrome85,firefox78,safari14`
- fallback cache: `src/rust/target/esbuild`
Optional overrides:
- `ESBUILD_BIN`
- `ESBUILD_VERSION`
- `ESBUILD_TARGETS`
- `ESBUILD_INSTALL_DIR`
@@ -28,6 +28,7 @@ config_lts.js
config_iprange.js
config_flows.js
config_dynamic_circuits.js
config_radius_accounting.js
config_integration.js
config_splynx.js
config_netzur.js
@@ -7,23 +7,94 @@ SRC_DIR="${SCRIPT_DIR}/src"
OUT_DIR="${SCRIPT_DIR}/out"
ESBUILD_VERSION="${ESBUILD_VERSION:-0.25.3}"
ESBUILD_TARGETS="${ESBUILD_TARGETS:-chrome85,firefox78,safari14}"
ESBUILD_INSTALL_DIR="${ESBUILD_INSTALL_DIR:-${SCRIPT_DIR}/../../../../target/esbuild}"
ESBUILD_STAGE_PATTERN="${ESBUILD_INSTALL_DIR}/staging.XXXXXX"
if [[ ! -f "${ENTRYPOINTS_FILE}" ]]; then
echo "Missing entrypoints file: ${ENTRYPOINTS_FILE}" >&2
exit 1
fi
if [[ -z "${ESBUILD_BIN:-}" ]]; then
ESBUILD_BIN="$(command -v esbuild || true)"
MANAGED_ESBUILD=0
REQUESTED_ESBUILD_BIN="${ESBUILD_BIN:-}"
ESBUILD_BIN=""
ESBUILD_BIN_VERSION=""
esbuild_version() {
local bin="$1"
if [[ -x "${bin}" ]]; then
"${bin}" --version 2>/dev/null || true
fi
}
install_managed_esbuild() {
local stage_dir=""
local stage_bin=""
local stage_installer=""
local stage_tmp=""
local stage_version=""
mkdir -p "${ESBUILD_INSTALL_DIR}"
stage_dir="$(mktemp -d "${ESBUILD_STAGE_PATTERN}")"
stage_bin="${stage_dir}/esbuild"
stage_installer="${stage_dir}/install-esbuild.sh"
stage_tmp="${stage_dir}/tmp"
mkdir -p "${stage_tmp}"
curl -fsSL "https://esbuild.github.io/dl/v${ESBUILD_VERSION}" -o "${stage_installer}"
pushd "${stage_dir}" >/dev/null
TMPDIR="${stage_tmp}" sh "${stage_installer}"
popd >/dev/null
chmod a+x "${stage_bin}"
stage_version="$(esbuild_version "${stage_bin}")"
if [[ "${stage_version}" != "${ESBUILD_VERSION}" ]]; then
echo "Downloaded esbuild version ${stage_version:-unknown} does not match ${ESBUILD_VERSION}: ${stage_bin}" >&2
exit 1
fi
mv -f "${stage_bin}" "${ESBUILD_BIN}"
rm -rf "${stage_dir}"
}
if [[ -n "${REQUESTED_ESBUILD_BIN}" ]]; then
ESBUILD_BIN="${REQUESTED_ESBUILD_BIN}"
if [[ ! -x "${ESBUILD_BIN}" ]]; then
echo "Configured ESBUILD_BIN is not executable: ${ESBUILD_BIN}" >&2
exit 1
fi
ESBUILD_BIN_VERSION="$(esbuild_version "${ESBUILD_BIN}")"
else
SYSTEM_ESBUILD_BIN="$(command -v esbuild || true)"
if [[ -n "${SYSTEM_ESBUILD_BIN}" ]]; then
SYSTEM_ESBUILD_VERSION="$(esbuild_version "${SYSTEM_ESBUILD_BIN}")"
if [[ "${SYSTEM_ESBUILD_VERSION}" == "${ESBUILD_VERSION}" ]]; then
ESBUILD_BIN="${SYSTEM_ESBUILD_BIN}"
ESBUILD_BIN_VERSION="${SYSTEM_ESBUILD_VERSION}"
fi
fi
if [[ -z "${ESBUILD_BIN}" ]]; then
ESBUILD_BIN="${ESBUILD_INSTALL_DIR}/esbuild"
ESBUILD_BIN_VERSION="$(esbuild_version "${ESBUILD_BIN}")"
MANAGED_ESBUILD=1
fi
fi
if [[ -z "${ESBUILD_BIN}" ]]; then
mkdir -p /tmp/esbuild
pushd /tmp/esbuild >/dev/null
curl -fsSL "https://esbuild.github.io/dl/v${ESBUILD_VERSION}" | sh
popd >/dev/null
chmod a+x /tmp/esbuild/esbuild
ESBUILD_BIN="/tmp/esbuild/esbuild"
if [[ "${MANAGED_ESBUILD}" -eq 1 && "${ESBUILD_BIN_VERSION}" != "${ESBUILD_VERSION}" ]]; then
install_managed_esbuild
ESBUILD_BIN_VERSION="$(esbuild_version "${ESBUILD_BIN}")"
fi
if [[ ! -x "${ESBUILD_BIN}" ]]; then
echo "esbuild is not executable: ${ESBUILD_BIN}" >&2
exit 1
fi
if [[ "${ESBUILD_BIN_VERSION}" != "${ESBUILD_VERSION}" ]]; then
echo "esbuild version ${ESBUILD_BIN_VERSION:-unknown} does not match ${ESBUILD_VERSION}: ${ESBUILD_BIN}" >&2
exit 1
fi
mkdir -p "${OUT_DIR}"
@@ -683,6 +683,7 @@ export function renderConfigMenu(currentPage) {
{ href: "config_iprange.html", icon: "fa-address-card", text: "IP Ranges", id: "iprange" },
{ href: "config_flows.html", icon: "fa-arrow-circle-down", text: "Flow Tracking", id: "flows" },
{ href: "config_dynamic_circuits.html", icon: "fa-layer-group", text: "Dynamic Circuits", id: "dynamic_circuits" },
{ href: "config_radius_accounting.html", icon: "fa-satellite-dish", text: "RADIUS Accounting", id: "radius_accounting" },
{ href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" },
{ href: "config_splynx.html", icon: "fa-link", text: "Splynx", id: "splynx" },
{ href: "config_netzur.html", icon: "fa-link", text: "Netzur", id: "netzur" },
@@ -343,7 +343,7 @@ function renderTable() {
html += "<th class='text-nowrap'>Device ID</th>";
html += "<th>Device Name</th>";
html += "<th>Parent Node</th>";
html += "<th class='text-nowrap'>MAC</th>";
html += "<th class='text-nowrap'>MAC or RADIUS Username</th>";
html += "<th>IP Addresses</th>";
html += "<th class='text-nowrap'>Min Mbps</th>";
html += "<th class='text-nowrap'>Max Mbps</th>";
@@ -0,0 +1,376 @@
import { loadConfig, renderConfigMenu, saveConfig } from "./config/config_helper";
const DEFAULT_TTL_SECONDS = 900;
const DEFAULT_STALE_GRACE_SECONDS = 120;
function parsePositiveInt(value) {
const num = parseInt(String(value ?? "").trim(), 10);
if (!Number.isFinite(num) || num <= 0) return null;
return num;
}
function parsePositiveFloat(value) {
const num = parseFloat(String(value ?? "").trim());
if (!Number.isFinite(num) || num <= 0) return null;
return num;
}
function optionalText(value) {
const trimmed = String(value ?? "").trim();
return trimmed.length > 0 ? trimmed : null;
}
function ensureRadiusConfig(config) {
if (!config || typeof config !== "object") return;
if (!config.radius_accounting || typeof config.radius_accounting !== "object") {
config.radius_accounting = {};
}
const radius = config.radius_accounting;
if (typeof radius.enabled !== "boolean") radius.enabled = false;
if (!Number.isFinite(Number(radius.default_ttl_seconds))) {
radius.default_ttl_seconds = DEFAULT_TTL_SECONDS;
}
if (!Number.isFinite(Number(radius.stale_grace_seconds))) {
radius.stale_grace_seconds = DEFAULT_STALE_GRACE_SECONDS;
}
if (!radius.dynamic_circuit_application || typeof radius.dynamic_circuit_application !== "object") {
radius.dynamic_circuit_application = {};
}
const application = radius.dynamic_circuit_application;
if (typeof application.enabled !== "boolean") application.enabled = false;
if (typeof application.match_shaped_devices_by_mac !== "boolean") {
application.match_shaped_devices_by_mac = false;
}
if (typeof application.match_shaped_devices_by_username !== "boolean") {
application.match_shaped_devices_by_username = false;
}
if (!Array.isArray(radius.clients)) radius.clients = [];
}
function sourceListToText(source) {
if (Array.isArray(source)) {
return source.map((entry) => String(entry ?? "").trim()).filter(Boolean).join("\n");
}
return String(source ?? "").trim();
}
function textToSourceList(value) {
return String(value ?? "")
.split(/\r?\n|,/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function isValidIPv4(ip) {
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
const parts = ip.split(".").map((part) => parseInt(part, 10));
return parts.length === 4 && !parts.some((part) => Number.isNaN(part) || part < 0 || part > 255);
}
function isValidIPv6(ip) {
return ip.includes(":") && /^[0-9a-fA-F:.]+$/.test(ip);
}
function isValidIpAddress(ip) {
return isValidIPv4(ip) || isValidIPv6(ip);
}
function isValidIpOrCidr(value) {
const text = String(value ?? "").trim();
const [ip, mask, extra] = text.split("/");
if (!ip || extra !== undefined) return false;
if (!isValidIpAddress(ip)) return false;
if (mask === undefined) return true;
const maskNum = parseInt(mask, 10);
if (!Number.isInteger(maskNum)) return false;
return ip.includes(":") ? maskNum >= 0 && maskNum <= 128 : maskNum >= 0 && maskNum <= 32;
}
function getDefaultClient() {
return {
name: "",
source: [],
secret_file: "",
};
}
function announceClientChange(message) {
const status = document.getElementById("radiusClientsStatus");
if (status) status.textContent = message;
}
function renderClientsTable(focusClientIndex = null) {
const tbody = document.getElementById("radiusClientsBody");
if (!tbody) return;
tbody.innerHTML = "";
const clients = window.config?.radius_accounting?.clients;
if (!Array.isArray(clients) || clients.length === 0) {
const empty = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 4;
cell.className = "text-muted";
cell.textContent = "No trusted clients configured.";
empty.appendChild(cell);
tbody.appendChild(empty);
return;
}
clients.forEach((client, index) => {
const tr = document.createElement("tr");
const addCell = (label, child) => {
const td = document.createElement("td");
td.dataset.label = label;
td.appendChild(child);
tr.appendChild(td);
};
const name = document.createElement("input");
name.type = "text";
name.className = "form-control form-control-sm";
name.placeholder = "NAS name";
name.value = String(client?.name ?? "");
name.setAttribute("aria-label", `Client ${index + 1} name`);
name.addEventListener("input", (ev) => {
window.config.radius_accounting.clients[index].name = ev.target.value;
});
addCell("Name", name);
const source = document.createElement("textarea");
source.className = "form-control form-control-sm";
source.rows = 2;
source.placeholder = "192.0.2.10\n192.0.2.0/24";
source.value = sourceListToText(client?.source);
source.setAttribute("aria-label", `Client ${index + 1} source IPs or CIDRs`);
source.addEventListener("input", (ev) => {
window.config.radius_accounting.clients[index].source = textToSourceList(ev.target.value);
});
addCell("Source IPs or CIDRs", source);
const secretFile = document.createElement("input");
secretFile.type = "text";
secretFile.className = "form-control form-control-sm";
secretFile.placeholder = "/etc/libreqos/radius/client.secret";
secretFile.value = String(client?.secret_file ?? "");
secretFile.setAttribute("aria-label", `Client ${index + 1} secret file`);
secretFile.addEventListener("input", (ev) => {
window.config.radius_accounting.clients[index].secret_file = ev.target.value;
});
addCell("Secret File", secretFile);
const removeTd = document.createElement("td");
removeTd.dataset.label = "Actions";
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "btn btn-sm btn-outline-danger";
removeBtn.textContent = "Remove";
removeBtn.setAttribute("aria-label", `Remove client ${index + 1}`);
removeBtn.addEventListener("click", () => {
window.config.radius_accounting.clients.splice(index, 1);
renderClientsTable(Math.min(index, window.config.radius_accounting.clients.length - 1));
announceClientChange(`Removed client ${index + 1}.`);
});
removeTd.appendChild(removeBtn);
tr.appendChild(removeTd);
tbody.appendChild(tr);
if (index === focusClientIndex) {
name.focus();
}
});
}
function setFallbackSpeedInputsEnabled(enabled) {
[
"radiusDownloadMinMbps",
"radiusUploadMinMbps",
"radiusDownloadMaxMbps",
"radiusUploadMaxMbps",
].forEach((id) => {
const input = document.getElementById(id);
if (input) input.disabled = !enabled;
});
}
function updateConfigFromUi() {
const radius = window.config.radius_accounting;
radius.enabled = !!document.getElementById("radiusEnabled")?.checked;
radius.listen = optionalText(document.getElementById("radiusListen")?.value);
radius.default_ttl_seconds = parsePositiveInt(
document.getElementById("radiusTtlSeconds")?.value,
) ?? DEFAULT_TTL_SECONDS;
radius.stale_grace_seconds = parsePositiveInt(
document.getElementById("radiusStaleGraceSeconds")?.value,
) ?? DEFAULT_STALE_GRACE_SECONDS;
const application = radius.dynamic_circuit_application;
application.enabled = !!document.getElementById("radiusDynamicApplicationEnabled")?.checked;
application.match_shaped_devices_by_mac = !!document.getElementById("radiusMatchShapedDevicesByMac")?.checked;
application.match_shaped_devices_by_username = !!document.getElementById("radiusMatchShapedDevicesByUsername")?.checked;
application.fallback_parent_node = optionalText(document.getElementById("radiusFallbackParentNode")?.value);
application.fallback_parent_node_id = optionalText(document.getElementById("radiusFallbackParentNodeId")?.value);
application.fallback_anchor_node_id = optionalText(document.getElementById("radiusFallbackAnchorNodeId")?.value);
if (document.getElementById("radiusFallbackSpeedEnabled")?.checked) {
radius.fallback_speed_profile = {
download_min_mbps: parsePositiveFloat(document.getElementById("radiusDownloadMinMbps")?.value),
upload_min_mbps: parsePositiveFloat(document.getElementById("radiusUploadMinMbps")?.value),
download_max_mbps: parsePositiveFloat(document.getElementById("radiusDownloadMaxMbps")?.value),
upload_max_mbps: parsePositiveFloat(document.getElementById("radiusUploadMaxMbps")?.value),
};
} else {
radius.fallback_speed_profile = null;
}
if (Array.isArray(radius.clients)) {
radius.clients = radius.clients.map((client) => ({
name: String(client?.name ?? "").trim(),
source: textToSourceList(sourceListToText(client?.source)),
secret_file: String(client?.secret_file ?? "").trim(),
}));
}
}
function validateListenAddress(value) {
const text = String(value ?? "").trim();
if (!text) return false;
const ipv6Match = text.match(/^\[[^\]]+\]:(\d+)$/);
if (ipv6Match) {
const ip = text.slice(1, text.indexOf("]"));
if (!isValidIPv6(ip)) return false;
const port = parseInt(ipv6Match[1], 10);
return port > 0 && port <= 65535;
}
const portStart = text.lastIndexOf(":");
if (portStart <= 0) return false;
const ip = text.slice(0, portStart);
if (!isValidIPv4(ip)) return false;
const port = parseInt(text.slice(portStart + 1), 10);
return Number.isInteger(port) && port > 0 && port <= 65535;
}
function validateConfig() {
const errors = [];
const radius = window.config?.radius_accounting;
if (!radius || typeof radius !== "object") {
errors.push("radius_accounting section is missing");
} else {
if (radius.enabled && !validateListenAddress(radius.listen)) {
errors.push("Listen Address is required when RADIUS accounting is enabled and must include a valid port.");
}
if (parsePositiveInt(radius.default_ttl_seconds) === null) {
errors.push("TTL must be a positive integer.");
}
if (parsePositiveInt(radius.stale_grace_seconds) === null) {
errors.push("Stale Grace must be a positive integer.");
}
const application = radius.dynamic_circuit_application || {};
if (!application.fallback_parent_node
&& (application.fallback_parent_node_id || application.fallback_anchor_node_id)) {
errors.push("Fallback Parent Node is required when fallback node IDs are configured.");
}
if (radius.fallback_speed_profile) {
const profile = radius.fallback_speed_profile;
const dmin = parsePositiveFloat(profile.download_min_mbps);
const umin = parsePositiveFloat(profile.upload_min_mbps);
const dmax = parsePositiveFloat(profile.download_max_mbps);
const umax = parsePositiveFloat(profile.upload_max_mbps);
if (dmin === null || umin === null || dmax === null || umax === null) {
errors.push("Fallback speed profile values must be positive numbers.");
} else {
if (dmin > dmax) errors.push("Fallback Download Min must be <= Download Max.");
if (umin > umax) errors.push("Fallback Upload Min must be <= Upload Max.");
}
}
const clients = Array.isArray(radius.clients) ? radius.clients : [];
if (radius.enabled && clients.length === 0) {
errors.push("At least one trusted client is required when RADIUS accounting is enabled.");
}
clients.forEach((client, index) => {
const label = client?.name ? `Client '${client.name}'` : `Client #${index + 1}`;
const sources = Array.isArray(client?.source) ? client.source : [];
if (sources.length === 0) {
errors.push(`${label}: Source IPs or CIDRs are required.`);
}
sources.forEach((source) => {
if (!isValidIpOrCidr(source)) {
errors.push(`${label}: '${source}' must be an IP address or CIDR network.`);
}
});
if (!String(client?.secret_file ?? "").trim()) {
errors.push(`${label}: Secret File is required.`);
}
});
}
if (errors.length === 0) return true;
alert("Validation errors:\n" + errors.join("\n"));
return false;
}
function loadUiFromConfig() {
const radius = window.config.radius_accounting;
const application = radius.dynamic_circuit_application;
const profile = radius.fallback_speed_profile;
document.getElementById("radiusEnabled").checked = !!radius.enabled;
document.getElementById("radiusListen").value = radius.listen ?? "";
document.getElementById("radiusTtlSeconds").value = radius.default_ttl_seconds ?? DEFAULT_TTL_SECONDS;
document.getElementById("radiusStaleGraceSeconds").value = radius.stale_grace_seconds ?? DEFAULT_STALE_GRACE_SECONDS;
document.getElementById("radiusDynamicApplicationEnabled").checked = !!application.enabled;
document.getElementById("radiusMatchShapedDevicesByMac").checked = !!application.match_shaped_devices_by_mac;
document.getElementById("radiusMatchShapedDevicesByUsername").checked = !!application.match_shaped_devices_by_username;
document.getElementById("radiusFallbackParentNode").value = application.fallback_parent_node ?? "";
document.getElementById("radiusFallbackParentNodeId").value = application.fallback_parent_node_id ?? "";
document.getElementById("radiusFallbackAnchorNodeId").value = application.fallback_anchor_node_id ?? "";
document.getElementById("radiusFallbackSpeedEnabled").checked = !!profile;
document.getElementById("radiusDownloadMinMbps").value = profile?.download_min_mbps ?? "";
document.getElementById("radiusUploadMinMbps").value = profile?.upload_min_mbps ?? "";
document.getElementById("radiusDownloadMaxMbps").value = profile?.download_max_mbps ?? "";
document.getElementById("radiusUploadMaxMbps").value = profile?.upload_max_mbps ?? "";
setFallbackSpeedInputsEnabled(!!profile);
renderClientsTable();
}
renderConfigMenu("radius_accounting");
loadConfig(() => {
ensureRadiusConfig(window.config);
loadUiFromConfig();
document.getElementById("radiusFallbackSpeedEnabled").addEventListener("change", (ev) => {
setFallbackSpeedInputsEnabled(!!ev.target.checked);
});
document.getElementById("addRadiusClient").addEventListener("click", () => {
window.config.radius_accounting.clients.push(getDefaultClient());
const newIndex = window.config.radius_accounting.clients.length - 1;
renderClientsTable(newIndex);
announceClientChange(`Added client ${newIndex + 1}.`);
});
document.getElementById("saveButton").addEventListener("click", () => {
updateConfigFromUi();
if (!validateConfig()) return;
saveConfig(() => {
alert("Configuration saved successfully!");
});
});
});
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { fileURLToPath } from "node:url";
const sourceDirectory = new URL(".", import.meta.url);
const configSourcePath = fileURLToPath(new URL("config_radius_accounting.js", sourceDirectory));
const configPagePath = fileURLToPath(new URL("../../static2/config_radius_accounting.html", sourceDirectory));
test("RADIUS username matching toggle has a complete form contract", async () => {
const [source, page] = await Promise.all([
readFile(configSourcePath, "utf8"),
readFile(configPagePath, "utf8"),
]);
assert.match(page, /id="radiusMatchShapedDevicesByUsername"/);
assert.match(source, /application\.match_shaped_devices_by_username = false/);
assert.match(source, /radiusMatchShapedDevicesByUsername"\)\?\.checked/);
assert.match(source, /radiusMatchShapedDevicesByUsername"\)\.checked = !!application\.match_shaped_devices_by_username/);
});
@@ -17,6 +17,7 @@ node --test \
"${SCRIPT_DIR}/src/tree_stormguard.test.mjs" \
"${SCRIPT_DIR}/src/config/shaped_device_identity.test.mjs" \
"${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs" \
"${SCRIPT_DIR}/src/config_radius_accounting_contract.test.mjs" \
"${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs" \
"${SCRIPT_DIR}/src/config/local_api_token.test.mjs" \
"${SCRIPT_DIR}/src/config/network_mode_storage.test.mjs" \
@@ -360,11 +360,13 @@ mod tests {
#[test]
fn merge_network_mode_rejects_invalid_bridge_mtu() {
let mut candidate = Config::default();
candidate.bridge = Some(BridgeConfig {
mtu: Some(9217),
..BridgeConfig::default()
});
let candidate = Config {
bridge: Some(BridgeConfig {
mtu: Some(9217),
..BridgeConfig::default()
}),
..Config::default()
};
let error = merge_network_mode_with_live(&Config::default(), candidate)
.expect_err("invalid MTU should fail");
@@ -379,12 +381,14 @@ mod tests {
#[test]
fn merge_network_mode_rejects_invalid_single_interface_mtu() {
let mut candidate = Config::default();
candidate.bridge = None;
candidate.single_interface = Some(SingleInterfaceConfig {
mtu: Some(575),
..SingleInterfaceConfig::default()
});
let candidate = Config {
bridge: None,
single_interface: Some(SingleInterfaceConfig {
mtu: Some(575),
..SingleInterfaceConfig::default()
}),
..Config::default()
};
let error = merge_network_mode_with_live(&Config::default(), candidate)
.expect_err("invalid MTU should fail");
@@ -24,7 +24,7 @@
<div class="col-12 col-lg-6">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-search"></i></span>
<input id="sdSearch" type="text" class="form-control" placeholder="Search device/circuit/parent/MAC/IP...">
<input id="sdSearch" type="text" class="form-control" placeholder="Search device/circuit/parent/MAC or username/IP...">
</div>
</div>
<div class="col-12 col-md-4 col-lg-3">
@@ -91,8 +91,9 @@
<select class="form-select" id="sdModalParentNode"></select>
</div>
<div class="col-md-6">
<label for="sdModalMac" class="form-label">MAC</label>
<input type="text" class="form-control" id="sdModalMac">
<label for="sdModalMac" class="form-label">MAC or RADIUS Username</label>
<input type="text" class="form-control" id="sdModalMac" aria-describedby="sdModalMacHelp">
<div id="sdModalMacHelp" class="form-text">Use a MAC address for Calling-Station-Id matching, or a username for User-Name matching.</div>
</div>
<div class="col-md-6">
<label for="sdModalIpv4" class="form-label">IPv4 (one per line)</label>
@@ -0,0 +1,188 @@
<div class="lqos-config-page">
<div id="configMenuContainer"></div>
<form class="lqos-config-grid">
<section class="lqos-config-panel">
<div class="lqos-config-panel-header">
<div>
<h5 class="lqos-config-panel-title">RADIUS Accounting</h5>
<div class="lqos-config-panel-subtitle">
Listen for accounting packets and apply RADIUS-backed dynamic circuits.
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Listener</h6>
<div class="lqos-config-section-subtitle">Enable the UDP listener and choose session expiry behavior.</div>
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusEnabled">
<label class="form-check-label" for="radiusEnabled">Enable RADIUS Accounting</label>
<div class="form-text">Starts the lqosd RADIUS accounting listener.</div>
</div>
</div>
<div class="col-12 col-xl-4">
<label for="radiusListen" class="form-label">Listen Address</label>
<input type="text" class="form-control" id="radiusListen" placeholder="0.0.0.0:1813">
<div class="form-text">UDP socket address used by NAS accounting clients.</div>
</div>
<div class="col-12 col-md-6 col-xl-2">
<label for="radiusTtlSeconds" class="form-label">TTL</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusTtlSeconds" min="1" step="1" value="900">
<span class="input-group-text">sec</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-2">
<label for="radiusStaleGraceSeconds" class="form-label">Stale Grace</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusStaleGraceSeconds" min="1" step="1" value="120">
<span class="input-group-text">sec</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Dynamic Circuit Application</h6>
<div class="lqos-config-section-subtitle">Control whether accepted sessions create or refresh dynamic circuits.</div>
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusDynamicApplicationEnabled">
<label class="form-check-label" for="radiusDynamicApplicationEnabled">Apply Dynamic Circuits</label>
<div class="form-text">Allows RADIUS sessions to update dynamic circuit state.</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusMatchShapedDevicesByMac">
<label class="form-check-label" for="radiusMatchShapedDevicesByMac">Match ShapedDevices by MAC</label>
<div class="form-text">Uses Calling-Station-Id to reuse shaped device metadata.</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusMatchShapedDevicesByUsername">
<label class="form-check-label" for="radiusMatchShapedDevicesByUsername">Match ShapedDevices by Username</label>
<div class="form-text">Uses User-Name to match the device MAC field verbatim.</div>
</div>
</div>
<div class="col-12 col-xl-4">
<label for="radiusFallbackParentNode" class="form-label">Fallback Parent Node</label>
<input type="text" class="form-control" id="radiusFallbackParentNode" placeholder="Optional network node">
</div>
<div class="col-12 col-xl-6">
<label for="radiusFallbackParentNodeId" class="form-label">Fallback Parent Node ID</label>
<input type="text" class="form-control" id="radiusFallbackParentNodeId" placeholder="Optional stable node ID">
</div>
<div class="col-12 col-xl-6">
<label for="radiusFallbackAnchorNodeId" class="form-label">Fallback Anchor Node ID</label>
<input type="text" class="form-control" id="radiusFallbackAnchorNodeId" placeholder="Optional stable anchor ID">
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Fallback Speed Profile</h6>
<div class="lqos-config-section-subtitle">Optional rates used when packets and matched shaped devices do not supply rates.</div>
<div class="row g-3">
<div class="col-12">
<div class="mb-0 form-check form-switch">
<input type="checkbox" class="form-check-input" id="radiusFallbackSpeedEnabled">
<label class="form-check-label" for="radiusFallbackSpeedEnabled">Enable Fallback Speed Profile</label>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusDownloadMinMbps" class="form-label">Download Min</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusDownloadMinMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusUploadMinMbps" class="form-label">Upload Min</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusUploadMinMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusDownloadMaxMbps" class="form-label">Download Max</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusDownloadMaxMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<label for="radiusUploadMaxMbps" class="form-label">Upload Max</label>
<div class="input-group">
<input type="number" class="form-control" id="radiusUploadMaxMbps" min="0.1" step="0.1">
<span class="input-group-text">Mbps</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="lqos-config-section">
<h6 class="lqos-config-section-title">Trusted Clients</h6>
<div class="lqos-config-section-subtitle">Allow only known NAS sources and shared secret files.</div>
<div class="d-flex justify-content-end mb-3">
<button type="button" id="addRadiusClient" class="btn btn-outline-primary btn-sm">
<i class="fa fa-plus me-1"></i>Add Client
</button>
</div>
<div id="radiusClientsStatus" class="visually-hidden" aria-live="polite"></div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle lqos-config-responsive-table">
<thead>
<tr>
<th style="min-width: 9rem;">Name</th>
<th style="min-width: 14rem;">Source IPs or CIDRs</th>
<th style="min-width: 14rem;">Secret File</th>
<th style="min-width: 5rem;">Actions</th>
</tr>
</thead>
<tbody id="radiusClientsBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<div class="lqos-config-actions">
<button type="button" id="saveButton" class="btn btn-outline-primary">Save Changes</button>
</div>
</form>
</div>
<script src="config_radius_accounting.js%CACHEBUSTERS%"></script>
@@ -66,6 +66,7 @@ pub(super) fn static_routes() -> Result<Router> {
"config_iprange.html",
"config_flows.html",
"config_dynamic_circuits.html",
"config_radius_accounting.html",
"config_integration.html",
"config_splynx.html",
"config_netzur.html",
File diff suppressed because it is too large. Load diff
@@ -1153,6 +1153,7 @@ mod tests {
current_icmp_packets: DownUpOrder::default(),
current_tcp_retransmits: DownUpOrder::default(),
current_tcp_retransmit_packets: DownUpOrder::default(),
current_rtt_flows: DownUpOrder::default(),
current_marks: DownUpOrder::default(),
current_drops: DownUpOrder::default(),
rtt_buffer: RttBuffer::default(),