Compare commits

...
112 Commits
Author SHA1 Message Date
Andrei Cravtov 7a8153ecf3 perf(dataplane): reduce TCP hot-path overhead 2026-05-27 12:43:30 +01:00
Andrei Cravtov 6464bed05b perf(dataplane): tune forced TCP batches
Default forced TCP to 65535-byte TUN MTU and use 256 KiB TCP read/write batches so max-size frames do not require multiple TCP reads.
2026-05-27 11:58:56 +01:00
Andrei Cravtov 34a879ff54 fix(dataplane): use one macOS TCP listener 2026-05-26 23:07:31 +01:00
Andrei Cravtov 80e5da948e fix(dataplane): harden forced TCP path
Reject inbound TCP streams unless they match a link-local Babel neighbour, avoid receive-side frame allocation, and split queued vs written TCP counters. Forced TCP defaults to a 9000-byte TUN MTU for Mac throughput sweeps.
2026-05-26 21:54:32 +01:00
Andrei Cravtov 76f69a80a4 Fix macOS TCP dataplane listeners 2026-05-26 20:16:22 +01:00
Andrei Cravtov bd3151a4ff Add forced TCP dataplane transport 2026-05-26 19:41:32 +01:00
Andrei Cravtov 0b7a3ad377 receiver side batching (different socket type) 2026-05-19 22:32:23 +01:00
Andrei Cravtov 3cbb575824 try to cut costs 2026-05-19 21:57:54 +01:00
Andrei Cravtov 2f03858832 en0 en1 infinite cost 2026-05-19 21:11:27 +01:00
Andrei Cravtov b02cf2cbb5 added support for naive scoring 2026-05-19 21:02:17 +01:00
Andrei Cravtov 5a158a51e4 added support for scoring 2026-05-19 20:47:06 +01:00
Andrei Cravtov 5bf8f62f9f added iperf3 2026-05-19 20:05:41 +01:00
Andrei Cravtov af0b6e17d1 remove first interface requirement 2026-05-19 19:54:08 +01:00
Andrei Cravtov 82adc5d9ba it builds 2026-05-19 19:09:24 +01:00
Andrei Cravtov 597475294f no longer need optional build flags (may need to update hash) 2026-05-15 20:42:51 +01:00
Andrei Cravtov 49845f287d Merge branch 'main' into babbler
# Conflicts:
#	Cargo.lock
#	flake.nix
#	python/parts.nix
2026-05-15 20:36:31 +01:00
Andrei Cravtov 58ed37e652 Enable nix uio for PBProbe timestamps 2026-04-24 21:11:18 +01:00
Andrei Cravtov 9173f22e0c Use kernel timestamps for Apple PBProbe receive 2026-04-24 21:10:01 +01:00
Andrei Cravtov d60ef66375 Fix Apple PBProbe recv build 2026-04-24 21:06:09 +01:00
Andrei Cravtov c62d723d4e Use minimal Apple recvmsg_x for PBProbe 2026-04-24 21:05:11 +01:00
Andrei Cravtov 5e3ab72a1c Use connected sendmsg_x for Apple PBProbe 2026-04-24 21:03:10 +01:00
Andrei Cravtov 5492943151 Avoid per-packet PBProbe timeout reset 2026-04-24 20:59:16 +01:00
Andrei Cravtov d62d96770a Batch PBProbe bulk receives 2026-04-24 20:55:45 +01:00
Andrei Cravtov adf490683a Use connected PBProbe sender on Apple 2026-04-24 20:49:52 +01:00
Andrei Cravtov ae1bdeee62 Batch PBProbe bulk sends 2026-04-24 20:41:24 +01:00
Andrei Cravtov a9fcdd0302 Reject incomplete PBProbe bulks 2026-04-24 20:31:25 +01:00
Andrei Cravtov 891d1e125e Simplify babel bootstrap receive 2026-04-24 20:25:06 +01:00
Andrei Cravtov 43e6b36802 Reuse standard timing and iteration helpers 2026-04-24 20:18:38 +01:00
Andrei Cravtov 337f8f83dd Use standard helpers in profiling tools 2026-04-24 20:04:22 +01:00
Andrei Cravtov 4806710001 Log PBProbe server issue rate 2026-04-24 19:52:15 +01:00
Andrei Cravtov a830095af2 Keep PBProbe sender timing nonzero 2026-04-24 19:40:29 +01:00
Andrei Cravtov 5a51779dc0 Inline PBProbe sender timing 2026-04-24 19:37:02 +01:00
Andrei Cravtov 6585ae3209 Report PBProbe sender issue summary 2026-04-24 19:28:49 +01:00
Andrei Cravtov 3205cb02c2 Add PBProbe sender diagnostics 2026-04-24 19:19:31 +01:00
Andrei Cravtov 70440748f3 profiling 2026-04-24 18:52:44 +01:00
Andrei Cravtov 1e01124f82 profiling 2026-04-24 18:36:18 +01:00
Andrei Cravtov da848d9c21 make babblerd tun io nonblocking 2026-04-24 16:39:12 +01:00
Andrei Cravtov 0cfc424f8a optimize babblerd dataplane hot path 2026-04-24 16:27:16 +01:00
Andrei Cravtov b0f508ac74 fix babbler ula prefix 2026-04-23 19:13:16 +01:00
Andrei Cravtov 2e17eb03f7 retry skipped dataplane sockets 2026-04-23 18:41:04 +01:00
Andrei Cravtov 4efc883fad dedup unchanged fib snapshots 2026-04-23 18:13:14 +01:00
Andrei Cravtov 79a5a84bb5 instrument dataplane backpressure 2026-04-23 18:08:06 +01:00
Andrei Cravtov f0140a816d trim dataplane logs for live testing 2026-04-23 16:06:54 +01:00
Andrei Cravtov df2760b6c0 admit dataplane interfaces from babel neighbours 2026-04-23 15:42:58 +01:00
Andrei Cravtov 4aac2c97f3 update babblerd notes after one-hop bringup 2026-04-23 15:19:26 +01:00
Andrei Cravtov e1e4643e37 use tun-rs packet io in dataplane 2026-04-23 15:15:45 +01:00
Andrei Cravtov a6760fa9c0 instrument dataplane hot path 2026-04-23 15:03:41 +01:00
Andrei Cravtov 2d86256699 share tun device with dataplane 2026-04-23 14:58:13 +01:00
Andrei Cravtov ef523966a7 pass first interface to babeld 2026-04-23 14:49:31 +01:00
Andrei Cravtov 0e14989408 add interface allowlist for lab bringup 2026-04-23 14:46:09 +01:00
Andrei Cravtov 9dc1cf78fc enable nix fs support 2026-04-23 14:41:28 +01:00
Andrei Cravtov 1b4d7e6c68 fix tun clone on macos 2026-04-23 14:38:48 +01:00
Andrei Cravtov 3c2e1ee3f9 harden dataplane error handling 2026-04-23 14:19:09 +01:00
Andrei Cravtov 5c3f1dc21d fix more bugs before live test 2026-04-23 14:10:14 +01:00
Andrei Cravtov 6f530e47a6 databplane and fib bugs 2026-04-23 13:42:45 +01:00
Andrei Cravtov ffc7d67816 fix some things 2026-04-22 21:14:34 +01:00
Andrei Cravtov 3bf44983b4 add dataplane 2026-04-22 20:26:50 +01:00
Andrei Cravtov 1c9f0f15ab remove stale code, document shortcuts 2026-04-22 19:48:25 +01:00
Andrei Cravtov 01c603eb91 ensure tunnel device is configured correctly 2026-04-22 18:30:57 +01:00
Andrei Cravtov af872f0693 fix architecture notes 2026-04-22 15:53:02 +01:00
Andrei Cravtov 9b55968141 added dummy client to keep connection alive 2026-04-22 15:21:21 +01:00
Andrei Cravtov 9d0b0aa51a fix bug 2026-04-22 14:07:04 +01:00
Andrei Cravtov 84e8ebc9b8 moved out logic from main 2026-04-22 12:48:03 +01:00
Andrei Cravtov d3558f7aa9 added identity file 2026-04-21 18:07:29 +01:00
Andrei Cravtov 89eca49a7c clean config 2026-04-21 17:28:27 +01:00
Andrei Cravtov 019093f00b abnormal termination elimiated 2026-04-21 14:46:58 +01:00
Andrei Cravtov cf6947f577 moved towards monitor 2026-04-21 14:45:32 +01:00
Andrei Cravtov 87d0e2f3e8 fixed some runtime bugs 2026-04-21 14:31:41 +01:00
Andrei Cravtov c18a95ca97 watch value 2026-04-20 19:14:28 +01:00
Andrei Cravtov a5d2e23d44 reorganized the code to collapse into single runtime.rs concept 2026-04-20 18:59:03 +01:00
Andrei Cravtov 1ea79b1a78 state folder 2026-04-20 18:53:04 +01:00
Andrei Cravtov 3c77d40538 Box<str> 2026-04-20 18:23:42 +01:00
Andrei Cravtov 0c70a725e9 split out into commands and session and process 2026-04-20 18:21:02 +01:00
Andrei Cravtov dda297c268 docs 2026-04-20 17:39:35 +01:00
Andrei Cravtov cb060dc532 docs 2026-04-20 17:15:19 +01:00
Andrei Cravtov daff8d2bb4 moved around some things 2026-04-20 17:12:16 +01:00
Andrei Cravtov 82fe6ef741 moved around some things 2026-04-20 17:03:56 +01:00
Andrei Cravtov 2c3ba858d3 revert back to dump-logic 2026-04-20 15:17:16 +01:00
Andrei Cravtov 731d4a4acb revert back to dump-logic 2026-04-20 15:12:27 +01:00
Andrei Cravtov a5c01b6681 revert back to dump-logic 2026-04-20 15:04:24 +01:00
Andrei Cravtov 9a30017969 revert back to dump-logic 2026-04-20 13:51:29 +01:00
Andrei Cravtov 1a73cfa457 parsing docs 2026-04-20 13:46:40 +01:00
Andrei Cravtov 0766265076 remove boilerplate 2026-04-20 13:30:45 +01:00
Andrei Cravtov 54fab3f9dd add dependencies to remove reinventing the wheels 2026-04-20 13:23:52 +01:00
Andrei Cravtov 099fcdd60a run format + added initial parser 2026-04-20 13:10:04 +01:00
Andrei Cravtov ca49944f80 build new babeld fork 2026-04-20 12:55:01 +01:00
Andrei Cravtov 9083258d2f ripped out the lo-based logic and instead use utun 2026-04-17 20:30:43 +01:00
Andrei Cravtov 1afe56504d test if applications themselves can run this 2026-04-17 17:48:23 +01:00
Andrei Cravtov 4d0d1987fa filter 2026-04-17 15:06:39 +01:00
Andrei Cravtov 271f6755fd loopback only 2026-04-17 12:57:49 +01:00
Andrei Cravtov 04614b734c hot fix 2026-04-16 17:47:56 +01:00
Andrei Cravtov 69b9d2e5d6 whoops 2026-04-15 18:12:33 +01:00
Andrei Cravtov 1f9098cf86 minimal fix 2026-04-15 18:03:16 +01:00
Evan 34a0e35d93 flush old addrs 2026-04-13 16:59:51 +01:00
Evan 98c44406cd just /64s i guess 2026-04-13 16:59:51 +01:00
Evan 03873a916c remove iface flushing 2026-04-13 16:59:51 +01:00
Evan 9b78aac613 correctness 2026-04-13 16:59:51 +01:00
Evan 35afc3c160 add leo's bandwidth test 2026-04-13 16:59:51 +01:00
Evan 80bf99b8ca NO BAD lo 2026-04-13 16:59:51 +01:00
Evan f4a311a9ad trying lo again 2026-04-13 16:59:50 +01:00
Evan 94aebaf246 ok didnt work lets try /112 2026-04-13 16:59:50 +01:00
Evan 732d318d7f lets try just one thing per thing 2026-04-13 16:59:50 +01:00
Evan 342bd995ad cleanup 2026-04-13 16:59:50 +01:00
Evan f8c180c14c move private directories 2026-04-13 16:59:50 +01:00
Evan dd209fc733 error handling 2026-04-13 16:59:50 +01:00
Evan b424e4a9a1 uniform node ids 2026-04-13 16:59:50 +01:00
Evan e1e4b8ffb9 oops 2026-04-13 16:59:50 +01:00
Evan 8d369f6381 handle receiver lagged better 2026-04-13 16:59:50 +01:00
Evan 027b0d5153 more logs 2026-04-13 16:59:50 +01:00
Evan 6e9653a294 clippy 2026-04-13 16:59:50 +01:00
Evan 5c40d47be1 some safety 2026-04-13 16:59:50 +01:00
Evan 73a50e4d44 babble babble 2026-04-13 16:59:50 +01:00
46 changed files with 15047 additions and 1457 deletions

No files matched your search

