diff --git a/docs-es/conf.py b/docs-es/conf.py index e47eb31a..3f6722b0 100644 --- a/docs-es/conf.py +++ b/docs-es/conf.py @@ -16,7 +16,7 @@ author = "LibreQoE, LLC" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["myst_parser"] +extensions = ["myst_parser", "sphinxcontrib.mermaid"] myst_enable_extensions = [ 'substitution' diff --git a/docs-es/requirements.txt b/docs-es/requirements.txt index 17ed7e25..5838e5f0 100644 --- a/docs-es/requirements.txt +++ b/docs-es/requirements.txt @@ -1,3 +1,4 @@ sphinx==6.2.1 sphinx_rtd_theme -myst-parser \ No newline at end of file +myst-parser +sphinxcontrib-mermaid diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index e06312e6..f538e847 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -62,6 +62,65 @@ netflow_version = 5 do_not_track_subnets = ["192.168.0.0/16"] ``` +### Contabilidad RADIUS (opcional) + +LibreQoS acepta una sección opcional `[radius_accounting]` para definir clientes NAS de confianza. Cuando está habilitada, `lqosd` inicia un servicio de contabilidad RADIUS, verifica paquetes de los clientes configurados, envía paquetes Accounting-Response para solicitudes aceptadas y mantiene el estado de sesión decodificado en memoria. Cuando `radius_accounting.dynamic_circuit_application.enabled` y la opción global `dynamic_circuits.enabled` están habilitadas, las sesiones Start e Interim-Update aptas se envían a la ruta de circuitos dinámicos. + +Para patrones de despliegue, configuración de un BNG PPPoE MikroTik y validación +del ciclo de vida, consulte [Contabilidad RADIUS y circuitos dinámicos](radius-es.md). + +Ejemplo: + +```toml +[dynamic_circuits] +enabled = true + +[radius_accounting] +enabled = true +listen = "0.0.0.0:1813" +default_ttl_seconds = 900 +stale_grace_seconds = 120 + +[radius_accounting.dynamic_circuit_application] +enabled = true +match_shaped_devices_by_mac = true +match_shaped_devices_by_username = true +# Nodo padre opcional para identidades RADIUS por defecto cuando no se usan metadatos MAC. +# fallback_parent_node = "Core PPPoE" +# fallback_parent_node_id = "core-pppoe" +# fallback_anchor_node_id = "radius-anchor" + +[radius_accounting.fallback_speed_profile] +download_min_mbps = 5.0 +upload_min_mbps = 3.0 +download_max_mbps = 25.0 +upload_max_mbps = 10.0 + +[[radius_accounting.clients]] +name = "pppoe-core-1" +source = ["192.0.2.10/32"] +secret_file = "/etc/lqos/radius-secrets/pppoe-core-1" +``` + +Notas: +- Omita la sección o configure `enabled = false` para mantener deshabilitada la contabilidad RADIUS. Puede omitir los clientes mientras está deshabilitada; cualquier entrada de cliente que configure se sigue validando. +- Con `radius_accounting.dynamic_circuit_application.enabled = false`, los paquetes de contabilidad se aceptan y se rastrean, pero no cambian el shaping. Cuando está habilitado, LibreQoS puede resolver sesiones aptas como definiciones `ShapedDevice` en memoria. Los circuitos dinámicos solo se aplican si también está configurado `dynamic_circuits.enabled = true` en la sección global. +- Los paquetes Accounting-Response se envían de forma independiente de la aplicación de circuitos dinámicos. Si falla una creación o actualización, `lqosd` registra los identificadores de circuito y sesión y sigue escuchando. +- La aplicación de circuitos dinámicos crea o actualiza circuitos desde paquetes Start e Interim-Update aptos para shaping. Los paquetes Stop, la expiración por TTL y la expiración de sesiones obsoletas por reinicio NAS envían `RemoveDynamicCircuit` para el circuito activo creado por RADIUS. Los paquetes Accounting-Response se siguen enviando sin esperar esa eliminación asíncrona; los fallos se registran con los identificadores de circuito y sesión. +- Configure `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_username = true` para comparar el valor RADIUS `User-Name` con el campo existente `MAC` de `ShapedDevices.csv`. Coloque un nombre de usuario en ese campo para usuarios PPPoE o DHCP-RADIUS; la coincidencia es literal. Una coincidencia única por nombre de usuario tiene prioridad sobre la coincidencia MAC. Los archivos anteriores con un campo MAC vacío y una columna `RADIUS Username` se leen de forma compatible, pero los archivos nuevos o reescritos usan solo el campo MAC. +- Configure `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_mac = true` para comparar los valores RADIUS `Calling-Station-Id` con los valores MAC de `ShapedDevices.csv` cuando no haya una fila con el nombre de usuario. LibreQoS normaliza formatos con dos puntos, guiones, puntos, hexadecimal sin separadores y combinaciones de mayúsculas/minúsculas antes de comparar. Una coincidencia única aporta los campos de circuito, dispositivo, nodo padre, override SQM y velocidades de `ShapedDevices.csv`; las velocidades decodificadas del paquete tienen prioridad. Las direcciones IPv4 e IPv6 activas vienen de la sesión RADIUS. Las coincidencias duplicadas por nombre de usuario o MAC dejan la sesión pendiente. +- El servicio RADIUS carga metadatos de identidad desde `ShapedDevices.csv` cuando inicia. Reinicie `lqosd` después de cambiar el valor MAC o nombre de usuario, nodo padre, circuito, dispositivo, SQM o velocidad que las coincidencias RADIUS deban usar. +- Configure `fallback_parent_node` cuando las identidades RADIUS sin coincidencia deban quedar aptas para shaping mediante el perfil de velocidad de respaldo. Sin `fallback_parent_node`, las sesiones sin coincidencia quedan pendientes por falta de metadatos de nodo padre. +- `fallback_parent_node`, `fallback_parent_node_id` y `fallback_anchor_node_id` se usan solo para identidades dinámicas sin coincidencia. LibreQoS deriva su ID de circuito estable del NAS más el RADIUS `User-Name`, o del NAS más `Calling-Station-Id` cuando no hay nombre de usuario. `Acct-Session-Id` se usa solo para el ciclo de vida, por lo que los clientes que se reconectan conservan un único ID de circuito. Los paquetes de accounting sin ninguna de esas identidades de abonado quedan pendientes. Las sesiones con coincidencia conservan los metadatos de circuito y nodo padre de su fila de `ShapedDevices.csv`. +- Una sesión RADIUS solo queda apta para shaping cuando LibreQoS tiene una identidad estable de NAS más `Acct-Session-Id`, una identidad de dispositivo, al menos una dirección IP o prefijo recibido por RADIUS, metadatos de conexión a un nodo padre y un perfil de velocidad resuelto. Las sesiones sin metadatos de nodo padre quedan pendientes. +- Cualquier valor configurado en `listen` debe ser una dirección de escucha IP:puerto con un puerto distinto de cero, como `0.0.0.0:1813`. Cuando `enabled = true`, configure al menos un cliente. Cada cliente configurado debe incluir al menos una entrada `source`. +- `source` acepta una cadena IP/CIDR o una lista de cadenas IP/CIDR. Las direcciones IP sin prefijo se aceptan como hosts individuales. +- Cada cliente configurado debe incluir un `secret_file` no vacío. `lqosd` lee este archivo cuando inicia el servicio y usa su contenido como secreto compartido. LibreQoS conserva la ruta configurada en `/etc/lqos.conf`. La salida de depuración generada a partir de este campo oculta la ruta, pero los paquetes de soporte que incluyan `/etc/lqos.conf` pueden mostrar esa ruta. +- `default_ttl_seconds` y `stale_grace_seconds` deben ser mayores que cero. +- Omita `[radius_accounting.fallback_speed_profile]` cuando las sesiones sin una velocidad decodificada utilizable en el paquete RADIUS ni una velocidad de coincidencia MAC en `ShapedDevices.csv` deban quedar pendientes con motivo de velocidad faltante. Si una fila coincidente de `ShapedDevices.csv` contiene velocidades inválidas, la sesión queda pendiente en lugar de usar el perfil de respaldo. +- Cuando la aplicación de circuitos dinámicos de RADIUS está habilitada, los valores del perfil de velocidad de respaldo deben ser finitos y mayores que cero. `download_min_mbps` no debe superar `download_max_mbps`, y `upload_min_mbps` no debe superar `upload_max_mbps`. +- Reinicie `lqosd` después de cambiar esta sección para recargar el servicio y los archivos de secreto compartido. + ### Integraciones con CRM/NMS Más información sobre [configuración de integraciones aquí.](integrations-es.md). diff --git a/docs-es/v2.0/radius-es.md b/docs-es/v2.0/radius-es.md new file mode 100644 index 00000000..0757e671 --- /dev/null +++ b/docs-es/v2.0/radius-es.md @@ -0,0 +1,191 @@ +# Contabilidad RADIUS y circuitos dinámicos + +LibreQoS puede recibir paquetes de contabilidad RADIUS de un BNG, NAS, +concentrador PPPoE o sistema DHCP-RADIUS y crear circuitos dinámicos para +abonados activos. Cuando un abonado recibe una dirección IP solo durante la +conexión, el circuito aparece con Accounting-Start, se actualiza con +Interim-Update y se elimina con Accounting-Stop. + +La contabilidad RADIUS no sustituye la topología normal. LibreQoS todavía +necesita un nodo padre y un perfil de velocidad utilizable antes de poder dar +forma a una sesión. + +## Colocar LibreQoS en la ruta de datos del abonado + +RADIUS es información del plano de control, no la ruta del tráfico. Coloque +LibreQoS en línea donde pueda inspeccionar el tráfico dentro de la sesión PPPoE: +el acceso de clientes pasa por LibreQoS hasta el BNG y después a Internet. Los +paquetes Accounting indican a LibreQoS qué circuito activo de abonado es dueño +del tráfico que ya puede observar. + +```{mermaid} +flowchart LR + CPE[CPE del cliente] --> ACCESS[Red de acceso] + ACCESS --> LQOS[Shaper LibreQoS en línea] + LQOS --> BNG[BNG / NAS PPPoE] + BNG --> INTERNET[Internet] + BNG -. Contabilidad RADIUS
identidad y ciclo de sesión .-> LQOS +``` + +LibreQoS no termina PPPoE y la contabilidad RADIUS no redirige el tráfico de +clientes a través de LibreQoS. El BNG termina PPPoE; LibreQoS usa la identidad +de contabilidad para crear y eliminar el circuito de shaping correspondiente. + +```{mermaid} +sequenceDiagram + participant CPE as CPE del abonado + participant BNG as BNG / NAS + participant RADIUS as Servidor RADIUS + participant LQOS as LibreQoS lqosd + participant Q as Circuito dinámico + + CPE->>BNG: Sesión PPPoE o DHCP + BNG->>RADIUS: Access-Request + RADIUS-->>BNG: Access-Accept (atributos de velocidad opcionales) + BNG->>LQOS: Accounting-Start + LQOS->>Q: Crear circuito + BNG->>LQOS: Accounting-Interim-Update + LQOS->>Q: Actualizar circuito + CPE->>BNG: Desconexión + BNG->>LQOS: Accounting-Stop + LQOS->>Q: Eliminar circuito +``` + +## Elegir la fuente de velocidad e identidad + +LibreQoS admite los tres patrones siguientes. Se pueden habilitar juntos; las +velocidades decodificadas del paquete tienen prioridad sobre una fila coincidente +de `ShapedDevices.csv`, y una fila coincidente tiene prioridad sobre el perfil +de respaldo. + +| Patrón | Fuente de identidad y velocidad | Uso adecuado | +| --- | --- | --- | +| Detalles completos de RADIUS | El BNG reenvía identidad y atributos de velocidad, como `Mikrotik-Rate-Limit`. | El sistema de abonados controla la velocidad. | +| Coincidencia con un dispositivo | `User-Name` o `Calling-Station-Id` coincide con el campo `MAC` de `ShapedDevices.csv`; la fila aporta circuito, nodo padre, SQM y velocidades. | Abonados PPPoE o DHCP-RADIUS ya representados en LibreQoS. | +| Valores predeterminados | Una identidad sin coincidencia usa `fallback_parent_*` y `fallback_speed_profile`. | Servicio predeterminado controlado o migración gradual. | + +El arnés de prueba cubre los tres casos: una velocidad del paquete de 10/25 +Mbps, una fila de usuario conocido de 60/20 Mbps y un usuario desconocido con +respaldo de 30/12 Mbps. También verifica Start, Interim-Update y Stop en cada +caso. + +## Configurar LibreQoS + +Habilite los circuitos dinámicos globales y defina un listener RADIUS de +clientes confiables. Restrinja cada cliente a la dirección o CIDR que envía los +paquetes Accounting—normalmente el proxy RADIUS en una topología AAA separada. +No exponga el listener a una red no confiable ni guarde el secreto compartido en +`lqos.conf`. + +```toml +[dynamic_circuits] +enabled = true + +[radius_accounting] +enabled = true +listen = "192.0.2.10:1813" +default_ttl_seconds = 900 +stale_grace_seconds = 120 + +[radius_accounting.dynamic_circuit_application] +enabled = true +match_shaped_devices_by_mac = true +match_shaped_devices_by_username = true +fallback_parent_node = "BNG Access" +fallback_parent_node_id = "bng-access" +fallback_anchor_node_id = "core" + +[radius_accounting.fallback_speed_profile] +download_min_mbps = 5.0 +upload_min_mbps = 2.0 +download_max_mbps = 30.0 +upload_max_mbps = 10.0 + +[[radius_accounting.clients]] +name = "radius-proxy-1" +source = ["192.0.2.20/32"] +secret_file = "/etc/libreqos/radius-secrets/radius-proxy-1" +``` + +Guarde el archivo de secreto con permisos exclusivos del propietario y reinicie +`lqosd` después de cambiar los ajustes RADIUS o la identidad, el nodo padre, el +circuito, SQM o las velocidades en `ShapedDevices.csv`. + +### Coincidir abonados PPPoE o DHCP-RADIUS + +El campo `MAC` es un campo de identidad opcional. Con la coincidencia MAC +habilitada, LibreQoS normaliza los formatos MAC de `Calling-Station-Id`. Con la +coincidencia por usuario habilitada, compara `User-Name` de RADIUS literalmente +con el mismo campo. No agregue una columna de usuario separada a archivos nuevos. + +```csv +Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment +pppoe-42,Cliente 42,pppoe-42,Cliente 42,BNG Access,bng-access,core,customer42@example.net,,,10,5,60,20,Identidad de usuario PPPoE +``` + +Para DHCP-RADIUS, coloque el nombre de usuario DHCP en este campo. Para un NAS +basado en MAC, coloque allí la MAC del abonado. Una coincidencia única por +usuario tiene prioridad sobre la coincidencia MAC. Las identidades duplicadas +dejan la sesión pendiente en lugar de seleccionar un circuito arbitrario. + +## Construir un BNG PPPoE MikroTik + +El siguiente ejemplo de RouterOS es un esquema pequeño, no una configuración +completa de router de producción. Sustituya las interfaces, direcciones, rango +del pool, dirección del proxy y secreto por los de su red. + +RouterOS envía tanto autenticación como contabilidad a la `address` de una sola +entrada `/radius add`. Cuando AAA y LibreQoS son hosts separados, apunte RouterOS +a un proxy RADIUS. El proxy reenvía el tráfico Access-Request a AAA y los +paquetes Accounting a LibreQoS. Configure LibreQoS para confiar en la dirección +de origen estable del proxy, no en la dirección del BNG. + +```routeros +/ip pool add name=pppoe-subscribers ranges=100.64.0.2-100.64.255.254 +/ppp profile add name=pppoe-radius local-address=100.64.0.1 remote-address=pppoe-subscribers use-encryption=no +/interface pppoe-server server add interface=ether3 service-name=internet default-profile=pppoe-radius authentication=pap one-session-per-host=yes disabled=no + +/radius add service=ppp address=192.0.2.20 src-address=192.0.2.1 authentication-port=1812 accounting-port=1813 secret=replace-this-secret +/ppp aaa set use-radius=yes accounting=yes interim-update=5m +``` + +Permita UDP 1812 y 1813 entre el BNG y el proxy, y UDP 1813 desde el proxy hasta +LibreQoS. LibreQoS necesita paquetes de contabilidad, no paquetes Access-Request +ni Access-Accept. El proxy gestiona la relación de secreto compartido en ambos +tramos y es el cliente RADIUS incluido en `radius_accounting.clients`. + +```{mermaid} +flowchart LR + CPE[CPE del abonado] -->|PPPoE| BNG[BNG MikroTik] + BNG -->|UDP 1812/1813| PROXY[Proxy RADIUS] + PROXY -->|Access-Request / Accept| AUTH[FreeRADIUS o AAA] + PROXY -->|Accounting Start / Interim / Stop| LQOS[LibreQoS] + LQOS --> DYN[Circuitos dinámicos] + DYN --> TREE[Topología LibreQoS] +``` + +Cuando use límites de velocidad MikroTik, pruebe el mapeo de direcciones con una +captura de paquetes real o con el arnés. El arnés devuelve deliberadamente +`25M/10M` y verifica el resultado de contabilidad como 10 Mbps de descarga y 25 +Mbps de subida. + +## Validar antes de producción + +Use el [arnés PPPoE RADIUS](../../radius-harness/README.md) incluido en un host +libvirt/KVM para probar un checkout de LibreQoS sin instalar los artefactos Rust +en el huésped. Crea VMs descartables de RouterOS, FreeRADIUS, LibreQoS y cliente +PPPoE, y las elimina con `down`. + +Para un BNG de producción, verifique este ciclo de vida con un abonado de prueba: + +1. Conéctelo y confirme que LibreQoS registra un Accounting-Start aceptado y + crea un circuito dinámico con el nodo padre y las velocidades esperadas. +2. Espere un Accounting-Interim-Update y confirme que el circuito conserva el + perfil de velocidad esperado. +3. Desconéctelo y confirme que Accounting-Stop elimina el circuito. + +Si una sesión queda pendiente, revise primero la fuente del cliente confiable y +el secreto compartido; después la identidad del abonado, la dirección IP o +prefijo recibido, el nodo padre de topología y la fuente de velocidad. Consulte +la [referencia avanzada de configuración](configuration-advanced-es.md#contabilidad-radius-opcional) +para el contrato de configuración completo. diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index 293bc17e..56edbb1c 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -195,6 +195,65 @@ Notes: - `attach_to` is a `network.json` node name (optional; empty is allowed). - Unknown-IP promotions are applied to Bakery asynchronously. Repeated observations for the same promoted circuit are deduplicated while the live overlay is queued or waiting for Bakery. +#### RADIUS accounting (optional) + +LibreQoS accepts an optional `[radius_accounting]` section for trusted NAS client settings. When enabled, `lqosd` starts a RADIUS accounting listener, verifies packets from configured clients, sends Accounting-Response packets for accepted requests, and keeps the decoded session state in memory. When both `radius_accounting.dynamic_circuit_application.enabled` and top-level `dynamic_circuits.enabled` are true, shapeable Start and Interim-Update sessions are submitted to the dynamic-circuit path. + +For deployment patterns, MikroTik PPPoE BNG setup, and lifecycle validation, see +[RADIUS accounting and dynamic circuits](radius.md). + +Example: + +```toml +[dynamic_circuits] +enabled = true + +[radius_accounting] +enabled = true +listen = "0.0.0.0:1813" +default_ttl_seconds = 900 +stale_grace_seconds = 120 + +[radius_accounting.dynamic_circuit_application] +enabled = true +match_shaped_devices_by_mac = true +match_shaped_devices_by_username = true +# Optional fallback parent for default RADIUS identities when MAC metadata is not used. +# fallback_parent_node = "Core PPPoE" +# fallback_parent_node_id = "core-pppoe" +# fallback_anchor_node_id = "radius-anchor" + +[radius_accounting.fallback_speed_profile] +download_min_mbps = 5.0 +upload_min_mbps = 3.0 +download_max_mbps = 25.0 +upload_max_mbps = 10.0 + +[[radius_accounting.clients]] +name = "pppoe-core-1" +source = ["192.0.2.10/32"] +secret_file = "/etc/lqos/radius-secrets/pppoe-core-1" +``` + +Notes: +- Omit the section or set `enabled = false` to keep RADIUS accounting disabled. Clients may be omitted while it is disabled; any client entries you configure are still validated. +- With `radius_accounting.dynamic_circuit_application.enabled = false`, accounting packets are accepted and tracked but do not change shaping. With it enabled, LibreQoS can resolve eligible sessions into `ShapedDevice` definitions in memory. Dynamic circuits are applied only when top-level `dynamic_circuits.enabled = true` is also configured. +- Accounting-Response packets are sent independently from dynamic-circuit application. If a create or update fails, `lqosd` logs the circuit and session identifiers and keeps listening. +- Dynamic-circuit application creates or updates circuits from shapeable Start and Interim-Update packets. Stop packets, TTL expiry, and stale NAS reset expiry submit `RemoveDynamicCircuit` for the active RADIUS-created circuit. Accounting-Response packets are still sent without waiting for that asynchronous removal; failures are logged with the circuit and session identifiers. +- Set `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_username = true` to match the RADIUS `User-Name` against the existing `MAC` field in `ShapedDevices.csv`. Put a username in that field for PPPoE or DHCP-RADIUS users; matching is verbatim. A unique username match takes priority over MAC matching. Older files with an empty MAC field and a `RADIUS Username` column are read compatibly, but new or rewritten files use the MAC field only. +- Set `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_mac = true` to match RADIUS `Calling-Station-Id` values against `ShapedDevices.csv` MAC values when no username row matches. LibreQoS normalizes colon, hyphen, dotted, plain-hex, and mixed-case MAC formats before matching. A unique match supplies the circuit, device, parent, SQM override, and ShapedDevices speed fields; packet-decoded rates still take priority. The active IPv4 and IPv6 addresses come from the RADIUS session. Duplicate username or MAC matches leave the session pending. +- The RADIUS listener loads identity-match metadata from `ShapedDevices.csv` when it starts. Restart `lqosd` after changing a MAC or username value, parent, circuit, device, SQM, or speed fields that RADIUS matching should use. +- Configure `fallback_parent_node` when unmatched RADIUS identities should become shapeable from the fallback speed profile. Without `fallback_parent_node`, unmatched sessions remain pending with missing parent metadata. +- `fallback_parent_node`, `fallback_parent_node_id`, and `fallback_anchor_node_id` are used only for unmatched dynamic identities. LibreQoS derives their stable circuit ID from the NAS plus RADIUS `User-Name`, or from the NAS plus `Calling-Station-Id` when no username is supplied. `Acct-Session-Id` remains lifecycle state only, so reconnecting customers retain one circuit ID. Accounting packets without either subscriber identity remain pending. Matched sessions keep the circuit and parent metadata from their `ShapedDevices.csv` row. +- A RADIUS session is shapeable only after LibreQoS has a stable NAS plus `Acct-Session-Id` identity, a device identity, at least one framed or delegated IP address or prefix, parent attachment metadata, and a resolved speed profile. Sessions without parent metadata remain pending. +- Any configured `listen` value must be an IP:port listen address with a non-zero port, such as `0.0.0.0:1813`. When `enabled = true`, configure at least one client. Each configured client must include at least one `source` entry. +- `source` accepts one IP/CIDR string or a list of IP/CIDR strings. Bare IP addresses are accepted as host sources. +- Each configured client must include a non-empty `secret_file`. `lqosd` reads this file when the listener starts and uses its contents as the shared secret. LibreQoS preserves the configured path in `/etc/lqos.conf`. Debug output generated from this config field hides the configured path, but `/etc/lqos.conf` and support bundles that include it can still show the path. +- `default_ttl_seconds` and `stale_grace_seconds` must be greater than zero. +- Omit `[radius_accounting.fallback_speed_profile]` when sessions without a usable decoded packet rate or ShapedDevices MAC-match rate should stay pending with a missing-rate reason. If a matched `ShapedDevices.csv` row contains invalid speed fields, the session stays pending instead of falling back. +- When RADIUS dynamic-circuit application is enabled, fallback speed values must be finite and greater than zero. `download_min_mbps` must not exceed `download_max_mbps`, and `upload_min_mbps` must not exceed `upload_max_mbps`. +- Restart `lqosd` after changing this section so the listener and shared-secret files are reloaded. + #### CRM/NMS Integrations Learn more about [configuring integrations here](integrations.md). diff --git a/docs/v2.0/radius.md b/docs/v2.0/radius.md new file mode 100644 index 00000000..f3adeb91 --- /dev/null +++ b/docs/v2.0/radius.md @@ -0,0 +1,184 @@ +# RADIUS accounting and dynamic circuits + +LibreQoS can receive RADIUS Accounting packets from a broadband network gateway +(BNG), NAS, PPPoE concentrator, or DHCP-RADIUS system and create dynamic +circuits for active subscribers. It is useful when subscribers receive an IP +address only while connected: the circuit appears at Accounting-Start, is +refreshed by Interim-Update, and is removed at Accounting-Stop. + +RADIUS accounting does not replace the normal topology. LibreQoS still needs a +parent attachment and a usable speed profile before it can shape a session. + +## Place LibreQoS in the subscriber data path + +RADIUS is control-plane data, not the traffic path. Place LibreQoS inline where +it can inspect traffic inside the PPPoE session: customer access flows through +LibreQoS to the BNG, then to the Internet. The Accounting packets tell LibreQoS +which active subscriber circuit owns the traffic it can already observe. + +```{mermaid} +flowchart LR + CPE[Customer CPE] --> ACCESS[Access network] + ACCESS --> LQOS[LibreQoS inline shaper] + LQOS --> BNG[PPPoE BNG / NAS] + BNG --> INTERNET[Internet] + BNG -. RADIUS Accounting
identity and session lifecycle .-> LQOS +``` + +LibreQoS does not terminate PPPoE and RADIUS accounting does not redirect +customer traffic through LibreQoS. The BNG terminates PPPoE; LibreQoS uses the +accounting identity to create and remove the corresponding shaping circuit. + +```{mermaid} +sequenceDiagram + participant CPE as Subscriber CPE + participant BNG as BNG / NAS + participant RADIUS as RADIUS server + participant LQOS as LibreQoS lqosd + participant Q as Dynamic circuit + + CPE->>BNG: PPPoE or DHCP session + BNG->>RADIUS: Access-Request + RADIUS-->>BNG: Access-Accept (optional rate attributes) + BNG->>LQOS: Accounting-Start + LQOS->>Q: Create circuit + BNG->>LQOS: Accounting-Interim-Update + LQOS->>Q: Refresh circuit + CPE->>BNG: Disconnect + BNG->>LQOS: Accounting-Stop + LQOS->>Q: Remove circuit +``` + +## Choose a speed and identity source + +LibreQoS supports these three deployment patterns. They can be enabled together; +packet-decoded rates take precedence over a matching `ShapedDevices.csv` row, +and a matching row takes precedence over the fallback profile. + +| Pattern | Identity and speed source | Suitable use | +| --- | --- | --- | +| Full RADIUS details | The BNG forwards subscriber identity and rate attributes such as MikroTik `Mikrotik-Rate-Limit`. | The subscriber management system owns rate selection. | +| Match a shaped device | `User-Name` or `Calling-Station-Id` matches the existing `MAC` field in `ShapedDevices.csv`; the row supplies circuit, parent, SQM, and rates. | PPPoE or DHCP-RADIUS subscribers already represented in LibreQoS. | +| Defaults | An unmatched identity uses `fallback_parent_*` and `fallback_speed_profile`. | A controlled default service or gradual migration. | + +The test harness exercises all three: a packet rate of 10/25 Mbps, a known +username row at 60/20 Mbps, and an unknown username with a 30/12 Mbps fallback. +It also verifies Start, Interim-Update, and Stop behavior for each case. + +## Configure LibreQoS + +Enable global dynamic circuits and define a trusted RADIUS listener. Restrict +each client to the source address or CIDR that sends Accounting packets—typically +the RADIUS proxy in a split AAA topology. Never expose the listener to an +untrusted network or store the shared secret directly in `lqos.conf`. + +```toml +[dynamic_circuits] +enabled = true + +[radius_accounting] +enabled = true +listen = "192.0.2.10:1813" +default_ttl_seconds = 900 +stale_grace_seconds = 120 + +[radius_accounting.dynamic_circuit_application] +enabled = true +match_shaped_devices_by_mac = true +match_shaped_devices_by_username = true +fallback_parent_node = "BNG Access" +fallback_parent_node_id = "bng-access" +fallback_anchor_node_id = "core" + +[radius_accounting.fallback_speed_profile] +download_min_mbps = 5.0 +upload_min_mbps = 2.0 +download_max_mbps = 30.0 +upload_max_mbps = 10.0 + +[[radius_accounting.clients]] +name = "radius-proxy-1" +source = ["192.0.2.20/32"] +secret_file = "/etc/libreqos/radius-secrets/radius-proxy-1" +``` + +Create the secret file with owner-only permissions, then restart `lqosd` after +changing RADIUS settings or the identity, parent, circuit, SQM, or rate data in +`ShapedDevices.csv`. + +### Match PPPoE or DHCP-RADIUS subscribers + +The `MAC` field is an optional identity field. With MAC matching enabled, +LibreQoS normalizes `Calling-Station-Id` MAC formats. With username matching +enabled, it compares RADIUS `User-Name` to the same field verbatim. Do not add a +separate username column to new files. + +```csv +Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment +pppoe-42,Customer 42,pppoe-42,Customer 42,BNG Access,bng-access,core,customer42@example.net,,,10,5,60,20,PPPoE username identity +``` + +For a DHCP-RADIUS installation, place the DHCP username in this field instead. +For a MAC-based NAS, place the subscriber MAC there. A unique username match is +preferred before MAC matching. Duplicate identity values leave the session +pending rather than selecting an arbitrary circuit. + +## Build a MikroTik PPPoE BNG + +The following is a small RouterOS outline, not a complete production router +configuration. Replace interface names, addresses, pool ranges, proxy address, +and secret for your network. + +RouterOS sends both authentication and accounting to the `address` in one +`/radius add` entry. When AAA and LibreQoS are separate hosts, point RouterOS at +a RADIUS proxy. The proxy forwards Access-Request traffic to AAA and forwards +Accounting packets to LibreQoS. Configure LibreQoS to trust the proxy's stable +source address, not the BNG address. + +```routeros +/ip pool add name=pppoe-subscribers ranges=100.64.0.2-100.64.255.254 +/ppp profile add name=pppoe-radius local-address=100.64.0.1 remote-address=pppoe-subscribers use-encryption=no +/interface pppoe-server server add interface=ether3 service-name=internet default-profile=pppoe-radius authentication=pap one-session-per-host=yes disabled=no + +/radius add service=ppp address=192.0.2.20 src-address=192.0.2.1 authentication-port=1812 accounting-port=1813 secret=replace-this-secret +/ppp aaa set use-radius=yes accounting=yes interim-update=5m +``` + +Allow UDP 1812 and 1813 between the BNG and the proxy, and UDP 1813 from the +proxy to LibreQoS. LibreQoS needs Accounting packets, not Access-Request or +Access-Accept packets. The proxy owns the shared-secret relationship on both +legs and is the RADIUS client listed in `radius_accounting.clients`. + +```{mermaid} +flowchart LR + CPE[Subscriber CPE] -->|PPPoE| BNG[MikroTik BNG] + BNG -->|UDP 1812/1813| PROXY[RADIUS proxy] + PROXY -->|Access-Request / Accept| AUTH[FreeRADIUS or AAA] + PROXY -->|Accounting Start / Interim / Stop| LQOS[LibreQoS] + LQOS --> DYN[Dynamic circuits] + DYN --> TREE[LibreQoS topology] +``` + +When using MikroTik rate limits, test the direction mapping with a real packet +capture or the harness. The harness deliberately returns `25M/10M` and verifies +the accounting result as 10 Mbps download and 25 Mbps upload. + +## Validate before production + +Use the committed [RADIUS PPPoE harness](../../radius-harness/README.md) on a +libvirt/KVM host to test a LibreQoS checkout without installing Rust artifacts +inside the guest. It creates disposable RouterOS, FreeRADIUS, LibreQoS, and +PPPoE-client VMs, then removes them with `down`. + +For a production BNG, verify this lifecycle for one test subscriber: + +1. Connect and confirm LibreQoS logs an accepted Accounting-Start and creates a + dynamic circuit with the expected parent and rates. +2. Wait for an Accounting-Interim-Update and confirm the circuit remains with + the expected rate profile. +3. Disconnect and confirm Accounting-Stop removes the circuit. + +If a session remains pending, first check the trusted client source and shared +secret, then the subscriber identity, received IP address or prefix, topology +parent, and speed source. See the [advanced configuration reference](configuration-advanced.md#radius-accounting-optional) +for the complete configuration contract. diff --git a/radius-harness/.gitignore b/radius-harness/.gitignore new file mode 100644 index 00000000..6aac2f89 --- /dev/null +++ b/radius-harness/.gitignore @@ -0,0 +1,3 @@ +/artifacts/ +/images/ +/run/ diff --git a/radius-harness/README.md b/radius-harness/README.md new file mode 100644 index 00000000..c1a0d1c9 --- /dev/null +++ b/radius-harness/README.md @@ -0,0 +1,78 @@ +# LibreQoS RADIUS PPPoE harness + +This disposable libvirt harness verifies the RADIUS accounting lifecycle end to +end: a RouterOS CHR NAS authenticates a PPPoE client with FreeRADIUS, sends +accounting to a root-run LibreQoS guest, and the test verifies dynamic-circuit +creation, profile selection, Interim-Update, and removal on disconnect. + +It creates `radius-*` libvirt domains and networks, sparse qcow2 overlays, and +temporary `vbr-r*` host-side bridge interfaces for its isolated networks. +`./radius-harness/lab down` removes them with the overlays and VMs. `purge` +additionally removes downloaded base images and the host-built runtime bundle. +The harness does not change existing host interfaces, LibreQoS services, or +host shaping. + +The `radius-*` domain and network names are reserved for this harness. Do not +run `down` or `purge` on a host where another workload intentionally uses one +of those names. + +## Requirements + +Run from a LibreQoS checkout on a host with libvirt/KVM and OVMF/edk2 UEFI +firmware. Install `virsh`, `virt-install`, `qemu-img`, `cloud-localds` (from +`cloud-image-utils`), `curl`, `unzip`, `rsync`, `sshpass`, and a Rust toolchain. +The invoking account must be allowed to manage the configured libvirt URI +(default: `qemu:///system`). + +The harness discovers common OVMF locations. If your distribution stores its +firmware elsewhere, set `OVMF_CODE_PATH` and `OVMF_VARS_PATH` in the environment +before `up`. + +The pinned image hashes are in [lab.env](lab.env). Review and update the URL, +version, and SHA-256 together when changing an image. + +## Run + +Set the one-time RouterOS administrator password and the lab-only RADIUS shared +secret. They are never committed and are written only to ignored run-state and +disposable guests. + +```bash +export ROUTEROS_ADMIN_PASSWORD='a-routeros-password' +export RADIUS_SHARED_SECRET='a-lab-radius-secret' + +./radius-harness/lab init +./radius-harness/lab up +./radius-harness/lab console # set the RouterOS admin password once; exit with Ctrl+] +./radius-harness/lab configure +./radius-harness/lab test +./radius-harness/lab down +``` + +`init` downloads the pinned Ubuntu and RouterOS bases once and builds `lqosd` +and `lqos_python` on the host. The guest receives only the resulting runtime +bundle, not the Rust build tree. + +The test covers three cases: + +1. A RADIUS rate-limit attribute supplies the queue speed. +2. A known PPPoE username in the `MAC` field of `ShapedDevices.csv` supplies + the circuit identity and speed when RADIUS supplies no rate. +3. An unknown username receives the configured fallback queue. + +The harness uses temporary IPv4 ranges `198.18.10.0/24`, `198.18.30.0/24`, and +`100.64.0.0/16`. If those conflict with local infrastructure, change the +templates before running it. + +## Commands + +```text +./radius-harness/lab init Download bases and build the runtime bundle. +./radius-harness/lab up Create networks, overlays, and VMs. +./radius-harness/lab status Show domains and management addresses. +./radius-harness/lab configure Install fixtures and start services. +./radius-harness/lab test Run the lifecycle assertions. +./radius-harness/lab down Remove VMs, networks, overlays, and secrets. +./radius-harness/lab purge Also remove cached images and runtime artifacts. +./radius-harness/lab console Open the RouterOS serial console. +``` diff --git a/radius-harness/lab b/radius-harness/lab new file mode 100755 index 00000000..da698b58 --- /dev/null +++ b/radius-harness/lab @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "$0")" && pwd) +source "$LAB_ROOT/lib/common.sh" +source "$LAB_ROOT/lab.env" + +readonly UBUNTU_BASE="$IMAGE_DIR/$(basename "$UBUNTU_IMAGE_URL")" +readonly ROUTER_ARCHIVE="$IMAGE_DIR/chr-$ROUTEROS_VERSION.img.zip" +readonly ROUTER_BASE="$IMAGE_DIR/chr-$ROUTEROS_VERSION.qcow2" +readonly SSH_KEY="$RUN_DIR/id_ed25519" +OVMF_CODE= +OVMF_VARS= + +usage() { + cat <<'EOF' +Usage: ./radius-harness/lab + +Commands: init up status configure test down purge console +EOF +} + +require_host_tools() { + for tool in virsh virt-install qemu-img cloud-localds curl unzip sha256sum rsync ssh scp; do require_command "$tool"; done +} + +ensure_ssh_key() { + mkdir -p "$RUN_DIR" + [[ -f $SSH_KEY ]] || ssh-keygen -q -t ed25519 -N '' -f "$SSH_KEY" +} + +resolve_ovmf() { + local code_path vars_path + if [[ -n ${OVMF_CODE_PATH:-} || -n ${OVMF_VARS_PATH:-} ]]; then + [[ -n ${OVMF_CODE_PATH:-} && -n ${OVMF_VARS_PATH:-} ]] || die "set both OVMF_CODE_PATH and OVMF_VARS_PATH" + [[ -r $OVMF_CODE_PATH && -r $OVMF_VARS_PATH ]] || die "configured OVMF firmware paths are not readable" + OVMF_CODE=$OVMF_CODE_PATH + OVMF_VARS=$OVMF_VARS_PATH + return + fi + + for code_path in \ + /usr/share/OVMF/OVMF_CODE_4M.fd \ + /usr/share/OVMF/OVMF_CODE.fd \ + /usr/share/edk2-ovmf/x64/OVMF_CODE.fd \ + /usr/share/edk2/ovmf/OVMF_CODE.fd; do + vars_path=${code_path/CODE/VARS} + if [[ -r $code_path && -r $vars_path ]]; then + OVMF_CODE=$code_path + OVMF_VARS=$vars_path + return + fi + done + die "OVMF firmware was not found; install OVMF/edk2 or set OVMF_CODE_PATH and OVMF_VARS_PATH" +} + +download_routeros() { + [[ -n ${ROUTEROS_CHR_SHA256:-} ]] || die "set ROUTEROS_CHR_SHA256 in lab.env before downloading RouterOS" + if [[ ! -f $ROUTER_ARCHIVE ]]; then + note "downloading RouterOS CHR $ROUTEROS_VERSION" + curl --fail --location --output "$ROUTER_ARCHIVE.partial" "$ROUTEROS_CHR_URL" + printf '%s %s\n' "$ROUTEROS_CHR_SHA256" "$ROUTER_ARCHIVE.partial" | sha256sum --check --status || die "RouterOS archive checksum did not match" + mv "$ROUTER_ARCHIVE.partial" "$ROUTER_ARCHIVE" + fi + printf '%s %s\n' "$ROUTEROS_CHR_SHA256" "$ROUTER_ARCHIVE" | sha256sum --check --status || die "cached RouterOS archive checksum did not match" + if [[ ! -f $ROUTER_BASE ]]; then + unzip -p "$ROUTER_ARCHIVE" >"$IMAGE_DIR/chr-$ROUTEROS_VERSION.img" + qemu-img convert -f raw -O qcow2 "$IMAGE_DIR/chr-$ROUTEROS_VERSION.img" "$ROUTER_BASE" + rm -f "$IMAGE_DIR/chr-$ROUTEROS_VERSION.img" + fi +} + +download_ubuntu() { + [[ -n ${UBUNTU_IMAGE_SHA256:-} ]] || die "set UBUNTU_IMAGE_SHA256 in lab.env before downloading Ubuntu" + if [[ ! -f $UBUNTU_BASE ]]; then + note "downloading Ubuntu 24.04 cloud image" + curl --fail --location --output "$UBUNTU_BASE.partial" "$UBUNTU_IMAGE_URL" + printf '%s %s\n' "$UBUNTU_IMAGE_SHA256" "$UBUNTU_BASE.partial" | sha256sum --check --status || die "Ubuntu image checksum did not match" + mv "$UBUNTU_BASE.partial" "$UBUNTU_BASE" + fi + printf '%s %s\n' "$UBUNTU_IMAGE_SHA256" "$UBUNTU_BASE" | sha256sum --check --status || die "cached Ubuntu image checksum did not match" +} + +build_runtime_bundle() { + [[ -d $REPO_ROOT/src/rust ]] || die "LibreQoS checkout not found: $REPO_ROOT" + note "building lqosd and lqos_python on the host" + (cd "$REPO_ROOT/src/rust" && cargo build --release -p lqosd -p lqos_python) + safe_remove "$RUNTIME_DIR" + mkdir -p "$RUNTIME_DIR/bin" "$RUNTIME_DIR/src" + install -m 0755 "$REPO_ROOT/src/rust/target/release/lqosd" "$RUNTIME_DIR/bin/lqosd" + install -m 0755 "$REPO_ROOT/src/rust/target/release/liblqos_python.so" "$RUNTIME_DIR/liblqos_python.so" + rsync -a --delete --exclude rust --exclude dist --exclude bin --exclude geo2.bin --exclude lts2 --exclude '*.pyc' --exclude '__pycache__' "$REPO_ROOT/src/" "$RUNTIME_DIR/src/" +} + +create_network() { local name=$1 xml=$2; if ! network_exists "$name"; then virsh_lab net-define "$xml"; virsh_lab net-start "$name"; fi; } +destroy_network() { local name=$1; if network_exists "$name"; then virsh_lab net-destroy "$name" >/dev/null 2>&1 || true; virsh_lab net-undefine "$name"; fi; } +create_overlay() { local name=$1 backing=$2 size=${3:-}; if [[ -n $size ]]; then qemu-img create -f qcow2 -F qcow2 -b "$backing" "$RUN_DIR/$name.qcow2" "$size" >/dev/null; else qemu-img create -f qcow2 -F qcow2 -b "$backing" "$RUN_DIR/$name.qcow2" >/dev/null; fi; } + +render_cloud_init() { + local name=$1 network_config=$2 + sed "s|__LAB_SSH_KEY__|$(<"$SSH_KEY.pub")|" "$LAB_ROOT/templates/user-data.yaml" >"$RUN_DIR/$name-user-data" + cloud-localds --network-config="$network_config" "$RUN_DIR/$name-seed.iso" "$RUN_DIR/$name-user-data" +} + +install_linux_domain() { + local name=$1 memory=$2 vcpus=$3 network_args=$4 + install -m 0644 "$OVMF_VARS" "$RUN_DIR/$name-vars.fd" + virt-install --connect "$LIBVIRT_URI" --name "$name" --memory "$memory" --vcpus "$vcpus" --import --boot "loader=$OVMF_CODE,loader.type=pflash,loader.readonly=yes,loader.secure=no,nvram=$RUN_DIR/$name-vars.fd" --disk "path=$RUN_DIR/$name.qcow2,format=qcow2,bus=virtio" --disk "path=$RUN_DIR/$name-seed.iso,device=cdrom" --os-variant ubuntu24.04 --graphics vnc,listen=127.0.0.1 --noautoconsole $network_args +} + +create_domains() { + create_overlay "$LQOS_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$RADIUS_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$CLIENT_DOMAIN" "$UBUNTU_BASE" 8G; create_overlay "$ROUTER_DOMAIN" "$ROUTER_BASE" + install_linux_domain "$LQOS_DOMAIN" 4096 2 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:10:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:10:00:11 --network network=$TRANSIT_NETWORK,model=virtio,mac=52:54:00:10:00:12" + install_linux_domain "$RADIUS_DOMAIN" 512 1 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:20:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:20:00:11" + install_linux_domain "$CLIENT_DOMAIN" 512 1 "--network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:30:00:10 --network network=$ACCESS_NETWORK,model=virtio,mac=02:00:00:00:00:42" + virt-install --connect "$LIBVIRT_URI" --name "$ROUTER_DOMAIN" --memory 512 --vcpus 1 --import --disk "path=$RUN_DIR/$ROUTER_DOMAIN.qcow2,format=qcow2,bus=virtio" --os-variant generic --graphics none --noautoconsole --network network=$MANAGEMENT_NETWORK,model=virtio,mac=52:54:00:40:00:10 --network network=$CONTROL_NETWORK,model=virtio,mac=52:54:00:40:00:11 --network network=$ACCESS_NETWORK,model=virtio,mac=52:54:00:40:00:12 --network network=$TRANSIT_NETWORK,model=virtio,mac=52:54:00:40:00:13 + sleep 5 +} + +destroy_domain() { local name=$1; if domain_exists "$name"; then virsh_lab destroy "$name" >/dev/null 2>&1 || true; virsh_lab undefine "$name" --nvram >/dev/null 2>&1 || virsh_lab undefine "$name"; fi; } +cmd_init() { require_host_tools; mkdir -p "$IMAGE_DIR" "$RUN_DIR"; download_ubuntu; download_routeros; build_runtime_bundle; note "lab bases and runtime bundle are ready"; } +cmd_up() { + require_host_tools; resolve_ovmf; [[ -f $UBUNTU_BASE && -f $ROUTER_BASE && -x $RUNTIME_DIR/bin/lqosd ]] || die "run ./radius-harness/lab init first"; ensure_lab_stopped + trap 'status=$?; trap - ERR; note "lab creation failed; removing partial resources"; cmd_down || true; exit "$status"' ERR + safe_remove "$RUN_DIR"; mkdir -p "$RUN_DIR"; ensure_ssh_key + for guest in lqos radius client; do cp "$LAB_ROOT/templates/$guest-network.yaml" "$RUN_DIR/$guest-network.yaml"; done + render_cloud_init "$LQOS_DOMAIN" "$RUN_DIR/lqos-network.yaml"; render_cloud_init "$RADIUS_DOMAIN" "$RUN_DIR/radius-network.yaml"; render_cloud_init "$CLIENT_DOMAIN" "$RUN_DIR/client-network.yaml" + create_network "$CONTROL_NETWORK" "$LAB_ROOT/templates/network-control.xml"; create_network "$ACCESS_NETWORK" "$LAB_ROOT/templates/network-access.xml"; create_network "$TRANSIT_NETWORK" "$LAB_ROOT/templates/network-transit.xml"; create_domains + trap - ERR; note "VMs created. Use ./radius-harness/lab console once to set RouterOS admin password, then run ./radius-harness/lab configure." +} +cmd_status() { for item in "$LQOS_DOMAIN 52:54:00:10:00:10" "$RADIUS_DOMAIN 52:54:00:20:00:10" "$CLIENT_DOMAIN 52:54:00:30:00:10" "$ROUTER_DOMAIN 52:54:00:40:00:10"; do set -- $item; printf '%-20s %s\n' "$1" "$(management_ip "$2" || true)"; done; } +cmd_console() { domain_exists "$ROUTER_DOMAIN" || die "RouterOS domain is not running; use ./radius-harness/lab up first"; virsh_lab console "$ROUTER_DOMAIN"; } +cmd_configure() { "$LAB_ROOT/lib/configure.sh"; } +cmd_test() { "$LAB_ROOT/lib/test-lifecycle.sh"; } +cmd_down() { for domain in "$CLIENT_DOMAIN" "$RADIUS_DOMAIN" "$ROUTER_DOMAIN" "$LQOS_DOMAIN"; do destroy_domain "$domain"; done; for network in "$TRANSIT_NETWORK" "$ACCESS_NETWORK" "$CONTROL_NETWORK"; do destroy_network "$network"; done; [[ -d $RUN_DIR ]] && safe_remove "$RUN_DIR"; note "disposable lab resources removed"; } +cmd_purge() { cmd_down; [[ -d $IMAGE_DIR ]] && safe_remove "$IMAGE_DIR"; [[ -d $RUNTIME_DIR ]] && safe_remove "$RUNTIME_DIR"; note "cached bases and runtime artifacts removed"; } + +case ${1:-} in init) cmd_init ;; up) cmd_up ;; status) cmd_status ;; configure) cmd_configure ;; test) cmd_test ;; down) cmd_down ;; purge) cmd_purge ;; console) cmd_console ;; *) usage; exit 2 ;; esac diff --git a/radius-harness/lab.env b/radius-harness/lab.env new file mode 100644 index 00000000..61c872a5 --- /dev/null +++ b/radius-harness/lab.env @@ -0,0 +1,18 @@ +# Pinned RouterOS long-term CHR release. Update version, URL, and SHA together. +ROUTEROS_VERSION=7.21.5 +ROUTEROS_CHR_URL=https://download.mikrotik.com/routeros/7.21.5/chr-7.21.5.img.zip +ROUTEROS_CHR_SHA256=dbb8f1739da73a68e5167fcbc731a5ba3d50f9d9b195731b96f96fcc58904580 + +# Pinned Ubuntu 24.04 cloud image. Update URL and SHA together. +UBUNTU_IMAGE_URL=https://cloud-images.ubuntu.com/releases/noble/release-20260518/ubuntu-24.04-server-cloudimg-amd64.img +UBUNTU_IMAGE_SHA256=53fdde898feed8b027d94baa9cfe8229867f330a1d9c49dc7d84465ee7f229f7 + +# Set these before `./radius-harness/lab configure`; they are deliberately not +# committed here: +# export ROUTEROS_ADMIN_PASSWORD='choose-a-password' +# export RADIUS_SHARED_SECRET='choose-a-shared-secret' + +# Optional UEFI firmware overrides for distributions with non-standard OVMF +# locations. Set both paths together when automatic discovery does not find one. +# export OVMF_CODE_PATH=/path/to/OVMF_CODE.fd +# export OVMF_VARS_PATH=/path/to/OVMF_VARS.fd diff --git a/radius-harness/lib/common.sh b/radius-harness/lib/common.sh new file mode 100755 index 00000000..af4933ed --- /dev/null +++ b/radius-harness/lib/common.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +REPO_ROOT=${LIBREQOS_REPO_ROOT:-$(cd -- "$LAB_ROOT/.." && pwd)} +RUNTIME_DIR="$LAB_ROOT/artifacts/runtime" +IMAGE_DIR="$LAB_ROOT/images" +RUN_DIR="$LAB_ROOT/run" +LIBVIRT_URI=${LIBVIRT_URI:-qemu:///system} +MANAGEMENT_NETWORK=default + +readonly LQOS_DOMAIN=radius-lqos +readonly ROUTER_DOMAIN=radius-routeros +readonly RADIUS_DOMAIN=radius-freeradius +readonly CLIENT_DOMAIN=radius-client +readonly CONTROL_NETWORK=radius-control +readonly ACCESS_NETWORK=radius-access +readonly TRANSIT_NETWORK=radius-transit + +die() { echo "error: $*" >&2; exit 1; } +note() { echo "==> $*"; } +require_command() { command -v "$1" >/dev/null || die "required command not found: $1"; } +require_lab_secret() { local secret_name=$1; [[ -n ${!secret_name:-} ]] || die "set $secret_name in the environment"; } +virsh_lab() { virsh -c "$LIBVIRT_URI" "$@"; } +domain_exists() { virsh_lab dominfo "$1" >/dev/null 2>&1; } +network_exists() { virsh_lab net-info "$1" >/dev/null 2>&1; } + +ensure_lab_stopped() { + for domain in "$LQOS_DOMAIN" "$ROUTER_DOMAIN" "$RADIUS_DOMAIN" "$CLIENT_DOMAIN"; do + domain_exists "$domain" && die "lab domain already exists: $domain (run ./radius-harness/lab down first)" + done + for network in "$CONTROL_NETWORK" "$ACCESS_NETWORK" "$TRANSIT_NETWORK"; do + network_exists "$network" && die "lab network already exists: $network (inspect and remove it before starting this lab)" + done +} + +management_ip() { + local mac=$1 + virsh_lab net-dhcp-leases "$MANAGEMENT_NETWORK" | awk -v wanted_mac="$mac" 'tolower($0) ~ tolower(wanted_mac) { expires = $1 " " $2; if (expires > latest) { latest = expires; split($5, address, "/"); ip = address[1] } } END { print ip }' +} + +wait_for_management_ip() { + local mac=$1 ip + for _ in $(seq 1 60); do + ip=$(management_ip "$mac") + [[ -n $ip ]] && { printf '%s\n' "$ip"; return 0; } + sleep 2 + done + die "timed out waiting for DHCP lease for $mac" +} + +wait_for_ssh() { + local host=$1 + for _ in $(seq 1 60); do + if ssh -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=2 -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new "lab@$host" true >/dev/null 2>&1; then return 0; fi + sleep 2 + done + die "timed out waiting for SSH on $host" +} + +lab_ssh() { local host=$1; shift; ssh -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new "lab@$host" "$@"; } +lab_scp() { local source=$1 host=$2 destination=$3; scp -i "$RUN_DIR/id_ed25519" -o IdentitiesOnly=yes -p -o BatchMode=yes -o UserKnownHostsFile="$RUN_DIR/known_hosts" -o StrictHostKeyChecking=accept-new -r "$source" "lab@$host:$destination"; } + +safe_remove() { + local path=$1 + [[ $path == "$LAB_ROOT"/* ]] || die "refusing to remove path outside lab root: $path" + rm -rf -- "$path" +} diff --git a/radius-harness/lib/configure.sh b/radius-harness/lib/configure.sh new file mode 100755 index 00000000..15e5eab0 --- /dev/null +++ b/radius-harness/lib/configure.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd) +source "$LAB_ROOT/lib/common.sh" +source "$LAB_ROOT/lab.env" + +note "checking configuration prerequisites" +require_command sshpass +require_lab_secret RADIUS_SHARED_SECRET +require_lab_secret ROUTEROS_ADMIN_PASSWORD + +note "discovering guest management addresses" +lqos_ip=$(wait_for_management_ip 52:54:00:10:00:10) +radius_ip=$(wait_for_management_ip 52:54:00:20:00:10) +client_ip=$(wait_for_management_ip 52:54:00:30:00:10) +router_ip=$(wait_for_management_ip 52:54:00:40:00:10) +note "waiting for SSH on LibreQoS, FreeRADIUS, and PPPoE client guests" +wait_for_ssh "$lqos_ip"; wait_for_ssh "$radius_ip"; wait_for_ssh "$client_ip" + +note "rendering isolated lab configuration" +install -d -m 0700 "$RUN_DIR/config" +umask 077 +cp "$LAB_ROOT/templates/lqos.conf" "$RUN_DIR/config/lqos.conf" +cp "$LAB_ROOT/templates/network.json" "$RUN_DIR/config/network.json" +cp "$LAB_ROOT/templates/ShapedDevices.csv" "$RUN_DIR/config/ShapedDevices.csv" +escaped_radius_secret=$(printf '%s' "$RADIUS_SHARED_SECRET" | sed 's/[\\/&]/\\&/g') +sed "s/__RADIUS_SHARED_SECRET__/$escaped_radius_secret/g" "$LAB_ROOT/templates/freeradius-client.conf" >"$RUN_DIR/config/radius-lab.conf" +sed "s/__RADIUS_SHARED_SECRET__/$escaped_radius_secret/g" "$LAB_ROOT/templates/routeros.rsc" >"$RUN_DIR/config/radius-lab.rsc" + +note "uploading host-built LibreQoS runtime and fixtures" +lab_ssh "$lqos_ip" 'rm -rf /tmp/runtime' +lab_scp "$RUNTIME_DIR" "$lqos_ip" /tmp/ +for file in lqos.conf network.json ShapedDevices.csv; do lab_scp "$RUN_DIR/config/$file" "$lqos_ip" "/tmp/$file"; done +lab_scp "$RUN_DIR/config/radius-lab.conf" "$radius_ip" /home/lab/radius-lab.conf +lab_scp "$LAB_ROOT/templates/freeradius-users" "$radius_ip" /tmp/users + +note "installing LibreQoS fixture and RADIUS listener configuration" +lab_ssh "$lqos_ip" 'sudo mkdir -p /opt/libreqos/src /etc/libreqos; sudo rm -rf /opt/libreqos/src/*; sudo cp -a /tmp/runtime/src/. /opt/libreqos/src/; sudo mv /tmp/runtime/bin /opt/libreqos/src/bin; sudo mv /tmp/runtime/liblqos_python.so /opt/libreqos/src/liblqos_python.so; rm -rf /tmp/runtime; sudo install -m 0644 /tmp/lqos.conf /etc/lqos.conf; sudo install -m 0644 /tmp/network.json /opt/libreqos/src/network.json; sudo install -m 0644 /tmp/ShapedDevices.csv /opt/libreqos/src/ShapedDevices.csv' +note "creating isolated multiqueue veth interfaces for LibreQoS validation" +lab_ssh "$lqos_ip" 'for interface in lqos-lan lqos-wan; do sudo ip link del "$interface" 2>/dev/null || true; sudo ip link add "$interface" numrxqueues 2 numtxqueues 2 type veth peer name "${interface}-p" numrxqueues 2 numtxqueues 2; sudo ip link set "$interface" up; sudo ip link set "${interface}-p" up; done' +printf '%s\n' "$RADIUS_SHARED_SECRET" | lab_ssh "$lqos_ip" 'sudo tee /etc/libreqos/radius-shared-secret >/dev/null; sudo chmod 600 /etc/libreqos/radius-shared-secret' +lab_ssh "$lqos_ip" "sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null; sudo nft delete table ip radius_lab 2>/dev/null || true; sudo nft add table ip radius_lab; sudo nft 'add chain ip radius_lab prerouting { type nat hook prerouting priority dstnat; }'; sudo nft 'add chain ip radius_lab postrouting { type nat hook postrouting priority srcnat; }'; sudo nft add rule ip radius_lab prerouting ip daddr 198.18.10.10 udp dport 1812 dnat to 198.18.10.20; sudo nft add rule ip radius_lab postrouting ip daddr 198.18.10.20 udp dport 1812 snat to 198.18.10.10" + +note "configuring and starting FreeRADIUS" +lab_ssh "$radius_ip" "sudo install -m 0644 /tmp/users /etc/freeradius/3.0/mods-config/files/authorize; sudo grep -q 'client radius-lqos-nat' /etc/freeradius/3.0/clients.conf || sudo sh -c 'cat /home/lab/radius-lab.conf >> /etc/freeradius/3.0/clients.conf'; rm -f /home/lab/radius-lab.conf; sudo systemctl enable --now freeradius; sudo systemctl restart freeradius" +note "starting lqosd before building the LibreQoS fixture state" +lab_ssh "$lqos_ip" 'sudo systemctl stop radius-lqosd.service 2>/dev/null || true; sudo systemd-run --unit=radius-lqosd --collect --setenv=RUST_LOG=debug /opt/libreqos/src/bin/lqosd; sleep 1; sudo systemctl is-active --quiet radius-lqosd.service' +note "building the LibreQoS fixture state inside the lab" +lab_ssh "$lqos_ip" 'cd /opt/libreqos/src && sudo env PYTHONPATH=/opt/libreqos/src python3 LibreQoS.py' + +note "uploading RouterOS PPPoE and RADIUS configuration" +export SSHPASS=$ROUTEROS_ADMIN_PASSWORD +sshpass -e scp -p -o UserKnownHostsFile="$RUN_DIR/routeros_known_hosts" -o StrictHostKeyChecking=accept-new "$RUN_DIR/config/radius-lab.rsc" "admin@$router_ip:radius-lab.rsc" +sshpass -e ssh -o UserKnownHostsFile="$RUN_DIR/routeros_known_hosts" -o StrictHostKeyChecking=accept-new "admin@$router_ip" '/import file-name=radius-lab.rsc' +unset SSHPASS + +note "creating PPPoE client profile" +printf '%s\n' 'plugin rp-pppoe.so pppoe0' 'user pppoe-lab' 'password pppoe-lab-password' 'noauth' 'persist' 'maxfail 1' | lab_ssh "$client_ip" 'sudo tee /etc/ppp/peers/radius-lab >/dev/null' +note "guest configuration complete; run ./radius-harness/lab test" diff --git a/radius-harness/lib/test-lifecycle.sh b/radius-harness/lib/test-lifecycle.sh new file mode 100755 index 00000000..015b3a0f --- /dev/null +++ b/radius-harness/lib/test-lifecycle.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd) +source "$LAB_ROOT/lib/common.sh" + +journal_cursor() { local lqos_ip=$1; lab_ssh "$lqos_ip" "sudo journalctl -u radius-lqosd -n 0 --show-cursor --no-pager | sed -n 's/^-- cursor: //p'"; } + +wait_for_log() { + local lqos_ip=$1 cursor=$2 pattern=$3 description=$4 + for _ in $(seq 1 20); do + lab_ssh "$lqos_ip" "sudo journalctl -u radius-lqosd --after-cursor='$cursor' --no-pager | grep -q -- '$pattern'" && return 0 + sleep 2 + done + die "$description" +} + +dynamic_circuit_count_is() { local lqos_ip=$1 expected_count=$2; lab_ssh "$lqos_ip" "sudo python3 -c 'import json, os, sys; path = \"/opt/libreqos/src/dynamic_circuits.json\"; circuits = json.load(open(path)).get(\"circuits\", []) if os.path.exists(path) else []; sys.exit(0 if len(circuits) == $expected_count else 1)'"; } +dynamic_circuit_has_profile() { local lqos_ip=$1 expected_circuit=$2 expected_download=$3 expected_upload=$4; lab_ssh "$lqos_ip" "sudo python3 -c 'import json, math, os, sys; path = \"/opt/libreqos/src/dynamic_circuits.json\"; circuits = json.load(open(path)).get(\"circuits\", []) if os.path.exists(path) else []; matches = [c.get(\"shaped\", {}) for c in circuits if (\"$expected_circuit\" == \"any\" or c.get(\"shaped\", {}).get(\"circuit_id\") == \"$expected_circuit\")]; sys.exit(0 if any(math.isclose(float(c.get(\"download_max_mbps\", -1)), $expected_download) and math.isclose(float(c.get(\"upload_max_mbps\", -1)), $expected_upload) for c in matches) else 1)'"; } + +wait_for_dynamic_circuit_profile() { + local lqos_ip=$1 expected_circuit=$2 expected_download=$3 expected_upload=$4 description=$5 + for _ in $(seq 1 20); do dynamic_circuit_has_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" && return 0; sleep 2; done + die "$description" +} + +run_pppoe_case() { + local name=$1 username=$2 password=$3 expected_circuit=$4 expected_download=$5 expected_upload=$6 + note "checking that no dynamic circuit is present before $name" + dynamic_circuit_count_is "$lqos_ip" 0 || die "a dynamic circuit was already present before $name" + start_cursor=$(journal_cursor "$lqos_ip"); [[ -n $start_cursor ]] || die "could not establish an lqosd journal cursor" + note "starting PPPoE session for $name" + printf '%s\n' "plugin rp-pppoe.so pppoe0" "user $username" "password $password" noauth persist 'maxfail 1' | lab_ssh "$client_ip" 'sudo tee /etc/ppp/peers/radius-lab >/dev/null' + lab_ssh "$client_ip" 'sudo poff radius-lab || true; sudo pon radius-lab' + for _ in $(seq 1 30); do lab_ssh "$client_ip" "ip -4 addr show ppp0 | grep -q '100.64.0.2'" && break; sleep 2; done + lab_ssh "$client_ip" "ip -4 addr show ppp0 | grep -q '100.64.0.2'" || die "PPPoE client did not receive 100.64.0.2 for $name" + note "waiting for RADIUS Start and the expected $name queue" + wait_for_log "$lqos_ip" "$start_cursor" 'accepted RADIUS accounting packet.*status=Some[(]Start[)]' "lqosd did not accept a RADIUS Start packet for $name" + wait_for_log "$lqos_ip" "$start_cursor" 'applied RADIUS dynamic-circuit request.*intent="create"' "dynamic circuit creation was not observed for $name" + wait_for_dynamic_circuit_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" "the $name queue did not have the expected ${expected_download}/${expected_upload} Mbps profile" + interim_cursor=$(journal_cursor "$lqos_ip"); note "waiting for the RouterOS five-second RADIUS Interim-Update for $name" + wait_for_log "$lqos_ip" "$interim_cursor" 'accepted RADIUS accounting packet.*status=Some[(]InterimUpdate[)]' "lqosd did not accept a RADIUS Interim-Update packet for $name" + wait_for_dynamic_circuit_profile "$lqos_ip" "$expected_circuit" "$expected_download" "$expected_upload" "the $name queue did not retain its expected profile after Interim-Update" + stop_cursor=$(journal_cursor "$lqos_ip"); note "disconnecting $name and waiting for RADIUS Stop plus queue removal" + lab_ssh "$client_ip" 'sudo poff radius-lab' + wait_for_log "$lqos_ip" "$stop_cursor" 'accepted RADIUS accounting packet.*status=Some[(]Stop[)]' "lqosd did not accept a RADIUS Stop packet for $name" + wait_for_log "$lqos_ip" "$stop_cursor" 'applied RADIUS dynamic-circuit request.*intent="remove"' "dynamic circuit removal was not observed for $name" + for _ in $(seq 1 20); do dynamic_circuit_count_is "$lqos_ip" 0 && return 0; sleep 2; done + die "the $name dynamic circuit remained after RADIUS Stop" +} + +client_ip=$(wait_for_management_ip 52:54:00:30:00:10) +lqos_ip=$(wait_for_management_ip 52:54:00:10:00:10) +trap 'lab_ssh "$client_ip" "sudo poff radius-lab || true" >/dev/null 2>&1 || true' EXIT +run_pppoe_case 'packet-rate fallback identity' pppoe-rate pppoe-rate-password any 10 25 +run_pppoe_case 'known username ShapedDevices identity' pppoe-known pppoe-known-password radius-known-circuit 60 20 +run_pppoe_case 'unknown username default identity' pppoe-unknown pppoe-unknown-password any 30 12 +trap - EXIT +note "all RADIUS lifecycle and queue-profile tests passed" diff --git a/radius-harness/templates/ShapedDevices.csv b/radius-harness/templates/ShapedDevices.csv new file mode 100644 index 00000000..2b2e86ed --- /dev/null +++ b/radius-harness/templates/ShapedDevices.csv @@ -0,0 +1,2 @@ +Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment +radius-known-circuit,Radius Known Circuit,radius-known-device,Known RADIUS Subscriber,PPPoE Access,lab-pppoe-access,lab-core,pppoe-known,,,6,3,60,20,RADIUS username speed fixture diff --git a/radius-harness/templates/client-network.yaml b/radius-harness/templates/client-network.yaml new file mode 100644 index 00000000..a63c0e64 --- /dev/null +++ b/radius-harness/templates/client-network.yaml @@ -0,0 +1,4 @@ +version: 2 +ethernets: + mgmt0: {match: {macaddress: '52:54:00:30:00:10'}, set-name: mgmt0, dhcp4: true} + pppoe0: {match: {macaddress: '02:00:00:00:00:42'}, set-name: pppoe0, dhcp4: false} diff --git a/radius-harness/templates/freeradius-client.conf b/radius-harness/templates/freeradius-client.conf new file mode 100644 index 00000000..498e7144 --- /dev/null +++ b/radius-harness/templates/freeradius-client.conf @@ -0,0 +1,4 @@ +client radius-lqos-nat { + ipaddr = 198.18.10.10 + secret = __RADIUS_SHARED_SECRET__ +} diff --git a/radius-harness/templates/freeradius-users b/radius-harness/templates/freeradius-users new file mode 100644 index 00000000..72eec23f --- /dev/null +++ b/radius-harness/templates/freeradius-users @@ -0,0 +1,12 @@ +pppoe-rate Cleartext-Password := "pppoe-rate-password" + Framed-IP-Address := 100.64.0.2, + Framed-IP-Netmask := 255.255.255.255, + Mikrotik-Rate-Limit := "25M/10M" + +pppoe-known Cleartext-Password := "pppoe-known-password" + Framed-IP-Address := 100.64.0.2, + Framed-IP-Netmask := 255.255.255.255 + +pppoe-unknown Cleartext-Password := "pppoe-unknown-password" + Framed-IP-Address := 100.64.0.2, + Framed-IP-Netmask := 255.255.255.255 diff --git a/radius-harness/templates/lqos-network.yaml b/radius-harness/templates/lqos-network.yaml new file mode 100644 index 00000000..ed513879 --- /dev/null +++ b/radius-harness/templates/lqos-network.yaml @@ -0,0 +1,5 @@ +version: 2 +ethernets: + mgmt0: {match: {macaddress: '52:54:00:10:00:10'}, set-name: mgmt0, dhcp4: true} + control0: {match: {macaddress: '52:54:00:10:00:11'}, set-name: control0, addresses: [198.18.10.10/24]} + transit0: {match: {macaddress: '52:54:00:10:00:12'}, set-name: transit0, addresses: [198.18.30.10/24]} diff --git a/radius-harness/templates/lqos.conf b/radius-harness/templates/lqos.conf new file mode 100644 index 00000000..aa4da552 --- /dev/null +++ b/radius-harness/templates/lqos.conf @@ -0,0 +1,106 @@ +version = "1.5" +lqos_directory = "/opt/libreqos/src" +state_directory = "/opt/libreqos/state" +node_id = "radius-lab" +node_name = "RADIUS PPPoE Lab" +packet_capture_time = 10 +queue_check_period_ms = 1000 +display_cobrand = false +enable_circuit_heatmaps = true +enable_site_heatmaps = true +enable_asn_heatmaps = true + +[tuning] +set_cpu_governor_performance = false +stop_irq_balance = false +netdev_budget_usecs = 8000 +netdev_budget_packets = 300 +rx_usecs = 8 +tx_usecs = 8 +disable_rxvlan = true +disable_txvlan = true +disable_offload = ["gso", "tso", "lro", "sg", "gro"] + +[bridge] +use_xdp_bridge = false +to_internet = "lqos-wan" +to_network = "lqos-lan" + +[queues] +default_sqm = "cake diffserv4" +queue_mode = "shape" +monitor_only = false +uplink_bandwidth_mbps = 100 +downlink_bandwidth_mbps = 100 +generated_pn_download_mbps = 100 +generated_pn_upload_mbps = 100 +dry_run = true +sudo = false +use_binpacking = false + +[long_term_stats] +gather_stats = false +collation_period_seconds = 10 +license_key = "" + +[local_api] +bearer_token = "" + +[ip_ranges] +ignore_subnets = [] +allow_subnets = ["172.16.0.0/12", "10.0.0.0/8", "100.64.0.0/16", "192.168.0.0/16"] + +[integration_common] +circuit_name_as_address = false +queue_refresh_interval_mins = 30 +use_mikrotik_ipv6 = false +ethernet_port_limits_enabled = true + +[netzur_integration] +enable_netzur = false +api_key = "" +api_url = "" +timeout_secs = 60 + +[visp_integration] +enable_visp = false +client_id = "" +client_secret = "" +username = "" +password = "" +timeout_secs = 20 + +[influxdb] +enable_influxdb = false +url = "http://localhost:8086" +org = "libreqos" +bucket = "RADIUS Lab" +token = "" + +[dynamic_circuits] +enabled = true + +[radius_accounting] +enabled = true +listen = "198.18.10.10:1813" +default_ttl_seconds = 12 +stale_grace_seconds = 2 + +[radius_accounting.dynamic_circuit_application] +enabled = true +match_shaped_devices_by_mac = true +match_shaped_devices_by_username = true +fallback_parent_node = "PPPoE Access" +fallback_parent_node_id = "lab-pppoe-access" +fallback_anchor_node_id = "lab-core" + +[radius_accounting.fallback_speed_profile] +download_min_mbps = 4.0 +upload_min_mbps = 2.0 +download_max_mbps = 30.0 +upload_max_mbps = 12.0 + +[[radius_accounting.clients]] +name = "radius-routeros" +source = ["198.18.10.1/32"] +secret_file = "/etc/libreqos/radius-shared-secret" diff --git a/radius-harness/templates/network-access.xml b/radius-harness/templates/network-access.xml new file mode 100644 index 00000000..fc76fa07 --- /dev/null +++ b/radius-harness/templates/network-access.xml @@ -0,0 +1,5 @@ + + radius-access + + + diff --git a/radius-harness/templates/network-control.xml b/radius-harness/templates/network-control.xml new file mode 100644 index 00000000..55a281a8 --- /dev/null +++ b/radius-harness/templates/network-control.xml @@ -0,0 +1,5 @@ + + radius-control + + + diff --git a/radius-harness/templates/network-transit.xml b/radius-harness/templates/network-transit.xml new file mode 100644 index 00000000..5fc4433d --- /dev/null +++ b/radius-harness/templates/network-transit.xml @@ -0,0 +1,5 @@ + + radius-transit + + + diff --git a/radius-harness/templates/network.json b/radius-harness/templates/network.json new file mode 100644 index 00000000..2d8295ca --- /dev/null +++ b/radius-harness/templates/network.json @@ -0,0 +1,16 @@ +{ + "Lab Core": { + "id": "lab-core", + "downloadBandwidthMbps": 100, + "uploadBandwidthMbps": 100, + "type": "site", + "children": { + "PPPoE Access": { + "id": "lab-pppoe-access", + "downloadBandwidthMbps": 100, + "uploadBandwidthMbps": 100, + "type": "ap" + } + } + } +} diff --git a/radius-harness/templates/radius-network.yaml b/radius-harness/templates/radius-network.yaml new file mode 100644 index 00000000..b3e46fa3 --- /dev/null +++ b/radius-harness/templates/radius-network.yaml @@ -0,0 +1,4 @@ +version: 2 +ethernets: + mgmt0: {match: {macaddress: '52:54:00:20:00:10'}, set-name: mgmt0, dhcp4: true} + control0: {match: {macaddress: '52:54:00:20:00:11'}, set-name: control0, addresses: [198.18.10.20/24]} diff --git a/radius-harness/templates/routeros.rsc b/radius-harness/templates/routeros.rsc new file mode 100644 index 00000000..29794279 --- /dev/null +++ b/radius-harness/templates/routeros.rsc @@ -0,0 +1,8 @@ +/ip address add address=198.18.10.1/24 interface=ether2 +/ip address add address=198.18.30.1/24 interface=ether4 +/ip pool add name=radius-pppoe ranges=100.64.0.2-100.64.0.2 +/ppp profile add name=radius-pppoe local-address=100.64.0.1 remote-address=radius-pppoe use-encryption=no +/interface pppoe-server server add interface=ether3 service-name=radius-lab default-profile=radius-pppoe authentication=pap one-session-per-host=yes disabled=no +/radius add service=ppp address=198.18.10.10 authentication-port=1812 accounting-port=1813 secret=__RADIUS_SHARED_SECRET__ +/ppp aaa set use-radius=yes accounting=yes interim-update=5s +/ip service set ssh disabled=no diff --git a/radius-harness/templates/user-data.yaml b/radius-harness/templates/user-data.yaml new file mode 100644 index 00000000..1baa3a3a --- /dev/null +++ b/radius-harness/templates/user-data.yaml @@ -0,0 +1,12 @@ +#cloud-config +users: + - name: lab + groups: [adm, sudo] + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + - __LAB_SSH_KEY__ +package_update: true +packages: [libelf1, nftables, ppp, pppoe, python3-chardet, python3-deepdiff, python3-psutil, freeradius, freeradius-utils] +runcmd: + - systemctl disable --now freeradius || true diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 36e14605..28bdc3b7 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2404,6 +2404,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "lqos_radius" +version = "0.1.0" +dependencies = [ + "clap", + "hmac", + "ip_network", + "lqos_config", + "md-5", + "subtle", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "lqos_rs" version = "0.1.0" @@ -2568,6 +2582,7 @@ dependencies = [ "lqos_overrides", "lqos_probe", "lqos_queue_tracker", + "lqos_radius", "lqos_setup", "lqos_stormguard", "lqos_sys", @@ -2655,6 +2670,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memalloc" version = "0.1.0" diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 9e068f37..6ff4b947 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -39,6 +39,7 @@ members = [ "uisp", # REST support for the UISP API "uisp_integration", # UISP Integration in Rust "lqos_probe", # Shared active probe provider and result types. + "lqos_radius", # Rootless RADIUS accounting packet parsing and diagnostic UDP listener. "lqos_stormguard", # An implementation of CAKE AutoRotate using dynamic bus information. EXPERIMENTAL. "lqos_bakery", # The bakery makes CAKEs - controls queue creation. "lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv @@ -68,6 +69,8 @@ ip_network_table = "0" ip_network = "0" sha2 = "0.10" hmac = "0.12" +md-5 = "0.10" +subtle = "2.6" base64 = "0.22" argon2 = "0.5" rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index bc2e90fc..21a10a05 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -20,10 +20,13 @@ pub mod test_data; mod v15; pub use v15::{ BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode, - LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RttThresholds, - SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy, TopologyConfig, - TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, + LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RadiusAccountingClient, + RadiusAccountingConfig, RadiusClientSource, RadiusDynamicCircuitApplicationConfig, + RadiusFallbackSpeedProfile, RadiusSharedSecretSource, RateProfileValidationError, + RttThresholds, SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy, + TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, normalize_external_hostname, + validate_rate_profile_mbps, }; static CONFIG: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None)); diff --git a/src/rust/lqos_config/src/etc/v15/mod.rs b/src/rust/lqos_config/src/etc/v15/mod.rs index 966faa7d..b5ab63a3 100644 --- a/src/rust/lqos_config/src/etc/v15/mod.rs +++ b/src/rust/lqos_config/src/etc/v15/mod.rs @@ -17,6 +17,7 @@ mod mikrotik_ipv6; mod netzur_integration; mod powercode_integration; mod queues; +mod radius_accounting; mod sonar_integration; mod splynx_integration; mod stormguard; @@ -33,6 +34,11 @@ pub use integration_common::IntegrationConfig; pub use long_term_stats::LongTermStats; pub use mikrotik_ipv6::MikrotikIpv6Config; pub use queues::{LazyQueueMode, QueueMode}; +pub use radius_accounting::{ + RadiusAccountingClient, RadiusAccountingConfig, RadiusClientSource, + RadiusDynamicCircuitApplicationConfig, RadiusFallbackSpeedProfile, RadiusSharedSecretSource, + RateProfileValidationError, validate_rate_profile_mbps, +}; pub use stormguard::{StormguardConfig, StormguardStrategy}; pub use topology::{TopologyConfig, normalize_topology_compile_mode}; pub use treeguard::{ diff --git a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs new file mode 100644 index 00000000..5067a2bc --- /dev/null +++ b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs @@ -0,0 +1,1309 @@ +//! RADIUS accounting configuration. +//! +//! This module only defines and validates operator configuration. Runtime +//! listener startup, packet authentication, and session handling live outside +//! this config schema. + +use allocative::Allocative; +use ip_network::IpNetwork; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::net::{IpAddr, SocketAddr}; +use thiserror::Error; + +fn default_ttl_seconds() -> u64 { + 900 +} + +fn default_stale_grace_seconds() -> u64 { + 120 +} + +/// Configured `secret_file` value for a RADIUS shared secret. +/// +/// This stores the operator-configured `secret_file` string, normally a file +/// path. Serde preserves the value for `/etc/lqos.conf` round trips, while +/// `Debug` redacts this wrapper. +#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)] +#[serde(transparent)] +pub struct RadiusSharedSecretSource(String); + +impl RadiusSharedSecretSource { + /// Creates a wrapper for an operator-configured `secret_file` value. + pub fn new(source: impl Into) -> Self { + Self(source.into()) + } + + /// Returns the configured `secret_file` value. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns true when the configured `secret_file` value is blank. + pub fn is_empty(&self) -> bool { + self.0.trim().is_empty() + } +} + +impl fmt::Debug for RadiusSharedSecretSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadiusSharedSecretSource(REDACTED)") + } +} + +impl From for RadiusSharedSecretSource { + fn from(source: String) -> Self { + Self::new(source) + } +} + +impl From<&str> for RadiusSharedSecretSource { + fn from(source: &str) -> Self { + Self::new(source) + } +} + +/// One trusted RADIUS client source address or network. +#[derive(Clone, Debug, PartialEq, Allocative)] +pub struct RadiusClientSource { + #[allocative(skip)] + network: IpNetwork, +} + +impl RadiusClientSource { + /// Creates a RADIUS client source from a parsed IP network. + pub fn new(network: IpNetwork) -> Self { + Self { network } + } + + /// Returns the parsed source network. + pub fn network(&self) -> &IpNetwork { + &self.network + } +} + +impl Serialize for RadiusClientSource { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.network.to_string()) + } +} + +impl<'de> Deserialize<'de> for RadiusClientSource { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + parse_client_source(&raw) + .map(Self::new) + .map_err(serde::de::Error::custom) + } +} + +/// One trusted RADIUS NAS client entry. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct RadiusAccountingClient { + /// Optional operator label used for diagnostics. + #[serde(default)] + pub name: String, + /// UDP source allow-list for this NAS client. + /// + /// TOML may provide either one string or a list of strings. Values must be + /// IP addresses or CIDR networks. + #[serde(default, deserialize_with = "deserialize_source_allow_list")] + pub source: Vec, + /// Configured `secret_file` value for this client's shared secret. + #[serde(default)] + pub secret_file: RadiusSharedSecretSource, +} + +impl RadiusAccountingClient { + /// Validates one trusted RADIUS client definition. + pub fn validate(&self, index: usize) -> Result<(), String> { + let label = client_label(index, &self.name); + + if self.source.is_empty() { + return Err(format!( + "{label}.source must include at least one IP address or CIDR" + )); + } + + if self.secret_file.is_empty() { + return Err(format!("{label}.secret_file must not be empty")); + } + + Ok(()) + } +} + +/// Optional fallback speed profile for sessions without decoded packet rates or matched ShapedDevices rates. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct RadiusFallbackSpeedProfile { + /// Default minimum download bandwidth in Mbps. + pub download_min_mbps: f32, + /// Default minimum upload bandwidth in Mbps. + pub upload_min_mbps: f32, + /// Default maximum download bandwidth in Mbps. + pub download_max_mbps: f32, + /// Default maximum upload bandwidth in Mbps. + pub upload_max_mbps: f32, +} + +impl RadiusFallbackSpeedProfile { + /// Validates all fallback speed values. + pub fn validate(&self) -> Result<(), String> { + validate_rate_profile_mbps( + self.download_min_mbps, + self.upload_min_mbps, + self.download_max_mbps, + self.upload_max_mbps, + ) + .map_err(|error| error.with_config_prefix("radius_accounting.fallback_speed_profile")) + } +} + +/// Validation error for a complete Mbps rate profile. +#[derive(Clone, Copy, Debug, Error, PartialEq)] +pub enum RateProfileValidationError { + /// A rate value was NaN or infinite. + #[error("{field} must be finite, got {rate_mbps} Mbps")] + NonFinite { + /// Field that failed validation. + field: &'static str, + /// Invalid rate value in Mbps. + rate_mbps: f32, + }, + /// A rate value was zero or negative. + #[error("{field} must be > 0, got {rate_mbps} Mbps")] + NonPositive { + /// Field that failed validation. + field: &'static str, + /// Invalid rate value in Mbps. + rate_mbps: f32, + }, + /// A minimum rate exceeded its matching maximum rate. + #[error("{min_field} ({min_mbps} Mbps) must be <= {max_field} ({max_mbps} Mbps)")] + MinimumExceedsMaximum { + /// Minimum-rate field that failed validation. + min_field: &'static str, + /// Minimum-rate value in Mbps. + min_mbps: f32, + /// Maximum-rate field that failed validation. + max_field: &'static str, + /// Maximum-rate value in Mbps. + max_mbps: f32, + }, +} + +impl RateProfileValidationError { + /// Formats this validation error with a config-path prefix. + #[must_use] + pub fn with_config_prefix(&self, prefix: &str) -> String { + match self { + Self::NonFinite { field, .. } => format!("{prefix}.{field} must be finite"), + Self::NonPositive { field, .. } => format!("{prefix}.{field} must be > 0"), + Self::MinimumExceedsMaximum { + min_field, + max_field, + .. + } => format!("{prefix}.{min_field} must be <= {prefix}.{max_field}"), + } + } +} + +/// Validates a complete Mbps rate profile. +pub fn validate_rate_profile_mbps( + download_min_mbps: f32, + upload_min_mbps: f32, + download_max_mbps: f32, + upload_max_mbps: f32, +) -> Result<(), RateProfileValidationError> { + validate_rate_profile_field("download_min_mbps", download_min_mbps)?; + validate_rate_profile_field("upload_min_mbps", upload_min_mbps)?; + validate_rate_profile_field("download_max_mbps", download_max_mbps)?; + validate_rate_profile_field("upload_max_mbps", upload_max_mbps)?; + validate_rate_profile_order( + "download_min_mbps", + download_min_mbps, + "download_max_mbps", + download_max_mbps, + )?; + validate_rate_profile_order( + "upload_min_mbps", + upload_min_mbps, + "upload_max_mbps", + upload_max_mbps, + ) +} + +/// RADIUS accounting listener configuration. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct RadiusAccountingConfig { + /// Persisted operator flag for RADIUS accounting listener support. + /// + /// When true, `lqosd` starts the listener after configuration validation. + #[serde(default)] + pub enabled: bool, + /// UDP socket address for the listener, for example `0.0.0.0:1813`. + #[allocative(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + /// Time-to-live in seconds for accounting-derived session state. + #[serde(default = "default_ttl_seconds")] + pub default_ttl_seconds: u64, + /// Grace period in seconds before stale accounting state is considered expired. + #[serde(default = "default_stale_grace_seconds")] + pub stale_grace_seconds: u64, + /// Safety gate for RADIUS dynamic-circuit application. + #[serde(default)] + pub dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig, + /// Optional fallback speed profile for sessions without decoded packet rates or matched ShapedDevices rates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_speed_profile: Option, + /// Trusted RADIUS NAS clients. + #[serde(default)] + pub clients: Vec, +} + +/// RADIUS dynamic-circuit application settings. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)] +pub struct RadiusDynamicCircuitApplicationConfig { + /// Enable the RADIUS dynamic-circuit application gate. + #[serde(default)] + pub enabled: bool, + /// Match `Calling-Station-Id` values to `ShapedDevices.csv` MAC fields. + #[serde(default)] + pub match_shaped_devices_by_mac: bool, + /// Match RADIUS `User-Name` values to `ShapedDevices.csv` MAC fields verbatim. + #[serde(default)] + pub match_shaped_devices_by_username: bool, + /// Parent node used for default RADIUS identities when MAC matching does not supply metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_parent_node: Option, + /// Stable parent node ID used with `fallback_parent_node`, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_parent_node_id: Option, + /// Stable anchor node ID used with `fallback_parent_node`, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_anchor_node_id: Option, +} + +impl RadiusDynamicCircuitApplicationConfig { + /// Validates RADIUS dynamic-circuit application settings. + pub fn validate(&self) -> Result<(), String> { + validate_optional_text( + "radius_accounting.dynamic_circuit_application.fallback_parent_node", + self.fallback_parent_node.as_deref(), + )?; + validate_optional_text( + "radius_accounting.dynamic_circuit_application.fallback_parent_node_id", + self.fallback_parent_node_id.as_deref(), + )?; + validate_optional_text( + "radius_accounting.dynamic_circuit_application.fallback_anchor_node_id", + self.fallback_anchor_node_id.as_deref(), + )?; + + if self.fallback_parent_node.is_none() + && (self.fallback_parent_node_id.is_some() || self.fallback_anchor_node_id.is_some()) + { + return Err( + "radius_accounting.dynamic_circuit_application.fallback_parent_node must be set when fallback parent IDs are configured" + .to_string(), + ); + } + + Ok(()) + } +} + +impl Default for RadiusAccountingConfig { + fn default() -> Self { + Self { + enabled: false, + listen: None, + default_ttl_seconds: default_ttl_seconds(), + stale_grace_seconds: default_stale_grace_seconds(), + dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig::default(), + fallback_speed_profile: None, + clients: Vec::new(), + } + } +} + +impl RadiusAccountingConfig { + /// Validates RADIUS accounting configuration. + pub fn validate(&self) -> Result<(), String> { + if self.default_ttl_seconds == 0 { + return Err("radius_accounting.default_ttl_seconds must be > 0".to_string()); + } + if self.stale_grace_seconds == 0 { + return Err("radius_accounting.stale_grace_seconds must be > 0".to_string()); + } + + if self.listen.is_some_and(|listen| listen.port() == 0) { + return Err("radius_accounting.listen port must be > 0".to_string()); + } + + if self.dynamic_circuit_application.enabled { + self.dynamic_circuit_application.validate()?; + if let Some(fallback_speed_profile) = &self.fallback_speed_profile { + fallback_speed_profile.validate()?; + } + } + + for (index, client) in self.clients.iter().enumerate() { + client.validate(index)?; + } + + if self.enabled { + if self.listen.is_none() { + return Err("radius_accounting.listen must be configured when enabled".to_string()); + } + if self.clients.is_empty() { + return Err( + "radius_accounting.clients must include at least one client when enabled" + .to_string(), + ); + } + } + + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum SourceAllowList { + One(String), + Many(Vec), +} + +fn deserialize_source_allow_list<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + match SourceAllowList::deserialize(deserializer)? { + SourceAllowList::One(source) => parse_source_allow_list(vec![source]), + SourceAllowList::Many(sources) => parse_source_allow_list(sources), + } +} + +fn parse_source_allow_list(sources: Vec) -> Result, E> +where + E: serde::de::Error, +{ + sources + .into_iter() + .map(|source| { + parse_client_source(&source) + .map(RadiusClientSource::new) + .map_err(serde::de::Error::custom) + }) + .collect() +} + +fn client_label(index: usize, name: &str) -> String { + let trimmed_name = name.trim(); + if trimmed_name.is_empty() { + format!("radius_accounting.clients[{index}]") + } else { + format!("radius_accounting.clients[{index}] ('{trimmed_name}')") + } +} + +fn validate_optional_text(config_path: &str, value: Option<&str>) -> Result<(), String> { + if value.is_some_and(|value| value.trim().is_empty()) { + return Err(format!("{config_path} must not be empty when configured")); + } + Ok(()) +} + +fn validate_rate_profile_field( + field: &'static str, + rate_mbps: f32, +) -> Result<(), RateProfileValidationError> { + if !rate_mbps.is_finite() { + return Err(RateProfileValidationError::NonFinite { field, rate_mbps }); + } + if rate_mbps <= 0.0 { + return Err(RateProfileValidationError::NonPositive { field, rate_mbps }); + } + Ok(()) +} + +fn validate_rate_profile_order( + min_field: &'static str, + min_mbps: f32, + max_field: &'static str, + max_mbps: f32, +) -> Result<(), RateProfileValidationError> { + if min_mbps > max_mbps { + return Err(RateProfileValidationError::MinimumExceedsMaximum { + min_field, + min_mbps, + max_field, + max_mbps, + }); + } + Ok(()) +} + +fn parse_client_source(source: &str) -> Result { + let trimmed = source.trim(); + if trimmed.is_empty() { + return Err("source is empty".to_string()); + } + + if let Ok(network) = trimmed.parse::() { + return Ok(network); + } + + if let Ok(ip) = trimmed.parse::() { + return Ok(IpNetwork::from(ip)); + } + + Err(format!("'{trimmed}' is not a valid IP address or CIDR")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::etc::v15::Config; + + const TEST_CLIENT_NAME: &str = "pppoe-core-1"; + const TEST_CLIENT_NAME_LINE: &str = "name = \"pppoe-core-1\""; + const TEST_LISTEN: &str = "127.0.0.1:18130"; + const TEST_LISTEN_LINE: &str = "listen = \"127.0.0.1:18130\""; + const TEST_SOURCE: &str = "192.0.2.10/32"; + const TEST_SOURCE_LINE: &str = "source = \"192.0.2.10/32\""; + const TEST_SECRET_FILE: &str = "/etc/lqos/radius-secrets/pppoe-core-1"; + const TEST_SECRET_FILE_LINE: &str = "secret_file = \"/etc/lqos/radius-secrets/pppoe-core-1\""; + const TEST_TTL_SECONDS_LINE: &str = "default_ttl_seconds = 900"; + const TEST_STALE_GRACE_SECONDS_LINE: &str = "stale_grace_seconds = 120"; + const TEST_FALLBACK_SECTION_LINES: &[&str] = &[ + "[radius_accounting.fallback_speed_profile]", + "download_min_mbps = 5.0", + "upload_min_mbps = 3.0", + "download_max_mbps = 25.0", + "upload_max_mbps = 10.0", + ]; + const TEST_CLIENT_LINES: &[&str] = &[TEST_SOURCE_LINE, TEST_SECRET_FILE_LINE]; + const TEST_NAMED_CLIENT_LINES: &[&str] = &[ + TEST_CLIENT_NAME_LINE, + TEST_SOURCE_LINE, + TEST_SECRET_FILE_LINE, + ]; + + fn source(raw: &str) -> RadiusClientSource { + RadiusClientSource::new(parse_client_source(raw).expect("test source should parse")) + } + + fn radius_section( + enabled: bool, + section_lines: &[&str], + client_lines: Option<&[&str]>, + ) -> String { + let mut radius = format!("\n\n[radius_accounting]\nenabled = {enabled}\n"); + for line in section_lines { + radius.push_str(line); + radius.push('\n'); + } + if let Some(client_lines) = client_lines { + radius.push_str("\n[[radius_accounting.clients]]\n"); + for line in client_lines { + radius.push_str(line); + radius.push('\n'); + } + } + radius + } + + fn enabled_radius_section(section_lines: &[&str], client_lines: Option<&[&str]>) -> String { + let mut lines = Vec::with_capacity(section_lines.len() + 1); + lines.push(TEST_LISTEN_LINE); + lines.extend_from_slice(section_lines); + radius_section(true, &lines, client_lines) + } + + fn enabled_radius_section_with_clients(client_sections: &[&[&str]]) -> String { + let mut radius = format!("\n\n[radius_accounting]\nenabled = true\n{TEST_LISTEN_LINE}\n"); + for client_lines in client_sections { + radius.push_str("\n[[radius_accounting.clients]]\n"); + for line in *client_lines { + radius.push_str(line); + radius.push('\n'); + } + } + radius + } + + fn example_with_radius(radius: &str) -> String { + let mut raw = include_str!("example.toml").to_string(); + raw.push_str(radius); + raw + } + + fn load_error_contains(radius: &str, expected: &str) { + let raw = example_with_radius(radius); + let error = Config::load_from_string(&raw).expect_err("config should fail validation"); + assert!( + error.contains(expected), + "expected error to contain '{expected}', got '{error}'" + ); + } + + fn first_radius_client(radius: &str) -> RadiusAccountingClient { + Config::load_from_string(&example_with_radius(radius)) + .expect("radius accounting should deserialize") + .radius_accounting + .expect("radius accounting should be present") + .clients + .into_iter() + .next() + .expect("radius accounting should include a client") + } + + fn load_error_cases(cases: impl IntoIterator) { + for (radius, expected) in cases { + load_error_contains(&radius, expected); + } + } + + fn assert_disabled_defaults(radius: &RadiusAccountingConfig) { + assert!(!radius.enabled); + assert!(radius.listen.is_none()); + assert!(radius.clients.is_empty()); + assert_eq!(radius.default_ttl_seconds, default_ttl_seconds()); + assert_eq!(radius.stale_grace_seconds, default_stale_grace_seconds()); + assert!(!radius.dynamic_circuit_application.enabled); + assert!( + !radius + .dynamic_circuit_application + .match_shaped_devices_by_mac + ); + assert!( + radius + .dynamic_circuit_application + .fallback_parent_node + .is_none() + ); + assert!( + radius + .dynamic_circuit_application + .fallback_parent_node_id + .is_none() + ); + assert!( + radius + .dynamic_circuit_application + .fallback_anchor_node_id + .is_none() + ); + assert!(radius.fallback_speed_profile.is_none()); + } + + fn valid_client() -> RadiusAccountingClient { + RadiusAccountingClient { + name: TEST_CLIENT_NAME.to_string(), + source: vec![source(TEST_SOURCE), source("2001:db8::/48")], + secret_file: RadiusSharedSecretSource::from(TEST_SECRET_FILE), + } + } + + fn valid_fallback_speed_profile() -> RadiusFallbackSpeedProfile { + RadiusFallbackSpeedProfile { + download_min_mbps: 5.0, + upload_min_mbps: 3.0, + download_max_mbps: 25.0, + upload_max_mbps: 10.0, + } + } + + fn valid_enabled_config() -> RadiusAccountingConfig { + RadiusAccountingConfig { + enabled: true, + listen: Some( + TEST_LISTEN + .parse() + .expect("test listen address should parse"), + ), + default_ttl_seconds: default_ttl_seconds(), + stale_grace_seconds: default_stale_grace_seconds(), + dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig::default(), + fallback_speed_profile: None, + clients: vec![valid_client()], + } + } + + #[test] + fn disabled_radius_accounting_allows_no_clients() { + let config = RadiusAccountingConfig::default(); + + config + .validate() + .expect("disabled radius accounting should not require clients"); + } + + #[test] + fn disabled_radius_accounting_round_trips() { + let config = Config { + radius_accounting: Some(RadiusAccountingConfig::default()), + ..Config::default() + }; + + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + let radius = parsed + .radius_accounting + .expect("radius accounting section should round trip"); + + assert_disabled_defaults(&radius); + } + + #[test] + fn empty_radius_accounting_toml_section_uses_disabled_defaults() { + let config = Config::load_from_string(&example_with_radius( + r#" + +[radius_accounting] +"#, + )) + .expect("empty radius accounting section should deserialize"); + let radius = config + .radius_accounting + .expect("radius accounting should be present"); + + assert_disabled_defaults(&radius); + } + + #[test] + fn enabled_radius_accounting_round_trips_and_redacts_debug_secret_source() { + let config = Config { + radius_accounting: Some(valid_enabled_config()), + ..Config::default() + }; + + config + .validate() + .expect("radius accounting should validate"); + let debug = format!("{config:?}"); + assert!(!debug.contains(TEST_SECRET_FILE)); + assert!(debug.contains("RadiusSharedSecretSource(REDACTED)")); + + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + assert!(raw.contains("[radius_accounting]")); + assert!(raw.contains(TEST_SECRET_FILE_LINE)); + + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + assert_eq!(parsed.radius_accounting, config.radius_accounting); + + let toml_config = Config::load_from_string(&example_with_radius(&enabled_radius_section( + TEST_FALLBACK_SECTION_LINES, + Some(TEST_CLIENT_LINES), + ))) + .expect("fallback speed profile TOML should deserialize"); + assert_eq!( + toml_config + .radius_accounting + .expect("radius accounting should be present") + .fallback_speed_profile, + Some(valid_fallback_speed_profile()) + ); + } + + #[test] + fn disabled_dynamic_circuit_application_round_trips() { + let mut radius = valid_enabled_config(); + radius.dynamic_circuit_application.enabled = false; + let config = Config { + radius_accounting: Some(radius), + ..Config::default() + }; + + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + let radius = parsed + .radius_accounting + .expect("radius accounting section should round trip"); + + assert!(!radius.dynamic_circuit_application.enabled); + } + + #[test] + fn enabled_dynamic_circuit_application_round_trips_without_top_level_dynamic_circuits() { + let mut radius = valid_enabled_config(); + radius.dynamic_circuit_application.enabled = true; + let config = Config { + radius_accounting: Some(radius), + ..Config::default() + }; + + config + .validate() + .expect("valid RADIUS dynamic circuit activation should pass"); + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + assert!(raw.contains("[radius_accounting.dynamic_circuit_application]")); + + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + let radius = parsed + .radius_accounting + .expect("radius accounting section should round trip"); + assert!(radius.dynamic_circuit_application.enabled); + assert!( + !radius + .dynamic_circuit_application + .match_shaped_devices_by_mac + ); + assert!(parsed.dynamic_circuits.is_none()); + } + + #[test] + fn fallback_speed_profile_round_trips() { + let mut radius = valid_enabled_config(); + radius.fallback_speed_profile = Some(valid_fallback_speed_profile()); + let config = Config { + radius_accounting: Some(radius), + ..Config::default() + }; + + config + .validate() + .expect("valid fallback speed profile should pass"); + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + assert!(raw.contains("[radius_accounting.fallback_speed_profile]")); + assert!(raw.contains("download_min_mbps = 5.0")); + + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + assert_eq!(parsed.radius_accounting, config.radius_accounting); + } + + #[test] + fn mac_match_dynamic_application_setting_round_trips() { + let mut radius = valid_enabled_config(); + radius.dynamic_circuit_application.enabled = true; + radius + .dynamic_circuit_application + .match_shaped_devices_by_mac = true; + let config = Config { + radius_accounting: Some(radius), + ..Config::default() + }; + + config + .validate() + .expect("valid RADIUS MAC matching settings should pass"); + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + assert!(raw.contains("match_shaped_devices_by_mac = true")); + + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + let radius = parsed + .radius_accounting + .expect("radius accounting section should round trip"); + assert!( + radius + .dynamic_circuit_application + .match_shaped_devices_by_mac + ); + } + + #[test] + fn username_match_dynamic_application_setting_round_trips() { + let mut radius = valid_enabled_config(); + radius.dynamic_circuit_application.enabled = true; + radius + .dynamic_circuit_application + .match_shaped_devices_by_username = true; + let config = Config { + radius_accounting: Some(radius), + ..Config::default() + }; + + config + .validate() + .expect("valid RADIUS username matching settings should pass"); + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + assert!(raw.contains("match_shaped_devices_by_username = true")); + + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + let radius = parsed + .radius_accounting + .expect("radius accounting section should round trip"); + assert!( + radius + .dynamic_circuit_application + .match_shaped_devices_by_username + ); + } + + #[test] + fn fallback_parent_settings_round_trip() { + let mut radius = valid_enabled_config(); + radius.dynamic_circuit_application.enabled = true; + radius.dynamic_circuit_application.fallback_parent_node = Some("Core PPPoE".to_string()); + radius.dynamic_circuit_application.fallback_parent_node_id = Some("core-pppoe".to_string()); + radius.dynamic_circuit_application.fallback_anchor_node_id = + Some("radius-anchor".to_string()); + let config = Config { + radius_accounting: Some(radius), + ..Config::default() + }; + + config + .validate() + .expect("valid RADIUS fallback parent settings should pass"); + let raw = toml::to_string_pretty(&config).expect("config should serialize"); + assert!(raw.contains("fallback_parent_node = \"Core PPPoE\"")); + assert!(raw.contains("fallback_parent_node_id = \"core-pppoe\"")); + assert!(raw.contains("fallback_anchor_node_id = \"radius-anchor\"")); + + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + assert_eq!(parsed.radius_accounting, config.radius_accounting); + } + + #[test] + fn validation_rejects_invalid_fallback_speed_profile() { + for (label, profile, expected) in [ + ( + "non-finite download minimum", + RadiusFallbackSpeedProfile { + download_min_mbps: f32::NAN, + ..valid_fallback_speed_profile() + }, + "download_min_mbps must be finite", + ), + ( + "non-finite upload maximum", + RadiusFallbackSpeedProfile { + upload_max_mbps: f32::INFINITY, + ..valid_fallback_speed_profile() + }, + "upload_max_mbps must be finite", + ), + ( + "zero download maximum", + RadiusFallbackSpeedProfile { + download_max_mbps: 0.0, + ..valid_fallback_speed_profile() + }, + "download_max_mbps must be > 0", + ), + ( + "negative upload minimum", + RadiusFallbackSpeedProfile { + upload_min_mbps: -1.0, + ..valid_fallback_speed_profile() + }, + "upload_min_mbps must be > 0", + ), + ( + "download minimum greater than maximum", + RadiusFallbackSpeedProfile { + download_min_mbps: 30.0, + ..valid_fallback_speed_profile() + }, + "download_min_mbps must be <= radius_accounting.fallback_speed_profile.download_max_mbps", + ), + ( + "upload minimum greater than maximum", + RadiusFallbackSpeedProfile { + upload_min_mbps: 15.0, + ..valid_fallback_speed_profile() + }, + "upload_min_mbps must be <= radius_accounting.fallback_speed_profile.upload_max_mbps", + ), + ] { + let mut config = valid_enabled_config(); + config.dynamic_circuit_application.enabled = true; + config.fallback_speed_profile = Some(profile); + let error = config.validate().expect_err(label); + + assert!( + error.contains(expected), + "expected error to contain '{expected}', got '{error}'" + ); + } + } + + #[test] + fn dynamic_circuit_application_does_not_require_top_level_dynamic_circuits() { + let mut radius = valid_enabled_config(); + radius.dynamic_circuit_application.enabled = true; + + let missing_top_level_dynamic_circuits = Config { + radius_accounting: Some(radius.clone()), + ..Config::default() + }; + missing_top_level_dynamic_circuits + .validate() + .expect("RADIUS dynamic circuit activation should not require dynamic_circuits"); + + let disabled_top_level_dynamic_circuits = Config { + dynamic_circuits: Some(Default::default()), + radius_accounting: Some(radius), + ..Config::default() + }; + disabled_top_level_dynamic_circuits + .validate() + .expect("disabled dynamic_circuits should not block RADIUS dynamic circuit activation"); + } + + #[test] + fn disabled_radius_accounting_dynamic_application_does_not_require_dynamic_circuits() { + let radius = RadiusAccountingConfig { + dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig { + enabled: true, + ..RadiusDynamicCircuitApplicationConfig::default() + }, + ..RadiusAccountingConfig::default() + }; + let config = Config { + radius_accounting: Some(radius), + ..Config::default() + }; + + config + .validate() + .expect("disabled RADIUS accounting should not require dynamic_circuits"); + } + + #[test] + fn dynamic_circuit_activation_settings_deserialize_without_top_level_dynamic_circuits() { + let radius = format!( + r#" + +[radius_accounting] +enabled = true +{TEST_LISTEN_LINE} + +[radius_accounting.dynamic_circuit_application] +enabled = true + +[[radius_accounting.clients]] +{TEST_SOURCE_LINE} +{TEST_SECRET_FILE_LINE} +"# + ); + let config = Config::load_from_string(&example_with_radius(&radius)) + .expect("valid activation settings should deserialize"); + let radius = config + .radius_accounting + .expect("radius accounting should be present"); + + assert!(radius.dynamic_circuit_application.enabled); + assert!(config.dynamic_circuits.is_none()); + } + + #[test] + fn source_accepts_single_string_in_toml() { + let radius = enabled_radius_section( + &[TEST_TTL_SECONDS_LINE, TEST_STALE_GRACE_SECONDS_LINE], + Some(TEST_NAMED_CLIENT_LINES), + ); + let client = first_radius_client(&radius); + + assert_eq!(client.source[0].network().to_string(), "192.0.2.10/32"); + } + + #[test] + fn enabled_toml_without_timing_values_uses_defaults() { + let radius = enabled_radius_section(&[], Some(TEST_CLIENT_LINES)); + let config = Config::load_from_string(&example_with_radius(&radius)) + .expect("timing defaults should deserialize"); + let radius = config + .radius_accounting + .expect("radius accounting should be present"); + + assert_eq!(radius.default_ttl_seconds, default_ttl_seconds()); + assert_eq!(radius.stale_grace_seconds, default_stale_grace_seconds()); + } + + #[test] + fn enabled_radius_accounting_requires_listen_and_clients() { + let mut config = valid_enabled_config(); + config.listen = None; + let error = config + .validate() + .expect_err("enabled radius accounting should require listen"); + assert!(error.contains("listen")); + + let mut config = valid_enabled_config(); + config.clients.clear(); + let error = config + .validate() + .expect_err("enabled radius accounting should require clients"); + assert!(error.contains("clients")); + } + + #[test] + fn validation_rejects_invalid_listen_address() { + let radius = radius_section( + true, + &[r#"listen = "not-a-socket""#], + Some(TEST_CLIENT_LINES), + ); + load_error_contains(&radius, "invalid socket address"); + } + + #[test] + fn validation_rejects_zero_listen_port() { + let mut config = valid_enabled_config(); + config.listen = Some("0.0.0.0:0".parse().expect("test listen should parse")); + + let error = config + .validate() + .expect_err("zero listen port should fail validation"); + + assert!(error.contains("listen port")); + } + + #[test] + fn validation_rejects_invalid_client_source() { + let radius = enabled_radius_section( + &[], + Some(&[r#"source = "not-a-network""#, TEST_SECRET_FILE_LINE]), + ); + load_error_contains(&radius, "not a valid IP address or CIDR"); + } + + #[test] + fn validation_rejects_missing_secret_source() { + let mut config = valid_enabled_config(); + config.clients[0].secret_file = RadiusSharedSecretSource::default(); + + let error = config + .validate() + .expect_err("missing secret source should fail validation"); + + assert!(error.contains("secret_file")); + } + + #[test] + fn validation_rejects_missing_client_source() { + load_error_cases([ + ( + enabled_radius_section(&[], Some(&[TEST_SECRET_FILE_LINE])), + "source", + ), + ( + enabled_radius_section(&[], Some(&["source = []", TEST_SECRET_FILE_LINE])), + "source", + ), + ]); + } + + #[test] + fn validation_checks_each_configured_client() { + let mut config = valid_enabled_config(); + let mut second_client = valid_client(); + second_client.name = "pppoe-core-2".to_string(); + second_client.source.clear(); + config.clients.push(second_client); + + let error = config + .validate() + .expect_err("second client missing source should fail validation"); + + assert!(error.contains("clients[1]")); + assert!(error.contains("pppoe-core-2")); + assert!(error.contains("source")); + + let mut config = valid_enabled_config(); + let mut second_client = valid_client(); + second_client.name = "pppoe-core-2".to_string(); + second_client.secret_file = RadiusSharedSecretSource::default(); + config.clients.push(second_client); + + let error = config + .validate() + .expect_err("second client missing secret should fail validation"); + + assert!(error.contains("clients[1]")); + assert!(error.contains("pppoe-core-2")); + assert!(error.contains("secret_file")); + } + + #[test] + fn multiple_clients_toml_deserializes_and_validates() { + let radius = enabled_radius_section_with_clients(&[ + TEST_NAMED_CLIENT_LINES, + &[ + r#"name = "pppoe-core-2""#, + r#"source = ["192.0.2.11/32", "2001:db8:1::/48"]"#, + TEST_SECRET_FILE_LINE, + ], + ]); + let config = Config::load_from_string(&example_with_radius(&radius)) + .expect("multiple radius clients should deserialize"); + let clients = config + .radius_accounting + .expect("radius accounting should be present") + .clients; + + assert_eq!(clients.len(), 2); + assert_eq!(clients[1].name, "pppoe-core-2"); + assert_eq!(clients[1].source[0].network().to_string(), "192.0.2.11/32"); + assert_eq!( + clients[1].source[1].network().to_string(), + "2001:db8:1::/48" + ); + } + + #[test] + fn multiple_clients_toml_reports_second_client_validation_error() { + let radius = enabled_radius_section_with_clients(&[ + TEST_NAMED_CLIENT_LINES, + &[r#"name = "pppoe-core-2""#, TEST_SECRET_FILE_LINE], + ]); + let raw = example_with_radius(&radius); + + let error = Config::load_from_string(&raw) + .expect_err("second TOML client missing source should fail validation"); + + assert!(error.contains("clients[1]")); + assert!(error.contains("pppoe-core-2")); + assert!(error.contains("source")); + } + + #[test] + fn validation_rejects_non_positive_timing_values() { + let mut config = valid_enabled_config(); + config.default_ttl_seconds = 0; + let error = config + .validate() + .expect_err("zero ttl should fail validation"); + assert!(error.contains("default_ttl_seconds")); + + let mut config = valid_enabled_config(); + config.stale_grace_seconds = 0; + let error = config + .validate() + .expect_err("zero stale grace should fail validation"); + assert!(error.contains("stale_grace_seconds")); + } + + #[test] + fn top_level_load_rejects_validation_failures() { + load_error_cases([ + ( + enabled_radius_section(&[], Some(&[TEST_SOURCE_LINE])), + "secret_file", + ), + ( + enabled_radius_section(&["default_ttl_seconds = 0"], Some(TEST_CLIENT_LINES)), + "default_ttl_seconds", + ), + ( + enabled_radius_section(&["stale_grace_seconds = 0"], Some(TEST_CLIENT_LINES)), + "stale_grace_seconds", + ), + ( + enabled_radius_section( + &[ + "[radius_accounting.dynamic_circuit_application]", + "enabled = true", + "[radius_accounting.fallback_speed_profile]", + "download_min_mbps = 0.0", + "upload_min_mbps = 3.0", + "download_max_mbps = 25.0", + "upload_max_mbps = 10.0", + ], + Some(TEST_CLIENT_LINES), + ), + "fallback_speed_profile.download_min_mbps", + ), + ( + enabled_radius_section( + &[ + "[radius_accounting.dynamic_circuit_application]", + "enabled = true", + "fallback_parent_node = \" \"", + ], + Some(TEST_CLIENT_LINES), + ), + "fallback_parent_node", + ), + ( + enabled_radius_section( + &[ + "[radius_accounting.dynamic_circuit_application]", + "enabled = true", + "fallback_parent_node_id = \"core-pppoe\"", + ], + Some(TEST_CLIENT_LINES), + ), + "fallback_parent_node must be set", + ), + (enabled_radius_section(&[], None), "clients"), + ]); + } + + #[test] + fn disabled_radius_accounting_rejects_invalid_configured_values() { + load_error_contains( + r#" + +[radius_accounting] +enabled = false +default_ttl_seconds = 0 +"#, + "default_ttl_seconds", + ); + + load_error_contains( + r#" + +[radius_accounting] +enabled = false +listen = "0.0.0.0:0" +"#, + "listen port", + ); + } + + #[test] + fn disabled_radius_accounting_rejects_invalid_configured_clients() { + load_error_cases([ + ( + radius_section(false, &[], Some(&["source = []", TEST_SECRET_FILE_LINE])), + "source", + ), + ( + radius_section( + false, + &[], + Some(&[TEST_SOURCE_LINE, r#"secret_file = " ""#]), + ), + "secret_file", + ), + ( + radius_section( + false, + &[], + Some(&[r#"source = " ""#, TEST_SECRET_FILE_LINE]), + ), + "source is empty", + ), + ]); + } + + #[test] + fn validation_accepts_bare_ip_sources() { + let radius = enabled_radius_section( + &[], + Some(&[ + r#"source = ["192.0.2.10", "2001:db8::1"]"#, + TEST_SECRET_FILE_LINE, + ]), + ); + let client = first_radius_client(&radius); + + assert_eq!(client.source[0].network().to_string(), "192.0.2.10/32"); + assert_eq!(client.source[1].network().to_string(), "2001:db8::1/128"); + } +} diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs index ed6289bc..cfc98f59 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -181,6 +181,12 @@ pub struct Config { #[serde(default, skip_serializing_if = "Option::is_none")] pub dynamic_circuits: Option, + /// RADIUS accounting configuration. + /// + /// Optional so older configs without this section still deserialize cleanly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub radius_accounting: Option, + /// Network flows configuration pub flows: Option, @@ -372,6 +378,9 @@ impl Config { if let Some(dynamic_circuits) = &self.dynamic_circuits { dynamic_circuits.validate()?; } + if let Some(radius_accounting) = &self.radius_accounting { + radius_accounting.validate()?; + } Ok(()) } @@ -447,6 +456,7 @@ impl Default for Config { local_api: super::local_api::LocalApiConfig::default(), ip_ranges: super::ip_ranges::IpRanges::default(), dynamic_circuits: None, + radius_accounting: None, integration_common: super::integration_common::IntegrationConfig::default(), topology: super::topology::TopologyConfig::default(), mikrotik_ipv6: super::mikrotik_ipv6::MikrotikIpv6Config::default(), diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 07c3fc7b..d062c751 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -42,12 +42,14 @@ pub use cpu_topology::{ }; pub use etc::{ BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, - LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RttThresholds, - SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy, TopologyConfig, - TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, + LocalApiKeyConfig, MAX_LOCAL_API_KEYS, MikrotikIpv6Config, QueueMode, RadiusAccountingClient, + RadiusAccountingConfig, RadiusClientSource, RadiusDynamicCircuitApplicationConfig, + RadiusFallbackSpeedProfile, RadiusSharedSecretSource, RateProfileValidationError, + RttThresholds, SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy, + TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, clear_cached_config, disable_xdp_bridge, enable_long_term_stats, load_config, normalize_external_hostname, - treeguard_cpu_mode_migration_notice, update_config, + treeguard_cpu_mode_migration_notice, update_config, validate_rate_profile_mbps, }; pub use ethernet_port_limits::{ DEFAULT_ETHERNET_PORT_LIMIT_MULTIPLIER, EthernetPortLimitPolicy, EthernetPortObservation, diff --git a/src/rust/lqos_config/src/shaped_devices/mod.rs b/src/rust/lqos_config/src/shaped_devices/mod.rs index 876920ed..75f3365f 100644 --- a/src/rust/lqos_config/src/shaped_devices/mod.rs +++ b/src/rust/lqos_config/src/shaped_devices/mod.rs @@ -2,7 +2,7 @@ mod serializable; mod shaped_device; use csv::{QuoteStyle, ReaderBuilder, WriterBuilder}; -use lqos_utils::{XdpIpAddress, hash_to_i64}; +use lqos_utils::XdpIpAddress; use serializable::SerializableShapedDevice; pub use shaped_device::ShapedDevice; use std::net::IpAddr; @@ -33,9 +33,7 @@ impl Default for ConfigShapedDevices { impl ConfigShapedDevices { fn refresh_derived_fields(devices: &mut [ShapedDevice]) { for device in devices { - device.circuit_hash = hash_to_i64(&device.circuit_id); - device.device_hash = hash_to_i64(&device.device_id); - device.parent_hash = hash_to_i64(&device.parent_node); + device.refresh_hashes(); } } @@ -340,6 +338,7 @@ pub enum ShapedDevicesError { #[cfg(test)] mod test { + use lqos_utils::hash_to_i64; use std::net::{Ipv4Addr, Ipv6Addr}; use super::*; diff --git a/src/rust/lqos_config/src/shaped_devices/shaped_device.rs b/src/rust/lqos_config/src/shaped_devices/shaped_device.rs index 83082d44..353a669a 100644 --- a/src/rust/lqos_config/src/shaped_devices/shaped_device.rs +++ b/src/rust/lqos_config/src/shaped_devices/shaped_device.rs @@ -40,8 +40,7 @@ pub struct ShapedDevice { #[serde(skip_serializing_if = "Option::is_none")] pub anchor_node_id: Option, - /// The device's MAC address. This isn't actually used, it exists for - /// convenient mapping/seraching. + /// The device's MAC address or RADIUS username, used by imports and RADIUS identity matching. pub mac: String, /// A list of all IPv4 addresses and CIDR subnets associated with the @@ -91,6 +90,15 @@ pub struct ShapedDevice { } impl ShapedDevice { + /// Refreshes derived hash fields from the current circuit, device, and parent identifiers. + /// + /// Side effects: mutates only `circuit_hash`, `device_hash`, and `parent_hash` on this value. + pub fn refresh_hashes(&mut self) { + self.circuit_hash = hash_to_i64(&self.circuit_id); + self.device_hash = hash_to_i64(&self.device_id); + self.parent_hash = hash_to_i64(&self.parent_node); + } + fn normalize_header(value: &str) -> String { value .chars() @@ -149,6 +157,9 @@ impl ShapedDevice { "mac" => { layout.insert("mac", idx); } + "radiususername" | "radius_username" | "username" => { + layout.insert("legacy_radius_username", idx); + } "ipv4" => { layout.insert("ipv4", idx); } @@ -252,7 +263,14 @@ impl ShapedDevice { "" => None, value => Some(value.to_string()), }, - mac: Self::field(record, &layout, "mac").to_string(), + mac: { + let mac = Self::field(record, &layout, "mac"); + if mac.trim().is_empty() { + Self::field(record, &layout, "legacy_radius_username").to_string() + } else { + mac.to_string() + } + }, ipv4: ShapedDevice::parse_ipv4(Self::field(record, &layout, "ipv4")), ipv6: ShapedDevice::parse_ipv6(Self::field(record, &layout, "ipv6")), download_min_mbps: { @@ -321,10 +339,11 @@ impl ShapedDevice { }, comment: Self::field(record, &layout, "comment").to_string(), sqm_override: None, - circuit_hash: hash_to_i64(Self::field(record, &layout, "circuit_id")), - device_hash: hash_to_i64(Self::field(record, &layout, "device_id")), - parent_hash: hash_to_i64(Self::field(record, &layout, "parent_node")), + circuit_hash: 0, + device_hash: 0, + parent_hash: 0, }; + device.refresh_hashes(); // Optional 14th field: per-circuit SQM override token if let Some(raw) = layout.get("sqm").and_then(|idx| record.get(*idx)) { @@ -578,6 +597,128 @@ mod tests { assert_eq!(device.ipv4.len(), 1); } + #[test] + fn test_header_aware_username_in_mac_parsing() { + let headers = StringRecord::from(vec![ + "Circuit ID", + "Circuit Name", + "Device ID", + "Device Name", + "Parent Node", + "Parent Node ID", + "Anchor Node ID", + "MAC", + "IPv4", + "IPv6", + "Download Min Mbps", + "Upload Min Mbps", + "Download Max Mbps", + "Upload Max Mbps", + "Comment", + ]); + let record = StringRecord::from(vec![ + "radius-circuit", + "RADIUS circuit", + "radius-device", + "RADIUS device", + "Tower-A", + "", + "", + "pppoe-known", + "192.0.2.10", + "", + "10", + "10", + "100", + "100", + "RADIUS username fixture", + ]); + + let device = ShapedDevice::from_csv(&record, Some(&headers)) + .expect("a RADIUS username should be accepted in the MAC field"); + assert_eq!(device.mac, "pppoe-known"); + } + + #[test] + fn test_legacy_radius_username_populates_an_empty_mac_field() { + let headers = StringRecord::from(vec![ + "Circuit ID", + "Circuit Name", + "Device ID", + "Device Name", + "Parent Node", + "MAC", + "IPv4", + "IPv6", + "Download Min Mbps", + "Upload Min Mbps", + "Download Max Mbps", + "Upload Max Mbps", + "Comment", + "RADIUS Username", + ]); + let record = StringRecord::from(vec![ + "radius-circuit", + "RADIUS circuit", + "radius-device", + "RADIUS device", + "Tower-A", + "", + "192.0.2.10", + "", + "10", + "10", + "100", + "100", + "legacy RADIUS username fixture", + "pppoe-known", + ]); + + let device = ShapedDevice::from_csv(&record, Some(&headers)) + .expect("a legacy RADIUS Username should populate an empty MAC field"); + assert_eq!(device.mac, "pppoe-known"); + } + + #[test] + fn test_mac_field_takes_precedence_over_legacy_radius_username() { + let headers = StringRecord::from(vec![ + "Circuit ID", + "Circuit Name", + "Device ID", + "Device Name", + "Parent Node", + "MAC", + "IPv4", + "IPv6", + "Download Min Mbps", + "Upload Min Mbps", + "Download Max Mbps", + "Upload Max Mbps", + "Comment", + "RADIUS Username", + ]); + let record = StringRecord::from(vec![ + "radius-circuit", + "RADIUS circuit", + "radius-device", + "RADIUS device", + "Tower-A", + "aa:bb:cc:dd:ee:ff", + "192.0.2.10", + "", + "10", + "10", + "100", + "100", + "legacy RADIUS username fixture", + "pppoe-known", + ]); + + let device = ShapedDevice::from_csv(&record, Some(&headers)) + .expect("the MAC field should take precedence over a legacy RADIUS Username"); + assert_eq!(device.mac, "aa:bb:cc:dd:ee:ff"); + } + #[test] fn test_header_aware_id_alias_parsing() { let headers = StringRecord::from(vec![ diff --git a/src/rust/lqos_radius/Cargo.toml b/src/rust/lqos_radius/Cargo.toml new file mode 100644 index 00000000..33d730ca --- /dev/null +++ b/src/rust/lqos_radius/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lqos_radius" +version = "0.1.0" +edition = "2024" +license = "GPL-2.0-only" + +[dependencies] +clap = { workspace = true } +hmac = { workspace = true } +ip_network = { workspace = true } +lqos_config = { path = "../lqos_config" } +md-5 = { workspace = true } +subtle = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } diff --git a/src/rust/lqos_radius/src/accounting_event.rs b/src/rust/lqos_radius/src/accounting_event.rs new file mode 100644 index 00000000..2f5c6083 --- /dev/null +++ b/src/rust/lqos_radius/src/accounting_event.rs @@ -0,0 +1,566 @@ +//! Typed RADIUS Accounting-Request event extraction. + +use crate::attribute_type::{ACCT_STATUS_TYPE, VENDOR_SPECIFIC}; +use crate::packet::split_radius_tlv; +use crate::{AccountingRequest, RadiusAttribute, VerifiedAccountingRequest}; +use std::net::{Ipv4Addr, Ipv6Addr}; + +const USER_NAME: u8 = 1; +const NAS_IP_ADDRESS: u8 = 4; +const NAS_PORT: u8 = 5; +const FRAMED_IP_ADDRESS: u8 = 8; +const FRAMED_IP_NETMASK: u8 = 9; +const FRAMED_ROUTE: u8 = 22; +const CLASS: u8 = 25; +const CALLED_STATION_ID: u8 = 30; +const CALLING_STATION_ID: u8 = 31; +const NAS_IDENTIFIER: u8 = 32; +const ACCT_SESSION_ID: u8 = 44; +const EVENT_TIMESTAMP: u8 = 55; +const NAS_PORT_ID: u8 = 87; +const NAS_IPV6_ADDRESS: u8 = 95; +const FRAMED_IPV6_PREFIX: u8 = 97; +const DELEGATED_IPV6_PREFIX: u8 = 123; +const FRAMED_IPV6_ADDRESS: u8 = 168; + +const MIKROTIK_VENDOR_ID: u32 = 14988; +const MIKROTIK_RATE_LIMIT: u8 = 8; + +/// Common RADIUS accounting attributes extracted into typed fields. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AccountingEvent { + /// Acct-Status-Type when present. + pub status_type: Option, + /// Acct-Session-Id when present. + pub acct_session_id: Option, + /// NAS-IP-Address when present. + pub nas_ip_address: Option, + /// NAS-IPv6-Address when present. + pub nas_ipv6_address: Option, + /// NAS-Identifier when present. + pub nas_identifier: Option, + /// NAS-Port when present. + pub nas_port: Option, + /// NAS-Port-Id when present. + pub nas_port_id: Option, + /// User-Name when present. + pub user_name: Option, + /// Calling-Station-Id when present. + pub calling_station_id: Option, + /// Called-Station-Id when present. + pub called_station_id: Option, + /// Class attributes when present. RADIUS permits more than one Class value. + pub class: Vec>, + /// Event-Timestamp as Unix seconds when present. + pub event_timestamp: Option, + /// Framed-IP-Address when present. + pub framed_ip_address: Option, + /// Framed-IP-Netmask when present. + pub framed_ip_netmask: Option, + /// Framed-Route values when present. + pub framed_routes: Vec, + /// Framed-IPv6-Prefix values when present. + pub framed_ipv6_prefixes: Vec, + /// Delegated-IPv6-Prefix values when present. + pub delegated_ipv6_prefixes: Vec, + /// Framed-IPv6-Address when present. + pub framed_ipv6_address: Option, + /// Standard attributes this extractor does not expose as typed fields or could not decode. + pub unknown_standard_attributes: Vec, + /// Vendor-Specific attributes this extractor does not understand or cannot decode. + pub unknown_vendor_attributes: Vec, + /// Decoded MikroTik-Rate-Limit attributes when present. + pub mikrotik_rate_limits: Vec, +} + +impl AccountingEvent { + /// Extracts a typed accounting event from a parsed Accounting-Request. + /// + /// Side effects: none. The packet is inspected in memory only. + #[must_use] + pub fn from_request(request: &AccountingRequest) -> Self { + Self::from_request_with_options(request, AccountingEventOptions::default()) + } + + /// Extracts a typed accounting event from a parsed Accounting-Request. + /// + /// Side effects: none. The packet is inspected in memory only. + #[must_use] + pub fn from_request_with_options( + request: &AccountingRequest, + options: AccountingEventOptions, + ) -> Self { + let mut event = Self::default(); + + for attribute in request.packet().attributes() { + event.apply_attribute(attribute, options); + } + + event + } + + /// Extracts a typed accounting event from a verified Accounting-Request. + /// + /// Side effects: none. The packet is inspected in memory only. + #[must_use] + pub fn from_verified(request: &VerifiedAccountingRequest) -> Self { + Self::from_verified_with_options(request, AccountingEventOptions::default()) + } + + /// Extracts a typed accounting event from a verified Accounting-Request. + /// + /// Side effects: none. The packet is inspected in memory only. + #[must_use] + pub fn from_verified_with_options( + request: &VerifiedAccountingRequest, + options: AccountingEventOptions, + ) -> Self { + Self::from_request_with_options(request.request(), options) + } + + fn apply_attribute(&mut self, attribute: &RadiusAttribute, options: AccountingEventOptions) { + match attribute.kind() { + USER_NAME => set_once(&mut self.user_name, Some(text(attribute.value()))), + NAS_IP_ADDRESS => set_optional( + &mut self.unknown_standard_attributes, + &mut self.nas_ip_address, + attribute, + ipv4, + ), + NAS_PORT => set_optional( + &mut self.unknown_standard_attributes, + &mut self.nas_port, + attribute, + u32_value, + ), + FRAMED_IP_ADDRESS => set_optional( + &mut self.unknown_standard_attributes, + &mut self.framed_ip_address, + attribute, + ipv4, + ), + FRAMED_IP_NETMASK => set_optional( + &mut self.unknown_standard_attributes, + &mut self.framed_ip_netmask, + attribute, + ipv4, + ), + FRAMED_ROUTE => self.framed_routes.push(text(attribute.value())), + CLASS => self.class.push(attribute.value().to_vec()), + VENDOR_SPECIFIC => self.apply_vendor_specific(attribute.value(), options), + CALLED_STATION_ID => { + set_once(&mut self.called_station_id, Some(text(attribute.value()))) + } + CALLING_STATION_ID => { + set_once(&mut self.calling_station_id, Some(text(attribute.value()))); + } + NAS_IDENTIFIER => set_once(&mut self.nas_identifier, Some(text(attribute.value()))), + ACCT_STATUS_TYPE => set_optional( + &mut self.unknown_standard_attributes, + &mut self.status_type, + attribute, + |value| u32_value(value).map(AcctStatusType::from), + ), + ACCT_SESSION_ID => set_once(&mut self.acct_session_id, Some(text(attribute.value()))), + EVENT_TIMESTAMP => set_optional( + &mut self.unknown_standard_attributes, + &mut self.event_timestamp, + attribute, + u32_value, + ), + NAS_PORT_ID => set_once(&mut self.nas_port_id, Some(text(attribute.value()))), + NAS_IPV6_ADDRESS => set_optional( + &mut self.unknown_standard_attributes, + &mut self.nas_ipv6_address, + attribute, + ipv6, + ), + FRAMED_IPV6_PREFIX => push_optional( + &mut self.unknown_standard_attributes, + &mut self.framed_ipv6_prefixes, + attribute, + ipv6_prefix, + ), + DELEGATED_IPV6_PREFIX => push_optional( + &mut self.unknown_standard_attributes, + &mut self.delegated_ipv6_prefixes, + attribute, + ipv6_prefix, + ), + FRAMED_IPV6_ADDRESS => set_optional( + &mut self.unknown_standard_attributes, + &mut self.framed_ipv6_address, + attribute, + ipv6, + ), + _ => self.unknown_standard_attributes.push(attribute.clone()), + } + } + + fn apply_vendor_specific(&mut self, value: &[u8], options: AccountingEventOptions) { + let Some((vendor_id, mut remaining)) = vendor_id_and_payload(value) else { + self.unknown_vendor_attributes + .push(UnknownVendorAttribute::malformed(value)); + return; + }; + + if remaining.is_empty() { + self.unknown_vendor_attributes + .push(UnknownVendorAttribute::vendor_only(vendor_id)); + return; + } + + if vendor_id != MIKROTIK_VENDOR_ID { + self.unknown_vendor_attributes + .push(UnknownVendorAttribute::raw_vendor_payload( + vendor_id, remaining, + )); + return; + } + + while !remaining.is_empty() { + let Ok((vendor_type, vendor_value, next)) = split_radius_tlv(remaining) else { + self.unknown_vendor_attributes + .push(UnknownVendorAttribute::vendor_data(vendor_id, remaining)); + return; + }; + + if vendor_type == MIKROTIK_RATE_LIMIT + && let Some(rate_limit) = MikrotikRateLimit::parse(vendor_value, options) + { + self.mikrotik_rate_limits.push(rate_limit); + remaining = next; + continue; + } + + self.unknown_vendor_attributes + .push(UnknownVendorAttribute::subattribute( + vendor_id, + vendor_type, + vendor_value, + )); + + remaining = next; + } + } +} + +impl From<&AccountingRequest> for AccountingEvent { + fn from(request: &AccountingRequest) -> Self { + Self::from_request(request) + } +} + +impl From<&VerifiedAccountingRequest> for AccountingEvent { + fn from(request: &VerifiedAccountingRequest) -> Self { + Self::from_verified(request) + } +} + +/// Options that control typed accounting event extraction. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AccountingEventOptions { + /// Mapping between MikroTik NAS RX/TX rates and LibreQoS upload/download rates. + pub mikrotik_rate_limit_direction: MikrotikRateLimitDirection, +} + +/// Acct-Status-Type values understood by LibreQoS. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AcctStatusType { + /// Start, value 1. + Start, + /// Stop, value 2. + Stop, + /// Interim-Update, value 3. + InterimUpdate, + /// Accounting-On, value 7. + AccountingOn, + /// Accounting-Off, value 8. + AccountingOff, + /// A status value not enumerated by this crate. + Unknown(u32), +} + +impl AcctStatusType { + /// Returns the RADIUS integer value for this Acct-Status-Type. + #[must_use] + pub const fn as_u32(self) -> u32 { + match self { + Self::Start => 1, + Self::Stop => 2, + Self::InterimUpdate => 3, + Self::AccountingOn => 7, + Self::AccountingOff => 8, + Self::Unknown(value) => value, + } + } +} + +impl From for AcctStatusType { + fn from(value: u32) -> Self { + match value { + 1 => Self::Start, + 2 => Self::Stop, + 3 => Self::InterimUpdate, + 7 => Self::AccountingOn, + 8 => Self::AccountingOff, + other => Self::Unknown(other), + } + } +} + +impl From for u32 { + fn from(status_type: AcctStatusType) -> Self { + status_type.as_u32() + } +} + +/// IPv6 prefix decoded from RADIUS ipv6prefix attributes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Ipv6Prefix { + /// The IPv6 network address bytes supplied by the NAS. + pub address: Ipv6Addr, + /// Prefix length in bits. + pub prefix_len: u8, +} + +/// A Vendor-Specific attribute this extractor did not decode. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnknownVendorAttribute { + /// Vendor ID when the Vendor-Specific attribute had enough bytes to include it. + pub vendor_id: Option, + /// Vendor type when the vendor payload had a complete subattribute header. + pub vendor_type: Option, + /// Vendor attribute value, or the remaining malformed bytes when no value could be split out. + pub value: Vec, +} + +impl UnknownVendorAttribute { + fn malformed(value: &[u8]) -> Self { + Self { + vendor_id: None, + vendor_type: None, + value: value.to_vec(), + } + } + + fn vendor_only(vendor_id: u32) -> Self { + Self { + vendor_id: Some(vendor_id), + vendor_type: None, + value: Vec::new(), + } + } + + fn vendor_data(vendor_id: u32, value: &[u8]) -> Self { + Self { + vendor_id: Some(vendor_id), + vendor_type: value.first().copied(), + value: value.to_vec(), + } + } + + fn raw_vendor_payload(vendor_id: u32, value: &[u8]) -> Self { + Self { + vendor_id: Some(vendor_id), + vendor_type: None, + value: value.to_vec(), + } + } + + fn subattribute(vendor_id: u32, vendor_type: u8, value: &[u8]) -> Self { + Self { + vendor_id: Some(vendor_id), + vendor_type: Some(vendor_type), + value: value.to_vec(), + } + } +} + +/// Mapping between MikroTik NAS directions and LibreQoS directions. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum MikrotikRateLimitDirection { + /// Map NAS RX to LibreQoS upload and NAS TX to LibreQoS download. + #[default] + Normal, + /// Map NAS RX to LibreQoS download and NAS TX to LibreQoS upload. + Swapped, +} + +impl MikrotikRateLimitDirection { + const fn map_rates(self, nas_rx_bps: u64, nas_tx_bps: u64) -> (u64, u64) { + match self { + Self::Normal => (nas_rx_bps, nas_tx_bps), + Self::Swapped => (nas_tx_bps, nas_rx_bps), + } + } +} + +/// MikroTik-Rate-Limit values decoded from a Vendor-Specific attribute. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MikrotikRateLimit { + /// Original MikroTik-Rate-Limit string. + pub original: String, + /// NAS RX rate in bits per second. + pub nas_rx_bps: u64, + /// NAS TX rate in bits per second. + pub nas_tx_bps: u64, + /// LibreQoS upload rate in bits per second after direction mapping. + pub upload_bps: u64, + /// LibreQoS download rate in bits per second after direction mapping. + pub download_bps: u64, +} + +impl MikrotikRateLimit { + fn parse(value: &[u8], options: AccountingEventOptions) -> Option { + let original = text(value); + let rate_pair = original.split_whitespace().next()?; + let (nas_rx_bps, nas_tx_bps) = parse_mikrotik_rate_pair(rate_pair)?; + let (upload_bps, download_bps) = options + .mikrotik_rate_limit_direction + .map_rates(nas_rx_bps, nas_tx_bps); + + Some(Self { + original, + nas_rx_bps, + nas_tx_bps, + upload_bps, + download_bps, + }) + } +} + +fn set_once(slot: &mut Option, value: Option) { + if slot.is_none() { + *slot = value; + } +} + +fn set_optional( + unknown_standard_attributes: &mut Vec, + slot: &mut Option, + attribute: &RadiusAttribute, + decode: impl FnOnce(&[u8]) -> Option, +) { + let decoded = decode(attribute.value()); + if decoded.is_none() { + unknown_standard_attributes.push(attribute.clone()); + } + set_once(slot, decoded); +} + +fn push_optional( + unknown_standard_attributes: &mut Vec, + items: &mut Vec, + attribute: &RadiusAttribute, + decode: impl FnOnce(&[u8]) -> Option, +) { + let Some(decoded) = decode(attribute.value()) else { + unknown_standard_attributes.push(attribute.clone()); + return; + }; + items.push(decoded); +} + +fn text(value: &[u8]) -> String { + String::from_utf8_lossy(value).into_owned() +} + +fn ipv4(value: &[u8]) -> Option { + let bytes: [u8; 4] = value.try_into().ok()?; + Some(Ipv4Addr::from(bytes)) +} + +fn ipv6(value: &[u8]) -> Option { + let bytes: [u8; 16] = value.try_into().ok()?; + Some(Ipv6Addr::from(bytes)) +} + +fn ipv6_prefix(value: &[u8]) -> Option { + if !(2..=18).contains(&value.len()) { + return None; + } + if value[0] != 0 { + return None; + } + let prefix_len = value[1]; + if prefix_len > 128 { + return None; + } + let expected_prefix_bytes = usize::from(prefix_len).div_ceil(8); + let prefix_bytes = &value[2..]; + if prefix_bytes.len() < expected_prefix_bytes { + return None; + } + if prefix_bytes[expected_prefix_bytes..] + .iter() + .any(|byte| *byte != 0) + { + return None; + } + let mut bytes = [0_u8; 16]; + bytes[..expected_prefix_bytes].copy_from_slice(&prefix_bytes[..expected_prefix_bytes]); + if let Some(mask) = final_prefix_byte_mask(prefix_len) { + if bytes[expected_prefix_bytes - 1] & !mask != 0 { + return None; + } + bytes[expected_prefix_bytes - 1] &= mask; + } + Some(Ipv6Prefix { + address: Ipv6Addr::from(bytes), + prefix_len, + }) +} + +fn final_prefix_byte_mask(prefix_len: u8) -> Option { + let remainder = prefix_len % 8; + if remainder == 0 { + None + } else { + Some(u8::MAX << (8 - remainder)) + } +} + +fn u32_value(value: &[u8]) -> Option { + let bytes: [u8; 4] = value.try_into().ok()?; + Some(u32::from_be_bytes(bytes)) +} + +fn vendor_id_and_payload(value: &[u8]) -> Option<(u32, &[u8])> { + let vendor_id_bytes: [u8; 4] = value.get(..4)?.try_into().ok()?; + Some((u32::from_be_bytes(vendor_id_bytes), &value[4..])) +} + +fn parse_mikrotik_rate_pair(value: &str) -> Option<(u64, u64)> { + let (nas_rx, nas_tx) = match value.split_once('/') { + Some(pair) => pair, + None => (value, value), + }; + Some((parse_mikrotik_rate(nas_rx)?, parse_mikrotik_rate(nas_tx)?)) +} + +fn parse_mikrotik_rate(value: &str) -> Option { + let trimmed = value.trim(); + let digit_count = trimmed + .as_bytes() + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + if digit_count == 0 { + return None; + } + + let number = trimmed[..digit_count].parse::().ok()?; + let multiplier = match trimmed[digit_count..].to_ascii_lowercase().as_str() { + "" | "bps" => 1, + "k" | "kb" | "kbit" | "kbps" => 1_000, + "m" | "mb" | "mbit" | "mbps" => 1_000_000, + "g" | "gb" | "gbit" | "gbps" => 1_000_000_000, + _ => return None, + }; + + number.checked_mul(multiplier) +} + +#[cfg(test)] +mod tests; diff --git a/src/rust/lqos_radius/src/accounting_event/tests.rs b/src/rust/lqos_radius/src/accounting_event/tests.rs new file mode 100644 index 00000000..5e8a6f64 --- /dev/null +++ b/src/rust/lqos_radius/src/accounting_event/tests.rs @@ -0,0 +1,625 @@ +//! Tests for typed RADIUS Accounting-Request event extraction. + +use super::*; +use crate::packet::MESSAGE_AUTHENTICATOR_TYPE; +use crate::test_support::{ + SHARED_SECRET, accounting_request_packet, radius_attribute, radius_attributes, + radius_raw_vendor_attribute, radius_text_attribute, radius_u32_attribute, + radius_vendor_attribute, radius_vendor_attributes, radius_vendor_subattribute, + signed_accounting_request_packet, +}; +use crate::{ + MessageAuthenticatorPolicy, RadiusCode, handle_accounting_request, verify_accounting_request, +}; + +const RAW_ACCOUNTING_START_FIXTURE: [u8; 26] = [ + 4, + 51, + 0, + 26, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + ACCT_STATUS_TYPE, + 6, + 0, + 0, + 0, + 1, +]; +const RAW_ACCOUNTING_INTERIM_FIXTURE: [u8; 26] = [ + 4, + 52, + 0, + 26, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + ACCT_STATUS_TYPE, + 6, + 0, + 0, + 0, + 3, +]; +const RAW_ACCOUNTING_STOP_FIXTURE: [u8; 26] = [ + 4, + 53, + 0, + 26, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + ACCT_STATUS_TYPE, + 6, + 0, + 0, + 0, + 2, +]; +const RAW_UNKNOWN_VENDOR_IPV6_FIXTURE: [u8; 66] = [ + 4, + 54, + 0, + 66, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 241, + 5, + b's', + b't', + b'd', + 26, + 12, + 0, + 0, + 48, + 57, + b'v', + b'e', + b'n', + b'd', + b'o', + b'r', + NAS_IPV6_ADDRESS, + 18, + 0x20, + 0x01, + 0x0d, + 0xb8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + FRAMED_IPV6_PREFIX, + 11, + 0, + 56, + 0x20, + 0x01, + 0x0d, + 0xb8, + 0, + 0x10, + 0, +]; + +#[test] +fn extracts_acct_status_type_variants_and_unknown_values() { + let cases = [ + (1, AcctStatusType::Start), + (2, AcctStatusType::Stop), + (3, AcctStatusType::InterimUpdate), + (7, AcctStatusType::AccountingOn), + (8, AcctStatusType::AccountingOff), + (99, AcctStatusType::Unknown(99)), + ]; + + for (raw, expected) in cases { + let request = request_with_attributes(&[radius_u32_attribute(ACCT_STATUS_TYPE, raw)]); + let event = AccountingEvent::from_request(&request); + + assert_eq!(event.status_type, Some(expected)); + assert_eq!(event.status_type.unwrap().as_u32(), raw); + } +} + +#[test] +fn raw_packet_fixtures_cover_accounting_statuses_and_attribute_preservation() { + for (raw_packet, identifier, expected_status) in [ + ( + RAW_ACCOUNTING_START_FIXTURE.as_slice(), + 51, + AcctStatusType::Start, + ), + ( + RAW_ACCOUNTING_INTERIM_FIXTURE.as_slice(), + 52, + AcctStatusType::InterimUpdate, + ), + ( + RAW_ACCOUNTING_STOP_FIXTURE.as_slice(), + 53, + AcctStatusType::Stop, + ), + ] { + let request = handle_accounting_request(raw_packet).unwrap(); + let event = AccountingEvent::from_request(&request); + + assert_eq!(request.packet().code(), RadiusCode::AccountingRequest); + assert_eq!(request.packet().identifier(), identifier); + assert_eq!(event.status_type, Some(expected_status)); + } + + let request = handle_accounting_request(&RAW_UNKNOWN_VENDOR_IPV6_FIXTURE).unwrap(); + let event = AccountingEvent::from_request(&request); + + assert_unknown_standard_attributes(&event, &[(241, b"std")]); + assert_eq!( + event.unknown_vendor_attributes, + vec![UnknownVendorAttribute { + vendor_id: Some(12_345), + vendor_type: None, + value: b"vendor".to_vec(), + }] + ); + assert_eq!(event.nas_ipv6_address, Some("2001:db8::1".parse().unwrap())); + assert_eq!( + event.framed_ipv6_prefixes, + vec![Ipv6Prefix { + address: "2001:db8:10::".parse().unwrap(), + prefix_len: 56, + }] + ); +} + +#[test] +fn extracts_session_nas_identity_and_subscriber_address_fields() { + let request = request_with_attributes(&[ + radius_text_attribute(ACCT_SESSION_ID, "session-123"), + radius_attribute(NAS_IP_ADDRESS, &[192, 0, 2, 10]), + radius_attribute( + NAS_IPV6_ADDRESS, + &ipv6_bytes([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]), + ), + radius_text_attribute(NAS_IDENTIFIER, "nas-1"), + radius_u32_attribute(NAS_PORT, 17), + radius_text_attribute(NAS_PORT_ID, "ether5"), + radius_text_attribute(USER_NAME, "subscriber@example.net"), + radius_text_attribute(CALLING_STATION_ID, "00:11:22:33:44:55"), + radius_text_attribute(CALLED_STATION_ID, "service"), + radius_attribute(CLASS, b"class-data"), + radius_attribute(CLASS, b"class-data-2"), + radius_u32_attribute(EVENT_TIMESTAMP, 1_700_000_000), + radius_attribute(FRAMED_IP_ADDRESS, &[198, 51, 100, 25]), + radius_attribute(FRAMED_IP_NETMASK, &[255, 255, 255, 0]), + radius_text_attribute(FRAMED_ROUTE, "198.51.100.0/24 192.0.2.1 1"), + radius_text_attribute(FRAMED_ROUTE, "203.0.113.0/24 192.0.2.1 1"), + ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 56, 0x2001, 0xdb8, 1), + ipv6_prefix_attribute(DELEGATED_IPV6_PREFIX, 48, 0x2001, 0xdb8, 2), + radius_attribute( + FRAMED_IPV6_ADDRESS, + &ipv6_bytes([0x2001, 0xdb8, 3, 0, 0, 0, 0, 5]), + ), + ]); + let event = AccountingEvent::from_request(&request); + + assert_eq!(event.acct_session_id.as_deref(), Some("session-123")); + assert_eq!(event.nas_ip_address, Some(Ipv4Addr::new(192, 0, 2, 10))); + assert_eq!(event.nas_ipv6_address, Some("2001:db8::1".parse().unwrap())); + assert_eq!(event.nas_identifier.as_deref(), Some("nas-1")); + assert_eq!(event.nas_port, Some(17)); + assert_eq!(event.nas_port_id.as_deref(), Some("ether5")); + assert_eq!(event.user_name.as_deref(), Some("subscriber@example.net")); + assert_eq!( + event.calling_station_id.as_deref(), + Some("00:11:22:33:44:55") + ); + assert_eq!(event.called_station_id.as_deref(), Some("service")); + assert_eq!( + event.class, + vec![b"class-data".to_vec(), b"class-data-2".to_vec()] + ); + assert_eq!(event.event_timestamp, Some(1_700_000_000)); + assert_eq!( + event.framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 25)) + ); + assert_eq!( + event.framed_ip_netmask, + Some(Ipv4Addr::new(255, 255, 255, 0)) + ); + assert_eq!( + event.framed_routes, + vec![ + "198.51.100.0/24 192.0.2.1 1".to_string(), + "203.0.113.0/24 192.0.2.1 1".to_string() + ] + ); + assert_eq!( + event.framed_ipv6_prefixes, + vec![Ipv6Prefix { + address: "2001:db8:1::".parse().unwrap(), + prefix_len: 56, + }] + ); + assert_eq!( + event.delegated_ipv6_prefixes, + vec![Ipv6Prefix { + address: "2001:db8:2::".parse().unwrap(), + prefix_len: 48, + }] + ); + assert_eq!( + event.framed_ipv6_address, + Some("2001:db8:3::5".parse().unwrap()) + ); + assert_eq!(event.unknown_standard_attributes, Vec::new()); + assert_eq!(event.unknown_vendor_attributes, Vec::new()); +} + +#[test] +fn extracts_compact_ipv6_prefix_fields() { + let request = request_with_attributes(&[ + compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 0, &[]), + compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 56, &[0x20, 0x01, 0x0d, 0xb8, 0, 1, 0]), + compact_ipv6_prefix_attribute( + DELEGATED_IPV6_PREFIX, + 64, + &[0x20, 0x01, 0x0d, 0xb8, 0, 2, 0, 0], + ), + compact_ipv6_prefix_attribute( + DELEGATED_IPV6_PREFIX, + 128, + &ipv6_bytes([0x2001, 0xdb8, 3, 0, 0, 0, 0, 1]), + ), + compact_ipv6_prefix_attribute( + FRAMED_IPV6_PREFIX, + 60, + &[0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xc0, 0], + ), + ]); + let event = AccountingEvent::from_request(&request); + + assert_eq!( + event.framed_ipv6_prefixes, + vec![ + Ipv6Prefix { + address: "::".parse().unwrap(), + prefix_len: 0, + }, + Ipv6Prefix { + address: "2001:db8:1::".parse().unwrap(), + prefix_len: 56, + }, + Ipv6Prefix { + address: "2001:db8:4:abc0::".parse().unwrap(), + prefix_len: 60, + }, + ] + ); + assert_eq!( + event.delegated_ipv6_prefixes, + vec![ + Ipv6Prefix { + address: "2001:db8:2::".parse().unwrap(), + prefix_len: 64, + }, + Ipv6Prefix { + address: "2001:db8:3::1".parse().unwrap(), + prefix_len: 128, + }, + ] + ); + assert_eq!(event.unknown_standard_attributes, Vec::new()); +} + +#[test] +fn preserves_known_standard_attributes_that_fail_typed_decoding() { + let request = request_with_attributes(&[ + radius_attribute(NAS_IP_ADDRESS, &[192, 0, 2]), + radius_attribute(NAS_IPV6_ADDRESS, &[0; 15]), + radius_attribute(ACCT_STATUS_TYPE, &[1, 2, 3]), + radius_attribute(FRAMED_IPV6_ADDRESS, &[0; 15]), + compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 64, &[0x20, 0x01]), + compact_ipv6_prefix_attribute( + DELEGATED_IPV6_PREFIX, + 60, + &[0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xcd], + ), + compact_ipv6_prefix_attribute( + FRAMED_IPV6_PREFIX, + 56, + &[0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 1], + ), + compact_ipv6_prefix_attribute_with_reserved( + FRAMED_IPV6_PREFIX, + 1, + 64, + &[0x20, 0x01, 0x0d, 0xb8, 0, 5, 0, 0], + ), + ]); + let event = AccountingEvent::from_request(&request); + + assert_eq!(event.nas_ip_address, None); + assert_eq!(event.nas_ipv6_address, None); + assert_eq!(event.status_type, None); + assert_eq!(event.framed_ipv6_address, None); + assert_eq!(event.framed_ipv6_prefixes, Vec::new()); + assert_eq!(event.delegated_ipv6_prefixes, Vec::new()); + assert_unknown_standard_attributes( + &event, + &[ + (NAS_IP_ADDRESS, &[192, 0, 2][..]), + (NAS_IPV6_ADDRESS, &[0; 15][..]), + (ACCT_STATUS_TYPE, &[1, 2, 3][..]), + (FRAMED_IPV6_ADDRESS, &[0; 15][..]), + (FRAMED_IPV6_PREFIX, &[0, 64, 0x20, 0x01][..]), + ( + DELEGATED_IPV6_PREFIX, + &[0, 60, 0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xcd][..], + ), + ( + FRAMED_IPV6_PREFIX, + &[0, 56, 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 1][..], + ), + ( + FRAMED_IPV6_PREFIX, + &[1, 64, 0x20, 0x01, 0x0d, 0xb8, 0, 5, 0, 0][..], + ), + ], + ); +} + +#[test] +fn extracts_from_verified_accounting_request() { + let attributes = radius_attributes(&[radius_text_attribute(ACCT_SESSION_ID, "verified")]); + let packet = signed_accounting_request_packet(21, &attributes, SHARED_SECRET); + let verified = + verify_accounting_request(&packet, SHARED_SECRET, MessageAuthenticatorPolicy::Optional) + .unwrap(); + let event = AccountingEvent::from_verified(&verified); + + assert_eq!(event.acct_session_id.as_deref(), Some("verified")); +} + +#[test] +fn preserves_unknown_standard_and_vendor_attributes() { + let request = request_with_attributes(&[ + radius_attribute(241, b"standard"), + radius_attribute(MESSAGE_AUTHENTICATOR_TYPE, &[0_u8; 16]), + radius_raw_vendor_attribute(12_345, b"opaque-vendor"), + radius_raw_vendor_attribute(54_321, b""), + radius_attribute(VENDOR_SPECIFIC, &[0, 0, 0]), + ]); + let event = AccountingEvent::from_request(&request); + + assert_unknown_standard_attributes( + &event, + &[ + (241, b"standard"), + (MESSAGE_AUTHENTICATOR_TYPE, &[0_u8; 16][..]), + ], + ); + assert_eq!( + event.unknown_vendor_attributes, + vec![ + UnknownVendorAttribute { + vendor_id: Some(12_345), + vendor_type: None, + value: b"opaque-vendor".to_vec(), + }, + UnknownVendorAttribute { + vendor_id: Some(54_321), + vendor_type: None, + value: Vec::new(), + }, + UnknownVendorAttribute { + vendor_id: None, + vendor_type: None, + value: vec![0, 0, 0], + } + ] + ); +} + +#[test] +fn decodes_mikrotik_rate_limit_with_normal_direction_mapping() { + let request = request_with_attributes(&[radius_vendor_attribute( + MIKROTIK_VENDOR_ID, + MIKROTIK_RATE_LIMIT, + b"10M/25M 12M/30M 8M/20M 10/10 8 5M/15M", + )]); + let event = AccountingEvent::from_request(&request); + + assert_eq!(event.unknown_vendor_attributes, Vec::new()); + assert_eq!( + event.mikrotik_rate_limits, + vec![MikrotikRateLimit { + original: "10M/25M 12M/30M 8M/20M 10/10 8 5M/15M".to_string(), + nas_rx_bps: 10_000_000, + nas_tx_bps: 25_000_000, + upload_bps: 10_000_000, + download_bps: 25_000_000, + }] + ); +} + +#[test] +fn decodes_mikrotik_rate_limit_with_swapped_direction_mapping() { + let request = request_with_attributes(&[radius_vendor_attribute( + MIKROTIK_VENDOR_ID, + MIKROTIK_RATE_LIMIT, + b"512k/2M", + )]); + let options = AccountingEventOptions { + mikrotik_rate_limit_direction: MikrotikRateLimitDirection::Swapped, + }; + let event = AccountingEvent::from_request_with_options(&request, options); + + assert_eq!(event.mikrotik_rate_limits.len(), 1); + assert_eq!(event.mikrotik_rate_limits[0].nas_rx_bps, 512_000); + assert_eq!(event.mikrotik_rate_limits[0].nas_tx_bps, 2_000_000); + assert_eq!(event.mikrotik_rate_limits[0].upload_bps, 2_000_000); + assert_eq!(event.mikrotik_rate_limits[0].download_bps, 512_000); +} + +#[test] +fn preserves_unknown_and_invalid_mikrotik_subattributes() { + let request = request_with_attributes(&[radius_vendor_attributes( + MIKROTIK_VENDOR_ID, + &[ + radius_vendor_subattribute(MIKROTIK_RATE_LIMIT, b"10M/25M"), + radius_vendor_subattribute(99, b"opaque"), + radius_vendor_subattribute(MIKROTIK_RATE_LIMIT, b"not-a-rate"), + ], + )]); + let event = AccountingEvent::from_request(&request); + + assert_eq!(event.mikrotik_rate_limits.len(), 1); + assert_eq!(event.mikrotik_rate_limits[0].upload_bps, 10_000_000); + assert_eq!(event.mikrotik_rate_limits[0].download_bps, 25_000_000); + assert_eq!( + event.unknown_vendor_attributes, + vec![ + UnknownVendorAttribute { + vendor_id: Some(MIKROTIK_VENDOR_ID), + vendor_type: Some(99), + value: b"opaque".to_vec(), + }, + UnknownVendorAttribute { + vendor_id: Some(MIKROTIK_VENDOR_ID), + vendor_type: Some(MIKROTIK_RATE_LIMIT), + value: b"not-a-rate".to_vec(), + }, + ] + ); +} + +#[test] +fn preserves_malformed_mikrotik_subattributes() { + let request = request_with_attributes(&[radius_raw_vendor_attribute( + MIKROTIK_VENDOR_ID, + &[MIKROTIK_RATE_LIMIT, 1], + )]); + let event = AccountingEvent::from_request(&request); + + assert_eq!(event.mikrotik_rate_limits, Vec::new()); + assert_eq!( + event.unknown_vendor_attributes, + vec![UnknownVendorAttribute { + vendor_id: Some(MIKROTIK_VENDOR_ID), + vendor_type: Some(MIKROTIK_RATE_LIMIT), + value: vec![MIKROTIK_RATE_LIMIT, 1], + }] + ); +} + +fn assert_unknown_standard_attributes(event: &AccountingEvent, expected: &[(u8, &[u8])]) { + let actual: Vec<_> = event + .unknown_standard_attributes + .iter() + .map(|attribute| (attribute.kind(), attribute.value())) + .collect(); + + assert_eq!(actual.as_slice(), expected); +} + +fn request_with_attributes(attributes: &[Vec]) -> AccountingRequest { + let encoded = radius_attributes(attributes); + handle_accounting_request(&accounting_request_packet(7, &encoded)).unwrap() +} + +fn ipv6_prefix_attribute(kind: u8, prefix_len: u8, a: u16, b: u16, c: u16) -> Vec { + let mut value = Vec::with_capacity(18); + value.push(0); + value.push(prefix_len); + value.extend_from_slice(&ipv6_bytes([a, b, c, 0, 0, 0, 0, 0])); + radius_attribute(kind, &value) +} + +fn compact_ipv6_prefix_attribute(kind: u8, prefix_len: u8, prefix_bytes: &[u8]) -> Vec { + compact_ipv6_prefix_attribute_with_reserved(kind, 0, prefix_len, prefix_bytes) +} + +fn compact_ipv6_prefix_attribute_with_reserved( + kind: u8, + reserved: u8, + prefix_len: u8, + prefix_bytes: &[u8], +) -> Vec { + let mut value = Vec::with_capacity(2 + prefix_bytes.len()); + value.push(reserved); + value.push(prefix_len); + value.extend_from_slice(prefix_bytes); + radius_attribute(kind, &value) +} + +fn ipv6_bytes(segments: [u16; 8]) -> [u8; 16] { + let mut bytes = [0_u8; 16]; + for (index, segment) in segments.into_iter().enumerate() { + bytes[index * 2..index * 2 + 2].copy_from_slice(&segment.to_be_bytes()); + } + bytes +} diff --git a/src/rust/lqos_radius/src/attribute_type.rs b/src/rust/lqos_radius/src/attribute_type.rs new file mode 100644 index 00000000..4ae55f85 --- /dev/null +++ b/src/rust/lqos_radius/src/attribute_type.rs @@ -0,0 +1,4 @@ +//! Crate-private RADIUS attribute type constants shared across modules. + +pub(crate) const VENDOR_SPECIFIC: u8 = 26; +pub(crate) const ACCT_STATUS_TYPE: u8 = 40; diff --git a/src/rust/lqos_radius/src/dynamic_circuit.rs b/src/rust/lqos_radius/src/dynamic_circuit.rs new file mode 100644 index 00000000..0550557e --- /dev/null +++ b/src/rust/lqos_radius/src/dynamic_circuit.rs @@ -0,0 +1,77 @@ +//! Dynamic-circuit command intents emitted by RADIUS session handling. + +use crate::{AccountingEvent, AccountingSessionKey, NasResetStatus}; +use lqos_config::ShapedDevice; + +/// Sink boundary for dynamic-circuit intents produced by RADIUS session state. +pub trait DynamicCircuitCommandSink { + /// Receives one dynamic-circuit intent. + /// + /// Side effects: depend on the sink implementation. The `lqos_radius` crate + /// only calls this boundary; it does not write dynamic circuit files or talk + /// to `lqosd` directly. + fn emit(&mut self, intent: DynamicCircuitIntent); +} + +/// Dynamic-circuit intent that an lqosd-facing adapter can map onto daemon commands. +#[derive(Clone, Debug, PartialEq)] +pub enum DynamicCircuitIntent { + /// Create a runtime dynamic circuit. Adapters can map this to `CreateDynamicCircuit`. + CreateDynamicCircuit(DynamicCircuitUpsert), + /// Update a runtime dynamic circuit. Adapters can map this to `CreateDynamicCircuit`. + UpdateDynamicCircuit(DynamicCircuitUpsert), + /// Remove a runtime dynamic circuit. Adapters may defer this until runtime removals are enabled. + RemoveDynamicCircuit(DynamicCircuitRemoval), +} + +impl DynamicCircuitIntent { + /// Returns the stable dynamic-circuit identifier carried by this intent. + #[must_use] + pub fn circuit_id(&self) -> &str { + match self { + Self::CreateDynamicCircuit(upsert) | Self::UpdateDynamicCircuit(upsert) => { + &upsert.circuit_id + } + Self::RemoveDynamicCircuit(removal) => &removal.circuit_id, + } + } +} + +/// Data needed to create or update a dynamic circuit from a RADIUS session. +#[derive(Clone, Debug, PartialEq)] +pub struct DynamicCircuitUpsert { + /// Stable circuit identifier for the dynamic circuit overlay. + pub circuit_id: String, + /// Deterministic RADIUS session key that produced this intent. + pub session_key: AccountingSessionKey, + /// Latest decoded accounting event data for the shapeable session. + pub event: AccountingEvent, + /// Fully resolved in-memory `ShapedDevice` payload for the active session. + pub shaped_device: ShapedDevice, +} + +/// Data needed to remove a dynamic circuit from a RADIUS session. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DynamicCircuitRemoval { + /// Stable circuit identifier for the dynamic circuit overlay. + pub circuit_id: String, + /// Deterministic RADIUS session key that produced this intent. + pub session_key: AccountingSessionKey, + /// Why this removal was emitted. + pub reason: DynamicCircuitRemovalReason, +} + +/// Cause for a dynamic-circuit removal intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DynamicCircuitRemovalReason { + /// The RADIUS session emitted Acct-Status-Type Stop. + Stop, + /// The in-memory session was expired by the caller. + Expired, + /// A previously shapeable session no longer has enough data to stay active. + NoLongerShapeable, + /// A session was promoted to a different deterministic dynamic-circuit id. + Rekeyed, + /// A NAS reset event marked the session stale. + NasReset(NasResetStatus), +} diff --git a/src/rust/lqos_radius/src/lib.rs b/src/rust/lqos_radius/src/lib.rs new file mode 100644 index 00000000..fe9ec5e9 --- /dev/null +++ b/src/rust/lqos_radius/src/lib.rs @@ -0,0 +1,48 @@ +//! RADIUS accounting packet parsing and rootless listener support. +//! +//! The crate checks RADIUS packet framing, parses and verifies +//! Accounting-Request packets, builds Accounting-Response packets, tracks +//! decoded accounting sessions in memory, resolves shapeable sessions into +//! in-memory shaped-device definitions, exposes deferred dynamic-circuit +//! command intents for callers that opt in, and exposes a UDP listener that can +//! bind to non-privileged loopback addresses for development and automated tests. + +#![warn(missing_docs)] + +mod accounting_event; +mod attribute_type; +mod dynamic_circuit; +mod listener; +mod mac_match; +mod packet; +mod session; +#[cfg(test)] +mod test_support; + +pub use accounting_event::{ + AccountingEvent, AccountingEventOptions, AcctStatusType, Ipv6Prefix, MikrotikRateLimit, + MikrotikRateLimitDirection, UnknownVendorAttribute, +}; +pub use dynamic_circuit::{ + DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemoval, + DynamicCircuitRemovalReason, DynamicCircuitUpsert, +}; +pub use listener::{ + AccountingListenerOutcome, DEFAULT_LISTEN_ADDR, ListenerConfig, ListenerError, RadiusListener, + ReceivedAccountingPacket, ReceivedVerifiedAccountingPacket, TrustedClientSource, + TrustedClientSourceError, TrustedRadiusClient, TrustedRadiusClientError, start_listener, +}; +pub use mac_match::{ShapedDevicesMacMatch, ShapedDevicesMacMatcher, normalize_radius_mac}; +pub use packet::{ + AccountingRequest, MessageAuthenticatorPolicy, PacketError, RadiusAttribute, RadiusCode, + RadiusPacket, VerifiedAccountingRequest, build_accounting_response, handle_accounting_request, + parse_packet, verify_accounting_request, +}; +pub use session::{ + AccountingSession, AccountingSessionIgnoreReason, AccountingSessionKey, AccountingSessionState, + AccountingSessionStore, AccountingSessionUpdate, DynamicCircuitMapping, DynamicCircuitParent, + DynamicCircuitResolution, NasIdentity, NasResetStatus, PendingSessionFingerprint, + PendingSessionReason, RadiusActivationCounters, RadiusActivationDiagnostic, + RadiusActivationDiagnosticState, RadiusPacketCounters, ResolvedSessionRate, SessionRateProfile, + SessionRateProfileError, SessionRateSource, SessionRateSources, ShapedDevicesMatchOptions, +}; diff --git a/src/rust/lqos_radius/src/listener.rs b/src/rust/lqos_radius/src/listener.rs new file mode 100644 index 00000000..a6d636de --- /dev/null +++ b/src/rust/lqos_radius/src/listener.rs @@ -0,0 +1,511 @@ +//! UDP listener startup for RADIUS Accounting-Request packets. + +use crate::VerifiedAccountingRequest; +use crate::packet::{ + MessageAuthenticatorPolicy, PacketError, RADIUS_MAX_PACKET_LEN, handle_accounting_request, + verify_accounting_request, +}; +use ip_network::{IpNetwork, IpNetworkError}; +use std::fmt; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::str::FromStr; +use thiserror::Error; +use tokio::net::UdpSocket; + +/// Default non-privileged loopback listen address for local use and tests. +pub const DEFAULT_LISTEN_ADDR: SocketAddr = + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 18130)); + +/// Configuration used to start a RADIUS accounting listener. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ListenerConfig { + /// UDP address the listener will bind. + pub listen_addr: SocketAddr, +} + +impl Default for ListenerConfig { + fn default() -> Self { + Self { + listen_addr: DEFAULT_LISTEN_ADDR, + } + } +} + +/// A started UDP listener for RADIUS accounting datagrams. +pub struct RadiusListener { + socket: UdpSocket, +} + +impl RadiusListener { + /// Returns the local UDP address bound by the listener. + /// + /// Side effects: queries the operating system for the socket's local + /// address. It does not read packets or change socket state. + pub fn local_addr(&self) -> Result { + match self.socket.local_addr() { + Ok(addr) => Ok(addr), + Err(source) => Err(ListenerError::LocalAddr { source }), + } + } + + /// Waits for and parses the next RADIUS Accounting-Request datagram. + /// + /// Side effects: awaits network input on the listener's UDP socket. + /// Malformed or unsupported packets are returned as errors and no response + /// packets are sent. This diagnostic listener does not verify shared-secret + /// authenticators; callers that know the trusted client secret should use + /// `verify_accounting_request` before accepting a packet. + pub async fn receive_next(&self) -> Result { + let mut datagram = [0_u8; RADIUS_MAX_PACKET_LEN]; + let (received_len, peer) = receive_datagram(&self.socket, &mut datagram).await?; + + let request = match handle_accounting_request(&datagram[..received_len]) { + Ok(request) => request, + Err(source) => return Err(ListenerError::Packet { peer, source }), + }; + + Ok(ReceivedAccountingPacket { + peer, + received_len, + request, + }) + } + + /// Waits for the next datagram, verifies it against trusted clients, and + /// sends Accounting-Response packets for accepted Accounting-Requests. + /// + /// Side effects: awaits network input on the listener socket and sends one + /// UDP Accounting-Response when the packet source and authenticators are + /// trusted. Rejected packets do not produce accounting events and do not + /// receive responses. + pub async fn receive_next_verified( + &self, + clients: &[TrustedRadiusClient], + ) -> Result { + let mut datagram = [0_u8; RADIUS_MAX_PACKET_LEN]; + let (received_len, peer) = receive_datagram(&self.socket, &mut datagram).await?; + + let mut matched_client = None; + for client in clients { + if !client.source_matches(peer.ip()) { + continue; + } + if matched_client.replace(client).is_some() { + return Ok(AccountingListenerOutcome::RejectedAmbiguousSource { + peer, + received_len, + }); + } + } + let Some(client) = matched_client else { + return Ok(AccountingListenerOutcome::RejectedSource { peer, received_len }); + }; + + let request = match verify_accounting_request( + &datagram[..received_len], + client.shared_secret(), + client.message_authenticator_policy(), + ) { + Ok(request) => request, + Err(source) => { + return Ok(AccountingListenerOutcome::RejectedPacket { + peer, + received_len, + source, + }); + } + }; + let response = request.build_response(client.shared_secret()); + let response_len = match self.socket.send_to(&response, peer).await { + Ok(response_len) => response_len, + Err(source) => return Err(ListenerError::Send { peer, source }), + }; + + Ok(AccountingListenerOutcome::Accepted( + ReceivedVerifiedAccountingPacket { + peer, + received_len, + response_len, + request, + }, + )) + } +} + +/// One parse-only Accounting-Request datagram received by the listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReceivedAccountingPacket { + /// UDP peer address that sent the datagram. + pub peer: SocketAddr, + /// Number of bytes received in the datagram. + pub received_len: usize, + /// Parsed, unverified Accounting-Request packet. + pub request: crate::AccountingRequest, +} + +/// Runtime allow-list entry for a trusted RADIUS client source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrustedClientSource { + network: IpNetwork, +} + +impl TrustedClientSource { + /// Creates a source matcher for one host IP address. + #[must_use] + pub fn host(address: IpAddr) -> Self { + Self { + network: IpNetwork::from(address), + } + } + + /// Creates a source matcher for an IP network. + /// + /// Side effects: none. The supplied address must be the network address for + /// the prefix length. + pub fn network(address: IpAddr, prefix_len: u8) -> Result { + source_network(address, prefix_len).map(|network| Self { network }) + } + + /// Returns the canonical source address or network address. + #[must_use] + pub fn address(&self) -> IpAddr { + self.network.network_address() + } + + /// Returns the source prefix length. + #[must_use] + pub fn prefix_len(&self) -> u8 { + self.network.netmask() + } + + /// Returns true when `address` belongs to this source allow-list entry. + #[must_use] + pub fn contains(&self, address: IpAddr) -> bool { + self.network.contains(address) + } +} + +impl FromStr for TrustedClientSource { + type Err = TrustedClientSourceError; + + fn from_str(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(TrustedClientSourceError::Empty); + } + + let Some((address, prefix_len)) = trimmed.split_once('/') else { + return trimmed.parse::().map(Self::host).map_err(|_| { + TrustedClientSourceError::InvalidAddress { + raw: trimmed.to_string(), + } + }); + }; + + let address = + address + .parse::() + .map_err(|_| TrustedClientSourceError::InvalidAddress { + raw: address.to_string(), + })?; + let prefix_len = prefix_len.parse::().map_err(|_| { + TrustedClientSourceError::InvalidPrefixLengthText { + raw: prefix_len.to_string(), + } + })?; + + source_network(address, prefix_len).map(|network| Self { network }) + } +} + +/// Errors returned while building trusted RADIUS client source matchers. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum TrustedClientSourceError { + /// The supplied source text is empty. + #[error("RADIUS client source must not be empty")] + Empty, + /// The supplied source text does not contain a valid IP address. + #[error("RADIUS client source address '{raw}' is invalid")] + InvalidAddress { + /// Source address text supplied by the caller. + raw: String, + }, + /// The supplied CIDR prefix is not numeric. + #[error("RADIUS client source prefix '{raw}' is invalid")] + InvalidPrefixLengthText { + /// Prefix text supplied by the caller. + raw: String, + }, + /// The supplied prefix length is outside the address-family range. + #[error( + "RADIUS client source prefix length {prefix_len} is invalid for {address}; valid range is 0..={max_prefix_len}" + )] + InvalidPrefixLength { + /// Address supplied for the source matcher. + address: IpAddr, + /// Prefix length supplied for the source matcher. + prefix_len: u8, + /// Maximum prefix length for the address family. + max_prefix_len: u8, + }, + /// The supplied CIDR string contains host bits in the network address. + #[error("RADIUS client source '{address}/{prefix_len}' has host bits set")] + HostBitsSet { + /// Address supplied for the source matcher. + address: IpAddr, + /// Prefix length supplied for the source matcher. + prefix_len: u8, + }, +} + +/// Runtime trusted RADIUS client with source matching and shared secret. +#[derive(Clone, Eq, PartialEq)] +pub struct TrustedRadiusClient { + sources: Vec, + shared_secret: Vec, + message_authenticator_policy: MessageAuthenticatorPolicy, +} + +impl TrustedRadiusClient { + /// Creates a trusted RADIUS client using optional Message-Authenticator + /// policy. + /// + /// Side effects: none. The shared secret is copied into owned memory for + /// packet verification and response signing. + pub fn new( + sources: Vec, + shared_secret: impl Into>, + ) -> Result { + Self::with_message_authenticator_policy( + sources, + shared_secret, + MessageAuthenticatorPolicy::Optional, + ) + } + + /// Creates a trusted RADIUS client with an explicit Message-Authenticator + /// policy. + /// + /// Side effects: none. The shared secret is copied into owned memory for + /// packet verification and response signing. + pub fn with_message_authenticator_policy( + sources: Vec, + shared_secret: impl Into>, + message_authenticator_policy: MessageAuthenticatorPolicy, + ) -> Result { + if sources.is_empty() { + return Err(TrustedRadiusClientError::NoSources); + } + + let shared_secret = shared_secret.into(); + if shared_secret.is_empty() { + return Err(TrustedRadiusClientError::EmptySharedSecret); + } + + Ok(Self { + sources, + shared_secret, + message_authenticator_policy, + }) + } + + /// Returns the source allow-list for this client. + #[must_use] + pub fn sources(&self) -> &[TrustedClientSource] { + &self.sources + } + + /// Returns the shared secret used for packet verification and response + /// signing. + #[must_use] + pub fn shared_secret(&self) -> &[u8] { + &self.shared_secret + } + + /// Returns the Message-Authenticator policy for this client. + #[must_use] + pub const fn message_authenticator_policy(&self) -> MessageAuthenticatorPolicy { + self.message_authenticator_policy + } + + fn source_matches(&self, address: IpAddr) -> bool { + self.sources.iter().any(|source| source.contains(address)) + } +} + +impl fmt::Debug for TrustedRadiusClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TrustedRadiusClient") + .field("sources", &self.sources) + .field("shared_secret", &"REDACTED") + .field( + "message_authenticator_policy", + &self.message_authenticator_policy, + ) + .finish() + } +} + +/// Errors returned while building trusted RADIUS clients. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum TrustedRadiusClientError { + /// The client has no source allow-list entries. + #[error("trusted RADIUS client must include at least one source")] + NoSources, + /// The client has no shared secret bytes. + #[error("trusted RADIUS client shared secret must not be empty")] + EmptySharedSecret, +} + +/// Result of handling one verified-listener UDP datagram. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AccountingListenerOutcome { + /// The datagram was verified and received an Accounting-Response. + Accepted(ReceivedVerifiedAccountingPacket), + /// The datagram source IP did not match any trusted client. + RejectedSource { + /// UDP peer address that sent the rejected datagram. + peer: SocketAddr, + /// Number of bytes received in the rejected datagram. + received_len: usize, + }, + /// The datagram source IP matched more than one trusted client, so the + /// listener rejected it instead of guessing which shared secret should apply. + RejectedAmbiguousSource { + /// UDP peer address that sent the rejected datagram. + peer: SocketAddr, + /// Number of bytes received in the rejected datagram. + received_len: usize, + }, + /// The datagram source matched a trusted client, but packet parsing or + /// authenticator verification failed. + RejectedPacket { + /// UDP peer address that sent the rejected datagram. + peer: SocketAddr, + /// Number of bytes received in the rejected datagram. + received_len: usize, + /// Packet parsing or authenticator verification error. + source: PacketError, + }, +} + +/// One accepted and verified Accounting-Request handled by the listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReceivedVerifiedAccountingPacket { + /// UDP peer address that sent the datagram. + pub peer: SocketAddr, + /// Number of bytes received in the accepted datagram. + pub received_len: usize, + /// Number of bytes sent in the Accounting-Response datagram. + pub response_len: usize, + /// Verified Accounting-Request packet. + pub request: VerifiedAccountingRequest, +} + +/// Errors returned while starting or receiving from the RADIUS listener. +#[derive(Debug, Error)] +pub enum ListenerError { + /// The UDP listener could not bind the requested address. + #[error("failed to bind RADIUS UDP listener to {addr}: {source}")] + Bind { + /// Address that failed to bind. + addr: SocketAddr, + /// Operating-system bind error. + #[source] + source: std::io::Error, + }, + /// The UDP listener could not receive a datagram. + #[error("failed to receive RADIUS UDP datagram: {source}")] + Receive { + /// Operating-system receive error. + #[source] + source: std::io::Error, + }, + /// The UDP listener could not send a response datagram. + #[error("failed to send RADIUS Accounting-Response to {peer}: {source}")] + Send { + /// UDP peer address that should have received the response. + peer: SocketAddr, + /// Operating-system send error. + #[source] + source: std::io::Error, + }, + /// The listener's local address could not be queried. + #[error("failed to read RADIUS UDP listener local address: {source}")] + LocalAddr { + /// Operating-system local-address error. + #[source] + source: std::io::Error, + }, + /// The listener received a malformed or unsupported packet. + #[error("received malformed or unsupported RADIUS accounting packet from {peer}: {source}")] + Packet { + /// UDP peer address that sent the rejected datagram. + peer: SocketAddr, + /// Packet parsing error. + #[source] + source: PacketError, + }, +} + +async fn receive_datagram( + socket: &UdpSocket, + datagram: &mut [u8; RADIUS_MAX_PACKET_LEN], +) -> Result<(usize, SocketAddr), ListenerError> { + match socket.recv_from(datagram).await { + Ok(received) => Ok(received), + Err(source) => Err(ListenerError::Receive { source }), + } +} + +const fn max_prefix_len(address: IpAddr) -> u8 { + match address { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + } +} + +fn network_error( + address: IpAddr, + prefix_len: u8, + source: IpNetworkError, +) -> TrustedClientSourceError { + match source { + IpNetworkError::NetmaskError(_) => TrustedClientSourceError::InvalidPrefixLength { + address, + prefix_len, + max_prefix_len: max_prefix_len(address), + }, + IpNetworkError::HostBitsSet => TrustedClientSourceError::HostBitsSet { + address, + prefix_len, + }, + } +} + +fn source_network(address: IpAddr, prefix_len: u8) -> Result { + IpNetwork::new(address, prefix_len).map_err(|source| network_error(address, prefix_len, source)) +} + +/// Starts a UDP listener for RADIUS accounting packets. +/// +/// Side effects: binds a UDP socket to `config.listen_addr`. This function does +/// not touch TC/XDP state, services, files, or privileged ports unless the caller +/// explicitly supplies such an address. +pub async fn start_listener(config: ListenerConfig) -> Result { + let socket = match UdpSocket::bind(config.listen_addr).await { + Ok(socket) => socket, + Err(source) => { + return Err(ListenerError::Bind { + addr: config.listen_addr, + source, + }); + } + }; + + Ok(RadiusListener { socket }) +} + +#[cfg(test)] +mod tests; diff --git a/src/rust/lqos_radius/src/listener/tests.rs b/src/rust/lqos_radius/src/listener/tests.rs new file mode 100644 index 00000000..ec5edb3c --- /dev/null +++ b/src/rust/lqos_radius/src/listener/tests.rs @@ -0,0 +1,550 @@ +//! Tests for RADIUS UDP listener behavior. + +use super::*; +use crate::attribute_type::ACCT_STATUS_TYPE; +use crate::packet::{RADIUS_HEADER_LEN, RADIUS_MAX_ACCOUNTING_PACKET_LEN, RADIUS_MAX_PACKET_LEN}; +use crate::test_support::{ + SHARED_SECRET, accounting_request_packet, accounting_request_packet_with_message_authenticator, + max_sized_accounting_request_packet, radius_attributes, radius_packet, radius_text_attribute, + radius_u32_attribute, signed_accounting_request_packet, +}; +use crate::{PacketError, RadiusCode, parse_packet}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; +use tokio::net::UdpSocket; +use tokio::time::timeout; + +const ACCT_SESSION_ID: u8 = 44; +const UDP_NO_RESPONSE_TIMEOUT: Duration = Duration::from_millis(25); +const UDP_TEST_TIMEOUT: Duration = Duration::from_secs(1); + +#[test] +fn default_listener_address_is_rootless_loopback() { + let config = ListenerConfig::default(); + + assert!(config.listen_addr.ip().is_loopback()); + assert!(config.listen_addr.port() > 1024); +} + +mod udp_listener { + //! Loopback UDP listener tests. + + use super::*; + + #[tokio::test] + async fn receives_accounting_request_on_loopback_ephemeral_port() { + let fixture = LoopbackUdpFixture::bind().await; + + fixture.send(&accounting_request_packet(11, &[])).await; + let received = fixture.receive_next().await.unwrap(); + + assert_eq!(received.peer, fixture.sender_addr()); + assert_eq!(received.received_len, RADIUS_HEADER_LEN); + assert_eq!(received.request.packet().identifier(), 11); + } + + #[tokio::test] + async fn receives_maximum_sized_accounting_request() { + let fixture = LoopbackUdpFixture::bind().await; + + fixture.send(&max_sized_accounting_request_packet(14)).await; + let received = fixture.receive_next().await.unwrap(); + + assert_eq!(received.received_len, RADIUS_MAX_ACCOUNTING_PACKET_LEN); + assert_eq!(received.request.packet().identifier(), 14); + assert_eq!(received.request.packet().attributes().len(), 16); + } + + #[tokio::test] + async fn reports_packet_errors_with_sender_address() { + let fixture = LoopbackUdpFixture::bind().await; + + fixture + .send(&radius_packet(RadiusCode::AccessRequest, 12, &[])) + .await; + let err = fixture.receive_next().await.unwrap_err(); + + let ListenerError::Packet { peer, source } = err else { + panic!("expected packet parsing error, got {err:?}"); + }; + assert_eq!(peer, fixture.sender_addr()); + assert_eq!(source, PacketError::UnsupportedCode { code: 1 }); + } + + #[tokio::test] + async fn reports_malformed_datagrams_with_sender_address() { + let fixture = LoopbackUdpFixture::bind().await; + + fixture + .send(&[RadiusCode::AccountingRequest.as_u8(), 13]) + .await; + let err = fixture.receive_next().await.unwrap_err(); + + let ListenerError::Packet { peer, source } = err else { + panic!("expected packet parsing error, got {err:?}"); + }; + assert_eq!(peer, fixture.sender_addr()); + assert_eq!(source, PacketError::PacketTooShort { actual: 2 }); + + fixture.send(&accounting_request_packet(15, &[])).await; + let received = fixture.receive_next().await.unwrap(); + + assert_eq!(received.peer, fixture.sender_addr()); + assert_eq!(received.request.packet().identifier(), 15); + } + + #[tokio::test] + async fn verified_listener_accepts_request_and_sends_response() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = trusted_loopback_clients(SHARED_SECRET); + let attributes = radius_attributes(&[ + radius_u32_attribute(ACCT_STATUS_TYPE, 1), + radius_text_attribute(ACCT_SESSION_ID, "session-accepted"), + ]); + let request = signed_accounting_request_packet(21, &attributes, SHARED_SECRET); + + let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await); + let response = fixture.receive_response().await; + + assert_eq!(accepted.peer, fixture.sender_addr()); + assert_eq!(accepted.received_len, request.len()); + assert_eq!(accepted.response_len, response.len()); + assert_eq!(accepted.request.packet().identifier(), 21); + let event = crate::AccountingEvent::from_verified(&accepted.request); + assert_eq!(event.acct_session_id.as_deref(), Some("session-accepted")); + assert_eq!(response, accepted.request.build_response(SHARED_SECRET)); + assert_accounting_response(&response, 21); + } + + #[tokio::test] + async fn verified_listener_accepts_later_source_in_client_allow_list() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = vec![ + TrustedRadiusClient::new( + vec![alternate_loopback_source(), trusted_loopback_source()], + SHARED_SECRET, + ) + .unwrap(), + ]; + let request = signed_accounting_request_packet(31, &[], SHARED_SECRET); + + let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await); + let response = fixture.receive_response().await; + + assert_eq!(accepted.request.packet().identifier(), 31); + assert_eq!(response, accepted.request.build_response(SHARED_SECRET)); + assert_accounting_response(&response, 31); + } + + #[tokio::test] + async fn verified_listener_accepts_message_authenticator_when_required() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = trusted_loopback_clients_with_policy( + SHARED_SECRET, + MessageAuthenticatorPolicy::Required, + ); + let request = accounting_request_packet_with_message_authenticator(28, &[], SHARED_SECRET); + + let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await); + let response = fixture.receive_response().await; + + assert_eq!(accepted.request.packet().identifier(), 28); + assert!(accepted.request.has_message_authenticator()); + assert_eq!(accepted.response_len, response.len()); + assert_eq!(response, accepted.request.build_response(SHARED_SECRET)); + assert_accounting_response(&response, 28); + } + + #[tokio::test] + async fn verified_listener_rejects_missing_message_authenticator_when_required() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = trusted_loopback_clients_with_policy( + SHARED_SECRET, + MessageAuthenticatorPolicy::Required, + ); + let request = signed_accounting_request_packet(29, &[], SHARED_SECRET); + + let outcome = fixture + .verified_outcome_without_response(&request, &clients) + .await; + + assert_eq!( + outcome, + AccountingListenerOutcome::RejectedPacket { + peer: fixture.sender_addr(), + received_len: request.len(), + source: PacketError::MissingMessageAuthenticator, + } + ); + } + + #[tokio::test] + async fn verified_listener_rejects_ambiguous_source_before_parsing() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = vec![ + TrustedRadiusClient::new( + vec![ + TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8) + .unwrap(), + ], + SHARED_SECRET, + ) + .unwrap(), + TrustedRadiusClient::new(vec![trusted_loopback_source()], SHARED_SECRET).unwrap(), + ]; + let request = [RadiusCode::AccountingRequest.as_u8(), 24]; + + let outcome = fixture + .verified_outcome_without_response(&request, &clients) + .await; + + assert_eq!( + outcome, + AccountingListenerOutcome::RejectedAmbiguousSource { + peer: fixture.sender_addr(), + received_len: request.len(), + } + ); + } + + #[tokio::test] + async fn verified_listener_rejects_unconfigured_source_without_event_or_response() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = alternate_loopback_clients(); + let request = signed_accounting_request_packet(22, &[], SHARED_SECRET); + + let outcome = fixture + .verified_outcome_without_response(&request, &clients) + .await; + + assert_eq!( + outcome, + AccountingListenerOutcome::RejectedSource { + peer: fixture.sender_addr(), + received_len: request.len(), + } + ); + } + + #[tokio::test] + async fn verified_listener_rejects_malformed_untrusted_source_before_parsing() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = alternate_loopback_clients(); + let request = [RadiusCode::AccountingRequest.as_u8(), 27]; + + let outcome = fixture + .verified_outcome_without_response(&request, &clients) + .await; + + assert_eq!( + outcome, + AccountingListenerOutcome::RejectedSource { + peer: fixture.sender_addr(), + received_len: request.len(), + } + ); + } + + #[tokio::test] + async fn verified_listener_rejects_malformed_trusted_packet_without_response() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = trusted_loopback_clients(SHARED_SECRET); + let request = [RadiusCode::AccountingRequest.as_u8(), 26]; + + let outcome = fixture + .verified_outcome_without_response(&request, &clients) + .await; + + assert_eq!( + outcome, + AccountingListenerOutcome::RejectedPacket { + peer: fixture.sender_addr(), + received_len: request.len(), + source: PacketError::PacketTooShort { actual: 2 }, + } + ); + } + + #[tokio::test] + async fn verified_listener_rejects_unsupported_trusted_packet_without_response() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = trusted_loopback_clients(SHARED_SECRET); + let request = radius_packet(RadiusCode::AccessRequest, 30, &[]); + + let outcome = fixture + .verified_outcome_without_response(&request, &clients) + .await; + + assert_eq!( + outcome, + AccountingListenerOutcome::RejectedPacket { + peer: fixture.sender_addr(), + received_len: request.len(), + source: PacketError::UnsupportedCode { code: 1 }, + } + ); + } + + #[tokio::test] + async fn verified_listener_rejects_invalid_authenticator_without_event_or_response() { + let fixture = LoopbackUdpFixture::bind().await; + let clients = trusted_loopback_clients(SHARED_SECRET); + let request = signed_accounting_request_packet(23, &[], b"wrong-secret"); + + let outcome = fixture + .verified_outcome_without_response(&request, &clients) + .await; + + assert_eq!( + outcome, + AccountingListenerOutcome::RejectedPacket { + peer: fixture.sender_addr(), + received_len: request.len(), + source: PacketError::InvalidRequestAuthenticator, + } + ); + } +} + +#[test] +fn trusted_client_source_matches_exact_hosts_and_networks() { + let host = TrustedClientSource::host(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + let network = + TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 0)), 24).unwrap(); + let ipv6_network = TrustedClientSource::network("2001:db8::".parse().unwrap(), 64).unwrap(); + + assert!(host.contains(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); + assert!(!host.contains(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))); + assert_eq!(network.address(), IpAddr::V4(Ipv4Addr::new(192, 0, 2, 0))); + assert!(network.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 25)))); + assert!(!network.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 3, 25)))); + assert_eq!( + ipv6_network.address(), + "2001:db8::".parse::().unwrap() + ); + assert!(ipv6_network.contains("2001:db8::abcd".parse().unwrap())); + assert!(!ipv6_network.contains("2001:db9::abcd".parse().unwrap())); +} + +#[test] +fn trusted_client_source_parses_hosts_and_cidr_networks() { + assert_eq!( + "127.0.0.1".parse::().unwrap(), + trusted_loopback_source() + ); + assert_eq!( + "2001:db8::/64".parse::().unwrap(), + TrustedClientSource::network("2001:db8::".parse().unwrap(), 64).unwrap() + ); + assert_eq!( + "192.0.2.1/33".parse::(), + Err(TrustedClientSourceError::InvalidPrefixLength { + address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + prefix_len: 33, + max_prefix_len: 32, + }) + ); + assert_eq!( + "2001:db8::/129".parse::(), + Err(TrustedClientSourceError::InvalidPrefixLength { + address: "2001:db8::".parse().unwrap(), + prefix_len: 129, + max_prefix_len: 128, + }) + ); +} + +#[test] +fn trusted_client_source_rejects_invalid_text() { + assert_eq!( + "".parse::(), + Err(TrustedClientSourceError::Empty) + ); + assert_eq!( + "not-an-address".parse::(), + Err(TrustedClientSourceError::InvalidAddress { + raw: "not-an-address".to_string(), + }) + ); + assert_eq!( + "192.0.2.1/nope".parse::(), + Err(TrustedClientSourceError::InvalidPrefixLengthText { + raw: "nope".to_string(), + }) + ); + assert_eq!( + "192.0.2.129/24".parse::(), + Err(TrustedClientSourceError::HostBitsSet { + address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)), + prefix_len: 24, + }) + ); + assert_eq!( + TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)), 24), + Err(TrustedClientSourceError::HostBitsSet { + address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)), + prefix_len: 24, + }) + ); +} + +#[test] +fn trusted_radius_client_rejects_missing_sources_or_secret() { + assert_eq!( + TrustedRadiusClient::with_message_authenticator_policy( + Vec::new(), + SHARED_SECRET, + MessageAuthenticatorPolicy::Required, + ), + Err(TrustedRadiusClientError::NoSources) + ); + assert_eq!( + TrustedRadiusClient::with_message_authenticator_policy( + vec![trusted_loopback_source()], + Vec::new(), + MessageAuthenticatorPolicy::Required, + ), + Err(TrustedRadiusClientError::EmptySharedSecret) + ); +} + +struct LoopbackUdpFixture { + listener: RadiusListener, + listener_addr: SocketAddr, + sender: UdpSocket, +} + +impl LoopbackUdpFixture { + async fn bind() -> Self { + let listener = start_listener(ListenerConfig { + listen_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), + }) + .await + .unwrap(); + let listener_addr = listener.local_addr().unwrap(); + let sender = UdpSocket::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + + Self { + listener, + listener_addr, + sender, + } + } + + fn sender_addr(&self) -> SocketAddr { + self.sender.local_addr().unwrap() + } + + async fn send(&self, packet: &[u8]) { + self.sender + .send_to(packet, self.listener_addr) + .await + .unwrap(); + } + + async fn receive_next(&self) -> Result { + timeout(UDP_TEST_TIMEOUT, self.listener.receive_next()) + .await + .unwrap() + } + + async fn verified_outcome( + &self, + packet: &[u8], + clients: &[TrustedRadiusClient], + ) -> AccountingListenerOutcome { + self.send(packet).await; + timeout( + UDP_TEST_TIMEOUT, + self.listener.receive_next_verified(clients), + ) + .await + .unwrap() + .unwrap() + } + + async fn verified_outcome_without_response( + &self, + packet: &[u8], + clients: &[TrustedRadiusClient], + ) -> AccountingListenerOutcome { + let outcome = self.verified_outcome(packet, clients).await; + self.assert_no_response().await; + outcome + } + + async fn receive_response(&self) -> Vec { + let mut response = [0_u8; RADIUS_MAX_PACKET_LEN]; + let (response_len, peer) = timeout(UDP_TEST_TIMEOUT, self.sender.recv_from(&mut response)) + .await + .unwrap() + .unwrap(); + + assert!(peer.ip().is_loopback()); + response[..response_len].to_vec() + } + + async fn assert_no_response(&self) { + let mut response = [0_u8; RADIUS_MAX_PACKET_LEN]; + + match timeout( + UDP_NO_RESPONSE_TIMEOUT, + self.sender.recv_from(&mut response), + ) + .await + { + Err(_) => {} + Ok(Ok((response_len, peer))) => { + panic!("rejected packet received {response_len} response bytes from {peer}"); + } + Ok(Err(source)) => panic!("failed to check for rejected-packet response: {source}"), + } + } +} + +fn trusted_loopback_clients(shared_secret: &[u8]) -> Vec { + trusted_loopback_clients_with_policy(shared_secret, MessageAuthenticatorPolicy::Optional) +} + +fn trusted_loopback_clients_with_policy( + shared_secret: &[u8], + policy: MessageAuthenticatorPolicy, +) -> Vec { + vec![ + TrustedRadiusClient::with_message_authenticator_policy( + vec![trusted_loopback_source()], + shared_secret, + policy, + ) + .unwrap(), + ] +} + +fn alternate_loopback_clients() -> Vec { + vec![TrustedRadiusClient::new(vec![alternate_loopback_source()], SHARED_SECRET).unwrap()] +} + +fn trusted_loopback_source() -> TrustedClientSource { + TrustedClientSource::host(IpAddr::V4(Ipv4Addr::LOCALHOST)) +} + +fn alternate_loopback_source() -> TrustedClientSource { + TrustedClientSource::host(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))) +} + +fn expect_accepted(outcome: AccountingListenerOutcome) -> ReceivedVerifiedAccountingPacket { + match outcome { + AccountingListenerOutcome::Accepted(accepted) => accepted, + other => panic!("expected accepted packet, got {other:?}"), + } +} + +fn assert_accounting_response(response: &[u8], identifier: u8) { + let parsed = parse_packet(response).unwrap(); + + assert_eq!(parsed.code(), RadiusCode::AccountingResponse); + assert_eq!(parsed.identifier(), identifier); + assert_eq!(parsed.attributes(), &[]); +} diff --git a/src/rust/lqos_radius/src/mac_match.rs b/src/rust/lqos_radius/src/mac_match.rs new file mode 100644 index 00000000..e7970f20 --- /dev/null +++ b/src/rust/lqos_radius/src/mac_match.rs @@ -0,0 +1,287 @@ +//! RADIUS identity matching against `ShapedDevices.csv` MAC fields. + +use crate::AccountingEvent; +use lqos_config::ShapedDevice; +use std::collections::HashMap; + +/// Matches RADIUS identities against shaped-device MAC fields. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ShapedDevicesMacMatcher { + matches_by_mac: HashMap, + matches_by_username: HashMap, +} + +impl ShapedDevicesMacMatcher { + /// Builds a RADIUS identity matcher from shaped-device rows. + /// + /// Values that are valid MAC addresses are indexed for `Calling-Station-Id` + /// matching. Every non-empty MAC field is also indexed verbatim for + /// `User-Name` matching, so integrations can use the existing field for + /// either identity. Duplicate identity values are retained as ambiguous matches. + /// + /// Side effects: none. The supplied rows are inspected and cloned in memory. + #[must_use] + pub fn from_devices(devices: &[ShapedDevice]) -> Self { + let mut matches_by_mac = HashMap::new(); + let mut matches_by_username = HashMap::new(); + for device in devices { + if let Some(normalized_mac) = normalize_radius_mac(&device.mac) { + matches_by_mac + .entry(normalized_mac) + .and_modify(|entry| *entry = ShapedDevicesMacEntry::Ambiguous) + .or_insert_with(|| ShapedDevicesMacEntry::Unique(Box::new(device.clone()))); + } + let username = &device.mac; + if !username.is_empty() { + matches_by_username + .entry(username.to_string()) + .and_modify(|entry| *entry = ShapedDevicesMacEntry::Ambiguous) + .or_insert_with(|| ShapedDevicesMacEntry::Unique(Box::new(device.clone()))); + } + } + + Self { + matches_by_mac, + matches_by_username, + } + } + + /// Matches one accounting event by `Calling-Station-Id`. + /// + /// Side effects: none. The event and in-memory matcher are inspected only. + #[must_use] + pub fn match_event(&self, event: &AccountingEvent) -> ShapedDevicesMacMatch { + self.match_event_with_identities(event, false, true) + } + + /// Matches one accounting event by configured RADIUS username and MAC identities. + /// + /// A unique username match takes precedence; MAC matching is attempted only when no + /// username row matches. Side effects: none. + #[must_use] + pub fn match_event_with_identities( + &self, + event: &AccountingEvent, + match_by_username: bool, + match_by_mac: bool, + ) -> ShapedDevicesMacMatch { + if match_by_username + && let Some(username) = event + .user_name + .as_deref() + && let Some(result) = Self::match_entry(&self.matches_by_username, username) + { + return result; + } + if !match_by_mac { + return ShapedDevicesMacMatch::NoMatch; + } + let Some(calling_station_id) = event.calling_station_id.as_deref() else { + return ShapedDevicesMacMatch::NoMatch; + }; + let Some(normalized_mac) = normalize_radius_mac(calling_station_id) else { + return ShapedDevicesMacMatch::NoMatch; + }; + + Self::match_entry(&self.matches_by_mac, &normalized_mac) + .unwrap_or(ShapedDevicesMacMatch::NoMatch) + } + + fn match_entry( + entries: &HashMap, + key: &str, + ) -> Option { + match entries.get(key) { + Some(ShapedDevicesMacEntry::Unique(device)) => { + Some(ShapedDevicesMacMatch::Unique(device.clone())) + } + Some(ShapedDevicesMacEntry::Ambiguous) => Some(ShapedDevicesMacMatch::Ambiguous), + None => None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +enum ShapedDevicesMacEntry { + Unique(Box), + Ambiguous, +} + +/// Result of matching one RADIUS identity to shaped-device rows. +#[derive(Clone, Debug, PartialEq)] +pub enum ShapedDevicesMacMatch { + /// Exactly one shaped-device row matched the configured identity. + Unique(Box), + /// No shaped-device row matched the configured identity. + NoMatch, + /// More than one shaped-device row matched the configured identity. + Ambiguous, +} + +/// Normalizes common RADIUS and `ShapedDevices.csv` MAC address formats. +/// +/// Accepted inputs include colon-separated, hyphen-separated, dotted, +/// plain-hex, and mixed-case forms. The returned value is twelve lowercase +/// hexadecimal characters. +#[must_use] +pub fn normalize_radius_mac(raw_mac: &str) -> Option { + let raw_mac = raw_mac.trim(); + if raw_mac.len() == 12 && raw_mac.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Some(raw_mac.to_ascii_lowercase()); + } + + if raw_mac.contains(':') { + return normalize_delimited_mac(raw_mac, ':', 6, 2); + } + if raw_mac.contains('-') { + return normalize_delimited_mac(raw_mac, '-', 6, 2); + } + if raw_mac.contains('.') { + return normalize_delimited_mac(raw_mac, '.', 3, 4); + } + + None +} + +fn normalize_delimited_mac( + raw_mac: &str, + separator: char, + expected_groups: usize, + expected_group_len: usize, +) -> Option { + let mut normalized_mac = String::with_capacity(12); + let mut group_count = 0; + for group in raw_mac.split(separator) { + group_count += 1; + if group.len() != expected_group_len { + return None; + } + for ch in group.chars() { + if !ch.is_ascii_hexdigit() { + return None; + } + normalized_mac.push(ch.to_ascii_lowercase()); + } + } + + (group_count == expected_groups).then_some(normalized_mac) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_common_mac_formats() { + for raw_mac in [ + "AA:BB:CC:DD:EE:FF", + "aa-bb-cc-dd-ee-ff", + "AABB.CCDD.EEFF", + "aabbccddeeff", + " AaBbCcDdEeFf ", + ] { + assert_eq!( + normalize_radius_mac(raw_mac).as_deref(), + Some("aabbccddeeff") + ); + } + + assert_eq!(normalize_radius_mac("aa:bb:cc"), None); + assert_eq!(normalize_radius_mac("aa:bbccddeeff"), None); + assert_eq!(normalize_radius_mac("aa-bbccddeeff"), None); + assert_eq!(normalize_radius_mac("aabb.ccddeeff"), None); + assert_eq!(normalize_radius_mac("aa-bb.cc:ddEeFf"), None); + assert_eq!(normalize_radius_mac("aa:bb:cc:dd:ee:zz"), None); + assert_eq!(normalize_radius_mac("aa bb cc dd ee ff"), None); + } + + #[test] + fn matcher_ignores_empty_and_invalid_shaped_device_mac_rows() { + let matcher = ShapedDevicesMacMatcher::from_devices(&[ + shaped_device("empty-mac", ""), + shaped_device("invalid-mac", "aa:bbccddeeff"), + ]); + let mut event = AccountingEvent { + calling_station_id: Some("aa:bb:cc:dd:ee:ff".to_string()), + ..AccountingEvent::default() + }; + + assert_eq!(matcher.match_event(&event), ShapedDevicesMacMatch::NoMatch); + + let matcher = ShapedDevicesMacMatcher::from_devices(&[ + shaped_device("valid-mac", "aa-bb-cc-dd-ee-ff"), + shaped_device("ignored-invalid-mac", "aa:bbccddeeff"), + ]); + let ShapedDevicesMacMatch::Unique(device) = matcher.match_event(&event) else { + panic!("valid shaped-device MAC should match uniquely"); + }; + assert_eq!(device.circuit_id, "valid-mac"); + + event.calling_station_id = Some("aa:bbccddeeff".to_string()); + assert_eq!(matcher.match_event(&event), ShapedDevicesMacMatch::NoMatch); + } + + #[test] + fn matcher_marks_duplicate_normalized_macs_ambiguous() { + let matcher = ShapedDevicesMacMatcher::from_devices(&[ + shaped_device("first-circuit", "aa-bb-cc-dd-ee-ff"), + shaped_device("second-circuit", "AABB.CCDD.EEFF"), + ]); + let event = AccountingEvent { + calling_station_id: Some("AA:BB:CC:DD:EE:FF".to_string()), + ..AccountingEvent::default() + }; + + assert_eq!( + matcher.match_event(&event), + ShapedDevicesMacMatch::Ambiguous + ); + } + + #[test] + fn username_match_precedes_mac_when_mac_field_holds_a_username() { + let username_device = shaped_device("username-circuit", " pppoe-known "); + let mac_device = shaped_device("mac-circuit", "aa:bb:cc:dd:ee:ff"); + let matcher = ShapedDevicesMacMatcher::from_devices(&[username_device, mac_device]); + let event = AccountingEvent { + user_name: Some(" pppoe-known ".to_string()), + calling_station_id: Some("aa:bb:cc:dd:ee:ff".to_string()), + ..AccountingEvent::default() + }; + + let ShapedDevicesMacMatch::Unique(device) = + matcher.match_event_with_identities(&event, true, true) + else { + panic!("username should match uniquely before Calling-Station-Id"); + }; + assert_eq!(device.circuit_id, "username-circuit"); + + let untrimmed_username = AccountingEvent { + user_name: Some("pppoe-known".to_string()), + ..AccountingEvent::default() + }; + assert_eq!( + matcher.match_event_with_identities(&untrimmed_username, true, false), + ShapedDevicesMacMatch::NoMatch + ); + + let mac_as_username = AccountingEvent { + user_name: Some("aa:bb:cc:dd:ee:ff".to_string()), + ..AccountingEvent::default() + }; + let ShapedDevicesMacMatch::Unique(device) = + matcher.match_event_with_identities(&mac_as_username, true, false) + else { + panic!("a username that resembles a MAC should match verbatim"); + }; + assert_eq!(device.circuit_id, "mac-circuit"); + } + + fn shaped_device(circuit_id: &str, mac: &str) -> ShapedDevice { + ShapedDevice { + circuit_id: circuit_id.to_string(), + mac: mac.to_string(), + ..ShapedDevice::default() + } + } +} diff --git a/src/rust/lqos_radius/src/main.rs b/src/rust/lqos_radius/src/main.rs new file mode 100644 index 00000000..48c23598 --- /dev/null +++ b/src/rust/lqos_radius/src/main.rs @@ -0,0 +1,206 @@ +//! Rootless diagnostic executable for the LibreQoS RADIUS accounting listener. + +use clap::{ArgGroup, Parser}; +use lqos_radius::{ + AccountingListenerOutcome, DEFAULT_LISTEN_ADDR, ListenerConfig, ListenerError, RadiusListener, + TrustedClientSource, TrustedRadiusClient, start_listener, +}; +use std::net::SocketAddr; +use thiserror::Error; + +#[derive(Parser)] +#[command( + name = "lqos_radius", + about = "Run the LibreQoS RADIUS accounting diagnostic listener.", + group( + ArgGroup::new("run_mode") + .required(true) + .args(["parse_only", "shared_secret"]) + ) +)] +struct Args { + #[arg(long, value_name = "ADDR", default_value_t = DEFAULT_LISTEN_ADDR)] + listen: SocketAddr, + #[arg(long, conflicts_with_all = ["client_sources", "shared_secret"])] + parse_only: bool, + #[arg( + long = "client-source", + value_name = "IP_OR_CIDR", + requires = "shared_secret" + )] + client_sources: Vec, + #[arg( + long, + value_name = "SECRET", + requires = "client_sources", + value_parser = clap::builder::NonEmptyStringValueParser::new() + )] + shared_secret: Option, +} + +#[tokio::main] +async fn main() -> Result<(), MainError> { + let args = Args::parse(); + let listen = args.listen; + let run_mode = args.run_mode(); + let listener = start_listener(ListenerConfig { + listen_addr: listen, + }) + .await?; + let local_addr = listener.local_addr()?; + + println!("Listening for RADIUS accounting packets on {local_addr}"); + match run_mode { + RunMode::ParseOnly => parse_only_loop(&listener).await, + RunMode::Verified(clients) => verified_loop(&listener, &clients).await, + } +} + +impl Args { + fn run_mode(self) -> RunMode { + let Self { + listen: _, + parse_only, + client_sources, + shared_secret, + } = self; + + if parse_only { + return RunMode::ParseOnly; + } + let shared_secret = shared_secret.expect("clap requires --shared-secret in verified mode"); + let client = TrustedRadiusClient::new(client_sources, shared_secret.into_bytes()).expect( + "clap requires at least one --client-source and a non-empty --shared-secret in verified mode", + ); + + RunMode::Verified(vec![client]) + } +} + +enum RunMode { + ParseOnly, + Verified(Vec), +} + +#[derive(Debug, Error)] +enum MainError { + #[error(transparent)] + Listener(#[from] ListenerError), +} + +async fn parse_only_loop(listener: &RadiusListener) -> Result<(), MainError> { + loop { + match listener.receive_next().await { + Ok(received) => println!( + "Parsed Accounting-Request from {}: identifier={}, attributes={}", + received.peer, + received.request.packet().identifier(), + received.request.packet().attributes().len() + ), + Err(ListenerError::Packet { peer, source }) => { + eprintln!("Discarded malformed or unsupported RADIUS packet from {peer}: {source}"); + } + Err(err) => return Err(err.into()), + } + } +} + +async fn verified_loop( + listener: &RadiusListener, + clients: &[TrustedRadiusClient], +) -> Result<(), MainError> { + loop { + match listener.receive_next_verified(clients).await { + Ok(AccountingListenerOutcome::Accepted(accepted)) => println!( + "Accepted Accounting-Request from {}: identifier={}, attributes={}, response_bytes={}", + accepted.peer, + accepted.request.packet().identifier(), + accepted.request.packet().attributes().len(), + accepted.response_len + ), + Ok(AccountingListenerOutcome::RejectedSource { peer, .. }) => { + eprintln!("Discarded RADIUS packet from unconfigured source {peer}"); + } + Ok(AccountingListenerOutcome::RejectedAmbiguousSource { peer, .. }) => { + eprintln!("Discarded RADIUS packet from ambiguous trusted source {peer}"); + } + Ok(AccountingListenerOutcome::RejectedPacket { peer, source, .. }) => { + eprintln!("Rejected RADIUS packet from {peer}: {source}"); + } + Err(ListenerError::Send { peer, source }) => { + eprintln!("Failed to send RADIUS Accounting-Response to {peer}: {source}"); + } + Err(err) => return Err(err.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn cli_parser_accepts_parse_only_mode() { + let args = Args::try_parse_from(["lqos_radius", "--parse-only"]).unwrap(); + + assert_eq!(args.listen, DEFAULT_LISTEN_ADDR); + assert!(matches!(args.run_mode(), RunMode::ParseOnly)); + } + + #[test] + fn cli_parser_accepts_verified_mode_with_multiple_sources() { + let args = Args::try_parse_from([ + "lqos_radius", + "--listen", + "127.0.0.1:0", + "--client-source", + "127.0.0.1", + "--client-source", + "192.0.2.0/24", + "--shared-secret", + "radius-secret", + ]) + .unwrap(); + assert_eq!(args.listen, SocketAddr::from((Ipv4Addr::LOCALHOST, 0))); + + let RunMode::Verified(clients) = args.run_mode() else { + panic!("expected verified run mode"); + }; + assert_eq!(clients.len(), 1); + assert_eq!(clients[0].sources().len(), 2); + assert!(clients[0].sources()[0].contains(Ipv4Addr::LOCALHOST.into())); + assert!(clients[0].sources()[1].contains(Ipv4Addr::new(192, 0, 2, 99).into())); + assert_eq!(clients[0].shared_secret(), b"radius-secret"); + } + + #[test] + fn cli_parser_rejects_missing_or_conflicting_modes() { + assert!(Args::try_parse_from(["lqos_radius"]).is_err()); + assert!( + Args::try_parse_from(["lqos_radius", "--parse-only", "--shared-secret", "secret"]) + .is_err() + ); + assert!( + Args::try_parse_from([ + "lqos_radius", + "--parse-only", + "--client-source", + "127.0.0.1" + ]) + .is_err() + ); + assert!(Args::try_parse_from(["lqos_radius", "--client-source", "127.0.0.1"]).is_err()); + assert!(Args::try_parse_from(["lqos_radius", "--shared-secret", "secret"]).is_err()); + assert!( + Args::try_parse_from([ + "lqos_radius", + "--client-source", + "127.0.0.1", + "--shared-secret", + "", + ]) + .is_err() + ); + } +} diff --git a/src/rust/lqos_radius/src/packet.rs b/src/rust/lqos_radius/src/packet.rs new file mode 100644 index 00000000..7b47fa8b --- /dev/null +++ b/src/rust/lqos_radius/src/packet.rs @@ -0,0 +1,640 @@ +//! RADIUS packet framing, Accounting-Request verification, and response building. + +use hmac::{Hmac, Mac}; +use md5::{Digest, Md5}; +use subtle::ConstantTimeEq; +use thiserror::Error; + +type HmacMd5 = Hmac; + +pub(crate) const RADIUS_HEADER_LEN: usize = 20; +pub(crate) const RADIUS_MAX_PACKET_LEN: usize = 4096; +// RFC 2866 section 3 caps RADIUS Accounting packet Length at 4095 bytes. +pub(crate) const RADIUS_MAX_ACCOUNTING_PACKET_LEN: usize = 4095; +pub(crate) const RADIUS_AUTHENTICATOR_LEN: usize = 16; +pub(crate) const RADIUS_ATTRIBUTE_HEADER_LEN: usize = 2; +const RADIUS_MIN_ATTRIBUTE_LEN: usize = 2; +const ACCOUNTING_REQUEST_CODE: u8 = 4; +const ACCOUNTING_RESPONSE_CODE: u8 = 5; +const PROXY_STATE_TYPE: u8 = 33; +pub(crate) const MESSAGE_AUTHENTICATOR_TYPE: u8 = 80; +pub(crate) const MESSAGE_AUTHENTICATOR_VALUE_LEN: usize = 16; +#[cfg(test)] +pub(crate) const MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN: usize = + RADIUS_ATTRIBUTE_HEADER_LEN + MESSAGE_AUTHENTICATOR_VALUE_LEN; + +/// RADIUS packet code values understood by the packet parser. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RadiusCode { + /// Access-Request packet code. + AccessRequest, + /// Access-Accept packet code. + AccessAccept, + /// Access-Reject packet code. + AccessReject, + /// Accounting-Request packet code. + AccountingRequest, + /// Accounting-Response packet code. + AccountingResponse, + /// Any RADIUS packet code not enumerated by this crate. + Other(u8), +} + +impl RadiusCode { + /// Returns the wire value for this packet code. + #[must_use] + pub const fn as_u8(self) -> u8 { + match self { + Self::AccessRequest => 1, + Self::AccessAccept => 2, + Self::AccessReject => 3, + Self::AccountingRequest => ACCOUNTING_REQUEST_CODE, + Self::AccountingResponse => ACCOUNTING_RESPONSE_CODE, + Self::Other(code) => code, + } + } +} + +impl From for RadiusCode { + fn from(code: u8) -> Self { + match code { + 1 => Self::AccessRequest, + 2 => Self::AccessAccept, + 3 => Self::AccessReject, + ACCOUNTING_REQUEST_CODE => Self::AccountingRequest, + ACCOUNTING_RESPONSE_CODE => Self::AccountingResponse, + other => Self::Other(other), + } + } +} + +impl From for u8 { + fn from(code: RadiusCode) -> Self { + code.as_u8() + } +} + +/// One decoded RADIUS attribute. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RadiusAttribute { + kind: u8, + value: Vec, +} + +impl RadiusAttribute { + /// Returns the raw RADIUS attribute type value. + #[must_use] + pub const fn kind(&self) -> u8 { + self.kind + } + + /// Returns the raw RADIUS attribute value bytes. + #[must_use] + pub fn value(&self) -> &[u8] { + &self.value + } +} + +/// A well-formed RADIUS packet. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RadiusPacket { + code: RadiusCode, + identifier: u8, + authenticator: [u8; RADIUS_AUTHENTICATOR_LEN], + attributes: Vec, +} + +impl RadiusPacket { + /// Returns the packet code. + #[must_use] + pub const fn code(&self) -> RadiusCode { + self.code + } + + /// Returns the packet identifier. + #[must_use] + pub const fn identifier(&self) -> u8 { + self.identifier + } + + /// Returns the 16-byte request authenticator from the packet header. + #[must_use] + pub const fn authenticator(&self) -> &[u8; RADIUS_AUTHENTICATOR_LEN] { + &self.authenticator + } + + /// Returns the decoded packet attributes. + #[must_use] + pub fn attributes(&self) -> &[RadiusAttribute] { + &self.attributes + } +} + +/// A well-formed RADIUS Accounting-Request packet. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountingRequest { + packet: RadiusPacket, +} + +impl AccountingRequest { + /// Returns the decoded RADIUS packet. + #[must_use] + pub const fn packet(&self) -> &RadiusPacket { + &self.packet + } + + /// Consumes the accounting request and returns the decoded RADIUS packet. + #[must_use] + pub fn into_packet(self) -> RadiusPacket { + self.packet + } +} + +/// Policy for accepting Accounting-Request packets without Message-Authenticator. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum MessageAuthenticatorPolicy { + /// Verify Message-Authenticator when present, but do not require it. + #[default] + Optional, + /// Reject Accounting-Request packets that lack Message-Authenticator. + Required, +} + +impl MessageAuthenticatorPolicy { + /// Returns true when the policy requires Message-Authenticator attribute 80. + #[must_use] + pub const fn requires_message_authenticator(self) -> bool { + matches!(self, Self::Required) + } +} + +/// A RADIUS Accounting-Request whose authenticators matched the shared secret. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerifiedAccountingRequest { + request: AccountingRequest, + has_message_authenticator: bool, +} + +impl VerifiedAccountingRequest { + /// Returns the verified Accounting-Request wrapper. + #[must_use] + pub const fn request(&self) -> &AccountingRequest { + &self.request + } + + /// Returns the verified decoded RADIUS packet. + #[must_use] + pub const fn packet(&self) -> &RadiusPacket { + self.request.packet() + } + + /// Returns true when the accepted packet contained Message-Authenticator. + #[must_use] + pub const fn has_message_authenticator(&self) -> bool { + self.has_message_authenticator + } + + /// Consumes the verified request and returns the Accounting-Request wrapper. + #[must_use] + pub fn into_request(self) -> AccountingRequest { + self.request + } + + /// Builds an Accounting-Response packet for this verified request. + /// + /// Side effects: none. The returned bytes are not sent to the network. + #[must_use] + pub fn build_response(&self, shared_secret: &[u8]) -> Vec { + build_accounting_response(self, shared_secret) + } +} + +/// Errors returned while parsing RADIUS packets. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum PacketError { + /// The datagram was shorter than the fixed RADIUS header. + #[error("RADIUS packet is {actual} bytes; minimum is 20 bytes")] + PacketTooShort { + /// Number of bytes received in the datagram. + actual: usize, + }, + /// The packet length field was outside the RADIUS framing bounds. + #[error("RADIUS packet length field is {declared} bytes; valid range is 20..=4096 bytes")] + InvalidPacketLength { + /// Length declared in the packet header. + declared: usize, + }, + /// The packet length field exceeded the RADIUS Accounting framing limit. + #[error( + "RADIUS accounting packet length field is {declared} bytes; valid range is 20..=4095 bytes" + )] + InvalidAccountingPacketLength { + /// Length declared in the packet header. + declared: usize, + }, + /// The datagram ended before the declared RADIUS packet length. + #[error( + "RADIUS packet length field is {declared} bytes, but datagram contains only {actual} bytes" + )] + PacketTruncated { + /// Length declared in the packet header. + declared: usize, + /// Number of bytes received in the datagram. + actual: usize, + }, + /// An attribute was missing its type and length bytes. + #[error("RADIUS attribute at offset {offset} is missing its type/length header")] + AttributeHeaderTruncated { + /// Byte offset of the malformed attribute. + offset: usize, + }, + /// An attribute declared a length smaller than its own header. + #[error( + "RADIUS attribute at offset {offset} declares invalid length {declared}; minimum is 2 bytes" + )] + InvalidAttributeLength { + /// Byte offset of the malformed attribute. + offset: usize, + /// Length declared by the attribute. + declared: usize, + }, + /// An attribute declared more bytes than remain in the packet. + #[error( + "RADIUS attribute at offset {offset} declares length {declared}, but only {remaining} bytes remain" + )] + AttributeTruncated { + /// Byte offset of the malformed attribute. + offset: usize, + /// Length declared by the attribute. + declared: usize, + /// Remaining bytes in the packet from the attribute offset. + remaining: usize, + }, + /// The packet code is not supported by the accounting handler. + #[error("RADIUS code {code} is not supported by the accounting handler")] + UnsupportedCode { + /// Unsupported raw packet code. + code: u8, + }, + /// The Accounting-Request authenticator did not match the shared secret. + #[error("RADIUS Accounting-Request authenticator does not match the shared secret")] + InvalidRequestAuthenticator, + /// Message-Authenticator is required by policy but absent from the packet. + #[error("RADIUS Message-Authenticator is required but missing")] + MissingMessageAuthenticator, + /// Message-Authenticator had an invalid value length. + #[error("RADIUS Message-Authenticator value is {actual} bytes; expected 16 bytes")] + InvalidMessageAuthenticatorLength { + /// Actual Message-Authenticator value length. + actual: usize, + }, + /// More than one Message-Authenticator attribute was present. + #[error("RADIUS packet contains more than one Message-Authenticator attribute")] + MultipleMessageAuthenticators, + /// The Message-Authenticator HMAC did not match the shared secret. + #[error("RADIUS Message-Authenticator does not match the shared secret")] + InvalidMessageAuthenticator, +} + +/// Parses the RADIUS packet framing in one UDP datagram. +/// +/// Extra bytes beyond the declared RADIUS length are ignored as UDP padding. +/// This function performs no shared-secret verification and does not mutate +/// process or system state. +pub fn parse_packet(datagram: &[u8]) -> Result { + if datagram.len() < RADIUS_HEADER_LEN { + return Err(PacketError::PacketTooShort { + actual: datagram.len(), + }); + } + + let declared_len = u16::from_be_bytes([datagram[2], datagram[3]]) as usize; + if !(RADIUS_HEADER_LEN..=RADIUS_MAX_PACKET_LEN).contains(&declared_len) { + return Err(PacketError::InvalidPacketLength { + declared: declared_len, + }); + } + if datagram.len() < declared_len { + return Err(PacketError::PacketTruncated { + declared: declared_len, + actual: datagram.len(), + }); + } + + let packet_bytes = &datagram[..declared_len]; + let mut authenticator = [0_u8; RADIUS_AUTHENTICATOR_LEN]; + authenticator.copy_from_slice(&packet_bytes[4..RADIUS_HEADER_LEN]); + let attributes = parse_attributes(&packet_bytes[RADIUS_HEADER_LEN..])?; + + Ok(RadiusPacket { + code: RadiusCode::from(packet_bytes[0]), + identifier: packet_bytes[1], + authenticator, + attributes, + }) +} + +/// Parses a UDP datagram and returns only RADIUS Accounting-Request packets. +/// +/// This function performs syntactic packet checks only. It does not verify +/// authenticators, build Accounting-Response packets, or update session state. +pub fn handle_accounting_request(datagram: &[u8]) -> Result { + let packet = parse_packet(datagram)?; + if packet.code() != RadiusCode::AccountingRequest { + return Err(PacketError::UnsupportedCode { + code: packet.code().as_u8(), + }); + } + let declared_len = u16::from_be_bytes([datagram[2], datagram[3]]) as usize; + if declared_len > RADIUS_MAX_ACCOUNTING_PACKET_LEN { + return Err(PacketError::InvalidAccountingPacketLength { + declared: declared_len, + }); + } + + Ok(AccountingRequest { packet }) +} + +/// Parses and verifies one RADIUS Accounting-Request datagram. +/// +/// The caller is responsible for matching the UDP peer to a trusted client and +/// passing that client's shared secret. Packet framing and attribute bounds are +/// checked before authenticators are evaluated. +/// When Message-Authenticator is present, it is verified with the +/// Accounting-Request vector set to zero before the request authenticator is +/// verified with the received Message-Authenticator value present. +/// +/// Side effects: none. This function does not send responses, update session +/// state, touch files, or modify host networking. +pub fn verify_accounting_request( + datagram: &[u8], + shared_secret: &[u8], + message_authenticator_policy: MessageAuthenticatorPolicy, +) -> Result { + let request = handle_accounting_request(datagram)?; + let message_authenticator_index = message_authenticator_index(request.packet())?; + + if message_authenticator_policy.requires_message_authenticator() + && message_authenticator_index.is_none() + { + return Err(PacketError::MissingMessageAuthenticator); + } + + if let Some(index) = message_authenticator_index { + verify_message_authenticator(request.packet(), shared_secret, index)?; + } + verify_request_authenticator(request.packet(), shared_secret)?; + + Ok(VerifiedAccountingRequest { + request, + has_message_authenticator: message_authenticator_index.is_some(), + }) +} + +/// Builds a minimal RADIUS Accounting-Response for a verified request. +/// +/// The response uses code 5, copies the request identifier, preserves any +/// Proxy-State attributes from the request, and signs the response +/// authenticator with the shared secret. +/// +/// Side effects: none. The returned bytes are not sent to the network. +#[must_use] +pub fn build_accounting_response( + request: &VerifiedAccountingRequest, + shared_secret: &[u8], +) -> Vec { + let mut response = Vec::with_capacity(RADIUS_HEADER_LEN + shared_secret.len()); + response.push(ACCOUNTING_RESPONSE_CODE); + response.push(request.packet().identifier()); + response.extend_from_slice(&[0, 0]); + response.extend_from_slice(request.packet().authenticator()); + encode_proxy_state_attributes(request.packet(), &mut response); + let response_len = response.len(); + response[2..4].copy_from_slice(&(response_len as u16).to_be_bytes()); + response.extend_from_slice(shared_secret); + let authenticator = md5_digest(&response); + + response.truncate(response_len); + response[4..RADIUS_HEADER_LEN].copy_from_slice(&authenticator); + response +} + +fn parse_attributes(mut attribute_bytes: &[u8]) -> Result, PacketError> { + let mut attributes = Vec::new(); + let mut offset = RADIUS_HEADER_LEN; + + while !attribute_bytes.is_empty() { + let (kind, value, remaining) = split_radius_tlv(attribute_bytes) + .map_err(|error| packet_error_from_tlv_split(error, offset, attribute_bytes.len()))?; + let declared_len = attribute_bytes.len() - remaining.len(); + + attributes.push(RadiusAttribute { + kind, + value: value.to_vec(), + }); + attribute_bytes = remaining; + offset += declared_len; + } + + Ok(attributes) +} + +pub(crate) fn split_radius_tlv(value: &[u8]) -> Result<(u8, &[u8], &[u8]), RadiusTlvError> { + if value.len() < RADIUS_ATTRIBUTE_HEADER_LEN { + return Err(RadiusTlvError::HeaderTruncated); + } + + let kind = value[0]; + let declared_len = usize::from(value[1]); + if declared_len < RADIUS_MIN_ATTRIBUTE_LEN { + return Err(RadiusTlvError::InvalidLength { declared_len }); + } + if declared_len > value.len() { + return Err(RadiusTlvError::Truncated { declared_len }); + } + + Ok(( + kind, + &value[RADIUS_ATTRIBUTE_HEADER_LEN..declared_len], + &value[declared_len..], + )) +} + +pub(crate) enum RadiusTlvError { + HeaderTruncated, + InvalidLength { declared_len: usize }, + Truncated { declared_len: usize }, +} + +fn packet_error_from_tlv_split( + error: RadiusTlvError, + offset: usize, + remaining: usize, +) -> PacketError { + match error { + RadiusTlvError::HeaderTruncated => PacketError::AttributeHeaderTruncated { offset }, + RadiusTlvError::InvalidLength { declared_len } => PacketError::InvalidAttributeLength { + offset, + declared: declared_len, + }, + RadiusTlvError::Truncated { declared_len } => PacketError::AttributeTruncated { + offset, + declared: declared_len, + remaining, + }, + } +} + +fn verify_request_authenticator( + packet: &RadiusPacket, + shared_secret: &[u8], +) -> Result<(), PacketError> { + let expected = expected_request_authenticator(packet, shared_secret); + + if constant_time_eq(packet.authenticator(), &expected) { + Ok(()) + } else { + Err(PacketError::InvalidRequestAuthenticator) + } +} + +fn verify_message_authenticator( + packet: &RadiusPacket, + shared_secret: &[u8], + message_authenticator_index: usize, +) -> Result<(), PacketError> { + let expected = + expected_message_authenticator(packet, shared_secret, message_authenticator_index); + let actual = packet.attributes()[message_authenticator_index].value(); + + if constant_time_eq(actual, &expected) { + Ok(()) + } else { + Err(PacketError::InvalidMessageAuthenticator) + } +} + +pub(crate) fn message_authenticator_index( + packet: &RadiusPacket, +) -> Result, 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, +) -> Vec { + let packet_len = RADIUS_HEADER_LEN + + packet + .attributes() + .iter() + .map(encoded_attribute_len) + .sum::(); + + 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) { + for attribute in packet.attributes() { + if attribute.kind() != PROXY_STATE_TYPE { + continue; + } + encode_attribute(attribute, encoded); + } +} + +fn encode_attribute(attribute: &RadiusAttribute, encoded: &mut Vec) { + encode_attribute_value(attribute.kind(), attribute.value(), encoded); +} + +#[cfg(test)] +pub(crate) fn encode_radius_tlv(kind: u8, value: &[u8]) -> Vec { + 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) { + 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; diff --git a/src/rust/lqos_radius/src/packet/tests.rs b/src/rust/lqos_radius/src/packet/tests.rs new file mode 100644 index 00000000..ce40d4c8 --- /dev/null +++ b/src/rust/lqos_radius/src/packet/tests.rs @@ -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 { + verify_accounting_request( + datagram, + SHARED_SECRET, + MessageAuthenticatorPolicy::Optional, + ) +} + +fn verify_required(datagram: &[u8]) -> Result { + verify_accounting_request( + datagram, + SHARED_SECRET, + MessageAuthenticatorPolicy::Required, + ) +} + +fn packet_with_declared_len(declared_len: u16) -> Vec { + 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 +} diff --git a/src/rust/lqos_radius/src/session.rs b/src/rust/lqos_radius/src/session.rs new file mode 100644 index 00000000..2e5f31ff --- /dev/null +++ b/src/rust/lqos_radius/src/session.rs @@ -0,0 +1,2357 @@ +//! In-memory RADIUS accounting session tracking. + +use crate::mac_match::ShapedDevicesMacMatch; +use crate::{ + AccountingEvent, AcctStatusType, DynamicCircuitCommandSink, DynamicCircuitIntent, + DynamicCircuitRemoval, DynamicCircuitRemovalReason, DynamicCircuitUpsert, MikrotikRateLimit, +}; +use lqos_config::{ShapedDevice, validate_rate_profile_mbps}; +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv4Addr, Ipv6Addr}; + +pub use lqos_config::RateProfileValidationError as SessionRateProfileError; + +/// Identity and fallback settings for ShapedDevices-backed RADIUS resolution. +#[derive(Clone, Debug)] +pub struct ShapedDevicesMatchOptions { + /// Whether `User-Name` matches the ShapedDevices MAC field verbatim. + pub match_by_username: bool, + /// Whether `Calling-Station-Id` matches the ShapedDevices MAC field. + pub match_by_mac: bool, + /// Rate profile for an unmatched identity when packet rates are absent. + pub fallback_profile: Option, + /// Parent attachment for an unmatched identity. + pub fallback_parent: Option, +} + +/// In-memory session store for accepted RADIUS accounting events. +#[derive(Debug, Default)] +pub struct AccountingSessionStore { + sessions: HashMap, + nas_session_keys_by_acct_session_id: HashMap>, + pending_keys_by_lookup: HashMap>, + fallback_keys_by_lookup: HashMap>, + activation_counters: RadiusActivationCounters, +} + +impl AccountingSessionStore { + /// Creates an empty session store. + /// + /// Side effects: none. Session state is kept in memory only. + #[must_use] + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + nas_session_keys_by_acct_session_id: HashMap::new(), + pending_keys_by_lookup: HashMap::new(), + fallback_keys_by_lookup: HashMap::new(), + activation_counters: RadiusActivationCounters::default(), + } + } + + /// Returns the number of retained sessions. + #[must_use] + pub fn len(&self) -> usize { + self.sessions.len() + } + + /// Returns true when the store has no retained sessions. + #[must_use] + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + /// Iterates over retained sessions and their keys. + pub fn sessions(&self) -> impl Iterator { + self.sessions.iter() + } + + /// Returns one retained session by key. + #[must_use] + pub fn session(&self, key: &AccountingSessionKey) -> Option<&AccountingSession> { + self.sessions.get(key) + } + + /// Returns dynamic-circuit activation counters tracked by this session store. + #[must_use] + pub const fn activation_counters(&self) -> RadiusActivationCounters { + self.activation_counters + } + + /// Returns diagnostics for all retained accounting sessions. + /// + /// Side effects: none. The returned snapshot is built from in-memory state. + #[must_use] + pub fn activation_diagnostics(&self) -> Vec { + self.sessions + .iter() + .map(|(key, session)| RadiusActivationDiagnostic::from_retained_session(key, session)) + .collect() + } + + /// Applies one accounting event using the default missing-parent mapping. + /// + /// Side effects: mutates only this in-memory store. It does not write files, + /// emit dynamic-circuit commands, or touch shaping state. + pub fn apply_event(&mut self, event: AccountingEvent) -> AccountingSessionUpdate { + self.apply_event_with_mapping(event, DynamicCircuitMapping::default()) + } + + /// Applies one accounting event with explicit dynamic-circuit mapping state. + /// + /// Side effects: mutates only this in-memory store. It does not write files, + /// emit dynamic-circuit commands, or touch shaping state. + pub fn apply_event_with_mapping( + &mut self, + event: AccountingEvent, + mapping: DynamicCircuitMapping, + ) -> AccountingSessionUpdate { + self.apply_event_with_mapping_and_rate_sources( + event, + mapping, + SessionRateSources::default(), + ) + } + + /// Applies one accounting event with explicit mapping and rate-source state. + /// + /// Resolution priority is decoded packet rate, then any supplied + /// ShapedDevices profile, then any supplied fallback profile. + /// + /// Side effects: mutates only this in-memory store. It does not write files, + /// emit dynamic-circuit commands, or touch shaping state. + pub fn apply_event_with_mapping_and_rate_sources( + &mut self, + event: AccountingEvent, + mapping: DynamicCircuitMapping, + rate_sources: SessionRateSources, + ) -> AccountingSessionUpdate { + self.apply_event_with_dynamic_circuit_resolution( + event, + DynamicCircuitResolution { + mapping, + rate_sources, + matched_shaped_device: None, + }, + ) + } + + /// Applies one accounting event with resolved dynamic-circuit metadata. + /// + /// Resolution priority is decoded packet rate, then any supplied + /// ShapedDevices profile, then any supplied fallback profile. + /// + /// Side effects: mutates only this in-memory store. It does not write files, + /// emit dynamic-circuit commands, or touch shaping state. + pub fn apply_event_with_dynamic_circuit_resolution( + &mut self, + event: AccountingEvent, + resolution: DynamicCircuitResolution, + ) -> AccountingSessionUpdate { + self.apply_event_with_resolution_resolver(event, |_| resolution) + } + + /// Applies one accounting event using a shaped-devices MAC matcher. + /// + /// The matcher is evaluated after active-session sparse fields are merged, + /// allowing an update packet to use a `Calling-Station-Id` carried from an + /// earlier packet in the same session. + /// + /// Side effects: mutates only this in-memory store. It does not write files, + /// emit dynamic-circuit commands, or touch shaping state. + pub fn apply_event_with_shaped_devices_mac_matcher( + &mut self, + event: AccountingEvent, + matcher: &crate::ShapedDevicesMacMatcher, + fallback_profile: Option, + ) -> AccountingSessionUpdate { + self.apply_event_with_resolution_resolver(event, |latest_event| { + DynamicCircuitResolution::from_shaped_devices_mac_match( + matcher.match_event(latest_event), + fallback_profile, + ) + }) + } + + /// Applies one accounting event using configured ShapedDevices username and MAC matching. + /// + /// A unique username match is preferred over a MAC match. When neither identity matches, + /// `fallback_parent` permits a default dynamic circuit to be created from the configured + /// fallback speed profile. + /// + /// Side effects: mutates only this in-memory store. + pub fn apply_event_with_shaped_devices_matcher( + &mut self, + event: AccountingEvent, + matcher: &crate::ShapedDevicesMacMatcher, + options: ShapedDevicesMatchOptions, + ) -> AccountingSessionUpdate { + self.apply_event_with_resolution_resolver(event, |latest_event| { + DynamicCircuitResolution::from_shaped_devices_identity_match( + matcher.match_event_with_identities( + latest_event, + options.match_by_username, + options.match_by_mac, + ), + options.fallback_profile, + options.fallback_parent, + ) + }) + } + + /// Applies one accounting event and emits dynamic-circuit intents for shapeable sessions. + /// + /// Side effects: mutates only this in-memory store and invokes `sink` for + /// emitted intents. This method does not write files, talk to `lqosd`, or + /// touch shaping state. + pub fn apply_event_with_mapping_and_commands( + &mut self, + event: AccountingEvent, + mapping: DynamicCircuitMapping, + sink: &mut impl DynamicCircuitCommandSink, + ) -> AccountingSessionUpdate { + self.apply_event_with_dynamic_circuit_resolution_and_commands( + event, + DynamicCircuitResolution { + mapping, + rate_sources: SessionRateSources::default(), + matched_shaped_device: None, + }, + sink, + ) + } + + /// Applies one accounting event with resolved metadata and emits dynamic-circuit intents. + /// + /// Side effects: mutates only this in-memory store and invokes `sink` for + /// emitted intents. This method does not write files, talk to `lqosd`, or + /// touch shaping state. + pub fn apply_event_with_dynamic_circuit_resolution_and_commands( + &mut self, + event: AccountingEvent, + resolution: DynamicCircuitResolution, + sink: &mut impl DynamicCircuitCommandSink, + ) -> AccountingSessionUpdate { + let update = self.apply_event_with_dynamic_circuit_resolution(event, resolution); + self.emit_dynamic_circuit_intents(&update, sink); + update + } + + /// Applies one accounting event with a MAC matcher and emits dynamic-circuit intents. + /// + /// Side effects: mutates only this in-memory store and invokes `sink` for + /// emitted intents. This method does not write files, talk to `lqosd`, or + /// touch shaping state. + pub fn apply_event_with_shaped_devices_mac_matcher_and_commands( + &mut self, + event: AccountingEvent, + matcher: &crate::ShapedDevicesMacMatcher, + fallback_profile: Option, + sink: &mut impl DynamicCircuitCommandSink, + ) -> AccountingSessionUpdate { + let update = + self.apply_event_with_shaped_devices_mac_matcher(event, matcher, fallback_profile); + self.emit_dynamic_circuit_intents(&update, sink); + update + } + + /// Applies one accounting event using configured ShapedDevices identity matching and emits intents. + /// + /// Side effects: mutates this store and invokes `sink` for emitted dynamic-circuit intents. + pub fn apply_event_with_shaped_devices_matcher_and_commands( + &mut self, + event: AccountingEvent, + matcher: &crate::ShapedDevicesMacMatcher, + options: ShapedDevicesMatchOptions, + sink: &mut impl DynamicCircuitCommandSink, + ) -> AccountingSessionUpdate { + let update = self.apply_event_with_shaped_devices_matcher(event, matcher, options); + self.emit_dynamic_circuit_intents(&update, sink); + update + } + + fn apply_event_with_resolution_resolver( + &mut self, + event: AccountingEvent, + resolve_metadata: impl FnOnce(&AccountingEvent) -> DynamicCircuitResolution, + ) -> AccountingSessionUpdate { + let Some(status_type) = event.status_type else { + return AccountingSessionUpdate::Ignored { + reason: AccountingSessionIgnoreReason::MissingStatusType, + }; + }; + + match status_type { + AcctStatusType::Start | AcctStatusType::InterimUpdate => { + self.upsert_active_session(event, resolve_metadata) + } + AcctStatusType::Stop => self.stop_session(event, resolve_metadata), + AcctStatusType::AccountingOn => { + self.mark_nas_sessions_stale(event, NasResetStatus::AccountingOn) + } + AcctStatusType::AccountingOff => { + self.mark_nas_sessions_stale(event, NasResetStatus::AccountingOff) + } + AcctStatusType::Unknown(value) => AccountingSessionUpdate::Ignored { + reason: AccountingSessionIgnoreReason::UnsupportedStatusType(value), + }, + } + } + + /// Removes one retained session and emits a dynamic-circuit removal when possible. + /// + /// Side effects: mutates only this in-memory store and invokes `sink` for + /// dynamic circuits previously emitted by this session. This method does not + /// write files, talk to `lqosd`, or touch shaping state. + pub fn expire_session_with_commands( + &mut self, + key: &AccountingSessionKey, + sink: &mut impl DynamicCircuitCommandSink, + ) -> Option { + let mut session = self.remove_session(key)?; + self.activation_counters.expiry += 1; + let removal_reason = match session.state { + AccountingSessionState::Stale(reset) => DynamicCircuitRemovalReason::NasReset(reset), + AccountingSessionState::Active | AccountingSessionState::Stopped => { + DynamicCircuitRemovalReason::Expired + } + }; + emit_active_dynamic_circuit_removals( + key, + &mut session, + removal_reason, + &mut self.activation_counters, + sink, + ); + Some(session) + } + + fn emit_dynamic_circuit_intents( + &mut self, + update: &AccountingSessionUpdate, + sink: &mut impl DynamicCircuitCommandSink, + ) { + match update { + AccountingSessionUpdate::SessionUpdated { key, state } => { + self.emit_session_dynamic_circuit_intents(key, *state, sink); + } + AccountingSessionUpdate::NasSessionsMarkedStale { .. } => {} + AccountingSessionUpdate::Ignored { .. } => {} + } + } + + fn emit_session_dynamic_circuit_intents( + &mut self, + key: &AccountingSessionKey, + state: AccountingSessionState, + sink: &mut impl DynamicCircuitCommandSink, + ) { + match state { + AccountingSessionState::Active => self.emit_active_session_intents(key, sink), + AccountingSessionState::Stopped => { + let Some(session) = self.sessions.get_mut(key) else { + return; + }; + emit_active_dynamic_circuit_removals( + key, + session, + DynamicCircuitRemovalReason::Stop, + &mut self.activation_counters, + sink, + ); + } + AccountingSessionState::Stale(_) => {} + } + } + + fn emit_active_session_intents( + &mut self, + key: &AccountingSessionKey, + sink: &mut impl DynamicCircuitCommandSink, + ) { + let Some(session) = self.sessions.get_mut(key) else { + return; + }; + let Some(shaped_device) = session.resolved_shaped_device.clone() else { + emit_active_dynamic_circuit_removals( + key, + session, + DynamicCircuitRemovalReason::NoLongerShapeable, + &mut self.activation_counters, + sink, + ); + return; + }; + let circuit_id = shaped_device.circuit_id.clone(); + if circuit_id.trim().is_empty() { + emit_active_dynamic_circuit_removals( + key, + session, + DynamicCircuitRemovalReason::NoLongerShapeable, + &mut self.activation_counters, + sink, + ); + return; + } + + let already_emitted = session + .active_dynamic_circuit_ids + .iter() + .any(|active_id| active_id == &circuit_id); + let upsert = DynamicCircuitUpsert { + circuit_id: circuit_id.clone(), + session_key: key.clone(), + event: session.latest_event.clone(), + shaped_device, + }; + match (already_emitted, session.latest_event.status_type) { + (true, Some(AcctStatusType::InterimUpdate)) => { + self.activation_counters.update += 1; + sink.emit(DynamicCircuitIntent::UpdateDynamicCircuit(upsert)); + } + (_, Some(AcctStatusType::Start | AcctStatusType::InterimUpdate)) => { + self.activation_counters.create += 1; + sink.emit(DynamicCircuitIntent::CreateDynamicCircuit(upsert)); + } + _ => {} + } + push_active_dynamic_circuit_id(&mut session.active_dynamic_circuit_ids, circuit_id.clone()); + push_unique_text(&mut session.diagnostic_circuit_ids, &circuit_id); + emit_rekeyed_dynamic_circuit_removals( + key, + session, + &circuit_id, + &mut self.activation_counters, + sink, + ); + } + + fn upsert_active_session( + &mut self, + event: AccountingEvent, + resolve_metadata: impl FnOnce(&AccountingEvent) -> DynamicCircuitResolution, + ) -> AccountingSessionUpdate { + self.apply_session_state(event, resolve_metadata, AccountingSessionState::Active) + } + + fn stop_session( + &mut self, + event: AccountingEvent, + resolve_metadata: impl FnOnce(&AccountingEvent) -> DynamicCircuitResolution, + ) -> AccountingSessionUpdate { + self.apply_session_state(event, resolve_metadata, AccountingSessionState::Stopped) + } + + fn apply_session_state( + &mut self, + event: AccountingEvent, + resolve_metadata: impl FnOnce(&AccountingEvent) -> DynamicCircuitResolution, + state: AccountingSessionState, + ) -> AccountingSessionUpdate { + let key_resolution = self.resolve_session_key(&event); + let promoted_sessions = key_resolution + .previous_keys + .iter() + .filter_map(|previous_key| self.remove_session(previous_key)) + .collect::>(); + let key = key_resolution.key; + + if self.sessions.contains_key(&key) { + self.remove_session_indexes(&key); + let session = self + .sessions + .get_mut(&key) + .expect("session key existed before index removal"); + let mut previous_event = session.latest_event.clone(); + for promoted_session in promoted_sessions { + merge_known_nas_identity_values( + &mut session.known_nas_identities, + promoted_session.known_nas_identities, + ); + merge_active_dynamic_circuit_ids( + &mut session.active_dynamic_circuit_ids, + promoted_session.active_dynamic_circuit_ids, + ); + merge_diagnostic_circuit_ids( + &mut session.diagnostic_circuit_ids, + promoted_session.diagnostic_circuit_ids, + ); + if state == AccountingSessionState::Active { + previous_event = + merge_sparse_active_event(&previous_event, promoted_session.latest_event); + } + } + session.state = state; + session.latest_event = latest_session_event(Some(&previous_event), event, state); + let metadata = if state == AccountingSessionState::Active { + session_dynamic_metadata( + &key, + &session.latest_event, + resolve_metadata(&session.latest_event), + ) + } else { + SessionDynamicMetadata::inactive() + }; + session.resolved_rate = metadata.resolved_rate; + session.resolved_shaped_device = metadata.resolved_shaped_device; + if let Some(device) = &session.resolved_shaped_device { + push_unique_text(&mut session.diagnostic_circuit_ids, &device.circuit_id); + } + session.pending_reasons = metadata.pending_reasons; + merge_known_nas_identities(&mut session.known_nas_identities, &session.latest_event); + self.index_session(&key); + } else { + let mut known_nas_identities = Vec::new(); + let mut active_dynamic_circuit_ids = Vec::new(); + let mut diagnostic_circuit_ids = Vec::new(); + let mut previous_event = None; + for promoted_session in promoted_sessions { + merge_known_nas_identity_values( + &mut known_nas_identities, + promoted_session.known_nas_identities, + ); + merge_active_dynamic_circuit_ids( + &mut active_dynamic_circuit_ids, + promoted_session.active_dynamic_circuit_ids, + ); + merge_diagnostic_circuit_ids( + &mut diagnostic_circuit_ids, + promoted_session.diagnostic_circuit_ids, + ); + previous_event = Some(match previous_event { + Some(previous_event) if state == AccountingSessionState::Active => { + merge_sparse_active_event(&previous_event, promoted_session.latest_event) + } + Some(previous_event) => previous_event, + None => promoted_session.latest_event, + }); + } + let latest_event = latest_session_event(previous_event.as_ref(), event, state); + merge_known_nas_identities(&mut known_nas_identities, &latest_event); + let metadata = if state == AccountingSessionState::Active { + session_dynamic_metadata(&key, &latest_event, resolve_metadata(&latest_event)) + } else { + SessionDynamicMetadata::inactive() + }; + if let Some(device) = &metadata.resolved_shaped_device { + push_unique_text(&mut diagnostic_circuit_ids, &device.circuit_id); + } + self.sessions.insert( + key.clone(), + AccountingSession { + state, + latest_event, + known_nas_identities, + resolved_rate: metadata.resolved_rate, + resolved_shaped_device: metadata.resolved_shaped_device, + active_dynamic_circuit_ids, + diagnostic_circuit_ids, + pending_reasons: metadata.pending_reasons, + }, + ); + self.index_session(&key); + } + + AccountingSessionUpdate::SessionUpdated { key, state } + } + + fn mark_nas_sessions_stale( + &mut self, + event: AccountingEvent, + reset: NasResetStatus, + ) -> AccountingSessionUpdate { + let reset_identities = NasIdentitySet::from_event(&event); + let Some(nas) = reset_identities.primary() else { + return AccountingSessionUpdate::Ignored { + reason: AccountingSessionIgnoreReason::MissingNasIdentityForReset(reset), + }; + }; + + let state = AccountingSessionState::Stale(reset); + let mut marked_count = 0; + let mut newly_stale_session_keys = HashSet::new(); + let mut stale_session_keys = HashSet::new(); + for (key, session) in &mut self.sessions { + if !session_matches_identities(key, session, &reset_identities) { + continue; + } + if !matches!(session.state, AccountingSessionState::Stale(_)) { + newly_stale_session_keys.insert(key.clone()); + } + stale_session_keys.insert(key.clone()); + session.state = state; + session.resolved_rate = None; + session.resolved_shaped_device = None; + session.pending_reasons.clear(); + marked_count += 1; + } + + AccountingSessionUpdate::NasSessionsMarkedStale { + nas, + reset, + marked_count, + newly_stale_session_keys, + stale_session_keys, + } + } + + fn resolve_session_key(&self, event: &AccountingEvent) -> SessionKeyResolution { + let identities = NasIdentitySet::from_event(event); + let nas = identities.primary(); + let acct_session_id = event + .acct_session_id + .as_ref() + .filter(|acct_session_id| !acct_session_id.is_empty()); + + if let Some(acct_session_id) = acct_session_id + && let Some(nas) = nas.clone() + { + let candidate = AccountingSessionKey::NasSession { + nas, + acct_session_id: acct_session_id.clone(), + }; + if self.sessions.contains_key(&candidate) { + let mut previous_keys = Vec::new(); + if let Some(previous_key) = + self.alternate_session_key(acct_session_id, &identities, &candidate) + { + previous_keys.push(previous_key); + } + if let Some(previous_key) = self.existing_pending_key(event, &identities) { + push_unique_key(&mut previous_keys, previous_key); + } + if !previous_keys.is_empty() { + return SessionKeyResolution::promoted(candidate, previous_keys); + } + return SessionKeyResolution::new(candidate); + } + if let Some(existing_key) = self.existing_session_key(acct_session_id, &identities) { + if let Some(previous_key) = self.existing_pending_key(event, &identities) { + return SessionKeyResolution::promoted(existing_key, vec![previous_key]); + } + return SessionKeyResolution::new(existing_key); + } + if let Some(previous_key) = self.existing_pending_key(event, &identities) { + return SessionKeyResolution::promoted(candidate, vec![previous_key]); + } + + return SessionKeyResolution::new(candidate); + } + + SessionKeyResolution::new(AccountingSessionKey::Pending { + fingerprint: PendingSessionFingerprint::from_event(event, nas), + }) + .or_existing(self.existing_fallback_session_key(event, &identities)) + } + + fn existing_session_key( + &self, + acct_session_id: &str, + identities: &NasIdentitySet, + ) -> Option { + self.matching_session_key(acct_session_id, identities, None) + } + + fn alternate_session_key( + &self, + acct_session_id: &str, + identities: &NasIdentitySet, + candidate: &AccountingSessionKey, + ) -> Option { + self.matching_session_key(acct_session_id, identities, Some(candidate)) + } + + fn matching_session_key( + &self, + acct_session_id: &str, + identities: &NasIdentitySet, + excluded_key: Option<&AccountingSessionKey>, + ) -> Option { + let mut found = None; + let candidate_keys = self + .nas_session_keys_by_acct_session_id + .get(acct_session_id)?; + for key in candidate_keys { + if excluded_key.is_some_and(|excluded_key| excluded_key == key) { + continue; + } + let Some(session) = self.sessions.get(key) else { + continue; + }; + if !session_matches_identities(key, session, identities) { + continue; + } + if found.is_some() { + return None; + } + found = Some(key.clone()); + } + found + } + + fn existing_pending_key( + &self, + event: &AccountingEvent, + identities: &NasIdentitySet, + ) -> Option { + let event_fingerprint = PendingSessionFingerprint::from_event(event, identities.primary()); + let mut found = None; + for key in self.lookup_candidates(&self.pending_keys_by_lookup, event, identities) { + let Some(session) = self.sessions.get(&key) else { + continue; + }; + let AccountingSessionKey::Pending { fingerprint } = &key else { + continue; + }; + let nas_context_matches = + !identities.is_empty() && session_matches_identities(&key, session, identities); + if fingerprint.matches_with_nas_context(&event_fingerprint, nas_context_matches) { + if found.is_some() { + return None; + } + found = Some(key); + } + } + found + } + + fn existing_fallback_session_key( + &self, + event: &AccountingEvent, + identities: &NasIdentitySet, + ) -> Option { + let event_fingerprint = PendingSessionFingerprint::from_event(event, identities.primary()); + let mut found = None; + for key in self.lookup_candidates(&self.fallback_keys_by_lookup, event, identities) { + if matches!(key, AccountingSessionKey::Pending { .. }) { + continue; + } + let Some(session) = self.sessions.get(&key) else { + continue; + }; + if !identities.is_empty() && !session_matches_identities(&key, session, identities) { + continue; + } + let session_fingerprint = session_fingerprint(&key, session); + if !session_fingerprint + .matches_with_nas_context(&event_fingerprint, !identities.is_empty()) + { + continue; + } + if found.is_some() { + return None; + } + found = Some(key); + } + found + } + + fn lookup_candidates( + &self, + index: &HashMap>, + event: &AccountingEvent, + identities: &NasIdentitySet, + ) -> HashSet { + let mut candidates = HashSet::new(); + for lookup_key in event_lookup_index_keys(event, identities) { + let Some(keys) = index.get(&lookup_key) else { + continue; + }; + candidates.extend(keys.iter().cloned()); + } + candidates + } + + fn remove_session(&mut self, key: &AccountingSessionKey) -> Option { + self.remove_session_indexes(key); + self.sessions.remove(key) + } + + fn index_session(&mut self, key: &AccountingSessionKey) { + let Some(session) = self.sessions.get(key) else { + return; + }; + let index_entries = session_index_entries(key, session); + + if let Some(acct_session_id) = index_entries.nas_session_id { + self.nas_session_keys_by_acct_session_id + .entry(acct_session_id) + .or_default() + .insert(key.clone()); + } + for lookup_key in index_entries.pending_lookup_keys { + self.pending_keys_by_lookup + .entry(lookup_key) + .or_default() + .insert(key.clone()); + } + for lookup_key in index_entries.fallback_lookup_keys { + self.fallback_keys_by_lookup + .entry(lookup_key) + .or_default() + .insert(key.clone()); + } + } + + fn remove_session_indexes(&mut self, key: &AccountingSessionKey) { + let Some(session) = self.sessions.get(key) else { + return; + }; + let index_entries = session_index_entries(key, session); + + if let Some(acct_session_id) = index_entries.nas_session_id { + remove_indexed_session_key( + &mut self.nas_session_keys_by_acct_session_id, + &acct_session_id, + key, + ); + } + for lookup_key in index_entries.pending_lookup_keys { + remove_indexed_session_key(&mut self.pending_keys_by_lookup, &lookup_key, key); + } + for lookup_key in index_entries.fallback_lookup_keys { + remove_indexed_session_key(&mut self.fallback_keys_by_lookup, &lookup_key, key); + } + } +} + +struct SessionKeyResolution { + key: AccountingSessionKey, + previous_keys: Vec, +} + +impl SessionKeyResolution { + fn new(key: AccountingSessionKey) -> Self { + Self { + key, + previous_keys: Vec::new(), + } + } + + fn promoted(key: AccountingSessionKey, previous_keys: Vec) -> Self { + Self { key, previous_keys } + } + + fn or_existing(self, existing_key: Option) -> Self { + if let Some(key) = existing_key { + Self::new(key) + } else { + self + } + } +} + +/// One retained accounting session. +#[derive(Clone, Debug, PartialEq)] +pub struct AccountingSession { + /// Current lifecycle state for the session. + pub state: AccountingSessionState, + /// Latest decoded event data retained for this session. + pub latest_event: AccountingEvent, + /// NAS identities observed across retained events for this session. + pub known_nas_identities: Vec, + /// Rate profile resolved for this session, when one is available. + pub resolved_rate: Option, + /// Resolved in-memory shaped-device payload for this RADIUS session. + pub resolved_shaped_device: Option, + /// Dynamic-circuit IDs this session has emitted and not yet removed. + pub active_dynamic_circuit_ids: Vec, + /// Dynamic-circuit IDs retained for final stopped or expired diagnostics. + pub diagnostic_circuit_ids: Vec, + /// Reasons this session cannot currently become a dynamic circuit. + pub pending_reasons: Vec, +} + +/// Dynamic-circuit activation counters tracked separately from packet accept/reject counts. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RadiusActivationCounters { + /// Create intents emitted for shapeable Start or first Interim-Update events. + pub create: u64, + /// Update intents emitted for already-active Interim-Update events. + pub update: u64, + /// Remove intents emitted for Stop, stale, expiry, rekey, or no-longer-shapeable events. + pub remove: u64, + /// Retained sessions expired by TTL or stale grace cleanup. + pub expiry: u64, +} + +/// Listener packet counters tracked separately from dynamic-circuit activation counters. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RadiusPacketCounters { + /// Verified packets accepted by the listener. + pub accepted: u64, + /// Packets rejected before accounting-session processing. + pub rejected: u64, +} + +/// Operator-facing activation state for one RADIUS accounting session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RadiusActivationDiagnosticState { + /// The retained session is active and currently has a resolved dynamic-circuit payload. + Active, + /// The retained session is active but lacks required data to create a circuit. + Pending, + /// The retained session has received Acct-Status-Type Stop. + Stopped, + /// The retained session was marked stale by Accounting-On or Accounting-Off. + Stale(NasResetStatus), + /// The retained session was removed by TTL or stale-grace expiry. + Expired, + /// A resolved dynamic-circuit request failed after it was emitted to the caller. + ApplyFailed, +} + +/// Diagnostic snapshot for one RADIUS dynamic-circuit activation outcome. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RadiusActivationDiagnostic { + /// Stable session key used by the in-memory store. + pub session_key: AccountingSessionKey, + /// Best-known Acct-Session-Id for troubleshooting. + pub acct_session_id: Option, + /// Best-known NAS identity for troubleshooting. + pub nas: Option, + /// Dynamic-circuit IDs associated with this session. + pub circuit_ids: Vec, + /// Current activation outcome. + pub state: RadiusActivationDiagnosticState, + /// Reasons a pending session cannot be shaped yet. + pub pending_reasons: Vec, + /// Sanitized apply-failure detail when `state` is `ApplyFailed`. + pub apply_error: Option, +} + +impl RadiusActivationDiagnostic { + /// Builds a diagnostic snapshot for a retained session. + #[must_use] + pub fn from_retained_session(key: &AccountingSessionKey, session: &AccountingSession) -> Self { + Self::from_session_with_state(key, session, retained_activation_state(session), None) + } + + /// Builds a diagnostic snapshot for a session removed by expiry cleanup. + #[must_use] + pub fn from_expired_session(key: &AccountingSessionKey, session: &AccountingSession) -> Self { + Self::from_session_with_state(key, session, RadiusActivationDiagnosticState::Expired, None) + } + + /// Builds a diagnostic snapshot for a dynamic-circuit apply failure. + #[must_use] + pub fn apply_failed( + session_key: AccountingSessionKey, + circuit_id: String, + apply_error: String, + ) -> Self { + Self { + acct_session_id: session_key.acct_session_id().map(ToString::to_string), + nas: session_key.nas().cloned(), + session_key, + circuit_ids: vec![circuit_id], + state: RadiusActivationDiagnosticState::ApplyFailed, + pending_reasons: Vec::new(), + apply_error: Some(apply_error), + } + } + + fn from_session_with_state( + key: &AccountingSessionKey, + session: &AccountingSession, + state: RadiusActivationDiagnosticState, + apply_error: Option, + ) -> Self { + Self { + session_key: key.clone(), + acct_session_id: key + .acct_session_id() + .or(session.latest_event.acct_session_id.as_deref()) + .map(ToString::to_string), + nas: key + .nas() + .cloned() + .or_else(|| NasIdentitySet::from_event(&session.latest_event).primary()), + circuit_ids: diagnostic_circuit_ids(key, session), + state, + pending_reasons: session.pending_reasons.clone(), + apply_error, + } + } +} + +/// Stable key used for retained accounting sessions. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum AccountingSessionKey { + /// Deterministic key when both NAS identity and Acct-Session-Id are known. + NasSession { + /// NAS identity selected for the key. + nas: NasIdentity, + /// Acct-Session-Id supplied by the NAS. + acct_session_id: String, + }, + /// Fingerprinted key for events that cannot be keyed by Acct-Session-Id. + Pending { + /// Best-effort identity fingerprint for coalescing pending events. + fingerprint: PendingSessionFingerprint, + }, +} + +impl AccountingSessionKey { + /// Returns the NAS identity associated with this key, when known. + #[must_use] + pub fn nas(&self) -> Option<&NasIdentity> { + match self { + Self::NasSession { nas, .. } => Some(nas), + Self::Pending { fingerprint } => fingerprint.nas.as_ref(), + } + } + + /// Returns the Acct-Session-Id associated with this key, when known. + #[must_use] + pub fn acct_session_id(&self) -> Option<&str> { + match self { + Self::NasSession { + acct_session_id, .. + } => Some(acct_session_id), + Self::Pending { fingerprint } => fingerprint.acct_session_id.as_deref(), + } + } + + /// Returns the deterministic dynamic-circuit id for keyed sessions. + #[must_use] + pub fn dynamic_circuit_id(&self) -> Option { + let Self::NasSession { + nas, + acct_session_id, + } = self + else { + return None; + }; + + Some(format!( + "radius:{}:session:{}", + nas.circuit_id_component(), + hex_component(acct_session_id.as_bytes()) + )) + } +} + +/// Best-effort identity for sessions missing Acct-Session-Id. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct PendingSessionFingerprint { + /// NAS identity when present. + pub nas: Option, + /// Acct-Session-Id when present but not enough to form a deterministic key. + pub acct_session_id: Option, + /// User-Name when present. + pub user_name: Option, + /// Calling-Station-Id when present. + pub calling_station_id: Option, + /// NAS-Port-Id when present. + pub nas_port_id: Option, + /// NAS-Port when present. + pub nas_port: Option, +} + +impl PendingSessionFingerprint { + fn from_event(event: &AccountingEvent, nas: Option) -> Self { + Self { + nas, + acct_session_id: non_empty_text(&event.acct_session_id), + user_name: non_empty_text(&event.user_name), + calling_station_id: non_empty_text(&event.calling_station_id), + nas_port_id: non_empty_text(&event.nas_port_id), + nas_port: event.nas_port, + } + } + + fn matches_with_nas_context(&self, other: &Self, nas_context_matches: bool) -> bool { + let nas_matches = if nas_context_matches { + true + } else { + let Some(nas_matches) = optional_match(&self.nas, &other.nas) else { + return false; + }; + nas_matches + }; + let Some(session_id_matches) = + optional_match(&self.acct_session_id, &other.acct_session_id) + else { + return false; + }; + if session_id_matches && nas_matches { + return true; + } + let Some(user_name_matches) = optional_match(&self.user_name, &other.user_name) else { + return false; + }; + let Some(calling_station_matches) = + optional_match(&self.calling_station_id, &other.calling_station_id) + else { + return false; + }; + let Some(nas_port_id_matches) = optional_match(&self.nas_port_id, &other.nas_port_id) + else { + return false; + }; + let Some(nas_port_matches) = optional_match(&self.nas_port, &other.nas_port) else { + return false; + }; + let non_session_matches = [ + user_name_matches, + calling_station_matches, + nas_port_id_matches, + nas_port_matches, + ] + .into_iter() + .filter(|matched| *matched) + .count(); + ((session_id_matches || nas_matches) && non_session_matches > 0) || non_session_matches >= 2 + } +} + +/// NAS identity used to group accounting sessions. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum NasIdentity { + /// NAS-Identifier text. + Identifier(String), + /// NAS-IP-Address value. + Ipv4(Ipv4Addr), + /// NAS-IPv6-Address value. + Ipv6(Ipv6Addr), +} + +impl NasIdentity { + fn circuit_id_component(&self) -> String { + match self { + Self::Identifier(identifier) => { + format!("nas-id:{}", hex_component(identifier.as_bytes())) + } + Self::Ipv4(address) => format!("nas-ipv4:{}", hex_component(&address.octets())), + Self::Ipv6(address) => format!("nas-ipv6:{}", hex_component(&address.octets())), + } + } +} + +/// Lifecycle state retained for an accounting session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountingSessionState { + /// The latest lifecycle event is Start or Interim-Update. + Active, + /// A Stop event has been observed. + Stopped, + /// A NAS reset event marked the session stale. + Stale(NasResetStatus), +} + +/// NAS reset status that can stale existing sessions. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NasResetStatus { + /// Acct-Status-Type Accounting-On. + AccountingOn, + /// Acct-Status-Type Accounting-Off. + AccountingOff, +} + +/// Parent attachment metadata for a resolved dynamic circuit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DynamicCircuitParent { + /// Human-readable parent node name from `network.json`. + pub parent_node: String, + /// Stable parent node identifier, when known. + pub parent_node_id: Option, + /// Stable anchor node identifier, when known. + pub anchor_node_id: Option, +} + +impl DynamicCircuitParent { + /// Creates parent metadata from a parent node name. + #[must_use] + pub fn new(parent_node: impl Into) -> Self { + Self { + parent_node: parent_node.into(), + parent_node_id: None, + anchor_node_id: None, + } + } + + /// Returns true when the parent metadata can populate a `ShapedDevice` parent. + #[must_use] + pub fn has_parent_node(&self) -> bool { + !self.parent_node.trim().is_empty() + } +} + +/// Current dynamic-circuit mapping state for a session event. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum DynamicCircuitMapping { + /// Subscriber IP, rate, and identity can map to a dynamic circuit when + /// `matched_shaped_device` supplies valid parent attachment metadata. + /// + /// This variant is used when another resolved object, such as a + /// `ShapedDevices.csv` MAC match, carries the parent attachment fields. Use + /// `ReadyWithParent` for fallback identities that do not have a matched + /// shaped-device row. + MatchedShapedDevice, + /// Subscriber IP, rate, identity, and explicit parent metadata can map to a dynamic circuit. + ReadyWithParent(DynamicCircuitParent), + /// No parent node mapping is known yet. + #[default] + MissingParent, + /// More than one parent or subscriber mapping matches the event. + Ambiguous, + /// MAC matching is enabled, but no shaped-device row matches the event. + NoMacMatch, + /// MAC matching is enabled, and more than one shaped-device row matches the event. + AmbiguousMacMatch, + /// Configured ShapedDevices identity matching found no matching row. + NoIdentityMatch, + /// Configured ShapedDevices identity matching found duplicate rows. + AmbiguousIdentityMatch, +} + +/// Dynamic-circuit metadata resolved for one accounting event. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DynamicCircuitResolution { + /// Parent or ShapedDevices mapping state for the event. + pub mapping: DynamicCircuitMapping, + /// Rate profiles available outside the accounting packet. + pub rate_sources: SessionRateSources, + /// Shaped-device row supplying circuit metadata, when MAC matching found one. + pub matched_shaped_device: Option, +} + +impl DynamicCircuitResolution { + /// Builds a resolution from a shaped-devices MAC match result. + /// + /// Unique ShapedDevices matches use the matched row as the authoritative + /// account-plan source. The fallback profile is kept only for no-match and + /// ambiguous-match pending diagnostics, and does not mask invalid rates in + /// a matched ShapedDevices row. No-match and ambiguous-match results stay + /// pending with MAC-specific reasons; callers with another parent source + /// should construct a `ReadyWithParent` resolution directly. + /// + /// Side effects: none. The match result is converted in memory only. + #[must_use] + pub fn from_shaped_devices_mac_match( + mac_match: ShapedDevicesMacMatch, + fallback_profile: Option, + ) -> Self { + Self::from_shaped_devices_match_with_fallback_parent(mac_match, fallback_profile, None) + } + + /// Builds a resolution from a ShapedDevices identity match with an optional fallback parent. + /// + /// Side effects: none. + #[must_use] + pub fn from_shaped_devices_match_with_fallback_parent( + mac_match: ShapedDevicesMacMatch, + fallback_profile: Option, + fallback_parent: Option, + ) -> Self { + match mac_match { + ShapedDevicesMacMatch::Unique(device) => { + let shaped_device_profile = session_rate_profile_from_shaped_device(&device); + Self { + mapping: DynamicCircuitMapping::MatchedShapedDevice, + rate_sources: SessionRateSources { + shaped_device_profile, + fallback_profile: None, + }, + matched_shaped_device: Some(*device), + } + } + ShapedDevicesMacMatch::NoMatch => Self { + mapping: fallback_parent + .map(DynamicCircuitMapping::ReadyWithParent) + .unwrap_or(DynamicCircuitMapping::NoMacMatch), + rate_sources: SessionRateSources { + shaped_device_profile: None, + fallback_profile, + }, + matched_shaped_device: None, + }, + ShapedDevicesMacMatch::Ambiguous => Self { + mapping: DynamicCircuitMapping::AmbiguousMacMatch, + rate_sources: SessionRateSources { + shaped_device_profile: None, + fallback_profile, + }, + matched_shaped_device: None, + }, + } + } + + /// Builds a resolution from username and/or MAC identity matching. + /// + /// Side effects: none. + #[must_use] + pub fn from_shaped_devices_identity_match( + identity_match: ShapedDevicesMacMatch, + fallback_profile: Option, + fallback_parent: Option, + ) -> Self { + let mut resolution = Self::from_shaped_devices_match_with_fallback_parent( + identity_match, + fallback_profile, + fallback_parent, + ); + resolution.mapping = match resolution.mapping { + DynamicCircuitMapping::NoMacMatch => DynamicCircuitMapping::NoIdentityMatch, + DynamicCircuitMapping::AmbiguousMacMatch => { + DynamicCircuitMapping::AmbiguousIdentityMatch + } + mapping => mapping, + }; + resolution + } +} + +/// Complete speed profile resolved for a RADIUS accounting session. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SessionRateProfile { + download_min_mbps: f32, + upload_min_mbps: f32, + download_max_mbps: f32, + upload_max_mbps: f32, +} + +impl SessionRateProfile { + /// Creates a validated session rate profile. + /// + /// Side effects: none. + pub fn new( + download_min_mbps: f32, + upload_min_mbps: f32, + download_max_mbps: f32, + upload_max_mbps: f32, + ) -> Result { + validate_rate_profile_mbps( + download_min_mbps, + upload_min_mbps, + download_max_mbps, + upload_max_mbps, + )?; + + Ok(Self { + download_min_mbps, + upload_min_mbps, + download_max_mbps, + upload_max_mbps, + }) + } + + /// Returns the minimum download rate in Mbps. + #[must_use] + pub const fn download_min_mbps(&self) -> f32 { + self.download_min_mbps + } + + /// Returns the minimum upload rate in Mbps. + #[must_use] + pub const fn upload_min_mbps(&self) -> f32 { + self.upload_min_mbps + } + + /// Returns the maximum download rate in Mbps. + #[must_use] + pub const fn download_max_mbps(&self) -> f32 { + self.download_max_mbps + } + + /// Returns the maximum upload rate in Mbps. + #[must_use] + pub const fn upload_max_mbps(&self) -> f32 { + self.upload_max_mbps + } + + fn from_packet_rate(rate_limit: &MikrotikRateLimit) -> Option { + let upload_mbps = bits_per_second_to_mbps(rate_limit.upload_bps); + let download_mbps = bits_per_second_to_mbps(rate_limit.download_bps); + + Self::new(download_mbps, upload_mbps, download_mbps, upload_mbps).ok() + } +} + +/// Candidate non-packet rate profiles for resolving a RADIUS accounting session. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SessionRateSources { + /// Rate profile supplied by a `ShapedDevices.csv` MAC match. + /// + /// Used only when no usable rate was decoded from the accounting packet. + pub shaped_device_profile: Option, + /// Configured fallback profile. + /// + /// Used only when no decoded packet rate is usable and no unique ShapedDevices MAC match + /// supplied the session metadata. + pub fallback_profile: Option, +} + +/// Rate source selected for a RADIUS accounting session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionRateSource { + /// Rate decoded from the accounting packet. + Packet, + /// Rate supplied by matched `ShapedDevices.csv` metadata. + ShapedDevice, + /// Configured fallback profile used when no usable decoded packet or + /// unique ShapedDevices MAC-match rate is available. + Fallback, +} + +/// Resolved rate profile and its source. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedSessionRate { + /// Source that supplied the rate profile. + pub source: SessionRateSource, + /// Complete min/max speed profile. + pub profile: SessionRateProfile, +} + +/// Reason a retained session cannot currently become a dynamic circuit. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PendingSessionReason { + /// Acct-Session-Id is missing. + MissingSessionId, + /// No NAS identity attribute is present. + MissingNasIdentity, + /// No stable dynamic-circuit identity is available. + MissingCircuitIdentity, + /// No stable dynamic-device identity is available. + MissingDeviceIdentity, + /// No subscriber IPv4, IPv6, or prefix address is present. + MissingIpAddress, + /// No usable rate source is available. + MissingRate, + /// No parent node mapping is available. + MissingParent, + /// The event maps ambiguously to more than one parent or subscriber target. + AmbiguousMapping, + /// MAC matching is enabled, but no shaped-device MAC matched the event. + NoMacMatch, + /// MAC matching is enabled, and more than one shaped-device MAC matched the event. + AmbiguousMacMatch, + /// Configured ShapedDevices identity matching found no matching row. + NoIdentityMatch, + /// Configured ShapedDevices identity matching found duplicate rows. + AmbiguousIdentityMatch, +} + +/// Result of applying one accounting event to the session store. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AccountingSessionUpdate { + /// A single session was created, refreshed, or stopped. + SessionUpdated { + /// Session key that was updated. + key: AccountingSessionKey, + /// Lifecycle state after the update. + state: AccountingSessionState, + }, + /// A NAS reset event marked matching sessions stale. + NasSessionsMarkedStale { + /// NAS identity used to match sessions. + nas: NasIdentity, + /// Reset status that caused the stale mark. + reset: NasResetStatus, + /// Number of sessions marked stale. + marked_count: usize, + /// Session keys that transitioned from active or stopped to stale. + newly_stale_session_keys: HashSet, + /// Session keys whose retained stale state was updated by this reset. + stale_session_keys: HashSet, + }, + /// The event did not carry an actionable accounting status. + Ignored { + /// Reason the event was ignored. + reason: AccountingSessionIgnoreReason, + }, +} + +/// Reason an accounting event did not update session state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountingSessionIgnoreReason { + /// Acct-Status-Type is missing. + MissingStatusType, + /// Acct-Status-Type is not handled by this store. + UnsupportedStatusType(u32), + /// Accounting-On or Accounting-Off had no NAS identity to match sessions. + MissingNasIdentityForReset(NasResetStatus), +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum SessionLookupIndexKey { + AcctSessionId(String), + UserName(String), + CallingStationId(String), + NasPortId(String), + NasPort(u32), +} + +struct SessionIndexEntries { + nas_session_id: Option, + pending_lookup_keys: Vec, + fallback_lookup_keys: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct NasIdentitySet { + identifier: Option, + ipv4: Option, + ipv6: Option, +} + +impl NasIdentitySet { + fn from_event(event: &AccountingEvent) -> Self { + Self { + identifier: event + .nas_identifier + .as_ref() + .filter(|identifier| !identifier.is_empty()) + .cloned(), + ipv4: event.nas_ip_address, + ipv6: event.nas_ipv6_address, + } + } + + fn primary(&self) -> Option { + if let Some(identifier) = &self.identifier { + return Some(NasIdentity::Identifier(identifier.clone())); + } + if let Some(ipv4) = self.ipv4 { + return Some(NasIdentity::Ipv4(ipv4)); + } + self.ipv6.map(NasIdentity::Ipv6) + } + + fn is_empty(&self) -> bool { + self.identifier.is_none() && self.ipv4.is_none() && self.ipv6.is_none() + } + + fn contains(&self, identity: &NasIdentity) -> bool { + match identity { + NasIdentity::Identifier(identifier) => self.identifier.as_ref() == Some(identifier), + NasIdentity::Ipv4(ipv4) => self.ipv4 == Some(*ipv4), + NasIdentity::Ipv6(ipv6) => self.ipv6 == Some(*ipv6), + } + } + + fn identities(&self) -> Vec { + let mut identities = Vec::new(); + if let Some(identifier) = &self.identifier { + identities.push(NasIdentity::Identifier(identifier.clone())); + } + if let Some(ipv4) = self.ipv4 { + identities.push(NasIdentity::Ipv4(ipv4)); + } + if let Some(ipv6) = self.ipv6 { + identities.push(NasIdentity::Ipv6(ipv6)); + } + identities + } + + fn overlaps(&self, other: &Self) -> bool { + self.identifier + .as_ref() + .zip(other.identifier.as_ref()) + .is_some_and(|(left, right)| left == right) + || self + .ipv4 + .zip(other.ipv4) + .is_some_and(|(left, right)| left == right) + || self + .ipv6 + .zip(other.ipv6) + .is_some_and(|(left, right)| left == right) + } +} + +fn session_index_entries( + key: &AccountingSessionKey, + session: &AccountingSession, +) -> SessionIndexEntries { + let nas_session_id = match key { + AccountingSessionKey::NasSession { + acct_session_id, .. + } => Some(acct_session_id.clone()), + AccountingSessionKey::Pending { .. } => None, + }; + let lookup_keys = session_lookup_index_keys(key, session); + let (pending_lookup_keys, fallback_lookup_keys) = match key { + AccountingSessionKey::Pending { .. } => (lookup_keys, Vec::new()), + AccountingSessionKey::NasSession { .. } => (Vec::new(), lookup_keys), + }; + + SessionIndexEntries { + nas_session_id, + pending_lookup_keys, + fallback_lookup_keys, + } +} + +fn session_lookup_index_keys( + key: &AccountingSessionKey, + session: &AccountingSession, +) -> Vec { + let mut lookup_keys = Vec::new(); + let fingerprint = session_fingerprint(key, session); + push_fingerprint_lookup_keys(&mut lookup_keys, &fingerprint); + lookup_keys +} + +fn event_lookup_index_keys( + event: &AccountingEvent, + identities: &NasIdentitySet, +) -> Vec { + let mut lookup_keys = Vec::new(); + let fingerprint = PendingSessionFingerprint::from_event(event, identities.primary()); + push_fingerprint_lookup_keys(&mut lookup_keys, &fingerprint); + lookup_keys +} + +fn push_fingerprint_lookup_keys( + lookup_keys: &mut Vec, + fingerprint: &PendingSessionFingerprint, +) { + if let Some(acct_session_id) = &fingerprint.acct_session_id { + push_unique_lookup_key( + lookup_keys, + SessionLookupIndexKey::AcctSessionId(acct_session_id.clone()), + ); + } + if let Some(user_name) = &fingerprint.user_name { + push_unique_lookup_key( + lookup_keys, + SessionLookupIndexKey::UserName(user_name.clone()), + ); + } + if let Some(calling_station_id) = &fingerprint.calling_station_id { + push_unique_lookup_key( + lookup_keys, + SessionLookupIndexKey::CallingStationId(calling_station_id.clone()), + ); + } + if let Some(nas_port_id) = &fingerprint.nas_port_id { + push_unique_lookup_key( + lookup_keys, + SessionLookupIndexKey::NasPortId(nas_port_id.clone()), + ); + } + if let Some(nas_port) = fingerprint.nas_port { + push_unique_lookup_key(lookup_keys, SessionLookupIndexKey::NasPort(nas_port)); + } +} + +fn push_unique_lookup_key( + lookup_keys: &mut Vec, + lookup_key: SessionLookupIndexKey, +) { + if !lookup_keys.contains(&lookup_key) { + lookup_keys.push(lookup_key); + } +} + +fn remove_indexed_session_key( + index: &mut HashMap>, + index_key: &K, + session_key: &AccountingSessionKey, +) where + K: Eq + std::hash::Hash, +{ + let remove_empty_entry = if let Some(keys) = index.get_mut(index_key) { + keys.remove(session_key); + keys.is_empty() + } else { + false + }; + if remove_empty_entry { + index.remove(index_key); + } +} + +fn session_matches_identities( + key: &AccountingSessionKey, + session: &AccountingSession, + identities: &NasIdentitySet, +) -> bool { + if let Some(nas) = key.nas() + && identities.contains(nas) + { + return true; + } + if session + .known_nas_identities + .iter() + .any(|identity| identities.contains(identity)) + { + return true; + } + + identities.overlaps(&NasIdentitySet::from_event(&session.latest_event)) +} + +fn retained_activation_state(session: &AccountingSession) -> RadiusActivationDiagnosticState { + match session.state { + AccountingSessionState::Active if session.pending_reasons.is_empty() => { + RadiusActivationDiagnosticState::Active + } + AccountingSessionState::Active => RadiusActivationDiagnosticState::Pending, + AccountingSessionState::Stopped => RadiusActivationDiagnosticState::Stopped, + AccountingSessionState::Stale(reset) => RadiusActivationDiagnosticState::Stale(reset), + } +} + +fn diagnostic_circuit_ids(key: &AccountingSessionKey, session: &AccountingSession) -> Vec { + let mut circuit_ids = Vec::new(); + if let Some(device) = &session.resolved_shaped_device { + push_unique_text(&mut circuit_ids, &device.circuit_id); + } + for circuit_id in &session.active_dynamic_circuit_ids { + push_unique_text(&mut circuit_ids, circuit_id); + } + for circuit_id in &session.diagnostic_circuit_ids { + push_unique_text(&mut circuit_ids, circuit_id); + } + if circuit_ids.is_empty() + && let Some(circuit_id) = key.dynamic_circuit_id() + { + push_unique_text(&mut circuit_ids, &circuit_id); + } + circuit_ids +} + +fn push_unique_text(values: &mut Vec, value: &str) { + if value.trim().is_empty() || values.iter().any(|existing| existing == value) { + return; + } + values.push(value.to_string()); +} + +fn latest_session_event( + previous_event: Option<&AccountingEvent>, + event: AccountingEvent, + state: AccountingSessionState, +) -> AccountingEvent { + if state != AccountingSessionState::Active { + return event; + } + let Some(previous_event) = previous_event else { + return event; + }; + merge_sparse_active_event(previous_event, event) +} + +fn merge_sparse_active_event( + previous_event: &AccountingEvent, + mut event: AccountingEvent, +) -> AccountingEvent { + carry_text(&mut event.acct_session_id, &previous_event.acct_session_id); + carry_text(&mut event.nas_identifier, &previous_event.nas_identifier); + carry_text(&mut event.nas_port_id, &previous_event.nas_port_id); + carry_text(&mut event.user_name, &previous_event.user_name); + carry_text( + &mut event.calling_station_id, + &previous_event.calling_station_id, + ); + carry_text( + &mut event.called_station_id, + &previous_event.called_station_id, + ); + carry_optional(&mut event.nas_ip_address, previous_event.nas_ip_address); + carry_optional(&mut event.nas_ipv6_address, previous_event.nas_ipv6_address); + carry_optional(&mut event.nas_port, previous_event.nas_port); + carry_optional( + &mut event.framed_ip_address, + previous_event.framed_ip_address, + ); + carry_optional( + &mut event.framed_ip_netmask, + previous_event.framed_ip_netmask, + ); + carry_optional( + &mut event.framed_ipv6_address, + previous_event.framed_ipv6_address, + ); + if event.framed_routes.is_empty() { + event.framed_routes = previous_event.framed_routes.clone(); + } + if event.class.is_empty() { + event.class = previous_event.class.clone(); + } + if event.framed_ipv6_prefixes.is_empty() { + event.framed_ipv6_prefixes = previous_event.framed_ipv6_prefixes.clone(); + } + if event.delegated_ipv6_prefixes.is_empty() { + event.delegated_ipv6_prefixes = previous_event.delegated_ipv6_prefixes.clone(); + } + if event.mikrotik_rate_limits.is_empty() { + event.mikrotik_rate_limits = previous_event.mikrotik_rate_limits.clone(); + } + event +} + +fn resolved_session_rate( + event: &AccountingEvent, + rate_sources: SessionRateSources, +) -> Option { + if let Some(profile) = event + .mikrotik_rate_limits + .iter() + .find_map(SessionRateProfile::from_packet_rate) + { + return Some(ResolvedSessionRate { + source: SessionRateSource::Packet, + profile, + }); + } + + if let Some(profile) = rate_sources.shaped_device_profile { + return Some(ResolvedSessionRate { + source: SessionRateSource::ShapedDevice, + profile, + }); + } + + rate_sources + .fallback_profile + .map(|profile| ResolvedSessionRate { + source: SessionRateSource::Fallback, + profile, + }) +} + +fn session_rate_profile_from_shaped_device(device: &ShapedDevice) -> Option { + SessionRateProfile::new( + device.download_min_mbps, + device.upload_min_mbps, + device.download_max_mbps, + device.upload_max_mbps, + ) + .ok() +} + +struct SessionDynamicMetadata { + resolved_rate: Option, + resolved_shaped_device: Option, + pending_reasons: Vec, +} + +impl SessionDynamicMetadata { + fn inactive() -> Self { + Self { + resolved_rate: None, + resolved_shaped_device: None, + pending_reasons: Vec::new(), + } + } +} + +fn session_dynamic_metadata( + key: &AccountingSessionKey, + event: &AccountingEvent, + resolution: DynamicCircuitResolution, +) -> SessionDynamicMetadata { + let subscriber_ips = SubscriberIpMetadata::from_event(event); + let resolved_rate = resolved_session_rate(event, resolution.rate_sources); + let parent = resolved_parent_attachment( + resolution.matched_shaped_device.as_ref(), + &resolution.mapping, + ); + let has_circuit_identity = + circuit_identity_available(key, resolution.matched_shaped_device.as_ref(), event); + let has_device_identity = + device_identity_available(key, resolution.matched_shaped_device.as_ref(), event); + let pending_reasons = pending_reasons( + event, + &resolution.mapping, + resolved_rate, + &subscriber_ips, + parent.as_ref(), + has_circuit_identity, + has_device_identity, + ); + let resolved_shaped_device = pending_reasons + .is_empty() + .then(|| { + resolved_shaped_device( + key, + event, + resolution.matched_shaped_device.as_ref(), + resolved_rate, + &subscriber_ips, + parent.as_ref(), + ) + }) + .flatten(); + + SessionDynamicMetadata { + resolved_rate, + resolved_shaped_device, + pending_reasons, + } +} + +struct SubscriberIpMetadata { + ipv4: Vec<(Ipv4Addr, u32)>, + ipv6: Vec<(Ipv6Addr, u32)>, +} + +impl SubscriberIpMetadata { + fn from_event(event: &AccountingEvent) -> Self { + Self { + ipv4: subscriber_ipv4_cidrs(event), + ipv6: subscriber_ipv6_cidrs(event), + } + } + + fn is_empty(&self) -> bool { + self.ipv4.is_empty() && self.ipv6.is_empty() + } +} + +fn resolved_shaped_device( + key: &AccountingSessionKey, + event: &AccountingEvent, + matched_shaped_device: Option<&ShapedDevice>, + resolved_rate: Option, + subscriber_ips: &SubscriberIpMetadata, + parent: Option<&DynamicCircuitParent>, +) -> Option { + let resolved_rate = resolved_rate?; + let parent = parent?; + let mut device = if let Some(matched_shaped_device) = matched_shaped_device { + matched_shaped_device.clone() + } else { + default_resolved_shaped_device(key, event, parent)? + }; + apply_parent_attachment(&mut device, parent); + device.ipv4 = subscriber_ips.ipv4.clone(); + device.ipv6 = subscriber_ips.ipv6.clone(); + apply_session_rate_profile(&mut device, resolved_rate.profile); + device.refresh_hashes(); + Some(device) +} + +fn resolved_parent_attachment( + matched_shaped_device: Option<&ShapedDevice>, + mapping: &DynamicCircuitMapping, +) -> Option { + matched_shaped_device + .and_then(parent_from_shaped_device) + .or_else(|| parent_from_mapping(mapping)) +} + +fn parent_from_shaped_device(device: &ShapedDevice) -> Option { + // lqos_bakery dynamic-circuit validation still requires parent_node. + let parent = DynamicCircuitParent { + parent_node: device.parent_node.clone(), + parent_node_id: device.parent_node_id.clone(), + anchor_node_id: device.anchor_node_id.clone(), + }; + parent.has_parent_node().then_some(parent) +} + +fn parent_from_mapping(mapping: &DynamicCircuitMapping) -> Option { + let DynamicCircuitMapping::ReadyWithParent(parent) = mapping else { + return None; + }; + parent.has_parent_node().then(|| parent.clone()) +} + +fn circuit_identity_available( + key: &AccountingSessionKey, + matched_shaped_device: Option<&ShapedDevice>, + event: &AccountingEvent, +) -> bool { + if let Some(device) = matched_shaped_device { + return !device.circuit_id.trim().is_empty(); + } + + stable_subscriber_circuit_id(key, event).is_some() +} + +fn device_identity_available( + key: &AccountingSessionKey, + matched_shaped_device: Option<&ShapedDevice>, + event: &AccountingEvent, +) -> bool { + if let Some(device) = matched_shaped_device { + return !device.device_id.trim().is_empty(); + } + + stable_subscriber_circuit_id(key, event).is_some() +} + +fn default_resolved_shaped_device( + key: &AccountingSessionKey, + event: &AccountingEvent, + parent: &DynamicCircuitParent, +) -> Option { + let circuit_id = stable_subscriber_circuit_id(key, event)?; + let device_id = circuit_id.clone(); + Some(ShapedDevice { + circuit_name: default_circuit_name(event, &circuit_id), + device_name: default_device_name(event, &device_id), + circuit_id, + device_id, + parent_node: parent.parent_node.clone(), + parent_node_id: parent.parent_node_id.clone(), + anchor_node_id: parent.anchor_node_id.clone(), + mac: non_empty_str(&event.calling_station_id) + .unwrap_or_default() + .to_string(), + comment: String::new(), + sqm_override: None, + circuit_hash: 0, + device_hash: 0, + parent_hash: 0, + ..ShapedDevice::default() + }) +} + +/// Returns the stable customer circuit ID for an unmatched dynamic RADIUS session. +/// +/// This deliberately does not use the RADIUS accounting session ID: a subscriber +/// reconnects with a new session ID, but must retain the same circuit identity for +/// Insight and exported historical data. The NAS identity scopes the subscriber +/// identity so identical usernames on separate BNGs cannot collide. Username is +/// preferred because it is the configured subscriber identity for PPPoE and +/// DHCP-RADIUS; otherwise a normalized Calling-Station-Id is used. +fn stable_subscriber_circuit_id( + key: &AccountingSessionKey, + event: &AccountingEvent, +) -> Option { + let nas = key.nas()?.circuit_id_component(); + if let Some(username) = event.user_name.as_deref().filter(|value| !value.is_empty()) + { + return Some(format!( + "radius:{nas}:username:{}", + hex_component(username.as_bytes()) + )); + } + + let calling_station_id = non_empty_str(&event.calling_station_id)?; + let identity = crate::mac_match::normalize_radius_mac(calling_station_id) + .unwrap_or_else(|| calling_station_id.to_string()); + Some(format!( + "radius:{nas}:calling-station:{}", + hex_component(identity.as_bytes()) + )) +} + +fn apply_parent_attachment(device: &mut ShapedDevice, parent: &DynamicCircuitParent) { + device.parent_node = parent.parent_node.clone(); + device.parent_node_id = parent.parent_node_id.clone(); + device.anchor_node_id = parent.anchor_node_id.clone(); +} + +fn default_circuit_name(event: &AccountingEvent, fallback: &str) -> String { + non_empty_str(&event.user_name) + .or_else(|| non_empty_str(&event.calling_station_id)) + .or_else(|| non_empty_str(&event.acct_session_id)) + .unwrap_or(fallback) + .to_string() +} + +fn default_device_name(event: &AccountingEvent, fallback: &str) -> String { + non_empty_str(&event.calling_station_id) + .or_else(|| non_empty_str(&event.user_name)) + .or_else(|| non_empty_str(&event.acct_session_id)) + .unwrap_or(fallback) + .to_string() +} + +fn apply_session_rate_profile(device: &mut ShapedDevice, profile: SessionRateProfile) { + device.download_min_mbps = profile.download_min_mbps(); + device.upload_min_mbps = profile.upload_min_mbps(); + device.download_max_mbps = profile.download_max_mbps(); + device.upload_max_mbps = profile.upload_max_mbps(); +} + +fn bits_per_second_to_mbps(bits_per_second: u64) -> f32 { + bits_per_second as f32 / 1_000_000.0 +} + +fn pending_reasons( + event: &AccountingEvent, + mapping: &DynamicCircuitMapping, + resolved_rate: Option, + subscriber_ips: &SubscriberIpMetadata, + parent: Option<&DynamicCircuitParent>, + has_circuit_identity: bool, + has_device_identity: bool, +) -> Vec { + let mut reasons = Vec::new(); + + let missing_session_id = event.acct_session_id.as_ref().is_none_or(String::is_empty); + if missing_session_id { + reasons.push(PendingSessionReason::MissingSessionId); + } + let missing_nas_identity = NasIdentitySet::from_event(event).is_empty(); + if missing_nas_identity { + reasons.push(PendingSessionReason::MissingNasIdentity); + } + if !has_circuit_identity && !missing_session_id && !missing_nas_identity { + reasons.push(PendingSessionReason::MissingCircuitIdentity); + } + if !has_device_identity && !missing_session_id && !missing_nas_identity { + reasons.push(PendingSessionReason::MissingDeviceIdentity); + } + if subscriber_ips.is_empty() { + reasons.push(PendingSessionReason::MissingIpAddress); + } + if resolved_rate.is_none() { + reasons.push(PendingSessionReason::MissingRate); + } + if parent.is_none() + && matches!( + mapping, + DynamicCircuitMapping::MatchedShapedDevice + | DynamicCircuitMapping::ReadyWithParent(_) + | DynamicCircuitMapping::MissingParent + ) + { + reasons.push(PendingSessionReason::MissingParent); + } + match mapping { + DynamicCircuitMapping::MatchedShapedDevice + | DynamicCircuitMapping::ReadyWithParent(_) + | DynamicCircuitMapping::MissingParent => {} + DynamicCircuitMapping::Ambiguous => reasons.push(PendingSessionReason::AmbiguousMapping), + DynamicCircuitMapping::NoMacMatch => reasons.push(PendingSessionReason::NoMacMatch), + DynamicCircuitMapping::AmbiguousMacMatch => { + reasons.push(PendingSessionReason::AmbiguousMacMatch); + } + DynamicCircuitMapping::NoIdentityMatch => { + reasons.push(PendingSessionReason::NoIdentityMatch); + } + DynamicCircuitMapping::AmbiguousIdentityMatch => { + reasons.push(PendingSessionReason::AmbiguousIdentityMatch); + } + } + + reasons +} + +fn subscriber_ipv4_cidrs(event: &AccountingEvent) -> Vec<(Ipv4Addr, u32)> { + let mut ipv4_cidrs = Vec::new(); + if let Some(framed_ip_address) = event.framed_ip_address { + let prefix_len = event + .framed_ip_netmask + .and_then(ipv4_netmask_prefix_len) + .filter(|prefix_len| *prefix_len > 0) + .unwrap_or(32); + if let Some(cidr) = subscriber_ipv4_cidr(framed_ip_address, prefix_len) { + ipv4_cidrs.push(cidr); + } + } + for framed_route in &event.framed_routes { + if let Some(route_destination) = framed_route_destination(framed_route) { + ipv4_cidrs.push(route_destination); + } + } + ipv4_cidrs +} + +fn subscriber_ipv6_cidrs(event: &AccountingEvent) -> Vec<(Ipv6Addr, u32)> { + let mut ipv6_cidrs = Vec::new(); + if let Some(framed_ipv6_address) = event.framed_ipv6_address + && let Some(cidr) = subscriber_ipv6_cidr(framed_ipv6_address, 128) + { + ipv6_cidrs.push(cidr); + } + ipv6_cidrs.extend( + event.framed_ipv6_prefixes.iter().filter_map(|prefix| { + subscriber_ipv6_cidr(prefix.address, u32::from(prefix.prefix_len)) + }), + ); + ipv6_cidrs.extend( + event.delegated_ipv6_prefixes.iter().filter_map(|prefix| { + subscriber_ipv6_cidr(prefix.address, u32::from(prefix.prefix_len)) + }), + ); + ipv6_cidrs +} + +fn subscriber_ipv6_cidr(address: Ipv6Addr, prefix_len: u32) -> Option<(Ipv6Addr, u32)> { + if prefix_len == 0 || prefix_len > 128 || address.is_unspecified() || address.is_multicast() { + return None; + } + + Some((address, prefix_len)) +} + +fn framed_route_destination(framed_route: &str) -> Option<(Ipv4Addr, u32)> { + parse_ipv4_cidr(framed_route.split_whitespace().next()?) +} + +fn parse_ipv4_cidr(raw_cidr: &str) -> Option<(Ipv4Addr, u32)> { + let Some((address, prefix_len)) = raw_cidr.split_once('/') else { + return subscriber_ipv4_cidr(raw_cidr.parse().ok()?, 32); + }; + let address = address.parse().ok()?; + let prefix_len = prefix_len.parse().ok()?; + subscriber_ipv4_cidr(address, prefix_len) +} + +fn subscriber_ipv4_cidr(address: Ipv4Addr, prefix_len: u32) -> Option<(Ipv4Addr, u32)> { + if prefix_len == 0 + || prefix_len > 32 + || address.is_unspecified() + || address.is_multicast() + || address.octets() == [255, 255, 255, 255] + { + return None; + } + + Some((address, prefix_len)) +} + +fn ipv4_netmask_prefix_len(netmask: Ipv4Addr) -> Option { + let netmask_bits = u32::from(netmask); + let prefix_len = netmask_bits.count_ones(); + let expected_netmask = if prefix_len == 0 { + 0 + } else { + u32::MAX << (32 - prefix_len) + }; + (netmask_bits == expected_netmask).then_some(prefix_len) +} + +fn non_empty_text(value: &Option) -> Option { + value.as_ref().filter(|value| !value.is_empty()).cloned() +} + +fn non_empty_str(value: &Option) -> Option<&str> { + value.as_deref().filter(|value| !value.trim().is_empty()) +} + +fn carry_text(target: &mut Option, previous: &Option) { + if target.is_none() { + *target = previous.clone(); + } +} + +fn carry_optional(target: &mut Option, previous: Option) { + if target.is_none() { + *target = previous; + } +} + +fn optional_match(left: &Option, right: &Option) -> Option { + match (left, right) { + (Some(left), Some(right)) if left == right => Some(true), + (Some(_), Some(_)) => None, + _ => Some(false), + } +} + +fn session_fingerprint( + key: &AccountingSessionKey, + session: &AccountingSession, +) -> PendingSessionFingerprint { + let mut fingerprint = PendingSessionFingerprint::from_event(&session.latest_event, None); + if let AccountingSessionKey::NasSession { + acct_session_id, .. + } = key + { + fingerprint.acct_session_id = Some(acct_session_id.clone()); + } + fingerprint +} + +fn merge_known_nas_identities( + known_nas_identities: &mut Vec, + event: &AccountingEvent, +) { + merge_known_nas_identity_values( + known_nas_identities, + NasIdentitySet::from_event(event).identities(), + ); +} + +fn merge_known_nas_identity_values( + known_nas_identities: &mut Vec, + identities: Vec, +) { + for identity in identities { + if !known_nas_identities.contains(&identity) { + known_nas_identities.push(identity); + } + } +} + +fn merge_active_dynamic_circuit_ids(active_ids: &mut Vec, ids: Vec) { + for id in ids { + push_active_dynamic_circuit_id(active_ids, id); + } +} + +fn merge_diagnostic_circuit_ids(diagnostic_ids: &mut Vec, ids: Vec) { + for id in ids { + push_unique_text(diagnostic_ids, &id); + } +} + +fn push_active_dynamic_circuit_id(active_ids: &mut Vec, id: String) { + if !active_ids.contains(&id) { + active_ids.push(id); + } +} + +fn emit_active_dynamic_circuit_removals( + key: &AccountingSessionKey, + session: &mut AccountingSession, + reason: DynamicCircuitRemovalReason, + counters: &mut RadiusActivationCounters, + sink: &mut impl DynamicCircuitCommandSink, +) { + for circuit_id in std::mem::take(&mut session.active_dynamic_circuit_ids) { + counters.remove += 1; + sink.emit(DynamicCircuitIntent::RemoveDynamicCircuit( + DynamicCircuitRemoval { + circuit_id, + session_key: key.clone(), + reason, + }, + )); + } +} + +fn emit_rekeyed_dynamic_circuit_removals( + key: &AccountingSessionKey, + session: &mut AccountingSession, + current_circuit_id: &str, + counters: &mut RadiusActivationCounters, + sink: &mut impl DynamicCircuitCommandSink, +) { + let active_ids = std::mem::take(&mut session.active_dynamic_circuit_ids); + for circuit_id in active_ids { + if circuit_id == current_circuit_id { + push_active_dynamic_circuit_id(&mut session.active_dynamic_circuit_ids, circuit_id); + continue; + } + counters.remove += 1; + sink.emit(DynamicCircuitIntent::RemoveDynamicCircuit( + DynamicCircuitRemoval { + circuit_id, + session_key: key.clone(), + reason: DynamicCircuitRemovalReason::Rekeyed, + }, + )); + } +} + +fn push_unique_key(keys: &mut Vec, key: AccountingSessionKey) { + if !keys.contains(&key) { + keys.push(key); + } +} + +fn hex_component(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(HEX[usize::from(byte >> 4)] as char); + encoded.push(HEX[usize::from(byte & 0x0f)] as char); + } + encoded +} + +#[cfg(test)] +mod tests; diff --git a/src/rust/lqos_radius/src/session/tests.rs b/src/rust/lqos_radius/src/session/tests.rs new file mode 100644 index 00000000..207a7010 --- /dev/null +++ b/src/rust/lqos_radius/src/session/tests.rs @@ -0,0 +1,3301 @@ +//! Tests for in-memory accounting session tracking. + +use super::*; +use crate::{ + DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemovalReason, Ipv6Prefix, + MikrotikRateLimit, ShapedDevicesMacMatcher, +}; +use lqos_config::ShapedDevice; +use std::net::{Ipv4Addr, Ipv6Addr}; + +#[test] +fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { + let mut store = AccountingSessionStore::new(); + let start = complete_event( + AcctStatusType::Start, + "nas-a", + "session-1", + Ipv4Addr::new(198, 51, 100, 10), + ); + + let update = store.apply_event_with_mapping(start, ready_mapping()); + let expected_key = nas_session_key("nas-a", "session-1"); + + assert_eq!( + update, + AccountingSessionUpdate::SessionUpdated { + key: expected_key.clone(), + state: AccountingSessionState::Active, + } + ); + assert_eq!(store.len(), 1); + let created = store.session(&expected_key).unwrap(); + assert_eq!(created.pending_reasons, Vec::new()); + assert_eq!( + created.latest_event.status_type, + Some(AcctStatusType::Start) + ); + assert_eq!( + created.latest_event.nas_identifier.as_deref(), + Some("nas-a") + ); + assert_eq!( + created.latest_event.framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 10)) + ); + + let mut interim = complete_event( + AcctStatusType::InterimUpdate, + "nas-a", + "session-1", + Ipv4Addr::new(198, 51, 100, 11), + ); + interim.user_name = Some("updated-subscriber".to_string()); + store.apply_event_with_mapping(interim, ready_mapping()); + + let refreshed = store.session(&expected_key).unwrap(); + assert_eq!(store.len(), 1); + assert_eq!(refreshed.state, AccountingSessionState::Active); + assert_eq!( + refreshed.latest_event.status_type, + Some(AcctStatusType::InterimUpdate) + ); + assert_eq!( + refreshed.latest_event.framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 11)) + ); + assert_eq!( + refreshed.latest_event.user_name.as_deref(), + Some("updated-subscriber") + ); + + let mut sparse_store = AccountingSessionStore::new(); + let sparse_key = nas_session_key("nas-sparse", "session-sparse"); + let mut sparse_start = complete_event( + AcctStatusType::Start, + "nas-sparse", + "session-sparse", + Ipv4Addr::new(198, 51, 100, 13), + ); + sparse_start.class = vec![b"class-data".to_vec()]; + sparse_store.apply_event_with_mapping(sparse_start, ready_mapping()); + sparse_store.apply_event_with_mapping( + minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-sparse", + "session-sparse", + ), + ready_mapping(), + ); + let sparse_session = sparse_store.session(&sparse_key).unwrap(); + assert_eq!( + sparse_session.latest_event.status_type, + Some(AcctStatusType::InterimUpdate) + ); + assert_eq!( + sparse_session.latest_event.framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 13)) + ); + assert!(!sparse_session.latest_event.mikrotik_rate_limits.is_empty()); + assert_eq!( + sparse_session.latest_event.class, + vec![b"class-data".to_vec()] + ); + assert_eq!(sparse_session.pending_reasons, Vec::new()); + + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-b", + "session-1", + Ipv4Addr::new(198, 51, 100, 12), + ), + ready_mapping(), + ); + assert_eq!(store.len(), 2); + + let mut interim_only_store = AccountingSessionStore::new(); + interim_only_store.apply_event_with_mapping( + complete_event( + AcctStatusType::InterimUpdate, + "nas-c", + "session-9", + Ipv4Addr::new(198, 51, 100, 19), + ), + ready_mapping(), + ); + assert_eq!(interim_only_store.len(), 1); + assert_eq!( + interim_only_store + .session(&nas_session_key("nas-c", "session-9")) + .unwrap() + .state, + AccountingSessionState::Active + ); + + let nas_ip = Ipv4Addr::new(192, 0, 2, 50); + let mut mixed_identity_store = AccountingSessionStore::new(); + let mut start_by_ip = complete_event( + AcctStatusType::Start, + "ignored-by-test", + "session-mixed", + Ipv4Addr::new(198, 51, 100, 50), + ); + start_by_ip.nas_identifier = None; + start_by_ip.nas_ip_address = Some(nas_ip); + let ip_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(nas_ip), + acct_session_id: "session-mixed".to_string(), + }; + mixed_identity_store.apply_event_with_mapping(start_by_ip, ready_mapping()); + + let mut interim_with_identifier = complete_event( + AcctStatusType::InterimUpdate, + "nas-with-new-identifier", + "session-mixed", + Ipv4Addr::new(198, 51, 100, 51), + ); + interim_with_identifier.nas_ip_address = Some(nas_ip); + let mixed_update = + mixed_identity_store.apply_event_with_mapping(interim_with_identifier, ready_mapping()); + + assert_eq!( + mixed_update, + AccountingSessionUpdate::SessionUpdated { + key: ip_key.clone(), + state: AccountingSessionState::Active, + } + ); + assert_eq!(mixed_identity_store.len(), 1); + assert_eq!( + mixed_identity_store + .session(&ip_key) + .unwrap() + .latest_event + .nas_identifier + .as_deref(), + Some("nas-with-new-identifier") + ); + + let pending_collision_ip = Ipv4Addr::new(192, 0, 2, 53); + let mut pending_collision_store = AccountingSessionStore::new(); + pending_collision_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_identifier: Some("unrelated-pending".to_string()), + ..AccountingEvent::default() + }); + let mut pending_collision_start = complete_event( + AcctStatusType::Start, + "ignored-by-test", + "session-pending-collision", + Ipv4Addr::new(198, 51, 100, 58), + ); + pending_collision_start.nas_identifier = None; + pending_collision_start.nas_ip_address = Some(pending_collision_ip); + pending_collision_store.apply_event_with_mapping(pending_collision_start, ready_mapping()); + let pending_collision_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(pending_collision_ip), + acct_session_id: "session-pending-collision".to_string(), + }; + let mut pending_collision_interim = complete_event( + AcctStatusType::InterimUpdate, + "nas-pending-collision", + "session-pending-collision", + Ipv4Addr::new(198, 51, 100, 59), + ); + pending_collision_interim.nas_ip_address = Some(pending_collision_ip); + pending_collision_store.apply_event_with_mapping(pending_collision_interim, ready_mapping()); + assert_eq!(pending_collision_store.len(), 2); + assert_eq!( + pending_collision_store + .session(&pending_collision_key) + .unwrap() + .latest_event + .nas_identifier + .as_deref(), + Some("nas-pending-collision") + ); + + let mut pending_to_keyed_store = AccountingSessionStore::new(); + let mut missing_nas = complete_event( + AcctStatusType::Start, + "ignored-by-test", + "session-late-nas", + Ipv4Addr::new(198, 51, 100, 52), + ); + missing_nas.nas_identifier = None; + pending_to_keyed_store.apply_event_with_mapping(missing_nas, ready_mapping()); + let late_nas_key = nas_session_key("nas-late", "session-late-nas"); + pending_to_keyed_store.apply_event_with_mapping( + complete_event( + AcctStatusType::InterimUpdate, + "nas-late", + "session-late-nas", + Ipv4Addr::new(198, 51, 100, 53), + ), + ready_mapping(), + ); + assert_eq!(pending_to_keyed_store.len(), 1); + assert_eq!( + pending_to_keyed_store + .session(&late_nas_key) + .unwrap() + .latest_event + .status_type, + Some(AcctStatusType::InterimUpdate) + ); + + let split_nas_ip = Ipv4Addr::new(192, 0, 2, 51); + let split_key = nas_session_key("nas-split", "session-split"); + let mut split_store = AccountingSessionStore::new(); + split_store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-split", + "session-split", + Ipv4Addr::new(198, 51, 100, 54), + ), + ready_mapping(), + ); + let mut ip_only_interim = complete_event( + AcctStatusType::InterimUpdate, + "ignored-by-test", + "session-split", + Ipv4Addr::new(198, 51, 100, 55), + ); + ip_only_interim.nas_identifier = None; + ip_only_interim.nas_ip_address = Some(split_nas_ip); + split_store.apply_event_with_mapping(ip_only_interim, ready_mapping()); + assert_eq!(split_store.len(), 2); + + let mut combined_identity_interim = complete_event( + AcctStatusType::InterimUpdate, + "nas-split", + "session-split", + Ipv4Addr::new(198, 51, 100, 56), + ); + combined_identity_interim.nas_ip_address = Some(split_nas_ip); + split_store.apply_event_with_mapping(combined_identity_interim, ready_mapping()); + assert_eq!(split_store.len(), 1); + assert_eq!( + split_store + .session(&split_key) + .unwrap() + .latest_event + .framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 56)) + ); + + let retained_ipv6: Ipv6Addr = "2001:db8::60".parse().unwrap(); + let mut split_with_extra_alias = AccountingSessionStore::new(); + split_with_extra_alias.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-extra-alias", + "session-extra-alias", + Ipv4Addr::new(198, 51, 100, 60), + ), + ready_mapping(), + ); + let alternate_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(split_nas_ip), + acct_session_id: "session-extra-alias".to_string(), + }; + insert_retained_session( + &mut split_with_extra_alias, + alternate_key, + AccountingSession { + state: AccountingSessionState::Active, + latest_event: AccountingEvent { + status_type: Some(AcctStatusType::InterimUpdate), + acct_session_id: Some("session-extra-alias".to_string()), + nas_ip_address: Some(split_nas_ip), + nas_ipv6_address: Some(retained_ipv6), + ..AccountingEvent::default() + }, + known_nas_identities: vec![ + NasIdentity::Ipv4(split_nas_ip), + NasIdentity::Ipv6(retained_ipv6), + ], + resolved_rate: None, + resolved_shaped_device: None, + active_dynamic_circuit_ids: Vec::new(), + diagnostic_circuit_ids: Vec::new(), + pending_reasons: Vec::new(), + }, + ); + let mut extra_alias_merge = complete_event( + AcctStatusType::InterimUpdate, + "nas-extra-alias", + "session-extra-alias", + Ipv4Addr::new(198, 51, 100, 61), + ); + extra_alias_merge.nas_ip_address = Some(split_nas_ip); + split_with_extra_alias.apply_event_with_mapping(extra_alias_merge, ready_mapping()); + let extra_alias_key = nas_session_key("nas-extra-alias", "session-extra-alias"); + split_with_extra_alias.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::AccountingOff), + nas_ipv6_address: Some(retained_ipv6), + ..AccountingEvent::default() + }); + assert_eq!( + split_with_extra_alias + .session(&extra_alias_key) + .unwrap() + .state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + + let rich_alternate_ip = Ipv4Addr::new(192, 0, 2, 61); + let mut rich_alternate_store = AccountingSessionStore::new(); + let rich_target_key = nas_session_key("nas-rich-merge", "session-rich-merge"); + rich_alternate_store.apply_event(minimal_session_event( + AcctStatusType::Start, + "nas-rich-merge", + "session-rich-merge", + )); + let rich_alternate_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(rich_alternate_ip), + acct_session_id: "session-rich-merge".to_string(), + }; + let mut rich_alternate_event = complete_event( + AcctStatusType::InterimUpdate, + "ignored-by-test", + "session-rich-merge", + Ipv4Addr::new(198, 51, 100, 62), + ); + rich_alternate_event.nas_identifier = None; + rich_alternate_event.nas_ip_address = Some(rich_alternate_ip); + insert_retained_session( + &mut rich_alternate_store, + rich_alternate_key, + AccountingSession { + state: AccountingSessionState::Active, + latest_event: rich_alternate_event, + known_nas_identities: vec![NasIdentity::Ipv4(rich_alternate_ip)], + resolved_rate: None, + resolved_shaped_device: None, + active_dynamic_circuit_ids: Vec::new(), + diagnostic_circuit_ids: Vec::new(), + pending_reasons: Vec::new(), + }, + ); + let mut sparse_bridge = minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-rich-merge", + "session-rich-merge", + ); + sparse_bridge.nas_ip_address = Some(rich_alternate_ip); + rich_alternate_store.apply_event_with_mapping(sparse_bridge, ready_mapping()); + + let rich_merged = rich_alternate_store.session(&rich_target_key).unwrap(); + assert_eq!(rich_alternate_store.len(), 1); + assert_eq!( + rich_merged.latest_event.framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 62)) + ); + assert!(!rich_merged.latest_event.mikrotik_rate_limits.is_empty()); + assert_eq!(rich_merged.pending_reasons, Vec::new()); + + let ambiguous_ip = Ipv4Addr::new(192, 0, 2, 52); + let mut ambiguous_retained_store = AccountingSessionStore::new(); + let (ambiguous_key_a, ambiguous_session_a) = + retained_session_with_known_ip("nas-ambiguous-a", "shared-session", ambiguous_ip); + let (ambiguous_key_b, ambiguous_session_b) = + retained_session_with_known_ip("nas-ambiguous-b", "shared-session", ambiguous_ip); + insert_retained_session( + &mut ambiguous_retained_store, + ambiguous_key_a.clone(), + ambiguous_session_a, + ); + insert_retained_session( + &mut ambiguous_retained_store, + ambiguous_key_b.clone(), + ambiguous_session_b, + ); + let mut ambiguous_interim = complete_event( + AcctStatusType::InterimUpdate, + "ignored-by-test", + "shared-session", + Ipv4Addr::new(198, 51, 100, 57), + ); + ambiguous_interim.nas_identifier = None; + ambiguous_interim.nas_ip_address = Some(ambiguous_ip); + ambiguous_retained_store.apply_event_with_mapping(ambiguous_interim, ready_mapping()); + + assert_eq!(ambiguous_retained_store.len(), 3); + assert_eq!( + ambiguous_retained_store + .session(&ambiguous_key_a) + .unwrap() + .state, + AccountingSessionState::Active + ); + assert_eq!( + ambiguous_retained_store + .session(&ambiguous_key_b) + .unwrap() + .state, + AccountingSessionState::Active + ); +} + +#[test] +fn stop_marks_session_stopped_and_duplicate_stop_is_harmless() { + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-a", "session-1"); + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-a", + "session-1", + Ipv4Addr::new(198, 51, 100, 20), + ), + ready_mapping(), + ); + + let stop = minimal_session_event(AcctStatusType::Stop, "nas-a", "session-1"); + let first_stop = store.apply_event(stop.clone()); + let second_stop = store.apply_event(stop); + + assert_eq!( + first_stop, + AccountingSessionUpdate::SessionUpdated { + key: key.clone(), + state: AccountingSessionState::Stopped, + } + ); + assert_eq!(second_stop, first_stop); + assert_eq!(store.len(), 1); + let session = store.session(&key).unwrap(); + assert_eq!(session.state, AccountingSessionState::Stopped); + assert_eq!(session.latest_event.status_type, Some(AcctStatusType::Stop)); + assert_eq!(session.latest_event.framed_ip_address, None); + assert_eq!(session.resolved_rate, None); + assert!(session.resolved_shaped_device.is_none()); + + let mut pending_stop_store = AccountingSessionStore::new(); + let pending_stop = AccountingEvent { + status_type: Some(AcctStatusType::Stop), + nas_identifier: Some("nas-pending".to_string()), + user_name: Some("subscriber-without-session-id".to_string()), + ..AccountingEvent::default() + }; + let first_pending_stop = pending_stop_store.apply_event(pending_stop.clone()); + let second_pending_stop = pending_stop_store.apply_event(pending_stop); + + let AccountingSessionUpdate::SessionUpdated { + key: pending_stop_key, + state: pending_stop_state, + } = first_pending_stop.clone() + else { + panic!("expected pending stop update, got {first_pending_stop:?}"); + }; + + assert_eq!(pending_stop_state, AccountingSessionState::Stopped); + assert_eq!(second_pending_stop, first_pending_stop); + assert_eq!(pending_stop_store.len(), 1); + let pending_session = pending_stop_store.session(&pending_stop_key).unwrap(); + assert_eq!(pending_session.state, AccountingSessionState::Stopped); + assert_eq!( + pending_session.latest_event.status_type, + Some(AcctStatusType::Stop) + ); + + let mut fallback_stop_store = AccountingSessionStore::new(); + let fallback_key = nas_session_key("nas-fallback", "session-fallback"); + fallback_stop_store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-fallback", + "session-fallback", + Ipv4Addr::new(198, 51, 100, 21), + ), + ready_mapping(), + ); + let fallback_stop = AccountingEvent { + status_type: Some(AcctStatusType::Stop), + nas_identifier: Some("nas-fallback".to_string()), + user_name: Some("subscriber".to_string()), + ..AccountingEvent::default() + }; + let fallback_update = fallback_stop_store.apply_event(fallback_stop.clone()); + let duplicate_fallback_update = fallback_stop_store.apply_event(fallback_stop); + + assert_eq!( + fallback_update, + AccountingSessionUpdate::SessionUpdated { + key: fallback_key.clone(), + state: AccountingSessionState::Stopped, + } + ); + assert_eq!(duplicate_fallback_update, fallback_update); + assert_eq!(fallback_stop_store.len(), 1); + assert_eq!( + fallback_stop_store.session(&fallback_key).unwrap().state, + AccountingSessionState::Stopped + ); + assert!( + fallback_stop_store + .session(&fallback_key) + .unwrap() + .resolved_shaped_device + .is_none() + ); +} + +#[test] +fn accounting_on_and_off_mark_only_matching_nas_sessions_stale() { + let mut store = AccountingSessionStore::new(); + let nas_a_session_1 = nas_session_key("nas-a", "session-1"); + let nas_a_session_2 = nas_session_key("nas-a", "session-2"); + let nas_b_session = nas_session_key("nas-b", "session-1"); + + for (nas, session_id, last_octet) in [ + ("nas-a", "session-1", 30), + ("nas-a", "session-2", 31), + ("nas-b", "session-1", 32), + ] { + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + nas, + session_id, + Ipv4Addr::new(198, 51, 100, last_octet), + ), + ready_mapping(), + ); + } + + let accounting_off = store.apply_event(reset_event(AcctStatusType::AccountingOff, "nas-a")); + + assert_eq!( + accounting_off, + AccountingSessionUpdate::NasSessionsMarkedStale { + nas: NasIdentity::Identifier("nas-a".to_string()), + reset: NasResetStatus::AccountingOff, + marked_count: 2, + newly_stale_session_keys: HashSet::from([ + nas_a_session_1.clone(), + nas_a_session_2.clone(), + ]), + stale_session_keys: HashSet::from([nas_a_session_1.clone(), nas_a_session_2.clone()]), + } + ); + assert_eq!( + store.session(&nas_a_session_1).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + assert!( + store + .session(&nas_a_session_1) + .unwrap() + .resolved_shaped_device + .is_none() + ); + assert_eq!( + store.session(&nas_a_session_2).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + assert!( + store + .session(&nas_a_session_2) + .unwrap() + .resolved_shaped_device + .is_none() + ); + assert_eq!( + store.session(&nas_b_session).unwrap().state, + AccountingSessionState::Active + ); + + let accounting_on = store.apply_event(reset_event(AcctStatusType::AccountingOn, "nas-b")); + + assert_eq!( + accounting_on, + AccountingSessionUpdate::NasSessionsMarkedStale { + nas: NasIdentity::Identifier("nas-b".to_string()), + reset: NasResetStatus::AccountingOn, + marked_count: 1, + newly_stale_session_keys: HashSet::from([nas_b_session.clone()]), + stale_session_keys: HashSet::from([nas_b_session.clone()]), + } + ); + assert_eq!( + store.session(&nas_b_session).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOn) + ); + + let nas_c_ip = Ipv4Addr::new(192, 0, 2, 60); + let nas_c_session = nas_session_key("nas-c", "session-1"); + let mut nas_c_start = complete_event( + AcctStatusType::Start, + "nas-c", + "session-1", + Ipv4Addr::new(198, 51, 100, 33), + ); + nas_c_start.nas_ip_address = Some(nas_c_ip); + store.apply_event_with_mapping(nas_c_start, ready_mapping()); + store.apply_event_with_mapping( + complete_event( + AcctStatusType::InterimUpdate, + "nas-c", + "session-1", + Ipv4Addr::new(198, 51, 100, 34), + ), + ready_mapping(), + ); + + let accounting_off_by_old_ip = store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::AccountingOff), + nas_ip_address: Some(nas_c_ip), + ..AccountingEvent::default() + }); + + assert_eq!( + accounting_off_by_old_ip, + AccountingSessionUpdate::NasSessionsMarkedStale { + nas: NasIdentity::Ipv4(nas_c_ip), + reset: NasResetStatus::AccountingOff, + marked_count: 1, + newly_stale_session_keys: HashSet::from([nas_c_session.clone()]), + stale_session_keys: HashSet::from([nas_c_session.clone()]), + } + ); + assert_eq!( + store.session(&nas_c_session).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); +} + +#[test] +fn pending_reasons_are_retained_for_unshapeable_sessions() { + let mut store = AccountingSessionStore::new(); + let missing_session_id = AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_identifier: Some("nas-a".to_string()), + ..AccountingEvent::default() + }; + + let update = store.apply_event(missing_session_id); + let AccountingSessionUpdate::SessionUpdated { key, state } = update else { + panic!("expected pending session update, got {update:?}"); + }; + + assert_eq!( + key, + AccountingSessionKey::Pending { + fingerprint: PendingSessionFingerprint { + nas: Some(NasIdentity::Identifier("nas-a".to_string())), + acct_session_id: None, + user_name: None, + calling_station_id: None, + nas_port_id: None, + nas_port: None, + }, + } + ); + assert_eq!(state, AccountingSessionState::Active); + assert_eq!( + store.session(&key).unwrap().pending_reasons, + vec![ + PendingSessionReason::MissingSessionId, + PendingSessionReason::MissingIpAddress, + PendingSessionReason::MissingRate, + PendingSessionReason::MissingParent, + ] + ); + + let missing_nas = AccountingEvent { + status_type: Some(AcctStatusType::Start), + acct_session_id: Some("session-without-nas".to_string()), + framed_ip_address: Some(Ipv4Addr::new(198, 51, 100, 40)), + mikrotik_rate_limits: vec![rate_limit()], + ..AccountingEvent::default() + }; + let missing_nas_update = store.apply_event_with_mapping(missing_nas, ready_mapping()); + let AccountingSessionUpdate::SessionUpdated { + key: missing_nas_key, + .. + } = missing_nas_update + else { + panic!("expected missing-NAS pending session update, got {missing_nas_update:?}"); + }; + assert_eq!( + store.session(&missing_nas_key).unwrap().pending_reasons, + vec![PendingSessionReason::MissingNasIdentity] + ); + + let ambiguous_mapping_key = nas_session_key("nas-b", "session-ambiguous"); + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-b", + "session-ambiguous", + Ipv4Addr::new(198, 51, 100, 41), + ), + DynamicCircuitMapping::Ambiguous, + ); + assert_eq!( + store + .session(&ambiguous_mapping_key) + .unwrap() + .pending_reasons, + vec![PendingSessionReason::AmbiguousMapping] + ); + + let mut route_only = minimal_session_event(AcctStatusType::Start, "nas-route", "session-route"); + route_only.user_name = Some("subscriber".to_string()); + route_only.framed_routes = vec!["198.51.100.0/24 192.0.2.1 1".to_string()]; + route_only.mikrotik_rate_limits = vec![rate_limit()]; + let route_key = nas_session_key("nas-route", "session-route"); + store.apply_event_with_mapping(route_only, ready_mapping()); + assert_eq!( + store.session(&route_key).unwrap().pending_reasons, + Vec::new() + ); + assert_eq!( + store + .session(&route_key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap() + .ipv4, + vec![(Ipv4Addr::new(198, 51, 100, 0), 24)] + ); + + let mut framed_ipv6_prefix_only = minimal_session_event( + AcctStatusType::Start, + "nas-framed-ipv6-prefix", + "session-framed-ipv6-prefix", + ); + framed_ipv6_prefix_only.user_name = Some("subscriber".to_string()); + framed_ipv6_prefix_only.framed_ipv6_prefixes = vec![Ipv6Prefix { + address: "2001:db8:200::".parse().unwrap(), + prefix_len: 56, + }]; + framed_ipv6_prefix_only.mikrotik_rate_limits = vec![rate_limit()]; + let framed_ipv6_prefix_key = + nas_session_key("nas-framed-ipv6-prefix", "session-framed-ipv6-prefix"); + store.apply_event_with_mapping(framed_ipv6_prefix_only, ready_mapping()); + let framed_ipv6_prefix_session = store.session(&framed_ipv6_prefix_key).unwrap(); + assert_eq!(framed_ipv6_prefix_session.pending_reasons, Vec::new()); + let framed_ipv6_prefix_device = framed_ipv6_prefix_session + .resolved_shaped_device + .as_ref() + .unwrap(); + assert!(framed_ipv6_prefix_device.ipv4.is_empty()); + assert_eq!( + framed_ipv6_prefix_device.ipv6, + vec![("2001:db8:200::".parse().unwrap(), 56)] + ); + + let mut invalid_ipv6_only = minimal_session_event( + AcctStatusType::Start, + "nas-invalid-ipv6", + "session-invalid-ipv6", + ); + invalid_ipv6_only.user_name = Some("subscriber".to_string()); + invalid_ipv6_only.framed_ipv6_address = Some(Ipv6Addr::UNSPECIFIED); + invalid_ipv6_only.framed_ipv6_prefixes = vec![Ipv6Prefix { + address: "2001:db8:bad::".parse().unwrap(), + prefix_len: 0, + }]; + invalid_ipv6_only.delegated_ipv6_prefixes = vec![Ipv6Prefix { + address: "ff00::".parse().unwrap(), + prefix_len: 8, + }]; + invalid_ipv6_only.mikrotik_rate_limits = vec![rate_limit()]; + let invalid_ipv6_key = nas_session_key("nas-invalid-ipv6", "session-invalid-ipv6"); + store.apply_event_with_mapping(invalid_ipv6_only, ready_mapping()); + let invalid_ipv6_session = store.session(&invalid_ipv6_key).unwrap(); + assert_eq!( + invalid_ipv6_session.pending_reasons, + vec![PendingSessionReason::MissingIpAddress] + ); + assert!(invalid_ipv6_session.resolved_shaped_device.is_none()); + + let mut default_route_only = minimal_session_event( + AcctStatusType::Start, + "nas-default-route", + "session-default-route", + ); + default_route_only.user_name = Some("subscriber".to_string()); + default_route_only.framed_routes = vec!["0.0.0.0/0 192.0.2.1 1".to_string()]; + default_route_only.mikrotik_rate_limits = vec![rate_limit()]; + let default_route_key = nas_session_key("nas-default-route", "session-default-route"); + store.apply_event_with_mapping(default_route_only, ready_mapping()); + assert_eq!( + store.session(&default_route_key).unwrap().pending_reasons, + vec![PendingSessionReason::MissingIpAddress] + ); + + let mut default_netmask = minimal_session_event( + AcctStatusType::Start, + "nas-default-netmask", + "session-default-netmask", + ); + default_netmask.user_name = Some("subscriber".to_string()); + default_netmask.framed_ip_address = Some(Ipv4Addr::new(198, 51, 100, 44)); + default_netmask.framed_ip_netmask = Some(Ipv4Addr::UNSPECIFIED); + default_netmask.mikrotik_rate_limits = vec![rate_limit()]; + let default_netmask_key = nas_session_key("nas-default-netmask", "session-default-netmask"); + store.apply_event_with_mapping(default_netmask, ready_mapping()); + assert_eq!( + store.session(&default_netmask_key).unwrap().pending_reasons, + Vec::new() + ); + assert_eq!( + store + .session(&default_netmask_key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap() + .ipv4, + vec![(Ipv4Addr::new(198, 51, 100, 44), 32)] + ); + + let mut pending_promotion_store = AccountingSessionStore::new(); + let mut missing_id = AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_identifier: Some("nas-promotion".to_string()), + user_name: Some("subscriber-late-session-id".to_string()), + ..AccountingEvent::default() + }; + pending_promotion_store.apply_event(missing_id.clone()); + missing_id.status_type = Some(AcctStatusType::InterimUpdate); + missing_id.acct_session_id = Some("promoted-session".to_string()); + missing_id.framed_ip_address = Some(Ipv4Addr::new(198, 51, 100, 42)); + missing_id.mikrotik_rate_limits = vec![rate_limit()]; + let promoted_key = nas_session_key("nas-promotion", "promoted-session"); + pending_promotion_store.apply_event_with_mapping(missing_id, ready_mapping()); + assert_eq!(pending_promotion_store.len(), 1); + assert_eq!( + pending_promotion_store + .session(&promoted_key) + .unwrap() + .pending_reasons, + Vec::new() + ); + + let mut delayed_pending_store = AccountingSessionStore::new(); + let delayed_pending_ip = Ipv4Addr::new(192, 0, 2, 72); + delayed_pending_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_ip_address: Some(delayed_pending_ip), + user_name: Some("delayed-pending-user".to_string()), + ..AccountingEvent::default() + }); + delayed_pending_store.apply_event_with_mapping( + minimal_session_event( + AcctStatusType::Start, + "nas-delayed-pending", + "delayed-pending-session", + ), + ready_mapping(), + ); + assert_eq!(delayed_pending_store.len(), 2); + let mut delayed_rich_event = complete_event( + AcctStatusType::InterimUpdate, + "nas-delayed-pending", + "delayed-pending-session", + Ipv4Addr::new(198, 51, 100, 46), + ); + delayed_rich_event.nas_ip_address = Some(delayed_pending_ip); + delayed_rich_event.user_name = Some("delayed-pending-user".to_string()); + let delayed_key = nas_session_key("nas-delayed-pending", "delayed-pending-session"); + delayed_pending_store.apply_event_with_mapping(delayed_rich_event, ready_mapping()); + assert_eq!(delayed_pending_store.len(), 1); + assert_eq!( + delayed_pending_store + .session(&delayed_key) + .unwrap() + .latest_event + .framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 46)) + ); + + let mut delayed_alternate_store = AccountingSessionStore::new(); + let delayed_alternate_ip = Ipv4Addr::new(192, 0, 2, 73); + delayed_alternate_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_ip_address: Some(delayed_alternate_ip), + user_name: Some("delayed-alternate-user".to_string()), + ..AccountingEvent::default() + }); + let mut sparse_ip_keyed = minimal_session_event( + AcctStatusType::Start, + "ignored-by-test", + "delayed-alternate-session", + ); + sparse_ip_keyed.nas_identifier = None; + sparse_ip_keyed.nas_ip_address = Some(delayed_alternate_ip); + delayed_alternate_store.apply_event_with_mapping(sparse_ip_keyed, ready_mapping()); + assert_eq!(delayed_alternate_store.len(), 2); + let mut full_alternate_bridge = complete_event( + AcctStatusType::InterimUpdate, + "nas-delayed-alternate", + "delayed-alternate-session", + Ipv4Addr::new(198, 51, 100, 47), + ); + full_alternate_bridge.nas_ip_address = Some(delayed_alternate_ip); + full_alternate_bridge.user_name = Some("delayed-alternate-user".to_string()); + let delayed_alternate_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(delayed_alternate_ip), + acct_session_id: "delayed-alternate-session".to_string(), + }; + delayed_alternate_store.apply_event_with_mapping(full_alternate_bridge, ready_mapping()); + assert_eq!(delayed_alternate_store.len(), 1); + assert_eq!( + delayed_alternate_store + .session(&delayed_alternate_key) + .unwrap() + .latest_event + .framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 47)) + ); + + let mut three_way_store = AccountingSessionStore::new(); + let three_way_ip = Ipv4Addr::new(192, 0, 2, 74); + three_way_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_ip_address: Some(three_way_ip), + user_name: Some("three-way-user".to_string()), + ..AccountingEvent::default() + }); + three_way_store.apply_event(minimal_session_event( + AcctStatusType::Start, + "nas-three-way", + "three-way-session", + )); + let mut three_way_ip_keyed = minimal_session_event( + AcctStatusType::InterimUpdate, + "ignored-by-test", + "three-way-session", + ); + three_way_ip_keyed.nas_identifier = None; + three_way_ip_keyed.nas_ip_address = Some(three_way_ip); + three_way_store.apply_event_with_mapping(three_way_ip_keyed, ready_mapping()); + assert_eq!(three_way_store.len(), 3); + let mut three_way_bridge = complete_event( + AcctStatusType::InterimUpdate, + "nas-three-way", + "three-way-session", + Ipv4Addr::new(198, 51, 100, 48), + ); + three_way_bridge.nas_ip_address = Some(three_way_ip); + three_way_bridge.user_name = Some("three-way-user".to_string()); + let three_way_key = nas_session_key("nas-three-way", "three-way-session"); + three_way_store.apply_event_with_mapping(three_way_bridge, ready_mapping()); + + assert_eq!(three_way_store.len(), 1); + assert_eq!( + three_way_store + .session(&three_way_key) + .unwrap() + .latest_event + .framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 48)) + ); + + let mut ip_pending_store = AccountingSessionStore::new(); + let pending_nas_ip = Ipv4Addr::new(192, 0, 2, 70); + ip_pending_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_ip_address: Some(pending_nas_ip), + user_name: Some("ip-only-pending-user".to_string()), + ..AccountingEvent::default() + }); + let mut later_with_identifier = complete_event( + AcctStatusType::InterimUpdate, + "nas-from-later-identifier", + "ip-promoted-session", + Ipv4Addr::new(198, 51, 100, 43), + ); + later_with_identifier.nas_ip_address = Some(pending_nas_ip); + later_with_identifier.user_name = Some("ip-only-pending-user".to_string()); + let ip_promoted_key = nas_session_key("nas-from-later-identifier", "ip-promoted-session"); + ip_pending_store.apply_event_with_mapping(later_with_identifier, ready_mapping()); + assert_eq!(ip_pending_store.len(), 1); + assert!(ip_pending_store.session(&ip_promoted_key).is_some()); + + let mut pending_alias_store = AccountingSessionStore::new(); + let pending_alias_ip = Ipv4Addr::new(192, 0, 2, 71); + let pending_alias_ipv6: Ipv6Addr = "2001:db8::71".parse().unwrap(); + pending_alias_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_ip_address: Some(pending_alias_ip), + nas_ipv6_address: Some(pending_alias_ipv6), + user_name: Some("pending-alias-user".to_string()), + ..AccountingEvent::default() + }); + let mut pending_alias_promotion = complete_event( + AcctStatusType::InterimUpdate, + "nas-pending-alias", + "pending-alias-session", + Ipv4Addr::new(198, 51, 100, 44), + ); + pending_alias_promotion.nas_ip_address = Some(pending_alias_ip); + pending_alias_promotion.user_name = Some("pending-alias-user".to_string()); + let pending_alias_key = nas_session_key("nas-pending-alias", "pending-alias-session"); + pending_alias_store.apply_event_with_mapping(pending_alias_promotion, ready_mapping()); + pending_alias_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::AccountingOff), + nas_ipv6_address: Some(pending_alias_ipv6), + ..AccountingEvent::default() + }); + assert_eq!( + pending_alias_store + .session(&pending_alias_key) + .unwrap() + .state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + + let mut conflicting_unknown_nas_store = AccountingSessionStore::new(); + conflicting_unknown_nas_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + acct_session_id: Some("unknown-nas-session".to_string()), + user_name: Some("original-user".to_string()), + ..AccountingEvent::default() + }); + conflicting_unknown_nas_store.apply_event_with_mapping( + complete_event( + AcctStatusType::InterimUpdate, + "nas-conflicting-user", + "unknown-nas-session", + Ipv4Addr::new(198, 51, 100, 45), + ), + ready_mapping(), + ); + assert_eq!(conflicting_unknown_nas_store.len(), 2); + assert!( + conflicting_unknown_nas_store + .session(&nas_session_key( + "nas-conflicting-user", + "unknown-nas-session" + )) + .is_some() + ); + + let mut ambiguous_pending_store = AccountingSessionStore::new(); + for calling_station_id in ["00:00:00:00:00:01", "00:00:00:00:00:02"] { + ambiguous_pending_store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_identifier: Some("nas-ambiguous-pending".to_string()), + user_name: Some("shared-pending-user".to_string()), + calling_station_id: Some(calling_station_id.to_string()), + ..AccountingEvent::default() + }); + } + let mut ambiguous_resolution = minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-ambiguous-pending", + "resolved-session", + ); + ambiguous_resolution.user_name = Some("shared-pending-user".to_string()); + ambiguous_pending_store.apply_event(ambiguous_resolution); + + assert_eq!(ambiguous_pending_store.len(), 3); + assert!( + ambiguous_pending_store + .session(&nas_session_key( + "nas-ambiguous-pending", + "resolved-session" + )) + .is_some() + ); +} + +#[test] +fn rate_resolution_prefers_packet_then_shaped_devices_then_fallback() { + let fallback_rate = SessionRateProfile::new(5.0, 3.0, 25.0, 10.0).unwrap(); + let shaped_device_rate = SessionRateProfile::new(7.0, 4.0, 50.0, 20.0).unwrap(); + let shaped_device_and_fallback = SessionRateSources { + shaped_device_profile: Some(shaped_device_rate), + fallback_profile: Some(fallback_rate), + }; + let mut no_rate_event = minimal_session_event( + AcctStatusType::Start, + "nas-fallback-rate", + "session-fallback-rate", + ); + no_rate_event.user_name = Some("subscriber".to_string()); + no_rate_event.framed_ip_address = Some(Ipv4Addr::new(198, 51, 100, 90)); + let key = nas_session_key("nas-fallback-rate", "session-fallback-rate"); + + let mut no_rate_store = AccountingSessionStore::new(); + no_rate_store.apply_event_with_mapping_and_rate_sources( + no_rate_event.clone(), + ready_mapping(), + SessionRateSources::default(), + ); + let no_rate_session = no_rate_store.session(&key).unwrap(); + assert_eq!(no_rate_session.resolved_rate, None); + assert_eq!( + no_rate_session.pending_reasons, + vec![PendingSessionReason::MissingRate] + ); + + let mut fallback_store = AccountingSessionStore::new(); + fallback_store.apply_event_with_mapping_and_rate_sources( + no_rate_event.clone(), + ready_mapping(), + SessionRateSources { + shaped_device_profile: None, + fallback_profile: Some(fallback_rate), + }, + ); + let fallback_session = fallback_store.session(&key).unwrap(); + assert_eq!(fallback_session.pending_reasons, Vec::new()); + assert_eq!( + fallback_session.resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::Fallback, + profile: fallback_rate, + }) + ); + + let mut shaped_device_rate_store = AccountingSessionStore::new(); + shaped_device_rate_store.apply_event_with_mapping_and_rate_sources( + no_rate_event, + ready_mapping(), + shaped_device_and_fallback, + ); + let shaped_device_session = shaped_device_rate_store.session(&key).unwrap(); + assert_eq!(shaped_device_session.pending_reasons, Vec::new()); + assert_eq!( + shaped_device_session.resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::ShapedDevice, + profile: shaped_device_rate, + }) + ); + + shaped_device_rate_store.apply_event_with_mapping_and_rate_sources( + complete_event( + AcctStatusType::InterimUpdate, + "nas-fallback-rate", + "session-fallback-rate", + Ipv4Addr::new(198, 51, 100, 94), + ), + ready_mapping(), + shaped_device_and_fallback, + ); + assert_eq!( + shaped_device_rate_store + .session(&key) + .unwrap() + .resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::Packet, + profile: SessionRateProfile::new(25.0, 10.0, 25.0, 10.0).unwrap(), + }) + ); + + let mut zero_packet_store = AccountingSessionStore::new(); + let zero_packet_key = nas_session_key("nas-zero-rate", "session-zero-rate"); + let mut zero_packet_rate = complete_event( + AcctStatusType::Start, + "nas-zero-rate", + "session-zero-rate", + Ipv4Addr::new(198, 51, 100, 92), + ); + zero_packet_rate.mikrotik_rate_limits = vec![zero_rate_limit()]; + zero_packet_store.apply_event_with_mapping_and_rate_sources( + zero_packet_rate, + ready_mapping(), + shaped_device_and_fallback, + ); + assert_eq!( + zero_packet_store + .session(&zero_packet_key) + .unwrap() + .resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::ShapedDevice, + profile: shaped_device_rate, + }) + ); + + let mut zero_packet_fallback_store = AccountingSessionStore::new(); + let zero_packet_fallback_key = + nas_session_key("nas-zero-fallback-rate", "session-zero-fallback-rate"); + let mut zero_packet_fallback = complete_event( + AcctStatusType::Start, + "nas-zero-fallback-rate", + "session-zero-fallback-rate", + Ipv4Addr::new(198, 51, 100, 93), + ); + zero_packet_fallback.mikrotik_rate_limits = vec![zero_rate_limit()]; + zero_packet_fallback_store.apply_event_with_mapping_and_rate_sources( + zero_packet_fallback, + ready_mapping(), + SessionRateSources { + shaped_device_profile: None, + fallback_profile: Some(fallback_rate), + }, + ); + assert_eq!( + zero_packet_fallback_store + .session(&zero_packet_fallback_key) + .unwrap() + .resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::Fallback, + profile: fallback_rate, + }) + ); +} + +#[test] +fn unique_mac_match_supplies_dynamic_circuit_metadata_with_radius_ips() { + let matched_device = shaped_device("circuit-mac", "device-mac", "aa-bb-cc-dd-ee-ff"); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut event = minimal_session_event(AcctStatusType::Start, "nas-mac", "session-mac"); + event.calling_station_id = Some("AABB.CCDD.EEFF".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 10)); + event.framed_ip_netmask = Some(Ipv4Addr::new(255, 255, 255, 0)); + event.framed_ipv6_address = Some("2001:db8::10".parse().unwrap()); + event.mikrotik_rate_limits = vec![rate_limit()]; + event.delegated_ipv6_prefixes = vec![Ipv6Prefix { + address: "2001:db8:100::".parse().unwrap(), + prefix_len: 56, + }]; + let resolution = + DynamicCircuitResolution::from_shaped_devices_mac_match(matcher.match_event(&event), None); + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-mac", "session-mac"); + + store.apply_event_with_dynamic_circuit_resolution(event, resolution); + + let session = store.session(&key).unwrap(); + assert_eq!(session.pending_reasons, Vec::new()); + assert_eq!( + session.resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::Packet, + profile: SessionRateProfile::new(25.0, 10.0, 25.0, 10.0).unwrap(), + }) + ); + let resolved_device = session.resolved_shaped_device.as_ref().unwrap(); + assert_eq!(resolved_device.circuit_id, matched_device.circuit_id); + assert_eq!(resolved_device.circuit_name, matched_device.circuit_name); + assert_eq!(resolved_device.device_id, matched_device.device_id); + assert_eq!(resolved_device.device_name, matched_device.device_name); + assert_eq!(resolved_device.parent_node, matched_device.parent_node); + assert_eq!( + resolved_device.parent_node_id, + matched_device.parent_node_id + ); + assert_eq!( + resolved_device.anchor_node_id, + matched_device.anchor_node_id + ); + assert_resolved_hashes_refreshed(resolved_device); + assert_eq!(resolved_device.sqm_override, matched_device.sqm_override); + assert_eq!(resolved_device.download_min_mbps, 25.0); + assert_eq!(resolved_device.upload_min_mbps, 10.0); + assert_eq!(resolved_device.download_max_mbps, 25.0); + assert_eq!(resolved_device.upload_max_mbps, 10.0); + assert_eq!( + resolved_device.ipv4, + vec![(Ipv4Addr::new(203, 0, 113, 10), 24)] + ); + assert_eq!( + resolved_device.ipv6, + vec![ + ("2001:db8::10".parse().unwrap(), 128), + ("2001:db8:100::".parse().unwrap(), 56), + ] + ); + assert!( + !resolved_device + .ipv4 + .contains(&(Ipv4Addr::new(198, 51, 100, 200), 32)) + ); + + let mut interim = complete_event( + AcctStatusType::InterimUpdate, + "nas-mac", + "session-mac", + Ipv4Addr::new(203, 0, 113, 16), + ); + interim.calling_station_id = Some("aa-bb-cc-dd-ee-ff".to_string()); + interim.mikrotik_rate_limits = vec![MikrotikRateLimit { + original: "12M/30M".to_string(), + nas_rx_bps: 12_000_000, + nas_tx_bps: 30_000_000, + upload_bps: 12_000_000, + download_bps: 30_000_000, + }]; + let resolution = DynamicCircuitResolution::from_shaped_devices_mac_match( + matcher.match_event(&interim), + None, + ); + + store.apply_event_with_dynamic_circuit_resolution(interim, resolution); + + let refreshed_device = store + .session(&key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap(); + assert_eq!(refreshed_device.circuit_id, matched_device.circuit_id); + assert_eq!(refreshed_device.device_id, matched_device.device_id); + assert_eq!(refreshed_device.parent_node, matched_device.parent_node); + assert_eq!( + refreshed_device.ipv4, + vec![(Ipv4Addr::new(203, 0, 113, 16), 24)] + ); + assert_eq!(refreshed_device.download_min_mbps, 30.0); + assert_eq!(refreshed_device.upload_min_mbps, 12.0); + assert_eq!(refreshed_device.download_max_mbps, 30.0); + assert_eq!(refreshed_device.upload_max_mbps, 12.0); +} + +#[test] +fn sparse_mac_start_promotes_when_later_update_adds_nas_identity() { + let matched_device = shaped_device( + "circuit-delayed-nas", + "device-delayed-nas", + "aa-bb-cc-dd-ee-ff", + ); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut store = AccountingSessionStore::new(); + let mut start = minimal_session_event( + AcctStatusType::Start, + "ignored-until-later", + "session-delayed-nas", + ); + start.nas_identifier = None; + start.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + start.nas_port_id = Some("pppoe-port-1".to_string()); + + store.apply_event_with_shaped_devices_mac_matcher(start, &matcher, None); + assert_eq!(store.len(), 1); + assert_eq!( + store + .sessions() + .next() + .unwrap() + .1 + .pending_reasons + .as_slice(), + &[ + PendingSessionReason::MissingNasIdentity, + PendingSessionReason::MissingIpAddress, + ] + ); + + let mut interim = complete_event( + AcctStatusType::InterimUpdate, + "nas-delayed", + "session-delayed-nas", + Ipv4Addr::new(203, 0, 113, 17), + ); + interim.calling_station_id = None; + interim.nas_port_id = Some("pppoe-port-1".to_string()); + store.apply_event_with_shaped_devices_mac_matcher(interim, &matcher, None); + + let key = nas_session_key("nas-delayed", "session-delayed-nas"); + let session = store.session(&key).unwrap(); + assert_eq!(store.len(), 1); + assert_eq!( + session.latest_event.calling_station_id.as_deref(), + Some("aa:bb:cc:dd:ee:ff") + ); + assert_eq!(session.pending_reasons, Vec::new()); + let resolved_device = session.resolved_shaped_device.as_ref().unwrap(); + assert_eq!(resolved_device.circuit_id, matched_device.circuit_id); + assert_eq!(resolved_device.device_id, matched_device.device_id); + assert_eq!( + resolved_device.ipv4, + vec![(Ipv4Addr::new(203, 0, 113, 17), 32)] + ); +} + +#[test] +fn session_id_without_nas_context_does_not_promote_unrelated_pending_session() { + let matched_device = shaped_device( + "circuit-unrelated-pending", + "device-unrelated-pending", + "aa-bb-cc-dd-ee-ff", + ); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut store = AccountingSessionStore::new(); + let mut pending = minimal_session_event( + AcctStatusType::Start, + "ignored-without-nas", + "shared-session-id", + ); + pending.nas_identifier = None; + pending.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + pending.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 88)); + pending.mikrotik_rate_limits = vec![rate_limit()]; + + let pending_update = store.apply_event_with_shaped_devices_mac_matcher(pending, &matcher, None); + let AccountingSessionUpdate::SessionUpdated { + key: pending_key, .. + } = pending_update + else { + panic!("expected pending session update, got {pending_update:?}"); + }; + + let later = minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-later", + "shared-session-id", + ); + let later = AccountingEvent { + user_name: Some("different-subscriber".to_string()), + ..later + }; + let later_key = nas_session_key("nas-later", "shared-session-id"); + store.apply_event_with_mapping(later, ready_mapping()); + + assert!(store.session(&pending_key).is_some()); + let later_session = store.session(&later_key).unwrap(); + assert_eq!(store.len(), 2); + assert_eq!( + later_session.pending_reasons, + vec![ + PendingSessionReason::MissingIpAddress, + PendingSessionReason::MissingRate, + ] + ); + assert!(later_session.resolved_shaped_device.is_none()); +} + +#[test] +fn unique_mac_match_uses_shaped_devices_rate_without_packet_rate() { + let matched_device = shaped_device( + "circuit-shaped-rate", + "device-shaped-rate", + "aa-bb-cc-dd-ee-ff", + ); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut event = minimal_session_event( + AcctStatusType::Start, + "nas-shaped-rate", + "session-shaped-rate", + ); + event.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 11)); + event.mikrotik_rate_limits.clear(); + let resolution = + DynamicCircuitResolution::from_shaped_devices_mac_match(matcher.match_event(&event), None); + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-shaped-rate", "session-shaped-rate"); + + store.apply_event_with_dynamic_circuit_resolution(event, resolution); + + let session = store.session(&key).unwrap(); + assert_eq!(session.pending_reasons, Vec::new()); + assert_eq!( + session.resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::ShapedDevice, + profile: SessionRateProfile::new(5.0, 2.0, 50.0, 20.0).unwrap(), + }) + ); + let resolved_device = session.resolved_shaped_device.as_ref().unwrap(); + assert_eq!(resolved_device.circuit_id, matched_device.circuit_id); + assert_eq!(resolved_device.device_id, matched_device.device_id); + assert_eq!( + resolved_device.ipv4, + vec![(Ipv4Addr::new(203, 0, 113, 11), 32)] + ); + assert_eq!(resolved_device.download_min_mbps, 5.0); + assert_eq!(resolved_device.upload_min_mbps, 2.0); + assert_eq!(resolved_device.download_max_mbps, 50.0); + assert_eq!(resolved_device.upload_max_mbps, 20.0); +} + +#[test] +fn active_mac_update_clears_resolved_shaped_device_when_match_disappears() { + let matched_device = shaped_device( + "circuit-cleared-match", + "device-cleared-match", + "aa-bb-cc-dd-ee-ff", + ); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-cleared-match", "session-cleared-match"); + let mut start = complete_event( + AcctStatusType::Start, + "nas-cleared-match", + "session-cleared-match", + Ipv4Addr::new(203, 0, 113, 60), + ); + start.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + + store.apply_event_with_shaped_devices_mac_matcher(start, &matcher, None); + + assert!( + store + .session(&key) + .unwrap() + .resolved_shaped_device + .is_some() + ); + + let mut interim = complete_event( + AcctStatusType::InterimUpdate, + "nas-cleared-match", + "session-cleared-match", + Ipv4Addr::new(203, 0, 113, 61), + ); + interim.calling_station_id = Some("11:22:33:44:55:66".to_string()); + store.apply_event_with_shaped_devices_mac_matcher(interim, &matcher, None); + + let session = store.session(&key).unwrap(); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::NoMacMatch] + ); + assert_eq!( + session.latest_event.calling_station_id.as_deref(), + Some("11:22:33:44:55:66") + ); + assert_eq!( + session.latest_event.framed_ip_address, + Some(Ipv4Addr::new(203, 0, 113, 61)) + ); + assert!(session.resolved_shaped_device.is_none()); +} + +#[test] +fn invalid_shaped_devices_rate_is_not_hidden_by_fallback_rate() { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + for (label, matched_device) in [ + ("zero download minimum", { + let mut device = + shaped_device("circuit-bad-rate", "device-bad-rate", "aa-bb-cc-dd-ee-ff"); + device.download_min_mbps = 0.0; + device + }), + ("non-finite upload maximum", { + let mut device = + shaped_device("circuit-bad-rate", "device-bad-rate", "aa-bb-cc-dd-ee-ff"); + device.upload_max_mbps = f32::INFINITY; + device + }), + ("download minimum above maximum", { + let mut device = + shaped_device("circuit-bad-rate", "device-bad-rate", "aa-bb-cc-dd-ee-ff"); + device.download_min_mbps = device.download_max_mbps + 1.0; + device + }), + ("upload minimum above maximum", { + let mut device = + shaped_device("circuit-bad-rate", "device-bad-rate", "aa-bb-cc-dd-ee-ff"); + device.upload_min_mbps = device.upload_max_mbps + 1.0; + device + }), + ] { + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut event = + minimal_session_event(AcctStatusType::Start, "nas-bad-rate", "session-bad-rate"); + event.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 12)); + let resolution = DynamicCircuitResolution::from_shaped_devices_mac_match( + matcher.match_event(&event), + Some(fallback_rate), + ); + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-bad-rate", "session-bad-rate"); + + store.apply_event_with_dynamic_circuit_resolution(event, resolution); + + let session = store.session(&key).unwrap(); + assert_eq!(session.resolved_rate, None, "{label}"); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::MissingRate], + "{label}" + ); + assert!(session.resolved_shaped_device.is_none(), "{label}"); + } +} + +#[test] +fn matched_shaped_device_without_device_id_stays_pending() { + let mut matched_device = shaped_device( + "circuit-missing-device", + "device-missing-device", + "aa-bb-cc-dd-ee-ff", + ); + matched_device.device_id.clear(); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut event = minimal_session_event( + AcctStatusType::Start, + "nas-missing-device", + "session-missing-device", + ); + event.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 13)); + event.mikrotik_rate_limits = vec![rate_limit()]; + let resolution = + DynamicCircuitResolution::from_shaped_devices_mac_match(matcher.match_event(&event), None); + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-missing-device", "session-missing-device"); + + store.apply_event_with_dynamic_circuit_resolution(event, resolution); + + let session = store.session(&key).unwrap(); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::MissingDeviceIdentity] + ); + assert!(session.resolved_shaped_device.is_none()); +} + +#[test] +fn matched_shaped_device_without_circuit_id_stays_pending() { + let mut matched_device = shaped_device( + "circuit-missing-circuit", + "device-missing-circuit", + "aa-bb-cc-dd-ee-ff", + ); + matched_device.circuit_id.clear(); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut event = minimal_session_event( + AcctStatusType::Start, + "nas-missing-circuit", + "session-missing-circuit", + ); + event.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 14)); + event.mikrotik_rate_limits = vec![rate_limit()]; + let resolution = + DynamicCircuitResolution::from_shaped_devices_mac_match(matcher.match_event(&event), None); + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-missing-circuit", "session-missing-circuit"); + + store.apply_event_with_dynamic_circuit_resolution(event, resolution); + + let session = store.session(&key).unwrap(); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::MissingCircuitIdentity] + ); + assert!(session.resolved_shaped_device.is_none()); +} + +#[test] +fn matched_shaped_device_without_parent_node_stays_pending() { + let mut matched_device = shaped_device( + "circuit-missing-parent", + "device-missing-parent", + "aa-bb-cc-dd-ee-ff", + ); + matched_device.parent_node.clear(); + let matcher = ShapedDevicesMacMatcher::from_devices(std::slice::from_ref(&matched_device)); + let mut event = minimal_session_event( + AcctStatusType::Start, + "nas-missing-parent", + "session-missing-parent", + ); + event.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 15)); + event.mikrotik_rate_limits = vec![rate_limit()]; + let resolution = + DynamicCircuitResolution::from_shaped_devices_mac_match(matcher.match_event(&event), None); + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-missing-parent", "session-missing-parent"); + + store.apply_event_with_dynamic_circuit_resolution(event, resolution); + + let session = store.session(&key).unwrap(); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::MissingParent] + ); + assert!(session.resolved_shaped_device.is_none()); +} + +#[test] +fn missing_or_ambiguous_mac_match_stays_pending_without_resolved_shaped_device() { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + let matcher = ShapedDevicesMacMatcher::from_devices(&[ + shaped_device("circuit-a", "device-a", "00:11:22:33:44:55"), + shaped_device("circuit-b", "device-b", "0011.2233.4455"), + ]); + let mut store = AccountingSessionStore::new(); + + let mut no_match_event = complete_event( + AcctStatusType::Start, + "nas-no-mac-match", + "session-no-mac-match", + Ipv4Addr::new(198, 51, 100, 81), + ); + no_match_event.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + no_match_event.mikrotik_rate_limits.clear(); + let no_match_resolution = DynamicCircuitResolution::from_shaped_devices_mac_match( + matcher.match_event(&no_match_event), + Some(fallback_rate), + ); + store.apply_event_with_dynamic_circuit_resolution(no_match_event, no_match_resolution); + let no_match_session = store + .session(&nas_session_key("nas-no-mac-match", "session-no-mac-match")) + .unwrap(); + assert_eq!( + no_match_session.resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::Fallback, + profile: fallback_rate, + }) + ); + assert_eq!( + no_match_session.pending_reasons, + vec![PendingSessionReason::NoMacMatch] + ); + assert!(no_match_session.resolved_shaped_device.is_none()); + + let mut ambiguous_event = complete_event( + AcctStatusType::Start, + "nas-ambiguous-mac", + "session-ambiguous-mac", + Ipv4Addr::new(198, 51, 100, 82), + ); + ambiguous_event.calling_station_id = Some("00-11-22-33-44-55".to_string()); + ambiguous_event.mikrotik_rate_limits.clear(); + let ambiguous_resolution = DynamicCircuitResolution::from_shaped_devices_mac_match( + matcher.match_event(&ambiguous_event), + Some(fallback_rate), + ); + store.apply_event_with_dynamic_circuit_resolution(ambiguous_event, ambiguous_resolution); + let ambiguous_session = store + .session(&nas_session_key( + "nas-ambiguous-mac", + "session-ambiguous-mac", + )) + .unwrap(); + assert_eq!( + ambiguous_session.resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::Fallback, + profile: fallback_rate, + }) + ); + assert_eq!( + ambiguous_session.pending_reasons, + vec![PendingSessionReason::AmbiguousMacMatch] + ); + assert!(ambiguous_session.resolved_shaped_device.is_none()); +} + +#[test] +fn unmatched_identity_uses_the_configured_default_parent_and_rate_profile() { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + let resolution = DynamicCircuitResolution::from_shaped_devices_match_with_fallback_parent( + ShapedDevicesMacMatch::NoMatch, + Some(fallback_rate), + Some(parent_attachment()), + ); + + assert_eq!( + resolution.mapping, + DynamicCircuitMapping::ReadyWithParent(parent_attachment()) + ); + assert_eq!( + resolution.rate_sources.fallback_profile, + Some(fallback_rate) + ); + assert!(resolution.matched_shaped_device.is_none()); +} + +#[test] +fn fallback_identity_generation_builds_dynamic_shaped_device() { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + let mut event = minimal_session_event( + AcctStatusType::Start, + "nas-default-identity", + "session-default-identity", + ); + event.user_name = Some("subscriber@example.net".to_string()); + event.calling_station_id = Some("AA-BB-CC-DD-EE-FF".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 44)); + let key = nas_session_key("nas-default-identity", "session-default-identity"); + let circuit_id = "radius:nas-id:6e61732d64656661756c742d6964656e74697479:username:73756273637269626572406578616d706c652e6e6574"; + let resolution = DynamicCircuitResolution { + mapping: ready_mapping(), + rate_sources: SessionRateSources { + shaped_device_profile: None, + fallback_profile: Some(fallback_rate), + }, + matched_shaped_device: None, + }; + let mut store = AccountingSessionStore::new(); + + store.apply_event_with_dynamic_circuit_resolution(event, resolution); + + let session = store.session(&key).unwrap(); + assert_eq!(session.pending_reasons, Vec::new()); + assert_eq!( + session.resolved_rate, + Some(ResolvedSessionRate { + source: SessionRateSource::Fallback, + profile: fallback_rate, + }) + ); + let resolved_device = session.resolved_shaped_device.as_ref().unwrap(); + assert_eq!(resolved_device.circuit_id, circuit_id); + assert_eq!(resolved_device.device_id, circuit_id); + assert_eq!(resolved_device.circuit_name, "subscriber@example.net"); + assert_eq!(resolved_device.device_name, "AA-BB-CC-DD-EE-FF"); + assert_eq!(resolved_device.mac, "AA-BB-CC-DD-EE-FF"); + assert_eq!(resolved_device.parent_node, "Parent Node"); + assert_resolved_hashes_refreshed(resolved_device); + assert_eq!( + resolved_device.parent_node_id.as_deref(), + Some("parent-node-id") + ); + assert_eq!( + resolved_device.anchor_node_id.as_deref(), + Some("anchor-node-id") + ); + assert_eq!( + resolved_device.ipv4, + vec![(Ipv4Addr::new(203, 0, 113, 44), 32)] + ); + assert_eq!(resolved_device.download_min_mbps, 4.0); + assert_eq!(resolved_device.upload_min_mbps, 2.0); + assert_eq!(resolved_device.download_max_mbps, 40.0); + assert_eq!(resolved_device.upload_max_mbps, 12.0); +} + +#[test] +fn fallback_identity_generation_uses_display_name_fallbacks_and_ipv6_prefixes() { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + let mut store = AccountingSessionStore::new(); + + let mut calling_event = minimal_session_event( + AcctStatusType::Start, + "nas-fallback-calling", + "session-fallback-calling", + ); + calling_event.calling_station_id = Some("AA-BB-CC-DD-EE-FF".to_string()); + calling_event.user_name = None; + calling_event.framed_ipv6_prefixes = vec![Ipv6Prefix { + address: "2001:db8:300::".parse().unwrap(), + prefix_len: 56, + }]; + let calling_key = nas_session_key("nas-fallback-calling", "session-fallback-calling"); + store.apply_event_with_dynamic_circuit_resolution( + calling_event, + fallback_resolution(fallback_rate), + ); + + let calling_device = store + .session(&calling_key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap(); + assert_eq!(calling_device.circuit_name, "AA-BB-CC-DD-EE-FF"); + assert_eq!(calling_device.device_name, "AA-BB-CC-DD-EE-FF"); + assert_eq!(calling_device.mac, "AA-BB-CC-DD-EE-FF"); + assert!(calling_device.ipv4.is_empty()); + assert_eq!( + calling_device.ipv6, + vec![("2001:db8:300::".parse().unwrap(), 56)] + ); + + let mut acct_event = minimal_session_event( + AcctStatusType::Start, + "nas-fallback-acct", + "session-fallback-acct", + ); + acct_event.user_name = None; + acct_event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 46)); + let acct_key = nas_session_key("nas-fallback-acct", "session-fallback-acct"); + store.apply_event_with_dynamic_circuit_resolution( + acct_event, + fallback_resolution(fallback_rate), + ); + + let acct_session = store.session(&acct_key).unwrap(); + assert_eq!( + acct_session.pending_reasons, + vec![ + PendingSessionReason::MissingCircuitIdentity, + PendingSessionReason::MissingDeviceIdentity, + ] + ); + assert!(acct_session.resolved_shaped_device.is_none()); + + let empty_event = AccountingEvent::default(); + assert_eq!( + default_circuit_name(&empty_event, "generated-circuit"), + "generated-circuit" + ); + assert_eq!( + default_device_name(&empty_event, "generated-device"), + "generated-device" + ); +} + +#[test] +fn fallback_identity_generation_uses_nas_ip_key_variants() { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + let mut store = AccountingSessionStore::new(); + + let ipv4_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(Ipv4Addr::new(192, 0, 2, 9)), + acct_session_id: "s-ip4".to_string(), + }; + store.apply_event_with_dynamic_circuit_resolution( + AccountingEvent { + status_type: Some(AcctStatusType::Start), + acct_session_id: Some("s-ip4".to_string()), + nas_ip_address: Some(Ipv4Addr::new(192, 0, 2, 9)), + user_name: Some("ipv4-subscriber".to_string()), + framed_ip_address: Some(Ipv4Addr::new(203, 0, 113, 47)), + ..AccountingEvent::default() + }, + fallback_resolution(fallback_rate), + ); + let ipv4_device = store + .session(&ipv4_key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap(); + assert_eq!( + ipv4_device.circuit_id, + "radius:nas-ipv4:c0000209:username:697076342d73756273637269626572" + ); + assert_eq!(ipv4_device.device_id, ipv4_device.circuit_id); + + let ipv6_nas = "2001:db8::9".parse().unwrap(); + let ipv6_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv6(ipv6_nas), + acct_session_id: "s-ip6".to_string(), + }; + store.apply_event_with_dynamic_circuit_resolution( + AccountingEvent { + status_type: Some(AcctStatusType::Start), + acct_session_id: Some("s-ip6".to_string()), + nas_ipv6_address: Some(ipv6_nas), + user_name: Some("ipv6-subscriber".to_string()), + framed_ip_address: Some(Ipv4Addr::new(203, 0, 113, 48)), + ..AccountingEvent::default() + }, + fallback_resolution(fallback_rate), + ); + let ipv6_device = store + .session(&ipv6_key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap(); + assert_eq!( + ipv6_device.circuit_id, + "radius:nas-ipv6:20010db8000000000000000000000009:username:697076362d73756273637269626572" + ); + assert_eq!(ipv6_device.device_id, ipv6_device.circuit_id); +} + +#[test] +fn dynamic_circuit_resolution_updates_when_pending_session_becomes_shapeable() { + let mut store = AccountingSessionStore::new(); + let key = nas_session_key("nas-late-command", "session-late-command"); + let circuit_id = subscriber_circuit_id("nas-late-command"); + let mut start_without_ip = minimal_session_event( + AcctStatusType::Start, + "nas-late-command", + "session-late-command", + ); + start_without_ip.user_name = Some("subscriber".to_string()); + start_without_ip.mikrotik_rate_limits = vec![rate_limit()]; + + store.apply_event_with_mapping(start_without_ip, ready_mapping()); + let pending_session = store.session(&key).unwrap(); + assert_eq!( + pending_session.pending_reasons, + vec![PendingSessionReason::MissingIpAddress] + ); + assert!(pending_session.resolved_shaped_device.is_none()); + + store.apply_event_with_mapping( + complete_event( + AcctStatusType::InterimUpdate, + "nas-late-command", + "session-late-command", + Ipv4Addr::new(198, 51, 100, 73), + ), + ready_mapping(), + ); + + let resolved_device = store + .session(&key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap(); + assert_eq!(resolved_device.circuit_id, circuit_id); + assert_eq!( + resolved_device.ipv4, + vec![(Ipv4Addr::new(198, 51, 100, 73), 32)] + ); +} + +#[test] +fn command_sink_receives_deferred_upsert_and_removal_intents() { + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + let key = nas_session_key("nas-command", "session-command"); + let mut start_without_ip = + minimal_session_event(AcctStatusType::Start, "nas-command", "session-command"); + start_without_ip.user_name = Some("subscriber".to_string()); + start_without_ip.mikrotik_rate_limits = vec![rate_limit()]; + + store.apply_event_with_mapping_and_commands(start_without_ip, ready_mapping(), &mut sink); + assert!(sink.intents.is_empty()); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::InterimUpdate, + "nas-command", + "session-command", + Ipv4Addr::new(198, 51, 100, 74), + ), + ready_mapping(), + &mut sink, + ); + + assert_eq!(sink.intents.len(), 1); + let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { + panic!("expected create intent, got {:?}", sink.intents[0]); + }; + let circuit_id = subscriber_circuit_id("nas-command"); + assert_eq!(create.circuit_id, circuit_id); + assert_eq!(create.session_key, key); + assert_eq!(create.shaped_device.circuit_id, circuit_id); + assert_eq!( + create.shaped_device.ipv4, + vec![(Ipv4Addr::new(198, 51, 100, 74), 32)] + ); + assert_eq!(create.shaped_device.parent_node, "Parent Node"); + + let mut missing_ip_update = minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-command", + "session-command", + ); + missing_ip_update.calling_station_id = Some("AA-BB-CC-DD-EE-FF".to_string()); + store.apply_event_with_dynamic_circuit_resolution_and_commands( + missing_ip_update, + DynamicCircuitResolution { + mapping: DynamicCircuitMapping::NoMacMatch, + rate_sources: SessionRateSources::default(), + matched_shaped_device: None, + }, + &mut sink, + ); + + assert_eq!(sink.intents.len(), 2); + let DynamicCircuitIntent::RemoveDynamicCircuit(removal) = &sink.intents[1] else { + panic!("expected removal intent, got {:?}", sink.intents[1]); + }; + assert_eq!(removal.circuit_id, circuit_id); + assert_eq!( + removal.reason, + DynamicCircuitRemovalReason::NoLongerShapeable + ); + assert!( + store + .session(&key) + .unwrap() + .active_dynamic_circuit_ids + .is_empty() + ); +} + +#[test] +fn command_sink_preserves_deferred_lifecycle_intent_boundary() { + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + let key = nas_session_key("nas-command-lifecycle", "session-command-lifecycle"); + let circuit_id = subscriber_circuit_id("nas-command-lifecycle"); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-command-lifecycle", + "session-command-lifecycle", + Ipv4Addr::new(198, 51, 100, 75), + ), + ready_mapping(), + &mut sink, + ); + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::InterimUpdate, + "nas-command-lifecycle", + "session-command-lifecycle", + Ipv4Addr::new(198, 51, 100, 76), + ), + ready_mapping(), + &mut sink, + ); + store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::Stop, + "nas-command-lifecycle", + "session-command-lifecycle", + ), + ready_mapping(), + &mut sink, + ); + + assert_eq!(sink.intents.len(), 3); + let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { + panic!("expected create intent, got {:?}", sink.intents[0]); + }; + assert_eq!(create.circuit_id, circuit_id); + assert_eq!(create.session_key, key); + + let DynamicCircuitIntent::UpdateDynamicCircuit(update) = &sink.intents[1] else { + panic!("expected update intent, got {:?}", sink.intents[1]); + }; + assert_eq!(update.circuit_id, circuit_id); + assert_eq!(update.session_key, key); + assert_eq!( + update.event.framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 76)) + ); + + let DynamicCircuitIntent::RemoveDynamicCircuit(stop) = &sink.intents[2] else { + panic!("expected stop removal intent, got {:?}", sink.intents[2]); + }; + assert_eq!(stop.circuit_id, circuit_id); + assert_eq!(stop.session_key, key); + assert_eq!(stop.reason, DynamicCircuitRemovalReason::Stop); + + store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::Stop, + "nas-command-lifecycle", + "session-command-lifecycle", + ), + ready_mapping(), + &mut sink, + ); + assert_eq!(sink.intents.len(), 3); +} + +#[test] +fn activation_counters_track_dynamic_intents_and_expiry_separately() { + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + let key = nas_session_key("nas-counter", "session-counter"); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-counter", + "session-counter", + Ipv4Addr::new(198, 51, 100, 83), + ), + ready_mapping(), + &mut sink, + ); + assert_eq!( + store.activation_counters(), + RadiusActivationCounters { + create: 1, + update: 0, + remove: 0, + expiry: 0, + } + ); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::InterimUpdate, + "nas-counter", + "session-counter", + Ipv4Addr::new(198, 51, 100, 84), + ), + ready_mapping(), + &mut sink, + ); + assert_eq!(store.activation_counters().update, 1); + + store.apply_event_with_mapping_and_commands( + minimal_session_event(AcctStatusType::Stop, "nas-counter", "session-counter"), + ready_mapping(), + &mut sink, + ); + assert_eq!(store.activation_counters().remove, 1); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-counter", + "session-counter", + Ipv4Addr::new(198, 51, 100, 85), + ), + ready_mapping(), + &mut sink, + ); + assert!( + store + .expire_session_with_commands(&key, &mut sink) + .is_some() + ); + assert_eq!( + store.activation_counters(), + RadiusActivationCounters { + create: 2, + update: 1, + remove: 2, + expiry: 1, + } + ); +} + +#[test] +fn activation_diagnostics_distinguish_session_outcomes() { + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + let active_key = nas_session_key("nas-diagnostic-active", "session-active"); + let stopped_key = nas_session_key("nas-diagnostic-stopped", "session-stopped"); + let stale_key = nas_session_key("nas-diagnostic-stale", "session-stale"); + let expired_key = nas_session_key("nas-diagnostic-expired", "session-expired"); + + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-diagnostic-active", + "session-active", + Ipv4Addr::new(198, 51, 100, 86), + ), + ready_mapping(), + ); + store.apply_event(AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_identifier: Some("nas-diagnostic-pending".to_string()), + calling_station_id: Some("aa-bb-cc-dd-ee-ff".to_string()), + ..AccountingEvent::default() + }); + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-diagnostic-stopped", + "session-stopped", + Ipv4Addr::new(198, 51, 100, 87), + ), + ready_mapping(), + ); + store.apply_event(minimal_session_event( + AcctStatusType::Stop, + "nas-diagnostic-stopped", + "session-stopped", + )); + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-diagnostic-stale", + "session-stale", + Ipv4Addr::new(198, 51, 100, 88), + ), + ready_mapping(), + ); + store.apply_event(reset_event( + AcctStatusType::AccountingOff, + "nas-diagnostic-stale", + )); + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-diagnostic-expired", + "session-expired", + Ipv4Addr::new(198, 51, 100, 89), + ), + ready_mapping(), + &mut sink, + ); + let expired_session = store + .expire_session_with_commands(&expired_key, &mut sink) + .expect("expiry should return the removed session"); + let expired_diagnostic = + RadiusActivationDiagnostic::from_expired_session(&expired_key, &expired_session); + + let diagnostics = store.activation_diagnostics(); + assert_eq!( + diagnostic_state(&diagnostics, &active_key), + Some(RadiusActivationDiagnosticState::Active) + ); + assert_eq!( + diagnostic_state(&diagnostics, &stopped_key), + Some(RadiusActivationDiagnosticState::Stopped) + ); + assert_eq!( + diagnostic_state(&diagnostics, &stale_key), + Some(RadiusActivationDiagnosticState::Stale( + NasResetStatus::AccountingOff + )) + ); + assert_eq!( + expired_diagnostic.state, + RadiusActivationDiagnosticState::Expired + ); + assert_eq!(expired_diagnostic.session_key, expired_key); + assert_eq!( + expired_diagnostic.acct_session_id.as_deref(), + Some("session-expired") + ); + + let pending = diagnostics + .iter() + .find(|diagnostic| diagnostic.state == RadiusActivationDiagnosticState::Pending) + .expect("pending diagnostic should be present"); + assert!( + pending + .pending_reasons + .contains(&PendingSessionReason::MissingSessionId) + ); + assert!( + pending + .pending_reasons + .contains(&PendingSessionReason::MissingIpAddress) + ); + assert!( + pending + .pending_reasons + .contains(&PendingSessionReason::MissingRate) + ); + assert!( + pending + .pending_reasons + .contains(&PendingSessionReason::MissingParent) + ); +} + +#[test] +fn pending_diagnostics_include_mac_match_reasons() { + let mut store = AccountingSessionStore::new(); + let no_match_key = nas_session_key("nas-no-mac", "session-no-mac"); + let ambiguous_key = nas_session_key("nas-ambiguous-mac", "session-ambiguous-mac"); + + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-no-mac", + "session-no-mac", + Ipv4Addr::new(198, 51, 100, 90), + ), + DynamicCircuitMapping::NoMacMatch, + ); + store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-ambiguous-mac", + "session-ambiguous-mac", + Ipv4Addr::new(198, 51, 100, 91), + ), + DynamicCircuitMapping::AmbiguousMacMatch, + ); + + let diagnostics = store.activation_diagnostics(); + let no_match = diagnostic_for(&diagnostics, &no_match_key).unwrap(); + let ambiguous = diagnostic_for(&diagnostics, &ambiguous_key).unwrap(); + + assert_eq!(no_match.state, RadiusActivationDiagnosticState::Pending); + assert!( + no_match + .pending_reasons + .contains(&PendingSessionReason::NoMacMatch) + ); + assert_eq!(ambiguous.state, RadiusActivationDiagnosticState::Pending); + assert!( + ambiguous + .pending_reasons + .contains(&PendingSessionReason::AmbiguousMacMatch) + ); +} + +#[test] +fn command_sink_emits_expiry_rekey_and_stale_expiry_removals() { + let mut expiry_store = AccountingSessionStore::new(); + let mut expiry_sink = RecordingCommandSink::default(); + let expiry_key = nas_session_key("nas-expiry-command", "session-expiry-command"); + let expiry_circuit_id = subscriber_circuit_id("nas-expiry-command"); + expiry_store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-expiry-command", + "session-expiry-command", + Ipv4Addr::new(198, 51, 100, 77), + ), + ready_mapping(), + &mut expiry_sink, + ); + assert!( + expiry_store + .expire_session_with_commands(&expiry_key, &mut expiry_sink) + .is_some() + ); + assert_eq!(expiry_sink.intents.len(), 2); + let DynamicCircuitIntent::RemoveDynamicCircuit(expiry) = &expiry_sink.intents[1] else { + panic!( + "expected expiry removal intent, got {:?}", + expiry_sink.intents[1] + ); + }; + assert_eq!(expiry.circuit_id, expiry_circuit_id); + assert_eq!(expiry.session_key, expiry_key); + assert_eq!(expiry.reason, DynamicCircuitRemovalReason::Expired); + + let mut rekey_store = AccountingSessionStore::new(); + let mut rekey_sink = RecordingCommandSink::default(); + let rekey_key = nas_session_key("nas-rekey-command", "session-rekey-command"); + let rekey_circuit_id = subscriber_circuit_id("nas-rekey-command"); + rekey_store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-rekey-command", + "session-rekey-command", + Ipv4Addr::new(198, 51, 100, 78), + ), + ready_mapping(), + &mut rekey_sink, + ); + let alternate_nas_ip = Ipv4Addr::new(192, 0, 2, 80); + let alternate_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(alternate_nas_ip), + acct_session_id: "session-rekey-command".to_string(), + }; + let alternate_circuit_id = stable_subscriber_circuit_id( + &alternate_key, + &AccountingEvent { + user_name: Some("subscriber".to_string()), + ..AccountingEvent::default() + }, + ) + .unwrap(); + let mut alternate_start = complete_event( + AcctStatusType::Start, + "ignored-by-test", + "session-rekey-command", + Ipv4Addr::new(198, 51, 100, 79), + ); + alternate_start.nas_identifier = None; + alternate_start.nas_ip_address = Some(alternate_nas_ip); + rekey_store.apply_event_with_mapping_and_commands( + alternate_start, + ready_mapping(), + &mut rekey_sink, + ); + let mut bridge = complete_event( + AcctStatusType::InterimUpdate, + "nas-rekey-command", + "session-rekey-command", + Ipv4Addr::new(198, 51, 100, 80), + ); + bridge.nas_ip_address = Some(alternate_nas_ip); + rekey_store.apply_event_with_mapping_and_commands(bridge, ready_mapping(), &mut rekey_sink); + + assert_eq!(rekey_sink.intents.len(), 4); + let DynamicCircuitIntent::UpdateDynamicCircuit(update) = &rekey_sink.intents[2] else { + panic!( + "expected rekey update intent, got {:?}", + rekey_sink.intents[2] + ); + }; + assert_eq!(update.circuit_id, rekey_circuit_id); + assert_eq!(update.session_key, rekey_key); + let DynamicCircuitIntent::RemoveDynamicCircuit(rekeyed) = &rekey_sink.intents[3] else { + panic!( + "expected rekey removal intent, got {:?}", + rekey_sink.intents[3] + ); + }; + assert_eq!(rekeyed.circuit_id, alternate_circuit_id); + assert_eq!(rekeyed.session_key, rekey_key); + assert_eq!(rekeyed.reason, DynamicCircuitRemovalReason::Rekeyed); + + let mut reset_store = AccountingSessionStore::new(); + let mut reset_sink = RecordingCommandSink::default(); + let reset_key = nas_session_key("nas-reset-command", "session-reset-command"); + let reset_circuit_id = subscriber_circuit_id("nas-reset-command"); + let unrelated_key = nas_session_key("nas-reset-other", "session-reset-other"); + let unrelated_circuit_id = subscriber_circuit_id("nas-reset-other"); + reset_store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-reset-command", + "session-reset-command", + Ipv4Addr::new(198, 51, 100, 81), + ), + ready_mapping(), + &mut reset_sink, + ); + reset_store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::Start, + "nas-reset-command", + "session-reset-pending", + ), + ready_mapping(), + &mut reset_sink, + ); + reset_store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-reset-other", + "session-reset-other", + Ipv4Addr::new(198, 51, 100, 82), + ), + ready_mapping(), + &mut reset_sink, + ); + reset_store.apply_event_with_mapping_and_commands( + reset_event(AcctStatusType::AccountingOff, "nas-reset-command"), + ready_mapping(), + &mut reset_sink, + ); + + assert_eq!(reset_sink.intents.len(), 2); + assert_eq!( + reset_store + .session(&reset_key) + .unwrap() + .active_dynamic_circuit_ids, + vec![reset_circuit_id.to_string()] + ); + assert!( + reset_store + .expire_session_with_commands(&reset_key, &mut reset_sink) + .is_some() + ); + assert_eq!(reset_sink.intents.len(), 3); + let DynamicCircuitIntent::RemoveDynamicCircuit(reset) = &reset_sink.intents[2] else { + panic!( + "expected stale expiry removal intent, got {:?}", + reset_sink.intents[2] + ); + }; + assert_eq!(reset.circuit_id, reset_circuit_id); + assert_eq!(reset.session_key, reset_key); + assert_eq!( + reset.reason, + DynamicCircuitRemovalReason::NasReset(NasResetStatus::AccountingOff) + ); + assert_eq!( + reset_store + .session(&unrelated_key) + .unwrap() + .active_dynamic_circuit_ids, + vec![unrelated_circuit_id] + ); +} + +#[test] +fn command_sink_uses_stable_fallback_identity_payload_when_parent_metadata_exists() { + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + let mut event = minimal_session_event( + AcctStatusType::Start, + "nas-fallback-command", + "session-fallback-command", + ); + event.user_name = Some("subscriber@example.net".to_string()); + event.calling_station_id = Some("AA-BB-CC-DD-EE-FF".to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 49)); + let key = nas_session_key("nas-fallback-command", "session-fallback-command"); + + store.apply_event_with_dynamic_circuit_resolution_and_commands( + event, + fallback_resolution(fallback_rate), + &mut sink, + ); + + assert_eq!(sink.intents.len(), 1); + let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { + panic!("expected fallback create intent, got {:?}", sink.intents[0]); + }; + assert_eq!(create.session_key, key); + assert_eq!( + create.shaped_device.circuit_id, + "radius:nas-id:6e61732d66616c6c6261636b2d636f6d6d616e64:username:73756273637269626572406578616d706c652e6e6574" + ); + assert_eq!(create.shaped_device.circuit_name, "subscriber@example.net"); + assert_eq!(create.shaped_device.device_name, "AA-BB-CC-DD-EE-FF"); + assert_eq!(create.shaped_device.parent_node, "Parent Node"); + assert_eq!(create.shaped_device.download_min_mbps, 4.0); + assert_eq!(create.shaped_device.upload_min_mbps, 2.0); + assert_eq!(create.shaped_device.download_max_mbps, 40.0); + assert_eq!(create.shaped_device.upload_max_mbps, 12.0); + let first_circuit_id = create.circuit_id.clone(); + + let mut reconnect = minimal_session_event( + AcctStatusType::Start, + "nas-fallback-command", + "session-fallback-reconnect", + ); + reconnect.user_name = Some("subscriber@example.net".to_string()); + reconnect.calling_station_id = Some("AA:BB:CC:DD:EE:FF".to_string()); + reconnect.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 50)); + store.apply_event_with_dynamic_circuit_resolution_and_commands( + reconnect, + fallback_resolution(fallback_rate), + &mut sink, + ); + + let DynamicCircuitIntent::CreateDynamicCircuit(reconnected) = &sink.intents[1] else { + panic!( + "expected reconnect fallback create intent, got {:?}", + sink.intents[1] + ); + }; + assert_eq!(reconnected.circuit_id, first_circuit_id); + + let mut other_nas = minimal_session_event( + AcctStatusType::Start, + "nas-fallback-command-other", + "session-fallback-other-nas", + ); + other_nas.user_name = Some("subscriber@example.net".to_string()); + other_nas.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 51)); + store.apply_event_with_dynamic_circuit_resolution_and_commands( + other_nas, + fallback_resolution(fallback_rate), + &mut sink, + ); + + let DynamicCircuitIntent::CreateDynamicCircuit(other_nas) = &sink.intents[2] else { + panic!("expected other NAS fallback create intent"); + }; + assert_ne!(other_nas.circuit_id, first_circuit_id); +} + +#[test] +fn calling_station_identity_is_stable_across_sessions_and_scoped_to_the_nas() { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap(); + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + + for (nas, session, calling_station_id, address) in [ + ( + "nas-calling", + "calling-session-one", + "AA-BB-CC-DD-EE-FF", + 51, + ), + ("nas-calling", "calling-session-two", "aabb.ccdd.eeff", 52), + ( + "nas-calling-other", + "calling-session-three", + "AA:BB:CC:DD:EE:FF", + 53, + ), + ] { + let mut event = minimal_session_event(AcctStatusType::Start, nas, session); + event.user_name = None; + event.calling_station_id = Some(calling_station_id.to_string()); + event.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, address)); + store.apply_event_with_dynamic_circuit_resolution_and_commands( + event, + fallback_resolution(fallback_rate), + &mut sink, + ); + } + + let DynamicCircuitIntent::CreateDynamicCircuit(first) = &sink.intents[0] else { + panic!("expected first Calling-Station-Id create intent"); + }; + let DynamicCircuitIntent::CreateDynamicCircuit(reconnected) = &sink.intents[1] else { + panic!("expected reconnect Calling-Station-Id create intent"); + }; + let DynamicCircuitIntent::CreateDynamicCircuit(other_nas) = &sink.intents[2] else { + panic!("expected other NAS Calling-Station-Id create intent"); + }; + assert_eq!(first.circuit_id, reconnected.circuit_id); + assert_ne!(first.circuit_id, other_nas.circuit_id); +} + +#[test] +fn username_identity_preserves_radius_whitespace() { + let key = nas_session_key("nas-trimmed-username", "trimmed-username-session"); + let canonical = AccountingEvent { + user_name: Some("subscriber@example.net".to_string()), + ..AccountingEvent::default() + }; + let padded = AccountingEvent { + user_name: Some(" subscriber@example.net ".to_string()), + ..AccountingEvent::default() + }; + + assert_ne!( + stable_subscriber_circuit_id(&key, &canonical), + stable_subscriber_circuit_id(&key, &padded) + ); +} + +#[test] +fn username_in_mac_field_creates_the_shaped_devices_dynamic_circuit() { + let device = shaped_device("username-circuit", "username-device", "pppoe-known"); + let matcher = ShapedDevicesMacMatcher::from_devices(&[device]); + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + let mut event = complete_event( + AcctStatusType::Start, + "nas-username", + "session-username", + Ipv4Addr::new(198, 51, 100, 91), + ); + event.user_name = Some("pppoe-known".to_string()); + event.calling_station_id = None; + event.mikrotik_rate_limits.clear(); + + store.apply_event_with_shaped_devices_matcher_and_commands( + event, + &matcher, + ShapedDevicesMatchOptions { + match_by_username: true, + match_by_mac: false, + fallback_profile: None, + fallback_parent: None, + }, + &mut sink, + ); + + assert_eq!(sink.intents.len(), 1); + let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { + panic!("username-only match should create a dynamic circuit"); + }; + assert_eq!(create.circuit_id, "username-circuit"); + assert_eq!(create.shaped_device.device_id, "username-device"); +} + +#[test] +fn duplicate_username_rows_remain_pending_with_an_identity_diagnostic() { + let first = shaped_device("first-circuit", "first-device", "duplicate-user"); + let second = shaped_device("second-circuit", "second-device", "duplicate-user"); + let matcher = ShapedDevicesMacMatcher::from_devices(&[first, second]); + let mut store = AccountingSessionStore::new(); + let mut sink = RecordingCommandSink::default(); + let mut event = complete_event( + AcctStatusType::Start, + "nas-duplicate-username", + "session-duplicate-username", + Ipv4Addr::new(198, 51, 100, 92), + ); + event.user_name = Some("duplicate-user".to_string()); + event.calling_station_id = None; + event.mikrotik_rate_limits.clear(); + + store.apply_event_with_shaped_devices_matcher_and_commands( + event, + &matcher, + ShapedDevicesMatchOptions { + match_by_username: true, + match_by_mac: false, + fallback_profile: Some(SessionRateProfile::new(4.0, 2.0, 40.0, 12.0).unwrap()), + fallback_parent: Some(parent_attachment()), + }, + &mut sink, + ); + + assert!(sink.intents.is_empty()); + let session = store + .session(&nas_session_key( + "nas-duplicate-username", + "session-duplicate-username", + )) + .expect("duplicate username session should be retained for diagnostics"); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::AmbiguousIdentityMatch] + ); +} + +#[test] +fn session_lookup_indexes_track_promotion_stop_reset_and_expiry() { + let mut store = AccountingSessionStore::new(); + let mut pending = AccountingEvent { + status_type: Some(AcctStatusType::Start), + acct_session_id: Some("session-index".to_string()), + user_name: Some("index-user".to_string()), + calling_station_id: Some("AA-BB-CC-DD-EE-FF".to_string()), + nas_port_id: Some("port-index".to_string()), + ..AccountingEvent::default() + }; + let pending_update = store.apply_event_with_mapping(pending.clone(), ready_mapping()); + let AccountingSessionUpdate::SessionUpdated { + key: pending_key, .. + } = pending_update + else { + panic!("expected pending session update, got {pending_update:?}"); + }; + assert_pending_index_contains( + &store, + SessionLookupIndexKey::AcctSessionId("session-index".to_string()), + &pending_key, + ); + assert_pending_index_contains( + &store, + SessionLookupIndexKey::UserName("index-user".to_string()), + &pending_key, + ); + + pending.status_type = Some(AcctStatusType::InterimUpdate); + pending.nas_identifier = Some("nas-index".to_string()); + pending.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 50)); + pending.mikrotik_rate_limits = vec![rate_limit()]; + let promoted_key = nas_session_key("nas-index", "session-index"); + store.apply_event_with_mapping(pending, ready_mapping()); + + assert_no_index_references(&store, &pending_key); + assert_nas_session_index_contains(&store, "session-index", &promoted_key); + assert_fallback_index_contains( + &store, + SessionLookupIndexKey::AcctSessionId("session-index".to_string()), + &promoted_key, + ); + assert_fallback_index_contains( + &store, + SessionLookupIndexKey::UserName("index-user".to_string()), + &promoted_key, + ); + + store.apply_event_with_mapping( + minimal_session_event(AcctStatusType::Stop, "nas-index", "session-index"), + ready_mapping(), + ); + assert_nas_session_index_contains(&store, "session-index", &promoted_key); + assert_fallback_index_contains( + &store, + SessionLookupIndexKey::AcctSessionId("session-index".to_string()), + &promoted_key, + ); + assert_fallback_index_not_contains( + &store, + SessionLookupIndexKey::CallingStationId("AA-BB-CC-DD-EE-FF".to_string()), + &promoted_key, + ); + assert_fallback_index_not_contains( + &store, + SessionLookupIndexKey::UserName("index-user".to_string()), + &promoted_key, + ); + + let mut reset_store = AccountingSessionStore::new(); + let reset_key = nas_session_key("nas-index-reset", "session-index-reset"); + reset_store.apply_event_with_mapping( + complete_event( + AcctStatusType::Start, + "nas-index-reset", + "session-index-reset", + Ipv4Addr::new(203, 0, 113, 51), + ), + ready_mapping(), + ); + reset_store.apply_event(reset_event( + AcctStatusType::AccountingOff, + "nas-index-reset", + )); + assert_nas_session_index_contains(&reset_store, "session-index-reset", &reset_key); + assert_fallback_index_contains( + &reset_store, + SessionLookupIndexKey::AcctSessionId("session-index-reset".to_string()), + &reset_key, + ); + let mut sink = RecordingCommandSink::default(); + reset_store.expire_session_with_commands(&reset_key, &mut sink); + assert_no_index_references(&reset_store, &reset_key); + + store.expire_session_with_commands(&promoted_key, &mut sink); + assert_no_index_references(&store, &promoted_key); +} + +#[test] +fn lookup_indexes_do_not_return_nas_only_candidate_sets() { + let mut store = AccountingSessionStore::new(); + let pending_key = AccountingSessionKey::Pending { + fingerprint: PendingSessionFingerprint { + nas: Some(NasIdentity::Identifier("shared-nas".to_string())), + acct_session_id: None, + user_name: Some("pending-user".to_string()), + calling_station_id: None, + nas_port_id: None, + nas_port: None, + }, + }; + insert_retained_session( + &mut store, + pending_key.clone(), + AccountingSession { + state: AccountingSessionState::Active, + latest_event: AccountingEvent { + status_type: Some(AcctStatusType::Start), + nas_identifier: Some("shared-nas".to_string()), + user_name: Some("pending-user".to_string()), + ..AccountingEvent::default() + }, + known_nas_identities: vec![NasIdentity::Identifier("shared-nas".to_string())], + resolved_rate: None, + resolved_shaped_device: None, + active_dynamic_circuit_ids: Vec::new(), + diagnostic_circuit_ids: Vec::new(), + pending_reasons: Vec::new(), + }, + ); + let fallback_key = nas_session_key("shared-nas", "fallback-session"); + let (_, fallback_session) = retained_session_with_known_ip( + "shared-nas", + "fallback-session", + Ipv4Addr::new(192, 0, 2, 50), + ); + insert_retained_session(&mut store, fallback_key.clone(), fallback_session); + + let same_nas_only = AccountingEvent { + nas_identifier: Some("shared-nas".to_string()), + ..AccountingEvent::default() + }; + let identities = NasIdentitySet::from_event(&same_nas_only); + assert!( + store + .lookup_candidates(&store.pending_keys_by_lookup, &same_nas_only, &identities) + .is_empty(), + "same-NAS-only pending lookup should not return every session for the NAS" + ); + assert!( + store + .lookup_candidates(&store.fallback_keys_by_lookup, &same_nas_only, &identities) + .is_empty(), + "same-NAS-only fallback lookup should not return every session for the NAS" + ); + + let selective_pending = AccountingEvent { + nas_identifier: Some("shared-nas".to_string()), + user_name: Some("pending-user".to_string()), + ..AccountingEvent::default() + }; + let identities = NasIdentitySet::from_event(&selective_pending); + assert!( + store + .lookup_candidates( + &store.pending_keys_by_lookup, + &selective_pending, + &identities + ) + .contains(&pending_key), + "selective pending lookup keys should still find matching sessions" + ); + + let selective_fallback = AccountingEvent { + nas_identifier: Some("shared-nas".to_string()), + acct_session_id: Some("fallback-session".to_string()), + ..AccountingEvent::default() + }; + let identities = NasIdentitySet::from_event(&selective_fallback); + assert!( + store + .lookup_candidates( + &store.fallback_keys_by_lookup, + &selective_fallback, + &identities + ) + .contains(&fallback_key), + "selective fallback lookup keys should still find matching sessions" + ); +} + +#[derive(Default)] +struct RecordingCommandSink { + intents: Vec, +} + +impl DynamicCircuitCommandSink for RecordingCommandSink { + fn emit(&mut self, intent: DynamicCircuitIntent) { + self.intents.push(intent); + } +} + +fn diagnostic_for<'a>( + diagnostics: &'a [RadiusActivationDiagnostic], + key: &AccountingSessionKey, +) -> Option<&'a RadiusActivationDiagnostic> { + diagnostics + .iter() + .find(|diagnostic| diagnostic.session_key == *key) +} + +fn diagnostic_state( + diagnostics: &[RadiusActivationDiagnostic], + key: &AccountingSessionKey, +) -> Option { + diagnostic_for(diagnostics, key).map(|diagnostic| diagnostic.state) +} + +fn ready_mapping() -> DynamicCircuitMapping { + DynamicCircuitMapping::ReadyWithParent(parent_attachment()) +} + +fn fallback_resolution(fallback_rate: SessionRateProfile) -> DynamicCircuitResolution { + DynamicCircuitResolution { + mapping: ready_mapping(), + rate_sources: SessionRateSources { + shaped_device_profile: None, + fallback_profile: Some(fallback_rate), + }, + matched_shaped_device: None, + } +} + +fn parent_attachment() -> DynamicCircuitParent { + DynamicCircuitParent { + parent_node: "Parent Node".to_string(), + parent_node_id: Some("parent-node-id".to_string()), + anchor_node_id: Some("anchor-node-id".to_string()), + } +} + +fn complete_event( + status_type: AcctStatusType, + nas_identifier: &str, + acct_session_id: &str, + framed_ip_address: Ipv4Addr, +) -> AccountingEvent { + AccountingEvent { + status_type: Some(status_type), + acct_session_id: Some(acct_session_id.to_string()), + nas_identifier: Some(nas_identifier.to_string()), + user_name: Some("subscriber".to_string()), + framed_ip_address: Some(framed_ip_address), + mikrotik_rate_limits: vec![rate_limit()], + ..AccountingEvent::default() + } +} + +fn minimal_session_event( + status_type: AcctStatusType, + nas_identifier: &str, + acct_session_id: &str, +) -> AccountingEvent { + AccountingEvent { + status_type: Some(status_type), + acct_session_id: Some(acct_session_id.to_string()), + nas_identifier: Some(nas_identifier.to_string()), + ..AccountingEvent::default() + } +} + +fn subscriber_circuit_id(nas_identifier: &str) -> String { + let key = nas_session_key(nas_identifier, "subscriber-id-fixture"); + let event = AccountingEvent { + user_name: Some("subscriber".to_string()), + ..AccountingEvent::default() + }; + stable_subscriber_circuit_id(&key, &event).expect("keyed subscriber should have a circuit ID") +} + +fn reset_event(status_type: AcctStatusType, nas_identifier: &str) -> AccountingEvent { + AccountingEvent { + status_type: Some(status_type), + nas_identifier: Some(nas_identifier.to_string()), + ..AccountingEvent::default() + } +} + +fn nas_session_key(nas_identifier: &str, acct_session_id: &str) -> AccountingSessionKey { + AccountingSessionKey::NasSession { + nas: NasIdentity::Identifier(nas_identifier.to_string()), + acct_session_id: acct_session_id.to_string(), + } +} + +fn retained_session_with_known_ip( + nas_identifier: &str, + acct_session_id: &str, + nas_ip_address: Ipv4Addr, +) -> (AccountingSessionKey, AccountingSession) { + ( + nas_session_key(nas_identifier, acct_session_id), + AccountingSession { + state: AccountingSessionState::Active, + latest_event: minimal_session_event( + AcctStatusType::Start, + nas_identifier, + acct_session_id, + ), + known_nas_identities: vec![ + NasIdentity::Identifier(nas_identifier.to_string()), + NasIdentity::Ipv4(nas_ip_address), + ], + resolved_rate: None, + resolved_shaped_device: None, + active_dynamic_circuit_ids: Vec::new(), + diagnostic_circuit_ids: Vec::new(), + pending_reasons: Vec::new(), + }, + ) +} + +fn insert_retained_session( + store: &mut AccountingSessionStore, + key: AccountingSessionKey, + session: AccountingSession, +) { + store.sessions.insert(key.clone(), session); + store.index_session(&key); +} + +fn assert_nas_session_index_contains( + store: &AccountingSessionStore, + acct_session_id: &str, + key: &AccountingSessionKey, +) { + assert!( + store + .nas_session_keys_by_acct_session_id + .get(acct_session_id) + .is_some_and(|keys| keys.contains(key)), + "expected NAS session index for {acct_session_id:?} to contain {key:?}" + ); +} + +fn assert_pending_index_contains( + store: &AccountingSessionStore, + lookup_key: SessionLookupIndexKey, + key: &AccountingSessionKey, +) { + assert_index_contains(&store.pending_keys_by_lookup, lookup_key, key); +} + +fn assert_fallback_index_contains( + store: &AccountingSessionStore, + lookup_key: SessionLookupIndexKey, + key: &AccountingSessionKey, +) { + assert_index_contains(&store.fallback_keys_by_lookup, lookup_key, key); +} + +fn assert_fallback_index_not_contains( + store: &AccountingSessionStore, + lookup_key: SessionLookupIndexKey, + key: &AccountingSessionKey, +) { + assert_index_not_contains(&store.fallback_keys_by_lookup, lookup_key, key); +} + +fn assert_index_contains( + index: &std::collections::HashMap< + SessionLookupIndexKey, + std::collections::HashSet, + >, + lookup_key: SessionLookupIndexKey, + key: &AccountingSessionKey, +) { + assert!( + index + .get(&lookup_key) + .is_some_and(|keys| keys.contains(key)), + "expected lookup index {lookup_key:?} to contain {key:?}" + ); +} + +fn assert_index_not_contains( + index: &std::collections::HashMap< + SessionLookupIndexKey, + std::collections::HashSet, + >, + lookup_key: SessionLookupIndexKey, + key: &AccountingSessionKey, +) { + assert!( + !index + .get(&lookup_key) + .is_some_and(|keys| keys.contains(key)), + "expected lookup index {lookup_key:?} not to contain {key:?}" + ); +} + +fn assert_no_index_references(store: &AccountingSessionStore, key: &AccountingSessionKey) { + assert!( + !store + .nas_session_keys_by_acct_session_id + .values() + .any(|keys| keys.contains(key)), + "expected NAS session index not to contain {key:?}" + ); + assert!( + !store + .pending_keys_by_lookup + .values() + .any(|keys| keys.contains(key)), + "expected pending lookup index not to contain {key:?}" + ); + assert!( + !store + .fallback_keys_by_lookup + .values() + .any(|keys| keys.contains(key)), + "expected fallback lookup index not to contain {key:?}" + ); +} + +fn shaped_device(circuit_id: &str, device_id: &str, mac: &str) -> ShapedDevice { + ShapedDevice { + circuit_id: circuit_id.to_string(), + circuit_name: format!("Circuit {circuit_id}"), + device_id: device_id.to_string(), + device_name: format!("Device {device_id}"), + parent_node: "Parent Node".to_string(), + parent_node_id: Some("parent-node-id".to_string()), + anchor_node_id: Some("anchor-node-id".to_string()), + mac: mac.to_string(), + ipv4: vec![(Ipv4Addr::new(198, 51, 100, 200), 32)], + ipv6: vec![("2001:db8:ffff::1".parse().unwrap(), 128)], + download_min_mbps: 5.0, + upload_min_mbps: 2.0, + download_max_mbps: 50.0, + upload_max_mbps: 20.0, + comment: "matched from shaped devices".to_string(), + sqm_override: Some("cake/none".to_string()), + circuit_hash: 0, + device_hash: 0, + parent_hash: 0, + } +} + +fn assert_resolved_hashes_refreshed(device: &ShapedDevice) { + let mut expected = device.clone(); + expected.circuit_hash = 0; + expected.device_hash = 0; + expected.parent_hash = 0; + expected.refresh_hashes(); + assert_eq!(device.circuit_hash, expected.circuit_hash); + assert_eq!(device.device_hash, expected.device_hash); + assert_eq!(device.parent_hash, expected.parent_hash); +} + +fn rate_limit() -> MikrotikRateLimit { + MikrotikRateLimit { + original: "10M/25M".to_string(), + nas_rx_bps: 10_000_000, + nas_tx_bps: 25_000_000, + upload_bps: 10_000_000, + download_bps: 25_000_000, + } +} + +fn zero_rate_limit() -> MikrotikRateLimit { + MikrotikRateLimit { + original: "0/0".to_string(), + nas_rx_bps: 0, + nas_tx_bps: 0, + upload_bps: 0, + download_bps: 0, + } +} diff --git a/src/rust/lqos_radius/src/test_support.rs b/src/rust/lqos_radius/src/test_support.rs new file mode 100644 index 00000000..8df7d0eb --- /dev/null +++ b/src/rust/lqos_radius/src/test_support.rs @@ -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 { + radius_packet(RadiusCode::AccountingRequest, identifier, attributes) +} + +pub(crate) fn radius_attributes(attributes: &[Vec]) -> Vec { + attributes.concat() +} + +pub(crate) fn radius_attribute(kind: u8, value: &[u8]) -> Vec { + encode_radius_tlv(kind, value) +} + +pub(crate) fn radius_text_attribute(kind: u8, value: &str) -> Vec { + radius_attribute(kind, value.as_bytes()) +} + +pub(crate) fn radius_u32_attribute(kind: u8, value: u32) -> Vec { + radius_attribute(kind, &value.to_be_bytes()) +} + +pub(crate) fn radius_vendor_attribute(vendor_id: u32, vendor_type: u8, value: &[u8]) -> Vec { + 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]) -> Vec { + let vendor_payload_len = subattributes.iter().map(Vec::len).sum::(); + 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 { + radius_attribute(vendor_type, value) +} + +pub(crate) fn radius_raw_vendor_attribute(vendor_id: u32, value: &[u8]) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/rust/lqos_radius/test-radclient-harness.sh b/src/rust/lqos_radius/test-radclient-harness.sh new file mode 100755 index 00000000..9e42b5ee --- /dev/null +++ b/src/rust/lqos_radius/test-radclient-harness.sh @@ -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 <"${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}" diff --git a/src/rust/lqos_topology/src/shaping_inputs.rs b/src/rust/lqos_topology/src/shaping_inputs.rs index 229f5fb6..210f84ed 100644 --- a/src/rust/lqos_topology/src/shaping_inputs.rs +++ b/src/rust/lqos_topology/src/shaping_inputs.rs @@ -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(", ") )); } diff --git a/src/rust/lqos_topology/src/tests/group_02.rs b/src/rust/lqos_topology/src/tests/group_02.rs index cce2a690..d422fc73 100644 --- a/src/rust/lqos_topology/src/tests/group_02.rs +++ b/src/rust/lqos_topology/src/tests/group_02.rs @@ -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); } diff --git a/src/rust/lqos_utils/src/process_lock.rs b/src/rust/lqos_utils/src/process_lock.rs index cfe8e855..77382ad4 100644 --- a/src/rust/lqos_utils/src/process_lock.rs +++ b/src/rust/lqos_utils/src/process_lock.rs @@ -172,18 +172,20 @@ struct LockMetadata { process: Option, operation: Option, created_unix: Option, + process_start_ticks: Option, } 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::().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 { .and_then(|name| non_empty_sanitized(name.trim())) } +fn process_start_ticks_for_pid(pid: i32) -> Option { + 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 { 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")); diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index 88ff7945..7bf7ed2b 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -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 } diff --git a/src/rust/lqosd/copy_files.sh b/src/rust/lqosd/copy_files.sh index 43d2d605..bb657350 100755 --- a/src/rust/lqosd/copy_files.sh +++ b/src/rust/lqosd/copy_files.sh @@ -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" diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 5a80df98..180f1de9 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -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); diff --git a/src/rust/lqosd/src/memory_watchdog.rs b/src/rust/lqosd/src/memory_watchdog.rs index dc8f2251..f31139fb 100644 --- a/src/rust/lqosd/src/memory_watchdog.rs +++ b/src/rust/lqosd/src/memory_watchdog.rs @@ -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); diff --git a/src/rust/lqosd/src/node_manager/js_build/README.md b/src/rust/lqosd/src/node_manager/js_build/README.md index 2de3efc7..372a1d50 100644 --- a/src/rust/lqosd/src/node_manager/js_build/README.md +++ b/src/rust/lqosd/src/node_manager/js_build/README.md @@ -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` diff --git a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt index 54331dbd..0df92c8f 100644 --- a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt +++ b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt @@ -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 diff --git a/src/rust/lqosd/src/node_manager/js_build/esbuild.sh b/src/rust/lqosd/src/node_manager/js_build/esbuild.sh index d4abeebd..a59b362d 100755 --- a/src/rust/lqosd/src/node_manager/js_build/esbuild.sh +++ b/src/rust/lqosd/src/node_manager/js_build/esbuild.sh @@ -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}" diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 94475ee8..3303b82f 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -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" }, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js b/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js index 5c1dcbf3..7389b411 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_devices.js @@ -343,7 +343,7 @@ function renderTable() { html += "Device ID"; html += "Device Name"; html += "Parent Node"; - html += "MAC"; + html += "MAC or RADIUS Username"; html += "IP Addresses"; html += "Min Mbps"; html += "Max Mbps"; diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting.js b/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting.js new file mode 100644 index 00000000..3536b35c --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting.js @@ -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!"); + }); + }); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting_contract.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting_contract.test.mjs new file mode 100644 index 00000000..a27d83fe --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting_contract.test.mjs @@ -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/); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh index 6fc8065f..2abf06cb 100755 --- a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh +++ b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh @@ -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" \ diff --git a/src/rust/lqosd/src/node_manager/local_api/network_mode.rs b/src/rust/lqosd/src/node_manager/local_api/network_mode.rs index fa0a7af1..ee8a231f 100644 --- a/src/rust/lqosd/src/node_manager/local_api/network_mode.rs +++ b/src/rust/lqosd/src/node_manager/local_api/network_mode.rs @@ -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"); diff --git a/src/rust/lqosd/src/node_manager/static2/config_devices.html b/src/rust/lqosd/src/node_manager/static2/config_devices.html index b6963cee..21841413 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_devices.html +++ b/src/rust/lqosd/src/node_manager/static2/config_devices.html @@ -24,7 +24,7 @@
- +
@@ -91,8 +91,9 @@
- - + + +
Use a MAC address for Calling-Station-Id matching, or a username for User-Name matching.
diff --git a/src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html b/src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html new file mode 100644 index 00000000..decc9116 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html @@ -0,0 +1,188 @@ +
+
+ +
+
+
+
+
RADIUS Accounting
+
+ Listen for accounting packets and apply RADIUS-backed dynamic circuits. +
+
+
+ +
+
+
+
Listener
+
Enable the UDP listener and choose session expiry behavior.
+ +
+
+
+ + +
Starts the lqosd RADIUS accounting listener.
+
+
+ +
+ + +
UDP socket address used by NAS accounting clients.
+
+ +
+ +
+ + sec +
+
+ +
+ +
+ + sec +
+
+
+
+
+ +
+
+
Dynamic Circuit Application
+
Control whether accepted sessions create or refresh dynamic circuits.
+ +
+
+
+ + +
Allows RADIUS sessions to update dynamic circuit state.
+
+
+ +
+
+ + +
Uses Calling-Station-Id to reuse shaped device metadata.
+
+
+ +
+
+ + +
Uses User-Name to match the device MAC field verbatim.
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+
Fallback Speed Profile
+
Optional rates used when packets and matched shaped devices do not supply rates.
+ +
+
+
+ + +
+
+ +
+ +
+ + Mbps +
+
+ +
+ +
+ + Mbps +
+
+ +
+ +
+ + Mbps +
+
+ +
+ +
+ + Mbps +
+
+
+
+
+ +
+
+
Trusted Clients
+
Allow only known NAS sources and shared secret files.
+ +
+ +
+ +
+ +
+ + + + + + + + + + +
NameSource IPs or CIDRsSecret FileActions
+
+
+
+
+
+ +
+ +
+
+
+ + diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index a57108a5..917e6d12 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -66,6 +66,7 @@ pub(super) fn static_routes() -> Result { "config_iprange.html", "config_flows.html", "config_dynamic_circuits.html", + "config_radius_accounting.html", "config_integration.html", "config_splynx.html", "config_netzur.html", diff --git a/src/rust/lqosd/src/radius_accounting.rs b/src/rust/lqosd/src/radius_accounting.rs new file mode 100644 index 00000000..6070b7e9 --- /dev/null +++ b/src/rust/lqosd/src/radius_accounting.rs @@ -0,0 +1,5684 @@ +//! lqosd integration for the RADIUS accounting listener. + +use lqos_bus::{BusReply, BusRequest, BusResponse}; +use lqos_config::{ + Config, ConfigShapedDevices, RadiusAccountingClient, RadiusAccountingConfig, + RadiusFallbackSpeedProfile, +}; +use lqos_radius::{ + AccountingEvent, AccountingListenerOutcome, AccountingSessionKey, AccountingSessionState, + AccountingSessionStore, AccountingSessionUpdate, DynamicCircuitCommandSink, + DynamicCircuitIntent, DynamicCircuitMapping, DynamicCircuitParent, DynamicCircuitRemoval, + DynamicCircuitResolution, DynamicCircuitUpsert, ListenerConfig, RadiusActivationDiagnostic, + RadiusListener, RadiusPacketCounters, SessionRateProfile, SessionRateProfileError, + SessionRateSources, ShapedDevicesMacMatcher, ShapedDevicesMatchOptions, TrustedClientSource, + TrustedRadiusClient, start_listener, +}; +use parking_lot::Mutex; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use thiserror::Error; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use tokio::time::{Sleep, sleep_until}; +use tracing::{Level, debug, error, info, trace, warn}; + +const DYNAMIC_CIRCUIT_APPLICATION_TIMEOUT: Duration = Duration::from_secs(35); +const DYNAMIC_CIRCUIT_APPLICATION_QUEUE_CAPACITY: usize = 1024; +const RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT: usize = 1024; +const RADIUS_APPLY_FAILED_DIAGNOSTIC_LIMIT: usize = 1024; +const DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT: usize = 240; + +/// lqosd bus channel used to submit daemon-local requests without opening a Unix socket. +pub(crate) type DynamicCircuitBusSender = mpsc::Sender<(oneshot::Sender, BusRequest)>; + +/// Starts the RADIUS accounting listener when it is enabled in configuration. +/// +/// Side effects: reads configured shared-secret files, conditionally loads +/// `ShapedDevices.csv` for MAC matching during startup, binds the configured +/// UDP socket when enabled, and spawns a Tokio task to receive accounting +/// packets. When both dynamic-circuit safety gates are enabled, the spawned task +/// submits dynamic-circuit bus requests after sending Accounting-Response +/// packets; it does not write dynamic-circuit files directly or touch TC/XDP in +/// the UDP response path. +pub(crate) async fn start_configured_radius_accounting( + config: Option, + config_snapshot: &Config, + dynamic_circuit_bus_tx: DynamicCircuitBusSender, +) -> Result>, RadiusAccountingStartupError> { + let Some(runtime_config) = runtime_config_from_config(config, config_snapshot).await? else { + return Ok(None); + }; + + let listener = start_listener(ListenerConfig { + listen_addr: runtime_config.listen_addr, + }) + .await + .map_err(RadiusAccountingStartupError::Listener)?; + let local_addr = listener + .local_addr() + .map_err(RadiusAccountingStartupError::Listener)?; + info!( + "RADIUS accounting listener started on {local_addr} with {} trusted client(s)", + runtime_config.clients.len() + ); + + Ok(Some(tokio::spawn(run_radius_accounting_listener( + listener, + runtime_config, + dynamic_circuit_bus_tx, + )))) +} + +async fn runtime_config_from_config( + config: Option, + config_snapshot: &Config, +) -> Result, RadiusAccountingStartupError> { + let Some(config) = config else { + return Ok(None); + }; + if !config.enabled { + return Ok(None); + } + + let listen_addr = config + .listen + .ok_or(RadiusAccountingStartupError::MissingListen)?; + let dynamic_circuit_application_enabled = config.dynamic_circuit_application.enabled; + let apply_dynamic_circuits = dynamic_circuit_application_enabled + && config_snapshot + .dynamic_circuits + .as_ref() + .is_some_and(|dynamic_circuits| dynamic_circuits.enabled); + let fallback_rate_profile = if apply_dynamic_circuits { + session_rate_profile_from_config(config.fallback_speed_profile.as_ref())? + } else { + None + }; + let fallback_parent = if apply_dynamic_circuits { + fallback_parent_from_config(&config.dynamic_circuit_application) + } else { + None + }; + let match_shaped_devices_by_username = config + .dynamic_circuit_application + .match_shaped_devices_by_username; + let match_shaped_devices_by_mac = config + .dynamic_circuit_application + .match_shaped_devices_by_mac; + let mac_matcher = if apply_dynamic_circuits + && (match_shaped_devices_by_username || match_shaped_devices_by_mac) + { + Some(load_mac_matcher(config_snapshot)?) + } else { + None + }; + let mut clients = Vec::with_capacity(config.clients.len()); + for (index, client) in config.clients.iter().enumerate() { + clients.push(load_trusted_client(index, client).await?); + } + if clients.is_empty() { + return Err(RadiusAccountingStartupError::NoClients); + } + + Ok(Some(RadiusAccountingRuntimeConfig { + listen_addr, + clients, + default_ttl: Duration::from_secs(config.default_ttl_seconds), + stale_grace: Duration::from_secs(config.stale_grace_seconds), + fallback_rate_profile, + fallback_parent, + mac_matcher, + match_shaped_devices_by_username, + match_shaped_devices_by_mac, + apply_dynamic_circuits, + })) +} + +fn load_mac_matcher( + config_snapshot: &Config, +) -> Result { + let shaped_devices_path = ConfigShapedDevices::path_for_config(config_snapshot) + .to_string_lossy() + .into_owned(); + let shaped_devices = + ConfigShapedDevices::load_for_config(config_snapshot).map_err(|source| { + RadiusAccountingStartupError::ShapedDevicesLoad { + path: shaped_devices_path, + detail: source.to_string(), + } + })?; + Ok(ShapedDevicesMacMatcher::from_devices( + &shaped_devices.devices, + )) +} + +fn session_rate_profile_from_config( + fallback_speed_profile: Option<&RadiusFallbackSpeedProfile>, +) -> Result, RadiusAccountingStartupError> { + let Some(profile) = fallback_speed_profile else { + return Ok(None); + }; + + SessionRateProfile::new( + profile.download_min_mbps, + profile.upload_min_mbps, + profile.download_max_mbps, + profile.upload_max_mbps, + ) + .map(Some) + .map_err(RadiusAccountingStartupError::InvalidFallbackSpeedProfile) +} + +fn fallback_parent_from_config( + application_config: &lqos_config::RadiusDynamicCircuitApplicationConfig, +) -> Option { + let parent_node = trimmed_config_text(application_config.fallback_parent_node.as_deref())?; + Some(DynamicCircuitParent { + parent_node, + parent_node_id: trimmed_config_text(application_config.fallback_parent_node_id.as_deref()), + anchor_node_id: trimmed_config_text(application_config.fallback_anchor_node_id.as_deref()), + }) +} + +fn trimmed_config_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +async fn load_trusted_client( + index: usize, + client: &RadiusAccountingClient, +) -> Result { + let label = client_label(index, &client.name); + let mut shared_secret = + tokio::fs::read(client.secret_file.as_str()) + .await + .map_err(|source| RadiusAccountingStartupError::SecretFileRead { + label: label.clone(), + source, + })?; + trim_line_endings(&mut shared_secret); + if shared_secret.is_empty() { + return Err(RadiusAccountingStartupError::EmptySecretFile { label }); + } + + TrustedRadiusClient::new(trusted_sources(index, client)?, shared_secret).map_err(|source| { + RadiusAccountingStartupError::TrustedClient { + label: client_label(index, &client.name), + source, + } + }) +} + +fn trusted_sources( + index: usize, + client: &RadiusAccountingClient, +) -> Result, RadiusAccountingStartupError> { + client + .source + .iter() + .map(|source| { + let network = source.network(); + TrustedClientSource::network(network.network_address(), network.netmask()).map_err( + |source| RadiusAccountingStartupError::TrustedSource { + label: client_label(index, &client.name), + source, + }, + ) + }) + .collect() +} + +async fn run_radius_accounting_listener( + listener: RadiusListener, + runtime_config: RadiusAccountingRuntimeConfig, + dynamic_circuit_bus_tx: DynamicCircuitBusSender, +) { + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_identity_matcher( + runtime_config.default_ttl, + runtime_config.stale_grace, + runtime_config.fallback_rate_profile, + runtime_config.fallback_parent, + runtime_config.mac_matcher, + runtime_config.match_shaped_devices_by_username, + runtime_config.match_shaped_devices_by_mac, + ); + let mut applying_sink = dynamic_circuit_application_sink( + runtime_config.apply_dynamic_circuits, + dynamic_circuit_bus_tx, + ); + let mut expiry_timer = RadiusExpiryTimer::new(&sessions, radius_accounting_now()); + + loop { + tokio::select! { + outcome = listener.receive_next_verified(&runtime_config.clients) => { + let now = radius_accounting_now(); + expire_due_before_packet( + &mut sessions, + &mut expiry_timer, + now, + &mut applying_sink, + ); + match outcome { + Ok(outcome) => { + handle_listener_outcome_with_application_sink( + outcome, + &mut sessions, + &mut expiry_timer, + now, + &mut applying_sink, + ); + } + Err(err) if listener_error_is_recoverable(&err) => { + warn!("RADIUS accounting listener packet handling failed: {err}"); + } + Err(err) => { + error!("RADIUS accounting listener stopped: {err}"); + return; + } + } + } + _ = expiry_timer.sleep_mut() => { + let now = radius_accounting_now(); + expire_due_after_timer_wake( + &mut sessions, + &mut expiry_timer, + now, + &mut applying_sink, + ); + } + } + } +} + +struct RadiusExpiryTimer { + sleep: Pin>, + wake_at: Instant, +} + +impl RadiusExpiryTimer { + fn new(sessions: &RadiusAccountingSessions, now: Instant) -> Self { + let wake_at = radius_expiry_wake_at(sessions, now); + Self { + sleep: Box::pin(sleep_until(tokio::time::Instant::from_std(wake_at))), + wake_at, + } + } + + fn reset_from_sessions(&mut self, sessions: &RadiusAccountingSessions, now: Instant) { + self.wake_at = radius_expiry_wake_at(sessions, now); + self.sleep + .as_mut() + .reset(tokio::time::Instant::from_std(self.wake_at)); + } + + fn schedule_after_update( + &mut self, + sessions: &RadiusAccountingSessions, + update: &AccountingSessionUpdate, + now: Instant, + ) { + let changed_deadline = sessions.next_changed_expiry_deadline(update); + let next_wake_at = changed_deadline + .map(|deadline| deadline.min(now + sessions.expiry_check_interval())) + .unwrap_or(self.wake_at); + + if next_wake_at < self.wake_at { + self.wake_at = next_wake_at; + self.sleep + .as_mut() + .reset(tokio::time::Instant::from_std(self.wake_at)); + } + } + + fn is_due(&self, now: Instant) -> bool { + now >= self.wake_at + } + + fn sleep_mut(&mut self) -> Pin<&mut Sleep> { + self.sleep.as_mut() + } +} + +fn radius_expiry_wake_at(sessions: &RadiusAccountingSessions, now: Instant) -> Instant { + let next_cleanup = now + sessions.expiry_check_interval(); + sessions + .next_expiry_deadline() + .map_or(next_cleanup, |deadline| deadline.min(next_cleanup)) +} + +fn radius_accounting_now() -> Instant { + tokio::time::Instant::now().into_std() +} + +fn expire_due_before_packet( + sessions: &mut RadiusAccountingSessions, + expiry_timer: &mut RadiusExpiryTimer, + now: Instant, + applying_sink: &mut Option, +) { + if expiry_timer.is_due(now) { + expire_due_and_log_with_application_sink(sessions, now, applying_sink); + expiry_timer.reset_from_sessions(sessions, now); + } +} + +fn expire_due_after_timer_wake( + sessions: &mut RadiusAccountingSessions, + expiry_timer: &mut RadiusExpiryTimer, + now: Instant, + applying_sink: &mut Option, +) { + expire_due_and_log_with_application_sink(sessions, now, applying_sink); + expiry_timer.reset_from_sessions(sessions, now); +} + +fn dynamic_circuit_application_sink( + apply_dynamic_circuits: bool, + dynamic_circuit_bus_tx: DynamicCircuitBusSender, +) -> Option { + apply_dynamic_circuits.then(|| ApplyingDynamicCircuitSink::new(dynamic_circuit_bus_tx)) +} + +fn handle_listener_outcome_with_application_sink( + outcome: AccountingListenerOutcome, + sessions: &mut RadiusAccountingSessions, + expiry_timer: &mut RadiusExpiryTimer, + now: Instant, + applying_sink: &mut Option, +) { + match outcome { + AccountingListenerOutcome::Accepted(accepted) => { + sessions.record_packet_accepted(); + let event = AccountingEvent::from_verified(&accepted.request); + handle_accounting_event_with_application_sink_and_expiry_schedule( + event, + sessions, + expiry_timer, + now, + applying_sink, + AccountingPacketLogContext { + peer: accepted.peer, + received_len: accepted.received_len, + response_len: accepted.response_len, + }, + ); + } + AccountingListenerOutcome::RejectedSource { peer, received_len } => { + sessions.record_packet_rejected(); + warn!( + peer = %peer, + received_len, + "rejected RADIUS accounting packet from untrusted source" + ); + } + AccountingListenerOutcome::RejectedAmbiguousSource { peer, received_len } => { + sessions.record_packet_rejected(); + warn!( + peer = %peer, + received_len, + "rejected RADIUS accounting packet matching multiple trusted clients" + ); + } + AccountingListenerOutcome::RejectedPacket { + peer, + received_len, + source, + } => { + sessions.record_packet_rejected(); + warn!( + peer = %peer, + received_len, + error = %source, + "rejected RADIUS accounting packet" + ); + } + } +} + +fn handle_accounting_event_with_application_sink_and_expiry_schedule( + event: AccountingEvent, + sessions: &mut RadiusAccountingSessions, + expiry_timer: &mut RadiusExpiryTimer, + now: Instant, + applying_sink: &mut Option, + log_context: AccountingPacketLogContext, +) { + let update = { + let mut command_sink = selected_dynamic_circuit_sink(applying_sink); + handle_accounting_event_with_command_sink( + event, + sessions, + &mut command_sink, + now, + log_context.peer, + log_context.received_len, + log_context.response_len, + ) + }; + expiry_timer.schedule_after_update(sessions, &update, now); + trace_activation_diagnostics(sessions, applying_sink.as_ref()); +} + +#[derive(Clone, Copy)] +struct AccountingPacketLogContext { + peer: SocketAddr, + received_len: usize, + response_len: usize, +} + +fn expire_due_with_application_sink( + sessions: &mut RadiusAccountingSessions, + now: Instant, + applying_sink: &mut Option, +) -> usize { + let mut command_sink = selected_dynamic_circuit_sink(applying_sink); + sessions.expire_due_with_command_sink(now, &mut command_sink) +} + +fn expire_due_and_log_with_application_sink( + sessions: &mut RadiusAccountingSessions, + now: Instant, + applying_sink: &mut Option, +) { + let expired = expire_due_with_application_sink(sessions, now, applying_sink); + if expired > 0 { + debug!(expired, "expired RADIUS accounting session(s)"); + } + trace_activation_diagnostics(sessions, applying_sink.as_ref()); +} + +fn trace_activation_diagnostics( + sessions: &RadiusAccountingSessions, + applying_sink: Option<&ApplyingDynamicCircuitSink>, +) { + if !tracing::enabled!(Level::TRACE) { + return; + } + let counters = sessions.activation_counters(); + let packet_counters = sessions.packet_counters(); + let diagnostics = applying_sink.map_or_else( + || sessions.activation_diagnostics(), + |sink| sink.activation_diagnostics(sessions), + ); + trace!( + ?counters, + ?packet_counters, + diagnostic_count = diagnostics.len(), + "retained RADIUS activation diagnostic snapshot" + ); + for diagnostic in diagnostics { + trace!(?diagnostic, "retained RADIUS activation diagnostic"); + } +} + +fn push_limited(items: &mut VecDeque, item: T, limit: usize) { + if limit == 0 { + return; + } + items.push_back(item); + while items.len() > limit { + items.pop_front(); + } +} + +fn next_update_sequence(next_sequence: &mut u64) -> u64 { + let sequence = *next_sequence; + *next_sequence = next_sequence.wrapping_add(1); + sequence +} + +enum SelectedDynamicCircuitSink<'a> { + Applying(&'a mut ApplyingDynamicCircuitSink), + Deferred(DeferredDynamicCircuitSink), +} + +impl DynamicCircuitCommandSink for SelectedDynamicCircuitSink<'_> { + fn emit(&mut self, intent: DynamicCircuitIntent) { + match self { + Self::Applying(sink) => sink.emit(intent), + Self::Deferred(sink) => sink.emit(intent), + } + } +} + +fn selected_dynamic_circuit_sink( + applying_sink: &mut Option, +) -> SelectedDynamicCircuitSink<'_> { + applying_sink.as_mut().map_or_else( + || SelectedDynamicCircuitSink::Deferred(DeferredDynamicCircuitSink::application_disabled()), + SelectedDynamicCircuitSink::Applying, + ) +} + +#[cfg(test)] +fn handle_accounting_event( + event: AccountingEvent, + sessions: &mut RadiusAccountingSessions, + now: Instant, + peer: SocketAddr, + received_len: usize, + response_len: usize, +) { + let mut command_sink = DeferredDynamicCircuitSink::application_disabled(); + handle_accounting_event_with_command_sink( + event, + sessions, + &mut command_sink, + now, + peer, + received_len, + response_len, + ); +} + +fn handle_accounting_event_with_command_sink( + event: AccountingEvent, + sessions: &mut RadiusAccountingSessions, + command_sink: &mut impl DynamicCircuitCommandSink, + now: Instant, + peer: SocketAddr, + received_len: usize, + response_len: usize, +) -> AccountingSessionUpdate { + let status = event.status_type; + let update = sessions.apply_event_with_command_sink(event, now, command_sink); + debug!( + peer = %peer, + received_len, + response_len, + ?status, + ?update, + "accepted RADIUS accounting packet" + ); + update +} + +fn listener_error_is_recoverable(err: &lqos_radius::ListenerError) -> bool { + matches!(err, lqos_radius::ListenerError::Send { .. }) +} + +struct RadiusAccountingSessions { + store: AccountingSessionStore, + updated_at: HashMap, + update_sequence_by_key: HashMap, + next_update_sequence: u64, + activation_diagnostics_by_key: HashMap, + recent_expired_activation_diagnostics: VecDeque, + packet_counters: RadiusPacketCounters, + default_ttl: Duration, + stale_grace: Duration, + fallback_rate_profile: Option, + fallback_parent: Option, + mac_matcher: Option, + match_shaped_devices_by_username: bool, + match_shaped_devices_by_mac: bool, +} + +impl RadiusAccountingSessions { + #[cfg(test)] + fn new(default_ttl: Duration, stale_grace: Duration) -> Self { + Self::new_with_fallback_and_mac_matcher(default_ttl, stale_grace, None, None, None) + } + + #[cfg(test)] + fn new_with_fallback_and_mac_matcher( + default_ttl: Duration, + stale_grace: Duration, + fallback_rate_profile: Option, + fallback_parent: Option, + mac_matcher: Option, + ) -> Self { + Self::new_with_fallback_and_identity_matcher( + default_ttl, + stale_grace, + fallback_rate_profile, + fallback_parent, + mac_matcher, + false, + true, + ) + } + + fn new_with_fallback_and_identity_matcher( + default_ttl: Duration, + stale_grace: Duration, + fallback_rate_profile: Option, + fallback_parent: Option, + mac_matcher: Option, + match_shaped_devices_by_username: bool, + match_shaped_devices_by_mac: bool, + ) -> Self { + Self { + store: AccountingSessionStore::new(), + updated_at: HashMap::new(), + update_sequence_by_key: HashMap::new(), + next_update_sequence: 0, + activation_diagnostics_by_key: HashMap::new(), + recent_expired_activation_diagnostics: VecDeque::new(), + packet_counters: RadiusPacketCounters::default(), + default_ttl, + stale_grace, + fallback_rate_profile, + fallback_parent, + mac_matcher, + match_shaped_devices_by_username, + match_shaped_devices_by_mac, + } + } + + #[cfg(test)] + fn apply_event(&mut self, event: AccountingEvent, now: Instant) -> AccountingSessionUpdate { + let mut command_sink = DeferredDynamicCircuitSink::application_disabled(); + self.apply_event_with_command_sink(event, now, &mut command_sink) + } + + fn apply_event_with_command_sink( + &mut self, + event: AccountingEvent, + now: Instant, + command_sink: &mut impl DynamicCircuitCommandSink, + ) -> AccountingSessionUpdate { + let update = if let Some(mac_matcher) = &self.mac_matcher { + self.store + .apply_event_with_shaped_devices_matcher_and_commands( + event, + mac_matcher, + ShapedDevicesMatchOptions { + match_by_username: self.match_shaped_devices_by_username, + match_by_mac: self.match_shaped_devices_by_mac, + fallback_profile: self.fallback_rate_profile, + fallback_parent: self.fallback_parent.clone(), + }, + command_sink, + ) + } else { + let mapping = self + .fallback_parent + .clone() + .map(DynamicCircuitMapping::ReadyWithParent) + .unwrap_or(DynamicCircuitMapping::MissingParent); + self.store + .apply_event_with_dynamic_circuit_resolution_and_commands( + event, + DynamicCircuitResolution { + mapping, + rate_sources: SessionRateSources { + shaped_device_profile: None, + fallback_profile: self.fallback_rate_profile, + }, + matched_shaped_device: None, + }, + command_sink, + ) + }; + self.record_update(&update, now); + if self.updated_at.len() > self.store.len() { + self.prune_removed_sessions(); + self.prune_retained_activation_diagnostics(); + } + self.record_activation_diagnostics_for_update(&update); + update + } + + fn activation_counters(&self) -> lqos_radius::RadiusActivationCounters { + self.store.activation_counters() + } + + fn packet_counters(&self) -> RadiusPacketCounters { + self.packet_counters + } + + fn record_packet_accepted(&mut self) { + self.packet_counters.accepted += 1; + } + + fn record_packet_rejected(&mut self) { + self.packet_counters.rejected += 1; + } + + fn activation_diagnostics(&self) -> Vec { + self.activation_diagnostics_by_key + .values() + .chain(self.recent_expired_activation_diagnostics.iter()) + .cloned() + .collect() + } + + fn record_activation_diagnostics_for_update(&mut self, update: &AccountingSessionUpdate) { + match update { + AccountingSessionUpdate::SessionUpdated { key, .. } => { + self.record_retained_activation_diagnostic(key); + } + AccountingSessionUpdate::NasSessionsMarkedStale { + stale_session_keys, .. + } => { + for key in stale_session_keys { + self.record_retained_activation_diagnostic(key); + } + } + AccountingSessionUpdate::Ignored { .. } => {} + } + } + + fn record_retained_activation_diagnostic(&mut self, key: &AccountingSessionKey) { + let Some(session) = self.store.session(key) else { + self.activation_diagnostics_by_key.remove(key); + return; + }; + let diagnostic = RadiusActivationDiagnostic::from_retained_session(key, session); + self.remove_recent_expired_activation_diagnostic(key); + self.activation_diagnostics_by_key + .insert(key.clone(), diagnostic); + } + + fn remove_recent_expired_activation_diagnostic(&mut self, key: &AccountingSessionKey) { + if self.recent_expired_activation_diagnostics.is_empty() { + return; + } + self.recent_expired_activation_diagnostics + .retain(|diagnostic| &diagnostic.session_key != key); + } + + fn prune_retained_activation_diagnostics(&mut self) { + let store = &self.store; + self.activation_diagnostics_by_key + .retain(|key, _| store.session(key).is_some()); + } + + fn record_update(&mut self, update: &AccountingSessionUpdate, now: Instant) { + match update { + AccountingSessionUpdate::SessionUpdated { key, .. } => { + self.record_session_update_time(key, now); + } + AccountingSessionUpdate::NasSessionsMarkedStale { + newly_stale_session_keys, + .. + } => { + for key in newly_stale_session_keys { + self.updated_at.insert(key.clone(), now); + if !self.update_sequence_by_key.contains_key(key) { + let sequence = next_update_sequence(&mut self.next_update_sequence); + self.update_sequence_by_key.insert(key.clone(), sequence); + } + } + } + AccountingSessionUpdate::Ignored { .. } => {} + } + } + + fn prune_removed_sessions(&mut self) { + let store = &self.store; + self.updated_at + .retain(|key, _| store.session(key).is_some()); + self.update_sequence_by_key + .retain(|key, _| store.session(key).is_some()); + } + + fn record_session_update_time(&mut self, key: &AccountingSessionKey, now: Instant) { + self.updated_at.insert(key.clone(), now); + self.update_sequence_by_key.insert( + key.clone(), + next_update_sequence(&mut self.next_update_sequence), + ); + } + + #[cfg(test)] + fn expire_due(&mut self, now: Instant) -> usize { + let mut command_sink = DeferredDynamicCircuitSink::application_disabled(); + self.expire_due_with_command_sink(now, &mut command_sink) + } + + fn expire_due_with_command_sink( + &mut self, + now: Instant, + command_sink: &mut impl DynamicCircuitCommandSink, + ) -> usize { + let mut expired_keys = self + .store + .sessions() + .filter_map(|(key, session)| { + let updated_at = *self.updated_at.get(key)?; + let ttl = self.expiry_duration_for(session.state); + let deadline = updated_at + ttl; + let update_sequence = self.update_sequence_by_key.get(key).copied().unwrap_or(0); + (now >= deadline).then(|| (deadline, update_sequence, key.clone())) + }) + .collect::>(); + expired_keys.sort_by_key(|(deadline, update_sequence, _)| (*deadline, *update_sequence)); + + let expired_count = expired_keys.len(); + for (_, _, key) in expired_keys { + self.updated_at.remove(&key); + self.update_sequence_by_key.remove(&key); + if let Some(expired_session) = + self.store.expire_session_with_commands(&key, command_sink) + { + self.activation_diagnostics_by_key.remove(&key); + push_limited( + &mut self.recent_expired_activation_diagnostics, + RadiusActivationDiagnostic::from_expired_session(&key, &expired_session), + RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT, + ); + } + } + expired_count + } + + fn next_expiry_deadline(&self) -> Option { + self.store + .sessions() + .filter_map(|(key, session)| { + self.updated_at + .get(key) + .map(|updated_at| *updated_at + self.expiry_duration_for(session.state)) + }) + .min() + } + + fn next_changed_expiry_deadline(&self, update: &AccountingSessionUpdate) -> Option { + match update { + AccountingSessionUpdate::SessionUpdated { key, .. } => self.expiry_deadline_for(key), + AccountingSessionUpdate::NasSessionsMarkedStale { + newly_stale_session_keys, + .. + } => newly_stale_session_keys + .iter() + .filter_map(|key| self.expiry_deadline_for(key)) + .min(), + AccountingSessionUpdate::Ignored { .. } => None, + } + } + + fn expiry_deadline_for(&self, key: &AccountingSessionKey) -> Option { + let session = self.store.session(key)?; + let updated_at = self.updated_at.get(key)?; + Some(*updated_at + self.expiry_duration_for(session.state)) + } + + fn expiry_duration_for(&self, state: AccountingSessionState) -> Duration { + match state { + AccountingSessionState::Stale(_) => self.stale_grace, + AccountingSessionState::Active | AccountingSessionState::Stopped => self.default_ttl, + } + } + + fn expiry_check_interval(&self) -> Duration { + cleanup_interval(self.default_ttl, self.stale_grace) + } +} + +fn cleanup_interval(default_ttl: Duration, stale_grace: Duration) -> Duration { + Duration::from_secs(default_ttl.min(stale_grace).as_secs().clamp(1, 60)) +} + +struct DeferredDynamicCircuitSink { + reason: &'static str, +} + +impl DeferredDynamicCircuitSink { + const fn application_disabled() -> Self { + Self { + reason: "daemon-side application is disabled", + } + } +} + +impl DynamicCircuitCommandSink for DeferredDynamicCircuitSink { + fn emit(&mut self, intent: DynamicCircuitIntent) { + let intent_name = dynamic_circuit_intent_name(&intent); + debug!( + circuit_id = intent.circuit_id(), + intent = intent_name, + reason = self.reason, + "RADIUS dynamic-circuit intent deferred" + ); + } +} + +struct ApplyingDynamicCircuitSink { + queue_tx: mpsc::Sender, + application_state: Arc>, + next_sequence: u64, +} + +impl ApplyingDynamicCircuitSink { + fn new(bus_tx: DynamicCircuitBusSender) -> Self { + Self::new_with_capacity(bus_tx, DYNAMIC_CIRCUIT_APPLICATION_QUEUE_CAPACITY) + } + + fn new_with_capacity(bus_tx: DynamicCircuitBusSender, capacity: usize) -> Self { + let (queue_tx, queue_rx) = mpsc::channel(capacity); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + tokio::spawn(run_dynamic_circuit_application_worker( + bus_tx, + application_state.clone(), + queue_rx, + )); + Self { + queue_tx, + application_state, + next_sequence: 0, + } + } + + #[cfg(test)] + fn new_with_queue_for_test( + queue_tx: mpsc::Sender, + application_state: Arc>, + next_sequence: u64, + ) -> Self { + Self { + queue_tx, + application_state, + next_sequence, + } + } + + fn next_sequence(&mut self) -> u64 { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + sequence + } + + fn activation_diagnostics( + &self, + sessions: &RadiusAccountingSessions, + ) -> Vec { + let mut diagnostics = sessions.activation_diagnostics(); + let application_diagnostics = self.application_state.lock().application_diagnostics(); + let failed_owners = application_diagnostics + .iter() + .flat_map(diagnostic_owner_keys) + .collect::>(); + suppress_failed_owner_diagnostics(&mut diagnostics, &failed_owners); + diagnostics.extend(application_diagnostics); + diagnostics + } +} + +fn suppress_failed_owner_diagnostics( + diagnostics: &mut Vec, + failed_owners: &HashSet, +) { + diagnostics.retain_mut(|diagnostic| { + let had_circuit_ids = !diagnostic.circuit_ids.is_empty(); + let session_key = diagnostic.session_key.clone(); + diagnostic.circuit_ids.retain(|circuit_id| { + !failed_owners.contains(&(circuit_id.clone(), session_key.clone())) + }); + !had_circuit_ids || !diagnostic.circuit_ids.is_empty() + }); +} + +fn diagnostic_owner_keys(diagnostic: &RadiusActivationDiagnostic) -> Vec { + diagnostic + .circuit_ids + .iter() + .map(|circuit_id| (circuit_id.clone(), diagnostic.session_key.clone())) + .collect() +} + +impl DynamicCircuitCommandSink for ApplyingDynamicCircuitSink { + fn emit(&mut self, intent: DynamicCircuitIntent) { + let context = DynamicCircuitApplicationContext::from_intent(&intent); + let is_upsert = matches!( + intent, + DynamicCircuitIntent::CreateDynamicCircuit(_) + | DynamicCircuitIntent::UpdateDynamicCircuit(_) + ); + let is_removal = matches!(intent, DynamicCircuitIntent::RemoveDynamicCircuit(_)); + let sequence = self.next_sequence(); + if is_upsert { + self.application_state + .lock() + .track_pending_upsert(&context, sequence); + } + if is_removal { + self.application_state + .lock() + .track_deferred_removal_for_pending_upserts(&context, sequence); + } + let queued_intent = QueuedDynamicCircuitIntent { sequence, intent }; + + if let Err(queue_failure) = try_queue_dynamic_circuit_intent(&self.queue_tx, queued_intent) + { + let queue_failure = *queue_failure; + let error = log_dynamic_circuit_queue_unavailable(&context, queue_failure.reason); + let defer_removal_until_worker_drains = + is_removal && queue_failure.reason == DynamicCircuitQueueUnavailableReason::Full; + let mut application_state = self.application_state.lock(); + application_state.record_application_failure(&context, error.to_string()); + if is_upsert { + application_state.clear_pending_upsert(&context, sequence); + } + if defer_removal_until_worker_drains { + application_state.record_pending_removal(&context, queue_failure.queued_intent); + } else if is_removal { + log_dynamic_circuit_removal_not_queued(&context); + } + } + } +} + +struct QueuedDynamicCircuitIntent { + sequence: u64, + intent: DynamicCircuitIntent, +} + +struct DynamicCircuitQueueFailure { + reason: DynamicCircuitQueueUnavailableReason, + queued_intent: QueuedDynamicCircuitIntent, +} + +fn try_queue_dynamic_circuit_intent( + queue_tx: &mpsc::Sender, + queued_intent: QueuedDynamicCircuitIntent, +) -> Result<(), Box> { + match queue_tx.try_send(queued_intent) { + Ok(()) => Ok(()), + Err(mpsc::error::TrySendError::Full(queued_intent)) => { + Err(Box::new(DynamicCircuitQueueFailure { + reason: DynamicCircuitQueueUnavailableReason::Full, + queued_intent, + })) + } + Err(mpsc::error::TrySendError::Closed(queued_intent)) => { + Err(Box::new(DynamicCircuitQueueFailure { + reason: DynamicCircuitQueueUnavailableReason::Closed, + queued_intent, + })) + } + } +} + +#[cfg(test)] +fn queue_dynamic_circuit_intent( + queue_tx: &mpsc::Sender, + context: &DynamicCircuitApplicationContext, + queued_intent: QueuedDynamicCircuitIntent, +) -> Result<(), DynamicCircuitApplicationError> { + match try_queue_dynamic_circuit_intent(queue_tx, queued_intent) { + Ok(()) => Ok(()), + Err(queue_failure) => Err(log_dynamic_circuit_queue_unavailable( + context, + queue_failure.reason, + )), + } +} + +fn log_dynamic_circuit_queue_unavailable( + context: &DynamicCircuitApplicationContext, + reason: DynamicCircuitQueueUnavailableReason, +) -> DynamicCircuitApplicationError { + log_dynamic_circuit_application_result( + context, + Err(DynamicCircuitApplicationError::QueueUnavailable(reason)), + ); + DynamicCircuitApplicationError::QueueUnavailable(reason) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DynamicCircuitQueueUnavailableReason { + Full, + Closed, +} + +impl fmt::Display for DynamicCircuitQueueUnavailableReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Full => "queue full", + Self::Closed => "queue closed", + }) + } +} + +async fn run_dynamic_circuit_application_worker( + bus_tx: DynamicCircuitBusSender, + application_state: Arc>, + mut queue_rx: mpsc::Receiver, +) { + while let Some(queued_intent) = queue_rx.recv().await { + process_dynamic_circuit_intent_and_pending_removals( + bus_tx.clone(), + &application_state, + queued_intent, + ) + .await; + } +} + +async fn process_dynamic_circuit_intent_and_pending_removals( + bus_tx: DynamicCircuitBusSender, + application_state: &Arc>, + queued_intent: QueuedDynamicCircuitIntent, +) { + process_dynamic_circuit_intent(bus_tx.clone(), application_state, queued_intent).await; + drain_pending_dynamic_circuit_removals(bus_tx, application_state).await; +} + +async fn drain_pending_dynamic_circuit_removals( + bus_tx: DynamicCircuitBusSender, + application_state: &Arc>, +) { + loop { + let queued_intent = { + let mut application_state = application_state.lock(); + application_state.take_next_pending_removal() + }; + let Some(queued_intent) = queued_intent else { + break; + }; + process_dynamic_circuit_intent(bus_tx.clone(), application_state, queued_intent).await; + } +} + +async fn process_dynamic_circuit_intent( + bus_tx: DynamicCircuitBusSender, + application_state: &Arc>, + queued_intent: QueuedDynamicCircuitIntent, +) { + let QueuedDynamicCircuitIntent { sequence, intent } = queued_intent; + let context = DynamicCircuitApplicationContext::from_intent(&intent); + match intent { + DynamicCircuitIntent::CreateDynamicCircuit(upsert) + | DynamicCircuitIntent::UpdateDynamicCircuit(upsert) => { + process_dynamic_circuit_upsert(bus_tx, application_state, context, sequence, upsert) + .await; + } + DynamicCircuitIntent::RemoveDynamicCircuit(removal) => { + process_dynamic_circuit_removal(bus_tx, application_state, context, sequence, removal) + .await; + } + } +} + +async fn process_dynamic_circuit_upsert( + bus_tx: DynamicCircuitBusSender, + application_state: &Arc>, + context: DynamicCircuitApplicationContext, + sequence: u64, + upsert: DynamicCircuitUpsert, +) { + if application_state + .lock() + .upsert_was_removed_before_application(&context, sequence) + { + log_dynamic_circuit_stale_upsert_dropped(&context); + application_state + .lock() + .clear_pending_upsert(&context, sequence); + return; + } + + let shared_upsert_may_apply = { + let mut application_state = application_state.lock(); + let may_apply = application_state.shared_upsert_may_apply(&context); + if !may_apply { + application_state.record_retained_upsert(&context, sequence, upsert.clone()); + } + may_apply + }; + + if !shared_upsert_may_apply { + log_dynamic_circuit_shared_upsert_retained(&context); + application_state + .lock() + .clear_pending_upsert(&context, sequence); + return; + } + + let request = BusRequest::CreateDynamicCircuit { + shaped_device: Box::new(upsert.shaped_device), + }; + apply_dynamic_circuit_request_and_record( + bus_tx, + application_state, + &context, + sequence, + request, + ) + .await; + application_state + .lock() + .clear_pending_upsert(&context, sequence); +} + +async fn process_dynamic_circuit_removal( + bus_tx: DynamicCircuitBusSender, + application_state: &Arc>, + context: DynamicCircuitApplicationContext, + sequence: u64, + removal: DynamicCircuitRemoval, +) { + let owner_context = { + let mut state = application_state.lock(); + let Some(owner_context) = state.removal_owner_context(&context, removal.reason) else { + state.record_application_success(&context); + log_dynamic_circuit_removal_skipped(&context); + return; + }; + let removal_is_stale = state.removal_is_stale(&owner_context, sequence); + if removal_is_stale { + log_dynamic_circuit_removal_skipped(&owner_context); + return; + } + owner_context + }; + let action = application_state.lock().removal_action(&owner_context); + match action { + DynamicCircuitRemovalAction::ReleaseOnly => { + application_state.lock().release_owner(&owner_context); + log_dynamic_circuit_removal_skipped(&owner_context); + } + DynamicCircuitRemovalAction::Remove => { + apply_dynamic_circuit_removal_request( + bus_tx, + application_state, + &owner_context, + removal.circuit_id, + None, + ) + .await; + } + DynamicCircuitRemovalAction::Promote(upsert) => { + let retained_upsert = *upsert; + let upsert = retained_upsert.upsert; + let promoted_context = DynamicCircuitApplicationContext::from_upsert("update", &upsert); + let request = BusRequest::CreateDynamicCircuit { + shaped_device: Box::new(upsert.shaped_device), + }; + let result = apply_dynamic_circuit_request(bus_tx.clone(), request).await; + if dynamic_circuit_upsert_result_records_success(&result) { + let mut application_state = application_state.lock(); + application_state.release_owner(&owner_context); + application_state + .record_applied_upsert(&promoted_context, retained_upsert.first_sequence); + application_state.record_application_success(&promoted_context); + log_dynamic_circuit_retained_upsert_promoted(&owner_context, &promoted_context); + log_dynamic_circuit_application_result(&promoted_context, result); + return; + } + if let Err(err) = &result { + application_state + .lock() + .record_application_failure(&promoted_context, err.to_string()); + } + log_dynamic_circuit_application_result(&promoted_context, result); + + apply_dynamic_circuit_removal_request( + bus_tx, + application_state, + &owner_context, + removal.circuit_id, + Some(&promoted_context), + ) + .await; + } + } +} + +#[derive(Clone)] +struct DynamicCircuitApplicationContext { + intent_name: &'static str, + circuit_id: String, + session_key: AccountingSessionKey, +} + +type DynamicCircuitOwnerKey = (String, AccountingSessionKey); + +impl DynamicCircuitApplicationContext { + fn from_intent(intent: &DynamicCircuitIntent) -> Self { + let intent_name = dynamic_circuit_intent_name(intent); + match intent { + DynamicCircuitIntent::CreateDynamicCircuit(upsert) => { + Self::from_upsert(intent_name, upsert) + } + DynamicCircuitIntent::UpdateDynamicCircuit(upsert) => { + Self::from_upsert(intent_name, upsert) + } + DynamicCircuitIntent::RemoveDynamicCircuit(removal) => Self { + intent_name, + circuit_id: removal.circuit_id.clone(), + session_key: removal.session_key.clone(), + }, + } + } + + fn from_upsert(intent_name: &'static str, upsert: &DynamicCircuitUpsert) -> Self { + Self { + intent_name, + circuit_id: upsert.circuit_id.clone(), + session_key: upsert.session_key.clone(), + } + } + + fn owner_key(&self) -> DynamicCircuitOwnerKey { + (self.circuit_id.clone(), self.session_key.clone()) + } +} + +#[derive(Default)] +struct DynamicCircuitApplicationState { + current_owners_by_circuit: HashMap>, + authoritative_session_by_circuit: HashMap, + pending_upsert_sequences_by_owner: HashMap>, + deferred_removal_sequence_by_owner: HashMap, + pending_removals_by_owner: HashMap, + pending_removal_order: VecDeque, + latest_applied_upsert_sequence_by_owner: HashMap, + retained_upserts_by_owner: HashMap, + retained_owners_by_circuit: HashMap>, + application_diagnostics_by_owner: HashMap, + application_diagnostic_order: VecDeque, +} + +#[derive(Clone)] +struct RetainedDynamicCircuitUpsert { + first_sequence: u64, + upsert: DynamicCircuitUpsert, +} + +enum DynamicCircuitRemovalAction { + ReleaseOnly, + Remove, + Promote(Box), +} + +impl DynamicCircuitApplicationState { + fn record_application_success(&mut self, context: &DynamicCircuitApplicationContext) { + let owner_key = context.owner_key(); + self.application_diagnostics_by_owner.remove(&owner_key); + self.application_diagnostic_order + .retain(|retained_owner| retained_owner != &owner_key); + } + + fn record_application_failure( + &mut self, + context: &DynamicCircuitApplicationContext, + error: String, + ) { + let owner_key = context.owner_key(); + self.application_diagnostic_order + .retain(|existing_owner| existing_owner != &owner_key); + push_limited( + &mut self.application_diagnostic_order, + owner_key.clone(), + RADIUS_APPLY_FAILED_DIAGNOSTIC_LIMIT, + ); + self.application_diagnostics_by_owner.insert( + owner_key, + RadiusActivationDiagnostic::apply_failed( + context.session_key.clone(), + context.circuit_id.clone(), + error, + ), + ); + self.prune_application_diagnostics_to_order(); + } + + fn application_diagnostics(&self) -> Vec { + self.application_diagnostic_order + .iter() + .filter_map(|owner_key| self.application_diagnostics_by_owner.get(owner_key)) + .cloned() + .collect() + } + + fn prune_application_diagnostics_to_order(&mut self) { + let retained_owners = self + .application_diagnostic_order + .iter() + .cloned() + .collect::>(); + self.application_diagnostics_by_owner + .retain(|owner_key, _| retained_owners.contains(owner_key)); + } + + fn shared_upsert_may_apply(&self, context: &DynamicCircuitApplicationContext) -> bool { + let Some(session_keys) = self.current_owners_by_circuit.get(&context.circuit_id) else { + return true; + }; + if !session_keys + .iter() + .any(|session_key| session_key != &context.session_key) + { + return true; + } + + self.authoritative_session_by_circuit + .get(&context.circuit_id) + .is_none_or(|session_key| session_key == &context.session_key) + } + + fn record_applied_upsert(&mut self, context: &DynamicCircuitApplicationContext, sequence: u64) { + self.record_upsert_owner(context); + self.latest_applied_upsert_sequence_by_owner + .insert(context.owner_key(), sequence); + } + + #[cfg(test)] + fn record_upsert(&mut self, context: &DynamicCircuitApplicationContext) { + self.record_applied_upsert(context, 0); + } + + fn record_retained_upsert( + &mut self, + context: &DynamicCircuitApplicationContext, + sequence: u64, + upsert: DynamicCircuitUpsert, + ) { + let owner_key = context.owner_key(); + if let Some(removal_sequence) = self + .deferred_removal_sequence_by_owner + .get(&owner_key) + .copied() + { + if sequence <= removal_sequence { + return; + } + if !self.has_pending_upsert_at_or_before(&owner_key, removal_sequence) { + self.deferred_removal_sequence_by_owner.remove(&owner_key); + } + } + + self.record_upsert_owner(context); + self.retained_upserts_by_owner + .entry(owner_key.clone()) + .and_modify(|retained| retained.upsert = upsert.clone()) + .or_insert(RetainedDynamicCircuitUpsert { + first_sequence: sequence, + upsert, + }); + self.retained_owners_by_circuit + .entry(owner_key.0) + .or_default() + .insert(owner_key.1); + } + + fn record_upsert_owner(&mut self, context: &DynamicCircuitApplicationContext) { + let circuit_id = context.circuit_id.clone(); + let session_key = context.session_key.clone(); + self.remove_retained_upsert(&context.owner_key()); + self.current_owners_by_circuit + .entry(circuit_id.clone()) + .or_default() + .insert(session_key.clone()); + self.authoritative_session_by_circuit + .entry(circuit_id.clone()) + .or_insert_with(|| session_key.clone()); + } + + fn track_pending_upsert(&mut self, context: &DynamicCircuitApplicationContext, sequence: u64) { + self.pending_upsert_sequences_by_owner + .entry(context.owner_key()) + .or_default() + .insert(sequence); + } + + fn upsert_was_removed_before_application( + &self, + context: &DynamicCircuitApplicationContext, + sequence: u64, + ) -> bool { + self.deferred_removal_sequence_by_owner + .get(&context.owner_key()) + .is_some_and(|removal_sequence| sequence <= *removal_sequence) + } + + fn clear_pending_upsert(&mut self, context: &DynamicCircuitApplicationContext, sequence: u64) { + let owner_key = context.owner_key(); + if let Some(pending_sequences) = self.pending_upsert_sequences_by_owner.get_mut(&owner_key) + { + pending_sequences.remove(&sequence); + if pending_sequences.is_empty() { + self.pending_upsert_sequences_by_owner.remove(&owner_key); + } + } + self.prune_deferred_removal(&owner_key); + } + + fn prune_deferred_removal(&mut self, owner_key: &DynamicCircuitOwnerKey) { + let Some(removal_sequence) = self + .deferred_removal_sequence_by_owner + .get(owner_key) + .copied() + else { + return; + }; + if !self.has_pending_upsert_at_or_before(owner_key, removal_sequence) { + self.deferred_removal_sequence_by_owner.remove(owner_key); + } + } + + fn has_pending_upsert_at_or_before( + &self, + owner_key: &DynamicCircuitOwnerKey, + removal_sequence: u64, + ) -> bool { + self.pending_upsert_sequences_by_owner + .get(owner_key) + .is_some_and(|pending_sequences| { + pending_sequences + .iter() + .any(|pending_sequence| *pending_sequence <= removal_sequence) + }) + } + + fn track_deferred_removal_for_pending_upserts( + &mut self, + context: &DynamicCircuitApplicationContext, + sequence: u64, + ) { + let owner_key = context.owner_key(); + if self.has_pending_upsert_at_or_before(&owner_key, sequence) { + self.deferred_removal_sequence_by_owner + .insert(owner_key, sequence); + } + } + + fn record_pending_removal( + &mut self, + context: &DynamicCircuitApplicationContext, + queued_intent: QueuedDynamicCircuitIntent, + ) { + let owner_key = context.owner_key(); + if self.pending_removals_by_owner.contains_key(&owner_key) { + self.pending_removal_order + .retain(|pending_owner| pending_owner != &owner_key); + } + self.pending_removals_by_owner + .insert(owner_key.clone(), queued_intent); + self.pending_removal_order.push_back(owner_key); + } + + fn take_next_pending_removal(&mut self) -> Option { + while let Some(owner_key) = self.pending_removal_order.pop_front() { + if let Some(queued_intent) = self.pending_removals_by_owner.remove(&owner_key) { + return Some(queued_intent); + } + } + None + } + + fn removal_is_stale( + &self, + context: &DynamicCircuitApplicationContext, + removal_sequence: u64, + ) -> bool { + self.latest_applied_upsert_sequence_by_owner + .get(&context.owner_key()) + .is_some_and(|upsert_sequence| *upsert_sequence > removal_sequence) + } + + fn release_owner(&mut self, context: &DynamicCircuitApplicationContext) { + let owner_was_authoritative = self + .authoritative_session_by_circuit + .get(&context.circuit_id) + .is_some_and(|session_key| session_key == &context.session_key); + let owner_key = context.owner_key(); + self.record_application_success(context); + self.remove_retained_upsert(&owner_key); + self.pending_upsert_sequences_by_owner.remove(&owner_key); + self.deferred_removal_sequence_by_owner.remove(&owner_key); + self.pending_removals_by_owner.remove(&owner_key); + self.latest_applied_upsert_sequence_by_owner + .remove(&owner_key); + + let remaining_owners = if let Some(session_keys) = + self.current_owners_by_circuit.get_mut(&context.circuit_id) + { + session_keys.remove(&context.session_key); + session_keys.len() + } else { + 0 + }; + + if owner_was_authoritative { + self.authoritative_session_by_circuit + .remove(&context.circuit_id); + } + if remaining_owners == 0 { + self.current_owners_by_circuit.remove(&context.circuit_id); + self.authoritative_session_by_circuit + .remove(&context.circuit_id); + } + } + + fn removal_action( + &self, + context: &DynamicCircuitApplicationContext, + ) -> DynamicCircuitRemovalAction { + let owner_is_authoritative = self + .authoritative_session_by_circuit + .get(&context.circuit_id) + .is_some_and(|session_key| session_key == &context.session_key); + if !owner_is_authoritative { + return DynamicCircuitRemovalAction::ReleaseOnly; + } + + self.promotable_retained_upsert(context) + .map(|upsert| DynamicCircuitRemovalAction::Promote(Box::new(upsert))) + .unwrap_or(DynamicCircuitRemovalAction::Remove) + } + + fn removal_owner_context( + &self, + context: &DynamicCircuitApplicationContext, + reason: lqos_radius::DynamicCircuitRemovalReason, + ) -> Option { + if self + .current_owners_by_circuit + .get(&context.circuit_id) + .is_some_and(|session_keys| session_keys.contains(&context.session_key)) + { + return Some(context.clone()); + } + + if reason != lqos_radius::DynamicCircuitRemovalReason::Rekeyed { + return None; + } + + let session_keys = self.current_owners_by_circuit.get(&context.circuit_id)?; + if session_keys.len() != 1 { + return None; + } + let owner = session_keys.iter().next()?; + if self + .authoritative_session_by_circuit + .get(&context.circuit_id) + != Some(owner) + { + return None; + } + + Some(DynamicCircuitApplicationContext { + intent_name: context.intent_name, + circuit_id: context.circuit_id.clone(), + session_key: owner.clone(), + }) + } + + fn promotable_retained_upsert( + &self, + context: &DynamicCircuitApplicationContext, + ) -> Option { + let retained_owners = self.retained_owners_by_circuit.get(&context.circuit_id)?; + retained_owners + .iter() + .filter(|session_key| *session_key != &context.session_key) + .filter_map(|session_key| { + self.retained_upserts_by_owner + .get(&(context.circuit_id.clone(), session_key.clone())) + }) + .min_by_key(|retained| retained.first_sequence) + .cloned() + } + + fn remove_retained_upsert(&mut self, owner_key: &DynamicCircuitOwnerKey) { + self.retained_upserts_by_owner.remove(owner_key); + if let Some(retained_owners) = self.retained_owners_by_circuit.get_mut(&owner_key.0) { + retained_owners.remove(&owner_key.1); + if retained_owners.is_empty() { + self.retained_owners_by_circuit.remove(&owner_key.0); + } + } + } +} + +async fn apply_dynamic_circuit_request_and_record( + bus_tx: DynamicCircuitBusSender, + application_state: &Arc>, + context: &DynamicCircuitApplicationContext, + sequence: u64, + request: BusRequest, +) { + let result = apply_dynamic_circuit_request(bus_tx, request).await; + if dynamic_circuit_upsert_result_records_success(&result) { + application_state + .lock() + .record_applied_upsert(context, sequence); + } + record_dynamic_circuit_application_result(application_state, context, &result); + log_dynamic_circuit_application_result(context, result); +} + +async fn apply_dynamic_circuit_removal_request( + bus_tx: DynamicCircuitBusSender, + application_state: &Arc>, + context: &DynamicCircuitApplicationContext, + circuit_id: String, + extra_release_context: Option<&DynamicCircuitApplicationContext>, +) { + let request = BusRequest::RemoveDynamicCircuit { circuit_id }; + let result = apply_dynamic_circuit_request(bus_tx, request).await; + if dynamic_circuit_removal_result_releases_owner(&result) { + let mut application_state = application_state.lock(); + application_state.release_owner(context); + if let Some(extra_release_context) = extra_release_context { + application_state.release_owner(extra_release_context); + } + } + record_dynamic_circuit_application_result(application_state, context, &result); + log_dynamic_circuit_application_result(context, result); +} + +fn record_dynamic_circuit_application_result( + application_state: &Arc>, + context: &DynamicCircuitApplicationContext, + result: &Result<(), DynamicCircuitApplicationError>, +) { + let mut application_state = application_state.lock(); + match result { + Ok(()) => application_state.record_application_success(context), + Err(err) => application_state.record_application_failure(context, err.to_string()), + } +} + +fn dynamic_circuit_upsert_result_records_success( + result: &Result<(), DynamicCircuitApplicationError>, +) -> bool { + // Once lqosd accepts an upsert into the internal bus, a missing reply is ambiguous. + // Tracking ownership keeps later shared-circuit upserts from overwriting it. + result.is_ok() + || matches!( + result, + Err(DynamicCircuitApplicationError::ReplyDropped + | DynamicCircuitApplicationError::ReplyTimeout) + ) +} + +fn dynamic_circuit_removal_result_releases_owner( + result: &Result<(), DynamicCircuitApplicationError>, +) -> bool { + result.is_ok() +} + +async fn apply_dynamic_circuit_request( + bus_tx: DynamicCircuitBusSender, + request: BusRequest, +) -> Result<(), DynamicCircuitApplicationError> { + let (reply_tx, reply_rx) = oneshot::channel(); + tokio::time::timeout( + DYNAMIC_CIRCUIT_APPLICATION_TIMEOUT, + bus_tx.send((reply_tx, request)), + ) + .await + .map_err(|_| DynamicCircuitApplicationError::BusSendTimeout)? + .map_err(|_| DynamicCircuitApplicationError::BusClosed)?; + let reply = tokio::time::timeout(DYNAMIC_CIRCUIT_APPLICATION_TIMEOUT, reply_rx) + .await + .map_err(|_| DynamicCircuitApplicationError::ReplyTimeout)? + .map_err(|_| DynamicCircuitApplicationError::ReplyDropped)?; + dynamic_circuit_application_result(reply) +} + +fn dynamic_circuit_application_result( + reply: BusReply, +) -> Result<(), DynamicCircuitApplicationError> { + let mut responses = reply.responses; + match responses.len() { + 0 => Err(DynamicCircuitApplicationError::MissingResponse), + 1 => match responses.remove(0) { + BusResponse::Ack => Ok(()), + BusResponse::Fail(message) => { + Err(DynamicCircuitApplicationError::RequestFailed(message)) + } + response => Err(DynamicCircuitApplicationError::UnexpectedResponse(format!( + "{response:?}" + ))), + }, + count => Err(DynamicCircuitApplicationError::UnexpectedResponseCount( + count, + )), + } +} + +fn log_dynamic_circuit_application_result( + context: &DynamicCircuitApplicationContext, + result: Result<(), DynamicCircuitApplicationError>, +) { + match result { + Ok(()) => { + debug!( + circuit_id = %context.circuit_id, + session = ?context.session_key, + intent = context.intent_name, + "applied RADIUS dynamic-circuit request" + ); + } + Err(err) => { + warn!( + circuit_id = %context.circuit_id, + session = ?context.session_key, + intent = context.intent_name, + error = %err, + "failed to apply RADIUS dynamic-circuit request" + ); + } + } +} + +fn log_dynamic_circuit_shared_upsert_retained(context: &DynamicCircuitApplicationContext) { + warn!( + circuit_id = %context.circuit_id, + session = ?context.session_key, + intent = context.intent_name, + "skipped RADIUS dynamic-circuit upsert because another session already owns the circuit id" + ); +} + +fn log_dynamic_circuit_stale_upsert_dropped(context: &DynamicCircuitApplicationContext) { + debug!( + circuit_id = %context.circuit_id, + session = ?context.session_key, + intent = context.intent_name, + "skipped RADIUS dynamic-circuit upsert because a later removal was already deferred" + ); +} + +fn log_dynamic_circuit_retained_upsert_promoted( + removed_context: &DynamicCircuitApplicationContext, + promoted_context: &DynamicCircuitApplicationContext, +) { + debug!( + circuit_id = %removed_context.circuit_id, + removed_session = ?removed_context.session_key, + promoted_session = ?promoted_context.session_key, + "promoted retained RADIUS dynamic-circuit owner after authoritative owner removal" + ); +} + +fn log_dynamic_circuit_removal_not_queued(context: &DynamicCircuitApplicationContext) { + debug!( + circuit_id = %context.circuit_id, + session = ?context.session_key, + intent = context.intent_name, + "kept RADIUS dynamic-circuit owner after removal queue failure" + ); +} + +fn log_dynamic_circuit_removal_skipped(context: &DynamicCircuitApplicationContext) { + debug!( + circuit_id = %context.circuit_id, + session = ?context.session_key, + intent = context.intent_name, + "skipped RADIUS dynamic-circuit removal because this session did not own an applied circuit" + ); +} + +#[derive(Debug, Error, PartialEq, Eq)] +enum DynamicCircuitApplicationError { + #[error("dynamic circuit application queue unavailable: {0}")] + QueueUnavailable(DynamicCircuitQueueUnavailableReason), + #[error("timed out sending dynamic circuit bus request")] + BusSendTimeout, + #[error("dynamic circuit bus channel is closed")] + BusClosed, + #[error("timed out waiting for dynamic circuit bus reply")] + ReplyTimeout, + #[error("dynamic circuit bus reply sender was dropped")] + ReplyDropped, + #[error("dynamic circuit bus returned no response")] + MissingResponse, + #[error("dynamic circuit bus returned {0} responses for one request")] + UnexpectedResponseCount(usize), + #[error( + "dynamic circuit request failed: {}", + sanitize_dynamic_circuit_bus_failure_detail(.0) + )] + RequestFailed(String), + #[error( + "dynamic circuit request returned unexpected response: {}", + sanitize_dynamic_circuit_bus_failure_detail(.0) + )] + UnexpectedResponse(String), +} + +fn sanitize_dynamic_circuit_bus_failure_detail(detail: &str) -> String { + let mut sanitized = String::new(); + let mut sanitized_bytes = 0; + let mut redact_next_value = false; + let mut tokens = detail.split_whitespace().peekable(); + while let Some(token) = tokens.next() { + let token_is_sensitive = dynamic_circuit_bus_failure_token_is_sensitive(token) + || dynamic_circuit_bus_failure_token_starts_sensitive_phrase( + token, + tokens.peek().copied(), + ); + let redact_token = redact_next_value || token_is_sensitive; + let sanitized_token = if redact_token { "[redacted]" } else { token }; + if !push_bounded_dynamic_circuit_bus_failure_token( + &mut sanitized, + &mut sanitized_bytes, + sanitized_token, + ) { + sanitized.push_str("..."); + return sanitized; + } + if token_is_sensitive { + redact_next_value = !dynamic_circuit_bus_failure_token_has_inline_secret_value(token); + } else if redact_next_value && !dynamic_circuit_bus_failure_token_is_secret_connector(token) + { + redact_next_value = false; + } + } + if sanitized.is_empty() { + "no additional detail".to_string() + } else { + sanitized + } +} + +fn dynamic_circuit_bus_failure_token_is_sensitive(token: &str) -> bool { + let compact = compact_dynamic_circuit_bus_failure_token(token); + compact.contains("secret") + || compact.contains("password") + || compact.contains("passwd") + || compact.contains("token") + || compact.contains("apikey") + || compact.contains("privatekey") + || compact.contains("accesskey") + || compact.contains("credential") + || compact.contains("authorization") + || compact.contains("bearer") + || dynamic_circuit_bus_failure_token_is_bare_key_assignment_label(token) +} + +fn dynamic_circuit_bus_failure_token_starts_sensitive_phrase( + token: &str, + next_token: Option<&str>, +) -> bool { + let token = compact_dynamic_circuit_bus_failure_token(token); + let Some(next_token) = next_token else { + return false; + }; + if token == "key" + && (dynamic_circuit_bus_failure_token_is_secret_connector(next_token) + || dynamic_circuit_bus_failure_token_has_inline_secret_value(next_token)) + { + return true; + } + matches!(token.as_str(), "api" | "private" | "access") + && dynamic_circuit_bus_failure_token_is_key_label(next_token) +} + +fn dynamic_circuit_bus_failure_token_is_secret_connector(token: &str) -> bool { + let token = trimmed_dynamic_circuit_bus_failure_token(token); + let connector = + token.trim_matches(|character: char| matches!(character, ':' | '=' | '>' | '-')); + matches!(connector, "" | "is" | "was" | "value" | "key") +} + +fn dynamic_circuit_bus_failure_token_is_key_label(token: &str) -> bool { + let token = trimmed_dynamic_circuit_bus_failure_token(token); + if token == "key" { + return true; + } + token + .strip_prefix("key") + .is_some_and(|suffix| suffix.starts_with([':', '=', '-'])) +} + +fn dynamic_circuit_bus_failure_token_is_bare_key_assignment_label(token: &str) -> bool { + let token = trimmed_dynamic_circuit_bus_failure_token(token); + token + .strip_prefix("key") + .is_some_and(|suffix| suffix.starts_with([':', '='])) +} + +fn dynamic_circuit_bus_failure_token_has_inline_secret_value(token: &str) -> bool { + let token = token.trim_matches(|character: char| { + matches!(character, '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}') + }); + let Some(separator_index) = token.find([':', '=']) else { + return false; + }; + token[separator_index + 1..] + .chars() + .any(|character| !matches!(character, ':' | '=' | '>' | '-')) +} + +fn trimmed_dynamic_circuit_bus_failure_token(token: &str) -> String { + token + .trim_matches(|character: char| { + matches!(character, '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}') + }) + .chars() + .take(16) + .collect::() + .to_ascii_lowercase() +} + +fn compact_dynamic_circuit_bus_failure_token(token: &str) -> String { + token + .chars() + .take(DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT) + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_lowercase()) + .collect() +} + +fn push_bounded_dynamic_circuit_bus_failure_token( + sanitized: &mut String, + sanitized_bytes: &mut usize, + token: &str, +) -> bool { + if !sanitized.is_empty() { + if *sanitized_bytes >= DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT { + return false; + } + sanitized.push(' '); + *sanitized_bytes += 1; + } + for character in token.chars() { + let character_bytes = character.len_utf8(); + if *sanitized_bytes + character_bytes > DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT { + return false; + } + sanitized.push(character); + *sanitized_bytes += character_bytes; + } + true +} + +fn dynamic_circuit_intent_name(intent: &DynamicCircuitIntent) -> &'static str { + match intent { + DynamicCircuitIntent::CreateDynamicCircuit(_) => "create", + DynamicCircuitIntent::UpdateDynamicCircuit(_) => "update", + DynamicCircuitIntent::RemoveDynamicCircuit(_) => "remove", + } +} + +fn trim_line_endings(value: &mut Vec) { + while value + .last() + .is_some_and(|byte| matches!(byte, b'\n' | b'\r')) + { + value.pop(); + } +} + +fn client_label(index: usize, name: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + format!("radius_accounting.clients[{index}]") + } else { + format!("radius_accounting.clients[{index}] ('{trimmed}')") + } +} + +struct RadiusAccountingRuntimeConfig { + listen_addr: SocketAddr, + clients: Vec, + default_ttl: Duration, + stale_grace: Duration, + fallback_rate_profile: Option, + fallback_parent: Option, + mac_matcher: Option, + match_shaped_devices_by_username: bool, + match_shaped_devices_by_mac: bool, + apply_dynamic_circuits: bool, +} + +/// Startup errors for lqosd RADIUS accounting integration. +#[derive(Debug, Error)] +pub(crate) enum RadiusAccountingStartupError { + /// Enabled configuration is missing a listen address. + #[error("radius_accounting.listen must be configured when enabled")] + MissingListen, + /// Enabled configuration is missing trusted clients. + #[error("radius_accounting.clients must include at least one client when enabled")] + NoClients, + /// Configured fallback speed profile failed runtime validation. + #[error("radius_accounting.fallback_speed_profile is invalid: {0}")] + InvalidFallbackSpeedProfile(#[source] SessionRateProfileError), + /// `ShapedDevices.csv` could not be loaded for MAC matching. + #[error( + "radius_accounting.dynamic_circuit_application.match_shaped_devices_by_mac failed to load {path}: {detail}" + )] + ShapedDevicesLoad { + /// Resolved `ShapedDevices.csv` path. + path: String, + /// Underlying load error text. + detail: String, + }, + /// Shared-secret file read failed. + #[error("{label}: failed to read configured RADIUS shared-secret file: {source}")] + SecretFileRead { + /// Client label from configuration. + label: String, + /// File read error. + #[source] + source: io::Error, + }, + /// Shared-secret file had no bytes after line-ending cleanup. + #[error("{label}: configured RADIUS shared-secret file is empty")] + EmptySecretFile { + /// Client label from configuration. + label: String, + }, + /// Configured source could not become a runtime source matcher. + #[error("{label}: invalid RADIUS client source: {source}")] + TrustedSource { + /// Client label from configuration. + label: String, + /// Runtime source conversion error. + #[source] + source: lqos_radius::TrustedClientSourceError, + }, + /// Runtime trusted-client construction failed. + #[error("{label}: invalid RADIUS trusted client: {source}")] + TrustedClient { + /// Client label from configuration. + label: String, + /// Runtime client conversion error. + #[source] + source: lqos_radius::TrustedRadiusClientError, + }, + /// UDP listener startup or local-address lookup failed. + #[error("RADIUS accounting listener startup failed: {0}")] + Listener(#[source] lqos_radius::ListenerError), +} + +#[cfg(test)] +mod tests { + use super::*; + use ip_network::IpNetwork; + use lqos_config::{ + DynamicCircuitsConfig, RadiusClientSource, RadiusDynamicCircuitApplicationConfig, + RadiusSharedSecretSource, ShapedDevice, + }; + use lqos_radius::{ + AcctStatusType, DynamicCircuitRemoval, DynamicCircuitRemovalReason, + MessageAuthenticatorPolicy, MikrotikRateLimit, NasIdentity, PendingSessionReason, + RadiusActivationDiagnosticState, ReceivedVerifiedAccountingPacket, ShapedDevicesMacMatch, + verify_accounting_request, + }; + use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[tokio::test] + async fn absent_or_disabled_config_does_not_build_runtime_listener() -> anyhow::Result<()> { + let config_snapshot = Config::default(); + assert!( + runtime_config_from_config(None, &config_snapshot) + .await? + .is_none() + ); + assert!( + runtime_config_from_config(Some(RadiusAccountingConfig::default()), &config_snapshot) + .await? + .is_none() + ); + + Ok(()) + } + + #[tokio::test] + async fn disabled_start_returns_no_listener_handle() -> anyhow::Result<()> { + let config_snapshot = Config::default(); + assert!( + start_configured_radius_accounting(None, &config_snapshot, test_bus_sender()) + .await? + .is_none() + ); + assert!( + start_configured_radius_accounting( + Some(RadiusAccountingConfig::default()), + &config_snapshot, + test_bus_sender(), + ) + .await? + .is_none() + ); + + Ok(()) + } + + #[tokio::test] + async fn enabled_start_binds_listener_and_abort_finishes() -> anyhow::Result<()> { + let secret_path = unique_secret_path("bind-secret")?; + std::fs::write(&secret_path, b"radius-secret")?; + let mut config = enabled_config(&secret_path); + config.listen = Some(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))); + let config_snapshot = Config::default(); + + let handle = + start_configured_radius_accounting(Some(config), &config_snapshot, test_bus_sender()) + .await; + let _ = std::fs::remove_file(&secret_path); + let Some(handle) = handle? else { + anyhow::bail!("enabled config should start a listener task"); + }; + + handle.abort(); + let join_result = tokio::time::timeout(Duration::from_secs(1), handle).await?; + let Err(join_error) = join_result else { + anyhow::bail!("aborted listener task should not complete normally"); + }; + assert!(join_error.is_cancelled()); + + Ok(()) + } + + #[tokio::test] + async fn enabled_config_loads_secret_file_without_exposing_secret() -> anyhow::Result<()> { + let secret_path = unique_secret_path("configured-secret")?; + std::fs::write(&secret_path, b" shared secret \r\n")?; + + let mut config = enabled_config(&secret_path); + config.default_ttl_seconds = 321; + config.stale_grace_seconds = 45; + let config_snapshot = Config::default(); + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config"); + }; + + assert_eq!(runtime_config.listen_addr, test_listen_addr()); + assert_eq!(runtime_config.default_ttl, Duration::from_secs(321)); + assert_eq!(runtime_config.stale_grace, Duration::from_secs(45)); + assert_eq!(runtime_config.clients.len(), 1); + assert_eq!( + runtime_config.clients[0].shared_secret(), + b" shared secret " + ); + assert!(!format!("{:?}", runtime_config.clients[0]).contains("shared secret")); + + Ok(()) + } + + #[tokio::test] + async fn fallback_speed_profile_is_ignored_when_dynamic_application_disabled() + -> anyhow::Result<()> { + let secret_path = unique_secret_path("disabled-fallback-speed-profile")?; + std::fs::write(&secret_path, b"radius-secret")?; + + let mut config = enabled_config(&secret_path); + config.fallback_speed_profile = Some(RadiusFallbackSpeedProfile { + download_min_mbps: f32::NAN, + upload_min_mbps: 3.0, + download_max_mbps: 25.0, + upload_max_mbps: 10.0, + }); + let config_snapshot = Config::default(); + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config"); + }; + + assert!(runtime_config.fallback_rate_profile.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn mac_match_setting_is_ignored_when_dynamic_application_disabled() -> anyhow::Result<()> + { + let secret_path = unique_secret_path("disabled-mac-match-secret")?; + let lqos_directory = unique_secret_path("disabled-mac-match-lqos-directory")?; + std::fs::write(&secret_path, b"radius-secret")?; + + let mut config = enabled_config(&secret_path); + config + .dynamic_circuit_application + .match_shaped_devices_by_mac = true; + let config_snapshot = Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + lqos_directory: lqos_directory.to_string_lossy().to_string(), + ..Config::default() + }; + + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config"); + }; + + assert!(runtime_config.mac_matcher.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn fallback_speed_profile_builds_runtime_config_when_dynamic_application_enabled() + -> anyhow::Result<()> { + let secret_path = unique_secret_path("fallback-speed-profile")?; + std::fs::write(&secret_path, b"radius-secret")?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + config.dynamic_circuit_application.fallback_parent_node = Some("Core PPPoE".to_string()); + config.dynamic_circuit_application.fallback_parent_node_id = Some("core-pppoe".to_string()); + config.dynamic_circuit_application.fallback_anchor_node_id = + Some("radius-anchor".to_string()); + config.fallback_speed_profile = Some(RadiusFallbackSpeedProfile { + download_min_mbps: 5.0, + upload_min_mbps: 3.0, + download_max_mbps: 25.0, + upload_max_mbps: 10.0, + }); + let config_snapshot = Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + ..Config::default() + }; + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config"); + }; + + assert_eq!( + runtime_config.fallback_rate_profile, + Some(SessionRateProfile::new(5.0, 3.0, 25.0, 10.0)?) + ); + assert_eq!( + runtime_config.fallback_parent, + Some(DynamicCircuitParent { + parent_node: "Core PPPoE".to_string(), + parent_node_id: Some("core-pppoe".to_string()), + anchor_node_id: Some("radius-anchor".to_string()), + }) + ); + + Ok(()) + } + + #[tokio::test] + async fn invalid_fallback_speed_profile_fails_before_secrets() -> anyhow::Result<()> { + let secret_path = unique_secret_path("invalid-fallback-speed-profile")?; + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + config.fallback_speed_profile = Some(RadiusFallbackSpeedProfile { + download_min_mbps: f32::NAN, + upload_min_mbps: 3.0, + download_max_mbps: 25.0, + upload_max_mbps: 10.0, + }); + + let config_snapshot = Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + ..Config::default() + }; + let error = match runtime_config_from_config(Some(config), &config_snapshot).await { + Ok(_) => anyhow::bail!("invalid fallback speed profile should fail startup"), + Err(error) => error, + }; + + assert!(matches!( + error, + RadiusAccountingStartupError::InvalidFallbackSpeedProfile(_) + )); + + Ok(()) + } + + #[tokio::test] + async fn secret_file_errors_identify_client_without_secret_bytes() -> anyhow::Result<()> { + let secret_path = unique_secret_path("do-not-log-this-secret")?; + let mut config = enabled_config(&secret_path); + config.clients[0].name = "core-nas".to_string(); + + let config_snapshot = Config::default(); + let error = match runtime_config_from_config(Some(config), &config_snapshot).await { + Ok(_) => anyhow::bail!("missing secret file should fail startup"), + Err(error) => error, + }; + let message = error.to_string(); + + assert!(message.contains("core-nas")); + assert!(!message.contains("do-not-log-this-secret")); + + Ok(()) + } + + #[tokio::test] + async fn enabled_dynamic_application_builds_runtime_config() -> anyhow::Result<()> { + let secret_path = unique_secret_path("application-secret")?; + std::fs::write(&secret_path, b"radius-secret")?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + let config_snapshot = Config::default(); + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config"); + }; + + assert_eq!(runtime_config.listen_addr, test_listen_addr()); + assert!(!runtime_config.apply_dynamic_circuits); + + Ok(()) + } + + #[tokio::test] + async fn dynamic_application_requires_both_safety_gates_to_apply() -> anyhow::Result<()> { + let secret_path = unique_secret_path("application-safety-gates")?; + std::fs::write(&secret_path, b"radius-secret")?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + let missing_dynamic_circuits = runtime_config_from_config( + Some(config.clone()), + &Config { + dynamic_circuits: None, + ..Config::default() + }, + ) + .await?; + let disabled_dynamic_circuits = runtime_config_from_config( + Some(config.clone()), + &Config { + dynamic_circuits: Some(DynamicCircuitsConfig::default()), + ..Config::default() + }, + ) + .await?; + let enabled_dynamic_circuits = runtime_config_from_config( + Some(config), + &Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + ..Config::default() + }, + ) + .await?; + let _ = std::fs::remove_file(&secret_path); + + assert!( + !missing_dynamic_circuits + .expect("runtime config should build") + .apply_dynamic_circuits + ); + assert!( + !disabled_dynamic_circuits + .expect("runtime config should build") + .apply_dynamic_circuits + ); + assert!( + enabled_dynamic_circuits + .expect("runtime config should build") + .apply_dynamic_circuits + ); + + Ok(()) + } + + #[tokio::test] + async fn safety_gates_control_event_bus_application() -> anyhow::Result<()> { + let secret_path = unique_secret_path("application-gate-bus-use")?; + std::fs::write(&secret_path, b"radius-secret")?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + config.dynamic_circuit_application.fallback_parent_node = Some("Core PPPoE".to_string()); + config.fallback_speed_profile = Some(RadiusFallbackSpeedProfile { + download_min_mbps: 5.0, + upload_min_mbps: 3.0, + download_max_mbps: 25.0, + upload_max_mbps: 10.0, + }); + let disabled_runtime = runtime_config_from_config( + Some(config.clone()), + &Config { + dynamic_circuits: Some(DynamicCircuitsConfig::default()), + ..Config::default() + }, + ) + .await?; + let enabled_runtime = runtime_config_from_config( + Some(config), + &Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + ..Config::default() + }, + ) + .await?; + let _ = std::fs::remove_file(&secret_path); + + let disabled_runtime = disabled_runtime.expect("runtime config should build"); + let (disabled_bus_tx, mut disabled_bus_rx) = mpsc::channel(4); + let mut disabled_sink = dynamic_circuit_application_sink( + disabled_runtime.apply_dynamic_circuits, + disabled_bus_tx, + ); + assert!(disabled_sink.is_none()); + let mut disabled_sessions = sessions_from_runtime_config(disabled_runtime); + + let mut disabled_command_sink = selected_dynamic_circuit_sink(&mut disabled_sink); + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut disabled_sessions, + &mut disabled_command_sink, + Instant::now(), + test_listen_addr(), + 64, + 20, + ); + tokio::task::yield_now().await; + assert!(matches!( + disabled_bus_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) + )); + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Stop), + &mut disabled_sessions, + &mut disabled_command_sink, + Instant::now() + Duration::from_secs(1), + test_listen_addr(), + 64, + 20, + ); + tokio::task::yield_now().await; + assert!(matches!( + disabled_bus_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) + )); + + let enabled_runtime = enabled_runtime.expect("runtime config should build"); + let (enabled_bus_tx, mut enabled_bus_rx) = mpsc::channel(4); + let mut enabled_sink = dynamic_circuit_application_sink( + enabled_runtime.apply_dynamic_circuits, + enabled_bus_tx, + ); + assert!(enabled_sink.is_some()); + let mut enabled_sessions = sessions_from_runtime_config(enabled_runtime); + + let mut enabled_command_sink = selected_dynamic_circuit_sink(&mut enabled_sink); + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut enabled_sessions, + &mut enabled_command_sink, + Instant::now(), + test_listen_addr(), + 64, + 20, + ); + let (reply_tx, shaped_device) = receive_create_dynamic_circuit(&mut enabled_bus_rx).await?; + assert_eq!(shaped_device.parent_node, "Core PPPoE"); + ack_bus_reply(reply_tx)?; + + Ok(()) + } + + #[tokio::test] + async fn mac_match_setting_loads_shaped_devices_matcher() -> anyhow::Result<()> { + let secret_path = unique_secret_path("mac-match-secret")?; + let lqos_directory = unique_secret_path("mac-match-lqos-directory")?; + std::fs::write(&secret_path, b"radius-secret")?; + std::fs::create_dir(&lqos_directory)?; + std::fs::write( + lqos_directory.join("ShapedDevices.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,sqm\n\ +circuit-runtime,Runtime Circuit,device-runtime,Runtime Device,Parent,ParentId,AnchorId,aa-bb-cc-dd-ee-ff,198.51.100.200,,5,2,50,20,fixture,cake\n", + )?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + config + .dynamic_circuit_application + .match_shaped_devices_by_mac = true; + let config_snapshot = Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + lqos_directory: lqos_directory.to_string_lossy().to_string(), + ..Config::default() + }; + + let dynamic_circuits_path = lqos_directory.join("dynamic_circuits.json"); + assert!(!dynamic_circuits_path.exists()); + + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config"); + }; + let default_ttl = runtime_config.default_ttl; + let stale_grace = runtime_config.stale_grace; + let fallback_rate_profile = runtime_config.fallback_rate_profile; + let matcher = runtime_config + .mac_matcher + .expect("MAC match setting should load a matcher"); + let mut event = complete_event(AcctStatusType::Start); + event.calling_station_id = Some("AABB.CCDD.EEFF".to_string()); + + let ShapedDevicesMacMatch::Unique(device) = matcher.match_event(&event) else { + anyhow::bail!("runtime matcher should find shaped-device MAC"); + }; + assert_eq!(device.circuit_id, "circuit-runtime"); + assert_eq!(device.device_id, "device-runtime"); + + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + default_ttl, + stale_grace, + fallback_rate_profile, + None, + Some(matcher), + ); + let mut event = complete_event_for(AcctStatusType::Start, "nas-runtime", "session-runtime"); + event.calling_station_id = Some("aa-bb-cc-dd-ee-ff".to_string()); + sessions.apply_event(event, Instant::now()); + + let key = session_key_for("nas-runtime", "session-runtime"); + let session = sessions + .store + .session(&key) + .expect("configured MAC matcher should retain the RADIUS session"); + assert!(session.pending_reasons.is_empty()); + let Some(resolved_device) = session.resolved_shaped_device.as_ref() else { + anyhow::bail!("configured MAC matcher should resolve an in-memory ShapedDevice"); + }; + assert_eq!(resolved_device.circuit_id, "circuit-runtime"); + assert_eq!(resolved_device.device_id, "device-runtime"); + assert_eq!(resolved_device.parent_node, "Parent"); + assert_eq!(resolved_device.parent_node_id.as_deref(), Some("ParentId")); + assert_eq!(resolved_device.anchor_node_id.as_deref(), Some("AnchorId")); + assert_eq!( + resolved_device.ipv4, + vec![(Ipv4Addr::new(198, 51, 100, 20), 32)] + ); + assert_eq!(resolved_device.download_min_mbps, 25.0); + assert_eq!(resolved_device.upload_min_mbps, 10.0); + assert_eq!(resolved_device.download_max_mbps, 25.0); + assert_eq!(resolved_device.upload_max_mbps, 10.0); + + let mut shaped_rate_event = complete_event_for( + AcctStatusType::Start, + "nas-runtime", + "session-runtime-shaped-rate", + ); + shaped_rate_event.calling_station_id = Some("aa-bb-cc-dd-ee-ff".to_string()); + shaped_rate_event.mikrotik_rate_limits.clear(); + sessions.apply_event(shaped_rate_event, Instant::now()); + + let shaped_rate_session = sessions + .store + .session(&session_key_for( + "nas-runtime", + "session-runtime-shaped-rate", + )) + .expect("configured MAC matcher should retain the no-packet-rate session"); + assert!(shaped_rate_session.pending_reasons.is_empty()); + let Some(shaped_rate_device) = shaped_rate_session.resolved_shaped_device.as_ref() else { + anyhow::bail!("configured MAC matcher should use ShapedDevices.csv rates"); + }; + assert_eq!(shaped_rate_device.download_min_mbps, 5.0); + assert_eq!(shaped_rate_device.upload_min_mbps, 2.0); + assert_eq!(shaped_rate_device.download_max_mbps, 50.0); + assert_eq!(shaped_rate_device.upload_max_mbps, 20.0); + assert!(!dynamic_circuits_path.exists()); + + let _ = std::fs::remove_file(lqos_directory.join("ShapedDevices.csv")); + let _ = std::fs::remove_dir(&lqos_directory); + + Ok(()) + } + + #[tokio::test] + async fn duplicate_mac_rows_load_and_leave_sessions_pending() -> anyhow::Result<()> { + let secret_path = unique_secret_path("duplicate-mac-secret")?; + let lqos_directory = unique_secret_path("duplicate-mac-lqos-directory")?; + std::fs::write(&secret_path, b"radius-secret")?; + std::fs::create_dir(&lqos_directory)?; + std::fs::write( + lqos_directory.join("ShapedDevices.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,sqm\n\ +circuit-a,Runtime Circuit A,device-a,Runtime Device A,Parent,ParentId,AnchorId,aa-bb-cc-dd-ee-ff,198.51.100.200,,5,2,50,20,fixture,cake\n\ +circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,AABB.CCDD.EEFF,198.51.100.201,,5,2,50,20,fixture,cake\n", + )?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + config + .dynamic_circuit_application + .match_shaped_devices_by_mac = true; + let config_snapshot = Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + lqos_directory: lqos_directory.to_string_lossy().to_string(), + ..Config::default() + }; + + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config"); + }; + let default_ttl = runtime_config.default_ttl; + let stale_grace = runtime_config.stale_grace; + let fallback_rate_profile = runtime_config.fallback_rate_profile; + let matcher = runtime_config + .mac_matcher + .expect("MAC match setting should load a matcher"); + + let mut event = + complete_event_for(AcctStatusType::Start, "nas-duplicate", "session-duplicate"); + event.calling_station_id = Some("AA:BB:CC:DD:EE:FF".to_string()); + assert_eq!( + matcher.match_event(&event), + ShapedDevicesMacMatch::Ambiguous + ); + + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + default_ttl, + stale_grace, + fallback_rate_profile, + None, + Some(matcher), + ); + sessions.apply_event(event, Instant::now()); + + let session = sessions + .store + .session(&session_key_for("nas-duplicate", "session-duplicate")) + .expect("ambiguous MAC match should retain the RADIUS session"); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::AmbiguousIdentityMatch] + ); + assert!(session.resolved_shaped_device.is_none()); + + let _ = std::fs::remove_file(lqos_directory.join("ShapedDevices.csv")); + let _ = std::fs::remove_dir(&lqos_directory); + + Ok(()) + } + + #[tokio::test] + async fn disabled_top_level_dynamic_circuits_skip_mac_matcher_load() -> anyhow::Result<()> { + let secret_path = unique_secret_path("mac-match-top-gate-secret")?; + let lqos_directory = unique_secret_path("mac-match-top-gate-lqos-directory")?; + std::fs::write(&secret_path, b"radius-secret")?; + std::fs::create_dir(&lqos_directory)?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + config + .dynamic_circuit_application + .match_shaped_devices_by_mac = true; + let config_snapshot = Config { + dynamic_circuits: Some(DynamicCircuitsConfig::default()), + lqos_directory: lqos_directory.to_string_lossy().to_string(), + ..Config::default() + }; + + let runtime_config = runtime_config_from_config(Some(config), &config_snapshot).await; + let _ = std::fs::remove_file(&secret_path); + let _ = std::fs::remove_dir(&lqos_directory); + let Some(runtime_config) = runtime_config? else { + anyhow::bail!("enabled config should produce runtime config") + }; + + assert!(!runtime_config.apply_dynamic_circuits); + assert!(runtime_config.mac_matcher.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn mac_match_load_error_reports_shaped_devices_path() -> anyhow::Result<()> { + let secret_path = unique_secret_path("mac-match-missing-secret")?; + let lqos_directory = unique_secret_path("mac-match-missing-lqos-directory")?; + std::fs::create_dir(&lqos_directory)?; + + let mut config = enabled_config(&secret_path); + config.dynamic_circuit_application.enabled = true; + config + .dynamic_circuit_application + .match_shaped_devices_by_mac = true; + let config_snapshot = Config { + dynamic_circuits: Some(DynamicCircuitsConfig { + enabled: true, + ..DynamicCircuitsConfig::default() + }), + lqos_directory: lqos_directory.to_string_lossy().to_string(), + ..Config::default() + }; + let expected_path = lqos_directory.join("ShapedDevices.csv"); + + let error = match runtime_config_from_config(Some(config), &config_snapshot).await { + Ok(_) => { + let _ = std::fs::remove_dir(&lqos_directory); + anyhow::bail!("missing ShapedDevices.csv should fail startup") + } + Err(error) => error, + }; + let _ = std::fs::remove_dir(&lqos_directory); + + assert!(matches!( + error, + RadiusAccountingStartupError::ShapedDevicesLoad { .. } + )); + assert!( + error + .to_string() + .contains(&expected_path.display().to_string()) + ); + + Ok(()) + } + + #[test] + fn mac_matching_uses_carried_session_mac_after_sparse_merge() -> anyhow::Result<()> { + let matched_device = shaped_device("circuit-carried-mac", "device-carried-mac"); + let matcher = ShapedDevicesMacMatcher::from_devices(&[matched_device]); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + None, + None, + Some(matcher), + ); + let key = session_key_for("nas-carried-mac", "session-carried-mac"); + + let mut start = complete_event_for( + AcctStatusType::Start, + "nas-carried-mac", + "session-carried-mac", + ); + start.calling_station_id = Some("aa:bb:cc:dd:ee:ff".to_string()); + start.framed_ip_address = None; + start.mikrotik_rate_limits.clear(); + sessions.apply_event(start, Instant::now()); + assert_eq!( + sessions + .store + .session(&key) + .expect("start should retain the RADIUS session") + .pending_reasons, + vec![PendingSessionReason::MissingIpAddress] + ); + + let mut interim = complete_event_for( + AcctStatusType::InterimUpdate, + "nas-carried-mac", + "session-carried-mac", + ); + interim.calling_station_id = None; + interim.framed_ip_address = Some(Ipv4Addr::new(203, 0, 113, 45)); + sessions.apply_event(interim, Instant::now() + Duration::from_secs(1)); + + let session = sessions + .store + .session(&key) + .expect("interim update should retain the RADIUS session"); + assert_eq!( + session.latest_event.calling_station_id.as_deref(), + Some("aa:bb:cc:dd:ee:ff") + ); + assert!(session.pending_reasons.is_empty()); + let Some(resolved_device) = session.resolved_shaped_device.as_ref() else { + anyhow::bail!("MAC match should resolve a shaped-device definition"); + }; + assert_eq!(resolved_device.circuit_id, "circuit-carried-mac"); + assert_eq!(resolved_device.device_id, "device-carried-mac"); + assert_eq!(resolved_device.parent_node, "Parent Node"); + assert_eq!( + resolved_device.parent_node_id.as_deref(), + Some("parent-node-id") + ); + assert_eq!( + resolved_device.anchor_node_id.as_deref(), + Some("anchor-node-id") + ); + assert_eq!( + resolved_device.ipv4, + vec![(Ipv4Addr::new(203, 0, 113, 45), 32)] + ); + assert!(resolved_device.ipv6.is_empty()); + assert_eq!(resolved_device.download_min_mbps, 25.0); + assert_eq!(resolved_device.upload_min_mbps, 10.0); + assert_eq!(resolved_device.download_max_mbps, 25.0); + assert_eq!(resolved_device.upload_max_mbps, 10.0); + + Ok(()) + } + + #[test] + fn fallback_profile_without_parent_metadata_stays_pending() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + None, + None, + ); + let mut event = complete_event(AcctStatusType::Start); + event.mikrotik_rate_limits.clear(); + event.user_name = Some("subscriber@example.net".to_string()); + event.calling_station_id = Some("AA-BB-CC-DD-EE-FF".to_string()); + let now = Instant::now(); + + sessions.apply_event(event, now); + + let key = session_key(); + let Some(session) = sessions.store.session(&key) else { + anyhow::bail!("accepted event should create an in-memory session"); + }; + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::MissingParent] + ); + assert_eq!(sessions.updated_at.get(&key), Some(&now)); + assert_eq!( + session.resolved_rate.map(|rate| rate.profile), + Some(fallback_rate) + ); + assert!(session.resolved_shaped_device.is_none()); + + Ok(()) + } + + #[test] + fn fallback_parent_metadata_resolves_default_identity() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent { + parent_node: "Core PPPoE".to_string(), + parent_node_id: Some("core-pppoe".to_string()), + anchor_node_id: Some("radius-anchor".to_string()), + }; + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let mut event = complete_event(AcctStatusType::Start); + event.mikrotik_rate_limits.clear(); + event.user_name = Some("subscriber@example.net".to_string()); + event.calling_station_id = Some("AA-BB-CC-DD-EE-FF".to_string()); + + sessions.apply_event(event, Instant::now()); + + let Some(session) = sessions.store.session(&session_key()) else { + anyhow::bail!("accepted event should create an in-memory session"); + }; + assert!(session.pending_reasons.is_empty()); + let Some(resolved_device) = session.resolved_shaped_device.as_ref() else { + anyhow::bail!("fallback parent should resolve a default shaped device"); + }; + assert_eq!(resolved_device.circuit_name, "subscriber@example.net"); + assert_eq!(resolved_device.device_name, "AA-BB-CC-DD-EE-FF"); + assert_eq!(resolved_device.parent_node, "Core PPPoE"); + assert_eq!( + resolved_device.parent_node_id.as_deref(), + Some("core-pppoe") + ); + assert_eq!( + resolved_device.anchor_node_id.as_deref(), + Some("radius-anchor") + ); + assert_eq!(resolved_device.download_min_mbps, 4.0); + assert_eq!(resolved_device.upload_min_mbps, 2.0); + assert_eq!(resolved_device.download_max_mbps, 40.0); + assert_eq!(resolved_device.upload_max_mbps, 12.0); + + Ok(()) + } + + #[test] + fn adapter_boundary_emits_deferred_command_intents() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent { + parent_node: "Core PPPoE".to_string(), + parent_node_id: Some("core-pppoe".to_string()), + anchor_node_id: Some("radius-anchor".to_string()), + }; + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let mut sink = RecordingDynamicCircuitSink::default(); + let key = session_key(); + let circuit_id = expected_circuit_id("nas-adapter"); + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut sessions, + &mut sink, + Instant::now(), + test_listen_addr(), + 64, + 20, + ); + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::InterimUpdate), + &mut sessions, + &mut sink, + Instant::now() + Duration::from_secs(1), + test_listen_addr(), + 64, + 20, + ); + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Stop), + &mut sessions, + &mut sink, + Instant::now() + Duration::from_secs(2), + test_listen_addr(), + 64, + 20, + ); + + assert_eq!(sink.intents.len(), 3); + let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { + anyhow::bail!("expected create intent, got {:?}", sink.intents[0]); + }; + assert_eq!(create.circuit_id, circuit_id); + assert_eq!(create.session_key, key); + assert_eq!(create.shaped_device.parent_node, "Core PPPoE"); + assert_eq!(create.shaped_device.circuit_name, "subscriber-adapter"); + + let DynamicCircuitIntent::UpdateDynamicCircuit(update) = &sink.intents[1] else { + anyhow::bail!("expected update intent, got {:?}", sink.intents[1]); + }; + assert_eq!(update.circuit_id, circuit_id); + assert_eq!(update.session_key, key); + + let DynamicCircuitIntent::RemoveDynamicCircuit(stop) = &sink.intents[2] else { + anyhow::bail!("expected stop removal intent, got {:?}", sink.intents[2]); + }; + assert_eq!(stop.circuit_id, circuit_id); + assert_eq!(stop.session_key, key); + assert_eq!(stop.reason, DynamicCircuitRemovalReason::Stop); + + Ok(()) + } + + #[tokio::test] + async fn applying_sink_orders_create_requests_without_blocking_packet_handling() + -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let circuit_id = expected_circuit_id("nas-adapter"); + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut sessions, + &mut sink, + Instant::now(), + test_listen_addr(), + 64, + 20, + ); + let (start_reply, start_device) = receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(start_device.circuit_id, circuit_id); + assert_eq!(start_device.parent_node, "Core PPPoE"); + assert_eq!(start_device.circuit_name, "subscriber-adapter"); + + let mut interim = complete_event(AcctStatusType::InterimUpdate); + interim.framed_ip_address = Some(Ipv4Addr::new(198, 51, 100, 21)); + handle_accounting_event_with_command_sink( + interim, + &mut sessions, + &mut sink, + Instant::now() + Duration::from_secs(1), + test_listen_addr(), + 64, + 20, + ); + tokio::task::yield_now().await; + assert!( + matches!(bus_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)), + "second upsert should wait behind the first bus reply" + ); + fail_bus_reply(start_reply, "fake create failure")?; + + let (update_reply, update_device) = receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(update_device.circuit_id, circuit_id); + assert_eq!( + update_device.ipv4, + vec![(Ipv4Addr::new(198, 51, 100, 21), 32)] + ); + ack_bus_reply(update_reply)?; + + Ok(()) + } + + #[tokio::test] + async fn failed_create_records_apply_failed_diagnostic_without_secret() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let key = session_key(); + let circuit_id = expected_circuit_id("nas-adapter"); + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut sessions, + &mut sink, + Instant::now(), + test_listen_addr(), + 64, + 20, + ); + let (reply_tx, shaped_device) = receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(shaped_device.circuit_id, circuit_id); + fail_bus_reply( + reply_tx, + "fake apply failure code=queue-parent-missing shared secret secret-value api key = diagnostic-key", + )?; + + let diagnostic = + wait_for_apply_failed_diagnostic(&sink, &sessions, &circuit_id, &key).await?; + assert_eq!( + diagnostic.state, + RadiusActivationDiagnosticState::ApplyFailed + ); + assert_eq!(diagnostic.session_key, key); + assert_eq!(diagnostic.circuit_ids, vec![circuit_id.clone()]); + let apply_error = diagnostic + .apply_error + .as_deref() + .ok_or_else(|| anyhow::anyhow!("expected apply_error detail"))?; + assert!(apply_error.contains("code=queue-parent-missing")); + assert!(apply_error.contains("[redacted]")); + assert!(!apply_error.contains("secret-value")); + assert!(!apply_error.contains("diagnostic-key")); + let diagnostic_debug = format!("{diagnostic:?}"); + assert!(!diagnostic_debug.contains("secret-value")); + assert!(!diagnostic_debug.contains("diagnostic-key")); + let diagnostics = sink.activation_diagnostics(&sessions); + assert_eq!( + diagnostics + .iter() + .filter(|diagnostic| diagnostic.session_key == key + && diagnostic.circuit_ids.iter().any(|id| id == &circuit_id)) + .count(), + 1 + ); + assert!(!diagnostics.iter().any(|diagnostic| { + diagnostic.session_key == key + && diagnostic.circuit_ids.iter().any(|id| id == &circuit_id) + && diagnostic.state == RadiusActivationDiagnosticState::Active + })); + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Stop), + &mut sessions, + &mut sink, + Instant::now() + Duration::from_secs(1), + test_listen_addr(), + 64, + 20, + ); + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + let diagnostics = sink.activation_diagnostics(&sessions); + assert!(!diagnostics.iter().any(|diagnostic| { + diagnostic.session_key == key + && diagnostic.circuit_ids.iter().any(|id| id == &circuit_id) + && diagnostic.state == RadiusActivationDiagnosticState::ApplyFailed + })); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.session_key == key + && diagnostic.circuit_ids.iter().any(|id| id == &circuit_id) + && diagnostic.state == RadiusActivationDiagnosticState::Stopped + })); + + Ok(()) + } + + #[tokio::test] + async fn stop_while_create_is_pending_queues_removal_after_create_reply() -> anyhow::Result<()> + { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let circuit_id = expected_circuit_id("nas-adapter"); + let started_at = Instant::now(); + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut sessions, + &mut sink, + started_at, + test_listen_addr(), + 64, + 20, + ); + let (start_reply, start_device) = receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(start_device.circuit_id, circuit_id); + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Stop), + &mut sessions, + &mut sink, + started_at + Duration::from_secs(1), + test_listen_addr(), + 64, + 20, + ); + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + + ack_bus_reply(start_reply)?; + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, circuit_id); + + Ok(()) + } + + #[tokio::test] + async fn applying_sink_submits_stop_expiry_and_stale_removals() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let (bus_tx, mut bus_rx) = mpsc::channel(8); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + + let mut stop_sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + Some(fallback_parent.clone()), + None, + ); + let stop_circuit_id = expected_circuit_id("nas-adapter"); + let started_at = Instant::now(); + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut stop_sessions, + &mut sink, + started_at, + test_listen_addr(), + 64, + 20, + ); + receive_create_and_ack(&mut bus_rx).await?; + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Stop), + &mut stop_sessions, + &mut sink, + started_at + Duration::from_secs(1), + test_listen_addr(), + 64, + 20, + ); + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, stop_circuit_id); + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Stop), + &mut stop_sessions, + &mut sink, + started_at + Duration::from_secs(2), + test_listen_addr(), + 64, + 20, + ); + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + + let mut expiry_sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(10), + Duration::from_secs(2), + Some(fallback_rate), + Some(fallback_parent.clone()), + None, + ); + let expiry_circuit_id = expected_circuit_id("nas-expiry"); + expiry_sessions.apply_event_with_command_sink( + complete_event_for(AcctStatusType::Start, "nas-expiry", "session-expiry"), + started_at, + &mut sink, + ); + receive_create_and_ack(&mut bus_rx).await?; + assert_eq!( + expiry_sessions + .expire_due_with_command_sink(started_at + Duration::from_secs(9), &mut sink,), + 0 + ); + assert_bus_request_channel_empty(&mut bus_rx); + assert_eq!( + expiry_sessions + .expire_due_with_command_sink(started_at + Duration::from_secs(10), &mut sink,), + 1 + ); + assert_eq!( + receive_remove_and_ack(&mut bus_rx).await?, + expiry_circuit_id + ); + + let mut reset_sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(10), + Duration::from_secs(2), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let reset_circuit_id = expected_circuit_id("nas-reset"); + reset_sessions.apply_event_with_command_sink( + complete_event_for(AcctStatusType::Start, "nas-reset", "session-reset"), + started_at, + &mut sink, + ); + receive_create_and_ack(&mut bus_rx).await?; + let reset_at = started_at + Duration::from_secs(1); + reset_sessions.apply_event_with_command_sink( + reset_event_for("nas-reset"), + reset_at, + &mut sink, + ); + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + assert_eq!( + reset_sessions + .expire_due_with_command_sink(reset_at + Duration::from_secs(1), &mut sink), + 0 + ); + assert_bus_request_channel_empty(&mut bus_rx); + assert_eq!( + reset_sessions + .expire_due_with_command_sink(reset_at + Duration::from_secs(2), &mut sink), + 1 + ); + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, reset_circuit_id); + + Ok(()) + } + + #[tokio::test(start_paused = true)] + async fn stale_reset_after_cleanup_wake_removes_at_grace_deadline() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(60), + Duration::from_secs(2), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let sink = ApplyingDynamicCircuitSink::new(bus_tx); + let (event_tx, event_rx) = mpsc::channel(4); + let loop_handle = tokio::spawn(run_test_accounting_event_loop( + event_rx, + sessions, + Some(sink), + )); + let circuit_id = expected_circuit_id("nas-deadline"); + + send_test_accounting_event( + &event_tx, + complete_event_for(AcctStatusType::Start, "nas-deadline", "session-deadline"), + ) + .await?; + receive_create_and_ack(&mut bus_rx).await?; + + tokio::time::advance(Duration::from_secs(2)).await; + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + + tokio::time::advance(Duration::from_millis(1)).await; + let reset_at = radius_accounting_now(); + send_test_accounting_event(&event_tx, reset_event_for("nas-deadline")).await?; + assert_bus_request_channel_empty(&mut bus_rx); + + tokio::time::advance(Duration::from_millis(1_999)).await; + tokio::task::yield_now().await; + assert!(radius_accounting_now() < reset_at + Duration::from_secs(2)); + assert_bus_request_channel_empty(&mut bus_rx); + + tokio::time::advance(Duration::from_millis(1)).await; + send_test_accounting_event( + &event_tx, + complete_event_for( + AcctStatusType::InterimUpdate, + "nas-deadline", + "session-deadline", + ), + ) + .await?; + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, circuit_id); + let refreshed_device = receive_create_and_ack(&mut bus_rx).await?; + assert_eq!(refreshed_device.circuit_id, circuit_id); + + drop(event_tx); + loop_handle.await?; + + Ok(()) + } + + #[tokio::test(start_paused = true)] + async fn stale_reset_timer_removes_without_followup_packet() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let stale_grace = Duration::from_millis(500); + let sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(60), + stale_grace, + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let sink = ApplyingDynamicCircuitSink::new(bus_tx); + let (event_tx, event_rx) = mpsc::channel(4); + let loop_handle = tokio::spawn(run_test_accounting_event_loop( + event_rx, + sessions, + Some(sink), + )); + let circuit_id = expected_circuit_id("nas-timer"); + + send_test_accounting_event( + &event_tx, + complete_event_for(AcctStatusType::Start, "nas-timer", "session-timer"), + ) + .await?; + receive_create_and_ack(&mut bus_rx).await?; + send_test_accounting_event(&event_tx, reset_event_for("nas-timer")).await?; + assert_bus_request_channel_empty(&mut bus_rx); + + tokio::time::advance(stale_grace).await; + tokio::task::yield_now().await; + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, circuit_id); + + drop(event_tx); + loop_handle.await?; + + Ok(()) + } + + #[tokio::test] + async fn refresh_before_expiry_keeps_radius_circuit_active() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(10), + Duration::from_secs(2), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let started_at = Instant::now(); + let reset_at = started_at + Duration::from_secs(1); + let refreshed_at = started_at + Duration::from_secs(2); + let circuit_id = expected_circuit_id("nas-refresh"); + + sessions.apply_event_with_command_sink( + complete_event_for(AcctStatusType::Start, "nas-refresh", "session-refresh"), + started_at, + &mut sink, + ); + receive_create_and_ack(&mut bus_rx).await?; + sessions.apply_event_with_command_sink(reset_event_for("nas-refresh"), reset_at, &mut sink); + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + + sessions.apply_event_with_command_sink( + complete_event_for( + AcctStatusType::InterimUpdate, + "nas-refresh", + "session-refresh", + ), + refreshed_at, + &mut sink, + ); + let (refresh_reply, refreshed_device) = receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(refreshed_device.circuit_id, circuit_id); + ack_bus_reply(refresh_reply)?; + assert_eq!( + sessions.expire_due_with_command_sink(reset_at + Duration::from_secs(2), &mut sink), + 0 + ); + assert_bus_request_channel_empty(&mut bus_rx); + assert_eq!( + sessions + .expire_due_with_command_sink(refreshed_at + Duration::from_secs(10), &mut sink), + 1 + ); + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, circuit_id); + + Ok(()) + } + + #[tokio::test] + async fn single_owner_mac_matched_stop_removes_shared_circuit_id() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let mut sessions = + shared_circuit_sessions(Duration::from_secs(900), Duration::from_secs(120)); + let started_at = Instant::now(); + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-a", "session-a", SHARED_MAC_A), + started_at, + &mut sink, + ); + assert_shared_circuit_create(&mut bus_rx, "device-a").await?; + + sessions.apply_event_with_command_sink( + complete_event_for(AcctStatusType::Stop, "nas-a", "session-a"), + started_at + Duration::from_secs(1), + &mut sink, + ); + assert_eq!( + receive_remove_and_ack(&mut bus_rx).await?, + SHARED_CIRCUIT_ID + ); + + Ok(()) + } + + #[test] + fn mac_matched_stopped_diagnostic_retains_shaped_devices_circuit_id() -> anyhow::Result<()> { + let mut sink = RecordingDynamicCircuitSink::default(); + let mut sessions = + shared_circuit_sessions(Duration::from_secs(900), Duration::from_secs(120)); + let started_at = Instant::now(); + let session_key = session_key_for("nas-stop-diagnostic", "session-stop-diagnostic"); + + sessions.apply_event_with_command_sink( + shared_circuit_event( + AcctStatusType::Start, + "nas-stop-diagnostic", + "session-stop-diagnostic", + SHARED_MAC_A, + ), + started_at, + &mut sink, + ); + sessions.apply_event_with_command_sink( + complete_event_for( + AcctStatusType::Stop, + "nas-stop-diagnostic", + "session-stop-diagnostic", + ), + started_at + Duration::from_secs(1), + &mut sink, + ); + + let diagnostic = sessions + .activation_diagnostics() + .into_iter() + .find(|diagnostic| diagnostic.session_key == session_key) + .expect("stopped diagnostic should be retained"); + assert_eq!(diagnostic.state, RadiusActivationDiagnosticState::Stopped); + assert_eq!(diagnostic.circuit_ids, vec![SHARED_CIRCUIT_ID.to_string()]); + + Ok(()) + } + + #[test] + fn mac_matched_stale_expired_diagnostic_retains_shaped_devices_circuit_id() -> anyhow::Result<()> + { + let mut sink = RecordingDynamicCircuitSink::default(); + let mut sessions = + shared_circuit_sessions(Duration::from_secs(900), Duration::from_secs(2)); + let started_at = Instant::now(); + let reset_at = started_at + Duration::from_secs(1); + let session_key = session_key_for("nas-expired-diagnostic", "session-expired-diagnostic"); + + sessions.apply_event_with_command_sink( + shared_circuit_event( + AcctStatusType::Start, + "nas-expired-diagnostic", + "session-expired-diagnostic", + SHARED_MAC_A, + ), + started_at, + &mut sink, + ); + sessions.apply_event_with_command_sink( + reset_event_for("nas-expired-diagnostic"), + reset_at, + &mut sink, + ); + assert_eq!( + sessions.expire_due_with_command_sink(reset_at + Duration::from_secs(2), &mut sink), + 1 + ); + + let diagnostic = sessions + .activation_diagnostics() + .into_iter() + .find(|diagnostic| diagnostic.session_key == session_key) + .expect("expired diagnostic should be retained"); + assert_eq!(diagnostic.state, RadiusActivationDiagnosticState::Expired); + assert_eq!(diagnostic.circuit_ids, vec![SHARED_CIRCUIT_ID.to_string()]); + + Ok(()) + } + + #[tokio::test] + async fn stop_removal_promotes_retained_shared_circuit_owner() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let mut sessions = + shared_circuit_sessions(Duration::from_secs(900), Duration::from_secs(120)); + let started_at = Instant::now(); + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-a", "session-a", SHARED_MAC_A), + started_at, + &mut sink, + ); + assert_shared_circuit_create(&mut bus_rx, "device-a").await?; + wait_for_authoritative_owner(&sink, &session_key_for("nas-a", "session-a")).await?; + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-b", "session-b", SHARED_MAC_B), + started_at + Duration::from_secs(1), + &mut sink, + ); + + sessions.apply_event_with_command_sink( + complete_event_for(AcctStatusType::Stop, "nas-a", "session-a"), + started_at + Duration::from_secs(2), + &mut sink, + ); + assert_shared_circuit_create(&mut bus_rx, "device-b").await?; + wait_for_authoritative_owner(&sink, &session_key_for("nas-b", "session-b")).await?; + + let session_c = session_key_for("nas-c", "session-c"); + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-c", "session-c", SHARED_MAC_C), + started_at + Duration::from_secs(3), + &mut sink, + ); + wait_for_retained_owner(&sink, &session_c).await?; + assert_bus_request_channel_empty(&mut bus_rx); + + sessions.apply_event_with_command_sink( + complete_event_for(AcctStatusType::Stop, "nas-a", "session-a"), + started_at + Duration::from_secs(4), + &mut sink, + ); + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + + Ok(()) + } + + #[tokio::test] + async fn failed_shared_owner_promotion_falls_back_to_removal() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let mut sessions = + shared_circuit_sessions(Duration::from_secs(900), Duration::from_secs(120)); + let started_at = Instant::now(); + let owner_session = session_key_for("nas-a", "session-a"); + let retained_session = session_key_for("nas-b", "session-b"); + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-a", "session-a", SHARED_MAC_A), + started_at, + &mut sink, + ); + assert_shared_circuit_create(&mut bus_rx, "device-a").await?; + wait_for_authoritative_owner(&sink, &owner_session).await?; + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-b", "session-b", SHARED_MAC_B), + started_at + Duration::from_secs(1), + &mut sink, + ); + wait_for_retained_owner(&sink, &retained_session).await?; + + sessions.apply_event_with_command_sink( + complete_event_for(AcctStatusType::Stop, "nas-a", "session-a"), + started_at + Duration::from_secs(2), + &mut sink, + ); + let (promotion_reply, promotion_device) = + receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(promotion_device.device_id, "device-b"); + fail_bus_reply(promotion_reply, "fake promotion failure")?; + assert_eq!( + receive_remove_and_ack(&mut bus_rx).await?, + SHARED_CIRCUIT_ID + ); + wait_for_released_owner(&sink, &owner_session).await?; + + let state = sink.application_state.lock(); + assert!( + !state + .current_owners_by_circuit + .contains_key(SHARED_CIRCUIT_ID) + ); + assert!( + !state + .authoritative_session_by_circuit + .contains_key(SHARED_CIRCUIT_ID) + ); + assert!( + !state + .retained_upserts_by_owner + .contains_key(&(SHARED_CIRCUIT_ID.to_string(), retained_session.clone())) + ); + drop(state); + let diagnostics = sink.activation_diagnostics(&sessions); + assert!(!diagnostics.iter().any(|diagnostic| { + diagnostic.session_key == retained_session + && diagnostic.circuit_ids == vec![SHARED_CIRCUIT_ID.to_string()] + && diagnostic.state == RadiusActivationDiagnosticState::ApplyFailed + })); + + Ok(()) + } + + #[tokio::test] + async fn expiry_removal_promotes_retained_shared_circuit_owner() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let mut sessions = shared_circuit_sessions(Duration::from_secs(10), Duration::from_secs(2)); + let started_at = Instant::now(); + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-a", "session-a", SHARED_MAC_A), + started_at, + &mut sink, + ); + assert_shared_circuit_create(&mut bus_rx, "device-a").await?; + wait_for_authoritative_owner(&sink, &session_key_for("nas-a", "session-a")).await?; + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-b", "session-b", SHARED_MAC_B), + started_at + Duration::from_secs(1), + &mut sink, + ); + + assert_eq!( + sessions.expire_due_with_command_sink(started_at + Duration::from_secs(10), &mut sink), + 1 + ); + assert_shared_circuit_create(&mut bus_rx, "device-b").await?; + wait_for_authoritative_owner(&sink, &session_key_for("nas-b", "session-b")).await?; + + let session_c = session_key_for("nas-c", "session-c"); + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-c", "session-c", SHARED_MAC_C), + started_at + Duration::from_secs(11), + &mut sink, + ); + wait_for_retained_owner(&sink, &session_c).await?; + assert_bus_request_channel_empty(&mut bus_rx); + + Ok(()) + } + + #[tokio::test] + async fn stale_expiry_removal_promotes_retained_shared_circuit_owner() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let mut sessions = + shared_circuit_sessions(Duration::from_secs(900), Duration::from_secs(2)); + let started_at = Instant::now(); + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-a", "session-a", SHARED_MAC_A), + started_at, + &mut sink, + ); + assert_shared_circuit_create(&mut bus_rx, "device-a").await?; + wait_for_authoritative_owner(&sink, &session_key_for("nas-a", "session-a")).await?; + + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-b", "session-b", SHARED_MAC_B), + started_at + Duration::from_secs(1), + &mut sink, + ); + + sessions.apply_event_with_command_sink( + reset_event_for("nas-a"), + started_at + Duration::from_secs(2), + &mut sink, + ); + tokio::task::yield_now().await; + assert_bus_request_channel_empty(&mut bus_rx); + assert_eq!( + sessions.expire_due_with_command_sink(started_at + Duration::from_secs(3), &mut sink), + 0 + ); + assert_eq!( + sessions.expire_due_with_command_sink(started_at + Duration::from_secs(4), &mut sink), + 1 + ); + assert_shared_circuit_create(&mut bus_rx, "device-b").await?; + wait_for_authoritative_owner(&sink, &session_key_for("nas-b", "session-b")).await?; + + let session_c = session_key_for("nas-c", "session-c"); + sessions.apply_event_with_command_sink( + shared_circuit_event(AcctStatusType::Start, "nas-c", "session-c", SHARED_MAC_C), + started_at + Duration::from_secs(5), + &mut sink, + ); + wait_for_retained_owner(&sink, &session_c).await?; + assert_bus_request_channel_empty(&mut bus_rx); + + Ok(()) + } + + #[test] + fn dynamic_circuit_application_queue_reports_unavailable_states() { + let (queue_tx, _queue_rx) = mpsc::channel(1); + assert_eq!( + queue_test_application_intent(&queue_tx, 0, "queued-circuit"), + Ok(()) + ); + assert_eq!( + queue_test_application_intent(&queue_tx, 1, "overflow-circuit"), + Err(DynamicCircuitApplicationError::QueueUnavailable( + DynamicCircuitQueueUnavailableReason::Full + )) + ); + + let (closed_queue_tx, closed_queue_rx) = mpsc::channel(1); + drop(closed_queue_rx); + assert_eq!( + queue_test_application_intent(&closed_queue_tx, 2, "closed-circuit"), + Err(DynamicCircuitApplicationError::QueueUnavailable( + DynamicCircuitQueueUnavailableReason::Closed + )) + ); + } + + #[test] + fn applying_sink_records_queue_failure_diagnostic() { + let sessions = + RadiusAccountingSessions::new(Duration::from_secs(900), Duration::from_secs(120)); + let (queue_tx, queue_rx) = mpsc::channel(1); + drop(queue_rx); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + let mut sink = ApplyingDynamicCircuitSink::new_with_queue_for_test( + queue_tx, + application_state.clone(), + 0, + ); + let intent = test_application_intent("closed-queue-circuit"); + let context = DynamicCircuitApplicationContext::from_intent(&intent); + + sink.emit(intent); + + let diagnostics = sink.activation_diagnostics(&sessions); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| { + diagnostic.session_key == context.session_key + && diagnostic.circuit_ids == vec![context.circuit_id.clone()] + }) + .expect("queue failure should record an apply-failed diagnostic"); + assert_eq!( + diagnostic.state, + RadiusActivationDiagnosticState::ApplyFailed + ); + assert_eq!(diagnostic.session_key, context.session_key); + assert_eq!(diagnostic.circuit_ids, vec![context.circuit_id]); + assert!( + diagnostic + .apply_error + .as_deref() + .is_some_and(|error| error.contains("queue closed")) + ); + assert!(!format!("{diagnostic:?}").contains("shared secret")); + } + + #[tokio::test] + async fn full_queue_removal_defers_and_drops_older_pending_upsert() -> anyhow::Result<()> { + let (queue_tx, mut queue_rx) = mpsc::channel(1); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + let (owner_session, owner_intent, owner_context) = + shared_owner_intent("nas-a", "session-a", "device-a"); + application_state.lock().record_upsert(&owner_context); + application_state + .lock() + .track_pending_upsert(&owner_context, 3); + assert!( + queue_tx + .try_send(test_queued_intent(3, owner_intent.clone())) + .is_ok() + ); + + let mut sink = ApplyingDynamicCircuitSink::new_with_queue_for_test( + queue_tx, + application_state.clone(), + 4, + ); + sink.emit(shared_removal_intent(owner_session.clone())); + + { + let state = application_state.lock(); + let owners = state + .current_owners_by_circuit + .get(SHARED_CIRCUIT_ID) + .expect("owner should remain tracked until removal applies"); + assert_eq!(owners.len(), 1); + assert!(owners.contains(&owner_session)); + assert!( + state + .authoritative_session_by_circuit + .get(SHARED_CIRCUIT_ID) + == Some(&owner_session) + ); + assert_eq!(state.deferred_removal_sequence_by_owner.len(), 1); + } + + let queued_upsert = queue_rx + .recv() + .await + .ok_or_else(|| anyhow::anyhow!("expected older queued upsert"))?; + + let (bus_tx, mut bus_rx) = mpsc::channel(1); + let _keep_bus_open = bus_tx.clone(); + let queued_intent = process_dynamic_circuit_intent_and_pending_removals( + bus_tx, + &application_state, + queued_upsert, + ); + let (removed_circuit_id, ()) = + tokio::join!(receive_remove_and_ack(&mut bus_rx), queued_intent); + assert_eq!(removed_circuit_id?, SHARED_CIRCUIT_ID); + + { + let state = application_state.lock(); + assert!( + !state + .current_owners_by_circuit + .contains_key(SHARED_CIRCUIT_ID) + ); + assert!(state.deferred_removal_sequence_by_owner.is_empty()); + assert!(state.pending_upsert_sequences_by_owner.is_empty()); + } + + Ok(()) + } + + #[tokio::test] + async fn bus_failure_detail_is_sanitized_in_apply_failed_diagnostic() -> anyhow::Result<()> { + let sessions = + RadiusAccountingSessions::new(Duration::from_secs(900), Duration::from_secs(120)); + let (bus_tx, mut bus_rx) = mpsc::channel(1); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let intent = test_application_intent("failed-detail-circuit"); + let context = DynamicCircuitApplicationContext::from_intent(&intent); + + sink.emit(intent); + let (reply_tx, shaped_device) = receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(shaped_device.circuit_id, context.circuit_id); + fail_bus_reply( + reply_tx, + "bakery failure code=runtime-node-missing shared secret super-secret-value token = abc123", + )?; + + let diagnostic = wait_for_apply_failed_diagnostic( + &sink, + &sessions, + &context.circuit_id, + &context.session_key, + ) + .await?; + let apply_error = diagnostic + .apply_error + .as_deref() + .ok_or_else(|| anyhow::anyhow!("expected apply_error detail"))?; + + assert!(apply_error.contains("code=runtime-node-missing")); + assert!(apply_error.contains("[redacted]")); + assert!(!apply_error.contains("super-secret-value")); + assert!(!apply_error.contains("abc123")); + + Ok(()) + } + + #[tokio::test] + async fn queued_removal_drops_older_pending_upsert_before_bus_request() -> anyhow::Result<()> { + let (queue_tx, mut queue_rx) = mpsc::channel(2); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + let (owner_session, owner_intent, owner_context) = + shared_owner_intent("nas-queued", "session-queued", "device-queued"); + application_state.lock().record_upsert(&owner_context); + application_state + .lock() + .track_pending_upsert(&owner_context, 2); + assert!( + queue_tx + .try_send(test_queued_intent(2, owner_intent)) + .is_ok() + ); + + let mut sink = ApplyingDynamicCircuitSink::new_with_queue_for_test( + queue_tx, + application_state.clone(), + 3, + ); + sink.emit(shared_removal_intent(owner_session)); + + let Ok(queued_upsert) = queue_rx.try_recv() else { + anyhow::bail!("expected older queued upsert"); + }; + let Ok(queued_removal) = queue_rx.try_recv() else { + anyhow::bail!("expected queued removal"); + }; + let (bus_tx, mut bus_rx) = mpsc::channel(1); + let _keep_bus_open = bus_tx.clone(); + process_dynamic_circuit_intent(bus_tx.clone(), &application_state, queued_upsert).await; + assert_bus_request_channel_empty(&mut bus_rx); + + let removal = process_dynamic_circuit_intent(bus_tx, &application_state, queued_removal); + let (removed_circuit_id, ()) = tokio::join!(receive_remove_and_ack(&mut bus_rx), removal); + assert_eq!(removed_circuit_id?, SHARED_CIRCUIT_ID); + let state = application_state.lock(); + assert!( + !state + .current_owners_by_circuit + .contains_key(SHARED_CIRCUIT_ID) + ); + assert!(state.deferred_removal_sequence_by_owner.is_empty()); + + Ok(()) + } + + #[test] + fn pending_removal_replacement_moves_owner_to_latest_fifo_position() -> anyhow::Result<()> { + let mut state = DynamicCircuitApplicationState::default(); + let (session_a, _intent_a, context_a) = + shared_owner_intent("nas-a", "session-a", "device-a"); + let (session_b, _intent_b, context_b) = + shared_owner_intent("nas-b", "session-b", "device-b"); + + state.record_pending_removal( + &context_a, + test_queued_intent(10, shared_removal_intent(session_a.clone())), + ); + state.record_pending_removal( + &context_b, + test_queued_intent(11, shared_removal_intent(session_b.clone())), + ); + state.record_pending_removal( + &context_a, + test_queued_intent(12, shared_removal_intent(session_a.clone())), + ); + + let Some(queued_b) = state.take_next_pending_removal() else { + anyhow::bail!("expected second owner removal first"); + }; + assert_eq!(queued_b.sequence, 11); + assert_eq!( + DynamicCircuitApplicationContext::from_intent(&queued_b.intent).session_key, + session_b + ); + + let Some(queued_a) = state.take_next_pending_removal() else { + anyhow::bail!("expected latest first-owner removal second"); + }; + assert_eq!(queued_a.sequence, 12); + assert_eq!( + DynamicCircuitApplicationContext::from_intent(&queued_a.intent).session_key, + session_a + ); + assert!(state.take_next_pending_removal().is_none()); + + Ok(()) + } + + #[tokio::test] + async fn failed_queued_removal_keeps_authoritative_owner() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(1); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + let (owner_session, _owner_intent, owner_context) = shared_owner_intent( + "nas-failed-remove", + "session-failed-remove", + "device-failed", + ); + application_state.lock().record_upsert(&owner_context); + + let application_state_for_removal = application_state.clone(); + let owner_session_for_removal = owner_session.clone(); + let removal_task = tokio::spawn(async move { + process_dynamic_circuit_intent( + bus_tx, + &application_state_for_removal, + test_queued_intent(2, shared_removal_intent(owner_session_for_removal)), + ) + .await; + }); + + let (reply_tx, circuit_id) = receive_remove_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(circuit_id, SHARED_CIRCUIT_ID); + fail_bus_reply(reply_tx, "fake remove failure")?; + removal_task.await?; + + let state = application_state.lock(); + let owners = state + .current_owners_by_circuit + .get(SHARED_CIRCUIT_ID) + .expect("authoritative owner should remain tracked after failed removal"); + assert!(owners.contains(&owner_session)); + assert_eq!( + state + .authoritative_session_by_circuit + .get(SHARED_CIRCUIT_ID), + Some(&owner_session) + ); + + Ok(()) + } + + #[tokio::test] + async fn stale_removal_after_newer_upsert_is_skipped() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(1); + let _keep_bus_open = bus_tx.clone(); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + let (owner_session, owner_intent, _owner_context) = + shared_owner_intent("nas-newer", "session-newer", "device-newer"); + + let newer_upsert = process_dynamic_circuit_intent( + bus_tx.clone(), + &application_state, + test_queued_intent(5, owner_intent), + ); + let (upserted, ()) = tokio::join!(receive_create_and_ack(&mut bus_rx), newer_upsert); + assert_eq!(upserted?.device_id, "device-newer"); + assert_bus_request_channel_empty(&mut bus_rx); + + process_dynamic_circuit_intent( + bus_tx, + &application_state, + test_queued_intent(4, shared_removal_intent(owner_session.clone())), + ) + .await; + assert_bus_request_channel_empty(&mut bus_rx); + + let state = application_state.lock(); + let owners = state + .current_owners_by_circuit + .get(SHARED_CIRCUIT_ID) + .expect("newer owner should remain tracked"); + assert!(owners.contains(&owner_session)); + assert_eq!( + state + .authoritative_session_by_circuit + .get(SHARED_CIRCUIT_ID), + Some(&owner_session) + ); + + Ok(()) + } + + #[tokio::test] + async fn rekeyed_removal_uses_original_single_owner() -> anyhow::Result<()> { + let (bus_tx, mut bus_rx) = mpsc::channel(1); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + let original_session = session_key_for("nas-original", "session-rekeyed"); + let current_session = session_key_for("nas-current", "session-rekeyed"); + let circuit_id = expected_circuit_id("nas-original"); + let upsert = DynamicCircuitIntent::CreateDynamicCircuit(DynamicCircuitUpsert { + circuit_id: circuit_id.clone(), + session_key: original_session.clone(), + event: complete_event_for(AcctStatusType::Start, "nas-original", "session-rekeyed"), + shaped_device: shaped_device(&circuit_id, "device-rekeyed"), + }); + let owner_context = DynamicCircuitApplicationContext::from_intent(&upsert); + application_state.lock().record_upsert(&owner_context); + + let application_state_for_removal = application_state.clone(); + let circuit_id_for_removal = circuit_id.clone(); + let removal_task = tokio::spawn(async move { + process_dynamic_circuit_intent( + bus_tx, + &application_state_for_removal, + test_queued_intent( + 3, + DynamicCircuitIntent::RemoveDynamicCircuit(DynamicCircuitRemoval { + circuit_id: circuit_id_for_removal, + session_key: current_session, + reason: DynamicCircuitRemovalReason::Rekeyed, + }), + ), + ) + .await; + }); + + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, circuit_id); + removal_task.await?; + + let state = application_state.lock(); + assert!(!state.current_owners_by_circuit.contains_key(&circuit_id)); + assert!( + !state + .authoritative_session_by_circuit + .contains_key(&circuit_id) + ); + + Ok(()) + } + + #[test] + fn closed_queue_removal_keeps_authoritative_owner_and_deferred_marker() -> anyhow::Result<()> { + let (queue_tx, queue_rx) = mpsc::channel(1); + drop(queue_rx); + let application_state = Arc::new(Mutex::new(DynamicCircuitApplicationState::default())); + let (owner_session, _owner_intent, owner_context) = + shared_owner_intent("nas-closed", "session-closed", "device-closed"); + let (other_session, _other_intent, other_context) = + shared_owner_intent("nas-other", "session-other", "device-other"); + application_state.lock().record_upsert(&owner_context); + application_state.lock().record_upsert(&other_context); + application_state + .lock() + .track_pending_upsert(&owner_context, 3); + + let mut sink = ApplyingDynamicCircuitSink::new_with_queue_for_test( + queue_tx, + application_state.clone(), + 4, + ); + sink.emit(shared_removal_intent(owner_session.clone())); + + let state = application_state.lock(); + let owners = state + .current_owners_by_circuit + .get(SHARED_CIRCUIT_ID) + .expect("shared owners should remain tracked"); + assert_eq!(owners.len(), 2); + assert!(owners.contains(&other_session)); + assert!(owners.contains(&owner_session)); + assert!( + state + .authoritative_session_by_circuit + .get(SHARED_CIRCUIT_ID) + == Some(&owner_session) + ); + assert_eq!(state.deferred_removal_sequence_by_owner.len(), 1); + assert_eq!(state.pending_upsert_sequences_by_owner.len(), 1); + + Ok(()) + } + + #[test] + fn authoritative_release_preserves_other_shared_owners() { + let mut state = DynamicCircuitApplicationState::default(); + let (_session_a, _intent_a, context_a) = + shared_owner_intent("nas-a", "session-a", "device-a"); + let (_session_b, _intent_b, context_b) = + shared_owner_intent("nas-b", "session-b", "device-b"); + let (_session_c, _intent_c, context_c) = + shared_owner_intent("nas-c", "session-c", "device-c"); + + state.record_upsert(&context_a); + state.record_upsert(&context_b); + state.release_owner(&context_a); + + let owners = state + .current_owners_by_circuit + .get(SHARED_CIRCUIT_ID) + .expect("non-authoritative owner should remain tracked"); + assert!(!owners.contains(&context_a.session_key)); + assert!(owners.contains(&context_b.session_key)); + assert!( + !state + .authoritative_session_by_circuit + .contains_key(SHARED_CIRCUIT_ID) + ); + assert!(state.shared_upsert_may_apply(&context_c)); + } + + #[test] + fn dynamic_circuit_application_result_classifies_bus_replies() { + assert_eq!( + dynamic_circuit_application_result(BusReply { + responses: vec![BusResponse::Ack], + }), + Ok(()) + ); + assert_eq!( + dynamic_circuit_application_result(BusReply { responses: vec![] }), + Err(DynamicCircuitApplicationError::MissingResponse) + ); + assert_eq!( + dynamic_circuit_application_result(BusReply { + responses: vec![BusResponse::Fail("fake failure".to_string())], + }), + Err(DynamicCircuitApplicationError::RequestFailed( + "fake failure".to_string() + )) + ); + assert_eq!( + DynamicCircuitApplicationError::RequestFailed("shared secret value".to_string()) + .to_string(), + "dynamic circuit request failed: shared [redacted] [redacted]" + ); + assert_eq!( + DynamicCircuitApplicationError::RequestFailed( + "bakery failure code=runtime-node-missing parent not found".to_string() + ) + .to_string(), + "dynamic circuit request failed: bakery failure code=runtime-node-missing parent not found" + ); + assert_eq!( + dynamic_circuit_application_result(BusReply { + responses: vec![BusResponse::Ack, BusResponse::Ack], + }), + Err(DynamicCircuitApplicationError::UnexpectedResponseCount(2)) + ); + assert_eq!( + dynamic_circuit_application_result(BusReply { + responses: vec![BusResponse::NotReadyYet], + }), + Err(DynamicCircuitApplicationError::UnexpectedResponse( + "NotReadyYet".to_string() + )) + ); + assert_eq!( + DynamicCircuitApplicationError::UnexpectedResponse("NotReadyYet".to_string()) + .to_string(), + "dynamic circuit request returned unexpected response: NotReadyYet" + ); + } + + #[test] + fn dynamic_circuit_application_error_display_redacts_and_bounds_bus_detail() { + let secret_error = DynamicCircuitApplicationError::RequestFailed( + "backend rejected shared secret super-secret-value token -> abc123 password => hunter2 api key = keyvalue private key - privatevalue authorization Bearer bearer-value" + .to_string(), + ) + .to_string(); + + assert!(secret_error.contains("backend rejected")); + assert!(secret_error.contains("[redacted]")); + assert!(!secret_error.contains("super-secret-value")); + assert!(!secret_error.contains("abc123")); + assert!(!secret_error.contains("hunter2")); + assert!(!secret_error.contains("keyvalue")); + assert!(!secret_error.contains("privatevalue")); + assert!(!secret_error.contains("bearer-value")); + + let bounded_error = DynamicCircuitApplicationError::RequestFailed( + "x".repeat(DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT + 10), + ) + .to_string(); + + assert!(bounded_error.ends_with("...")); + assert!( + bounded_error.len() + <= "dynamic circuit request failed: ".len() + + DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT + + "...".len() + ); + + let bounded_non_ascii_error = DynamicCircuitApplicationError::RequestFailed( + "á".repeat(DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT), + ) + .to_string(); + assert!(bounded_non_ascii_error.ends_with("...")); + assert!( + bounded_non_ascii_error.len() + <= "dynamic circuit request failed: ".len() + + DYNAMIC_CIRCUIT_BUS_FAILURE_DETAIL_LIMIT + + "...".len() + ); + } + + #[test] + fn dynamic_circuit_bus_failure_sanitizer_redacts_two_word_key_labels() { + for (detail, expected) in [ + ( + "backend rejected api key: abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected private key: abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected access key: abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected api key abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected private key abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected access key abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected api key= abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected api key=abc123 parent missing", + "backend rejected [redacted] [redacted] parent missing", + ), + ( + "backend rejected api-key=abc123 parent missing", + "backend rejected [redacted] parent missing", + ), + ( + "backend rejected private-key=abc123 parent missing", + "backend rejected [redacted] parent missing", + ), + ( + "backend rejected access-key=abc123 parent missing", + "backend rejected [redacted] parent missing", + ), + ( + "backend rejected accesskey=abc123 parent missing", + "backend rejected [redacted] parent missing", + ), + ( + "backend rejected key: abc123 parent missing", + "backend rejected [redacted] [redacted] parent missing", + ), + ( + "backend rejected key= abc123 parent missing", + "backend rejected [redacted] [redacted] parent missing", + ), + ( + "backend rejected key=abc123 parent missing", + "backend rejected [redacted] parent missing", + ), + ( + "backend rejected key = abc123 parent missing", + "backend rejected [redacted] [redacted] [redacted] parent missing", + ), + ( + "backend rejected key =abc123 parent missing", + "backend rejected [redacted] [redacted] parent missing", + ), + ( + "backend rejected key :abc123 parent missing", + "backend rejected [redacted] [redacted] parent missing", + ), + ] { + let sanitized = sanitize_dynamic_circuit_bus_failure_detail(detail); + + assert_eq!(sanitized, expected); + assert!(!sanitized.contains("abc123")); + } + } + + #[test] + fn dynamic_circuit_upsert_ownership_records_ambiguous_replies() { + assert!(dynamic_circuit_upsert_result_records_success(&Ok(()))); + assert!(dynamic_circuit_upsert_result_records_success(&Err( + DynamicCircuitApplicationError::ReplyDropped + ))); + assert!(dynamic_circuit_upsert_result_records_success(&Err( + DynamicCircuitApplicationError::ReplyTimeout + ))); + assert!(!dynamic_circuit_upsert_result_records_success(&Err( + DynamicCircuitApplicationError::BusClosed + ))); + + assert!(dynamic_circuit_removal_result_releases_owner(&Ok(()))); + assert!(!dynamic_circuit_removal_result_releases_owner(&Err( + DynamicCircuitApplicationError::ReplyDropped + ))); + assert!(!dynamic_circuit_removal_result_releases_owner(&Err( + DynamicCircuitApplicationError::ReplyTimeout + ))); + assert!(!dynamic_circuit_removal_result_releases_owner(&Err( + DynamicCircuitApplicationError::BusClosed + ))); + } + + #[tokio::test] + async fn stop_after_dropped_create_reply_still_removes_dynamic_circuit() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(900), + Duration::from_secs(120), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let (bus_tx, mut bus_rx) = mpsc::channel(4); + let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); + let key = session_key(); + let circuit_id = expected_circuit_id("nas-adapter"); + let started_at = Instant::now(); + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Start), + &mut sessions, + &mut sink, + started_at, + test_listen_addr(), + 64, + 20, + ); + let (start_reply, start_device) = receive_create_dynamic_circuit(&mut bus_rx).await?; + assert_eq!(start_device.circuit_id, circuit_id); + drop(start_reply); + wait_for_authoritative_owner_for_circuit(&sink, &circuit_id, &key).await?; + + handle_accounting_event_with_command_sink( + complete_event(AcctStatusType::Stop), + &mut sessions, + &mut sink, + started_at + Duration::from_secs(1), + test_listen_addr(), + 64, + 20, + ); + assert_eq!(receive_remove_and_ack(&mut bus_rx).await?, circuit_id); + + Ok(()) + } + + #[test] + fn adapter_expiry_emits_deferred_removal_intent() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(10), + Duration::from_secs(2), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let mut sink = RecordingDynamicCircuitSink::default(); + let started_at = Instant::now(); + let key = session_key(); + let circuit_id = expected_circuit_id("nas-adapter"); + + sessions.apply_event_with_command_sink( + complete_event(AcctStatusType::Start), + started_at, + &mut sink, + ); + assert_eq!(sink.intents.len(), 1); + assert_eq!(sessions.activation_counters().create, 1); + assert_eq!( + sessions.expire_due_with_command_sink(started_at + Duration::from_secs(9), &mut sink), + 0 + ); + assert_eq!(sink.intents.len(), 1); + assert_eq!( + sessions.expire_due_with_command_sink(started_at + Duration::from_secs(10), &mut sink), + 1 + ); + + assert_eq!(sink.intents.len(), 2); + let DynamicCircuitIntent::RemoveDynamicCircuit(expiry) = &sink.intents[1] else { + anyhow::bail!("expected expiry removal intent, got {:?}", sink.intents[1]); + }; + assert_eq!(expiry.circuit_id, circuit_id); + assert_eq!(expiry.session_key, key); + assert_eq!(expiry.reason, DynamicCircuitRemovalReason::Expired); + assert_eq!(sessions.activation_counters().remove, 1); + assert_eq!(sessions.activation_counters().expiry, 1); + let diagnostics = sessions.activation_diagnostics(); + let expired = diagnostics + .iter() + .find(|diagnostic| diagnostic.session_key == key) + .expect("expired diagnostic should be retained"); + assert_eq!(expired.state, RadiusActivationDiagnosticState::Expired); + assert_eq!(expired.circuit_ids, vec![circuit_id]); + + Ok(()) + } + + #[test] + fn default_adapter_paths_use_deferred_sink() -> anyhow::Result<()> { + let fallback_rate = SessionRateProfile::new(4.0, 2.0, 40.0, 12.0)?; + let fallback_parent = DynamicCircuitParent::new("Core PPPoE"); + let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + Duration::from_secs(10), + Duration::from_secs(2), + Some(fallback_rate), + Some(fallback_parent), + None, + ); + let started_at = Instant::now(); + + handle_accounting_event( + complete_event(AcctStatusType::Start), + &mut sessions, + started_at, + test_listen_addr(), + 64, + 20, + ); + assert_eq!(sessions.expire_due(started_at + Duration::from_secs(10)), 1); + + Ok(()) + } + + #[test] + fn accepted_event_updates_sessions_without_dynamic_application() -> anyhow::Result<()> { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(900), Duration::from_secs(120)); + let now = Instant::now(); + + handle_accounting_event( + complete_event(AcctStatusType::Start), + &mut sessions, + now, + test_listen_addr(), + 64, + 20, + ); + + let key = session_key(); + let Some(session) = sessions.store.session(&key) else { + anyhow::bail!("accepted event should create an in-memory session"); + }; + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::MissingParent] + ); + assert_eq!(sessions.updated_at.get(&key), Some(&now)); + + Ok(()) + } + + #[tokio::test] + async fn packet_counters_track_listener_outcomes_separately_from_activation_counters() + -> anyhow::Result<()> { + const SHARED_SECRET: &[u8] = b"radius-secret"; + const ACCOUNTING_START_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, + ]; + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(900), Duration::from_secs(120)); + let mut expiry_timer = RadiusExpiryTimer::new(&sessions, Instant::now()); + let mut applying_sink = None; + let accepted_request = verify_accounting_request( + &ACCOUNTING_START_REQUEST, + SHARED_SECRET, + MessageAuthenticatorPolicy::Optional, + )?; + + handle_listener_outcome_with_application_sink( + AccountingListenerOutcome::Accepted(ReceivedVerifiedAccountingPacket { + peer: test_listen_addr(), + received_len: ACCOUNTING_START_REQUEST.len(), + response_len: 20, + request: accepted_request, + }), + &mut sessions, + &mut expiry_timer, + Instant::now(), + &mut applying_sink, + ); + handle_listener_outcome_with_application_sink( + AccountingListenerOutcome::RejectedSource { + peer: test_listen_addr(), + received_len: 20, + }, + &mut sessions, + &mut expiry_timer, + Instant::now(), + &mut applying_sink, + ); + + assert_eq!( + sessions.packet_counters(), + RadiusPacketCounters { + accepted: 1, + rejected: 1, + } + ); + assert_eq!(sessions.activation_counters(), Default::default()); + + Ok(()) + } + + #[test] + fn session_expiry_uses_default_ttl_and_stale_grace() -> anyhow::Result<()> { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(10), Duration::from_secs(2)); + let started_at = Instant::now(); + + sessions.apply_event(complete_event(AcctStatusType::Start), started_at); + assert_eq!(sessions.expire_due(started_at + Duration::from_secs(9)), 0); + assert_eq!(sessions.expire_due(started_at + Duration::from_secs(10)), 1); + assert!(!sessions.updated_at.contains_key(&session_key())); + + let restarted_at = started_at + Duration::from_secs(20); + let reset_at = restarted_at + Duration::from_secs(1); + sessions.apply_event(complete_event(AcctStatusType::Start), restarted_at); + sessions.apply_event(reset_event(), reset_at); + assert_eq!( + sessions + .store + .session(&session_key()) + .map(|session| session.state), + Some(AccountingSessionState::Stale( + lqos_radius::NasResetStatus::AccountingOff + )) + ); + assert_eq!(sessions.expire_due(reset_at + Duration::from_secs(1)), 0); + assert_eq!(sessions.expire_due(reset_at + Duration::from_secs(2)), 1); + assert!(!sessions.updated_at.contains_key(&session_key())); + + Ok(()) + } + + #[tokio::test(start_paused = true)] + async fn stale_update_advances_timer_to_changed_deadline() { + let stale_grace = Duration::from_millis(500); + let mut sessions = RadiusAccountingSessions::new(Duration::from_secs(60), stale_grace); + let now = radius_accounting_now(); + let mut expiry_timer = RadiusExpiryTimer::new(&sessions, now); + + let start_update = sessions.apply_event(complete_event(AcctStatusType::Start), now); + expiry_timer.schedule_after_update(&sessions, &start_update, now); + assert_eq!(expiry_timer.wake_at, now + Duration::from_secs(1)); + + let stale_update = sessions.apply_event(reset_event(), now); + expiry_timer.schedule_after_update(&sessions, &stale_update, now); + assert_eq!(expiry_timer.wake_at, now + stale_grace); + } + + #[test] + fn promoted_sessions_prune_old_timestamp_keys() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(900), Duration::from_secs(120)); + let started_at = Instant::now(); + let mut pending_event = complete_event(AcctStatusType::Start); + pending_event.nas_identifier = None; + + sessions.apply_event(pending_event, started_at); + assert_eq!(sessions.updated_at.len(), 1); + + let promoted_at = started_at + Duration::from_secs(1); + sessions.apply_event(complete_event(AcctStatusType::InterimUpdate), promoted_at); + + assert_eq!(sessions.updated_at.len(), 1); + assert_eq!(sessions.updated_at.get(&session_key()), Some(&promoted_at)); + } + + #[test] + fn nas_reset_refreshes_only_matching_stale_sessions() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(60), Duration::from_secs(2)); + let started_at = Instant::now(); + let nas_a_reset_at = started_at + Duration::from_secs(1); + let nas_b_started_at = started_at + Duration::from_secs(2); + let nas_b_reset_at = started_at + Duration::from_secs(3); + let nas_a_key = session_key_for("nas-a", "session-a"); + + sessions.apply_event( + complete_event_for(AcctStatusType::Start, "nas-a", "session-a"), + started_at, + ); + sessions.apply_event(reset_event_for("nas-a"), nas_a_reset_at); + assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&nas_a_reset_at)); + + sessions.apply_event( + complete_event_for(AcctStatusType::Start, "nas-b", "session-b"), + nas_b_started_at, + ); + sessions.apply_event(reset_event_for("nas-b"), nas_b_reset_at); + + assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&nas_a_reset_at)); + assert_eq!(sessions.expire_due(nas_b_reset_at), 1); + assert!(sessions.store.session(&nas_a_key).is_none()); + } + + #[test] + fn repeated_nas_reset_does_not_refresh_already_stale_sessions() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(60), Duration::from_secs(2)); + let started_at = Instant::now(); + let first_reset_at = started_at + Duration::from_secs(1); + let repeated_reset_at = first_reset_at + Duration::from_secs(1); + let stale_deadline = first_reset_at + Duration::from_secs(2); + let nas_a_key = session_key_for("nas-a", "session-a"); + + sessions.apply_event( + complete_event_for(AcctStatusType::Start, "nas-a", "session-a"), + started_at, + ); + sessions.apply_event(reset_event_for("nas-a"), first_reset_at); + sessions.apply_event(reset_event_for("nas-a"), repeated_reset_at); + + assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&first_reset_at)); + assert_eq!(sessions.expire_due(stale_deadline), 1); + assert!(sessions.store.session(&nas_a_key).is_none()); + } + + #[test] + fn alternating_nas_reset_status_does_not_refresh_already_stale_sessions() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(60), Duration::from_secs(2)); + let started_at = Instant::now(); + let first_reset_at = started_at + Duration::from_secs(1); + let second_reset_at = first_reset_at + Duration::from_secs(1); + let stale_deadline = first_reset_at + Duration::from_secs(2); + let nas_a_key = session_key_for("nas-a", "session-a"); + + sessions.apply_event( + complete_event_for(AcctStatusType::Start, "nas-a", "session-a"), + started_at, + ); + sessions.apply_event(reset_event_for("nas-a"), first_reset_at); + sessions.apply_event( + reset_event_for_status("nas-a", AcctStatusType::AccountingOn), + second_reset_at, + ); + + assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&first_reset_at)); + assert_eq!( + sessions + .store + .session(&nas_a_key) + .map(|session| session.state), + Some(AccountingSessionState::Stale( + lqos_radius::NasResetStatus::AccountingOn + )) + ); + assert_eq!(sessions.expire_due(stale_deadline), 1); + assert!(sessions.store.session(&nas_a_key).is_none()); + } + + #[test] + fn repeated_nas_reset_refreshes_cached_stale_diagnostic_reason() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(60), Duration::from_secs(2)); + let started_at = Instant::now(); + let first_reset_at = started_at + Duration::from_secs(1); + let second_reset_at = first_reset_at + Duration::from_secs(1); + let nas_a_key = session_key_for("nas-cached-stale", "session-cached-stale"); + + sessions.apply_event( + complete_event_for( + AcctStatusType::Start, + "nas-cached-stale", + "session-cached-stale", + ), + started_at, + ); + sessions.apply_event(reset_event_for("nas-cached-stale"), first_reset_at); + sessions.apply_event( + reset_event_for_status("nas-cached-stale", AcctStatusType::AccountingOn), + second_reset_at, + ); + + let diagnostic = sessions + .activation_diagnostics() + .into_iter() + .find(|diagnostic| diagnostic.session_key == nas_a_key) + .expect("stale diagnostic should remain cached"); + assert_eq!( + diagnostic.state, + RadiusActivationDiagnosticState::Stale(lqos_radius::NasResetStatus::AccountingOn) + ); + assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&first_reset_at)); + } + + #[test] + fn expired_activation_diagnostics_are_bounded() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(1), Duration::from_secs(1)); + let started_at = Instant::now(); + + for index in 0..(RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + 2) { + let session_id = format!("session-{index}"); + sessions.apply_event( + complete_event_for(AcctStatusType::Start, "nas-expiry", &session_id), + started_at, + ); + } + + assert_eq!( + sessions.expire_due(started_at + Duration::from_secs(2)), + RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + 2 + ); + let diagnostics = sessions.activation_diagnostics(); + assert_eq!(diagnostics.len(), RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT); + assert_eq!( + sessions.recent_expired_activation_diagnostics.len(), + RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + ); + assert!(sessions.update_sequence_by_key.is_empty()); + assert!(sessions.activation_diagnostics_by_key.is_empty()); + assert!( + diagnostics + .iter() + .all(|diagnostic| { diagnostic.state == RadiusActivationDiagnosticState::Expired }) + ); + let retained_keys = diagnostics + .iter() + .map(|diagnostic| diagnostic.session_key.clone()) + .collect::>(); + assert!(!retained_keys.contains(&session_key_for("nas-expiry", "session-0"))); + assert!(!retained_keys.contains(&session_key_for("nas-expiry", "session-1"))); + for index in 2..(RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + 2) { + assert!( + retained_keys.contains(&session_key_for("nas-expiry", &format!("session-{index}"))) + ); + } + } + + #[test] + fn expired_activation_diagnostic_is_removed_on_reactivation() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(1), Duration::from_secs(1)); + let started_at = Instant::now(); + let reactivated_at = started_at + Duration::from_secs(2); + let key = session_key_for("nas-reactivated", "session-reactivated"); + let other_key = session_key_for("nas-reactivated", "session-other"); + + sessions.apply_event( + complete_event_for( + AcctStatusType::Start, + "nas-reactivated", + "session-reactivated", + ), + started_at, + ); + sessions.apply_event( + complete_event_for(AcctStatusType::Start, "nas-reactivated", "session-other"), + started_at, + ); + assert_eq!(sessions.expire_due(started_at + Duration::from_secs(1)), 2); + assert_eq!(sessions.activation_diagnostics().len(), 2); + + sessions.apply_event( + complete_event_for( + AcctStatusType::Start, + "nas-reactivated", + "session-reactivated", + ), + reactivated_at, + ); + + let diagnostics = sessions.activation_diagnostics(); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.session_key == key + && diagnostic.state == RadiusActivationDiagnosticState::Pending + })); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.session_key == other_key + && diagnostic.state == RadiusActivationDiagnosticState::Expired + })); + assert_eq!(sessions.recent_expired_activation_diagnostics.len(), 1); + assert_eq!( + sessions.recent_expired_activation_diagnostics[0].session_key, + other_key + ); + } + + #[test] + fn stale_expired_activation_diagnostics_are_bounded() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(60), Duration::from_secs(1)); + let started_at = Instant::now(); + let reset_at = started_at + Duration::from_secs(1); + + for index in 0..(RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + 2) { + let session_id = format!("session-{index}"); + sessions.apply_event( + complete_event_for(AcctStatusType::Start, "nas-stale-expiry", &session_id), + started_at, + ); + } + + sessions.apply_event(reset_event_for("nas-stale-expiry"), reset_at); + assert_eq!( + sessions.expire_due(reset_at + Duration::from_secs(1)), + RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + 2 + ); + + let diagnostics = sessions.activation_diagnostics(); + assert_eq!(diagnostics.len(), RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT); + assert_eq!( + sessions.recent_expired_activation_diagnostics.len(), + RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + ); + assert!(sessions.update_sequence_by_key.is_empty()); + assert!(sessions.activation_diagnostics_by_key.is_empty()); + let retained_keys = diagnostics + .iter() + .map(|diagnostic| diagnostic.session_key.clone()) + .collect::>(); + assert!(!retained_keys.contains(&session_key_for("nas-stale-expiry", "session-0"))); + assert!(!retained_keys.contains(&session_key_for("nas-stale-expiry", "session-1"))); + for index in 2..(RADIUS_RECENT_EXPIRED_DIAGNOSTIC_LIMIT + 2) { + assert!(retained_keys.contains(&session_key_for( + "nas-stale-expiry", + &format!("session-{index}") + ))); + } + } + + #[test] + fn apply_failed_activation_diagnostics_are_bounded() { + let mut state = DynamicCircuitApplicationState::default(); + + for index in 0..(RADIUS_APPLY_FAILED_DIAGNOSTIC_LIMIT + 2) { + let session_id = format!("session-{index}"); + let (_, _, context) = shared_owner_intent("nas-apply-failed", &session_id, "device-a"); + state + .record_application_failure(&context, "dynamic circuit request failed".to_string()); + } + + let diagnostics = state.application_diagnostics(); + assert_eq!(diagnostics.len(), RADIUS_APPLY_FAILED_DIAGNOSTIC_LIMIT); + assert_eq!( + state.application_diagnostic_order.len(), + RADIUS_APPLY_FAILED_DIAGNOSTIC_LIMIT + ); + assert_eq!( + state.application_diagnostics_by_owner.len(), + RADIUS_APPLY_FAILED_DIAGNOSTIC_LIMIT + ); + assert_eq!( + diagnostics + .first() + .map(|diagnostic| &diagnostic.session_key), + Some(&session_key_for("nas-apply-failed", "session-2")) + ); + assert!(diagnostics.iter().all(|diagnostic| { + diagnostic.state == RadiusActivationDiagnosticState::ApplyFailed + })); + } + + #[test] + fn apply_failed_filter_preserves_other_circuit_ids_for_same_session() { + let session_key = session_key_for("nas-filter", "session-filter"); + let failed_owner = ("failed-circuit".to_string(), session_key.clone()); + let failed_owners = HashSet::from([failed_owner]); + let mut diagnostics = vec![RadiusActivationDiagnostic { + session_key: session_key.clone(), + acct_session_id: Some("session-filter".to_string()), + nas: Some(NasIdentity::Identifier("nas-filter".to_string())), + circuit_ids: vec!["failed-circuit".to_string(), "healthy-circuit".to_string()], + state: RadiusActivationDiagnosticState::Active, + pending_reasons: Vec::new(), + apply_error: None, + }]; + + suppress_failed_owner_diagnostics(&mut diagnostics, &failed_owners); + + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].session_key, session_key); + assert_eq!(diagnostics[0].circuit_ids, vec!["healthy-circuit"]); + } + + #[test] + fn push_limited_handles_zero_exact_and_over_limit() { + let mut items = VecDeque::from([1, 2]); + push_limited(&mut items, 3, 0); + assert_eq!(items, VecDeque::from([1, 2])); + + push_limited(&mut items, 3, 3); + assert_eq!(items, VecDeque::from([1, 2, 3])); + + push_limited(&mut items, 4, 3); + assert_eq!(items, VecDeque::from([2, 3, 4])); + } + + #[test] + fn listener_send_errors_are_recoverable() { + let err = lqos_radius::ListenerError::Send { + peer: test_listen_addr(), + source: std::io::Error::from_raw_os_error(nix::libc::ECONNREFUSED), + }; + + assert!(listener_error_is_recoverable(&err)); + } + + #[derive(Default)] + struct RecordingDynamicCircuitSink { + intents: Vec, + } + + impl DynamicCircuitCommandSink for RecordingDynamicCircuitSink { + fn emit(&mut self, intent: DynamicCircuitIntent) { + self.intents.push(intent); + } + } + + fn enabled_config(secret_path: &Path) -> RadiusAccountingConfig { + RadiusAccountingConfig { + enabled: true, + listen: Some(test_listen_addr()), + default_ttl_seconds: 900, + stale_grace_seconds: 120, + dynamic_circuit_application: RadiusDynamicCircuitApplicationConfig::default(), + fallback_speed_profile: None, + clients: vec![RadiusAccountingClient { + name: "pppoe-core-1".to_string(), + source: vec![RadiusClientSource::new(IpNetwork::from(IpAddr::V4( + Ipv4Addr::new(127, 0, 0, 1), + )))], + secret_file: RadiusSharedSecretSource::from(secret_path.to_string_lossy().as_ref()), + }], + } + } + + fn test_listen_addr() -> SocketAddr { + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 18130)) + } + + fn test_bus_sender() -> DynamicCircuitBusSender { + let (bus_tx, _bus_rx) = mpsc::channel(1); + bus_tx + } + + fn sessions_from_runtime_config( + runtime_config: RadiusAccountingRuntimeConfig, + ) -> RadiusAccountingSessions { + RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + runtime_config.default_ttl, + runtime_config.stale_grace, + runtime_config.fallback_rate_profile, + runtime_config.fallback_parent, + runtime_config.mac_matcher, + ) + } + + const SHARED_CIRCUIT_ID: &str = "circuit-shared"; + const SHARED_MAC_A: &str = "aa-bb-cc-dd-ee-ff"; + const SHARED_MAC_B: &str = "11-22-33-44-55-66"; + const SHARED_MAC_C: &str = "77-88-99-aa-bb-cc"; + + fn shared_circuit_sessions( + default_ttl: Duration, + stale_grace: Duration, + ) -> RadiusAccountingSessions { + let mut device_a = shaped_device(SHARED_CIRCUIT_ID, "device-a"); + device_a.mac = SHARED_MAC_A.to_string(); + let mut device_b = shaped_device(SHARED_CIRCUIT_ID, "device-b"); + device_b.mac = SHARED_MAC_B.to_string(); + let mut device_c = shaped_device(SHARED_CIRCUIT_ID, "device-c"); + device_c.mac = SHARED_MAC_C.to_string(); + + RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + default_ttl, + stale_grace, + None, + None, + Some(ShapedDevicesMacMatcher::from_devices(&[ + device_a, device_b, device_c, + ])), + ) + } + + fn shared_circuit_event( + status_type: AcctStatusType, + nas: &str, + session_id: &str, + mac: &str, + ) -> AccountingEvent { + let mut event = complete_event_for(status_type, nas, session_id); + event.calling_station_id = Some(mac.to_string()); + event + } + + async fn assert_shared_circuit_create( + bus_rx: &mut TestBusReceiver, + device_id: &str, + ) -> anyhow::Result<()> { + let shaped_device = receive_create_and_ack(bus_rx).await?; + assert_eq!(shaped_device.circuit_id, SHARED_CIRCUIT_ID); + assert_eq!(shaped_device.device_id, device_id); + Ok(()) + } + + fn test_application_intent(circuit_id: &str) -> DynamicCircuitIntent { + DynamicCircuitIntent::CreateDynamicCircuit(DynamicCircuitUpsert { + circuit_id: circuit_id.to_string(), + session_key: session_key(), + event: complete_event(AcctStatusType::Start), + shaped_device: shaped_device(circuit_id, "device-test"), + }) + } + + fn shared_owner_intent( + nas: &str, + session_id: &str, + device_id: &str, + ) -> ( + AccountingSessionKey, + DynamicCircuitIntent, + DynamicCircuitApplicationContext, + ) { + let session_key = session_key_for(nas, session_id); + let intent = DynamicCircuitIntent::CreateDynamicCircuit(DynamicCircuitUpsert { + circuit_id: SHARED_CIRCUIT_ID.to_string(), + session_key: session_key.clone(), + event: complete_event_for(AcctStatusType::Start, nas, session_id), + shaped_device: shaped_device(SHARED_CIRCUIT_ID, device_id), + }); + let context = DynamicCircuitApplicationContext::from_intent(&intent); + (session_key, intent, context) + } + + fn shared_removal_intent(session_key: AccountingSessionKey) -> DynamicCircuitIntent { + DynamicCircuitIntent::RemoveDynamicCircuit(DynamicCircuitRemoval { + circuit_id: SHARED_CIRCUIT_ID.to_string(), + session_key, + reason: DynamicCircuitRemovalReason::Stop, + }) + } + + fn test_queued_intent( + sequence: u64, + intent: DynamicCircuitIntent, + ) -> QueuedDynamicCircuitIntent { + QueuedDynamicCircuitIntent { sequence, intent } + } + + fn queue_test_application_intent( + queue_tx: &mpsc::Sender, + sequence: u64, + circuit_id: &str, + ) -> Result<(), DynamicCircuitApplicationError> { + let intent = test_application_intent(circuit_id); + let context = DynamicCircuitApplicationContext::from_intent(&intent); + queue_dynamic_circuit_intent(queue_tx, &context, test_queued_intent(sequence, intent)) + } + + type TestAccountingEvent = (AccountingEvent, oneshot::Sender<()>); + + async fn run_test_accounting_event_loop( + mut event_rx: mpsc::Receiver, + mut sessions: RadiusAccountingSessions, + mut applying_sink: Option, + ) { + let mut expiry_timer = RadiusExpiryTimer::new(&sessions, radius_accounting_now()); + loop { + tokio::select! { + maybe_event = event_rx.recv() => { + let Some((event, processed_tx)) = maybe_event else { + return; + }; + let now = radius_accounting_now(); + expire_due_before_packet( + &mut sessions, + &mut expiry_timer, + now, + &mut applying_sink, + ); + handle_accounting_event_with_application_sink_and_expiry_schedule( + event, + &mut sessions, + &mut expiry_timer, + now, + &mut applying_sink, + AccountingPacketLogContext { + peer: test_listen_addr(), + received_len: 64, + response_len: 20, + }, + ); + let _ = processed_tx.send(()); + } + _ = expiry_timer.sleep_mut() => { + let now = radius_accounting_now(); + expire_due_after_timer_wake( + &mut sessions, + &mut expiry_timer, + now, + &mut applying_sink, + ); + } + } + } + } + + async fn send_test_accounting_event( + event_tx: &mpsc::Sender, + event: AccountingEvent, + ) -> anyhow::Result<()> { + let (processed_tx, processed_rx) = oneshot::channel(); + event_tx + .send((event, processed_tx)) + .await + .map_err(|_| anyhow::anyhow!("test accounting loop stopped before receiving event"))?; + processed_rx + .await + .map_err(|_| anyhow::anyhow!("test accounting loop stopped before processing event")) + } + + type TestBusReceiver = mpsc::Receiver<(oneshot::Sender, BusRequest)>; + + async fn receive_bus_request( + bus_rx: &mut TestBusReceiver, + ) -> anyhow::Result<(oneshot::Sender, BusRequest)> { + match tokio::time::timeout(Duration::from_secs(1), bus_rx.recv()).await { + Ok(Some(request)) => Ok(request), + Ok(None) => Err(anyhow::anyhow!( + "dynamic circuit bus request channel closed" + )), + Err(_) => Err(anyhow::anyhow!( + "timed out waiting for dynamic circuit bus request" + )), + } + } + + async fn receive_create_dynamic_circuit( + bus_rx: &mut TestBusReceiver, + ) -> anyhow::Result<(oneshot::Sender, ShapedDevice)> { + let (reply_tx, request) = receive_bus_request(bus_rx).await?; + let shaped_device = expect_create_dynamic_circuit_request(request)?; + Ok((reply_tx, shaped_device)) + } + + async fn receive_create_and_ack(bus_rx: &mut TestBusReceiver) -> anyhow::Result { + let (reply_tx, shaped_device) = receive_create_dynamic_circuit(bus_rx).await?; + ack_bus_reply(reply_tx)?; + Ok(shaped_device) + } + + async fn receive_remove_dynamic_circuit( + bus_rx: &mut TestBusReceiver, + ) -> anyhow::Result<(oneshot::Sender, String)> { + let (reply_tx, request) = receive_bus_request(bus_rx).await?; + let circuit_id = expect_remove_dynamic_circuit_request(request)?; + Ok((reply_tx, circuit_id)) + } + + async fn receive_remove_and_ack(bus_rx: &mut TestBusReceiver) -> anyhow::Result { + let (reply_tx, circuit_id) = receive_remove_dynamic_circuit(bus_rx).await?; + ack_bus_reply(reply_tx)?; + Ok(circuit_id) + } + + async fn wait_for_authoritative_owner( + sink: &ApplyingDynamicCircuitSink, + session_key: &AccountingSessionKey, + ) -> anyhow::Result<()> { + wait_for_authoritative_owner_for_circuit(sink, SHARED_CIRCUIT_ID, session_key).await + } + + async fn wait_for_authoritative_owner_for_circuit( + sink: &ApplyingDynamicCircuitSink, + circuit_id: &str, + session_key: &AccountingSessionKey, + ) -> anyhow::Result<()> { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if sink + .application_state + .lock() + .authoritative_session_by_circuit + .get(circuit_id) + == Some(session_key) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for authoritative dynamic-circuit owner")) + } + + async fn wait_for_retained_owner( + sink: &ApplyingDynamicCircuitSink, + session_key: &AccountingSessionKey, + ) -> anyhow::Result<()> { + let owner_key = (SHARED_CIRCUIT_ID.to_string(), session_key.clone()); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if sink + .application_state + .lock() + .retained_upserts_by_owner + .contains_key(&owner_key) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for retained shared-circuit owner")) + } + + async fn wait_for_apply_failed_diagnostic( + sink: &ApplyingDynamicCircuitSink, + sessions: &RadiusAccountingSessions, + circuit_id: &str, + session_key: &AccountingSessionKey, + ) -> anyhow::Result { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if let Some(diagnostic) = + sink.activation_diagnostics(sessions) + .into_iter() + .find(|diagnostic| { + diagnostic.session_key == *session_key + && diagnostic.circuit_ids.iter().any(|id| id == circuit_id) + && diagnostic.state == RadiusActivationDiagnosticState::ApplyFailed + }) + { + break diagnostic; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for apply-failed diagnostic")) + } + + async fn wait_for_released_owner( + sink: &ApplyingDynamicCircuitSink, + session_key: &AccountingSessionKey, + ) -> anyhow::Result<()> { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let owner_released = { + let state = sink.application_state.lock(); + state + .authoritative_session_by_circuit + .get(SHARED_CIRCUIT_ID) + != Some(session_key) + && state + .current_owners_by_circuit + .get(SHARED_CIRCUIT_ID) + .is_none_or(|owners| !owners.contains(session_key)) + }; + if owner_released { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for released shared-circuit owner")) + } + + fn assert_bus_request_channel_empty(bus_rx: &mut TestBusReceiver) { + match bus_rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => {} + Ok((_reply_tx, request)) => panic!("unexpected bus request: {request:?}"), + Err(err) => panic!("unexpected bus channel state: {err:?}"), + } + } + + fn ack_bus_reply(reply_tx: oneshot::Sender) -> anyhow::Result<()> { + send_bus_response(reply_tx, BusResponse::Ack) + } + + fn fail_bus_reply(reply_tx: oneshot::Sender, message: &str) -> anyhow::Result<()> { + send_bus_response(reply_tx, BusResponse::Fail(message.to_string())) + } + + fn send_bus_response( + reply_tx: oneshot::Sender, + response: BusResponse, + ) -> anyhow::Result<()> { + reply_tx + .send(BusReply { + responses: vec![response], + }) + .map_err(|_| anyhow::anyhow!("failed to send fake bus response")) + } + + fn expect_create_dynamic_circuit_request(request: BusRequest) -> anyhow::Result { + let BusRequest::CreateDynamicCircuit { shaped_device } = request else { + anyhow::bail!("expected CreateDynamicCircuit request, got {request:?}"); + }; + Ok(*shaped_device) + } + + fn expect_remove_dynamic_circuit_request(request: BusRequest) -> anyhow::Result { + let BusRequest::RemoveDynamicCircuit { circuit_id } = request else { + anyhow::bail!("expected RemoveDynamicCircuit request, got {request:?}"); + }; + Ok(circuit_id) + } + + fn session_key() -> AccountingSessionKey { + session_key_for("nas-adapter", "session-adapter") + } + + fn expected_circuit_id(nas: &str) -> String { + fn hex(value: &str) -> String { + value.bytes().map(|byte| format!("{byte:02x}")).collect() + } + format!( + "radius:nas-id:{}:username:{}", + hex(nas), + hex("subscriber-adapter") + ) + } + + fn session_key_for(nas: &str, session_id: &str) -> AccountingSessionKey { + AccountingSessionKey::NasSession { + nas: NasIdentity::Identifier(nas.to_string()), + acct_session_id: session_id.to_string(), + } + } + + fn complete_event(status_type: AcctStatusType) -> AccountingEvent { + complete_event_for(status_type, "nas-adapter", "session-adapter") + } + + fn complete_event_for( + status_type: AcctStatusType, + nas: &str, + session_id: &str, + ) -> AccountingEvent { + AccountingEvent { + status_type: Some(status_type), + acct_session_id: Some(session_id.to_string()), + nas_identifier: Some(nas.to_string()), + user_name: Some("subscriber-adapter".to_string()), + framed_ip_address: Some(Ipv4Addr::new(198, 51, 100, 20)), + mikrotik_rate_limits: vec![MikrotikRateLimit { + original: "10M/25M".to_string(), + nas_rx_bps: 10_000_000, + nas_tx_bps: 25_000_000, + upload_bps: 10_000_000, + download_bps: 25_000_000, + }], + ..AccountingEvent::default() + } + } + + fn reset_event() -> AccountingEvent { + reset_event_for("nas-adapter") + } + + fn reset_event_for(nas: &str) -> AccountingEvent { + reset_event_for_status(nas, AcctStatusType::AccountingOff) + } + + fn reset_event_for_status(nas: &str, status_type: AcctStatusType) -> AccountingEvent { + AccountingEvent { + status_type: Some(status_type), + nas_identifier: Some(nas.to_string()), + ..AccountingEvent::default() + } + } + + fn shaped_device(circuit_id: &str, device_id: &str) -> ShapedDevice { + ShapedDevice { + circuit_id: circuit_id.to_string(), + circuit_name: format!("Circuit {circuit_id}"), + device_id: device_id.to_string(), + device_name: format!("Device {device_id}"), + parent_node: "Parent Node".to_string(), + parent_node_id: Some("parent-node-id".to_string()), + anchor_node_id: Some("anchor-node-id".to_string()), + mac: "aa-bb-cc-dd-ee-ff".to_string(), + ipv4: Vec::new(), + ipv6: Vec::new(), + download_min_mbps: 5.0, + upload_min_mbps: 2.0, + download_max_mbps: 50.0, + upload_max_mbps: 20.0, + comment: "matched from shaped devices".to_string(), + sqm_override: Some("cake/none".to_string()), + circuit_hash: 0, + device_hash: 0, + parent_hash: 0, + } + } + + fn unique_secret_path(label: &str) -> anyhow::Result { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + Ok(std::env::temp_dir().join(format!( + "lqosd-radius-{label}-{}-{nanos}", + std::process::id() + ))) + } +} diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index bf268437..63e15f14 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -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(),