refactor(platform): restructure platform-specific abstractions and add synthetic backends

This commit is contained in:
Nils Andreas Svee committed 2026-07-17 13:02:17 +07:00
1 parent 50f0dcb4dd
commit ae9193580c
8 files changed
+228 -172

No files matched your search

+18
View File
@@ -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() {}
+172
View File
@@ -0,0 +1,172 @@
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use std::io;
use std::str::Utf8Error;
use log::info;
use netlink_bindings::{rt_link, tc};
use netlink_socket2::NetlinkSocket;
use thiserror::Error;
use crate::platform::{InterfaceStats, InterfaceStatsProvider, TrafficControlBackend};
#[derive(Debug, Error)]
pub(crate) enum NetlinkError {
#[error("Couldn't find interface `{0}`")]
InterfaceNotFound(String),
#[error("Netlink error: {0}")]
Netlink(#[from] io::Error),
#[error("Netlink reply error: {0}")]
Reply(#[from] netlink_socket2::ReplyError),
#[error("Something went wrong while finding qdisc")]
NlQdiscError(String),
#[error("Couldn't find CAKE qdisc on interface `{0}`")]
NoQdiscFound(String),
#[error("Couldn't find interface statistics: `{0}`")]
NoInterfaceStatsFound(String),
#[error("Error happened while parsing UTF-8 string")]
Utf8Error(#[from] Utf8Error),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Qdisc {
ifindex: i32,
parent: u32,
}
#[derive(Debug, Default)]
pub(crate) struct Netlink {}
impl Netlink {
fn find_interface(ifname: &str) -> Result<i32, NetlinkError> {
let mut socket = NetlinkSocket::new();
let mut request = rt_link::Request::new().op_getlink_do(&Default::default());
request.encode().push_ifname_bytes(ifname.as_bytes());
let mut iter = socket.request(&request)?;
let (header, _) = iter
.recv_one()
.map_err(|_| NetlinkError::InterfaceNotFound(ifname.to_string()))?;
Ok(header.ifi_index)
}
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());
request
.encode()
.push_ifname_bytes(ifname.as_bytes())
.push_ext_mask(1 /* RTEXT_FILTER_VF */);
let mut iter = socket.request(&request)?;
while let Some(reply) = iter.recv() {
let (_, attrs) = reply?;
if let Ok(stats) = attrs.get_stats64() {
return Ok((stats.rx_bytes, stats.tx_bytes));
}
}
Err(NetlinkError::NoInterfaceStatsFound(ifname.to_string()))
}
fn qdisc_from_ifindex(ifindex: i32, ifname: &str) -> Result<Qdisc, NetlinkError> {
let mut socket = NetlinkSocket::new();
let header = tc::Tcmsg::new();
let request = tc::Request::new().op_getqdisc_dump(&header);
let mut iter = socket.request(&request)?;
while let Some(reply) = iter.recv() {
let (header, attrs) = reply?;
if header.ifindex == ifindex
&& let Ok(kind) = attrs.get_kind()
&& kind
.to_str()
.map_err(|_| NetlinkError::NlQdiscError("Invalid UTF-8".to_string()))?
== "cake"
{
return Ok(Qdisc {
ifindex,
parent: header.parent,
});
}
}
Err(NetlinkError::NoQdiscFound(ifname.to_string()))
}
fn qdisc_from_ifname(ifname: &str) -> Result<Qdisc, NetlinkError> {
let ifindex = Netlink::find_interface(ifname)?;
Netlink::qdisc_from_ifindex(ifindex, ifname)
}
fn set_qdisc_rate(
qdisc: Qdisc,
bandwidth_kbit: u64,
dry_run: bool,
) -> Result<(), NetlinkError> {
if dry_run {
info!(
"dry-run: would set qdisc ifindex={} to {} kbit/s",
qdisc.ifindex, bandwidth_kbit
);
return Ok(());
}
let mut socket = NetlinkSocket::new();
let bandwidth = bandwidth_kbit * 1000 / 8;
let mut header = tc::Tcmsg::new();
header.ifindex = qdisc.ifindex;
header.parent = qdisc.parent;
let mut request = tc::Request::new().set_change().op_newqdisc_do(&header);
request
.encode()
.push_kind(c"cake")
.nested_options_cake()
.push_base_rate64(bandwidth)
.end_nested();
let mut iter = socket.request(&request)?;
iter.recv_ack()?;
Ok(())
}
}
impl InterfaceStatsProvider for Netlink {
type Error = NetlinkError;
fn read_stats(&mut self, interface: &str) -> Result<InterfaceStats, Self::Error> {
let (rx_bytes, tx_bytes) = Self::get_interface_stats(interface)?;
Ok(InterfaceStats { rx_bytes, tx_bytes })
}
}
impl TrafficControlBackend for Netlink {
type Error = NetlinkError;
type Handle = Qdisc;
fn find_shaper(&mut self, interface: &str) -> Result<Self::Handle, Self::Error> {
Self::qdisc_from_ifname(interface)
}
fn set_rate(
&mut self,
shaper: &Self::Handle,
bandwidth_kbit: u64,
dry_run: bool,
) -> Result<(), Self::Error> {
Self::set_qdisc_rate(*shaper, bandwidth_kbit, dry_run)
}
}
+22 -3
View File
@@ -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::<libc::c_ushort>() + 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."
);
}
+39
View File
@@ -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<Self::Handle, Self::Error> {
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(())
}
}
+100
View File
@@ -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<u64> = 500..=25_000;
const SYNTHETIC_TX_KBIT_RANGE: std::ops::RangeInclusive<u64> = 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<String, SyntheticInterfaceState>,
}
impl InterfaceStatsProvider for SyntheticInterfaceStats {
type Error = Infallible;
fn read_stats(&mut self, interface: &str) -> Result<InterfaceStats, Self::Error> {
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."
);
}