Add RADIUS accounting MVP plumbing

This commit is contained in:
Herbert Wolverson committed 2026-06-09 21:58:05 -05:00
1 parent 0b1fb8b72e
commit 9f4ab17c4e
24 files changed
+7272 -2

No files matched your search

+26
View File
@@ -62,6 +62,32 @@ netflow_version = 5
do_not_track_subnets = ["192.168.0.0/16"]
```
### Contabilidad RADIUS (opcional)
LibreQoS acepta una sección opcional `[radius_accounting]` para definir clientes NAS de confianza. En v2.0, esta sección solo se valida y se conserva. `lqosd` no abrirá un servicio de contabilidad RADIUS con esta configuración.
Ejemplo:
```toml
[radius_accounting]
enabled = false
listen = "0.0.0.0:1813"
default_ttl_seconds = 900
stale_grace_seconds = 120
[[radius_accounting.clients]]
name = "pppoe-core-1"
source = ["192.0.2.10/32"]
secret_file = "/etc/lqos/radius-secrets/pppoe-core-1"
```
Notas:
- Omita la sección o configure `enabled = false` para mantener deshabilitada la contabilidad RADIUS. Puede omitir los clientes mientras está deshabilitada; cualquier entrada de cliente que configure también debe ser válida.
- Cualquier valor configurado en `listen` debe ser una dirección de escucha IP:puerto con un puerto distinto de cero, como `0.0.0.0:1813`. Cuando `enabled = true`, configure al menos un cliente. Cada cliente configurado debe incluir al menos una entrada `source`.
- `source` acepta una cadena IP/CIDR o una lista de cadenas IP/CIDR. Las direcciones IP sin prefijo se aceptan como hosts individuales.
- Cada cliente configurado debe incluir un `secret_file` no vacío. Esta es la ruta configurada para el secreto compartido del cliente. LibreQoS conserva ese valor al reescribir `/etc/lqos.conf`. La salida de depuración generada a partir de este campo oculta la ruta, pero los paquetes de soporte que incluyan `/etc/lqos.conf` pueden mostrar esa ruta.
- `default_ttl_seconds` y `stale_grace_seconds` deben ser mayores que cero.
### Integraciones con CRM/NMS
Más información sobre [configuración de integraciones aquí.](integrations-es.md).
+26
View File
@@ -194,6 +194,32 @@ Notes:
- `ip_range` must be a CIDR. `0.0.0.0` (and `::`) are allowed shorthands for the match-all `/0` networks.
- `attach_to` is a `network.json` node name (optional; empty is allowed).
#### RADIUS accounting (optional)
LibreQoS accepts an optional `[radius_accounting]` section for trusted NAS client settings. In v2.0, this section is validated and saved only. `lqosd` will not start a RADIUS accounting listener from it.
Example:
```toml
[radius_accounting]
enabled = false
listen = "0.0.0.0:1813"
default_ttl_seconds = 900
stale_grace_seconds = 120
[[radius_accounting.clients]]
name = "pppoe-core-1"
source = ["192.0.2.10/32"]
secret_file = "/etc/lqos/radius-secrets/pppoe-core-1"
```
Notes:
- Omit the section or set `enabled = false` to keep RADIUS accounting disabled. Clients may be omitted while it is disabled; any client entries you configure are still validated.
- Any configured `listen` value must be an IP:port listen address with a non-zero port, such as `0.0.0.0:1813`. When `enabled = true`, configure at least one client. Each configured client must include at least one `source` entry.
- `source` accepts one IP/CIDR string or a list of IP/CIDR strings. Bare IP addresses are accepted as host sources.
- Each configured client must include a non-empty `secret_file`. This is the configured location for the client's shared secret. LibreQoS preserves that value in `/etc/lqos.conf`. Debug output generated from this config field hides the configured path, but `/etc/lqos.conf` and support bundles that include it can still show the path.
- `default_ttl_seconds` and `stale_grace_seconds` must be greater than zero.
#### CRM/NMS Integrations
Learn more about [configuring integrations here](integrations.md).
+135
View File
@@ -0,0 +1,135 @@
{
"product": "LibreQoS",
"branchName": "radius-accounting-listener",
"summary": "Add an opt-in RADIUS accounting listener MVP that can be developed and tested without root, decode common accounting packets, and prepare dynamic-circuit command plumbing for lqosd integration.",
"stories": [
{
"id": "RADIUS-001",
"title": "Create the lqos_radius crate",
"description": "As a LibreQoS developer, I want a separate lqos_radius crate with a rootless test executable, so that RADIUS accounting protocol work can be built and exercised without running lqosd as root.",
"priority": 1,
"passes": true,
"acceptanceCriteria": [
"The Rust workspace includes lqos_radius as a buildable member.",
"lqos_radius exposes a library API for packet handling and listener startup.",
"lqos_radius includes an executable entrypoint that can run on a configurable non-privileged listen address.",
"cargo test for lqos_radius runs without requiring root privileges or live shaping state."
]
},
{
"id": "RADIUS-002",
"title": "Configure RADIUS accounting",
"description": "As a LibreQoS operator, I want RADIUS accounting settings in LibreQoS configuration, so that I can explicitly enable the listener and control which NAS clients are trusted.",
"priority": 1,
"passes": true,
"acceptanceCriteria": [
"radius_accounting can be disabled without requiring any RADIUS clients.",
"When radius_accounting is enabled, configuration requires a valid listen address and at least one client source.",
"Each configured client has a source allow-list and a shared secret source that is not emitted in logs or serialized debug output.",
"Configuration validation rejects invalid listen addresses, invalid client sources, missing secrets, and non-positive timing values.",
"Configuration round-trip tests cover enabled and disabled RADIUS accounting settings."
]
},
{
"id": "RADIUS-003",
"title": "Decode and verify accounting packets",
"description": "As a LibreQoS operator, I want incoming RADIUS accounting packets to be decoded and verified, so that only authentic Accounting-Request packets from trusted clients are accepted.",
"priority": 1,
"passes": true,
"acceptanceCriteria": [
"The decoder accepts Accounting-Request packets and rejects unsupported RADIUS packet codes for this MVP.",
"Packet length, attribute bounds, and malformed attribute cases are validated before packet acceptance.",
"Accounting-Request authenticators are verified against the matched client shared secret.",
"Message-Authenticator is verified when present and is required when configuration enables that policy.",
"Valid accepted requests can produce an Accounting-Response packet with response code 5."
]
},
{
"id": "RADIUS-004",
"title": "Extract common accounting attributes",
"description": "As a LibreQoS operator, I want common RADIUS accounting attributes translated into typed session events, so that subscriber session lifecycle, identity, and address data can be inspected and used later.",
"priority": 1,
"passes": true,
"acceptanceCriteria": [
"Typed events include Acct-Status-Type values for Start, Stop, Interim-Update, Accounting-On, and Accounting-Off.",
"Typed events include session and NAS identity fields such as Acct-Session-Id, NAS-IP-Address, NAS-IPv6-Address, NAS-Identifier, NAS-Port, NAS-Port-Id, User-Name, Calling-Station-Id, Called-Station-Id, Class, and Event-Timestamp when present.",
"Typed events include subscriber address fields such as Framed-IP-Address, Framed-IP-Netmask, Framed-Route, Framed-IPv6-Prefix, Delegated-IPv6-Prefix, and Framed-IPv6-Address when present.",
"Unknown standard attributes and unknown vendor-specific attributes are preserved for diagnostics without rejecting otherwise valid packets.",
"MikroTik-Rate-Limit vendor attributes are decoded into rate values and tests verify configurable NAS-to-LibreQoS direction mapping."
]
},
{
"id": "RADIUS-005",
"title": "Run a rootless UDP accounting listener",
"description": "As a LibreQoS developer, I want the lqos_radius listener to run against loopback test traffic, so that protocol and server behavior can be proven before wiring live lqosd behavior.",
"priority": 1,
"passes": true,
"acceptanceCriteria": [
"The listener can bind to 127.0.0.1:0 or another configured non-privileged address for tests.",
"Accepted Accounting-Request packets receive an Accounting-Response without waiting for dynamic-circuit application.",
"Packets from unconfigured sources do not create session events or command placeholders.",
"Packets with invalid authenticators are rejected and do not create session events or command placeholders.",
"Loopback UDP tests verify accepted requests, rejected sources, invalid authenticators, and response behavior."
]
},
{
"id": "RADIUS-006",
"title": "Track accounting sessions in memory",
"description": "As a LibreQoS operator, I want accepted accounting events to update runtime session state, so that Starts, Interims, Stops, and NAS resets have predictable results during the MVP.",
"priority": 2,
"passes": true,
"acceptanceCriteria": [
"Sessions are keyed primarily by NAS identity plus Acct-Session-Id when both values are available.",
"Start and Interim-Update events create or refresh a session and preserve the latest decoded identity and address data.",
"Stop events mark the session stopped and are harmless when duplicated.",
"Accounting-On and Accounting-Off mark sessions for the affected NAS stale without affecting other NAS clients.",
"Sessions that cannot become dynamic circuits retain a specific pending reason such as missing IP, missing rate, missing parent, missing session ID, or ambiguous mapping."
]
},
{
"id": "RADIUS-007",
"title": "Prepare dynamic-circuit command plumbing",
"description": "As a LibreQoS developer, I want RADIUS session handling to emit create, update, and remove command placeholders, so that lqosd can later connect those commands to the existing dynamic-circuit path without changing the protocol server.",
"priority": 2,
"passes": true,
"acceptanceCriteria": [
"lqos_radius exposes a command sink boundary for dynamic-circuit intents that the lqosd-facing adapter can map to CreateDynamicCircuit and RemoveDynamicCircuit placeholders.",
"Shapeable Start and Interim-Update events emit create or update command intents through the sink boundary.",
"Stop events emit remove command intents when a deterministic circuit identity is available.",
"Fake sink tests assert the emitted command type and circuit identity for Start, Interim-Update, Stop, and expiry scenarios.",
"The listener never writes dynamic_circuits.json directly."
]
},
{
"id": "RADIUS-008",
"title": "Start the listener from lqosd when configured",
"description": "As a LibreQoS operator, I want lqosd to start the RADIUS accounting listener only when configured, so that existing installations are unchanged unless RADIUS accounting is enabled.",
"priority": 2,
"passes": false,
"acceptanceCriteria": [
"lqosd does not bind a RADIUS accounting socket when radius_accounting is absent or disabled.",
"When radius_accounting is enabled, lqosd starts the listener inside the existing tokio runtime after configuration is loaded.",
"Listener startup failures are reported clearly without logging shared secrets.",
"lqosd shutdown stops the listener without blocking daemon shutdown.",
"The lqosd wiring uses the lqos_radius command sink boundary and leaves final dynamic-circuit application behind the existing bus or daemon channel path for this MVP."
]
},
{
"id": "RADIUS-009",
"title": "Provide protocol test fixtures",
"description": "As a LibreQoS developer, I want focused protocol fixtures and scenario tests, so that regressions in packet parsing, authentication, and listener behavior are caught without NAS hardware.",
"priority": 1,
"passes": false,
"acceptanceCriteria": [
"Raw packet fixtures cover valid Accounting-Start, Accounting-Interim, Accounting-Stop, invalid packet length, invalid authenticator, unknown standard attributes, unknown vendor-specific attributes, and IPv6 address or prefix attributes.",
"A test packet builder can generate authenticated Accounting-Request packets for readable scenario tests.",
"Authenticator tests cover correct secrets, wrong secrets, modified attributes, truncated packets, valid Message-Authenticator, invalid Message-Authenticator, and required Message-Authenticator policy.",
"Session tests use fake time and a fake command sink instead of sockets, root privileges, bakery, TC, XDP, or disk writes."
]
}
],
"notes": [
"Full dynamic-circuit application, operator UI or CLI diagnostics, persistent session storage, and CoA or Disconnect support remain outside this MVP unless separately requested.",
"Binding the default UDP 1813 port may require elevated privileges; the crate executable and automated tests should support non-privileged addresses for development."
]
}
+23
View File
@@ -2404,6 +2404,19 @@ dependencies = [
"tracing",
]
[[package]]
name = "lqos_radius"
version = "0.1.0"
dependencies = [
"clap",
"hmac",
"ip_network",
"md-5",
"subtle",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "lqos_rs"
version = "0.1.0"
@@ -2653,6 +2666,16 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memalloc"
version = "0.1.0"
+3
View File
@@ -39,6 +39,7 @@ members = [
"uisp", # REST support for the UISP API
"uisp_integration", # UISP Integration in Rust
"lqos_probe", # Shared active probe provider and result types.
"lqos_radius", # Rootless RADIUS accounting packet parsing and diagnostic UDP listener.
"lqos_stormguard", # An implementation of CAKE AutoRotate using dynamic bus information. EXPERIMENTAL.
"lqos_bakery", # The bakery makes CAKEs - controls queue creation.
"lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv
@@ -68,6 +69,8 @@ ip_network_table = "0"
ip_network = "0"
sha2 = "0.10"
hmac = "0.12"
md-5 = "0.10"
subtle = "2.6"
base64 = "0.22"
argon2 = "0.5"
rand_core = { version = "0.6", features = ["getrandom"] }
+2 -1
View File
@@ -20,7 +20,8 @@ pub mod test_data;
mod v15;
pub use v15::{
BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode,
MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, SslConfig,
MikrotikIpv6Config, QueueMode, RadiusAccountingClient, RadiusAccountingConfig,
RadiusClientSource, RadiusSharedSecretSource, RttThresholds, SingleInterfaceConfig, SslConfig,
StormguardConfig, StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig,
TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables,
normalize_external_hostname,
+4
View File
@@ -15,6 +15,7 @@ mod mikrotik_ipv6;
mod netzur_integration;
mod powercode_integration;
mod queues;
mod radius_accounting;
mod sonar_integration;
mod splynx_integration;
mod stormguard;
@@ -31,6 +32,9 @@ pub use integration_common::IntegrationConfig;
pub use long_term_stats::LongTermStats;
pub use mikrotik_ipv6::MikrotikIpv6Config;
pub use queues::{LazyQueueMode, QueueMode};
pub use radius_accounting::{
RadiusAccountingClient, RadiusAccountingConfig, RadiusClientSource, RadiusSharedSecretSource,
};
pub use stormguard::{StormguardConfig, StormguardStrategy};
pub use topology::{TopologyConfig, normalize_topology_compile_mode};
pub use treeguard::{
@@ -0,0 +1,729 @@
//! RADIUS accounting configuration.
//!
//! This module only defines and validates operator configuration. Runtime
//! listener startup, packet authentication, and session handling live outside
//! this config schema.
use allocative::Allocative;
use ip_network::IpNetwork;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::net::{IpAddr, SocketAddr};
fn default_ttl_seconds() -> u64 {
900
}
fn default_stale_grace_seconds() -> u64 {
120
}
/// Configured `secret_file` value for a RADIUS shared secret.
///
/// This stores the operator-configured `secret_file` string, normally a file
/// path. Serde preserves the value for `/etc/lqos.conf` round trips, while
/// `Debug` redacts this wrapper.
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)]
#[serde(transparent)]
pub struct RadiusSharedSecretSource(String);
impl RadiusSharedSecretSource {
/// Creates a wrapper for an operator-configured `secret_file` value.
pub fn new(source: impl Into<String>) -> Self {
Self(source.into())
}
/// Returns the configured `secret_file` value.
pub fn as_str(&self) -> &str {
&self.0
}
/// Returns true when the configured `secret_file` value is blank.
pub fn is_empty(&self) -> bool {
self.0.trim().is_empty()
}
}
impl fmt::Debug for RadiusSharedSecretSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("RadiusSharedSecretSource(REDACTED)")
}
}
impl From<String> for RadiusSharedSecretSource {
fn from(source: String) -> Self {
Self::new(source)
}
}
impl From<&str> for RadiusSharedSecretSource {
fn from(source: &str) -> Self {
Self::new(source)
}
}
/// One trusted RADIUS client source address or network.
#[derive(Clone, Debug, PartialEq, Allocative)]
pub struct RadiusClientSource {
#[allocative(skip)]
network: IpNetwork,
}
impl RadiusClientSource {
/// Creates a RADIUS client source from a parsed IP network.
pub fn new(network: IpNetwork) -> Self {
Self { network }
}
/// Returns the parsed source network.
pub fn network(&self) -> &IpNetwork {
&self.network
}
}
impl Serialize for RadiusClientSource {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.network.to_string())
}
}
impl<'de> Deserialize<'de> for RadiusClientSource {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
parse_client_source(&raw)
.map(Self::new)
.map_err(serde::de::Error::custom)
}
}
/// One trusted RADIUS NAS client entry.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
pub struct RadiusAccountingClient {
/// Optional operator label used for diagnostics.
#[serde(default)]
pub name: String,
/// UDP source allow-list for this NAS client.
///
/// TOML may provide either one string or a list of strings. Values must be
/// IP addresses or CIDR networks.
#[serde(default, deserialize_with = "deserialize_source_allow_list")]
pub source: Vec<RadiusClientSource>,
/// Configured `secret_file` value for this client's shared secret.
#[serde(default)]
pub secret_file: RadiusSharedSecretSource,
}
impl RadiusAccountingClient {
/// Validates one trusted RADIUS client definition.
pub fn validate(&self, index: usize) -> Result<(), String> {
let label = client_label(index, &self.name);
if self.source.is_empty() {
return Err(format!(
"{label}.source must include at least one IP address or CIDR"
));
}
if self.secret_file.is_empty() {
return Err(format!("{label}.secret_file must not be empty"));
}
Ok(())
}
}
/// RADIUS accounting listener configuration.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)]
pub struct RadiusAccountingConfig {
/// Persisted operator flag for RADIUS accounting listener support.
///
/// In v2.0 this value is validated and saved, but does not start a listener.
#[serde(default)]
pub enabled: bool,
/// UDP socket address for the listener, for example `0.0.0.0:1813`.
#[allocative(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<SocketAddr>,
/// Time-to-live in seconds for accounting-derived session state.
#[serde(default = "default_ttl_seconds")]
pub default_ttl_seconds: u64,
/// Grace period in seconds before stale accounting state is considered expired.
#[serde(default = "default_stale_grace_seconds")]
pub stale_grace_seconds: u64,
/// Trusted RADIUS NAS clients.
#[serde(default)]
pub clients: Vec<RadiusAccountingClient>,
}
impl Default for RadiusAccountingConfig {
fn default() -> Self {
Self {
enabled: false,
listen: None,
default_ttl_seconds: default_ttl_seconds(),
stale_grace_seconds: default_stale_grace_seconds(),
clients: Vec::new(),
}
}
}
impl RadiusAccountingConfig {
/// Validates RADIUS accounting configuration.
pub fn validate(&self) -> Result<(), String> {
if self.default_ttl_seconds == 0 {
return Err("radius_accounting.default_ttl_seconds must be > 0".to_string());
}
if self.stale_grace_seconds == 0 {
return Err("radius_accounting.stale_grace_seconds must be > 0".to_string());
}
if self.listen.is_some_and(|listen| listen.port() == 0) {
return Err("radius_accounting.listen port must be > 0".to_string());
}
for (index, client) in self.clients.iter().enumerate() {
client.validate(index)?;
}
if self.enabled {
if self.listen.is_none() {
return Err("radius_accounting.listen must be configured when enabled".to_string());
}
if self.clients.is_empty() {
return Err(
"radius_accounting.clients must include at least one client when enabled"
.to_string(),
);
}
}
Ok(())
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum SourceAllowList {
One(String),
Many(Vec<String>),
}
fn deserialize_source_allow_list<'de, D>(
deserializer: D,
) -> Result<Vec<RadiusClientSource>, D::Error>
where
D: serde::Deserializer<'de>,
{
match SourceAllowList::deserialize(deserializer)? {
SourceAllowList::One(source) => parse_source_allow_list(vec![source]),
SourceAllowList::Many(sources) => parse_source_allow_list(sources),
}
}
fn parse_source_allow_list<E>(sources: Vec<String>) -> Result<Vec<RadiusClientSource>, E>
where
E: serde::de::Error,
{
sources
.into_iter()
.map(|source| {
parse_client_source(&source)
.map(RadiusClientSource::new)
.map_err(serde::de::Error::custom)
})
.collect()
}
fn client_label(index: usize, name: &str) -> String {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
format!("radius_accounting.clients[{index}]")
} else {
format!("radius_accounting.clients[{index}] ('{trimmed_name}')")
}
}
fn parse_client_source(source: &str) -> Result<IpNetwork, String> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err("source is empty".to_string());
}
if let Ok(network) = trimmed.parse::<IpNetwork>() {
return Ok(network);
}
if let Ok(ip) = trimmed.parse::<IpAddr>() {
return Ok(IpNetwork::from(ip));
}
Err(format!("'{trimmed}' is not a valid IP address or CIDR"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::etc::v15::Config;
const TEST_CLIENT_NAME: &str = "pppoe-core-1";
const TEST_CLIENT_NAME_LINE: &str = "name = \"pppoe-core-1\"";
const TEST_LISTEN: &str = "127.0.0.1:18130";
const TEST_LISTEN_LINE: &str = "listen = \"127.0.0.1:18130\"";
const TEST_SOURCE: &str = "192.0.2.10/32";
const TEST_SOURCE_LINE: &str = "source = \"192.0.2.10/32\"";
const TEST_SECRET_FILE: &str = "/etc/lqos/radius-secrets/pppoe-core-1";
const TEST_SECRET_FILE_LINE: &str = "secret_file = \"/etc/lqos/radius-secrets/pppoe-core-1\"";
const TEST_TTL_SECONDS_LINE: &str = "default_ttl_seconds = 900";
const TEST_STALE_GRACE_SECONDS_LINE: &str = "stale_grace_seconds = 120";
const TEST_CLIENT_LINES: &[&str] = &[TEST_SOURCE_LINE, TEST_SECRET_FILE_LINE];
const TEST_NAMED_CLIENT_LINES: &[&str] = &[
TEST_CLIENT_NAME_LINE,
TEST_SOURCE_LINE,
TEST_SECRET_FILE_LINE,
];
fn source(raw: &str) -> RadiusClientSource {
RadiusClientSource::new(parse_client_source(raw).expect("test source should parse"))
}
fn radius_section(
enabled: bool,
section_lines: &[&str],
client_lines: Option<&[&str]>,
) -> String {
let mut radius = format!("\n\n[radius_accounting]\nenabled = {enabled}\n");
for line in section_lines {
radius.push_str(line);
radius.push('\n');
}
if let Some(client_lines) = client_lines {
radius.push_str("\n[[radius_accounting.clients]]\n");
for line in client_lines {
radius.push_str(line);
radius.push('\n');
}
}
radius
}
fn enabled_radius_section(section_lines: &[&str], client_lines: Option<&[&str]>) -> String {
let mut lines = Vec::with_capacity(section_lines.len() + 1);
lines.push(TEST_LISTEN_LINE);
lines.extend_from_slice(section_lines);
radius_section(true, &lines, client_lines)
}
fn enabled_radius_section_with_clients(client_sections: &[&[&str]]) -> String {
let mut radius = format!("\n\n[radius_accounting]\nenabled = true\n{TEST_LISTEN_LINE}\n");
for client_lines in client_sections {
radius.push_str("\n[[radius_accounting.clients]]\n");
for line in *client_lines {
radius.push_str(line);
radius.push('\n');
}
}
radius
}
fn example_with_radius(radius: &str) -> String {
let mut raw = include_str!("example.toml").to_string();
raw.push_str(radius);
raw
}
fn load_error_contains(radius: &str, expected: &str) {
let raw = example_with_radius(radius);
let error = Config::load_from_string(&raw).expect_err("config should fail validation");
assert!(
error.contains(expected),
"expected error to contain '{expected}', got '{error}'"
);
}
fn first_radius_client(radius: &str) -> RadiusAccountingClient {
Config::load_from_string(&example_with_radius(radius))
.expect("radius accounting should deserialize")
.radius_accounting
.expect("radius accounting should be present")
.clients
.into_iter()
.next()
.expect("radius accounting should include a client")
}
fn load_error_cases(cases: impl IntoIterator<Item = (String, &'static str)>) {
for (radius, expected) in cases {
load_error_contains(&radius, expected);
}
}
fn assert_disabled_defaults(radius: &RadiusAccountingConfig) {
assert!(!radius.enabled);
assert!(radius.listen.is_none());
assert!(radius.clients.is_empty());
assert_eq!(radius.default_ttl_seconds, default_ttl_seconds());
assert_eq!(radius.stale_grace_seconds, default_stale_grace_seconds());
}
fn valid_client() -> RadiusAccountingClient {
RadiusAccountingClient {
name: TEST_CLIENT_NAME.to_string(),
source: vec![source(TEST_SOURCE), source("2001:db8::/48")],
secret_file: RadiusSharedSecretSource::from(TEST_SECRET_FILE),
}
}
fn valid_enabled_config() -> RadiusAccountingConfig {
RadiusAccountingConfig {
enabled: true,
listen: Some(
TEST_LISTEN
.parse()
.expect("test listen address should parse"),
),
default_ttl_seconds: default_ttl_seconds(),
stale_grace_seconds: default_stale_grace_seconds(),
clients: vec![valid_client()],
}
}
#[test]
fn disabled_radius_accounting_allows_no_clients() {
let config = RadiusAccountingConfig::default();
config
.validate()
.expect("disabled radius accounting should not require clients");
}
#[test]
fn disabled_radius_accounting_round_trips() {
let config = Config {
radius_accounting: Some(RadiusAccountingConfig::default()),
..Config::default()
};
let raw = toml::to_string_pretty(&config).expect("config should serialize");
let parsed = Config::load_from_string(&raw).expect("config should deserialize");
let radius = parsed
.radius_accounting
.expect("radius accounting section should round trip");
assert_disabled_defaults(&radius);
}
#[test]
fn empty_radius_accounting_toml_section_uses_disabled_defaults() {
let config = Config::load_from_string(&example_with_radius(
r#"
[radius_accounting]
"#,
))
.expect("empty radius accounting section should deserialize");
let radius = config
.radius_accounting
.expect("radius accounting should be present");
assert_disabled_defaults(&radius);
}
#[test]
fn enabled_radius_accounting_round_trips_and_redacts_debug_secret_source() {
let config = Config {
radius_accounting: Some(valid_enabled_config()),
..Config::default()
};
config
.validate()
.expect("radius accounting should validate");
let debug = format!("{config:?}");
assert!(!debug.contains(TEST_SECRET_FILE));
assert!(debug.contains("RadiusSharedSecretSource(REDACTED)"));
let raw = toml::to_string_pretty(&config).expect("config should serialize");
assert!(raw.contains("[radius_accounting]"));
assert!(raw.contains(TEST_SECRET_FILE_LINE));
let parsed = Config::load_from_string(&raw).expect("config should deserialize");
assert_eq!(parsed.radius_accounting, config.radius_accounting);
}
#[test]
fn source_accepts_single_string_in_toml() {
let radius = enabled_radius_section(
&[TEST_TTL_SECONDS_LINE, TEST_STALE_GRACE_SECONDS_LINE],
Some(TEST_NAMED_CLIENT_LINES),
);
let client = first_radius_client(&radius);
assert_eq!(client.source[0].network().to_string(), "192.0.2.10/32");
}
#[test]
fn enabled_toml_without_timing_values_uses_defaults() {
let radius = enabled_radius_section(&[], Some(TEST_CLIENT_LINES));
let config = Config::load_from_string(&example_with_radius(&radius))
.expect("timing defaults should deserialize");
let radius = config
.radius_accounting
.expect("radius accounting should be present");
assert_eq!(radius.default_ttl_seconds, default_ttl_seconds());
assert_eq!(radius.stale_grace_seconds, default_stale_grace_seconds());
}
#[test]
fn enabled_radius_accounting_requires_listen_and_clients() {
let mut config = valid_enabled_config();
config.listen = None;
let error = config
.validate()
.expect_err("enabled radius accounting should require listen");
assert!(error.contains("listen"));
let mut config = valid_enabled_config();
config.clients.clear();
let error = config
.validate()
.expect_err("enabled radius accounting should require clients");
assert!(error.contains("clients"));
}
#[test]
fn validation_rejects_invalid_listen_address() {
let radius = radius_section(
true,
&[r#"listen = "not-a-socket""#],
Some(TEST_CLIENT_LINES),
);
load_error_contains(&radius, "invalid socket address");
}
#[test]
fn validation_rejects_zero_listen_port() {
let mut config = valid_enabled_config();
config.listen = Some("0.0.0.0:0".parse().expect("test listen should parse"));
let error = config
.validate()
.expect_err("zero listen port should fail validation");
assert!(error.contains("listen port"));
}
#[test]
fn validation_rejects_invalid_client_source() {
let radius = enabled_radius_section(
&[],
Some(&[r#"source = "not-a-network""#, TEST_SECRET_FILE_LINE]),
);
load_error_contains(&radius, "not a valid IP address or CIDR");
}
#[test]
fn validation_rejects_missing_secret_source() {
let mut config = valid_enabled_config();
config.clients[0].secret_file = RadiusSharedSecretSource::default();
let error = config
.validate()
.expect_err("missing secret source should fail validation");
assert!(error.contains("secret_file"));
}
#[test]
fn validation_rejects_missing_client_source() {
load_error_cases([
(
enabled_radius_section(&[], Some(&[TEST_SECRET_FILE_LINE])),
"source",
),
(
enabled_radius_section(&[], Some(&["source = []", TEST_SECRET_FILE_LINE])),
"source",
),
]);
}
#[test]
fn validation_checks_each_configured_client() {
let mut config = valid_enabled_config();
let mut second_client = valid_client();
second_client.name = "pppoe-core-2".to_string();
second_client.source.clear();
config.clients.push(second_client);
let error = config
.validate()
.expect_err("second client missing source should fail validation");
assert!(error.contains("clients[1]"));
assert!(error.contains("pppoe-core-2"));
assert!(error.contains("source"));
let mut config = valid_enabled_config();
let mut second_client = valid_client();
second_client.name = "pppoe-core-2".to_string();
second_client.secret_file = RadiusSharedSecretSource::default();
config.clients.push(second_client);
let error = config
.validate()
.expect_err("second client missing secret should fail validation");
assert!(error.contains("clients[1]"));
assert!(error.contains("pppoe-core-2"));
assert!(error.contains("secret_file"));
}
#[test]
fn multiple_clients_toml_deserializes_and_validates() {
let radius = enabled_radius_section_with_clients(&[
TEST_NAMED_CLIENT_LINES,
&[
r#"name = "pppoe-core-2""#,
r#"source = ["192.0.2.11/32", "2001:db8:1::/48"]"#,
TEST_SECRET_FILE_LINE,
],
]);
let config = Config::load_from_string(&example_with_radius(&radius))
.expect("multiple radius clients should deserialize");
let clients = config
.radius_accounting
.expect("radius accounting should be present")
.clients;
assert_eq!(clients.len(), 2);
assert_eq!(clients[1].name, "pppoe-core-2");
assert_eq!(clients[1].source[0].network().to_string(), "192.0.2.11/32");
assert_eq!(
clients[1].source[1].network().to_string(),
"2001:db8:1::/48"
);
}
#[test]
fn multiple_clients_toml_reports_second_client_validation_error() {
let radius = enabled_radius_section_with_clients(&[
TEST_NAMED_CLIENT_LINES,
&[r#"name = "pppoe-core-2""#, TEST_SECRET_FILE_LINE],
]);
let raw = example_with_radius(&radius);
let error = Config::load_from_string(&raw)
.expect_err("second TOML client missing source should fail validation");
assert!(error.contains("clients[1]"));
assert!(error.contains("pppoe-core-2"));
assert!(error.contains("source"));
}
#[test]
fn validation_rejects_non_positive_timing_values() {
let mut config = valid_enabled_config();
config.default_ttl_seconds = 0;
let error = config
.validate()
.expect_err("zero ttl should fail validation");
assert!(error.contains("default_ttl_seconds"));
let mut config = valid_enabled_config();
config.stale_grace_seconds = 0;
let error = config
.validate()
.expect_err("zero stale grace should fail validation");
assert!(error.contains("stale_grace_seconds"));
}
#[test]
fn top_level_load_rejects_validation_failures() {
load_error_cases([
(
enabled_radius_section(&[], Some(&[TEST_SOURCE_LINE])),
"secret_file",
),
(
enabled_radius_section(&["default_ttl_seconds = 0"], Some(TEST_CLIENT_LINES)),
"default_ttl_seconds",
),
(
enabled_radius_section(&["stale_grace_seconds = 0"], Some(TEST_CLIENT_LINES)),
"stale_grace_seconds",
),
(enabled_radius_section(&[], None), "clients"),
]);
}
#[test]
fn disabled_radius_accounting_rejects_invalid_configured_values() {
load_error_contains(
r#"
[radius_accounting]
enabled = false
default_ttl_seconds = 0
"#,
"default_ttl_seconds",
);
load_error_contains(
r#"
[radius_accounting]
enabled = false
listen = "0.0.0.0:0"
"#,
"listen port",
);
}
#[test]
fn disabled_radius_accounting_rejects_invalid_configured_clients() {
load_error_cases([
(
radius_section(false, &[], Some(&["source = []", TEST_SECRET_FILE_LINE])),
"source",
),
(
radius_section(
false,
&[],
Some(&[TEST_SOURCE_LINE, r#"secret_file = " ""#]),
),
"secret_file",
),
(
radius_section(
false,
&[],
Some(&[r#"source = " ""#, TEST_SECRET_FILE_LINE]),
),
"source is empty",
),
]);
}
#[test]
fn validation_accepts_bare_ip_sources() {
let radius = enabled_radius_section(
&[],
Some(&[
r#"source = ["192.0.2.10", "2001:db8::1"]"#,
TEST_SECRET_FILE_LINE,
]),
);
let client = first_radius_client(&radius);
assert_eq!(client.source[0].network().to_string(), "192.0.2.10/32");
assert_eq!(client.source[1].network().to_string(), "2001:db8::1/128");
}
}
@@ -177,6 +177,12 @@ pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_circuits: Option<super::dynamic_circuits::DynamicCircuitsConfig>,
/// RADIUS accounting configuration.
///
/// Optional so older configs without this section still deserialize cleanly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub radius_accounting: Option<super::radius_accounting::RadiusAccountingConfig>,
/// Network flows configuration
pub flows: Option<super::flows::FlowConfig>,
@@ -368,6 +374,9 @@ impl Config {
if let Some(dynamic_circuits) = &self.dynamic_circuits {
dynamic_circuits.validate()?;
}
if let Some(radius_accounting) = &self.radius_accounting {
radius_accounting.validate()?;
}
Ok(())
}
@@ -442,6 +451,7 @@ impl Default for Config {
long_term_stats: super::long_term_stats::LongTermStats::default(),
ip_ranges: super::ip_ranges::IpRanges::default(),
dynamic_circuits: None,
radius_accounting: None,
integration_common: super::integration_common::IntegrationConfig::default(),
topology: super::topology::TopologyConfig::default(),
mikrotik_ipv6: super::mikrotik_ipv6::MikrotikIpv6Config::default(),
+2 -1
View File
@@ -42,7 +42,8 @@ pub use cpu_topology::{
};
pub use etc::{
BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode,
MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, SslConfig,
MikrotikIpv6Config, QueueMode, RadiusAccountingClient, RadiusAccountingConfig,
RadiusClientSource, RadiusSharedSecretSource, RttThresholds, SingleInterfaceConfig, SslConfig,
StormguardConfig, StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig,
TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables,
clear_cached_config, disable_xdp_bridge, enable_long_term_stats, load_config,
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "lqos_radius"
version = "0.1.0"
edition = "2024"
license = "GPL-2.0-only"
[dependencies]
clap = { workspace = true }
hmac = { workspace = true }
ip_network = { workspace = true }
md-5 = { workspace = true }
subtle = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
@@ -0,0 +1,566 @@
//! Typed RADIUS Accounting-Request event extraction.
use crate::attribute_type::{ACCT_STATUS_TYPE, VENDOR_SPECIFIC};
use crate::packet::split_radius_tlv;
use crate::{AccountingRequest, RadiusAttribute, VerifiedAccountingRequest};
use std::net::{Ipv4Addr, Ipv6Addr};
const USER_NAME: u8 = 1;
const NAS_IP_ADDRESS: u8 = 4;
const NAS_PORT: u8 = 5;
const FRAMED_IP_ADDRESS: u8 = 8;
const FRAMED_IP_NETMASK: u8 = 9;
const FRAMED_ROUTE: u8 = 22;
const CLASS: u8 = 25;
const CALLED_STATION_ID: u8 = 30;
const CALLING_STATION_ID: u8 = 31;
const NAS_IDENTIFIER: u8 = 32;
const ACCT_SESSION_ID: u8 = 44;
const EVENT_TIMESTAMP: u8 = 55;
const NAS_PORT_ID: u8 = 87;
const NAS_IPV6_ADDRESS: u8 = 95;
const FRAMED_IPV6_PREFIX: u8 = 97;
const DELEGATED_IPV6_PREFIX: u8 = 123;
const FRAMED_IPV6_ADDRESS: u8 = 168;
const MIKROTIK_VENDOR_ID: u32 = 14988;
const MIKROTIK_RATE_LIMIT: u8 = 8;
/// Common RADIUS accounting attributes extracted into typed fields.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct AccountingEvent {
/// Acct-Status-Type when present.
pub status_type: Option<AcctStatusType>,
/// Acct-Session-Id when present.
pub acct_session_id: Option<String>,
/// NAS-IP-Address when present.
pub nas_ip_address: Option<Ipv4Addr>,
/// NAS-IPv6-Address when present.
pub nas_ipv6_address: Option<Ipv6Addr>,
/// NAS-Identifier when present.
pub nas_identifier: Option<String>,
/// NAS-Port when present.
pub nas_port: Option<u32>,
/// NAS-Port-Id when present.
pub nas_port_id: Option<String>,
/// User-Name when present.
pub user_name: Option<String>,
/// Calling-Station-Id when present.
pub calling_station_id: Option<String>,
/// Called-Station-Id when present.
pub called_station_id: Option<String>,
/// Class attributes when present. RADIUS permits more than one Class value.
pub class: Vec<Vec<u8>>,
/// Event-Timestamp as Unix seconds when present.
pub event_timestamp: Option<u32>,
/// Framed-IP-Address when present.
pub framed_ip_address: Option<Ipv4Addr>,
/// Framed-IP-Netmask when present.
pub framed_ip_netmask: Option<Ipv4Addr>,
/// Framed-Route values when present.
pub framed_routes: Vec<String>,
/// Framed-IPv6-Prefix values when present.
pub framed_ipv6_prefixes: Vec<Ipv6Prefix>,
/// Delegated-IPv6-Prefix values when present.
pub delegated_ipv6_prefixes: Vec<Ipv6Prefix>,
/// Framed-IPv6-Address when present.
pub framed_ipv6_address: Option<Ipv6Addr>,
/// Standard attributes this extractor does not expose as typed fields or could not decode.
pub unknown_standard_attributes: Vec<RadiusAttribute>,
/// Vendor-Specific attributes this extractor does not understand or cannot decode.
pub unknown_vendor_attributes: Vec<UnknownVendorAttribute>,
/// Decoded MikroTik-Rate-Limit attributes when present.
pub mikrotik_rate_limits: Vec<MikrotikRateLimit>,
}
impl AccountingEvent {
/// Extracts a typed accounting event from a parsed Accounting-Request.
///
/// Side effects: none. The packet is inspected in memory only.
#[must_use]
pub fn from_request(request: &AccountingRequest) -> Self {
Self::from_request_with_options(request, AccountingEventOptions::default())
}
/// Extracts a typed accounting event from a parsed Accounting-Request.
///
/// Side effects: none. The packet is inspected in memory only.
#[must_use]
pub fn from_request_with_options(
request: &AccountingRequest,
options: AccountingEventOptions,
) -> Self {
let mut event = Self::default();
for attribute in request.packet().attributes() {
event.apply_attribute(attribute, options);
}
event
}
/// Extracts a typed accounting event from a verified Accounting-Request.
///
/// Side effects: none. The packet is inspected in memory only.
#[must_use]
pub fn from_verified(request: &VerifiedAccountingRequest) -> Self {
Self::from_verified_with_options(request, AccountingEventOptions::default())
}
/// Extracts a typed accounting event from a verified Accounting-Request.
///
/// Side effects: none. The packet is inspected in memory only.
#[must_use]
pub fn from_verified_with_options(
request: &VerifiedAccountingRequest,
options: AccountingEventOptions,
) -> Self {
Self::from_request_with_options(request.request(), options)
}
fn apply_attribute(&mut self, attribute: &RadiusAttribute, options: AccountingEventOptions) {
match attribute.kind() {
USER_NAME => set_once(&mut self.user_name, Some(text(attribute.value()))),
NAS_IP_ADDRESS => set_optional(
&mut self.unknown_standard_attributes,
&mut self.nas_ip_address,
attribute,
ipv4,
),
NAS_PORT => set_optional(
&mut self.unknown_standard_attributes,
&mut self.nas_port,
attribute,
u32_value,
),
FRAMED_IP_ADDRESS => set_optional(
&mut self.unknown_standard_attributes,
&mut self.framed_ip_address,
attribute,
ipv4,
),
FRAMED_IP_NETMASK => set_optional(
&mut self.unknown_standard_attributes,
&mut self.framed_ip_netmask,
attribute,
ipv4,
),
FRAMED_ROUTE => self.framed_routes.push(text(attribute.value())),
CLASS => self.class.push(attribute.value().to_vec()),
VENDOR_SPECIFIC => self.apply_vendor_specific(attribute.value(), options),
CALLED_STATION_ID => {
set_once(&mut self.called_station_id, Some(text(attribute.value())))
}
CALLING_STATION_ID => {
set_once(&mut self.calling_station_id, Some(text(attribute.value())));
}
NAS_IDENTIFIER => set_once(&mut self.nas_identifier, Some(text(attribute.value()))),
ACCT_STATUS_TYPE => set_optional(
&mut self.unknown_standard_attributes,
&mut self.status_type,
attribute,
|value| u32_value(value).map(AcctStatusType::from),
),
ACCT_SESSION_ID => set_once(&mut self.acct_session_id, Some(text(attribute.value()))),
EVENT_TIMESTAMP => set_optional(
&mut self.unknown_standard_attributes,
&mut self.event_timestamp,
attribute,
u32_value,
),
NAS_PORT_ID => set_once(&mut self.nas_port_id, Some(text(attribute.value()))),
NAS_IPV6_ADDRESS => set_optional(
&mut self.unknown_standard_attributes,
&mut self.nas_ipv6_address,
attribute,
ipv6,
),
FRAMED_IPV6_PREFIX => push_optional(
&mut self.unknown_standard_attributes,
&mut self.framed_ipv6_prefixes,
attribute,
ipv6_prefix,
),
DELEGATED_IPV6_PREFIX => push_optional(
&mut self.unknown_standard_attributes,
&mut self.delegated_ipv6_prefixes,
attribute,
ipv6_prefix,
),
FRAMED_IPV6_ADDRESS => set_optional(
&mut self.unknown_standard_attributes,
&mut self.framed_ipv6_address,
attribute,
ipv6,
),
_ => self.unknown_standard_attributes.push(attribute.clone()),
}
}
fn apply_vendor_specific(&mut self, value: &[u8], options: AccountingEventOptions) {
let Some((vendor_id, mut remaining)) = vendor_id_and_payload(value) else {
self.unknown_vendor_attributes
.push(UnknownVendorAttribute::malformed(value));
return;
};
if remaining.is_empty() {
self.unknown_vendor_attributes
.push(UnknownVendorAttribute::vendor_only(vendor_id));
return;
}
if vendor_id != MIKROTIK_VENDOR_ID {
self.unknown_vendor_attributes
.push(UnknownVendorAttribute::raw_vendor_payload(
vendor_id, remaining,
));
return;
}
while !remaining.is_empty() {
let Ok((vendor_type, vendor_value, next)) = split_radius_tlv(remaining) else {
self.unknown_vendor_attributes
.push(UnknownVendorAttribute::vendor_data(vendor_id, remaining));
return;
};
if vendor_type == MIKROTIK_RATE_LIMIT
&& let Some(rate_limit) = MikrotikRateLimit::parse(vendor_value, options)
{
self.mikrotik_rate_limits.push(rate_limit);
remaining = next;
continue;
}
self.unknown_vendor_attributes
.push(UnknownVendorAttribute::subattribute(
vendor_id,
vendor_type,
vendor_value,
));
remaining = next;
}
}
}
impl From<&AccountingRequest> for AccountingEvent {
fn from(request: &AccountingRequest) -> Self {
Self::from_request(request)
}
}
impl From<&VerifiedAccountingRequest> for AccountingEvent {
fn from(request: &VerifiedAccountingRequest) -> Self {
Self::from_verified(request)
}
}
/// Options that control typed accounting event extraction.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AccountingEventOptions {
/// Mapping between MikroTik NAS RX/TX rates and LibreQoS upload/download rates.
pub mikrotik_rate_limit_direction: MikrotikRateLimitDirection,
}
/// Acct-Status-Type values understood by LibreQoS.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AcctStatusType {
/// Start, value 1.
Start,
/// Stop, value 2.
Stop,
/// Interim-Update, value 3.
InterimUpdate,
/// Accounting-On, value 7.
AccountingOn,
/// Accounting-Off, value 8.
AccountingOff,
/// A status value not enumerated by this crate.
Unknown(u32),
}
impl AcctStatusType {
/// Returns the RADIUS integer value for this Acct-Status-Type.
#[must_use]
pub const fn as_u32(self) -> u32 {
match self {
Self::Start => 1,
Self::Stop => 2,
Self::InterimUpdate => 3,
Self::AccountingOn => 7,
Self::AccountingOff => 8,
Self::Unknown(value) => value,
}
}
}
impl From<u32> for AcctStatusType {
fn from(value: u32) -> Self {
match value {
1 => Self::Start,
2 => Self::Stop,
3 => Self::InterimUpdate,
7 => Self::AccountingOn,
8 => Self::AccountingOff,
other => Self::Unknown(other),
}
}
}
impl From<AcctStatusType> for u32 {
fn from(status_type: AcctStatusType) -> Self {
status_type.as_u32()
}
}
/// IPv6 prefix decoded from RADIUS ipv6prefix attributes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Ipv6Prefix {
/// The IPv6 network address bytes supplied by the NAS.
pub address: Ipv6Addr,
/// Prefix length in bits.
pub prefix_len: u8,
}
/// A Vendor-Specific attribute this extractor did not decode.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnknownVendorAttribute {
/// Vendor ID when the Vendor-Specific attribute had enough bytes to include it.
pub vendor_id: Option<u32>,
/// Vendor type when the vendor payload had a complete subattribute header.
pub vendor_type: Option<u8>,
/// Vendor attribute value, or the remaining malformed bytes when no value could be split out.
pub value: Vec<u8>,
}
impl UnknownVendorAttribute {
fn malformed(value: &[u8]) -> Self {
Self {
vendor_id: None,
vendor_type: None,
value: value.to_vec(),
}
}
fn vendor_only(vendor_id: u32) -> Self {
Self {
vendor_id: Some(vendor_id),
vendor_type: None,
value: Vec::new(),
}
}
fn vendor_data(vendor_id: u32, value: &[u8]) -> Self {
Self {
vendor_id: Some(vendor_id),
vendor_type: value.first().copied(),
value: value.to_vec(),
}
}
fn raw_vendor_payload(vendor_id: u32, value: &[u8]) -> Self {
Self {
vendor_id: Some(vendor_id),
vendor_type: None,
value: value.to_vec(),
}
}
fn subattribute(vendor_id: u32, vendor_type: u8, value: &[u8]) -> Self {
Self {
vendor_id: Some(vendor_id),
vendor_type: Some(vendor_type),
value: value.to_vec(),
}
}
}
/// Mapping between MikroTik NAS directions and LibreQoS directions.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MikrotikRateLimitDirection {
/// Map NAS RX to LibreQoS upload and NAS TX to LibreQoS download.
#[default]
Normal,
/// Map NAS RX to LibreQoS download and NAS TX to LibreQoS upload.
Swapped,
}
impl MikrotikRateLimitDirection {
const fn map_rates(self, nas_rx_bps: u64, nas_tx_bps: u64) -> (u64, u64) {
match self {
Self::Normal => (nas_rx_bps, nas_tx_bps),
Self::Swapped => (nas_tx_bps, nas_rx_bps),
}
}
}
/// MikroTik-Rate-Limit values decoded from a Vendor-Specific attribute.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MikrotikRateLimit {
/// Original MikroTik-Rate-Limit string.
pub original: String,
/// NAS RX rate in bits per second.
pub nas_rx_bps: u64,
/// NAS TX rate in bits per second.
pub nas_tx_bps: u64,
/// LibreQoS upload rate in bits per second after direction mapping.
pub upload_bps: u64,
/// LibreQoS download rate in bits per second after direction mapping.
pub download_bps: u64,
}
impl MikrotikRateLimit {
fn parse(value: &[u8], options: AccountingEventOptions) -> Option<Self> {
let original = text(value);
let rate_pair = original.split_whitespace().next()?;
let (nas_rx_bps, nas_tx_bps) = parse_mikrotik_rate_pair(rate_pair)?;
let (upload_bps, download_bps) = options
.mikrotik_rate_limit_direction
.map_rates(nas_rx_bps, nas_tx_bps);
Some(Self {
original,
nas_rx_bps,
nas_tx_bps,
upload_bps,
download_bps,
})
}
}
fn set_once<T>(slot: &mut Option<T>, value: Option<T>) {
if slot.is_none() {
*slot = value;
}
}
fn set_optional<T>(
unknown_standard_attributes: &mut Vec<RadiusAttribute>,
slot: &mut Option<T>,
attribute: &RadiusAttribute,
decode: impl FnOnce(&[u8]) -> Option<T>,
) {
let decoded = decode(attribute.value());
if decoded.is_none() {
unknown_standard_attributes.push(attribute.clone());
}
set_once(slot, decoded);
}
fn push_optional<T>(
unknown_standard_attributes: &mut Vec<RadiusAttribute>,
items: &mut Vec<T>,
attribute: &RadiusAttribute,
decode: impl FnOnce(&[u8]) -> Option<T>,
) {
let Some(decoded) = decode(attribute.value()) else {
unknown_standard_attributes.push(attribute.clone());
return;
};
items.push(decoded);
}
fn text(value: &[u8]) -> String {
String::from_utf8_lossy(value).into_owned()
}
fn ipv4(value: &[u8]) -> Option<Ipv4Addr> {
let bytes: [u8; 4] = value.try_into().ok()?;
Some(Ipv4Addr::from(bytes))
}
fn ipv6(value: &[u8]) -> Option<Ipv6Addr> {
let bytes: [u8; 16] = value.try_into().ok()?;
Some(Ipv6Addr::from(bytes))
}
fn ipv6_prefix(value: &[u8]) -> Option<Ipv6Prefix> {
if !(2..=18).contains(&value.len()) {
return None;
}
if value[0] != 0 {
return None;
}
let prefix_len = value[1];
if prefix_len > 128 {
return None;
}
let expected_prefix_bytes = usize::from(prefix_len).div_ceil(8);
let prefix_bytes = &value[2..];
if prefix_bytes.len() < expected_prefix_bytes {
return None;
}
if prefix_bytes[expected_prefix_bytes..]
.iter()
.any(|byte| *byte != 0)
{
return None;
}
let mut bytes = [0_u8; 16];
bytes[..expected_prefix_bytes].copy_from_slice(&prefix_bytes[..expected_prefix_bytes]);
if let Some(mask) = final_prefix_byte_mask(prefix_len) {
if bytes[expected_prefix_bytes - 1] & !mask != 0 {
return None;
}
bytes[expected_prefix_bytes - 1] &= mask;
}
Some(Ipv6Prefix {
address: Ipv6Addr::from(bytes),
prefix_len,
})
}
fn final_prefix_byte_mask(prefix_len: u8) -> Option<u8> {
let remainder = prefix_len % 8;
if remainder == 0 {
None
} else {
Some(u8::MAX << (8 - remainder))
}
}
fn u32_value(value: &[u8]) -> Option<u32> {
let bytes: [u8; 4] = value.try_into().ok()?;
Some(u32::from_be_bytes(bytes))
}
fn vendor_id_and_payload(value: &[u8]) -> Option<(u32, &[u8])> {
let vendor_id_bytes: [u8; 4] = value.get(..4)?.try_into().ok()?;
Some((u32::from_be_bytes(vendor_id_bytes), &value[4..]))
}
fn parse_mikrotik_rate_pair(value: &str) -> Option<(u64, u64)> {
let (nas_rx, nas_tx) = match value.split_once('/') {
Some(pair) => pair,
None => (value, value),
};
Some((parse_mikrotik_rate(nas_rx)?, parse_mikrotik_rate(nas_tx)?))
}
fn parse_mikrotik_rate(value: &str) -> Option<u64> {
let trimmed = value.trim();
let digit_count = trimmed
.as_bytes()
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
if digit_count == 0 {
return None;
}
let number = trimmed[..digit_count].parse::<u64>().ok()?;
let multiplier = match trimmed[digit_count..].to_ascii_lowercase().as_str() {
"" | "bps" => 1,
"k" | "kb" | "kbit" | "kbps" => 1_000,
"m" | "mb" | "mbit" | "mbps" => 1_000_000,
"g" | "gb" | "gbit" | "gbps" => 1_000_000_000,
_ => return None,
};
number.checked_mul(multiplier)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,421 @@
//! Tests for typed RADIUS Accounting-Request event extraction.
use super::*;
use crate::packet::MESSAGE_AUTHENTICATOR_TYPE;
use crate::test_support::{
SHARED_SECRET, accounting_request_packet, radius_attribute, radius_attributes,
radius_raw_vendor_attribute, radius_text_attribute, radius_u32_attribute,
radius_vendor_attribute, radius_vendor_attributes, radius_vendor_subattribute,
signed_accounting_request_packet,
};
use crate::{MessageAuthenticatorPolicy, handle_accounting_request, verify_accounting_request};
#[test]
fn extracts_acct_status_type_variants_and_unknown_values() {
let cases = [
(1, AcctStatusType::Start),
(2, AcctStatusType::Stop),
(3, AcctStatusType::InterimUpdate),
(7, AcctStatusType::AccountingOn),
(8, AcctStatusType::AccountingOff),
(99, AcctStatusType::Unknown(99)),
];
for (raw, expected) in cases {
let request = request_with_attributes(&[radius_u32_attribute(ACCT_STATUS_TYPE, raw)]);
let event = AccountingEvent::from_request(&request);
assert_eq!(event.status_type, Some(expected));
assert_eq!(event.status_type.unwrap().as_u32(), raw);
}
}
#[test]
fn extracts_session_nas_identity_and_subscriber_address_fields() {
let request = request_with_attributes(&[
radius_text_attribute(ACCT_SESSION_ID, "session-123"),
radius_attribute(NAS_IP_ADDRESS, &[192, 0, 2, 10]),
radius_attribute(
NAS_IPV6_ADDRESS,
&ipv6_bytes([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]),
),
radius_text_attribute(NAS_IDENTIFIER, "nas-1"),
radius_u32_attribute(NAS_PORT, 17),
radius_text_attribute(NAS_PORT_ID, "ether5"),
radius_text_attribute(USER_NAME, "subscriber@example.net"),
radius_text_attribute(CALLING_STATION_ID, "00:11:22:33:44:55"),
radius_text_attribute(CALLED_STATION_ID, "service"),
radius_attribute(CLASS, b"class-data"),
radius_attribute(CLASS, b"class-data-2"),
radius_u32_attribute(EVENT_TIMESTAMP, 1_700_000_000),
radius_attribute(FRAMED_IP_ADDRESS, &[198, 51, 100, 25]),
radius_attribute(FRAMED_IP_NETMASK, &[255, 255, 255, 0]),
radius_text_attribute(FRAMED_ROUTE, "198.51.100.0/24 192.0.2.1 1"),
radius_text_attribute(FRAMED_ROUTE, "203.0.113.0/24 192.0.2.1 1"),
ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 56, 0x2001, 0xdb8, 1),
ipv6_prefix_attribute(DELEGATED_IPV6_PREFIX, 48, 0x2001, 0xdb8, 2),
radius_attribute(
FRAMED_IPV6_ADDRESS,
&ipv6_bytes([0x2001, 0xdb8, 3, 0, 0, 0, 0, 5]),
),
]);
let event = AccountingEvent::from_request(&request);
assert_eq!(event.acct_session_id.as_deref(), Some("session-123"));
assert_eq!(event.nas_ip_address, Some(Ipv4Addr::new(192, 0, 2, 10)));
assert_eq!(event.nas_ipv6_address, Some("2001:db8::1".parse().unwrap()));
assert_eq!(event.nas_identifier.as_deref(), Some("nas-1"));
assert_eq!(event.nas_port, Some(17));
assert_eq!(event.nas_port_id.as_deref(), Some("ether5"));
assert_eq!(event.user_name.as_deref(), Some("subscriber@example.net"));
assert_eq!(
event.calling_station_id.as_deref(),
Some("00:11:22:33:44:55")
);
assert_eq!(event.called_station_id.as_deref(), Some("service"));
assert_eq!(
event.class,
vec![b"class-data".to_vec(), b"class-data-2".to_vec()]
);
assert_eq!(event.event_timestamp, Some(1_700_000_000));
assert_eq!(
event.framed_ip_address,
Some(Ipv4Addr::new(198, 51, 100, 25))
);
assert_eq!(
event.framed_ip_netmask,
Some(Ipv4Addr::new(255, 255, 255, 0))
);
assert_eq!(
event.framed_routes,
vec![
"198.51.100.0/24 192.0.2.1 1".to_string(),
"203.0.113.0/24 192.0.2.1 1".to_string()
]
);
assert_eq!(
event.framed_ipv6_prefixes,
vec![Ipv6Prefix {
address: "2001:db8:1::".parse().unwrap(),
prefix_len: 56,
}]
);
assert_eq!(
event.delegated_ipv6_prefixes,
vec![Ipv6Prefix {
address: "2001:db8:2::".parse().unwrap(),
prefix_len: 48,
}]
);
assert_eq!(
event.framed_ipv6_address,
Some("2001:db8:3::5".parse().unwrap())
);
assert_eq!(event.unknown_standard_attributes, Vec::new());
assert_eq!(event.unknown_vendor_attributes, Vec::new());
}
#[test]
fn extracts_compact_ipv6_prefix_fields() {
let request = request_with_attributes(&[
compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 0, &[]),
compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 56, &[0x20, 0x01, 0x0d, 0xb8, 0, 1, 0]),
compact_ipv6_prefix_attribute(
DELEGATED_IPV6_PREFIX,
64,
&[0x20, 0x01, 0x0d, 0xb8, 0, 2, 0, 0],
),
compact_ipv6_prefix_attribute(
DELEGATED_IPV6_PREFIX,
128,
&ipv6_bytes([0x2001, 0xdb8, 3, 0, 0, 0, 0, 1]),
),
compact_ipv6_prefix_attribute(
FRAMED_IPV6_PREFIX,
60,
&[0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xc0, 0],
),
]);
let event = AccountingEvent::from_request(&request);
assert_eq!(
event.framed_ipv6_prefixes,
vec![
Ipv6Prefix {
address: "::".parse().unwrap(),
prefix_len: 0,
},
Ipv6Prefix {
address: "2001:db8:1::".parse().unwrap(),
prefix_len: 56,
},
Ipv6Prefix {
address: "2001:db8:4:abc0::".parse().unwrap(),
prefix_len: 60,
},
]
);
assert_eq!(
event.delegated_ipv6_prefixes,
vec![
Ipv6Prefix {
address: "2001:db8:2::".parse().unwrap(),
prefix_len: 64,
},
Ipv6Prefix {
address: "2001:db8:3::1".parse().unwrap(),
prefix_len: 128,
},
]
);
assert_eq!(event.unknown_standard_attributes, Vec::new());
}
#[test]
fn preserves_known_standard_attributes_that_fail_typed_decoding() {
let request = request_with_attributes(&[
radius_attribute(NAS_IP_ADDRESS, &[192, 0, 2]),
radius_attribute(NAS_IPV6_ADDRESS, &[0; 15]),
radius_attribute(ACCT_STATUS_TYPE, &[1, 2, 3]),
radius_attribute(FRAMED_IPV6_ADDRESS, &[0; 15]),
compact_ipv6_prefix_attribute(FRAMED_IPV6_PREFIX, 64, &[0x20, 0x01]),
compact_ipv6_prefix_attribute(
DELEGATED_IPV6_PREFIX,
60,
&[0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xcd],
),
compact_ipv6_prefix_attribute(
FRAMED_IPV6_PREFIX,
56,
&[0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 1],
),
compact_ipv6_prefix_attribute_with_reserved(
FRAMED_IPV6_PREFIX,
1,
64,
&[0x20, 0x01, 0x0d, 0xb8, 0, 5, 0, 0],
),
]);
let event = AccountingEvent::from_request(&request);
assert_eq!(event.nas_ip_address, None);
assert_eq!(event.nas_ipv6_address, None);
assert_eq!(event.status_type, None);
assert_eq!(event.framed_ipv6_address, None);
assert_eq!(event.framed_ipv6_prefixes, Vec::new());
assert_eq!(event.delegated_ipv6_prefixes, Vec::new());
assert_unknown_standard_attributes(
&event,
&[
(NAS_IP_ADDRESS, &[192, 0, 2][..]),
(NAS_IPV6_ADDRESS, &[0; 15][..]),
(ACCT_STATUS_TYPE, &[1, 2, 3][..]),
(FRAMED_IPV6_ADDRESS, &[0; 15][..]),
(FRAMED_IPV6_PREFIX, &[0, 64, 0x20, 0x01][..]),
(
DELEGATED_IPV6_PREFIX,
&[0, 60, 0x20, 0x01, 0x0d, 0xb8, 0, 4, 0xab, 0xcd][..],
),
(
FRAMED_IPV6_PREFIX,
&[0, 56, 0x20, 0x01, 0x0d, 0xb8, 0, 1, 0, 1][..],
),
(
FRAMED_IPV6_PREFIX,
&[1, 64, 0x20, 0x01, 0x0d, 0xb8, 0, 5, 0, 0][..],
),
],
);
}
#[test]
fn extracts_from_verified_accounting_request() {
let attributes = radius_attributes(&[radius_text_attribute(ACCT_SESSION_ID, "verified")]);
let packet = signed_accounting_request_packet(21, &attributes, SHARED_SECRET);
let verified =
verify_accounting_request(&packet, SHARED_SECRET, MessageAuthenticatorPolicy::Optional)
.unwrap();
let event = AccountingEvent::from_verified(&verified);
assert_eq!(event.acct_session_id.as_deref(), Some("verified"));
}
#[test]
fn preserves_unknown_standard_and_vendor_attributes() {
let request = request_with_attributes(&[
radius_attribute(241, b"standard"),
radius_attribute(MESSAGE_AUTHENTICATOR_TYPE, &[0_u8; 16]),
radius_raw_vendor_attribute(12_345, b"opaque-vendor"),
radius_raw_vendor_attribute(54_321, b""),
radius_attribute(VENDOR_SPECIFIC, &[0, 0, 0]),
]);
let event = AccountingEvent::from_request(&request);
assert_unknown_standard_attributes(
&event,
&[
(241, b"standard"),
(MESSAGE_AUTHENTICATOR_TYPE, &[0_u8; 16][..]),
],
);
assert_eq!(
event.unknown_vendor_attributes,
vec![
UnknownVendorAttribute {
vendor_id: Some(12_345),
vendor_type: None,
value: b"opaque-vendor".to_vec(),
},
UnknownVendorAttribute {
vendor_id: Some(54_321),
vendor_type: None,
value: Vec::new(),
},
UnknownVendorAttribute {
vendor_id: None,
vendor_type: None,
value: vec![0, 0, 0],
}
]
);
}
#[test]
fn decodes_mikrotik_rate_limit_with_normal_direction_mapping() {
let request = request_with_attributes(&[radius_vendor_attribute(
MIKROTIK_VENDOR_ID,
MIKROTIK_RATE_LIMIT,
b"10M/25M 12M/30M 8M/20M 10/10 8 5M/15M",
)]);
let event = AccountingEvent::from_request(&request);
assert_eq!(event.unknown_vendor_attributes, Vec::new());
assert_eq!(
event.mikrotik_rate_limits,
vec![MikrotikRateLimit {
original: "10M/25M 12M/30M 8M/20M 10/10 8 5M/15M".to_string(),
nas_rx_bps: 10_000_000,
nas_tx_bps: 25_000_000,
upload_bps: 10_000_000,
download_bps: 25_000_000,
}]
);
}
#[test]
fn decodes_mikrotik_rate_limit_with_swapped_direction_mapping() {
let request = request_with_attributes(&[radius_vendor_attribute(
MIKROTIK_VENDOR_ID,
MIKROTIK_RATE_LIMIT,
b"512k/2M",
)]);
let options = AccountingEventOptions {
mikrotik_rate_limit_direction: MikrotikRateLimitDirection::Swapped,
};
let event = AccountingEvent::from_request_with_options(&request, options);
assert_eq!(event.mikrotik_rate_limits.len(), 1);
assert_eq!(event.mikrotik_rate_limits[0].nas_rx_bps, 512_000);
assert_eq!(event.mikrotik_rate_limits[0].nas_tx_bps, 2_000_000);
assert_eq!(event.mikrotik_rate_limits[0].upload_bps, 2_000_000);
assert_eq!(event.mikrotik_rate_limits[0].download_bps, 512_000);
}
#[test]
fn preserves_unknown_and_invalid_mikrotik_subattributes() {
let request = request_with_attributes(&[radius_vendor_attributes(
MIKROTIK_VENDOR_ID,
&[
radius_vendor_subattribute(MIKROTIK_RATE_LIMIT, b"10M/25M"),
radius_vendor_subattribute(99, b"opaque"),
radius_vendor_subattribute(MIKROTIK_RATE_LIMIT, b"not-a-rate"),
],
)]);
let event = AccountingEvent::from_request(&request);
assert_eq!(event.mikrotik_rate_limits.len(), 1);
assert_eq!(event.mikrotik_rate_limits[0].upload_bps, 10_000_000);
assert_eq!(event.mikrotik_rate_limits[0].download_bps, 25_000_000);
assert_eq!(
event.unknown_vendor_attributes,
vec![
UnknownVendorAttribute {
vendor_id: Some(MIKROTIK_VENDOR_ID),
vendor_type: Some(99),
value: b"opaque".to_vec(),
},
UnknownVendorAttribute {
vendor_id: Some(MIKROTIK_VENDOR_ID),
vendor_type: Some(MIKROTIK_RATE_LIMIT),
value: b"not-a-rate".to_vec(),
},
]
);
}
#[test]
fn preserves_malformed_mikrotik_subattributes() {
let request = request_with_attributes(&[radius_raw_vendor_attribute(
MIKROTIK_VENDOR_ID,
&[MIKROTIK_RATE_LIMIT, 1],
)]);
let event = AccountingEvent::from_request(&request);
assert_eq!(event.mikrotik_rate_limits, Vec::new());
assert_eq!(
event.unknown_vendor_attributes,
vec![UnknownVendorAttribute {
vendor_id: Some(MIKROTIK_VENDOR_ID),
vendor_type: Some(MIKROTIK_RATE_LIMIT),
value: vec![MIKROTIK_RATE_LIMIT, 1],
}]
);
}
fn assert_unknown_standard_attributes(event: &AccountingEvent, expected: &[(u8, &[u8])]) {
let actual: Vec<_> = event
.unknown_standard_attributes
.iter()
.map(|attribute| (attribute.kind(), attribute.value()))
.collect();
assert_eq!(actual.as_slice(), expected);
}
fn request_with_attributes(attributes: &[Vec<u8>]) -> AccountingRequest {
let encoded = radius_attributes(attributes);
handle_accounting_request(&accounting_request_packet(7, &encoded)).unwrap()
}
fn ipv6_prefix_attribute(kind: u8, prefix_len: u8, a: u16, b: u16, c: u16) -> Vec<u8> {
let mut value = Vec::with_capacity(18);
value.push(0);
value.push(prefix_len);
value.extend_from_slice(&ipv6_bytes([a, b, c, 0, 0, 0, 0, 0]));
radius_attribute(kind, &value)
}
fn compact_ipv6_prefix_attribute(kind: u8, prefix_len: u8, prefix_bytes: &[u8]) -> Vec<u8> {
compact_ipv6_prefix_attribute_with_reserved(kind, 0, prefix_len, prefix_bytes)
}
fn compact_ipv6_prefix_attribute_with_reserved(
kind: u8,
reserved: u8,
prefix_len: u8,
prefix_bytes: &[u8],
) -> Vec<u8> {
let mut value = Vec::with_capacity(2 + prefix_bytes.len());
value.push(reserved);
value.push(prefix_len);
value.extend_from_slice(prefix_bytes);
radius_attribute(kind, &value)
}
fn ipv6_bytes(segments: [u16; 8]) -> [u8; 16] {
let mut bytes = [0_u8; 16];
for (index, segment) in segments.into_iter().enumerate() {
bytes[index * 2..index * 2 + 2].copy_from_slice(&segment.to_be_bytes());
}
bytes
}
@@ -0,0 +1,4 @@
//! Crate-private RADIUS attribute type constants shared across modules.
pub(crate) const VENDOR_SPECIFIC: u8 = 26;
pub(crate) const ACCT_STATUS_TYPE: u8 = 40;
@@ -0,0 +1,74 @@
//! Dynamic-circuit command intents emitted by RADIUS session handling.
use crate::{AccountingEvent, AccountingSessionKey, NasResetStatus};
/// Sink boundary for dynamic-circuit intents produced by RADIUS session state.
pub trait DynamicCircuitCommandSink {
/// Receives one dynamic-circuit intent.
///
/// Side effects: depend on the sink implementation. The `lqos_radius` crate
/// only calls this boundary; it does not write dynamic circuit files or talk
/// to `lqosd` directly.
fn emit(&mut self, intent: DynamicCircuitIntent);
}
/// Dynamic-circuit intent that an lqosd-facing adapter can map onto daemon commands.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DynamicCircuitIntent {
/// Create a runtime dynamic circuit. Adapters can map this to `CreateDynamicCircuit`.
CreateDynamicCircuit(DynamicCircuitUpsert),
/// Update a runtime dynamic circuit. Adapters can map this to `CreateDynamicCircuit`.
UpdateDynamicCircuit(DynamicCircuitUpsert),
/// Remove a runtime dynamic circuit. Adapters can map this to `RemoveDynamicCircuit`.
RemoveDynamicCircuit(DynamicCircuitRemoval),
}
impl DynamicCircuitIntent {
/// Returns the stable dynamic-circuit identifier carried by this intent.
#[must_use]
pub fn circuit_id(&self) -> &str {
match self {
Self::CreateDynamicCircuit(upsert) | Self::UpdateDynamicCircuit(upsert) => {
&upsert.circuit_id
}
Self::RemoveDynamicCircuit(removal) => &removal.circuit_id,
}
}
}
/// Data needed to create or update a dynamic circuit from a RADIUS session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DynamicCircuitUpsert {
/// Stable circuit identifier for the dynamic circuit overlay.
pub circuit_id: String,
/// Deterministic RADIUS session key that produced this intent.
pub session_key: AccountingSessionKey,
/// Latest decoded accounting event data for the shapeable session.
pub event: AccountingEvent,
}
/// Data needed to remove a dynamic circuit from a RADIUS session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DynamicCircuitRemoval {
/// Stable circuit identifier for the dynamic circuit overlay.
pub circuit_id: String,
/// Deterministic RADIUS session key that produced this intent.
pub session_key: AccountingSessionKey,
/// Why this removal was emitted.
pub reason: DynamicCircuitRemovalReason,
}
/// Cause for a dynamic-circuit removal intent.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DynamicCircuitRemovalReason {
/// The RADIUS session emitted Acct-Status-Type Stop.
Stop,
/// The in-memory session was expired by the caller.
Expired,
/// A previously shapeable session no longer has enough data to stay active.
NoLongerShapeable,
/// A session was promoted to a different deterministic dynamic-circuit id.
Rekeyed,
/// A NAS reset event marked the session stale.
NasReset(NasResetStatus),
}
+42
View File
@@ -0,0 +1,42 @@
//! RADIUS accounting packet parsing and rootless listener support.
//!
//! The crate checks RADIUS packet framing, parses and verifies
//! Accounting-Request packets, builds Accounting-Response packets, tracks
//! decoded accounting sessions in memory, emits dynamic-circuit command intents
//! through an explicit sink boundary, and exposes a UDP listener that can bind
//! to non-privileged loopback addresses for development and automated tests.
#![warn(missing_docs)]
mod accounting_event;
mod attribute_type;
mod dynamic_circuit;
mod listener;
mod packet;
mod session;
#[cfg(test)]
mod test_support;
pub use accounting_event::{
AccountingEvent, AccountingEventOptions, AcctStatusType, Ipv6Prefix, MikrotikRateLimit,
MikrotikRateLimitDirection, UnknownVendorAttribute,
};
pub use dynamic_circuit::{
DynamicCircuitCommandSink, DynamicCircuitIntent, DynamicCircuitRemoval,
DynamicCircuitRemovalReason, DynamicCircuitUpsert,
};
pub use listener::{
AccountingListenerOutcome, DEFAULT_LISTEN_ADDR, ListenerConfig, ListenerError, RadiusListener,
ReceivedAccountingPacket, ReceivedVerifiedAccountingPacket, TrustedClientSource,
TrustedClientSourceError, TrustedRadiusClient, TrustedRadiusClientError, start_listener,
};
pub use packet::{
AccountingRequest, MessageAuthenticatorPolicy, PacketError, RadiusAttribute, RadiusCode,
RadiusPacket, VerifiedAccountingRequest, build_accounting_response, handle_accounting_request,
parse_packet, verify_accounting_request,
};
pub use session::{
AccountingSession, AccountingSessionIgnoreReason, AccountingSessionKey, AccountingSessionState,
AccountingSessionStore, AccountingSessionUpdate, DynamicCircuitMapping, NasIdentity,
NasResetStatus, PendingSessionFingerprint, PendingSessionReason,
};
+511
View File
@@ -0,0 +1,511 @@
//! UDP listener startup for RADIUS Accounting-Request packets.
use crate::VerifiedAccountingRequest;
use crate::packet::{
MessageAuthenticatorPolicy, PacketError, RADIUS_MAX_PACKET_LEN, handle_accounting_request,
verify_accounting_request,
};
use ip_network::{IpNetwork, IpNetworkError};
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::str::FromStr;
use thiserror::Error;
use tokio::net::UdpSocket;
/// Default non-privileged loopback listen address for local use and tests.
pub const DEFAULT_LISTEN_ADDR: SocketAddr =
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 18130));
/// Configuration used to start a RADIUS accounting listener.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ListenerConfig {
/// UDP address the listener will bind.
pub listen_addr: SocketAddr,
}
impl Default for ListenerConfig {
fn default() -> Self {
Self {
listen_addr: DEFAULT_LISTEN_ADDR,
}
}
}
/// A started UDP listener for RADIUS accounting datagrams.
pub struct RadiusListener {
socket: UdpSocket,
}
impl RadiusListener {
/// Returns the local UDP address bound by the listener.
///
/// Side effects: queries the operating system for the socket's local
/// address. It does not read packets or change socket state.
pub fn local_addr(&self) -> Result<SocketAddr, ListenerError> {
match self.socket.local_addr() {
Ok(addr) => Ok(addr),
Err(source) => Err(ListenerError::LocalAddr { source }),
}
}
/// Waits for and parses the next RADIUS Accounting-Request datagram.
///
/// Side effects: awaits network input on the listener's UDP socket.
/// Malformed or unsupported packets are returned as errors and no response
/// packets are sent. This diagnostic listener does not verify shared-secret
/// authenticators; callers that know the trusted client secret should use
/// `verify_accounting_request` before accepting a packet.
pub async fn receive_next(&self) -> Result<ReceivedAccountingPacket, ListenerError> {
let mut datagram = [0_u8; RADIUS_MAX_PACKET_LEN];
let (received_len, peer) = receive_datagram(&self.socket, &mut datagram).await?;
let request = match handle_accounting_request(&datagram[..received_len]) {
Ok(request) => request,
Err(source) => return Err(ListenerError::Packet { peer, source }),
};
Ok(ReceivedAccountingPacket {
peer,
received_len,
request,
})
}
/// Waits for the next datagram, verifies it against trusted clients, and
/// sends Accounting-Response packets for accepted Accounting-Requests.
///
/// Side effects: awaits network input on the listener socket and sends one
/// UDP Accounting-Response when the packet source and authenticators are
/// trusted. Rejected packets do not produce accounting events and do not
/// receive responses.
pub async fn receive_next_verified(
&self,
clients: &[TrustedRadiusClient],
) -> Result<AccountingListenerOutcome, ListenerError> {
let mut datagram = [0_u8; RADIUS_MAX_PACKET_LEN];
let (received_len, peer) = receive_datagram(&self.socket, &mut datagram).await?;
let mut matched_client = None;
for client in clients {
if !client.source_matches(peer.ip()) {
continue;
}
if matched_client.replace(client).is_some() {
return Ok(AccountingListenerOutcome::RejectedAmbiguousSource {
peer,
received_len,
});
}
}
let Some(client) = matched_client else {
return Ok(AccountingListenerOutcome::RejectedSource { peer, received_len });
};
let request = match verify_accounting_request(
&datagram[..received_len],
client.shared_secret(),
client.message_authenticator_policy(),
) {
Ok(request) => request,
Err(source) => {
return Ok(AccountingListenerOutcome::RejectedPacket {
peer,
received_len,
source,
});
}
};
let response = request.build_response(client.shared_secret());
let response_len = match self.socket.send_to(&response, peer).await {
Ok(response_len) => response_len,
Err(source) => return Err(ListenerError::Send { peer, source }),
};
Ok(AccountingListenerOutcome::Accepted(
ReceivedVerifiedAccountingPacket {
peer,
received_len,
response_len,
request,
},
))
}
}
/// One parse-only Accounting-Request datagram received by the listener.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReceivedAccountingPacket {
/// UDP peer address that sent the datagram.
pub peer: SocketAddr,
/// Number of bytes received in the datagram.
pub received_len: usize,
/// Parsed, unverified Accounting-Request packet.
pub request: crate::AccountingRequest,
}
/// Runtime allow-list entry for a trusted RADIUS client source.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrustedClientSource {
network: IpNetwork,
}
impl TrustedClientSource {
/// Creates a source matcher for one host IP address.
#[must_use]
pub fn host(address: IpAddr) -> Self {
Self {
network: IpNetwork::from(address),
}
}
/// Creates a source matcher for an IP network.
///
/// Side effects: none. The supplied address must be the network address for
/// the prefix length.
pub fn network(address: IpAddr, prefix_len: u8) -> Result<Self, TrustedClientSourceError> {
source_network(address, prefix_len).map(|network| Self { network })
}
/// Returns the canonical source address or network address.
#[must_use]
pub fn address(&self) -> IpAddr {
self.network.network_address()
}
/// Returns the source prefix length.
#[must_use]
pub fn prefix_len(&self) -> u8 {
self.network.netmask()
}
/// Returns true when `address` belongs to this source allow-list entry.
#[must_use]
pub fn contains(&self, address: IpAddr) -> bool {
self.network.contains(address)
}
}
impl FromStr for TrustedClientSource {
type Err = TrustedClientSourceError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(TrustedClientSourceError::Empty);
}
let Some((address, prefix_len)) = trimmed.split_once('/') else {
return trimmed.parse::<IpAddr>().map(Self::host).map_err(|_| {
TrustedClientSourceError::InvalidAddress {
raw: trimmed.to_string(),
}
});
};
let address =
address
.parse::<IpAddr>()
.map_err(|_| TrustedClientSourceError::InvalidAddress {
raw: address.to_string(),
})?;
let prefix_len = prefix_len.parse::<u8>().map_err(|_| {
TrustedClientSourceError::InvalidPrefixLengthText {
raw: prefix_len.to_string(),
}
})?;
source_network(address, prefix_len).map(|network| Self { network })
}
}
/// Errors returned while building trusted RADIUS client source matchers.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum TrustedClientSourceError {
/// The supplied source text is empty.
#[error("RADIUS client source must not be empty")]
Empty,
/// The supplied source text does not contain a valid IP address.
#[error("RADIUS client source address '{raw}' is invalid")]
InvalidAddress {
/// Source address text supplied by the caller.
raw: String,
},
/// The supplied CIDR prefix is not numeric.
#[error("RADIUS client source prefix '{raw}' is invalid")]
InvalidPrefixLengthText {
/// Prefix text supplied by the caller.
raw: String,
},
/// The supplied prefix length is outside the address-family range.
#[error(
"RADIUS client source prefix length {prefix_len} is invalid for {address}; valid range is 0..={max_prefix_len}"
)]
InvalidPrefixLength {
/// Address supplied for the source matcher.
address: IpAddr,
/// Prefix length supplied for the source matcher.
prefix_len: u8,
/// Maximum prefix length for the address family.
max_prefix_len: u8,
},
/// The supplied CIDR string contains host bits in the network address.
#[error("RADIUS client source '{address}/{prefix_len}' has host bits set")]
HostBitsSet {
/// Address supplied for the source matcher.
address: IpAddr,
/// Prefix length supplied for the source matcher.
prefix_len: u8,
},
}
/// Runtime trusted RADIUS client with source matching and shared secret.
#[derive(Clone, Eq, PartialEq)]
pub struct TrustedRadiusClient {
sources: Vec<TrustedClientSource>,
shared_secret: Vec<u8>,
message_authenticator_policy: MessageAuthenticatorPolicy,
}
impl TrustedRadiusClient {
/// Creates a trusted RADIUS client using optional Message-Authenticator
/// policy.
///
/// Side effects: none. The shared secret is copied into owned memory for
/// packet verification and response signing.
pub fn new(
sources: Vec<TrustedClientSource>,
shared_secret: impl Into<Vec<u8>>,
) -> Result<Self, TrustedRadiusClientError> {
Self::with_message_authenticator_policy(
sources,
shared_secret,
MessageAuthenticatorPolicy::Optional,
)
}
/// Creates a trusted RADIUS client with an explicit Message-Authenticator
/// policy.
///
/// Side effects: none. The shared secret is copied into owned memory for
/// packet verification and response signing.
pub fn with_message_authenticator_policy(
sources: Vec<TrustedClientSource>,
shared_secret: impl Into<Vec<u8>>,
message_authenticator_policy: MessageAuthenticatorPolicy,
) -> Result<Self, TrustedRadiusClientError> {
if sources.is_empty() {
return Err(TrustedRadiusClientError::NoSources);
}
let shared_secret = shared_secret.into();
if shared_secret.is_empty() {
return Err(TrustedRadiusClientError::EmptySharedSecret);
}
Ok(Self {
sources,
shared_secret,
message_authenticator_policy,
})
}
/// Returns the source allow-list for this client.
#[must_use]
pub fn sources(&self) -> &[TrustedClientSource] {
&self.sources
}
/// Returns the shared secret used for packet verification and response
/// signing.
#[must_use]
pub fn shared_secret(&self) -> &[u8] {
&self.shared_secret
}
/// Returns the Message-Authenticator policy for this client.
#[must_use]
pub const fn message_authenticator_policy(&self) -> MessageAuthenticatorPolicy {
self.message_authenticator_policy
}
fn source_matches(&self, address: IpAddr) -> bool {
self.sources.iter().any(|source| source.contains(address))
}
}
impl fmt::Debug for TrustedRadiusClient {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TrustedRadiusClient")
.field("sources", &self.sources)
.field("shared_secret", &"REDACTED")
.field(
"message_authenticator_policy",
&self.message_authenticator_policy,
)
.finish()
}
}
/// Errors returned while building trusted RADIUS clients.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum TrustedRadiusClientError {
/// The client has no source allow-list entries.
#[error("trusted RADIUS client must include at least one source")]
NoSources,
/// The client has no shared secret bytes.
#[error("trusted RADIUS client shared secret must not be empty")]
EmptySharedSecret,
}
/// Result of handling one verified-listener UDP datagram.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AccountingListenerOutcome {
/// The datagram was verified and received an Accounting-Response.
Accepted(ReceivedVerifiedAccountingPacket),
/// The datagram source IP did not match any trusted client.
RejectedSource {
/// UDP peer address that sent the rejected datagram.
peer: SocketAddr,
/// Number of bytes received in the rejected datagram.
received_len: usize,
},
/// The datagram source IP matched more than one trusted client, so the
/// listener rejected it instead of guessing which shared secret should apply.
RejectedAmbiguousSource {
/// UDP peer address that sent the rejected datagram.
peer: SocketAddr,
/// Number of bytes received in the rejected datagram.
received_len: usize,
},
/// The datagram source matched a trusted client, but packet parsing or
/// authenticator verification failed.
RejectedPacket {
/// UDP peer address that sent the rejected datagram.
peer: SocketAddr,
/// Number of bytes received in the rejected datagram.
received_len: usize,
/// Packet parsing or authenticator verification error.
source: PacketError,
},
}
/// One accepted and verified Accounting-Request handled by the listener.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReceivedVerifiedAccountingPacket {
/// UDP peer address that sent the datagram.
pub peer: SocketAddr,
/// Number of bytes received in the accepted datagram.
pub received_len: usize,
/// Number of bytes sent in the Accounting-Response datagram.
pub response_len: usize,
/// Verified Accounting-Request packet.
pub request: VerifiedAccountingRequest,
}
/// Errors returned while starting or receiving from the RADIUS listener.
#[derive(Debug, Error)]
pub enum ListenerError {
/// The UDP listener could not bind the requested address.
#[error("failed to bind RADIUS UDP listener to {addr}: {source}")]
Bind {
/// Address that failed to bind.
addr: SocketAddr,
/// Operating-system bind error.
#[source]
source: std::io::Error,
},
/// The UDP listener could not receive a datagram.
#[error("failed to receive RADIUS UDP datagram: {source}")]
Receive {
/// Operating-system receive error.
#[source]
source: std::io::Error,
},
/// The UDP listener could not send a response datagram.
#[error("failed to send RADIUS Accounting-Response to {peer}: {source}")]
Send {
/// UDP peer address that should have received the response.
peer: SocketAddr,
/// Operating-system send error.
#[source]
source: std::io::Error,
},
/// The listener's local address could not be queried.
#[error("failed to read RADIUS UDP listener local address: {source}")]
LocalAddr {
/// Operating-system local-address error.
#[source]
source: std::io::Error,
},
/// The listener received a malformed or unsupported packet.
#[error("received malformed or unsupported RADIUS accounting packet from {peer}: {source}")]
Packet {
/// UDP peer address that sent the rejected datagram.
peer: SocketAddr,
/// Packet parsing error.
#[source]
source: PacketError,
},
}
async fn receive_datagram(
socket: &UdpSocket,
datagram: &mut [u8; RADIUS_MAX_PACKET_LEN],
) -> Result<(usize, SocketAddr), ListenerError> {
match socket.recv_from(datagram).await {
Ok(received) => Ok(received),
Err(source) => Err(ListenerError::Receive { source }),
}
}
const fn max_prefix_len(address: IpAddr) -> u8 {
match address {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
}
}
fn network_error(
address: IpAddr,
prefix_len: u8,
source: IpNetworkError,
) -> TrustedClientSourceError {
match source {
IpNetworkError::NetmaskError(_) => TrustedClientSourceError::InvalidPrefixLength {
address,
prefix_len,
max_prefix_len: max_prefix_len(address),
},
IpNetworkError::HostBitsSet => TrustedClientSourceError::HostBitsSet {
address,
prefix_len,
},
}
}
fn source_network(address: IpAddr, prefix_len: u8) -> Result<IpNetwork, TrustedClientSourceError> {
IpNetwork::new(address, prefix_len).map_err(|source| network_error(address, prefix_len, source))
}
/// Starts a UDP listener for RADIUS accounting packets.
///
/// Side effects: binds a UDP socket to `config.listen_addr`. This function does
/// not touch TC/XDP state, services, files, or privileged ports unless the caller
/// explicitly supplies such an address.
pub async fn start_listener(config: ListenerConfig) -> Result<RadiusListener, ListenerError> {
let socket = match UdpSocket::bind(config.listen_addr).await {
Ok(socket) => socket,
Err(source) => {
return Err(ListenerError::Bind {
addr: config.listen_addr,
source,
});
}
};
Ok(RadiusListener { socket })
}
#[cfg(test)]
mod tests;
+550
View File
@@ -0,0 +1,550 @@
//! Tests for RADIUS UDP listener behavior.
use super::*;
use crate::attribute_type::ACCT_STATUS_TYPE;
use crate::packet::{RADIUS_HEADER_LEN, RADIUS_MAX_ACCOUNTING_PACKET_LEN, RADIUS_MAX_PACKET_LEN};
use crate::test_support::{
SHARED_SECRET, accounting_request_packet, accounting_request_packet_with_message_authenticator,
max_sized_accounting_request_packet, radius_attributes, radius_packet, radius_text_attribute,
radius_u32_attribute, signed_accounting_request_packet,
};
use crate::{PacketError, RadiusCode, parse_packet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::time::timeout;
const ACCT_SESSION_ID: u8 = 44;
const UDP_NO_RESPONSE_TIMEOUT: Duration = Duration::from_millis(25);
const UDP_TEST_TIMEOUT: Duration = Duration::from_secs(1);
#[test]
fn default_listener_address_is_rootless_loopback() {
let config = ListenerConfig::default();
assert!(config.listen_addr.ip().is_loopback());
assert!(config.listen_addr.port() > 1024);
}
mod udp_listener {
//! Loopback UDP listener tests.
use super::*;
#[tokio::test]
async fn receives_accounting_request_on_loopback_ephemeral_port() {
let fixture = LoopbackUdpFixture::bind().await;
fixture.send(&accounting_request_packet(11, &[])).await;
let received = fixture.receive_next().await.unwrap();
assert_eq!(received.peer, fixture.sender_addr());
assert_eq!(received.received_len, RADIUS_HEADER_LEN);
assert_eq!(received.request.packet().identifier(), 11);
}
#[tokio::test]
async fn receives_maximum_sized_accounting_request() {
let fixture = LoopbackUdpFixture::bind().await;
fixture.send(&max_sized_accounting_request_packet(14)).await;
let received = fixture.receive_next().await.unwrap();
assert_eq!(received.received_len, RADIUS_MAX_ACCOUNTING_PACKET_LEN);
assert_eq!(received.request.packet().identifier(), 14);
assert_eq!(received.request.packet().attributes().len(), 16);
}
#[tokio::test]
async fn reports_packet_errors_with_sender_address() {
let fixture = LoopbackUdpFixture::bind().await;
fixture
.send(&radius_packet(RadiusCode::AccessRequest, 12, &[]))
.await;
let err = fixture.receive_next().await.unwrap_err();
let ListenerError::Packet { peer, source } = err else {
panic!("expected packet parsing error, got {err:?}");
};
assert_eq!(peer, fixture.sender_addr());
assert_eq!(source, PacketError::UnsupportedCode { code: 1 });
}
#[tokio::test]
async fn reports_malformed_datagrams_with_sender_address() {
let fixture = LoopbackUdpFixture::bind().await;
fixture
.send(&[RadiusCode::AccountingRequest.as_u8(), 13])
.await;
let err = fixture.receive_next().await.unwrap_err();
let ListenerError::Packet { peer, source } = err else {
panic!("expected packet parsing error, got {err:?}");
};
assert_eq!(peer, fixture.sender_addr());
assert_eq!(source, PacketError::PacketTooShort { actual: 2 });
fixture.send(&accounting_request_packet(15, &[])).await;
let received = fixture.receive_next().await.unwrap();
assert_eq!(received.peer, fixture.sender_addr());
assert_eq!(received.request.packet().identifier(), 15);
}
#[tokio::test]
async fn verified_listener_accepts_request_and_sends_response() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = trusted_loopback_clients(SHARED_SECRET);
let attributes = radius_attributes(&[
radius_u32_attribute(ACCT_STATUS_TYPE, 1),
radius_text_attribute(ACCT_SESSION_ID, "session-accepted"),
]);
let request = signed_accounting_request_packet(21, &attributes, SHARED_SECRET);
let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await);
let response = fixture.receive_response().await;
assert_eq!(accepted.peer, fixture.sender_addr());
assert_eq!(accepted.received_len, request.len());
assert_eq!(accepted.response_len, response.len());
assert_eq!(accepted.request.packet().identifier(), 21);
let event = crate::AccountingEvent::from_verified(&accepted.request);
assert_eq!(event.acct_session_id.as_deref(), Some("session-accepted"));
assert_eq!(response, accepted.request.build_response(SHARED_SECRET));
assert_accounting_response(&response, 21);
}
#[tokio::test]
async fn verified_listener_accepts_later_source_in_client_allow_list() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = vec![
TrustedRadiusClient::new(
vec![alternate_loopback_source(), trusted_loopback_source()],
SHARED_SECRET,
)
.unwrap(),
];
let request = signed_accounting_request_packet(31, &[], SHARED_SECRET);
let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await);
let response = fixture.receive_response().await;
assert_eq!(accepted.request.packet().identifier(), 31);
assert_eq!(response, accepted.request.build_response(SHARED_SECRET));
assert_accounting_response(&response, 31);
}
#[tokio::test]
async fn verified_listener_accepts_message_authenticator_when_required() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = trusted_loopback_clients_with_policy(
SHARED_SECRET,
MessageAuthenticatorPolicy::Required,
);
let request = accounting_request_packet_with_message_authenticator(28, &[], SHARED_SECRET);
let accepted = expect_accepted(fixture.verified_outcome(&request, &clients).await);
let response = fixture.receive_response().await;
assert_eq!(accepted.request.packet().identifier(), 28);
assert!(accepted.request.has_message_authenticator());
assert_eq!(accepted.response_len, response.len());
assert_eq!(response, accepted.request.build_response(SHARED_SECRET));
assert_accounting_response(&response, 28);
}
#[tokio::test]
async fn verified_listener_rejects_missing_message_authenticator_when_required() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = trusted_loopback_clients_with_policy(
SHARED_SECRET,
MessageAuthenticatorPolicy::Required,
);
let request = signed_accounting_request_packet(29, &[], SHARED_SECRET);
let outcome = fixture
.verified_outcome_without_response(&request, &clients)
.await;
assert_eq!(
outcome,
AccountingListenerOutcome::RejectedPacket {
peer: fixture.sender_addr(),
received_len: request.len(),
source: PacketError::MissingMessageAuthenticator,
}
);
}
#[tokio::test]
async fn verified_listener_rejects_ambiguous_source_before_parsing() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = vec![
TrustedRadiusClient::new(
vec![
TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8)
.unwrap(),
],
SHARED_SECRET,
)
.unwrap(),
TrustedRadiusClient::new(vec![trusted_loopback_source()], SHARED_SECRET).unwrap(),
];
let request = [RadiusCode::AccountingRequest.as_u8(), 24];
let outcome = fixture
.verified_outcome_without_response(&request, &clients)
.await;
assert_eq!(
outcome,
AccountingListenerOutcome::RejectedAmbiguousSource {
peer: fixture.sender_addr(),
received_len: request.len(),
}
);
}
#[tokio::test]
async fn verified_listener_rejects_unconfigured_source_without_event_or_response() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = alternate_loopback_clients();
let request = signed_accounting_request_packet(22, &[], SHARED_SECRET);
let outcome = fixture
.verified_outcome_without_response(&request, &clients)
.await;
assert_eq!(
outcome,
AccountingListenerOutcome::RejectedSource {
peer: fixture.sender_addr(),
received_len: request.len(),
}
);
}
#[tokio::test]
async fn verified_listener_rejects_malformed_untrusted_source_before_parsing() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = alternate_loopback_clients();
let request = [RadiusCode::AccountingRequest.as_u8(), 27];
let outcome = fixture
.verified_outcome_without_response(&request, &clients)
.await;
assert_eq!(
outcome,
AccountingListenerOutcome::RejectedSource {
peer: fixture.sender_addr(),
received_len: request.len(),
}
);
}
#[tokio::test]
async fn verified_listener_rejects_malformed_trusted_packet_without_response() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = trusted_loopback_clients(SHARED_SECRET);
let request = [RadiusCode::AccountingRequest.as_u8(), 26];
let outcome = fixture
.verified_outcome_without_response(&request, &clients)
.await;
assert_eq!(
outcome,
AccountingListenerOutcome::RejectedPacket {
peer: fixture.sender_addr(),
received_len: request.len(),
source: PacketError::PacketTooShort { actual: 2 },
}
);
}
#[tokio::test]
async fn verified_listener_rejects_unsupported_trusted_packet_without_response() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = trusted_loopback_clients(SHARED_SECRET);
let request = radius_packet(RadiusCode::AccessRequest, 30, &[]);
let outcome = fixture
.verified_outcome_without_response(&request, &clients)
.await;
assert_eq!(
outcome,
AccountingListenerOutcome::RejectedPacket {
peer: fixture.sender_addr(),
received_len: request.len(),
source: PacketError::UnsupportedCode { code: 1 },
}
);
}
#[tokio::test]
async fn verified_listener_rejects_invalid_authenticator_without_event_or_response() {
let fixture = LoopbackUdpFixture::bind().await;
let clients = trusted_loopback_clients(SHARED_SECRET);
let request = signed_accounting_request_packet(23, &[], b"wrong-secret");
let outcome = fixture
.verified_outcome_without_response(&request, &clients)
.await;
assert_eq!(
outcome,
AccountingListenerOutcome::RejectedPacket {
peer: fixture.sender_addr(),
received_len: request.len(),
source: PacketError::InvalidRequestAuthenticator,
}
);
}
}
#[test]
fn trusted_client_source_matches_exact_hosts_and_networks() {
let host = TrustedClientSource::host(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let network =
TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 0)), 24).unwrap();
let ipv6_network = TrustedClientSource::network("2001:db8::".parse().unwrap(), 64).unwrap();
assert!(host.contains(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
assert!(!host.contains(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))));
assert_eq!(network.address(), IpAddr::V4(Ipv4Addr::new(192, 0, 2, 0)));
assert!(network.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 25))));
assert!(!network.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 3, 25))));
assert_eq!(
ipv6_network.address(),
"2001:db8::".parse::<IpAddr>().unwrap()
);
assert!(ipv6_network.contains("2001:db8::abcd".parse().unwrap()));
assert!(!ipv6_network.contains("2001:db9::abcd".parse().unwrap()));
}
#[test]
fn trusted_client_source_parses_hosts_and_cidr_networks() {
assert_eq!(
"127.0.0.1".parse::<TrustedClientSource>().unwrap(),
trusted_loopback_source()
);
assert_eq!(
"2001:db8::/64".parse::<TrustedClientSource>().unwrap(),
TrustedClientSource::network("2001:db8::".parse().unwrap(), 64).unwrap()
);
assert_eq!(
"192.0.2.1/33".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::InvalidPrefixLength {
address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
prefix_len: 33,
max_prefix_len: 32,
})
);
assert_eq!(
"2001:db8::/129".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::InvalidPrefixLength {
address: "2001:db8::".parse().unwrap(),
prefix_len: 129,
max_prefix_len: 128,
})
);
}
#[test]
fn trusted_client_source_rejects_invalid_text() {
assert_eq!(
"".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::Empty)
);
assert_eq!(
"not-an-address".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::InvalidAddress {
raw: "not-an-address".to_string(),
})
);
assert_eq!(
"192.0.2.1/nope".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::InvalidPrefixLengthText {
raw: "nope".to_string(),
})
);
assert_eq!(
"192.0.2.129/24".parse::<TrustedClientSource>(),
Err(TrustedClientSourceError::HostBitsSet {
address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)),
prefix_len: 24,
})
);
assert_eq!(
TrustedClientSource::network(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)), 24),
Err(TrustedClientSourceError::HostBitsSet {
address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)),
prefix_len: 24,
})
);
}
#[test]
fn trusted_radius_client_rejects_missing_sources_or_secret() {
assert_eq!(
TrustedRadiusClient::with_message_authenticator_policy(
Vec::new(),
SHARED_SECRET,
MessageAuthenticatorPolicy::Required,
),
Err(TrustedRadiusClientError::NoSources)
);
assert_eq!(
TrustedRadiusClient::with_message_authenticator_policy(
vec![trusted_loopback_source()],
Vec::new(),
MessageAuthenticatorPolicy::Required,
),
Err(TrustedRadiusClientError::EmptySharedSecret)
);
}
struct LoopbackUdpFixture {
listener: RadiusListener,
listener_addr: SocketAddr,
sender: UdpSocket,
}
impl LoopbackUdpFixture {
async fn bind() -> Self {
let listener = start_listener(ListenerConfig {
listen_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
})
.await
.unwrap();
let listener_addr = listener.local_addr().unwrap();
let sender = UdpSocket::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
Self {
listener,
listener_addr,
sender,
}
}
fn sender_addr(&self) -> SocketAddr {
self.sender.local_addr().unwrap()
}
async fn send(&self, packet: &[u8]) {
self.sender
.send_to(packet, self.listener_addr)
.await
.unwrap();
}
async fn receive_next(&self) -> Result<ReceivedAccountingPacket, ListenerError> {
timeout(UDP_TEST_TIMEOUT, self.listener.receive_next())
.await
.unwrap()
}
async fn verified_outcome(
&self,
packet: &[u8],
clients: &[TrustedRadiusClient],
) -> AccountingListenerOutcome {
self.send(packet).await;
timeout(
UDP_TEST_TIMEOUT,
self.listener.receive_next_verified(clients),
)
.await
.unwrap()
.unwrap()
}
async fn verified_outcome_without_response(
&self,
packet: &[u8],
clients: &[TrustedRadiusClient],
) -> AccountingListenerOutcome {
let outcome = self.verified_outcome(packet, clients).await;
self.assert_no_response().await;
outcome
}
async fn receive_response(&self) -> Vec<u8> {
let mut response = [0_u8; RADIUS_MAX_PACKET_LEN];
let (response_len, peer) = timeout(UDP_TEST_TIMEOUT, self.sender.recv_from(&mut response))
.await
.unwrap()
.unwrap();
assert!(peer.ip().is_loopback());
response[..response_len].to_vec()
}
async fn assert_no_response(&self) {
let mut response = [0_u8; RADIUS_MAX_PACKET_LEN];
match timeout(
UDP_NO_RESPONSE_TIMEOUT,
self.sender.recv_from(&mut response),
)
.await
{
Err(_) => {}
Ok(Ok((response_len, peer))) => {
panic!("rejected packet received {response_len} response bytes from {peer}");
}
Ok(Err(source)) => panic!("failed to check for rejected-packet response: {source}"),
}
}
}
fn trusted_loopback_clients(shared_secret: &[u8]) -> Vec<TrustedRadiusClient> {
trusted_loopback_clients_with_policy(shared_secret, MessageAuthenticatorPolicy::Optional)
}
fn trusted_loopback_clients_with_policy(
shared_secret: &[u8],
policy: MessageAuthenticatorPolicy,
) -> Vec<TrustedRadiusClient> {
vec![
TrustedRadiusClient::with_message_authenticator_policy(
vec![trusted_loopback_source()],
shared_secret,
policy,
)
.unwrap(),
]
}
fn alternate_loopback_clients() -> Vec<TrustedRadiusClient> {
vec![TrustedRadiusClient::new(vec![alternate_loopback_source()], SHARED_SECRET).unwrap()]
}
fn trusted_loopback_source() -> TrustedClientSource {
TrustedClientSource::host(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
fn alternate_loopback_source() -> TrustedClientSource {
TrustedClientSource::host(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))
}
fn expect_accepted(outcome: AccountingListenerOutcome) -> ReceivedVerifiedAccountingPacket {
match outcome {
AccountingListenerOutcome::Accepted(accepted) => accepted,
other => panic!("expected accepted packet, got {other:?}"),
}
}
fn assert_accounting_response(response: &[u8], identifier: u8) {
let parsed = parse_packet(response).unwrap();
assert_eq!(parsed.code(), RadiusCode::AccountingResponse);
assert_eq!(parsed.identifier(), identifier);
assert_eq!(parsed.attributes(), &[]);
}
+206
View File
@@ -0,0 +1,206 @@
//! Rootless diagnostic executable for the LibreQoS RADIUS accounting listener.
use clap::{ArgGroup, Parser};
use lqos_radius::{
AccountingListenerOutcome, DEFAULT_LISTEN_ADDR, ListenerConfig, ListenerError, RadiusListener,
TrustedClientSource, TrustedRadiusClient, start_listener,
};
use std::net::SocketAddr;
use thiserror::Error;
#[derive(Parser)]
#[command(
name = "lqos_radius",
about = "Run the LibreQoS RADIUS accounting diagnostic listener.",
group(
ArgGroup::new("run_mode")
.required(true)
.args(["parse_only", "shared_secret"])
)
)]
struct Args {
#[arg(long, value_name = "ADDR", default_value_t = DEFAULT_LISTEN_ADDR)]
listen: SocketAddr,
#[arg(long, conflicts_with_all = ["client_sources", "shared_secret"])]
parse_only: bool,
#[arg(
long = "client-source",
value_name = "IP_OR_CIDR",
requires = "shared_secret"
)]
client_sources: Vec<TrustedClientSource>,
#[arg(
long,
value_name = "SECRET",
requires = "client_sources",
value_parser = clap::builder::NonEmptyStringValueParser::new()
)]
shared_secret: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), MainError> {
let args = Args::parse();
let listen = args.listen;
let run_mode = args.run_mode();
let listener = start_listener(ListenerConfig {
listen_addr: listen,
})
.await?;
let local_addr = listener.local_addr()?;
println!("Listening for RADIUS accounting packets on {local_addr}");
match run_mode {
RunMode::ParseOnly => parse_only_loop(&listener).await,
RunMode::Verified(clients) => verified_loop(&listener, &clients).await,
}
}
impl Args {
fn run_mode(self) -> RunMode {
let Self {
listen: _,
parse_only,
client_sources,
shared_secret,
} = self;
if parse_only {
return RunMode::ParseOnly;
}
let shared_secret = shared_secret.expect("clap requires --shared-secret in verified mode");
let client = TrustedRadiusClient::new(client_sources, shared_secret.into_bytes()).expect(
"clap requires at least one --client-source and a non-empty --shared-secret in verified mode",
);
RunMode::Verified(vec![client])
}
}
enum RunMode {
ParseOnly,
Verified(Vec<TrustedRadiusClient>),
}
#[derive(Debug, Error)]
enum MainError {
#[error(transparent)]
Listener(#[from] ListenerError),
}
async fn parse_only_loop(listener: &RadiusListener) -> Result<(), MainError> {
loop {
match listener.receive_next().await {
Ok(received) => println!(
"Parsed Accounting-Request from {}: identifier={}, attributes={}",
received.peer,
received.request.packet().identifier(),
received.request.packet().attributes().len()
),
Err(ListenerError::Packet { peer, source }) => {
eprintln!("Discarded malformed or unsupported RADIUS packet from {peer}: {source}");
}
Err(err) => return Err(err.into()),
}
}
}
async fn verified_loop(
listener: &RadiusListener,
clients: &[TrustedRadiusClient],
) -> Result<(), MainError> {
loop {
match listener.receive_next_verified(clients).await {
Ok(AccountingListenerOutcome::Accepted(accepted)) => println!(
"Accepted Accounting-Request from {}: identifier={}, attributes={}, response_bytes={}",
accepted.peer,
accepted.request.packet().identifier(),
accepted.request.packet().attributes().len(),
accepted.response_len
),
Ok(AccountingListenerOutcome::RejectedSource { peer, .. }) => {
eprintln!("Discarded RADIUS packet from unconfigured source {peer}");
}
Ok(AccountingListenerOutcome::RejectedAmbiguousSource { peer, .. }) => {
eprintln!("Discarded RADIUS packet from ambiguous trusted source {peer}");
}
Ok(AccountingListenerOutcome::RejectedPacket { peer, source, .. }) => {
eprintln!("Rejected RADIUS packet from {peer}: {source}");
}
Err(ListenerError::Send { peer, source }) => {
eprintln!("Failed to send RADIUS Accounting-Response to {peer}: {source}");
}
Err(err) => return Err(err.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
#[test]
fn cli_parser_accepts_parse_only_mode() {
let args = Args::try_parse_from(["lqos_radius", "--parse-only"]).unwrap();
assert_eq!(args.listen, DEFAULT_LISTEN_ADDR);
assert!(matches!(args.run_mode(), RunMode::ParseOnly));
}
#[test]
fn cli_parser_accepts_verified_mode_with_multiple_sources() {
let args = Args::try_parse_from([
"lqos_radius",
"--listen",
"127.0.0.1:0",
"--client-source",
"127.0.0.1",
"--client-source",
"192.0.2.0/24",
"--shared-secret",
"radius-secret",
])
.unwrap();
assert_eq!(args.listen, SocketAddr::from((Ipv4Addr::LOCALHOST, 0)));
let RunMode::Verified(clients) = args.run_mode() else {
panic!("expected verified run mode");
};
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].sources().len(), 2);
assert!(clients[0].sources()[0].contains(Ipv4Addr::LOCALHOST.into()));
assert!(clients[0].sources()[1].contains(Ipv4Addr::new(192, 0, 2, 99).into()));
assert_eq!(clients[0].shared_secret(), b"radius-secret");
}
#[test]
fn cli_parser_rejects_missing_or_conflicting_modes() {
assert!(Args::try_parse_from(["lqos_radius"]).is_err());
assert!(
Args::try_parse_from(["lqos_radius", "--parse-only", "--shared-secret", "secret"])
.is_err()
);
assert!(
Args::try_parse_from([
"lqos_radius",
"--parse-only",
"--client-source",
"127.0.0.1"
])
.is_err()
);
assert!(Args::try_parse_from(["lqos_radius", "--client-source", "127.0.0.1"]).is_err());
assert!(Args::try_parse_from(["lqos_radius", "--shared-secret", "secret"]).is_err());
assert!(
Args::try_parse_from([
"lqos_radius",
"--client-source",
"127.0.0.1",
"--shared-secret",
"",
])
.is_err()
);
}
}
+640
View File
@@ -0,0 +1,640 @@
//! RADIUS packet framing, Accounting-Request verification, and response building.
use hmac::{Hmac, Mac};
use md5::{Digest, Md5};
use subtle::ConstantTimeEq;
use thiserror::Error;
type HmacMd5 = Hmac<Md5>;
pub(crate) const RADIUS_HEADER_LEN: usize = 20;
pub(crate) const RADIUS_MAX_PACKET_LEN: usize = 4096;
// RFC 2866 section 3 caps RADIUS Accounting packet Length at 4095 bytes.
pub(crate) const RADIUS_MAX_ACCOUNTING_PACKET_LEN: usize = 4095;
pub(crate) const RADIUS_AUTHENTICATOR_LEN: usize = 16;
pub(crate) const RADIUS_ATTRIBUTE_HEADER_LEN: usize = 2;
const RADIUS_MIN_ATTRIBUTE_LEN: usize = 2;
const ACCOUNTING_REQUEST_CODE: u8 = 4;
const ACCOUNTING_RESPONSE_CODE: u8 = 5;
const PROXY_STATE_TYPE: u8 = 33;
pub(crate) const MESSAGE_AUTHENTICATOR_TYPE: u8 = 80;
pub(crate) const MESSAGE_AUTHENTICATOR_VALUE_LEN: usize = 16;
#[cfg(test)]
pub(crate) const MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN: usize =
RADIUS_ATTRIBUTE_HEADER_LEN + MESSAGE_AUTHENTICATOR_VALUE_LEN;
/// RADIUS packet code values understood by the packet parser.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RadiusCode {
/// Access-Request packet code.
AccessRequest,
/// Access-Accept packet code.
AccessAccept,
/// Access-Reject packet code.
AccessReject,
/// Accounting-Request packet code.
AccountingRequest,
/// Accounting-Response packet code.
AccountingResponse,
/// Any RADIUS packet code not enumerated by this crate.
Other(u8),
}
impl RadiusCode {
/// Returns the wire value for this packet code.
#[must_use]
pub const fn as_u8(self) -> u8 {
match self {
Self::AccessRequest => 1,
Self::AccessAccept => 2,
Self::AccessReject => 3,
Self::AccountingRequest => ACCOUNTING_REQUEST_CODE,
Self::AccountingResponse => ACCOUNTING_RESPONSE_CODE,
Self::Other(code) => code,
}
}
}
impl From<u8> for RadiusCode {
fn from(code: u8) -> Self {
match code {
1 => Self::AccessRequest,
2 => Self::AccessAccept,
3 => Self::AccessReject,
ACCOUNTING_REQUEST_CODE => Self::AccountingRequest,
ACCOUNTING_RESPONSE_CODE => Self::AccountingResponse,
other => Self::Other(other),
}
}
}
impl From<RadiusCode> for u8 {
fn from(code: RadiusCode) -> Self {
code.as_u8()
}
}
/// One decoded RADIUS attribute.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RadiusAttribute {
kind: u8,
value: Vec<u8>,
}
impl RadiusAttribute {
/// Returns the raw RADIUS attribute type value.
#[must_use]
pub const fn kind(&self) -> u8 {
self.kind
}
/// Returns the raw RADIUS attribute value bytes.
#[must_use]
pub fn value(&self) -> &[u8] {
&self.value
}
}
/// A well-formed RADIUS packet.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RadiusPacket {
code: RadiusCode,
identifier: u8,
authenticator: [u8; RADIUS_AUTHENTICATOR_LEN],
attributes: Vec<RadiusAttribute>,
}
impl RadiusPacket {
/// Returns the packet code.
#[must_use]
pub const fn code(&self) -> RadiusCode {
self.code
}
/// Returns the packet identifier.
#[must_use]
pub const fn identifier(&self) -> u8 {
self.identifier
}
/// Returns the 16-byte request authenticator from the packet header.
#[must_use]
pub const fn authenticator(&self) -> &[u8; RADIUS_AUTHENTICATOR_LEN] {
&self.authenticator
}
/// Returns the decoded packet attributes.
#[must_use]
pub fn attributes(&self) -> &[RadiusAttribute] {
&self.attributes
}
}
/// A well-formed RADIUS Accounting-Request packet.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AccountingRequest {
packet: RadiusPacket,
}
impl AccountingRequest {
/// Returns the decoded RADIUS packet.
#[must_use]
pub const fn packet(&self) -> &RadiusPacket {
&self.packet
}
/// Consumes the accounting request and returns the decoded RADIUS packet.
#[must_use]
pub fn into_packet(self) -> RadiusPacket {
self.packet
}
}
/// Policy for accepting Accounting-Request packets without Message-Authenticator.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MessageAuthenticatorPolicy {
/// Verify Message-Authenticator when present, but do not require it.
#[default]
Optional,
/// Reject Accounting-Request packets that lack Message-Authenticator.
Required,
}
impl MessageAuthenticatorPolicy {
/// Returns true when the policy requires Message-Authenticator attribute 80.
#[must_use]
pub const fn requires_message_authenticator(self) -> bool {
matches!(self, Self::Required)
}
}
/// A RADIUS Accounting-Request whose authenticators matched the shared secret.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VerifiedAccountingRequest {
request: AccountingRequest,
has_message_authenticator: bool,
}
impl VerifiedAccountingRequest {
/// Returns the verified Accounting-Request wrapper.
#[must_use]
pub const fn request(&self) -> &AccountingRequest {
&self.request
}
/// Returns the verified decoded RADIUS packet.
#[must_use]
pub const fn packet(&self) -> &RadiusPacket {
self.request.packet()
}
/// Returns true when the accepted packet contained Message-Authenticator.
#[must_use]
pub const fn has_message_authenticator(&self) -> bool {
self.has_message_authenticator
}
/// Consumes the verified request and returns the Accounting-Request wrapper.
#[must_use]
pub fn into_request(self) -> AccountingRequest {
self.request
}
/// Builds an Accounting-Response packet for this verified request.
///
/// Side effects: none. The returned bytes are not sent to the network.
#[must_use]
pub fn build_response(&self, shared_secret: &[u8]) -> Vec<u8> {
build_accounting_response(self, shared_secret)
}
}
/// Errors returned while parsing RADIUS packets.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum PacketError {
/// The datagram was shorter than the fixed RADIUS header.
#[error("RADIUS packet is {actual} bytes; minimum is 20 bytes")]
PacketTooShort {
/// Number of bytes received in the datagram.
actual: usize,
},
/// The packet length field was outside the RADIUS framing bounds.
#[error("RADIUS packet length field is {declared} bytes; valid range is 20..=4096 bytes")]
InvalidPacketLength {
/// Length declared in the packet header.
declared: usize,
},
/// The packet length field exceeded the RADIUS Accounting framing limit.
#[error(
"RADIUS accounting packet length field is {declared} bytes; valid range is 20..=4095 bytes"
)]
InvalidAccountingPacketLength {
/// Length declared in the packet header.
declared: usize,
},
/// The datagram ended before the declared RADIUS packet length.
#[error(
"RADIUS packet length field is {declared} bytes, but datagram contains only {actual} bytes"
)]
PacketTruncated {
/// Length declared in the packet header.
declared: usize,
/// Number of bytes received in the datagram.
actual: usize,
},
/// An attribute was missing its type and length bytes.
#[error("RADIUS attribute at offset {offset} is missing its type/length header")]
AttributeHeaderTruncated {
/// Byte offset of the malformed attribute.
offset: usize,
},
/// An attribute declared a length smaller than its own header.
#[error(
"RADIUS attribute at offset {offset} declares invalid length {declared}; minimum is 2 bytes"
)]
InvalidAttributeLength {
/// Byte offset of the malformed attribute.
offset: usize,
/// Length declared by the attribute.
declared: usize,
},
/// An attribute declared more bytes than remain in the packet.
#[error(
"RADIUS attribute at offset {offset} declares length {declared}, but only {remaining} bytes remain"
)]
AttributeTruncated {
/// Byte offset of the malformed attribute.
offset: usize,
/// Length declared by the attribute.
declared: usize,
/// Remaining bytes in the packet from the attribute offset.
remaining: usize,
},
/// The packet code is not supported by the accounting handler.
#[error("RADIUS code {code} is not supported by the accounting handler")]
UnsupportedCode {
/// Unsupported raw packet code.
code: u8,
},
/// The Accounting-Request authenticator did not match the shared secret.
#[error("RADIUS Accounting-Request authenticator does not match the shared secret")]
InvalidRequestAuthenticator,
/// Message-Authenticator is required by policy but absent from the packet.
#[error("RADIUS Message-Authenticator is required but missing")]
MissingMessageAuthenticator,
/// Message-Authenticator had an invalid value length.
#[error("RADIUS Message-Authenticator value is {actual} bytes; expected 16 bytes")]
InvalidMessageAuthenticatorLength {
/// Actual Message-Authenticator value length.
actual: usize,
},
/// More than one Message-Authenticator attribute was present.
#[error("RADIUS packet contains more than one Message-Authenticator attribute")]
MultipleMessageAuthenticators,
/// The Message-Authenticator HMAC did not match the shared secret.
#[error("RADIUS Message-Authenticator does not match the shared secret")]
InvalidMessageAuthenticator,
}
/// Parses the RADIUS packet framing in one UDP datagram.
///
/// Extra bytes beyond the declared RADIUS length are ignored as UDP padding.
/// This function performs no shared-secret verification and does not mutate
/// process or system state.
pub fn parse_packet(datagram: &[u8]) -> Result<RadiusPacket, PacketError> {
if datagram.len() < RADIUS_HEADER_LEN {
return Err(PacketError::PacketTooShort {
actual: datagram.len(),
});
}
let declared_len = u16::from_be_bytes([datagram[2], datagram[3]]) as usize;
if !(RADIUS_HEADER_LEN..=RADIUS_MAX_PACKET_LEN).contains(&declared_len) {
return Err(PacketError::InvalidPacketLength {
declared: declared_len,
});
}
if datagram.len() < declared_len {
return Err(PacketError::PacketTruncated {
declared: declared_len,
actual: datagram.len(),
});
}
let packet_bytes = &datagram[..declared_len];
let mut authenticator = [0_u8; RADIUS_AUTHENTICATOR_LEN];
authenticator.copy_from_slice(&packet_bytes[4..RADIUS_HEADER_LEN]);
let attributes = parse_attributes(&packet_bytes[RADIUS_HEADER_LEN..])?;
Ok(RadiusPacket {
code: RadiusCode::from(packet_bytes[0]),
identifier: packet_bytes[1],
authenticator,
attributes,
})
}
/// Parses a UDP datagram and returns only RADIUS Accounting-Request packets.
///
/// This function performs syntactic packet checks only. It does not verify
/// authenticators, build Accounting-Response packets, or update session state.
pub fn handle_accounting_request(datagram: &[u8]) -> Result<AccountingRequest, PacketError> {
let packet = parse_packet(datagram)?;
if packet.code() != RadiusCode::AccountingRequest {
return Err(PacketError::UnsupportedCode {
code: packet.code().as_u8(),
});
}
let declared_len = u16::from_be_bytes([datagram[2], datagram[3]]) as usize;
if declared_len > RADIUS_MAX_ACCOUNTING_PACKET_LEN {
return Err(PacketError::InvalidAccountingPacketLength {
declared: declared_len,
});
}
Ok(AccountingRequest { packet })
}
/// Parses and verifies one RADIUS Accounting-Request datagram.
///
/// The caller is responsible for matching the UDP peer to a trusted client and
/// passing that client's shared secret. Packet framing and attribute bounds are
/// checked before authenticators are evaluated.
/// When Message-Authenticator is present, it is verified with the
/// Accounting-Request vector set to zero before the request authenticator is
/// verified with the received Message-Authenticator value present.
///
/// Side effects: none. This function does not send responses, update session
/// state, touch files, or modify host networking.
pub fn verify_accounting_request(
datagram: &[u8],
shared_secret: &[u8],
message_authenticator_policy: MessageAuthenticatorPolicy,
) -> Result<VerifiedAccountingRequest, PacketError> {
let request = handle_accounting_request(datagram)?;
let message_authenticator_index = message_authenticator_index(request.packet())?;
if message_authenticator_policy.requires_message_authenticator()
&& message_authenticator_index.is_none()
{
return Err(PacketError::MissingMessageAuthenticator);
}
if let Some(index) = message_authenticator_index {
verify_message_authenticator(request.packet(), shared_secret, index)?;
}
verify_request_authenticator(request.packet(), shared_secret)?;
Ok(VerifiedAccountingRequest {
request,
has_message_authenticator: message_authenticator_index.is_some(),
})
}
/// Builds a minimal RADIUS Accounting-Response for a verified request.
///
/// The response uses code 5, copies the request identifier, preserves any
/// Proxy-State attributes from the request, and signs the response
/// authenticator with the shared secret.
///
/// Side effects: none. The returned bytes are not sent to the network.
#[must_use]
pub fn build_accounting_response(
request: &VerifiedAccountingRequest,
shared_secret: &[u8],
) -> Vec<u8> {
let mut response = Vec::with_capacity(RADIUS_HEADER_LEN + shared_secret.len());
response.push(ACCOUNTING_RESPONSE_CODE);
response.push(request.packet().identifier());
response.extend_from_slice(&[0, 0]);
response.extend_from_slice(request.packet().authenticator());
encode_proxy_state_attributes(request.packet(), &mut response);
let response_len = response.len();
response[2..4].copy_from_slice(&(response_len as u16).to_be_bytes());
response.extend_from_slice(shared_secret);
let authenticator = md5_digest(&response);
response.truncate(response_len);
response[4..RADIUS_HEADER_LEN].copy_from_slice(&authenticator);
response
}
fn parse_attributes(mut attribute_bytes: &[u8]) -> Result<Vec<RadiusAttribute>, PacketError> {
let mut attributes = Vec::new();
let mut offset = RADIUS_HEADER_LEN;
while !attribute_bytes.is_empty() {
let (kind, value, remaining) = split_radius_tlv(attribute_bytes)
.map_err(|error| packet_error_from_tlv_split(error, offset, attribute_bytes.len()))?;
let declared_len = attribute_bytes.len() - remaining.len();
attributes.push(RadiusAttribute {
kind,
value: value.to_vec(),
});
attribute_bytes = remaining;
offset += declared_len;
}
Ok(attributes)
}
pub(crate) fn split_radius_tlv(value: &[u8]) -> Result<(u8, &[u8], &[u8]), RadiusTlvError> {
if value.len() < RADIUS_ATTRIBUTE_HEADER_LEN {
return Err(RadiusTlvError::HeaderTruncated);
}
let kind = value[0];
let declared_len = usize::from(value[1]);
if declared_len < RADIUS_MIN_ATTRIBUTE_LEN {
return Err(RadiusTlvError::InvalidLength { declared_len });
}
if declared_len > value.len() {
return Err(RadiusTlvError::Truncated { declared_len });
}
Ok((
kind,
&value[RADIUS_ATTRIBUTE_HEADER_LEN..declared_len],
&value[declared_len..],
))
}
pub(crate) enum RadiusTlvError {
HeaderTruncated,
InvalidLength { declared_len: usize },
Truncated { declared_len: usize },
}
fn packet_error_from_tlv_split(
error: RadiusTlvError,
offset: usize,
remaining: usize,
) -> PacketError {
match error {
RadiusTlvError::HeaderTruncated => PacketError::AttributeHeaderTruncated { offset },
RadiusTlvError::InvalidLength { declared_len } => PacketError::InvalidAttributeLength {
offset,
declared: declared_len,
},
RadiusTlvError::Truncated { declared_len } => PacketError::AttributeTruncated {
offset,
declared: declared_len,
remaining,
},
}
}
fn verify_request_authenticator(
packet: &RadiusPacket,
shared_secret: &[u8],
) -> Result<(), PacketError> {
let expected = expected_request_authenticator(packet, shared_secret);
if constant_time_eq(packet.authenticator(), &expected) {
Ok(())
} else {
Err(PacketError::InvalidRequestAuthenticator)
}
}
fn verify_message_authenticator(
packet: &RadiusPacket,
shared_secret: &[u8],
message_authenticator_index: usize,
) -> Result<(), PacketError> {
let expected =
expected_message_authenticator(packet, shared_secret, message_authenticator_index);
let actual = packet.attributes()[message_authenticator_index].value();
if constant_time_eq(actual, &expected) {
Ok(())
} else {
Err(PacketError::InvalidMessageAuthenticator)
}
}
pub(crate) fn message_authenticator_index(
packet: &RadiusPacket,
) -> Result<Option<usize>, PacketError> {
let mut found = None;
for (index, attribute) in packet.attributes().iter().enumerate() {
if attribute.kind() != MESSAGE_AUTHENTICATOR_TYPE {
continue;
}
if attribute.value().len() != MESSAGE_AUTHENTICATOR_VALUE_LEN {
return Err(PacketError::InvalidMessageAuthenticatorLength {
actual: attribute.value().len(),
});
}
if found.replace(index).is_some() {
return Err(PacketError::MultipleMessageAuthenticators);
}
}
Ok(found)
}
pub(crate) fn expected_request_authenticator(
packet: &RadiusPacket,
shared_secret: &[u8],
) -> [u8; RADIUS_AUTHENTICATOR_LEN] {
let mut signed_packet = encode_packet(packet, [0_u8; RADIUS_AUTHENTICATOR_LEN], None);
signed_packet.extend_from_slice(shared_secret);
md5_digest(&signed_packet)
}
pub(crate) fn expected_message_authenticator(
packet: &RadiusPacket,
shared_secret: &[u8],
message_authenticator_index: usize,
) -> [u8; MESSAGE_AUTHENTICATOR_VALUE_LEN] {
let signed_packet = encode_packet(
packet,
[0_u8; RADIUS_AUTHENTICATOR_LEN],
Some(message_authenticator_index),
);
hmac_md5(shared_secret, &signed_packet)
}
fn encode_packet(
packet: &RadiusPacket,
authenticator: [u8; RADIUS_AUTHENTICATOR_LEN],
zero_message_authenticator_index: Option<usize>,
) -> Vec<u8> {
let packet_len = RADIUS_HEADER_LEN
+ packet
.attributes()
.iter()
.map(encoded_attribute_len)
.sum::<usize>();
let mut encoded = Vec::with_capacity(packet_len);
encoded.push(packet.code().as_u8());
encoded.push(packet.identifier());
encoded.extend_from_slice(&(packet_len as u16).to_be_bytes());
encoded.extend_from_slice(&authenticator);
for (index, attribute) in packet.attributes().iter().enumerate() {
if Some(index) == zero_message_authenticator_index {
encode_attribute_value(
attribute.kind(),
&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN],
&mut encoded,
);
} else {
encode_attribute(attribute, &mut encoded);
}
}
encoded
}
fn encode_proxy_state_attributes(packet: &RadiusPacket, encoded: &mut Vec<u8>) {
for attribute in packet.attributes() {
if attribute.kind() != PROXY_STATE_TYPE {
continue;
}
encode_attribute(attribute, encoded);
}
}
fn encode_attribute(attribute: &RadiusAttribute, encoded: &mut Vec<u8>) {
encode_attribute_value(attribute.kind(), attribute.value(), encoded);
}
#[cfg(test)]
pub(crate) fn encode_radius_tlv(kind: u8, value: &[u8]) -> Vec<u8> {
let encoded_len = RADIUS_ATTRIBUTE_HEADER_LEN + value.len();
assert!(encoded_len <= u8::MAX as usize);
let mut encoded = Vec::with_capacity(encoded_len);
encode_attribute_value(kind, value, &mut encoded);
encoded
}
fn encode_attribute_value(kind: u8, value: &[u8], encoded: &mut Vec<u8>) {
encoded.push(kind);
encoded.push((RADIUS_ATTRIBUTE_HEADER_LEN + value.len()) as u8);
encoded.extend_from_slice(value);
}
fn encoded_attribute_len(attribute: &RadiusAttribute) -> usize {
RADIUS_ATTRIBUTE_HEADER_LEN + attribute.value().len()
}
pub(crate) fn md5_digest(bytes: &[u8]) -> [u8; RADIUS_AUTHENTICATOR_LEN] {
let mut hasher = Md5::new();
hasher.update(bytes);
hasher.finalize().into()
}
pub(crate) fn hmac_md5(key: &[u8], bytes: &[u8]) -> [u8; MESSAGE_AUTHENTICATOR_VALUE_LEN] {
let mut mac = HmacMd5::new_from_slice(key).expect("HMAC-MD5 accepts any key length");
mac.update(bytes);
mac.finalize().into_bytes().into()
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
left.ct_eq(right).into()
}
#[cfg(test)]
mod tests;
+560
View File
@@ -0,0 +1,560 @@
//! Packet tests for RADIUS accounting framing and authentication.
use super::*;
use crate::attribute_type::ACCT_STATUS_TYPE;
use crate::test_support::{
SHARED_SECRET, accounting_request_packet, accounting_request_packet_with_message_authenticator,
accounting_request_packet_with_message_authenticator_at,
accounting_request_packet_with_total_len, max_sized_accounting_request_packet, radius_packet,
signed_accounting_request_packet, signed_radius_packet,
};
const ACCOUNTING_START_ATTRIBUTE: [u8; 6] = [ACCT_STATUS_TYPE, 6, 0, 0, 0, 1];
const FIXED_ACCOUNTING_REQUEST: [u8; 26] = [
4, 7, 0, 26, 234, 109, 208, 193, 96, 89, 23, 174, 213, 177, 203, 9, 123, 217, 127, 22, 40, 6,
0, 0, 0, 1,
];
const FIXED_ACCOUNTING_RESPONSE: [u8; 20] = [
5, 7, 0, 20, 35, 106, 121, 65, 4, 237, 106, 10, 15, 20, 216, 122, 222, 158, 144, 160,
];
const FIXED_PROXY_ACCOUNTING_REQUEST: [u8; 33] = [
4, 9, 0, 33, 96, 121, 47, 229, 56, 17, 23, 167, 140, 77, 87, 100, 57, 14, 218, 128, 40, 6, 0,
0, 0, 1, 33, 7, 112, 114, 111, 120, 121,
];
const FIXED_PROXY_ACCOUNTING_RESPONSE: [u8; 27] = [
5, 9, 0, 27, 125, 39, 136, 19, 21, 211, 192, 196, 215, 106, 80, 129, 89, 194, 251, 134, 33, 7,
112, 114, 111, 120, 121,
];
// Matches FreeRADIUS v3.2.x Accounting-Request handling: HMAC with the request
// vector zeroed, then Accounting MD5 with the Message-Authenticator value present.
const FIXED_MESSAGE_AUTHENTICATOR_REQUEST: [u8; 44] = [
4, 11, 0, 44, 17, 225, 44, 198, 149, 240, 255, 183, 213, 29, 93, 75, 88, 244, 33, 62, 40, 6, 0,
0, 0, 1, 80, 18, 190, 213, 77, 117, 156, 25, 155, 188, 192, 113, 189, 123, 238, 165, 229, 7,
];
const FIXED_MESSAGE_AUTHENTICATOR_RESPONSE: [u8; 20] = [
5, 11, 0, 20, 120, 245, 78, 218, 247, 24, 76, 17, 208, 88, 100, 153, 63, 124, 107, 242,
];
const FIXED_MULTI_PROXY_ACCOUNTING_REQUEST: [u8; 39] = [
4, 12, 0, 39, 158, 213, 142, 15, 167, 123, 197, 221, 162, 68, 20, 166, 78, 6, 157, 152, 40, 6,
0, 0, 0, 1, 33, 5, 111, 110, 101, 241, 3, 9, 33, 5, 116, 119, 111,
];
const FIXED_MULTI_PROXY_ACCOUNTING_RESPONSE: [u8; 30] = [
5, 12, 0, 30, 178, 201, 10, 40, 135, 29, 40, 136, 69, 8, 142, 127, 30, 126, 130, 165, 33, 5,
111, 110, 101, 33, 5, 116, 119, 111,
];
const UNKNOWN_ATTRIBUTE_TYPE: u8 = 241;
#[test]
fn parses_accounting_request_with_attributes() {
let request =
handle_accounting_request(&accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE))
.unwrap();
let packet = request.packet();
assert_eq!(packet.code(), RadiusCode::AccountingRequest);
assert_eq!(packet.identifier(), 7);
assert_eq!(packet.authenticator(), &[1_u8; RADIUS_AUTHENTICATOR_LEN]);
assert_eq!(packet.attributes().len(), 1);
assert_eq!(packet.attributes()[0].kind(), ACCT_STATUS_TYPE);
assert_eq!(packet.attributes()[0].value(), &[0, 0, 0, 1]);
}
#[test]
fn parses_minimal_accounting_request_raw_fixture() {
let datagram = [
ACCOUNTING_REQUEST_CODE,
42,
0,
RADIUS_HEADER_LEN as u8,
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
];
let parsed = parse_packet(&datagram).unwrap();
assert_eq!(parsed.code(), RadiusCode::AccountingRequest);
assert_eq!(parsed.identifier(), 42);
assert_eq!(
parsed.authenticator(),
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
);
assert_eq!(parsed.attributes(), &[]);
}
#[test]
fn rejects_short_packet_header() {
assert_eq!(
parse_packet(&[ACCOUNTING_REQUEST_CODE, 1]),
Err(PacketError::PacketTooShort { actual: 2 })
);
}
#[test]
fn rejects_declared_packet_length_below_header() {
assert_eq!(
parse_packet(&packet_with_declared_len((RADIUS_HEADER_LEN - 1) as u16)),
Err(PacketError::InvalidPacketLength {
declared: RADIUS_HEADER_LEN - 1,
})
);
}
#[test]
fn rejects_declared_packet_length_above_maximum() {
assert_eq!(
parse_packet(&packet_with_declared_len(
(RADIUS_MAX_PACKET_LEN + 1) as u16
)),
Err(PacketError::InvalidPacketLength {
declared: RADIUS_MAX_PACKET_LEN + 1,
})
);
}
#[test]
fn parses_declared_packet_length_at_radius_maximum() {
let packet = accounting_request_packet_with_total_len(7, RADIUS_MAX_PACKET_LEN);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(packet.len(), RADIUS_MAX_PACKET_LEN);
assert_eq!(parsed.attributes().len(), 16);
assert_eq!(parsed.attributes()[15].kind(), 16);
assert_eq!(parsed.attributes()[15].value().len(), 249);
}
#[test]
fn parses_generated_packet_length_that_would_leave_one_byte_chunk() {
let packet = accounting_request_packet_with_total_len(7, RADIUS_HEADER_LEN + 256);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(packet.len(), RADIUS_HEADER_LEN + 256);
assert_eq!(parsed.attributes().len(), 2);
assert_eq!(parsed.attributes()[0].value().len(), 252);
assert_eq!(parsed.attributes()[1].value().len(), 0);
}
#[test]
fn accounting_handler_accepts_declared_packet_length_at_accounting_maximum() {
let packet = max_sized_accounting_request_packet(7);
let request = handle_accounting_request(&packet).unwrap();
assert_eq!(packet.len(), RADIUS_MAX_ACCOUNTING_PACKET_LEN);
assert_eq!(request.packet().attributes().len(), 16);
assert_eq!(request.packet().attributes()[15].kind(), 16);
assert_eq!(request.packet().attributes()[15].value().len(), 248);
}
#[test]
fn accounting_handler_rejects_declared_packet_length_above_accounting_maximum() {
let packet = accounting_request_packet_with_total_len(7, RADIUS_MAX_PACKET_LEN);
assert_eq!(
handle_accounting_request(&packet),
Err(PacketError::InvalidAccountingPacketLength {
declared: RADIUS_MAX_PACKET_LEN,
})
);
}
#[test]
fn rejects_truncated_declared_packet_length() {
assert_eq!(
parse_packet(&packet_with_declared_len((RADIUS_HEADER_LEN + 1) as u16)),
Err(PacketError::PacketTruncated {
declared: RADIUS_HEADER_LEN + 1,
actual: RADIUS_HEADER_LEN,
})
);
}
#[test]
fn parses_empty_attribute_value_and_continues() {
let mut attributes = vec![UNKNOWN_ATTRIBUTE_TYPE, 2];
attributes.extend_from_slice(&ACCOUNTING_START_ATTRIBUTE);
let packet = accounting_request_packet(7, &attributes);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(parsed.attributes().len(), 2);
assert_eq!(parsed.attributes()[0].kind(), UNKNOWN_ATTRIBUTE_TYPE);
assert_eq!(parsed.attributes()[0].value(), &[]);
assert_eq!(parsed.attributes()[1].kind(), ACCT_STATUS_TYPE);
assert_eq!(parsed.attributes()[1].value(), &[0, 0, 0, 1]);
}
#[test]
fn rejects_truncated_attribute_header() {
let packet = accounting_request_packet(7, &[ACCT_STATUS_TYPE]);
assert_eq!(
parse_packet(&packet),
Err(PacketError::AttributeHeaderTruncated {
offset: RADIUS_HEADER_LEN,
})
);
}
#[test]
fn rejects_attribute_length_smaller_than_header() {
let packet = accounting_request_packet(7, &[ACCT_STATUS_TYPE, 1]);
assert_eq!(
parse_packet(&packet),
Err(PacketError::InvalidAttributeLength {
offset: RADIUS_HEADER_LEN,
declared: 1,
})
);
}
#[test]
fn rejects_attribute_length_overrun() {
let mut packet = vec![ACCOUNTING_REQUEST_CODE, 7, 0, 24];
packet.extend_from_slice(&[1_u8; RADIUS_AUTHENTICATOR_LEN]);
packet.extend_from_slice(&[ACCT_STATUS_TYPE, 6, 0, 0]);
assert_eq!(
parse_packet(&packet),
Err(PacketError::AttributeTruncated {
offset: RADIUS_HEADER_LEN,
declared: 6,
remaining: 4,
})
);
}
#[test]
fn ignores_padding_after_declared_length() {
let mut packet = accounting_request_packet(7, &[]);
packet.extend_from_slice(&[ACCT_STATUS_TYPE, 1]);
let parsed = parse_packet(&packet).unwrap();
assert_eq!(parsed.attributes(), &[]);
}
#[test]
fn accounting_handler_rejects_non_accounting_code() {
let packet = radius_packet(RadiusCode::AccessRequest, 7, &ACCOUNTING_START_ATTRIBUTE);
assert_eq!(
handle_accounting_request(&packet),
Err(PacketError::UnsupportedCode { code: 1 })
);
}
#[test]
fn verifies_fixed_accounting_request_authenticator() {
let verified = verify_optional(&FIXED_ACCOUNTING_REQUEST).unwrap();
assert_eq!(verified.packet().identifier(), 7);
assert!(!verified.has_message_authenticator());
assert_eq!(verified.packet().attributes()[0].kind(), ACCT_STATUS_TYPE);
assert_eq!(verified.packet().attributes()[0].value(), &[0, 0, 0, 1]);
}
#[test]
fn rejects_accounting_request_with_wrong_secret() {
let packet = signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
assert_eq!(
verify_accounting_request(
&packet,
b"wrong-secret",
MessageAuthenticatorPolicy::Optional
),
Err(PacketError::InvalidRequestAuthenticator)
);
}
#[test]
fn rejects_accounting_request_with_modified_attributes() {
let mut packet =
signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
let final_attribute_byte = packet.len() - 1;
packet[final_attribute_byte] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidRequestAuthenticator)
);
}
#[test]
fn verification_rejects_unsupported_code() {
let packet = signed_radius_packet(
RadiusCode::AccessRequest,
7,
&ACCOUNTING_START_ATTRIBUTE,
SHARED_SECRET,
);
assert_eq!(
verify_optional(&packet),
Err(PacketError::UnsupportedCode { code: 1 })
);
}
#[test]
fn verification_rejects_truncated_packet_before_authentication() {
let mut packet =
signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
packet.truncate(RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len() - 1);
assert_eq!(
verify_optional(&packet),
Err(PacketError::PacketTruncated {
declared: RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len(),
actual: RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len() - 1,
})
);
}
#[test]
fn verifies_fixed_message_authenticator_when_optional() {
let verified = verify_optional(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST).unwrap();
assert_eq!(verified.packet().identifier(), 11);
assert!(verified.has_message_authenticator());
}
#[test]
fn verifies_fixed_message_authenticator_when_required() {
let verified = verify_required(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST).unwrap();
assert_eq!(verified.packet().identifier(), 11);
assert!(verified.has_message_authenticator());
}
#[test]
fn fixed_message_authenticator_uses_accounting_interop_order() {
let value_start =
RADIUS_HEADER_LEN + ACCOUNTING_START_ATTRIBUTE.len() + RADIUS_ATTRIBUTE_HEADER_LEN;
let value_end = value_start + MESSAGE_AUTHENTICATOR_VALUE_LEN;
let mut received_message_authenticator = [0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN];
received_message_authenticator
.copy_from_slice(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST[value_start..value_end]);
let mut received_request_authenticator = [0_u8; RADIUS_AUTHENTICATOR_LEN];
received_request_authenticator
.copy_from_slice(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST[4..RADIUS_HEADER_LEN]);
let mut hmac_input = FIXED_MESSAGE_AUTHENTICATOR_REQUEST.to_vec();
hmac_input[4..RADIUS_HEADER_LEN].fill(0);
hmac_input[value_start..value_end].fill(0);
assert_eq!(
hmac_md5(SHARED_SECRET, &hmac_input),
received_message_authenticator
);
let mut md5_input = FIXED_MESSAGE_AUTHENTICATOR_REQUEST.to_vec();
md5_input[4..RADIUS_HEADER_LEN].fill(0);
md5_input.extend_from_slice(SHARED_SECRET);
assert_eq!(md5_digest(&md5_input), received_request_authenticator);
let mut old_md5_input = FIXED_MESSAGE_AUTHENTICATOR_REQUEST.to_vec();
old_md5_input[4..RADIUS_HEADER_LEN].fill(0);
old_md5_input[value_start..value_end].fill(0);
old_md5_input.extend_from_slice(SHARED_SECRET);
assert_ne!(md5_digest(&old_md5_input), received_request_authenticator);
}
#[test]
fn rejects_message_authenticator_packet_with_invalid_request_authenticator() {
let mut packet = FIXED_MESSAGE_AUTHENTICATOR_REQUEST;
packet[4] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidRequestAuthenticator)
);
}
#[test]
fn verifies_message_authenticator_before_other_attributes() {
let mut attributes = vec![
MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8,
];
attributes.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
attributes.extend_from_slice(&ACCOUNTING_START_ATTRIBUTE);
let packet = accounting_request_packet_with_message_authenticator_at(
7,
&attributes,
RADIUS_ATTRIBUTE_HEADER_LEN,
SHARED_SECRET,
);
let verified = verify_required(&packet).unwrap();
assert_eq!(verified.packet().attributes().len(), 2);
assert_eq!(verified.packet().attributes()[1].kind(), ACCT_STATUS_TYPE);
}
#[test]
fn rejects_invalid_message_authenticator() {
let mut packet = accounting_request_packet_with_message_authenticator(
7,
&ACCOUNTING_START_ATTRIBUTE,
SHARED_SECRET,
);
let message_authenticator_value = packet.len() - MESSAGE_AUTHENTICATOR_VALUE_LEN;
packet[message_authenticator_value] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidMessageAuthenticator)
);
}
#[test]
fn rejects_modified_accounting_attribute_with_message_authenticator() {
let mut packet = FIXED_MESSAGE_AUTHENTICATOR_REQUEST;
let accounting_status_value = RADIUS_HEADER_LEN + RADIUS_ATTRIBUTE_HEADER_LEN + 3;
packet[accounting_status_value] ^= 1;
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidMessageAuthenticator)
);
}
#[test]
fn rejects_multiple_message_authenticators() {
let mut attributes = ACCOUNTING_START_ATTRIBUTE.to_vec();
attributes.extend_from_slice(&[
MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8,
]);
attributes.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
attributes.extend_from_slice(&[
MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8,
]);
attributes.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
let packet = signed_accounting_request_packet(7, &attributes, SHARED_SECRET);
assert_eq!(
verify_optional(&packet),
Err(PacketError::MultipleMessageAuthenticators)
);
}
#[test]
fn rejects_missing_message_authenticator_when_required() {
let packet = signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
assert_eq!(
verify_required(&packet),
Err(PacketError::MissingMessageAuthenticator)
);
}
#[test]
fn rejects_malformed_message_authenticator_length() {
let mut attributes = ACCOUNTING_START_ATTRIBUTE.to_vec();
attributes.extend_from_slice(&[MESSAGE_AUTHENTICATOR_TYPE, 3, 1]);
let packet = signed_accounting_request_packet(7, &attributes, SHARED_SECRET);
assert_eq!(
verify_optional(&packet),
Err(PacketError::InvalidMessageAuthenticatorLength { actual: 1 })
);
}
#[test]
fn builds_accounting_response_code_5() {
let packet = signed_accounting_request_packet(7, &ACCOUNTING_START_ATTRIBUTE, SHARED_SECRET);
let verified = verify_optional(&packet).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response.len(), RADIUS_HEADER_LEN);
assert_eq!(response[0], RadiusCode::AccountingResponse.as_u8());
assert_eq!(u16::from_be_bytes([response[2], response[3]]), 20);
assert_eq!(parsed_response.code(), RadiusCode::AccountingResponse);
assert_eq!(parsed_response.identifier(), 7);
assert_eq!(parsed_response.attributes(), &[]);
}
#[test]
fn builds_fixed_accounting_response_fixture() {
let verified = verify_optional(&FIXED_ACCOUNTING_REQUEST).unwrap();
assert_eq!(
build_accounting_response(&verified, SHARED_SECRET),
FIXED_ACCOUNTING_RESPONSE
);
}
#[test]
fn builds_accounting_response_for_message_authenticator_request() {
let verified = verify_required(&FIXED_MESSAGE_AUTHENTICATOR_REQUEST).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response.len(), RADIUS_HEADER_LEN);
assert_eq!(response, FIXED_MESSAGE_AUTHENTICATOR_RESPONSE);
assert_eq!(parsed_response.code(), RadiusCode::AccountingResponse);
assert_eq!(parsed_response.identifier(), 11);
assert_eq!(parsed_response.attributes(), &[]);
}
#[test]
fn accounting_response_preserves_proxy_state_attributes() {
let verified = verify_optional(&FIXED_PROXY_ACCOUNTING_REQUEST).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response, FIXED_PROXY_ACCOUNTING_RESPONSE);
assert_eq!(parsed_response.attributes().len(), 1);
assert_eq!(parsed_response.attributes()[0].kind(), PROXY_STATE_TYPE);
assert_eq!(parsed_response.attributes()[0].value(), b"proxy");
}
#[test]
fn accounting_response_preserves_all_proxy_state_attributes_only() {
let verified = verify_optional(&FIXED_MULTI_PROXY_ACCOUNTING_REQUEST).unwrap();
let response = build_accounting_response(&verified, SHARED_SECRET);
let parsed_response = parse_packet(&response).unwrap();
assert_eq!(response, FIXED_MULTI_PROXY_ACCOUNTING_RESPONSE);
assert_eq!(parsed_response.attributes().len(), 2);
assert_eq!(parsed_response.attributes()[0].kind(), PROXY_STATE_TYPE);
assert_eq!(parsed_response.attributes()[0].value(), b"one");
assert_eq!(parsed_response.attributes()[1].kind(), PROXY_STATE_TYPE);
assert_eq!(parsed_response.attributes()[1].value(), b"two");
}
fn verify_optional(datagram: &[u8]) -> Result<VerifiedAccountingRequest, PacketError> {
verify_accounting_request(
datagram,
SHARED_SECRET,
MessageAuthenticatorPolicy::Optional,
)
}
fn verify_required(datagram: &[u8]) -> Result<VerifiedAccountingRequest, PacketError> {
verify_accounting_request(
datagram,
SHARED_SECRET,
MessageAuthenticatorPolicy::Required,
)
}
fn packet_with_declared_len(declared_len: u16) -> Vec<u8> {
let mut packet = vec![ACCOUNTING_REQUEST_CODE, 1];
packet.extend_from_slice(&declared_len.to_be_bytes());
packet.extend_from_slice(&[1_u8; RADIUS_AUTHENTICATOR_LEN]);
packet
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+188
View File
@@ -0,0 +1,188 @@
//! Test helpers for constructing minimal RADIUS packets.
use crate::RadiusCode;
use crate::attribute_type::VENDOR_SPECIFIC;
use crate::packet::{
MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN, MESSAGE_AUTHENTICATOR_TYPE,
MESSAGE_AUTHENTICATOR_VALUE_LEN, RADIUS_ATTRIBUTE_HEADER_LEN, RADIUS_AUTHENTICATOR_LEN,
RADIUS_HEADER_LEN, RADIUS_MAX_ACCOUNTING_PACKET_LEN, RADIUS_MAX_PACKET_LEN, encode_radius_tlv,
expected_message_authenticator, expected_request_authenticator, message_authenticator_index,
parse_packet,
};
pub(crate) const SHARED_SECRET: &[u8] = b"radius-secret";
pub(crate) fn accounting_request_packet(identifier: u8, attributes: &[u8]) -> Vec<u8> {
radius_packet(RadiusCode::AccountingRequest, identifier, attributes)
}
pub(crate) fn radius_attributes(attributes: &[Vec<u8>]) -> Vec<u8> {
attributes.concat()
}
pub(crate) fn radius_attribute(kind: u8, value: &[u8]) -> Vec<u8> {
encode_radius_tlv(kind, value)
}
pub(crate) fn radius_text_attribute(kind: u8, value: &str) -> Vec<u8> {
radius_attribute(kind, value.as_bytes())
}
pub(crate) fn radius_u32_attribute(kind: u8, value: u32) -> Vec<u8> {
radius_attribute(kind, &value.to_be_bytes())
}
pub(crate) fn radius_vendor_attribute(vendor_id: u32, vendor_type: u8, value: &[u8]) -> Vec<u8> {
let subattribute = radius_vendor_subattribute(vendor_type, value);
radius_vendor_attributes(vendor_id, &[subattribute])
}
pub(crate) fn radius_vendor_attributes(vendor_id: u32, subattributes: &[Vec<u8>]) -> Vec<u8> {
let vendor_payload_len = subattributes.iter().map(Vec::len).sum::<usize>();
let mut vendor_value = Vec::with_capacity(4 + vendor_payload_len);
vendor_value.extend_from_slice(&vendor_id.to_be_bytes());
for subattribute in subattributes {
vendor_value.extend_from_slice(subattribute);
}
radius_attribute(VENDOR_SPECIFIC, &vendor_value)
}
pub(crate) fn radius_vendor_subattribute(vendor_type: u8, value: &[u8]) -> Vec<u8> {
radius_attribute(vendor_type, value)
}
pub(crate) fn radius_raw_vendor_attribute(vendor_id: u32, value: &[u8]) -> Vec<u8> {
let mut vendor_value = Vec::with_capacity(4 + value.len());
vendor_value.extend_from_slice(&vendor_id.to_be_bytes());
vendor_value.extend_from_slice(value);
radius_attribute(VENDOR_SPECIFIC, &vendor_value)
}
pub(crate) fn max_sized_accounting_request_packet(identifier: u8) -> Vec<u8> {
accounting_request_packet_with_total_len(identifier, RADIUS_MAX_ACCOUNTING_PACKET_LEN)
}
pub(crate) fn accounting_request_packet_with_total_len(
identifier: u8,
packet_len: usize,
) -> Vec<u8> {
assert!(
(RADIUS_HEADER_LEN + RADIUS_ATTRIBUTE_HEADER_LEN..=RADIUS_MAX_PACKET_LEN)
.contains(&packet_len)
);
let mut remaining_attribute_bytes = packet_len - RADIUS_HEADER_LEN;
let mut attributes = Vec::with_capacity(remaining_attribute_bytes);
let mut kind = 1_u8;
while remaining_attribute_bytes > u8::MAX as usize {
let attribute_len = if remaining_attribute_bytes - u8::MAX as usize == 1 {
u8::MAX as usize - 1
} else {
u8::MAX as usize
};
attributes.push(kind);
attributes.push(attribute_len as u8);
attributes.resize(
attributes.len() + attribute_len - RADIUS_ATTRIBUTE_HEADER_LEN,
kind,
);
remaining_attribute_bytes -= attribute_len;
kind = kind.wrapping_add(1);
}
let final_attribute_len = remaining_attribute_bytes;
attributes.push(kind);
attributes.push(final_attribute_len as u8);
attributes.resize(
attributes.len() + final_attribute_len - RADIUS_ATTRIBUTE_HEADER_LEN,
0,
);
accounting_request_packet(identifier, &attributes)
}
pub(crate) fn radius_packet(code: RadiusCode, identifier: u8, attributes: &[u8]) -> Vec<u8> {
let packet_len = RADIUS_HEADER_LEN + attributes.len();
assert!(packet_len <= RADIUS_MAX_PACKET_LEN);
let mut packet = Vec::with_capacity(packet_len);
packet.push(code.as_u8());
packet.push(identifier);
packet.extend_from_slice(&(packet_len as u16).to_be_bytes());
packet.extend_from_slice(&[1_u8; RADIUS_AUTHENTICATOR_LEN]);
packet.extend_from_slice(attributes);
packet
}
pub(crate) fn signed_accounting_request_packet(
identifier: u8,
attributes: &[u8],
shared_secret: &[u8],
) -> Vec<u8> {
signed_radius_packet(
RadiusCode::AccountingRequest,
identifier,
attributes,
shared_secret,
)
}
pub(crate) fn signed_radius_packet(
code: RadiusCode,
identifier: u8,
attributes: &[u8],
shared_secret: &[u8],
) -> Vec<u8> {
let mut packet = radius_packet(code, identifier, attributes);
let parsed = parse_packet(&packet).unwrap();
let request_authenticator = expected_request_authenticator(&parsed, shared_secret);
packet[4..RADIUS_HEADER_LEN].copy_from_slice(&request_authenticator);
packet
}
pub(crate) fn accounting_request_packet_with_message_authenticator(
identifier: u8,
attributes: &[u8],
shared_secret: &[u8],
) -> Vec<u8> {
let mut attributes_with_message_authenticator =
Vec::with_capacity(attributes.len() + 2 + MESSAGE_AUTHENTICATOR_VALUE_LEN);
attributes_with_message_authenticator.extend_from_slice(attributes);
attributes_with_message_authenticator.push(MESSAGE_AUTHENTICATOR_TYPE);
attributes_with_message_authenticator.push(MESSAGE_AUTHENTICATOR_ATTRIBUTE_LEN as u8);
attributes_with_message_authenticator
.extend_from_slice(&[0_u8; MESSAGE_AUTHENTICATOR_VALUE_LEN]);
let message_authenticator_value_offset = attributes.len() + RADIUS_ATTRIBUTE_HEADER_LEN;
accounting_request_packet_with_message_authenticator_at(
identifier,
&attributes_with_message_authenticator,
message_authenticator_value_offset,
shared_secret,
)
}
pub(crate) fn accounting_request_packet_with_message_authenticator_at(
identifier: u8,
attributes_with_message_authenticator: &[u8],
message_authenticator_value_offset: usize,
shared_secret: &[u8],
) -> Vec<u8> {
assert!(
message_authenticator_value_offset + MESSAGE_AUTHENTICATOR_VALUE_LEN
<= attributes_with_message_authenticator.len()
);
let mut packet = accounting_request_packet(identifier, attributes_with_message_authenticator);
let value_start = RADIUS_HEADER_LEN + message_authenticator_value_offset;
let value_end = value_start + MESSAGE_AUTHENTICATOR_VALUE_LEN;
let parsed = parse_packet(&packet).unwrap();
let message_authenticator_index = message_authenticator_index(&parsed).unwrap().unwrap();
let message_authenticator =
expected_message_authenticator(&parsed, shared_secret, message_authenticator_index);
packet[value_start..value_end].copy_from_slice(&message_authenticator);
let parsed = parse_packet(&packet).unwrap();
let request_authenticator = expected_request_authenticator(&parsed, shared_secret);
packet[4..RADIUS_HEADER_LEN].copy_from_slice(&request_authenticator);
packet
}