86 Commits
Author SHA1 Message Date
renovate Bot f0eae4b2ce chore(deps): update netlink dependencies to v0.3.4
Build / build (pull_request) Successful in 1m4s
DevSkim / DevSkim (pull_request) Failing after 1m32s
rust-clippy analyze / Run rust-clippy analyzing (pull_request) Failing after 2m5s
2026-08-29 14:10:14 +00:00
Lochnair a6067910ad chore: make renovate combine netlink deps
Build / build (push) Successful in 1m4s
DevSkim / DevSkim (push) Failing after 1m18s
rust-clippy analyze / Run rust-clippy analyzing (push) Failing after 1m57s
2026-08-28 09:15:20 +00:00
Lochnair ffd7b0beb7 fix: remove unused import
DevSkim / DevSkim (push) Failing after 4s
Build / build (push) Successful in 57s
rust-clippy analyze / Run rust-clippy analyzing (push) Failing after 2m10s
2026-08-23 17:23:10 +00:00
Lochnair 377fb3be2d chore(ci): install latest rust
DevSkim / DevSkim (push) Failing after 4s
rust-clippy analyze / Run rust-clippy analyzing (push) Failing after 1m42s
Build / build (push) Failing after 50s
2026-08-23 17:12:17 +00:00
Lochnair d765bef002 chore(ci): add workflow_dispatch to build workflow
Build / build (push) Failing after 2s
DevSkim / DevSkim (push) Failing after 4s
rust-clippy analyze / Run rust-clippy analyzing (push) Failing after 5s
2026-08-23 16:50:26 +00:00
renovate[bot] 86b13f948e chore(deps): update rust crate thiserror to v2.0.20 2026-08-14 16:44:07 +00:00
Nils Andreas Svee 4abf4bfcb8 refactor(ratecontroller): own random generator 2026-08-13 22:16:43 +07:00
Nils Andreas Svee 1a49c9adce refactor(platform): move platform.rs to platform/mod.rs
Restructure the platform module into a directory to allow for future sub-module expansion.
2026-08-13 21:10:53 +07:00
Nils Andreas Svee 8c831e4287 refactor(ratecontroller): reorder methods to improve code organization 2026-08-13 14:20:15 +07:00
Nils Andreas Svee 6a90446b56 fix(ratecontroller): use egress interface for traffic stats
Read both RX and TX byte counters from the configured upload/egress
interface. Remove the obsolete per-direction stats selection and related
plumbing now that download load uses RX and upload load uses TX from the
same interface.
2026-08-13 14:19:53 +07:00
renovate[bot] 1ad47eaeb0 chore(deps): update rust crate syn to v3.0.3 2026-08-07 09:19:39 +00:00
renovate[bot] a27b95e9ca fix(deps): update rust crate syn to v3 2026-08-07 09:11:56 +00:00
Nils Andreas Svee be6546b443 Merge pull request #68 from Lochnair/feature/config
feat(config): migrate configuration loading to config-rs
2026-08-07 16:10:13 +07:00
Nils Andreas Svee 605ec6ef81 refactor(main): simplify interface stats direction logic
Remove the ifb/veth interface prefix detection from
interface_stats_directions. The function now unconditionally returns
RX for download and TX for upload, as the previous heuristic was
unreliable and the simplified behavior matches actual usage.
2026-08-07 16:05:00 +07:00
Nils Andreas Svee 7e0f434251 refactor: replace legacy Config with Settings
Remove legacy src/config.rs and migrate all application consumers to
the nested Settings model. Baseliner, metrics, pingers, ratecontroller,
reflector selector, and main now use Settings. Derived min rates become
methods on NetworkSettings rather than stored legacy fields.
MeasurementType and ObservabilityProtocol move into the settings module.
2026-08-07 16:04:59 +07:00
Nils Andreas Svee 3c688089a1 feat(settings): add validation and flexible boolean parsing
Add settings validation with Warning and Error severity levels.
Warnings print to stderr without preventing startup; errors are
aggregated and prevent startup. Percentage validation accepts 1..=100
rather than silently clamping. Add UCI-compatible flexible boolean
parsing through Serde, accepting 1/true/yes/y/on/enabled for true and
0/false/no/n/off/disabled for false, while native booleans continue to
deserialize correctly.
2026-08-07 16:04:59 +07:00
Nils Andreas Svee a6478c9bd4 feat(uci): derive UCI schema from settings
Add uci-derive proc-macro crate with UciConfig and UciSection derive
macros that generate UCI schema metadata from Settings structs. The
schema maps UCI section types and option names to config-rs field
paths. UciSource::from_schema() creates a source from the derived
schema, mapping known UCI options to their Settings fields while
preserving unknown options under raw UCI-derived paths.
2026-08-07 16:04:59 +07:00
Nils Andreas Svee f51d90bff6 feat(settings): parse reflector lists with CSV
Add CSV-based reflector list parsing with Serde reflector records.
The load_reflectors method reads a CSV file with reflector_ip,
ip_version, and description columns, validating IP version
consistency for each entry.
2026-08-07 16:04:59 +07:00
Nils Andreas Svee a653bcf279 ci: support static cross-compilation with vendored UCI
Add Dockerfile.ci with pkg-config for vendored UCI builds. Switch
Jenkinsfile from pre-built Docker images to Dockerfile-based agents
for full control over the build environment. Add static CRT linking
via RUSTFLAGS for standalone static binaries across the CI matrix.
2026-08-07 16:04:59 +07:00
Nils Andreas Svee af07915441 build(uci): enable vendored UCI builds and Unix development support
Switch rust-uci dependency to the Lochnair fork with vendored UCI
feature for self-contained builds without system libuci. Add
uci-vendored feature flag (enabled by default) that pulls in the
vendored C library. UCI support on non-Linux Unix is intended for
development and testing.
2026-08-07 16:04:59 +07:00
Nils Andreas Svee 39add2b2e0 feat(uci): add generalized UCI configuration source
Introduce the UciSource abstraction as a config-rs Source that reads
UCI packages and maps them into the configuration tree. Supports
package selection, required/optional behavior, and custom config/save
directories. Move rust-uci dependency to cfg(unix) to enable UCI
support on all Unix systems for development and testing, while
Linux/OpenWrt remains the production-supported platform.
2026-08-07 16:04:59 +07:00
Nils Andreas Svee c0a50c68f9 feat(config): add config-rs settings support
Introduce config-rs and Serde support with a nested Settings model.
Add config and serde dependencies with toml feature for file-based
configuration loading. The Settings struct provides typed configuration
with sensible defaults for network, output, observability, and advanced
settings sections. Environment variable override via SQMA__ prefix is
supported. The legacy Config remains in use; migration follows later.
2026-08-07 16:04:58 +07:00
renovate[bot] 8bacb102b7 chore(deps): update google/osv-scanner-action action to v2.5.0 2026-08-07 08:37:03 +00:00
renovate[bot] bc86a2cd53 fix(deps): update rust crate icmp-socket2 to 0.6.0 2026-07-29 10:04:32 +00:00
renovate[bot] c6338b596e chore(deps): update rust crate libc to v0.2.189 2026-07-24 00:05:41 +07:00
Nils Andreas Svee 7f557f26d4 style(reflector): remove redundant comment in reflector selector
Eliminate an outdated comment to improve code readability and maintain a clean codebase.
2026-07-24 00:05:41 +07:00
Nils Andreas Svee c1bcb22b23 refactor(baseliner): Replace shared Mutex state with thread-safe control snapshots
Eliminate lock contention between the baseliner, ratecontroller, and selector by moving to a message-passing architecture using immutable snapshots of reflector state. This improves data consistency and simplifies cross-thread communication.
2026-07-24 00:05:41 +07:00
Nils Andreas Svee 51377f947f refactor(baseliner): extract reflector statistics logic and add unit tests 2026-07-24 00:05:41 +07:00
Nils Andreas Svee dc4ce1159f chore(deps): update rust crate dependencies 2026-07-24 00:05:41 +07:00
Nils Andreas Svee 288d1afdb9 chore(deps): switch netlink dependencies from git to crates.io 2026-07-24 00:05:41 +07:00
renovate[bot] bf0027a0b3 chore(deps): update rust crate thiserror to v2.0.19 2026-07-24 00:05:40 +07:00
renovate[bot] 49b700d4b2 chore(deps): update rust crate libc to v0.2.187 2026-07-24 00:05:40 +07:00
renovate[bot] f6fa7b8814 fix(deps): update netlink-socket2 digest to 928e9ca 2026-07-24 00:05:40 +07:00
renovate[bot] cd160e67fe fix(deps): update netlink-bindings digest to 928e9ca 2026-07-24 00:05:40 +07:00
renovate[bot] bab8278bdd chore(deps): update rust crate fastrand to v2.5.0 2026-07-24 00:05:40 +07:00
renovate[bot] c9cecb6945 chore(deps): update rust crate anyhow to v1.0.104 2026-07-24 00:05:40 +07:00
Nils Andreas Svee b45ae4b6d6 build: update development and cross-compilation tools 2026-07-24 00:05:40 +07:00
Nils Andreas Svee b762901470 feat(platform): add support for FreeBSD interface statistics 2026-07-24 00:05:19 +07:00
Nils Andreas Svee cd6d631afa refactor(config): remove unimplemented measurement types
Remove NTP and TCP timestamp variants from the measurement type configuration since they are currently unimplemented and lead to runtime panics.
2026-07-20 23:06:23 +07:00
Nils Andreas Svee b07dc99bf5 fix(deps): update rust crate icmp-socket2 to 0.5.1 2026-07-17 13:07:30 +07:00
Nils Andreas Svee ae9193580c refactor(platform): restructure platform-specific abstractions and add synthetic backends 2026-07-17 13:02:17 +07:00
Nils Andreas Svee 50f0dcb4dd perf(baseliner): bound the reselection channel to prevent memory buildup
Change the reselection channel from unbounded to a size of 1 and use non-blocking sends to avoid unnecessary queuing of reselection triggers.
2026-07-17 11:39:07 +07:00
Nils Andreas Svee 9811592f13 feat(platform): introduce abstractions for cross-platform interface statistics and traffic control
Decouple the rate controller from Linux-specific Netlink calls to support other operating systems. This adds a macOS backend for interface statistics and an "observe-only" mode for platforms where traffic shaping is not yet implemented.
2026-07-17 11:35:43 +07:00
Nils Andreas Svee c5d673fcb8 build(uci): restrict UCI dependency and feature to Linux targets
Ensure the UCI integration is only compiled on Linux, as the underlying native dependency is platform-specific. Added a build script to warn users when the feature is enabled on unsupported platforms.
2026-07-17 01:43:10 +07:00
Nils Andreas Svee b56c8b72cc refactor(main): modularize main loop and improve resource cleanup logic 2026-07-14 22:53:01 +07:00
Nils Andreas Svee 8b0c37bc83 refactor: simplify control flow and error handling in metrics and netlink 2026-07-05 12:44:44 +07:00
Nils Andreas Svee 694cad1fac chore(deps): update wasm and web-related dependencies 2026-07-03 00:50:00 +07:00
Nils Andreas Svee 908fbfdf04 refactor(deps): cleanup netlink-bindings and shlex dependencies 2026-07-03 00:50:00 +07:00
Nils Andreas Svee 2dacde8a3f chore(deps): update cargo dependencies 2026-07-03 00:50:00 +07:00
renovate[bot] 371b3efcdc chore(deps): update rust crate anyhow to v1.0.103 2026-07-03 00:47:45 +07:00
renovate[bot] 81d6ebe3a8 chore(deps): update rust crate log to v0.4.33 2026-07-03 00:47:45 +07:00
renovate[bot] 8ccbd30582 chore(deps): update actions/checkout action to v7 2026-07-03 00:47:45 +07:00
renovate[bot] ae3cd96313 fix(deps): update netlink-socket2 digest to 0b609e9 2026-07-03 00:47:45 +07:00
renovate[bot] 3d1c14d312 fix(deps): update netlink-bindings digest to 0b609e9 2026-07-03 00:46:39 +07:00
renovate[bot] 7955046426 fix(deps): update rust-uci digest to 3e7b615 2026-07-03 00:46:39 +07:00
renovate[bot] 30e3281178 chore(deps): update google/osv-scanner-action action to v2.3.8 2026-07-03 00:46:39 +07:00
renovate[bot] f3f033cf0f chore(deps): update rust crate libc to v0.2.186 2026-07-03 00:46:39 +07:00
Nils Andreas Svee 4aaf8fa100 chore: add SPDX license identifiers and copyright headers to source files
Apply the MPL-2.0 license to all source files and include the full license text to ensure legal compliance and proper attribution.
2026-04-23 12:39:52 +07:00
Nils Andreas Svee dfa11be236 chore: add MPLv2.0 license
The original Lua sqm-autorate project is licensed under the MPLv2.0, and this project started life as a pure port of that. Thus our code should also be licensed under the MPL
2026-04-23 12:19:49 +07:00
Nils Andreas Svee 58d536bb22 refactor(pinger): replace hashlru with standard HashMap and manual TTL cleanup
Remove the hashlru dependency to reduce bloat, replacing it with a Mutex-wrapped HashMap and manual entry expiration logic.
2026-04-23 00:33:07 +07:00
Nils Andreas Svee 6f16f3a93c build(deps): disable default features for hashlru to reduce dependency bloat
Remove unused dependencies including ahash, hashbrown, and zerocopy by disabling default features for hashlru.
2026-04-23 00:19:00 +07:00
Nils Andreas Svee 1b959d52ed feat(main): Initialize and pass InFlightProbeCache to pinger threads 2026-04-23 00:16:19 +07:00
Nils Andreas Svee 8c29d7b251 feat(pinger): Integrate in-flight probe cache into sender and listener 2026-04-23 00:16:19 +07:00
Nils Andreas Svee 80adb368d4 feat(pinger): Implement new craft_packet signature for ICMP types 2026-04-23 00:16:19 +07:00
Nils Andreas Svee 6ae19b619a feat(pinger): Introduce InFlightProbeCache and update Ping traits 2026-04-23 00:16:19 +07:00
Nils Andreas Svee 7250050f63 feat(deps): Add hashlru for in-flight probe caching 2026-04-23 00:16:19 +07:00
Nils Andreas Svee 3322e82b6c refactor: replace std::sync::mpsc with flume for improved channel performance
Migrate internal message passing to flume to leverage better performance and additional features like eventual fairness and selection.
2026-04-22 22:39:06 +07:00
Nils Andreas Svee ea19c6d68b style: remove unused synchronization imports
Eliminate unused Arc, Mutex, and RwLock imports to clean up codebase and reduce compiler warnings.
2026-04-22 22:26:44 +07:00
Nils Andreas Svee a81d208c0d style: refine logging verbosity and improve diagnostic messages
Demote baseline telemetry to debug level, add context to rate controller warnings, and remove redundant string conversions in reflector selection logs.
2026-04-22 22:09:26 +07:00
Nils Andreas Svee e22c100e57 refactor(main): Relocate and enhance signal handler with improved logging 2026-04-22 21:45:27 +07:00
Nils Andreas Svee 72fe0230ee style: Apply general code formatting and import reordering 2026-04-22 21:45:25 +07:00
Nils Andreas Svee 6690402a34 chore(deps): update dependencies and switch rust-uci to git revision
Update bindgen, nom, and other core dependencies while switching rust-uci and libuci-sys to a specific git revision to ensure compatibility with recent project changes.
2026-04-22 16:55:46 +07:00
Nils Andreas Svee 3ec53a1224 refactor: Migrate configuration to new UCI path prefix and boolean helper 2026-04-22 16:55:05 +07:00
Nils Andreas Svee 7d5c2f0275 refactor: Add boolean configuration helper and streamline host tag retrieval 2026-04-22 16:55:05 +07:00
Nils Andreas Svee 45da0c030e feat: Add flexible boolean parsing for configuration 2026-04-22 16:55:05 +07:00
Nils Andreas Svee 33169182be chore(deps): update rust-uci repository source
Switch the rust-uci dependency to the namib-project fork while maintaining the same revision to ensure availability and consistency with project requirements.
2026-04-22 15:11:16 +07:00
Nils Andreas Svee 2aea1bea86 chore(deps): switch rust-uci to git dependency
Update rust-uci to use a specific git revision from the upstream repository to incorporate latest changes not yet available in the published version.
2026-04-22 15:06:45 +07:00
Nils Andreas Svee d0e3394f03 refactor: add ArcMutex/ArcRwLock type aliases
Replace repeated Arc> and Arc> with concise type aliases for readability.
2026-04-18 19:04:35 +02:00
Nils Andreas Svee 7c40990e93 chore(deps): switch netlink dependencies to upstream git and remove vendor patch
Replace the local vendored patch for netlink-socket2 with a direct reference to the upstream git repository to maintain the fix for musl targets.

This should be changed back to using a release once the fix has a published release
2026-04-18 15:41:02 +07:00
renovate[bot] a739d1a5e2 chore(deps): update google/osv-scanner-action action to v2.3.5 2026-04-16 23:37:02 +07:00
Nils Andreas Svee 11837195de chore(deps): update rust dependencies
Update several crates including cc, cmake, fastrand, and quote to their latest versions to maintain up-to-date build tools and libraries.
2026-04-14 16:04:02 +07:00
renovate[bot] 706d93f146 chore(deps): update rust crate libc to v0.2.185 2026-04-14 16:00:57 +07:00
Nils Andreas Svee 7b0eb942ea fix(deps): patch netlink-socket2 to support musl targets
Vendor netlink-socket2 to resolve compilation failures caused by direct struct-literal initialization of libc types on musl.
2026-04-14 15:00:49 +07:00
Nils Andreas Svee 5daccecc1d fix(deps): update rust crate netlink-bindings to 0.3
Adjust netlink and traffic control logic to accommodate breaking API changes, including shifts from method calls to direct field access.
2026-04-14 13:09:50 +07:00
renovate[bot] 2c336799b8 fix(deps): update rust crate netlink-socket2 to 0.3 2026-04-14 12:07:32 +07:00
Nils Andreas Svee 8739ed4419 fix(metrics): use AtomicU32 for drop counter to support MIPS targets
Also bumps version to 0.4.1.
2026-04-14 02:14:55 +07:00
41 changed files with 4786 additions and 1326 deletions

No files matched your search

