[PATCH v4 06/10] tools: add spectrum-router

Yureka Lilian
2025-11-29T19:47:35Z

The tokio-vhost & vhost-device-net crates which we also wrote and depend
on are left external in the outlook of becoming a rust-vmm project soon.

Signed-off-by: Yureka Lilian <yureka@cyberchaos.dev>
---
pkgs/default.nix | 2 +
tools/router/Cargo.lock | 807 ++++++++++++++++++++++++++++++++
tools/router/Cargo.lock.license | 2 +
tools/router/Cargo.toml | 21 +
tools/router/default.nix | 18 +
tools/router/src/main.rs | 73 +++
tools/router/src/packet.rs | 187 ++++++++
tools/router/src/protocol.rs | 65 +++
tools/router/src/router.rs | 147 ++++++
tools/router/src/upstream.rs | 175 +++++++
10 files changed, 1497 insertions(+)
create mode 100644 tools/router/Cargo.lock
create mode 100644 tools/router/Cargo.lock.license
create mode 100644 tools/router/Cargo.toml
create mode 100644 tools/router/default.nix
create mode 100644 tools/router/src/main.rs
create mode 100644 tools/router/src/packet.rs
create mode 100644 tools/router/src/protocol.rs
create mode 100644 tools/router/src/router.rs
create mode 100644 tools/router/src/upstream.rs

diff --git a/pkgs/default.nix b/pkgs/default.nix
index 0d159c9..c7311cb 100644
--- a/pkgs/default.nix
+++ b/pkgs/default.nix
@@ -52,6 +52,8 @@ let
xdg-desktop-portal-spectrum-host =
self.callSpectrumPackage ../tools/xdg-desktop-portal-spectrum-host {};

