From 9f4ab17c4ef5ae1f584ef19dc92a71fe507d1a4a Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 9 Jun 2026 21:58:05 -0500 Subject: [PATCH 01/18] Add RADIUS accounting MVP plumbing --- docs-es/v2.0/configuration-advanced-es.md | 26 + docs/v2.0/configuration-advanced.md | 26 + prd.json | 135 ++ src/rust/Cargo.lock | 23 + src/rust/Cargo.toml | 3 + src/rust/lqos_config/src/etc/mod.rs | 3 +- src/rust/lqos_config/src/etc/v15/mod.rs | 4 + .../src/etc/v15/radius_accounting.rs | 729 +++++++++ .../lqos_config/src/etc/v15/top_config.rs | 10 + src/rust/lqos_config/src/lib.rs | 3 +- src/rust/lqos_radius/Cargo.toml | 14 + src/rust/lqos_radius/src/accounting_event.rs | 566 +++++++ .../lqos_radius/src/accounting_event/tests.rs | 421 +++++ src/rust/lqos_radius/src/attribute_type.rs | 4 + src/rust/lqos_radius/src/dynamic_circuit.rs | 74 + src/rust/lqos_radius/src/lib.rs | 42 + src/rust/lqos_radius/src/listener.rs | 511 ++++++ src/rust/lqos_radius/src/listener/tests.rs | 550 +++++++ src/rust/lqos_radius/src/main.rs | 206 +++ src/rust/lqos_radius/src/packet.rs | 640 ++++++++ src/rust/lqos_radius/src/packet/tests.rs | 560 +++++++ src/rust/lqos_radius/src/session.rs | 1093 +++++++++++++ src/rust/lqos_radius/src/session/tests.rs | 1443 +++++++++++++++++ src/rust/lqos_radius/src/test_support.rs | 188 +++ 24 files changed, 7272 insertions(+), 2 deletions(-) create mode 100644 prd.json create mode 100644 src/rust/lqos_config/src/etc/v15/radius_accounting.rs create mode 100644 src/rust/lqos_radius/Cargo.toml create mode 100644 src/rust/lqos_radius/src/accounting_event.rs create mode 100644 src/rust/lqos_radius/src/accounting_event/tests.rs create mode 100644 src/rust/lqos_radius/src/attribute_type.rs create mode 100644 src/rust/lqos_radius/src/dynamic_circuit.rs create mode 100644 src/rust/lqos_radius/src/lib.rs create mode 100644 src/rust/lqos_radius/src/listener.rs create mode 100644 src/rust/lqos_radius/src/listener/tests.rs create mode 100644 src/rust/lqos_radius/src/main.rs create mode 100644 src/rust/lqos_radius/src/packet.rs create mode 100644 src/rust/lqos_radius/src/packet/tests.rs create mode 100644 src/rust/lqos_radius/src/session.rs create mode 100644 src/rust/lqos_radius/src/session/tests.rs create mode 100644 src/rust/lqos_radius/src/test_support.rs diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index 1a0fee03..085793f6 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -62,6 +62,32 @@ 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. En v2.0, esta sección solo se valida y se conserva. `lqosd` no abrirá un servicio de contabilidad RADIUS con esta configuración. + +Ejemplo: + +```toml +[radius_accounting] +enabled = false +listen = "0.0.0.0:1813" +default_ttl_seconds = 900 +stale_grace_seconds = 120 + +[[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 también debe ser válida. +- 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. Esta es la ruta configurada para el secreto compartido del cliente. LibreQoS conserva ese valor al reescribir `/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. + ### Integraciones con CRM/NMS Más información sobre [configuración de integraciones aquí.](integrations-es.md). diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index ce11dceb..746dfbc3 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -194,6 +194,32 @@ Notes: - `ip_range` must be a CIDR. `0.0.0.0` (and `::`) are allowed shorthands for the match-all `/0` networks. - `attach_to` is a `network.json` node name (optional; empty is allowed). +#### RADIUS accounting (optional) + +LibreQoS accepts an optional `[radius_accounting]` section for trusted NAS client settings. In v2.0, this section is validated and saved only. `lqosd` will not start a RADIUS accounting listener from it. + +Example: + +```toml +[radius_accounting] +enabled = false +listen = "0.0.0.0:1813" +default_ttl_seconds = 900 +stale_grace_seconds = 120 + +[[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. +- 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`. This is the configured location for the client's shared secret. LibreQoS preserves that value 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. + #### CRM/NMS Integrations Learn more about [configuring integrations here](integrations.md). diff --git a/prd.json b/prd.json new file mode 100644 index 00000000..4ed83728 --- /dev/null +++ b/prd.json @@ -0,0 +1,135 @@ +{ + "product": "LibreQoS", + "branchName": "radius-accounting-listener", + "summary": "Add an opt-in RADIUS accounting listener MVP that can be developed and tested without root, decode common accounting packets, and prepare dynamic-circuit command plumbing for lqosd integration.", + "stories": [ + { + "id": "RADIUS-001", + "title": "Create the lqos_radius crate", + "description": "As a LibreQoS developer, I want a separate lqos_radius crate with a rootless test executable, so that RADIUS accounting protocol work can be built and exercised without running lqosd as root.", + "priority": 1, + "passes": true, + "acceptanceCriteria": [ + "The Rust workspace includes lqos_radius as a buildable member.", + "lqos_radius exposes a library API for packet handling and listener startup.", + "lqos_radius includes an executable entrypoint that can run on a configurable non-privileged listen address.", + "cargo test for lqos_radius runs without requiring root privileges or live shaping state." + ] + }, + { + "id": "RADIUS-002", + "title": "Configure RADIUS accounting", + "description": "As a LibreQoS operator, I want RADIUS accounting settings in LibreQoS configuration, so that I can explicitly enable the listener and control which NAS clients are trusted.", + "priority": 1, + "passes": true, + "acceptanceCriteria": [ + "radius_accounting can be disabled without requiring any RADIUS clients.", + "When radius_accounting is enabled, configuration requires a valid listen address and at least one client source.", + "Each configured client has a source allow-list and a shared secret source that is not emitted in logs or serialized debug output.", + "Configuration validation rejects invalid listen addresses, invalid client sources, missing secrets, and non-positive timing values.", + "Configuration round-trip tests cover enabled and disabled RADIUS accounting settings." + ] + }, + { + "id": "RADIUS-003", + "title": "Decode and verify accounting packets", + "description": "As a LibreQoS operator, I want incoming RADIUS accounting packets to be decoded and verified, so that only authentic Accounting-Request packets from trusted clients are accepted.", + "priority": 1, + "passes": true, + "acceptanceCriteria": [ + "The decoder accepts Accounting-Request packets and rejects unsupported RADIUS packet codes for this MVP.", + "Packet length, attribute bounds, and malformed attribute cases are validated before packet acceptance.", + "Accounting-Request authenticators are verified against the matched client shared secret.", + "Message-Authenticator is verified when present and is required when configuration enables that policy.", + "Valid accepted requests can produce an Accounting-Response packet with response code 5." + ] + }, + { + "id": "RADIUS-004", + "title": "Extract common accounting attributes", + "description": "As a LibreQoS operator, I want common RADIUS accounting attributes translated into typed session events, so that subscriber session lifecycle, identity, and address data can be inspected and used later.", + "priority": 1, + "passes": true, + "acceptanceCriteria": [ + "Typed events include Acct-Status-Type values for Start, Stop, Interim-Update, Accounting-On, and Accounting-Off.", + "Typed events include session and NAS identity fields such as Acct-Session-Id, NAS-IP-Address, NAS-IPv6-Address, NAS-Identifier, NAS-Port, NAS-Port-Id, User-Name, Calling-Station-Id, Called-Station-Id, Class, and Event-Timestamp when present.", + "Typed events include subscriber address fields such as Framed-IP-Address, Framed-IP-Netmask, Framed-Route, Framed-IPv6-Prefix, Delegated-IPv6-Prefix, and Framed-IPv6-Address when present.", + "Unknown standard attributes and unknown vendor-specific attributes are preserved for diagnostics without rejecting otherwise valid packets.", + "MikroTik-Rate-Limit vendor attributes are decoded into rate values and tests verify configurable NAS-to-LibreQoS direction mapping." + ] + }, + { + "id": "RADIUS-005", + "title": "Run a rootless UDP accounting listener", + "description": "As a LibreQoS developer, I want the lqos_radius listener to run against loopback test traffic, so that protocol and server behavior can be proven before wiring live lqosd behavior.", + "priority": 1, + "passes": true, + "acceptanceCriteria": [ + "The listener can bind to 127.0.0.1:0 or another configured non-privileged address for tests.", + "Accepted Accounting-Request packets receive an Accounting-Response without waiting for dynamic-circuit application.", + "Packets from unconfigured sources do not create session events or command placeholders.", + "Packets with invalid authenticators are rejected and do not create session events or command placeholders.", + "Loopback UDP tests verify accepted requests, rejected sources, invalid authenticators, and response behavior." + ] + }, + { + "id": "RADIUS-006", + "title": "Track accounting sessions in memory", + "description": "As a LibreQoS operator, I want accepted accounting events to update runtime session state, so that Starts, Interims, Stops, and NAS resets have predictable results during the MVP.", + "priority": 2, + "passes": true, + "acceptanceCriteria": [ + "Sessions are keyed primarily by NAS identity plus Acct-Session-Id when both values are available.", + "Start and Interim-Update events create or refresh a session and preserve the latest decoded identity and address data.", + "Stop events mark the session stopped and are harmless when duplicated.", + "Accounting-On and Accounting-Off mark sessions for the affected NAS stale without affecting other NAS clients.", + "Sessions that cannot become dynamic circuits retain a specific pending reason such as missing IP, missing rate, missing parent, missing session ID, or ambiguous mapping." + ] + }, + { + "id": "RADIUS-007", + "title": "Prepare dynamic-circuit command plumbing", + "description": "As a LibreQoS developer, I want RADIUS session handling to emit create, update, and remove command placeholders, so that lqosd can later connect those commands to the existing dynamic-circuit path without changing the protocol server.", + "priority": 2, + "passes": true, + "acceptanceCriteria": [ + "lqos_radius exposes a command sink boundary for dynamic-circuit intents that the lqosd-facing adapter can map to CreateDynamicCircuit and RemoveDynamicCircuit placeholders.", + "Shapeable Start and Interim-Update events emit create or update command intents through the sink boundary.", + "Stop events emit remove command intents when a deterministic circuit identity is available.", + "Fake sink tests assert the emitted command type and circuit identity for Start, Interim-Update, Stop, and expiry scenarios.", + "The listener never writes dynamic_circuits.json directly." + ] + }, + { + "id": "RADIUS-008", + "title": "Start the listener from lqosd when configured", + "description": "As a LibreQoS operator, I want lqosd to start the RADIUS accounting listener only when configured, so that existing installations are unchanged unless RADIUS accounting is enabled.", + "priority": 2, + "passes": false, + "acceptanceCriteria": [ + "lqosd does not bind a RADIUS accounting socket when radius_accounting is absent or disabled.", + "When radius_accounting is enabled, lqosd starts the listener inside the existing tokio runtime after configuration is loaded.", + "Listener startup failures are reported clearly without logging shared secrets.", + "lqosd shutdown stops the listener without blocking daemon shutdown.", + "The lqosd wiring uses the lqos_radius command sink boundary and leaves final dynamic-circuit application behind the existing bus or daemon channel path for this MVP." + ] + }, + { + "id": "RADIUS-009", + "title": "Provide protocol test fixtures", + "description": "As a LibreQoS developer, I want focused protocol fixtures and scenario tests, so that regressions in packet parsing, authentication, and listener behavior are caught without NAS hardware.", + "priority": 1, + "passes": false, + "acceptanceCriteria": [ + "Raw packet fixtures cover valid Accounting-Start, Accounting-Interim, Accounting-Stop, invalid packet length, invalid authenticator, unknown standard attributes, unknown vendor-specific attributes, and IPv6 address or prefix attributes.", + "A test packet builder can generate authenticated Accounting-Request packets for readable scenario tests.", + "Authenticator tests cover correct secrets, wrong secrets, modified attributes, truncated packets, valid Message-Authenticator, invalid Message-Authenticator, and required Message-Authenticator policy.", + "Session tests use fake time and a fake command sink instead of sockets, root privileges, bakery, TC, XDP, or disk writes." + ] + } + ], + "notes": [ + "Full dynamic-circuit application, operator UI or CLI diagnostics, persistent session storage, and CoA or Disconnect support remain outside this MVP unless separately requested.", + "Binding the default UDP 1813 port may require elevated privileges; the crate executable and automated tests should support non-privileged addresses for development." + ] +} diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 9d4352c8..99da517d 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2404,6 +2404,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "lqos_radius" +version = "0.1.0" +dependencies = [ + "clap", + "hmac", + "ip_network", + "md-5", + "subtle", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "lqos_rs" version = "0.1.0" @@ -2653,6 +2666,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 f2a0f51d..fdd797a6 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 32621138..6b525416 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -20,7 +20,8 @@ pub mod test_data; mod v15; pub use v15::{ BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode, - MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, SslConfig, + MikrotikIpv6Config, QueueMode, RadiusAccountingClient, RadiusAccountingConfig, + RadiusClientSource, RadiusSharedSecretSource, RttThresholds, SingleInterfaceConfig, SslConfig, StormguardConfig, StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, normalize_external_hostname, diff --git a/src/rust/lqos_config/src/etc/v15/mod.rs b/src/rust/lqos_config/src/etc/v15/mod.rs index 2dc4d6e6..d551c4d6 100644 --- a/src/rust/lqos_config/src/etc/v15/mod.rs +++ b/src/rust/lqos_config/src/etc/v15/mod.rs @@ -15,6 +15,7 @@ mod mikrotik_ipv6; mod netzur_integration; mod powercode_integration; mod queues; +mod radius_accounting; mod sonar_integration; mod splynx_integration; mod stormguard; @@ -31,6 +32,9 @@ 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, RadiusSharedSecretSource, +}; 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..e0979641 --- /dev/null +++ b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs @@ -0,0 +1,729 @@ +//! 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}; + +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(()) + } +} + +/// RADIUS accounting listener configuration. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct RadiusAccountingConfig { + /// Persisted operator flag for RADIUS accounting listener support. + /// + /// In v2.0 this value is validated and saved, but does not start a listener. + #[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, + /// Trusted RADIUS NAS clients. + #[serde(default)] + pub clients: Vec, +} + +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(), + 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()); + } + + 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 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_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()); + } + + 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_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(), + 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); + } + + #[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(&[], 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 9e607c45..09affb03 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -177,6 +177,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, @@ -368,6 +374,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(()) } @@ -442,6 +451,7 @@ impl Default for Config { long_term_stats: super::long_term_stats::LongTermStats::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 bb4d4fa7..4abe732c 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -42,7 +42,8 @@ pub use cpu_topology::{ }; pub use etc::{ BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, - MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, SslConfig, + MikrotikIpv6Config, QueueMode, RadiusAccountingClient, RadiusAccountingConfig, + RadiusClientSource, RadiusSharedSecretSource, 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, diff --git a/src/rust/lqos_radius/Cargo.toml b/src/rust/lqos_radius/Cargo.toml new file mode 100644 index 00000000..2880ed7a --- /dev/null +++ b/src/rust/lqos_radius/Cargo.toml @@ -0,0 +1,14 @@ +[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 } +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..6815a7b2 --- /dev/null +++ b/src/rust/lqos_radius/src/accounting_event/tests.rs @@ -0,0 +1,421 @@ +//! 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, handle_accounting_request, verify_accounting_request}; + +#[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 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..673f8c29 --- /dev/null +++ b/src/rust/lqos_radius/src/dynamic_circuit.rs @@ -0,0 +1,74 @@ +//! Dynamic-circuit command intents emitted by RADIUS session handling. + +use crate::{AccountingEvent, AccountingSessionKey, NasResetStatus}; + +/// 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, Eq, 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 can map this to `RemoveDynamicCircuit`. + 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, Eq, 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, +} + +/// 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..aeab74ac --- /dev/null +++ b/src/rust/lqos_radius/src/lib.rs @@ -0,0 +1,42 @@ +//! 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, emits dynamic-circuit command intents +//! through an explicit sink boundary, 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 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 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, NasIdentity, + NasResetStatus, PendingSessionFingerprint, PendingSessionReason, +}; 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/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..2d178627 --- /dev/null +++ b/src/rust/lqos_radius/src/packet/tests.rs @@ -0,0 +1,560 @@ +//! 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 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(&packet_with_declared_len((RADIUS_HEADER_LEN - 1) as u16)), + 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(), &[]); + 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() { + let packet = signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET); + + assert_eq!( + verify_accounting_request( + &packet, + 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..4c8956f8 --- /dev/null +++ b/src/rust/lqos_radius/src/session.rs @@ -0,0 +1,1093 @@ +//! In-memory RADIUS accounting session tracking. + +use crate::{AccountingEvent, AcctStatusType}; +use crate::{ + DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemoval, + DynamicCircuitRemovalReason, DynamicCircuitUpsert, +}; +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// In-memory session store for accepted RADIUS accounting events. +#[derive(Debug, Default)] +pub struct AccountingSessionStore { + sessions: HashMap, +} + +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(), + } + } + + /// 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) + } + + /// 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 { + 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, mapping) + } + AcctStatusType::Stop => self.stop_session(event, mapping), + 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), + }, + } + } + + /// 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 { + let reset_identities = match event.status_type { + Some(AcctStatusType::AccountingOn | AcctStatusType::AccountingOff) => { + Some(NasIdentitySet::from_event(&event)) + } + _ => None, + }; + let update = self.apply_event_with_mapping(event, mapping); + self.emit_dynamic_circuit_intents(&update, reset_identities.as_ref(), sink); + update + } + + /// 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.sessions.remove(key)?; + emit_active_dynamic_circuit_removals( + key, + &mut session, + DynamicCircuitRemovalReason::Expired, + sink, + ); + Some(session) + } + + fn emit_dynamic_circuit_intents( + &mut self, + update: &AccountingSessionUpdate, + reset_identities: Option<&NasIdentitySet>, + sink: &mut impl DynamicCircuitCommandSink, + ) { + match update { + AccountingSessionUpdate::SessionUpdated { key, state } => { + self.emit_session_dynamic_circuit_intents(key, *state, sink); + } + AccountingSessionUpdate::NasSessionsMarkedStale { reset, .. } => { + let Some(reset_identities) = reset_identities else { + return; + }; + self.emit_nas_reset_dynamic_circuit_removals(reset_identities, *reset, sink); + } + AccountingSessionUpdate::Ignored { .. } => {} + } + } + + fn emit_session_dynamic_circuit_intents( + &mut self, + key: &AccountingSessionKey, + state: AccountingSessionState, + sink: &mut impl DynamicCircuitCommandSink, + ) { + match state { + AccountingSessionState::Active => { + let Some(circuit_id) = key.dynamic_circuit_id() else { + return; + }; + let Some(session) = self.sessions.get_mut(key) else { + return; + }; + if !session.pending_reasons.is_empty() { + emit_active_dynamic_circuit_removals( + key, + session, + DynamicCircuitRemovalReason::NoLongerShapeable, + 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(), + }; + match (already_emitted, session.latest_event.status_type) { + (true, Some(AcctStatusType::InterimUpdate)) => { + sink.emit(DynamicCircuitIntent::UpdateDynamicCircuit(upsert)); + } + (_, Some(AcctStatusType::Start | AcctStatusType::InterimUpdate)) => { + sink.emit(DynamicCircuitIntent::CreateDynamicCircuit(upsert)); + } + _ => {} + } + push_active_dynamic_circuit_id(&mut session.active_dynamic_circuit_ids, circuit_id); + emit_rekeyed_dynamic_circuit_removals(key, session, sink); + } + AccountingSessionState::Stopped => { + let Some(session) = self.sessions.get_mut(key) else { + return; + }; + emit_active_dynamic_circuit_removals( + key, + session, + DynamicCircuitRemovalReason::Stop, + sink, + ); + } + AccountingSessionState::Stale(_) => {} + } + } + + fn emit_nas_reset_dynamic_circuit_removals( + &mut self, + reset_identities: &NasIdentitySet, + reset: NasResetStatus, + sink: &mut impl DynamicCircuitCommandSink, + ) { + for (key, session) in &mut self.sessions { + if !session_matches_identities(key, session, reset_identities) { + continue; + } + emit_active_dynamic_circuit_removals( + key, + session, + DynamicCircuitRemovalReason::NasReset(reset), + sink, + ); + } + } + + fn upsert_active_session( + &mut self, + event: AccountingEvent, + mapping: DynamicCircuitMapping, + ) -> AccountingSessionUpdate { + self.apply_session_state(event, mapping, AccountingSessionState::Active) + } + + fn stop_session( + &mut self, + event: AccountingEvent, + mapping: DynamicCircuitMapping, + ) -> AccountingSessionUpdate { + self.apply_session_state(event, mapping, AccountingSessionState::Stopped) + } + + fn apply_session_state( + &mut self, + event: AccountingEvent, + mapping: DynamicCircuitMapping, + state: AccountingSessionState, + ) -> AccountingSessionUpdate { + let key_resolution = self.resolve_session_key(&event); + let promoted_sessions = key_resolution + .previous_keys + .iter() + .filter_map(|previous_key| self.sessions.remove(previous_key)) + .collect::>(); + let key = key_resolution.key; + + if let Some(session) = self.sessions.get_mut(&key) { + 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, + ); + 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); + session.pending_reasons = pending_reasons(&session.latest_event, mapping); + merge_known_nas_identities(&mut session.known_nas_identities, &session.latest_event); + } else { + let mut known_nas_identities = Vec::new(); + let mut active_dynamic_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, + ); + 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 pending_reasons = pending_reasons(&latest_event, mapping); + self.sessions.insert( + key.clone(), + AccountingSession { + state, + latest_event, + known_nas_identities, + active_dynamic_circuit_ids, + pending_reasons, + }, + ); + } + + 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; + for (key, session) in &mut self.sessions { + if !session_matches_identities(key, session, &reset_identities) { + continue; + } + session.state = state; + marked_count += 1; + } + + AccountingSessionUpdate::NasSessionsMarkedStale { + nas, + reset, + marked_count, + } + } + + 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; + for (key, session) in &self.sessions { + if excluded_key.is_some_and(|excluded_key| excluded_key == key) { + continue; + } + let AccountingSessionKey::NasSession { + acct_session_id: existing_session_id, + .. + } = key + else { + continue; + }; + if existing_session_id != acct_session_id { + 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, session) in &self.sessions { + 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.clone()); + } + } + 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, session) in &self.sessions { + if matches!(key, AccountingSessionKey::Pending { .. }) { + 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.clone()); + } + found + } +} + +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, Eq, 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, + /// Dynamic-circuit IDs this session has emitted and not yet removed. + pub active_dynamic_circuit_ids: Vec, + /// Reasons this session cannot currently become a dynamic circuit. + pub pending_reasons: Vec, +} + +/// 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 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, +} + +/// Current dynamic-circuit mapping state for a session event. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum DynamicCircuitMapping { + /// Subscriber IP, rate, parent, and identity can map to a dynamic circuit. + Ready, + /// No parent node mapping is known yet. + #[default] + MissingParent, + /// More than one parent or subscriber mapping matches the event. + Ambiguous, +} + +/// 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 subscriber IPv4, IPv6, or prefix address is present. + MissingIpAddress, + /// No decoded rate-limit attribute is present. + MissingRate, + /// No parent node mapping is available. + MissingParent, + /// The event maps ambiguously to more than one parent or subscriber target. + AmbiguousMapping, +} + +/// 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, + }, + /// 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, 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_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 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 pending_reasons( + event: &AccountingEvent, + mapping: DynamicCircuitMapping, +) -> Vec { + let mut reasons = Vec::new(); + + if event.acct_session_id.as_ref().is_none_or(String::is_empty) { + reasons.push(PendingSessionReason::MissingSessionId); + } + if NasIdentitySet::from_event(event).is_empty() { + reasons.push(PendingSessionReason::MissingNasIdentity); + } + if !has_subscriber_ip(event) { + reasons.push(PendingSessionReason::MissingIpAddress); + } + if event.mikrotik_rate_limits.is_empty() { + reasons.push(PendingSessionReason::MissingRate); + } + match mapping { + DynamicCircuitMapping::Ready => {} + DynamicCircuitMapping::MissingParent => reasons.push(PendingSessionReason::MissingParent), + DynamicCircuitMapping::Ambiguous => reasons.push(PendingSessionReason::AmbiguousMapping), + } + + reasons +} + +fn has_subscriber_ip(event: &AccountingEvent) -> bool { + event.framed_ip_address.is_some() + || event.framed_ipv6_address.is_some() + || !event.framed_routes.is_empty() + || !event.framed_ipv6_prefixes.is_empty() + || !event.delegated_ipv6_prefixes.is_empty() +} + +fn non_empty_text(value: &Option) -> Option { + value.as_ref().filter(|value| !value.is_empty()).cloned() +} + +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 { + if !active_ids.contains(&id) { + active_ids.push(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, + sink: &mut impl DynamicCircuitCommandSink, +) { + for circuit_id in std::mem::take(&mut session.active_dynamic_circuit_ids) { + sink.emit(DynamicCircuitIntent::RemoveDynamicCircuit( + DynamicCircuitRemoval { + circuit_id, + session_key: key.clone(), + reason, + }, + )); + } +} + +fn emit_rekeyed_dynamic_circuit_removals( + key: &AccountingSessionKey, + session: &mut AccountingSession, + sink: &mut impl DynamicCircuitCommandSink, +) { + let Some(current_circuit_id) = key.dynamic_circuit_id() else { + return; + }; + 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; + } + 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..7bc35048 --- /dev/null +++ b/src/rust/lqos_radius/src/session/tests.rs @@ -0,0 +1,1443 @@ +//! Tests for in-memory accounting session tracking. + +use super::*; +use crate::{ + DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemovalReason, MikrotikRateLimit, +}; +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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + + 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, DynamicCircuitMapping::Ready); + sparse_store.apply_event_with_mapping( + minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-sparse", + "session-sparse", + ), + DynamicCircuitMapping::Ready, + ); + 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), + ), + DynamicCircuitMapping::Ready, + ); + 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), + ), + DynamicCircuitMapping::Ready, + ); + 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, DynamicCircuitMapping::Ready); + + 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, DynamicCircuitMapping::Ready); + + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + 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), + ), + DynamicCircuitMapping::Ready, + ); + 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), + ), + DynamicCircuitMapping::Ready, + ); + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + 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), + ), + DynamicCircuitMapping::Ready, + ); + let alternate_key = AccountingSessionKey::NasSession { + nas: NasIdentity::Ipv4(split_nas_ip), + acct_session_id: "session-extra-alias".to_string(), + }; + split_with_extra_alias.sessions.insert( + 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), + ], + active_dynamic_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, DynamicCircuitMapping::Ready); + 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); + rich_alternate_store.sessions.insert( + rich_alternate_key, + AccountingSession { + state: AccountingSessionState::Active, + latest_event: rich_alternate_event, + known_nas_identities: vec![NasIdentity::Ipv4(rich_alternate_ip)], + active_dynamic_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, DynamicCircuitMapping::Ready); + + 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); + ambiguous_retained_store + .sessions + .insert(ambiguous_key_a.clone(), ambiguous_session_a); + ambiguous_retained_store + .sessions + .insert(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, DynamicCircuitMapping::Ready); + + 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), + ), + DynamicCircuitMapping::Ready, + ); + + 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); + + 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), + ), + DynamicCircuitMapping::Ready, + ); + 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 + ); +} + +#[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), + ), + DynamicCircuitMapping::Ready, + ); + } + + 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, + } + ); + assert_eq!( + store.session(&nas_a_session_1).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + assert_eq!( + store.session(&nas_a_session_2).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + 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, + } + ); + 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, DynamicCircuitMapping::Ready); + store.apply_event_with_mapping( + complete_event( + AcctStatusType::InterimUpdate, + "nas-c", + "session-1", + Ipv4Addr::new(198, 51, 100, 34), + ), + DynamicCircuitMapping::Ready, + ); + + 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, + } + ); + 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, DynamicCircuitMapping::Ready); + 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.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, DynamicCircuitMapping::Ready); + assert_eq!( + store.session(&route_key).unwrap().pending_reasons, + Vec::new() + ); + + 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, DynamicCircuitMapping::Ready); + 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", + ), + DynamicCircuitMapping::Ready, + ); + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + + 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, DynamicCircuitMapping::Ready); + 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, DynamicCircuitMapping::Ready); + 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), + ), + DynamicCircuitMapping::Ready, + ); + 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 dynamic_circuit_sink_receives_session_lifecycle_intents() { + let mut store = AccountingSessionStore::new(); + let mut sink = FakeDynamicCircuitSink::default(); + let key = nas_session_key("nas-command", "session-command"); + let circuit_id = key.dynamic_circuit_id().unwrap(); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-command", + "session-command", + Ipv4Addr::new(198, 51, 100, 70), + ), + DynamicCircuitMapping::Ready, + &mut sink, + ); + assert_eq!(sink.intents.len(), 1); + assert_eq!(sink.intents[0].circuit_id(), circuit_id); + let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { + panic!("expected create intent, got {:?}", sink.intents[0]); + }; + assert_eq!(create.session_key, key); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::InterimUpdate, + "nas-command", + "session-command", + Ipv4Addr::new(198, 51, 100, 71), + ), + DynamicCircuitMapping::Ready, + &mut sink, + ); + assert_eq!(sink.intents.len(), 2); + assert_eq!(sink.intents[1].circuit_id(), circuit_id); + let DynamicCircuitIntent::UpdateDynamicCircuit(update) = &sink.intents[1] else { + panic!("expected update intent, got {:?}", sink.intents[1]); + }; + assert_eq!(update.session_key, key); + assert_eq!( + update.event.framed_ip_address, + Some(Ipv4Addr::new(198, 51, 100, 71)) + ); + + store.apply_event_with_mapping_and_commands( + minimal_session_event(AcctStatusType::Stop, "nas-command", "session-command"), + DynamicCircuitMapping::Ready, + &mut sink, + ); + assert_eq!(sink.intents.len(), 3); + assert_eq!(sink.intents[2].circuit_id(), circuit_id); + let DynamicCircuitIntent::RemoveDynamicCircuit(stop) = &sink.intents[2] else { + panic!("expected stop removal intent, got {:?}", sink.intents[2]); + }; + assert_eq!(stop.session_key, key); + assert_eq!(stop.reason, DynamicCircuitRemovalReason::Stop); + assert!( + store + .session(&key) + .unwrap() + .active_dynamic_circuit_ids + .is_empty() + ); + store.apply_event_with_mapping_and_commands( + minimal_session_event(AcctStatusType::Stop, "nas-command", "session-command"), + DynamicCircuitMapping::Ready, + &mut sink, + ); + assert_eq!(sink.intents.len(), 3); + + let mut expiry_store = AccountingSessionStore::new(); + let mut expiry_sink = FakeDynamicCircuitSink::default(); + let expiry_key = nas_session_key("nas-expiry", "session-expiry"); + let expiry_circuit_id = expiry_key.dynamic_circuit_id().unwrap(); + expiry_store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-expiry", + "session-expiry", + Ipv4Addr::new(198, 51, 100, 72), + ), + DynamicCircuitMapping::Ready, + &mut expiry_sink, + ); + assert!( + expiry_store + .expire_session_with_commands(&expiry_key, &mut expiry_sink) + .is_some() + ); + assert_eq!(expiry_sink.intents.len(), 2); + assert_eq!(expiry_sink.intents[1].circuit_id(), expiry_circuit_id); + let DynamicCircuitIntent::RemoveDynamicCircuit(expiry) = &expiry_sink.intents[1] else { + panic!( + "expected expiry removal intent, got {:?}", + expiry_sink.intents[1] + ); + }; + assert_eq!(expiry.session_key, expiry_key); + assert_eq!(expiry.reason, DynamicCircuitRemovalReason::Expired); + + let mut unshapeable_store = AccountingSessionStore::new(); + let mut unshapeable_sink = FakeDynamicCircuitSink::default(); + unshapeable_store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::Start, + "nas-pending-command", + "session-pending", + ), + DynamicCircuitMapping::MissingParent, + &mut unshapeable_sink, + ); + assert!(unshapeable_sink.intents.is_empty()); + unshapeable_store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::Stop, + "nas-pending-command", + "session-pending", + ), + DynamicCircuitMapping::Ready, + &mut unshapeable_sink, + ); + assert!(unshapeable_sink.intents.is_empty()); + + let never_emitted_expiry_key = nas_session_key("nas-never-emitted", "session-expiry"); + unshapeable_store.apply_event_with_mapping_and_commands( + minimal_session_event(AcctStatusType::Start, "nas-never-emitted", "session-expiry"), + DynamicCircuitMapping::MissingParent, + &mut unshapeable_sink, + ); + assert!( + unshapeable_store + .expire_session_with_commands(&never_emitted_expiry_key, &mut unshapeable_sink) + .is_some() + ); + assert!(unshapeable_sink.intents.is_empty()); +} + +#[test] +fn dynamic_circuit_sink_creates_when_pending_session_becomes_shapeable() { + let mut store = AccountingSessionStore::new(); + let mut sink = FakeDynamicCircuitSink::default(); + let key = nas_session_key("nas-late-command", "session-late-command"); + let circuit_id = key.dynamic_circuit_id().unwrap(); + + store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::Start, + "nas-late-command", + "session-late-command", + ), + DynamicCircuitMapping::MissingParent, + &mut sink, + ); + assert!(sink.intents.is_empty()); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::InterimUpdate, + "nas-late-command", + "session-late-command", + Ipv4Addr::new(198, 51, 100, 73), + ), + DynamicCircuitMapping::Ready, + &mut sink, + ); + + assert_eq!(sink.intents.len(), 1); + assert_eq!(sink.intents[0].circuit_id(), circuit_id); + let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { + panic!("expected create intent, got {:?}", sink.intents[0]); + }; + assert_eq!(create.session_key, key); + assert_eq!( + store.session(&key).unwrap().active_dynamic_circuit_ids, + vec![circuit_id] + ); +} + +#[test] +fn dynamic_circuit_sink_removes_when_shapeable_session_becomes_pending() { + let mut store = AccountingSessionStore::new(); + let mut sink = FakeDynamicCircuitSink::default(); + let key = nas_session_key("nas-removal-command", "session-removal-command"); + let circuit_id = key.dynamic_circuit_id().unwrap(); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-removal-command", + "session-removal-command", + Ipv4Addr::new(198, 51, 100, 74), + ), + DynamicCircuitMapping::Ready, + &mut sink, + ); + store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-removal-command", + "session-removal-command", + ), + DynamicCircuitMapping::MissingParent, + &mut sink, + ); + + assert_eq!(sink.intents.len(), 2); + assert_eq!(sink.intents[1].circuit_id(), circuit_id); + let DynamicCircuitIntent::RemoveDynamicCircuit(removal) = &sink.intents[1] else { + panic!( + "expected no-longer-shapeable removal, got {:?}", + sink.intents[1] + ); + }; + assert_eq!(removal.session_key, key); + assert_eq!( + removal.reason, + DynamicCircuitRemovalReason::NoLongerShapeable + ); + assert!( + store + .session(&key) + .unwrap() + .active_dynamic_circuit_ids + .is_empty() + ); +} + +#[test] +fn dynamic_circuit_sink_removes_rekeyed_promoted_session_ids() { + let mut store = AccountingSessionStore::new(); + let mut sink = FakeDynamicCircuitSink::default(); + let key = nas_session_key("nas-rekey-command", "session-rekey-command"); + let circuit_id = key.dynamic_circuit_id().unwrap(); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-rekey-command", + "session-rekey-command", + Ipv4Addr::new(198, 51, 100, 75), + ), + DynamicCircuitMapping::Ready, + &mut 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 = alternate_key.dynamic_circuit_id().unwrap(); + let mut alternate_start = complete_event( + AcctStatusType::Start, + "ignored-by-test", + "session-rekey-command", + Ipv4Addr::new(198, 51, 100, 76), + ); + alternate_start.nas_identifier = None; + alternate_start.nas_ip_address = Some(alternate_nas_ip); + store.apply_event_with_mapping_and_commands( + alternate_start, + DynamicCircuitMapping::Ready, + &mut sink, + ); + + let mut bridge = complete_event( + AcctStatusType::InterimUpdate, + "nas-rekey-command", + "session-rekey-command", + Ipv4Addr::new(198, 51, 100, 77), + ); + bridge.nas_ip_address = Some(alternate_nas_ip); + store.apply_event_with_mapping_and_commands(bridge, DynamicCircuitMapping::Ready, &mut sink); + + assert_eq!(store.len(), 1); + assert_eq!(sink.intents.len(), 4); + assert_eq!(sink.intents[2].circuit_id(), circuit_id); + let DynamicCircuitIntent::UpdateDynamicCircuit(update) = &sink.intents[2] else { + panic!("expected update intent, got {:?}", sink.intents[2]); + }; + assert_eq!(update.session_key, key); + assert_eq!(sink.intents[3].circuit_id(), alternate_circuit_id); + let DynamicCircuitIntent::RemoveDynamicCircuit(removal) = &sink.intents[3] else { + panic!("expected rekeyed removal, got {:?}", sink.intents[3]); + }; + assert_eq!(removal.session_key, key); + assert_eq!(removal.reason, DynamicCircuitRemovalReason::Rekeyed); + assert_eq!( + store.session(&key).unwrap().active_dynamic_circuit_ids, + vec![circuit_id] + ); +} + +#[test] +fn dynamic_circuit_sink_removes_active_circuits_on_nas_reset() { + let mut store = AccountingSessionStore::new(); + let mut sink = FakeDynamicCircuitSink::default(); + let key = nas_session_key("nas-reset-command", "session-reset-command"); + let circuit_id = key.dynamic_circuit_id().unwrap(); + let pending_key = nas_session_key("nas-reset-command", "session-reset-pending"); + let unrelated_key = nas_session_key("nas-reset-other", "session-reset-other"); + let unrelated_circuit_id = unrelated_key.dynamic_circuit_id().unwrap(); + + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-reset-command", + "session-reset-command", + Ipv4Addr::new(198, 51, 100, 78), + ), + DynamicCircuitMapping::Ready, + &mut sink, + ); + store.apply_event_with_mapping_and_commands( + minimal_session_event( + AcctStatusType::Start, + "nas-reset-command", + "session-reset-pending", + ), + DynamicCircuitMapping::MissingParent, + &mut sink, + ); + store.apply_event_with_mapping_and_commands( + complete_event( + AcctStatusType::Start, + "nas-reset-other", + "session-reset-other", + Ipv4Addr::new(198, 51, 100, 79), + ), + DynamicCircuitMapping::Ready, + &mut sink, + ); + store.apply_event_with_mapping_and_commands( + reset_event(AcctStatusType::AccountingOff, "nas-reset-command"), + DynamicCircuitMapping::Ready, + &mut sink, + ); + + assert_eq!(sink.intents.len(), 3); + assert_eq!(sink.intents[2].circuit_id(), circuit_id); + let DynamicCircuitIntent::RemoveDynamicCircuit(removal) = &sink.intents[2] else { + panic!("expected NAS reset removal, got {:?}", sink.intents[2]); + }; + assert_eq!(removal.session_key, key); + assert_eq!( + removal.reason, + DynamicCircuitRemovalReason::NasReset(NasResetStatus::AccountingOff) + ); + assert_eq!( + store.session(&key).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + assert!( + store + .session(&key) + .unwrap() + .active_dynamic_circuit_ids + .is_empty() + ); + assert_eq!( + store.session(&pending_key).unwrap().state, + AccountingSessionState::Stale(NasResetStatus::AccountingOff) + ); + assert!( + store + .session(&pending_key) + .unwrap() + .active_dynamic_circuit_ids + .is_empty() + ); + assert_eq!( + store.session(&unrelated_key).unwrap().state, + AccountingSessionState::Active + ); + assert_eq!( + store + .session(&unrelated_key) + .unwrap() + .active_dynamic_circuit_ids, + vec![unrelated_circuit_id] + ); +} + +#[derive(Default)] +struct FakeDynamicCircuitSink { + intents: Vec, +} + +impl DynamicCircuitCommandSink for FakeDynamicCircuitSink { + fn emit(&mut self, intent: DynamicCircuitIntent) { + self.intents.push(intent); + } +} + +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 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), + ], + active_dynamic_circuit_ids: Vec::new(), + pending_reasons: Vec::new(), + }, + ) +} + +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, + } +} 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 +} From 543b8d52dbfc1e0504da148748ecfb82bd1caa7d Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Wed, 10 Jun 2026 12:36:02 -0500 Subject: [PATCH 02/18] Complete RADIUS accounting listener and fixtures --- docs-es/v2.0/configuration-advanced-es.md | 5 +- docs/v2.0/configuration-advanced.md | 5 +- prd.json | 4 +- src/rust/Cargo.lock | 1 + .../lqos_radius/src/accounting_event/tests.rs | 206 ++++- src/rust/lqos_radius/src/packet/tests.rs | 28 +- src/rust/lqosd/Cargo.toml | 1 + src/rust/lqosd/src/main.rs | 9 + src/rust/lqosd/src/memory_watchdog.rs | 2 +- .../node_manager/local_api/network_mode.rs | 34 +- src/rust/lqosd/src/radius_accounting.rs | 838 ++++++++++++++++++ 11 files changed, 1105 insertions(+), 28 deletions(-) create mode 100644 src/rust/lqosd/src/radius_accounting.rs diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index 085793f6..6af1f882 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -64,7 +64,7 @@ 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. En v2.0, esta sección solo se valida y se conserva. `lqosd` no abrirá un servicio de contabilidad RADIUS con esta configuración. +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. La aplicación de circuitos dinámicos desde la contabilidad RADIUS aún no está habilitada. Ejemplo: @@ -85,8 +85,9 @@ 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 también debe ser válida. - 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. Esta es la ruta configurada para el secreto compartido del cliente. LibreQoS conserva ese valor al reescribir `/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. +- 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. +- Reinicie `lqosd` después de cambiar esta sección para recargar el servicio y los archivos de secreto compartido. ### Integraciones con CRM/NMS diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index 746dfbc3..7ecee3a3 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -196,7 +196,7 @@ Notes: #### RADIUS accounting (optional) -LibreQoS accepts an optional `[radius_accounting]` section for trusted NAS client settings. In v2.0, this section is validated and saved only. `lqosd` will not start a RADIUS accounting listener from it. +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. Dynamic-circuit application from RADIUS accounting is not enabled yet. Example: @@ -217,8 +217,9 @@ 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. - 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`. This is the configured location for the client's shared secret. LibreQoS preserves that value 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. +- 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. +- Restart `lqosd` after changing this section so the listener and shared-secret files are reloaded. #### CRM/NMS Integrations diff --git a/prd.json b/prd.json index 4ed83728..70cbfcfe 100644 --- a/prd.json +++ b/prd.json @@ -105,7 +105,7 @@ "title": "Start the listener from lqosd when configured", "description": "As a LibreQoS operator, I want lqosd to start the RADIUS accounting listener only when configured, so that existing installations are unchanged unless RADIUS accounting is enabled.", "priority": 2, - "passes": false, + "passes": true, "acceptanceCriteria": [ "lqosd does not bind a RADIUS accounting socket when radius_accounting is absent or disabled.", "When radius_accounting is enabled, lqosd starts the listener inside the existing tokio runtime after configuration is loaded.", @@ -119,7 +119,7 @@ "title": "Provide protocol test fixtures", "description": "As a LibreQoS developer, I want focused protocol fixtures and scenario tests, so that regressions in packet parsing, authentication, and listener behavior are caught without NAS hardware.", "priority": 1, - "passes": false, + "passes": true, "acceptanceCriteria": [ "Raw packet fixtures cover valid Accounting-Start, Accounting-Interim, Accounting-Stop, invalid packet length, invalid authenticator, unknown standard attributes, unknown vendor-specific attributes, and IPv6 address or prefix attributes.", "A test packet builder can generate authenticated Accounting-Request packets for readable scenario tests.", diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 99da517d..61229d07 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2580,6 +2580,7 @@ dependencies = [ "lqos_overrides", "lqos_probe", "lqos_queue_tracker", + "lqos_radius", "lqos_setup", "lqos_stormguard", "lqos_sys", diff --git a/src/rust/lqos_radius/src/accounting_event/tests.rs b/src/rust/lqos_radius/src/accounting_event/tests.rs index 6815a7b2..5e8a6f64 100644 --- a/src/rust/lqos_radius/src/accounting_event/tests.rs +++ b/src/rust/lqos_radius/src/accounting_event/tests.rs @@ -8,7 +8,162 @@ use crate::test_support::{ radius_vendor_attribute, radius_vendor_attributes, radius_vendor_subattribute, signed_accounting_request_packet, }; -use crate::{MessageAuthenticatorPolicy, handle_accounting_request, verify_accounting_request}; +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() { @@ -30,6 +185,55 @@ fn extracts_acct_status_type_variants_and_unknown_values() { } } +#[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(&[ diff --git a/src/rust/lqos_radius/src/packet/tests.rs b/src/rust/lqos_radius/src/packet/tests.rs index 2d178627..1e9fdd79 100644 --- a/src/rust/lqos_radius/src/packet/tests.rs +++ b/src/rust/lqos_radius/src/packet/tests.rs @@ -42,6 +42,28 @@ 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] @@ -105,7 +127,7 @@ fn rejects_short_packet_header() { #[test] fn rejects_declared_packet_length_below_header() { assert_eq!( - parse_packet(&packet_with_declared_len((RADIUS_HEADER_LEN - 1) as u16)), + parse_packet(&RAW_INVALID_PACKET_LENGTH_FIXTURE), Err(PacketError::InvalidPacketLength { declared: RADIUS_HEADER_LEN - 1, }) @@ -267,11 +289,9 @@ fn verifies_fixed_accounting_request_authenticator() { #[test] fn rejects_accounting_request_with_wrong_secret() { - let packet = signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET); - assert_eq!( verify_accounting_request( - &packet, + &FIXED_ACCOUNTING_REQUEST, b"wrong-secret", MessageAuthenticatorPolicy::Optional ), diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index 4f894e5b..01633005 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" ] } diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index 29b4ebd1..b43919fb 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -16,6 +16,7 @@ mod node_manager; mod preflight_checks; mod probe_provider; mod program_control; +mod radius_accounting; mod remote_commands; mod rtt_exclusions; mod scheduler_control; @@ -197,6 +198,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,13 @@ fn main() -> Result<()> { BusRequest, )>(100); + if let Err(err) = + radius_accounting::start_configured_radius_accounting(radius_accounting_config) + .await + { + error!("RADIUS accounting listener was not started: {err}"); + } + let webserver_disabled = web_config.disable_webserver.unwrap_or(false); if !webserver_disabled { let control_tx_for_webserver = control_tx_for_webserver.clone(); diff --git a/src/rust/lqosd/src/memory_watchdog.rs b/src/rust/lqosd/src/memory_watchdog.rs index c284ff46..a6bf973a 100644 --- a/src/rust/lqosd/src/memory_watchdog.rs +++ b/src/rust/lqosd/src/memory_watchdog.rs @@ -290,7 +290,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/local_api/network_mode.rs b/src/rust/lqosd/src/node_manager/local_api/network_mode.rs index 5ab55238..16705a83 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 @@ -202,11 +202,9 @@ pub async fn apply( MergeNetworkModeError::InvalidCandidate(message) => { helper_validation_error_response(message) } - MergeNetworkModeError::LoadLiveConfig => { - helper_internal_error_response( - "Unable to load the live LibreQoS configuration".to_string(), - ) - } + MergeNetworkModeError::LoadLiveConfig => helper_internal_error_response( + "Unable to load the live LibreQoS configuration".to_string(), + ), }; } }; @@ -390,11 +388,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 = with_config_env(|| merge_network_mode(candidate).expect_err("invalid MTU should fail")); @@ -409,12 +409,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 = with_config_env(|| merge_network_mode(candidate).expect_err("invalid MTU should fail")); diff --git a/src/rust/lqosd/src/radius_accounting.rs b/src/rust/lqosd/src/radius_accounting.rs new file mode 100644 index 00000000..a34f1d0e --- /dev/null +++ b/src/rust/lqosd/src/radius_accounting.rs @@ -0,0 +1,838 @@ +//! lqosd integration for the RADIUS accounting listener. + +use lqos_config::{RadiusAccountingClient, RadiusAccountingConfig}; +use lqos_radius::{ + AccountingEvent, AccountingListenerOutcome, AccountingSessionKey, AccountingSessionState, + AccountingSessionStore, AccountingSessionUpdate, DynamicCircuitCommandSink, + DynamicCircuitIntent, DynamicCircuitMapping, ListenerConfig, RadiusListener, + TrustedClientSource, TrustedRadiusClient, start_listener, +}; +use std::collections::{HashMap, HashSet}; +use std::io; +use std::net::SocketAddr; +use std::time::{Duration, Instant}; +use thiserror::Error; +use tokio::task::JoinHandle; +use tracing::{debug, error, info, warn}; + +/// Starts the RADIUS accounting listener when it is enabled in configuration. +/// +/// Side effects: reads configured shared-secret files, binds the configured UDP +/// socket when enabled, and spawns a Tokio task to receive accounting packets. +/// It does not write files, call dynamic-circuit persistence, or touch TC/XDP. +pub(crate) async fn start_configured_radius_accounting( + config: Option, +) -> Result>, RadiusAccountingStartupError> { + let Some(runtime_config) = runtime_config_from_config(config).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, + )))) +} + +async fn runtime_config_from_config( + config: Option, +) -> 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 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), + })) +} + +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, +) { + let mut sessions = + RadiusAccountingSessions::new(runtime_config.default_ttl, runtime_config.stale_grace); + let mut command_sink = DeferredDynamicCircuitSink; + let mut expiry_interval = tokio::time::interval(cleanup_interval( + runtime_config.default_ttl, + runtime_config.stale_grace, + )); + + loop { + tokio::select! { + outcome = listener.receive_next_verified(&runtime_config.clients) => { + match outcome { + Ok(outcome) => handle_listener_outcome( + outcome, + &mut sessions, + &mut command_sink, + Instant::now(), + ), + 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_interval.tick() => { + let expired = sessions.expire_due(Instant::now(), &mut command_sink); + if expired > 0 { + debug!(expired, "expired RADIUS accounting session(s)"); + } + } + } + } +} + +fn handle_listener_outcome( + outcome: AccountingListenerOutcome, + sessions: &mut RadiusAccountingSessions, + command_sink: &mut impl DynamicCircuitCommandSink, + now: Instant, +) { + match outcome { + AccountingListenerOutcome::Accepted(accepted) => { + let event = AccountingEvent::from_verified(&accepted.request); + handle_accounting_event( + event, + sessions, + command_sink, + now, + accepted.peer, + accepted.received_len, + accepted.response_len, + ); + } + AccountingListenerOutcome::RejectedSource { peer, received_len } => { + warn!( + peer = %peer, + received_len, + "rejected RADIUS accounting packet from untrusted source" + ); + } + AccountingListenerOutcome::RejectedAmbiguousSource { peer, received_len } => { + warn!( + peer = %peer, + received_len, + "rejected RADIUS accounting packet matching multiple trusted clients" + ); + } + AccountingListenerOutcome::RejectedPacket { + peer, + received_len, + source, + } => { + warn!( + peer = %peer, + received_len, + error = %source, + "rejected RADIUS accounting packet" + ); + } + } +} + +fn handle_accounting_event( + event: AccountingEvent, + sessions: &mut RadiusAccountingSessions, + command_sink: &mut impl DynamicCircuitCommandSink, + now: Instant, + peer: SocketAddr, + received_len: usize, + response_len: usize, +) { + let update = sessions.apply_event(event, now, command_sink); + debug!( + peer = %peer, + received_len, + response_len, + ?update, + "accepted RADIUS accounting packet" + ); +} + +fn listener_error_is_recoverable(err: &lqos_radius::ListenerError) -> bool { + matches!(err, lqos_radius::ListenerError::Send { .. }) +} + +struct RadiusAccountingSessions { + store: AccountingSessionStore, + updated_at: HashMap, + default_ttl: Duration, + stale_grace: Duration, +} + +impl RadiusAccountingSessions { + fn new(default_ttl: Duration, stale_grace: Duration) -> Self { + Self { + store: AccountingSessionStore::new(), + updated_at: HashMap::new(), + default_ttl, + stale_grace, + } + } + + fn apply_event( + &mut self, + event: AccountingEvent, + now: Instant, + command_sink: &mut impl DynamicCircuitCommandSink, + ) -> AccountingSessionUpdate { + let previous_stale_keys = previous_stale_keys(&self.store, &event); + let update = self.store.apply_event_with_mapping_and_commands( + event, + DynamicCircuitMapping::MissingParent, + command_sink, + ); + self.record_update(&update, previous_stale_keys.as_ref(), now); + if self.updated_at.len() > self.store.len() { + self.prune_removed_sessions(); + } + update + } + + fn record_update( + &mut self, + update: &AccountingSessionUpdate, + previous_stale_keys: Option<&HashSet>, + now: Instant, + ) { + match update { + AccountingSessionUpdate::SessionUpdated { key, .. } => { + self.updated_at.insert(key.clone(), now); + } + AccountingSessionUpdate::NasSessionsMarkedStale { reset, .. } => { + let Some(previous_stale_keys) = previous_stale_keys else { + return; + }; + for (key, session) in self.store.sessions() { + if !previous_stale_keys.contains(key) + && session.state == AccountingSessionState::Stale(*reset) + { + self.updated_at.insert(key.clone(), now); + } + } + } + AccountingSessionUpdate::Ignored { .. } => {} + } + } + + fn prune_removed_sessions(&mut self) { + let store = &self.store; + self.updated_at + .retain(|key, _| store.session(key).is_some()); + } + + fn expire_due( + &mut self, + now: Instant, + command_sink: &mut impl DynamicCircuitCommandSink, + ) -> usize { + let expired_keys = self + .store + .sessions() + .filter_map(|(key, session)| { + let updated_at = *self.updated_at.get(key)?; + let ttl = match session.state { + AccountingSessionState::Stale(_) => self.stale_grace, + AccountingSessionState::Active | AccountingSessionState::Stopped => { + self.default_ttl + } + }; + (now.duration_since(updated_at) >= ttl).then(|| key.clone()) + }) + .collect::>(); + + let expired_count = expired_keys.len(); + for key in expired_keys { + self.updated_at.remove(&key); + self.store.expire_session_with_commands(&key, command_sink); + } + expired_count + } +} + +fn previous_stale_keys( + store: &AccountingSessionStore, + event: &AccountingEvent, +) -> Option> { + if !matches!( + event.status_type, + Some( + lqos_radius::AcctStatusType::AccountingOn | lqos_radius::AcctStatusType::AccountingOff + ) + ) { + return None; + } + + Some( + store + .sessions() + .filter(|(_, session)| matches!(session.state, AccountingSessionState::Stale(_))) + .map(|(key, _)| key.clone()) + .collect(), + ) +} + +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; + +impl DynamicCircuitCommandSink for DeferredDynamicCircuitSink { + fn emit(&mut self, intent: DynamicCircuitIntent) { + debug!( + circuit_id = intent.circuit_id(), + "RADIUS dynamic-circuit intent deferred until daemon-side application is enabled" + ); + } +} + +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, +} + +/// 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, + /// 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::{RadiusClientSource, RadiusSharedSecretSource}; + use lqos_radius::{AcctStatusType, MikrotikRateLimit, NasIdentity, PendingSessionReason}; + 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<()> { + assert!(runtime_config_from_config(None).await?.is_none()); + assert!( + runtime_config_from_config(Some(RadiusAccountingConfig::default())) + .await? + .is_none() + ); + + Ok(()) + } + + #[tokio::test] + async fn disabled_start_returns_no_listener_handle() -> anyhow::Result<()> { + assert!(start_configured_radius_accounting(None).await?.is_none()); + assert!( + start_configured_radius_accounting(Some(RadiusAccountingConfig::default())) + .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 handle = start_configured_radius_accounting(Some(config)).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 runtime_config = runtime_config_from_config(Some(config)).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 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 error = match runtime_config_from_config(Some(config)).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(()) + } + + #[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 mut sink = RecordingDynamicCircuitSink::default(); + let now = Instant::now(); + + handle_accounting_event( + complete_event(AcctStatusType::Start), + &mut sessions, + &mut sink, + 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)); + assert!(sink.intents.is_empty()); + + 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 mut sink = RecordingDynamicCircuitSink::default(); + let started_at = Instant::now(); + + sessions.apply_event(complete_event(AcctStatusType::Start), started_at, &mut sink); + assert_eq!( + sessions.expire_due(started_at + Duration::from_secs(9), &mut sink), + 0 + ); + assert_eq!( + sessions.expire_due(started_at + Duration::from_secs(10), &mut sink), + 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, + &mut sink, + ); + sessions.apply_event(reset_event(), reset_at, &mut sink); + 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), &mut sink), + 0 + ); + assert_eq!( + sessions.expire_due(reset_at + Duration::from_secs(2), &mut sink), + 1 + ); + assert!(!sessions.updated_at.contains_key(&session_key())); + assert!(sink.intents.is_empty()); + + Ok(()) + } + + #[test] + fn promoted_sessions_prune_old_timestamp_keys() { + let mut sessions = + RadiusAccountingSessions::new(Duration::from_secs(900), Duration::from_secs(120)); + let mut sink = RecordingDynamicCircuitSink::default(); + 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, &mut sink); + 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, + &mut sink, + ); + + 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 mut sink = RecordingDynamicCircuitSink::default(); + 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, + &mut sink, + ); + sessions.apply_event(reset_event_for("nas-a"), nas_a_reset_at, &mut sink); + 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, + &mut sink, + ); + sessions.apply_event(reset_event_for("nas-b"), nas_b_reset_at, &mut sink); + + assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&nas_a_reset_at)); + assert_eq!(sessions.expire_due(nas_b_reset_at, &mut sink), 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 mut sink = RecordingDynamicCircuitSink::default(); + 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, + &mut sink, + ); + sessions.apply_event(reset_event_for("nas-a"), first_reset_at, &mut sink); + sessions.apply_event(reset_event_for("nas-a"), repeated_reset_at, &mut sink); + + assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&first_reset_at)); + assert_eq!(sessions.expire_due(stale_deadline, &mut sink), 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 mut sink = RecordingDynamicCircuitSink::default(); + 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, + &mut sink, + ); + sessions.apply_event(reset_event_for("nas-a"), first_reset_at, &mut sink); + sessions.apply_event( + reset_event_for_status("nas-a", AcctStatusType::AccountingOn), + second_reset_at, + &mut sink, + ); + + 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, &mut sink), 1); + assert!(sessions.store.session(&nas_a_key).is_none()); + } + + #[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)); + } + + fn enabled_config(secret_path: &Path) -> RadiusAccountingConfig { + RadiusAccountingConfig { + enabled: true, + listen: Some(test_listen_addr()), + default_ttl_seconds: 900, + stale_grace_seconds: 120, + 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 session_key() -> AccountingSessionKey { + session_key_for("nas-adapter", "session-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() + } + } + + #[derive(Default)] + struct RecordingDynamicCircuitSink { + intents: Vec, + } + + impl DynamicCircuitCommandSink for RecordingDynamicCircuitSink { + fn emit(&mut self, intent: DynamicCircuitIntent) { + self.intents.push(intent); + } + } + + 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() + ))) + } +} From 5a77c254ddd3b870b2ac7751bf5c733d40317823 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 22 Jun 2026 09:24:54 -0500 Subject: [PATCH 03/18] Implement RADIUS dynamic circuit handling --- docs-es/v2.0/configuration-advanced-es.md | 33 +- docs/v2.0/configuration-advanced.md | 31 +- prd.json | 157 +- src/rust/Cargo.lock | 1 + src/rust/lqos_config/src/etc/mod.rs | 9 +- src/rust/lqos_config/src/etc/v15/mod.rs | 4 +- .../src/etc/v15/radius_accounting.rs | 550 +- src/rust/lqos_config/src/lib.rs | 12 +- .../lqos_config/src/shaped_devices/mod.rs | 7 +- .../src/shaped_devices/shaped_device.rs | 19 +- src/rust/lqos_radius/Cargo.toml | 1 + src/rust/lqos_radius/src/dynamic_circuit.rs | 9 +- src/rust/lqos_radius/src/lib.rs | 16 +- src/rust/lqos_radius/src/mac_match.rs | 202 + src/rust/lqos_radius/src/packet/tests.rs | 2 +- src/rust/lqos_radius/src/session.rs | 1402 ++++- src/rust/lqos_radius/src/session/tests.rs | 2221 ++++++- src/rust/lqosd/Cargo.toml | 3 + src/rust/lqosd/src/main.rs | 9 +- src/rust/lqosd/src/radius_accounting.rs | 5142 ++++++++++++++++- 20 files changed, 9107 insertions(+), 723 deletions(-) create mode 100644 src/rust/lqos_radius/src/mac_match.rs diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index 6af1f882..ec5a4e56 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -64,17 +64,34 @@ 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. La aplicación de circuitos dinámicos desde la contabilidad RADIUS aún no está habilitada. +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. Ejemplo: ```toml +[dynamic_circuits] +enabled = true + [radius_accounting] -enabled = false +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 +# 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"] @@ -82,11 +99,21 @@ 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 también debe ser válida. +- 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_mac = true` para comparar los valores RADIUS `Calling-Station-Id` con los valores MAC de `ShapedDevices.csv`. 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. Si no hay coincidencia o hay varias filas coincidentes, la sesión queda pendiente. +- El servicio RADIUS carga los metadatos de coincidencia MAC desde `ShapedDevices.csv` cuando inicia. Reinicie `lqosd` después de cambiar campos de MAC, nodo padre, circuito, dispositivo, SQM o velocidad que la coincidencia MAC de RADIUS deba usar. +- Si `radius_accounting.dynamic_circuit_application.enabled = true` pero `match_shaped_devices_by_mac = false`, configure `fallback_parent_node` cuando las identidades RADIUS por defecto deban quedar aptas para shaping. Sin `fallback_parent_node`, las sesiones 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 por defecto derivadas de la identidad NAS más `Acct-Session-Id`. Las sesiones con coincidencia MAC conservan los metadatos de nodo padre de la fila coincidente 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 diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index 7ecee3a3..59a64478 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -196,17 +196,34 @@ Notes: #### 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. Dynamic-circuit application from RADIUS accounting is not enabled yet. +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. Example: ```toml +[dynamic_circuits] +enabled = true + [radius_accounting] -enabled = false +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 +# 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"] @@ -215,10 +232,20 @@ 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_mac = true` to match RADIUS `Calling-Station-Id` values against `ShapedDevices.csv` MAC values. 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. No match or multiple matching rows leaves the session pending. +- The RADIUS listener loads MAC-match metadata from `ShapedDevices.csv` when it starts. Restart `lqosd` after changing MAC, parent, circuit, device, SQM, or speed fields that RADIUS MAC matching should use. +- If `radius_accounting.dynamic_circuit_application.enabled = true` but `match_shaped_devices_by_mac = false`, configure `fallback_parent_node` when default RADIUS identities should become shapeable. Without `fallback_parent_node`, sessions remain pending with missing parent metadata. +- `fallback_parent_node`, `fallback_parent_node_id`, and `fallback_anchor_node_id` are used only for default identities derived from NAS identity plus `Acct-Session-Id`. MAC-matched sessions keep the parent metadata from the matched `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 diff --git a/prd.json b/prd.json index 70cbfcfe..8357e860 100644 --- a/prd.json +++ b/prd.json @@ -1,135 +1,108 @@ { "product": "LibreQoS", - "branchName": "radius-accounting-listener", - "summary": "Add an opt-in RADIUS accounting listener MVP that can be developed and tested without root, decode common accounting packets, and prepare dynamic-circuit command plumbing for lqosd integration.", + "branchName": "radius-accounting-dynamic-circuits", + "summary": "Extend the opt-in RADIUS accounting listener from session tracking to dynamic circuit application, with fallback speeds and MAC-based ShapedDevices matching for operator-managed account plans.", "stories": [ { - "id": "RADIUS-001", - "title": "Create the lqos_radius crate", - "description": "As a LibreQoS developer, I want a separate lqos_radius crate with a rootless test executable, so that RADIUS accounting protocol work can be built and exercised without running lqosd as root.", + "id": "RADIUS-010", + "title": "Gate RADIUS dynamic circuit application", + "description": "As a LibreQoS operator, I want RADIUS dynamic circuit application to have its own explicit activation settings, so that I can receive accounting packets without changing shaping until I choose to enable it.", "priority": 1, "passes": true, "acceptanceCriteria": [ - "The Rust workspace includes lqos_radius as a buildable member.", - "lqos_radius exposes a library API for packet handling and listener startup.", - "lqos_radius includes an executable entrypoint that can run on a configurable non-privileged listen address.", - "cargo test for lqos_radius runs without requiring root privileges or live shaping state." + "RADIUS accounting continues to listen and track sessions when dynamic circuit application is disabled.", + "RADIUS-created dynamic circuits are not applied unless both RADIUS application settings and the existing dynamic_circuits feature are enabled.", + "Invalid activation settings are rejected during configuration validation before listener startup.", + "Config round-trip tests cover disabled application, enabled application, missing dynamic_circuits dependency, and valid activation settings." ] }, { - "id": "RADIUS-002", - "title": "Configure RADIUS accounting", - "description": "As a LibreQoS operator, I want RADIUS accounting settings in LibreQoS configuration, so that I can explicitly enable the listener and control which NAS clients are trusted.", + "id": "RADIUS-011", + "title": "Configure fallback RADIUS speeds", + "description": "As a LibreQoS operator, I want an optional default speed profile for RADIUS-created circuits, so that sessions without packet-provided or lookup-provided rates can still be shaped at a safe default plan.", "priority": 1, "passes": true, "acceptanceCriteria": [ - "radius_accounting can be disabled without requiring any RADIUS clients.", - "When radius_accounting is enabled, configuration requires a valid listen address and at least one client source.", - "Each configured client has a source allow-list and a shared secret source that is not emitted in logs or serialized debug output.", - "Configuration validation rejects invalid listen addresses, invalid client sources, missing secrets, and non-positive timing values.", - "Configuration round-trip tests cover enabled and disabled RADIUS accounting settings." + "RADIUS accounting configuration can define download_min_mbps, upload_min_mbps, download_max_mbps, and upload_max_mbps as an optional fallback profile.", + "Validation rejects non-finite rates, rates less than or equal to zero, and minimum rates greater than maximum rates.", + "When no packet or lookup rate exists and no fallback profile is configured, the session remains pending with a missing-rate reason.", + "When a fallback profile is configured, it is used only after higher-priority rate sources fail to resolve a rate." ] }, { - "id": "RADIUS-003", - "title": "Decode and verify accounting packets", - "description": "As a LibreQoS operator, I want incoming RADIUS accounting packets to be decoded and verified, so that only authentic Accounting-Request packets from trusted clients are accepted.", + "id": "RADIUS-012", + "title": "Match ShapedDevices by RADIUS MAC", + "description": "As a LibreQoS operator, I want RADIUS accounting sessions to optionally match ShapedDevices entries by MAC address, so that my existing account-plan sync can provide circuit metadata while RADIUS supplies the active IP addresses.", "priority": 1, "passes": true, "acceptanceCriteria": [ - "The decoder accepts Accounting-Request packets and rejects unsupported RADIUS packet codes for this MVP.", - "Packet length, attribute bounds, and malformed attribute cases are validated before packet acceptance.", - "Accounting-Request authenticators are verified against the matched client shared secret.", - "Message-Authenticator is verified when present and is required when configuration enables that policy.", - "Valid accepted requests can produce an Accounting-Response packet with response code 5." + "A configuration option enables or disables matching RADIUS Calling-Station-Id values against ShapedDevices MAC values.", + "MAC matching normalizes common colon, hyphen, dot, plain-hex, and mixed-case formats before comparison.", + "A unique MAC match supplies circuit ID, circuit name, device ID, device name, parent fields, speed fields, and SQM override metadata for the dynamic circuit.", + "RADIUS-provided IPv4 and IPv6 address data replaces the matched ShapedDevices address fields for the active dynamic circuit.", + "No match or more than one matching ShapedDevices row leaves the session pending and emits no dynamic circuit." ] }, { - "id": "RADIUS-004", - "title": "Extract common accounting attributes", - "description": "As a LibreQoS operator, I want common RADIUS accounting attributes translated into typed session events, so that subscriber session lifecycle, identity, and address data can be inspected and used later.", + "id": "RADIUS-013", + "title": "Resolve RADIUS sessions into shaped devices", + "description": "As a LibreQoS operator, I want shapeable RADIUS sessions converted into valid dynamic circuit device definitions, so that active subscriber addresses can be attached to the correct circuit and plan.", "priority": 1, "passes": true, "acceptanceCriteria": [ - "Typed events include Acct-Status-Type values for Start, Stop, Interim-Update, Accounting-On, and Accounting-Off.", - "Typed events include session and NAS identity fields such as Acct-Session-Id, NAS-IP-Address, NAS-IPv6-Address, NAS-Identifier, NAS-Port, NAS-Port-Id, User-Name, Calling-Station-Id, Called-Station-Id, Class, and Event-Timestamp when present.", - "Typed events include subscriber address fields such as Framed-IP-Address, Framed-IP-Netmask, Framed-Route, Framed-IPv6-Prefix, Delegated-IPv6-Prefix, and Framed-IPv6-Address when present.", - "Unknown standard attributes and unknown vendor-specific attributes are preserved for diagnostics without rejecting otherwise valid packets.", - "MikroTik-Rate-Limit vendor attributes are decoded into rate values and tests verify configurable NAS-to-LibreQoS direction mapping." + "A session becomes shapeable only when it has a stable circuit identity, device identity, at least one IP address or prefix, parent attachment metadata, and resolved rates.", + "When no ShapedDevices MAC match supplies identity, the default circuit ID is derived from NAS identity and Acct-Session-Id, and display names prefer User-Name and Calling-Station-Id when present.", + "Rate resolution uses decoded RADIUS rate data before ShapedDevices MAC-match rates, and uses the configured fallback profile only when both are missing.", + "Missing IP, missing parent, missing rate, and ambiguous MAC match cases remain pending with distinct reasons.", + "Unit tests cover Start without IP, Interim activation with IP, matched ShapedDevices metadata with RADIUS IP replacement, and fallback identity generation." ] }, { - "id": "RADIUS-005", - "title": "Run a rootless UDP accounting listener", - "description": "As a LibreQoS developer, I want the lqos_radius listener to run against loopback test traffic, so that protocol and server behavior can be proven before wiring live lqosd behavior.", + "id": "RADIUS-014", + "title": "Apply RADIUS dynamic circuit upserts", + "description": "As a LibreQoS operator, I want accepted RADIUS Start and Interim-Update packets to create or update dynamic circuits through the existing daemon path, so that active sessions are shaped without editing ShapedDevices.csv.", "priority": 1, "passes": true, "acceptanceCriteria": [ - "The listener can bind to 127.0.0.1:0 or another configured non-privileged address for tests.", - "Accepted Accounting-Request packets receive an Accounting-Response without waiting for dynamic-circuit application.", - "Packets from unconfigured sources do not create session events or command placeholders.", - "Packets with invalid authenticators are rejected and do not create session events or command placeholders.", - "Loopback UDP tests verify accepted requests, rejected sources, invalid authenticators, and response behavior." + "Shapeable Start and Interim-Update events submit CreateDynamicCircuit requests with the resolved ShapedDevice payload.", + "RADIUS dynamic circuit application uses the existing lqosd dynamic circuit path and never writes dynamic_circuits.json directly from lqos_radius.", + "The UDP Accounting-Response path does not wait for dynamic circuit persistence or bakery overlay work to finish.", + "Create or update failures are logged with circuit and session identity and do not stop the RADIUS listener.", + "Tests use fake command or bus sinks and do not require live shaping, bakery, TC, XDP, or root privileges." ] }, { - "id": "RADIUS-006", - "title": "Track accounting sessions in memory", - "description": "As a LibreQoS operator, I want accepted accounting events to update runtime session state, so that Starts, Interims, Stops, and NAS resets have predictable results during the MVP.", + "id": "RADIUS-015", + "title": "Expire RADIUS dynamic circuits", + "description": "As a LibreQoS operator, I want RADIUS-created dynamic circuits removed when accounting sessions stop or expire, so that stale subscriber IPs do not remain shaped after the session ends.", + "priority": 1, + "passes": true, + "acceptanceCriteria": [ + "Stop events submit RemoveDynamicCircuit for the active circuit ID, and duplicate Stop events are harmless.", + "Sessions that exceed default_ttl_seconds without refresh remove any active RADIUS-created dynamic circuit.", + "Accounting-On and Accounting-Off events mark matching NAS sessions stale and remove active circuits no later than stale_grace_seconds unless refreshed by Start or Interim-Update.", + "Removal uses the same circuit ID that was used for creation, including IDs supplied by ShapedDevices MAC matching.", + "Fake-time tests cover Stop removal, TTL expiry removal, stale NAS reset expiry, and refresh before expiry." + ] + }, + { + "id": "RADIUS-016", + "title": "Report RADIUS activation outcomes", + "description": "As a LibreQoS operator, I want clear RADIUS dynamic circuit diagnostics, so that I can see why a session was shaped, pending, expired, or rejected without exposing shared secrets.", "priority": 2, "passes": true, "acceptanceCriteria": [ - "Sessions are keyed primarily by NAS identity plus Acct-Session-Id when both values are available.", - "Start and Interim-Update events create or refresh a session and preserve the latest decoded identity and address data.", - "Stop events mark the session stopped and are harmless when duplicated.", - "Accounting-On and Accounting-Off mark sessions for the affected NAS stale without affecting other NAS clients.", - "Sessions that cannot become dynamic circuits retain a specific pending reason such as missing IP, missing rate, missing parent, missing session ID, or ambiguous mapping." - ] - }, - { - "id": "RADIUS-007", - "title": "Prepare dynamic-circuit command plumbing", - "description": "As a LibreQoS developer, I want RADIUS session handling to emit create, update, and remove command placeholders, so that lqosd can later connect those commands to the existing dynamic-circuit path without changing the protocol server.", - "priority": 2, - "passes": true, - "acceptanceCriteria": [ - "lqos_radius exposes a command sink boundary for dynamic-circuit intents that the lqosd-facing adapter can map to CreateDynamicCircuit and RemoveDynamicCircuit placeholders.", - "Shapeable Start and Interim-Update events emit create or update command intents through the sink boundary.", - "Stop events emit remove command intents when a deterministic circuit identity is available.", - "Fake sink tests assert the emitted command type and circuit identity for Start, Interim-Update, Stop, and expiry scenarios.", - "The listener never writes dynamic_circuits.json directly." - ] - }, - { - "id": "RADIUS-008", - "title": "Start the listener from lqosd when configured", - "description": "As a LibreQoS operator, I want lqosd to start the RADIUS accounting listener only when configured, so that existing installations are unchanged unless RADIUS accounting is enabled.", - "priority": 2, - "passes": true, - "acceptanceCriteria": [ - "lqosd does not bind a RADIUS accounting socket when radius_accounting is absent or disabled.", - "When radius_accounting is enabled, lqosd starts the listener inside the existing tokio runtime after configuration is loaded.", - "Listener startup failures are reported clearly without logging shared secrets.", - "lqosd shutdown stops the listener without blocking daemon shutdown.", - "The lqosd wiring uses the lqos_radius command sink boundary and leaves final dynamic-circuit application behind the existing bus or daemon channel path for this MVP." - ] - }, - { - "id": "RADIUS-009", - "title": "Provide protocol test fixtures", - "description": "As a LibreQoS developer, I want focused protocol fixtures and scenario tests, so that regressions in packet parsing, authentication, and listener behavior are caught without NAS hardware.", - "priority": 1, - "passes": true, - "acceptanceCriteria": [ - "Raw packet fixtures cover valid Accounting-Start, Accounting-Interim, Accounting-Stop, invalid packet length, invalid authenticator, unknown standard attributes, unknown vendor-specific attributes, and IPv6 address or prefix attributes.", - "A test packet builder can generate authenticated Accounting-Request packets for readable scenario tests.", - "Authenticator tests cover correct secrets, wrong secrets, modified attributes, truncated packets, valid Message-Authenticator, invalid Message-Authenticator, and required Message-Authenticator policy.", - "Session tests use fake time and a fake command sink instead of sockets, root privileges, bakery, TC, XDP, or disk writes." + "Diagnostics distinguish active, pending, stopped, stale, expired, and apply-failed RADIUS session states.", + "Pending diagnostics include missing IP, missing parent, missing rate, missing session identity, no MAC match, and ambiguous MAC match reasons.", + "Create, update, remove, and expiry counters are tracked separately from packet accept and reject counters.", + "Logs and diagnostics include circuit/session identifiers needed for troubleshooting but never include shared secret contents." ] } ], "notes": [ - "Full dynamic-circuit application, operator UI or CLI diagnostics, persistent session storage, and CoA or Disconnect support remain outside this MVP unless separately requested.", - "Binding the default UDP 1813 port may require elevated privileges; the crate executable and automated tests should support non-privileged addresses for development." + "This PRD starts after the completed listener, packet parsing, session tracking, and deferred command-intent work from RADIUS-001 through RADIUS-009.", + "RADIUS-provided IP and prefix data is authoritative for active dynamic circuit addresses; ShapedDevices MAC matches supply account-plan and topology metadata only.", + "Dynamic circuit application must use the existing lqosd dynamic circuit path and must continue to respect the existing dynamic_circuits.enabled safety gate.", + "Automated tests for this PRD should use fake sinks, fake time, and loopback-only packet handling; they must not require root privileges or live TC/XDP shaping state." ] } diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index 61229d07..e74a7e2e 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -2411,6 +2411,7 @@ dependencies = [ "clap", "hmac", "ip_network", + "lqos_config", "md-5", "subtle", "thiserror 1.0.69", diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index 6b525416..b2eccb47 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -21,10 +21,11 @@ mod v15; pub use v15::{ BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode, MikrotikIpv6Config, QueueMode, RadiusAccountingClient, RadiusAccountingConfig, - RadiusClientSource, RadiusSharedSecretSource, RttThresholds, SingleInterfaceConfig, SslConfig, - StormguardConfig, StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, - TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, - normalize_external_hostname, + 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 d551c4d6..d33dacad 100644 --- a/src/rust/lqos_config/src/etc/v15/mod.rs +++ b/src/rust/lqos_config/src/etc/v15/mod.rs @@ -33,7 +33,9 @@ pub use long_term_stats::LongTermStats; pub use mikrotik_ipv6::MikrotikIpv6Config; pub use queues::{LazyQueueMode, QueueMode}; pub use radius_accounting::{ - RadiusAccountingClient, RadiusAccountingConfig, RadiusClientSource, RadiusSharedSecretSource, + RadiusAccountingClient, RadiusAccountingConfig, RadiusClientSource, + RadiusDynamicCircuitApplicationConfig, RadiusFallbackSpeedProfile, RadiusSharedSecretSource, + RateProfileValidationError, validate_rate_profile_mbps, }; pub use stormguard::{StormguardConfig, StormguardStrategy}; pub use topology::{TopologyConfig, normalize_topology_compile_mode}; diff --git a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs index e0979641..a9cc6731 100644 --- a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs +++ b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs @@ -9,6 +9,7 @@ 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 @@ -138,12 +139,112 @@ impl RadiusAccountingClient { } } +/// 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. /// - /// In v2.0 this value is validated and saved, but does not start a listener. + /// 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`. @@ -156,11 +257,66 @@ pub struct RadiusAccountingConfig { /// 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, + /// 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 { @@ -168,6 +324,8 @@ impl Default for RadiusAccountingConfig { 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(), } } @@ -187,6 +345,13 @@ impl RadiusAccountingConfig { 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)?; } @@ -249,6 +414,43 @@ fn client_label(index: usize, name: &str) -> String { } } +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() { @@ -281,6 +483,13 @@ mod tests { 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, @@ -369,6 +578,31 @@ mod tests { 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 { @@ -379,6 +613,15 @@ mod tests { } } + 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, @@ -389,6 +632,8 @@ mod tests { ), 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()], } } @@ -454,6 +699,272 @@ mod tests { 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 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] @@ -659,6 +1170,43 @@ mod tests { 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"), ]); } diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 4abe732c..75a067aa 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -43,11 +43,13 @@ pub use cpu_topology::{ pub use etc::{ BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, MikrotikIpv6Config, QueueMode, RadiusAccountingClient, RadiusAccountingConfig, - RadiusClientSource, RadiusSharedSecretSource, 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, + 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, + 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..75e4869f 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, used by imports and RADIUS MAC 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() @@ -321,10 +329,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)) { diff --git a/src/rust/lqos_radius/Cargo.toml b/src/rust/lqos_radius/Cargo.toml index 2880ed7a..33d730ca 100644 --- a/src/rust/lqos_radius/Cargo.toml +++ b/src/rust/lqos_radius/Cargo.toml @@ -8,6 +8,7 @@ license = "GPL-2.0-only" 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 } diff --git a/src/rust/lqos_radius/src/dynamic_circuit.rs b/src/rust/lqos_radius/src/dynamic_circuit.rs index 673f8c29..0550557e 100644 --- a/src/rust/lqos_radius/src/dynamic_circuit.rs +++ b/src/rust/lqos_radius/src/dynamic_circuit.rs @@ -1,6 +1,7 @@ //! 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 { @@ -13,13 +14,13 @@ pub trait DynamicCircuitCommandSink { } /// Dynamic-circuit intent that an lqosd-facing adapter can map onto daemon commands. -#[derive(Clone, Debug, Eq, PartialEq)] +#[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 can map this to `RemoveDynamicCircuit`. + /// Remove a runtime dynamic circuit. Adapters may defer this until runtime removals are enabled. RemoveDynamicCircuit(DynamicCircuitRemoval), } @@ -37,7 +38,7 @@ impl DynamicCircuitIntent { } /// Data needed to create or update a dynamic circuit from a RADIUS session. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct DynamicCircuitUpsert { /// Stable circuit identifier for the dynamic circuit overlay. pub circuit_id: String, @@ -45,6 +46,8 @@ pub struct DynamicCircuitUpsert { 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. diff --git a/src/rust/lqos_radius/src/lib.rs b/src/rust/lqos_radius/src/lib.rs index aeab74ac..10a6a8ff 100644 --- a/src/rust/lqos_radius/src/lib.rs +++ b/src/rust/lqos_radius/src/lib.rs @@ -2,9 +2,10 @@ //! //! The crate checks RADIUS packet framing, parses and verifies //! Accounting-Request packets, builds Accounting-Response packets, tracks -//! decoded accounting sessions in memory, emits dynamic-circuit command intents -//! through an explicit sink boundary, and exposes a UDP listener that can bind -//! to non-privileged loopback addresses for development and automated tests. +//! 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)] @@ -12,6 +13,7 @@ mod accounting_event; mod attribute_type; mod dynamic_circuit; mod listener; +mod mac_match; mod packet; mod session; #[cfg(test)] @@ -30,6 +32,7 @@ pub use listener::{ 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, @@ -37,6 +40,9 @@ pub use packet::{ }; pub use session::{ AccountingSession, AccountingSessionIgnoreReason, AccountingSessionKey, AccountingSessionState, - AccountingSessionStore, AccountingSessionUpdate, DynamicCircuitMapping, NasIdentity, - NasResetStatus, PendingSessionFingerprint, PendingSessionReason, + AccountingSessionStore, AccountingSessionUpdate, DynamicCircuitMapping, DynamicCircuitParent, + DynamicCircuitResolution, NasIdentity, NasResetStatus, PendingSessionFingerprint, + PendingSessionReason, RadiusActivationCounters, RadiusActivationDiagnostic, + RadiusActivationDiagnosticState, RadiusPacketCounters, ResolvedSessionRate, SessionRateProfile, + SessionRateProfileError, SessionRateSource, SessionRateSources, }; 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..8a6eda72 --- /dev/null +++ b/src/rust/lqos_radius/src/mac_match.rs @@ -0,0 +1,202 @@ +//! MAC-address matching against `ShapedDevices.csv` rows. + +use crate::AccountingEvent; +use lqos_config::ShapedDevice; +use std::collections::HashMap; + +/// Matches RADIUS `Calling-Station-Id` values to `ShapedDevices.csv` MAC fields. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ShapedDevicesMacMatcher { + matches_by_mac: HashMap, +} + +impl ShapedDevicesMacMatcher { + /// Builds a MAC matcher from shaped-device rows. + /// + /// Rows with empty or invalid MAC values are ignored. More than one row with + /// the same normalized MAC is retained as an ambiguous match. + /// + /// 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(); + for device in devices { + let Some(normalized_mac) = normalize_radius_mac(&device.mac) else { + continue; + }; + matches_by_mac + .entry(normalized_mac) + .and_modify(|entry| *entry = ShapedDevicesMacEntry::Ambiguous) + .or_insert_with(|| ShapedDevicesMacEntry::Unique(Box::new(device.clone()))); + } + + Self { matches_by_mac } + } + + /// 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 { + 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; + }; + + match self.matches_by_mac.get(&normalized_mac) { + Some(ShapedDevicesMacEntry::Unique(device)) => { + ShapedDevicesMacMatch::Unique(device.clone()) + } + Some(ShapedDevicesMacEntry::Ambiguous) => ShapedDevicesMacMatch::Ambiguous, + None => ShapedDevicesMacMatch::NoMatch, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +enum ShapedDevicesMacEntry { + Unique(Box), + Ambiguous, +} + +/// Result of matching one RADIUS `Calling-Station-Id` to shaped-device MAC rows. +#[derive(Clone, Debug, PartialEq)] +pub enum ShapedDevicesMacMatch { + /// Exactly one shaped-device row matched the normalized MAC address. + Unique(Box), + /// No shaped-device row matched the normalized MAC address. + NoMatch, + /// More than one shaped-device row matched the normalized MAC address. + 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 + ); + } + + 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/packet/tests.rs b/src/rust/lqos_radius/src/packet/tests.rs index 1e9fdd79..ce40d4c8 100644 --- a/src/rust/lqos_radius/src/packet/tests.rs +++ b/src/rust/lqos_radius/src/packet/tests.rs @@ -211,7 +211,7 @@ fn parses_empty_attribute_value_and_continues() { assert_eq!(parsed.attributes().len(), 2); assert_eq!(parsed.attributes()[0].kind(), UNKNOWN_ATTRIBUTE_TYPE); - assert_eq!(parsed.attributes()[0].value(), &[]); + 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]); } diff --git a/src/rust/lqos_radius/src/session.rs b/src/rust/lqos_radius/src/session.rs index 4c8956f8..a67a7249 100644 --- a/src/rust/lqos_radius/src/session.rs +++ b/src/rust/lqos_radius/src/session.rs @@ -1,17 +1,24 @@ //! In-memory RADIUS accounting session tracking. -use crate::{AccountingEvent, AcctStatusType}; +use crate::mac_match::ShapedDevicesMacMatch; use crate::{ - DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemoval, - DynamicCircuitRemovalReason, DynamicCircuitUpsert, + AccountingEvent, AcctStatusType, DynamicCircuitCommandSink, DynamicCircuitIntent, + DynamicCircuitRemoval, DynamicCircuitRemovalReason, DynamicCircuitUpsert, MikrotikRateLimit, }; -use std::collections::HashMap; +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; + /// 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 { @@ -22,6 +29,10 @@ impl AccountingSessionStore { 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(), } } @@ -48,6 +59,23 @@ impl AccountingSessionStore { 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, @@ -65,27 +93,71 @@ impl AccountingSessionStore { event: AccountingEvent, mapping: DynamicCircuitMapping, ) -> AccountingSessionUpdate { - let Some(status_type) = event.status_type else { - return AccountingSessionUpdate::Ignored { - reason: AccountingSessionIgnoreReason::MissingStatusType, - }; - }; + self.apply_event_with_mapping_and_rate_sources( + event, + mapping, + SessionRateSources::default(), + ) + } - match status_type { - AcctStatusType::Start | AcctStatusType::InterimUpdate => { - self.upsert_active_session(event, mapping) - } - AcctStatusType::Stop => self.stop_session(event, mapping), - 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), + /// 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 and emits dynamic-circuit intents for shapeable sessions. @@ -99,17 +171,79 @@ impl AccountingSessionStore { mapping: DynamicCircuitMapping, sink: &mut impl DynamicCircuitCommandSink, ) -> AccountingSessionUpdate { - let reset_identities = match event.status_type { - Some(AcctStatusType::AccountingOn | AcctStatusType::AccountingOff) => { - Some(NasIdentitySet::from_event(&event)) - } - _ => None, - }; - let update = self.apply_event_with_mapping(event, mapping); - self.emit_dynamic_circuit_intents(&update, reset_identities.as_ref(), sink); + 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 + } + + 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 @@ -120,11 +254,19 @@ impl AccountingSessionStore { key: &AccountingSessionKey, sink: &mut impl DynamicCircuitCommandSink, ) -> Option { - let mut session = self.sessions.remove(key)?; + 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, - DynamicCircuitRemovalReason::Expired, + removal_reason, + &mut self.activation_counters, sink, ); Some(session) @@ -133,19 +275,13 @@ impl AccountingSessionStore { fn emit_dynamic_circuit_intents( &mut self, update: &AccountingSessionUpdate, - reset_identities: Option<&NasIdentitySet>, sink: &mut impl DynamicCircuitCommandSink, ) { match update { AccountingSessionUpdate::SessionUpdated { key, state } => { self.emit_session_dynamic_circuit_intents(key, *state, sink); } - AccountingSessionUpdate::NasSessionsMarkedStale { reset, .. } => { - let Some(reset_identities) = reset_identities else { - return; - }; - self.emit_nas_reset_dynamic_circuit_removals(reset_identities, *reset, sink); - } + AccountingSessionUpdate::NasSessionsMarkedStale { .. } => {} AccountingSessionUpdate::Ignored { .. } => {} } } @@ -157,44 +293,7 @@ impl AccountingSessionStore { sink: &mut impl DynamicCircuitCommandSink, ) { match state { - AccountingSessionState::Active => { - let Some(circuit_id) = key.dynamic_circuit_id() else { - return; - }; - let Some(session) = self.sessions.get_mut(key) else { - return; - }; - if !session.pending_reasons.is_empty() { - emit_active_dynamic_circuit_removals( - key, - session, - DynamicCircuitRemovalReason::NoLongerShapeable, - 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(), - }; - match (already_emitted, session.latest_event.status_type) { - (true, Some(AcctStatusType::InterimUpdate)) => { - sink.emit(DynamicCircuitIntent::UpdateDynamicCircuit(upsert)); - } - (_, Some(AcctStatusType::Start | AcctStatusType::InterimUpdate)) => { - sink.emit(DynamicCircuitIntent::CreateDynamicCircuit(upsert)); - } - _ => {} - } - push_active_dynamic_circuit_id(&mut session.active_dynamic_circuit_ids, circuit_id); - emit_rekeyed_dynamic_circuit_removals(key, session, sink); - } + AccountingSessionState::Active => self.emit_active_session_intents(key, sink), AccountingSessionState::Stopped => { let Some(session) = self.sessions.get_mut(key) else { return; @@ -203,6 +302,7 @@ impl AccountingSessionStore { key, session, DynamicCircuitRemovalReason::Stop, + &mut self.activation_counters, sink, ); } @@ -210,56 +310,104 @@ impl AccountingSessionStore { } } - fn emit_nas_reset_dynamic_circuit_removals( + fn emit_active_session_intents( &mut self, - reset_identities: &NasIdentitySet, - reset: NasResetStatus, + key: &AccountingSessionKey, sink: &mut impl DynamicCircuitCommandSink, ) { - for (key, session) in &mut self.sessions { - if !session_matches_identities(key, session, reset_identities) { - continue; - } + 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::NasReset(reset), + 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, - mapping: DynamicCircuitMapping, + resolve_metadata: impl FnOnce(&AccountingEvent) -> DynamicCircuitResolution, ) -> AccountingSessionUpdate { - self.apply_session_state(event, mapping, AccountingSessionState::Active) + self.apply_session_state(event, resolve_metadata, AccountingSessionState::Active) } fn stop_session( &mut self, event: AccountingEvent, - mapping: DynamicCircuitMapping, + resolve_metadata: impl FnOnce(&AccountingEvent) -> DynamicCircuitResolution, ) -> AccountingSessionUpdate { - self.apply_session_state(event, mapping, AccountingSessionState::Stopped) + self.apply_session_state(event, resolve_metadata, AccountingSessionState::Stopped) } fn apply_session_state( &mut self, event: AccountingEvent, - mapping: DynamicCircuitMapping, + 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.sessions.remove(previous_key)) + .filter_map(|previous_key| self.remove_session(previous_key)) .collect::>(); let key = key_resolution.key; - if let Some(session) = self.sessions.get_mut(&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( @@ -270,6 +418,10 @@ impl AccountingSessionStore { &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); @@ -277,11 +429,27 @@ impl AccountingSessionStore { } session.state = state; session.latest_event = latest_session_event(Some(&previous_event), event, state); - session.pending_reasons = pending_reasons(&session.latest_event, mapping); + 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( @@ -292,6 +460,10 @@ impl AccountingSessionStore { &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) @@ -302,17 +474,28 @@ impl AccountingSessionStore { } let latest_event = latest_session_event(previous_event.as_ref(), event, state); merge_known_nas_identities(&mut known_nas_identities, &latest_event); - let pending_reasons = pending_reasons(&latest_event, mapping); + 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, - pending_reasons, + diagnostic_circuit_ids, + pending_reasons: metadata.pending_reasons, }, ); + self.index_session(&key); } AccountingSessionUpdate::SessionUpdated { key, state } @@ -332,11 +515,20 @@ impl AccountingSessionStore { 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; } @@ -344,6 +536,8 @@ impl AccountingSessionStore { nas, reset, marked_count, + newly_stale_session_keys, + stale_session_keys, } } @@ -420,20 +614,16 @@ impl AccountingSessionStore { excluded_key: Option<&AccountingSessionKey>, ) -> Option { let mut found = None; - for (key, session) in &self.sessions { + 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 AccountingSessionKey::NasSession { - acct_session_id: existing_session_id, - .. - } = key - else { + let Some(session) = self.sessions.get(key) else { continue; }; - if existing_session_id != acct_session_id { - continue; - } if !session_matches_identities(key, session, identities) { continue; } @@ -452,17 +642,20 @@ impl AccountingSessionStore { ) -> Option { let event_fingerprint = PendingSessionFingerprint::from_event(event, identities.primary()); let mut found = None; - for (key, session) in &self.sessions { - let AccountingSessionKey::Pending { fingerprint } = key else { + 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); + !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.clone()); + found = Some(key); } } found @@ -475,14 +668,17 @@ impl AccountingSessionStore { ) -> Option { let event_fingerprint = PendingSessionFingerprint::from_event(event, identities.primary()); let mut found = None; - for (key, session) in &self.sessions { + for key in self.lookup_candidates(&self.fallback_keys_by_lookup, event, identities) { if matches!(key, AccountingSessionKey::Pending { .. }) { continue; } - if !identities.is_empty() && !session_matches_identities(key, session, identities) { + 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); + let session_fingerprint = session_fingerprint(&key, session); if !session_fingerprint .matches_with_nas_context(&event_fingerprint, !identities.is_empty()) { @@ -491,10 +687,78 @@ impl AccountingSessionStore { if found.is_some() { return None; } - found = Some(key.clone()); + 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 { @@ -524,7 +788,7 @@ impl SessionKeyResolution { } /// One retained accounting session. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct AccountingSession { /// Current lifecycle state for the session. pub state: AccountingSessionState, @@ -532,12 +796,131 @@ pub struct AccountingSession { 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 { @@ -565,6 +948,17 @@ impl AccountingSessionKey { } } + /// 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 { @@ -645,7 +1039,6 @@ impl PendingSessionFingerprint { 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, @@ -702,16 +1095,217 @@ pub enum NasResetStatus { 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, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum DynamicCircuitMapping { - /// Subscriber IP, rate, parent, and identity can map to a dynamic circuit. - Ready, + /// 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, +} + +/// 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 { + 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: 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, + }, + } + } +} + +/// 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. @@ -721,14 +1315,22 @@ pub enum PendingSessionReason { 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 decoded rate-limit attribute is present. + /// 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, } /// Result of applying one accounting event to the session store. @@ -749,6 +1351,10 @@ pub enum AccountingSessionUpdate { 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 { @@ -768,6 +1374,21 @@ pub enum AccountingSessionIgnoreReason { 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, @@ -840,6 +1461,109 @@ impl NasIdentitySet { } } +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, @@ -861,6 +1585,43 @@ fn session_matches_identities( 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, @@ -924,45 +1685,404 @@ fn merge_sparse_active_event( 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()); + let has_device_identity = + device_identity_available(key, resolution.matched_shaped_device.as_ref()); + 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>, +) -> bool { + if let Some(device) = matched_shaped_device { + return !device.circuit_id.trim().is_empty(); + } + + key.dynamic_circuit_id().is_some() +} + +fn device_identity_available( + key: &AccountingSessionKey, + matched_shaped_device: Option<&ShapedDevice>, +) -> bool { + if let Some(device) = matched_shaped_device { + return !device.device_id.trim().is_empty(); + } + + key.dynamic_circuit_id().is_some() +} + +fn default_resolved_shaped_device( + key: &AccountingSessionKey, + event: &AccountingEvent, + parent: &DynamicCircuitParent, +) -> Option { + let circuit_id = key.dynamic_circuit_id()?; + 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() + }) +} + +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, + 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(); - if event.acct_session_id.as_ref().is_none_or(String::is_empty) { + let missing_session_id = event.acct_session_id.as_ref().is_none_or(String::is_empty); + if missing_session_id { reasons.push(PendingSessionReason::MissingSessionId); } - if NasIdentitySet::from_event(event).is_empty() { + let missing_nas_identity = NasIdentitySet::from_event(event).is_empty(); + if missing_nas_identity { reasons.push(PendingSessionReason::MissingNasIdentity); } - if !has_subscriber_ip(event) { + 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 event.mikrotik_rate_limits.is_empty() { + 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::Ready => {} - DynamicCircuitMapping::MissingParent => reasons.push(PendingSessionReason::MissingParent), + DynamicCircuitMapping::MatchedShapedDevice + | DynamicCircuitMapping::ReadyWithParent(_) + | DynamicCircuitMapping::MissingParent => {} DynamicCircuitMapping::Ambiguous => reasons.push(PendingSessionReason::AmbiguousMapping), + DynamicCircuitMapping::NoMacMatch => reasons.push(PendingSessionReason::NoMacMatch), + DynamicCircuitMapping::AmbiguousMacMatch => { + reasons.push(PendingSessionReason::AmbiguousMacMatch); + } } reasons } -fn has_subscriber_ip(event: &AccountingEvent) -> bool { - event.framed_ip_address.is_some() - || event.framed_ipv6_address.is_some() - || !event.framed_routes.is_empty() - || !event.framed_ipv6_prefixes.is_empty() - || !event.delegated_ipv6_prefixes.is_empty() +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(); @@ -1020,9 +2140,13 @@ fn merge_known_nas_identity_values( fn merge_active_dynamic_circuit_ids(active_ids: &mut Vec, ids: Vec) { for id in ids { - if !active_ids.contains(&id) { - active_ids.push(id); - } + 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); } } @@ -1036,9 +2160,11 @@ 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, @@ -1052,17 +2178,17 @@ fn emit_active_dynamic_circuit_removals( fn emit_rekeyed_dynamic_circuit_removals( key: &AccountingSessionKey, session: &mut AccountingSession, + current_circuit_id: &str, + counters: &mut RadiusActivationCounters, sink: &mut impl DynamicCircuitCommandSink, ) { - let Some(current_circuit_id) = key.dynamic_circuit_id() else { - return; - }; 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, diff --git a/src/rust/lqos_radius/src/session/tests.rs b/src/rust/lqos_radius/src/session/tests.rs index 7bc35048..54f5a72e 100644 --- a/src/rust/lqos_radius/src/session/tests.rs +++ b/src/rust/lqos_radius/src/session/tests.rs @@ -2,8 +2,10 @@ use super::*; use crate::{ - DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemovalReason, MikrotikRateLimit, + DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemovalReason, Ipv6Prefix, + MikrotikRateLimit, ShapedDevicesMacMatcher, }; +use lqos_config::ShapedDevice; use std::net::{Ipv4Addr, Ipv6Addr}; #[test] @@ -16,7 +18,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { Ipv4Addr::new(198, 51, 100, 10), ); - let update = store.apply_event_with_mapping(start, DynamicCircuitMapping::Ready); + let update = store.apply_event_with_mapping(start, ready_mapping()); let expected_key = nas_session_key("nas-a", "session-1"); assert_eq!( @@ -49,7 +51,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { Ipv4Addr::new(198, 51, 100, 11), ); interim.user_name = Some("updated-subscriber".to_string()); - store.apply_event_with_mapping(interim, DynamicCircuitMapping::Ready); + store.apply_event_with_mapping(interim, ready_mapping()); let refreshed = store.session(&expected_key).unwrap(); assert_eq!(store.len(), 1); @@ -76,14 +78,14 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { Ipv4Addr::new(198, 51, 100, 13), ); sparse_start.class = vec![b"class-data".to_vec()]; - sparse_store.apply_event_with_mapping(sparse_start, DynamicCircuitMapping::Ready); + 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", ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); let sparse_session = sparse_store.session(&sparse_key).unwrap(); assert_eq!( @@ -108,7 +110,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { "session-1", Ipv4Addr::new(198, 51, 100, 12), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); assert_eq!(store.len(), 2); @@ -120,7 +122,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { "session-9", Ipv4Addr::new(198, 51, 100, 19), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); assert_eq!(interim_only_store.len(), 1); assert_eq!( @@ -145,7 +147,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { nas: NasIdentity::Ipv4(nas_ip), acct_session_id: "session-mixed".to_string(), }; - mixed_identity_store.apply_event_with_mapping(start_by_ip, DynamicCircuitMapping::Ready); + mixed_identity_store.apply_event_with_mapping(start_by_ip, ready_mapping()); let mut interim_with_identifier = complete_event( AcctStatusType::InterimUpdate, @@ -154,8 +156,8 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { 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, DynamicCircuitMapping::Ready); + let mixed_update = + mixed_identity_store.apply_event_with_mapping(interim_with_identifier, ready_mapping()); assert_eq!( mixed_update, @@ -190,8 +192,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { ); 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, DynamicCircuitMapping::Ready); + 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(), @@ -203,8 +204,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { 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, DynamicCircuitMapping::Ready); + pending_collision_store.apply_event_with_mapping(pending_collision_interim, ready_mapping()); assert_eq!(pending_collision_store.len(), 2); assert_eq!( pending_collision_store @@ -224,7 +224,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { Ipv4Addr::new(198, 51, 100, 52), ); missing_nas.nas_identifier = None; - pending_to_keyed_store.apply_event_with_mapping(missing_nas, DynamicCircuitMapping::Ready); + 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( @@ -233,7 +233,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { "session-late-nas", Ipv4Addr::new(198, 51, 100, 53), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); assert_eq!(pending_to_keyed_store.len(), 1); assert_eq!( @@ -255,7 +255,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { "session-split", Ipv4Addr::new(198, 51, 100, 54), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); let mut ip_only_interim = complete_event( AcctStatusType::InterimUpdate, @@ -265,7 +265,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { ); 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, DynamicCircuitMapping::Ready); + split_store.apply_event_with_mapping(ip_only_interim, ready_mapping()); assert_eq!(split_store.len(), 2); let mut combined_identity_interim = complete_event( @@ -275,7 +275,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { 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, DynamicCircuitMapping::Ready); + split_store.apply_event_with_mapping(combined_identity_interim, ready_mapping()); assert_eq!(split_store.len(), 1); assert_eq!( split_store @@ -295,13 +295,14 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { "session-extra-alias", Ipv4Addr::new(198, 51, 100, 60), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); let alternate_key = AccountingSessionKey::NasSession { nas: NasIdentity::Ipv4(split_nas_ip), acct_session_id: "session-extra-alias".to_string(), }; - split_with_extra_alias.sessions.insert( + insert_retained_session( + &mut split_with_extra_alias, alternate_key, AccountingSession { state: AccountingSessionState::Active, @@ -316,7 +317,10 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { 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(), }, ); @@ -327,8 +331,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { 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, DynamicCircuitMapping::Ready); + 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), @@ -363,13 +366,17 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { ); rich_alternate_event.nas_identifier = None; rich_alternate_event.nas_ip_address = Some(rich_alternate_ip); - rich_alternate_store.sessions.insert( + 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(), }, ); @@ -379,7 +386,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { "session-rich-merge", ); sparse_bridge.nas_ip_address = Some(rich_alternate_ip); - rich_alternate_store.apply_event_with_mapping(sparse_bridge, DynamicCircuitMapping::Ready); + 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); @@ -396,12 +403,16 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { 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); - ambiguous_retained_store - .sessions - .insert(ambiguous_key_a.clone(), ambiguous_session_a); - ambiguous_retained_store - .sessions - .insert(ambiguous_key_b.clone(), ambiguous_session_b); + 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", @@ -410,8 +421,7 @@ fn start_and_interim_update_create_or_refresh_sessions_by_nas_and_session_id() { ); ambiguous_interim.nas_identifier = None; ambiguous_interim.nas_ip_address = Some(ambiguous_ip); - ambiguous_retained_store - .apply_event_with_mapping(ambiguous_interim, DynamicCircuitMapping::Ready); + ambiguous_retained_store.apply_event_with_mapping(ambiguous_interim, ready_mapping()); assert_eq!(ambiguous_retained_store.len(), 3); assert_eq!( @@ -441,7 +451,7 @@ fn stop_marks_session_stopped_and_duplicate_stop_is_harmless() { "session-1", Ipv4Addr::new(198, 51, 100, 20), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); let stop = minimal_session_event(AcctStatusType::Stop, "nas-a", "session-1"); @@ -461,6 +471,8 @@ fn stop_marks_session_stopped_and_duplicate_stop_is_harmless() { 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 { @@ -499,7 +511,7 @@ fn stop_marks_session_stopped_and_duplicate_stop_is_harmless() { "session-fallback", Ipv4Addr::new(198, 51, 100, 21), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); let fallback_stop = AccountingEvent { status_type: Some(AcctStatusType::Stop), @@ -523,6 +535,13 @@ fn stop_marks_session_stopped_and_duplicate_stop_is_harmless() { fallback_stop_store.session(&fallback_key).unwrap().state, AccountingSessionState::Stopped ); + assert!( + fallback_stop_store + .session(&fallback_key) + .unwrap() + .resolved_shaped_device + .is_none() + ); } #[test] @@ -544,7 +563,7 @@ fn accounting_on_and_off_mark_only_matching_nas_sessions_stale() { session_id, Ipv4Addr::new(198, 51, 100, last_octet), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); } @@ -556,16 +575,35 @@ fn accounting_on_and_off_mark_only_matching_nas_sessions_stale() { 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 @@ -579,6 +617,8 @@ fn accounting_on_and_off_mark_only_matching_nas_sessions_stale() { 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!( @@ -595,7 +635,7 @@ fn accounting_on_and_off_mark_only_matching_nas_sessions_stale() { Ipv4Addr::new(198, 51, 100, 33), ); nas_c_start.nas_ip_address = Some(nas_c_ip); - store.apply_event_with_mapping(nas_c_start, DynamicCircuitMapping::Ready); + store.apply_event_with_mapping(nas_c_start, ready_mapping()); store.apply_event_with_mapping( complete_event( AcctStatusType::InterimUpdate, @@ -603,7 +643,7 @@ fn accounting_on_and_off_mark_only_matching_nas_sessions_stale() { "session-1", Ipv4Addr::new(198, 51, 100, 34), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); let accounting_off_by_old_ip = store.apply_event(AccountingEvent { @@ -618,6 +658,8 @@ fn accounting_on_and_off_mark_only_matching_nas_sessions_stale() { 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!( @@ -671,8 +713,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { mikrotik_rate_limits: vec![rate_limit()], ..AccountingEvent::default() }; - let missing_nas_update = - store.apply_event_with_mapping(missing_nas, DynamicCircuitMapping::Ready); + let missing_nas_update = store.apply_event_with_mapping(missing_nas, ready_mapping()); let AccountingSessionUpdate::SessionUpdated { key: missing_nas_key, .. @@ -707,11 +748,109 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { 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, DynamicCircuitMapping::Ready); + 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.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.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.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.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 { @@ -726,7 +865,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { 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, DynamicCircuitMapping::Ready); + pending_promotion_store.apply_event_with_mapping(missing_id, ready_mapping()); assert_eq!(pending_promotion_store.len(), 1); assert_eq!( pending_promotion_store @@ -750,7 +889,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { "nas-delayed-pending", "delayed-pending-session", ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); assert_eq!(delayed_pending_store.len(), 2); let mut delayed_rich_event = complete_event( @@ -762,8 +901,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { 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, DynamicCircuitMapping::Ready); + delayed_pending_store.apply_event_with_mapping(delayed_rich_event, ready_mapping()); assert_eq!(delayed_pending_store.len(), 1); assert_eq!( delayed_pending_store @@ -789,7 +927,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { ); 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, DynamicCircuitMapping::Ready); + 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, @@ -803,8 +941,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { nas: NasIdentity::Ipv4(delayed_alternate_ip), acct_session_id: "delayed-alternate-session".to_string(), }; - delayed_alternate_store - .apply_event_with_mapping(full_alternate_bridge, DynamicCircuitMapping::Ready); + delayed_alternate_store.apply_event_with_mapping(full_alternate_bridge, ready_mapping()); assert_eq!(delayed_alternate_store.len(), 1); assert_eq!( delayed_alternate_store @@ -835,7 +972,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { ); 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, DynamicCircuitMapping::Ready); + 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, @@ -846,7 +983,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { 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, DynamicCircuitMapping::Ready); + three_way_store.apply_event_with_mapping(three_way_bridge, ready_mapping()); assert_eq!(three_way_store.len(), 1); assert_eq!( @@ -875,7 +1012,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { 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, DynamicCircuitMapping::Ready); + 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()); @@ -898,8 +1035,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { 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, DynamicCircuitMapping::Ready); + 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), @@ -927,7 +1063,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { "unknown-nas-session", Ipv4Addr::new(198, 51, 100, 45), ), - DynamicCircuitMapping::Ready, + ready_mapping(), ); assert_eq!(conflicting_unknown_nas_store.len(), 2); assert!( @@ -969,220 +1105,970 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { } #[test] -fn dynamic_circuit_sink_receives_session_lifecycle_intents() { - let mut store = AccountingSessionStore::new(); - let mut sink = FakeDynamicCircuitSink::default(); - let key = nas_session_key("nas-command", "session-command"); - let circuit_id = key.dynamic_circuit_id().unwrap(); - - store.apply_event_with_mapping_and_commands( - complete_event( - AcctStatusType::Start, - "nas-command", - "session-command", - Ipv4Addr::new(198, 51, 100, 70), - ), - DynamicCircuitMapping::Ready, - &mut sink, - ); - assert_eq!(sink.intents.len(), 1); - assert_eq!(sink.intents[0].circuit_id(), circuit_id); - let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { - panic!("expected create intent, got {:?}", sink.intents[0]); +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), }; - assert_eq!(create.session_key, key); + let mut no_rate_event = minimal_session_event( + AcctStatusType::Start, + "nas-fallback-rate", + "session-fallback-rate", + ); + no_rate_event.framed_ip_address = Some(Ipv4Addr::new(198, 51, 100, 90)); + let key = nas_session_key("nas-fallback-rate", "session-fallback-rate"); - store.apply_event_with_mapping_and_commands( + 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-command", - "session-command", - Ipv4Addr::new(198, 51, 100, 71), + "nas-fallback-rate", + "session-fallback-rate", + Ipv4Addr::new(198, 51, 100, 94), ), - DynamicCircuitMapping::Ready, - &mut sink, + ready_mapping(), + shaped_device_and_fallback, ); - assert_eq!(sink.intents.len(), 2); - assert_eq!(sink.intents[1].circuit_id(), circuit_id); - let DynamicCircuitIntent::UpdateDynamicCircuit(update) = &sink.intents[1] else { - panic!("expected update intent, got {:?}", sink.intents[1]); - }; - assert_eq!(update.session_key, key); assert_eq!( - update.event.framed_ip_address, - Some(Ipv4Addr::new(198, 51, 100, 71)) + 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(), + }) ); - store.apply_event_with_mapping_and_commands( - minimal_session_event(AcctStatusType::Stop, "nas-command", "session-command"), - DynamicCircuitMapping::Ready, - &mut sink, + 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), ); - assert_eq!(sink.intents.len(), 3); - assert_eq!(sink.intents[2].circuit_id(), circuit_id); - let DynamicCircuitIntent::RemoveDynamicCircuit(stop) = &sink.intents[2] else { - panic!("expected stop removal intent, got {:?}", sink.intents[2]); + 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:?}"); }; - assert_eq!(stop.session_key, key); - assert_eq!(stop.reason, DynamicCircuitRemovalReason::Stop); + + let later = minimal_session_event( + AcctStatusType::InterimUpdate, + "nas-later", + "shared-session-id", + ); + 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() - .active_dynamic_circuit_ids - .is_empty() - ); - store.apply_event_with_mapping_and_commands( - minimal_session_event(AcctStatusType::Stop, "nas-command", "session-command"), - DynamicCircuitMapping::Ready, - &mut sink, - ); - assert_eq!(sink.intents.len(), 3); - - let mut expiry_store = AccountingSessionStore::new(); - let mut expiry_sink = FakeDynamicCircuitSink::default(); - let expiry_key = nas_session_key("nas-expiry", "session-expiry"); - let expiry_circuit_id = expiry_key.dynamic_circuit_id().unwrap(); - expiry_store.apply_event_with_mapping_and_commands( - complete_event( - AcctStatusType::Start, - "nas-expiry", - "session-expiry", - Ipv4Addr::new(198, 51, 100, 72), - ), - DynamicCircuitMapping::Ready, - &mut expiry_sink, - ); - assert!( - expiry_store - .expire_session_with_commands(&expiry_key, &mut expiry_sink) + .resolved_shaped_device .is_some() ); - assert_eq!(expiry_sink.intents.len(), 2); - assert_eq!(expiry_sink.intents[1].circuit_id(), expiry_circuit_id); - let DynamicCircuitIntent::RemoveDynamicCircuit(expiry) = &expiry_sink.intents[1] else { - panic!( - "expected expiry removal intent, got {:?}", - expiry_sink.intents[1] - ); - }; - assert_eq!(expiry.session_key, expiry_key); - assert_eq!(expiry.reason, DynamicCircuitRemovalReason::Expired); - let mut unshapeable_store = AccountingSessionStore::new(); - let mut unshapeable_sink = FakeDynamicCircuitSink::default(); - unshapeable_store.apply_event_with_mapping_and_commands( - minimal_session_event( - AcctStatusType::Start, - "nas-pending-command", - "session-pending", - ), - DynamicCircuitMapping::MissingParent, - &mut unshapeable_sink, + let mut interim = complete_event( + AcctStatusType::InterimUpdate, + "nas-cleared-match", + "session-cleared-match", + Ipv4Addr::new(203, 0, 113, 61), ); - assert!(unshapeable_sink.intents.is_empty()); - unshapeable_store.apply_event_with_mapping_and_commands( - minimal_session_event( - AcctStatusType::Stop, - "nas-pending-command", - "session-pending", - ), - DynamicCircuitMapping::Ready, - &mut unshapeable_sink, - ); - assert!(unshapeable_sink.intents.is_empty()); + 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 never_emitted_expiry_key = nas_session_key("nas-never-emitted", "session-expiry"); - unshapeable_store.apply_event_with_mapping_and_commands( - minimal_session_event(AcctStatusType::Start, "nas-never-emitted", "session-expiry"), - DynamicCircuitMapping::MissingParent, - &mut unshapeable_sink, + let session = store.session(&key).unwrap(); + assert_eq!( + session.pending_reasons, + vec![PendingSessionReason::NoMacMatch] ); - assert!( - unshapeable_store - .expire_session_with_commands(&never_emitted_expiry_key, &mut unshapeable_sink) - .is_some() + assert_eq!( + session.latest_event.calling_station_id.as_deref(), + Some("11:22:33:44:55:66") ); - assert!(unshapeable_sink.intents.is_empty()); + assert_eq!( + session.latest_event.framed_ip_address, + Some(Ipv4Addr::new(203, 0, 113, 61)) + ); + assert!(session.resolved_shaped_device.is_none()); } #[test] -fn dynamic_circuit_sink_creates_when_pending_session_becomes_shapeable() { +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 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:session:73657373696f6e2d64656661756c742d6964656e74697479"; + 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.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.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_device = store + .session(&acct_key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap(); + assert_eq!(acct_device.circuit_name, "session-fallback-acct"); + assert_eq!(acct_device.device_name, "session-fallback-acct"); + assert!(acct_device.mac.is_empty()); + assert_eq!(acct_device.ipv4, vec![(Ipv4Addr::new(203, 0, 113, 46), 32)]); + + 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)), + 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:session:732d697034" + ); + 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), + 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:session:732d697036" + ); + 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 mut sink = FakeDynamicCircuitSink::default(); let key = nas_session_key("nas-late-command", "session-late-command"); let circuit_id = key.dynamic_circuit_id().unwrap(); - - store.apply_event_with_mapping_and_commands( - minimal_session_event( - AcctStatusType::Start, - "nas-late-command", - "session-late-command", - ), - DynamicCircuitMapping::MissingParent, - &mut sink, + let mut start_without_ip = minimal_session_event( + AcctStatusType::Start, + "nas-late-command", + "session-late-command", ); - assert!(sink.intents.is_empty()); + start_without_ip.mikrotik_rate_limits = vec![rate_limit()]; - store.apply_event_with_mapping_and_commands( + 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), ), - DynamicCircuitMapping::Ready, - &mut sink, + ready_mapping(), ); - assert_eq!(sink.intents.len(), 1); - assert_eq!(sink.intents[0].circuit_id(), circuit_id); - let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { - panic!("expected create intent, got {:?}", sink.intents[0]); - }; - assert_eq!(create.session_key, key); + let resolved_device = store + .session(&key) + .unwrap() + .resolved_shaped_device + .as_ref() + .unwrap(); + assert_eq!(resolved_device.circuit_id, circuit_id); assert_eq!( - store.session(&key).unwrap().active_dynamic_circuit_ids, - vec![circuit_id] + resolved_device.ipv4, + vec![(Ipv4Addr::new(198, 51, 100, 73), 32)] ); } #[test] -fn dynamic_circuit_sink_removes_when_shapeable_session_becomes_pending() { +fn command_sink_receives_deferred_upsert_and_removal_intents() { let mut store = AccountingSessionStore::new(); - let mut sink = FakeDynamicCircuitSink::default(); - let key = nas_session_key("nas-removal-command", "session-removal-command"); - let circuit_id = key.dynamic_circuit_id().unwrap(); + 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.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::Start, - "nas-removal-command", - "session-removal-command", + AcctStatusType::InterimUpdate, + "nas-command", + "session-command", Ipv4Addr::new(198, 51, 100, 74), ), - DynamicCircuitMapping::Ready, + ready_mapping(), &mut sink, ); - store.apply_event_with_mapping_and_commands( - minimal_session_event( - AcctStatusType::InterimUpdate, - "nas-removal-command", - "session-removal-command", - ), - DynamicCircuitMapping::MissingParent, + + 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 = key.dynamic_circuit_id().unwrap(); + 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); - assert_eq!(sink.intents[1].circuit_id(), circuit_id); let DynamicCircuitIntent::RemoveDynamicCircuit(removal) = &sink.intents[1] else { - panic!( - "expected no-longer-shapeable removal, got {:?}", - sink.intents[1] - ); + panic!("expected removal intent, got {:?}", sink.intents[1]); }; - assert_eq!(removal.session_key, key); + assert_eq!(removal.circuit_id, circuit_id); assert_eq!( removal.reason, DynamicCircuitRemovalReason::NoLongerShapeable @@ -1197,23 +2083,356 @@ fn dynamic_circuit_sink_removes_when_shapeable_session_becomes_pending() { } #[test] -fn dynamic_circuit_sink_removes_rekeyed_promoted_session_ids() { +fn command_sink_preserves_deferred_lifecycle_intent_boundary() { let mut store = AccountingSessionStore::new(); - let mut sink = FakeDynamicCircuitSink::default(); - let key = nas_session_key("nas-rekey-command", "session-rekey-command"); + let mut sink = RecordingCommandSink::default(); + let key = nas_session_key("nas-command-lifecycle", "session-command-lifecycle"); let circuit_id = key.dynamic_circuit_id().unwrap(); store.apply_event_with_mapping_and_commands( complete_event( AcctStatusType::Start, - "nas-rekey-command", - "session-rekey-command", + "nas-command-lifecycle", + "session-command-lifecycle", Ipv4Addr::new(198, 51, 100, 75), ), - DynamicCircuitMapping::Ready, + 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 = expiry_key.dynamic_circuit_id().unwrap(); + 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 = rekey_key.dynamic_circuit_id().unwrap(); + 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), @@ -1224,127 +2443,112 @@ fn dynamic_circuit_sink_removes_rekeyed_promoted_session_ids() { AcctStatusType::Start, "ignored-by-test", "session-rekey-command", - Ipv4Addr::new(198, 51, 100, 76), + Ipv4Addr::new(198, 51, 100, 79), ); alternate_start.nas_identifier = None; alternate_start.nas_ip_address = Some(alternate_nas_ip); - store.apply_event_with_mapping_and_commands( + rekey_store.apply_event_with_mapping_and_commands( alternate_start, - DynamicCircuitMapping::Ready, - &mut sink, + ready_mapping(), + &mut rekey_sink, ); - let mut bridge = complete_event( AcctStatusType::InterimUpdate, "nas-rekey-command", "session-rekey-command", - Ipv4Addr::new(198, 51, 100, 77), + Ipv4Addr::new(198, 51, 100, 80), ); bridge.nas_ip_address = Some(alternate_nas_ip); - store.apply_event_with_mapping_and_commands(bridge, DynamicCircuitMapping::Ready, &mut sink); + rekey_store.apply_event_with_mapping_and_commands(bridge, ready_mapping(), &mut rekey_sink); - assert_eq!(store.len(), 1); - assert_eq!(sink.intents.len(), 4); - assert_eq!(sink.intents[2].circuit_id(), circuit_id); - let DynamicCircuitIntent::UpdateDynamicCircuit(update) = &sink.intents[2] else { - panic!("expected update intent, got {:?}", sink.intents[2]); + 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.session_key, key); - assert_eq!(sink.intents[3].circuit_id(), alternate_circuit_id); - let DynamicCircuitIntent::RemoveDynamicCircuit(removal) = &sink.intents[3] else { - panic!("expected rekeyed removal, got {:?}", sink.intents[3]); + 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!(removal.session_key, key); - assert_eq!(removal.reason, DynamicCircuitRemovalReason::Rekeyed); - assert_eq!( - store.session(&key).unwrap().active_dynamic_circuit_ids, - vec![circuit_id] - ); -} + assert_eq!(rekeyed.circuit_id, alternate_circuit_id); + assert_eq!(rekeyed.session_key, rekey_key); + assert_eq!(rekeyed.reason, DynamicCircuitRemovalReason::Rekeyed); -#[test] -fn dynamic_circuit_sink_removes_active_circuits_on_nas_reset() { - let mut store = AccountingSessionStore::new(); - let mut sink = FakeDynamicCircuitSink::default(); - let key = nas_session_key("nas-reset-command", "session-reset-command"); - let circuit_id = key.dynamic_circuit_id().unwrap(); - let pending_key = nas_session_key("nas-reset-command", "session-reset-pending"); + 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 = reset_key.dynamic_circuit_id().unwrap(); let unrelated_key = nas_session_key("nas-reset-other", "session-reset-other"); let unrelated_circuit_id = unrelated_key.dynamic_circuit_id().unwrap(); - - store.apply_event_with_mapping_and_commands( + reset_store.apply_event_with_mapping_and_commands( complete_event( AcctStatusType::Start, "nas-reset-command", "session-reset-command", - Ipv4Addr::new(198, 51, 100, 78), + Ipv4Addr::new(198, 51, 100, 81), ), - DynamicCircuitMapping::Ready, - &mut sink, + ready_mapping(), + &mut reset_sink, ); - store.apply_event_with_mapping_and_commands( + reset_store.apply_event_with_mapping_and_commands( minimal_session_event( AcctStatusType::Start, "nas-reset-command", "session-reset-pending", ), - DynamicCircuitMapping::MissingParent, - &mut sink, + ready_mapping(), + &mut reset_sink, ); - store.apply_event_with_mapping_and_commands( + reset_store.apply_event_with_mapping_and_commands( complete_event( AcctStatusType::Start, "nas-reset-other", "session-reset-other", - Ipv4Addr::new(198, 51, 100, 79), + Ipv4Addr::new(198, 51, 100, 82), ), - DynamicCircuitMapping::Ready, - &mut sink, + ready_mapping(), + &mut reset_sink, ); - store.apply_event_with_mapping_and_commands( + reset_store.apply_event_with_mapping_and_commands( reset_event(AcctStatusType::AccountingOff, "nas-reset-command"), - DynamicCircuitMapping::Ready, - &mut sink, + ready_mapping(), + &mut reset_sink, ); - assert_eq!(sink.intents.len(), 3); - assert_eq!(sink.intents[2].circuit_id(), circuit_id); - let DynamicCircuitIntent::RemoveDynamicCircuit(removal) = &sink.intents[2] else { - panic!("expected NAS reset removal, got {:?}", sink.intents[2]); - }; - assert_eq!(removal.session_key, key); + assert_eq!(reset_sink.intents.len(), 2); assert_eq!( - removal.reason, + reset_store + .session(&reset_key) + .unwrap() + .active_dynamic_circuit_ids, + vec![reset_circuit_id.clone()] + ); + 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!( - store.session(&key).unwrap().state, - AccountingSessionState::Stale(NasResetStatus::AccountingOff) - ); - assert!( - store - .session(&key) - .unwrap() - .active_dynamic_circuit_ids - .is_empty() - ); - assert_eq!( - store.session(&pending_key).unwrap().state, - AccountingSessionState::Stale(NasResetStatus::AccountingOff) - ); - assert!( - store - .session(&pending_key) - .unwrap() - .active_dynamic_circuit_ids - .is_empty() - ); - assert_eq!( - store.session(&unrelated_key).unwrap().state, - AccountingSessionState::Active - ); - assert_eq!( - store + reset_store .session(&unrelated_key) .unwrap() .active_dynamic_circuit_ids, @@ -1352,17 +2556,287 @@ fn dynamic_circuit_sink_removes_active_circuits_on_nas_reset() { ); } +#[test] +fn command_sink_uses_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, + key.dynamic_circuit_id().unwrap() + ); + 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); +} + +#[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 FakeDynamicCircuitSink { +struct RecordingCommandSink { intents: Vec, } -impl DynamicCircuitCommandSink for FakeDynamicCircuitSink { +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, @@ -1426,12 +2900,153 @@ fn retained_session_with_known_ip( 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(), @@ -1441,3 +3056,13 @@ fn rate_limit() -> MikrotikRateLimit { 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/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index 01633005..ee3ebf41 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -80,3 +80,6 @@ allocative_derive.workspace = true #[target.'cfg(any(target_arch = "x86", target_arch = "x86_64"))'.dependencies] #jemallocator = { workspace = true } futures-util = "0.3.31" + +[dev-dependencies] +tokio = { version = "1", features = [ "full", "test-util" ] } diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index b43919fb..be0bb8dc 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -415,9 +415,12 @@ fn main() -> Result<()> { BusRequest, )>(100); - if let Err(err) = - radius_accounting::start_configured_radius_accounting(radius_accounting_config) - .await + 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}"); } diff --git a/src/rust/lqosd/src/radius_accounting.rs b/src/rust/lqosd/src/radius_accounting.rs index a34f1d0e..1b3b6ad9 100644 --- a/src/rust/lqosd/src/radius_accounting.rs +++ b/src/rust/lqosd/src/radius_accounting.rs @@ -1,29 +1,57 @@ //! lqosd integration for the RADIUS accounting listener. -use lqos_config::{RadiusAccountingClient, RadiusAccountingConfig}; +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, ListenerConfig, RadiusListener, - TrustedClientSource, TrustedRadiusClient, start_listener, + DynamicCircuitIntent, DynamicCircuitMapping, DynamicCircuitParent, DynamicCircuitRemoval, + DynamicCircuitResolution, DynamicCircuitUpsert, ListenerConfig, RadiusActivationDiagnostic, + RadiusListener, RadiusPacketCounters, SessionRateProfile, SessionRateProfileError, + SessionRateSources, ShapedDevicesMacMatcher, TrustedClientSource, TrustedRadiusClient, + start_listener, }; -use std::collections::{HashMap, HashSet}; +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 tracing::{debug, error, info, warn}; +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, binds the configured UDP -/// socket when enabled, and spawns a Tokio task to receive accounting packets. -/// It does not write files, call dynamic-circuit persistence, or touch TC/XDP. +/// 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).await? else { + let Some(runtime_config) = runtime_config_from_config(config, config_snapshot).await? else { return Ok(None); }; @@ -43,11 +71,13 @@ pub(crate) async fn start_configured_radius_accounting( 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); @@ -59,6 +89,31 @@ async fn runtime_config_from_config( 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 mac_matcher = if apply_dynamic_circuits + && config + .dynamic_circuit_application + .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?); @@ -72,9 +127,66 @@ async fn runtime_config_from_config( 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, + 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, @@ -122,25 +234,41 @@ fn trusted_sources( async fn run_radius_accounting_listener( listener: RadiusListener, runtime_config: RadiusAccountingRuntimeConfig, + dynamic_circuit_bus_tx: DynamicCircuitBusSender, ) { - let mut sessions = - RadiusAccountingSessions::new(runtime_config.default_ttl, runtime_config.stale_grace); - let mut command_sink = DeferredDynamicCircuitSink; - let mut expiry_interval = tokio::time::interval(cleanup_interval( + let mut sessions = 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, + ); + 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( - outcome, - &mut sessions, - &mut command_sink, - Instant::now(), - ), + 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}"); } @@ -150,36 +278,134 @@ async fn run_radius_accounting_listener( } } } - _ = expiry_interval.tick() => { - let expired = sessions.expire_due(Instant::now(), &mut command_sink); - if expired > 0 { - debug!(expired, "expired RADIUS accounting session(s)"); - } + _ = expiry_timer.sleep_mut() => { + let now = radius_accounting_now(); + expire_due_after_timer_wake( + &mut sessions, + &mut expiry_timer, + now, + &mut applying_sink, + ); } } } } -fn handle_listener_outcome( +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, - command_sink: &mut impl DynamicCircuitCommandSink, + 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( + handle_accounting_event_with_application_sink_and_expiry_schedule( event, sessions, - command_sink, + expiry_timer, now, - accepted.peer, - accepted.received_len, - accepted.response_len, + 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, @@ -187,6 +413,7 @@ fn handle_listener_outcome( ); } AccountingListenerOutcome::RejectedAmbiguousSource { peer, received_len } => { + sessions.record_packet_rejected(); warn!( peer = %peer, received_len, @@ -198,6 +425,7 @@ fn handle_listener_outcome( received_len, source, } => { + sessions.record_packet_rejected(); warn!( peer = %peer, received_len, @@ -208,7 +436,143 @@ fn handle_listener_outcome( } } +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, @@ -216,8 +580,8 @@ fn handle_accounting_event( peer: SocketAddr, received_len: usize, response_len: usize, -) { - let update = sessions.apply_event(event, now, command_sink); +) -> AccountingSessionUpdate { + let update = sessions.apply_event_with_command_sink(event, now, command_sink); debug!( peer = %peer, received_len, @@ -225,6 +589,7 @@ fn handle_accounting_event( ?update, "accepted RADIUS accounting packet" ); + update } fn listener_error_is_recoverable(err: &lqos_radius::ListenerError) -> bool { @@ -234,58 +599,175 @@ fn listener_error_is_recoverable(err: &lqos_radius::ListenerError) -> bool { 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, } 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) + } + + 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 { 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, } } - fn apply_event( + #[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 previous_stale_keys = previous_stale_keys(&self.store, &event); - let update = self.store.apply_event_with_mapping_and_commands( - event, - DynamicCircuitMapping::MissingParent, - command_sink, - ); - self.record_update(&update, previous_stale_keys.as_ref(), now); + let update = if let Some(mac_matcher) = &self.mac_matcher { + self.store + .apply_event_with_shaped_devices_mac_matcher_and_commands( + event, + mac_matcher, + self.fallback_rate_profile, + 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 record_update( - &mut self, - update: &AccountingSessionUpdate, - previous_stale_keys: Option<&HashSet>, - now: Instant, - ) { + 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.updated_at.insert(key.clone(), now); + self.record_retained_activation_diagnostic(key); } - AccountingSessionUpdate::NasSessionsMarkedStale { reset, .. } => { - let Some(previous_stale_keys) = previous_stale_keys else { - return; - }; - for (key, session) in self.store.sessions() { - if !previous_stale_keys.contains(key) - && session.state == AccountingSessionState::Stale(*reset) - { - self.updated_at.insert(key.clone(), now); + 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); } } } @@ -297,74 +779,1285 @@ impl RadiusAccountingSessions { 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 expire_due( + 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 expired_keys = self + let mut expired_keys = self .store .sessions() .filter_map(|(key, session)| { let updated_at = *self.updated_at.get(key)?; - let ttl = match session.state { - AccountingSessionState::Stale(_) => self.stale_grace, - AccountingSessionState::Active | AccountingSessionState::Stopped => { - self.default_ttl - } - }; - (now.duration_since(updated_at) >= ttl).then(|| key.clone()) + 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 { + for (_, _, key) in expired_keys { self.updated_at.remove(&key); - self.store.expire_session_with_commands(&key, command_sink); + 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 previous_stale_keys( - store: &AccountingSessionStore, - event: &AccountingEvent, -) -> Option> { - if !matches!( - event.status_type, - Some( - lqos_radius::AcctStatusType::AccountingOn | lqos_radius::AcctStatusType::AccountingOff - ) - ) { - return None; + 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() } - Some( - store - .sessions() - .filter(|(_, session)| matches!(session.state, AccountingSessionState::Stale(_))) - .map(|(key, _)| key.clone()) - .collect(), - ) + 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; +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(), - "RADIUS dynamic-circuit intent deferred until daemon-side application is enabled" + 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() @@ -388,6 +2081,10 @@ struct RadiusAccountingRuntimeConfig { clients: Vec, default_ttl: Duration, stale_grace: Duration, + fallback_rate_profile: Option, + fallback_parent: Option, + mac_matcher: Option, + apply_dynamic_circuits: bool, } /// Startup errors for lqosd RADIUS accounting integration. @@ -399,6 +2096,19 @@ pub(crate) enum RadiusAccountingStartupError { /// 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 { @@ -441,17 +2151,30 @@ pub(crate) enum RadiusAccountingStartupError { mod tests { use super::*; use ip_network::IpNetwork; - use lqos_config::{RadiusClientSource, RadiusSharedSecretSource}; - use lqos_radius::{AcctStatusType, MikrotikRateLimit, NasIdentity, PendingSessionReason}; + 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<()> { - assert!(runtime_config_from_config(None).await?.is_none()); + let config_snapshot = Config::default(); assert!( - runtime_config_from_config(Some(RadiusAccountingConfig::default())) + runtime_config_from_config(None, &config_snapshot) + .await? + .is_none() + ); + assert!( + runtime_config_from_config(Some(RadiusAccountingConfig::default()), &config_snapshot) .await? .is_none() ); @@ -461,12 +2184,21 @@ mod tests { #[tokio::test] async fn disabled_start_returns_no_listener_handle() -> anyhow::Result<()> { - assert!(start_configured_radius_accounting(None).await?.is_none()); + let config_snapshot = Config::default(); assert!( - start_configured_radius_accounting(Some(RadiusAccountingConfig::default())) + 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(()) } @@ -477,8 +2209,11 @@ mod tests { 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)).await; + 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"); @@ -502,7 +2237,8 @@ mod tests { let mut config = enabled_config(&secret_path); config.default_ttl_seconds = 321; config.stale_grace_seconds = 45; - let runtime_config = runtime_config_from_config(Some(config)).await; + 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"); @@ -521,13 +2257,149 @@ mod tests { 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 error = match runtime_config_from_config(Some(config)).await { + 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, }; @@ -539,17 +2411,2298 @@ mod tests { 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::AmbiguousMacMatch] + ); + 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 Some(circuit_id) = key.dynamic_circuit_id() else { + anyhow::bail!("test session key should have a dynamic circuit id"); + }; + + 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 key = session_key(); + let circuit_id = key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + + 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 = key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + + 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 key = session_key(); + let circuit_id = key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + 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 = session_key() + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + 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_key = session_key_for("nas-expiry", "session-expiry"); + let expiry_circuit_id = expiry_key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + 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_key = session_key_for("nas-reset", "session-reset"); + let reset_circuit_id = reset_key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + 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 session_key = session_key_for("nas-deadline", "session-deadline"); + let circuit_id = session_key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + + 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 session_key = session_key_for("nas-timer", "session-timer"); + let circuit_id = session_key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + + 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 key = session_key_for("nas-refresh", "session-refresh"); + let circuit_id = key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + + 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 = original_session + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + 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 = key + .dynamic_circuit_id() + .expect("test session key should have a dynamic circuit id"); + 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 Some(circuit_id) = key.dynamic_circuit_id() else { + anyhow::bail!("test session key should have a dynamic circuit id"); + }; + + 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 mut sink = RecordingDynamicCircuitSink::default(); let now = Instant::now(); handle_accounting_event( complete_event(AcctStatusType::Start), &mut sessions, - &mut sink, now, test_listen_addr(), 64, @@ -565,7 +4718,59 @@ mod tests { vec![PendingSessionReason::MissingParent] ); assert_eq!(sessions.updated_at.get(&key), Some(&now)); - assert!(sink.intents.is_empty()); + + 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(()) } @@ -574,28 +4779,17 @@ mod tests { 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 mut sink = RecordingDynamicCircuitSink::default(); let started_at = Instant::now(); - sessions.apply_event(complete_event(AcctStatusType::Start), started_at, &mut sink); - assert_eq!( - sessions.expire_due(started_at + Duration::from_secs(9), &mut sink), - 0 - ); - assert_eq!( - sessions.expire_due(started_at + Duration::from_secs(10), &mut sink), - 1 - ); + 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, - &mut sink, - ); - sessions.apply_event(reset_event(), reset_at, &mut sink); + sessions.apply_event(complete_event(AcctStatusType::Start), restarted_at); + sessions.apply_event(reset_event(), reset_at); assert_eq!( sessions .store @@ -605,38 +4799,42 @@ mod tests { lqos_radius::NasResetStatus::AccountingOff )) ); - assert_eq!( - sessions.expire_due(reset_at + Duration::from_secs(1), &mut sink), - 0 - ); - assert_eq!( - sessions.expire_due(reset_at + Duration::from_secs(2), &mut sink), - 1 - ); + 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())); - assert!(sink.intents.is_empty()); 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 mut sink = RecordingDynamicCircuitSink::default(); 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, &mut sink); + 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, - &mut sink, - ); + 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)); @@ -646,7 +4844,6 @@ mod tests { fn nas_reset_refreshes_only_matching_stale_sessions() { let mut sessions = RadiusAccountingSessions::new(Duration::from_secs(60), Duration::from_secs(2)); - let mut sink = RecordingDynamicCircuitSink::default(); 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); @@ -656,20 +4853,18 @@ mod tests { sessions.apply_event( complete_event_for(AcctStatusType::Start, "nas-a", "session-a"), started_at, - &mut sink, ); - sessions.apply_event(reset_event_for("nas-a"), nas_a_reset_at, &mut sink); + 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, - &mut sink, ); - sessions.apply_event(reset_event_for("nas-b"), nas_b_reset_at, &mut sink); + 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, &mut sink), 1); + assert_eq!(sessions.expire_due(nas_b_reset_at), 1); assert!(sessions.store.session(&nas_a_key).is_none()); } @@ -677,7 +4872,6 @@ mod tests { fn repeated_nas_reset_does_not_refresh_already_stale_sessions() { let mut sessions = RadiusAccountingSessions::new(Duration::from_secs(60), Duration::from_secs(2)); - let mut sink = RecordingDynamicCircuitSink::default(); 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); @@ -687,13 +4881,12 @@ mod tests { sessions.apply_event( complete_event_for(AcctStatusType::Start, "nas-a", "session-a"), started_at, - &mut sink, ); - sessions.apply_event(reset_event_for("nas-a"), first_reset_at, &mut sink); - sessions.apply_event(reset_event_for("nas-a"), repeated_reset_at, &mut sink); + 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, &mut sink), 1); + assert_eq!(sessions.expire_due(stale_deadline), 1); assert!(sessions.store.session(&nas_a_key).is_none()); } @@ -701,7 +4894,6 @@ mod tests { 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 mut sink = RecordingDynamicCircuitSink::default(); 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); @@ -711,13 +4903,11 @@ mod tests { sessions.apply_event( complete_event_for(AcctStatusType::Start, "nas-a", "session-a"), started_at, - &mut sink, ); - sessions.apply_event(reset_event_for("nas-a"), first_reset_at, &mut sink); + 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, - &mut sink, ); assert_eq!(sessions.updated_at.get(&nas_a_key), Some(&first_reset_at)); @@ -730,10 +4920,249 @@ mod tests { lqos_radius::NasResetStatus::AccountingOn )) ); - assert_eq!(sessions.expire_due(stale_deadline, &mut sink), 1); + 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 { @@ -744,12 +5173,25 @@ mod tests { 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( @@ -764,6 +5206,377 @@ mod tests { 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") } @@ -817,14 +5630,27 @@ mod tests { } } - #[derive(Default)] - struct RecordingDynamicCircuitSink { - intents: Vec, - } - - impl DynamicCircuitCommandSink for RecordingDynamicCircuitSink { - fn emit(&mut self, intent: DynamicCircuitIntent) { - self.intents.push(intent); + 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, } } From 645cf55191fd616065e44fea40623d22837346c9 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Fri, 26 Jun 2026 12:05:09 -0500 Subject: [PATCH 04/18] Add RADIUS radclient harness --- .../lqos_radius/test-radclient-harness.sh | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100755 src/rust/lqos_radius/test-radclient-harness.sh 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}" From c6e5dc49667f2c2317377c19fb93b812802a90ae Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Fri, 26 Jun 2026 14:22:23 -0500 Subject: [PATCH 05/18] Add RADIUS accounting config page --- .../src/node_manager/js_build/entrypoints.txt | 1 + .../js_build/src/config/config_helper.js | 1 + .../js_build/src/config_radius_accounting.js | 371 ++++++++++++++++++ .../static2/config_radius_accounting.html | 180 +++++++++ .../lqosd/src/node_manager/static_pages.rs | 1 + 5 files changed, 554 insertions(+) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting.js create mode 100644 src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html 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/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index 077b45fa..534420e0 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 @@ -680,6 +680,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_radius_accounting.js b/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting.js new file mode 100644 index 00000000..f33de9b9 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting.js @@ -0,0 +1,371 @@ +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 (!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.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("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/static2/config_radius_accounting.html b/src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html new file mode 100644 index 00000000..e885dc3f --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html @@ -0,0 +1,180 @@ +
+
+ +
+
+
+
+
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.
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+
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 5ea24fe0..8b09f627 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -67,6 +67,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", From 7322dcd5a3ce5542e44815b05f3da8702410c5c3 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 6 Jul 2026 14:33:41 -0500 Subject: [PATCH 06/18] Fix: WebUI was missing some options. --- src/rust/lqosd/copy_files.sh | 26 ++++-- .../lqosd/src/node_manager/js_build/README.md | 7 +- .../src/node_manager/js_build/esbuild.sh | 89 +++++++++++++++++-- 3 files changed, 102 insertions(+), 20 deletions(-) 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/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/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}" From 49d4a29139e069c8a00221642bd518de66168dee Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Mon, 20 Jul 2026 12:06:44 -0500 Subject: [PATCH 07/18] Log RADIUS accounting status --- src/rust/lqosd/src/radius_accounting.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rust/lqosd/src/radius_accounting.rs b/src/rust/lqosd/src/radius_accounting.rs index 1b3b6ad9..398a0655 100644 --- a/src/rust/lqosd/src/radius_accounting.rs +++ b/src/rust/lqosd/src/radius_accounting.rs @@ -581,11 +581,13 @@ fn handle_accounting_event_with_command_sink( 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" ); From 45976a75bcdf4576119f1599cf7d8eb7db0a5bc3 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 11:33:30 -0500 Subject: [PATCH 08/18] Add RADIUS username circuit matching --- docs-es/v2.0/configuration-advanced-es.md | 8 +- docs/v2.0/configuration-advanced.md | 8 +- src/rust/lqos_config/src/dynamic_status.rs | 1 + .../src/etc/v15/radius_accounting.rs | 32 +++++ .../src/shaped_devices/shaped_device.rs | 55 ++++++++- src/rust/lqos_radius/src/lib.rs | 2 +- src/rust/lqos_radius/src/mac_match.rs | 110 ++++++++++++++---- src/rust/lqos_radius/src/session.rs | 108 ++++++++++++++++- src/rust/lqos_radius/src/session/tests.rs | 102 ++++++++++++++++ src/rust/lqosd/src/radius_accounting.rs | 57 +++++++-- 10 files changed, 445 insertions(+), 38 deletions(-) diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index a7da67c7..1b40971f 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -81,6 +81,7 @@ 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" @@ -103,9 +104,10 @@ Notas: - 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_mac = true` para comparar los valores RADIUS `Calling-Station-Id` con los valores MAC de `ShapedDevices.csv`. 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. Si no hay coincidencia o hay varias filas coincidentes, la sesión queda pendiente. -- El servicio RADIUS carga los metadatos de coincidencia MAC desde `ShapedDevices.csv` cuando inicia. Reinicie `lqosd` después de cambiar campos de MAC, nodo padre, circuito, dispositivo, SQM o velocidad que la coincidencia MAC de RADIUS deba usar. -- Si `radius_accounting.dynamic_circuit_application.enabled = true` pero `match_shaped_devices_by_mac = false`, configure `fallback_parent_node` cuando las identidades RADIUS por defecto deban quedar aptas para shaping. Sin `fallback_parent_node`, las sesiones quedan pendientes por falta de metadatos de nodo padre. +- Configure `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_username = true` para comparar el valor RADIUS `User-Name` con una columna opcional `RADIUS Username` de `ShapedDevices.csv`. Una coincidencia única por nombre de usuario tiene prioridad sobre la coincidencia MAC, lo que permite sesiones PPPoE y DHCP-RADIUS sin requerir una dirección 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 los metadatos de coincidencia por nombre de usuario y MAC desde `ShapedDevices.csv` cuando inicia. Reinicie `lqosd` después de cambiar `RADIUS Username`, MAC, 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 por defecto derivadas de la identidad NAS más `Acct-Session-Id`. Las sesiones con coincidencia MAC conservan los metadatos de nodo padre de la fila coincidente 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`. diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index f567f9b5..c7367aa5 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -214,6 +214,7 @@ 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" @@ -236,9 +237,10 @@ Notes: - 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_mac = true` to match RADIUS `Calling-Station-Id` values against `ShapedDevices.csv` MAC values. 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. No match or multiple matching rows leaves the session pending. -- The RADIUS listener loads MAC-match metadata from `ShapedDevices.csv` when it starts. Restart `lqosd` after changing MAC, parent, circuit, device, SQM, or speed fields that RADIUS MAC matching should use. -- If `radius_accounting.dynamic_circuit_application.enabled = true` but `match_shaped_devices_by_mac = false`, configure `fallback_parent_node` when default RADIUS identities should become shapeable. Without `fallback_parent_node`, sessions remain pending with missing parent metadata. +- Set `radius_accounting.dynamic_circuit_application.match_shaped_devices_by_username = true` to match the RADIUS `User-Name` against an optional `RADIUS Username` column in `ShapedDevices.csv`. A unique username match takes priority over MAC matching, which supports PPPoE and DHCP-RADIUS sessions without requiring a MAC address. +- 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 username and MAC match metadata from `ShapedDevices.csv` when it starts. Restart `lqosd` after changing `RADIUS Username`, MAC, 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 default identities derived from NAS identity plus `Acct-Session-Id`. MAC-matched sessions keep the parent metadata from the matched `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. diff --git a/src/rust/lqos_config/src/dynamic_status.rs b/src/rust/lqos_config/src/dynamic_status.rs index c6631e72..6c920b2e 100644 --- a/src/rust/lqos_config/src/dynamic_status.rs +++ b/src/rust/lqos_config/src/dynamic_status.rs @@ -224,6 +224,7 @@ impl DynamicCircuitRecord { device_name, parent_node, mac: self.mac.clone().unwrap_or_default(), + radius_username: String::new(), ipv4, ipv6, download_min_mbps: self.download_min_mbps, diff --git a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs index a9cc6731..0700deec 100644 --- a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs +++ b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs @@ -277,6 +277,9 @@ pub struct RadiusDynamicCircuitApplicationConfig { /// 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` `RADIUS Username` fields. + #[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, @@ -809,6 +812,35 @@ mod tests { ); } + #[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(); 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 75e4869f..1f1827f7 100644 --- a/src/rust/lqos_config/src/shaped_devices/shaped_device.rs +++ b/src/rust/lqos_config/src/shaped_devices/shaped_device.rs @@ -10,7 +10,7 @@ use tracing::error; /// Represents a row in the `ShapedDevices.csv` file. #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Allocative)] pub struct ShapedDevice { - // 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] + // 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][,RADIUS Username] /// The ID of the circuit to which the device belongs. Circuits are 1:many, /// multiple devices may be in a single circuit. pub circuit_id: String, @@ -43,6 +43,10 @@ pub struct ShapedDevice { /// The device's MAC address, used by imports and RADIUS MAC matching. pub mac: String, + /// Optional RADIUS `User-Name` used to resolve DHCP- and PPP-based sessions. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub radius_username: String, + /// A list of all IPv4 addresses and CIDR subnets associated with the /// device. For example, ("192.168.1.0", 24) is equivalent to /// "192.168.1.0/24" @@ -157,6 +161,9 @@ impl ShapedDevice { "mac" => { layout.insert("mac", idx); } + "radiususername" | "radius_username" | "username" => { + layout.insert("radius_username", idx); + } "ipv4" => { layout.insert("ipv4", idx); } @@ -261,6 +268,7 @@ impl ShapedDevice { value => Some(value.to_string()), }, mac: Self::field(record, &layout, "mac").to_string(), + radius_username: Self::field(record, &layout, "radius_username").to_string(), ipv4: ShapedDevice::parse_ipv4(Self::field(record, &layout, "ipv4")), ipv6: ShapedDevice::parse_ipv6(Self::field(record, &layout, "ipv6")), download_min_mbps: { @@ -587,6 +595,51 @@ mod tests { assert_eq!(device.ipv4.len(), 1); } + #[test] + fn test_header_aware_radius_username_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", + "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", + "RADIUS username fixture", + "pppoe-known", + ]); + + let device = ShapedDevice::from_csv(&record, Some(&headers)) + .expect("RADIUS Username should be accepted as an optional CSV field"); + assert_eq!(device.radius_username, "pppoe-known"); + assert!(device.mac.is_empty()); + } + #[test] fn test_header_aware_id_alias_parsing() { let headers = StringRecord::from(vec![ diff --git a/src/rust/lqos_radius/src/lib.rs b/src/rust/lqos_radius/src/lib.rs index 10a6a8ff..fe9ec5e9 100644 --- a/src/rust/lqos_radius/src/lib.rs +++ b/src/rust/lqos_radius/src/lib.rs @@ -44,5 +44,5 @@ pub use session::{ DynamicCircuitResolution, NasIdentity, NasResetStatus, PendingSessionFingerprint, PendingSessionReason, RadiusActivationCounters, RadiusActivationDiagnostic, RadiusActivationDiagnosticState, RadiusPacketCounters, ResolvedSessionRate, SessionRateProfile, - SessionRateProfileError, SessionRateSource, SessionRateSources, + SessionRateProfileError, SessionRateSource, SessionRateSources, ShapedDevicesMatchOptions, }; diff --git a/src/rust/lqos_radius/src/mac_match.rs b/src/rust/lqos_radius/src/mac_match.rs index 8a6eda72..92f6d4b2 100644 --- a/src/rust/lqos_radius/src/mac_match.rs +++ b/src/rust/lqos_radius/src/mac_match.rs @@ -1,36 +1,48 @@ -//! MAC-address matching against `ShapedDevices.csv` rows. +//! RADIUS identity matching against `ShapedDevices.csv` rows. use crate::AccountingEvent; use lqos_config::ShapedDevice; use std::collections::HashMap; -/// Matches RADIUS `Calling-Station-Id` values to `ShapedDevices.csv` MAC fields. +/// Matches RADIUS usernames and `Calling-Station-Id` values to shaped-device rows. #[derive(Clone, Debug, Default, PartialEq)] pub struct ShapedDevicesMacMatcher { matches_by_mac: HashMap, + matches_by_username: HashMap, } impl ShapedDevicesMacMatcher { - /// Builds a MAC matcher from shaped-device rows. + /// Builds a RADIUS identity matcher from shaped-device rows. /// - /// Rows with empty or invalid MAC values are ignored. More than one row with - /// the same normalized MAC is retained as an ambiguous match. + /// Empty or invalid MAC values are ignored for MAC matching. Empty usernames + /// are ignored for username matching. 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 { - let Some(normalized_mac) = normalize_radius_mac(&device.mac) else { - continue; - }; - matches_by_mac - .entry(normalized_mac) - .and_modify(|entry| *entry = ShapedDevicesMacEntry::Ambiguous) - .or_insert_with(|| ShapedDevicesMacEntry::Unique(Box::new(device.clone()))); + 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.radius_username.trim(); + 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 } + Self { + matches_by_mac, + matches_by_username, + } } /// Matches one accounting event by `Calling-Station-Id`. @@ -38,6 +50,33 @@ impl ShapedDevicesMacMatcher { /// 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() + .map(str::trim) + .filter(|value| !value.is_empty()) + && 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; }; @@ -45,12 +84,20 @@ impl ShapedDevicesMacMatcher { return ShapedDevicesMacMatch::NoMatch; }; - match self.matches_by_mac.get(&normalized_mac) { + 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)) => { - ShapedDevicesMacMatch::Unique(device.clone()) + Some(ShapedDevicesMacMatch::Unique(device.clone())) } - Some(ShapedDevicesMacEntry::Ambiguous) => ShapedDevicesMacMatch::Ambiguous, - None => ShapedDevicesMacMatch::NoMatch, + Some(ShapedDevicesMacEntry::Ambiguous) => Some(ShapedDevicesMacMatch::Ambiguous), + None => None, } } } @@ -61,14 +108,14 @@ enum ShapedDevicesMacEntry { Ambiguous, } -/// Result of matching one RADIUS `Calling-Station-Id` to shaped-device MAC rows. +/// Result of matching one RADIUS identity to shaped-device rows. #[derive(Clone, Debug, PartialEq)] pub enum ShapedDevicesMacMatch { - /// Exactly one shaped-device row matched the normalized MAC address. + /// Exactly one shaped-device row matched the configured identity. Unique(Box), - /// No shaped-device row matched the normalized MAC address. + /// No shaped-device row matched the configured identity. NoMatch, - /// More than one shaped-device row matched the normalized MAC address. + /// More than one shaped-device row matched the configured identity. Ambiguous, } @@ -192,6 +239,27 @@ mod tests { ); } + #[test] + fn username_match_precedes_mac_and_accepts_a_row_without_a_mac() { + let mut username_device = shaped_device("username-circuit", ""); + username_device.radius_username = "pppoe-known".to_string(); + let mut mac_device = shaped_device("mac-circuit", "aa:bb:cc:dd:ee:ff"); + mac_device.radius_username = "another-user".to_string(); + 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"); + } + fn shaped_device(circuit_id: &str, mac: &str) -> ShapedDevice { ShapedDevice { circuit_id: circuit_id.to_string(), diff --git a/src/rust/lqos_radius/src/session.rs b/src/rust/lqos_radius/src/session.rs index a67a7249..66982297 100644 --- a/src/rust/lqos_radius/src/session.rs +++ b/src/rust/lqos_radius/src/session.rs @@ -11,6 +11,19 @@ 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 optional ShapedDevices RADIUS username. + 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 { @@ -160,6 +173,32 @@ impl AccountingSessionStore { }) } + /// 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 @@ -216,6 +255,21 @@ impl AccountingSessionStore { 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, @@ -1146,6 +1200,10 @@ pub enum DynamicCircuitMapping { 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. @@ -1174,6 +1232,18 @@ impl DynamicCircuitResolution { 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) => { @@ -1188,7 +1258,9 @@ impl DynamicCircuitResolution { } } ShapedDevicesMacMatch::NoMatch => Self { - mapping: DynamicCircuitMapping::NoMacMatch, + mapping: fallback_parent + .map(DynamicCircuitMapping::ReadyWithParent) + .unwrap_or(DynamicCircuitMapping::NoMacMatch), rate_sources: SessionRateSources { shaped_device_profile: None, fallback_profile, @@ -1205,6 +1277,30 @@ impl DynamicCircuitResolution { }, } } + + /// 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. @@ -1331,6 +1427,10 @@ pub enum PendingSessionReason { 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. @@ -1985,6 +2085,12 @@ fn pending_reasons( DynamicCircuitMapping::AmbiguousMacMatch => { reasons.push(PendingSessionReason::AmbiguousMacMatch); } + DynamicCircuitMapping::NoIdentityMatch => { + reasons.push(PendingSessionReason::NoIdentityMatch); + } + DynamicCircuitMapping::AmbiguousIdentityMatch => { + reasons.push(PendingSessionReason::AmbiguousIdentityMatch); + } } reasons diff --git a/src/rust/lqos_radius/src/session/tests.rs b/src/rust/lqos_radius/src/session/tests.rs index 54f5a72e..90d24275 100644 --- a/src/rust/lqos_radius/src/session/tests.rs +++ b/src/rust/lqos_radius/src/session/tests.rs @@ -1779,6 +1779,26 @@ fn missing_or_ambiguous_mac_match_stays_pending_without_resolved_shaped_device() 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(); @@ -2595,6 +2615,87 @@ fn command_sink_uses_fallback_identity_payload_when_parent_metadata_exists() { assert_eq!(create.shaped_device.upload_max_mbps, 12.0); } +#[test] +fn username_only_match_creates_the_shaped_devices_dynamic_circuit() { + let mut device = shaped_device("username-circuit", "username-device", ""); + device.radius_username = "pppoe-known".to_string(); + 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 mut first = shaped_device("first-circuit", "first-device", ""); + first.radius_username = "duplicate-user".to_string(); + let mut second = shaped_device("second-circuit", "second-device", ""); + second.radius_username = "duplicate-user".to_string(); + 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(); @@ -3022,6 +3123,7 @@ fn shaped_device(circuit_id: &str, device_id: &str, mac: &str) -> ShapedDevice { parent_node_id: Some("parent-node-id".to_string()), anchor_node_id: Some("anchor-node-id".to_string()), mac: mac.to_string(), + radius_username: String::new(), ipv4: vec![(Ipv4Addr::new(198, 51, 100, 200), 32)], ipv6: vec![("2001:db8:ffff::1".parse().unwrap(), 128)], download_min_mbps: 5.0, diff --git a/src/rust/lqosd/src/radius_accounting.rs b/src/rust/lqosd/src/radius_accounting.rs index 398a0655..0e0e74bc 100644 --- a/src/rust/lqosd/src/radius_accounting.rs +++ b/src/rust/lqosd/src/radius_accounting.rs @@ -11,8 +11,8 @@ use lqos_radius::{ DynamicCircuitIntent, DynamicCircuitMapping, DynamicCircuitParent, DynamicCircuitRemoval, DynamicCircuitResolution, DynamicCircuitUpsert, ListenerConfig, RadiusActivationDiagnostic, RadiusListener, RadiusPacketCounters, SessionRateProfile, SessionRateProfileError, - SessionRateSources, ShapedDevicesMacMatcher, TrustedClientSource, TrustedRadiusClient, - start_listener, + SessionRateSources, ShapedDevicesMacMatcher, ShapedDevicesMatchOptions, TrustedClientSource, + TrustedRadiusClient, start_listener, }; use parking_lot::Mutex; use std::collections::{HashMap, HashSet, VecDeque}; @@ -105,10 +105,14 @@ async fn runtime_config_from_config( } 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 - && config - .dynamic_circuit_application - .match_shaped_devices_by_mac + && (match_shaped_devices_by_username || match_shaped_devices_by_mac) { Some(load_mac_matcher(config_snapshot)?) } else { @@ -130,6 +134,8 @@ async fn runtime_config_from_config( fallback_rate_profile, fallback_parent, mac_matcher, + match_shaped_devices_by_username, + match_shaped_devices_by_mac, apply_dynamic_circuits, })) } @@ -236,12 +242,14 @@ async fn run_radius_accounting_listener( runtime_config: RadiusAccountingRuntimeConfig, dynamic_circuit_bus_tx: DynamicCircuitBusSender, ) { - let mut sessions = RadiusAccountingSessions::new_with_fallback_and_mac_matcher( + 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, @@ -611,6 +619,8 @@ struct RadiusAccountingSessions { fallback_rate_profile: Option, fallback_parent: Option, mac_matcher: Option, + match_shaped_devices_by_username: bool, + match_shaped_devices_by_mac: bool, } impl RadiusAccountingSessions { @@ -619,12 +629,33 @@ impl RadiusAccountingSessions { 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(), @@ -639,6 +670,8 @@ impl RadiusAccountingSessions { fallback_rate_profile, fallback_parent, mac_matcher, + match_shaped_devices_by_username, + match_shaped_devices_by_mac, } } @@ -656,10 +689,15 @@ impl RadiusAccountingSessions { ) -> AccountingSessionUpdate { let update = if let Some(mac_matcher) = &self.mac_matcher { self.store - .apply_event_with_shaped_devices_mac_matcher_and_commands( + .apply_event_with_shaped_devices_matcher_and_commands( event, mac_matcher, - self.fallback_rate_profile, + 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 { @@ -2086,6 +2124,8 @@ struct RadiusAccountingRuntimeConfig { 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, } @@ -5642,6 +5682,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A 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(), + radius_username: String::new(), ipv4: Vec::new(), ipv6: Vec::new(), download_min_mbps: 5.0, From e203bb3a94bb7d27a53a16457e7426b05bc76958 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 12:06:48 -0500 Subject: [PATCH 09/18] Fix stable RADIUS dynamic circuit IDs --- docs-es/v2.0/configuration-advanced-es.md | 2 +- docs/v2.0/configuration-advanced.md | 2 +- src/rust/lqos_radius/src/session.rs | 46 +++++- src/rust/lqos_radius/src/session/tests.rs | 183 +++++++++++++++++++--- 4 files changed, 202 insertions(+), 31 deletions(-) diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index 1b40971f..b1786fdc 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -108,7 +108,7 @@ Notas: - 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 los metadatos de coincidencia por nombre de usuario y MAC desde `ShapedDevices.csv` cuando inicia. Reinicie `lqosd` después de cambiar `RADIUS Username`, MAC, 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 por defecto derivadas de la identidad NAS más `Acct-Session-Id`. Las sesiones con coincidencia MAC conservan los metadatos de nodo padre de la fila coincidente de `ShapedDevices.csv`. +- `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. diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index c7367aa5..f7390e62 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -241,7 +241,7 @@ Notes: - 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 username and MAC match metadata from `ShapedDevices.csv` when it starts. Restart `lqosd` after changing `RADIUS Username`, MAC, 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 default identities derived from NAS identity plus `Acct-Session-Id`. MAC-matched sessions keep the parent metadata from the matched `ShapedDevices.csv` row. +- `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. diff --git a/src/rust/lqos_radius/src/session.rs b/src/rust/lqos_radius/src/session.rs index 66982297..a63dd6ec 100644 --- a/src/rust/lqos_radius/src/session.rs +++ b/src/rust/lqos_radius/src/session.rs @@ -1853,9 +1853,9 @@ fn session_dynamic_metadata( &resolution.mapping, ); let has_circuit_identity = - circuit_identity_available(key, resolution.matched_shaped_device.as_ref()); + 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()); + device_identity_available(key, resolution.matched_shaped_device.as_ref(), event); let pending_reasons = pending_reasons( event, &resolution.mapping, @@ -1956,23 +1956,25 @@ fn parent_from_mapping(mapping: &DynamicCircuitMapping) -> Option, + event: &AccountingEvent, ) -> bool { if let Some(device) = matched_shaped_device { return !device.circuit_id.trim().is_empty(); } - key.dynamic_circuit_id().is_some() + 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(); } - key.dynamic_circuit_id().is_some() + stable_subscriber_circuit_id(key, event).is_some() } fn default_resolved_shaped_device( @@ -1980,7 +1982,7 @@ fn default_resolved_shaped_device( event: &AccountingEvent, parent: &DynamicCircuitParent, ) -> Option { - let circuit_id = key.dynamic_circuit_id()?; + 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), @@ -2002,6 +2004,40 @@ fn default_resolved_shaped_device( }) } +/// 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() + .map(str::trim) + .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(); diff --git a/src/rust/lqos_radius/src/session/tests.rs b/src/rust/lqos_radius/src/session/tests.rs index 90d24275..4b1a2b5b 100644 --- a/src/rust/lqos_radius/src/session/tests.rs +++ b/src/rust/lqos_radius/src/session/tests.rs @@ -745,6 +745,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { ); 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"); @@ -769,6 +770,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { "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, @@ -794,6 +796,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { "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(), @@ -818,6 +821,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { "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"); @@ -832,6 +836,7 @@ fn pending_reasons_are_retained_for_unshapeable_sessions() { "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()]; @@ -1117,6 +1122,7 @@ fn rate_resolution_prefers_packet_then_shaped_devices_then_fallback() { "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"); @@ -1444,6 +1450,10 @@ fn session_id_without_nas_context_does_not_promote_unrelated_pending_session() { "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()); @@ -1811,7 +1821,7 @@ fn fallback_identity_generation_builds_dynamic_shaped_device() { 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:session:73657373696f6e2d64656661756c742d6964656e74697479"; + let circuit_id = "radius:nas-id:6e61732d64656661756c742d6964656e74697479:username:73756273637269626572406578616d706c652e6e6574"; let resolution = DynamicCircuitResolution { mapping: ready_mapping(), rate_sources: SessionRateSources { @@ -1870,6 +1880,7 @@ fn fallback_identity_generation_uses_display_name_fallbacks_and_ipv6_prefixes() "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, @@ -1900,6 +1911,7 @@ fn fallback_identity_generation_uses_display_name_fallbacks_and_ipv6_prefixes() "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( @@ -1907,16 +1919,15 @@ fn fallback_identity_generation_uses_display_name_fallbacks_and_ipv6_prefixes() fallback_resolution(fallback_rate), ); - let acct_device = store - .session(&acct_key) - .unwrap() - .resolved_shaped_device - .as_ref() - .unwrap(); - assert_eq!(acct_device.circuit_name, "session-fallback-acct"); - assert_eq!(acct_device.device_name, "session-fallback-acct"); - assert!(acct_device.mac.is_empty()); - assert_eq!(acct_device.ipv4, vec![(Ipv4Addr::new(203, 0, 113, 46), 32)]); + 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!( @@ -1943,6 +1954,7 @@ fn fallback_identity_generation_uses_nas_ip_key_variants() { 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() }, @@ -1956,7 +1968,7 @@ fn fallback_identity_generation_uses_nas_ip_key_variants() { .unwrap(); assert_eq!( ipv4_device.circuit_id, - "radius:nas-ipv4:c0000209:session:732d697034" + "radius:nas-ipv4:c0000209:username:697076342d73756273637269626572" ); assert_eq!(ipv4_device.device_id, ipv4_device.circuit_id); @@ -1970,6 +1982,7 @@ fn fallback_identity_generation_uses_nas_ip_key_variants() { 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() }, @@ -1983,7 +1996,7 @@ fn fallback_identity_generation_uses_nas_ip_key_variants() { .unwrap(); assert_eq!( ipv6_device.circuit_id, - "radius:nas-ipv6:20010db8000000000000000000000009:session:732d697036" + "radius:nas-ipv6:20010db8000000000000000000000009:username:697076362d73756273637269626572" ); assert_eq!(ipv6_device.device_id, ipv6_device.circuit_id); } @@ -1992,12 +2005,13 @@ fn fallback_identity_generation_uses_nas_ip_key_variants() { 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 = key.dynamic_circuit_id().unwrap(); + 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()); @@ -2038,6 +2052,7 @@ fn command_sink_receives_deferred_upsert_and_removal_intents() { 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); @@ -2058,7 +2073,7 @@ fn command_sink_receives_deferred_upsert_and_removal_intents() { let DynamicCircuitIntent::CreateDynamicCircuit(create) = &sink.intents[0] else { panic!("expected create intent, got {:?}", sink.intents[0]); }; - let circuit_id = key.dynamic_circuit_id().unwrap(); + 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); @@ -2107,7 +2122,7 @@ 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 = key.dynamic_circuit_id().unwrap(); + let circuit_id = subscriber_circuit_id("nas-command-lifecycle"); store.apply_event_with_mapping_and_commands( complete_event( @@ -2412,7 +2427,7 @@ 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 = expiry_key.dynamic_circuit_id().unwrap(); + let expiry_circuit_id = subscriber_circuit_id("nas-expiry-command"); expiry_store.apply_event_with_mapping_and_commands( complete_event( AcctStatusType::Start, @@ -2442,7 +2457,7 @@ fn command_sink_emits_expiry_rekey_and_stale_expiry_removals() { 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 = rekey_key.dynamic_circuit_id().unwrap(); + let rekey_circuit_id = subscriber_circuit_id("nas-rekey-command"); rekey_store.apply_event_with_mapping_and_commands( complete_event( AcctStatusType::Start, @@ -2458,7 +2473,14 @@ fn command_sink_emits_expiry_rekey_and_stale_expiry_removals() { nas: NasIdentity::Ipv4(alternate_nas_ip), acct_session_id: "session-rekey-command".to_string(), }; - let alternate_circuit_id = alternate_key.dynamic_circuit_id().unwrap(); + 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", @@ -2503,9 +2525,9 @@ fn command_sink_emits_expiry_rekey_and_stale_expiry_removals() { 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 = reset_key.dynamic_circuit_id().unwrap(); + 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 = unrelated_key.dynamic_circuit_id().unwrap(); + let unrelated_circuit_id = subscriber_circuit_id("nas-reset-other"); reset_store.apply_event_with_mapping_and_commands( complete_event( AcctStatusType::Start, @@ -2547,7 +2569,7 @@ fn command_sink_emits_expiry_rekey_and_stale_expiry_removals() { .session(&reset_key) .unwrap() .active_dynamic_circuit_ids, - vec![reset_circuit_id.clone()] + vec![reset_circuit_id.to_string()] ); assert!( reset_store @@ -2577,7 +2599,7 @@ fn command_sink_emits_expiry_rekey_and_stale_expiry_removals() { } #[test] -fn command_sink_uses_fallback_identity_payload_when_parent_metadata_exists() { +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(); @@ -2604,7 +2626,7 @@ fn command_sink_uses_fallback_identity_payload_when_parent_metadata_exists() { assert_eq!(create.session_key, key); assert_eq!( create.shaped_device.circuit_id, - key.dynamic_circuit_id().unwrap() + "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"); @@ -2613,6 +2635,110 @@ fn command_sink_uses_fallback_identity_payload_when_parent_metadata_exists() { 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_trims_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_eq!( + stable_subscriber_circuit_id(&key, &canonical), + stable_subscriber_circuit_id(&key, &padded) + ); } #[test] @@ -2968,6 +3094,15 @@ fn minimal_session_event( } } +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), From 341d2fa39a8d8a41e507a05b2c0c25a6db8c55ad Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 12:26:32 -0500 Subject: [PATCH 10/18] Recover stale override locks after PID reuse --- src/rust/lqos_overrides/src/main.rs | 1 + src/rust/lqos_utils/src/process_lock.rs | 69 +++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/rust/lqos_overrides/src/main.rs b/src/rust/lqos_overrides/src/main.rs index fbc2cde2..259987d3 100644 --- a/src/rust/lqos_overrides/src/main.rs +++ b/src/rust/lqos_overrides/src/main.rs @@ -249,6 +249,7 @@ impl AddArgs { parent_node_id: None, anchor_node_id: None, mac: self.mac, + radius_username: String::new(), ipv4, ipv6, download_min_mbps: self.download_min_mbps, diff --git a/src/rust/lqos_utils/src/process_lock.rs b/src/rust/lqos_utils/src/process_lock.rs index cfe8e855..95a2fabf 100644 --- a/src/rust/lqos_utils/src/process_lock.rs +++ b/src/rust/lqos_utils/src/process_lock.rs @@ -172,6 +172,7 @@ struct LockMetadata { process: Option, operation: Option, created_unix: Option, + process_start_ticks: Option, } impl LockMetadata { @@ -184,6 +185,7 @@ impl LockMetadata { .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,15 @@ 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; + } + } + let Some(needle) = process_name_contains else { return true; }; @@ -543,6 +572,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 +617,7 @@ 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_name_matches, process_start_ticks_for_pid, }; use std::{ fs::{create_dir_all, read_to_string, remove_dir_all, write}, @@ -678,6 +713,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 +745,39 @@ 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 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(); From 57268267352fe79bc4d984b7054de5bbfc225e30 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 12:43:07 -0500 Subject: [PATCH 11/18] Fix topology duplicate and stale locks --- src/rust/lqos_topology/src/shaping_inputs.rs | 5 +-- src/rust/lqos_topology/src/tests/group_02.rs | 19 +++++++--- src/rust/lqos_utils/src/process_lock.rs | 37 +++++++++++++++++--- 3 files changed, 50 insertions(+), 11 deletions(-) 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 95a2fabf..77382ad4 100644 --- a/src/rust/lqos_utils/src/process_lock.rs +++ b/src/rust/lqos_utils/src/process_lock.rs @@ -178,7 +178,7 @@ struct LockMetadata { 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() @@ -411,6 +411,17 @@ impl ProcessFileLock { 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 { @@ -617,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_start_ticks_for_pid, + 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}, @@ -763,6 +775,19 @@ mod tests { 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 { @@ -819,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"); @@ -836,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")); From 6416672f7b4d78e6be09b37d86c5dfd1f1c5f564 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 13:13:45 -0500 Subject: [PATCH 12/18] Fix RADIUS MAC identity field --- docs-es/v2.0/configuration-advanced-es.md | 4 +- docs/v2.0/configuration-advanced.md | 4 +- src/rust/lqos_config/src/dynamic_status.rs | 1 - .../src/etc/v15/radius_accounting.rs | 2 +- .../src/shaped_devices/shaped_device.rs | 111 +++++++++++++++--- src/rust/lqos_overrides/src/main.rs | 1 - src/rust/lqos_radius/src/mac_match.rs | 43 +++++-- src/rust/lqos_radius/src/session.rs | 8 +- src/rust/lqos_radius/src/session/tests.rs | 16 +-- .../js_build/src/config_devices.js | 2 +- .../js_build/src/config_radius_accounting.js | 5 + ...config_radius_accounting_contract.test.mjs | 20 ++++ .../js_build/test-node-manager.sh | 1 + .../node_manager/static2/config_devices.html | 7 +- .../static2/config_radius_accounting.html | 8 ++ src/rust/lqosd/src/radius_accounting.rs | 1 - 16 files changed, 177 insertions(+), 57 deletions(-) create mode 100644 src/rust/lqosd/src/node_manager/js_build/src/config_radius_accounting_contract.test.mjs diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index b1786fdc..890e7df0 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -104,9 +104,9 @@ Notas: - 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 una columna opcional `RADIUS Username` de `ShapedDevices.csv`. Una coincidencia única por nombre de usuario tiene prioridad sobre la coincidencia MAC, lo que permite sesiones PPPoE y DHCP-RADIUS sin requerir una dirección MAC. +- 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 los metadatos de coincidencia por nombre de usuario y MAC desde `ShapedDevices.csv` cuando inicia. Reinicie `lqosd` después de cambiar `RADIUS Username`, MAC, nodo padre, circuito, dispositivo, SQM o velocidad que las coincidencias RADIUS deban usar. +- 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. diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index f7390e62..94195e21 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -237,9 +237,9 @@ Notes: - 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 an optional `RADIUS Username` column in `ShapedDevices.csv`. A unique username match takes priority over MAC matching, which supports PPPoE and DHCP-RADIUS sessions without requiring a MAC address. +- 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 username and MAC match metadata from `ShapedDevices.csv` when it starts. Restart `lqosd` after changing `RADIUS Username`, MAC, parent, circuit, device, SQM, or speed fields that RADIUS matching should use. +- 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. diff --git a/src/rust/lqos_config/src/dynamic_status.rs b/src/rust/lqos_config/src/dynamic_status.rs index 6c920b2e..c6631e72 100644 --- a/src/rust/lqos_config/src/dynamic_status.rs +++ b/src/rust/lqos_config/src/dynamic_status.rs @@ -224,7 +224,6 @@ impl DynamicCircuitRecord { device_name, parent_node, mac: self.mac.clone().unwrap_or_default(), - radius_username: String::new(), ipv4, ipv6, download_min_mbps: self.download_min_mbps, diff --git a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs index 0700deec..5067a2bc 100644 --- a/src/rust/lqos_config/src/etc/v15/radius_accounting.rs +++ b/src/rust/lqos_config/src/etc/v15/radius_accounting.rs @@ -277,7 +277,7 @@ pub struct RadiusDynamicCircuitApplicationConfig { /// 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` `RADIUS Username` fields. + /// 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. 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 1f1827f7..353a669a 100644 --- a/src/rust/lqos_config/src/shaped_devices/shaped_device.rs +++ b/src/rust/lqos_config/src/shaped_devices/shaped_device.rs @@ -10,7 +10,7 @@ use tracing::error; /// Represents a row in the `ShapedDevices.csv` file. #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Allocative)] pub struct ShapedDevice { - // 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][,RADIUS Username] + // 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] /// The ID of the circuit to which the device belongs. Circuits are 1:many, /// multiple devices may be in a single circuit. pub circuit_id: String, @@ -40,13 +40,9 @@ pub struct ShapedDevice { #[serde(skip_serializing_if = "Option::is_none")] pub anchor_node_id: Option, - /// The device's MAC address, used by imports and RADIUS MAC matching. + /// The device's MAC address or RADIUS username, used by imports and RADIUS identity matching. pub mac: String, - /// Optional RADIUS `User-Name` used to resolve DHCP- and PPP-based sessions. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub radius_username: String, - /// A list of all IPv4 addresses and CIDR subnets associated with the /// device. For example, ("192.168.1.0", 24) is equivalent to /// "192.168.1.0/24" @@ -162,7 +158,7 @@ impl ShapedDevice { layout.insert("mac", idx); } "radiususername" | "radius_username" | "username" => { - layout.insert("radius_username", idx); + layout.insert("legacy_radius_username", idx); } "ipv4" => { layout.insert("ipv4", idx); @@ -267,8 +263,14 @@ impl ShapedDevice { "" => None, value => Some(value.to_string()), }, - mac: Self::field(record, &layout, "mac").to_string(), - radius_username: Self::field(record, &layout, "radius_username").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: { @@ -596,7 +598,7 @@ mod tests { } #[test] - fn test_header_aware_radius_username_parsing() { + fn test_header_aware_username_in_mac_parsing() { let headers = StringRecord::from(vec![ "Circuit ID", "Circuit Name", @@ -613,6 +615,46 @@ mod tests { "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![ @@ -622,22 +664,59 @@ mod tests { "RADIUS device", "Tower-A", "", - "", - "", "192.0.2.10", "", "10", "10", "100", "100", - "RADIUS username fixture", + "legacy RADIUS username fixture", "pppoe-known", ]); let device = ShapedDevice::from_csv(&record, Some(&headers)) - .expect("RADIUS Username should be accepted as an optional CSV field"); - assert_eq!(device.radius_username, "pppoe-known"); - assert!(device.mac.is_empty()); + .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] diff --git a/src/rust/lqos_overrides/src/main.rs b/src/rust/lqos_overrides/src/main.rs index 259987d3..fbc2cde2 100644 --- a/src/rust/lqos_overrides/src/main.rs +++ b/src/rust/lqos_overrides/src/main.rs @@ -249,7 +249,6 @@ impl AddArgs { parent_node_id: None, anchor_node_id: None, mac: self.mac, - radius_username: String::new(), ipv4, ipv6, download_min_mbps: self.download_min_mbps, diff --git a/src/rust/lqos_radius/src/mac_match.rs b/src/rust/lqos_radius/src/mac_match.rs index 92f6d4b2..e7970f20 100644 --- a/src/rust/lqos_radius/src/mac_match.rs +++ b/src/rust/lqos_radius/src/mac_match.rs @@ -1,10 +1,10 @@ -//! RADIUS identity matching against `ShapedDevices.csv` rows. +//! RADIUS identity matching against `ShapedDevices.csv` MAC fields. use crate::AccountingEvent; use lqos_config::ShapedDevice; use std::collections::HashMap; -/// Matches RADIUS usernames and `Calling-Station-Id` values to shaped-device rows. +/// Matches RADIUS identities against shaped-device MAC fields. #[derive(Clone, Debug, Default, PartialEq)] pub struct ShapedDevicesMacMatcher { matches_by_mac: HashMap, @@ -14,9 +14,10 @@ pub struct ShapedDevicesMacMatcher { impl ShapedDevicesMacMatcher { /// Builds a RADIUS identity matcher from shaped-device rows. /// - /// Empty or invalid MAC values are ignored for MAC matching. Empty usernames - /// are ignored for username matching. Duplicate identity values are retained - /// as ambiguous matches. + /// 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] @@ -30,7 +31,7 @@ impl ShapedDevicesMacMatcher { .and_modify(|entry| *entry = ShapedDevicesMacEntry::Ambiguous) .or_insert_with(|| ShapedDevicesMacEntry::Unique(Box::new(device.clone()))); } - let username = device.radius_username.trim(); + let username = &device.mac; if !username.is_empty() { matches_by_username .entry(username.to_string()) @@ -68,8 +69,6 @@ impl ShapedDevicesMacMatcher { && let Some(username) = event .user_name .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) && let Some(result) = Self::match_entry(&self.matches_by_username, username) { return result; @@ -240,11 +239,9 @@ mod tests { } #[test] - fn username_match_precedes_mac_and_accepts_a_row_without_a_mac() { - let mut username_device = shaped_device("username-circuit", ""); - username_device.radius_username = "pppoe-known".to_string(); - let mut mac_device = shaped_device("mac-circuit", "aa:bb:cc:dd:ee:ff"); - mac_device.radius_username = "another-user".to_string(); + 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()), @@ -258,6 +255,26 @@ mod tests { 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 { diff --git a/src/rust/lqos_radius/src/session.rs b/src/rust/lqos_radius/src/session.rs index a63dd6ec..2e5f31ff 100644 --- a/src/rust/lqos_radius/src/session.rs +++ b/src/rust/lqos_radius/src/session.rs @@ -14,7 +14,7 @@ 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 optional ShapedDevices RADIUS username. + /// 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, @@ -2017,11 +2017,7 @@ fn stable_subscriber_circuit_id( event: &AccountingEvent, ) -> Option { let nas = key.nas()?.circuit_id_component(); - if let Some(username) = event - .user_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) + if let Some(username) = event.user_name.as_deref().filter(|value| !value.is_empty()) { return Some(format!( "radius:{nas}:username:{}", diff --git a/src/rust/lqos_radius/src/session/tests.rs b/src/rust/lqos_radius/src/session/tests.rs index 4b1a2b5b..207a7010 100644 --- a/src/rust/lqos_radius/src/session/tests.rs +++ b/src/rust/lqos_radius/src/session/tests.rs @@ -2724,7 +2724,7 @@ fn calling_station_identity_is_stable_across_sessions_and_scoped_to_the_nas() { } #[test] -fn username_identity_trims_radius_whitespace() { +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()), @@ -2735,16 +2735,15 @@ fn username_identity_trims_radius_whitespace() { ..AccountingEvent::default() }; - assert_eq!( + assert_ne!( stable_subscriber_circuit_id(&key, &canonical), stable_subscriber_circuit_id(&key, &padded) ); } #[test] -fn username_only_match_creates_the_shaped_devices_dynamic_circuit() { - let mut device = shaped_device("username-circuit", "username-device", ""); - device.radius_username = "pppoe-known".to_string(); +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(); @@ -2780,10 +2779,8 @@ fn username_only_match_creates_the_shaped_devices_dynamic_circuit() { #[test] fn duplicate_username_rows_remain_pending_with_an_identity_diagnostic() { - let mut first = shaped_device("first-circuit", "first-device", ""); - first.radius_username = "duplicate-user".to_string(); - let mut second = shaped_device("second-circuit", "second-device", ""); - second.radius_username = "duplicate-user".to_string(); + 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(); @@ -3258,7 +3255,6 @@ fn shaped_device(circuit_id: &str, device_id: &str, mac: &str) -> ShapedDevice { parent_node_id: Some("parent-node-id".to_string()), anchor_node_id: Some("anchor-node-id".to_string()), mac: mac.to_string(), - radius_username: String::new(), ipv4: vec![(Ipv4Addr::new(198, 51, 100, 200), 32)], ipv6: vec![("2001:db8:ffff::1".parse().unwrap(), 128)], download_min_mbps: 5.0, 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 index f33de9b9..3536b35c 100644 --- 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 @@ -43,6 +43,9 @@ function ensureRadiusConfig(config) { 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 = []; } @@ -208,6 +211,7 @@ function updateConfigFromUi() { 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); @@ -330,6 +334,7 @@ function loadUiFromConfig() { 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 ?? ""; 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 532be479..25506654 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 @@ -16,6 +16,7 @@ node --test \ "${SCRIPT_DIR}/src/tree_limit_reason.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/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 index e885dc3f..decc9116 100644 --- a/src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html +++ b/src/rust/lqosd/src/node_manager/static2/config_radius_accounting.html @@ -74,6 +74,14 @@
+
+
+ + +
Uses User-Name to match the device MAC field verbatim.
+
+
+
diff --git a/src/rust/lqosd/src/radius_accounting.rs b/src/rust/lqosd/src/radius_accounting.rs index 0e0e74bc..65074383 100644 --- a/src/rust/lqosd/src/radius_accounting.rs +++ b/src/rust/lqosd/src/radius_accounting.rs @@ -5682,7 +5682,6 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A 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(), - radius_username: String::new(), ipv4: Vec::new(), ipv6: Vec::new(), download_min_mbps: 5.0, From 998730283039df500a4576f317b01fe0c63d0de2 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 13:39:07 -0500 Subject: [PATCH 13/18] Add portable RADIUS lifecycle harness --- radius-harness/.gitignore | 3 + radius-harness/README.md | 78 ++++++++++ radius-harness/lab | 138 ++++++++++++++++++ radius-harness/lab.env | 18 +++ radius-harness/lib/common.sh | 68 +++++++++ radius-harness/lib/configure.sh | 60 ++++++++ radius-harness/lib/test-lifecycle.sh | 59 ++++++++ radius-harness/templates/ShapedDevices.csv | 2 + radius-harness/templates/client-network.yaml | 4 + .../templates/freeradius-client.conf | 4 + radius-harness/templates/freeradius-users | 12 ++ radius-harness/templates/lqos-network.yaml | 5 + radius-harness/templates/lqos.conf | 106 ++++++++++++++ radius-harness/templates/network-access.xml | 5 + radius-harness/templates/network-control.xml | 5 + radius-harness/templates/network-transit.xml | 5 + radius-harness/templates/network.json | 16 ++ radius-harness/templates/radius-network.yaml | 4 + radius-harness/templates/routeros.rsc | 8 + radius-harness/templates/user-data.yaml | 12 ++ 20 files changed, 612 insertions(+) create mode 100644 radius-harness/.gitignore create mode 100644 radius-harness/README.md create mode 100755 radius-harness/lab create mode 100644 radius-harness/lab.env create mode 100755 radius-harness/lib/common.sh create mode 100755 radius-harness/lib/configure.sh create mode 100755 radius-harness/lib/test-lifecycle.sh create mode 100644 radius-harness/templates/ShapedDevices.csv create mode 100644 radius-harness/templates/client-network.yaml create mode 100644 radius-harness/templates/freeradius-client.conf create mode 100644 radius-harness/templates/freeradius-users create mode 100644 radius-harness/templates/lqos-network.yaml create mode 100644 radius-harness/templates/lqos.conf create mode 100644 radius-harness/templates/network-access.xml create mode 100644 radius-harness/templates/network-control.xml create mode 100644 radius-harness/templates/network-transit.xml create mode 100644 radius-harness/templates/network.json create mode 100644 radius-harness/templates/radius-network.yaml create mode 100644 radius-harness/templates/routeros.rsc create mode 100644 radius-harness/templates/user-data.yaml 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 From 87e83d2cc4cc15516023f41a5e3cb992468cfc01 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 13:55:10 -0500 Subject: [PATCH 14/18] Document RADIUS dynamic circuits --- docs-es/v2.0/configuration-advanced-es.md | 3 + docs-es/v2.0/radius-es.md | 191 ++++++++++++++++++++++ docs/v2.0/configuration-advanced.md | 3 + docs/v2.0/radius.md | 184 +++++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 docs-es/v2.0/radius-es.md create mode 100644 docs/v2.0/radius.md diff --git a/docs-es/v2.0/configuration-advanced-es.md b/docs-es/v2.0/configuration-advanced-es.md index 890e7df0..f538e847 100644 --- a/docs-es/v2.0/configuration-advanced-es.md +++ b/docs-es/v2.0/configuration-advanced-es.md @@ -66,6 +66,9 @@ do_not_track_subnets = ["192.168.0.0/16"] 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 diff --git a/docs-es/v2.0/radius-es.md b/docs-es/v2.0/radius-es.md new file mode 100644 index 00000000..6b4dc793 --- /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 94195e21..56edbb1c 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -199,6 +199,9 @@ Notes: 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 diff --git a/docs/v2.0/radius.md b/docs/v2.0/radius.md new file mode 100644 index 00000000..669fdb1f --- /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. From 8ed78852affe77cd9f9daafc531eb999ed08bd6b Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 13:57:33 -0500 Subject: [PATCH 15/18] Render RADIUS diagrams in Read the Docs --- docs-es/conf.py | 2 +- docs-es/requirements.txt | 3 ++- docs-es/v2.0/radius-es.md | 6 +++--- docs/v2.0/radius.md | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) 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/radius-es.md b/docs-es/v2.0/radius-es.md index 6b4dc793..0757e671 100644 --- a/docs-es/v2.0/radius-es.md +++ b/docs-es/v2.0/radius-es.md @@ -18,7 +18,7 @@ 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 +```{mermaid} flowchart LR CPE[CPE del cliente] --> ACCESS[Red de acceso] ACCESS --> LQOS[Shaper LibreQoS en línea] @@ -31,7 +31,7 @@ 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 +```{mermaid} sequenceDiagram participant CPE as CPE del abonado participant BNG as BNG / NAS @@ -154,7 +154,7 @@ 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 +```{mermaid} flowchart LR CPE[CPE del abonado] -->|PPPoE| BNG[BNG MikroTik] BNG -->|UDP 1812/1813| PROXY[Proxy RADIUS] diff --git a/docs/v2.0/radius.md b/docs/v2.0/radius.md index 669fdb1f..f3adeb91 100644 --- a/docs/v2.0/radius.md +++ b/docs/v2.0/radius.md @@ -16,7 +16,7 @@ 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 +```{mermaid} flowchart LR CPE[Customer CPE] --> ACCESS[Access network] ACCESS --> LQOS[LibreQoS inline shaper] @@ -29,7 +29,7 @@ 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 +```{mermaid} sequenceDiagram participant CPE as Subscriber CPE participant BNG as BNG / NAS @@ -149,7 +149,7 @@ 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 +```{mermaid} flowchart LR CPE[Subscriber CPE] -->|PPPoE| BNG[MikroTik BNG] BNG -->|UDP 1812/1813| PROXY[RADIUS proxy] From 2de0e0ad002cab5998222833c5c45d9a8c6fd756 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 14:05:11 -0500 Subject: [PATCH 16/18] Remove RADIUS implementation PRD --- prd.json | 108 ------------------------------------------------------- 1 file changed, 108 deletions(-) delete mode 100644 prd.json diff --git a/prd.json b/prd.json deleted file mode 100644 index 8357e860..00000000 --- a/prd.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "product": "LibreQoS", - "branchName": "radius-accounting-dynamic-circuits", - "summary": "Extend the opt-in RADIUS accounting listener from session tracking to dynamic circuit application, with fallback speeds and MAC-based ShapedDevices matching for operator-managed account plans.", - "stories": [ - { - "id": "RADIUS-010", - "title": "Gate RADIUS dynamic circuit application", - "description": "As a LibreQoS operator, I want RADIUS dynamic circuit application to have its own explicit activation settings, so that I can receive accounting packets without changing shaping until I choose to enable it.", - "priority": 1, - "passes": true, - "acceptanceCriteria": [ - "RADIUS accounting continues to listen and track sessions when dynamic circuit application is disabled.", - "RADIUS-created dynamic circuits are not applied unless both RADIUS application settings and the existing dynamic_circuits feature are enabled.", - "Invalid activation settings are rejected during configuration validation before listener startup.", - "Config round-trip tests cover disabled application, enabled application, missing dynamic_circuits dependency, and valid activation settings." - ] - }, - { - "id": "RADIUS-011", - "title": "Configure fallback RADIUS speeds", - "description": "As a LibreQoS operator, I want an optional default speed profile for RADIUS-created circuits, so that sessions without packet-provided or lookup-provided rates can still be shaped at a safe default plan.", - "priority": 1, - "passes": true, - "acceptanceCriteria": [ - "RADIUS accounting configuration can define download_min_mbps, upload_min_mbps, download_max_mbps, and upload_max_mbps as an optional fallback profile.", - "Validation rejects non-finite rates, rates less than or equal to zero, and minimum rates greater than maximum rates.", - "When no packet or lookup rate exists and no fallback profile is configured, the session remains pending with a missing-rate reason.", - "When a fallback profile is configured, it is used only after higher-priority rate sources fail to resolve a rate." - ] - }, - { - "id": "RADIUS-012", - "title": "Match ShapedDevices by RADIUS MAC", - "description": "As a LibreQoS operator, I want RADIUS accounting sessions to optionally match ShapedDevices entries by MAC address, so that my existing account-plan sync can provide circuit metadata while RADIUS supplies the active IP addresses.", - "priority": 1, - "passes": true, - "acceptanceCriteria": [ - "A configuration option enables or disables matching RADIUS Calling-Station-Id values against ShapedDevices MAC values.", - "MAC matching normalizes common colon, hyphen, dot, plain-hex, and mixed-case formats before comparison.", - "A unique MAC match supplies circuit ID, circuit name, device ID, device name, parent fields, speed fields, and SQM override metadata for the dynamic circuit.", - "RADIUS-provided IPv4 and IPv6 address data replaces the matched ShapedDevices address fields for the active dynamic circuit.", - "No match or more than one matching ShapedDevices row leaves the session pending and emits no dynamic circuit." - ] - }, - { - "id": "RADIUS-013", - "title": "Resolve RADIUS sessions into shaped devices", - "description": "As a LibreQoS operator, I want shapeable RADIUS sessions converted into valid dynamic circuit device definitions, so that active subscriber addresses can be attached to the correct circuit and plan.", - "priority": 1, - "passes": true, - "acceptanceCriteria": [ - "A session becomes shapeable only when it has a stable circuit identity, device identity, at least one IP address or prefix, parent attachment metadata, and resolved rates.", - "When no ShapedDevices MAC match supplies identity, the default circuit ID is derived from NAS identity and Acct-Session-Id, and display names prefer User-Name and Calling-Station-Id when present.", - "Rate resolution uses decoded RADIUS rate data before ShapedDevices MAC-match rates, and uses the configured fallback profile only when both are missing.", - "Missing IP, missing parent, missing rate, and ambiguous MAC match cases remain pending with distinct reasons.", - "Unit tests cover Start without IP, Interim activation with IP, matched ShapedDevices metadata with RADIUS IP replacement, and fallback identity generation." - ] - }, - { - "id": "RADIUS-014", - "title": "Apply RADIUS dynamic circuit upserts", - "description": "As a LibreQoS operator, I want accepted RADIUS Start and Interim-Update packets to create or update dynamic circuits through the existing daemon path, so that active sessions are shaped without editing ShapedDevices.csv.", - "priority": 1, - "passes": true, - "acceptanceCriteria": [ - "Shapeable Start and Interim-Update events submit CreateDynamicCircuit requests with the resolved ShapedDevice payload.", - "RADIUS dynamic circuit application uses the existing lqosd dynamic circuit path and never writes dynamic_circuits.json directly from lqos_radius.", - "The UDP Accounting-Response path does not wait for dynamic circuit persistence or bakery overlay work to finish.", - "Create or update failures are logged with circuit and session identity and do not stop the RADIUS listener.", - "Tests use fake command or bus sinks and do not require live shaping, bakery, TC, XDP, or root privileges." - ] - }, - { - "id": "RADIUS-015", - "title": "Expire RADIUS dynamic circuits", - "description": "As a LibreQoS operator, I want RADIUS-created dynamic circuits removed when accounting sessions stop or expire, so that stale subscriber IPs do not remain shaped after the session ends.", - "priority": 1, - "passes": true, - "acceptanceCriteria": [ - "Stop events submit RemoveDynamicCircuit for the active circuit ID, and duplicate Stop events are harmless.", - "Sessions that exceed default_ttl_seconds without refresh remove any active RADIUS-created dynamic circuit.", - "Accounting-On and Accounting-Off events mark matching NAS sessions stale and remove active circuits no later than stale_grace_seconds unless refreshed by Start or Interim-Update.", - "Removal uses the same circuit ID that was used for creation, including IDs supplied by ShapedDevices MAC matching.", - "Fake-time tests cover Stop removal, TTL expiry removal, stale NAS reset expiry, and refresh before expiry." - ] - }, - { - "id": "RADIUS-016", - "title": "Report RADIUS activation outcomes", - "description": "As a LibreQoS operator, I want clear RADIUS dynamic circuit diagnostics, so that I can see why a session was shaped, pending, expired, or rejected without exposing shared secrets.", - "priority": 2, - "passes": true, - "acceptanceCriteria": [ - "Diagnostics distinguish active, pending, stopped, stale, expired, and apply-failed RADIUS session states.", - "Pending diagnostics include missing IP, missing parent, missing rate, missing session identity, no MAC match, and ambiguous MAC match reasons.", - "Create, update, remove, and expiry counters are tracked separately from packet accept and reject counters.", - "Logs and diagnostics include circuit/session identifiers needed for troubleshooting but never include shared secret contents." - ] - } - ], - "notes": [ - "This PRD starts after the completed listener, packet parsing, session tracking, and deferred command-intent work from RADIUS-001 through RADIUS-009.", - "RADIUS-provided IP and prefix data is authoritative for active dynamic circuit addresses; ShapedDevices MAC matches supply account-plan and topology metadata only.", - "Dynamic circuit application must use the existing lqosd dynamic circuit path and must continue to respect the existing dynamic_circuits.enabled safety gate.", - "Automated tests for this PRD should use fake sinks, fake time, and loopback-only packet handling; they must not require root privileges or live TC/XDP shaping state." - ] -} From 12609a18c5fbe0ab7c2afcbb79cdd77d4c2c19d0 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 14:17:08 -0500 Subject: [PATCH 17/18] Fix NetworkJsonNode test fixture --- src/rust/lqosd/src/shaped_devices_tracker/mod.rs | 1 + 1 file changed, 1 insertion(+) 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(), From 68725364f9abf75670d8dbff3f3e170d9cbc64e0 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 15:18:55 -0500 Subject: [PATCH 18/18] Update RADIUS accounting tests for stable IDs --- src/rust/lqosd/src/radius_accounting.rs | 72 +++++++++---------------- 1 file changed, 25 insertions(+), 47 deletions(-) diff --git a/src/rust/lqosd/src/radius_accounting.rs b/src/rust/lqosd/src/radius_accounting.rs index 65074383..6070b7e9 100644 --- a/src/rust/lqosd/src/radius_accounting.rs +++ b/src/rust/lqosd/src/radius_accounting.rs @@ -2805,7 +2805,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A .expect("ambiguous MAC match should retain the RADIUS session"); assert_eq!( session.pending_reasons, - vec![PendingSessionReason::AmbiguousMacMatch] + vec![PendingSessionReason::AmbiguousIdentityMatch] ); assert!(session.resolved_shaped_device.is_none()); @@ -3066,9 +3066,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A ); let mut sink = RecordingDynamicCircuitSink::default(); let key = session_key(); - let Some(circuit_id) = key.dynamic_circuit_id() else { - anyhow::bail!("test session key should have a dynamic circuit id"); - }; + let circuit_id = expected_circuit_id("nas-adapter"); handle_accounting_event_with_command_sink( complete_event(AcctStatusType::Start), @@ -3137,10 +3135,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A ); let (bus_tx, mut bus_rx) = mpsc::channel(4); let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); - let key = session_key(); - let circuit_id = key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-adapter"); handle_accounting_event_with_command_sink( complete_event(AcctStatusType::Start), @@ -3199,9 +3194,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A let (bus_tx, mut bus_rx) = mpsc::channel(4); let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); let key = session_key(); - let circuit_id = key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-adapter"); handle_accounting_event_with_command_sink( complete_event(AcctStatusType::Start), @@ -3293,10 +3286,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A ); let (bus_tx, mut bus_rx) = mpsc::channel(4); let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); - let key = session_key(); - let circuit_id = key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-adapter"); let started_at = Instant::now(); handle_accounting_event_with_command_sink( @@ -3343,9 +3333,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A Some(fallback_parent.clone()), None, ); - let stop_circuit_id = session_key() - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let stop_circuit_id = expected_circuit_id("nas-adapter"); let started_at = Instant::now(); handle_accounting_event_with_command_sink( complete_event(AcctStatusType::Start), @@ -3387,10 +3375,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A Some(fallback_parent.clone()), None, ); - let expiry_key = session_key_for("nas-expiry", "session-expiry"); - let expiry_circuit_id = expiry_key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + 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, @@ -3420,10 +3405,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A Some(fallback_parent), None, ); - let reset_key = session_key_for("nas-reset", "session-reset"); - let reset_circuit_id = reset_key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + 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, @@ -3473,10 +3455,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A sessions, Some(sink), )); - let session_key = session_key_for("nas-deadline", "session-deadline"); - let circuit_id = session_key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-deadline"); send_test_accounting_event( &event_tx, @@ -3539,10 +3518,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A sessions, Some(sink), )); - let session_key = session_key_for("nas-timer", "session-timer"); - let circuit_id = session_key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-timer"); send_test_accounting_event( &event_tx, @@ -3579,10 +3555,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A let started_at = Instant::now(); let reset_at = started_at + Duration::from_secs(1); let refreshed_at = started_at + Duration::from_secs(2); - let key = session_key_for("nas-refresh", "session-refresh"); - let circuit_id = key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-refresh"); sessions.apply_event_with_command_sink( complete_event_for(AcctStatusType::Start, "nas-refresh", "session-refresh"), @@ -4290,9 +4263,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A 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 = original_session - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-original"); let upsert = DynamicCircuitIntent::CreateDynamicCircuit(DynamicCircuitUpsert { circuit_id: circuit_id.clone(), session_key: original_session.clone(), @@ -4622,9 +4593,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A let (bus_tx, mut bus_rx) = mpsc::channel(4); let mut sink = ApplyingDynamicCircuitSink::new(bus_tx); let key = session_key(); - let circuit_id = key - .dynamic_circuit_id() - .expect("test session key should have a dynamic circuit id"); + let circuit_id = expected_circuit_id("nas-adapter"); let started_at = Instant::now(); handle_accounting_event_with_command_sink( @@ -4669,9 +4638,7 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A let mut sink = RecordingDynamicCircuitSink::default(); let started_at = Instant::now(); let key = session_key(); - let Some(circuit_id) = key.dynamic_circuit_id() else { - anyhow::bail!("test session key should have a dynamic circuit id"); - }; + let circuit_id = expected_circuit_id("nas-adapter"); sessions.apply_event_with_command_sink( complete_event(AcctStatusType::Start), @@ -5623,6 +5590,17 @@ circuit-b,Runtime Circuit B,device-b,Runtime Device B,Parent,ParentId,AnchorId,A 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()),