diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..e0a7a7d --- /dev/null +++ b/Cross.toml @@ -0,0 +1,3 @@ +[target.x86_64-unknown-freebsd] +image.name = "ghcr.io/cross-rs/x86_64-unknown-freebsd:local" +image.toolchain = ["linux/arm64=aarch64-unknown-linux-gnu"] diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..323ab97 --- /dev/null +++ b/check.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +targets=( + aarch64-apple-darwin + x86_64-unknown-linux-gnu + x86_64-unknown-linux-musl + aarch64-unknown-linux-musl + x86_64-unknown-freebsd +) + +for target in "${targets[@]}"; do + echo + echo "==> Checking ${target}" + cargo check \ + --workspace \ + --all-targets \ + --target "${target}" +done diff --git a/src/platform.rs b/src/platform.rs index 3dffec7..170d41a 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -33,22 +33,28 @@ pub(crate) trait TrafficControlBackend: Send { #[cfg(not(target_os = "linux"))] mod noop; +#[cfg(target_os = "freebsd")] +mod freebsd; + #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "macos")] mod macos; -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(not(any(target_os = "freebsd", target_os = "linux", target_os = "macos")))] mod unsupported; +#[cfg(target_os = "freebsd")] +use freebsd as imp; + #[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")))] +#[cfg(not(any(target_os = "freebsd", target_os = "linux", target_os = "macos")))] use unsupported as imp; pub(crate) use imp::{ diff --git a/src/platform/freebsd.rs b/src/platform/freebsd.rs new file mode 100644 index 0000000..fee7e81 --- /dev/null +++ b/src/platform/freebsd.rs @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair) +// +// SPDX-License-Identifier: MPL-2.0 + +use std::ffi::CString; +use std::io; +use std::mem::{MaybeUninit, size_of}; +use std::ptr; + +use log::warn; + +use super::noop::NoopTrafficControl; +use crate::platform::{InterfaceStats, InterfaceStatsProvider}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum FreeBsdInterfaceStatsError { + #[error("interface name contains a NUL byte")] + InterfaceNameContainsNul(#[source] std::ffi::NulError), + + #[error("interface `{interface}` was not found")] + InterfaceNotFound { + interface: String, + #[source] + source: io::Error, + }, + + #[error("failed to query statistics for interface `{interface}`")] + Sysctl { + interface: String, + #[source] + source: io::Error, + }, + + #[error( + "unexpected interface-statistics response size for `{interface}`: \ + expected {expected} bytes, received {actual}" + )] + UnexpectedResponseSize { + interface: String, + expected: usize, + actual: usize, + }, +} + +#[derive(Debug, Default)] +pub(crate) struct FreeBsdInterfaceStats; + +impl InterfaceStatsProvider for FreeBsdInterfaceStats { + type Error = FreeBsdInterfaceStatsError; + + fn read_stats(&mut self, interface: &str) -> Result { + let interface_name = CString::new(interface) + .map_err(FreeBsdInterfaceStatsError::InterfaceNameContainsNul)?; + let interface_index = unsafe { libc::if_nametoindex(interface_name.as_ptr()) }; + if interface_index == 0 { + return Err(FreeBsdInterfaceStatsError::InterfaceNotFound { + interface: interface.to_string(), + source: io::Error::last_os_error(), + }); + } + + let mib = [ + libc::CTL_NET, + libc::PF_LINK, + libc::NETLINK_GENERIC, + libc::IFMIB_IFDATA, + interface_index as libc::c_int, + libc::IFDATA_GENERAL, + ]; + + let mut ifmd = MaybeUninit::::uninit(); + let mut returned_len = size_of::(); + + let result = unsafe { + libc::sysctl( + mib.as_ptr(), + mib.len() as libc::c_uint, + ifmd.as_mut_ptr().cast::(), + &mut returned_len, + ptr::null(), + 0, + ) + }; + + if result == -1 { + return Err(FreeBsdInterfaceStatsError::Sysctl { + interface: interface.to_string(), + source: io::Error::last_os_error(), + }); + } + + let expected_len = size_of::(); + if returned_len != expected_len { + return Err(FreeBsdInterfaceStatsError::UnexpectedResponseSize { + interface: interface.to_string(), + expected: expected_len, + actual: returned_len, + }); + } + + let ifmd = unsafe { ifmd.assume_init() }; + + Ok(InterfaceStats { + rx_bytes: ifmd.ifmd_data.ifi_ibytes, + tx_bytes: ifmd.ifmd_data.ifi_obytes, + }) + } +} + +pub(crate) type PlatformInterfaceStats = FreeBsdInterfaceStats; +pub(crate) type PlatformTrafficControl = NoopTrafficControl; + +pub(crate) fn interface_stats_provider() -> PlatformInterfaceStats { + FreeBsdInterfaceStats +} + +pub(crate) fn traffic_control_backend() -> PlatformTrafficControl { + NoopTrafficControl +} + +pub(crate) fn warn_platform_limitations() { + warn!( + "Traffic control is unavailable on FreeBSD; requested rate changes will be calculated but not applied." + ); +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index a5050fa..66a9695 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -3,6 +3,8 @@ // SPDX-License-Identifier: MPL-2.0 mod netlink; +#[cfg(all(target_os = "linux", feature = "uci"))] +mod uci; pub(crate) type PlatformInterfaceStats = netlink::Netlink; pub(crate) type PlatformTrafficControl = netlink::Netlink; diff --git a/src/platform/linux/uci.rs b/src/platform/linux/uci.rs new file mode 100644 index 0000000..9639f91 --- /dev/null +++ b/src/platform/linux/uci.rs @@ -0,0 +1,72 @@ +use config::{ConfigError, Map, Source, Value, ValueKind}; +use rust_uci::Uci; + +#[derive(Clone, Copy, Debug)] +struct ConfigKey { + canonical: &'static str, + uci: &'static str, +} + +const UCI_KEYS: &[ConfigKey] = &[ + ConfigKey { + canonical: "network.download_base_kbits", + uci: "sqm-autorate-rust.@network[0].download_base_kbits", + }, + ConfigKey { + canonical: "network.download_interface", + uci: "sqm-autorate-rust.@network[0].download_interface", + }, + ConfigKey { + canonical: "observability.enabled", + uci: "sqm-autorate-rust.@observability[0].enabled", + }, + // ... +]; + +#[derive(Clone, Debug)] +struct UciSource { + keys: &'static [ConfigKey], +} + +impl UciSource { + fn new() -> Self { + Self { keys: UCI_KEYS } + } +} + +impl Source for UciSource { + fn clone_into_box(&self) -> Box { + Box::new(self.clone()) + } + + fn collect(&self) -> Result, ConfigError> { + let mut uci = + Uci::new().map_err(|e| ConfigError::Message(format!("failed to open UCI: {e}")))?; + + let origin = "UCI package sqm-autorate-rust".to_owned(); + let mut values = Map::new(); + + for key in self.keys { + match uci.get(key.uci) { + Ok(raw) => { + values.insert( + key.canonical.to_owned(), + Value::new(Some(&origin), ValueKind::String(raw)), + ); + } + + // This needs a real check for UCI's "not found" result. + Err(e) if is_missing_uci_value(&e) => {} + + Err(e) => { + return Err(ConfigError::Message(format!( + "failed reading UCI key {}: {e}", + key.uci + ))); + } + } + } + + Ok(values) + } +}