Generated
+1663 -649
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -1,6 +1,11 @@
[workspace]
resolver = "3"
members = ["rust/networking", "rust/exo_pyo3_bindings", "rust/util"]
members = [
"rust/networking",
"rust/exo_pyo3_bindings",
"rust/util",
"rust/babblerd",
]
[workspace.package]
version = "0.0.1"
Generated
+24 -24
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1775790182,
"narHash": "sha256-pG2RWVQY0Pe+rmmXJx+Jpyi+JcgjWzS18m7fcD1B64Q=",
"lastModified": 1779130139,
"narHash": "sha256-BLrtr42azquO7MdGFU5a7KiMl3YpFlTeIXqy1fT5GlQ=",
"owner": "ipetkov",
"repo": "crane",
"rev": "534982f1c41834b101e381b07b1121a4f065a374",
"rev": "edb38893982a3338972bb4a2ec7ce7c29ba10fd9",
"type": "github"
},
"original": {
@@ -47,11 +47,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1775807984,
"narHash": "sha256-Redoe3D9zGN5I9QPHWL9vfMVQBehY1fKsMiRXQ83X3w=",
"lastModified": 1779185128,
"narHash": "sha256-Kl2bkmwZJD3n2KWDxuIlturZ7emqRK+anpD1LmDwpmY=",
"owner": "nix-community",
"repo": "fenix",
"rev": "fcf90c0c4d368b2ca917a7afa6d08e98a397e5fd",
"rev": "b7bd9323fe26a3b4f4bddbb2c2a1dacabced2f88",
"type": "github"
},
"original": {
@@ -83,11 +83,11 @@
]
},
"locked": {
"lastModified": 1775087534,
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -118,11 +118,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775595990,
"narHash": "sha256-OEf7YqhF9IjJFYZJyuhAypgU+VsRB5lD4DuiMws5Ltc=",
"lastModified": 1779102034,
"narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4e92bbcdb030f3b4782be4751dc08e6b6cb6ccf2",
"rev": "687f05a9184cad4eaf905c48b63649e3a86f5433",
"type": "github"
},
"original": {
@@ -168,11 +168,11 @@
]
},
"locked": {
"lastModified": 1773870109,
"narHash": "sha256-ZoTdqZP03DcdoyxvpFHCAek4bkPUTUPUF3oCCgc3dP4=",
"lastModified": 1776659114,
"narHash": "sha256-qapCOQmR++yZSY43dzrp3wCrkOTLpod+ONtJWBk6iKU=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "b6e74f433b02fa4b8a7965ee24680f4867e2926f",
"rev": "ffaa2161dd5d63e0e94591f86b54fc239660fb2e",
"type": "github"
},
"original": {
@@ -188,11 +188,11 @@
]
},
"locked": {
"lastModified": 1775439158,
"narHash": "sha256-NHY9SJNU019n+8NCabBDtmuzRFeE2gZlYKHowp9bV24=",
"lastModified": 1778901413,
"narHash": "sha256-GSKXTAnFqRAMlZkJrIPcQMYf+lpMr66K3i60mB9STvc=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "fb6b728260f3f32761367e9fd1e1a25b4245bcd0",
"rev": "a228447c3e179d477c1b6246ef3efa8cfe3c469a",
"type": "github"
},
"original": {
@@ -218,11 +218,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1775745684,
"narHash": "sha256-8MbfLwd60FNa8dRFkjE+G3TT/x21G3Rsplm1bMBQUtU=",
"lastModified": 1779074864,
"narHash": "sha256-0M3WqsWmtXmv9Ev/vnFfCHosWvISDwiuuhQ104UO3CI=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "64ddb549bc9a70d011328746fa46a8883f937b6b",
"rev": "cdfe408d4b436e806ff525cb3e67588a6a009ed1",
"type": "github"
},
"original": {
@@ -284,11 +284,11 @@
]
},
"locked": {
"lastModified": 1775706324,
"narHash": "sha256-BTb4sydzX2B5/oNbvCdQFeSbk97xEnbb8bk84CiKCOs=",
"lastModified": 1778664018,
"narHash": "sha256-ogNyNANNLo0SMFevIeUpbTMOL9uUDu/hXvp7JlOYbwQ=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "5707df99097375896a3dda811d492a2fabe63500",
"rev": "b48abe99ef639cd100c224898529370e5d935294",
"type": "github"
},
"original": {
+11 -1
View File
@@ -82,7 +82,7 @@
overlays = [
inputs.nixglhost.overlays.default
(import ./nix/apple-sdk-overlay.nix)
(final: _: {
(final: prev: {
macmon = final.rustPlatform.buildRustPackage {
pname = "macmon";
version = "git";
@@ -94,6 +94,15 @@
};
cargoHash = "sha256-Epj3L+db1flGNK5y6yfSig8piEiXTz15lPo/FNkqlkA=";
};
babeld = final.callPackage ./nix/babeld.nix { };
iperf3 = prev.iperf3.overrideAttrs (_old: {
version = "3.21+local";
src = final.fetchgit {
url = "https://github.com/AndreiCravtov/iperf.git";
fetchSubmodules = true;
sha256 = "sha256-2laL7DrEVZxC7sVieaRXBACzMri7YOpu/yO7sC+t3aI=";
};
});
})
];
};
@@ -132,6 +141,7 @@
packages = {
default = self'.packages.exo;
iperf3 = pkgs.iperf3;
} //
lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
+26
View File
@@ -0,0 +1,26 @@
{ stdenv
, lib
, fetchgit
}:
stdenv.mkDerivation {
pname = "babeld";
version = "1.13.1+local";
# TODO: pin to specific version/revision, or better yet, use a patch file
src = fetchgit {
url = "https://github.com/AndreiCravtov/babeld.git";
fetchSubmodules = true;
sha256 = "sha256-Z4fZNh9ZdWRaTrUxgbXZnDCqvG6m4F/CND3ApyavbLw=";
};
outputs = [
"out"
"man"
];
makeFlags = [
"PREFIX=${placeholder "out"}"
"ETCDIR=${placeholder "out"}/etc"
];
}
-6
View File
@@ -1,6 +0,0 @@
{
"name": "exo",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
Symlink
+1
View File
@@ -0,0 +1 @@
/nix/store/41rd4g2p69a2106qscl3xvkzmh6j7nry-babblerd
+1
View File
@@ -0,0 +1 @@
/pbprobe/*
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "babblerd"
version.workspace = true
edition.workspace = true
[dependencies]
color-eyre = "0.6.5"
clap = { version = "4.5.53", features = ["derive"] }
futures-lite.workspace = true
ipnet = "2.12.0"
nix = { version = "0.31", features = [
"fs",
"signal",
"process",
"user",
"net",
"uio",
] }
netdev = "0.42"
ahash = "0.8.12"
arrayvec = "0.7.6"
crossbeam-channel = "0.5.15"
hashbrown = "0.16.0"
iroh-quinn-udp = { version = "0.8.0", default-features = false, features = [
"fast-apple-datapath",
] }
libc = "0.2"
mio = { version = "1.1.0", features = ["net", "os-ext", "os-poll"] }
n0-watcher = "0.6"
netwatch = "0.16"
rand = "0.10"
route_manager = "0.2.11"
slab = "0.4.11"
socket2 = "0.6.1"
tokio = { workspace = true, features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
tun-rs = "2.8.1" # if you update, it causes transitive dependeny clashes that need patch.crates-io fixes or whatnot, too long to do now :)
# parsing
memchr = "2.8"
winnow = "1.0"
thiserror = "2.0"
macaddr = "1.0"
zerocopy = { version = "0.8.31", features = ["derive"] }
[lints]
workspace = true
+4
View File
@@ -0,0 +1,4 @@
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
babblerd::profiling::standalone::run_from_env()
}
+4
View File
@@ -0,0 +1,4 @@
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
babblerd::profiling::pbprobe::standalone::run_from_env()
}
@@ -0,0 +1,586 @@
# `babblerd` Future Architectural Directions
This file is not a debt list.
Use [shortcuts.md](./shortcuts.md) for concrete shortcuts, footguns, and
implementation compromises that should be cleaned up later.
This file is for directional reasoning:
- what the current architecture is trying to become,
- which major steps are worth doing next,
- and why those steps are ordered the way they are.
It should evolve as the architecture evolves.
## Current Position
`babblerd` is no longer just a thin wrapper around `babeld`.
It now has the beginnings of a real daemon architecture:
- a resident daemon process,
- a resident TUN interface,
- a keepalive-driven daemon core,
- a heavy routing stack that can turn on and off,
- a typed Babel control/runtime layer,
- a derived FIB layer,
- a dedicated dataplane thread wired into the routing stack,
- a persisted node identity,
- and a central config module.
That is enough structure to stop treating the whole project as “just Babel
plumbing”.
For bring-up, the current tree also contains a temporary internal self-client
that connects to the public socket and periodically sends keepalive commands.
That is only a testing scaffold so the routing stack stays on without a real
frontend process yet. It should be removed once a real controller exists.
It is also enough structure to improve the actual dataplane without needing to
perfect every IPC and control-plane detail first.
Stable forwarding is now proven on the four-Mac Thunderbolt lab:
- adjacent overlay traffic works,
- single-hop forwarded UDP works at modest rates,
- steady-state ICMPv6 reachability is green across the full ring,
- small generic TCP application payloads work after convergence,
- and the temporary `enN` link-cost policy successfully keeps steady-state
routes away from `en0`/`en1` and toward the intended lower-numbered direct
Thunderbolt-style links.
The current remaining gap is no longer basic dataplane correctness or forcing
Babel onto the intended route. The route-selection heuristic is now good enough
to expose the next bottleneck: raw dataplane throughput and overload behavior.
The latest lab numbers put the userspace overlay far below the direct physical
UDP baseline:
- direct UDP without the software router: about `11 Gbit/s` observed outside
the overlay,
- direct overlay UDP, `e4 -> e16`, `iperf3 -6 -u -b 0 -t 10`: about
`1.46 Gbit/s` received with negligible loss,
- direct overlay TCP, `e4 -> e16`, `iperf3 -6 -b 0 -t 10`: about
`1.24 Gbit/s` received,
- single-hop overlay UDP, `e2 -> e16`, `iperf3 -6 -u -b 0 -t 10`: server
intervals around `1.11-1.16 Gbit/s` with `12-14%` loss in one run; a later
run sent about `1.32 Gbit/s` and dataplane counters showed about `1.16M`
packets delivered, but the `iperf3` control connection broke before a valid
receiver summary was produced and the overlay path needed a `babblerd`
restart to recover,
- single-hop overlay TCP, `e2 -> e16`, `iperf3 -6 -b 0 -t 10`: about
`1.07 Gbit/s` received.
So the architecture is good enough for continued correctness bring-up, but
serious performance work should now treat packet processing cost, syscalls,
copies, batching, and backpressure as the main suspects.
## The Most Important Architectural Decision
The current codebase is already good enough to serve as the shell around the
first real dataplane.
That means the next major effort should **not** automatically be:
- replacing the line protocol with `zbus`,
- perfecting lease ownership,
- or fully polishing lifecycle semantics.
Those are still desirable, but they are not the blocking step for getting to a
working end-to-end system.
The next big milestone should be:
- make the existing UDP dataplane observable enough to explain overload,
- make the forwarding path robust when the sender exceeds what the router can
currently drain,
- and then reduce per-packet cost enough to move beyond the current
`1-1.5 Gbit/s` overlay ceiling.
In other words: the project has moved from “build the router” to “make the
router fast and predictable under load”.
## Near-Term Goal
The near-term target is now:
> a measurable end-to-end router where:
>
> - the daemon has a stable node identity,
> - the daemon can be kept alive by the frontend,
> - the daemon maintains Babel-derived routing state,
> - the daemon forwards through the UDP overlay between nodes,
> - the route heuristic selects the intended fast links after convergence,
> - and overload is visible through counters rather than guessed from `iperf3`
> alone.
This still does **not** require the final IPC architecture first.
## Recommended Next Phase
### 1. Harden and measure the UDP dataplane
This is the current major feature.
The basic pieces are now in place:
- `fib.rs` derives immutable forwarding snapshots from `BabelState`,
- those snapshots now carry the admitted interface set as well as routes,
- `dataplane.rs` provides a dedicated-thread hot-path module using `mio`,
`socket2`, `crossbeam-channel`, `hashbrown`, `ahash`, `slab`, and
`arrayvec`.
- `routing_stack.rs` now starts the dataplane and publishes coalesced
`FibSnapshot` updates into it.
- dataplane socket ownership is now driven by interfaces that currently have
live Babel neighbours rather than only by currently selected routes, and
retained sockets are refreshed when an `ifname` resolves to a new ifindex.
- socket reconcile is now best-effort under interface churn: transient
resolution/open failures are logged and retried without killing the
dataplane during reconcile.
- unchanged FIB snapshots are now deduplicated in the control plane, so the
dataplane also carries its own lightweight timer-driven reconcile retry for
admitted interfaces that still do not have usable sockets.
- the stable-link packet path is now working on the lab ring for adjacent
one-hop traffic.
- the dataplane now drains ready TUN and UDP fds up to fairness budgets instead
of handling only one packet per readiness event.
- the dataplane logs useful packet/drop counters every few seconds when active:
TUN RX/TX, UDP RX/TX, TUN-to-UDP, forwarded, local-delivered, no-route,
invalid, hop-limit, and UDP/TUN `WouldBlock` drops.
- the UDP receive path now mutates the stack buffer slice directly instead of
allocating a `Vec` per received packet.
- the dataplane compiles each `FibSnapshot` into a local fast route table that
stores direct socket slots, avoiding the old per-packet `FibEntry` clone and
route-ifname-to-socket lookup.
So the next step is no longer “invent or wire the modules”.
It is:
- explain the current `1-1.5 Gbit/s` ceiling against packet counters and host
CPU/syscall behavior,
- make full-blast UDP overload recover cleanly instead of destabilizing the
path,
- compare direct, single-hop, and multi-hop runs with the same counter set,
- and then fill the first obvious protocol gaps such as ICMPv6 error handling.
One caveat that is now proven on the lab Macs: the current macOS receive path
cannot treat "which UDP socket got the packet" as trustworthy interface
attribution. In live tests, packets sent directly over one Thunderbolt
interface are still being received by a different reuseport socket while the
peer scope-id reflects the real physical ingress interface. That means the
current multi-socket receive model is acceptable for basic forwarding bring-up,
but it is not yet a reliable source of receive-side interface truth on macOS.
The likely long-term fix is to move receive-side interface attribution onto
ancillary packet metadata (`IPV6_PKTINFO` / receive-interface data) rather than
inferring it from which socket woke up.
The forked `babeld` used by the Nix build can now start with no managed
interfaces as long as `babblerd` gives it a read-write local control socket.
That means `babblerd` no longer waits for a first interface before spawning
`babeld`; it starts `babeld` immediately and sends `interface <ifname>` commands
later as the watcher discovers eligible links.
For the current four-Mac Thunderbolt lab, the broad macOS `en*` watcher
heuristic has proven too permissive in practice. The dataplane now corrects
that somewhat by only owning sockets on interfaces that Babel has actually
formed neighbour adjacencies on, but the watcher/bootstrap side is still broad.
The env allowlist should remain an escape hatch, not the default topology
description.
The current broad-admission behavior is acceptable for v1 as long as point to
point and multihop forwarding remain reliable, but it does mean that multiple
wired interfaces can become equally admissible at once.
The desired longer-term policy is:
- admit any interface that Babel can actually form a live neighbour adjacency
on, regardless of naming convention,
- keep that broad admissibility for reachability,
- but rank competing links by measured quality rather than treating all wired
links as equivalent.
The forked `babeld` now has the primitive needed for this: the read-write local
socket accepts `neighbour-cost` commands, and neighbour monitor lines report the
active `external-bias-256` and `external-coef-256` fields. Those values are
fixed-point controls in units of `1/256`: the bias is additive, and the
coefficient multiplies the native base cost before the RTT penalty is added.
The measured scoring system is still post-MVP work. It likely requires:
- computing local link metrics such as latency, loss, and possibly sustainable
throughput without generating excessive probe traffic,
- feeding those metrics into `neighbour-cost`,
- and then validating that Babel path selection consistently prefers the better
direct link when multiple usable adjacencies exist.
For MVP, `babblerd` now applies a deliberately simpler policy: for each live
neighbour on an `enN` interface, it sends `neighbour-cost` with
`coef-256 0`. Most `enN` links get `bias-256 N * 100 * 256`, making Babel
treat the native base cost as an absolute synthetic interface-index cost of
roughly `N * 100`, so lower-numbered links such as `en2`, `en3`, and `en4`
win over high-numbered links such as `en18`. `en0` and `en1` are temporarily
deprioritized with the maximum finite `bias-256` value so shared low-index
networks do not dominate Thunderbolt-style links during throughput smoke tests.
This is not a robust scoring model; it is a temporary selection heuristic so
raw throughput work can proceed on the intended fast links.
The current sustained-throughput investigation is therefore focused on the
dataplane hot path and overload behavior:
- direct overlay UDP tops out around `1.46 Gbit/s` in the latest test, far
below the `11 Gbit/s` direct physical UDP baseline,
- single-hop overlay UDP can receive around `1.1 Gbit/s` during full-blast
`-b 0` tests; a later run sent about `1.32 Gbit/s` and delivered roughly
`1.16M` packets at the receiver according to dataplane counters, but the
`iperf3` control connection broke before a receiver summary was produced and
the overlay path needed a restart to recover,
- full-blast UDP should be treated as a stress/failure test until the
backpressure story is better,
- route selection is still important during convergence, but after the mesh
settles the `enN` policy is no longer the main explanation for the throughput
gap.
Be precise about "control traffic" during these tests. Babel's own protocol
packets should remain link-local traffic on the direct physical `en*`
interfaces that `babblerd` explicitly gives to `babeld`; the TUN/overlay
interface is not a Babel interface. The overlay does carry traffic addressed to
node ULAs, including `iperf3` payload, the `iperf3` TCP control/session
connection, and `ping6` to peer ULAs. Saturating the overlay can still disturb
Babel indirectly through shared physical NIC queues, socket buffers, and CPU
scheduling, but not because Babel packets are routed through the userspace
overlay.
That distinction matters for the next diagnosis step. Protecting or separating
control traffic may make tests less fragile and may avoid `iperf3` control
connection failures, but it does not by itself close the `7-8x` dataplane
throughput gap. When a full-load run wedges the path, capture raw Babel state,
the derived `BabelState`, the dataplane FIB/socket map, dataplane counters, and
host route state before concluding whether the failure is route churn, overlay
queue exhaustion, or application-control failure.
At small inner MTUs, approaching `11 Gbit/s` is a packet-rate problem. Ignoring
Ethernet/IP/UDP overhead, the per-packet budget is:
```text
packet_budget_seconds = dataplane_packet_bytes * 8 / target_bits_per_second
```
For `11 Gbit/s`:
- `1452` byte packets, the UDP default TUN MTU: about `947 kpps`, or
`1.06 us/packet`.
- `65535` byte packets, the forced-TCP default TUN MTU: about `21.0 kpps`, or
`47.7 us/packet`.
- `1500` byte packets: about `917 kpps`, or `1.09 us/packet`.
- `1200` byte packets: about `1.15 Mpps`, or `873 ns/packet`.
- `9000` byte jumbo packets: about `153 kpps`, or `6.55 us/packet`.
- `64` byte minimum-size packets: about `21.5 Mpps`, or `46.5 ns/packet`.
So for MTU-sized `iperf3` traffic this is not a "few nanoseconds per packet"
target, but it is roughly a one-microsecond total budget per packet. That
budget has to cover all user/kernel crossings, copies, route lookup, hop-limit
mutation on forwarded packets, UDP send/receive, TUN read/write, and scheduler
overhead. The latest direct overlay result of `1.46 Gbit/s` at `1452` bytes
corresponds to roughly `126 kpps`, or about `8 us/packet`, so getting near
`11 Gbit/s` means shrinking per-packet cost by around `7-8x` or reducing packet
rate with larger packets/aggregation.
Likely optimization directions, in priority order:
- keep improving counters and expose them over the public state surface, so
tests can distinguish no-route, UDP send backpressure, TUN reinjection
backpressure, invalid packets, forwarding, and local delivery without log
scraping;
- add recovery/backpressure policy for overload rather than just
drop-on-`WouldBlock`;
- keep expanding OS packet batching where the target platform allows it. The
dataplane now receives through `iroh-quinn-udp`, which maps to
`recvmsg_x`/`sendmsg_x` on Apple fast builds and `recvmmsg` on Linux-like
Unix. Transmit still sends one packet at a time from the forwarding loop, so
real output batching remains future work;
- consider overlay aggregation, where one outer UDP datagram carries several
inner packets, to amortize syscall and UDP/IP overhead;
- explore jumbo MTUs on the Thunderbolt links, because `9000` byte packets
reduce the `11 Gbit/s` packet rate from about `947 kpps` to about `153 kpps`;
- consider multi-core dataplane sharding once single-thread costs are
measured, because one dedicated thread is a likely ceiling for this design;
- treat kernel-bypass or moving more forwarding into the kernel as a separate
architecture track if `11 Gbit/s` at standard MTU is a hard requirement on
macOS.
Before jumbo frames or overlay aggregation, the current per-packet cost audit
points at these near-term bottlenecks:
- A transit packet still implies one UDP receive syscall and one UDP send
syscall in the forwarding process. At `11 Gbit/s` and `1452` byte packets,
that is roughly `947 kpps`, or nearly `1.9M` UDP syscalls/sec on the transit
node before counting TUN work on endpoints. That alone makes a full `7-8x`
improvement unlikely from ordinary Rust-level cleanup.
- The UDP ingress path was still cloning `socket.ifname` for every received
packet just to support logging/error context. Because `Box<str>::clone()`
allocates, that is an avoidable heap allocation per overlay packet.
- UDP ingress previously used `recv_from` even though the peer address is not
needed for forwarding. Decoding the source address is useful for debugging
but should not be mandatory hot-path work.
- TUN and UDP packet buffers were stack-created as zeroed arrays for each
packet. Reusing worker-owned buffers avoids repeated stack initialization and
keeps the packet loop closer to "syscall, parse, lookup, syscall".
- The send path still builds a `SocketAddrV6` and emits one `iroh-quinn-udp`
transmit per packet. A future connected per-neighbour output-socket model
could remove that address construction and let the kernel cache more route
state.
- The dataplane now uses `iroh-quinn-udp` with the Apple fast datapath, which
exposes `sendmsg_x`/`recvmsg_x` batching. This is not a QUIC routing change;
the useful part is Quinn's UDP socket layer. The current patch batches
receive calls and routes transmit through the same abstraction, but still
emits one transmit call per forwarded packet. The next useful version should
group same-peer/same-size packets into a single `Transmit` with
`segment_size` set.
- Any output batching must be opportunistic, not latency-gating. A single ready
packet must still be sent immediately; batching should flush at the end of a
poll/drain slice or when the next packet targets a different peer/size, never
wait for a full batch.
- The tree now also has an opt-in TCP neighbour transport for Mac Thunderbolt
experiments: `BABBLER_ROUTER_TRANSPORT=tcp`, `--router-transport tcp`, or
`--force-tcp`. UDP remains the default. TCP mode opens scoped link-local TCP
streams to next-hop neighbours, frames inner IPv6 packets with a `u16`
big-endian length, batches framed packets in bounded per-peer write buffers,
and flushes partial batches at drain/poll boundaries. This is intended to
test whether macOS Thunderbolt TCP can expose the higher native TCP path while
avoiding one syscall per inner packet.
- macOS TCP mode keeps one wildcard IPv6 listener per daemon, not one
`IPV6_BOUND_IF` listener per admitted interface. The lab showed
per-interface-bound TCP listeners can stall Thunderbolt handshakes in
`SYN_RCVD`; outbound streams are still scoped to the Babel-selected
interface. Inbound streams are accepted only from link-local peers that match
a live Babel neighbour on the accepted interface/scope.
- TCP mode now defaults the TUN MTU to `65535` to cut packet rate through
macOS `utun` on the Thunderbolt fast path; `BABBLER_TUN_MTU=<mtu>` or
`--tun-mtu <mtu>` can be used for smaller/larger sweeps. All forced-TCP peers
in one test mesh must use the same value, because receivers reject TCP frames
larger than their local TUN MTU. UDP mode still defaults to the
physical-MTU-derived `1452`.
- TCP mode uses `256 KiB` TCP read buffers and `256 KiB` opportunistic write
batch targets by default. Sweep write targets dynamically with
`BABBLER_TCP_BATCH_TARGET_BYTES=<bytes>`; useful first values are `512 KiB`,
`1 MiB`, and `2 MiB`.
- TCP socket send/receive buffers default to `4 MiB`. Sweep them with
`BABBLER_TCP_SOCKET_BUFFER_BYTES=<bytes>`; useful first values are `8 MiB`,
`16 MiB`, and `32 MiB`.
- TCP receive reads directly into the frame decoder buffer, avoiding the old
`tcp_read_buf` copy. Stream readiness is reregistered only when write interest
changes, so a busy stream should not do one `kevent` update per queued packet.
- TCP mode changes overload behavior. Instead of UDP drops on send backpressure,
it can accumulate bounded per-stream pending bytes and then drop once that
bound is reached. Its counters must be watched separately:
`tcp_tx_batches`, `tcp_tx_bytes`, `tcp_queued_packets`,
`tcp_written_frames`, `tcp_rx_batches`, `tcp_rx_bytes`, `tcp_rx_frames`,
`tcp_reregisters`, `tcp_blocked_writes`, `tcp_queue_drops`,
`tcp_frame_errors`, `tcp_stream_errors`, and TUN packet-size buckets.
The first low-risk cleanup pass has now landed: UDP ingress no longer clones
`ifname`, packet buffers are worker-owned instead of stack-created for every
packet, and UDP I/O goes through `iroh-quinn-udp` so the OS-specific fast path
is selected by the crate. These are worth doing, but they should be expected to
remove avoidable overhead rather than close the full `7-8x` gap by themselves.
The current tree now owns the kernel route that steers overlay traffic into the
resident TUN interface:
- the local node `/128` address is installed on the TUN device,
- `EXO_ULA_PREFIX -> tunX` is added when the routing stack turns on,
- that prefix route is removed when the routing stack turns off,
- and `babeld` kernel installs remain disabled.
That means local application traffic can now be steered into the overlay once
the UDP dataplane is active.
The current MVP can stay simple:
- one UDP datagram carries exactly one inner IPv6 packet,
- no custom framing,
- no batching,
- no crypto,
- no relays,
- no multiplexed control/data protocol.
The dataplane currently does this basic loop:
- reads packets from TUN,
- classifies local-delivery vs forwarding,
- looks up next-hop information from a derived forwarding view,
- sends encapsulated packets to direct neighbors over UDP,
- receives UDP packets from neighbors,
- decapsulates them,
- either injects them locally into TUN or forwards them onward.
This gives the project a real “V” and “M” to go with the current daemon/control
shell.
The current tree now hardcodes:
- physical link MTU assumption: `1500`
- outer overhead assumption: `40 bytes IPv6 + 8 bytes UDP`
- UDP TUN MTU default: `1452`
- forced-TCP TUN MTU default: `65535`, with `BABBLER_TUN_MTU`/`--tun-mtu`
override
That is acceptable for bring-up, but it is still only a temporary model.
The future direction should be:
- route-aware MTU derivation,
- PMTUD-aware behavior,
- and better support for environments where hop-to-hop links can use jumbo
frames without exposing that complexity to user traffic.
### 2. Keep the derived forwarding table separate from `BabelState`
`BabelState` should remain a mirror of what `babeld` says.
The current code now reflects that direction:
- `BabelState` is still the protocol mirror,
- `FibSnapshot` is the dataplane view.
The derived forwarding layer should keep moving toward a table that:
- is keyed by destination prefix or node address,
- only keeps the routes the dataplane should actually use,
- captures next hop / outgoing interface / any other forwarding metadata,
- and is cheap for the dataplane to consult.
This avoids mixing:
- “what Babel currently knows”
- with
- “what the UDP router should do with packets”.
### 3. Add a stronger public state/readiness model
The current `ServiceState` is useful, but it is only lifecycle state:
- `Off`
- `Starting`
- `On`
- `Stopping`
That is not the same thing as routing readiness.
Now that the dataplane exists, a separate readiness/status view should exist too.
For example, the frontend may want to distinguish:
- daemon is idle,
- daemon is starting,
- Babel is running but no eligible interfaces exist,
- interfaces exist but no neighbors are usable,
- forwarding is nominal,
- forwarding is degraded.
That should be modeled separately from `ServiceState`, not by making
`ServiceState::On` carry too much meaning.
## What Can Wait Until After Throughput Bring-Up
These are still desirable, but they do not need to block the current
throughput/backpressure work:
### `zbus` / D-Bus-style IPC
This is still the likely long-term direction.
But the current line protocol is good enough for:
- `keepalive <ttl_ms>`
- `get-state`
while the dataplane is being measured and tuned.
So `zbus` should remain a planned improvement, not the immediate blocker.
### Per-client leases
The daemon should eventually track leases per client/connection rather than via
a single global keepalive deadline.
That is a real architectural improvement, but it is control-plane polish rather
than dataplane unblocker.
It can happen after the current router path is faster and better characterized.
### Structured diagnostics/debug output
Right now diagnostics are tracing-only.
That is acceptable for development while the dataplane is being characterized.
A configurable debug stream or structured diagnostics feed should be added
later, preferably once the public IPC shape is stabilized.
## The MVP Dataplane Should Stay Intentionally Small
The MVP version should avoid solving every future overlay concern.
It should **not** attempt to solve:
- encryption,
- authentication,
- path quality metrics beyond what Babel already provides,
- relay protocols,
- or multi-transport negotiation.
Batching and packet aggregation are now valid throughput experiments, but they
should be evaluated as dataplane optimizations rather than bundled with
unrelated control-plane redesign.
The current version has proven the simplest useful thing:
- stable node addresses,
- UDP transport between neighbors,
- Babel-driven next-hop selection,
- TUN injection/extraction,
- packet forwarding that actually works end-to-end.
The rest can be improved incrementally.
## Architectural Path After the MVP Dataplane Works
Now that the basic dataplane exists and works, the likely next path is:
1. Expose dataplane counters and route/FIB state through a better public status
surface.
2. Make overload/backpressure behavior recoverable and measurable.
3. Benchmark batching, aggregation, jumbo MTUs, and eventually multi-core
dataplane options.
4. Replace the temporary `enN` link policy with measured link-quality scoring.
5. Replace the ad-hoc control socket with `zbus`.
6. Replace the single keepalive deadline with per-client leases.
7. Tighten interface admission beyond the current broad heuristic.
8. Pin and explicitly invoke the exact forked `babeld`.
9. Harden node-id file mode checks and other local security edges.
10. Revisit diagnostics streaming.
11. Revisit platform abstractions around TUN / transport / forwarding.
That ordering is intentional:
- keep the router measurable while improving throughput,
- then harden and refine the daemon architecture around it.
## Guiding Principle
The project should prefer:
- a coherent working router with a few acknowledged shortcuts
over:
- a beautifully abstract control plane that still does not move packets.
That does **not** mean ignoring architecture.
It means using the current architecture as a platform for the next real
capability, rather than polishing the control shell ahead of the dataplane's
current throughput and overload problems.
+590
View File
@@ -0,0 +1,590 @@
# babblerd Handoff
This is the current handoff for a new session picking up `babblerd` work.
## Repo / Branch / State
- Repo: `/home/royalguard/Desktop/exo-all/networking-related/exo-babbler`
- Branch: `babbler`
- Current handoff tracks the forced-TCP tuning work on branch `babbler`.
- Recent relevant commits:
- `0b7a3ad3` wires dataplane UDP receive/send through `iroh-quinn-udp`
while keeping `mio` readiness; receive-side batching is in, true transmit
batching is not
- `3cbb5758` removes several avoidable hot-path costs and adds dataplane
counter coverage
- `2f038588` deprioritizes `en0` and `en1` with maximum finite neighbour cost
- `b02cf2cb` adds temporary `enN -> N * 100` link scoring
- `5a158a51` adds Babel neighbour-cost parsing/command support
- `5bf8f62f` added iperf3
- `af0b6e17` remove first interface requirement
- `82adc5d9` it builds
- `59747529` no longer need optional build flags
- earlier dataplane bring-up commits remain relevant, but the local repo has
since moved to `networking-related/exo-babbler`
- Do not trust this file for working-tree cleanliness; run `git status --short`.
## Handoff To Next Agent
The committed baseline before the forced-TCP work was `0b7a3ad3`. Do not infer
working-tree cleanliness from this file; run `git status --short`.
What is implemented:
- `src/dataplane.rs` keeps `mio::net::UdpSocket` for readiness polling.
- Each dataplane interface socket also owns an `iroh_quinn_udp::UdpSocketState`.
- UDP receive now uses `UdpSocketState::recv`, so Apple fast builds can use
`recvmsg_x` and Linux-like Unix can use `recvmmsg` through the crate.
- UDP send now goes through `UdpSocketState::try_send`, but still one transmit
call per forwarded packet.
- Receive batching handles `RecvMeta::stride`, so GRO-style buffers containing
multiple datagrams are split back into inner packets.
- Regression test:
`dataplane::tests::udp_batch_recv_returns_single_datagram_without_full_batch`
proves a single datagram returns immediately as a one-packet batch.
- The current working tree adds an opt-in TCP neighbour transport:
`--force-tcp`, `--router-transport tcp`, or `BABBLER_ROUTER_TRANSPORT=tcp`.
UDP remains the default transport.
- TCP mode opens scoped link-local TCP streams to next-hop neighbours, frames
inner IPv6 packets with a `u16` big-endian length, batches framed packets into
bounded per-peer write buffers, and flushes partial batches at drain/poll
boundaries or when the batch reaches the target size.
- On macOS, TCP mode keeps one wildcard IPv6 listener per daemon and separate
no-fd slots for admitted interfaces. Lab testing found per-interface-bound
TCP listeners could leave e4/e16 Thunderbolt handshakes stuck in `SYN_RCVD`;
outbound TCP streams are still scoped to the Babel-selected interface.
Accepted streams are admitted only when the peer is a link-local Babel
neighbour on the accepted scope/interface.
- TCP mode is intended as an experimental Mac Thunderbolt fast path to reduce
one-syscall-per-packet overhead. It is not the default mesh transport.
- TCP mode uses a jumbo `65535` byte TUN MTU by default to reduce userspace TUN
packet rate on the Mac Thunderbolt fast path. Override with `--tun-mtu <mtu>`
or `BABBLER_TUN_MTU=<mtu>` when sweeping Mac `utun` limits. All forced-TCP
peers in a test mesh must use the same TUN MTU; a receiver rejects TCP frames
larger than its local MTU. UDP mode keeps the old `1452` byte default.
- TCP mode uses `256 KiB` TCP read buffers and `256 KiB` opportunistic write
batch targets by default. Override the write target with
`BABBLER_TCP_BATCH_TARGET_BYTES=<bytes>` when sweeping `512 KiB`, `1 MiB`, or
`2 MiB` batches. Partial batches still flush at drain/poll boundaries.
- TCP socket send/receive buffers default to `4 MiB`; override with
`BABBLER_TCP_SOCKET_BUFFER_BYTES=<bytes>` for matrix runs such as `8 MiB`,
`16 MiB`, or `32 MiB`.
- TCP receive now reads directly into the frame decoder buffer, and stream
readiness is reregistered only on `READABLE` / `READABLE|WRITABLE` changes.
What is not implemented:
- No true UDP output batching yet.
- No UDP output queue, aggregation, or waiting-to-fill behavior.
- No connected per-neighbour output sockets yet.
- Full-load remote tests have not yet been recorded for TCP mode in this
handoff.
If implementing actual UDP transmit batching next:
1. Add a worker-owned `TxBatch` scratch buffer.
2. Append only packets with the same output socket, next-hop peer, and packet
length.
3. Flush on peer change, packet-size change, full batch, end of TUN/UDP drain
slice, poll-loop boundary, snapshot/reconcile, stop, or send error.
4. Flush via one `Transmit { contents: batch_bytes, segment_size: Some(packet_len), ... }`.
5. Never wait for a full batch. Batching is syscall amortization inside an
already-ready drain turn, not a latency queue.
6. Add tests for single-packet flush, peer-change flush, size-change flush, and
full-batch flush.
Docs are part of the fix. Any future code change should update this handoff and
the relevant architecture/lab notes in the same patch, especially when it
changes what is implemented versus future work.
## Core Conclusion
The project is past the “is the overlay architecture wrong?” phase.
The current architecture is the right one:
- `babeld` is control plane only
- Babel kernel installs are disabled
- local mesh traffic is steered into a resident TUN
- userspace dataplane forwards one inner IPv6 packet per UDP datagram hop-by-hop over neighbour link-locals
So the main remaining work is now:
- dataplane throughput and overload behavior
- making full-blast UDP recover cleanly
- restart/convergence robustness around transient route choices
- eventually replacing the temporary `enN` link scoring with measured link
quality
## Why The Old Approach Failed
The original macOS idea was effectively:
- let `babeld` install routes
- try to make kernel source selection behave
That did not work cleanly for this use case:
- no usable IPv6 pref-src route install path on macOS/BSD for this design
- no native source-specific IPv6 routing model that solves the app behavior wanted here
- putting ULAs on `lo0` or `utun` did not reliably fix source selection
- app-aware binding alone was not enough in practice
That is why the design pivoted to the userspace overlay dataplane.
## Files To Read First
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/dataplane.rs`
- `src/fib.rs`
- `src/tun.rs`
- `src/babel/runtime.rs`
- `src/route_ctl.rs`
- `lab_topology_reference.md`
- `shortcuts.md`
- `future_architectural_directions.md`
## Current Intended Architecture
Model:
- one stable node `/128` on TUN
- `EXO_ULA_PREFIX -> tunX` installed by `babblerd`
- `babeld` kernel installs are disabled / ignored
- `BabelState` mirrors `babeld`
- `FibSnapshot` is a reduced immutable dataplane view
- control plane stays on Tokio
- dataplane is a dedicated thread
- one UDP datagram carries exactly one inner IPv6 packet
- no custom framing yet
- outer IPv6 destination is neighbour link-local
- outer UDP port is `router_udp_port`
This is the v1 forwarding model:
- exact-match `/128` host routes only
- interface identity in FIB is `ifname`
- dataplane owns sockets from admitted interface set
- admitted dataplane interfaces come from live Babel neighbours
## Important Design Decisions Already Landed
- typed Babel parsing/state model, not raw string handling
- monitor-driven Babel runtime, not periodic dump polling
- persistent node identity across restarts
- explicit daemon lifecycle: `Off | Starting | On | Stopping`
- resident TUN lifetime, separate heavy routing stack
- dataplane thread + immutable FIB snapshot swaps
- socket ownership from admitted interface set, not only current route set
- same-name/new-ifindex socket refresh handled
- timer-driven socket reconcile retry in dataplane, so deduped unchanged FIB snapshots do not suppress retries forever
- dataplane exit supervision back into routing stack / daemon
- macOS dataplane uses `tun-rs` packet I/O (`SyncDevice::recv/send`), not raw fd reads/writes
## Important Forked `babeld` Changes
The Nix build now uses a forked `babeld` from
`/home/royalguard/Desktop/exo-all/networking-related/babeld`, packaged as
`1.13.1+local`.
Recent fork behavior that matters to `babblerd`:
- `babeld` can start with no managed interfaces when a read-write local control
socket exists.
- `babblerd` now spawns `babeld` immediately and adds interfaces later with
local-socket `interface <ifname>` commands from the watcher.
- `kernel-install false` is still used so `babeld` performs route selection and
reports installed routes without touching kernel routes.
- `neighbour-cost <ifname> <link-local-neighbour> bias-256 <bias> coef-256 <coef>`
is available for external link-cost steering.
- `bias-256` is a signed fixed-point additive value in units of `1/256`.
`256` adds one Babel cost unit and `-256` subtracts one.
- `coef-256` is an unsigned fixed-point multiplier in units of `1/256`.
`256` is neutral, `128` halves the native base cost, and `0` ignores the
native base cost while still adding the RTT penalty.
- `dump`/`monitor` neighbour lines now include `external-bias-256` and
`external-coef-256` before `cost`.
Automatic measured link scoring is not part of the MVP. The current temporary
policy is a simple Mac heuristic: for most `enN` links, set an absolute
synthetic base cost around `N * 100` with `coef-256 0` and
`bias-256 N * 100 * 256`, so lower-numbered Thunderbolt-style interfaces are
preferred over high-numbered interfaces such as `en18`. `en0` and `en1` are
temporarily assigned the maximum finite `bias-256` value (`16776704`, yielding
cost `65534` with `coef-256 0`) so shared low-index networks do not dominate
the direct-link smoke tests. This is intentionally a temporary selection aid so
raw throughput work can assume the good direct links are chosen after
convergence. Immediately after a restart, Babel may still transiently install a
bad high-cost route until better neighbour state arrives.
## Very Important Fix After Earlier Handovers
The previously-deployed node addresses were wrong.
There was a real bug in `EXO_ULA_PREFIX` construction:
- intended prefix: `fde0:20c6:1fa7:ffff::/64`
- broken runtime prefix had become: `20c6:1fa7:ffff:0::/64`
Cause:
- `config.rs` used a `u128` left-shift construction that dropped the high `fde0` bits
Fix:
- commit `b0f508ac` changed the prefix constant to explicit hextets and added a regression test
Live verification after redeploy:
- `e4 utun5`: `fde0:20c6:1fa7:ffff:cc78:aec2:d64e:f125/128`
- `e2 utun5`: `fde0:20c6:1fa7:ffff:aeb:e53a:cb17:aa42/128`
- `e11 utun5`: `fde0:20c6:1fa7:ffff:34a:26dd:46ff:1a3f/128`
- `e16 utun5`: `fde0:20c6:1fa7:ffff:7c5d:5e2d:54df:e665/128`
So any older notes mentioning the truncated non-ULA prefix are stale.
## Current Dataplane Behavior
In `src/dataplane.rs`:
- TUN ingress:
- read inner IPv6 packet
- parse destination
- drop self-directed
- FIB lookup
- send raw inner packet as UDP payload to neighbour
- UDP ingress:
- receive UDP payload
- payload is raw inner IPv6 packet
- if destination local, inject into TUN
- else decrement inner hop limit and forward
Fast-path traits:
- dedicated OS thread
- `mio` polling
- `socket2` UDP sockets
- immutable FIB snapshot swaps over `crossbeam-channel`
- no lock on packet lookup path
- ready TUN/UDP fds are drained up to fairness budgets
- UDP receive uses a stack buffer slice directly, not `to_vec()`
- each `FibSnapshot` is compiled into dataplane-local fast routes that include
direct socket slots, so packets no longer clone `FibEntry` or do an ifname
lookup to find the output socket
- dataplane counters are logged periodically when active: TUN RX/TX, UDP RX/TX,
TUN-to-UDP, forwarded, local-delivered, no-route, invalid, hop-limit,
UDP/TUN `WouldBlock` drops, TCP batch/write/read counters, TCP reregisters,
and TUN packet-size buckets
## What Works
These things are now real:
- one-hop two-node `ping6`
- adjacent dataplane path
- small low-rate UDP matrix
- single-hop forwarded UDP at `100M` with no loss in the latest smoke test
- encapsulation / decapsulation itself
- basic generic TCP correctness after convergence
- steady-state route choice avoiding `en0`/`en1` after the temporary cost policy
has converged
- full-bandwidth direct overlay tests that show a repeatable `1-1.5 Gbit/s`
dataplane ceiling rather than a basic correctness failure
Latest route examples after convergence:
- `e2 -> e11`: direct on `en3`, metric `300`
- `e2 -> e16`: single-hop via `e4` on `en2`, metric `400`
- `e4 -> e16`: direct on `en2`, metric `200`
- `e16 -> e2`: single-hop via `e11` on `en2`, metric `400`
## What Is Still Broken
The main remaining live problem is dataplane throughput and overload behavior.
Observed latest performance shape:
- direct physical UDP without the software router is about `11 Gbit/s`
according to the latest external baseline,
- direct overlay UDP is about `1.46 Gbit/s` received,
- direct overlay TCP is about `1.24 Gbit/s` received,
- single-hop overlay TCP is about `1.07-1.08 Gbit/s` received,
- single-hop overlay UDP at `-b 0` receives around `1.11-1.16 Gbit/s` during
one run and loses `12-14%`; a later run sent about `1.32 Gbit/s` and
delivered about `1.16M` packets according to dataplane counters, but the
`iperf3` control connection broke before a valid receiver summary was
produced and the overlay path needed a `babblerd` restart to recover.
So the current blocker is:
- not “UDP overlay cannot carry packets”,
- not primarily “Babel selected the wrong steady-state route”,
- but packet processing cost, syscall/copy overhead, and drop/recovery behavior
when the dataplane is overdriven.
Restart/convergence route quality is still worth watching. Immediately after a
restart, Babel can transiently install high-cost `en0`/`en1` routes before the
better neighbours converge. But after convergence, the current `enN` policy is
good enough for throughput work.
At `11 Gbit/s` with `1452` byte inner packets, the budget is about `947 kpps`,
or `1.06 us/packet`. The latest direct overlay result at `1.46 Gbit/s` is about
`126 kpps`, or `8 us/packet`. Closing the gap means cutting per-packet cost by
roughly `7-8x`, increasing effective packet size with jumbo/aggregation, or
both.
Near-term cost audit before jumbo/aggregation:
- A transit node needs one UDP receive syscall and one UDP send syscall per
forwarded packet, so standard-MTU `11 Gbit/s` implies nearly `1.9M` UDP
syscalls/sec on that node.
- Rust-level cleanup alone is unlikely to recover a full `7-8x`, but avoidable
hot-path work should still be removed before blaming the architecture.
- First targets now landed: remove the per-packet `socket.ifname` clone on UDP
ingress, avoid source-address decoding when the peer address is not needed,
and reuse packet buffers instead of stack-zeroing a new array per packet.
- Initial `iroh-quinn-udp` wiring has landed. The dataplane still keeps `mio`
sockets for readiness, but each socket also has a `UdpSocketState`; receives
can batch through the crate's Apple `recvmsg_x` path or Linux `recvmmsg`
path, and sends go through the same abstraction. This is not QUIC.
- Next candidates are connected per-neighbour output sockets and true output
batching: collect same-peer/same-size packets and send them as one
`Transmit` with `segment_size` set, instead of one transmit call per forwarded
packet.
- Batching invariant: never wait for a full batch. Receive batching must return
whatever is already queued on the nonblocking fd, and future output batching
must flush partial batches at drain boundaries or peer/size changes.
Control traffic terminology:
- Babel protocol packets should stay on the direct link-local `en*`
interfaces that `babblerd` explicitly adds to `babeld`; the TUN/overlay
interface is not added to Babel.
- `iperf3` data, the `iperf3` TCP control/session connection, and `ping6` to a
peer ULA do traverse the overlay because they are addressed to node ULAs.
- Full overlay load can still perturb Babel indirectly through shared physical
NIC queues, kernel buffers, and CPU scheduling, but Babel packets are not
being encapsulated by the software router in the normal design.
- Therefore "protect control traffic" means making measurements and recovery
less fragile; it is not a direct explanation for the order-of-magnitude
throughput gap.
## Very Important macOS Receive-Side Finding
On macOS, receive-side socket attribution is not trustworthy in the current one-socket-per-interface model.
Observed live behavior:
- traffic sent directly over one Thunderbolt link can be delivered to a different UDP socket than expected
- the peer scope-id still reflects the real ingress interface
Implication:
- do not trust “which socket woke up” as authoritative ingress truth on macOS
- if receive-side interface attribution matters, use peer scope-id and likely ancillary packet metadata later
This is a real quirk, but it is not the primary explanation for the current
order-of-magnitude throughput gap.
## Key Local FIB Caveat
Do not assume route-choice issues are only Babels fault.
In `src/fib.rs`, `FibBuilder` collapses multiple installed host routes by choosing the lowest:
- `metric`
- then `refmetric`
- then `handle`
So if restart churn leaves multiple `installed=yes` candidates, babblerds
derived `FibSnapshot` can still be part of why traffic transiently goes via
`en0`/`en1`.
If route-choice anomalies reappear, compare all three:
1. raw Babel route events / dump
2. current `BabelState`
3. derived `FibSnapshot`
Not Babel in isolation.
## Lab Topology / Operations
Source of truth file:
- `lab_topology_reference.md`
Key facts:
- four Mac minis
- hostnames:
- `e4@e4`
- `e2@e2`
- `e11@e11`
- `e16@e16`
- ring topology:
- `e4 -> e2 -> e11 -> e16 -> e4`
- remote repo path:
- `~/babeld-exo`
- each remote must `git pull` before running
- current start command:
- `cd ~/babeld-exo && git pull && RUST_LOG=info sudo -E nix run .#babblerd --impure`
- temporary internal keepalive client exists, so no external `nc -U ...` client is needed just to keep daemon alive
- `iperf3` is provided by the flake:
- `nix run .#iperf3 -- -s`
- `nix run .#iperf3 -- -c <addr>`
- Force the experimental TCP dataplane transport with either:
- `BABBLER_ROUTER_TRANSPORT=tcp RUST_LOG=info sudo -E nix run .#babblerd --impure`
- `RUST_LOG=info sudo -E nix run .#babblerd --impure -- --force-tcp`
- In forced TCP mode on macOS, each daemon has one wildcard listener shared by
all admitted interfaces; per-peer outbound streams remain interface-scoped.
Accepted TCP streams are rejected unless the peer is link-local and matches a
live Babel neighbour on the accepted scope/interface.
- Forced TCP defaults the TUN MTU to `65535`. Use `--tun-mtu <mtu>` or
`BABBLER_TUN_MTU=<mtu>` to test smaller values such as `16384` or `32768`. Keep the
value identical on every forced-TCP peer in a run.
- Tune forced-TCP write batching and TCP socket buffers with:
- `BABBLER_TCP_BATCH_TARGET_BYTES=<bytes>`; default `262144`
- `BABBLER_TCP_SOCKET_BUFFER_BYTES=<bytes>`; default `4194304`
- The current `iperf3` source is the fork at
`/home/royalguard/Desktop/exo-all/networking-related/iperf3`.
Commit `962e05b` adds `%scopeID` rendering for link-local IPv6 output.
## Current Docs Status
Read:
- `shortcuts.md`
- `future_architectural_directions.md`
- `lab_topology_reference.md`
They correctly capture:
- broad admissibility is acceptable for v1 reachability
- flat wired costs are not enough for good best-path choice, hence the
temporary `enN` policy
- forked `babeld` now has the `neighbour-cost` primitive needed for temporary
external cost steering
- the route-selection heuristic is now active and good enough after convergence
to expose dataplane throughput limits
- the latest direct/single-hop `iperf3` results and the `11 Gbit/s` direct UDP
baseline
- the remaining debt around backpressure, batching/aggregation, jumbo MTU,
interface identity, macOS receive attribution, IPC/authz, and incomplete
ICMP/PMTUD behavior
## Important Remaining Technical Debt
Still unresolved:
- public IPC socket is too open
- `ServiceState::On` is not the same as “fully ready/routable”
- broad interface admission is still heuristic
- route ownership of `EXO_ULA_PREFIX` is aggressive
- no ICMPv6 Time Exceeded
- no Packet Too Big handling
- no real backpressure/queueing; `WouldBlock` is still drop-on-backpressure
- counters are logged but not yet exposed as a structured public status surface
- direct overlay throughput is still about `1.46 Gbit/s`, far below the
`11 Gbit/s` direct physical UDP baseline
- full-blast single-hop UDP can destabilize the overlay path after the run
- macOS receive-side interface attribution needs a better long-term path
- multi-link path selection still uses a temporary `enN -> N * 100`
absolute-cost heuristic, not measured scoring
- automatic measured link-scoring policy is not implemented yet
## What Not To Revisit Right Now
These are settled enough for now:
- overlay architecture itself
- TUN + userspace UDP forwarding model
- one-packet-per-datagram framing as the MVP correctness model; batching or
aggregation can now be evaluated as a performance extension
- control plane on Tokio, dataplane on dedicated thread
- exact-match `/128` FIB for v1
- `tun-rs` packet I/O on macOS instead of raw fd reads/writes
- disabling Babel kernel installs and owning `EXO_ULA_PREFIX -> tunX` locally
## Best Next Performance Step
Use the current route heuristic and focus on dataplane cost.
The most concrete current experiment is forced TCP transport on the Mac
Thunderbolt lab. It should be compared against the UDP default with the same
routes, same iperf pairs, same dataplane counter deltas, and same recovery
checks. TCP mode batches framed inner packets before kernel writes; UDP mode
still emits one send operation per forwarded packet.
Capture each run with:
1. `iperf3` sender/receiver summaries
2. `babblerd` dataplane counter deltas
3. CPU usage on sender, transit node, and receiver
4. route/FIB snapshots before and after the run
5. whether bidirectional `ping6` still works after the run
6. for TCP mode, `tcp_tx_batches`, `tcp_tx_bytes`, `tcp_queued_packets`,
`tcp_written_frames`, `tcp_rx_batches`, `tcp_rx_bytes`, `tcp_rx_frames`,
`tcp_reregisters`, `tcp_rejected_peers`, `tcp_queue_drops`,
`tcp_frame_errors`, `tcp_stream_errors`, and TUN packet-size bucket deltas
Goal:
- separate CPU/syscall ceiling from UDP/TUN backpressure,
- explain the single-hop UDP wedge and distinguish overlay application-control
failure from Babel route churn,
- and measure whether receive-side batching changed direct and single-hop
throughput before implementing true transmit batching, aggregation, jumbo MTU
support, or multi-core sharding.
The temporary `neighbour-cost` policy now lives in `babel/link_policy.rs`.
For each live neighbour on an `enN` interface, `babblerd` sets `coef-256 0`.
Most `enN` links use `bias-256 N * 100 * 256`, so Babel sees lower-numbered
interfaces as cheaper while keeping the distributed Babel view and dataplane
view aligned. `en0` and `en1` are the temporary exceptions: they get the
largest finite `bias-256` value so they lose to the explicit direct-link
interfaces during smoke tests.
If route-choice bugs reappear, then inspect all three together for the
problematic `/128` pair: raw Babel route events over time, current `BabelState`,
and derived `FibSnapshot`.
## Best Next Live Tests
1. full directed `ping6` matrix on node `/128`s
2. small directed UDP matrix
3. direct overlay TCP/UDP `-b 0` with counter capture
4. single-hop overlay TCP/UDP `-b 0` with counter capture
5. short soak tests on adjacent and two-hop pairs
6. restart/convergence tests
7. physical churn tests
8. for failures, always capture:
- symptom
- raw Babel route state / dump
- current `BabelState`
- derived FIB state if relevant
- dataplane logs
- relevant `ifconfig`
## Short Version
The project is now in the:
- throughput / backpressure robustness
- overload recovery
- restart convergence sanity-checking
phase.
The dataplane is basically real.
The current main question is not “can the overlay forward packets at all?”
It is:
- why the userspace router tops out around `1-1.5 Gbit/s` when direct physical
UDP can reach about `11 Gbit/s`
- and how much of that gap comes from one-packet-per-datagram syscalls/copies,
single-thread processing, TUN/UDP backpressure, or recoverability bugs under
overload
+191
View File
@@ -0,0 +1,191 @@
# Lab Topology Reference
This file records the current, still-relevant lab topology and bring-up
context for `babblerd`.
## Hosts
- The lab consists of four Mac minis.
- SSH targets:
- `e4@e4`
- `e2@e2`
- `e11@e11`
- `e16@e16`
- You can SSH into these machines directly to inspect or run commands.
## Physical Topology
- The machines are connected in a Thunderbolt ring:
- `e4 -> e2 -> e11 -> e16 -> e4`
- The Thunderbolt-facing interface names are not fixed to `en2` and `en3`.
macOS can expose additional Thunderbolt links as other `en*` interfaces such
as `en5`, `en6`, or host-specific names after reconfiguration.
- Treat `en2,en3` as an old bring-up heuristic only. For normal lab testing,
run without `BABBLER_INTERFACE_ALLOWLIST` and let `babblerd`/Babel discover
the live interfaces.
## Repository Location On The Macs
- Each machine has a checkout of the Exo repository at:
- `~/babeld-exo`
- That checkout is expected to already be on the correct branch for this work.
## Current Node Addresses
- `e2`: `fde0:20c6:1fa7:ffff:aeb:e53a:cb17:aa42`
- `e11`: `fde0:20c6:1fa7:ffff:34a:26dd:46ff:1a3f`
- `e16`: `fde0:20c6:1fa7:ffff:7c5d:5e2d:54df:e665`
- `e4`: `fde0:20c6:1fa7:ffff:cc78:aec2:d64e:f125`
## Current Route Policy
`babblerd` now uses the forked `babeld` `neighbour-cost` command to bias route
selection after neighbours appear:
- `en0` and `en1` get maximum finite cost with `bias-256 16776704` and
`coef-256 0`, yielding cost `65534`.
- Most other `enN` interfaces get `bias-256 N * 100 * 256` and `coef-256 0`,
so `en2` costs `200`, `en3` costs `300`, `en5` costs `500`, and so on.
- This is a temporary Mac lab heuristic, not measured link scoring.
- After convergence it keeps steady-state routes away from `en0`/`en1` and
toward lower-numbered direct Thunderbolt-style links. Immediately after
restart, Babel can still transiently install worse high-cost routes until
better neighbour state arrives.
## Running `babblerd`
From `~/babeld-exo`, pull first so the machine is not testing stale commits,
then start `babblerd`:
```sh
cd ~/babeld-exo
git pull
RUST_LOG=info sudo -E nix run .#babblerd --impure
```
The default dataplane transport is UDP. To force the experimental TCP
neighbour transport for Mac Thunderbolt throughput tests, start every node with
one of:
```sh
BABBLER_ROUTER_TRANSPORT=tcp RUST_LOG=info sudo -E nix run .#babblerd --impure
RUST_LOG=info sudo -E nix run .#babblerd --impure -- --force-tcp
```
TCP mode still uses Babel for route selection and still sends Babel packets on
the direct link-local `en*` interfaces. Only node-ULA overlay traffic uses the
TCP streams.
On macOS, forced-TCP listener sockets are wildcard listeners rather than
per-interface-bound listeners. Per-interface `IPV6_BOUND_IF` on TCP listeners
left e4/e16 handshakes stuck in `SYN_RCVD` during lab testing, while a plain
link-local TCP listener on the same cable completed. Outbound TCP streams remain
scoped to the Babel-selected interface. Accepted TCP streams are rejected unless
the peer is link-local and matches a live Babel neighbour on the accepted
scope/interface.
Forced TCP defaults the TUN MTU to `65535` to reduce per-packet TUN syscalls on
the Mac Thunderbolt fast path. Override it during sweeps with either command
form, but keep the value identical on every forced-TCP peer in the run:
```sh
BABBLER_TUN_MTU=16384 BABBLER_ROUTER_TRANSPORT=tcp RUST_LOG=info sudo -E nix run .#babblerd --impure
RUST_LOG=info sudo -E nix run .#babblerd --impure -- --force-tcp --tun-mtu 32768
```
TCP mode uses `256 KiB` TCP read buffers and `256 KiB` opportunistic write batch
targets by default. Sweep write targets and TCP socket buffers dynamically with
the same values on every node:
```sh
BABBLER_TCP_BATCH_TARGET_BYTES=1048576 BABBLER_TCP_SOCKET_BUFFER_BYTES=16777216 BABBLER_ROUTER_TRANSPORT=tcp RUST_LOG=info sudo -E nix run .#babblerd --impure
```
Good first matrix values are `262144`, `524288`, `1048576`, and `2097152` for
`BABBLER_TCP_BATCH_TARGET_BYTES`, plus `4194304`, `8388608`, `16777216`, and
`33554432` for `BABBLER_TCP_SOCKET_BUFFER_BYTES`. TCP receive reads directly
into the frame decoder buffer, and stream readiness is reregistered only when
write interest changes. UDP mode still defaults to `1452`, derived from a
`1500` byte physical MTU minus outer IPv6 and UDP headers.
If broad interface discovery causes unrelated links to interfere with a
specific debug run, `BABBLER_INTERFACE_ALLOWLIST` is still available as a
temporary escape hatch. Do not use it as the default lab topology description.
`babblerd` no longer needs to wait for an initial interface before starting
`babeld`. The forked `babeld` can start with no managed interfaces, and
`babblerd` adds interfaces later through the read-write local control socket.
## `iperf3`
Use the flake-provided forked `iperf3` when testing this branch:
```sh
nix run .#iperf3 -- -s
nix run .#iperf3 -- -c <addr>
```
The current fork lives at
`/home/royalguard/Desktop/exo-all/networking-related/iperf3`; commit `962e05b`
adds `%scopeID` rendering for link-local IPv6 output.
Latest useful test commands:
```sh
nix run .#iperf3 -- -s -1
nix run .#iperf3 -- -6 -b 100M -t 5 -c <node-ula>
nix run .#iperf3 -- -6 -u -b 100M -t 5 -c <node-ula>
nix run .#iperf3 -- -6 -u -b 0 -t 10 -c <node-ula>
nix run .#iperf3 -- -6 -b 0 -t 10 -c <node-ula>
```
For the forced-TCP dataplane experiment, capture both correctness and
performance:
1. Start all four nodes with TCP mode enabled.
2. Wait for convergence and record route/FIB state.
3. Run a directed `ping6` matrix over node ULAs.
4. Run adjacent TCP and UDP iperf smoke tests at `100M`.
5. Run direct overlay TCP/UDP `-b 0` on an adjacent pair such as `e4 -> e16`.
6. Run single-hop overlay TCP/UDP `-b 0` such as `e2 -> e16`.
7. Capture dataplane counter deltas, especially TCP batches/frames/errors, and
verify bidirectional `ping6` still works after each full-rate run.
Latest findings:
- Direct physical UDP without the software router is about `11 Gbit/s`.
- Direct overlay `e4 -> e16`, UDP `-b 0`: about `1.46 Gbit/s` received with
negligible loss.
- Direct overlay `e4 -> e16`, TCP `-b 0`: about `1.24 Gbit/s` received.
- Single-hop overlay `e2 -> e16`, UDP `-b 100M`: `100 Mbit/s` with no loss.
- Single-hop overlay `e2 -> e16`, UDP `-b 0`: earlier server intervals were
around `1.11-1.16 Gbit/s` with `12-14%` loss. A later run sent about
`1.32 Gbit/s`; dataplane counters showed roughly `1.16M` packets delivered
at `e16`, but the `iperf3` control connection broke before a receiver
summary was produced. Treat that as an overload/control-path failure, not as
a zero-throughput receiver result. The overlay path then needed a
`babblerd` restart to recover.
- Single-hop overlay `e2 -> e16`, TCP `-b 0`: about `1.07-1.08 Gbit/s`
received, with route/path recovery sometimes lagging briefly after the run.
For `1452` byte packets, `11 Gbit/s` is roughly a one-microsecond packet
budget: about `947 kpps`, or `1.06 us/packet`. The current direct overlay
result is roughly `126 kpps`, or `8 us/packet`. Forced TCP now defaults the TUN
MTU to `65535`, so compare packet counters before attributing any result to the
outer TCP socket alone.
Babel protocol packets should not traverse the software router. `babblerd`
starts `babeld` without startup interfaces and later adds only eligible
physical `en*` interfaces through the local control socket; the TUN/overlay
interface is not added to Babel. What does traverse the overlay is traffic
addressed to node ULAs, including `iperf3` data, the `iperf3` TCP control
connection, and `ping6` to a peer ULA. Full overlay load can still indirectly
perturb Babel by consuming shared NIC queues, kernel buffers, and CPU time on
the same physical interfaces, but it is not because Babel's link-local packets
are being encapsulated by `babblerd`.
## Important Current Note
- With the current codebase, `babblerd` has an internal dummy keepalive client.
- That means you do **not** need to connect an external client socket just to
make the daemon stay active during testing.
@@ -0,0 +1,51 @@
# PBProbe Implementation Plan
This file tracks the staged implementation and validation of a paper-faithful
PBProbe profiler for link-local lab links.
## Stage 1: Local Implementation
- Add `src/profiling/pbprobe/` as a separate module from the simple packet
train profiler.
- Implement the paper protocol:
- START initiates one direction.
- RTS requests each sample.
- the sender replies with a packet bulk of length `k`, meaning `k + 1`
packets.
- the receiver measures first and last packet arrival time, delay sum, and
dispersion.
- END reports the selected sample and estimate.
- Implement Algorithm 1:
- start with `k = 1`.
- if measured minimum dispersion is below `D_thresh`, multiply `k` by 10 and
restart.
- otherwise pace samples with `G = 2D / U`.
- stop after fixed `n` accepted samples.
- Keep the C implementation as a reference, but use the paper's units for `G`.
## Stage 2: Local Verification
- Unit-test packet encoding/decoding.
- Unit-test estimator selection by minimum delay sum.
- Unit-test bulk-length adaptation and pacing calculations.
- Compile the standalone example.
## Stage 3: Lab Validation
- Discover the current link-local addresses and interface names on the Mac mini
ring via SSH.
- Build or run the PBProbe example on the relevant remotes.
- Run the flake-provided forked `iperf3` over the same link-local scoped
addresses as the baseline. The fork at
`/home/royalguard/Desktop/exo-all/networking-related/iperf3` includes commit
`962e05b`, which renders `%scopeID` for link-local IPv6 output.
- Compare PBProbe estimates against `iperf3` with a reasonable tolerance.
- If estimates are outside tolerance, adjust only algorithm parameters or
implementation bugs, not the scoring target.
## Current Notes
- The repo license is Apache-2.0. The dropped PBProbe source has a permissive
MIT-like license header with notice retention and academic citation language.
- The C code appears to implement the core estimator, but its `G` sleep units
look inconsistent with the paper. This implementation should follow the paper.
+381
View File
@@ -0,0 +1,381 @@
# `babblerd` Shortcuts
This file tracks architectural and implementation shortcuts that were taken
deliberately during the refactors. They are acceptable for now, but they are
not meant to be the final design.
This is not a dump of every `TODO` comment in the crate. It is the curated list
of shortcuts that should be revisited later.
## Architecture / IPC
- The public control socket still uses an ad-hoc line protocol instead of the
intended `zbus`/D-Bus-style IPC surface.
Files:
- `src/daemon.rs`
- `src/main.rs`
Follow-up:
- Replace `keepalive <ttl_ms>` / `get-state` string commands with a typed IPC
API.
- The daemon core currently tracks a single global keepalive deadline, not
per-client leases.
Files:
- `src/daemon.rs`
- `src/main.rs`
Why this is a shortcut:
- It does not model multiple clients independently.
- It cannot distinguish which client is keeping the service alive.
- The current tree also includes a temporary internal self-client in
`main.rs` that periodically issues keepalive commands just to keep the
daemon/routing stack alive during bring-up.
Follow-up:
- Introduce real lease ownership/tracking in the daemon core.
- Remove the temporary internal keepalive client once a real frontend or test
harness is driving the daemon.
- Raw Babel debug output currently only goes to tracing logs.
Files:
- `src/babel/runtime.rs`
- `src/daemon.rs`
Why this is a shortcut:
- There is no configurable or structured diagnostics stream anymore.
- That is fine for now, but eventually debugging should not require tailing
daemon logs.
Follow-up:
- Add configurable debug output or a separate structured diagnostics stream
once the real IPC surface exists.
- The daemon core exposes state only through `get-state` polling and inline
command responses.
Files:
- `src/daemon.rs`
Follow-up:
- Add real state publication/signals once the IPC surface is upgraded.
## Service Lifecycle
- The daemon now has explicit `Off/Starting/On/Stopping`, but the control model
is still minimal.
Files:
- `src/daemon.rs`
Why this is a shortcut:
- There is no richer lifecycle API yet.
- There is no explicit enable/disable policy beyond keepalive-driven on/off.
Follow-up:
- Revisit the final lifecycle API once IPC is made real.
- `ServiceState::On` currently means “the routing tasks were started”, not a
stronger readiness guarantee such as “babeld is healthy, has admitted
interfaces, and is actually usable for mesh forwarding”.
Files:
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/babel/runtime.rs`
Why this is a shortcut:
- The frontend may eventually want to distinguish process/task liveness from
actual routing readiness.
Follow-up:
- Add a separate readiness field or richer public state model instead of
overloading `ServiceState::On`.
- The resident TUN vs heavy routing-stack split is now in place, but the
naming and abstractions are still transitional.
Files:
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/tun.rs`
Follow-up:
- Revisit names and boundaries after the daemon core / IPC architecture settles.
- `RoutingStack::stop` still uses abort-driven shutdown for the interface
watcher and logger task.
Files:
- `src/routing_stack.rs`
Why this is a shortcut:
- It is pragmatic, but not a carefully coordinated shutdown protocol.
Follow-up:
- Replace task abortion with explicit shutdown signaling where it matters.
## Babel Integration
- `babeld` runtime startup config is still assembled partly as raw strings.
Files:
- `src/babel/runtime.rs`
- `src/babel/command.rs`
Why this is a shortcut:
- The local-socket command side is typed, but spawn-time `-C` config is not.
Follow-up:
- Add a typed Babel config/config-statement layer.
- The runtime still depends on fork-specific `babeld` behavior
while spawning `"babeld"` from `PATH`.
Files:
- `src/babel/runtime.rs`
- `../nix/babeld.nix`
Why this is a shortcut:
- The current fork supplies `kernel-install false`, no-interface startup, and
the `neighbour-cost` local-socket command.
- It assumes the right binary is on `PATH`.
- The Nix packaging is still not pinned to a specific revision.
Follow-up:
- Pin the fork revision and make the runtime use that exact binary.
## Networking / Interface Admission
- Interface admission is still heuristic and too broad on macOS.
Files:
- `src/lib.rs` (`if_watcher`)
- `src/config.rs`
- `src/fib.rs`
Why this is a shortcut:
- Any `en*` interface with link-local IPv6 and `is_up()` can still get pulled
into Babel during bootstrap.
- This can include unrelated WiFi, built-in Ethernet, USB Ethernet, etc.
- The dataplane now narrows that broad bootstrap set back down to interfaces
that actually have live Babel neighbours, which is much closer to the real
transport set.
- But the watcher/bootstrap side is still using the coarse `en*` heuristic,
and the env allowlist is still just a bring-up escape hatch rather than the
long-term admission policy.
- When multiple admissible wired links exist, Babel's native wired scoring
still treats them essentially flatly.
- `babblerd` now applies a temporary `enN -> N * 100` absolute-cost policy
with forked `babeld`'s `neighbour-cost` command, except that `en0` and
`en1` are assigned the maximum finite cost so shared low-index networks do
not win simply because their interface indexes are small.
Follow-up:
- Replace the watcher-side bootstrap heuristic with a stronger admission
policy (neighbor proof, richer metadata, or both), so Babel does not need
broad speculative interface admission just to discover the right links.
- Keep validating route choice during restart/convergence, but treat the
temporary macOS `en0`/`en1` deprioritization plus `enN -> N * 100`
neighbour-cost policy as good enough for steady-state throughput work.
- Later, replace that heuristic with measured link-quality scoring so broadly
admissible direct links can be ranked by actual observed quality.
- The dataplane now derives immutable FIB snapshots and runs on a dedicated
thread, but it still assumes interface names are the stable long-lived
identity for socket ownership.
Files:
- `src/fib.rs`
- `src/dataplane.rs`
Why this is a shortcut:
- The dataplane now owns sockets from the admitted interface set rather than
inferring them only from current routes, and it refreshes retained sockets
when a name resolves to a new ifindex.
- The dataplane now also has a lightweight timer-driven reconcile retry for
admitted interfaces whose socket setup was skipped or failed, so unchanged
FIB snapshots no longer suppress retries completely.
- That fixes the earlier route-derived and stale-ifindex bugs, but the design
still assumes interface names are stable enough to be the long-lived
control-plane identity.
Follow-up:
- Revisit whether the long-term identity should be richer than `ifname`,
especially if interface renames/hotplug churn become common during runtime.
- On macOS in particular, the current "one socket per interface" receive
model is not trustworthy enough to identify the real ingress interface:
live testing shows packets sent directly over one Thunderbolt link can be
received on a different `MioUdpSocket` while the peer scope-id still
reflects the actual physical ingress interface.
- Revisit receive-side interface attribution on macOS, likely using ancillary
packet-info / receive-interface metadata instead of assuming the receiving
socket tells the truth.
- The current dataplane is intentionally minimal and still drops several packet
classes silently.
Files:
- `src/dataplane.rs`
- `src/fib.rs`
- `src/routing_stack.rs`
Why this is a shortcut:
- The current lab state has reliable ICMPv6 reachability, basic generic TCP
correctness after convergence, a clean `100M` single-hop UDP smoke test,
and direct/single-hop full-bandwidth `iperf3` measurements.
- Earlier `iperf3` results were confounded by route selection. That is no
longer the main explanation after the temporary `neighbour-cost` policy:
direct overlay UDP still tops out around `1.46 Gbit/s`, while direct
physical UDP without the software router is about `11 Gbit/s`.
- The first round of hot-path cleanup is in: readiness drains have fairness
budgets, UDP receive no longer allocates a `Vec`, `FibSnapshot`s are
compiled into dataplane-local fast routes with socket slots, and dataplane
counters are logged periodically.
- The current code also still treats `WouldBlock` on UDP send and TUN
reinjection as drop-on-backpressure behavior. That is now visible in logs,
but it is not yet a proper queued/backpressured forwarding model.
- Single-hop UDP at `-b 0` can receive around `1.1 Gbit/s` during the run but
with heavy loss and a post-test overlay wedge until `babblerd` is
restarted. A later `-b 0` run sent about `1.32 Gbit/s` and dataplane
counters showed about `1.16M` packets delivered, but the `iperf3` control
connection failed before a valid receiver summary and the path needed a
restart to recover. That points at overload/recovery and test-control
fragility, not basic packet decoding.
- There is no ICMPv6 Time Exceeded generation yet.
- There is no Packet Too Big handling yet.
- No-route and invalid-packet cases are mostly tracing-and-drop behavior.
Follow-up:
- Use dataplane counter deltas, CPU measurements, and route/FIB snapshots
around each `iperf3` run to separate syscall/CPU ceiling from UDP/TUN
backpressure.
- The first avoidable per-packet costs have been removed: no heap allocation
for UDP receive logging, no peer-address decoding when it is not needed,
and no per-packet zeroed buffer construction.
- Initial `iroh-quinn-udp` wiring is in: `mio` still owns readiness, but UDP
receive/send calls now go through Quinn's UDP socket layer, which selects
Apple `recvmsg_x`/`sendmsg_x` or Linux `recvmmsg` internally where
available. True output batching is still future work, and it must flush
partial batches rather than wait for a full batch.
- An opt-in TCP transport now exists for Mac Thunderbolt experiments. It is
selected with `BABBLER_ROUTER_TRANSPORT=tcp`, `--router-transport tcp`, or
`--force-tcp`. It frames inner IPv6 packets onto scoped link-local TCP
streams and batches writes through bounded per-peer buffers. This is a
deliberate experimental shortcut, not the default transport policy.
- On macOS, TCP mode uses one wildcard IPv6 listener per daemon instead of
one per-interface-bound listener per admitted interface; outbound streams
still use the Babel-selected interface. Accepted streams must be link-local
Babel neighbours on the accepted interface/scope. Bound listeners caused
e4/e16 lab handshakes to stick in `SYN_RCVD`.
- TCP mode defaults the TUN MTU to `65535`; use `--tun-mtu <mtu>` or
`BABBLER_TUN_MTU=<mtu>` for smaller/larger lab sweeps. Use the same value on
every forced-TCP peer in a run. UDP keeps the old `1452` default.
- TCP mode uses `256 KiB` TCP read buffers and `256 KiB` opportunistic write
batch targets by default. Sweep write batches with
`BABBLER_TCP_BATCH_TARGET_BYTES=<bytes>` and socket buffers with
`BABBLER_TCP_SOCKET_BUFFER_BYTES=<bytes>`; start with `512 KiB`, `1 MiB`,
`2 MiB` batches and `8 MiB`, `16 MiB`, `32 MiB` socket buffers.
- TCP receive reads directly into the frame decoder buffer, and stream
readiness is reregistered only when write interest changes.
- TCP mode relies on a lab assumption: direct cabled Mac Thunderbolt links are
low-loss enough that TCP-over-TCP pathologies should be limited during
throughput tests. It should not be treated as a general unreliable-mesh
replacement for UDP without more overload and loss testing.
- Benchmark output batching, aggregation, jumbo MTU support, and eventually
multi-core dataplane sharding. At `11 Gbit/s` with `1452` byte packets, the
budget is about `947 kpps`, or `1.06 us/packet`; the current direct overlay
result is roughly `126 kpps`, or `8 us/packet`.
- Do not describe Babel packets as going through the software router in the
normal design. Babel should be direct-interface link-local traffic on the
`en*` interfaces that `babblerd` gives to `babeld`; overlay load can still
disturb it indirectly through shared NIC/kernel/CPU resources.
- Later, replace the suffix heuristic with measured scoring and investigate
how that should interact with restart/convergence behavior.
- Add proper ICMPv6 error generation and tighter packet-validation behavior.
- `TunDevice` is still a thin platform-specific wrapper with some rough edges.
Files:
- `src/tun.rs`
- `src/dataplane.rs`
Why this is a shortcut:
- It still stores the address as `Ipv6Net` even though usage is `/128`-only.
- On macOS, the actual kernel interface name is still `utunN`; the daemon's
cross-platform naming has been cleaned up, but the OS-level interface name
is not under our control there.
- It still has hard-coded MTU and other tun-rs builder assumptions.
- The dataplane still relies on `mio::unix::SourceFd` and `AsRawFd` to poll
the TUN fd on Unix.
- On macOS, packet I/O must still go through `tun-rs`'s `SyncDevice::recv`
and `SyncDevice::send`; bypassing those with raw fd `read`/`write` breaks
utun packet-information handling even if `mio` polling itself is correct.
- That low-level fd borrowing is smaller and safer than the old
`unsafe`/owned-fd handoff, but it still keeps raw-fd details in the
dataplane hot path.
Follow-up:
- Tighten the type and revisit the platform-specific tuning once the dataplane
is implemented.
- Revisit whether a future dataplane/eventing design can remove the direct
`mio`/raw-fd dependency entirely.
- The current MTU model is still intentionally crude:
- assume physical links must support 1500-byte packets,
- use `1452` as the UDP TUN MTU default,
- use `65535` as the forced-TCP TUN MTU default,
- allow `BABBLER_TUN_MTU` or `--tun-mtu` for explicit experiments,
- reject candidate physical interfaces below 1500 MTU.
Files:
- `src/config.rs`
- `src/lib.rs`
- `src/tun.rs`
Why this is a shortcut:
- It does not handle PMTUD, VLAN overhead, per-route MTU variation, or
jumbo-frame opportunities.
Follow-up:
- Replace the current coarse MTU model with route-aware MTU derivation once
the dataplane is better characterized.
- The overlay route controller currently claims the whole overlay prefix
aggressively.
Files:
- `src/route_ctl.rs`
Why this is a shortcut:
- It removes any existing route matching `EXO_ULA_PREFIX` before adding the
daemon's own interface route, and removes all matching routes again on
shutdown.
- That is acceptable only if babblerd is the sole owner of the overlay
prefix.
Follow-up:
- Narrow route deletion so it only removes routes that this daemon installed,
or otherwise encode route ownership more precisely.
## Identity / Security / Filesystem
- The node-id file is created with `0600`, but existing files are only
owner-checked, not mode-checked.
Files:
- `src/identity.rs`
Why this is a shortcut:
- A root-owned but group/world-writable file would still be accepted.
Follow-up:
- Enforce safe permissions on reload, not just on initial creation.
- The public IPC socket is intentionally world-accessible for now.
Files:
- `src/main.rs`
Why this is a shortcut:
- Any local user can connect, issue keepalives, and drive the daemon's public
control surface.
Follow-up:
- Revisit permissions/authz once the IPC surface is finalized.
## Error Modeling
- Several orchestration-layer errors are flattened to `String`/`Arc<str>` too
early.
Files:
- `src/daemon.rs`
- `src/babel/runtime.rs`
- `src/lib.rs` (`BabbleError::Other(String)`)
Why this is a shortcut:
- It loses structure and source-chain information.
Follow-up:
- Prefer typed errors or `eyre::Report` internally, and stringify only at the
IPC/UI boundary.
## Constants / Magic Values
- A few important constants are still effectively magic values:
- EXO ULA prefix details
- default router UDP port
- various timeout/sleep durations in the Babel runtime
- tun MTU
Files:
- `src/config.rs`
- `src/babel/runtime.rs`
- `src/tun.rs`
Follow-up:
- Either justify them clearly as real protocol/runtime constants or move them
into better configuration/abstraction layers.
## Testing
- The typed Babel parser/state layers are tested, but the newer daemon-core and
routing-stack lifecycle behavior is still lightly tested.
Files:
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/main.rs`
Follow-up:
- Add focused tests for:
- keepalive-driven transitions,
- stack start/stop behavior,
- public socket command behavior,
- failure propagation from the routing stack.
+244
View File
@@ -0,0 +1,244 @@
//! Typed representation of commands sent to `babeld`'s local socket.
//!
//! This is the outbound counterpart to [`crate::babel::line`]:
//!
//! - [`crate::babel::line`] models what `babeld` emits
//! - this module models the runtime control lines that `babblerd` sends
//!
//! The scope here is intentionally narrow: this module only models the local-socket
//! commands that `babblerd` currently issues at runtime.
//!
//! NOTE: spawn-time `-C` configuration strings are still assembled in the runtime layer for now.
//! If you want to push the protocol model further, the next obvious extraction is a typed
//! configuration/config-statement layer rather than more runtime socket commands.
use std::fmt;
use std::net::Ipv6Addr;
pub const BABEL_INFINITY: i32 = 65_535;
pub const NEIGHBOUR_COST_BIAS_256_MIN: i32 = -((BABEL_INFINITY - 1) * 256);
pub const NEIGHBOUR_COST_BIAS_256_MAX: i32 = (BABEL_INFINITY - 1) * 256;
pub const NEIGHBOUR_COST_COEF_256_MIN: u32 = 0;
pub const NEIGHBOUR_COST_COEF_256_MAX: u32 = 65_535;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BabelCommand {
Dump,
Monitor,
Unmonitor,
Quit,
Interface(Box<str>),
NeighbourCost(NeighbourCostCommand),
}
impl BabelCommand {
/// Encode this command for the local `babeld` socket, including line framing.
#[must_use]
pub fn encode(&self) -> String {
format!("{self}\n")
}
}
/// Signed fixed-point additive neighbour-cost bias in units of 1/256.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NeighbourCostBias256(i32);
impl NeighbourCostBias256 {
#[must_use]
pub fn new(value: i32) -> Option<Self> {
(NEIGHBOUR_COST_BIAS_256_MIN..=NEIGHBOUR_COST_BIAS_256_MAX)
.contains(&value)
.then_some(Self(value))
}
#[must_use]
pub const fn neutral() -> Self {
Self(0)
}
#[must_use]
pub const fn raw(self) -> i32 {
self.0
}
}
/// Unsigned fixed-point neighbour-cost multiplier in units of 1/256.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NeighbourCostCoef256(u32);
impl NeighbourCostCoef256 {
#[must_use]
pub fn new(value: u32) -> Option<Self> {
(NEIGHBOUR_COST_COEF_256_MIN..=NEIGHBOUR_COST_COEF_256_MAX)
.contains(&value)
.then_some(Self(value))
}
#[must_use]
pub const fn neutral() -> Self {
Self(256)
}
#[must_use]
pub const fn raw(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeighbourCostCommand {
ifname: Box<str>,
link_local_neighbour: Ipv6Addr,
bias_256: NeighbourCostBias256,
coef_256: NeighbourCostCoef256,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NeighbourCostCommandError {
NonLinkLocalNeighbour,
}
impl NeighbourCostCommand {
pub fn new(
ifname: impl Into<Box<str>>,
link_local_neighbour: Ipv6Addr,
bias_256: NeighbourCostBias256,
coef_256: NeighbourCostCoef256,
) -> Result<Self, NeighbourCostCommandError> {
if !link_local_neighbour.is_unicast_link_local() {
return Err(NeighbourCostCommandError::NonLinkLocalNeighbour);
}
Ok(Self {
ifname: ifname.into(),
link_local_neighbour,
bias_256,
coef_256,
})
}
pub fn neutral(
ifname: impl Into<Box<str>>,
link_local_neighbour: Ipv6Addr,
) -> Result<Self, NeighbourCostCommandError> {
Self::new(
ifname,
link_local_neighbour,
NeighbourCostBias256::neutral(),
NeighbourCostCoef256::neutral(),
)
}
}
impl fmt::Display for BabelCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dump => f.write_str("dump"),
Self::Monitor => f.write_str("monitor"),
Self::Unmonitor => f.write_str("unmonitor"),
Self::Quit => f.write_str("quit"),
Self::Interface(ifname) => write!(f, "interface {ifname}"),
Self::NeighbourCost(cmd) => write!(f, "{cmd}"),
}
}
}
impl fmt::Display for NeighbourCostCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"neighbour-cost {} {} bias-256 {} coef-256 {}",
self.ifname,
self.link_local_neighbour,
self.bias_256.raw(),
self.coef_256.raw()
)
}
}
#[cfg(test)]
mod tests {
use std::net::Ipv6Addr;
use super::{
BabelCommand, NeighbourCostBias256, NeighbourCostCoef256, NeighbourCostCommand,
NeighbourCostCommandError,
};
#[test]
fn renders_commands() {
assert_eq!(BabelCommand::Dump.to_string(), "dump");
assert_eq!(BabelCommand::Monitor.to_string(), "monitor");
assert_eq!(BabelCommand::Unmonitor.to_string(), "unmonitor");
assert_eq!(BabelCommand::Quit.to_string(), "quit");
assert_eq!(
BabelCommand::Interface("en2".into()).to_string(),
"interface en2"
);
assert_eq!(
BabelCommand::NeighbourCost(
NeighbourCostCommand::new(
"en18",
"fe80::42".parse().unwrap(),
NeighbourCostBias256::new(4_096).unwrap(),
NeighbourCostCoef256::new(128).unwrap(),
)
.unwrap()
)
.to_string(),
"neighbour-cost en18 fe80::42 bias-256 4096 coef-256 128"
);
}
#[test]
fn encodes_commands() {
assert_eq!(BabelCommand::Dump.encode(), "dump\n");
assert_eq!(BabelCommand::Monitor.encode(), "monitor\n");
assert_eq!(
BabelCommand::Interface("en2".into()).encode(),
"interface en2\n"
);
assert_eq!(
BabelCommand::NeighbourCost(
NeighbourCostCommand::neutral("en2", "fe80::1".parse().unwrap()).unwrap()
)
.encode(),
"neighbour-cost en2 fe80::1 bias-256 0 coef-256 256\n"
);
}
#[test]
fn validates_neighbour_cost_fixed_point_ranges() {
assert_eq!(
NeighbourCostBias256::new(super::NEIGHBOUR_COST_BIAS_256_MIN)
.unwrap()
.raw(),
-16_776_704
);
assert_eq!(
NeighbourCostBias256::new(super::NEIGHBOUR_COST_BIAS_256_MAX)
.unwrap()
.raw(),
16_776_704
);
assert!(NeighbourCostBias256::new(super::NEIGHBOUR_COST_BIAS_256_MIN - 1).is_none());
assert!(NeighbourCostBias256::new(super::NEIGHBOUR_COST_BIAS_256_MAX + 1).is_none());
assert_eq!(NeighbourCostCoef256::new(0).unwrap().raw(), 0);
assert_eq!(
NeighbourCostCoef256::new(super::NEIGHBOUR_COST_COEF_256_MAX)
.unwrap()
.raw(),
65_535
);
assert!(NeighbourCostCoef256::new(super::NEIGHBOUR_COST_COEF_256_MAX + 1).is_none());
}
#[test]
fn rejects_non_link_local_neighbour_cost_address() {
assert_eq!(
NeighbourCostCommand::neutral("en2", Ipv6Addr::LOCALHOST),
Err(NeighbourCostCommandError::NonLinkLocalNeighbour)
);
}
}
+785
View File
@@ -0,0 +1,785 @@
//! Typed representation of lines emitted by `babeld`'s local socket.
//!
//! This module models the inbound side of the Babel local control protocol:
//!
//! - [`BabelLine`] is one parsed wire line.
//! - [`HeaderLine`] covers the connection prelude.
//! - [`Status`] covers command completion lines such as `ok`, `bad`, and `no ...`.
//! - [`Event`] and its associated structs cover the asynchronous routing/interface updates
//! emitted by `dump` and `monitor`.
//!
//! The sibling parser lives in [`parse`]. Its job is to turn raw socket lines into these domain
//! types. Higher layers such as the Babel runtime/state code should depend on this module's
//! types, and keep raw strings only at the actual socket boundary.
//!
//! More concretely:
//!
//! - use [`parse::parse_line`] when reading from `babeld`
//! - reduce [`Event`] values into [`crate::babel::state::BabelState`]
//! - treat [`Status`] as command acknowledgements
//! - keep outbound socket/config commands in a separate module rather than mixing them into
//! this inbound line model
use crate::babel::Eui64;
use ipnet::IpNet;
use std::net::{IpAddr, Ipv4Addr};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BabelLine {
Header(HeaderLine),
Status(Status),
Event(Event),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeaderLine {
Banner { major: u8, minor: u8 },
Version(Box<str>),
Host(Box<str>),
MyId(Eui64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Ok,
Bad,
No(Option<Box<str>>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Interface(InterfaceEvent),
Neighbour(NeighbourEvent),
XRoute(XRouteEvent),
Route(RouteEvent),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
Add,
Change,
Flush,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceEvent {
pub kind: EventKind,
pub ifname: Box<str>,
pub up: bool,
pub ipv6: Option<IpAddr>,
pub ipv4: Option<Ipv4Addr>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeighbourEvent {
pub kind: EventKind,
pub handle: u64,
pub address: IpAddr,
pub ifname: Box<str>,
pub reach: u16,
pub ureach: u16,
pub rxcost: u32,
pub txcost: u32,
pub rtt_millis: Option<u32>,
pub rttcost: Option<u32>,
pub external_bias_256: i32,
pub external_coef_256: u32,
pub cost: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XRouteEvent {
pub kind: EventKind,
pub prefix: IpNet,
pub from: IpNet,
pub metric: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteEvent {
pub kind: EventKind,
pub handle: u64,
pub prefix: IpNet,
pub from: IpNet,
pub installed: bool,
pub id: Eui64,
pub metric: u32,
pub refmetric: u32,
pub via: IpAddr,
pub ifname: Box<str>,
}
/// Parser for `babeld`'s local socket output.
///
/// This submodule is the wire-format counterpart to the parent [`crate::babel::line`] domain
/// types. It turns raw socket text into [`BabelLine`] values.
///
/// The local socket protocol implemented in `networking-related/babeld/local.c` is line-oriented
/// ASCII. The parser is split into two layers:
///
/// - [`RawLines`] does zero-copy line framing over buffered bytes with [`memchr`].
/// - [`parse_line`] parses one complete line with [`winnow`].
/// - [`ParsedLines`] is a convenience adapter for buffered transcripts such as `dump` output.
///
/// `monitor` mode uses the exact same line grammar as `dump`; it simply keeps emitting event lines
/// after the initial snapshot.
///
/// The accepted grammar is:
///
/// ```text
/// stream ::= (line "\n")* line?
/// line ::= header | status | event
///
/// header ::= banner | version | host | my-id
/// banner ::= "BABEL " uint "." uint
/// version ::= "version " text
/// host ::= "host " text
/// my-id ::= "my-id " eui64
///
/// status ::= "ok" | "bad" | ("no" (" " text)?)
///
/// event ::= kind " " (interface | neighbour | xroute | route)
/// kind ::= "add" | "change" | "flush"
///
/// interface ::= "interface " ifname " up " bool
/// (" ipv6 " ip)?
/// (" ipv4 " ipv4)?
///
/// neighbour ::= "neighbour " hex " address " ip " if " ifname
/// " reach " hex " ureach " hex
/// " rxcost " uint " txcost " uint
/// (" rtt " millis " rttcost " uint)?
/// (" external-bias-256 " int " external-coef-256 " uint)?
/// " cost " uint
///
/// xroute ::= "xroute " prefix "-" prefix
/// " prefix " prefix " from " prefix " metric " uint
///
/// route ::= "route " hex
/// " prefix " prefix " from " prefix
/// " installed " yesno
/// " id " eui64
/// " metric " uint " refmetric " uint
/// " via " ip " if " ifname
/// ```
///
/// The accepted grammar is written in a regex/BNF-ish notation:
///
/// - `e1 e2` means concatenation
/// - `e1 | e2` means choice
/// - `e*` means zero or more
/// - `e+` means one or more
/// - `e?` means optional
/// - `(e)` groups expressions
///
/// # Notes
///
/// - The `xroute` summary `prefix-from` token is parsed only to consume the wire format;
/// the later `prefix` and `from` fields are treated as the authoritative values.
/// - The parser is intentionally strict about the documented token set. Internal defensive
/// fallbacks in `babeld` such as `???` are not treated as part of the formal grammar.
pub mod parse {
use crate::babel::Eui64;
use crate::babel::command::{
NEIGHBOUR_COST_BIAS_256_MAX, NEIGHBOUR_COST_BIAS_256_MIN, NEIGHBOUR_COST_COEF_256_MAX,
NEIGHBOUR_COST_COEF_256_MIN,
};
use crate::babel::line::{
BabelLine, Event, EventKind, HeaderLine, InterfaceEvent, NeighbourEvent, RouteEvent,
Status, XRouteEvent,
};
use ipnet::IpNet;
use memchr::memchr;
use std::{
net::{IpAddr, Ipv4Addr},
str::FromStr,
};
use thiserror::Error;
use winnow::{
ascii::{dec_int, dec_uint, hex_uint, space1},
combinator::{alt, eof, opt, preceded, terminated},
error::ContextError,
prelude::*,
token::{rest, take_till},
};
#[derive(Error, Debug)]
pub enum ParseError {
#[error("invalid utf8 in babeld output: {0}")]
InvalidUtf8(#[from] std::str::Utf8Error),
#[error("failed to parse babeld line {line:?}: {error}")]
Syntax { line: String, error: String },
}
/// Zero-copy line framing for already-buffered socket output.
///
/// This is the `stream = { line }` part of the grammar: framing happens first,
/// then each line is parsed independently by `parse_line`.
#[derive(Debug, Clone)]
pub struct RawLines<'a> {
remaining: &'a [u8],
}
impl<'a> RawLines<'a> {
pub fn new(bytes: &'a [u8]) -> Self {
Self { remaining: bytes }
}
}
impl<'a> Iterator for RawLines<'a> {
type Item = Result<&'a str, ParseError>;
fn next(&mut self) -> Option<Self::Item> {
if self.remaining.is_empty() {
return None;
}
let split = memchr(b'\n', self.remaining);
let (line, rest_bytes) = match split {
Some(idx) => (&self.remaining[..idx], &self.remaining[idx + 1..]),
None => (self.remaining, &[][..]),
};
self.remaining = rest_bytes;
let line = if let Some(stripped) = line.strip_suffix(b"\r") {
stripped
} else {
line
};
Some(std::str::from_utf8(line).map_err(ParseError::InvalidUtf8))
}
}
/// Convenience adapter for parsing a fully buffered transcript, e.g. a dump.
#[derive(Debug, Clone)]
pub struct ParsedLines<'a> {
raw: RawLines<'a>,
}
impl<'a> ParsedLines<'a> {
pub fn new(bytes: &'a [u8]) -> Self {
Self {
raw: RawLines::new(bytes),
}
}
}
impl<'a> Iterator for ParsedLines<'a> {
type Item = Result<BabelLine, ParseError>;
fn next(&mut self) -> Option<Self::Item> {
self.raw.next().map(|line| line.and_then(parse_line))
}
}
pub fn parse_line(line: &str) -> Result<BabelLine, ParseError> {
terminated(parse_babel_line, eof)
.parse(line)
.map_err(|err| ParseError::Syntax {
line: line.to_owned(),
error: err.to_string(),
})
}
fn parse_babel_line(input: &mut &str) -> ModalResult<BabelLine> {
alt((
parse_banner,
parse_version,
parse_host,
parse_my_id,
parse_ok,
parse_bad,
parse_no,
parse_event,
))
.parse_next(input)
}
fn parse_banner(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "BABEL ".parse_next(input)?;
let major = dec_uint::<_, u8, _>.parse_next(input)?;
let _ = '.'.parse_next(input)?;
let minor = dec_uint::<_, u8, _>.parse_next(input)?;
Ok(BabelLine::Header(HeaderLine::Banner { major, minor }))
}
fn parse_version(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "version ".parse_next(input)?;
let version = Box::<str>::from(rest.parse_next(input)?);
Ok(BabelLine::Header(HeaderLine::Version(version)))
}
fn parse_host(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "host ".parse_next(input)?;
let host = Box::<str>::from(rest.parse_next(input)?);
Ok(BabelLine::Header(HeaderLine::Host(host)))
}
fn parse_my_id(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "my-id ".parse_next(input)?;
let id = parse_eui64.parse_next(input)?;
Ok(BabelLine::Header(HeaderLine::MyId(id)))
}
fn parse_ok(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "ok".parse_next(input)?;
Ok(BabelLine::Status(Status::Ok))
}
fn parse_bad(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "bad".parse_next(input)?;
Ok(BabelLine::Status(Status::Bad))
}
fn parse_no(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "no".parse_next(input)?;
let message = opt(preceded(space1, rest)).parse_next(input)?;
let message = message.filter(|msg| !msg.is_empty()).map(Into::into);
Ok(BabelLine::Status(Status::No(message)))
}
fn parse_event(input: &mut &str) -> ModalResult<BabelLine> {
let kind = parse_kind.parse_next(input)?;
let _ = ' '.parse_next(input)?;
let entity = parse_word.parse_next(input)?;
match entity {
"interface" => parse_interface_event(kind, input).map(Event::Interface),
"neighbour" => parse_neighbour_event(kind, input).map(Event::Neighbour),
"xroute" => parse_xroute_event(kind, input).map(Event::XRoute),
"route" => parse_route_event(kind, input).map(Event::Route),
_ => Err(winnow::error::ErrMode::Backtrack(ContextError::new())),
}
.map(BabelLine::Event)
}
fn parse_interface_event(kind: EventKind, input: &mut &str) -> ModalResult<InterfaceEvent> {
let _ = ' '.parse_next(input)?;
let ifname = parse_word.parse_next(input)?;
let _ = " up ".parse_next(input)?;
let up = parse_bool.parse_next(input)?;
let ipv6 = opt(preceded(" ipv6 ", parse_ip_addr)).parse_next(input)?;
let ipv4 = opt(preceded(" ipv4 ", parse_ipv4_addr)).parse_next(input)?;
Ok(InterfaceEvent {
kind,
ifname: ifname.into(),
up,
ipv6,
ipv4,
})
}
fn parse_neighbour_event(kind: EventKind, input: &mut &str) -> ModalResult<NeighbourEvent> {
let _ = ' '.parse_next(input)?;
let handle = parse_hex_u64.parse_next(input)?;
let _ = " address ".parse_next(input)?;
let address = parse_ip_addr.parse_next(input)?;
let _ = " if ".parse_next(input)?;
let ifname = parse_word.parse_next(input)?;
let _ = " reach ".parse_next(input)?;
let reach = parse_hex_u16.parse_next(input)?;
let _ = " ureach ".parse_next(input)?;
let ureach = parse_hex_u16.parse_next(input)?;
let _ = " rxcost ".parse_next(input)?;
let rxcost = dec_uint::<_, u32, _>.parse_next(input)?;
let _ = " txcost ".parse_next(input)?;
let txcost = dec_uint::<_, u32, _>.parse_next(input)?;
let rtt = opt(parse_rtt_clause).parse_next(input)?;
let external_cost = opt(parse_external_cost_clause).parse_next(input)?;
let (external_bias_256, external_coef_256) = external_cost.unwrap_or((0, 256));
let _ = " cost ".parse_next(input)?;
let cost = dec_uint::<_, u32, _>.parse_next(input)?;
Ok(NeighbourEvent {
kind,
handle,
address,
ifname: ifname.into(),
reach,
ureach,
rxcost,
txcost,
rtt_millis: rtt.map(|(millis, _)| millis),
rttcost: rtt.map(|(_, cost)| cost),
external_bias_256,
external_coef_256,
cost,
})
}
fn parse_xroute_event(kind: EventKind, input: &mut &str) -> ModalResult<XRouteEvent> {
let _ = ' '.parse_next(input)?;
let _summary_prefix = parse_prefix_until('-').parse_next(input)?;
let _ = '-'.parse_next(input)?;
let _summary_from = parse_prefix.parse_next(input)?;
let _ = " prefix ".parse_next(input)?;
let prefix = parse_prefix.parse_next(input)?;
let _ = " from ".parse_next(input)?;
let from = parse_prefix.parse_next(input)?;
let _ = " metric ".parse_next(input)?;
let metric = dec_uint::<_, u32, _>.parse_next(input)?;
Ok(XRouteEvent {
kind,
prefix,
from,
metric,
})
}
fn parse_route_event<'a>(kind: EventKind, input: &mut &'a str) -> ModalResult<RouteEvent> {
let _ = ' '.parse_next(input)?;
let handle = parse_hex_u64.parse_next(input)?;
let _ = " prefix ".parse_next(input)?;
let prefix = parse_prefix.parse_next(input)?;
let _ = " from ".parse_next(input)?;
let from = parse_prefix.parse_next(input)?;
let _ = " installed ".parse_next(input)?;
let installed = parse_yes_no.parse_next(input)?;
let _ = " id ".parse_next(input)?;
let id = parse_eui64.parse_next(input)?;
let _ = " metric ".parse_next(input)?;
let metric = dec_uint::<_, u32, _>.parse_next(input)?;
let _ = " refmetric ".parse_next(input)?;
let refmetric = dec_uint::<_, u32, _>.parse_next(input)?;
let _ = " via ".parse_next(input)?;
let via = parse_ip_addr.parse_next(input)?;
let _ = " if ".parse_next(input)?;
let ifname = parse_word.parse_next(input)?;
Ok(RouteEvent {
kind,
handle,
prefix,
from,
installed,
id,
metric,
refmetric,
via,
ifname: ifname.into(),
})
}
fn parse_rtt_clause(input: &mut &str) -> ModalResult<(u32, u32)> {
let _ = " rtt ".parse_next(input)?;
let millis = parse_millis.parse_next(input)?;
let _ = " rttcost ".parse_next(input)?;
let rttcost = dec_uint::<_, u32, _>.parse_next(input)?;
Ok((millis, rttcost))
}
fn parse_external_cost_clause(input: &mut &str) -> ModalResult<(i32, u32)> {
let _ = " external-bias-256 ".parse_next(input)?;
let bias_256 = parse_external_bias_256.parse_next(input)?;
let _ = " external-coef-256 ".parse_next(input)?;
let coef_256 = parse_external_coef_256.parse_next(input)?;
Ok((bias_256, coef_256))
}
fn parse_external_bias_256(input: &mut &str) -> ModalResult<i32> {
let value = dec_int::<_, i32, _>.parse_next(input)?;
if (NEIGHBOUR_COST_BIAS_256_MIN..=NEIGHBOUR_COST_BIAS_256_MAX).contains(&value) {
Ok(value)
} else {
Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
}
}
fn parse_external_coef_256(input: &mut &str) -> ModalResult<u32> {
let value = dec_uint::<_, u32, _>.parse_next(input)?;
if (NEIGHBOUR_COST_COEF_256_MIN..=NEIGHBOUR_COST_COEF_256_MAX).contains(&value) {
Ok(value)
} else {
Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
}
}
fn parse_kind(input: &mut &str) -> ModalResult<EventKind> {
alt((
"add".value(EventKind::Add),
"change".value(EventKind::Change),
"flush".value(EventKind::Flush),
))
.parse_next(input)
}
fn parse_bool(input: &mut &str) -> ModalResult<bool> {
alt(("true".value(true), "false".value(false))).parse_next(input)
}
fn parse_yes_no(input: &mut &str) -> ModalResult<bool> {
alt(("yes".value(true), "no".value(false))).parse_next(input)
}
fn parse_ip_addr(input: &mut &str) -> ModalResult<IpAddr> {
parse_word.try_map(IpAddr::from_str).parse_next(input)
}
fn parse_ipv4_addr(input: &mut &str) -> ModalResult<Ipv4Addr> {
parse_word.try_map(Ipv4Addr::from_str).parse_next(input)
}
fn parse_prefix(input: &mut &str) -> ModalResult<IpNet> {
parse_word.try_map(IpNet::from_str).parse_next(input)
}
fn parse_prefix_until(separator: char) -> impl FnMut(&mut &str) -> ModalResult<IpNet> {
move |input: &mut &str| {
let token = take_till(1.., |c: char| c == separator).parse_next(input)?;
IpNet::from_str(token)
.map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
}
}
fn parse_eui64(input: &mut &str) -> ModalResult<Eui64> {
parse_word.try_map(Eui64::from_str).parse_next(input)
}
fn parse_hex_u64(input: &mut &str) -> ModalResult<u64> {
hex_uint.parse_next(input)
}
fn parse_hex_u16(input: &mut &str) -> ModalResult<u16> {
hex_uint.parse_next(input)
}
fn parse_millis(input: &mut &str) -> ModalResult<u32> {
let word = parse_word.parse_next(input)?;
parse_millis_str(word).map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
}
fn parse_word<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
take_till(1.., |c: char| c == ' ').parse_next(input)
}
fn parse_millis_str(value: &str) -> Result<u32, &'static str> {
let (secs, millis) = value
.split_once('.')
.ok_or("missing milliseconds separator")?;
if millis.len() != 3 || !millis.bytes().all(|b| b.is_ascii_digit()) {
return Err("expected 3-digit millisecond suffix");
}
let secs = secs
.parse::<u32>()
.map_err(|_| "invalid seconds field in rtt value")?;
let millis = millis
.parse::<u32>()
.map_err(|_| "invalid milliseconds field in rtt value")?;
secs.checked_mul(1000)
.and_then(|s| s.checked_add(millis))
.ok_or("rtt value overflowed u32")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::babel::line::parse::{ParsedLines, parse_line};
use std::str::FromStr;
#[test]
fn parse_header_banner() {
assert_eq!(
parse_line("BABEL 1.0").unwrap(),
BabelLine::Header(HeaderLine::Banner { major: 1, minor: 0 })
);
}
#[test]
fn parse_header_metadata() {
assert_eq!(
parse_line("version babeld-1.13.1").unwrap(),
BabelLine::Header(HeaderLine::Version("babeld-1.13.1".into()))
);
assert_eq!(
parse_line("host e2").unwrap(),
BabelLine::Header(HeaderLine::Host("e2".into()))
);
assert_eq!(
parse_line("my-id 02:00:00:00:00:00:00:01").unwrap(),
BabelLine::Header(HeaderLine::MyId(Eui64::new(2, 0, 0, 0, 0, 0, 0, 1)))
);
}
#[test]
fn parse_status_lines() {
assert_eq!(parse_line("ok").unwrap(), BabelLine::Status(Status::Ok));
assert_eq!(parse_line("bad").unwrap(), BabelLine::Status(Status::Bad));
assert_eq!(
parse_line("no No such interface").unwrap(),
BabelLine::Status(Status::No(Some("No such interface".into())))
);
}
#[test]
fn parse_interface_event() {
assert_eq!(
parse_line("add interface en2 up true ipv6 fe80::1 ipv4 169.254.1.2").unwrap(),
BabelLine::Event(Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: true,
ipv6: Some(IpAddr::from_str("fe80::1").unwrap()),
ipv4: Some(Ipv4Addr::new(169, 254, 1, 2)),
}))
);
assert_eq!(
parse_line("change interface en3 up false").unwrap(),
BabelLine::Event(Event::Interface(InterfaceEvent {
kind: EventKind::Change,
ifname: "en3".into(),
up: false,
ipv6: None,
ipv4: None,
}))
);
}
#[test]
fn parse_neighbour_event() {
assert_eq!(
parse_line(
"add neighbour 7ffdeadbeef address fe80::1 if en2 reach 00ff ureach 000f rxcost 256 txcost 96 rtt 0.123 rttcost 32 cost 128"
)
.unwrap(),
BabelLine::Event(Event::Neighbour(NeighbourEvent {
kind: EventKind::Add,
handle: 0x7ffdeadbeef,
address: IpAddr::from_str("fe80::1").unwrap(),
ifname: "en2".into(),
reach: 0x00ff,
ureach: 0x000f,
rxcost: 256,
txcost: 96,
rtt_millis: Some(123),
rttcost: Some(32),
external_bias_256: 0,
external_coef_256: 256,
cost: 128,
}))
);
assert_eq!(
parse_line(
"change neighbour 7ffdeadbeef address fe80::1 if en2 reach ffff ureach 000f rxcost 96 txcost 96 external-bias-256 -512 external-coef-256 128 cost 48"
)
.unwrap(),
BabelLine::Event(Event::Neighbour(NeighbourEvent {
kind: EventKind::Change,
handle: 0x7ffdeadbeef,
address: IpAddr::from_str("fe80::1").unwrap(),
ifname: "en2".into(),
reach: 0xffff,
ureach: 0x000f,
rxcost: 96,
txcost: 96,
rtt_millis: None,
rttcost: None,
external_bias_256: -512,
external_coef_256: 128,
cost: 48,
}))
);
assert_eq!(
parse_line(
"change neighbour 7ffdeadbeef address fe80::1 if en2 reach ffff ureach 000f rxcost 96 txcost 96 rtt 0.123 rttcost 32 external-bias-256 512 external-coef-256 512 cost 224"
)
.unwrap(),
BabelLine::Event(Event::Neighbour(NeighbourEvent {
kind: EventKind::Change,
handle: 0x7ffdeadbeef,
address: IpAddr::from_str("fe80::1").unwrap(),
ifname: "en2".into(),
reach: 0xffff,
ureach: 0x000f,
rxcost: 96,
txcost: 96,
rtt_millis: Some(123),
rttcost: Some(32),
external_bias_256: 512,
external_coef_256: 512,
cost: 224,
}))
);
}
#[test]
fn parse_neighbour_event_rejects_out_of_range_external_cost() {
assert!(
parse_line(
"change neighbour 7ffdeadbeef address fe80::1 if en2 reach ffff ureach 000f rxcost 96 txcost 96 external-bias-256 16776705 external-coef-256 256 cost 96"
)
.is_err()
);
assert!(
parse_line(
"change neighbour 7ffdeadbeef address fe80::1 if en2 reach ffff ureach 000f rxcost 96 txcost 96 external-bias-256 0 external-coef-256 65536 cost 96"
)
.is_err()
);
}
#[test]
fn parse_xroute_event() {
assert_eq!(
parse_line(
"add xroute fd00::1/128-fd00::/64 prefix fd00::1/128 from fd00::/64 metric 0"
)
.unwrap(),
BabelLine::Event(Event::XRoute(XRouteEvent {
kind: EventKind::Add,
prefix: IpNet::from_str("fd00::1/128").unwrap(),
from: IpNet::from_str("fd00::/64").unwrap(),
metric: 0,
}))
);
}
#[test]
fn parse_route_event() {
assert_eq!(
parse_line(
"change route 7ffdeadbeef prefix fd00::1/128 from fd00::/64 installed yes id 02:00:00:00:00:00:00:01 metric 96 refmetric 0 via fe80::2 if en2"
)
.unwrap(),
BabelLine::Event(Event::Route(RouteEvent {
kind: EventKind::Change,
handle: 0x7ffdeadbeef,
prefix: IpNet::from_str("fd00::1/128").unwrap(),
from: IpNet::from_str("fd00::/64").unwrap(),
installed: true,
id: Eui64::new(2, 0, 0, 0, 0, 0, 0, 1),
metric: 96,
refmetric: 0,
via: IpAddr::from_str("fe80::2").unwrap(),
ifname: "en2".into(),
}))
);
}
#[test]
fn raw_lines_uses_memchr_framing() {
let bytes = b"BABEL 1.0\nok\nadd interface en2 up false\n";
let parsed = ParsedLines::new(bytes)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
parsed,
vec![
BabelLine::Header(HeaderLine::Banner { major: 1, minor: 0 }),
BabelLine::Status(Status::Ok),
BabelLine::Event(Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
})),
]
);
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Temporary link-selection policy for broad macOS interface admission.
//!
//! The current MVP admits every usable interface and then steers Babel by
//! assigning each `enN` neighbour an absolute synthetic base cost of `N * 100`,
//! except that `en0` and `en1` are assigned the largest finite Babel cost. This is
//! intentionally a stopgap until measured link scoring lands.
use std::net::IpAddr;
use crate::babel::command::{
NEIGHBOUR_COST_BIAS_256_MAX, NeighbourCostBias256, NeighbourCostCoef256,
NeighbourCostCommand,
};
use crate::babel::line::{EventKind, NeighbourEvent};
use crate::babel::state::NeighbourState;
const EN_INDEX_COST_UNITS: u64 = 100;
const FIXED_POINT_SCALE: u64 = 256;
const ABSOLUTE_COST_COEF_256: u32 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DesiredNeighbourCost {
bias_256: NeighbourCostBias256,
coef_256: NeighbourCostCoef256,
}
pub(crate) fn command_for_neighbour_event(event: &NeighbourEvent) -> Option<NeighbourCostCommand> {
if !matches!(event.kind, EventKind::Add | EventKind::Change) {
return None;
}
command_for_neighbour(
&event.ifname,
event.address,
event.external_bias_256,
event.external_coef_256,
)
}
pub(crate) fn command_for_neighbour_state(
neighbour: &NeighbourState,
) -> Option<NeighbourCostCommand> {
command_for_neighbour(
&neighbour.ifname,
neighbour.address,
neighbour.external_bias_256,
neighbour.external_coef_256,
)
}
fn command_for_neighbour(
ifname: &str,
address: IpAddr,
external_bias_256: i32,
external_coef_256: u32,
) -> Option<NeighbourCostCommand> {
let desired = desired_en_index_cost(ifname)?;
if external_bias_256 == desired.bias_256.raw() && external_coef_256 == desired.coef_256.raw() {
return None;
}
let IpAddr::V6(link_local_neighbour) = address else {
return None;
};
NeighbourCostCommand::new(
ifname,
link_local_neighbour,
desired.bias_256,
desired.coef_256,
)
.ok()
}
fn desired_en_index_cost(ifname: &str) -> Option<DesiredNeighbourCost> {
let index = parse_en_index(ifname)?;
let bias_256 = if index <= 1 {
NEIGHBOUR_COST_BIAS_256_MAX
} else {
let bias_256 = u64::from(index)
.checked_mul(EN_INDEX_COST_UNITS)?
.checked_mul(FIXED_POINT_SCALE)?;
i32::try_from(bias_256).ok()?
};
let bias_256 = NeighbourCostBias256::new(bias_256)?;
let coef_256 = NeighbourCostCoef256::new(ABSOLUTE_COST_COEF_256)
.expect("absolute-cost coefficient is within babeld's accepted range");
Some(DesiredNeighbourCost { bias_256, coef_256 })
}
fn parse_en_index(ifname: &str) -> Option<u32> {
let suffix = ifname.strip_prefix("en")?;
if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
suffix.parse().ok()
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use super::{command_for_neighbour_event, command_for_neighbour_state};
use crate::babel::line::{EventKind, NeighbourEvent};
use crate::babel::state::NeighbourState;
fn neighbour_event(ifname: &str, bias: i32, coef: u32) -> NeighbourEvent {
NeighbourEvent {
kind: EventKind::Change,
handle: 0x42,
address: "fe80::1".parse().unwrap(),
ifname: ifname.into(),
reach: 0xffff,
ureach: 0,
rxcost: 96,
txcost: 96,
rtt_millis: None,
rttcost: None,
external_bias_256: bias,
external_coef_256: coef,
cost: 96,
}
}
fn neighbour_state(ifname: &str, bias: i32, coef: u32) -> NeighbourState {
NeighbourState {
handle: 0x42,
address: "fe80::1".parse().unwrap(),
ifname: ifname.into(),
reach: 0xffff,
ureach: 0,
rxcost: 96,
txcost: 96,
rtt_millis: None,
rttcost: None,
external_bias_256: bias,
external_coef_256: coef,
cost: 96,
}
}
#[test]
fn en_index_policy_sets_absolute_cost() {
let command = command_for_neighbour_event(&neighbour_event("en18", 0, 256)).unwrap();
assert_eq!(
command.to_string(),
"neighbour-cost en18 fe80::1 bias-256 460800 coef-256 0"
);
}
#[test]
fn en_index_policy_deprioritizes_en0_and_en1() {
let command = command_for_neighbour_event(&neighbour_event("en0", 0, 256)).unwrap();
assert_eq!(
command.to_string(),
"neighbour-cost en0 fe80::1 bias-256 16776704 coef-256 0"
);
let command = command_for_neighbour_event(&neighbour_event("en1", 0, 256)).unwrap();
assert_eq!(
command.to_string(),
"neighbour-cost en1 fe80::1 bias-256 16776704 coef-256 0"
);
}
#[test]
fn en_index_policy_skips_already_configured_neighbour() {
assert!(command_for_neighbour_state(&neighbour_state("en2", 51_200, 0)).is_none());
assert!(
command_for_neighbour_state(&neighbour_state("en0", 16_776_704, 0)).is_none()
);
assert!(
command_for_neighbour_state(&neighbour_state("en1", 16_776_704, 0)).is_none()
);
}
#[test]
fn en_index_policy_ignores_non_en_or_non_link_local_neighbours() {
assert!(command_for_neighbour_event(&neighbour_event("awdl0", 0, 256)).is_none());
let mut ipv4 = neighbour_event("en2", 0, 256);
ipv4.address = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2));
assert!(command_for_neighbour_event(&ipv4).is_none());
let mut non_link_local = neighbour_event("en2", 0, 256);
non_link_local.address = "2001:db8::1".parse().unwrap();
assert!(command_for_neighbour_event(&non_link_local).is_none());
}
#[test]
fn en_index_policy_ignores_flush_events() {
let mut event = neighbour_event("en2", 0, 256);
event.kind = EventKind::Flush;
assert!(command_for_neighbour_event(&event).is_none());
}
}
+38
View File
@@ -0,0 +1,38 @@
use ipnet::Ipv6Net;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use crate::Result;
pub mod command;
pub mod line;
pub mod link_policy;
pub mod runtime;
pub mod state;
use runtime::BabelRuntime;
/// An EUI-64 type aliased to [`macaddr::MacAddr8`].
pub type Eui64 = macaddr::MacAddr8;
pub use command::{
NeighbourCostBias256, NeighbourCostCoef256, NeighbourCostCommand, NeighbourCostCommandError,
};
pub use state::BabelState;
#[derive(Debug)]
pub enum Babble {
AddIface(Box<str>),
SetNeighbourCost(NeighbourCostCommand),
}
#[tracing::instrument(skip(state_send, recv))]
pub async fn babel(
advertised: Ipv6Net,
recv: mpsc::Receiver<Babble>,
state_send: watch::Sender<Arc<BabelState>>,
) -> Result<()> {
let mut runtime = BabelRuntime::spawn(advertised, state_send).await?;
let res1 = runtime.run(recv).await;
let res2 = runtime.shutdown().await;
res1.and(res2)
}
+487
View File
@@ -0,0 +1,487 @@
//! Managed `babeld` runtime for `babblerd`.
//!
//! This module owns the full lifecycle of the private `babeld` instance:
//!
//! - spawn-time configuration of the child process
//! - the private Unix socket path used for the local control connection
//! - connecting to that socket and speaking the local Babel protocol
//! - running the monitor-driven control loop
//! - shutdown and cleanup of the child process and socket
//!
//! Unlike the old `process` / `session` split, this is intended to model the real runtime unit:
//! a single managed `babeld` process together with its single local control session.
use std::fs::Permissions;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use ipnet::Ipv6Net;
use nix::errno::Errno;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, watch};
use tokio::time::{Duration, MissedTickBehavior, timeout};
use crate::babel::Babble;
use crate::babel::command::BabelCommand;
use crate::babel::line::parse::ParseError;
use crate::babel::line::{self, BabelLine, Event, HeaderLine, NeighbourEvent, Status};
use crate::babel::link_policy;
use crate::babel::state::BabelState;
use crate::{BabbleError, Result};
#[cfg(target_os = "macos")]
const PRIVATE_SOCK_PATH: &str = "/var/run/babbler/private/babeld.sock";
#[cfg(target_os = "linux")]
const PRIVATE_SOCK_PATH: &str = "/run/babbler/private/babeld.sock";
#[cfg(target_os = "macos")]
const PRIVATE_DIR: &str = "/var/run/babbler/private";
#[cfg(target_os = "linux")]
const PRIVATE_DIR: &str = "/run/babbler/private";
const STARTUP_SOCKET_TIMEOUT: Duration = Duration::from_secs(10);
const STARTUP_SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) struct BabelRuntime {
proc: Child,
read: Lines<BufReader<OwnedReadHalf>>,
write: OwnedWriteHalf,
state_send: watch::Sender<Arc<BabelState>>,
state: BabelState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartupStage {
Banner,
Version,
Host,
MyId,
Ready,
}
impl StartupStage {
fn advance(self, line: BabelLine) -> Result<Option<Self>> {
match (self, line) {
(Self::Banner, BabelLine::Header(HeaderLine::Banner { major: 1, minor: 0 })) => {
Ok(Some(Self::Version))
}
(Self::Version, BabelLine::Header(HeaderLine::Version(_))) => Ok(Some(Self::Host)),
(Self::Host, BabelLine::Header(HeaderLine::Host(_))) => Ok(Some(Self::MyId)),
(Self::MyId, BabelLine::Header(HeaderLine::MyId(_))) => Ok(Some(Self::Ready)),
(Self::Ready, BabelLine::Status(Status::Ok)) => Ok(None),
(stage, other) => Err(BabbleError::Other(format!(
"unexpected babeld startup line while waiting for {stage:?}: {other:?}"
))),
}
}
}
impl Drop for BabelRuntime {
#[inline]
fn drop(&mut self) {
// Emergency SIGKILL to avoid leaking an unmanaged babeld subprocess.
match self.proc.try_wait() {
Ok(None) => {}
Ok(Some(sc)) => {
if !sc.success() {
_ = self.proc.start_kill();
}
}
_ => {
_ = self.proc.start_kill();
}
}
}
}
impl BabelRuntime {
#[tracing::instrument(skip(state_send))]
pub(crate) async fn spawn(
advertised: Ipv6Net,
state_send: watch::Sender<Arc<BabelState>>,
) -> Result<Self> {
tokio::fs::create_dir_all(PRIVATE_DIR).await?;
// TODO: remove this magic constant (and magic constants in general)
tokio::fs::set_permissions(PRIVATE_DIR, Permissions::from_mode(0o0700)).await?;
tracing::info!("spawning babeld socket in {PRIVATE_SOCK_PATH}");
let mut proc = match Command::new("babeld")
.arg("-G")
.arg(PRIVATE_SOCK_PATH)
.arg("-I")
.arg(format!("{PRIVATE_DIR}/babeld.pid"))
.arg("-C")
.arg("kernel-install false")
.arg("-C")
.arg(format!("redistribute local ip {advertised}"))
.arg("-C")
.arg("redistribute local deny")
.spawn()
{
Ok(proc) => {
tracing::info!(
"babeld spawned PID={}",
proc.id().expect("babeld process shouldn't die this early")
);
proc
}
Err(e) => {
tracing::warn!(error=%e, "failed to spawn babeld");
return Err(e.into());
}
};
if let Err(err) = Self::wait_for_socket(&mut proc).await {
Self::abort_child(&mut proc).await;
return Err(err);
}
// TODO: magic undocumented number
if let Err(err) =
std::fs::set_permissions(PRIVATE_SOCK_PATH, Permissions::from_mode(0o0600))
{
Self::abort_child(&mut proc).await;
return Err(err.into());
}
let (reader, write) = match UnixStream::connect(PRIVATE_SOCK_PATH).await {
Ok(stream) => stream.into_split(),
Err(err) => {
Self::abort_child(&mut proc).await;
return Err(err.into());
}
};
let mut runtime = Self {
proc,
read: BufReader::new(reader).lines(),
write,
state_send,
state: BabelState::new(),
};
if let Err(err) = runtime.await_ready().await {
let _ = runtime.shutdown().await;
return Err(err);
}
Ok(runtime)
}
async fn wait_for_socket(proc: &mut Child) -> Result<()> {
timeout(STARTUP_SOCKET_TIMEOUT, async {
let mut poll = tokio::time::interval(STARTUP_SOCKET_POLL_INTERVAL);
poll.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
poll.tick().await;
match tokio::fs::try_exists(PRIVATE_SOCK_PATH).await {
Ok(true) => return Ok(()),
Ok(false) => {}
Err(err) => return Err(err.into()),
}
if let Some(status) = proc.try_wait()? {
return Err(BabbleError::BabeldCrashed(status.code()));
}
}
})
.await
.unwrap_or_else(|_| {
Err(BabbleError::Other(format!(
"timed out after {}s waiting for babeld socket {PRIVATE_SOCK_PATH}",
STARTUP_SOCKET_TIMEOUT.as_secs()
)))
})
}
async fn abort_child(proc: &mut Child) {
let _ = proc.kill().await;
}
#[tracing::instrument(skip_all)]
async fn await_ready(&mut self) -> Result<()> {
let mut stage = StartupStage::Banner;
while let Some(line) = self.read.next_line().await? {
match self.observe_line(line)? {
Ok(parsed) => match stage.advance(parsed)? {
Some(next) => stage = next,
None => {
tracing::info!("babeld ok");
return Ok(());
}
},
Err(err) => {
return Err(BabbleError::Other(format!(
"failed to parse babeld startup prelude: {err}"
)));
}
}
}
Err(BabbleError::Other(
"babeld closed before completing startup prelude".into(),
))
}
#[tracing::instrument(skip(self))]
async fn query(&mut self, cmd: &BabelCommand) -> io::Result<Option<Status>> {
self.write.write_all(cmd.encode().as_bytes()).await?;
loop {
let Some(line) = self.read.next_line().await? else {
tracing::warn!("babeld closed unexpectedly");
return Ok(None);
};
match self.observe_line(line)? {
Ok(parsed) => {
let status = self.reduce_live_line(parsed)?;
let Some(status) = status else {
continue;
};
match &status {
Status::Ok => {}
Status::Bad => tracing::warn!("malformed message sent to babeld"),
Status::No(rest) => tracing::warn!("message rejected: {rest:?}"),
}
return Ok(Some(status));
}
Err(err) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse babeld command output: {err}"),
));
}
}
}
}
#[tracing::instrument(skip(self))]
fn observe_line(&self, line: String) -> io::Result<std::result::Result<BabelLine, ParseError>> {
tracing::info!("[babel] {:?}", line);
let observed = match line::parse::parse_line(&line) {
Ok(parsed) => {
tracing::info!("[parsed] {:?}", parsed);
Ok(parsed)
}
Err(err) => {
tracing::error!(error=%err, "failed to parse babeld line");
Err(err)
}
};
Ok(observed)
}
#[tracing::instrument(skip(self))]
async fn start_monitoring(&mut self) -> io::Result<Option<Status>> {
let mut snapshot = BabelState::new();
self.write
.write_all(BabelCommand::Monitor.encode().as_bytes())
.await?;
loop {
let Some(line) = self.read.next_line().await? else {
tracing::warn!("babeld closed unexpectedly");
return Ok(None);
};
match self.observe_line(line)? {
Ok(BabelLine::Event(event)) => {
snapshot.apply(event);
}
Ok(BabelLine::Status(status)) => {
match &status {
Status::Ok => {
self.state = snapshot;
self.publish_state();
}
Status::Bad => tracing::warn!("malformed message sent to babeld"),
Status::No(rest) => tracing::warn!("message rejected: {rest:?}"),
}
return Ok(Some(status));
}
Ok(BabelLine::Header(header)) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected header line during monitor bootstrap: {header:?}"),
));
}
Err(err) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse babeld monitor bootstrap output: {err}"),
));
}
}
}
}
fn publish_state(&self) {
self.state_send.send_replace(Arc::new(self.state.clone()));
}
fn reduce_live_line(&mut self, line: BabelLine) -> io::Result<Option<Status>> {
match line {
BabelLine::Event(event) => {
self.state.apply(event);
self.publish_state();
Ok(None)
}
BabelLine::Status(status) => Ok(Some(status)),
BabelLine::Header(header) => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected header line after startup: {header:?}"),
)),
}
}
#[tracing::instrument(skip(self))]
async fn reconcile_neighbour_cost_policy(&mut self) -> io::Result<()> {
let commands = self
.state
.neighbours
.values()
.filter_map(link_policy::command_for_neighbour_state)
.collect::<Vec<_>>();
for command in commands {
tracing::info!(%command, "applying en-index neighbour-cost policy");
let command = BabelCommand::NeighbourCost(command);
self.query(&command).await?;
}
Ok(())
}
#[tracing::instrument(skip(self, neighbour))]
async fn apply_neighbour_cost_policy(&mut self, neighbour: &NeighbourEvent) -> io::Result<()> {
let Some(command) = link_policy::command_for_neighbour_event(neighbour) else {
return Ok(());
};
tracing::info!(%command, "applying en-index neighbour-cost policy");
let command = BabelCommand::NeighbourCost(command);
self.query(&command).await?;
Ok(())
}
#[tracing::instrument(skip_all)]
pub(crate) async fn run(&mut self, mut recv: mpsc::Receiver<Babble>) -> Result<()> {
match self.start_monitoring().await? {
Some(Status::Ok) => {}
Some(Status::Bad) => {
return Err(BabbleError::Other(
"babeld rejected monitor command as malformed".into(),
));
}
Some(Status::No(reason)) => {
return Err(BabbleError::Other(format!(
"babeld rejected monitor command: {reason:?}"
)));
}
None => {
return Err(BabbleError::Other(
"babeld control socket closed during monitor bootstrap".into(),
));
}
}
self.reconcile_neighbour_cost_policy().await?;
loop {
tokio::select! {
babble = recv.recv() => {
tracing::debug!("[babble] {:?}", babble);
let Some(babble) = babble else {
break;
};
match babble {
Babble::AddIface(iface) => {
let cmd = BabelCommand::Interface(iface);
self.query(&cmd).await?;
self.reconcile_neighbour_cost_policy().await?;
}
Babble::SetNeighbourCost(neighbour_cost) => {
let cmd = BabelCommand::NeighbourCost(neighbour_cost);
self.query(&cmd).await?;
}
}
},
line = self.read.next_line() => {
let line = match line {
Ok(Some(line)) => line,
Ok(None) => {
return Err(BabbleError::Other(
"babeld control socket closed during live monitoring".into(),
));
}
Err(err) => {
return Err(BabbleError::Other(format!(
"failed to read babeld control socket during live monitoring: {err}"
)));
}
};
match self.observe_line(line)? {
Ok(parsed) => {
let neighbour = match &parsed {
BabelLine::Event(Event::Neighbour(neighbour)) => {
Some(neighbour.clone())
}
_ => None,
};
if let Some(status) = self.reduce_live_line(parsed)? {
tracing::debug!(?status, "ignoring unsolicited status line from babeld");
}
if let Some(neighbour) = neighbour {
self.apply_neighbour_cost_policy(&neighbour).await?;
}
}
Err(err) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse babeld monitor output: {err}"),
)
.into());
}
}
},
}
}
Ok(())
}
pub(crate) async fn shutdown(mut self) -> Result<()> {
let kill_res = if let Some(pid) = self.proc.id() {
let pid: i32 = pid.try_into().expect("pid overflow");
let rc_err = match kill(Pid::from_raw(pid), Signal::SIGINT) {
Ok(()) | Err(Errno::ESRCH) => Ok(()),
Err(err) => Err(io::Error::from_raw_os_error(err as i32).into()),
};
match timeout(SHUTDOWN_TIMEOUT, self.proc.wait()).await {
Ok(Ok(code)) => {
if code.success() {
rc_err
} else {
rc_err.and_then(|()| Err(BabbleError::BabeldCrashed(code.code())))
}
}
Ok(Err(e)) => Err(e.into()),
Err(_) => {
self.proc.kill().await?;
rc_err.and(Err(BabbleError::BabeldCrashed(None)))
}
}
} else {
Ok(())
};
let rem_res = match std::fs::remove_file(PRIVATE_SOCK_PATH) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
};
kill_res.and(rem_res)
}
}
+411
View File
@@ -0,0 +1,411 @@
//! Reduced in-memory state derived from `babeld` event lines.
//!
//! This module is the consumer-side counterpart to [`crate::babel::line`]:
//!
//! - [`Event`] is the wire/domain event stream emitted by `babeld`
//! - [`BabelState`] is the current snapshot obtained by reducing those events
//!
//! The reducer model is intentionally simple:
//!
//! - `add` inserts the entity into the relevant table
//! - `change` upserts the entity into the relevant table
//! - `flush` removes the entity from the relevant table
//!
//! The stored state types do **not** retain [`EventKind`], because the event
//! kind is transport/update metadata rather than persistent object state.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use crate::babel::Eui64;
use crate::babel::line::{
Event, EventKind, InterfaceEvent, NeighbourEvent, RouteEvent, XRouteEvent,
};
use ipnet::IpNet;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BabelState {
pub interfaces: HashMap<Box<str>, InterfaceState>,
pub neighbours: HashMap<u64, NeighbourState>,
pub xroutes: HashMap<XRouteKey, XRouteState>,
pub routes: HashMap<u64, RouteState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceState {
pub ifname: Box<str>,
pub up: bool,
pub ipv6: Option<IpAddr>,
pub ipv4: Option<Ipv4Addr>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeighbourState {
pub handle: u64,
pub address: IpAddr,
pub ifname: Box<str>,
pub reach: u16,
pub ureach: u16,
pub rxcost: u32,
pub txcost: u32,
pub rtt_millis: Option<u32>,
pub rttcost: Option<u32>,
pub external_bias_256: i32,
pub external_coef_256: u32,
pub cost: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct XRouteKey {
pub prefix: IpNet,
pub from: IpNet,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XRouteState {
pub prefix: IpNet,
pub from: IpNet,
pub metric: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteState {
pub handle: u64,
pub prefix: IpNet,
pub from: IpNet,
pub installed: bool,
pub id: Eui64,
pub metric: u32,
pub refmetric: u32,
pub via: IpAddr,
pub ifname: Box<str>,
}
impl BabelState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn apply(&mut self, event: Event) {
match event {
Event::Interface(event) => self.apply_interface(event),
Event::Neighbour(event) => self.apply_neighbour(event),
Event::XRoute(event) => self.apply_xroute(event),
Event::Route(event) => self.apply_route(event),
}
}
pub fn extend<I>(&mut self, events: I)
where
I: IntoIterator<Item = Event>,
{
for event in events {
self.apply(event);
}
}
fn apply_interface(&mut self, event: InterfaceEvent) {
let key = event.ifname.clone();
match event.kind {
EventKind::Add | EventKind::Change => {
self.interfaces.insert(key, event.into());
}
EventKind::Flush => {
self.interfaces.remove(&key);
}
}
}
fn apply_neighbour(&mut self, event: NeighbourEvent) {
let key = event.handle;
match event.kind {
EventKind::Add | EventKind::Change => {
self.neighbours.insert(key, event.into());
}
EventKind::Flush => {
self.neighbours.remove(&key);
}
}
}
fn apply_xroute(&mut self, event: XRouteEvent) {
let key = XRouteKey {
prefix: event.prefix,
from: event.from,
};
match event.kind {
EventKind::Add | EventKind::Change => {
self.xroutes.insert(key, event.into());
}
EventKind::Flush => {
self.xroutes.remove(&key);
}
}
}
fn apply_route(&mut self, event: RouteEvent) {
let key = event.handle;
match event.kind {
EventKind::Add | EventKind::Change => {
self.routes.insert(key, event.into());
}
EventKind::Flush => {
self.routes.remove(&key);
}
}
}
}
impl From<InterfaceEvent> for InterfaceState {
fn from(event: InterfaceEvent) -> Self {
Self {
ifname: event.ifname,
up: event.up,
ipv6: event.ipv6,
ipv4: event.ipv4,
}
}
}
impl From<NeighbourEvent> for NeighbourState {
fn from(event: NeighbourEvent) -> Self {
Self {
handle: event.handle,
address: event.address,
ifname: event.ifname,
reach: event.reach,
ureach: event.ureach,
rxcost: event.rxcost,
txcost: event.txcost,
rtt_millis: event.rtt_millis,
rttcost: event.rttcost,
external_bias_256: event.external_bias_256,
external_coef_256: event.external_coef_256,
cost: event.cost,
}
}
}
impl From<XRouteEvent> for XRouteState {
fn from(event: XRouteEvent) -> Self {
Self {
prefix: event.prefix,
from: event.from,
metric: event.metric,
}
}
}
impl From<RouteEvent> for RouteState {
fn from(event: RouteEvent) -> Self {
Self {
handle: event.handle,
prefix: event.prefix,
from: event.from,
installed: event.installed,
id: event.id,
metric: event.metric,
refmetric: event.refmetric,
via: event.via,
ifname: event.ifname,
}
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use super::{BabelState, InterfaceState, XRouteKey};
use crate::babel::Eui64;
use crate::babel::line::{
Event, EventKind, InterfaceEvent, NeighbourEvent, RouteEvent, XRouteEvent,
};
use ipnet::IpNet;
fn net(s: &str) -> IpNet {
s.parse().unwrap()
}
#[test]
fn interface_add_change_flush() {
let mut state = BabelState::new();
state.apply(Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: true,
ipv6: Some(IpAddr::V6(Ipv6Addr::LOCALHOST)),
ipv4: Some(Ipv4Addr::new(169, 254, 1, 2)),
}));
assert_eq!(
state.interfaces.get("en2"),
Some(&InterfaceState {
ifname: "en2".into(),
up: true,
ipv6: Some(IpAddr::V6(Ipv6Addr::LOCALHOST)),
ipv4: Some(Ipv4Addr::new(169, 254, 1, 2)),
})
);
state.apply(Event::Interface(InterfaceEvent {
kind: EventKind::Change,
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
}));
assert_eq!(
state.interfaces.get("en2"),
Some(&InterfaceState {
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
})
);
state.apply(Event::Interface(InterfaceEvent {
kind: EventKind::Flush,
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
}));
assert!(!state.interfaces.contains_key("en2"));
}
#[test]
fn neighbour_add_and_flush() {
let mut state = BabelState::new();
state.apply(Event::Neighbour(NeighbourEvent {
kind: EventKind::Add,
handle: 0xabc,
address: IpAddr::V6("fe80::1".parse().unwrap()),
ifname: "en3".into(),
reach: 0x00ff,
ureach: 0x000f,
rxcost: 96,
txcost: 128,
rtt_millis: Some(42),
rttcost: Some(10),
external_bias_256: 4096,
external_coef_256: 128,
cost: 224,
}));
assert_eq!(state.neighbours.len(), 1);
assert_eq!(state.neighbours.get(&0xabc).unwrap().ifname.as_ref(), "en3");
assert_eq!(
state.neighbours.get(&0xabc).unwrap().external_bias_256,
4096
);
assert_eq!(state.neighbours.get(&0xabc).unwrap().external_coef_256, 128);
state.apply(Event::Neighbour(NeighbourEvent {
kind: EventKind::Flush,
handle: 0xabc,
address: IpAddr::V6("fe80::1".parse().unwrap()),
ifname: "en3".into(),
reach: 0,
ureach: 0,
rxcost: 0,
txcost: 0,
rtt_millis: None,
rttcost: None,
external_bias_256: 0,
external_coef_256: 256,
cost: 0,
}));
assert!(state.neighbours.is_empty());
}
#[test]
fn xroute_change_upserts_by_prefix_pair() {
let mut state = BabelState::new();
state.apply(Event::XRoute(XRouteEvent {
kind: EventKind::Add,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
metric: 256,
}));
state.apply(Event::XRoute(XRouteEvent {
kind: EventKind::Change,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
metric: 42,
}));
assert_eq!(state.xroutes.len(), 1);
assert_eq!(
state
.xroutes
.get(&XRouteKey {
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
})
.unwrap()
.metric,
42
);
}
#[test]
fn route_add_and_flush_by_handle() {
let mut state = BabelState::new();
state.apply(Event::Route(RouteEvent {
kind: EventKind::Add,
handle: 0xdeadbeef,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
installed: true,
id: Eui64::new(0, 1, 2, 3, 4, 5, 6, 7),
metric: 96,
refmetric: 96,
via: IpAddr::V6("fe80::1234".parse().unwrap()),
ifname: "en2".into(),
}));
assert_eq!(state.routes.len(), 1);
assert!(state.routes.get(&0xdeadbeef).unwrap().installed);
state.apply(Event::Route(RouteEvent {
kind: EventKind::Flush,
handle: 0xdeadbeef,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
installed: false,
id: Eui64::new(0, 1, 2, 3, 4, 5, 6, 7),
metric: 0,
refmetric: 0,
via: IpAddr::V6("fe80::1234".parse().unwrap()),
ifname: "en2".into(),
}));
assert!(state.routes.is_empty());
}
#[test]
fn extend_applies_multiple_events() {
let mut state = BabelState::new();
state.extend([
Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: true,
ipv6: None,
ipv4: None,
}),
Event::XRoute(XRouteEvent {
kind: EventKind::Add,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
metric: 123,
}),
]);
assert_eq!(state.interfaces.len(), 1);
assert_eq!(state.xroutes.len(), 1);
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Process configuration and shared constants for `babblerd`.
//!
//! This module centralizes:
//!
//! - default runtime paths
//! - environment variable overrides
//! - protocol/application constants such as the mesh prefix
//! - coarse daemon defaults such as the router UDP port
use color_eyre::eyre::{self, eyre};
use ipnet::Ipv6Net;
use std::collections::HashSet;
use std::env;
use std::fmt::{Display, Formatter};
use std::net::Ipv6Addr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
pub const PUBLIC_SOCKET_PATH_ENV: &str = "BABBLER_SOCKET_PATH";
pub const NODE_ID_FILE_ENV: &str = "BABBLER_NODE_ID_FILE";
pub const ROUTER_UDP_PORT_ENV: &str = "BABBLER_ROUTER_UDP_PORT";
pub const ROUTER_TRANSPORT_ENV: &str = "BABBLER_ROUTER_TRANSPORT";
pub const TUN_MTU_ENV: &str = "BABBLER_TUN_MTU";
pub const TCP_BATCH_TARGET_BYTES_ENV: &str = "BABBLER_TCP_BATCH_TARGET_BYTES";
pub const TCP_SOCKET_BUFFER_BYTES_ENV: &str = "BABBLER_TCP_SOCKET_BUFFER_BYTES";
pub const INTERFACE_ALLOWLIST_ENV: &str = "BABBLER_INTERFACE_ALLOWLIST";
pub const DEFAULT_PUBLIC_SOCKET_PATH: &str = {
#[cfg(target_os = "macos")]
{
"/var/run/babbler/babblerd.sock"
}
#[cfg(target_os = "linux")]
{
"/run/babbler/babblerd.sock"
}
};
pub const DEFAULT_NODE_ID_FILE: &str = {
#[cfg(target_os = "macos")]
{
"/var/db/babbler/node-id"
}
#[cfg(target_os = "linux")]
{
"/var/lib/babbler/node-id"
}
};
// TODO: just picked a random one that didn't seem occupied, there is probably a better way
// to do this in the future :)
pub const DEFAULT_ROUTER_UDP_PORT: u16 = 41897;
pub const PHYSICAL_LINK_MTU: u16 = 1500;
pub const OUTER_IPV6_HEADER_BYTES: u16 = 40;
pub const OUTER_UDP_HEADER_BYTES: u16 = 8;
pub const UDP_TUN_MTU: u16 = PHYSICAL_LINK_MTU - OUTER_IPV6_HEADER_BYTES - OUTER_UDP_HEADER_BYTES;
pub const TCP_TUN_MTU: u16 = u16::MAX;
pub const MIN_TUN_MTU: u16 = 1280;
pub const MAX_TUN_MTU: u16 = u16::MAX;
pub const TUN_MTU: u16 = UDP_TUN_MTU;
pub const DEFAULT_TCP_BATCH_TARGET_BYTES: usize = 256 * 1024;
pub const TCP_PENDING_LIMIT_BYTES: usize = 4 * 1024 * 1024;
pub const DEFAULT_TCP_SOCKET_BUFFER_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_TCP_SOCKET_BUFFER_BYTES: usize = 512 * 1024 * 1024;
pub const EXO_ULA_PREFIX: Ipv6Net = Ipv6Net::new_assert(
// TODO: break out into "fd" for ULA
// e0_20c61fa7 for EXO address-space
// ffff for anything else we want, like maybe versioning and so on (but for now its not used)
//
// NOTE: spell the hextets explicitly here. A previous `u128` bit-shift
// construction accidentally truncated the leading `fde0` and produced
// `20c6:1fa7:ffff::/64`, which is not ULA.
Ipv6Addr::new(0xfde0, 0x20c6, 0x1fa7, 0xffff, 0, 0, 0, 0),
64,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportMode {
Udp,
Tcp,
}
impl Display for TransportMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Udp => write!(f, "udp"),
Self::Tcp => write!(f, "tcp"),
}
}
}
impl FromStr for TransportMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"udp" => Ok(Self::Udp),
"tcp" => Ok(Self::Tcp),
other => Err(format!("expected udp or tcp, got {other:?}")),
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub public_socket_path: PathBuf,
pub public_dir: PathBuf,
pub node_id_file: PathBuf,
pub router_udp_port: u16,
pub router_transport: TransportMode,
pub tun_mtu: u16,
pub tcp_batch_target_bytes: usize,
pub tcp_socket_buffer_bytes: usize,
pub exo_ula_prefix: Ipv6Net,
}
impl Config {
pub fn from_env() -> eyre::Result<Self> {
let public_socket_path = env::var_os(PUBLIC_SOCKET_PATH_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_PUBLIC_SOCKET_PATH));
let Some(public_dir) = public_socket_path.parent().map(Path::to_path_buf) else {
return Err(eyre!(
"public socket path has no parent directory: {}",
public_socket_path.display()
));
};
let node_id_file = env::var_os(NODE_ID_FILE_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_NODE_ID_FILE));
let router_udp_port = match env::var(ROUTER_UDP_PORT_ENV) {
Ok(raw) => raw
.parse::<u16>()
.map_err(|e| eyre!("invalid {ROUTER_UDP_PORT_ENV} value {raw:?}: {e}"))?,
Err(_) => DEFAULT_ROUTER_UDP_PORT,
};
let router_transport = match env::var(ROUTER_TRANSPORT_ENV) {
Ok(raw) => raw
.parse::<TransportMode>()
.map_err(|e| eyre!("invalid {ROUTER_TRANSPORT_ENV} value {raw:?}: {e}"))?,
Err(_) => TransportMode::Udp,
};
let tun_mtu = match env::var(TUN_MTU_ENV) {
Ok(raw) => parse_tun_mtu(&raw)
.map_err(|e| eyre!("invalid {TUN_MTU_ENV} value {raw:?}: {e}"))?,
Err(_) => default_tun_mtu(router_transport),
};
let tcp_batch_target_bytes = match env::var(TCP_BATCH_TARGET_BYTES_ENV) {
Ok(raw) => parse_tcp_batch_target_bytes(&raw)
.map_err(|e| eyre!("invalid {TCP_BATCH_TARGET_BYTES_ENV} value {raw:?}: {e}"))?,
Err(_) => DEFAULT_TCP_BATCH_TARGET_BYTES,
};
let tcp_socket_buffer_bytes = match env::var(TCP_SOCKET_BUFFER_BYTES_ENV) {
Ok(raw) => parse_tcp_socket_buffer_bytes(&raw)
.map_err(|e| eyre!("invalid {TCP_SOCKET_BUFFER_BYTES_ENV} value {raw:?}: {e}"))?,
Err(_) => DEFAULT_TCP_SOCKET_BUFFER_BYTES,
};
Ok(Self {
public_socket_path,
public_dir,
node_id_file,
router_udp_port,
router_transport,
tun_mtu,
tcp_batch_target_bytes,
tcp_socket_buffer_bytes,
exo_ula_prefix: EXO_ULA_PREFIX,
})
}
}
pub fn default_tun_mtu(transport: TransportMode) -> u16 {
match transport {
TransportMode::Udp => UDP_TUN_MTU,
TransportMode::Tcp => TCP_TUN_MTU,
}
}
pub fn parse_tun_mtu(raw: &str) -> Result<u16, String> {
let mtu = raw
.trim()
.parse::<u16>()
.map_err(|err| format!("expected integer MTU: {err}"))?;
if !(MIN_TUN_MTU..=MAX_TUN_MTU).contains(&mtu) {
return Err(format!(
"expected MTU in {MIN_TUN_MTU}..={MAX_TUN_MTU}, got {mtu}"
));
}
Ok(mtu)
}
pub fn parse_tcp_batch_target_bytes(raw: &str) -> Result<usize, String> {
parse_usize_in_range(raw, 1024, TCP_PENDING_LIMIT_BYTES, "TCP batch target bytes")
}
pub fn parse_tcp_socket_buffer_bytes(raw: &str) -> Result<usize, String> {
parse_usize_in_range(
raw,
1024,
MAX_TCP_SOCKET_BUFFER_BYTES,
"TCP socket buffer bytes",
)
}
fn parse_usize_in_range(raw: &str, min: usize, max: usize, name: &str) -> Result<usize, String> {
let value = raw
.trim()
.parse::<usize>()
.map_err(|err| format!("expected integer {name}: {err}"))?;
if !(min..=max).contains(&value) {
return Err(format!("expected {name} in {min}..={max}, got {value}"));
}
Ok(value)
}
pub fn interface_allowlist_from_env() -> eyre::Result<Option<HashSet<Box<str>>>> {
let Ok(raw) = env::var(INTERFACE_ALLOWLIST_ENV) else {
return Ok(None);
};
let allowlist = raw
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| name.into())
.collect::<HashSet<Box<str>>>();
if allowlist.is_empty() {
return Err(eyre!(
"{INTERFACE_ALLOWLIST_ENV} was set but contained no interface names"
));
}
Ok(Some(allowlist))
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_TCP_BATCH_TARGET_BYTES, DEFAULT_TCP_SOCKET_BUFFER_BYTES, EXO_ULA_PREFIX,
TCP_TUN_MTU, TransportMode, UDP_TUN_MTU, default_tun_mtu, parse_tcp_batch_target_bytes,
parse_tcp_socket_buffer_bytes, parse_tun_mtu,
};
use std::net::Ipv6Addr;
#[test]
fn exo_ula_prefix_keeps_fde0_high_bits() {
assert_eq!(
EXO_ULA_PREFIX.addr(),
Ipv6Addr::new(0xfde0, 0x20c6, 0x1fa7, 0xffff, 0, 0, 0, 0)
);
assert_eq!(EXO_ULA_PREFIX.prefix_len(), 64);
}
#[test]
fn transport_mode_parses_udp_and_tcp() {
assert_eq!("udp".parse::<TransportMode>().unwrap(), TransportMode::Udp);
assert_eq!("tcp".parse::<TransportMode>().unwrap(), TransportMode::Tcp);
assert_eq!("TCP".parse::<TransportMode>().unwrap(), TransportMode::Tcp);
assert!("quic".parse::<TransportMode>().is_err());
}
#[test]
fn tun_mtu_defaults_follow_transport_and_validate_bounds() {
assert_eq!(default_tun_mtu(TransportMode::Udp), UDP_TUN_MTU);
assert_eq!(default_tun_mtu(TransportMode::Tcp), TCP_TUN_MTU);
assert_eq!(parse_tun_mtu("9000").unwrap(), 9000);
assert_eq!(parse_tun_mtu("65535").unwrap(), 65535);
assert!(parse_tun_mtu("1279").is_err());
assert!(parse_tun_mtu("65536").is_err());
}
#[test]
fn tcp_tuning_knobs_validate_bounds() {
assert_eq!(
parse_tcp_batch_target_bytes(&DEFAULT_TCP_BATCH_TARGET_BYTES.to_string()).unwrap(),
DEFAULT_TCP_BATCH_TARGET_BYTES
);
assert_eq!(
parse_tcp_batch_target_bytes("2097152").unwrap(),
2 * 1024 * 1024
);
assert!(parse_tcp_batch_target_bytes("512").is_err());
assert!(parse_tcp_batch_target_bytes("4194305").is_err());
assert_eq!(
parse_tcp_socket_buffer_bytes(&DEFAULT_TCP_SOCKET_BUFFER_BYTES.to_string()).unwrap(),
DEFAULT_TCP_SOCKET_BUFFER_BYTES
);
assert_eq!(
parse_tcp_socket_buffer_bytes("33554432").unwrap(),
32 * 1024 * 1024
);
assert!(parse_tcp_socket_buffer_bytes("512").is_err());
}
}
+398
View File
@@ -0,0 +1,398 @@
use color_eyre::eyre::{Result, WrapErr, eyre};
use ipnet::Ipv6Net;
use std::fmt::{Display, Formatter};
use std::{future::pending, sync::Arc};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::UnixStream,
sync::{mpsc, oneshot, watch},
task::JoinHandle,
time::{Duration, Instant},
};
use crate::config::TransportMode;
use crate::route_ctl;
use crate::routing_stack::RoutingStack;
use crate::{babel::BabelState, tun::TunDevice};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceState {
Off,
Starting,
On,
Stopping,
}
impl Display for ServiceState {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Off => write!(f, "off"),
Self::Starting => write!(f, "starting"),
Self::On => write!(f, "on"),
Self::Stopping => write!(f, "stopping"),
}
}
}
#[derive(Debug, Clone)]
pub struct DaemonStatus {
pub service_state: ServiceState,
pub node_id: u64,
pub node_addr: Ipv6Net,
pub tun_ifname: Arc<str>,
// realistically should always have one?? right??
pub keepalive_deadline: Option<Instant>,
pub last_error: Option<Arc<str>>,
}
impl DaemonStatus {
fn new(node_id: u64, node_addr: Ipv6Net, tun_ifname: Arc<str>) -> Self {
Self {
service_state: ServiceState::Off,
node_id,
node_addr,
tun_ifname,
keepalive_deadline: None,
last_error: None,
}
}
pub fn render(&self) -> String {
let keepalive_remaining_ms = self
.keepalive_deadline
.and_then(|deadline| deadline.checked_duration_since(Instant::now()))
.map(|remaining| remaining.as_millis().to_string())
.unwrap_or_else(|| "none".to_owned());
let mut line = format!(
"state {} node_id={:#018x} node_addr={} tun={} keepalive_remaining_ms={}",
self.service_state,
self.node_id,
self.node_addr,
self.tun_ifname,
keepalive_remaining_ms
);
if let Some(err) = &self.last_error {
line.push_str(&format!(" last_error={err:?}"));
}
line
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackTaskKind {
Babel,
Watcher,
FibPublisher,
Dataplane,
}
#[derive(Debug)]
pub enum RoutingStackEvent {
Exited {
kind: StackTaskKind,
error: Option<String>,
},
}
#[derive(Debug)]
enum DaemonCommand {
KeepAlive {
ttl: Duration,
reply: oneshot::Sender<Result<DaemonStatus>>,
},
GetState {
reply: oneshot::Sender<DaemonStatus>,
},
}
#[derive(Clone)]
pub struct DaemonHandle {
send: mpsc::Sender<DaemonCommand>,
}
impl DaemonHandle {
pub async fn keep_alive(&self, ttl: Duration) -> Result<DaemonStatus> {
let (reply_send, reply_recv) = oneshot::channel();
self.send
.send(DaemonCommand::KeepAlive {
ttl,
reply: reply_send,
})
.await
.map_err(|_| eyre!("daemon core stopped"))?;
reply_recv.await.map_err(|_| eyre!("daemon core stopped"))?
}
pub async fn get_state(&self) -> Result<DaemonStatus> {
let (reply_send, reply_recv) = oneshot::channel();
self.send
.send(DaemonCommand::GetState { reply: reply_send })
.await
.map_err(|_| eyre!("daemon core stopped"))?;
reply_recv.await.map_err(|_| eyre!("daemon core stopped"))
}
}
pub struct DaemonCore {
status: DaemonStatus,
overlay_prefix: Ipv6Net,
router_udp_port: u16,
router_transport: TransportMode,
tun_mtu: u16,
tcp_batch_target_bytes: usize,
tcp_socket_buffer_bytes: usize,
_tun: TunDevice,
routing_stack: Option<RoutingStack>,
babel_state_send: watch::Sender<Arc<BabelState>>,
command_recv: mpsc::Receiver<DaemonCommand>,
event_send: mpsc::Sender<RoutingStackEvent>,
event_recv: mpsc::Receiver<RoutingStackEvent>,
}
impl DaemonCore {
pub fn spawn(
node_id: u64,
overlay_prefix: Ipv6Net,
router_udp_port: u16,
router_transport: TransportMode,
tun_mtu: u16,
tcp_batch_target_bytes: usize,
tcp_socket_buffer_bytes: usize,
node_addr: Ipv6Net,
tun: TunDevice,
babel_state_send: watch::Sender<Arc<BabelState>>,
) -> (DaemonHandle, JoinHandle<Result<()>>) {
let (command_send, command_recv) = mpsc::channel(32);
let (event_send, event_recv) = mpsc::channel(8);
let status = DaemonStatus::new(node_id, node_addr, Arc::from(tun.ifname().to_owned()));
let core = Self {
status,
overlay_prefix,
router_udp_port,
router_transport,
tun_mtu,
tcp_batch_target_bytes,
tcp_socket_buffer_bytes,
_tun: tun,
routing_stack: None,
babel_state_send,
command_recv,
event_send,
event_recv,
};
let handle = DaemonHandle { send: command_send };
let task = tokio::spawn(core.run());
(handle, task)
}
async fn run(mut self) -> Result<()> {
loop {
tokio::select! {
command = self.command_recv.recv() => {
let Some(command) = command else {
break;
};
self.handle_command(command).await?;
}
event = self.event_recv.recv() => {
let Some(event) = event else {
break;
};
self.handle_stack_event(event).await?;
}
_ = lease_timer(self.status.keepalive_deadline) => {
self.handle_lease_expiry().await?;
}
}
}
self.stop_stack().await?;
Ok(())
}
async fn handle_command(&mut self, command: DaemonCommand) -> Result<()> {
match command {
DaemonCommand::KeepAlive { ttl, reply } => {
self.status.keepalive_deadline = Some(Instant::now() + ttl);
if self.routing_stack.is_none() {
let result = self.start_stack().await.map(|()| self.status.clone());
let _ = reply.send(result);
return Ok(());
}
let _ = reply.send(Ok(self.status.clone()));
Ok(())
}
DaemonCommand::GetState { reply } => {
let _ = reply.send(self.status.clone());
Ok(())
}
}
}
async fn handle_stack_event(&mut self, event: RoutingStackEvent) -> Result<()> {
let RoutingStackEvent::Exited { kind, error } = event;
if self.routing_stack.is_none() {
return Ok(());
}
tracing::warn!(?kind, ?error, "routing stack task exited");
self.status.last_error =
Some(Arc::from(error.unwrap_or_else(|| {
format!("{kind:?} task exited unexpectedly")
})));
if let Err(err) = self.stop_stack().await {
self.status.last_error = Some(Arc::from(err.to_string()));
}
Ok(())
}
async fn handle_lease_expiry(&mut self) -> Result<()> {
let expired = self
.status
.keepalive_deadline
.is_some_and(|deadline| deadline <= Instant::now());
if expired {
tracing::info!("keepalive expired, transitioning routing stack off");
self.status.keepalive_deadline = None;
if let Err(err) = self.stop_stack().await {
self.status.last_error = Some(Arc::from(err.to_string()));
}
}
Ok(())
}
async fn start_stack(&mut self) -> Result<()> {
if self.routing_stack.is_some() {
return Ok(());
}
self.status.service_state = ServiceState::Starting;
self.status.last_error = None;
match RoutingStack::start(
self.status.node_addr,
&self._tun,
self.router_udp_port,
self.router_transport,
self.tun_mtu,
self.tcp_batch_target_bytes,
self.tcp_socket_buffer_bytes,
self.babel_state_send.clone(),
self.event_send.clone(),
) {
Ok(stack) => {
self.routing_stack = Some(stack);
if let Err(err) = route_ctl::ensure_overlay_route(
self.overlay_prefix,
self.status.tun_ifname.as_ref(),
) {
let _ = self.stop_stack().await;
self.status.service_state = ServiceState::Off;
self.status.last_error = Some(Arc::from(err.to_string()));
return Err(err.into());
}
self.status.service_state = ServiceState::On;
Ok(())
}
Err(err) => {
self.status.service_state = ServiceState::Off;
self.status.last_error = Some(Arc::from(err.to_string()));
Err(err)
}
}
}
async fn stop_stack(&mut self) -> Result<()> {
if let Err(err) = route_ctl::remove_overlay_route(self.overlay_prefix) {
tracing::warn!(error=%err, "failed to remove overlay route");
}
let Some(stack) = self.routing_stack.take() else {
self.status.service_state = ServiceState::Off;
self.babel_state_send
.send_replace(Arc::new(BabelState::new()));
return Ok(());
};
self.status.service_state = ServiceState::Stopping;
let stop_result = stack.stop().await;
self.babel_state_send
.send_replace(Arc::new(BabelState::new()));
self.status.service_state = ServiceState::Off;
if let Err(err) = stop_result {
self.status.last_error = Some(Arc::from(err.to_string()));
return Err(err);
}
Ok(())
}
}
async fn lease_timer(deadline: Option<Instant>) {
if let Some(deadline) = deadline {
tokio::time::sleep_until(deadline).await;
} else {
pending::<()>().await;
}
}
pub async fn handle_client(sock: UnixStream, daemon: DaemonHandle) {
tracing::info!("new socket conn");
let (reader, mut write) = sock.into_split();
let mut reader = BufReader::new(reader).lines();
if let Ok(state) = daemon.get_state().await {
let _ = write
.write_all(format!("{}\n", state.render()).as_bytes())
.await;
}
loop {
let Ok(Some(line)) = reader.next_line().await else {
break;
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let response = match handle_command_line(trimmed, &daemon).await {
Ok(response) => response,
Err(err) => format!("error {err}"),
};
if let Err(err) = write.write_all(format!("{response}\n").as_bytes()).await {
tracing::warn!(error=%err, "failed to write command response");
break;
}
}
tracing::info!("closing socket conn");
let _ = write.shutdown().await;
}
async fn handle_command_line(line: &str, daemon: &DaemonHandle) -> Result<String> {
let mut parts = line.split_whitespace();
let Some(command) = parts.next() else {
return Ok("error empty-command".to_owned());
};
match command {
"get-state" => Ok(daemon.get_state().await?.render()),
"keepalive" => {
let Some(ttl_ms) = parts.next() else {
return Err(eyre!("keepalive requires ttl_ms"));
};
let ttl_ms = ttl_ms
.parse::<u64>()
.wrap_err_with(|| format!("invalid ttl_ms: {ttl_ms:?}"))?;
Ok(daemon
.keep_alive(Duration::from_millis(ttl_ms))
.await?
.render())
}
"help" => Ok("commands: get-state | keepalive <ttl_ms>".to_owned()),
other => Err(eyre!("unknown command: {other}")),
}
}
File diff suppressed because it is too large. Load diff
+430
View File
@@ -0,0 +1,430 @@
//! Immutable forwarding snapshots derived from [`crate::babel::BabelState`].
//!
//! `BabelState` mirrors the control-plane view emitted by `babeld`.
//! `FibSnapshot` is the reduced dataplane view:
//!
//! - exact-match IPv6 host routes only for now,
//! - admitted interface ownership alongside those routes,
//! - one immutable snapshot swapped wholesale into the dataplane,
//! - keyed for fast lookup rather than protocol fidelity.
//!
//! The v1 forwarding model is intentionally narrow:
//!
//! - local addresses are explicit inputs, not inferred from every xroute,
//! - only interfaces with a live Babel neighbour are exposed to the dataplane,
//! - only installed IPv6 `/128` routes are considered,
//! - only destination-based forwarding is modeled,
//! - routes with non-link-local next hops are ignored.
use std::net::{IpAddr, Ipv6Addr};
use ahash::RandomState;
use hashbrown::{HashMap, HashSet, hash_map::Entry};
use ipnet::{IpNet, Ipv6Net};
use crate::babel::BabelState;
use crate::babel::state::RouteState;
pub type HostKey = u128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FibEntry {
pub next_hop_ll: Ipv6Addr,
pub ifname: Box<str>,
pub mtu: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AdmittedNeighbour {
pub ifname: Box<str>,
pub link_local: Ipv6Addr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FibSnapshot {
pub locals: HashSet<HostKey, RandomState>,
pub admitted_interfaces: HashSet<Box<str>, RandomState>,
pub admitted_neighbours: HashSet<AdmittedNeighbour, RandomState>,
pub interface_link_locals: HashMap<Box<str>, Ipv6Addr, RandomState>,
pub routes: HashMap<HostKey, FibEntry, RandomState>,
}
#[derive(Debug, Clone)]
pub struct FibBuilder {
local_addrs: Vec<Ipv6Addr>,
route_mtu: u16,
}
impl FibBuilder {
pub fn new<I>(local_addrs: I, route_mtu: u16) -> Self
where
I: IntoIterator<Item = Ipv6Addr>,
{
Self {
local_addrs: local_addrs.into_iter().collect(),
route_mtu,
}
}
pub fn derive(&self, state: &BabelState) -> FibSnapshot {
let mut locals =
HashSet::with_capacity_and_hasher(self.local_addrs.len(), RandomState::new());
for addr in &self.local_addrs {
locals.insert(host_key(*addr));
}
let up_interfaces: HashSet<&str, RandomState> = state
.interfaces
.values()
.filter(|interface| interface.up)
.map(|interface| interface.ifname.as_ref())
.collect::<HashSet<_, _>>();
let mut interface_link_locals = HashMap::with_hasher(RandomState::new());
for interface in state.interfaces.values().filter(|interface| interface.up) {
let Some(IpAddr::V6(link_local)) = interface.ipv6 else {
continue;
};
if link_local.is_unicast_link_local() {
interface_link_locals.insert(interface.ifname.clone(), link_local);
}
}
let mut admitted_interfaces = HashSet::with_hasher(RandomState::new());
let mut admitted_neighbours = HashSet::with_hasher(RandomState::new());
for neighbour in state.neighbours.values() {
if !up_interfaces.contains(neighbour.ifname.as_ref()) {
continue;
}
let IpAddr::V6(link_local) = neighbour.address else {
continue;
};
if !link_local.is_unicast_link_local() {
continue;
}
admitted_interfaces.insert(neighbour.ifname.clone());
admitted_neighbours.insert(AdmittedNeighbour {
ifname: neighbour.ifname.clone(),
link_local,
});
}
let mut routes = HashMap::with_hasher(RandomState::new());
let mut route_scores = HashMap::with_hasher(RandomState::new());
let mut candidates: Vec<&RouteState> = state.routes.values().collect();
candidates.sort_by(|left, right| {
left.ifname
.cmp(&right.ifname)
.then_with(|| left.prefix.to_string().cmp(&right.prefix.to_string()))
.then_with(|| left.metric.cmp(&right.metric))
.then_with(|| left.refmetric.cmp(&right.refmetric))
.then_with(|| left.handle.cmp(&right.handle))
});
for route in candidates {
let Some((dst, next_hop_ll)) = route_to_host(route) else {
continue;
};
if !admitted_interfaces.contains(route.ifname.as_ref()) {
continue;
}
if locals.contains(&dst) {
continue;
}
let candidate = FibEntry {
next_hop_ll,
ifname: route.ifname.clone(),
mtu: self.route_mtu,
};
let candidate_score = (route.metric, route.refmetric, route.handle);
match routes.entry(dst) {
Entry::Vacant(slot) => {
slot.insert(candidate);
route_scores.insert(dst, candidate_score);
}
Entry::Occupied(mut slot) => {
let Some(existing_score) = route_scores.get(&dst).copied() else {
slot.insert(candidate);
route_scores.insert(dst, candidate_score);
continue;
};
if candidate_score < existing_score {
slot.insert(candidate);
route_scores.insert(dst, candidate_score);
}
}
}
}
FibSnapshot {
locals,
admitted_interfaces,
admitted_neighbours,
interface_link_locals,
routes,
}
}
}
impl FibSnapshot {
pub fn empty() -> Self {
Self {
locals: HashSet::with_hasher(RandomState::new()),
admitted_interfaces: HashSet::with_hasher(RandomState::new()),
admitted_neighbours: HashSet::with_hasher(RandomState::new()),
interface_link_locals: HashMap::with_hasher(RandomState::new()),
routes: HashMap::with_hasher(RandomState::new()),
}
}
pub fn from_node_addr(node_addr: Ipv6Net, state: &BabelState, route_mtu: u16) -> Self {
FibBuilder::new([node_addr.addr()], route_mtu).derive(state)
}
pub fn is_local(&self, addr: Ipv6Addr) -> bool {
self.locals.contains(&host_key(addr))
}
pub fn lookup(&self, addr: Ipv6Addr) -> Option<&FibEntry> {
self.routes.get(&host_key(addr))
}
}
pub fn host_key(addr: Ipv6Addr) -> HostKey {
u128::from(addr)
}
fn route_to_host(route: &RouteState) -> Option<(HostKey, Ipv6Addr)> {
if !route.installed {
return None;
}
let IpNet::V6(prefix) = route.prefix else {
return None;
};
if prefix.prefix_len() != 128 {
return None;
}
let IpNet::V6(from) = route.from else {
return None;
};
if from.prefix_len() != 0 || from.addr() != Ipv6Addr::UNSPECIFIED {
return None;
}
let std::net::IpAddr::V6(via) = route.via else {
return None;
};
if !via.is_unicast_link_local() {
return None;
}
Some((host_key(prefix.addr()), via))
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv6Addr};
use crate::babel::Eui64;
use crate::babel::line::{Event, EventKind, InterfaceEvent, NeighbourEvent, RouteEvent};
use crate::babel::state::BabelState;
use super::{AdmittedNeighbour, FibBuilder};
fn route(
handle: u64,
prefix: &str,
from: &str,
installed: bool,
via: Ipv6Addr,
ifname: &str,
metric: u32,
refmetric: u32,
) -> Event {
Event::Route(RouteEvent {
kind: EventKind::Add,
handle,
prefix: prefix.parse().unwrap(),
from: from.parse().unwrap(),
installed,
id: Eui64::new(0, 1, 2, 3, 4, 5, 6, 7),
metric,
refmetric,
via: IpAddr::V6(via),
ifname: ifname.into(),
})
}
fn interface(ifname: &str, up: bool) -> Event {
Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: ifname.into(),
up,
ipv6: None,
ipv4: None,
})
}
fn neighbour(handle: u64, ifname: &str, address: &str) -> Event {
Event::Neighbour(NeighbourEvent {
kind: EventKind::Add,
handle,
address: IpAddr::V6(address.parse().unwrap()),
ifname: ifname.into(),
reach: 0xffff,
ureach: 0xffff,
rxcost: 96,
txcost: 96,
rtt_millis: Some(1),
rttcost: Some(0),
external_bias_256: 0,
external_coef_256: 256,
cost: 96,
})
}
#[test]
fn derives_local_and_host_routes() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(route(
1,
"fde0::1234/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.is_local("fde0::1".parse().unwrap()));
assert!(fib.admitted_interfaces.contains("en2"));
assert!(fib.admitted_neighbours.contains(&AdmittedNeighbour {
ifname: "en2".into(),
link_local: "fe80::1".parse().unwrap(),
}));
let entry = fib.lookup("fde0::1234".parse().unwrap()).unwrap();
assert_eq!(entry.next_hop_ll, "fe80::1".parse::<Ipv6Addr>().unwrap());
assert_eq!(entry.ifname.as_ref(), "en2");
assert_eq!(entry.mtu, 1452);
}
#[test]
fn skips_non_installed_or_non_host_routes() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(interface("en3", true));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(neighbour(2, "en3", "fe80::2"));
state.apply(route(
1,
"fde0::abcd/128",
"::/0",
false,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
state.apply(route(
2,
"fde0::/64",
"::/0",
true,
"fe80::2".parse().unwrap(),
"en3",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.routes.is_empty());
}
#[test]
fn prefers_lower_metric_when_multiple_installed_routes_exist() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(interface("en3", true));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(neighbour(2, "en3", "fe80::2"));
state.apply(route(
1,
"fde0::beef/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
200,
20,
));
state.apply(route(
2,
"fde0::beef/128",
"::/0",
true,
"fe80::2".parse().unwrap(),
"en3",
100,
10,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
let entry = fib.lookup("fde0::beef".parse().unwrap()).unwrap();
assert_eq!(entry.next_hop_ll, "fe80::2".parse::<Ipv6Addr>().unwrap());
assert_eq!(entry.ifname.as_ref(), "en3");
}
#[test]
fn skips_routes_for_interfaces_without_live_neighbours() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(route(
1,
"fde0::cafe/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.admitted_interfaces.is_empty());
assert!(fib.lookup("fde0::cafe".parse().unwrap()).is_none());
}
#[test]
fn skips_neighbour_interfaces_that_are_not_up() {
let mut state = BabelState::new();
state.apply(interface("en2", false));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(route(
1,
"fde0::cafe/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.admitted_interfaces.is_empty());
assert!(fib.lookup("fde0::cafe".parse().unwrap()).is_none());
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Persistent node identity for `babblerd`.
//!
//! The node ID occupies the full low 64 bits of the EXO ULA space.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::Path;
use color_eyre::eyre::{self, WrapErr, eyre};
use ipnet::Ipv6Net;
use nix::unistd::geteuid;
use std::net::Ipv6Addr;
pub fn load_or_create_node_id(path: &Path) -> eyre::Result<u64> {
match read_node_id(path) {
Ok(node_id) => Ok(node_id),
Err(err) if is_not_found(&err) => create_node_id(path),
Err(err) => Err(err),
}
}
pub fn node_addr(prefix: Ipv6Net, node_id: u64) -> eyre::Result<Ipv6Net> {
if prefix.prefix_len() != 64 {
return Err(eyre!(
"expected EXO ULA prefix to be /64, got {prefix} with /{}",
prefix.prefix_len()
));
}
Ok(Ipv6Net::new_assert(
Ipv6Addr::from_bits(prefix.trunc().addr().to_bits() | u128::from(node_id)),
128,
))
}
fn create_node_id(path: &Path) -> eyre::Result<u64> {
let Some(parent) = path.parent() else {
return Err(eyre!(
"node id file has no parent directory: {}",
path.display()
));
};
fs::create_dir_all(parent)
.wrap_err_with(|| format!("creating node id directory {}", parent.display()))?;
let node_id = generate_node_id();
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
return read_node_id(path);
}
Err(err) => {
return Err(err).wrap_err_with(|| format!("creating node id file {}", path.display()));
}
};
file.set_permissions(fs::Permissions::from_mode(0o600))
.wrap_err_with(|| format!("setting permissions on {}", path.display()))?;
writeln!(file, "{node_id:016x}")
.wrap_err_with(|| format!("writing node id file {}", path.display()))?;
file.sync_all()
.wrap_err_with(|| format!("syncing node id file {}", path.display()))?;
drop(file);
read_node_id(path)
}
fn read_node_id(path: &Path) -> eyre::Result<u64> {
let metadata =
fs::metadata(path).wrap_err_with(|| format!("reading metadata for {}", path.display()))?;
ensure_owner(path, &metadata)?;
let raw = fs::read_to_string(path)
.wrap_err_with(|| format!("reading node id file {}", path.display()))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(eyre!("node id file is empty: {}", path.display()));
}
let node_id = u64::from_str_radix(trimmed.trim_start_matches("0x"), 16)
.wrap_err_with(|| format!("invalid node id in {}: {:?}", path.display(), trimmed))?;
Ok(node_id)
}
fn ensure_owner(path: &Path, metadata: &fs::Metadata) -> eyre::Result<()> {
let expected_uid = geteuid().as_raw();
let actual_uid = metadata.uid();
if actual_uid != expected_uid {
return Err(eyre!(
"node id file {} is owned by uid {}, expected {}",
path.display(),
actual_uid,
expected_uid
));
}
Ok(())
}
fn generate_node_id() -> u64 {
rand::random::<u64>()
}
fn is_not_found(err: &eyre::Report) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(name: &str) -> std::path::PathBuf {
let nonce = rand::random::<u64>();
std::env::temp_dir().join(format!(
"babblerd-identity-{name}-{}-{nonce}",
std::process::id()
))
}
#[test]
fn creates_and_reloads_same_node_id() {
let dir = temp_path("create");
let path = dir.join("node-id");
let first = load_or_create_node_id(&path).expect("create node id");
let second = load_or_create_node_id(&path).expect("reload node id");
assert_eq!(first, second);
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn node_addr_uses_full_low_64_bits() {
let addr = node_addr(
Ipv6Net::new_assert(
Ipv6Addr::new(0xfde0, 0x20c6, 0x1fa7, 0xffff, 0, 0, 0, 0),
64,
),
0x1234_5678_9abc_def0,
)
.expect("node address should be constructed");
assert_eq!(addr.prefix_len(), 128);
assert_eq!(
addr.addr(),
Ipv6Addr::new(
0xfde0, 0x20c6, 0x1fa7, 0xffff, 0x1234, 0x5678, 0x9abc, 0xdef0,
)
);
}
}
+337
View File
@@ -0,0 +1,337 @@
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
compile_error!("babblerd is mac/linux-only");
pub mod babel;
pub mod config;
pub mod daemon;
pub mod dataplane;
pub mod fib;
pub mod identity;
pub mod profiling;
pub(crate) mod route_ctl;
pub mod routing_stack;
pub mod tun;
pub use babel::babel;
pub use config::EXO_ULA_PREFIX as PREFIX;
pub use error::{BabbleError, Result};
pub use if_watcher::watch;
pub mod error {
use std::io;
use thiserror::Error;
pub type Result<T> = core::result::Result<T, BabbleError>;
#[derive(Error, Debug)]
pub enum BabbleError {
#[error("An IO error occurred: {0}")]
Io(#[from] io::Error),
#[error("Unspecified error")]
Unspecified,
#[error("Babeld crashed unexpectedly with code: {0:?}")]
BabeldCrashed(Option<i32>),
#[error("Failed to set IP address")]
FailedToSetIp,
#[error("Other error: {0}")]
Other(String),
}
}
pub mod if_watcher {
#[cfg(target_os = "linux")]
use std::path::PathBuf;
use std::{collections::HashSet, net::IpAddr};
use futures_lite::StreamExt;
use n0_watcher::Watcher;
use netwatch::interfaces::{Interface, IpNet};
use tokio::sync::mpsc;
use crate::config::{EXO_ULA_PREFIX, PHYSICAL_LINK_MTU, interface_allowlist_from_env};
use crate::ip_manager::remove_ip;
use crate::{BabbleError, Result, babel::Babble};
pub const LOCALHOST_INTERFACE_NAMES: [&'static str; 2] = ["lo", "lo0"];
trait IfaceExt {
fn has_link_local_v6(&self) -> bool;
fn has_required_mtu(&self) -> bool;
fn is_real_interface(&self) -> bool;
fn will_babel(&self) -> bool;
}
impl IfaceExt for Interface {
fn will_babel(&self) -> bool {
self.has_link_local_v6()
&& self.has_required_mtu()
&& self.is_real_interface()
&& self.is_up()
}
fn has_link_local_v6(&self) -> bool {
let mut has = false;
for addr in self.addrs() {
let IpAddr::V6(a) = addr.addr() else {
continue;
};
if a.is_unicast_link_local() {
has = true;
break;
}
}
has
}
fn has_required_mtu(&self) -> bool {
let Some(mtu) = interface_mtu(self.name()) else {
tracing::debug!(
"skipping interface {} because MTU could not be determined",
self.name()
);
return false;
};
if mtu < u32::from(PHYSICAL_LINK_MTU) {
tracing::debug!(
"skipping interface {} because mtu {} is below required {}",
self.name(),
mtu,
PHYSICAL_LINK_MTU
);
return false;
}
true
}
fn is_real_interface(&self) -> bool {
// macOS interface names are only a broad bootstrap signal here:
// Thunderbolt links are not limited to en2/en3, and high-numbered
// en* devices can be unrelated USB/Ethernet adapters.
if self.name().strip_prefix("en").is_none()
//.and_then(|s| s.parse::<u8>().ok())
//.is_none_or(|_n| false)
{
return false;
}
#[cfg(target_os = "linux")]
{
if !PathBuf::from(format!("/sys/class/net/{}/device", self.name())).exists() {
tracing::debug!(
"skipping interface {} as it doesn't correspond to a physical link",
self.name()
);
return false;
}
let dev_type_path = PathBuf::from(format!("/sys/class/net/{}/type", self.name()));
if !dev_type_path.exists() {
tracing::debug!(
"skipping interface {} with no type file at {:?}",
self.name(),
dev_type_path.to_str()
);
return false;
}
let Ok(dev_type) = std::fs::read_to_string(dev_type_path) else {
return false;
};
if dev_type.trim() != "1" {
tracing::debug!(
"skipping interface {} with type {:?}",
self.name(),
dev_type
);
return false;
}
}
true
}
}
fn interface_mtu(name: &str) -> Option<u32> {
netdev::get_interfaces()
.into_iter()
.find(|iface| iface.name == name)
.and_then(|iface| iface.mtu)
}
#[tracing::instrument(skip(send))]
pub async fn watch(send: mpsc::Sender<Babble>) -> Result<()> {
let mut ready_ifaces = HashSet::new();
let interface_allowlist = interface_allowlist_from_env()
.map_err(|e| BabbleError::Other(format!("invalid interface allowlist: {e}")))?;
if let Some(allowlist) = &interface_allowlist {
tracing::info!(?allowlist, "interface allowlist active");
}
tracing::info!("starting interface monitor");
let mon = netwatch::netmon::Monitor::new()
.await
.map_err(|_| BabbleError::Unspecified)?;
// TODD: this should never really be a thing thats the case, BUT I like the idea of having
// "heuristic" scripts that can help resolve issues but not necessarily gurantee success;
// I like the idea of generalising this concept into a framework where we have "heuristic tasks"
// that run to aid in tyring to fix some system ale-ment or whatever
//
// one-shot cleanup:
// - remove any stale app-prefix addresses from lo0
// - remove any app-prefix addresses that accidentally landed on physical links
{
let state = mon.interface_state();
for iface in state.peek().interfaces.values() {
let cleanup_target =
LOCALHOST_INTERFACE_NAMES.contains(&iface.name()) || iface.is_real_interface();
if !cleanup_target {
continue;
}
for addr in iface.addrs() {
if let IpNet::V6 { net: v6, .. } = addr
&& EXO_ULA_PREFIX.contains(&v6.addr())
{
tracing::info!("removing stale app ip {v6} from {}", iface.name());
if let Err(e) = remove_ip(v6, iface).await {
tracing::warn!(%e, "failed to remove stale app ip");
}
}
}
}
}
// stream updates
let mut mon_stream = mon.interface_state().stream();
while let Some(s) = mon_stream.next().await {
for iface in s.interfaces.values() {
if let Some(allowlist) = &interface_allowlist
&& !allowlist.contains(iface.name())
{
tracing::debug!(
"skipping interface {} because it is not in {}",
iface.name(),
crate::config::INTERFACE_ALLOWLIST_ENV
);
continue;
}
if !iface.is_real_interface() {
continue;
}
// physical links should not carry babbler application-space addresses
for addr in iface.addrs() {
if let IpNet::V6 { net: v6, .. } = addr
&& EXO_ULA_PREFIX.contains(&v6.addr())
{
tracing::info!("removing app ip {v6} from {}", iface.name());
if let Err(e) = remove_ip(v6, iface).await {
tracing::warn!(%e, "failed to remove ip");
}
}
}
if !iface.will_babel() {
continue;
}
if ready_ifaces.insert(iface.name().to_owned()) {
tracing::info!("telling babeld to watch {}", iface.name());
let Ok(()) = send.send(Babble::AddIface(iface.name().into())).await else {
return Ok(());
};
}
}
}
tracing::info!("stopping interface monitor");
Ok(())
}
}
pub(crate) mod ip_manager {
pub use sys::add_ip;
pub use sys::remove_ip;
#[cfg(target_os = "linux")]
mod sys {
use ipnet::Ipv6Net;
use netwatch::interfaces::Interface;
use crate::{BabbleError, Result};
use tokio::process::Command;
#[tracing::instrument]
pub async fn add_ip(subnet: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ip")
.arg("addr")
.arg("add")
.arg(format!("{subnet}"))
.arg("dev")
.arg(iface.name())
.output()
.await?;
if out.status.success() {
Ok(())
} else {
Err(BabbleError::FailedToSetIp)
}
}
#[tracing::instrument]
pub async fn remove_ip(v6: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ip")
.arg("addr")
.arg("del")
.arg(format!("{v6}"))
.arg("dev")
.arg(iface.name())
.output()
.await?;
if out.status.success() {
Ok(())
} else {
let std_err = String::from_utf8_lossy(&out.stdout);
tracing::debug!(%std_err);
Err(BabbleError::FailedToSetIp)
}
}
}
#[cfg(target_os = "macos")]
mod sys {
use ipnet::Ipv6Net;
use netwatch::interfaces::Interface;
use crate::BabbleError;
use crate::Result;
use tokio::process::Command;
#[tracing::instrument]
pub async fn add_ip(subnet: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ifconfig")
.arg(iface.name())
.arg("inet6")
.arg(format!("{subnet}"))
.arg("add")
.output()
.await?;
if out.status.success() {
Ok(())
} else {
Err(BabbleError::FailedToSetIp)
}
}
#[tracing::instrument]
pub async fn remove_ip(v6: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ifconfig")
.arg(iface.name())
.arg("inet6")
.arg(format!("{v6}"))
.arg("delete")
.output()
.await?;
if out.status.success() {
Ok(())
} else {
let std_err = String::from_utf8_lossy(&out.stdout);
tracing::debug!(%std_err);
Err(BabbleError::FailedToSetIp)
}
}
}
}
+251
View File
@@ -0,0 +1,251 @@
// Major TODO: at some point don't call it "babbler" because that is a silly name that makes no sense
// but this is at the very bottom of my concerns right now :)
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
compile_error!("babblerd is mac/linux-only");
use std::{env, fs::Permissions, io, os::unix::fs::PermissionsExt, sync::Arc};
use babblerd::{
babel::BabelState,
config::{Config, TUN_MTU_ENV, TransportMode, default_tun_mtu},
daemon, identity,
tun::TunDevice,
};
use clap::Parser;
use color_eyre::eyre::{self, WrapErr, eyre};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::UnixListener,
net::UnixStream,
signal,
sync::watch,
task::JoinSet,
time::{Duration, sleep},
};
const INTERNAL_KEEPALIVE_TTL_MS: u64 = 30_000;
const INTERNAL_KEEPALIVE_INTERVAL_MS: u64 = 10_000;
#[derive(Debug, Parser)]
struct Cli {
#[arg(long, value_parser = parse_transport_mode)]
router_transport: Option<TransportMode>,
#[arg(long, conflicts_with = "router_transport")]
force_tcp: bool,
#[arg(long, value_parser = parse_tun_mtu)]
tun_mtu: Option<u16>,
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
color_eyre::install()?;
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
let mut config = Config::from_env()?;
let tun_mtu_from_env = env::var_os(TUN_MTU_ENV).is_some();
let transport_overridden = cli.router_transport.is_some() || cli.force_tcp;
if let Some(router_transport) = cli.router_transport {
config.router_transport = router_transport;
}
if cli.force_tcp {
config.router_transport = TransportMode::Tcp;
}
if transport_overridden && !tun_mtu_from_env && cli.tun_mtu.is_none() {
config.tun_mtu = default_tun_mtu(config.router_transport);
}
if let Some(tun_mtu) = cli.tun_mtu {
config.tun_mtu = tun_mtu;
}
// cleanup old public socket path
match std::fs::remove_file(&config.public_socket_path) {
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()),
Ok(()) => {
tracing::info!(
"cleaned up old file at {}",
config.public_socket_path.display()
);
}
_ => {}
}
// create new public directory
std::fs::create_dir_all(&config.public_dir)?;
if let Err(e) = std::fs::set_permissions(&config.public_dir, Permissions::from_mode(0o0755)) {
if e.kind() == io::ErrorKind::PermissionDenied {
return Err(eyre!(
"Insufficient permissions to run daemon -- did you forget sudo?"
));
}
return Err(e.into());
}
let res = inner_main(&config).await;
let _ = std::fs::remove_file(&config.public_socket_path);
res
}
async fn inner_main(config: &Config) -> eyre::Result<()> {
let node_id = identity::load_or_create_node_id(&config.node_id_file)?;
let node_addr = identity::node_addr(config.exo_ula_prefix, node_id)?;
let tun = TunDevice::create(node_addr.addr(), config.tun_mtu)
.wrap_err("creating tun for node address")?;
tracing::info!("creating socket at {}", config.public_socket_path.display());
tracing::info!(
"router defaults: transport={} port={} tun_mtu={} tcp_batch_target_bytes={} tcp_socket_buffer_bytes={} node_id_file={} app_prefix={} node_id={:#018x} node_addr={} tun={}",
config.router_transport,
config.router_udp_port,
config.tun_mtu,
config.tcp_batch_target_bytes,
config.tcp_socket_buffer_bytes,
config.node_id_file.display(),
config.exo_ula_prefix,
node_id,
node_addr,
tun.ifname(),
);
if config.router_transport == TransportMode::Tcp {
tracing::warn!(
tun_mtu = config.tun_mtu,
tcp_batch_target_bytes = config.tcp_batch_target_bytes,
tcp_socket_buffer_bytes = config.tcp_socket_buffer_bytes,
"forced TCP transport requires matching TUN MTU on every peer; larger received frames are rejected"
);
}
let public_socket = UnixListener::bind(&config.public_socket_path)?;
// make our socket world accessible
std::fs::set_permissions(&config.public_socket_path, Permissions::from_mode(0o0666))?;
let (babel_state_send, _) = watch::channel(Arc::new(BabelState::new()));
let (daemon, mut core_task) = daemon::DaemonCore::spawn(
node_id,
config.exo_ula_prefix,
config.router_udp_port,
config.router_transport,
config.tun_mtu,
config.tcp_batch_target_bytes,
config.tcp_socket_buffer_bytes,
node_addr,
tun,
babel_state_send,
);
// TEMP: keep the daemon alive without an external client until the real
// frontend/test harness exists. This should be removed later.
let mut internal_keepalive =
tokio::spawn(internal_keepalive_client(config.public_socket_path.clone()));
let mut listeners = JoinSet::new();
loop {
tokio::select! {
sig = signal::ctrl_c() => {
sig?;
internal_keepalive.abort();
let _ = (&mut internal_keepalive).await;
listeners.abort_all();
while let Some(res) = listeners.join_next().await {
res.wrap_err("while ctrl-c")?;
}
drop(daemon);
core_task.await??;
break;
}
sock = public_socket.accept() => {
let sock = sock?.0;
listeners.spawn(daemon::handle_client(sock, daemon.clone()));
}
res = &mut core_task => {
res??;
internal_keepalive.abort();
let _ = (&mut internal_keepalive).await;
listeners.abort_all();
while let Some(res2) = listeners.join_next().await {
res2.wrap_err("while closing daemon core")?;
}
break;
}
res = &mut internal_keepalive => {
return Err(eyre!("internal keepalive client exited unexpectedly: {res:?}"));
}
next_join_result = listeners.join_next(), if !listeners.is_empty() => {
next_join_result.expect("checked")?;
tracing::info!("dropped a listener");
}
}
}
Ok(())
}
fn parse_transport_mode(raw: &str) -> Result<TransportMode, String> {
raw.parse()
}
fn parse_tun_mtu(raw: &str) -> Result<u16, String> {
babblerd::config::parse_tun_mtu(raw)
}
async fn internal_keepalive_client(socket_path: std::path::PathBuf) {
loop {
match UnixStream::connect(&socket_path).await {
Ok(stream) => {
tracing::info!(
socket=%socket_path.display(),
"internal keepalive client connected"
);
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader).lines();
match reader.next_line().await {
Ok(Some(line)) => {
tracing::debug!(?line, "internal keepalive initial state");
}
Ok(None) => {
tracing::warn!("internal keepalive connection closed before initial state");
sleep(Duration::from_secs(1)).await;
continue;
}
Err(err) => {
tracing::warn!(error=%err, "internal keepalive failed to read initial state");
sleep(Duration::from_secs(1)).await;
continue;
}
}
loop {
let command = format!("keepalive {INTERNAL_KEEPALIVE_TTL_MS}\n");
if let Err(err) = writer.write_all(command.as_bytes()).await {
tracing::warn!(error=%err, "internal keepalive failed to send keepalive");
break;
}
match reader.next_line().await {
Ok(Some(line)) => {
tracing::debug!(?line, "internal keepalive response");
}
Ok(None) => {
tracing::warn!("internal keepalive connection closed");
break;
}
Err(err) => {
tracing::warn!(error=%err, "internal keepalive failed to read response");
break;
}
}
sleep(Duration::from_millis(INTERNAL_KEEPALIVE_INTERVAL_MS)).await;
}
}
Err(err) => {
tracing::warn!(error=%err, socket=%socket_path.display(), "internal keepalive failed to connect");
}
}
sleep(Duration::from_secs(1)).await;
}
}
+124
View File
@@ -0,0 +1,124 @@
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LatencyStats {
pub sent: u32,
pub received: u32,
pub loss_ratio: f64,
pub min: Duration,
pub avg: Duration,
pub max: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapacitySample {
pub sent_packets: u32,
pub received_packets: u32,
pub received_bytes: u64,
pub span: Duration,
}
impl CapacitySample {
pub fn loss_ratio(self) -> f64 {
if self.sent_packets == 0 {
return 0.0;
}
let lost = self.sent_packets.saturating_sub(self.received_packets);
f64::from(lost) / f64::from(self.sent_packets)
}
pub fn mbps(self) -> Option<f64> {
capacity_mbps(self.received_bytes, self.span)
}
}
pub fn latency_stats(sent: u32, samples: &[Duration]) -> Option<LatencyStats> {
let received = u32::try_from(samples.len()).ok()?;
if sent == 0 || samples.is_empty() {
return None;
}
let mut min = samples.first().copied()?;
let mut max = min;
let mut total_nanos = 0_u128;
for sample in samples {
min = min.min(*sample);
max = max.max(*sample);
total_nanos = total_nanos.saturating_add(sample.as_nanos());
}
let avg_nanos = total_nanos / u128::from(received);
let avg = Duration::from_nanos(u64_saturating_from_u128(avg_nanos));
let lost = sent.saturating_sub(received);
Some(LatencyStats {
sent,
received,
loss_ratio: f64::from(lost) / f64::from(sent),
min,
avg,
max,
})
}
pub fn capacity_mbps(received_bytes: u64, span: Duration) -> Option<f64> {
let nanos = span.as_nanos();
if received_bytes == 0 || nanos == 0 {
return None;
}
let bits = received_bytes.saturating_mul(8);
Some((bits as f64) * 1_000.0 / (nanos as f64))
}
pub fn duration_nanos_u64(duration: Duration) -> u64 {
u64_saturating_from_u128(duration.as_nanos())
}
fn u64_saturating_from_u128(value: u128) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{CapacitySample, capacity_mbps, latency_stats};
#[test]
fn latency_summary_reports_loss_and_bounds() {
let samples = [
Duration::from_millis(3),
Duration::from_millis(1),
Duration::from_millis(2),
];
let Some(stats) = latency_stats(4, &samples) else {
panic!("expected latency stats");
};
assert_eq!(stats.sent, 4);
assert_eq!(stats.received, 3);
assert_eq!(stats.loss_ratio, 0.25);
assert_eq!(stats.min, Duration::from_millis(1));
assert_eq!(stats.avg, Duration::from_millis(2));
assert_eq!(stats.max, Duration::from_millis(3));
}
#[test]
fn capacity_summary_reports_mbps() {
let sample = CapacitySample {
sent_packets: 10,
received_packets: 10,
received_bytes: 125_000,
span: Duration::from_millis(1),
};
assert_eq!(sample.loss_ratio(), 0.0);
assert_eq!(
capacity_mbps(sample.received_bytes, sample.span),
Some(1000.0)
);
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Link-local profiling support.
//!
//! This module is intentionally independent of the Babel control plane. The
//! standalone example uses it to measure one physical link directly; the daemon
//! can later consume the same types and estimators when route scoring is wired
//! in.
pub mod estimator;
pub mod pbprobe;
pub mod protocol;
pub mod socket;
pub mod standalone;
pub mod types;
pub use estimator::{CapacitySample, LatencyStats, capacity_mbps, latency_stats};
pub use types::{DEFAULT_PROFILE_PORT, LinkKey, ProbeConfig};
@@ -0,0 +1,195 @@
use std::time::Duration;
use crate::config::{OUTER_IPV6_HEADER_BYTES, OUTER_UDP_HEADER_BYTES, PHYSICAL_LINK_MTU};
pub const DEFAULT_PBPROBE_PORT: u16 = 41_902;
pub const DEFAULT_SAMPLE_COUNT: u32 = 200;
pub const DEFAULT_UTILIZATION: f64 = 0.01;
pub const DEFAULT_DISPERSION_THRESHOLD_MS: u64 = 1;
pub const DEFAULT_DISPERSION_THRESHOLD: Duration =
Duration::from_millis(DEFAULT_DISPERSION_THRESHOLD_MS);
pub const DEFAULT_MAX_BULK_LEN: u32 = 10_000;
pub const DEFAULT_RTS_TIMEOUT_MS: u64 = 750;
pub const DEFAULT_RTS_TIMEOUT: Duration = Duration::from_millis(DEFAULT_RTS_TIMEOUT_MS);
pub const DEFAULT_START_TIMEOUT_MS: u64 = 750;
pub const DEFAULT_START_TIMEOUT: Duration = Duration::from_millis(DEFAULT_START_TIMEOUT_MS);
pub const DEFAULT_CONTROL_RETRIES: u32 = 5;
#[derive(Debug, Clone)]
pub struct PbProbeConfig {
pub port: u16,
pub sample_count: u32,
pub utilization: f64,
pub dispersion_threshold: Duration,
pub initial_bulk_len: u32,
pub max_bulk_len: u32,
pub ip_packet_bytes: usize,
pub start_timeout: Duration,
pub rts_timeout: Duration,
pub control_retries: u32,
}
impl Default for PbProbeConfig {
fn default() -> Self {
Self {
port: DEFAULT_PBPROBE_PORT,
sample_count: DEFAULT_SAMPLE_COUNT,
utilization: DEFAULT_UTILIZATION,
dispersion_threshold: DEFAULT_DISPERSION_THRESHOLD,
initial_bulk_len: 1,
max_bulk_len: DEFAULT_MAX_BULK_LEN,
ip_packet_bytes: usize::from(PHYSICAL_LINK_MTU),
start_timeout: DEFAULT_START_TIMEOUT,
rts_timeout: DEFAULT_RTS_TIMEOUT,
control_retries: DEFAULT_CONTROL_RETRIES,
}
}
}
impl PbProbeConfig {
pub fn udp_payload_bytes(&self) -> usize {
let overhead = usize::from(OUTER_IPV6_HEADER_BYTES + OUTER_UDP_HEADER_BYTES);
self.ip_packet_bytes.saturating_sub(overhead)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AcceptedSample {
pub sample_id: u32,
pub bulk_len: u32,
pub delay_first: Duration,
pub delay_last: Duration,
pub dispersion: Duration,
pub server_issue_duration: Option<Duration>,
}
impl AcceptedSample {
pub fn delay_sum(self) -> Duration {
self.delay_first.saturating_add(self.delay_last)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SelectedSample {
pub sample: AcceptedSample,
pub capacity_mbps: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Estimate {
pub bulk_len: u32,
pub sample_count: u32,
pub attempts: u32,
pub lost_samples: u32,
pub ip_packet_bytes: usize,
pub selected: SelectedSample,
pub min_dispersion: Duration,
pub server_issue_samples: u32,
pub min_server_issue_duration: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EstimateOutcome {
Complete(Estimate),
IncreaseBulk {
previous_bulk_len: u32,
next_bulk_len: u32,
observed_dispersion: Duration,
},
}
pub fn select_capacity_sample(
samples: &[AcceptedSample],
ip_packet_bytes: usize,
) -> Option<SelectedSample> {
let sample = samples
.iter()
.copied()
.min_by_key(|sample| sample.delay_sum())?;
let capacity_mbps = capacity_mbps(sample.bulk_len, ip_packet_bytes, sample.dispersion)?;
Some(SelectedSample {
sample,
capacity_mbps,
})
}
pub fn capacity_mbps(bulk_len: u32, ip_packet_bytes: usize, dispersion: Duration) -> Option<f64> {
let nanos = dispersion.as_nanos();
if bulk_len == 0 || ip_packet_bytes == 0 || nanos == 0 {
return None;
}
let bits = f64::from(bulk_len) * (ip_packet_bytes as f64) * 8.0;
Some(bits * 1_000.0 / (nanos as f64))
}
pub fn next_bulk_len(current: u32, max: u32) -> Option<u32> {
let next = current.checked_mul(10)?;
if next > max || next == current {
return None;
}
Some(next)
}
pub fn pacing_interval(dispersion: Duration, utilization: f64) -> Option<Duration> {
if dispersion.is_zero() || !utilization.is_finite() || utilization <= 0.0 {
return None;
}
Some(Duration::from_secs_f64(
(2.0 * dispersion.as_secs_f64()) / utilization,
))
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
AcceptedSample, capacity_mbps, next_bulk_len, pacing_interval, select_capacity_sample,
};
#[test]
fn capacity_uses_bulk_length_not_packet_count() {
let estimate = capacity_mbps(100, 1500, Duration::from_micros(1200));
assert_eq!(estimate, Some(1000.0));
}
#[test]
fn selector_uses_minimum_delay_sum() {
let samples = [
AcceptedSample {
sample_id: 1,
bulk_len: 10,
delay_first: Duration::from_millis(3),
delay_last: Duration::from_millis(4),
dispersion: Duration::from_micros(900),
server_issue_duration: None,
},
AcceptedSample {
sample_id: 2,
bulk_len: 10,
delay_first: Duration::from_millis(1),
delay_last: Duration::from_millis(2),
dispersion: Duration::from_micros(1200),
server_issue_duration: None,
},
];
let selected = select_capacity_sample(&samples, 1500).expect("sample should be selected");
assert_eq!(selected.sample.sample_id, 2);
assert_eq!(selected.capacity_mbps, 100.0);
}
#[test]
fn bulk_growth_is_tenfold_and_capped() {
assert_eq!(next_bulk_len(1, 1000), Some(10));
assert_eq!(next_bulk_len(1000, 1000), None);
}
#[test]
fn pacing_follows_paper_formula() {
let interval = pacing_interval(Duration::from_millis(1), 0.01);
assert_eq!(interval, Some(Duration::from_millis(200)));
}
}
@@ -0,0 +1,14 @@
//! Paper-faithful PBProbe implementation.
//!
//! PBProbe is a CapProbe-derived capacity estimator that uses packet bulks
//! instead of a single packet pair. This module follows the paper algorithm
//! rather than the old C implementation's process/control structure.
pub mod estimator;
pub mod protocol;
pub mod standalone;
pub use estimator::{
AcceptedSample, Estimate, EstimateOutcome, PbProbeConfig, SelectedSample, next_bulk_len,
pacing_interval, select_capacity_sample,
};
@@ -0,0 +1,317 @@
use std::mem::size_of;
use std::time::Duration;
use thiserror::Error;
use zerocopy::byteorder::{NetworkEndian, U16, U32, U64};
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
type U16Be = U16<NetworkEndian>;
type U32Be = U32<NetworkEndian>;
type U64Be = U64<NetworkEndian>;
pub const HEADER_LEN: usize = size_of::<WireHeader>();
pub const RESULT_BODY_LEN: usize = size_of::<WireResultBody>();
pub const RESULT_PACKET_LEN: usize = HEADER_LEN + RESULT_BODY_LEN;
const MAGIC: &[u8; 4] = b"BBPB";
const VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PacketKind {
Start = 1,
StartAck = 2,
Rts = 3,
Bulk = 4,
Result = 5,
End = 6,
ErrorMessage = 7,
}
impl TryFrom<u8> for PacketKind {
type Error = ProtocolError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Start),
2 => Ok(Self::StartAck),
3 => Ok(Self::Rts),
4 => Ok(Self::Bulk),
5 => Ok(Self::Result),
6 => Ok(Self::End),
7 => Ok(Self::ErrorMessage),
other => Err(ProtocolError::UnknownKind(other)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub kind: PacketKind,
pub run_id: u64,
pub sample_id: u32,
pub seq: u32,
pub bulk_len: u32,
pub sample_count: u32,
pub ip_packet_bytes: u32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResultBody {
pub attempts: u32,
pub lost_samples: u32,
pub selected_sample_id: u32,
pub accepted_samples: u32,
pub delay_sum: Duration,
pub dispersion: Duration,
pub min_dispersion: Duration,
pub capacity_mbps: f64,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireHeader {
magic: [u8; 4],
version: u8,
kind: u8,
flags: U16Be,
run_id: U64Be,
sample_id: U32Be,
seq: U32Be,
bulk_len: U32Be,
sample_count: U32Be,
ip_packet_bytes: U32Be,
reserved: U32Be,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireResultBody {
attempts: U32Be,
lost_samples: U32Be,
selected_sample_id: U32Be,
accepted_samples: U32Be,
delay_sum_nanos: U64Be,
dispersion_nanos: U64Be,
min_dispersion_nanos: U64Be,
capacity_mbps_bits: U64Be,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ProtocolError {
#[error("packet is too short")]
TooShort,
#[error("packet buffer is too small")]
BufferTooSmall,
#[error("bad PBProbe packet magic")]
BadMagic,
#[error("unsupported PBProbe protocol version {0}")]
BadVersion(u8),
#[error("unknown PBProbe packet kind {0}")]
UnknownKind(u8),
}
pub fn encode_header(dst: &mut [u8], header: Header) -> Result<usize, ProtocolError> {
encode_header_with_aux(dst, header, 0)
}
pub fn encode_header_with_aux(
dst: &mut [u8],
header: Header,
aux: u32,
) -> Result<usize, ProtocolError> {
write_bytes(dst, WireHeader::from_header(header, aux).as_bytes())
}
pub fn decode_header(src: &[u8]) -> Result<Header, ProtocolError> {
decode_header_with_aux(src).map(|(header, _aux)| header)
}
pub fn decode_header_with_aux(src: &[u8]) -> Result<(Header, u32), ProtocolError> {
let (wire, _) = WireHeader::read_from_prefix(src).map_err(|_| ProtocolError::TooShort)?;
wire.decode()
}
pub fn encode_result(
dst: &mut [u8],
header: Header,
body: ResultBody,
) -> Result<usize, ProtocolError> {
if dst.len() < RESULT_PACKET_LEN {
return Err(ProtocolError::BufferTooSmall);
}
let cursor = encode_header(dst, header)?;
write_bytes(&mut dst[cursor..], WireResultBody::from(body).as_bytes())?;
Ok(RESULT_PACKET_LEN)
}
pub fn decode_result_body(src: &[u8]) -> Result<ResultBody, ProtocolError> {
let body_src = src.get(HEADER_LEN..).ok_or(ProtocolError::TooShort)?;
let (wire, _) =
WireResultBody::read_from_prefix(body_src).map_err(|_| ProtocolError::TooShort)?;
Ok(ResultBody::from(wire))
}
pub fn duration_nanos(duration: Duration) -> u64 {
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
}
impl WireHeader {
fn from_header(header: Header, aux: u32) -> Self {
Self {
magic: *MAGIC,
version: VERSION,
kind: header.kind as u8,
flags: U16Be::ZERO,
run_id: U64Be::new(header.run_id),
sample_id: U32Be::new(header.sample_id),
seq: U32Be::new(header.seq),
bulk_len: U32Be::new(header.bulk_len),
sample_count: U32Be::new(header.sample_count),
ip_packet_bytes: U32Be::new(header.ip_packet_bytes),
reserved: U32Be::new(aux),
}
}
fn decode(self) -> Result<(Header, u32), ProtocolError> {
if self.magic != *MAGIC {
return Err(ProtocolError::BadMagic);
}
if self.version != VERSION {
return Err(ProtocolError::BadVersion(self.version));
}
Ok((
Header {
kind: PacketKind::try_from(self.kind)?,
run_id: self.run_id.get(),
sample_id: self.sample_id.get(),
seq: self.seq.get(),
bulk_len: self.bulk_len.get(),
sample_count: self.sample_count.get(),
ip_packet_bytes: self.ip_packet_bytes.get(),
},
self.reserved.get(),
))
}
}
impl From<ResultBody> for WireResultBody {
fn from(body: ResultBody) -> Self {
Self {
attempts: U32Be::new(body.attempts),
lost_samples: U32Be::new(body.lost_samples),
selected_sample_id: U32Be::new(body.selected_sample_id),
accepted_samples: U32Be::new(body.accepted_samples),
delay_sum_nanos: U64Be::new(duration_nanos(body.delay_sum)),
dispersion_nanos: U64Be::new(duration_nanos(body.dispersion)),
min_dispersion_nanos: U64Be::new(duration_nanos(body.min_dispersion)),
capacity_mbps_bits: U64Be::new(body.capacity_mbps.to_bits()),
}
}
}
impl From<WireResultBody> for ResultBody {
fn from(wire: WireResultBody) -> Self {
Self {
attempts: wire.attempts.get(),
lost_samples: wire.lost_samples.get(),
selected_sample_id: wire.selected_sample_id.get(),
accepted_samples: wire.accepted_samples.get(),
delay_sum: Duration::from_nanos(wire.delay_sum_nanos.get()),
dispersion: Duration::from_nanos(wire.dispersion_nanos.get()),
min_dispersion: Duration::from_nanos(wire.min_dispersion_nanos.get()),
capacity_mbps: f64::from_bits(wire.capacity_mbps_bits.get()),
}
}
}
fn write_bytes(dst: &mut [u8], src: &[u8]) -> Result<usize, ProtocolError> {
let Some(slot) = dst.get_mut(..src.len()) else {
return Err(ProtocolError::BufferTooSmall);
};
slot.copy_from_slice(src);
Ok(src.len())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
HEADER_LEN, Header, PacketKind, RESULT_PACKET_LEN, ResultBody, decode_header,
decode_header_with_aux, decode_result_body, encode_header, encode_header_with_aux,
encode_result,
};
#[test]
fn header_layout_is_stable() {
assert_eq!(HEADER_LEN, 40);
}
#[test]
fn header_round_trips() {
let header = Header {
kind: PacketKind::Bulk,
run_id: 7,
sample_id: 11,
seq: 3,
bulk_len: 100,
sample_count: 200,
ip_packet_bytes: 1500,
};
let mut buf = [0_u8; HEADER_LEN];
assert_eq!(encode_header(&mut buf, header), Ok(HEADER_LEN));
assert_eq!(decode_header(&buf), Ok(header));
}
#[test]
fn header_aux_round_trips() {
let header = Header {
kind: PacketKind::Bulk,
run_id: 7,
sample_id: 11,
seq: 100,
bulk_len: 100,
sample_count: 200,
ip_packet_bytes: 1500,
};
let mut buf = [0_u8; HEADER_LEN];
assert_eq!(
encode_header_with_aux(&mut buf, header, 12_345),
Ok(HEADER_LEN)
);
assert_eq!(decode_header_with_aux(&buf), Ok((header, 12_345)));
}
#[test]
fn result_round_trips() {
let header = Header {
kind: PacketKind::Result,
run_id: 9,
sample_id: 0,
seq: 0,
bulk_len: 100,
sample_count: 200,
ip_packet_bytes: 1500,
};
let body = ResultBody {
attempts: 210,
lost_samples: 10,
selected_sample_id: 42,
accepted_samples: 200,
delay_sum: Duration::from_micros(123),
dispersion: Duration::from_micros(1200),
min_dispersion: Duration::from_micros(1100),
capacity_mbps: 1000.25,
};
let mut buf = [0_u8; RESULT_PACKET_LEN];
assert_eq!(encode_result(&mut buf, header, body), Ok(RESULT_PACKET_LEN));
assert_eq!(decode_header(&buf), Ok(header));
assert_eq!(decode_result_body(&buf), Ok(body));
}
}
File diff suppressed because it is too large. Load diff
+228
View File
@@ -0,0 +1,228 @@
use std::mem::size_of;
use thiserror::Error;
use zerocopy::byteorder::{NetworkEndian, U16, U32, U64};
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
type U16Be = U16<NetworkEndian>;
type U32Be = U32<NetworkEndian>;
type U64Be = U64<NetworkEndian>;
pub const HEADER_LEN: usize = size_of::<WireHeader>();
pub const SUMMARY_BODY_LEN: usize = size_of::<WireSummaryBody>();
pub const SUMMARY_PACKET_LEN: usize = HEADER_LEN + SUMMARY_BODY_LEN;
const MAGIC: &[u8; 4] = b"BBLP";
const VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PacketKind {
EchoRequest = 1,
EchoReply = 2,
Train = 3,
SummaryRequest = 4,
SummaryReply = 5,
}
impl TryFrom<u8> for PacketKind {
type Error = ProtocolError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::EchoRequest),
2 => Ok(Self::EchoReply),
3 => Ok(Self::Train),
4 => Ok(Self::SummaryRequest),
5 => Ok(Self::SummaryReply),
other => Err(ProtocolError::UnknownKind(other)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub kind: PacketKind,
pub run_id: u64,
pub seq: u32,
pub count: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SummaryBody {
pub received_packets: u32,
pub received_bytes: u64,
pub span_nanos: u64,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireHeader {
magic: [u8; 4],
version: u8,
kind: u8,
flags: U16Be,
run_id: U64Be,
seq: U32Be,
count: U32Be,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireSummaryBody {
received_packets: U32Be,
received_bytes: U64Be,
span_nanos: U64Be,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ProtocolError {
#[error("packet is too short")]
TooShort,
#[error("packet buffer is too small")]
BufferTooSmall,
#[error("bad profiling packet magic")]
BadMagic,
#[error("unsupported profiling protocol version {0}")]
BadVersion(u8),
#[error("unknown profiling packet kind {0}")]
UnknownKind(u8),
}
pub fn encode_header(dst: &mut [u8], header: Header) -> Result<usize, ProtocolError> {
write_bytes(dst, WireHeader::from_header(header).as_bytes())
}
pub fn decode_header(src: &[u8]) -> Result<Header, ProtocolError> {
let (wire, _) = WireHeader::read_from_prefix(src).map_err(|_| ProtocolError::TooShort)?;
wire.decode()
}
pub fn encode_summary(
dst: &mut [u8],
header: Header,
body: SummaryBody,
) -> Result<usize, ProtocolError> {
if dst.len() < SUMMARY_PACKET_LEN {
return Err(ProtocolError::BufferTooSmall);
}
let cursor = encode_header(dst, header)?;
write_bytes(&mut dst[cursor..], WireSummaryBody::from(body).as_bytes())?;
Ok(SUMMARY_PACKET_LEN)
}
pub fn decode_summary_body(src: &[u8]) -> Result<SummaryBody, ProtocolError> {
let body_src = src.get(HEADER_LEN..).ok_or(ProtocolError::TooShort)?;
let (wire, _) =
WireSummaryBody::read_from_prefix(body_src).map_err(|_| ProtocolError::TooShort)?;
Ok(SummaryBody::from(wire))
}
impl WireHeader {
fn from_header(header: Header) -> Self {
Self {
magic: *MAGIC,
version: VERSION,
kind: header.kind as u8,
flags: U16Be::ZERO,
run_id: U64Be::new(header.run_id),
seq: U32Be::new(header.seq),
count: U32Be::new(header.count),
}
}
fn decode(self) -> Result<Header, ProtocolError> {
if self.magic != *MAGIC {
return Err(ProtocolError::BadMagic);
}
if self.version != VERSION {
return Err(ProtocolError::BadVersion(self.version));
}
Ok(Header {
kind: PacketKind::try_from(self.kind)?,
run_id: self.run_id.get(),
seq: self.seq.get(),
count: self.count.get(),
})
}
}
impl From<SummaryBody> for WireSummaryBody {
fn from(body: SummaryBody) -> Self {
Self {
received_packets: U32Be::new(body.received_packets),
received_bytes: U64Be::new(body.received_bytes),
span_nanos: U64Be::new(body.span_nanos),
}
}
}
impl From<WireSummaryBody> for SummaryBody {
fn from(wire: WireSummaryBody) -> Self {
Self {
received_packets: wire.received_packets.get(),
received_bytes: wire.received_bytes.get(),
span_nanos: wire.span_nanos.get(),
}
}
}
fn write_bytes(dst: &mut [u8], src: &[u8]) -> Result<usize, ProtocolError> {
let Some(slot) = dst.get_mut(..src.len()) else {
return Err(ProtocolError::BufferTooSmall);
};
slot.copy_from_slice(src);
Ok(src.len())
}
#[cfg(test)]
mod tests {
use super::{
HEADER_LEN, Header, PacketKind, SUMMARY_PACKET_LEN, SummaryBody, decode_header,
decode_summary_body, encode_header, encode_summary,
};
#[test]
fn layouts_are_stable() {
assert_eq!(HEADER_LEN, 24);
assert_eq!(SUMMARY_PACKET_LEN, 44);
}
#[test]
fn header_round_trips() {
let header = Header {
kind: PacketKind::Train,
run_id: 42,
seq: 7,
count: 64,
};
let mut buf = [0_u8; HEADER_LEN];
let encoded = encode_header(&mut buf, header);
assert_eq!(encoded, Ok(HEADER_LEN));
assert_eq!(decode_header(&buf), Ok(header));
}
#[test]
fn summary_round_trips() {
let header = Header {
kind: PacketKind::SummaryReply,
run_id: 99,
seq: 0,
count: 64,
};
let body = SummaryBody {
received_packets: 63,
received_bytes: 91_476,
span_nanos: 725_000,
};
let mut buf = [0_u8; SUMMARY_PACKET_LEN];
let encoded = encode_summary(&mut buf, header, body);
assert_eq!(encoded, Ok(SUMMARY_PACKET_LEN));
assert_eq!(decode_header(&buf), Ok(header));
assert_eq!(decode_summary_body(&buf), Ok(body));
}
}
+63
View File
@@ -0,0 +1,63 @@
use std::io;
use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6, UdpSocket};
use std::num::NonZeroU32;
use std::time::Duration;
use nix::net::if_::if_nametoindex;
use socket2::{Domain, Protocol, Socket, Type};
const PROFILE_SOCKET_BUFFER_BYTES: usize = 4 * 1024 * 1024;
pub fn open_link_local_udp(
ifname: &str,
port: u16,
read_timeout: Option<Duration>,
) -> io::Result<(UdpSocket, u32)> {
let ifindex = if_nametoindex(ifname).map_err(io::Error::from)?;
let Some(nonzero_ifindex) = NonZeroU32::new(ifindex) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid ifindex for {ifname}"),
));
};
let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?;
socket.set_reuse_address(true)?;
socket.set_reuse_port(true)?;
socket.set_only_v6(true)?;
socket.set_recv_buffer_size(PROFILE_SOCKET_BUFFER_BYTES)?;
socket.set_send_buffer_size(PROFILE_SOCKET_BUFFER_BYTES)?;
#[cfg(target_os = "linux")]
socket.bind_device(Some(ifname.as_bytes()))?;
socket.bind_device_by_index_v6(Some(nonzero_ifindex))?;
socket.bind(&SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, port, 0, 0).into())?;
let udp: UdpSocket = socket.into();
udp.set_read_timeout(read_timeout)?;
udp.set_write_timeout(read_timeout)?;
Ok((udp, ifindex))
}
pub fn scoped_peer_addr(peer: Ipv6Addr, port: u16, ifindex: u32) -> SocketAddr {
SocketAddr::V6(SocketAddrV6::new(peer, port, 0, ifindex))
}
pub fn with_default_scope(addr: SocketAddr, ifindex: u32) -> SocketAddr {
match addr {
SocketAddr::V6(v6) if v6.ip().is_unicast_link_local() && v6.scope_id() == 0 => {
SocketAddr::V6(SocketAddrV6::new(
*v6.ip(),
v6.port(),
v6.flowinfo(),
ifindex,
))
}
other => other,
}
}
pub fn parse_link_local_addr(raw: &str) -> Result<Ipv6Addr, std::net::AddrParseError> {
let addr = raw.split_once('%').map_or(raw, |(addr, _scope)| addr);
addr.parse()
}
+722
View File
@@ -0,0 +1,722 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::io::{self, ErrorKind};
use std::net::{Ipv6Addr, SocketAddr, UdpSocket};
use std::thread;
use std::time::{Duration, Instant};
use clap::{Args, Parser, Subcommand};
use color_eyre::eyre::{Result, WrapErr, eyre};
use super::estimator::{CapacitySample, duration_nanos_u64, latency_stats};
use super::protocol::{
HEADER_LEN, Header, PacketKind, SUMMARY_PACKET_LEN, SummaryBody, decode_header,
decode_summary_body, encode_header, encode_summary,
};
use super::socket::{
open_link_local_udp, parse_link_local_addr, scoped_peer_addr, with_default_scope,
};
use super::types::{
DEFAULT_CAPACITY_ROUNDS, DEFAULT_ECHO_COUNT, DEFAULT_ECHO_INTERVAL_MS, DEFAULT_ECHO_TIMEOUT_MS,
DEFAULT_PROFILE_PORT, DEFAULT_TRAIN_INTERVAL_MS, DEFAULT_TRAIN_PACKETS,
DEFAULT_TRAIN_SETTLE_MS, ProbeConfig,
};
const MAX_UDP_PACKET_BYTES: usize = 65_535;
const REFLECT_RECV_TIMEOUT: Duration = Duration::from_secs(1);
const STALE_TRAIN_AFTER: Duration = Duration::from_secs(60);
const SUMMARY_REQUEST_ATTEMPTS: u32 = 3;
pub fn run_from_env() -> Result<()> {
run_cli(Cli::parse())
}
pub fn run<I, S>(args: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: Into<OsString> + Clone,
{
run_cli(Cli::try_parse_from(args)?)
}
fn run_cli(cli: Cli) -> Result<()> {
match cli.command {
Command::Probe(args) => run_probe(args.into_probe_options()),
Command::Reflect(options) => run_reflect(options),
}
}
#[derive(Debug, Parser)]
#[command(name = "link_profile")]
#[command(about = "Run standalone link-local latency and packet-train probes")]
#[command(arg_required_else_help = true)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Probe(ProbeArgs),
Reflect(ReflectOptions),
}
#[derive(Debug, Clone)]
struct ProbeOptions {
ifname: String,
peer: Ipv6Addr,
config: ProbeConfig,
}
#[derive(Debug, Args, Clone)]
struct ReflectOptions {
#[arg(long)]
ifname: String,
#[arg(long, default_value_t = DEFAULT_PROFILE_PORT)]
port: u16,
}
#[derive(Debug, Args, Clone)]
struct ProbeArgs {
#[arg(long)]
ifname: String,
#[arg(long, value_parser = parse_link_local_addr_arg)]
peer: Ipv6Addr,
#[arg(long, default_value_t = DEFAULT_PROFILE_PORT)]
port: u16,
#[arg(long = "echo-count", default_value_t = DEFAULT_ECHO_COUNT)]
echo_count: u32,
#[arg(long = "echo-interval-ms", default_value_t = DEFAULT_ECHO_INTERVAL_MS)]
echo_interval_ms: u64,
#[arg(long = "timeout-ms", default_value_t = DEFAULT_ECHO_TIMEOUT_MS)]
timeout_ms: u64,
#[arg(long = "capacity-rounds", default_value_t = DEFAULT_CAPACITY_ROUNDS)]
capacity_rounds: u32,
#[arg(long = "train-packets", default_value_t = DEFAULT_TRAIN_PACKETS)]
train_packets: u32,
#[arg(long = "payload-bytes", default_value_t = usize::from(crate::config::TUN_MTU))]
payload_bytes: usize,
#[arg(long = "train-interval-ms", default_value_t = DEFAULT_TRAIN_INTERVAL_MS)]
train_interval_ms: u64,
#[arg(long = "settle-ms", default_value_t = DEFAULT_TRAIN_SETTLE_MS)]
settle_ms: u64,
}
impl ProbeArgs {
fn into_probe_options(self) -> ProbeOptions {
ProbeOptions {
ifname: self.ifname,
peer: self.peer,
config: ProbeConfig {
port: self.port,
echo_count: self.echo_count,
echo_interval: Duration::from_millis(self.echo_interval_ms),
echo_timeout: Duration::from_millis(self.timeout_ms),
capacity_rounds: self.capacity_rounds,
train_packets: self.train_packets,
train_payload_bytes: self.payload_bytes,
train_interval: Duration::from_millis(self.train_interval_ms),
train_settle: Duration::from_millis(self.settle_ms),
},
}
}
}
#[derive(Debug)]
struct TrainAccumulator {
received_packets: u32,
received_bytes: u64,
first_rx: Option<Instant>,
last_rx: Option<Instant>,
last_update: Instant,
seen: Vec<bool>,
}
impl TrainAccumulator {
fn new(expected_packets: u32, now: Instant) -> Self {
Self {
received_packets: 0,
received_bytes: 0,
first_rx: None,
last_rx: None,
last_update: now,
seen: vec![false; usize::try_from(expected_packets).unwrap_or(0)],
}
}
fn record(&mut self, seq: u32, packet_len: usize, now: Instant) {
self.last_update = now;
if !mark_seen(&mut self.seen, seq) {
return;
}
self.received_packets = self.received_packets.saturating_add(1);
self.received_bytes = self
.received_bytes
.saturating_add(u64::try_from(packet_len).unwrap_or(u64::MAX));
if self.first_rx.is_none() {
self.first_rx = Some(now);
}
self.last_rx = Some(now);
}
fn summary(&self) -> SummaryBody {
let span_nanos = match (self.first_rx, self.last_rx) {
(Some(first), Some(last)) => duration_nanos_u64(last.saturating_duration_since(first)),
_ => 0,
};
SummaryBody {
received_packets: self.received_packets,
received_bytes: self.received_bytes,
span_nanos,
}
}
}
fn mark_seen(seen: &mut [bool], seq: u32) -> bool {
let Ok(index) = usize::try_from(seq) else {
return false;
};
let Some(slot) = seen.get_mut(index) else {
return false;
};
if *slot {
return false;
}
*slot = true;
true
}
fn run_reflect(options: ReflectOptions) -> Result<()> {
let (socket, ifindex) =
open_link_local_udp(&options.ifname, options.port, Some(REFLECT_RECV_TIMEOUT))
.wrap_err_with(|| format!("opening profiling reflector on {}", options.ifname))?;
let local_addr = socket
.local_addr()
.wrap_err("reading reflector local address")?;
println!(
"reflecting profiling probes on {} ifindex={} local={}",
options.ifname, ifindex, local_addr
);
let mut buf = vec![0_u8; MAX_UDP_PACKET_BYTES];
let mut trains = HashMap::<u64, TrainAccumulator>::new();
let mut last_cleanup = Instant::now();
loop {
cleanup_stale_trains(&mut trains, &mut last_cleanup);
let (packet_len, from) = match socket.recv_from(&mut buf) {
Ok(received) => received,
Err(err) if is_timeout(&err) || err.kind() == ErrorKind::Interrupted => continue,
Err(err) => return Err(err).wrap_err("receiving profiling packet"),
};
let Some(packet) = buf.get(..packet_len) else {
continue;
};
let Ok(header) = decode_header(packet) else {
continue;
};
match header.kind {
PacketKind::EchoRequest => {
send_echo_reply(&socket, ifindex, from, header).wrap_err("sending echo reply")?;
}
PacketKind::Train => {
let now = Instant::now();
trains
.entry(header.run_id)
.or_insert_with(|| TrainAccumulator::new(header.count, now))
.record(header.seq, packet_len, now);
}
PacketKind::SummaryRequest => {
send_summary_reply(&socket, ifindex, from, header, &mut trains)
.wrap_err("sending train summary")?;
}
PacketKind::EchoReply | PacketKind::SummaryReply => {}
}
}
}
fn run_probe(options: ProbeOptions) -> Result<()> {
if options.config.train_payload_bytes < HEADER_LEN {
return Err(eyre!(
"train payload must be at least {HEADER_LEN} bytes, got {}",
options.config.train_payload_bytes
));
}
if options.config.train_packets == 0 {
return Err(eyre!("train packet count must be non-zero"));
}
let (socket, ifindex) =
open_link_local_udp(&options.ifname, 0, Some(options.config.echo_timeout))
.wrap_err_with(|| format!("opening profiling probe socket on {}", options.ifname))?;
let peer = scoped_peer_addr(options.peer, options.config.port, ifindex);
let local_addr = socket
.local_addr()
.wrap_err("reading probe local address")?;
let base_run_id = make_base_run_id();
println!(
"probing {} via {} ifindex={} local={} peer_port={}",
options.peer, options.ifname, ifindex, local_addr, options.config.port
);
println!(
"capacity probe: rounds={} train_packets={} payload_bytes={} interval_ms={}",
options.config.capacity_rounds,
options.config.train_packets,
options.config.train_payload_bytes,
options.config.train_interval.as_millis()
);
let latency_samples = run_echo_probes(&socket, peer, base_run_id, &options.config)
.wrap_err("running latency probes")?;
print_latency_summary(options.config.echo_count, &latency_samples);
let capacity_samples = run_capacity_probes(&socket, peer, base_run_id, &options.config)
.wrap_err("running capacity probes")?;
print_capacity_summary(&capacity_samples);
Ok(())
}
fn send_echo_reply(
socket: &UdpSocket,
ifindex: u32,
from: SocketAddr,
request: Header,
) -> Result<()> {
let reply = Header {
kind: PacketKind::EchoReply,
run_id: request.run_id,
seq: request.seq,
count: request.count,
};
let mut out = [0_u8; HEADER_LEN];
encode_header(&mut out, reply)?;
send_datagram(socket, &out, with_default_scope(from, ifindex))
}
fn send_summary_reply(
socket: &UdpSocket,
ifindex: u32,
from: SocketAddr,
request: Header,
trains: &mut HashMap<u64, TrainAccumulator>,
) -> Result<()> {
let body = trains
.get(&request.run_id)
.map_or_else(empty_summary, TrainAccumulator::summary);
let reply = Header {
kind: PacketKind::SummaryReply,
run_id: request.run_id,
seq: 0,
count: request.count,
};
let mut out = [0_u8; SUMMARY_PACKET_LEN];
let len = encode_summary(&mut out, reply, body)?;
send_datagram(
socket,
out.get(..len).unwrap_or(&out),
with_default_scope(from, ifindex),
)?;
trains.remove(&request.run_id);
Ok(())
}
fn run_echo_probes(
socket: &UdpSocket,
peer: SocketAddr,
base_run_id: u64,
config: &ProbeConfig,
) -> Result<Vec<Duration>> {
let mut samples = Vec::new();
let run_id = base_run_id ^ 0xe0c0_u64;
for seq in 0..config.echo_count {
let header = Header {
kind: PacketKind::EchoRequest,
run_id,
seq,
count: config.echo_count,
};
let mut out = [0_u8; HEADER_LEN];
encode_header(&mut out, header)?;
let start = Instant::now();
send_datagram(socket, &out, peer)?;
match receive_echo_reply(socket, run_id, seq, start, config.echo_timeout)? {
Some(sample) => {
println!("echo {:>3}: {}", seq + 1, format_duration(sample));
samples.push(sample);
}
None => {
println!("echo {:>3}: timeout", seq + 1);
}
}
thread::sleep(config.echo_interval);
}
Ok(samples)
}
fn run_capacity_probes(
socket: &UdpSocket,
peer: SocketAddr,
base_run_id: u64,
config: &ProbeConfig,
) -> Result<Vec<CapacitySample>> {
let mut samples = Vec::new();
let mut train = vec![0_u8; config.train_payload_bytes];
for round in 0..config.capacity_rounds {
let run_id = base_run_id ^ (0xc0_ffee_u64.wrapping_add(u64::from(round)));
let sender_start = Instant::now();
for seq in 0..config.train_packets {
let header = Header {
kind: PacketKind::Train,
run_id,
seq,
count: config.train_packets,
};
encode_header(&mut train, header)?;
send_datagram(socket, &train, peer)?;
}
let sender_span = sender_start.elapsed();
thread::sleep(config.train_settle);
let summary = request_summary(socket, peer, run_id, config)?;
match summary {
Some(body) => {
let sample = CapacitySample {
sent_packets: config.train_packets,
received_packets: body.received_packets,
received_bytes: body.received_bytes,
span: Duration::from_nanos(body.span_nanos),
};
print_capacity_round(round + 1, sample, sender_span);
samples.push(sample);
}
None => {
println!(
"capacity {:>3}: summary timeout after sender_burst={}",
round + 1,
format_duration(sender_span)
);
}
}
thread::sleep(config.train_interval);
}
Ok(samples)
}
fn request_summary(
socket: &UdpSocket,
peer: SocketAddr,
run_id: u64,
config: &ProbeConfig,
) -> Result<Option<SummaryBody>> {
let request = Header {
kind: PacketKind::SummaryRequest,
run_id,
seq: 0,
count: config.train_packets,
};
let mut out = [0_u8; HEADER_LEN];
encode_header(&mut out, request)?;
let attempt_timeout = div_duration(config.echo_timeout, SUMMARY_REQUEST_ATTEMPTS);
for _attempt in 0..SUMMARY_REQUEST_ATTEMPTS {
send_datagram(socket, &out, peer)?;
let deadline = Instant::now() + attempt_timeout;
if let Some(summary) = receive_summary_reply(socket, run_id, deadline)? {
return Ok(Some(summary));
}
}
Ok(None)
}
fn receive_echo_reply(
socket: &UdpSocket,
run_id: u64,
seq: u32,
start: Instant,
timeout: Duration,
) -> Result<Option<Duration>> {
let deadline = start + timeout;
let mut buf = vec![0_u8; MAX_UDP_PACKET_BYTES];
loop {
if !set_timeout_until(socket, deadline)? {
return Ok(None);
}
let (packet_len, _from) = match socket.recv_from(&mut buf) {
Ok(received) => received,
Err(err) if is_timeout(&err) => return Ok(None),
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(err) => return Err(err).wrap_err("receiving echo reply"),
};
let Some(packet) = buf.get(..packet_len) else {
continue;
};
let Ok(header) = decode_header(packet) else {
continue;
};
if header.kind == PacketKind::EchoReply && header.run_id == run_id && header.seq == seq {
return Ok(Some(start.elapsed()));
}
}
}
fn receive_summary_reply(
socket: &UdpSocket,
run_id: u64,
deadline: Instant,
) -> Result<Option<SummaryBody>> {
let mut buf = vec![0_u8; MAX_UDP_PACKET_BYTES];
loop {
if !set_timeout_until(socket, deadline)? {
return Ok(None);
}
let (packet_len, _from) = match socket.recv_from(&mut buf) {
Ok(received) => received,
Err(err) if is_timeout(&err) => return Ok(None),
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(err) => return Err(err).wrap_err("receiving train summary"),
};
let Some(packet) = buf.get(..packet_len) else {
continue;
};
let Ok(header) = decode_header(packet) else {
continue;
};
if header.kind == PacketKind::SummaryReply && header.run_id == run_id {
return Ok(Some(decode_summary_body(packet)?));
}
}
}
fn send_datagram(socket: &UdpSocket, buf: &[u8], target: SocketAddr) -> Result<()> {
let sent = socket
.send_to(buf, target)
.wrap_err_with(|| format!("sending profiling packet to {target}"))?;
if sent != buf.len() {
return Err(eyre!(
"short UDP send to {target}: sent {sent} of {} bytes",
buf.len()
));
}
Ok(())
}
fn set_timeout_until(socket: &UdpSocket, deadline: Instant) -> Result<bool> {
let now = Instant::now();
if now >= deadline {
return Ok(false);
}
socket
.set_read_timeout(Some(deadline.saturating_duration_since(now)))
.wrap_err("setting profiling socket read timeout")?;
Ok(true)
}
fn cleanup_stale_trains(trains: &mut HashMap<u64, TrainAccumulator>, last_cleanup: &mut Instant) {
if last_cleanup.elapsed() < Duration::from_secs(5) {
return;
}
trains.retain(|_run_id, train| train.last_update.elapsed() < STALE_TRAIN_AFTER);
*last_cleanup = Instant::now();
}
fn empty_summary() -> SummaryBody {
SummaryBody {
received_packets: 0,
received_bytes: 0,
span_nanos: 0,
}
}
fn print_latency_summary(sent: u32, samples: &[Duration]) {
match latency_stats(sent, samples) {
Some(stats) => println!(
"latency summary: sent={} received={} loss={:.1}% min={} avg={} max={}",
stats.sent,
stats.received,
stats.loss_ratio * 100.0,
format_duration(stats.min),
format_duration(stats.avg),
format_duration(stats.max)
),
None => println!("latency summary: no replies"),
}
}
fn print_capacity_round(round: u32, sample: CapacitySample, sender_span: Duration) {
let mbps = sample
.mbps()
.map_or_else(|| "n/a".to_owned(), |value| format!("{value:.1} Mbps"));
println!(
"capacity {:>3}: rx={}/{} loss={:.1}% span={} estimate={} sender_burst={}",
round,
sample.received_packets,
sample.sent_packets,
sample.loss_ratio() * 100.0,
format_duration(sample.span),
mbps,
format_duration(sender_span)
);
}
fn print_capacity_summary(samples: &[CapacitySample]) {
let mut estimates = samples
.iter()
.filter_map(|sample| sample.mbps())
.collect::<Vec<f64>>();
if estimates.is_empty() {
println!("capacity summary: no usable samples");
return;
}
estimates.sort_by(f64::total_cmp);
let median_index = estimates.len() / 2;
let median = estimates.get(median_index).copied().unwrap_or(0.0);
let best = estimates.last().copied().unwrap_or(median);
let received = samples
.iter()
.map(|sample| sample.received_packets)
.sum::<u32>();
let sent = samples
.iter()
.map(|sample| sample.sent_packets)
.sum::<u32>();
let loss = if sent == 0 {
0.0
} else {
f64::from(sent.saturating_sub(received)) / f64::from(sent)
};
println!(
"capacity summary: samples={} median={median:.1} Mbps best={best:.1} Mbps aggregate_loss={:.1}%",
estimates.len(),
loss * 100.0
);
}
fn parse_link_local_addr_arg(raw: &str) -> std::result::Result<Ipv6Addr, String> {
parse_link_local_addr(raw).map_err(|err| err.to_string())
}
fn is_timeout(err: &io::Error) -> bool {
matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut)
}
fn div_duration(duration: Duration, divisor: u32) -> Duration {
if divisor == 0 {
return duration;
}
Duration::from_nanos(duration_nanos_u64(duration) / u64::from(divisor))
}
fn format_duration(duration: Duration) -> String {
if duration < Duration::from_millis(1) {
return format!("{:.3} us", duration.as_secs_f64() * 1_000_000.0);
}
format!("{:.3} ms", duration.as_secs_f64() * 1_000.0)
}
fn make_base_run_id() -> u64 {
rand::random()
}
#[cfg(test)]
mod tests {
use std::net::Ipv6Addr;
use clap::Parser;
use super::{Cli, Command, mark_seen};
#[test]
fn parses_probe_options() {
let cli = Cli::try_parse_from([
"link_profile",
"probe",
"--ifname",
"en3",
"--peer",
"fe80::1%en3",
"--train-packets",
"32",
])
.expect("probe options should parse");
match cli.command {
Command::Probe(args) => {
let options = args.into_probe_options();
assert_eq!(options.ifname, "en3");
assert_eq!(
options.peer,
"fe80::1".parse::<Ipv6Addr>().expect("valid IPv6")
);
assert_eq!(options.config.train_packets, 32);
}
Command::Reflect(_) => panic!("expected probe command"),
}
}
#[test]
fn parses_reflect_options() {
let cli = Cli::try_parse_from([
"link_profile",
"reflect",
"--ifname",
"en2",
"--port",
"42000",
])
.expect("reflect options should parse");
match cli.command {
Command::Reflect(options) => {
assert_eq!(options.ifname, "en2");
assert_eq!(options.port, 42_000);
}
Command::Probe(_) => panic!("expected reflect command"),
}
}
#[test]
fn mark_seen_accepts_each_sequence_once() {
let mut seen = vec![false; 2];
assert!(mark_seen(&mut seen, 0));
assert!(!mark_seen(&mut seen, 0));
assert!(mark_seen(&mut seen, 1));
assert!(!mark_seen(&mut seen, 2));
}
}
+49
View File
@@ -0,0 +1,49 @@
use std::net::Ipv6Addr;
use std::time::Duration;
use crate::config::TUN_MTU;
pub const DEFAULT_PROFILE_PORT: u16 = 41_901;
pub const DEFAULT_ECHO_COUNT: u32 = 10;
pub const DEFAULT_ECHO_INTERVAL_MS: u64 = 250;
pub const DEFAULT_ECHO_TIMEOUT_MS: u64 = 500;
pub const DEFAULT_CAPACITY_ROUNDS: u32 = 5;
pub const DEFAULT_TRAIN_PACKETS: u32 = 64;
pub const DEFAULT_TRAIN_INTERVAL_MS: u64 = 1_000;
pub const DEFAULT_TRAIN_SETTLE_MS: u64 = 25;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LinkKey {
pub ifname: Box<str>,
pub ifindex: u32,
pub peer_link_local: Ipv6Addr,
}
#[derive(Debug, Clone)]
pub struct ProbeConfig {
pub port: u16,
pub echo_count: u32,
pub echo_interval: Duration,
pub echo_timeout: Duration,
pub capacity_rounds: u32,
pub train_packets: u32,
pub train_payload_bytes: usize,
pub train_interval: Duration,
pub train_settle: Duration,
}
impl Default for ProbeConfig {
fn default() -> Self {
Self {
port: DEFAULT_PROFILE_PORT,
echo_count: DEFAULT_ECHO_COUNT,
echo_interval: Duration::from_millis(DEFAULT_ECHO_INTERVAL_MS),
echo_timeout: Duration::from_millis(DEFAULT_ECHO_TIMEOUT_MS),
capacity_rounds: DEFAULT_CAPACITY_ROUNDS,
train_packets: DEFAULT_TRAIN_PACKETS,
train_payload_bytes: usize::from(TUN_MTU),
train_interval: Duration::from_millis(DEFAULT_TRAIN_INTERVAL_MS),
train_settle: Duration::from_millis(DEFAULT_TRAIN_SETTLE_MS),
}
}
}
+59
View File
@@ -0,0 +1,59 @@
use std::io;
use std::net::IpAddr;
use ipnet::Ipv6Net;
use nix::net::if_::if_nametoindex;
use route_manager::{Route, RouteManager};
use crate::Result;
fn route_destination(prefix: Ipv6Net) -> IpAddr {
IpAddr::V6(prefix.trunc().addr())
}
fn is_overlay_route(route: &Route, prefix: Ipv6Net) -> bool {
route.destination() == route_destination(prefix) && route.prefix() == prefix.prefix_len()
}
fn tun_if_index(tun_ifname: &str) -> io::Result<u32> {
if_nametoindex(tun_ifname).map_err(io::Error::from)
}
pub fn ensure_overlay_route(prefix: Ipv6Net, tun_ifname: &str) -> Result<()> {
let tun_ifindex = tun_if_index(tun_ifname)?;
let desired =
Route::new(route_destination(prefix), prefix.prefix_len()).with_if_index(tun_ifindex);
let mut manager = RouteManager::new()?;
let existing: Vec<Route> = manager
.list()?
.into_iter()
.filter(|route| is_overlay_route(route, prefix))
.collect();
let already_present = existing
.iter()
.any(|route| route.if_index() == Some(tun_ifindex) && route.gateway().is_none());
if already_present {
return Ok(());
}
for route in existing {
manager.delete(&route)?;
}
manager.add(&desired)?;
Ok(())
}
pub fn remove_overlay_route(prefix: Ipv6Net) -> Result<()> {
let mut manager = RouteManager::new()?;
let existing: Vec<Route> = manager
.list()?
.into_iter()
.filter(|route| is_overlay_route(route, prefix))
.collect();
for route in existing {
manager.delete(&route)?;
}
Ok(())
}
+215
View File
@@ -0,0 +1,215 @@
use std::sync::Arc;
use color_eyre::eyre::{Result, WrapErr};
use ipnet::Ipv6Net;
use tokio::{
sync::{mpsc, watch},
task::JoinHandle,
time::{Duration, MissedTickBehavior},
};
use crate::babel::BabelState;
use crate::config::TransportMode;
use crate::daemon::{RoutingStackEvent, StackTaskKind};
use crate::dataplane::{Dataplane, DataplaneConfig, DataplanePublisher, PublishSnapshotError};
use crate::fib::FibBuilder;
use crate::tun::TunDevice;
pub struct RoutingStack {
babel: JoinHandle<crate::Result<()>>,
watcher: JoinHandle<crate::Result<()>>,
state_logger: JoinHandle<()>,
fib_publisher: JoinHandle<crate::Result<()>>,
dataplane_monitor: JoinHandle<()>,
dataplane: Dataplane,
}
impl RoutingStack {
pub fn start(
node_addr: Ipv6Net,
tun: &TunDevice,
udp_port: u16,
transport_mode: TransportMode,
tun_mtu: u16,
tcp_batch_target_bytes: usize,
tcp_socket_buffer_bytes: usize,
state_send: watch::Sender<Arc<BabelState>>,
event_send: mpsc::Sender<RoutingStackEvent>,
) -> Result<Self> {
let (iface_send, iface_recv) = mpsc::channel(32);
let mut state_recv = state_send.subscribe();
let fib_state_recv = state_send.subscribe();
let initial_state = state_send.borrow().clone();
let mut dataplane = Dataplane::spawn(DataplaneConfig {
tun_device: tun.shared_device(),
udp_port,
transport_mode,
tun_mtu,
tcp_batch_target_bytes,
tcp_socket_buffer_bytes,
initial_fib: Arc::new(
FibBuilder::new([node_addr.addr()], tun_mtu).derive(initial_state.as_ref()),
),
})?;
let dataplane_exit = dataplane
.take_exit_receiver()
.ok_or_else(|| color_eyre::eyre::eyre!("dataplane exit receiver missing"))?;
let state_logger = tokio::spawn(async move {
while state_recv.changed().await.is_ok() {
let snapshot = state_recv.borrow_and_update();
tracing::info!(state = ?*snapshot, "babel state snapshot updated");
}
tracing::info!("babel state stream closed");
});
let babel_events = event_send.clone();
let babel = tokio::spawn(async move {
let res = crate::babel(node_addr, iface_recv, state_send).await;
let _ = babel_events
.send(RoutingStackEvent::Exited {
kind: StackTaskKind::Babel,
error: res.as_ref().err().map(ToString::to_string),
})
.await;
res
});
let watcher_events = event_send.clone();
let watcher = tokio::spawn(async move {
let res = crate::watch(iface_send).await;
let _ = watcher_events
.send(RoutingStackEvent::Exited {
kind: StackTaskKind::Watcher,
error: res.as_ref().err().map(ToString::to_string),
})
.await;
res
});
let fib_events = event_send.clone();
let dataplane_publisher = dataplane.publisher();
let fib_publisher = tokio::spawn(async move {
let res =
publish_fib_updates(node_addr, tun_mtu, fib_state_recv, dataplane_publisher).await;
let _ = fib_events
.send(RoutingStackEvent::Exited {
kind: StackTaskKind::FibPublisher,
error: res.as_ref().err().map(ToString::to_string),
})
.await;
res
});
let dataplane_events = event_send;
let dataplane_monitor = tokio::spawn(async move {
let exit = dataplane_exit.await;
let (kind, error) = match exit {
Ok(Ok(())) => (StackTaskKind::Dataplane, None),
Ok(Err(err)) => (StackTaskKind::Dataplane, Some(err)),
Err(err) => (
StackTaskKind::Dataplane,
Some(format!("dataplane exit receiver dropped: {err}")),
),
};
let _ = dataplane_events
.send(RoutingStackEvent::Exited { kind, error })
.await;
});
Ok(Self {
babel,
watcher,
state_logger,
fib_publisher,
dataplane_monitor,
dataplane,
})
}
pub async fn stop(self) -> Result<()> {
let Self {
babel,
watcher,
state_logger,
fib_publisher,
dataplane_monitor,
dataplane,
} = self;
watcher.abort();
if let Ok(res) = watcher.await {
res.wrap_err("stopping interface watcher")?;
}
state_logger.abort();
let _ = state_logger.await;
fib_publisher.abort();
let _ = fib_publisher.await;
dataplane_monitor.abort();
let _ = dataplane_monitor.await;
dataplane.stop().wrap_err("stopping dataplane thread")?;
babel.await?.wrap_err("stopping babeld runtime")?;
Ok(())
}
}
async fn publish_fib_updates(
node_addr: Ipv6Net,
tun_mtu: u16,
mut state_recv: watch::Receiver<Arc<BabelState>>,
publisher: DataplanePublisher,
) -> crate::Result<()> {
let builder = FibBuilder::new([node_addr.addr()], tun_mtu);
let mut pending = Some(Arc::new(builder.derive(state_recv.borrow().as_ref())));
let mut published: Option<Arc<crate::fib::FibSnapshot>> = None;
let mut retry_tick = tokio::time::interval(Duration::from_millis(10));
retry_tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
if let Some(snapshot) = pending.take() {
let published_snapshot = Arc::clone(&snapshot);
match publisher.try_publish(snapshot) {
Ok(()) => {
published = Some(published_snapshot);
}
Err(PublishSnapshotError::Full(snapshot)) => {
pending = Some(snapshot);
}
Err(PublishSnapshotError::Stopped) => {
return Err(crate::BabbleError::Other(
"dataplane thread stopped".to_owned(),
));
}
}
}
tokio::select! {
changed = state_recv.changed() => {
match changed {
Ok(()) => {
let snapshot = {
let state = state_recv.borrow_and_update();
Arc::new(builder.derive(state.as_ref()))
};
let matches_published = published
.as_ref()
.is_some_and(|current| current.as_ref() == snapshot.as_ref());
let matches_pending = pending
.as_ref()
.is_some_and(|current| current.as_ref() == snapshot.as_ref());
if !matches_published && !matches_pending {
pending = Some(snapshot);
}
}
Err(_) => return Ok(()),
}
}
_ = retry_tick.tick(), if pending.is_some() => {}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
use ipnet::Ipv6Net;
use std::net::Ipv6Addr;
use std::sync::Arc;
use tun_rs::{DeviceBuilder, SyncDevice};
#[cfg(target_os = "linux")]
const DESIRED_TUN_NAME: &str = "exonet";
/// Holds the TUN device open for the lifetime of the daemon.
/// The interface disappears when this is dropped.
pub struct TunDevice {
dev: Arc<SyncDevice>,
ifname: String,
node_addr: Ipv6Net, // TODO: we are only ever gonna install /128 subnets, maybe change to Ipv6Addr in future??
}
impl TunDevice {
pub fn create(node_addr: Ipv6Addr, mtu: u16) -> crate::Result<Self> {
let builder = DeviceBuilder::new().ipv6(node_addr, 128u8).mtu(mtu);
#[cfg(target_os = "linux")]
let builder = builder.name(DESIRED_TUN_NAME);
let dev = builder
.with(|builder| {
builder.packet_information(false);
#[cfg(target_os = "macos")]
{
// Route ownership stays in userspace; do not let tun-rs auto-add routes.
// The IPv6 /128 address itself is still applied by tun-rs.
builder.associate_route(false);
}
})
.build_sync()?;
dev.set_nonblocking(true)?;
let ifname = dev.name()?;
Ok(Self {
dev: Arc::new(dev),
ifname,
node_addr: Ipv6Net::new_assert(node_addr, 128), // TODO: i dont't like the magic numbers, I also don't like wrapping and unwrapping
})
}
pub fn ifname(&self) -> &str {
&self.ifname
}
pub fn node_addr(&self) -> Ipv6Net {
self.node_addr
}
pub fn device(&self) -> &SyncDevice {
self.dev.as_ref()
}
pub fn shared_device(&self) -> Arc<SyncDevice> {
Arc::clone(&self.dev)
}
}
+15 -2
View File
@@ -1,7 +1,7 @@
{ inputs, ... }:
{
perSystem =
{ inputs', pkgs, lib, ... }:
{ inputs', self', pkgs, lib, ... }:
let
# Fenix nightly toolchain with all components
rustToolchain = inputs'.fenix.packages.stable.withComponents [
@@ -79,7 +79,7 @@
};
config = {
packages = {
packages = rec {
# Python bindings wheel via maturin
exo_pyo3_bindings = craneLib.buildPackage (
commonArgs
@@ -110,6 +110,19 @@
'';
}
);
babblerd-unwrapped = craneLib.buildPackage (
commonArgs // {
inherit cargoArtifacts;
pname = "babblerd-unwrapped";
}
);
babblerd = pkgs.writeShellApplication {
name = "babblerd";
runtimeInputs = [ pkgs.babeld pkgs.iperf3 ];
text = ''
exec ${babblerd-unwrapped}/bin/babblerd "$@"
'';
};
};
checks = {
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import argparse
import socket
import sys
def main() -> int:
p = argparse.ArgumentParser(
description="IPv6 UDP client with optional explicit source bind"
)
p.add_argument("--dest", required=True, help="Destination IPv6 address")
p.add_argument("--port", type=int, default=45679, help="Destination UDP port")
p.add_argument("--source", help="Optional source IPv6 address to bind to")
p.add_argument("--message", default="hello", help="Payload to send")
p.add_argument(
"--timeout", type=float, default=5.0, help="Receive timeout in seconds"
)
args = p.parse_args()
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
s.settimeout(args.timeout)
if args.source:
s.bind((args.source, 0, 0, 0))
print(f"local-before-send={s.getsockname()}")
s.sendto(args.message.encode(), (args.dest, args.port, 0, 0))
print(f"sent to=[{args.dest}]:{args.port}")
print(f"local-after-send={s.getsockname()}")
data, peer = s.recvfrom(65535)
print(f"from={peer} data={data!r}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import argparse
import socket
import sys
def main() -> int:
p = argparse.ArgumentParser(
description="IPv6 UDP server bound to a specific local address"
)
p.add_argument(
"--bind", required=True, help="Local IPv6 address to bind to, e.g. fde0:..."
)
p.add_argument("--port", type=int, default=45679, help="UDP port to listen on")
p.add_argument("--reply", default="ok", help="Reply prefix")
args = p.parse_args()
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
s.bind((args.bind, args.port, 0, 0))
print(f"listening on [{args.bind}]:{args.port}")
print(f"sockname={s.getsockname()}")
data, peer = s.recvfrom(65535)
print(f"from={peer} data={data!r}")
out = args.reply.encode() + b":" + data
s.sendto(out, peer)
print(f"sent={out!r} to={peer}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Generated
+847 -774
View File
File diff suppressed because it is too large. Load diff