Add RADIUS username circuit matching

This commit is contained in:
Herbert Wolverson committed 2026-07-21 11:33:30 -05:00
1 parent 49d4a29139
commit 45976a75bc
10 files changed
+445 -38

No files matched your search

+5 -3
View File
@@ -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`.
+5 -3
View File
@@ -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.
@@ -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,
@@ -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<String>,
@@ -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();
@@ -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![
+1 -1
View File
@@ -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,
};
+89 -21
View File
@@ -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<String, ShapedDevicesMacEntry>,
matches_by_username: HashMap<String, ShapedDevicesMacEntry>,
}
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<String, ShapedDevicesMacEntry>,
key: &str,
) -> Option<ShapedDevicesMacMatch> {
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<ShapedDevice>),
/// 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(),
+107 -1
View File
@@ -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<SessionRateProfile>,
/// Parent attachment for an unmatched identity.
pub fallback_parent: Option<DynamicCircuitParent>,
}
/// 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<SessionRateProfile>,
) -> 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<SessionRateProfile>,
fallback_parent: Option<DynamicCircuitParent>,
) -> 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<SessionRateProfile>,
fallback_parent: Option<DynamicCircuitParent>,
) -> 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
+102
View File
@@ -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,
+49 -8
View File
@@ -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<SessionRateProfile>,
fallback_parent: Option<DynamicCircuitParent>,
mac_matcher: Option<ShapedDevicesMacMatcher>,
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<SessionRateProfile>,
fallback_parent: Option<DynamicCircuitParent>,
mac_matcher: Option<ShapedDevicesMacMatcher>,
) -> 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<SessionRateProfile>,
fallback_parent: Option<DynamicCircuitParent>,
mac_matcher: Option<ShapedDevicesMacMatcher>,
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<SessionRateProfile>,
fallback_parent: Option<DynamicCircuitParent>,
mac_matcher: Option<ShapedDevicesMacMatcher>,
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,