feat(measurement): integrate IRTT and configurable reflector selection

Add protocol-aware reflector definitions, TOML/UCI lists, validation and environment overrides. Resolve and select endpoint-aware measurement streams, honor configured ICMP timing and socket routing, and retain partial-set reselection behavior from the standalone fixes.

Drive the managed IRTT task throughout its lifecycle, retry terminal targets without restarting healthy sessions, normalize blank HMAC keys and use effective RTT for latency fallback. Fold the reviewed configuration, resolution and measurement corrections into their implementation.
This commit is contained in:
Nils Andreas Svee committed 2026-10-14 12:50:29 +07:00
1 parent 41929875f7
commit eaa20e79d2
13 files changed
+1158 -202

No files matched your search

+200 -66
View File
@@ -2,12 +2,10 @@
//
// SPDX-License-Identifier: MPL-2.0
use anyhow::{Context, bail};
use config::{Config, ConfigError, Environment};
use log::Level;
use serde::{Deserialize, Deserializer, de};
use std::fmt::{self, Display};
use std::net::IpAddr;
use std::str::FromStr;
use uci_derive::{UciConfig, UciSection};
@@ -48,7 +46,7 @@ where
type Value = bool;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a boolean or a UCI-compatible boolean string")
formatter.write_str("a boolean, integer 0 or 1, or a UCI-compatible boolean string")
}
fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
@@ -58,6 +56,28 @@ where
Ok(value)
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
match value {
0 => Ok(false),
1 => Ok(true),
_ => Err(E::custom("boolean integer must be 0 or 1")),
}
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
match value {
0 => Ok(false),
1 => Ok(true),
_ => Err(E::custom("boolean integer must be 0 or 1")),
}
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
@@ -79,28 +99,32 @@ where
deserializer.deserialize_any(FlexiBoolVisitor)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MeasurementType {
Icmp = 1,
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum ReflectorProtocol {
IcmpEcho = 1,
IcmpTimestamps,
Irtt,
}
impl Display for MeasurementType {
impl Display for ReflectorProtocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MeasurementType::Icmp => write!(f, "icmp"),
MeasurementType::IcmpTimestamps => write!(f, "icmp-timestamps"),
ReflectorProtocol::IcmpEcho => write!(f, "icmp-echo"),
ReflectorProtocol::IcmpTimestamps => write!(f, "icmp-timestamps"),
ReflectorProtocol::Irtt => write!(f, "irtt"),
}
}
}
impl FromStr for MeasurementType {
impl FromStr for ReflectorProtocol {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"icmp" => Ok(MeasurementType::Icmp),
"icmp-timestamps" | "icmp_timestamps" => Ok(MeasurementType::IcmpTimestamps),
"icmp-echo" => Ok(ReflectorProtocol::IcmpEcho),
"icmp-timestamps" => Ok(ReflectorProtocol::IcmpTimestamps),
"irtt" => Ok(ReflectorProtocol::Irtt),
_ => Err(ConfigError::Message(format!(
"invalid measurement type: {s}"
))),
@@ -128,12 +152,23 @@ impl FromStr for ObservabilityProtocol {
}
}
#[derive(Debug, Deserialize)]
struct ReflectorRecord {
reflector_ip: IpAddr,
ip_version: u8,
#[derive(Debug, Clone, Deserialize, UciSection)]
pub(crate) struct ReflectorDefinition {
pub(crate) host: String,
pub(crate) country: Option<String>,
pub(crate) protocols: Vec<ReflectorProtocol>,
pub(crate) irtt_port: Option<u16>,
#[allow(dead_code)]
description: String,
pub(crate) irtt_hmac: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ReflectorRow {
host: String,
country: Option<String>,
protocols: String,
irtt_port: Option<u16>,
irtt_hmac: Option<String>,
}
#[derive(Clone, Debug, Deserialize, UciConfig)]
@@ -142,6 +177,10 @@ pub(crate) struct Settings {
#[uci(section)]
pub(crate) network: NetworkSettings,
#[serde(default)]
#[uci(section)]
pub(crate) reflectors: ReflectorSettings,
#[serde(default)]
#[uci(section)]
pub(crate) output: OutputSettings,
@@ -160,55 +199,41 @@ impl Settings {
let builder = Config::builder();
#[cfg(all(feature = "uci", unix))]
let builder = builder.add_source(UciSource::from_schema::<Self>().required(false));
let builder = builder.add_source(
UciSource::from_schema::<Self>()
.with_repeated_schema(&[
uci_schema::UciRepeatedSectionMapping {
uci_section: "reflector",
config_path: "reflectors.entries",
options: <ReflectorDefinition as uci_schema::UciSectionSchema>::UCI_OPTIONS,
},
uci_schema::UciRepeatedSectionMapping {
uci_section: "bootstrap_reflector",
config_path: "reflectors.bootstrap",
options: <ReflectorDefinition as uci_schema::UciSectionSchema>::UCI_OPTIONS,
},
])
.required(false),
);
#[cfg(feature = "toml")]
let builder = builder.add_source(
config::File::with_name("/etc/sqm-autorate-rust/config.toml").required(false),
);
let builder = builder.add_source(Environment::with_prefix("SQMA").separator("__"));
let built_config = builder.build()?;
let builder = builder.add_source(
Environment::with_prefix("SQMA")
.separator("__")
.try_parsing(true)
.list_separator(",")
.with_list_parse_key("reflectors.files")
.with_list_parse_key("reflectors.countries"),
);
let mut settings: Self = builder.build()?.try_deserialize()?;
built_config.try_deserialize()
}
settings.reflectors.load_files()?;
pub fn load_reflectors(&self) -> anyhow::Result<Vec<IpAddr>> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.trim(csv::Trim::All)
.from_path(&self.advanced_settings.reflector_list_file)
.with_context(|| {
format!(
"failed to open reflector list: {}",
self.advanced_settings.reflector_list_file
)
})?;
let mut reflectors = Vec::with_capacity(50);
for record in reader.deserialize::<ReflectorRecord>() {
let record = record.with_context(|| {
format!(
"failed to parse reflector list: {}",
self.advanced_settings.reflector_list_file
)
})?;
match (record.ip_version, record.reflector_ip) {
(4, IpAddr::V4(_)) => {}
(6, address @ IpAddr::V6(_)) => {
bail!("reflector {address} is IPv6; only IPv4 reflectors are supported");
}
(version, address) => {
bail!("reflector {address} has inconsistent IP version {version}");
}
}
reflectors.push(record.reflector_ip);
}
Ok(reflectors)
Ok(settings)
}
}
@@ -241,6 +266,100 @@ impl NetworkSettings {
}
}
#[derive(Clone, Debug, Deserialize, UciSection)]
#[serde(default)]
pub(crate) struct ReflectorSettings {
#[serde(deserialize_with = "deserialize_reflector_files")]
pub(crate) files: Vec<String>,
pub(crate) countries: Vec<String>,
#[uci(skip)]
pub(crate) entries: Vec<ReflectorDefinition>,
#[uci(skip)]
pub(crate) bootstrap: Vec<ReflectorDefinition>,
pub(crate) num_reflectors: u8,
pub(crate) peer_reselection_time: u64,
}
impl ReflectorSettings {
fn load_files(&mut self) -> Result<(), ConfigError> {
for path in &self.files {
let mut reader = csv::Reader::from_path(path)
.map_err(|error| ConfigError::Message(error.to_string()))?;
for row in reader.deserialize::<ReflectorRow>() {
let row = row.map_err(|error| ConfigError::Message(error.to_string()))?;
let protocols = row
.protocols
.split(['|', ';'])
.map(str::trim)
.filter(|protocol| !protocol.is_empty())
.map(ReflectorProtocol::from_str)
.collect::<Result<Vec<_>, _>>()?;
if protocols.is_empty() {
return Err(ConfigError::Message(format!(
"reflector row in {path} has no protocols"
)));
}
self.entries.push(ReflectorDefinition {
host: row.host,
country: row.country,
protocols,
irtt_port: row.irtt_port,
irtt_hmac: row.irtt_hmac,
});
}
}
Ok(())
}
}
impl Default for ReflectorSettings {
fn default() -> Self {
let icmp = |host: &str| ReflectorDefinition {
host: host.to_owned(),
country: None,
protocols: vec![
ReflectorProtocol::IcmpEcho,
ReflectorProtocol::IcmpTimestamps,
],
irtt_port: None,
irtt_hmac: None,
};
let irtt = |host: &str, country: Option<&str>| ReflectorDefinition {
host: host.to_owned(),
country: country.map(str::to_owned),
protocols: vec![ReflectorProtocol::Irtt],
irtt_port: Some(2112),
irtt_hmac: None,
};
Self {
files: vec!["/etc/sqm-autorate-rust/reflectors.csv".into()],
countries: Vec::new(),
entries: Vec::new(),
bootstrap: vec![
icmp("9.9.9.9"),
icmp("8.238.120.14"),
icmp("74.82.42.42"),
icmp("194.242.2.2"),
icmp("208.67.222.222"),
icmp("94.140.14.14"),
irtt("global.irtt.lochnair.net", None),
irtt("uk-cov1.irtt.lochnair.net", Some("GB")),
irtt("uk-cov2.irtt.lochnair.net", Some("GB")),
irtt("de-fra.irtt.lochnair.net", Some("DE")),
irtt("nl-ams1.irtt.lochnair.net", Some("NL")),
],
num_reflectors: 5,
peer_reselection_time: 15,
}
}
}
#[derive(Clone, Debug, Deserialize, UciSection)]
#[serde(default)]
pub(crate) struct OutputSettings {
@@ -310,10 +429,7 @@ pub(crate) struct AdvancedSettings {
pub(crate) high_load_level: f64,
pub(crate) min_change_interval: f64,
#[serde(deserialize_with = "deserialize_from_str")]
pub(crate) measurement_type: MeasurementType,
pub(crate) num_reflectors: u8,
pub(crate) peer_reselection_time: u64,
pub(crate) reflector_list_file: String,
pub(crate) measurement_type: ReflectorProtocol,
pub(crate) speed_hist_size: u32,
pub(crate) tick_interval: f64,
pub(crate) upload_delay_ms: f64,
@@ -327,10 +443,7 @@ impl Default for AdvancedSettings {
download_delay_ms: 15.0,
high_load_level: 0.8,
min_change_interval: 0.5,
measurement_type: MeasurementType::IcmpTimestamps,
num_reflectors: 5,
peer_reselection_time: 15,
reflector_list_file: "/etc/sqm-autorate/reflectors-icmp.csv".to_owned(),
measurement_type: ReflectorProtocol::IcmpTimestamps,
speed_hist_size: 100,
tick_interval: 0.5,
upload_delay_ms: 15.0,
@@ -359,3 +472,24 @@ where
let value = String::deserialize(deserializer)?;
value.parse().map_err(de::Error::custom)
}
// UCI drops empty string options; `option files 'none'` disables file loading.
fn deserialize_reflector_files<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Files {
List(Vec<String>),
String(String),
}
match Files::deserialize(deserializer)? {
Files::List(files) => Ok(files),
Files::String(value) if value.trim().is_empty() || value.trim() == "none" => Ok(Vec::new()),
Files::String(_) => Err(de::Error::custom(
"reflector files must be a list, an empty string, or none",
)),
}
}