diff --git a/build.rs b/build.rs index ab69a5e..fd0ee4e 100644 --- a/build.rs +++ b/build.rs @@ -12,5 +12,15 @@ fn main() { ); } + match target_os.as_str() { + "linux" => {} + "macos" => println!( + "cargo::warning=traffic control is unavailable on macOS; requested rate changes will be calculated but not applied" + ), + _ => println!( + "cargo::warning=target `{target}` has no native interface-statistics or traffic-control backend; synthetic interface load will be used and requested rate changes will not be applied" + ), + } + println!("cargo::rerun-if-changed=build.rs"); } diff --git a/src/main.rs b/src/main.rs index 7a85833..bed1f06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,6 @@ mod baseliner; mod config; mod log; mod metrics; -#[cfg(target_os = "linux")] -mod netlink; mod pinger; mod pinger_icmp; mod pinger_icmp_ts; @@ -39,7 +37,7 @@ use crate::config::{Config, MeasurementType}; use crate::pinger::{InFlightProbeCache, PingListener, PingSender}; use crate::pinger_icmp::{PingerICMPEchoListener, PingerICMPEchoSender}; use crate::pinger_icmp_ts::{PingerICMPTimestampListener, PingerICMPTimestampSender}; -use crate::platform::{TrafficControlBackend, traffic_control_backend}; +use crate::platform::{TrafficControlBackend, traffic_control_backend, warn_platform_limitations}; use crate::ratecontroller::{Ratecontroller, StatsDirection}; use crate::reflector_selector::ReflectorSelector; @@ -337,11 +335,6 @@ fn run(config: Config) -> anyhow::Result<()> { }; let mut main_traffic_control = traffic_control_backend(); - if main_traffic_control.is_observe_only() { - warn!( - "Traffic control is observe-only on this platform; requested rate changes will not be applied." - ); - } let (down_shaper, up_shaper) = initialize_shaper(&config, &mut main_traffic_control)?; let err_tx = error_tx.clone(); @@ -483,6 +476,7 @@ fn main() -> anyhow::Result<()> { let config = Config::new()?; log::init(config.log_level)?; + warn_platform_limitations(); run(config) } diff --git a/src/platform.rs b/src/platform.rs index 37b18c6..3dffec7 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -2,37 +2,21 @@ // // SPDX-License-Identifier: MPL-2.0 -#[cfg(any(not(target_os = "linux"), test))] -use log::debug; -#[cfg(any(not(target_os = "linux"), test))] -use std::collections::HashMap; -#[cfg(any(not(target_os = "linux"), test))] -use std::convert::Infallible; use std::error::Error; -#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))] -use thiserror::Error; - -#[cfg(target_os = "linux")] -use crate::netlink::Netlink; - -#[cfg(target_os = "macos")] -mod macos; -#[cfg(target_os = "macos")] -pub use macos::MacOsInterfaceStats; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct InterfaceStats { - pub rx_bytes: u64, - pub tx_bytes: u64, +pub(crate) struct InterfaceStats { + pub(crate) rx_bytes: u64, + pub(crate) tx_bytes: u64, } -pub trait InterfaceStatsProvider: Send { +pub(crate) trait InterfaceStatsProvider: Send { type Error: Error + Send + Sync + 'static; fn read_stats(&mut self, interface: &str) -> Result; } -pub trait TrafficControlBackend: Send { +pub(crate) trait TrafficControlBackend: Send { type Error: Error + Send + Sync + 'static; type Handle: Send + 'static; @@ -44,138 +28,30 @@ pub trait TrafficControlBackend: Send { bandwidth_kbit: u64, dry_run: bool, ) -> Result<(), Self::Error>; - - fn is_observe_only(&self) -> bool { - false - } } -#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))] -#[derive(Debug, Default)] -pub struct UnsupportedInterfaceStats; - -#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))] -#[derive(Debug, Error, PartialEq, Eq)] -#[error("interface statistics are unsupported on target OS `{target_os}`")] -pub struct UnsupportedInterfaceStatsError { - target_os: &'static str, -} - -#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))] -impl InterfaceStatsProvider for UnsupportedInterfaceStats { - type Error = UnsupportedInterfaceStatsError; - - fn read_stats(&mut self, _: &str) -> Result { - Err(UnsupportedInterfaceStatsError { - target_os: std::env::consts::OS, - }) - } -} - -#[cfg(any(not(target_os = "linux"), test))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ObserveOnlyShaper { - interface: String, -} - -#[cfg(any(not(target_os = "linux"), test))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ObserveOnlyRequest { - bandwidth_kbit: u64, - dry_run: bool, -} - -#[cfg(any(not(target_os = "linux"), test))] -#[derive(Debug, Default)] -pub struct ObserveOnlyTrafficControl { - last_requests: HashMap, -} - -#[cfg(any(not(target_os = "linux"), test))] -impl TrafficControlBackend for ObserveOnlyTrafficControl { - type Error = Infallible; - type Handle = ObserveOnlyShaper; - - fn find_shaper(&mut self, interface: &str) -> Result { - Ok(ObserveOnlyShaper { - interface: interface.to_string(), - }) - } - - fn set_rate( - &mut self, - shaper: &Self::Handle, - bandwidth_kbit: u64, - dry_run: bool, - ) -> Result<(), Self::Error> { - debug!( - "Observe-only traffic control requested rate {} kbit/s for interface {} (dry_run={})", - bandwidth_kbit, shaper.interface, dry_run - ); - self.last_requests.insert( - shaper.interface.clone(), - ObserveOnlyRequest { - bandwidth_kbit, - dry_run, - }, - ); - Ok(()) - } - - fn is_observe_only(&self) -> bool { - true - } -} - -#[cfg(test)] -#[derive(Debug, Default)] -pub struct UnsupportedTrafficControl; - -#[cfg(test)] -#[derive(Debug, PartialEq, Eq)] -pub struct UnsupportedShaper; - -#[cfg(test)] -#[derive(Debug, Error, PartialEq, Eq)] -#[error("traffic control is unsupported on target OS `{target_os}`")] -pub struct UnsupportedTrafficControlError { - target_os: &'static str, -} - -#[cfg(test)] -impl TrafficControlBackend for UnsupportedTrafficControl { - type Error = UnsupportedTrafficControlError; - type Handle = UnsupportedShaper; - - fn find_shaper(&mut self, _: &str) -> Result { - Err(UnsupportedTrafficControlError { - target_os: std::env::consts::OS, - }) - } - - fn set_rate(&mut self, _: &UnsupportedShaper, _: u64, _: bool) -> Result<(), Self::Error> { - Err(UnsupportedTrafficControlError { - target_os: std::env::consts::OS, - }) - } -} - -#[cfg(target_os = "linux")] -pub type PlatformInterfaceStats = Netlink; -#[cfg(target_os = "macos")] -pub type PlatformInterfaceStats = MacOsInterfaceStats; -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -pub type PlatformInterfaceStats = UnsupportedInterfaceStats; - -#[cfg(target_os = "linux")] -pub type PlatformTrafficControl = Netlink; #[cfg(not(target_os = "linux"))] -pub type PlatformTrafficControl = ObserveOnlyTrafficControl; +mod noop; -pub fn interface_stats_provider() -> PlatformInterfaceStats { - PlatformInterfaceStats::default() -} +#[cfg(target_os = "linux")] +mod linux; -pub fn traffic_control_backend() -> PlatformTrafficControl { - PlatformTrafficControl::default() -} +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod unsupported; + +#[cfg(target_os = "linux")] +use linux as imp; + +#[cfg(target_os = "macos")] +use macos as imp; + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +use unsupported as imp; + +pub(crate) use imp::{ + PlatformInterfaceStats, PlatformTrafficControl, interface_stats_provider, + traffic_control_backend, warn_platform_limitations, +}; diff --git a/src/platform/linux.rs b/src/platform/linux.rs new file mode 100644 index 0000000..a5050fa --- /dev/null +++ b/src/platform/linux.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair) +// +// SPDX-License-Identifier: MPL-2.0 + +mod netlink; + +pub(crate) type PlatformInterfaceStats = netlink::Netlink; +pub(crate) type PlatformTrafficControl = netlink::Netlink; + +pub(crate) fn interface_stats_provider() -> PlatformInterfaceStats { + netlink::Netlink::default() +} + +pub(crate) fn traffic_control_backend() -> PlatformTrafficControl { + netlink::Netlink::default() +} + +pub(crate) fn warn_platform_limitations() {} diff --git a/src/netlink.rs b/src/platform/linux/netlink.rs similarity index 91% rename from src/netlink.rs rename to src/platform/linux/netlink.rs index 1efaf84..5742979 100644 --- a/src/netlink.rs +++ b/src/platform/linux/netlink.rs @@ -13,7 +13,7 @@ use thiserror::Error; use crate::platform::{InterfaceStats, InterfaceStatsProvider, TrafficControlBackend}; #[derive(Debug, Error)] -pub enum NetlinkError { +pub(crate) enum NetlinkError { #[error("Couldn't find interface `{0}`")] InterfaceNotFound(String), @@ -37,16 +37,16 @@ pub enum NetlinkError { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Qdisc { - pub ifindex: i32, - pub parent: u32, +pub(crate) struct Qdisc { + ifindex: i32, + parent: u32, } #[derive(Debug, Default)] -pub struct Netlink {} +pub(crate) struct Netlink {} impl Netlink { - pub fn find_interface(ifname: &str) -> Result { + fn find_interface(ifname: &str) -> Result { let mut socket = NetlinkSocket::new(); let mut request = rt_link::Request::new().op_getlink_do(&Default::default()); @@ -59,7 +59,7 @@ impl Netlink { Ok(header.ifi_index) } - pub fn get_interface_stats(ifname: &str) -> Result<(u64, u64), NetlinkError> { + fn get_interface_stats(ifname: &str) -> Result<(u64, u64), NetlinkError> { let mut socket = NetlinkSocket::new(); let mut request = rt_link::Request::new().op_getlink_do(&Default::default()); @@ -79,7 +79,7 @@ impl Netlink { Err(NetlinkError::NoInterfaceStatsFound(ifname.to_string())) } - pub fn qdisc_from_ifindex(ifindex: i32, ifname: &str) -> Result { + fn qdisc_from_ifindex(ifindex: i32, ifname: &str) -> Result { let mut socket = NetlinkSocket::new(); let header = tc::Tcmsg::new(); let request = tc::Request::new().op_getqdisc_dump(&header); @@ -105,12 +105,12 @@ impl Netlink { Err(NetlinkError::NoQdiscFound(ifname.to_string())) } - pub fn qdisc_from_ifname(ifname: &str) -> Result { + fn qdisc_from_ifname(ifname: &str) -> Result { let ifindex = Netlink::find_interface(ifname)?; Netlink::qdisc_from_ifindex(ifindex, ifname) } - pub fn set_qdisc_rate( + fn set_qdisc_rate( qdisc: Qdisc, bandwidth_kbit: u64, dry_run: bool, diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 53bf94a..c4bf5e8 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -7,15 +7,17 @@ use std::io; use std::mem::size_of; use std::ptr; +use log::warn; use thiserror::Error; -use super::{InterfaceStats, InterfaceStatsProvider}; +use super::noop::NoopTrafficControl; +use crate::platform::{InterfaceStats, InterfaceStatsProvider}; const ROUTE_MESSAGE_PREFIX_LEN: usize = size_of::() + 2; const SYSCTL_FETCH_ATTEMPTS: usize = 3; #[derive(Debug, Error)] -pub enum MacOsInterfaceStatsError { +pub(crate) enum MacOsInterfaceStatsError { #[error("interface name contains a NUL byte")] InterfaceNameContainsNul(#[source] NulError), @@ -41,7 +43,7 @@ pub enum MacOsInterfaceStatsError { } #[derive(Debug, Default)] -pub struct MacOsInterfaceStats; +pub(crate) struct MacOsInterfaceStats; impl InterfaceStatsProvider for MacOsInterfaceStats { type Error = MacOsInterfaceStatsError; @@ -197,3 +199,20 @@ fn parse_route_messages( interface_index: requested_interface_index, }) } + +pub(crate) type PlatformInterfaceStats = MacOsInterfaceStats; +pub(crate) type PlatformTrafficControl = NoopTrafficControl; + +pub(crate) fn interface_stats_provider() -> PlatformInterfaceStats { + MacOsInterfaceStats +} + +pub(crate) fn traffic_control_backend() -> PlatformTrafficControl { + NoopTrafficControl +} + +pub(crate) fn warn_platform_limitations() { + warn!( + "Traffic control is unavailable on macOS; requested rate changes will be calculated but not applied." + ); +} diff --git a/src/platform/noop.rs b/src/platform/noop.rs new file mode 100644 index 0000000..05c5e5b --- /dev/null +++ b/src/platform/noop.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair) +// +// SPDX-License-Identifier: MPL-2.0 + +use crate::platform::TrafficControlBackend; +use log::debug; +use std::convert::Infallible; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NoopShaper { + interface: String, +} + +#[derive(Debug, Default)] +pub(crate) struct NoopTrafficControl; + +impl TrafficControlBackend for NoopTrafficControl { + type Error = Infallible; + type Handle = NoopShaper; + + fn find_shaper(&mut self, interface: &str) -> Result { + Ok(NoopShaper { + interface: interface.to_string(), + }) + } + + fn set_rate( + &mut self, + shaper: &Self::Handle, + bandwidth_kbit: u64, + dry_run: bool, + ) -> Result<(), Self::Error> { + debug!( + "Ignoring traffic-control rate request of {} kbit/s for interface {} (dry_run={})", + bandwidth_kbit, shaper.interface, dry_run + ); + Ok(()) + } +} diff --git a/src/platform/unsupported.rs b/src/platform/unsupported.rs new file mode 100644 index 0000000..8119fd9 --- /dev/null +++ b/src/platform/unsupported.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair) +// +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; +use std::convert::Infallible; +use std::time::{Duration, Instant}; + +use log::warn; + +use crate::platform::noop::NoopTrafficControl; +use crate::platform::{InterfaceStats, InterfaceStatsProvider}; + +// Synthetic rates are fixed per interface and intentionally conservative. Counter increments are +// derived from elapsed monotonic time, so changing the polling frequency does not change the +// apparent traffic rate. +const SYNTHETIC_RX_KBIT_RANGE: std::ops::RangeInclusive = 500..=25_000; +const SYNTHETIC_TX_KBIT_RANGE: std::ops::RangeInclusive = 250..=10_000; +const INITIAL_COUNTER_BYTES: u64 = 1; + +#[derive(Debug)] +struct SyntheticInterfaceState { + rx_bytes: u64, + tx_bytes: u64, + rx_kbit: u64, + tx_kbit: u64, + last_update: Instant, +} + +impl SyntheticInterfaceState { + fn new(now: Instant) -> Self { + Self { + rx_bytes: INITIAL_COUNTER_BYTES, + tx_bytes: INITIAL_COUNTER_BYTES, + rx_kbit: fastrand::u64(SYNTHETIC_RX_KBIT_RANGE), + tx_kbit: fastrand::u64(SYNTHETIC_TX_KBIT_RANGE), + last_update: now, + } + } + + fn update(&mut self, now: Instant) { + let elapsed = now.saturating_duration_since(self.last_update); + self.rx_bytes = self + .rx_bytes + .saturating_add(bytes_for_elapsed(elapsed, self.rx_kbit)); + self.tx_bytes = self + .tx_bytes + .saturating_add(bytes_for_elapsed(elapsed, self.tx_kbit)); + self.last_update = now; + } +} + +fn bytes_for_elapsed(elapsed: Duration, bitrate_kbit: u64) -> u64 { + let bytes = elapsed + .as_nanos() + .saturating_mul(u128::from(bitrate_kbit)) + .saturating_mul(1_000) + / 8_000_000_000; + u64::try_from(bytes).unwrap_or(u64::MAX) +} + +#[derive(Debug, Default)] +pub(crate) struct SyntheticInterfaceStats { + interfaces: HashMap, +} + +impl InterfaceStatsProvider for SyntheticInterfaceStats { + type Error = Infallible; + + fn read_stats(&mut self, interface: &str) -> Result { + let now = Instant::now(); + let state = self + .interfaces + .entry(interface.to_string()) + .or_insert_with(|| SyntheticInterfaceState::new(now)); + state.update(now); + + Ok(InterfaceStats { + rx_bytes: state.rx_bytes, + tx_bytes: state.tx_bytes, + }) + } +} + +pub(crate) type PlatformInterfaceStats = SyntheticInterfaceStats; +pub(crate) type PlatformTrafficControl = NoopTrafficControl; + +pub(crate) fn interface_stats_provider() -> PlatformInterfaceStats { + SyntheticInterfaceStats::default() +} + +pub(crate) fn traffic_control_backend() -> PlatformTrafficControl { + NoopTrafficControl +} + +pub(crate) fn warn_platform_limitations() { + warn!( + "This operating system has no native interface-statistics or traffic-control backend; synthetic interface load will be used and requested rate changes will not be applied." + ); +}