From 6416672f7b4d78e6be09b37d86c5dfd1f1c5f564 Mon Sep 17 00:00:00 2001 From: Herbert Wolverson Date: Tue, 21 Jul 2026 13:13:45 -0500 Subject: [PATCH] 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,