mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
Add RADIUS accounting MVP plumbing
This commit is contained in:
1 parent
0b1fb8b72e
commit
9f4ab17c4e
24 files changed
+7272
-2
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
Generated
+23
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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(), &[]);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user