+5 -1
View File
@@ -5,6 +5,7 @@ on:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
@@ -15,8 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install Mold
run: sudo apt install mold
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
- name: Build
run: cargo build --verbose
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run DevSkim scanner
uses: microsoft/DevSkim-Action@v1
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
actions: read
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
# If you want to copy this config, highly suggest pinning this version to a release rather than tracking the main branch
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.3.3"
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.5.0"
with:
# Just scan the root directory and docs, since everything else is fixtures
scan-args: |-
@@ -47,7 +47,7 @@ jobs:
actions: read
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
# If you want to copy this config, highly suggest pinning this version to a release rather than tracking the main branch
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3"
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.5.0"
with:
# Just scan the root directory and docs, since everything else is fixtures
scan-args: |-
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1
Generated
+429 -191
View File
@@ -11,82 +11,59 @@ dependencies = [
"memchr",
]
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bindgen"
version = "0.58.1"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f8523b410d7187a43085e7e064416ea32ded16bd0a4e6fc025e21616d01258f"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 1.3.2",
"bitflags",
"cexpr",
"clang-sys",
"clap",
"env_logger",
"lazy_static",
"lazycell",
"itertools",
"log",
"peeking_take_while",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"which",
"shlex 1.3.0",
"syn 2.0.119",
]
[[package]]
name = "bitflags"
version = "1.3.2"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bitflags"
version = "2.11.0"
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.56"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [
"find-msvc-tools",
"shlex",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
@@ -108,43 +85,60 @@ dependencies = [
"libloading",
]
[[package]]
name = "clap"
version = "2.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"ansi_term",
"atty",
"bitflags 1.3.2",
"strsim",
"textwrap",
"unicode-width",
"vec_map",
]
[[package]]
name = "cmake"
version = "0.1.57"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "env_logger"
version = "0.8.4"
name = "config"
version = "0.15.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3"
checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139"
dependencies = [
"atty",
"humantime",
"log",
"regex",
"termcolor",
"pathdiff",
"serde_core",
"toml",
"winnow",
]
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@@ -157,9 +151,12 @@ dependencies = [
[[package]]
name = "fastrand"
version = "2.3.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
dependencies = [
"getrandom",
]
[[package]]
name = "find-msvc-tools"
@@ -167,6 +164,29 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flume"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
dependencies = [
"fastrand",
"spin",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasm-bindgen",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -174,46 +194,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hermit-abi"
version = "0.1.19"
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "icmp-socket2"
version = "0.4.2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e57405ef902d5506111426bb8a2498f10979c19273bcdadc852ae03d45d19d8"
checksum = "86fc005b72eb9dbc1df87318cf906a415d4f4c0d5eea42849b29dd710cdf670d"
dependencies = [
"socket2",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "lazycell"
version = "1.3.0"
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libloading"
@@ -227,11 +261,11 @@ dependencies = [
[[package]]
name = "libuci-sys"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "544c17966d9131e80b3616783974a31b526197ca5b56948bb130eeb6c04fbaf1"
version = "1.1.0"
source = "git+https://github.com/Lochnair/rust-uci.git?rev=fcfedd204fb76928ef617bf9631b041c816e03d4#fcfedd204fb76928ef617bf9631b041c816e03d4"
dependencies = [
"bindgen",
"cc",
"cmake",
]
@@ -242,39 +276,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.29"
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "maybe-async"
version = "0.2.10"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11"
checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "netlink-bindings"
version = "0.2.4"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e639b3a966932d8ab55cfc1e606e5421aef3d30a2610284535b854f11f6e4b44"
checksum = "2a2a0dd8beef757878b977fd3d662c82a93dac4bf7632f42577ca8f8f4d701a0"
[[package]]
name = "netlink-socket2"
version = "0.2.4"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffef37f26cf649b9f473ccc00fa52d419511225a7b3a2093bf16dc8673ab9cf1"
checksum = "5c7ddb0ea7aa692947d5105160acb7c75c17bc5ec6d5a6263440ca7db4be9a34"
dependencies = [
"libc",
"maybe-async",
@@ -283,43 +332,65 @@ dependencies = [
[[package]]
name = "nom"
version = "5.1.3"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"version_check",
"minimal-lexical",
]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
@@ -329,9 +400,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.14"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
@@ -340,26 +411,26 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.9"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rust-uci"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c664edb3ec081c1f2746960b04861354bbeaafdbb962fb8789d6ded5153ac3bb"
version = "0.1.6"
source = "git+https://github.com/Lochnair/rust-uci.git?rev=fcfedd204fb76928ef617bf9631b041c816e03d4#fcfedd204fb76928ef617bf9631b041c816e03d4"
dependencies = [
"libc",
"libuci-sys",
"log",
"thiserror",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
@@ -367,13 +438,83 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -381,21 +522,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "socket2"
version = "0.6.3"
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "sqm-autorate-rust"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"anyhow",
"config",
"csv",
"fastrand",
"flume",
"icmp-socket2",
"libc",
"log",
@@ -403,26 +562,39 @@ dependencies = [
"netlink-socket2",
"rust-uci",
"rustix",
"serde",
"thiserror",
"uci-derive",
]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "syn"
version = "2.0.117"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-triple"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171"
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -432,33 +604,88 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]]
name = "thiserror"
version = "2.0.18"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.3",
]
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "trybuild"
version = "1.0.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4"
dependencies = [
"glob",
"serde",
"serde_derive",
"serde_json",
"target-triple",
"termcolor",
"toml",
]
[[package]]
name = "uci-derive"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"trybuild",
]
[[package]]
@@ -468,47 +695,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "which"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"libc",
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "winapi"
version = "0.3.9"
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi-util"
@@ -519,12 +748,6 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -539,3 +762,18 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "sqm-autorate-rust"
version = "0.4.0"
version = "0.4.1"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -20,18 +20,34 @@ strip = "symbols"
[dependencies]
anyhow = "1.0"
icmp-socket2 = "0.4.2"
netlink-bindings = { version = "0.2", features = ["rt-link", "tc"] }
netlink-socket2 = "0.2"
icmp-socket2 = "0.6.0"
fastrand = "2"
rustix = { version = "1.1", features = ["fs", "system", "thread", "time"] }
rust-uci = { version = "0.1", optional = true }
thiserror = "2.0"
libc = "0.2"
flume = { version = "0.12.0", default-features = false, features = ["eventual-fairness", "select"] }
config = { version = "0.15.25", default-features = false }
csv = "1.4.0"
serde = { version = "1", features = ["derive"] }
uci-derive = { path = "crates/uci-derive" }
[dependencies.log]
features = ["std"]
version = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
netlink-bindings = { version = "0.3.3", features = ["rt-link", "tc"] }
netlink-socket2 = { version = "0.3.3" }
[target.'cfg(unix)'.dependencies]
rust-uci = { git = "https://github.com/Lochnair/rust-uci.git", rev = "fcfedd204fb76928ef617bf9631b041c816e03d4", optional = true, default-features = false, }
[features]
default = ["toml", "uci-vendored"]
toml = ["config/toml"]
uci = ["dep:rust-uci"]
uci-vendored = ["uci", "rust-uci/vendored"]
[workspace]
members = ["crates/uci-derive"]
resolver = "3"
+3
View File
@@ -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"]
+7
View File
@@ -0,0 +1,7 @@
ARG TARGET
FROM ghcr.io/rust-cross/rust-musl-cross:${TARGET}
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config \
&& rm -rf /var/lib/apt/lists/*
Vendored
+6 -3
View File
@@ -12,9 +12,9 @@ pipeline {
}
agent {
docker {
image "ghcr.io/rust-cross/rust-musl-cross:${TARGET}"
alwaysPull true
dockerfile {
filename 'Dockerfile.ci'
additionalBuildArgs "--pull --build-arg TARGET=${TARGET}"
}
}
@@ -44,6 +44,9 @@ pipeline {
echo "----------------------------------"
# 5. Check Toolchain & Build
# Force standalone static artifacts across the entire CI matrix.
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-C target-feature=+crt-static"
# We grep for "nightly" in the version string (e.g., "rustc 1.95.0-nightly...")
if rustc -V | grep -q "nightly"; then
echo "Detected Nightly toolchain. Enabling '-Z build-std' to support panic=abort..."
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+26
View File
@@ -0,0 +1,26 @@
use std::env;
fn main() {
let uci_enabled = env::var_os("CARGO_FEATURE_UCI").is_some();
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".into());
if uci_enabled && target_os != "linux" {
println!(
"cargo:warning=UCI support on {target} is intended for development \
and testing; Linux/OpenWrt remains the production-supported platform"
);
}
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");
}
Executable
+19
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "uci-derive"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "3", features = ["full"] }
[dev-dependencies]
trybuild = "1"
+252
View File
@@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use proc_macro::TokenStream;
use quote::quote;
use syn::ext::IdentExt;
use syn::{Data, DeriveInput, Field, Fields, FieldsNamed, LitStr, Token, parse_macro_input};
/// Generates `UciSectionSchema` from a settings struct.
///
/// Every named field is mapped one-to-one:
///
/// ```text
/// Rust field name -> UCI option name
/// Rust field name -> config-rs field name
/// ```
#[proc_macro_derive(UciSection)]
pub fn derive_uci_section(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match expand_uci_section(&input) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
/// Generates `UciConfigSchema` from the root settings struct.
///
/// Required container attribute:
///
/// ```ignore
/// #[uci(package = "sqm-autorate-rust")]
/// ```
///
/// Every field must specify its UCI section:
///
/// ```ignore
/// #[uci(section)]
/// network: NetworkSettings,
///
/// #[uci(section = "advanced_settings")]
/// advanced: AdvancedSettings,
/// ```
#[proc_macro_derive(UciConfig, attributes(uci))]
pub fn derive_uci_config(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match expand_uci_config(&input) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
fn expand_uci_section(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let struct_name = &input.ident;
let fields = require_named_fields(input)?;
let mappings = fields
.named
.iter()
.map(|field| {
let field_ident = field
.ident
.as_ref()
.expect("named fields always have identifiers");
let field_name = field_ident.unraw().to_string();
let field_name = LitStr::new(&field_name, field_ident.span());
quote! {
crate::settings::uci_schema::UciOptionMapping {
uci_option: #field_name,
config_field: #field_name,
}
}
})
.collect::<Vec<_>>();
let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
Ok(quote! {
#[automatically_derived]
impl #impl_generics
crate::settings::uci_schema::UciSectionSchema
for #struct_name #type_generics
#where_clause
{
const UCI_OPTIONS: &'static [
crate::settings::uci_schema::UciOptionMapping
] = &[
#(#mappings),*
];
}
})
}
fn expand_uci_config(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let struct_name = &input.ident;
let fields = require_named_fields(input)?;
let package = parse_package_name(input)?;
let mut sections = Vec::with_capacity(fields.named.len());
for field in &fields.named {
let field_ident = field
.ident
.as_ref()
.expect("named fields always have identifiers");
let field_type = &field.ty;
let config_section_name = field_ident.unraw().to_string();
let config_section_name = LitStr::new(&config_section_name, field_ident.span());
let uci_section_name = parse_section_name(field)?;
sections.push(quote! {
crate::settings::uci_schema::UciSectionMapping {
uci_section: #uci_section_name,
config_section: #config_section_name,
options: <
#field_type as
crate::settings::uci_schema::UciSectionSchema
>::UCI_OPTIONS,
}
});
}
let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
Ok(quote! {
#[automatically_derived]
impl #impl_generics
crate::settings::uci_schema::UciConfigSchema
for #struct_name #type_generics
#where_clause
{
const UCI_PACKAGE: &'static str = #package;
const UCI_SECTIONS: &'static [
crate::settings::uci_schema::UciSectionMapping
] = &[
#(#sections),*
];
}
})
}
fn require_named_fields(input: &DeriveInput) -> syn::Result<&FieldsNamed> {
match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(fields) => Ok(fields),
Fields::Unnamed(_) | Fields::Unit => Err(syn::Error::new_spanned(
input,
"this derive only supports structs with named fields",
)),
},
Data::Enum(_) | Data::Union(_) => Err(syn::Error::new_spanned(
input,
"this derive only supports structs",
)),
}
}
fn parse_package_name(input: &DeriveInput) -> syn::Result<LitStr> {
let mut package = None;
for attribute in &input.attrs {
if !attribute.path().is_ident("uci") {
continue;
}
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("package") {
if package.is_some() {
return Err(meta.error("duplicate `package` attribute"));
}
let value: LitStr = meta.value()?.parse()?;
if value.value().is_empty() {
return Err(syn::Error::new(value.span(), "`package` cannot be empty"));
}
package = Some(value);
return Ok(());
}
Err(meta.error("expected `package = \"...\"`"))
})?;
}
package.ok_or_else(|| {
syn::Error::new_spanned(
&input.ident,
"UciConfig requires `#[uci(package = \"...\")]`",
)
})
}
fn parse_section_name(field: &Field) -> syn::Result<LitStr> {
let field_ident = field
.ident
.as_ref()
.expect("named fields always have identifiers");
let default_name = field_ident.unraw().to_string();
let default_name = LitStr::new(&default_name, field_ident.span());
let mut section = None;
for attribute in &field.attrs {
if !attribute.path().is_ident("uci") {
continue;
}
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("section") {
if section.is_some() {
return Err(meta.error("duplicate `section` attribute"));
}
if meta.input.peek(Token![=]) {
let value: LitStr = meta.value()?.parse()?;
if value.value().is_empty() {
return Err(syn::Error::new(value.span(), "`section` cannot be empty"));
}
section = Some(value);
} else {
section = Some(default_name.clone());
}
return Ok(());
}
Err(meta.error("expected `section` or `section = \"...\"`"))
})?;
}
section.ok_or_else(|| {
syn::Error::new_spanned(
field,
"UciConfig fields require `#[uci(section)]` \
or `#[uci(section = \"...\")]`",
)
})
}
+13
View File
@@ -2,5 +2,18 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"description": "Keep netlink-bindings and netlink-socket2 updates together",
"matchManagers": [
"cargo"
],
"matchPackageNames": [
"netlink-bindings",
"netlink-socket2"
],
"groupName": "netlink dependencies"
}
]
}
+394 -107
View File
@@ -1,30 +1,81 @@
// SPDX-FileCopyrightText: 2022-Present Charles Corrigan mailto:chas-iot@runegate.org (github @chas-iot)
// SPDX-FileCopyrightText: 2022-Present Daniel Lakeland mailto:dlakelan@street-artists.org (github @dlakelan)
// SPDX-FileCopyrightText: 2022-Present Mark Baker mailto:mark@vpost.net (github @Fail-Safe)
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use crate::SHUTDOWN;
use crate::Config;
use crate::metrics::{Metric, MetricsSender};
use crate::pinger::PingReply;
use crate::util::MutexExt;
use log::info;
use crate::settings::Settings;
use flume::{Receiver, Sender};
use log::{debug, info};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::atomic::Ordering;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::Instant;
#[derive(Copy, Clone)]
pub struct ReflectorStats {
pub down_ewma: f64,
pub up_ewma: f64,
pub last_receive_time_s: Instant,
const STALE_AFTER: Duration = Duration::from_secs(30);
const ANOMALY_THRESHOLD_MS: f64 = 5_000.0;
const BASELINE_HALF_LIFE_SECS: f64 = 135.0;
const RECENT_HALF_LIFE_SECS: f64 = 0.4;
#[derive(Clone, Copy, Debug)]
pub struct EwmaStats {
pub down: f64,
pub up: f64,
}
impl EwmaStats {
fn from_reply(reply: &PingReply) -> Self {
Self {
down: reply.down_time,
up: reply.up_time,
}
}
fn update(&mut self, sample: Self, factor: f64) {
self.down = self.down * factor + sample.down * (1.0 - factor);
self.up = self.up * factor + sample.up * (1.0 - factor);
}
}
#[derive(Clone, Copy, Debug)]
pub struct ReflectorState {
pub baseline: EwmaStats,
pub recent: EwmaStats,
pub last_receive_at: Instant,
}
impl ReflectorState {
fn new(sample: EwmaStats, received_at: Instant) -> Self {
Self {
baseline: sample,
recent: sample,
last_receive_at: received_at,
}
}
fn reset(&mut self, sample: EwmaStats, received_at: Instant) {
*self = Self::new(sample, received_at);
}
}
#[derive(Clone, Debug)]
pub struct ControlSnapshot {
pub generated_at: Instant,
pub reflectors: HashMap<IpAddr, ReflectorState>,
}
pub struct Baseliner {
pub config: Config,
pub owd_baseline: Arc<Mutex<HashMap<IpAddr, ReflectorStats>>>,
pub owd_recent: Arc<Mutex<HashMap<IpAddr, ReflectorStats>>>,
pub settings: Settings,
pub reselect_trigger: Sender<bool>,
pub start_time: Instant,
pub stats_rx: Receiver<PingReply>,
pub control_tx: Sender<ControlSnapshot>,
pub selection_tx: Option<Sender<ControlSnapshot>>,
pub baseline_metrics: MetricsSender,
pub event_metrics: MetricsSender,
}
@@ -33,127 +84,363 @@ fn ewma_factor(tick: f64, dur: f64) -> f64 {
((0.5_f64).ln() / (dur / tick)).exp()
}
fn update_reflector_state(
state: &mut ReflectorState,
sample: EwmaStats,
received_at: Instant,
start_time: Instant,
slow_factor: f64,
fast_factor: f64,
) -> bool {
if received_at.saturating_duration_since(state.last_receive_at) > STALE_AFTER {
state.reset(sample, received_at);
}
if sample.up > state.baseline.up + ANOMALY_THRESHOLD_MS
|| sample.down > state.baseline.down + ANOMALY_THRESHOLD_MS
{
state.last_receive_at = start_time;
return true;
}
state.baseline.update(sample, slow_factor);
state.recent.update(sample, fast_factor);
state.baseline.down = state.baseline.down.min(state.recent.down);
state.baseline.up = state.baseline.up.min(state.recent.up);
state.last_receive_at = received_at;
false
}
fn process_reply(
reflectors: &mut HashMap<IpAddr, ReflectorState>,
reply: &PingReply,
start_time: Instant,
slow_factor: f64,
fast_factor: f64,
) -> (ReflectorState, bool) {
let sample = EwmaStats::from_reply(reply);
let state = reflectors
.entry(reply.reflector)
.or_insert_with(|| ReflectorState::new(sample, reply.last_receive_time_s));
let anomaly = update_reflector_state(
state,
sample,
reply.last_receive_time_s,
start_time,
slow_factor,
fast_factor,
);
(*state, anomaly)
}
fn control_snapshot(
reflectors: &HashMap<IpAddr, ReflectorState>,
generated_at: Instant,
) -> ControlSnapshot {
ControlSnapshot {
generated_at,
reflectors: reflectors.clone(),
}
}
impl Baseliner {
pub fn run(&self) -> anyhow::Result<()> {
/*
* 135 seconds to decay to 50% for the slow factor and
* 0.4 seconds to decay to 50% for the fast factor.
* The fast one can be adjusted to tune, try anything from 0.01 to 3.0 to get more or less sensitivity
* with more sensitivity we respond faster to bloat, but are at risk from triggering due to lag spikes that
* aren't bloat related, with less sensitivity (bigger numbers) we smooth through quick spikes
* but take longer to respond to real bufferbloat
*/
let slow_factor = ewma_factor(self.config.tick_interval, 135.0);
let fast_factor = ewma_factor(self.config.tick_interval, 0.4);
let mut reflectors = HashMap::<IpAddr, ReflectorState>::new();
let slow_factor = ewma_factor(
self.settings.advanced_settings.tick_interval,
BASELINE_HALF_LIFE_SECS,
);
let fast_factor = ewma_factor(
self.settings.advanced_settings.tick_interval,
RECENT_HALF_LIFE_SECS,
);
loop {
if SHUTDOWN.load(Ordering::Relaxed) {
info!("Baseliner shutting down");
return Ok(());
}
let time_data = self.stats_rx.recv()?;
let mut owd_baseline_map = self.owd_baseline.lock_anyhow()?;
let mut owd_recent_map = self.owd_recent.lock_anyhow()?;
let reply = self.stats_rx.recv()?;
let reflector = reply.reflector;
let (state, anomaly) = process_reply(
&mut reflectors,
&reply,
self.start_time,
slow_factor,
fast_factor,
);
let owd_baseline_new = ReflectorStats {
down_ewma: time_data.down_time,
up_ewma: time_data.up_time,
last_receive_time_s: time_data.last_receive_time_s,
};
let owd_recent_new = ReflectorStats {
down_ewma: time_data.down_time,
up_ewma: time_data.up_time,
last_receive_time_s: time_data.last_receive_time_s,
};
let owd_baseline = owd_baseline_map
.entry(time_data.reflector)
.or_insert(owd_baseline_new);
let owd_recent = owd_recent_map
.entry(time_data.reflector)
.or_insert(owd_recent_new);
if time_data
.last_receive_time_s
.duration_since(owd_baseline.last_receive_time_s)
.as_secs_f64()
> 30.0
|| time_data
.last_receive_time_s
.duration_since(owd_recent.last_receive_time_s)
.as_secs_f64()
> 30.0
{
owd_baseline.down_ewma = time_data.down_time;
owd_baseline.up_ewma = time_data.up_time;
owd_baseline.last_receive_time_s = time_data.last_receive_time_s;
owd_recent.down_ewma = time_data.down_time;
owd_recent.up_ewma = time_data.up_time;
owd_recent.last_receive_time_s = time_data.last_receive_time_s;
}
owd_baseline.last_receive_time_s = time_data.last_receive_time_s;
owd_recent.last_receive_time_s = time_data.last_receive_time_s;
// if this reflection is more than 5 seconds higher than baseline... mark it no good and trigger a reselection
if time_data.up_time > owd_baseline.up_ewma + 5000.0
|| time_data.down_time > owd_baseline.down_ewma + 5000.0
{
// mark the data as bad by setting the receive time to the time autorate was started
owd_baseline.last_receive_time_s = self.start_time;
owd_recent.last_receive_time_s = self.start_time;
if anomaly {
info!(
"Reflector {} has OWD > 5 seconds more than baseline, triggering reselection",
time_data.reflector
reflector
);
self.event_metrics.send(Metric::Event {
name: "reselection",
reason: "anomaly",
reflector: Some(time_data.reflector),
reflector: Some(reflector),
tags: &[],
});
// If reselection is disabled this would trigger an error
// so just ignore the result
let _ = self.reselect_trigger.send(true);
} else {
owd_baseline.down_ewma = owd_baseline.down_ewma * slow_factor
+ (1.0 - slow_factor) * time_data.down_time;
owd_baseline.up_ewma =
owd_baseline.up_ewma * slow_factor + (1.0 - slow_factor) * time_data.up_time;
owd_recent.down_ewma =
owd_recent.down_ewma * fast_factor + (1.0 - fast_factor) * time_data.down_time;
owd_recent.up_ewma =
owd_recent.up_ewma * fast_factor + (1.0 - fast_factor) * time_data.up_time;
if owd_baseline.down_ewma > owd_recent.down_ewma {
owd_baseline.down_ewma = owd_recent.down_ewma;
}
if owd_baseline.up_ewma > owd_recent.up_ewma {
owd_baseline.up_ewma = owd_recent.up_ewma;
}
let _ = self.reselect_trigger.try_send(true);
}
self.baseline_metrics.send(Metric::Baseline {
reflector: time_data.reflector,
baseline_up_ewma: owd_baseline.up_ewma,
baseline_down_ewma: owd_baseline.down_ewma,
recent_up_ewma: owd_recent.up_ewma,
recent_down_ewma: owd_recent.down_ewma,
reflector,
baseline_up_ewma: state.baseline.up,
baseline_down_ewma: state.baseline.down,
recent_up_ewma: state.recent.up,
recent_down_ewma: state.recent.down,
});
info!(
debug!(
"Reflector {} up baseline = {} down baseline = {}",
time_data.reflector, owd_baseline.up_ewma, owd_baseline.down_ewma
reflector, state.baseline.up, state.baseline.down
);
info!(
debug!(
"Reflector {} up recent = {} down recent = {}",
time_data.reflector, owd_recent.up_ewma, owd_recent.down_ewma
reflector, state.recent.up, state.recent.down
);
let snapshot = control_snapshot(&reflectors, Instant::now());
if let Some(selection_tx) = &self.selection_tx {
let _ = selection_tx.send(snapshot.clone());
}
self.control_tx.send(snapshot)?;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::MeasurementType;
use std::time::Duration;
fn state(
baseline_down: f64,
baseline_up: f64,
recent_down: f64,
recent_up: f64,
last_receive_at: Instant,
) -> ReflectorState {
ReflectorState {
baseline: EwmaStats {
down: baseline_down,
up: baseline_up,
},
recent: EwmaStats {
down: recent_down,
up: recent_up,
},
last_receive_at,
}
}
fn sample(down: f64, up: f64) -> EwmaStats {
EwmaStats { down, up }
}
fn reply(reflector: IpAddr, down_time: f64, up_time: f64, received_at: Instant) -> PingReply {
PingReply {
reflector,
measurement_type: MeasurementType::Icmp,
seq: 0,
rtt: down_time + up_time,
current_time: 0,
down_time,
up_time,
originate_timestamp: 0,
receive_timestamp: 0,
transmit_timestamp: 0,
last_receive_time_s: received_at,
}
}
fn assert_close(actual: f64, expected: f64) {
assert!(
(actual - expected).abs() < 1e-9,
"expected {expected}, got {actual}"
);
}
#[test]
fn updates_ewmas() {
let start = Instant::now();
let receive_time = start + Duration::from_secs(1);
let mut state = state(100.0, 100.0, 100.0, 100.0, start);
let anomaly = update_reflector_state(
&mut state,
sample(200.0, 300.0),
receive_time,
start,
0.9,
0.5,
);
assert!(!anomaly);
assert_close(state.baseline.down, 110.0);
assert_close(state.baseline.up, 120.0);
assert_close(state.recent.down, 150.0);
assert_close(state.recent.up, 200.0);
assert_eq!(state.last_receive_at, receive_time);
}
#[test]
fn keeps_baseline_at_or_below_recent() {
let start = Instant::now();
let mut state = state(200.0, 200.0, 100.0, 100.0, start);
let anomaly = update_reflector_state(
&mut state,
sample(100.0, 100.0),
start + Duration::from_secs(1),
start,
0.9,
0.5,
);
assert!(!anomaly);
assert_close(state.baseline.down, 100.0);
assert_close(state.baseline.up, 100.0);
assert_close(state.recent.down, 100.0);
assert_close(state.recent.up, 100.0);
}
#[test]
fn resets_stats_after_a_long_gap() {
let start = Instant::now();
let receive_time = start + Duration::from_secs(31);
let mut state = state(100.0, 200.0, 300.0, 400.0, start);
let anomaly = update_reflector_state(
&mut state,
sample(500.0, 600.0),
receive_time,
start,
0.9,
0.5,
);
assert!(!anomaly);
assert_close(state.baseline.down, 500.0);
assert_close(state.baseline.up, 600.0);
assert_close(state.recent.down, 500.0);
assert_close(state.recent.up, 600.0);
assert_eq!(state.last_receive_at, receive_time);
}
#[test]
fn does_not_reset_at_exactly_thirty_seconds() {
let start = Instant::now();
let receive_time = start + STALE_AFTER;
let mut state = state(100.0, 200.0, 100.0, 200.0, start);
let anomaly = update_reflector_state(
&mut state,
sample(200.0, 300.0),
receive_time,
start,
0.9,
0.5,
);
assert!(!anomaly);
assert_close(state.baseline.down, 110.0);
assert_close(state.baseline.up, 210.0);
assert_close(state.recent.down, 150.0);
assert_close(state.recent.up, 250.0);
}
#[test]
fn marks_anomalies_for_reselection() {
let start = Instant::now();
let mut state = state(100.0, 200.0, 300.0, 400.0, start);
let anomaly = update_reflector_state(
&mut state,
sample(5_101.0, 200.0),
start + Duration::from_secs(1),
start,
0.9,
0.5,
);
assert!(anomaly);
assert_close(state.baseline.down, 100.0);
assert_close(state.baseline.up, 200.0);
assert_close(state.recent.down, 300.0);
assert_close(state.recent.up, 400.0);
assert_eq!(state.last_receive_at, start);
}
#[test]
fn does_not_mark_exact_anomaly_threshold() {
let start = Instant::now();
let receive_time = start + Duration::from_secs(1);
let mut state = state(100.0, 200.0, 100.0, 200.0, start);
let anomaly = update_reflector_state(
&mut state,
sample(5_100.0, 5_200.0),
receive_time,
start,
0.9,
0.5,
);
assert!(!anomaly);
assert_eq!(state.last_receive_at, receive_time);
}
#[test]
fn processed_reply_updates_combined_reflector_state() {
let start = Instant::now();
let received_at = start + Duration::from_secs(1);
let reflector = "192.0.2.1".parse().unwrap();
let mut reflectors = HashMap::new();
reflectors.insert(reflector, state(100.0, 100.0, 100.0, 100.0, start));
let (updated, anomaly) = process_reply(
&mut reflectors,
&reply(reflector, 200.0, 300.0, received_at),
start,
0.9,
0.5,
);
assert!(!anomaly);
assert_close(updated.baseline.down, 110.0);
assert_close(updated.baseline.up, 120.0);
assert_close(updated.recent.down, 150.0);
assert_close(updated.recent.up, 200.0);
assert_eq!(updated.last_receive_at, received_at);
assert_close(reflectors[&reflector].recent.up, 200.0);
}
#[test]
fn snapshot_contains_updated_combined_state() {
let start = Instant::now();
let received_at = start + Duration::from_secs(1);
let generated_at = received_at + Duration::from_millis(1);
let reflector = "192.0.2.1".parse().unwrap();
let mut reflectors = HashMap::new();
let (updated, anomaly) = process_reply(
&mut reflectors,
&reply(reflector, 20.0, 30.0, received_at),
start,
0.9,
0.5,
);
let snapshot = control_snapshot(&reflectors, generated_at);
assert!(!anomaly);
assert_eq!(snapshot.generated_at, generated_at);
assert_close(
snapshot.reflectors[&reflector].baseline.down,
updated.baseline.down,
);
assert_close(snapshot.reflectors[&reflector].recent.up, updated.recent.up);
assert_eq!(snapshot.reflectors[&reflector].last_receive_at, received_at);
}
}
-413
View File
@@ -1,413 +0,0 @@
use anyhow::Result;
use log::Level;
#[cfg(feature = "uci")]
use log::warn;
#[cfg(feature = "uci")]
use rust_uci::Uci;
use std::fmt;
use std::fmt::Display;
use std::fs::File;
use std::io::BufRead;
use std::net::IpAddr;
use std::path::Path;
use std::str::FromStr;
use std::{env, io};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Invalid measurement type")]
InvalidMeasurementType(String),
#[error("Couldn't parse value for key: `{0}`: invalid value")]
ParseError(String),
#[error("No config value found for key: `{0}`")]
MissingValue(String),
#[error("Reflector list not found at: {0}")]
ReflectorListNotFound(String),
}
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
where
P: AsRef<Path>,
{
let file = File::open(filename)?;
Ok(io::BufReader::new(file).lines())
}
#[derive(Clone, Copy, Debug)]
pub enum MeasurementType {
Icmp = 1,
IcmpTimestamps,
Ntp,
TcpTimestamps,
}
impl Display for MeasurementType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MeasurementType::Icmp => write!(f, "icmp"),
MeasurementType::IcmpTimestamps => write!(f, "icmp-timestamps"),
MeasurementType::Ntp => write!(f, "ntp"),
MeasurementType::TcpTimestamps => write!(f, "tcp-timestamps"),
}
}
}
impl FromStr for MeasurementType {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
return match s.to_lowercase().as_str() {
"icmp" => Ok(MeasurementType::Icmp),
"icmp-timestamps" => Ok(MeasurementType::IcmpTimestamps),
"ntp" => Ok(MeasurementType::Ntp),
"tcp-timestamps" => Ok(MeasurementType::TcpTimestamps),
&_ => Err(ConfigError::InvalidMeasurementType(s.to_string())),
};
}
}
#[derive(Clone, Copy, Debug)]
pub enum ObservabilityProtocol {
Udp,
Tcp,
}
impl FromStr for ObservabilityProtocol {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"udp" => Ok(ObservabilityProtocol::Udp),
"tcp" => Ok(ObservabilityProtocol::Tcp),
_ => Err(ConfigError::InvalidMeasurementType(s.to_string())),
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
// Network section
pub download_interface: String,
pub upload_interface: String,
pub download_base_kbits: f64,
pub download_min_percent: f64,
pub download_min_kbits: f64,
pub upload_base_kbits: f64,
pub upload_min_percent: f64,
pub upload_min_kbits: f64,
// Output section
pub log_level: Level,
pub speed_hist_file: String,
pub stats_file: String,
pub suppress_statistics: bool,
// Observability section
pub observability_enabled: bool,
pub observability_protocol: ObservabilityProtocol,
pub observability_host: Option<String>,
pub observability_port: u16,
pub observability_batch_size: usize,
pub observability_batch_timeout_ms: u64,
pub observability_export_ping_metrics: bool,
pub observability_export_rate_metrics: bool,
pub observability_export_baseline_metrics: bool,
pub observability_export_events: bool,
pub observability_host_tag: String,
// Advanced section
pub download_delay_ms: f64,
pub high_load_level: f64,
pub min_change_interval: f64,
pub measurement_type: MeasurementType,
pub num_reflectors: u8,
pub peer_reselection_time: u64,
pub reflector_list_file: String,
pub speed_hist_size: u32,
pub tick_interval: f64,
pub upload_delay_ms: f64,
// Monitoring / dry-run
pub dry_run: bool,
}
impl Config {
pub fn new() -> Result<Self> {
let config = Self {
// Network section
download_base_kbits: Self::get::<f64>(
"SQMA_DOWNLOAD_BASE_KBITS",
"sqm-autorate.@network[0].download_base_kbits",
None,
)?,
download_interface: Self::get::<String>(
"SQMA_DOWNLOAD_INTERFACE",
"sqm-autorate.@network[0].download_interface",
None,
)?,
download_min_percent: 0.0, // placeholder, computed below
download_min_kbits: 0.0, // placeholder, computed below
upload_base_kbits: Self::get::<f64>(
"SQMA_UPLOAD_BASE_KBITS",
"sqm-autorate.@network[0].upload_base_kbits",
None,
)?,
upload_interface: Self::get::<String>(
"SQMA_UPLOAD_INTERFACE",
"sqm-autorate.@network[0].upload_interface",
None,
)?,
upload_min_percent: 0.0, // placeholder, computed below
upload_min_kbits: 0.0, // placeholder, computed below
// Output section
log_level: Self::get::<Level>(
"SQMA_LOG_LEVEL",
"sqm-autorate.@output[0].log_level",
Some(Level::Error),
)?,
speed_hist_file: Self::get::<String>(
"SQMA_SPEED_HIST_FILE",
"sqm-autorate.@output[0].speed_hist_file",
Some("/tmp/sqm-speedhist.csv".parse()?),
)?,
stats_file: Self::get::<String>(
"SQMA_STATS_FILE",
"sqm-autorate.@output[0].stats_file",
Some("/tmp/sqm-autorate.csv".parse()?),
)?,
suppress_statistics: Self::get::<bool>(
"SQMA_SUPPRESS_STATISTICS",
"sqm-autorate.@output[0].suppress_statistics",
Some(false),
)?,
// Observability section
observability_enabled: Self::get::<bool>(
"SQMA_OBSERVABILITY_ENABLED",
"sqm-autorate.@observability[0].enabled",
Some(false),
)?,
observability_protocol: Self::get::<ObservabilityProtocol>(
"SQMA_OBSERVABILITY_PROTOCOL",
"sqm-autorate.@observability[0].protocol",
Some(ObservabilityProtocol::Udp),
)?,
observability_host: Self::get_optional::<String>(
"SQMA_OBSERVABILITY_HOST",
"sqm-autorate.@observability[0].host",
),
observability_port: Self::get::<u16>(
"SQMA_OBSERVABILITY_PORT",
"sqm-autorate.@observability[0].port",
Some(8089),
)?,
observability_batch_size: Self::get::<usize>(
"SQMA_OBSERVABILITY_BATCH_SIZE",
"sqm-autorate.@observability[0].batch_size",
Some(25),
)?,
observability_batch_timeout_ms: Self::get::<u64>(
"SQMA_OBSERVABILITY_BATCH_TIMEOUT_MS",
"sqm-autorate.@observability[0].batch_timeout_ms",
Some(100),
)?,
observability_export_ping_metrics: Self::get::<bool>(
"SQMA_OBSERVABILITY_EXPORT_PING_METRICS",
"sqm-autorate.@observability[0].export_ping_metrics",
Some(false),
)?,
observability_export_rate_metrics: Self::get::<bool>(
"SQMA_OBSERVABILITY_EXPORT_RATE_METRICS",
"sqm-autorate.@observability[0].export_rate_metrics",
Some(true),
)?,
observability_export_baseline_metrics: Self::get::<bool>(
"SQMA_OBSERVABILITY_EXPORT_BASELINE_METRICS",
"sqm-autorate.@observability[0].export_baseline_metrics",
Some(false),
)?,
observability_export_events: Self::get::<bool>(
"SQMA_OBSERVABILITY_EXPORT_EVENTS",
"sqm-autorate.@observability[0].export_events",
Some(true),
)?,
observability_host_tag: Self::get_host_tag(),
// Advanced section
download_delay_ms: Self::get::<f64>(
"SQMA_DOWNLOAD_DELAY_MS",
"sqm-autorate.@advanced_settings[0].download_delay_ms",
Some(15.0),
)?,
high_load_level: Self::get::<f64>(
"SQMA_HIGH_LOAD_LEVEL",
"sqm-autorate.@advanced_settings[0].high_load_level",
Some(0.8),
)?,
measurement_type: Self::get::<MeasurementType>(
"SQMA_MEASUREMENT_TYPE",
"sqm-autorate.@advanced_settings[0].measurement_type",
Some(MeasurementType::IcmpTimestamps),
)?,
min_change_interval: Self::get::<f64>(
"SQMA_MIN_CHANGE_INTERVAL",
"sqm-autorate.@advanced_settings[0].min_change_interval",
Some(0.5),
)?,
num_reflectors: Self::get::<u8>(
"SQMA_NUM_REFLECTORS",
"sqm-autorate.@advanced_settings[0].num_reflectors",
Some(5),
)?,
peer_reselection_time: Self::get::<u64>(
"SQMA_PEER_RESELECTION_TIME",
"sqm-autorate.@advanced_settings[0].peer_reselection_time",
Some(15),
)?,
reflector_list_file: Self::get::<String>(
"SQMA_REFLECTOR_LIST_FILE",
"sqm-autorate.@advanced_settings[0].reflector_list_file",
Some("/etc/sqm-autorate/reflectors-icmp.csv".parse()?),
)?,
speed_hist_size: Self::get::<u32>(
"SQMA_SPEED_HIST_SIZE",
"sqm-autorate.@advanced_settings[0].speed_hist_size",
Some(100),
)?,
tick_interval: Self::get::<f64>(
"SQMA_TICK_INTERVAL",
"sqm-autorate.@advanced_settings[0].tick_interval",
Some(0.5),
)?,
upload_delay_ms: Self::get::<f64>(
"SQMA_UPLOAD_DELAY_MS",
"sqm-autorate.@advanced_settings[0].upload_delay_ms",
Some(15.0),
)?,
dry_run: Self::get::<bool>(
"SQMA_DRY_RUN",
"sqm-autorate.@advanced_settings[0].dry_run",
Some(false),
)?,
};
let mut config = config;
config.download_min_percent = Self::get::<f64>(
"SQMA_DOWNLOAD_MIN_PERCENT",
"sqm-autorate.@network[0].download_min_percent",
Some(20.0),
)?
.clamp(1.0, 80.0);
config.upload_min_percent = Self::get::<f64>(
"SQMA_UPLOAD_MIN_PERCENT",
"sqm-autorate.@network[0].upload_min_percent",
Some(20.0),
)?
.clamp(1.0, 80.0);
config.download_min_kbits =
(config.download_base_kbits * config.download_min_percent / 100.0).floor();
config.upload_min_kbits =
(config.upload_base_kbits * config.upload_min_percent / 100.0).floor();
Ok(config)
}
fn get<T: FromStr>(env_key: &str, uci_key: &str, default: Option<T>) -> Result<T, ConfigError> {
match Self::get_value(env_key, uci_key) {
Some(val) => match val.parse::<T>() {
Ok(parsed_val) => Ok(parsed_val),
// Ran into an compilation error while trying to return the
// error as-is, so using my own error type to indicate something went wrong while parsing
Err(_) => Err(ConfigError::ParseError(env_key.to_string())),
},
None => match default {
Some(val) => Ok(val),
None => Err(ConfigError::MissingValue(env_key.to_string())),
},
}
}
fn get_host_tag() -> String {
if let Ok(val) = env::var("SQMA_OBSERVABILITY_HOST_TAG") {
if !val.is_empty() {
return val;
}
}
// rustix can give us the hostname without shelling out
rustix::system::uname()
.nodename()
.to_string_lossy()
.into_owned()
}
fn get_optional<T: FromStr>(env_key: &str, uci_key: &str) -> Option<T> {
Self::get_value(env_key, uci_key).and_then(|val| val.parse::<T>().ok())
}
fn get_value(env_key: &str, uci_key: &str) -> Option<String> {
if let Ok(val) = env::var(env_key) {
return Some(val);
}
if let Some(val) = Self::get_from_uci(uci_key) {
return Some(val);
}
None
}
#[cfg(feature = "uci")]
fn get_from_uci(key: &str) -> Option<String> {
let mut uci = match Uci::new() {
Ok(val) => val,
Err(e) => {
warn!("Error opening UCI instance: {}", e);
return None;
}
};
return match uci.get(key) {
Ok(val) => Some(val),
Err(e) => {
warn!("Problem getting config from UCI: {}", e);
None
}
};
}
#[cfg(not(feature = "uci"))]
fn get_from_uci(_: &str) -> Option<String> {
None
}
pub fn load_reflectors(&self) -> Result<Vec<IpAddr>> {
let lines = read_lines(self.reflector_list_file.clone()).map_err(|e| {
ConfigError::ReflectorListNotFound(
self.reflector_list_file.clone() + ": " + e.to_string().as_str(),
)
})?;
let mut reflectors: Vec<IpAddr> = Vec::with_capacity(50);
let mut first = true;
for line in lines {
if first {
first = false;
continue;
}
let line = line?;
let columns: Vec<&str> = line.split(',').collect();
reflectors.push(IpAddr::from_str(columns[0])?);
}
Ok(reflectors)
}
}
+5 -1
View File
@@ -1,5 +1,9 @@
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use log::{Level, LevelFilter, Metadata, Record, SetLoggerError};
use rustix::thread::ClockId;
use rustix::time::ClockId;
use crate::time::Time;
+312 -172
View File
@@ -1,74 +1,196 @@
// SPDX-FileCopyrightText: 2022-Present Charles Corrigan mailto:chas-iot@runegate.org (github @chas-iot)
// SPDX-FileCopyrightText: 2022-Present Daniel Lakeland mailto:dlakelan@street-artists.org (github @dlakelan)
// SPDX-FileCopyrightText: 2022-Present Mark Baker mailto:mark@vpost.net (github @Fail-Safe)
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
extern crate core;
mod baseliner;
mod config;
mod log;
mod metrics;
mod netlink;
mod pinger;
mod pinger_icmp;
mod pinger_icmp_ts;
mod platform;
mod ratecontroller;
mod reflector_selector;
mod settings;
mod time;
mod util;
use crate::baseliner::{Baseliner, ReflectorStats};
use crate::baseliner::Baseliner;
use crate::metrics::{Metric, Metrics, MetricsSender};
use ::log::{debug, info};
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, warn_platform_limitations};
use crate::ratecontroller::Ratecontroller;
use crate::reflector_selector::ReflectorSelector;
use crate::settings::MeasurementType;
use crate::settings::Settings;
use ::log::{info, warn};
use flume::RecvTimeoutError;
use std::collections::HashMap;
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{channel, sync_channel, RecvTimeoutError};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::thread::sleep;
use std::time::Duration;
use std::time::Instant;
use std::time::{Duration, Instant};
use std::{process, thread};
struct ReflectorSetup {
peers: Arc<RwLock<Vec<IpAddr>>>,
pool: Vec<IpAddr>,
reselection_enabled: bool,
active_count: usize,
}
type PingListenerBox = Box<dyn PingListener + Send>;
type PingSenderBox = Box<dyn PingSender + Send>;
pub static SHUTDOWN: AtomicBool = AtomicBool::new(false);
extern "C" fn signal_handler(_: libc::c_int) {
SHUTDOWN.store(true, Ordering::Relaxed);
}
use crate::config::{Config, MeasurementType};
use crate::netlink::Netlink;
use crate::pinger::{PingListener, PingSender};
use crate::pinger_icmp::{PingerICMPEchoListener, PingerICMPEchoSender};
use crate::pinger_icmp_ts::{PingerICMPTimestampListener, PingerICMPTimestampSender};
use crate::ratecontroller::{Ratecontroller, StatsDirection};
use crate::reflector_selector::ReflectorSelector;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const RESELECTION_CANDIDATE_BURST: usize = 20;
const INFLIGHT_CAPACITY_DUPLICATE_FACTOR: usize = 2;
const INFLIGHT_CAPACITY_MIN: usize = 256;
fn main() -> anyhow::Result<()> {
println!("Starting sqm-autorate version {}", VERSION);
fn compute_inflight_probe_capacity(
settings: &Settings,
active_reflector_count: usize,
reselection_enabled: bool,
) -> usize {
let tick_interval_ms = (settings.advanced_settings.tick_interval * 1000.0).max(1.0);
let expected_path_delay_ms = (settings.advanced_settings.download_delay_ms
+ settings.advanced_settings.upload_delay_ms)
.max(10.0);
// Keep enough room for severe queueing events while still adapting to configured delay budgets.
let max_rtt_ms = (expected_path_delay_ms * 20.0).clamp(1000.0, 10000.0);
let burst_reflector_count = if reselection_enabled {
active_reflector_count + RESELECTION_CANDIDATE_BURST
} else {
active_reflector_count
};
let probes_per_reflector = (max_rtt_ms / tick_interval_ms).ceil() as usize;
(burst_reflector_count
.saturating_mul(probes_per_reflector)
.saturating_mul(INFLIGHT_CAPACITY_DUPLICATE_FACTOR))
.max(INFLIGHT_CAPACITY_MIN)
}
fn create_pinger(measurement_type: MeasurementType) -> (PingListenerBox, PingSenderBox) {
match measurement_type {
MeasurementType::Icmp => (
Box::new(PingerICMPEchoListener {}),
Box::new(PingerICMPEchoSender {}),
),
MeasurementType::IcmpTimestamps => (
Box::new(PingerICMPTimestampListener {}),
Box::new(PingerICMPTimestampSender {}),
),
}
}
fn initialize_shaper<T: TrafficControlBackend>(
settings: &Settings,
traffic_control: &mut T,
) -> anyhow::Result<(T::Handle, T::Handle)> {
initialize_shaper_with_settle_time(settings, traffic_control, Duration::from_secs(2))
}
fn initialize_shaper_with_settle_time<T: TrafficControlBackend>(
settings: &Settings,
traffic_control: &mut T,
settle_time: Duration,
) -> anyhow::Result<(T::Handle, T::Handle)> {
let down = traffic_control.find_shaper(&settings.network.download_interface)?;
let up = traffic_control.find_shaper(&settings.network.upload_interface)?;
info!(
"Requesting minimum shaper rates (D/U): {} / {}",
settings.network.download_min_kbits(),
settings.network.upload_min_kbits(),
);
traffic_control.set_rate(
&down,
settings.network.download_min_kbits() as u64,
settings.advanced_settings.dry_run,
)?;
traffic_control.set_rate(
&up,
settings.network.upload_min_kbits() as u64,
settings.advanced_settings.dry_run,
)?;
info!(
"Sleeping for {} seconds to give the shaper a chance to control existing bloat",
settle_time.as_secs_f64(),
);
sleep(settle_time);
Ok((down, up))
}
fn install_signal_handlers() {
unsafe {
libc::signal(libc::SIGINT, signal_handler as *const () as libc::sighandler_t);
libc::signal(libc::SIGTERM, signal_handler as *const () as libc::sighandler_t);
libc::signal(
libc::SIGINT,
signal_handler as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGTERM,
signal_handler as *const () as libc::sighandler_t,
);
}
}
let config = Config::new()?;
log::init(config.log_level)?;
if config.dry_run {
info!("*** MONITORING MODE ACTIVE — qdisc rates will NOT be changed ***");
fn probe_identifier() -> u16 {
// The ICMP identifier is only two bytes.
(process::id() & 0xffff) as u16
}
let mut reflectors = config.load_reflectors()?;
let start_t = Instant::now();
// The identifier field in ICMP is only 2 bytes
// so take the last 2 bytes of the PID as the ID
let id = (process::id() & 0xFFFF) as u16;
fn restore_shaper<T: TrafficControlBackend>(
settings: &Settings,
traffic_control: &mut T,
down: &T::Handle,
up: &T::Handle,
) {
info!(
"Requesting restoration to base shaper rates (D/U): {} / {}",
settings.network.download_base_kbits, settings.network.upload_base_kbits,
);
// Create data structures shared by different threads
let owd_baseline = Arc::new(Mutex::new(HashMap::<IpAddr, ReflectorStats>::new()));
let owd_recent = Arc::new(Mutex::new(HashMap::<IpAddr, ReflectorStats>::new()));
let reflector_peers_lock = Arc::new(RwLock::new(Vec::<IpAddr>::new()));
let mut reflector_pool = Vec::<IpAddr>::new();
let reflector_pool_size = reflectors.len();
if let Err(error) = traffic_control.set_rate(
down,
settings.network.download_base_kbits as u64,
settings.advanced_settings.dry_run,
) {
warn!("Failed to restore download shaper rate: {error}");
}
if let Err(error) = traffic_control.set_rate(
up,
settings.network.upload_base_kbits as u64,
settings.advanced_settings.dry_run,
) {
warn!("Failed to restore upload shaper rate: {error}");
}
}
fn setup_reflectors(settings: &Settings) -> anyhow::Result<ReflectorSetup> {
let configured = settings.load_reflectors()?;
let configured_count = configured.len();
let default_reflectors = [
IpAddr::from_str("9.9.9.9")?,
@@ -79,28 +201,74 @@ fn main() -> anyhow::Result<()> {
IpAddr::from_str("94.140.14.14")?,
];
match reflector_pool_size > config.num_reflectors as usize {
true => {
let mut peers = reflector_peers_lock.write().unwrap();
peers.extend_from_slice(&default_reflectors);
reflector_pool.append(reflectors.as_mut());
let reselection_enabled = configured_count > settings.advanced_settings.num_reflectors as usize;
let pool = if reselection_enabled {
configured
} else {
Vec::new()
};
Ok(ReflectorSetup {
peers: Arc::new(RwLock::new(default_reflectors.to_vec())),
pool,
reselection_enabled,
active_count: default_reflectors
.len()
.max(settings.advanced_settings.num_reflectors as usize),
})
}
fn wait_for_exit(error_rx: &flume::Receiver<anyhow::Error>) -> anyhow::Result<()> {
loop {
match error_rx.recv_timeout(Duration::from_secs(1)) {
Ok(error) => {
return Err(anyhow::anyhow!("thread exited with error: {error}"));
}
Err(RecvTimeoutError::Disconnected) => {
return Ok(());
}
Err(RecvTimeoutError::Timeout) => {
if SHUTDOWN.load(Ordering::Relaxed) {
info!("Received shutdown signal");
return Ok(());
}
}
}
false => {
let mut peers = reflector_peers_lock.write().unwrap();
peers.extend_from_slice(&default_reflectors);
}
}
let (baseliner_stats_tx, baseliner_stats_rx) = channel();
let (error_tx, error_rx) = channel::<anyhow::Error>();
let (reselect_tx, reselect_rx) = channel();
fn run(settings: &Settings) -> anyhow::Result<()> {
if settings.advanced_settings.dry_run {
info!("*** MONITORING MODE ACTIVE — qdisc rates will NOT be changed ***");
}
let dropped = Arc::new(AtomicU64::new(0));
let start_time = Instant::now();
let probe_id = probe_identifier();
let (metrics_tx, metrics_thread_handle) = if config.observability_enabled {
let (tx, rx) = sync_channel(1000);
let ReflectorSetup {
peers: reflector_peers,
pool: reflector_pool,
reselection_enabled,
active_count: active_reflector_count,
} = setup_reflectors(settings)?;
let (baseliner_stats_tx, baseliner_stats_rx) = flume::unbounded();
let (control_snapshot_tx, control_snapshot_rx) = flume::unbounded();
let (selection_snapshot_tx, selection_snapshot_rx) = if reselection_enabled {
let (tx, rx) = flume::unbounded();
(Some(tx), Some(rx))
} else {
(None, None)
};
let (error_tx, error_rx) = flume::unbounded::<anyhow::Error>();
let (reselect_tx, reselect_rx) = flume::bounded(1);
let dropped = Arc::new(AtomicU32::new(0));
let (metrics_tx, metrics_thread_handle) = if settings.observability.enabled {
let (tx, rx) = flume::bounded(1000);
let metrics = Metrics {
config: config.clone(),
settings: settings.clone(),
metrics_rx: rx,
metrics_dropped: Arc::clone(&dropped),
};
@@ -108,8 +276,8 @@ fn main() -> anyhow::Result<()> {
let handle = thread::Builder::new()
.name("metrics".to_string())
.spawn(move || {
if let Err(e) = metrics.run() {
let _ = err_tx.send(e);
if let Err(error) = metrics.run() {
let _ = err_tx.send(error);
}
})?;
(Some(tx), Some(handle))
@@ -125,79 +293,68 @@ fn main() -> anyhow::Result<()> {
.unwrap_or_else(MetricsSender::disabled)
};
let ping_metrics = make_sender(config.observability_export_ping_metrics);
let baseline_metrics = make_sender(config.observability_export_baseline_metrics);
let event_metrics = make_sender(config.observability_export_events);
let rate_metrics = make_sender(config.observability_export_rate_metrics);
let ping_metrics = make_sender(settings.observability.export_ping_metrics);
let baseline_metrics = make_sender(settings.observability.export_baseline_metrics);
let event_metrics = make_sender(settings.observability.export_events);
let rate_metrics = make_sender(settings.observability.export_rate_metrics);
event_metrics.send(Metric::Event {
name: "starting",
reason: "",
reflector: None,
tags: if config.dry_run { &[("dry_run", "true")] } else { &[] },
tags: if settings.advanced_settings.dry_run {
&[("dry_run", "true")]
} else {
&[]
},
});
let (mut ping_listener, mut ping_sender) = match config.measurement_type {
MeasurementType::Icmp => (
Box::new(PingerICMPEchoListener {}) as Box<dyn PingListener + Send>,
Box::new(PingerICMPEchoSender {}) as Box<dyn PingSender + Send>,
),
MeasurementType::IcmpTimestamps => (
Box::new(PingerICMPTimestampListener {}) as Box<dyn PingListener + Send>,
Box::new(PingerICMPTimestampSender {}) as Box<dyn PingSender + Send>,
),
MeasurementType::Ntp | MeasurementType::TcpTimestamps => {
todo!()
}
};
let (mut ping_listener, mut ping_sender) =
create_pinger(settings.advanced_settings.measurement_type);
let inflight_probe_capacity =
compute_inflight_probe_capacity(settings, active_reflector_count, reselection_enabled);
info!(
"In-flight probe cache capacity: {} (active_reflectors={}, reselection_enabled={}, tick_interval_s={})",
inflight_probe_capacity,
active_reflector_count,
reselection_enabled,
settings.advanced_settings.tick_interval
);
let inflight: InFlightProbeCache =
Arc::new(Mutex::new(HashMap::with_capacity(inflight_probe_capacity)));
let baseliner = Baseliner {
config: config.clone(),
owd_baseline: owd_baseline.clone(),
owd_recent: owd_recent.clone(),
settings: settings.clone(),
reselect_trigger: reselect_tx.clone(),
start_time: start_t,
start_time,
stats_rx: baseliner_stats_rx,
control_tx: control_snapshot_tx,
selection_tx: selection_snapshot_tx,
baseline_metrics,
event_metrics: event_metrics.clone(),
};
let down_qdisc = Netlink::qdisc_from_ifname(config.download_interface.as_str())?;
let up_qdisc = Netlink::qdisc_from_ifname(config.upload_interface.as_str())?;
/* Set initial TC values to minimum
* so there should be no initial bufferbloat to
* fool the baseliner
*/
info!(
"Setting shaper rates to minimum (D/L): {} / {}",
config.download_min_kbits, config.upload_min_kbits
);
Netlink::set_qdisc_rate(down_qdisc, config.download_min_kbits as u64, config.dry_run)?;
Netlink::set_qdisc_rate(up_qdisc, config.upload_min_kbits as u64, config.dry_run)?;
// Sleep for a few seconds to give the shaper a chance
// to control the queue if load is heavy
let settle_sleep_time = Duration::new(2, 0);
info!(
"Sleeping for {} to give the shaper a chance to get in control if there's bloat",
settle_sleep_time.as_secs_f64()
);
sleep(settle_sleep_time);
let mut main_traffic_control = traffic_control_backend();
let (down_shaper, up_shaper) = initialize_shaper(settings, &mut main_traffic_control)?;
let err_tx = error_tx.clone();
let reflector_peers_lock_clone = reflector_peers_lock.clone();
let listener_peers = Arc::clone(&reflector_peers);
let listener_inflight = Arc::clone(&inflight);
let measurement_type = settings.advanced_settings.measurement_type;
thread::Builder::new()
.name("receiver".to_string())
.spawn(move || {
if let Err(e) = ping_listener.listen(
id,
config.measurement_type,
reflector_peers_lock_clone,
if let Err(error) = ping_listener.listen(
probe_id,
measurement_type,
listener_peers,
listener_inflight,
baseliner_stats_tx,
ping_metrics,
) {
let _ = err_tx.send(e);
let _ = err_tx.send(error);
}
})?;
@@ -205,33 +362,38 @@ fn main() -> anyhow::Result<()> {
thread::Builder::new()
.name("baseliner".to_string())
.spawn(move || {
if let Err(e) = baseliner.run() {
let _ = err_tx.send(e);
if let Err(error) = baseliner.run() {
let _ = err_tx.send(error);
}
})?;
let err_tx = error_tx.clone();
let reflector_peers_lock_clone = reflector_peers_lock.clone();
let sender_peers = Arc::clone(&reflector_peers);
let sender_inflight = Arc::clone(&inflight);
let measurement_type = settings.advanced_settings.measurement_type;
let tick_interval = settings.advanced_settings.tick_interval;
thread::Builder::new()
.name("sender".to_string())
.spawn(move || {
if let Err(e) = ping_sender.send(
id,
config.measurement_type,
reflector_peers_lock_clone,
config.tick_interval,
if let Err(error) = ping_sender.send(
probe_id,
measurement_type,
sender_peers,
sender_inflight,
tick_interval,
) {
let _ = err_tx.send(e);
let _ = err_tx.send(error);
}
})?;
let main_event_metrics = event_metrics.clone();
if reflector_pool_size > config.num_reflectors as usize {
if reselection_enabled {
let reflector_selector = ReflectorSelector {
config: config.clone(),
owd_recent: owd_recent.clone(),
reflector_peers_lock: reflector_peers_lock.clone(),
settings: settings.clone(),
snapshot_rx: selection_snapshot_rx
.expect("reselection snapshot receiver must exist when reselection is enabled"),
reflector_peers_lock: Arc::clone(&reflector_peers),
reflector_pool,
trigger_channel: reselect_rx,
metrics: event_metrics,
@@ -240,75 +402,39 @@ fn main() -> anyhow::Result<()> {
thread::Builder::new()
.name("reselection".to_string())
.spawn(move || {
if let Err(e) = reflector_selector.run() {
let _ = err_tx.send(e);
if let Err(error) = reflector_selector.run() {
let _ = err_tx.send(error);
}
})?;
}
// Sleep 10 seconds before we start adjusting speeds
sleep(Duration::new(10, 0));
let dl_direction = if config.download_interface.starts_with("ifb")
|| config.download_interface.starts_with("veth")
{
StatsDirection::TX
} else {
StatsDirection::RX
};
let ul_direction = if config.upload_interface.starts_with("ifb")
|| config.upload_interface.starts_with("veth")
{
StatsDirection::RX
} else {
StatsDirection::TX
};
// Give the baseliner time to collect initial samples before adjusting rates.
sleep(Duration::from_secs(10));
let mut ratecontroller = Ratecontroller::new(
config.clone(),
owd_baseline,
owd_recent,
reflector_peers_lock,
settings.clone(),
control_snapshot_rx,
reflector_peers,
reselect_tx,
dl_direction,
ul_direction,
rate_metrics,
)?;
debug!(
"Download direction: {}:{:?}",
config.download_interface, dl_direction
);
debug!(
"Upload direction: {}:{:?}",
config.upload_interface, ul_direction
);
let err_tx = error_tx.clone();
thread::Builder::new()
.name("ratecontroller".to_string())
.spawn(move || {
if let Err(e) = ratecontroller.run() {
let _ = err_tx.send(e);
if let Err(error) = ratecontroller.run() {
let _ = err_tx.send(error);
}
})?;
// Drop original sender so the channel disconnects if all threads exit cleanly
// Drop the original sender so the channel disconnects if all workers exit cleanly.
drop(error_tx);
let result = loop {
match error_rx.recv_timeout(Duration::from_secs(1)) {
Ok(e) => break Err(anyhow::anyhow!("thread exited with error: {e}")),
Err(RecvTimeoutError::Disconnected) => break Ok(()),
Err(RecvTimeoutError::Timeout) => {
if SHUTDOWN.load(Ordering::Relaxed) {
info!("Received shutdown signal");
break Ok(());
}
}
}
};
let result = wait_for_exit(&error_rx);
// Make the error path request shutdown too, rather than leaving the other workers running.
SHUTDOWN.store(true, Ordering::Relaxed);
let stopping_reason = if result.is_err() { "error" } else { "signal" };
main_event_metrics.send(Metric::Event {
@@ -319,20 +445,34 @@ fn main() -> anyhow::Result<()> {
});
// Drop all MetricsSender instances and the raw tx so the metrics channel
// disconnects once all threads also drop their copies, allowing the
// metrics thread to drain and exit cleanly.
// disconnects once all worker threads also drop their copies.
drop(main_event_metrics);
drop(metrics_tx);
if let Some(handle) = metrics_thread_handle {
let _ = handle.join();
}
info!(
"Restoring base shaper rates (D/L): {} / {}",
config.download_base_kbits, config.upload_base_kbits
restore_shaper(
settings,
&mut main_traffic_control,
&down_shaper,
&up_shaper,
);
let _ = Netlink::set_qdisc_rate(down_qdisc, config.download_base_kbits as u64, config.dry_run);
let _ = Netlink::set_qdisc_rate(up_qdisc, config.upload_base_kbits as u64, config.dry_run);
result
}
fn main() -> anyhow::Result<()> {
println!("Starting sqm-autorate-rust version {}", VERSION);
install_signal_handlers();
let settings = Settings::load()?;
settings.validate()?;
log::init(settings.output.log_level)?;
warn_platform_limitations();
run(&settings)
}
+27 -25
View File
@@ -1,13 +1,17 @@
use crate::config::{MeasurementType, ObservabilityProtocol};
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use crate::settings::Settings;
use crate::settings::{MeasurementType, ObservabilityProtocol};
use crate::time::Time;
use crate::Config;
use flume::{Receiver, RecvTimeoutError, Sender};
use log::{error, info, warn};
use rustix::time::ClockId;
use std::fmt::Write;
use std::net::{IpAddr, TcpStream, UdpSocket};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
const MAX_RECONNECT_BACKOFF: u64 = 60;
@@ -74,8 +78,10 @@ impl Transport {
let full_data = format!("{}\n", data);
// take the stream out, write, put it back or set None on failure
if let Some(s) = stream {
if let Err(e) = std::io::Write::write_all(s, full_data.as_bytes()) {
let write_error = stream
.as_mut()
.and_then(|s| std::io::Write::write_all(s, full_data.as_bytes()).err());
if let Some(e) = write_error {
warn!("TCP send failed: {}", e);
*stream = None;
*reconnect_backoff = (*reconnect_backoff * 2).min(MAX_RECONNECT_BACKOFF);
@@ -84,7 +90,6 @@ impl Transport {
}
}
}
}
pub enum Metric {
Ping {
@@ -116,18 +121,18 @@ pub enum Metric {
tags: &'static [(&'static str, &'static str)],
},
Dropped {
count: u64,
count: u32,
},
}
#[derive(Clone)]
pub struct MetricsSender {
tx: Option<SyncSender<(Metric, u64)>>,
dropped: Arc<AtomicU64>,
tx: Option<Sender<(Metric, u64)>>,
dropped: Arc<AtomicU32>,
}
impl MetricsSender {
pub fn new(tx: SyncSender<(Metric, u64)>, dropped: Arc<AtomicU64>) -> Self {
pub fn new(tx: Sender<(Metric, u64)>, dropped: Arc<AtomicU32>) -> Self {
Self {
tx: Some(tx),
dropped,
@@ -137,7 +142,7 @@ impl MetricsSender {
pub fn disabled() -> Self {
Self {
tx: None,
dropped: Arc::new(AtomicU64::new(0)),
dropped: Arc::new(AtomicU32::new(0)),
}
}
@@ -152,14 +157,14 @@ impl MetricsSender {
}
pub struct Metrics {
pub config: Config,
pub metrics_dropped: Arc<AtomicU64>,
pub settings: Settings,
pub metrics_dropped: Arc<AtomicU32>,
pub metrics_rx: Receiver<(Metric, u64)>,
}
impl Metrics {
pub fn run(self) -> anyhow::Result<()> {
let host = match &self.config.observability_host {
let host = match &self.settings.observability.host {
Some(h) => h.clone(),
None => {
error!("Observability host not configured - metrics exporter disabled");
@@ -167,12 +172,12 @@ impl Metrics {
}
};
let port = self.config.observability_port;
let batch_size = self.config.observability_batch_size;
let timeout = Duration::from_millis(self.config.observability_batch_timeout_ms);
let host_tag = &self.config.observability_host_tag.clone();
let port = self.settings.observability.port;
let batch_size = self.settings.observability.batch_size;
let timeout = Duration::from_millis(self.settings.observability.batch_timeout_ms);
let host_tag = &self.settings.observability.host_tag.clone();
let mut transport = match self.config.observability_protocol {
let mut transport = match self.settings.observability.protocol {
ObservabilityProtocol::Udp => {
info!("Metrics exporter configured for UDP to {}:{}", host, port);
Transport::new_udp(&host, port)?
@@ -205,11 +210,8 @@ impl Metrics {
}
// greedily drain without blocking
loop {
match self.metrics_rx.try_recv() {
Ok(metric) => batch.push(metric),
Err(_) => break,
}
while let Ok(metric) = self.metrics_rx.try_recv() {
batch.push(metric);
if batch.len() >= batch_size {
break;
}
-145
View File
@@ -1,145 +0,0 @@
use std::io;
use std::str::Utf8Error;
use log::info;
use netlink_bindings::{rt_link, tc};
use netlink_socket2::NetlinkSocket;
use thiserror::Error;
#[derive(Debug, Error)]
pub 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)]
pub struct Qdisc {
pub ifindex: i32,
pub parent: u32,
}
pub struct Netlink {}
impl Netlink {
pub fn find_interface(ifname: &str) -> Result<i32, NetlinkError> {
let mut socket = NetlinkSocket::new();
let mut request = rt_link::Request::new()
.op_getlink_do_request(&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())
}
pub 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_request(&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?;
for attr in attrs {
if let Ok(rt_link::OpGetlinkDoReply::Stats64(stats)) = attr {
return Ok((stats.rx_bytes(), stats.tx_bytes()));
}
}
}
Err(NetlinkError::NoInterfaceStatsFound(ifname.to_string()))
}
pub fn qdisc_from_ifindex(ifindex: i32, ifname: &str) -> Result<Qdisc, NetlinkError> {
let mut socket = NetlinkSocket::new();
let header = tc::PushTcmsg::new();
let request = tc::Request::new().op_getqdisc_dump_request(&header);
let mut iter = socket.request(&request)?;
while let Some(reply) = iter.recv() {
let (header, attrs) = reply?;
if header.ifindex() == ifindex {
let mut is_cake = false;
for attr in attrs {
if let Ok(tc::OpGetqdiscDumpReply::Kind(kind)) = attr {
if kind.to_str().map_err(|_| NetlinkError::NlQdiscError("Invalid UTF-8".to_string()))? == "cake" {
is_cake = true;
break;
}
}
}
if is_cake {
return Ok(Qdisc {
ifindex,
parent: header.parent(),
});
}
}
}
Err(NetlinkError::NoQdiscFound(ifname.to_string()))
}
pub fn qdisc_from_ifname(ifname: &str) -> Result<Qdisc, NetlinkError> {
let ifindex = Netlink::find_interface(ifname)?;
Netlink::qdisc_from_ifindex(ifindex, ifname)
}
pub 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::PushTcmsg::new();
header.set_ifindex(qdisc.ifindex);
header.set_parent(qdisc.parent);
let mut request = tc::Request::new()
.set_change()
.op_newqdisc_do_request(&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(())
}
}
+52 -14
View File
@@ -1,14 +1,22 @@
use crate::SHUTDOWN;
// SPDX-FileCopyrightText: 2022-Present Charles Corrigan mailto:chas-iot@runegate.org (github @chas-iot)
// SPDX-FileCopyrightText: 2022-Present Daniel Lakeland mailto:dlakelan@street-artists.org (github @dlakelan)
// SPDX-FileCopyrightText: 2022-Present Mark Baker mailto:mark@vpost.net (github @Fail-Safe)
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use crate::MeasurementType;
use crate::SHUTDOWN;
use crate::metrics::{Metric, MetricsSender};
use crate::util::RwLockExt;
use crate::util::{MutexExt, RwLockExt};
use flume::Sender;
use icmp_socket2::socket::IcmpSocket;
use icmp_socket2::{IcmpSocket4, Icmpv4Packet};
use log::{debug, info, warn};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::atomic::Ordering;
use std::sync::mpsc::Sender;
use std::sync::{Arc, RwLock};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use std::{io, thread};
use thiserror::Error;
@@ -39,12 +47,19 @@ pub struct PingReply {
pub last_receive_time_s: Instant,
}
#[derive(Clone, Copy, Debug)]
pub struct InFlightProbe {
pub sent_at: Instant,
pub originate_timestamp: i64,
}
pub type InFlightProbeKey = (IpAddr, MeasurementType, u16);
pub type InFlightProbeCache = Arc<Mutex<HashMap<InFlightProbeKey, InFlightProbe>>>;
const INFLIGHT_PROBE_TTL: Duration = Duration::from_secs(30);
fn open_socket(type_: MeasurementType) -> io::Result<IcmpSocket4> {
match type_ {
MeasurementType::Icmp | MeasurementType::IcmpTimestamps => IcmpSocket4::new(),
_ => {
unimplemented!()
}
}
}
@@ -54,6 +69,7 @@ pub trait PingListener {
id: u16,
type_: MeasurementType,
reflectors_lock: Arc<RwLock<Vec<IpAddr>>>,
inflight: InFlightProbeCache,
stats_tx: Sender<PingReply>,
ping_metrics: MetricsSender,
) -> anyhow::Result<()> {
@@ -85,6 +101,14 @@ pub trait PingListener {
}
};
let key = (addr, type_, reply.seq);
let Some(probe) = inflight.lock_anyhow()?.remove(&key) else {
continue;
};
if reply.originate_timestamp != probe.originate_timestamp {
continue;
}
ping_metrics.send(Metric::Ping {
reflector: reply.reflector,
measurement_type: reply.measurement_type,
@@ -128,6 +152,7 @@ pub trait PingSender {
id: u16,
type_: MeasurementType,
reflectors_lock: Arc<RwLock<Vec<IpAddr>>>,
inflight: InFlightProbeCache,
tick_interval: f64,
) -> anyhow::Result<()> {
let mut socket = open_socket(type_)?;
@@ -151,6 +176,10 @@ pub trait PingSender {
continue;
}
inflight
.lock_anyhow()?
.retain(|_, probe| probe.sent_at.elapsed() < INFLIGHT_PROBE_TTL);
let sleep_duration =
Duration::from_millis((tick_duration_ms / reflectors.len() as u16) as u64);
@@ -160,17 +189,26 @@ pub trait PingSender {
IpAddr::V6(_) => unimplemented!(),
};
socket.send_to(addr, self.craft_packet(id, seq))?;
let (packet, originate_timestamp) = self.craft_packet(id, seq);
let sent_at = Instant::now();
let key = (*reflector, type_, seq);
inflight.lock_anyhow()?.insert(
key,
InFlightProbe {
sent_at,
originate_timestamp,
},
);
if let Err(e) = socket.send_to(addr, packet) {
inflight.lock_anyhow()?.remove(&key);
return Err(e.into());
}
thread::sleep(sleep_duration);
}
if seq == u16::MAX {
seq = 0;
} else {
seq += 1;
}
seq = seq.wrapping_add(1);
}
}
fn craft_packet(&self, id: u16, seq: u16) -> icmp_socket2::packet::Icmpv4Packet;
fn craft_packet(&self, id: u16, seq: u16) -> (icmp_socket2::packet::Icmpv4Packet, i64);
}
+14 -4
View File
@@ -1,13 +1,20 @@
// SPDX-FileCopyrightText: 2022-Present Charles Corrigan mailto:chas-iot@runegate.org (github @chas-iot)
// SPDX-FileCopyrightText: 2022-Present Daniel Lakeland mailto:dlakelan@street-artists.org (github @dlakelan)
// SPDX-FileCopyrightText: 2022-Present Mark Baker mailto:mark@vpost.net (github @Fail-Safe)
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use std::net::IpAddr;
use std::time::Instant;
use crate::config::MeasurementType;
use crate::pinger::{PingError, PingListener, PingReply, PingSender};
use crate::settings::MeasurementType;
use crate::time::Time;
use icmp_socket2::Icmpv4Message;
use icmp_socket2::Icmpv4Packet;
use icmp_socket2::packet::WithEchoRequest;
use rustix::thread::ClockId;
use rustix::time::ClockId;
pub struct PingerICMPEchoListener {}
@@ -77,11 +84,14 @@ impl PingListener for PingerICMPEchoListener {
}
impl PingSender for PingerICMPEchoSender {
fn craft_packet(&self, id: u16, seq: u16) -> Icmpv4Packet {
fn craft_packet(&self, id: u16, seq: u16) -> (Icmpv4Packet, i64) {
let clock = Time::new(ClockId::Monotonic);
let time_ms = clock.to_milliseconds();
let payload = time_ms.to_be_bytes().to_vec();
Icmpv4Packet::with_echo_request(id, seq, payload).unwrap()
(
Icmpv4Packet::with_echo_request(id, seq, payload).unwrap(),
time_ms as i64,
)
}
}
+15 -4
View File
@@ -1,10 +1,17 @@
use crate::config::MeasurementType;
// SPDX-FileCopyrightText: 2022-Present Charles Corrigan mailto:chas-iot@runegate.org (github @chas-iot)
// SPDX-FileCopyrightText: 2022-Present Daniel Lakeland mailto:dlakelan@street-artists.org (github @dlakelan)
// SPDX-FileCopyrightText: 2022-Present Mark Baker mailto:mark@vpost.net (github @Fail-Safe)
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use crate::pinger::{PingError, PingListener, PingReply, PingSender};
use crate::settings::MeasurementType;
use crate::time::Time;
use icmp_socket2::Icmpv4Message;
use icmp_socket2::Icmpv4Packet;
use icmp_socket2::packet::WithTimestampRequest;
use rustix::thread::ClockId;
use rustix::time::ClockId;
use std::net::IpAddr;
use std::time::Instant;
@@ -72,8 +79,12 @@ impl PingListener for PingerICMPTimestampListener {
}
impl PingSender for PingerICMPTimestampSender {
fn craft_packet(&self, id: u16, seq: u16) -> Icmpv4Packet {
fn craft_packet(&self, id: u16, seq: u16) -> (Icmpv4Packet, i64) {
let time_since_midnight = Time::new(ClockId::Realtime).get_time_since_midnight();
Icmpv4Packet::with_timestamp_request(id, seq, time_since_midnight as u32, 0, 0).unwrap()
(
Icmpv4Packet::with_timestamp_request(id, seq, time_since_midnight as u32, 0, 0)
.unwrap(),
time_since_midnight as i64,
)
}
}
+125
View File
@@ -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<InterfaceStats, Self::Error> {
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::<libc::ifmibdata>::uninit();
let mut returned_len = size_of::<libc::ifmibdata>();
let result = unsafe {
libc::sysctl(
mib.as_ptr(),
mib.len() as libc::c_uint,
ifmd.as_mut_ptr().cast::<libc::c_void>(),
&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::<libc::ifmibdata>();
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."
);
}
+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)
}
}
+218
View File
@@ -0,0 +1,218 @@
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use std::ffi::{CString, NulError};
use std::io;
use std::mem::size_of;
use std::ptr;
use log::warn;
use thiserror::Error;
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(crate) enum MacOsInterfaceStatsError {
#[error("interface name contains a NUL byte")]
InterfaceNameContainsNul(#[source] NulError),
#[error("interface `{interface}` was not found")]
InterfaceNotFound {
interface: String,
#[source]
source: io::Error,
},
#[error("sysctl failed while {operation}")]
Sysctl {
operation: &'static str,
#[source]
source: io::Error,
},
#[error("malformed route-message response: {0}")]
MalformedResponse(String),
#[error("no RTM_IFINFO2 statistics returned for interface index {interface_index}")]
NoInterfaceStats { interface_index: libc::c_uint },
}
#[derive(Debug, Default)]
pub(crate) struct MacOsInterfaceStats;
impl InterfaceStatsProvider for MacOsInterfaceStats {
type Error = MacOsInterfaceStatsError;
fn read_stats(&mut self, interface: &str) -> Result<InterfaceStats, Self::Error> {
let interface_name =
CString::new(interface).map_err(MacOsInterfaceStatsError::InterfaceNameContainsNul)?;
// SAFETY: `interface_name` is a live, NUL-terminated C string for the duration of the call.
let interface_index = unsafe { libc::if_nametoindex(interface_name.as_ptr()) };
if interface_index == 0 {
return Err(MacOsInterfaceStatsError::InterfaceNotFound {
interface: interface.to_string(),
source: io::Error::last_os_error(),
});
}
let response = route_interface_list(interface_index)?;
parse_route_messages(&response, interface_index)
}
}
fn route_interface_list(
interface_index: libc::c_uint,
) -> Result<Vec<u8>, MacOsInterfaceStatsError> {
let mut mib = [
libc::CTL_NET,
libc::PF_ROUTE,
0,
0,
libc::NET_RT_IFLIST2,
interface_index as libc::c_int,
];
for attempt in 0..SYSCTL_FETCH_ATTEMPTS {
let mut required_len = 0;
// SAFETY: `mib` has the declared six elements, `required_len` is writable, and the
// null data pointers request only the response size without reading or writing data.
let size_result = unsafe {
libc::sysctl(
mib.as_mut_ptr(),
mib.len() as libc::c_uint,
ptr::null_mut(),
&mut required_len,
ptr::null_mut(),
0,
)
};
if size_result != 0 {
return Err(MacOsInterfaceStatsError::Sysctl {
operation: "querying the interface-list buffer size",
source: io::Error::last_os_error(),
});
}
let mut response = vec![0_u8; required_len];
let mut returned_len = required_len;
// SAFETY: `response` owns `required_len` initialized bytes, `returned_len` describes
// that allocation, and all other pointers remain valid for the duration of the call.
let fetch_result = unsafe {
libc::sysctl(
mib.as_mut_ptr(),
mib.len() as libc::c_uint,
response.as_mut_ptr().cast(),
&mut returned_len,
ptr::null_mut(),
0,
)
};
if fetch_result == 0 {
if returned_len > response.len() {
return Err(MacOsInterfaceStatsError::MalformedResponse(format!(
"sysctl returned length {returned_len} for a {}-byte buffer",
response.len()
)));
}
response.truncate(returned_len);
return Ok(response);
}
let source = io::Error::last_os_error();
let buffer_too_small = matches!(source.raw_os_error(), Some(libc::ENOMEM | libc::ENOBUFS));
if !buffer_too_small || attempt + 1 == SYSCTL_FETCH_ATTEMPTS {
return Err(MacOsInterfaceStatsError::Sysctl {
operation: "fetching the interface list",
source,
});
}
}
unreachable!("the bounded sysctl retry loop always returns")
}
fn parse_route_messages(
response: &[u8],
requested_interface_index: libc::c_uint,
) -> Result<InterfaceStats, MacOsInterfaceStatsError> {
let mut offset = 0;
while offset < response.len() {
let remaining = response.len() - offset;
if remaining < ROUTE_MESSAGE_PREFIX_LEN {
return Err(MacOsInterfaceStatsError::MalformedResponse(format!(
"truncated route-message prefix at offset {offset}: {remaining} bytes remain"
)));
}
let message_len = usize::from(u16::from_ne_bytes([response[offset], response[offset + 1]]));
if message_len < ROUTE_MESSAGE_PREFIX_LEN {
return Err(MacOsInterfaceStatsError::MalformedResponse(format!(
"route message at offset {offset} has implausible length {message_len}"
)));
}
let message_end = offset.checked_add(message_len).ok_or_else(|| {
MacOsInterfaceStatsError::MalformedResponse(format!(
"route message length overflows at offset {offset}"
))
})?;
if message_end > response.len() {
return Err(MacOsInterfaceStatsError::MalformedResponse(format!(
"route message at offset {offset} declares length {message_len}, exceeding the {}-byte response",
response.len()
)));
}
let message_type = response[offset + 3];
if message_type == libc::RTM_IFINFO2 as u8 {
if message_len < size_of::<libc::if_msghdr2>() {
return Err(MacOsInterfaceStatsError::MalformedResponse(format!(
"RTM_IFINFO2 message at offset {offset} has length {message_len}, smaller than {}",
size_of::<libc::if_msghdr2>()
)));
}
// SAFETY: the bounds checks above prove that a complete `if_msghdr2` lies within
// `response`; route messages are not guaranteed to have Rust alignment, so the value
// is copied with `read_unaligned` instead of creating a reference into the byte slice.
let header = unsafe {
ptr::read_unaligned(response.as_ptr().add(offset).cast::<libc::if_msghdr2>())
};
if libc::c_uint::from(header.ifm_index) == requested_interface_index {
return Ok(InterfaceStats {
rx_bytes: header.ifm_data.ifi_ibytes,
tx_bytes: header.ifm_data.ifi_obytes,
});
}
}
offset = message_end;
}
Err(MacOsInterfaceStatsError::NoInterfaceStats {
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."
);
}
+66
View File
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use std::error::Error;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct InterfaceStats {
pub(crate) rx_bytes: u64,
pub(crate) tx_bytes: u64,
}
pub(crate) trait InterfaceStatsProvider: Send {
type Error: Error + Send + Sync + 'static;
fn read_stats(&mut self, interface: &str) -> Result<InterfaceStats, Self::Error>;
}
pub(crate) trait TrafficControlBackend: Send {
type Error: Error + Send + Sync + 'static;
type Handle: Send + 'static;
fn find_shaper(&mut self, interface: &str) -> Result<Self::Handle, Self::Error>;
fn set_rate(
&mut self,
shaper: &Self::Handle,
bandwidth_kbit: u64,
dry_run: bool,
) -> Result<(), Self::Error>;
}
#[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(unix)]
pub mod unix;
#[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 = "freebsd", 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,
};
+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(())
}
}
+2
View File
@@ -0,0 +1,2 @@
#[cfg(feature = "uci")]
pub mod uci;
+287
View File
@@ -0,0 +1,287 @@
use std::path::{Path, PathBuf};
use crate::settings::uci_schema::{UciConfigSchema, UciSectionMapping};
use config::{ConfigError as SourceError, Map, Source, Value, ValueKind};
use rust_uci::config::{
Config as UciConfig, Package as UciPackage, SectionSelector, Value as UciValue,
};
#[derive(Clone, Debug)]
enum PackageSelection {
Selected(Vec<String>),
All,
}
#[derive(Clone, Debug)]
pub(crate) struct UciSource {
packages: PackageSelection,
required: bool,
schema: &'static [UciSectionMapping],
config_dir: Option<PathBuf>,
save_dir: Option<PathBuf>,
}
impl UciSource {
fn with_selection(packages: PackageSelection) -> Self {
Self {
packages,
required: true,
schema: &[],
config_dir: None,
save_dir: None,
}
}
pub(crate) fn new<I, S>(packages: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::with_selection(PackageSelection::Selected(
packages.into_iter().map(Into::into).collect(),
))
}
#[allow(dead_code)]
pub(crate) fn all() -> Self {
Self::with_selection(PackageSelection::All)
}
pub(crate) fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
pub(crate) fn from_schema<T>() -> Self
where
T: UciConfigSchema,
{
Self::new([T::UCI_PACKAGE]).with_schema(T::UCI_SECTIONS)
}
pub(crate) fn with_schema(mut self, schema: &'static [UciSectionMapping]) -> Self {
self.schema = schema;
self
}
#[allow(dead_code)]
pub(crate) fn with_directories(
mut self,
config_dir: impl AsRef<Path>,
save_dir: impl AsRef<Path>,
) -> Self {
self.config_dir = Some(config_dir.as_ref().to_owned());
self.save_dir = Some(save_dir.as_ref().to_owned());
self
}
fn open(&self) -> Result<UciConfig, SourceError> {
let mut uci = rust_uci::Uci::new()
.map_err(|e| SourceError::Message(format!("failed to open UCI: {e}")))?;
if let Some(config_dir) = &self.config_dir {
uci.set_config_dir(config_dir).map_err(|e| {
SourceError::Message(format!(
"failed to set UCI config directory {config_dir:?}: {e}"
))
})?;
}
if let Some(save_dir) = &self.save_dir {
uci.set_save_dir(save_dir).map_err(|e| {
SourceError::Message(format!(
"failed to set UCI save directory {save_dir:?}: {e}"
))
})?;
}
Ok(uci.into())
}
///
/// Converts a [`SectionSelector`] into a string representation.
/// Anonymous sections are converted to `section_type[index]` format,
/// because config-rs does not allow @-prefixed section names.
fn config_selector(selector: &SectionSelector<'_>) -> String {
match selector {
SectionSelector::Named(name) => (*name).to_owned(),
SectionSelector::Anonymous {
section_type,
index,
} => format!("{section_type}[{index}]"),
}
}
fn mapped_config_key(
&self,
selector: &SectionSelector<'_>,
uci_option: &str,
) -> Option<String> {
let section_type = match selector {
SectionSelector::Anonymous {
section_type,
index,
} if *index == 0 => *section_type,
SectionSelector::Anonymous { .. } | SectionSelector::Named(_) => return None,
};
self.schema
.iter()
.filter(|section| section.uci_section == section_type)
.find_map(|section| {
section
.options
.iter()
.find(|option| option.uci_option == uci_option)
.map(|option| format!("{}.{}", section.config_section, option.config_field,))
})
}
fn collect_package(
&self,
package_name: &str,
package: &UciPackage,
values: &mut Map<String, Value>,
) -> Result<(), SourceError> {
let sections = package.sections().map_err(|e| {
SourceError::Message(format!(
"failed to retrieve sections from UCI package \
{package_name:?}: {e}"
))
})?;
for section in sections {
let selector = section.selector().ok_or_else(|| {
SourceError::Message(format!(
"loaded UCI section of type {:?} in package \
{package_name:?} has no selector",
section.type_()
))
})?;
let options = section.options().map_err(|e| {
SourceError::Message(format!(
"failed to retrieve options from UCI section \
{package_name}.{selector}: {e}"
))
})?;
for option in options {
let option_name = option.name();
let uci_relative_key = format!("{selector}.{option_name}");
let config_relative_key =
format!("{}.{option_name}", Self::config_selector(&selector),);
let uci_key = format!("{package_name}.{uci_relative_key}");
let source_key = format!("{package_name}.{config_relative_key}");
let destination_key = self
.mapped_config_key(&selector, option_name)
.unwrap_or(source_key);
let Some(value) = option.get().map_err(|e| {
SourceError::Message(format!(
"failed to retrieve UCI option \
{uci_key}: {e}"
))
})?
else {
continue;
};
let origin = format!("UCI option {uci_key}");
let value = match value {
UciValue::String(value) => Value::new(Some(&origin), ValueKind::String(value)),
UciValue::List(items) => {
let items = items
.into_iter()
.map(|item| Value::new(Some(&origin), ValueKind::String(item)))
.collect();
Value::new(Some(&origin), ValueKind::Array(items))
}
};
if values.insert(destination_key.clone(), value).is_some() {
return Err(SourceError::Message(format!(
"duplicate UCI destination key \
{destination_key:?} while mapping {uci_key:?}"
)));
}
}
}
Ok(())
}
}
impl Source for UciSource {
fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
Box::new(self.clone())
}
fn collect(&self) -> Result<Map<String, Value>, SourceError> {
let resolved = (|| {
let uci = self.open()?;
let packages: Vec<UciPackage> = match &self.packages {
PackageSelection::Selected(package_names) => package_names
.iter()
.map(|package_name| {
uci.package(package_name)
.map_err(|e| {
SourceError::Message(format!(
"failed to open UCI package {package_name:?}: {e}"
))
})?
.ok_or_else(|| {
SourceError::NotFound(format!("UCI package {package_name:?}"))
})
})
.collect::<Result<_, SourceError>>()?,
PackageSelection::All => uci
.packages()
.map_err(|e| {
SourceError::Message(format!("failed to enumerate UCI packages: {e}"))
})?
.collect(),
};
Ok::<_, SourceError>((uci, packages))
})();
let (_uci, packages) = match resolved {
Ok(resolved) => resolved,
Err(_) if !self.required => {
return Ok(Map::new());
}
Err(error) => {
return Err(error);
}
};
let mut values = Map::new();
for package in packages {
let package_name = package
.name()
.map_err(|e| SourceError::Message(format!("failed to read UCI package name: {e}")))?
.to_owned();
self.collect_package(&package_name, &package, &mut values)?;
}
Ok(values)
}
}
+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."
);
}
+344 -204
View File
@@ -1,21 +1,29 @@
// SPDX-FileCopyrightText: 2022-Present Charles Corrigan mailto:chas-iot@runegate.org (github @chas-iot)
// SPDX-FileCopyrightText: 2022-Present Daniel Lakeland mailto:dlakelan@street-artists.org (github @dlakelan)
// SPDX-FileCopyrightText: 2022-Present Mark Baker mailto:mark@vpost.net (github @Fail-Safe)
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use crate::SHUTDOWN;
use crate::baseliner::ControlSnapshot;
use crate::metrics::{Metric, MetricsSender};
use crate::netlink::{Netlink, NetlinkError, Qdisc};
use crate::platform::{
InterfaceStatsProvider, PlatformInterfaceStats, PlatformTrafficControl, TrafficControlBackend,
interface_stats_provider, traffic_control_backend,
};
use crate::settings::Settings;
use crate::time::Time;
use crate::util::{MutexExt, RwLockExt};
use crate::{Config, ReflectorStats};
use crate::util::{ArcRwLock, RwLockExt};
use flume::{Receiver, Sender};
use log::{debug, info, warn};
use rustix::thread::ClockId;
use std::collections::HashMap;
use rustix::time::ClockId;
use std::fs::File;
use std::io::Write;
use std::net::IpAddr;
use std::sync::atomic::Ordering;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex, RwLock};
use std::thread::sleep;
use std::time::{Duration, Instant};
use thiserror::Error;
#[derive(Copy, Clone, Debug, PartialEq)]
enum Direction {
@@ -23,56 +31,32 @@ enum Direction {
Up,
}
#[derive(Debug, Error)]
pub enum RatecontrolError {
#[error("Netlink error")]
Netlink(#[from] NetlinkError),
}
#[derive(Copy, Clone, Debug)]
pub enum StatsDirection {
RX,
TX,
}
fn generate_initial_speeds(base_speed: f64, size: u32) -> Vec<f64> {
fn generate_initial_speeds(rng: &mut fastrand::Rng, base_speed: f64, size: u32) -> Vec<f64> {
let mut rates = Vec::new();
for _ in 0..size {
rates.push((fastrand::f64() * 0.2 + 0.75) * base_speed);
rates.push((rng.f64() * 0.2 + 0.75) * base_speed);
}
rates
}
fn get_interface_stats(
config: &Config,
down_direction: StatsDirection,
up_direction: StatsDirection,
) -> Result<(i128, i128), RatecontrolError> {
let (down_rx, down_tx) = Netlink::get_interface_stats(config.download_interface.as_str())?;
let (up_rx, up_tx) = Netlink::get_interface_stats(config.upload_interface.as_str())?;
fn get_interface_stats<S: InterfaceStatsProvider>(
stats_provider: &mut S,
upload_interface: &str,
) -> Result<(i128, i128), S::Error> {
let stats = stats_provider.read_stats(upload_interface)?;
let rx_bytes = match down_direction {
StatsDirection::RX => down_rx,
StatsDirection::TX => down_tx,
};
let tx_bytes = match up_direction {
StatsDirection::RX => up_rx,
StatsDirection::TX => up_tx,
};
Ok((rx_bytes.into(), tx_bytes.into()))
Ok((stats.rx_bytes.into(), stats.tx_bytes.into()))
}
#[derive(Clone, Debug)]
struct State {
struct State<H> {
current_bytes: i128,
current_rate: f64,
delta_stat: f64,
deltas: Vec<f64>,
qdisc: Qdisc,
shaper: H,
load: f64,
next_rate: f64,
nrate: usize,
@@ -82,8 +66,8 @@ struct State {
utilisation: f64,
}
impl State {
fn new(qdisc: Qdisc, previous_bytes: i128, safe_rates: Vec<f64>) -> Self {
impl<H> State<H> {
fn new(shaper: H, previous_bytes: i128, safe_rates: Vec<f64>) -> Self {
State {
current_bytes: 0,
current_rate: 0.0,
@@ -92,7 +76,7 @@ impl State {
load: 0.0,
next_rate: 0.0,
nrate: 0,
qdisc,
shaper,
previous_bytes,
prev_t: Instant::now(),
safe_rates,
@@ -101,33 +85,146 @@ impl State {
}
}
pub struct Ratecontroller {
config: Config,
down_direction: StatsDirection,
owd_baseline: Arc<Mutex<HashMap<IpAddr, ReflectorStats>>>,
owd_recent: Arc<Mutex<HashMap<IpAddr, ReflectorStats>>>,
reflectors_lock: Arc<RwLock<Vec<IpAddr>>>,
fn drain_latest_snapshot(
snapshot_rx: &Receiver<ControlSnapshot>,
latest: &mut Option<ControlSnapshot>,
) {
while let Ok(snapshot) = snapshot_rx.try_recv() {
if latest
.as_ref()
.is_none_or(|current| snapshot.generated_at >= current.generated_at)
{
*latest = Some(snapshot);
}
}
}
fn deltas_from_snapshot(
snapshot: Option<&ControlSnapshot>,
reflectors: &[IpAddr],
now: Instant,
tick_interval: f64,
) -> (Vec<f64>, Vec<f64>) {
let mut down = Vec::new();
let mut up = Vec::new();
let Some(snapshot) = snapshot else {
return (down, up);
};
for reflector in reflectors {
let Some(state) = snapshot.reflectors.get(reflector) else {
continue;
};
if now.duration_since(state.last_receive_at).as_secs_f64() < tick_interval * 2.0 {
let down_delta = state.recent.down - state.baseline.down;
let up_delta = state.recent.up - state.baseline.up;
down.push(down_delta);
up.push(up_delta);
debug!(
"Reflector: {} down_delay: {} up_delay: {}",
reflector, down_delta, up_delta
);
}
}
down.sort_by(|a, b| a.total_cmp(b));
up.sort_by(|a, b| a.total_cmp(b));
(down, up)
}
pub struct Ratecontroller<S: InterfaceStatsProvider, T: TrafficControlBackend> {
settings: Settings,
control_snapshot: Option<ControlSnapshot>,
control_snapshot_rx: Receiver<ControlSnapshot>,
reflectors_lock: ArcRwLock<Vec<IpAddr>>,
reselect_trigger: Sender<bool>,
state_dl: State,
state_ul: State,
up_direction: StatsDirection,
state_dl: State<T::Handle>,
state_ul: State<T::Handle>,
rng: fastrand::Rng,
stats_provider: S,
traffic_control: T,
metrics: MetricsSender,
}
impl Ratecontroller {
impl<S: InterfaceStatsProvider, T: TrafficControlBackend> Ratecontroller<S, T> {
fn new_with_backends(
settings: Settings,
control_snapshot_rx: Receiver<ControlSnapshot>,
reflectors_lock: ArcRwLock<Vec<IpAddr>>,
reselect_trigger: Sender<bool>,
metrics: MetricsSender,
mut stats_provider: S,
mut traffic_control: T,
mut rng: fastrand::Rng,
) -> anyhow::Result<Self> {
let dl_shaper =
traffic_control.find_shaper(settings.network.download_interface.as_str())?;
let dl_safe_rates = generate_initial_speeds(
&mut rng,
settings.network.download_base_kbits,
settings.advanced_settings.speed_hist_size,
);
let ul_shaper = traffic_control.find_shaper(settings.network.upload_interface.as_str())?;
let ul_safe_rates = generate_initial_speeds(
&mut rng,
settings.network.upload_base_kbits,
settings.advanced_settings.speed_hist_size,
);
let (cur_rx, cur_tx) =
get_interface_stats(&mut stats_provider, &settings.network.upload_interface)?;
Ok(Self {
settings,
control_snapshot: None,
control_snapshot_rx,
reflectors_lock,
reselect_trigger,
state_dl: State::new(dl_shaper, cur_rx, dl_safe_rates),
state_ul: State::new(ul_shaper, cur_tx, ul_safe_rates),
rng,
stats_provider,
traffic_control,
metrics,
})
}
fn request_initial_rates(&mut self) -> anyhow::Result<()> {
// Set rates to 60% of base rate to make sure we start with sane baselines.
self.state_dl.current_rate = self.settings.network.download_base_kbits * 0.6;
self.state_ul.current_rate = self.settings.network.upload_base_kbits * 0.6;
self.traffic_control.set_rate(
&self.state_dl.shaper,
self.state_dl.current_rate.round() as u64,
self.settings.advanced_settings.dry_run,
)?;
self.traffic_control.set_rate(
&self.state_ul.shaper,
self.state_ul.current_rate.round() as u64,
self.settings.advanced_settings.dry_run,
)?;
Ok(())
}
fn calculate_rate(&mut self, direction: Direction) -> anyhow::Result<()> {
let (base_rate, delay_ms, min_rate, state) = if direction == Direction::Down {
(
self.config.download_base_kbits,
self.config.download_delay_ms,
self.config.download_min_kbits,
self.settings.network.download_base_kbits,
self.settings.advanced_settings.download_delay_ms,
self.settings.network.download_min_kbits(),
&mut self.state_dl,
)
} else {
(
self.config.upload_base_kbits,
self.config.upload_delay_ms,
self.config.upload_min_kbits,
self.settings.network.upload_base_kbits,
self.settings.advanced_settings.upload_delay_ms,
self.settings.network.upload_min_kbits(),
&mut self.state_ul,
)
};
@@ -155,7 +252,9 @@ impl Ratecontroller {
/ dur.as_secs_f64();
state.load = state.utilisation / state.current_rate;
if state.delta_stat < delay_ms && state.load > self.config.high_load_level {
if state.delta_stat < delay_ms
&& state.load > self.settings.advanced_settings.high_load_level
{
state.safe_rates[state.nrate] = (state.current_rate * state.load).floor();
let max_rate = state
.safe_rates
@@ -166,13 +265,13 @@ impl Ratecontroller {
* (1.0 + 0.1 * (1.0_f64 - state.current_rate / max_rate).max(0.0))
+ (base_rate * 0.03);
state.nrate += 1;
state.nrate %= self.config.speed_hist_size as usize;
state.nrate %= self.settings.advanced_settings.speed_hist_size as usize;
}
if state.delta_stat > delay_ms {
match state
.safe_rates
.get(fastrand::usize(..state.safe_rates.len()))
.get(self.rng.usize(..state.safe_rates.len()))
{
Some(rnd_rate) => {
state.next_rate = rnd_rate.min(0.9 * state.current_rate * state.load);
@@ -192,121 +291,26 @@ impl Ratecontroller {
Ok(())
}
fn update_deltas(&mut self) -> anyhow::Result<()> {
let state_dl = &mut self.state_dl;
let state_ul = &mut self.state_ul;
state_dl.deltas.clear();
state_ul.deltas.clear();
let now_t = Instant::now();
let owd_baseline = self.owd_baseline.lock_anyhow()?;
let owd_recent = self.owd_recent.lock_anyhow()?;
let reflectors = self.reflectors_lock.read_anyhow()?;
for reflector in reflectors.iter() {
// only consider this data if it's less than 2 * tick_duration seconds old
if owd_baseline.contains_key(reflector)
&& owd_recent.contains_key(reflector)
&& now_t
.duration_since(owd_recent[reflector].last_receive_time_s)
.as_secs_f64()
< self.config.tick_interval * 2.0
{
state_dl
.deltas
.push(owd_recent[reflector].down_ewma - owd_baseline[reflector].down_ewma);
state_ul
.deltas
.push(owd_recent[reflector].up_ewma - owd_baseline[reflector].up_ewma);
debug!(
"Reflector: {} down_delay: {} up_delay: {}",
reflector,
state_dl.deltas.last().unwrap(),
state_ul.deltas.last().unwrap()
);
}
}
// sort owd's lowest to highest
state_dl.deltas.sort_by(|a, b| a.total_cmp(b));
state_ul.deltas.sort_by(|a, b| a.total_cmp(b));
if state_dl.deltas.len() < 5 || state_ul.deltas.len() < 5 {
// trigger reselection
warn!("Not enough delta values, triggering reselection");
let _ = self.reselect_trigger.send(true);
}
Ok(())
}
pub fn new(
config: Config,
owd_baseline: Arc<Mutex<HashMap<IpAddr, ReflectorStats>>>,
owd_recent: Arc<Mutex<HashMap<IpAddr, ReflectorStats>>>,
reflectors_lock: Arc<RwLock<Vec<IpAddr>>>,
reselect_trigger: Sender<bool>,
down_direction: StatsDirection,
up_direction: StatsDirection,
metrics: MetricsSender,
) -> anyhow::Result<Self> {
let dl_qdisc = Netlink::qdisc_from_ifname(config.download_interface.as_str())?;
let dl_safe_rates =
generate_initial_speeds(config.download_base_kbits, config.speed_hist_size);
let ul_qdisc = Netlink::qdisc_from_ifname(config.upload_interface.as_str())?;
let ul_safe_rates =
generate_initial_speeds(config.upload_base_kbits, config.speed_hist_size);
let (cur_rx, cur_tx) = get_interface_stats(&config, down_direction, up_direction)?;
Ok(Self {
config,
down_direction,
owd_baseline,
owd_recent,
reflectors_lock,
reselect_trigger,
state_dl: State::new(dl_qdisc, cur_rx, dl_safe_rates),
state_ul: State::new(ul_qdisc, cur_tx, ul_safe_rates),
up_direction,
metrics,
})
}
pub fn run(&mut self) -> anyhow::Result<()> {
let sleep_time = Duration::from_secs_f64(self.config.min_change_interval);
let sleep_time =
Duration::from_secs_f64(self.settings.advanced_settings.min_change_interval);
let mut lastchg_t = Instant::now();
let mut lastdump_t = Instant::now();
// set qdisc rates to 60% of base rate to make sure we start with sane baselines
self.state_dl.current_rate = self.config.download_base_kbits * 0.6;
self.state_ul.current_rate = self.config.upload_base_kbits * 0.6;
Netlink::set_qdisc_rate(
self.state_dl.qdisc,
self.state_dl.current_rate.round() as u64,
self.config.dry_run,
)?;
Netlink::set_qdisc_rate(
self.state_ul.qdisc,
self.state_ul.current_rate.round() as u64,
self.config.dry_run,
)?;
self.request_initial_rates()?;
let mut speed_hist_fd: Option<File> = None;
let mut speed_hist_fd_inner: File;
let mut stats_fd: Option<File> = None;
let mut stats_fd_inner: File;
if !self.config.suppress_statistics {
if !self.settings.output.suppress_statistics {
speed_hist_fd_inner = File::options()
.create(true)
.write(true)
.truncate(true)
.open(self.config.speed_hist_file.as_str())?;
.open(self.settings.output.speed_hist_file.as_str())?;
speed_hist_fd_inner.write_all("time,counter,upspeed,downspeed\n".as_bytes())?;
speed_hist_fd_inner.flush()?;
@@ -317,7 +321,7 @@ impl Ratecontroller {
.create(true)
.write(true)
.truncate(true)
.open(self.config.stats_file.as_str())?;
.open(self.settings.output.stats_file.as_str())?;
stats_fd_inner.write_all(
"times,timens,rxload,txload,deltadelaydown,deltadelayup,dlrate,uprate\n".as_bytes(),
@@ -335,25 +339,16 @@ impl Ratecontroller {
sleep(sleep_time);
let now_t = Instant::now();
if now_t.duration_since(lastchg_t).as_secs_f64() > self.config.min_change_interval {
if now_t.duration_since(lastchg_t).as_secs_f64()
> self.settings.advanced_settings.min_change_interval
{
// if it's been long enough, and the stats indicate needing to change speeds
// change speeds here
(self.state_dl.current_bytes, self.state_ul.current_bytes) =
get_interface_stats(&self.config, self.down_direction, self.up_direction)?;
if self.state_dl.current_bytes == -1 || self.state_ul.current_bytes == -1 {
warn!(
"One or both Netlink stats could not be read. Skipping rate control algorithm"
);
self.metrics.send(Metric::Event {
name: "netlink_read_failure",
reason: "",
reflector: None,
tags: &[],
});
continue;
}
(self.state_dl.current_bytes, self.state_ul.current_bytes) = get_interface_stats(
&mut self.stats_provider,
&self.settings.network.upload_interface,
)?;
self.update_deltas()?;
if self.state_dl.deltas.is_empty() || self.state_ul.deltas.is_empty() {
@@ -364,18 +359,18 @@ impl Ratecontroller {
reflector: None,
tags: &[],
});
self.state_dl.next_rate = self.config.download_min_kbits;
self.state_ul.next_rate = self.config.upload_min_kbits;
self.state_dl.next_rate = self.settings.network.download_min_kbits();
self.state_ul.next_rate = self.settings.network.upload_min_kbits();
Netlink::set_qdisc_rate(
self.state_dl.qdisc,
self.traffic_control.set_rate(
&self.state_dl.shaper,
self.state_dl.next_rate as u64,
self.config.dry_run,
self.settings.advanced_settings.dry_run,
)?;
Netlink::set_qdisc_rate(
self.state_ul.qdisc,
self.traffic_control.set_rate(
&self.state_ul.shaper,
self.state_ul.next_rate as u64,
self.config.dry_run,
self.settings.advanced_settings.dry_run,
)?;
self.state_dl.current_rate = self.state_dl.next_rate;
@@ -390,24 +385,24 @@ impl Ratecontroller {
|| self.state_ul.next_rate != self.state_ul.current_rate
{
info!(
"self.state_ul.next_rate {} self.state_dl.next_rate {}",
self.state_ul.next_rate, self.state_dl.next_rate
"Requesting rates (D/U): {} / {} kbit/s",
self.state_dl.next_rate as u64, self.state_ul.next_rate as u64
);
}
if self.state_dl.next_rate != self.state_dl.current_rate {
Netlink::set_qdisc_rate(
self.state_dl.qdisc,
self.traffic_control.set_rate(
&self.state_dl.shaper,
self.state_dl.next_rate as u64,
self.config.dry_run,
self.settings.advanced_settings.dry_run,
)?;
}
if self.state_ul.next_rate != self.state_ul.current_rate {
Netlink::set_qdisc_rate(
self.state_ul.qdisc,
self.traffic_control.set_rate(
&self.state_ul.shaper,
self.state_ul.next_rate as u64,
self.config.dry_run,
self.settings.advanced_settings.dry_run,
)?;
}
@@ -436,8 +431,8 @@ impl Ratecontroller {
delta_delay_up: self.state_ul.delta_stat,
});
if let Some(ref mut fd) = stats_fd {
if let Err(e) = fd.write_all(
let stats_write_error = stats_fd.as_mut().and_then(|fd| {
fd.write_all(
format!(
"{},{},{},{},{},{},{},{}\n",
stats_time.secs(),
@@ -450,17 +445,20 @@ impl Ratecontroller {
self.state_ul.current_rate as u64
)
.as_bytes(),
) {
)
.err()
});
if let Some(e) = stats_write_error {
warn!("Failed to write statistics: {}", e);
}
}
lastchg_t = now_t;
}
if let Some(ref mut fd) = speed_hist_fd {
if now_t.duration_since(lastdump_t).as_secs_f64() > 300.0 {
for i in 0..self.config.speed_hist_size as usize {
if let Some(ref mut fd) = speed_hist_fd
&& now_t.duration_since(lastdump_t).as_secs_f64() > 300.0
{
for i in 0..self.settings.advanced_settings.speed_hist_size as usize {
let hist_time = Time::new(ClockId::Realtime);
if let Err(e) = fd.write_all(
format!(
@@ -480,5 +478,147 @@ impl Ratecontroller {
}
}
}
fn update_deltas(&mut self) -> anyhow::Result<()> {
drain_latest_snapshot(&self.control_snapshot_rx, &mut self.control_snapshot);
let now_t = Instant::now();
let reflectors = self.reflectors_lock.read_anyhow()?;
let (down_deltas, up_deltas) = deltas_from_snapshot(
self.control_snapshot.as_ref(),
&reflectors,
now_t,
self.settings.advanced_settings.tick_interval,
);
self.state_dl.deltas = down_deltas;
self.state_ul.deltas = up_deltas;
if self.state_dl.deltas.len() < 5 || self.state_ul.deltas.len() < 5 {
// trigger reselection
warn!(
"Not enough delta values (D: {}, U: {}, need 5), triggering reselection",
self.state_dl.deltas.len(),
self.state_ul.deltas.len()
);
let _ = self.reselect_trigger.try_send(true);
}
Ok(())
}
}
impl Ratecontroller<PlatformInterfaceStats, PlatformTrafficControl> {
pub fn new(
settings: Settings,
control_snapshot_rx: Receiver<ControlSnapshot>,
reflectors_lock: ArcRwLock<Vec<IpAddr>>,
reselect_trigger: Sender<bool>,
metrics: MetricsSender,
) -> anyhow::Result<Self> {
Self::new_with_backends(
settings,
control_snapshot_rx,
reflectors_lock,
reselect_trigger,
metrics,
interface_stats_provider(),
traffic_control_backend(),
fastrand::Rng::new(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::baseliner::{EwmaStats, ReflectorState};
use std::collections::HashMap;
#[test]
fn fixed_seed_reproduces_initial_safe_rates() {
let mut first = fastrand::Rng::with_seed(42);
let mut second = fastrand::Rng::with_seed(42);
let first_rates = generate_initial_speeds(&mut first, 100_000.0, 100);
let second_rates = generate_initial_speeds(&mut second, 100_000.0, 100);
assert_eq!(first_rates, second_rates);
}
fn reflector_state(
baseline_down: f64,
baseline_up: f64,
recent_down: f64,
recent_up: f64,
last_receive_at: Instant,
) -> ReflectorState {
ReflectorState {
baseline: EwmaStats {
down: baseline_down,
up: baseline_up,
},
recent: EwmaStats {
down: recent_down,
up: recent_up,
},
last_receive_at,
}
}
fn snapshot(generated_at: Instant, marker: f64) -> ControlSnapshot {
let reflector = "192.0.2.1".parse().unwrap();
ControlSnapshot {
generated_at,
reflectors: HashMap::from([(
reflector,
reflector_state(marker, marker, marker, marker, generated_at),
)]),
}
}
#[test]
fn drains_queued_snapshots_and_keeps_the_newest() {
let start = Instant::now();
let newest_at = start + Duration::from_secs(2);
let (tx, rx) = flume::unbounded();
tx.send(snapshot(start + Duration::from_secs(1), 1.0))
.unwrap();
tx.send(snapshot(newest_at, 2.0)).unwrap();
let mut latest = None;
drain_latest_snapshot(&rx, &mut latest);
let latest = latest.unwrap();
assert!(rx.is_empty());
assert_eq!(latest.generated_at, newest_at);
assert_eq!(
latest.reflectors[&"192.0.2.1".parse().unwrap()].recent.down,
2.0
);
}
#[test]
fn calculates_sorted_deltas_from_active_fresh_snapshot_state() {
let now = Instant::now();
let first: IpAddr = "192.0.2.1".parse().unwrap();
let second: IpAddr = "192.0.2.2".parse().unwrap();
let stale: IpAddr = "192.0.2.3".parse().unwrap();
let inactive: IpAddr = "192.0.2.4".parse().unwrap();
let snapshot = ControlSnapshot {
generated_at: now,
reflectors: HashMap::from([
(first, reflector_state(10.0, 20.0, 14.0, 28.0, now)),
(second, reflector_state(10.0, 20.0, 11.0, 22.0, now)),
(
stale,
reflector_state(10.0, 20.0, 110.0, 220.0, now - Duration::from_secs(3)),
),
(inactive, reflector_state(10.0, 20.0, 60.0, 80.0, now)),
]),
};
let (down, up) = deltas_from_snapshot(Some(&snapshot), &[first, second, stale], now, 1.0);
assert_eq!(down, vec![1.0, 4.0]);
assert_eq!(up, vec![2.0, 8.0]);
}
}
+283 -30
View File
@@ -1,19 +1,112 @@
// SPDX-FileCopyrightText: 2022-Present Charles Corrigan mailto:chas-iot@runegate.org (github @chas-iot)
// SPDX-FileCopyrightText: 2022-Present Daniel Lakeland mailto:dlakelan@street-artists.org (github @dlakelan)
// SPDX-FileCopyrightText: 2022-Present Mark Baker mailto:mark@vpost.net (github @Fail-Safe)
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use crate::SHUTDOWN;
use crate::baseliner::ControlSnapshot;
use crate::metrics::{Metric, MetricsSender};
use crate::util::{MutexExt, RwLockExt};
use crate::{Config, ReflectorStats};
use crate::settings::Settings;
use crate::util::RwLockExt;
use flume::{Receiver, RecvError, RecvTimeoutError, Selector};
use log::{debug, info};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::atomic::Ordering;
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex, RwLock};
use std::sync::{Arc, RwLock};
use std::thread::sleep;
use std::time::Duration;
use std::time::{Duration, Instant};
enum SelectorEvent {
Trigger(Result<bool, RecvError>),
Snapshot(Result<ControlSnapshot, RecvError>),
}
#[derive(Debug, Eq, PartialEq)]
enum WaitOutcome {
Triggered,
DeadlineReached,
ChannelsClosed,
}
fn wait_for_trigger_or_deadline(
trigger_rx: &Receiver<bool>,
snapshot_rx: &Receiver<ControlSnapshot>,
latest_snapshot: &mut Option<ControlSnapshot>,
deadline: Instant,
) -> WaitOutcome {
let mut trigger_connected = true;
let mut snapshot_connected = true;
loop {
if Instant::now() >= deadline {
return WaitOutcome::DeadlineReached;
}
let event = match (trigger_connected, snapshot_connected) {
(true, true) => Selector::new()
.recv(trigger_rx, SelectorEvent::Trigger)
.recv(snapshot_rx, SelectorEvent::Snapshot)
.wait_deadline(deadline)
.ok(),
(true, false) => match trigger_rx.recv_deadline(deadline) {
Ok(trigger) => Some(SelectorEvent::Trigger(Ok(trigger))),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => {
Some(SelectorEvent::Trigger(Err(RecvError::Disconnected)))
}
},
(false, true) => match snapshot_rx.recv_deadline(deadline) {
Ok(snapshot) => Some(SelectorEvent::Snapshot(Ok(snapshot))),
Err(RecvTimeoutError::Timeout) => None,
Err(RecvTimeoutError::Disconnected) => {
Some(SelectorEvent::Snapshot(Err(RecvError::Disconnected)))
}
},
(false, false) => return WaitOutcome::ChannelsClosed,
};
match event {
Some(SelectorEvent::Trigger(Ok(_))) => return WaitOutcome::Triggered,
Some(SelectorEvent::Trigger(Err(_))) => trigger_connected = false,
Some(SelectorEvent::Snapshot(Ok(snapshot))) => *latest_snapshot = Some(snapshot),
Some(SelectorEvent::Snapshot(Err(_))) => snapshot_connected = false,
None => return WaitOutcome::DeadlineReached,
}
}
}
fn receive_snapshots_until(
snapshot_rx: &Receiver<ControlSnapshot>,
latest_snapshot: &mut Option<ControlSnapshot>,
deadline: Instant,
) {
loop {
if Instant::now() >= deadline {
return;
}
match snapshot_rx.recv_deadline(deadline) {
Ok(snapshot) => *latest_snapshot = Some(snapshot),
Err(RecvTimeoutError::Timeout) => return,
Err(RecvTimeoutError::Disconnected) => {
sleep(deadline.saturating_duration_since(Instant::now()));
return;
}
}
}
}
fn recent_rtt_for_peer(snapshot: Option<&ControlSnapshot>, peer: &IpAddr) -> Option<u64> {
snapshot
.and_then(|snapshot| snapshot.reflectors.get(peer))
.map(|state| (state.recent.down + state.recent.up) as u64)
}
pub struct ReflectorSelector {
pub config: Config,
pub owd_recent: Arc<Mutex<HashMap<IpAddr, ReflectorStats>>>,
pub settings: Settings,
pub snapshot_rx: Receiver<ControlSnapshot>,
pub reflector_peers_lock: Arc<RwLock<Vec<IpAddr>>>,
pub reflector_pool: Vec<IpAddr>,
pub trigger_channel: Receiver<bool>,
@@ -24,25 +117,36 @@ impl ReflectorSelector {
pub fn run(&self) -> anyhow::Result<()> {
let mut selector_sleep_time = Duration::new(30, 0);
let mut reselection_count = 0;
let baseline_sleep_time =
Duration::from_secs_f64(self.config.tick_interval * std::f64::consts::PI);
let mut latest_snapshot = None;
let baseline_sleep_time = Duration::from_secs_f64(
self.settings.advanced_settings.tick_interval * std::f64::consts::PI,
);
// Initial wait of several seconds to allow some OWD data to build up
sleep(baseline_sleep_time);
receive_snapshots_until(
&self.snapshot_rx,
&mut latest_snapshot,
Instant::now() + baseline_sleep_time,
);
loop {
if SHUTDOWN.load(Ordering::Relaxed) {
info!("Reflector selector shutting down");
return Ok(());
}
/*
* Selection is triggered either by some other thread triggering it through the channel,
* or it passes the timeout. In any case we don't care about the result of this function,
* so we ignore the result of it.
*/
let triggered = self
.trigger_channel
.recv_timeout(selector_sleep_time)
.is_ok();
let triggered = match wait_for_trigger_or_deadline(
&self.trigger_channel,
&self.snapshot_rx,
&mut latest_snapshot,
Instant::now() + selector_sleep_time,
) {
WaitOutcome::Triggered => true,
WaitOutcome::DeadlineReached => false,
WaitOutcome::ChannelsClosed => {
info!("Reflector selector channels closed, shutting down");
return Ok(());
}
};
reselection_count += 1;
info!("Starting reselection [#{}]", reselection_count);
self.metrics.send(Metric::Event {
@@ -54,7 +158,10 @@ impl ReflectorSelector {
// After 40 reselections, slow down to every 15 minutes
if reselection_count > 40 {
selector_sleep_time = Duration::new(self.config.peer_reselection_time * 60, 0);
selector_sleep_time = Duration::new(
self.settings.advanced_settings.peer_reselection_time * 60,
0,
);
}
let mut next_peers: Vec<IpAddr> = Vec::new();
@@ -62,7 +169,7 @@ impl ReflectorSelector {
// Include all current peers
for reflector in reflectors_peers.iter() {
debug!("Current peer: {}", reflector.to_string());
debug!("Current peer: {}", reflector);
next_peers.push(*reflector);
}
@@ -72,7 +179,7 @@ impl ReflectorSelector {
if next_peers.contains(next_candidate) {
continue;
}
debug!("Next candidate: {}", next_candidate.to_string());
debug!("Next candidate: {}", next_candidate);
next_peers.push(*next_candidate);
}
@@ -86,23 +193,25 @@ impl ReflectorSelector {
debug!("Waiting for candidates to be baselined");
// Wait for several seconds to allow all reflectors to be re-baselined
sleep(baseline_sleep_time);
receive_snapshots_until(
&self.snapshot_rx,
&mut latest_snapshot,
Instant::now() + baseline_sleep_time,
);
// Re-acquire the lock when we wake up again
reflectors_peers = self.reflector_peers_lock.write_anyhow()?;
let mut candidates = Vec::new();
let owd_recent = self.owd_recent.lock_anyhow()?;
for peer in next_peers {
if owd_recent.contains_key(&peer) {
let rtt = (owd_recent[&peer].down_ewma + owd_recent[&peer].up_ewma) as u64;
if let Some(rtt) = recent_rtt_for_peer(latest_snapshot.as_ref(), &peer) {
candidates.push((peer, rtt));
info!("Candidate reflector: {} RTT: {}", peer.to_string(), rtt);
info!("Candidate reflector: {} RTT: {}", peer, rtt);
} else {
info!(
"No data found from candidate reflector: {} - skipping",
peer.to_string()
peer
);
}
}
@@ -111,7 +220,7 @@ impl ReflectorSelector {
candidates.sort_by(|a, b| a.1.cmp(&b.1));
// Now we will just limit the candidates down to 2 * num_reflectors
let mut num_reflectors = self.config.num_reflectors;
let mut num_reflectors = self.settings.advanced_settings.num_reflectors;
let candidate_pool_num = (2 * num_reflectors) as usize;
candidates.truncate(candidate_pool_num);
@@ -146,3 +255,147 @@ impl ReflectorSelector {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::baseliner::{EwmaStats, ReflectorState};
use std::collections::HashMap;
use std::thread;
fn snapshot(generated_at: Instant) -> ControlSnapshot {
ControlSnapshot {
generated_at,
reflectors: HashMap::new(),
}
}
fn snapshot_with_rtt(generated_at: Instant, peer: IpAddr, rtt: f64) -> ControlSnapshot {
let mut reflectors = HashMap::new();
reflectors.insert(
peer,
ReflectorState {
baseline: EwmaStats {
down: rtt / 2.0,
up: rtt / 2.0,
},
recent: EwmaStats {
down: rtt / 2.0,
up: rtt / 2.0,
},
last_receive_at: generated_at,
},
);
ControlSnapshot {
generated_at,
reflectors,
}
}
#[test]
fn wait_retains_the_newest_snapshot() {
let (_trigger_tx, trigger_rx) = flume::unbounded();
let (snapshot_tx, snapshot_rx) = flume::unbounded();
let generated_at = Instant::now();
snapshot_tx.send(snapshot(generated_at)).unwrap();
snapshot_tx
.send(snapshot(generated_at + Duration::from_millis(1)))
.unwrap();
snapshot_tx
.send(snapshot(generated_at + Duration::from_millis(2)))
.unwrap();
let mut latest_snapshot = None;
let result = wait_for_trigger_or_deadline(
&trigger_rx,
&snapshot_rx,
&mut latest_snapshot,
Instant::now() + Duration::from_millis(20),
);
assert_eq!(result, WaitOutcome::DeadlineReached);
assert_eq!(
latest_snapshot.unwrap().generated_at,
generated_at + Duration::from_millis(2)
);
}
#[test]
fn snapshots_do_not_extend_the_deadline() {
let (_trigger_tx, trigger_rx) = flume::unbounded();
let (snapshot_tx, snapshot_rx) = flume::unbounded();
let producer = thread::spawn(move || {
let stop_at = Instant::now() + Duration::from_millis(500);
while Instant::now() < stop_at {
if snapshot_tx.send(snapshot(Instant::now())).is_err() {
break;
}
sleep(Duration::from_millis(1));
}
});
let mut latest_snapshot = None;
let started_at = Instant::now();
let result = wait_for_trigger_or_deadline(
&trigger_rx,
&snapshot_rx,
&mut latest_snapshot,
started_at + Duration::from_millis(30),
);
let elapsed = started_at.elapsed();
drop(snapshot_rx);
producer.join().unwrap();
assert_eq!(result, WaitOutcome::DeadlineReached);
assert!(latest_snapshot.is_some());
assert!(elapsed < Duration::from_millis(250), "elapsed: {elapsed:?}");
}
#[test]
fn trigger_interrupts_the_wait() {
let (trigger_tx, trigger_rx) = flume::unbounded();
let (_snapshot_tx, snapshot_rx) = flume::unbounded();
trigger_tx.send(true).unwrap();
let mut latest_snapshot = None;
let started_at = Instant::now();
let result = wait_for_trigger_or_deadline(
&trigger_rx,
&snapshot_rx,
&mut latest_snapshot,
started_at + Duration::from_secs(1),
);
assert_eq!(result, WaitOutcome::Triggered);
assert!(started_at.elapsed() < Duration::from_millis(250));
}
#[test]
fn baseline_wait_retains_snapshot_for_evaluation() {
let (snapshot_tx, snapshot_rx) = flume::unbounded();
let peer = "192.0.2.1".parse().unwrap();
let generated_at = Instant::now();
snapshot_tx
.send(snapshot_with_rtt(generated_at, peer, 20.0))
.unwrap();
snapshot_tx
.send(snapshot_with_rtt(
generated_at + Duration::from_millis(1),
peer,
42.0,
))
.unwrap();
let mut latest_snapshot = None;
receive_snapshots_until(
&snapshot_rx,
&mut latest_snapshot,
Instant::now() + Duration::from_millis(20),
);
assert_eq!(
recent_rtt_for_peer(latest_snapshot.as_ref(), &peer),
Some(42)
);
}
}
+347
View File
@@ -0,0 +1,347 @@
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// 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};
pub(crate) mod uci_schema;
mod validation;
#[cfg(all(feature = "uci", unix))]
use crate::platform::unix::uci::UciSource;
struct FlexiBool(bool);
impl FromStr for FlexiBool {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "y" | "on" | "enabled" => Ok(FlexiBool(true)),
"0" | "false" | "no" | "n" | "off" | "disabled" => Ok(FlexiBool(false)),
_ => Err(ConfigError::Message(format!(
"invalid boolean string: {}",
s
))),
}
}
}
fn deserialize_flex_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
struct FlexiBoolVisitor;
impl<'de> de::Visitor<'de> for FlexiBoolVisitor {
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")
}
fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(value)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
value
.parse::<FlexiBool>()
.map(|value| value.0)
.map_err(E::custom)
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&value)
}
}
deserializer.deserialize_any(FlexiBoolVisitor)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MeasurementType {
Icmp = 1,
IcmpTimestamps,
}
impl Display for MeasurementType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MeasurementType::Icmp => write!(f, "icmp"),
MeasurementType::IcmpTimestamps => write!(f, "icmp-timestamps"),
}
}
}
impl FromStr for MeasurementType {
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),
_ => Err(ConfigError::Message(format!(
"invalid measurement type: {s}"
))),
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum ObservabilityProtocol {
Udp,
Tcp,
}
impl FromStr for ObservabilityProtocol {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"udp" => Ok(ObservabilityProtocol::Udp),
"tcp" => Ok(ObservabilityProtocol::Tcp),
_ => Err(ConfigError::Message(format!(
"invalid observability protocol: {s}",
))),
}
}
}
#[derive(Debug, Deserialize)]
struct ReflectorRecord {
reflector_ip: IpAddr,
ip_version: u8,
#[allow(dead_code)]
description: String,
}
#[derive(Clone, Debug, Deserialize, UciConfig)]
#[uci(package = "sqm-autorate-rust")]
pub(crate) struct Settings {
#[uci(section)]
pub(crate) network: NetworkSettings,
#[serde(default)]
#[uci(section)]
pub(crate) output: OutputSettings,
#[serde(default)]
#[uci(section)]
pub(crate) observability: ObservabilitySettings,
#[serde(default)]
#[uci(section)]
pub(crate) advanced_settings: AdvancedSettings,
}
impl Settings {
pub(crate) fn load() -> Result<Self, ConfigError> {
let builder = Config::builder();
#[cfg(all(feature = "uci", unix))]
let builder = builder.add_source(UciSource::from_schema::<Self>().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()?;
built_config.try_deserialize()
}
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, IpAddr::V6(_)) => {}
(version, address) => {
bail!("reflector {address} has inconsistent IP version {version}");
}
}
reflectors.push(record.reflector_ip);
}
Ok(reflectors)
}
}
#[derive(Clone, Debug, Deserialize, UciSection)]
pub(crate) struct NetworkSettings {
pub(crate) download_interface: String,
pub(crate) upload_interface: String,
pub(crate) download_base_kbits: f64,
#[serde(default = "default_min_percent")]
pub(crate) download_min_percent: f64,
pub(crate) upload_base_kbits: f64,
#[serde(default = "default_min_percent")]
pub(crate) upload_min_percent: f64,
}
impl NetworkSettings {
pub(crate) fn download_min_kbits(&self) -> f64 {
(self.download_base_kbits * self.download_min_percent / 100.0).floor()
}
pub(crate) fn upload_min_kbits(&self) -> f64 {
(self.upload_base_kbits * self.upload_min_percent / 100.0).floor()
}
}
#[derive(Clone, Debug, Deserialize, UciSection)]
#[serde(default)]
pub(crate) struct OutputSettings {
#[serde(deserialize_with = "deserialize_from_str")]
pub(crate) log_level: Level,
pub(crate) speed_hist_file: String,
pub(crate) stats_file: String,
#[serde(deserialize_with = "deserialize_flex_bool")]
pub(crate) suppress_statistics: bool,
}
impl Default for OutputSettings {
fn default() -> Self {
Self {
log_level: Level::Error,
speed_hist_file: "/tmp/sqm-speedhist.csv".to_owned(),
stats_file: "/tmp/sqm-autorate-rust.csv".to_owned(),
suppress_statistics: false,
}
}
}
#[derive(Clone, Debug, Deserialize, UciSection)]
#[serde(default)]
pub(crate) struct ObservabilitySettings {
#[serde(deserialize_with = "deserialize_flex_bool")]
pub(crate) enabled: bool,
#[serde(deserialize_with = "deserialize_from_str")]
pub(crate) protocol: ObservabilityProtocol,
pub(crate) host: Option<String>,
pub(crate) port: u16,
pub(crate) batch_size: usize,
pub(crate) batch_timeout_ms: u64,
#[serde(deserialize_with = "deserialize_flex_bool")]
pub(crate) export_ping_metrics: bool,
#[serde(deserialize_with = "deserialize_flex_bool")]
pub(crate) export_rate_metrics: bool,
#[serde(deserialize_with = "deserialize_flex_bool")]
pub(crate) export_baseline_metrics: bool,
#[serde(deserialize_with = "deserialize_flex_bool")]
pub(crate) export_events: bool,
pub(crate) host_tag: String,
}
impl Default for ObservabilitySettings {
fn default() -> Self {
Self {
enabled: false,
protocol: ObservabilityProtocol::Udp,
host: None,
port: 8089,
batch_size: 25,
batch_timeout_ms: 100,
export_ping_metrics: false,
export_rate_metrics: true,
export_baseline_metrics: false,
export_events: true,
host_tag: default_host_tag(),
}
}
}
#[derive(Clone, Debug, Deserialize, UciSection)]
#[serde(default)]
pub(crate) struct AdvancedSettings {
pub(crate) download_delay_ms: f64,
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) speed_hist_size: u32,
pub(crate) tick_interval: f64,
pub(crate) upload_delay_ms: f64,
#[serde(deserialize_with = "deserialize_flex_bool")]
pub(crate) dry_run: bool,
}
impl Default for AdvancedSettings {
fn default() -> Self {
Self {
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(),
speed_hist_size: 100,
tick_interval: 0.5,
upload_delay_ms: 15.0,
dry_run: false,
}
}
}
fn default_min_percent() -> f64 {
20.0
}
fn default_host_tag() -> String {
rustix::system::uname()
.nodename()
.to_string_lossy()
.into_owned()
}
fn deserialize_from_str<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: FromStr,
T::Err: Display,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(de::Error::custom)
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct UciOptionMapping {
pub(crate) uci_option: &'static str,
pub(crate) config_field: &'static str,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct UciSectionMapping {
/// UCI section type, corresponding to `@section_type[0]`.
pub(crate) uci_section: &'static str,
/// Top-level config-rs / Serde settings key.
pub(crate) config_section: &'static str,
pub(crate) options: &'static [UciOptionMapping],
}
pub(crate) trait UciSectionSchema {
const UCI_OPTIONS: &'static [UciOptionMapping];
}
pub(crate) trait UciConfigSchema {
const UCI_PACKAGE: &'static str;
const UCI_SECTIONS: &'static [UciSectionMapping];
}
+378
View File
@@ -0,0 +1,378 @@
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
//
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.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 < 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 < 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 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.num_reflectors < 5 {
report.error(
"advanced_settings.num_reflectors",
format!("must be at least 5, got {}", self.num_reflectors,),
);
}
if self.peer_reselection_time == 0 {
report.error(
"advanced_settings.peer_reselection_time",
"must be greater than 0",
);
}
if self.reflector_list_file.trim().is_empty() {
report.error("advanced_settings.reflector_list_file", "must not be empty");
}
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
}
}
+5 -2
View File
@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use rustix::fs::Timespec;
use rustix::thread::ClockId;
use rustix::time::clock_gettime;
use rustix::time::{ClockId, clock_gettime};
pub struct Time {
time_s: u64,
+7 -1
View File
@@ -1,5 +1,11 @@
// SPDX-FileCopyrightText: 2022-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
//
// SPDX-License-Identifier: MPL-2.0
use anyhow::anyhow;
use std::sync::{Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
pub type ArcRwLock<T> = Arc<RwLock<T>>;
pub trait MutexExt<T> {
fn lock_anyhow(&self) -> anyhow::Result<MutexGuard<'_, T>>;