Support optional IRTT measurements alongside ICMP with CSV and inline reflector definitions, repeated UCI registration, authentication, and endpoint-specific stream tracking. Keep the managed driver running during target acknowledgements and stop and join it on event-loop exits. Carry stream identity through baselines, selection, and rate control so endpoints sharing an IP remain distinct.
451 lines
13 KiB
Rust
451 lines
13 KiB
Rust
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
|
|
//
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
//
|
|
|
|
use crate::settings::ReflectorSettings;
|
|
|
|
use super::{AdvancedSettings, NetworkSettings, ObservabilitySettings, OutputSettings, Settings};
|
|
use std::error::Error;
|
|
use std::fmt::{self, Display, Formatter};
|
|
|
|
const WARN_DOWNLOAD_MIN_KBITS: f64 = 1_000.0;
|
|
const WARN_UPLOAD_MIN_KBITS: f64 = 500.0;
|
|
const WARN_HIGH_MIN_PERCENT: f64 = 80.0;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ValidationSeverity {
|
|
Warning,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ValidationIssue {
|
|
severity: ValidationSeverity,
|
|
path: &'static str,
|
|
message: String,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct ValidationReport {
|
|
issues: Vec<ValidationIssue>,
|
|
}
|
|
|
|
impl ValidationReport {
|
|
fn warning(&mut self, path: &'static str, message: impl Into<String>) {
|
|
self.issues.push(ValidationIssue {
|
|
severity: ValidationSeverity::Warning,
|
|
path,
|
|
message: message.into(),
|
|
});
|
|
}
|
|
|
|
fn error(&mut self, path: &'static str, message: impl Into<String>) {
|
|
self.issues.push(ValidationIssue {
|
|
severity: ValidationSeverity::Error,
|
|
path,
|
|
message: message.into(),
|
|
});
|
|
}
|
|
|
|
fn finish(self) -> Result<(), Self> {
|
|
let mut errors = Vec::new();
|
|
|
|
for issue in self.issues {
|
|
match issue.severity {
|
|
ValidationSeverity::Warning => {
|
|
eprintln!("warning: {}: {}", issue.path, issue.message);
|
|
}
|
|
|
|
ValidationSeverity::Error => {
|
|
errors.push(issue);
|
|
}
|
|
}
|
|
}
|
|
|
|
if errors.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(Self { issues: errors })
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Display for ValidationReport {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
write!(f, "settings validation failed:")?;
|
|
|
|
for issue in &self.issues {
|
|
write!(f, "\n {}: {}", issue.path, issue.message)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Error for ValidationReport {}
|
|
|
|
impl Settings {
|
|
pub(crate) fn validate(&self) -> Result<(), ValidationReport> {
|
|
let mut report = ValidationReport::default();
|
|
|
|
self.network.validate(&mut report);
|
|
self.reflectors.validate(&mut report);
|
|
self.output.validate(&mut report);
|
|
self.observability.validate(&mut report);
|
|
self.advanced_settings.validate(&mut report);
|
|
|
|
report.finish()
|
|
}
|
|
}
|
|
|
|
impl NetworkSettings {
|
|
fn validate(&self, report: &mut ValidationReport) {
|
|
if self.download_interface.trim().is_empty() {
|
|
report.error("network.download_interface", "must not be empty");
|
|
}
|
|
|
|
if self.upload_interface.trim().is_empty() {
|
|
report.error("network.upload_interface", "must not be empty");
|
|
}
|
|
|
|
let download_base_valid = validate_greater_than_zero_f64(
|
|
report,
|
|
"network.download_base_kbits",
|
|
self.download_base_kbits,
|
|
);
|
|
|
|
let upload_base_valid = validate_greater_than_zero_f64(
|
|
report,
|
|
"network.upload_base_kbits",
|
|
self.upload_base_kbits,
|
|
);
|
|
|
|
let download_percent_valid = validate_percent(
|
|
report,
|
|
"network.download_min_percent",
|
|
self.download_min_percent,
|
|
);
|
|
|
|
let upload_percent_valid = validate_percent(
|
|
report,
|
|
"network.upload_min_percent",
|
|
self.upload_min_percent,
|
|
);
|
|
|
|
if download_percent_valid && self.download_min_percent > WARN_HIGH_MIN_PERCENT {
|
|
report.warning(
|
|
"network.download_min_percent",
|
|
format!(
|
|
"{}% leaves little room for the shaper to reduce the download rate",
|
|
self.download_min_percent,
|
|
),
|
|
);
|
|
}
|
|
|
|
if upload_percent_valid && self.upload_min_percent > WARN_HIGH_MIN_PERCENT {
|
|
report.warning(
|
|
"network.upload_min_percent",
|
|
format!(
|
|
"{}% leaves little room for the shaper to reduce the upload rate",
|
|
self.upload_min_percent,
|
|
),
|
|
);
|
|
}
|
|
|
|
if download_base_valid && download_percent_valid {
|
|
let minimum = self.download_min_kbits();
|
|
|
|
if minimum < 1.0 {
|
|
report.error(
|
|
"network.download_min_percent",
|
|
"minimum download rate floors to zero",
|
|
);
|
|
} else if minimum < WARN_DOWNLOAD_MIN_KBITS {
|
|
report.warning(
|
|
"network.download_min_percent",
|
|
format!(
|
|
"results in a minimum download rate of only {minimum:.0} kbit/s \
|
|
({:.1}% of {:.0} kbit/s); connectivity may become severely constrained",
|
|
self.download_min_percent, self.download_base_kbits,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if upload_base_valid && upload_percent_valid {
|
|
let minimum = self.upload_min_kbits();
|
|
|
|
if minimum < 1.0 {
|
|
report.error(
|
|
"network.upload_min_percent",
|
|
"minimum upload rate floors to zero",
|
|
);
|
|
} else if minimum < WARN_UPLOAD_MIN_KBITS {
|
|
report.warning(
|
|
"network.upload_min_percent",
|
|
format!(
|
|
"results in a minimum upload rate of only {minimum:.0} kbit/s \
|
|
({:.1}% of {:.0} kbit/s); connectivity may become severely constrained",
|
|
self.upload_min_percent, self.upload_base_kbits,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ReflectorSettings {
|
|
fn validate(&self, report: &mut ValidationReport) {
|
|
if self.num_reflectors == 0 {
|
|
report.error("reflectors.num_reflectors", "must be greater than 0");
|
|
}
|
|
|
|
if self.peer_reselection_time == 0 {
|
|
report.error("reflectors.peer_reselection_time", "must be greater than 0");
|
|
}
|
|
|
|
if self.files.is_empty() && self.entries.is_empty() {
|
|
report.error(
|
|
"reflectors",
|
|
"at least one reflector file or inline entry is required",
|
|
);
|
|
}
|
|
|
|
for (index, file) in self.files.iter().enumerate() {
|
|
if file.trim().is_empty() {
|
|
report.error(
|
|
"reflectors.files",
|
|
format!("entry {index} must not be empty"),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (index, country) in self.countries.iter().enumerate() {
|
|
if country.len() != 2 || !country.bytes().all(|c| c.is_ascii_alphabetic()) {
|
|
report.error(
|
|
"reflectors.countries",
|
|
format!(
|
|
"entry {index} ({country:?}) must be an ISO 3166-1 alpha-2 country code"
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl OutputSettings {
|
|
fn validate(&self, report: &mut ValidationReport) {
|
|
if self.suppress_statistics {
|
|
return;
|
|
}
|
|
|
|
let speed_hist_file = self.speed_hist_file.trim();
|
|
let stats_file = self.stats_file.trim();
|
|
|
|
if speed_hist_file.is_empty() {
|
|
report.error(
|
|
"output.speed_hist_file",
|
|
"must not be empty when statistics are enabled",
|
|
);
|
|
}
|
|
|
|
if stats_file.is_empty() {
|
|
report.error(
|
|
"output.stats_file",
|
|
"must not be empty when statistics are enabled",
|
|
);
|
|
}
|
|
|
|
if !speed_hist_file.is_empty() && !stats_file.is_empty() && speed_hist_file == stats_file {
|
|
report.error(
|
|
"output.stats_file",
|
|
"must not refer to the same file as output.speed_hist_file",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ObservabilitySettings {
|
|
fn validate(&self, report: &mut ValidationReport) {
|
|
if !self.enabled {
|
|
return;
|
|
}
|
|
|
|
if self
|
|
.host
|
|
.as_deref()
|
|
.is_none_or(|host| host.trim().is_empty())
|
|
{
|
|
report.error(
|
|
"observability.host",
|
|
"must be set when observability is enabled",
|
|
);
|
|
}
|
|
|
|
if self.port == 0 {
|
|
report.error("observability.port", "must be greater than 0");
|
|
}
|
|
|
|
if self.batch_size == 0 {
|
|
report.error("observability.batch_size", "must be greater than 0");
|
|
}
|
|
|
|
if self.batch_timeout_ms == 0 {
|
|
report.error("observability.batch_timeout_ms", "must be greater than 0");
|
|
}
|
|
|
|
if !self.export_ping_metrics
|
|
&& !self.export_rate_metrics
|
|
&& !self.export_baseline_metrics
|
|
&& !self.export_events
|
|
{
|
|
report.warning(
|
|
"observability.enabled",
|
|
"observability is enabled but all metric and event exports are disabled",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AdvancedSettings {
|
|
fn validate(&self, report: &mut ValidationReport) {
|
|
validate_zero_or_greater_f64(
|
|
report,
|
|
"advanced_settings.download_delay_ms",
|
|
self.download_delay_ms,
|
|
);
|
|
|
|
validate_zero_or_greater_f64(
|
|
report,
|
|
"advanced_settings.upload_delay_ms",
|
|
self.upload_delay_ms,
|
|
);
|
|
|
|
let high_load_level_valid = validate_zero_or_greater_f64(
|
|
report,
|
|
"advanced_settings.high_load_level",
|
|
self.high_load_level,
|
|
);
|
|
|
|
validate_greater_than_zero_f64(
|
|
report,
|
|
"advanced_settings.min_change_interval",
|
|
self.min_change_interval,
|
|
);
|
|
|
|
validate_greater_than_zero_f64(
|
|
report,
|
|
"advanced_settings.tick_interval",
|
|
self.tick_interval,
|
|
);
|
|
|
|
if self.speed_hist_size == 0 {
|
|
report.error(
|
|
"advanced_settings.speed_hist_size",
|
|
"must be greater than 0",
|
|
);
|
|
}
|
|
|
|
if high_load_level_valid {
|
|
if self.high_load_level == 0.0 {
|
|
report.warning(
|
|
"advanced_settings.high_load_level",
|
|
"0 causes any positive load to be treated as high load",
|
|
);
|
|
} else if self.high_load_level > 1.0 {
|
|
report.warning(
|
|
"advanced_settings.high_load_level",
|
|
format!(
|
|
"{} requires utilisation to exceed the current shaper rate \
|
|
before the high-load threshold is reached",
|
|
self.high_load_level,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_greater_than_zero_f64(
|
|
report: &mut ValidationReport,
|
|
path: &'static str,
|
|
value: f64,
|
|
) -> bool {
|
|
if !value.is_finite() {
|
|
report.error(path, format!("must be finite, got {value}"));
|
|
false
|
|
} else if value <= 0.0 {
|
|
report.error(path, format!("must be greater than 0, got {value}"));
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
|
|
fn validate_zero_or_greater_f64(
|
|
report: &mut ValidationReport,
|
|
path: &'static str,
|
|
value: f64,
|
|
) -> bool {
|
|
if !value.is_finite() {
|
|
report.error(path, format!("must be finite, got {value}"));
|
|
false
|
|
} else if value < 0.0 {
|
|
report.error(path, format!("must be 0 or greater, got {value}"));
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
|
|
fn validate_percent(report: &mut ValidationReport, path: &'static str, value: f64) -> bool {
|
|
if !value.is_finite() {
|
|
report.error(path, format!("must be finite, got {value}"));
|
|
false
|
|
} else if !(1.0..=100.0).contains(&value) {
|
|
report.error(path, format!("must be between 1 and 100, got {value}"));
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn network(download_base_kbits: f64, upload_base_kbits: f64) -> NetworkSettings {
|
|
NetworkSettings {
|
|
download_interface: "ifb0".into(),
|
|
upload_interface: "eth0".into(),
|
|
download_base_kbits,
|
|
download_min_percent: 1.0,
|
|
upload_base_kbits,
|
|
upload_min_percent: 1.0,
|
|
measurement_bind_device: None,
|
|
measurement_mark: None,
|
|
measurement_fib: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn minimum_rates_that_floor_to_zero_are_errors_for_both_directions() {
|
|
let mut report = ValidationReport::default();
|
|
network(50.0, 50.0).validate(&mut report);
|
|
let error = report.finish().unwrap_err().to_string();
|
|
|
|
assert!(error.contains("network.download_min_percent"));
|
|
assert!(error.contains("network.upload_min_percent"));
|
|
assert!(error.contains("floors to zero"));
|
|
}
|
|
|
|
#[test]
|
|
fn representable_low_minimum_rates_remain_warnings() {
|
|
let mut report = ValidationReport::default();
|
|
network(10_000.0, 10_000.0).validate(&mut report);
|
|
|
|
assert!(report.finish().is_ok());
|
|
}
|
|
}
|