mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-26 06:40:05 -05:00
Add RADIUS username circuit matching
This commit is contained in:
1 parent
49d4a29139
commit
45976a75bc
10 files changed
+445
-38
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user