+ spectrum-router = self.callSpectrumPackage ../tools/router {};
+
# Packages from the overlay, so it's possible to build them from
# the CLI easily.
inherit (pkgs) cloud-hypervisor dbus;
diff --git a/tools/router/Cargo.lock b/tools/router/Cargo.lock
new file mode 100644
index 0000000..60d7657
--- /dev/null
+++ b/tools/router/Cargo.lock
@@ -0,0 +1,807 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
+[[package]]
+name = "bytes"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
+
+[[package]]
+name = "clap"
+version = "4.5.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "env_filter"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jiff"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
+dependencies = [
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.177"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
+
+[[package]]
+name = "log"
+version = "0.4.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+[[package]]
+name = "mio"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "portable-atomic"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "regex"
+version = "1.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+
+[[package]]
+name = "socket2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "spectrum-router"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "arrayvec",
+ "clap",
+ "env_logger",
+ "futures-util",
+ "log",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "vhost-device-net",
+ "vm-memory",
+ "zerocopy",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio"
+version = "1.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-eventfd"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e66bd133670ac39baa1aca5c3a86709f4595c08ca4464a1e1400b83d62c0639"
+dependencies = [
+ "futures-lite",
+ "libc",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-vhost"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e218319ac446f670a0e049050e22c3dd3bdd5cb34a6e91e90a95a48c0a76fe7"
+dependencies = [
+ "async-stream",
+ "bitvec",
+ "futures-util",
+ "libc",
+ "log",
+ "tokio",
+ "tokio-eventfd",
+ "virtio-queue",
+ "vm-memory",
+ "zerocopy",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vhost-device-net"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf2581adf4a04f8361e92208b6fb81aa8d82b7f0a2801a4621153919db085e6"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tokio-vhost",
+ "vm-memory",
+]
+
+[[package]]
+name = "virtio-bindings"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "804f498a26d5a63be7bbb8bdcd3869c3f286c4c4a17108905276454da0caf8cb"
+
+[[package]]
+name = "virtio-queue"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb0479158f863e59323771a1f684d843962f76960b86fecfec2bfa9c8f0f9180"
+dependencies = [
+ "log",
+ "virtio-bindings",
+ "vm-memory",
+ "vmm-sys-util",
+]
+
+[[package]]
+name = "vm-memory"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd5e56d48353c5f54ef50bd158a0452fc82f5383da840f7b8efc31695dd3b9d"
+dependencies = [
+ "libc",
+ "thiserror",
+ "winapi",
+]
+
+[[package]]
+name = "vmm-sys-util"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d21f366bf22bfba3e868349978766a965cbe628c323d58e026be80b8357ab789"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
diff --git a/tools/router/Cargo.lock.license b/tools/router/Cargo.lock.license
new file mode 100644
index 0000000..9b37611
--- /dev/null
+++ b/tools/router/Cargo.lock.license
@@ -0,0 +1,2 @@
+SPDX-License-Identifier: CC0-1.0
+SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
diff --git a/tools/router/Cargo.toml b/tools/router/Cargo.toml
new file mode 100644
index 0000000..0b96911
--- /dev/null
+++ b/tools/router/Cargo.toml
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: CC0-1.0
+# SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
+
+[package]
+name = "spectrum-router"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+anyhow = "1.0.100"
+clap = { version = "4.5.45", features = ["derive"] }
+env_logger = "0.11.8"
+log = { version = "0.4.27", features = ["release_max_level_debug"] }
+vhost-device-net = "0.1.0"
+tokio = { version = "1.48.0", features = ["macros", "rt"] }
+futures-util = "0.3.31"
+zerocopy = "0.8.27"
+tokio-stream = "0.1.17"
+arrayvec = "0.7.6"
+vm-memory = "0.16"
+tokio-util = "0.7.17"
diff --git a/tools/router/default.nix b/tools/router/default.nix
new file mode 100644
index 0000000..e70f9ec
--- /dev/null
+++ b/tools/router/default.nix
@@ -0,0 +1,18 @@
+# SPDX-FileCopyrightText: 2024 Alyssa Ross <hi@alyssa.is>
+# SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
+# SPDX-License-Identifier: MIT
+
+import ../../lib/call-package.nix (
+{ src, lib, rustPlatform }:
+
+rustPlatform.buildRustPackage {
+ name = "spectrum-router";
+
+ src = lib.fileset.toSource {
+ root = ../..;
+ fileset = lib.fileset.intersection src ./.;
+ };
+ sourceRoot = "source/tools/router";
+
+ cargoLock.lockFile = ./Cargo.lock;
+}) (_: {})
diff --git a/tools/router/src/main.rs b/tools/router/src/main.rs
new file mode 100644
index 0000000..e3aca65
--- /dev/null
+++ b/tools/router/src/main.rs
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: EUPL-1.2+
+// SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
+
+pub(crate) mod packet;
+pub(crate) mod protocol;
+mod router;
+mod upstream;
+
+use std::path::PathBuf;
+
+use packet::*;
+use router::{InterfaceId, Router};
+use upstream::Upstream;
+
+use clap::Parser;
+use futures_util::{SinkExt, TryStreamExt};
+use log::{error, info};
+use tokio::net::UnixListener;
+use vhost_device_net::{IncomingPacket, VhostDeviceNet};
+use vm_memory::GuestMemoryMmap;
+
+#[derive(Parser, Debug)]
+#[command()] //version = None, about = None, long_about = None)]
+struct Args {
+ #[arg(long)]
+ driver_listen_path: PathBuf,
+ #[arg(long)]
+ app_listen_path: PathBuf,
+}
+
+fn main() -> anyhow::Result<()> {
+ env_logger::init();
+ let args = Args::parse();
+
+ for path in [&args.driver_listen_path, &args.app_listen_path] {
+ let _ = std::fs::remove_file(path);
+ }
+
+ run_router(args)
+}
+#[tokio::main(flavor = "current_thread")]
+async fn run_router(args: Args) -> anyhow::Result<()> {
+ let app_listener = UnixListener::bind(&args.app_listen_path)?;
+ let driver_listener = UnixListener::bind(&args.driver_listen_path)?;
+
+ let mut router = Router::<GuestMemoryMmap>::new(InterfaceId::Upstream);
+
+ let (mut upstream, upstream_tx, upstream_rx) = Upstream::new(driver_listener);
+ router.add_iface(InterfaceId::Upstream, upstream_tx, upstream_rx);
+
+ tokio::spawn(async move { upstream.run().await });
+
+ let mut app_num = 0;
+
+ loop {
+ tokio::select! {
+ app_conn = app_listener.accept() => {
+ info!("app connected");
+ match app_conn {
+ Ok((stream, _addr)) => {
+ let device = VhostDeviceNet::from_unix_stream(stream).await?;
+ let stream = Box::pin(device.tx().await?.map_ok(|buf| Packet::Incoming { buf: Some(buf), decap_vlan: false }));
+ let sink = Box::pin(device.rx().await?.with(|packet: Packet<IncomingPacket<GuestMemoryMmap>>| async move { Ok(packet.out(None)?.into_reader()) }));
+ router.add_iface(InterfaceId::App(app_num), stream, sink);
+ app_num = app_num.checked_add(1).unwrap();
+ }
+ Err(e) => error!("app connection failed: {}", e),
+ }
+ }
+ _ = router.run() => {}
+ }
+ }
+}
diff --git a/tools/router/src/packet.rs b/tools/router/src/packet.rs
new file mode 100644
index 0000000..1f14ef1
--- /dev/null
+++ b/tools/router/src/packet.rs
@@ -0,0 +1,187 @@
+// SPDX-License-Identifier: EUPL-1.2+
+// SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
+
+use std::io::{self, Chain, Cursor, Read};
+
+use crate::protocol::*;
+
+use arrayvec::ArrayVec;
+use zerocopy::*;
+
+pub enum PacketData<R> {
+ Incoming(R),
+ Bytes(Cursor<Box<[u8]>>),
+}
+
+impl<R: Read> Read for PacketData<R> {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+ match self {
+ PacketData::Incoming(r) => r.read(buf),
+ PacketData::Bytes(b) => b.read(buf),
+ }
+ }
+}
+
+impl<R: Read> PacketData<R> {
+ pub fn full_packet(&mut self) -> &[u8] {
+ match self {
+ PacketData::Bytes(b) => b.get_ref().as_ref(),
+ PacketData::Incoming(r) => {
+ let mut buf = vec![];
+ r.read_to_end(&mut buf).unwrap();
+ *self = PacketData::Bytes(Cursor::new(buf.into_boxed_slice()));
+ let PacketData::Bytes(b) = self else {
+ unreachable!()
+ };
+ b.get_ref().as_ref()
+ }
+ }
+ }
+}
+
+pub enum Packet<R> {
+ /// The packet has not been looked at / read into our memory yet
+ Incoming { decap_vlan: bool, buf: Option<R> },
+ /// We've read the head of the packet to look at the headers.
+ Peek {
+ decap_vlan: bool,
+ peek: ArrayVec<u8, 64>,
+ buf: PacketData<R>,
+ },
+}
+
+pub struct PacketHeaders<'a, R> {
+ pub ether_frame: &'a mut EtherFrame,
+ pub vlan_tag: Option<&'a mut VlanTag>,
+ pub ether_type: &'a mut EtherType,
+ pub ipv6_hdr: Option<&'a mut Ipv6Header>,
+ pub peek_slice: &'a mut [u8],
+ pub buf: &'a mut PacketData<R>,
+}
+
+impl<R: Read> Packet<R> {
+ fn peek(
+ &mut self,
+ ) -> (
+ &mut ArrayVec<u8, 64>,
+ &mut PacketData<R>,
+ &mut bool, // decap_vlan
+ ) {
+ match self {
+ Packet::Incoming { buf, decap_vlan } => {
+ let mut buf = std::mem::take(buf).unwrap();
+ // A stack allocation which can keep all headers we are interested in
+ let mut peek = [0u8; 64];
+ // Read the first 64 bytes
+ // 64 >= 14 (ether) + 4 (vlan) + 40 (ipv6) + 4 (icmpv6)
+ let n = buf.read(&mut peek).unwrap();
+
+ let buf = PacketData::Incoming(buf);
+ let mut peek = ArrayVec::from(peek);
+ peek.truncate(n);
+ *self = Packet::Peek {
+ peek,
+ buf,
+ decap_vlan: *decap_vlan,
+ };
+ let Packet::Peek {
+ peek,
+ buf,
+ decap_vlan,
+ } = self
+ else {
+ unreachable!()
+ };
+ (peek, buf, decap_vlan)
+ }
+ Packet::Peek {
+ peek,
+ buf,
+ decap_vlan,
+ } => (peek, buf, decap_vlan),
+ }
+ }
+ pub fn headers(&mut self) -> io::Result<PacketHeaders<'_, R>> {
+ let (peek, buf, decap_vlan) = self.peek();
+ let peek_slice = peek.as_mut_slice();
+ let (ether_frame, peek_slice) = EtherFrame::mut_from_prefix(peek_slice)
+ .map_err(|_| io::Error::other("packet with <12 bytes"))?;
+ let (ether_type, _) = EtherType::ref_from_prefix(peek_slice)
+ .map_err(|_| io::Error::other("packet with <14 bytes"))?;
+
+ let (vlan_tag, peek_slice) = if *decap_vlan && *ether_type == ETHER_TYPE_802_1Q {
+ let (vlan, peek_slice) = VlanTag::mut_from_prefix(peek_slice)
+ .map_err(|_| io::Error::other("packet with <16 bytes"))?;
+ (Some(vlan), peek_slice)
+ } else {
+ (None, peek_slice)
+ };
+ let (ether_type, peek_slice) = EtherType::mut_from_prefix(peek_slice)
+ .map_err(|_| io::Error::other("packet with <18 bytes"))?;
+
+ let (ipv6_hdr, peek_slice) = if *ether_type == ETHER_TYPE_IPV6 {
+ let (ipv6_hdr, peek_slice) = Ipv6Header::mut_from_prefix(peek_slice)
+ .map_err(|_| io::Error::other("short ipv6 header"))?;
+ (Some(ipv6_hdr), peek_slice)
+ } else {
+ (None, peek_slice)
+ };
+
+ Ok(PacketHeaders {
+ ether_frame,
+ vlan_tag,
+ ether_type,
+ ipv6_hdr,
+ peek_slice,
+ buf,
+ })
+ }
+ pub fn out(mut self, vlan_encap: Option<VlanTag>) -> io::Result<OutgoingPacket<R>> {
+ let PacketHeaders {
+ ether_frame,
+ ether_type,
+ ipv6_hdr,
+ peek_slice,
+ ..
+ } = self.headers()?;
+
+ let mut headers_out = ArrayVec::<u8, 128>::new();
+ headers_out
+ .try_extend_from_slice(ether_frame.as_bytes())
+ .unwrap();
+ if let Some(vlan_tag) = vlan_encap {
+ headers_out
+ .try_extend_from_slice(vlan_tag.as_bytes())
+ .unwrap();
+ }
+ headers_out
+ .try_extend_from_slice(ether_type.as_bytes())
+ .unwrap();
+ if let Some(ipv6_hdr) = ipv6_hdr {
+ headers_out
+ .try_extend_from_slice(ipv6_hdr.as_bytes())
+ .unwrap();
+ }
+ headers_out.try_extend_from_slice(peek_slice).unwrap();
+
+ let Packet::Peek {
+ peek: _peek, buf, ..
+ } = self
+ else {
+ unreachable!()
+ };
+ Ok(OutgoingPacket { headers_out, buf })
+ }
+}
+
+pub struct OutgoingPacket<R> {
+ /// This has extra space for added encapsulation / VLAN tags
+ headers_out: ArrayVec<u8, 128>,
+ buf: PacketData<R>,
+}
+
+impl<R: Read> OutgoingPacket<R> {
+ pub fn into_reader(self) -> Chain<Cursor<ArrayVec<u8, 128>>, PacketData<R>> {
+ Cursor::new(self.headers_out).chain(self.buf)
+ }
+}
diff --git a/tools/router/src/protocol.rs b/tools/router/src/protocol.rs
new file mode 100644
index 0000000..341b7ff
--- /dev/null
+++ b/tools/router/src/protocol.rs
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: EUPL-1.2+
+// SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
+
+use zerocopy::byteorder::network_endian::{U16, U32};
+use zerocopy::*;
+
+pub const ETHER_TYPE_IPV6: u16 = 0x86dd;
+pub const ETHER_TYPE_802_1Q: u16 = 0x8100;
+pub const IP_PROTO_ICMP6: u8 = 0x3a;
+pub const ICMP6_TYPE_R_ADV: u8 = 134;
+
+pub type MacAddr = [u8; 6];
+pub fn is_multicast(mac: &MacAddr) -> bool {
+ match mac {
+ [0xff, 0xff, 0xff, 0xff, 0xff, 0xff] => true,
+ [0x01, 0x80, 0xc2, _, _, _] => true, // 802 group
+ [0x33, 0x33, _, _, _, _] => true, // IPv6 multicast
+ _ => false,
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
+#[repr(C)]
+pub struct EtherFrame {
+ pub dst_addr: MacAddr,
+ pub src_addr: MacAddr,
+}
+
+pub type EtherType = U16;
+
+#[derive(Debug, PartialEq, Eq, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
+#[repr(C)]
+pub struct VlanTag {
+ pub ether_type: U16,
+ pub tag_control_information: U16,
+}
+
+#[derive(Debug, PartialEq, Eq, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
+#[repr(C)]
+pub struct Ipv6Header {
+ pub version_traffic_class_flow_label: U32,
+ pub payload_length: U16,
+ pub next_header: u8,
+ pub hop_limit: u8,
+ pub src_addr: [u8; 16],
+ pub dst_addr: [u8; 16],
+}
+
+#[derive(Debug, PartialEq, Eq, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
+#[repr(C)]
+pub struct Icmpv6Header {
+ pub msg_type: u8,
+ pub code: u8,
+ pub checksum: U16,
+}
+
+#[derive(Debug, PartialEq, Eq, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
+#[repr(C)]
+pub struct Icmpv6RouterAdvertisement {
+ pub hop_limit: u8,
+ pub flags: u8,
+ pub router_lifetime: U16,
+ pub reachable_time: U32,
+ pub retrans_timer: U32,
+}
diff --git a/tools/router/src/router.rs b/tools/router/src/router.rs
new file mode 100644
index 0000000..a91e415
--- /dev/null
+++ b/tools/router/src/router.rs
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: EUPL-1.2+
+// SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
+
+use std::collections::HashMap;
+use std::io::{self, Cursor};
+use std::net::Ipv6Addr;
+use std::pin::Pin;
+use std::time::Duration;
+
+use crate::packet::*;
+use crate::protocol::*;
+
+use futures_util::{FutureExt, Sink, SinkExt, Stream, StreamExt};
+use log::{debug, info, warn};
+use tokio_stream::StreamMap;
+use vhost_device_net::IncomingPacket;
+use vm_memory::GuestMemory;
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum InterfaceId {
+ Upstream,
+ App(usize),
+ Broadcast,
+}
+
+pub type PacketStream<M> = Pin<Box<dyn Stream<Item = io::Result<Packet<IncomingPacket<M>>>>>>;
+pub type PacketSink<M> = Pin<Box<dyn Sink<Packet<IncomingPacket<M>>, Error = io::Error>>>;
+
+pub struct Router<M: GuestMemory> {
+ streams: StreamMap<InterfaceId, PacketStream<M>>,
+ sinks: HashMap<InterfaceId, PacketSink<M>>,
+ fib: HashMap<Ipv6Addr, (MacAddr, InterfaceId)>,
+ default_out: InterfaceId,
+}
+
+impl<M: GuestMemory> Router<M> {
+ pub fn new(default_out: InterfaceId) -> Self {
+ Self {
+ streams: Default::default(),
+ sinks: Default::default(),
+ fib: Default::default(),
+ default_out,
+ }
+ }
+
+ pub fn add_iface(&mut self, id: InterfaceId, stream: PacketStream<M>, sink: PacketSink<M>) {
+ self.streams.insert(id.clone(), stream);
+ self.sinks.insert(id.clone(), sink);
+ }
+
+ pub async fn run(&mut self) -> io::Result<()> {
+ loop {
+ let next_res = self.streams.next().await;
+ let Some((in_iface, Ok(mut packet))) = next_res else {
+ info!("incoming err");
+ continue;
+ };
+
+ let PacketHeaders {
+ ether_frame,
+ ipv6_hdr,
+ ..
+ } = packet.headers()?;
+
+ let Some(ipv6_hdr) = ipv6_hdr else {
+ continue;
+ };
+ let src_addr = Ipv6Addr::from(ipv6_hdr.src_addr);
+ let dst_addr = Ipv6Addr::from(ipv6_hdr.dst_addr);
+
+ let out_iface = if is_multicast(&ether_frame.dst_addr) {
+ InterfaceId::Broadcast
+ } else if let Some((dst_mac, if_idx)) = self.fib.get(&dst_addr) {
+ ether_frame.dst_addr = *dst_mac;
+ if_idx.clone()
+ } else if in_iface != self.default_out {
+ self.default_out.clone()
+ } else {
+ warn!("no fib match for {}, dropping packet", dst_addr);
+ continue;
+ };
+
+ if in_iface != self.default_out
+ && !src_addr.is_unspecified()
+ && !src_addr.is_multicast()
+ && !self.fib.contains_key(&src_addr)
+ {
+ debug!(
+ "adding fib entry for {} -> {:x?} {:?}",
+ src_addr, ether_frame.src_addr, in_iface
+ );
+ self.fib
+ .insert(src_addr, (ether_frame.src_addr, in_iface.clone()));
+ }
+
+ match out_iface {
+ InterfaceId::Broadcast => {
+ let Packet::Peek {
+ peek,
+ mut buf,
+ decap_vlan,
+ } = packet
+ else {
+ unreachable!()
+ };
+ let buf = Box::<[u8]>::from(buf.full_packet());
+ futures_util::future::try_join_all(
+ self.sinks
+ .iter_mut()
+ .filter(|(id, _)| **id != in_iface)
+ .map(|(id, sink)| {
+ let packet = Packet::Peek {
+ peek: peek.clone(),
+ buf: PacketData::Bytes(Cursor::new(buf.clone())),
+ decap_vlan,
+ };
+ let fut = sink.send(packet);
+ tokio::time::timeout(Duration::from_secs(1), fut).map(move |res| match res {
+ Err(_) => {
+ warn!("interface {:?} has been blocked for 1 sec, dropping packet", id);
+ Ok(())
+ },
+ Ok(Err(e)) => Err(e),
+ Ok(Ok(())) => Ok(()),
+ })
+ }),
+ )
+ .await?;
+ }
+ ref unicast => {
+ let Some(sink) = self.sinks.get_mut(unicast) else {
+ warn!("dropped packet because interface is not ready");
+ continue;
+ };
+ match tokio::time::timeout(Duration::from_secs(1), sink.send(packet)).await {
+ Err(_) => warn!(
+ "interface {:?} has been blocked for 1 sec, dropping packet",
+ unicast
+ ),
+ Ok(Err(e)) => return Err(e),
+ Ok(Ok(())) => {}
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tools/router/src/upstream.rs b/tools/router/src/upstream.rs
new file mode 100644
index 0000000..08229e8
--- /dev/null
+++ b/tools/router/src/upstream.rs
@@ -0,0 +1,175 @@
+// SPDX-License-Identifier: EUPL-1.2+
+// SPDX-FileCopyrightText: 2025 Yureka Lilian <yureka@cyberchaos.dev>
+
+use std::io::{self, Cursor, Read};
+use std::pin::Pin;
+use std::time::{Duration, Instant};
+
+use crate::packet::*;
+use crate::protocol::*;
+use crate::router::{PacketSink, PacketStream};
+
+use futures_util::{Sink, SinkExt, Stream, StreamExt};
+use log::{debug, error, info, warn};
+use tokio::net::UnixListener;
+use tokio::sync::mpsc;
+use tokio_stream::wrappers::ReceiverStream;
+use tokio_util::sync::PollSender;
+use vhost_device_net::{IncomingPacket, VhostDeviceNet};
+use vm_memory::GuestMemoryMmap;
+use zerocopy::FromBytes;
+
+pub struct Upstream {
+ driver_listener: UnixListener,
+ active_interface: Option<u16>,
+ reevaluate_active_interface: Pin<Box<tokio::time::Sleep>>,
+ radv_valid_until: Vec<(u16, Instant)>,
+ tx_sender: mpsc::Sender<Packet<IncomingPacket<GuestMemoryMmap>>>,
+ rx_receiver: mpsc::Receiver<Packet<IncomingPacket<GuestMemoryMmap>>>,
+}
+
+impl Upstream {
+ pub fn new(
+ driver_listener: UnixListener,
+ ) -> (
+ Upstream,
+ PacketStream<GuestMemoryMmap>,
+ PacketSink<GuestMemoryMmap>,
+ ) {
+ let (tx_sender, tx_receiver) = mpsc::channel(64);
+ let (rx_sender, rx_receiver) = mpsc::channel(64);
+
+ (
+ Upstream {
+ driver_listener,
+ active_interface: None,
+ reevaluate_active_interface: Box::pin(tokio::time::sleep(Duration::from_hours(
+ 24 * 365,
+ ))),
+ radv_valid_until: Default::default(),
+ tx_sender,
+ rx_receiver,
+ },
+ Box::pin(ReceiverStream::new(tx_receiver).map(Ok)),
+ Box::pin(
+ PollSender::new(rx_sender)
+ .sink_map_err(|_| io::Error::other("driver rx channel closed")),
+ ),
+ )
+ }
+ pub async fn run(&mut self) -> io::Result<()> {
+ let mut device_tx: Option<Pin<Box<dyn Stream<Item = _> + Send>>> = None;
+ let mut device_rx: Option<Pin<Box<dyn Sink<_, Error = _> + Send>>> = None;
+ loop {
+ tokio::select! {
+ driver_conn = self.driver_listener.accept() => {
+ info!("driver connected");
+ match driver_conn {
+ Ok((stream, _addr)) => {
+ self.radv_valid_until.clear();
+ self.active_interface = None;
+ self.reevaluate_active_interface.as_mut().reset((Instant::now() + Duration::from_hours(24 * 365)).into());
+
+ let device = VhostDeviceNet::from_unix_stream(stream).await?;
+ device_tx = Some(Box::pin(device.tx().await?));
+ device_rx = Some(Box::pin(device.rx().await?));
+ }
+ Err(e) => error!("driver connection failed: {}", e),
+ }
+ }
+ tx_res = async { device_tx.as_mut().unwrap().next().await }, if device_tx.is_some() => {
+ let Some(Ok(buf)) = tx_res else {
+ info!("driver tx err");
+ continue;
+ };
+
+ let mut packet = Packet::Incoming { buf: Some(buf), decap_vlan: true };
+ let PacketHeaders { ether_frame, vlan_tag: vlan_in, ipv6_hdr, peek_slice, buf, .. } = packet.headers()?;
+
+ let Some(vlan_tag) = vlan_in else {
+ warn!("untagged packet from driver");
+ continue;
+ };
+
+ let vlan_id = u16::from(vlan_tag.tag_control_information) & 0xfff;
+
+ if let Some(ref ipv6_hdr) = ipv6_hdr && ipv6_hdr.next_header == IP_PROTO_ICMP6 {
+ let (icmpv6_hdr, icmpv6_data) = Icmpv6Header::ref_from_prefix(peek_slice).map_err(|_| io::Error::other("short icmpv6 header"))?;
+
+ if icmpv6_hdr.msg_type == ICMP6_TYPE_R_ADV {
+ let data = Cursor::new(icmpv6_data).chain(Cursor::new(buf.full_packet()));
+ let r_adv = Icmpv6RouterAdvertisement::read_from_io(data)?;
+ if r_adv.router_lifetime != 0 {
+ let now = Instant::now();
+ let r_adv_timeout = now + Duration::from_secs(u16::from(r_adv.router_lifetime).into());
+ match self.radv_valid_until.binary_search_by_key(&vlan_id, |&(if_idx, _)| if_idx) {
+ Ok(pos) => self.radv_valid_until[pos] = (vlan_id, r_adv_timeout),
+ Err(insert_pos) => self.radv_valid_until.insert(insert_pos, (vlan_id, r_adv_timeout)),
+ };
+ debug!("router advertisement received on interface {}: {:x?} {:x?} {:?}", vlan_id, ether_frame, ipv6_hdr, r_adv);
+
+ let prev_active_interface = self.active_interface.unwrap_or(u16::MAX);
+ if vlan_id < prev_active_interface || self.reevaluate_active_interface.deadline() < now.into() {
+ self.active_interface = Some(vlan_id);
+ info!("set active interface to {}", vlan_id);
+ self.reevaluate_active_interface.as_mut().reset(r_adv_timeout.into());
+ } else if vlan_id == prev_active_interface {
+ self.reevaluate_active_interface.as_mut().reset(r_adv_timeout.into());
+ }
+ }
+ }
+ }
+
+ if Some(vlan_id) != self.active_interface {
+ debug!("dropping packet from inactive interface {}", vlan_id);
+ continue;
+ }
+
+ self.tx_sender.send(packet).await.map_err(io::Error::other)?;
+ }
+ rx_res = self.rx_receiver.recv() => {
+ let Some(packet) = rx_res else {
+ info!("driver rx err");
+ continue;
+ };
+
+ let Some(sink) = device_rx.as_mut() else {
+ warn!("dropped packet because driver is not ready");
+ continue;
+ };
+
+ let Some(active_interface) = &self.active_interface else {
+ warn!("dropped packet because active interface is unknown");
+ continue;
+ };
+
+ // Add active interface vlan
+ let vlan_out = VlanTag {
+ ether_type: ETHER_TYPE_802_1Q.into(),
+ tag_control_information: (*active_interface).into(),
+ };
+
+ let packet = packet.out(Some(vlan_out))?;
+
+ match tokio::time::timeout(Duration::from_secs(1), sink.send(packet.into_reader())).await {
+ Err(_) => warn!("driver rx has been blocked for 1 sec, dropping packet"),
+ Ok(Err(e)) => return Err(e),
+ Ok(Ok(())) => {},
+ }
+ }
+ () = &mut self.reevaluate_active_interface => {
+ let now = Instant::now();
+ let prev_active_interface = self.active_interface.unwrap_or(u16::MAX);
+ info!("router advertisement expired on interface {}", prev_active_interface);
+ if let Some((if_idx, valid_until)) = self.radv_valid_until.iter().find(|(_, valid_until)| *valid_until > now) {
+ self.active_interface = Some(*if_idx);
+ info!("set active interface to {}", if_idx);
+ self.reevaluate_active_interface.as_mut().reset((*valid_until).into());
+ } else {
+ self.reevaluate_active_interface.as_mut().reset((now + Duration::from_hours(24 * 365)).into());
+ }
+ }
+ }
+ }
+ }
+}
--
2.51.2