From 5e7734f45da7720f0ae5a5f9f3e3644013c305b1 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 15 Jul 2026 11:30:48 -0400 Subject: [PATCH 1/9] Encrypt VLAN/VXLAN tunnel traffic with per-tunnel AES-256 keys Adds per-tunnel AES-256 encryption for all VLAN/VXLAN tunnel traffic: MACsec (802.1AE, GCM-AES-256) wraps same-host veth-pair tunnels, and IPsec (XFRM/ESP) encrypts cross-host VXLAN traffic. Also includes the eBPF firewall and MTU fixes needed to make the cross-host path work end-to-end: recognize per-tunnel VXLAN dstports in the data plane, allow ESP so IPsec-wrapped VXLAN isn't dropped, and shrink the overlay MTU (down to 1080) to leave room for cross-host ESP overhead. Also fixes a clippy -D warnings failure in nullnet-proxy from a deprecated openssl API (Asn1StringRef::as_utf8 -> to_string). --- Cargo.lock | 1119 +++++++---------- ebpf/src/main.rs | 36 +- members/nullnet-client/Cargo.toml | 3 +- members/nullnet-client/src/commands/mod.rs | 30 +- members/nullnet-client/src/commands/ovs.rs | 57 +- members/nullnet-client/src/control_channel.rs | 88 +- members/nullnet-client/src/crypto.rs | 118 ++ members/nullnet-client/src/ebpf/mod.rs | 62 +- members/nullnet-client/src/forward/receive.rs | 28 +- members/nullnet-client/src/forward/send.rs | 23 +- members/nullnet-client/src/main.rs | 6 +- members/nullnet-client/src/peers/peer.rs | 17 + .../vxlan_scripts/vxlan-setup.sh | 129 +- .../vxlan_scripts/vxlan-teardown.sh | 35 +- .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 18 + .../src/proto/nullnet_grpc.rs | 24 + members/nullnet-proxy/src/tls.rs | 2 +- members/nullnet-server/src/net.rs | 45 +- members/nullnet-server/src/net_id_pool.rs | 109 ++ .../nullnet-server/src/nullnet_grpc_impl.rs | 30 + members/nullnet-server/src/orchestrator.rs | 68 +- 21 files changed, 1339 insertions(+), 708 deletions(-) create mode 100644 members/nullnet-client/src/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 49b0140..d3e2295 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,9 +82,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -156,24 +156,24 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -215,7 +215,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -227,7 +227,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -239,7 +239,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -274,7 +274,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -285,15 +285,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-credential-types" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -303,9 +303,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -314,21 +314,22 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.7.4" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ed8e8c52d2dc2390ad9f15647fe663f71e9780b4262c190fbb823a32721566" +checksum = "7816e98ee912159f45d307e5ee6bfea4a335a55aee15f7f3e32f81a6f3000f1d" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -341,8 +342,8 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "percent-encoding", "pin-project-lite", "tracing", @@ -351,9 +352,9 @@ dependencies = [ [[package]] name = "aws-sdk-route53" -version = "1.114.0" +version = "1.116.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4b3ded4d6f91aefec370eac63d8522786f17c0763ef531e6a3be88f459219bc" +checksum = "6e80f600bb96d1dca90a78681252685942e81e777b8e8ba653369ff48b6698c1" dependencies = [ "arc-swap", "aws-credential-types", @@ -364,21 +365,22 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.4.5" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -389,18 +391,18 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "percent-encoding", - "sha2 0.11.0", + "sha2", "time", "tracing", ] [[package]] name = "aws-smithy-async" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -409,9 +411,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.6" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -419,8 +421,8 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "percent-encoding", "pin-project-lite", @@ -430,26 +432,26 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.13" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", "h2 0.3.27", - "h2 0.4.13", + "h2 0.4.15", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", "pin-project-lite", "rustls 0.21.12", - "rustls 0.23.37", + "rustls 0.23.42", "rustls-native-certs", "rustls-pki-types", "tokio", @@ -460,9 +462,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.7" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -471,18 +473,18 @@ dependencies = [ [[package]] name = "aws-smithy-observability" -version = "0.2.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-runtime" -version = "1.11.3" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -494,9 +496,9 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "pin-project-lite", "pin-utils", @@ -506,16 +508,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -524,40 +526,40 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "aws-smithy-schema" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.0", + "http 1.4.2", ] [[package]] name = "aws-smithy-types" -version = "1.4.9" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f93074121a1be41317b9aa607143ae17900631f7f59a99f2b905d519d6783b" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "itoa", "num-integer", @@ -572,18 +574,21 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.15" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +checksum = "ea3f68eec3607f02acd24067969ce2abc6ba16aa7d5ce59ca450ed2fb5f78957" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.16" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +checksum = "e957a6c6dbce82b7a91f44231c09273159703769f447cbe85e854dfe9cf67f86" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -604,10 +609,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "itoa", "matchit", @@ -635,8 +640,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -656,12 +661,12 @@ dependencies = [ "bytes", "either", "fs-err", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", + "http 1.4.2", + "http-body 1.1.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", - "rustls 0.23.37", + "rustls 0.23.42", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -676,7 +681,7 @@ checksum = "d18bc4e506fbb85ab7392ed993a7db4d1a452c71b75a246af4a80ab8c9d2dd50" dependencies = [ "assert_matches", "aya-obj", - "bitflags 2.11.0", + "bitflags 2.13.0", "bytes", "libc", "log", @@ -747,9 +752,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake2" @@ -771,9 +776,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -823,20 +828,20 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -846,9 +851,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytes-utils" @@ -860,31 +865,11 @@ dependencies = [ "either", ] -[[package]] -name = "c2rust-bitfields" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcee50917f9de1a018e3f4f9a8f2ff3d030a288cffa4b18d9b391e97c12e4cfb" -dependencies = [ - "c2rust-bitfields-derive", -] - -[[package]] -name = "c2rust-bitfields-derive" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b457277798202ccd365b9c112ebee08ddd57f1033916c8b8ea52f222e5b715d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "cc" -version = "1.2.59" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -894,27 +879,27 @@ dependencies = [ [[package]] name = "cf-rustracing" -version = "1.3.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6565523d8145e63e0cf1b397a5f1bd4e90d5652a7dffb2de8cec460ff23ef6b1" +checksum = "93f85c3824e4191621dec0551e3cef3d511f329da9a8990bf3e450a85651d97e" dependencies = [ "backtrace", - "rand 0.10.1", + "rand 0.8.7", "tokio", "trackable", ] [[package]] name = "cf-rustracing-jaeger" -version = "1.3.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c0e4d8cce27f6a6eaff58d2b66f063a18b8ed0d6ef0947ae7a263afa3b7c08" +checksum = "a6a5f80d44c257c3300a7f45ada676c211e64bbbac591bbec19344a8f61fbcab" dependencies = [ "cf-rustracing", "hostname", "local-ip-address", "percent-encoding", - "rand 0.10.1", + "rand 0.9.5", "thrift_codec", "tokio", "trackable", @@ -934,9 +919,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -945,9 +930,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -968,9 +953,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -990,14 +975,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1116,18 +1101,18 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" @@ -1165,7 +1150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.31.2", + "nix 0.31.3", "windows-sys 0.61.2", ] @@ -1217,7 +1202,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1228,7 +1213,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1270,9 +1255,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derivative" @@ -1303,7 +1285,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1313,7 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1333,7 +1315,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", "ctutils", @@ -1345,7 +1327,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -1359,7 +1341,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1370,9 +1352,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -1528,9 +1510,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" dependencies = [ "autocfg", "tokio", @@ -1617,7 +1599,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1671,13 +1653,13 @@ dependencies = [ [[package]] name = "getifaddrs" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802c6e75f730346652928a55c200ca680741aaaa60c4973d52a999f520c4fde4" +checksum = "a542e1b7ac1f3d62c5777d430d66eca9cb59e813c46b86e29fa9ce94ff9a4810" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1709,28 +1691,27 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1761,7 +1742,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.13.1", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1770,17 +1751,17 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", - "indexmap 2.13.1", + "http 1.4.2", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1815,6 +1796,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "heck" version = "0.5.0" @@ -1860,9 +1852,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1881,24 +1873,24 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "pin-project-lite", ] @@ -1916,9 +1908,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -1949,17 +1941,17 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", "httparse", "httpdate", "itoa", @@ -1990,10 +1982,10 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.2", + "hyper 1.10.1", "hyper-util", - "rustls 0.23.37", + "rustls 0.23.42", "rustls-native-certs", "rustls-platform-verifier", "tokio", @@ -2007,7 +1999,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -2024,14 +2016,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", + "http 1.4.2", + "http-body 1.1.0", + "hyper 1.10.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -2145,12 +2137,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2190,32 +2176,30 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -2239,15 +2223,15 @@ dependencies = [ "aws-lc-rs", "base64", "bytes", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "httpdate", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "rcgen", - "rustls 0.23.37", + "rustls 0.23.42", "rustls-pki-types", "serde", "serde_json", @@ -2318,7 +2302,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2337,36 +2321,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -2374,11 +2357,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.0", "libc", ] @@ -2388,17 +2371,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2412,9 +2389,9 @@ dependencies = [ [[package]] name = "libz-ng-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be734b33b7bc6a42d92d23e25e69758f866cf564a88d0bf80866fcf5a52c2255" +checksum = "879917b256f6317769b9f374b435805a8697013098aacce5a38ac106cd6a9469" dependencies = [ "cmake", "libc", @@ -2447,9 +2424,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "local-ip-address" -version = "0.6.12" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b0187df4e614e42405b49511b82ff7a1774fbd9a816060ee465067847cac22" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" dependencies = [ "libc", "neli", @@ -2467,9 +2444,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -2494,9 +2471,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2550,9 +2527,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2572,7 +2549,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "byteorder", "derive_builder", "getset", @@ -2592,7 +2569,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2631,7 +2608,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "libc", "log", "netlink-packet-core", @@ -2643,7 +2620,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "libc", "log", "netlink-packet-core", @@ -2715,7 +2692,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2724,11 +2701,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2751,7 +2728,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "fsevent-sys", "inotify", "kqueue", @@ -2769,13 +2746,14 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] name = "nullnet-client" version = "0.1.0" dependencies = [ + "aes-gcm", "aya", "chrono", "clap", @@ -2873,9 +2851,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2928,7 +2906,7 @@ checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "crc32fast", "hashbrown 0.15.5", - "indexmap 2.13.1", + "indexmap 2.14.0", "memchr", ] @@ -2979,11 +2957,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -2999,7 +2977,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3016,18 +2994,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -3100,7 +3078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.13.1", + "indexmap 2.14.0", ] [[package]] @@ -3111,27 +3089,27 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.1", + "indexmap 2.14.0", ] [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3148,9 +3126,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pingora-cache" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59d8c4c939a3a193a3da0e061aa7acf7432431f92ee62a26f5a9e5167a0ade2" +checksum = "527735ac204efb9fa3884bfd9224d016c5735fabe1d394ebed145b40e7545b99" dependencies = [ "ahash", "async-trait", @@ -3160,7 +3138,7 @@ dependencies = [ "cf-rustracing", "cf-rustracing-jaeger", "hex", - "http 1.4.0", + "http 1.4.2", "httparse", "httpdate", "indexmap 1.9.3", @@ -3174,7 +3152,7 @@ dependencies = [ "pingora-http", "pingora-lru", "pingora-timeout", - "rand 0.8.6", + "rand 0.8.7", "regex", "rmp", "rmp-serde", @@ -3185,9 +3163,9 @@ dependencies = [ [[package]] name = "pingora-core" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08973c4853cef4c682f7a592907e81a32dcad69476c4846e5de079f16448b177" +checksum = "6a7ffe2f5acf9f94fd255cfd1438866bc9124f8f0c7d42562bd3f853df2094b7" dependencies = [ "ahash", "async-trait", @@ -3201,8 +3179,8 @@ dependencies = [ "derivative", "flate2", "futures", - "h2 0.4.13", - "http 1.4.0", + "h2 0.4.15", + "http 1.4.2", "httparse", "httpdate", "libc", @@ -3219,12 +3197,12 @@ dependencies = [ "pingora-runtime", "pingora-timeout", "prometheus", - "rand 0.8.6", + "rand 0.8.7", "regex", "serde", "serde_yaml", "sfv", - "socket2 0.6.3", + "socket2 0.6.5", "strum", "strum_macros", "tokio", @@ -3237,18 +3215,18 @@ dependencies = [ [[package]] name = "pingora-error" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9fa97a500e7e5c27a7b8609b9294c8922c9656322285268bfad9520f12feb38" +checksum = "b23f7bc013de67e44ed902a82843f6157460b89d11da882bcc6f09f8ae380af1" [[package]] name = "pingora-header-serde" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2705feb8b50d4e734e0c7d3879aa040e655a45656276323ff530e254585dd816" +checksum = "828c0e53e74160cbfe8e67dd3a811eb6a253c36acbaf7a39a01d9aacfb9ac139" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", "httparse", "pingora-error", "pingora-http", @@ -3259,25 +3237,25 @@ dependencies = [ [[package]] name = "pingora-http" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb52d4651b687fab6abf669539cfd97b7cd94b301fde8f57c63354f9c9cc5e2" +checksum = "d553d310a15ec88107b9388a02885f798efc57764d8e9bdaaf32a76722927a10" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", "pingora-error", ] [[package]] name = "pingora-lru" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91bb5030596a3d442c0866ac68afe29c14ba558e77c726dcdf7016b0dbb359d9" +checksum = "6705a26ad89d241a989a5395641931ba37076f5ab5fbd19ee92402414a43af32" dependencies = [ "arrayvec", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "parking_lot", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3295,9 +3273,9 @@ dependencies = [ [[package]] name = "pingora-pool" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67f034be36772f318370d058913db43dbd22c3763ad974c995ba2e4afb2bb52a" +checksum = "feb1237893b15a9cf6b371bee8d7e2e1c10742e4be6eb00ed38cfe87fd1363f8" dependencies = [ "crossbeam-queue", "log", @@ -3310,44 +3288,44 @@ dependencies = [ [[package]] name = "pingora-proxy" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e1e070a98a70d0d05f2fdcfb706237e06a043b2fbc9261e8772a3459cc2175e" +checksum = "8a92ee756ecf6ecb6419864da651cad6cecd933b6d420a26877031efa16bef57" dependencies = [ "async-trait", "bytes", "clap", "futures", - "h2 0.4.13", - "http 1.4.0", + "h2 0.4.15", + "http 1.4.2", "log", "once_cell", "pingora-cache", "pingora-core", "pingora-error", "pingora-http", - "rand 0.8.6", + "rand 0.8.7", "regex", "tokio", ] [[package]] name = "pingora-runtime" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e371315b1c44c2e5a8788fdc61577527b785e121e6ff49144755f40d86511430" +checksum = "41815a13691a3e7d9ad0e34767d4140284132e31b95a4481f5e73ab6f407f834" dependencies = [ "once_cell", - "rand 0.8.6", + "rand 0.8.7", "thread_local", "tokio", ] [[package]] name = "pingora-timeout" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a853fee5ce510a7f5db2561f99c752724112ed13fc3820e70d462d278d704ea" +checksum = "8e3e321452eaa461e0b6c5aaa35b7e42527ee89df33710279f37fae7f066b68e" dependencies = [ "once_cell", "parking_lot", @@ -3369,9 +3347,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polyval" @@ -3416,29 +3394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3467,9 +3423,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -3477,9 +3433,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", "itertools", @@ -3492,28 +3448,28 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.117", + "syn 2.0.118", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -3526,11 +3482,11 @@ checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" [[package]] name = "pulldown-cmark" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "memchr", "unicase", ] @@ -3555,9 +3511,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3565,8 +3521,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", - "socket2 0.6.3", + "rustls 0.23.42", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -3575,18 +3531,19 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.37", + "rustls 0.23.42", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -3597,23 +3554,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3632,9 +3589,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3643,9 +3600,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3653,12 +3610,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3706,6 +3663,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rcgen" version = "0.14.8" @@ -3727,14 +3693,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -3744,9 +3710,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -3761,9 +3727,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -3775,11 +3741,11 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -3788,7 +3754,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls 0.23.42", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -3841,9 +3807,9 @@ dependencies = [ [[package]] name = "route_manager" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "319bb478ff9aae1dc7a544fa599e9eb47e2521621dc3921fba6abcaaa312092c" +checksum = "1de4a73da5e20c4e521c395e21918034624165c31511e86b03e494bf0afa56ef" dependencies = [ "flume", "libc", @@ -3873,9 +3839,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" dependencies = [ "axum", "rust-embed-impl", @@ -3885,32 +3851,33 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" dependencies = [ + "mime_guess", "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.117", + "syn 2.0.118", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ - "sha2 0.10.9", + "sha2", "walkdir", ] [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "num-traits", @@ -3918,15 +3885,15 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3952,7 +3919,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3973,24 +3940,24 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "log", "once_cell", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -4000,9 +3967,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -4019,10 +3986,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.37", + "rustls 0.23.42", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -4047,9 +4014,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -4059,9 +4026,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4109,7 +4076,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4159,14 +4126,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4213,7 +4180,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -4227,21 +4194,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fa1f336066b758b7c9df34ed049c0e693a426afe2b27ff7d5b14f410ab1a132" dependencies = [ "base64", - "indexmap 2.13.1", + "indexmap 2.14.0", "rust_decimal", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha2" version = "0.11.0" @@ -4255,9 +4211,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -4277,9 +4233,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4299,9 +4255,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -4315,9 +4271,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4325,9 +4281,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -4363,7 +4319,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4385,9 +4341,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4411,7 +4367,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4420,7 +4376,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4442,7 +4398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4474,7 +4430,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4485,14 +4441,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -4509,12 +4465,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4524,15 +4479,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4550,9 +4505,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -4565,16 +4520,16 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -4587,7 +4542,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4617,7 +4572,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls 0.23.42", "tokio", ] @@ -4663,7 +4618,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -4698,25 +4653,25 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", "base64", "bytes", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", "rustls-native-certs", - "socket2 0.6.3", + "socket2 0.6.5", "sync_wrapper", "tokio", "tokio-rustls 0.26.4", @@ -4736,14 +4691,14 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "tonic-prost" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -4761,7 +4716,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.117", + "syn 2.0.118", "tempfile", "tonic-build", ] @@ -4774,7 +4729,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.1", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -4791,11 +4746,11 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body 1.1.0", "pin-project-lite", "tower", "tower-layer", @@ -4835,7 +4790,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4874,14 +4829,13 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tun-rs" -version = "2.8.1" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88c6aea62111e48fb67b733d6bbdc2019272e6d6f7108c9b0f4cd25d715d38a" +checksum = "ea75f145e8f32c72b1afdf137f2181810b0232be9930519e8d82071b4a3b3bdf" dependencies = [ "blocking", "byteorder", "bytes", - "c2rust-bitfields", "encoding_rs", "getifaddrs", "ipnet", @@ -4889,7 +4843,7 @@ dependencies = [ "libloading", "log", "netconfig-rs", - "nix 0.31.2", + "nix 0.31.3", "route_manager", "scopeguard", "tokio", @@ -4900,9 +4854,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -4916,12 +4870,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -4976,11 +4924,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -5030,27 +4978,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5061,9 +5000,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5071,9 +5010,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5081,65 +5020,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.1", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.1", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -5157,9 +5062,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -5300,7 +5205,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5311,7 +5216,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5578,107 +5483,25 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "winreg" -version = "0.55.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.13.1", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.1", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.1", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" @@ -5765,28 +5588,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5806,15 +5629,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -5846,14 +5669,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/ebpf/src/main.rs b/ebpf/src/main.rs index 046a440..8d0ec51 100644 --- a/ebpf/src/main.rs +++ b/ebpf/src/main.rs @@ -25,6 +25,13 @@ use network_types::{ // - ARP (next-hop resolution) // - TCP to/from SERVER_IP:PORT (nullnet control plane / gRPC) // - UDP 4789/9999 to/from a peer (nullnet data plane: VXLAN / forward) +// - UDP on a live tunnel's per-tunnel VXLAN dstport, to/from a peer (each +// VXLAN tunnel gets its own dynamically-allocated dstport instead of the +// shared 4789 default; VXLAN_PORTS is populated/depopulated by the control +// channel alongside PEERS as tunnels come and go) +// - ESP (proto 50) to/from a peer: cross-host VXLAN tunnels are wrapped in +// kernel IPsec/ESP (see vxlan-setup.sh); ESP is portless like ICMP, so it's +// scoped to known peers instead of a port check // Stateful additions: // - any packet whose flow is already in the CT map is allowed (established // return); every allowed non-ARP packet (re)inserts its canonical 5-tuple, @@ -45,11 +52,19 @@ const FORWARD_PORT: u16 = 9999; const PROTO_TCP: u8 = 6; const PROTO_UDP: u8 = 17; +const PROTO_ESP: u8 = 50; // Allowlist of peer underlay IPs (host-order `u32::from(Ipv4Addr)` keys). #[map] static PEERS: HashMap = HashMap::with_max_entries(4096, 0); +// Per-tunnel VXLAN dstports currently in use. Each live tunnel gets its own +// dynamically-allocated dstport (see nullnet-server's `UdpPortPool`) instead +// of the shared 4789 default, so this is what lets `data_plane()` recognize +// that traffic without widening the static ALLOW_PORTS operator policy. +#[map] +static VXLAN_PORTS: HashMap = HashMap::with_max_entries(4096, 0); + // Per-direction, per-proto destination-port allowlist. Key packs direction and // protocol alongside the port (see `allow_key`) so ingress/egress and TCP/UDP of // the same number are distinct. Populated by userspace from the four @@ -145,6 +160,10 @@ fn try_firewall(ctx: &TcContext, is_egress: bool) -> Result { // ICMP is portless and always allowed (echo + PMTUD/errors, both // directions). Not CT-tracked. Simpler than gating it for now. IpProto::Icmp => return Ok(TC_ACT_OK), + // ESP (cross-host VXLAN IPsec) is portless too, but unlike ICMP + // it carries real tunnel data, so it stays peer-scoped rather + // than unconditionally allowed — see the PROTO_ESP check below. + IpProto::Esp => (PROTO_ESP, 0u16, 0u16), _ => return Ok(TC_ACT_SHOT), }; @@ -161,7 +180,7 @@ fn try_firewall(ctx: &TcContext, is_egress: bool) -> Result { } // Base (stateless) allow decision, scoped by direction and node role. -#[inline] +#[inline(always)] fn base_allow( is_egress: bool, proto: u8, @@ -176,6 +195,9 @@ fn base_allow( if proto == PROTO_UDP && data_plane(src, dst, src_port, dst_port) { return true; } + if proto == PROTO_ESP && (is_peer(src) || is_peer(dst)) { + return true; + } // Gateway outbound: it is the internet boundary — allow all, track it. if is_gateway() && is_egress { return true; @@ -193,13 +215,16 @@ fn control_plane(src: u32, dst: u32, src_port: u16, dst_port: u16) -> bool { (dst == server && dst_port == ctrl_port) || (src == server && src_port == ctrl_port) } -// Data plane: UDP on the VXLAN (4789) or forward (9999) port with a known peer. +// Data plane: UDP on the VXLAN (4789), forward (9999), or a live tunnel's own +// per-tunnel dstport, with a known peer. #[inline] fn data_plane(src: u32, dst: u32, src_port: u16, dst_port: u16) -> bool { let on_data_port = dst_port == VXLAN_PORT || src_port == VXLAN_PORT || dst_port == FORWARD_PORT - || src_port == FORWARD_PORT; + || src_port == FORWARD_PORT + || is_vxlan_port(dst_port) + || is_vxlan_port(src_port); on_data_port && (is_peer(src) || is_peer(dst)) } @@ -208,6 +233,11 @@ fn is_peer(ip: u32) -> bool { unsafe { PEERS.get(&ip) }.is_some() } +#[inline] +fn is_vxlan_port(port: u16) -> bool { + unsafe { VXLAN_PORTS.get(&port) }.is_some() +} + // Is `port` allowed as a destination in this direction/proto? (ALLOW_PORTS key // layout must match userspace `allow_key` in members/nullnet-client/src/ebpf.) #[inline] diff --git a/members/nullnet-client/Cargo.toml b/members/nullnet-client/Cargo.toml index 825b496..dcbb664 100644 --- a/members/nullnet-client/Cargo.toml +++ b/members/nullnet-client/Cargo.toml @@ -22,5 +22,6 @@ network-interface = "2.0.5" gag.workspace = true chrono.workspace = true nfq = "0.2" +aes-gcm = "0.10" aya = "0.13" -libc = "0.2" \ No newline at end of file +libc = "0.2" diff --git a/members/nullnet-client/src/commands/mod.rs b/members/nullnet-client/src/commands/mod.rs index 3a55d42..9a72b4d 100644 --- a/members/nullnet-client/src/commands/mod.rs +++ b/members/nullnet-client/src/commands/mod.rs @@ -26,11 +26,20 @@ pub(crate) async fn setup_br0(rtnetlink_handle: &RtNetLinkHandle) { // delete existing OpenFlow rules OvsCommand::DeleteFlows.execute(); - // use the built-in switching logic - OvsCommand::AddFlow.execute(); - - // add our TAP to the bridge as a trunk port + // add our TAP to the bridge as a trunk port first, so the flow rules + // below can reference it by name OvsCommand::AddTrunkPort.execute(); + + // Safe fallback (same as OVS's original single default rule) for + // anything not covered by a more specific rule — mainly the brief + // window between an access port being created and its own redirect + // rule (installed in `configure_access_port`) landing. + OvsCommand::AddDefaultFlow.execute(); + + // Traffic arriving from the trunk (i.e. already decrypted by + // nullnet-client's userspace forwarder) is delivered by normal + // VLAN-aware switching. + OvsCommand::AddTrunkDeliveryFlow.execute(); } pub(crate) async fn configure_access_port( @@ -52,9 +61,22 @@ pub(crate) async fn configure_access_port( // add the peer interface to the bridge as an access port OvsCommand::AddAccessPort(&veth_peer_name, vlan_id).execute(); + + // Redirect this port's traffic to the trunk instead of letting OVS + // switch it directly to another local access port — and re-add the + // 802.1Q tag that gets stripped along the way, since the raw `output` + // action used to reach the trunk doesn't do that automatically the way + // `actions=normal` would. + OvsCommand::AddAccessRedirectFlow(&veth_peer_name, vlan_id).execute(); } pub(crate) async fn remove_vlan(rtnetlink_handle: &RtNetLinkHandle, vlan_id: u16) { + // remove this port's redirect flow before the port itself disappears, + // so no stale rule is left behind that could later match a different, + // unrelated port reusing the same OVS port number + let veth_peer_name = format!("veth-{vlan_id}p"); + OvsCommand::DeleteAccessRedirectFlow(&veth_peer_name).execute(); + // delete the veth pair rtnetlink_handle .execute(NetLinkCommand::DeleteVeth(vlan_id)) diff --git a/members/nullnet-client/src/commands/ovs.rs b/members/nullnet-client/src/commands/ovs.rs index 50ad74b..51d2c69 100644 --- a/members/nullnet-client/src/commands/ovs.rs +++ b/members/nullnet-client/src/commands/ovs.rs @@ -7,7 +7,30 @@ pub(super) enum OvsCommand<'a> { DeleteBridge, AddBridge, DeleteFlows, - AddFlow, + /// Fallback for anything not covered by a more specific rule below — + /// same behavior as OVS's original single default flow. Mainly covers + /// the brief startup window between an access port being created and + /// its own redirect flow (below) landing. + AddDefaultFlow, + /// Traffic arriving from the trunk (already decrypted by nullnet-client's + /// userspace forwarder) gets delivered by normal VLAN-aware L2 switching. + AddTrunkDeliveryFlow, + /// One rule per access port, installed alongside it: redirect this + /// port's traffic to the trunk instead of letting OVS switch it + /// directly to another local access port (which would bypass the TAP + /// and the encrypting userspace forwarder entirely when a tunnel's two + /// endpoints happen to be colocated on this host). `output:` is a + /// raw action — unlike `actions=normal`, it does *not* re-add the + /// 802.1Q tag that access ports carry only internally, so this + /// explicitly pushes the tag back on first: without that, packets would + /// arrive at nullnet-client's TAP already stripped of their VLAN tag + /// and get silently dropped as malformed. + AddAccessRedirectFlow(&'a str, u16), + /// Removes exactly the rule `AddAccessRedirectFlow` installed for this + /// port, so a torn-down tunnel doesn't leave a stale flow entry that + /// could wrongly match a future, unrelated port reusing the same + /// OVS port number. + DeleteAccessRedirectFlow(&'a str), AddTrunkPort, AddAccessPort(&'a str, u16), } @@ -33,7 +56,11 @@ impl OvsCommand<'_> { | OvsCommand::DeleteBridge | OvsCommand::AddAccessPort(_, _) | OvsCommand::AddTrunkPort => "ovs-vsctl", - OvsCommand::DeleteFlows | OvsCommand::AddFlow => "ovs-ofctl", + OvsCommand::DeleteFlows + | OvsCommand::AddDefaultFlow + | OvsCommand::AddTrunkDeliveryFlow + | OvsCommand::AddAccessRedirectFlow(_, _) + | OvsCommand::DeleteAccessRedirectFlow(_) => "ovs-ofctl", } } @@ -45,10 +72,34 @@ impl OvsCommand<'_> { .iter() .map(ToString::to_string) .collect(), - OvsCommand::AddFlow => ["add-flow", "br0", "priority=0,actions=normal"] + OvsCommand::AddDefaultFlow => ["add-flow", "br0", "priority=0,actions=normal"] .iter() .map(ToString::to_string) .collect(), + OvsCommand::AddTrunkDeliveryFlow => [ + "add-flow", + "br0", + &format!("priority=200,in_port={TAP_NAME},actions=normal"), + ] + .iter() + .map(ToString::to_string) + .collect(), + OvsCommand::AddAccessRedirectFlow(dev, vlan) => [ + "add-flow", + "br0", + &format!( + "priority=150,in_port={dev},actions=push_vlan:0x8100,mod_vlan_vid:{vlan},output:{TAP_NAME}" + ), + ] + .iter() + .map(ToString::to_string) + .collect(), + OvsCommand::DeleteAccessRedirectFlow(dev) => { + ["del-flows", "br0", &format!("in_port={dev}")] + .iter() + .map(ToString::to_string) + .collect() + } OvsCommand::AddTrunkPort => ["add-port", "br0", TAP_NAME] .iter() .map(ToString::to_string) diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index f657acd..9cf3ca0 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -1,5 +1,5 @@ use crate::commands::{RtNetLinkHandle, configure_access_port, dnat, egress, remove_vlan}; -use crate::ebpf::{FirewallPeers, NetId}; +use crate::ebpf::{FirewallPeers, FirewallVxlanPorts, NetId}; use crate::egress_state::{EgressRecord, EgressState}; use crate::host_mappings::HostMappingsState; use crate::peers::peer::{Peers, VethKey}; @@ -32,6 +32,7 @@ fn fire_event(grpc: &NullnetGrpcInterface, kind: AgentEventKind) { }); } +#[allow(clippy::too_many_arguments)] pub(crate) async fn control_channel( server: NullnetGrpcInterface, peers: Arc>, @@ -39,6 +40,7 @@ pub(crate) async fn control_channel( triggers_state: Arc, host_mappings_state: Arc, firewall_peers: Arc, + firewall_vxlan_ports: Arc, egress_state: Arc, ) -> Result<(), Error> { let (outbound, grpc_rx) = mpsc::channel(64); @@ -59,6 +61,7 @@ pub(crate) async fn control_channel( let host_mappings_state = host_mappings_state.clone(); let server = server.clone(); let firewall_peers = firewall_peers.clone(); + let firewall_vxlan_ports = firewall_vxlan_ports.clone(); match message.message { Some(net_message::Message::VlanSetup(vlan_setup)) => { tokio::spawn(async move { @@ -98,6 +101,7 @@ pub(crate) async fn control_channel( host_mappings_state, server, firewall_peers, + firewall_vxlan_ports, egress_state, ) .await; @@ -113,6 +117,7 @@ pub(crate) async fn control_channel( host_mappings_state, server, firewall_peers, + firewall_vxlan_ports, egress_state, ); }); @@ -176,6 +181,24 @@ async fn handle_vlan_setup( }), ); })?; + // Fail before touching the network if the key is malformed — running + // this tunnel without a valid key would mean forwarding traffic in the + // clear instead of encrypted. + let encryption_key: [u8; 32] = message + .encryption_key + .try_into() + .map_err(|_| "VLAN setup message carried a malformed encryption key") + .handle_err(location!()) + .inspect_err(|e| { + fire_event( + &grpc, + AgentEventKind::VlanSetupFailed(AgentVlanSetupFailed { + vlan_id: message.vlan_id, + local_veth: local_veth.to_string(), + error_reason: e.to_str().to_string(), + }), + ); + })?; // setup VLAN on this machine let init_t = std::time::Instant::now(); @@ -190,11 +213,12 @@ async fn handle_vlan_setup( init_t.elapsed().as_millis() ); - // register peer - peers - .write() - .await - .insert(VethKey::new(remote_veth, vlan_id), remote_ip); + // register peer + this tunnel's encryption key + { + let mut peers = peers.write().await; + peers.insert(VethKey::new(remote_veth, vlan_id), remote_ip); + peers.insert_key(vlan_id, &encryption_key); + } // allow this peer's data-plane traffic through the host firewall firewall_peers.add(NetId::Vlan(vlan_id), remote_ip); @@ -287,6 +311,7 @@ async fn handle_vxlan_setup( host_mappings_state: Arc, grpc: NullnetGrpcInterface, firewall_peers: Arc, + firewall_vxlan_ports: Arc, egress_state: Arc, ) -> Result<(), Error> { let egress_steer = message.egress_steer.unwrap_or(false); @@ -314,11 +339,41 @@ async fn handle_vxlan_setup( .remote_ip .parse::() .handle_err(location!())?; + // Fail before touching the network if the key is malformed — running + // this tunnel without a valid key would mean forwarding traffic in the + // clear instead of encrypted. + let encryption_key: [u8; 32] = message + .encryption_key + .try_into() + .map_err(|_| "VXLAN setup message carried a malformed encryption key") + .handle_err(location!()) + .inspect_err(|e| { + fire_event( + &grpc, + AgentEventKind::VxlanSetupFailed(AgentVxlanSetupFailed { + vxlan_id, + ns_name: ns_name.clone(), + error_code: -1, + }), + ); + eprintln!("[vxlan_setup] {}", e.to_str()); + })?; // allow this peer's data-plane (VXLAN underlay) traffic through the host // firewall before the tunnel comes up, so the first packets aren't dropped. firewall_peers.add(NetId::Vxlan(vxlan_id), remote_ip); + // also allow this tunnel's own per-tunnel dstport: the eBPF firewall's + // static data-plane check only knows the fixed 4789/9999 ports, not the + // dynamically-allocated dstport each VXLAN tunnel gets (see + // nullnet-server's `UdpPortPool`), so without this every packet on a real + // tunnel — cross-host VXLAN encapsulation, including the overlay ARP that + // has to succeed before any TCP connection can be routed — is silently + // dropped at this host's NIC. + if let Ok(dstport) = u16::try_from(message.dstport) { + firewall_vxlan_ports.add(vxlan_id, dstport); + } + // setup VXLAN on this machine (optionally attaching a Docker container) let init_t = std::time::Instant::now(); let mut cmd = std::process::Command::new("./vxlan_scripts/vxlan-setup.sh"); @@ -328,7 +383,9 @@ async fn handle_vxlan_setup( .arg(&br_name) .arg(br_net.to_string()) .arg(local_ip.to_string()) - .arg(remote_ip.to_string()); + .arg(remote_ip.to_string()) + .arg(hex_encode(&encryption_key)) + .arg(message.dstport.to_string()); // Egress-steer edges keep their tunnel endpoint in the host root namespace // (no `docker_container` arg) so the initiator container's *forwarded* // external traffic can be policy-routed into the bridge. Other edges attach @@ -529,6 +586,7 @@ fn handle_vxlan_teardown( host_mappings_state: Arc, grpc: NullnetGrpcInterface, firewall_peers: Arc, + firewall_vxlan_ports: Arc, egress_state: Arc, ) { // reverse egress steering/interception if this was an egress edge @@ -548,6 +606,9 @@ fn handle_vxlan_teardown( // drop this peer's firewall allowance (refcounted; only removed if unused) firewall_peers.remove(NetId::Vxlan(message.vxlan_id)); + // drop this tunnel's per-tunnel dstport allowance + firewall_vxlan_ports.remove(message.vxlan_id); + // remove DNAT before tearing the tunnel down so existing flows reset // cleanly. The `container_ip` matches the `-s` we used at install time. // `remove_by_vxlan` also matches the egress steer's sentinel-port entry @@ -583,7 +644,12 @@ fn handle_vxlan_teardown( let br_name = message.br_name; let mut cmd = std::process::Command::new("./vxlan_scripts/vxlan-teardown.sh"); - cmd.arg(vxlan_id.to_string()).arg(&ns_name).arg(&br_name); + cmd.arg(vxlan_id.to_string()) + .arg(&ns_name) + .arg(&br_name) + .arg(&message.local_ip) + .arg(&message.remote_ip) + .arg(message.dstport.to_string()); if let Some(container) = &message.docker_container { cmd.arg(container); } @@ -834,3 +900,9 @@ fn remove_hosts_entry(content: &str, name: &str) -> String { .collect(); lines.join("\n") + "\n" } + +/// Lowercase hex encoding, used to pass the tunnel's AES key to +/// `vxlan-setup.sh`/`vxlan-teardown.sh` as a shell argument. +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/members/nullnet-client/src/crypto.rs b/members/nullnet-client/src/crypto.rs new file mode 100644 index 0000000..537bc36 --- /dev/null +++ b/members/nullnet-client/src/crypto.rs @@ -0,0 +1,118 @@ +use aes_gcm::aead::rand_core::RngCore; +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; + +/// AES-256-GCM cipher for one VLAN tunnel's traffic. Wire format produced by +/// [`Self::encrypt`] / consumed by [`Self::decrypt`] is `nonce[12] || ciphertext+tag`. +/// Unlike `nullnet-server`'s cert-at-rest `Encryptor` (keyed once, process-wide, +/// operates on UTF-8 strings), this is constructed per-tunnel from the key the +/// server hands out at `VlanSetup` time and operates on raw Ethernet frames. +pub(crate) struct TunnelCipher { + cipher: Aes256Gcm, +} + +impl TunnelCipher { + pub(crate) fn new(key: &[u8; 32]) -> Self { + Self { + cipher: Aes256Gcm::new(Key::::from_slice(key)), + } + } + + pub(crate) fn encrypt(&self, plaintext: &[u8]) -> Option> { + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let mut out = nonce_bytes.to_vec(); + out.extend( + self.cipher + .encrypt(Nonce::from_slice(&nonce_bytes), plaintext) + .ok()?, + ); + Some(out) + } + + pub(crate) fn decrypt(&self, data: &[u8]) -> Option> { + if data.len() < 12 { + return None; + } + let (nonce_bytes, ciphertext) = data.split_at(12); + self.cipher + .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) + .ok() + } +} + +/// Wire framing for the VLAN userspace forwarder (`forward/send.rs`, +/// `forward/receive.rs`): every datagram on the forward socket is +/// `vlan_id[2, big-endian] || nonce[12] || ciphertext+tag`. The vlan_id has +/// to be readable in the clear so the receiver knows which tunnel's key to +/// decrypt with before it can read anything else. +pub(crate) fn seal(vlan_id: u16, cipher: &TunnelCipher, plaintext: &[u8]) -> Option> { + let mut out = vlan_id.to_be_bytes().to_vec(); + out.extend(cipher.encrypt(plaintext)?); + Some(out) +} + +/// Splits a raw forward-socket datagram into its cleartext `vlan_id` and the +/// remaining `nonce || ciphertext+tag` slice, ready for `TunnelCipher::decrypt`. +pub(crate) fn open_vlan_id(datagram: &[u8]) -> Option<(u16, &[u8])> { + if datagram.len() < 2 { + return None; + } + let (vlan_id_bytes, rest) = datagram.split_at(2); + Some(( + u16::from_be_bytes([vlan_id_bytes[0], vlan_id_bytes[1]]), + rest, + )) +} + +#[cfg(test)] +mod tests { + use super::{TunnelCipher, open_vlan_id, seal}; + + #[test] + fn round_trip() { + let cipher = TunnelCipher::new(&[7u8; 32]); + let frame = b"pretend this is an ethernet frame"; + let ct = cipher.encrypt(frame).unwrap(); + assert_ne!(ct, frame); + assert_eq!(cipher.decrypt(&ct).unwrap(), frame); + } + + #[test] + fn nonce_randomizes_ciphertext() { + let cipher = TunnelCipher::new(&[7u8; 32]); + assert_ne!( + cipher.encrypt(b"same").unwrap(), + cipher.encrypt(b"same").unwrap() + ); + } + + #[test] + fn wrong_key_fails() { + let ct = TunnelCipher::new(&[1u8; 32]).encrypt(b"secret").unwrap(); + assert!(TunnelCipher::new(&[2u8; 32]).decrypt(&ct).is_none()); + } + + #[test] + fn truncated_data_fails_without_panicking() { + let cipher = TunnelCipher::new(&[3u8; 32]); + assert!(cipher.decrypt(&[0u8; 5]).is_none()); + } + + #[test] + fn seal_then_open_round_trips_vlan_id_and_plaintext() { + let cipher = TunnelCipher::new(&[9u8; 32]); + let frame = b"ethernet frame payload"; + let datagram = seal(4242, &cipher, frame).unwrap(); + + let (vlan_id, rest) = open_vlan_id(&datagram).unwrap(); + assert_eq!(vlan_id, 4242); + assert_eq!(cipher.decrypt(rest).unwrap(), frame); + } + + #[test] + fn open_vlan_id_rejects_short_datagrams() { + assert!(open_vlan_id(&[0u8]).is_none()); + assert!(open_vlan_id(&[]).is_none()); + } +} diff --git a/members/nullnet-client/src/ebpf/mod.rs b/members/nullnet-client/src/ebpf/mod.rs index 20cf38a..bd83998 100644 --- a/members/nullnet-client/src/ebpf/mod.rs +++ b/members/nullnet-client/src/ebpf/mod.rs @@ -7,8 +7,9 @@ //! returns. ICMP is always allowed (both directions). Everything else is an //! explicit, env-driven allow: the four `{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTS` //! lists (→ `ALLOW_PORTS` map). On the egress-gateway host all outbound is -//! additionally allowed and tracked. Peers are added/removed from the `PEERS` map -//! by the control channel as edges come and go. +//! additionally allowed and tracked. Peers are added/removed from the `PEERS` map, +//! and each VXLAN tunnel's per-tunnel dstport from the `VXLAN_PORTS` map, by the +//! control channel as edges come and go. //! //! Loading: raise the memlock rlimit, `EbpfLoader` with `set_global`, ensure a //! clsact qdisc, load+attach each `SchedClassifier` to its hook, then populate @@ -55,7 +56,7 @@ pub enum NetId { } /// Live handle to the attached firewall. Holds the loaded `Ebpf` (dropping it -/// detaches the program) and the peer allowlist shared with the control +/// detaches the program) and the peer/port allowlists shared with the control /// channel. Keep it alive for the whole run. pub struct Firewall { // held only to keep the loaded program + attached links alive (drop = @@ -63,6 +64,7 @@ pub struct Firewall { #[allow(dead_code)] bpf: Ebpf, pub peers: Arc, + pub vxlan_ports: Arc, } /// Refcounted peer allowlist backing the `PEERS` BPF map. Multiple edges can @@ -134,6 +136,52 @@ impl FirewallPeers { } } +/// Live per-tunnel VXLAN dstport allowlist backing the `VXLAN_PORTS` BPF map, +/// added/removed by the control channel alongside `FirewallPeers` as VXLAN +/// edges come and go. Unlike peers (which can be shared by multiple edges), +/// the server's `UdpPortPool` guarantees a dstport belongs to at most one live +/// tunnel at a time, so this needs no refcounting — just insert on setup, +/// remove on teardown, keyed by `vxlan_id` so a re-point to a different port +/// doesn't leak the old one. +pub struct FirewallVxlanPorts { + inner: Mutex, +} + +struct PortInner { + map: AyaHashMap, + by_id: HashMap, +} + +impl FirewallVxlanPorts { + fn new(map: AyaHashMap) -> Self { + Self { + inner: Mutex::new(PortInner { + map, + by_id: HashMap::new(), + }), + } + } + + /// Allow data-plane traffic on `port` for tunnel `vxlan_id`. + pub fn add(&self, vxlan_id: u32, port: u16) { + let mut inner = self.inner.lock().unwrap(); + if let Some(old_port) = inner.by_id.insert(vxlan_id, port) + && old_port != port + { + let _ = inner.map.remove(&old_port); + } + let _ = inner.map.insert(port, 0u8, 0); + } + + /// Drop tunnel `vxlan_id`'s dstport allowance. + pub fn remove(&self, vxlan_id: u32) { + let mut inner = self.inner.lock().unwrap(); + if let Some(port) = inner.by_id.remove(&vxlan_id) { + let _ = inner.map.remove(&port); + } + } +} + pub fn enable(iface: &str, cfg: &FirewallConfig) -> Result { use aya::EbpfLoader; use aya::programs::{TcAttachType, tc}; @@ -180,9 +228,17 @@ pub fn enable(iface: &str, cfg: &FirewallConfig) -> Result { .try_into() .handle_err(location!())?; + let vxlan_ports_map: AyaHashMap = bpf + .take_map("VXLAN_PORTS") + .ok_or("VXLAN_PORTS map not found in bytecode") + .handle_err(location!())? + .try_into() + .handle_err(location!())?; + Ok(Firewall { bpf, peers: Arc::new(FirewallPeers::new(peers_map)), + vxlan_ports: Arc::new(FirewallVxlanPorts::new(vxlan_ports_map)), }) } diff --git a/members/nullnet-client/src/forward/receive.rs b/members/nullnet-client/src/forward/receive.rs index 66e0944..f7cca1c 100644 --- a/members/nullnet-client/src/forward/receive.rs +++ b/members/nullnet-client/src/forward/receive.rs @@ -1,24 +1,46 @@ use std::sync::Arc; use tokio::net::UdpSocket; +use tokio::sync::RwLock; use tun_rs::AsyncDevice; +use crate::crypto; use crate::forward::frame::Frame; +use crate::peers::peer::Peers; /// Handles incoming network packets (receives packets from the socket and /// writes them to the TAP interface). -pub async fn receive(device: &Arc, socket: &Arc) { +pub async fn receive( + device: &Arc, + socket: &Arc, + peers: &Arc>, +) { let mut frame = Frame::new(); loop { - // wait until there is an incoming packet on the socket (packets on the socket are raw IP) + // wait until there is an incoming datagram on the socket let Ok((s, _)) = socket.recv_from(&mut frame.frame).await else { continue; }; frame.size = s; if frame.size > 0 { + let datagram = &frame.frame[..frame.size]; + // the vlan_id has to be readable before decryption so we know + // which tunnel's key to decrypt with + let Some((vlan_id, sealed)) = crypto::open_vlan_id(datagram) else { + continue; + }; + let Some(cipher) = peers.read().await.get_key(vlan_id) else { + continue; + }; + // decrypt as the packet exits the tunnel; auth failure (wrong + // key, corrupted/spoofed datagram) drops it here + let Some(pkt_data) = cipher.decrypt(sealed) else { + continue; + }; + // write packet to the kernel - device.send(frame.pkt_data()).await.unwrap_or(0); + device.send(&pkt_data).await.unwrap_or(0); } } } diff --git a/members/nullnet-client/src/forward/send.rs b/members/nullnet-client/src/forward/send.rs index 26fad06..c055f25 100644 --- a/members/nullnet-client/src/forward/send.rs +++ b/members/nullnet-client/src/forward/send.rs @@ -6,6 +6,7 @@ use tokio::net::UdpSocket; use tokio::sync::RwLock; use tun_rs::AsyncDevice; +use crate::crypto; use crate::forward::frame::Frame; use crate::peers::peer::{Peers, VethKey}; @@ -20,15 +21,26 @@ pub async fn send(device: &Arc, socket: &Arc, peers: Arc if frame.size > 0 { // send the packet to the socket let pkt_data = frame.pkt_data(); - let Ok(dst_socket) = get_dst_socket(pkt_data, &peers).await else { + let Ok((dst_socket, vlan_id)) = get_dst_socket(pkt_data, &peers).await else { continue; }; - socket.send_to(pkt_data, dst_socket).await.unwrap_or(0); + // encrypt as the packet enters the tunnel: without a key for + // this vlan_id (tunnel torn down mid-flight, or setup never + // landed) there is nothing safe to send. + let Some(cipher) = peers.read().await.get_key(vlan_id) else { + continue; + }; + if let Some(datagram) = crypto::seal(vlan_id, &cipher, pkt_data) { + socket.send_to(&datagram, dst_socket).await.unwrap_or(0); + } } } } -async fn get_dst_socket(pkt_data: &[u8], peers: &Arc>) -> Result { +async fn get_dst_socket( + pkt_data: &[u8], + peers: &Arc>, +) -> Result<(SocketAddr, u16), Error> { let headers = LaxPacketHeaders::from_ethernet(pkt_data).handle_err(location!())?; let vlan_id = headers .vlan_ids() @@ -48,10 +60,11 @@ async fn get_dst_socket(pkt_data: &[u8], peers: &Arc>) -> Result Result<(), Error> { } }; let firewall_peers = ebpf_firewall.peers.clone(); + let firewall_vxlan_ports = ebpf_firewall.vxlan_ports.clone(); // shared dedup + waiter state, keyed by (initiator_container, port). // The NFQUEUE listener marks Pending and awaits the Notify; the control @@ -126,6 +128,7 @@ async fn main() -> Result<(), Error> { triggers_state_cc, host_mappings_state, firewall_peers, + firewall_vxlan_ports, egress_state, ) .await @@ -440,11 +443,12 @@ async fn setup_tap( let reader = reader_shared.clone(); let socket_1 = forward_socket.clone(); let socket_2 = socket_1.clone(); + let peers_1 = peers.clone(); let peers_2 = peers.clone(); // handle incoming traffic tokio::spawn(async move { - Box::pin(receive(&writer, &socket_1)).await; + Box::pin(receive(&writer, &socket_1, &peers_1)).await; }); // handle outgoing traffic diff --git a/members/nullnet-client/src/peers/peer.rs b/members/nullnet-client/src/peers/peer.rs index 813063d..7f12f7a 100644 --- a/members/nullnet-client/src/peers/peer.rs +++ b/members/nullnet-client/src/peers/peer.rs @@ -2,8 +2,10 @@ use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; use crate::FORWARD_PORT; +use crate::crypto::TunnelCipher; use serde::{Deserialize, Serialize}; /// Struct containing all peers information. @@ -11,6 +13,12 @@ use serde::{Deserialize, Serialize}; pub struct Peers { /// Mapping from veth addresses to Ethernet IPs. ips: HashMap, + /// Per-tunnel AES-256-GCM cipher, keyed by `vlan_id`. Populated from the + /// key the server hands out in `VlanSetup` and used by the userspace + /// forwarder (`forward/send.rs`, `forward/receive.rs`) to encrypt/decrypt + /// this tunnel's traffic. `Arc`-wrapped so callers can clone a handle out + /// without holding the `Peers` lock across the actual crypto work. + keys: HashMap>, } impl Peers { @@ -24,8 +32,17 @@ impl Peers { self.ips.insert(veth_key, eth_ip); } + pub fn insert_key(&mut self, vlan_id: u16, key: &[u8; 32]) { + self.keys.insert(vlan_id, Arc::new(TunnelCipher::new(key))); + } + + pub fn get_key(&self, vlan_id: u16) -> Option> { + self.keys.get(&vlan_id).cloned() + } + pub fn remove(&mut self, vlan_id: u16) { self.ips.retain(|key, _| key.vlan_id != vlan_id); + self.keys.remove(&vlan_id); } } diff --git a/members/nullnet-client/vxlan_scripts/vxlan-setup.sh b/members/nullnet-client/vxlan_scripts/vxlan-setup.sh index 366b734..ef8ae1f 100755 --- a/members/nullnet-client/vxlan_scripts/vxlan-setup.sh +++ b/members/nullnet-client/vxlan_scripts/vxlan-setup.sh @@ -1,10 +1,10 @@ #!/bin/bash # Read CLI arguments: -if [ "$#" -lt 7 ] || [ "$#" -gt 8 ]; then - echo "Usage: $0 [docker_container]" - echo "Example (standalone): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104" - echo "Example (docker): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104 my_container" +if [ "$#" -lt 9 ] || [ "$#" -gt 10 ]; then + echo "Usage: $0 [docker_container]" + echo "Example (standalone): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104 <64 hex chars> 20100" + echo "Example (docker): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104 <64 hex chars> 20100 my_container" exit 1 fi @@ -15,15 +15,26 @@ BR_NAME=$4 BR_NET=$5 LOCAL_IP=$6 REMOTE_IP=$7 -DOCKER_CONTAINER=$8 +KEY_HEX=$8 +DSTPORT=$9 +DOCKER_CONTAINER=${10} BR_IP=$(echo $BR_NET | cut -d'/' -f1) -# Overlay MTU: underlay 1500 - 50 (VXLAN encap) = 1450. Set on every interface -# in the chain so the path is uniformly sized and the kernel advertises a -# correct TCP MSS / fragments UDP at the right boundary. Complements the -# TCPMSS clamp installed by the client (mss 1400) — both target the same path. -OVERLAY_MTU=1450 +# Overlay MTU: empirically measured via `ping -M do -s ` against a live +# cross-host tunnel — an IP packet of 1104 bytes gets through cleanly, 1105 +# never does, every time (a hard, reproducible ceiling, not a fragmentation +# reliability issue). That's ~400 bytes tighter than a naive 1500 - VXLAN(50) +# - ESP(~35) estimate accounts for, which means something in this specific +# underlay path (likely the Proxmox host network's own overlay/SDN layer) +# adds overhead this VM's own `ens18` MTU doesn't reflect. Rather than keep +# guessing at a theoretical budget, 1080 just stays safely under the measured +# 1104-byte ceiling. Set on every interface in the chain so the path is +# uniformly sized and the kernel advertises a correct TCP MSS / fragments UDP +# at the right boundary. Same-host (MACsec veth) tunnels don't need this — no +# physical MTU applies to a local veth pair — but sharing one constant is +# simpler and costs nothing. +OVERLAY_MTU=1080 if [ -n "$DOCKER_CONTAINER" ]; then # Docker mode: get the container's PID to enter its network namespace via nsenter @@ -56,7 +67,15 @@ if [ -z "$DOCKER_CONTAINER" ]; then fi if [ "$LOCAL_IP" == "$REMOTE_IP" ]; then - # Same host: connect bridges with a veth pair instead of a VXLAN tunnel + # Same host: connect bridges with a veth pair instead of a VXLAN tunnel. + # This traffic never leaves the host, so there's no physical-network + # sniffer to defend against — but it's still worth encrypting for + # defense-in-depth against another, differently-privileged + # container/process on the SAME host that could otherwise read this + # veth's or bridge's plaintext traffic directly. MACsec (802.1AE) wraps + # the veth link itself in AES-256-GCM, keyed with this tunnel's key — + # no IP addressing involved, so it works regardless of what the + # containers on either side are doing. VETH_S="veth-${VXLAN_ID}-s" VETH_C="veth-${VXLAN_ID}-c" # Both ends are created atomically; the losing task's EEXIST is harmless @@ -64,17 +83,97 @@ if [ "$LOCAL_IP" == "$REMOTE_IP" ]; then # Attach our end to our bridge if [[ "$BR_NAME" == *_s ]]; then LOCAL_VETH="$VETH_S" + PEER_VETH="$VETH_C" + MACSEC_IF="macsec-${VXLAN_ID}-s" else LOCAL_VETH="$VETH_C" + PEER_VETH="$VETH_S" + MACSEC_IF="macsec-${VXLAN_ID}-c" fi - sudo ip link set "$LOCAL_VETH" master "$BR_NAME" - sudo ip link set "$LOCAL_VETH" mtu $OVERLAY_MTU up + + # The peer's MAC is available immediately: `ip link add ... peer name + # ...` creates both ends atomically in one kernel call, whether this + # invocation won the race above or lost it to the sibling script. + PEER_MAC=$(cat /sys/class/net/$PEER_VETH/address) + KEY_ID=$(printf '%032x' $VXLAN_ID) + + # MACsec adds up to 32 bytes of overhead (SecTAG + ICV for GCM-AES-256). + # Give the underlying veth the extra room — it's a virtual, host-only + # link with no physical MTU constraint — so the macsec interface on + # top of it can still carry a full OVERLAY_MTU-sized frame. + sudo ip link set "$LOCAL_VETH" mtu $((OVERLAY_MTU + 32)) up + + # Note the argument order: `port` (part of this device's own SCI) has + # to come before `cipher` — iproute2's macsec option parser is + # positional here, not a free-order keyword scanner, and silently + # rejects `port` if it comes after `cipher` ("unknown command + # \"port\"?"). Unlike the veth-pair creation above, none of these four + # commands race against the sibling script invocation (each side only + # ever touches its own uniquely-named macsec interface), so their + # stderr is deliberately left unsuppressed — a real failure here + # should be loud, not silently swallowed. + sudo ip link add link "$LOCAL_VETH" "$MACSEC_IF" type macsec port 1 cipher gcm-aes-256 encrypt on + sudo ip macsec add "$MACSEC_IF" tx sa 0 pn 1 on key "$KEY_ID" "$KEY_HEX" + sudo ip macsec add "$MACSEC_IF" rx port 1 address "$PEER_MAC" on + sudo ip macsec add "$MACSEC_IF" rx port 1 address "$PEER_MAC" sa 0 pn 1 on key "$KEY_ID" "$KEY_HEX" + + sudo ip link set "$MACSEC_IF" master "$BR_NAME" + sudo ip link set "$MACSEC_IF" mtu $OVERLAY_MTU up else - # Create the VXLAN tunnel using your physical IP and interface: - sudo ip link add vxlan-$NS_NAME type vxlan id $VXLAN_ID local $LOCAL_IP remote $REMOTE_IP dstport 4789 # dev ens18 + # Create the VXLAN tunnel using your physical IP and interface. Each + # tunnel gets its own dstport (instead of the IANA-standard 4789) so + # the XFRM policies below can tell concurrent tunnels between the same + # host pair apart. + sudo ip link add vxlan-$NS_NAME type vxlan id $VXLAN_ID local $LOCAL_IP remote $REMOTE_IP dstport $DSTPORT # dev ens18 # Attach the VXLAN to the bridge: sudo ip link set vxlan-$NS_NAME master $BR_NAME sudo ip link set vxlan-$NS_NAME mtu $OVERLAY_MTU up + + # Encrypt this tunnel's traffic at the kernel level (AES-256-GCM via + # IPsec/ESP, transport mode) between the two hosts' physical IPs, + # scoped to this tunnel's dstport so it doesn't collide with any other + # concurrent VXLAN tunnel between the same host pair. + # + # RFC4106 GCM keys are "AES key || 4-byte salt". The server only hands + # out a 32-byte AES key (shared verbatim by both VLAN's software AEAD + # and this XFRM SA), so the salt is derived here, identically on both + # ends, from that same key — it doesn't need to be secret on its own, + # only reproducible from the shared secret both sides already have. + SALT_HEX=$(printf '%s' "$KEY_HEX" | sha256sum | cut -c1-8) + # `ip xfrm state add`'s ALGO-KEYMAT requires a "0x" prefix — a bare hex + # string is rejected outright with a bare "RTNETLINK answers: Invalid + # argument", confirmed by extensive live testing (see commit history). + AEAD_KEY_HEX="0x${KEY_HEX}${SALT_HEX}" + # SPI values 1-255 are IANA-reserved (RFC 4301) and the kernel's XFRM + # code rejects them outright ("Invalid argument"). vxlan_id starts at + # 101 (see net_id_pool.rs), which falls straight into that reserved + # range — offset it well clear of 255 rather than using the raw ID. + SPI=$(printf '0x%08x' $((VXLAN_ID + 1000))) + + # Outbound: this host -> remote. + # Note the argument order in both commands below — same lesson as + # the macsec argument-order bug, `ip xfrm` is positional, not a + # free-order keyword scanner: + # - `state add`: the ALGO-LIST (`aead ...`) must come before + # `mode`, not after — "ID [ALGO-LIST] [mode MODE] ..." per + # `ip xfrm state help`. Reversed, it fails with a bare + # "RTNETLINK answers: Invalid argument". + # - `policy add`: the selector (src/dst/proto/dport) must stay + # contiguous, with `dir` only appearing after it's complete — + # "SELECTOR dir DIR ..." per `ip xfrm policy help`. Splitting it + # by putting `dir` in the middle confuses the parser into + # thinking `proto` was given twice ("duplicate \"unknown\": + # \"proto\" is the second value"). + sudo ip xfrm state add src $LOCAL_IP dst $REMOTE_IP proto esp spi $SPI \ + aead 'rfc4106(gcm(aes))' $AEAD_KEY_HEX 128 mode transport + sudo ip xfrm policy add src $LOCAL_IP dst $REMOTE_IP proto udp dport $DSTPORT dir out \ + tmpl src $LOCAL_IP dst $REMOTE_IP proto esp spi $SPI mode transport + + # Inbound: remote -> this host. + sudo ip xfrm state add src $REMOTE_IP dst $LOCAL_IP proto esp spi $SPI \ + aead 'rfc4106(gcm(aes))' $AEAD_KEY_HEX 128 mode transport + sudo ip xfrm policy add src $REMOTE_IP dst $LOCAL_IP proto udp dport $DSTPORT dir in \ + tmpl src $REMOTE_IP dst $LOCAL_IP proto esp spi $SPI mode transport fi # Enable IP forwarding: diff --git a/members/nullnet-client/vxlan_scripts/vxlan-teardown.sh b/members/nullnet-client/vxlan_scripts/vxlan-teardown.sh index 49a938a..f64ae11 100755 --- a/members/nullnet-client/vxlan_scripts/vxlan-teardown.sh +++ b/members/nullnet-client/vxlan_scripts/vxlan-teardown.sh @@ -1,19 +1,42 @@ #!/bin/bash # Read CLI arguments: -if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then - echo "Usage: $0 [docker_container]" - echo "Example (standalone): $0 100 ns_100_s br_100_s" - echo "Example (docker): $0 100 ns_100_s br_100_s my_container" +if [ "$#" -lt 6 ] || [ "$#" -gt 7 ]; then + echo "Usage: $0 [docker_container]" + echo "Example (standalone): $0 100 ns_100_s br_100_s 192.168.1.102 192.168.1.104 20100" + echo "Example (docker): $0 100 ns_100_s br_100_s 192.168.1.102 192.168.1.104 20100 my_container" exit 1 fi VXLAN_ID=$1 NS_NAME=$2 BR_NAME=$3 -DOCKER_CONTAINER=$4 +LOCAL_IP=$4 +REMOTE_IP=$5 +DSTPORT=$6 +DOCKER_CONTAINER=$7 -# Remove the VXLAN tunnel or same-host veth pair: +# Remove this tunnel's XFRM state + policy pair, if any was installed (the +# same-host branch of vxlan-setup.sh never creates one). +if [ "$LOCAL_IP" != "$REMOTE_IP" ]; then + # Must match the same offset vxlan-setup.sh uses, to delete the actual + # installed SPI rather than the raw (and IANA-reserved) vxlan_id. + SPI=$(printf '0x%08x' $((VXLAN_ID + 1000))) + # Same argument-order requirement as vxlan-setup.sh: selector fields + # (src/dst/proto/dport) must stay contiguous, with `dir` only after. + sudo ip xfrm policy delete src $LOCAL_IP dst $REMOTE_IP proto udp dport $DSTPORT dir out 2>/dev/null + sudo ip xfrm state delete src $LOCAL_IP dst $REMOTE_IP proto esp spi $SPI 2>/dev/null + sudo ip xfrm policy delete src $REMOTE_IP dst $LOCAL_IP proto udp dport $DSTPORT dir in 2>/dev/null + sudo ip xfrm state delete src $REMOTE_IP dst $LOCAL_IP proto esp spi $SPI 2>/dev/null +fi + +# Remove the VXLAN tunnel or same-host veth pair. Deleting a veth end also +# destroys its peer and cascades to remove any macsec interface stacked on +# either end (the same-host branch of vxlan-setup.sh wraps each end in one), +# but delete both macsec names explicitly too rather than depend solely on +# that cascade. +sudo ip link del macsec-${VXLAN_ID}-s 2>/dev/null +sudo ip link del macsec-${VXLAN_ID}-c 2>/dev/null sudo ip link set vxlan-$NS_NAME down && sudo ip link del vxlan-$NS_NAME sudo ip link set veth-${VXLAN_ID}-s down && sudo ip link del veth-${VXLAN_ID}-s diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 0f57bc6..48909f5 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -94,6 +94,10 @@ message VlanSetup { string local_ip = 5; string remote_ip = 6; optional HostMapping host_mapping = 7; + // Per-tunnel AES-256 key (32 raw bytes), generated once by the server and + // sent identically to both endpoints. Used to encrypt/decrypt traffic in + // the client's userspace VLAN forwarder (see forward/send.rs, forward/receive.rs). + bytes encryption_key = 8; } message VlanTeardown { @@ -123,6 +127,15 @@ message VxlanSetup { // Set on the gateway's edge: forward tunnelled external-bound packets to the // internet (kernel ip_forward + MASQUERADE); the Cilium egress-gateway model. optional bool egress_intercept = 13; + // Per-tunnel AES-256 key (32 raw bytes), generated once by the server and + // sent identically to both endpoints. Used as the XFRM/ESP SA key that + // encrypts this tunnel's traffic at the kernel level. + bytes encryption_key = 14; + // Per-tunnel VXLAN UDP destination port (replaces the IANA-standard 4789 + // default). Each tunnel gets a distinct port so an XFRM policy — which + // selects by src/dst IP and port, not by VNI — can tell concurrent + // tunnels between the same host pair apart. + uint32 dstport = 15; } message VxlanTeardown { @@ -130,6 +143,11 @@ message VxlanTeardown { string ns_name = 2; string br_name = 3; optional string docker_container = 4; + // local_ip/remote_ip/dstport: needed to remove this tunnel's XFRM SA + + // policy pair (same values used to install them in VxlanSetup). + string local_ip = 5; + string remote_ip = 6; + uint32 dstport = 7; } message MsgId { diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index c614476..8af9131 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -63,6 +63,11 @@ pub struct VlanSetup { pub remote_ip: ::prost::alloc::string::String, #[prost(message, optional, tag = "7")] pub host_mapping: ::core::option::Option, + /// Per-tunnel AES-256 key (32 raw bytes), generated once by the server and + /// sent identically to both endpoints. Used to encrypt/decrypt traffic in + /// the client's userspace VLAN forwarder (see forward/send.rs, forward/receive.rs). + #[prost(bytes = "vec", tag = "8")] + pub encryption_key: ::prost::alloc::vec::Vec, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct VlanTeardown { @@ -106,6 +111,17 @@ pub struct VxlanSetup { /// internet (kernel ip_forward + MASQUERADE); the Cilium egress-gateway model. #[prost(bool, optional, tag = "13")] pub egress_intercept: ::core::option::Option, + /// Per-tunnel AES-256 key (32 raw bytes), generated once by the server and + /// sent identically to both endpoints. Used as the XFRM/ESP SA key that + /// encrypts this tunnel's traffic at the kernel level. + #[prost(bytes = "vec", tag = "14")] + pub encryption_key: ::prost::alloc::vec::Vec, + /// Per-tunnel VXLAN UDP destination port (replaces the IANA-standard 4789 + /// default). Each tunnel gets a distinct port so an XFRM policy — which + /// selects by src/dst IP and port, not by VNI — can tell concurrent + /// tunnels between the same host pair apart. + #[prost(uint32, tag = "15")] + pub dstport: u32, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct VxlanTeardown { @@ -117,6 +133,14 @@ pub struct VxlanTeardown { pub br_name: ::prost::alloc::string::String, #[prost(string, optional, tag = "4")] pub docker_container: ::core::option::Option<::prost::alloc::string::String>, + /// local_ip/remote_ip/dstport: needed to remove this tunnel's XFRM SA + + /// policy pair (same values used to install them in VxlanSetup). + #[prost(string, tag = "5")] + pub local_ip: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub remote_ip: ::prost::alloc::string::String, + #[prost(uint32, tag = "7")] + pub dstport: u32, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct MsgId { diff --git a/members/nullnet-proxy/src/tls.rs b/members/nullnet-proxy/src/tls.rs index 7076c1c..3f8af69 100644 --- a/members/nullnet-proxy/src/tls.rs +++ b/members/nullnet-proxy/src/tls.rs @@ -89,7 +89,7 @@ fn cert_covers_domain(leaf: &X509Ref, domain: &str) -> bool { leaf.subject_name() .entries_by_nid(Nid::COMMONNAME) .next() - .and_then(|e| e.data().as_utf8().ok()) + .and_then(|e| e.data().to_string().ok()) .is_some_and(|cn| name_matches(&cn, domain)) } diff --git a/members/nullnet-server/src/net.rs b/members/nullnet-server/src/net.rs index 22a5b61..2e2b488 100644 --- a/members/nullnet-server/src/net.rs +++ b/members/nullnet-server/src/net.rs @@ -32,10 +32,21 @@ pub(crate) trait NetExt { remote: IpAddr, docker_containers: (Option, Option), dnat_port: Option, + encryption_key: [u8; 32], + dstport: Option, egress: EgressRole, ) -> Option<(Ipv4Addr, NetMessage)>; - fn teardown(self, net_id: u32, side: &str, docker_container: Option) -> NetMessage; + #[allow(clippy::too_many_arguments)] + fn teardown( + self, + net_id: u32, + side: &str, + docker_container: Option, + local_ip: IpAddr, + remote_ip: IpAddr, + dstport: Option, + ) -> NetMessage; } impl NetExt for Net { @@ -48,10 +59,19 @@ impl NetExt for Net { remote: IpAddr, docker_containers: (Option, Option), dnat_port: Option, + encryption_key: [u8; 32], + dstport: Option, egress: EgressRole, ) -> Option<(Ipv4Addr, NetMessage)> { match self { - Net::Vlan => vlan_setup(msg_id, dest, remote_server_name, net_id, remote), + Net::Vlan => vlan_setup( + msg_id, + dest, + remote_server_name, + net_id, + remote, + encryption_key, + ), Net::Vxlan => vxlan_setup( msg_id, dest, @@ -60,12 +80,22 @@ impl NetExt for Net { remote, docker_containers, dnat_port, + encryption_key, + dstport.unwrap_or(0), egress, ), } } - fn teardown(self, net_id: u32, side: &str, docker_container: Option) -> NetMessage { + fn teardown( + self, + net_id: u32, + side: &str, + docker_container: Option, + local_ip: IpAddr, + remote_ip: IpAddr, + dstport: Option, + ) -> NetMessage { match self { Net::Vlan => NetMessage { message: Some(net_message::Message::VlanTeardown(VlanTeardown { @@ -78,6 +108,9 @@ impl NetExt for Net { ns_name: format!("ns_{net_id}_{side}"), br_name: format!("br_{net_id}_{side}"), docker_container, + local_ip: local_ip.to_string(), + remote_ip: remote_ip.to_string(), + dstport: u32::from(dstport.unwrap_or(0)), })), }, } @@ -91,6 +124,7 @@ fn vlan_setup( remote_server_name: Option, vlan_id: u32, remote: IpAddr, + encryption_key: [u8; 32], ) -> Option<(Ipv4Addr, NetMessage)> { // Map vlan_id to a /30 block within 10.0.0.0/8. // Each ID gets 4 IPs (2 usable), with 2 IPs used for server/client veth. @@ -124,6 +158,7 @@ fn vlan_setup( local_ip: dest.to_string(), remote_ip: remote.to_string(), host_mapping, + encryption_key: encryption_key.to_vec(), })), }, )) @@ -138,6 +173,8 @@ fn vxlan_setup( remote: IpAddr, docker_containers: (Option, Option), dnat_port: Option, + encryption_key: [u8; 32], + dstport: u32, egress: EgressRole, ) -> Option<(Ipv4Addr, NetMessage)> { // Map vxlan_id to a /29 block within 10.0.0.0/8. @@ -204,6 +241,8 @@ fn vxlan_setup( host_mapping, docker_container, dnat_port, + encryption_key: encryption_key.to_vec(), + dstport, egress_steer, egress_intercept, })), diff --git a/members/nullnet-server/src/net_id_pool.rs b/members/nullnet-server/src/net_id_pool.rs index 291fa22..e17f507 100644 --- a/members/nullnet-server/src/net_id_pool.rs +++ b/members/nullnet-server/src/net_id_pool.rs @@ -1,3 +1,5 @@ +use aes_gcm::aead::OsRng; +use aes_gcm::aead::rand_core::RngCore; use std::collections::BTreeSet; use std::sync::LazyLock; @@ -68,6 +70,61 @@ impl NetIdPool { } } +/// Minimum/maximum allocatable UDP port for per-tunnel VXLAN dstports. +/// Kept out of the IANA ephemeral range (32768-60999) and away from 4789 +/// (the VXLAN default) to avoid colliding with unrelated local sockets. +const MIN_VXLAN_PORT: u16 = 20000; +const MAX_VXLAN_PORT: u16 = 60000; + +/// Pool of per-tunnel UDP destination ports, used so concurrent VXLAN +/// tunnels between the same physical host pair each get a distinct dstport. +/// This is what lets an XFRM policy (which selects by IP + port, not VNI) +/// tell those tunnels apart. Same allocate/free-with-reuse shape as `NetIdPool`. +#[derive(Debug)] +pub(crate) struct UdpPortPool { + next_fresh: u16, + freed: BTreeSet, +} + +impl UdpPortPool { + pub(crate) fn new() -> Self { + Self { + next_fresh: MIN_VXLAN_PORT, + freed: BTreeSet::new(), + } + } + + pub(crate) fn allocate(&mut self) -> Option { + if let Some(&port) = self.freed.iter().next() { + self.freed.remove(&port); + return Some(port); + } + + if self.next_fresh <= MAX_VXLAN_PORT { + let port = self.next_fresh; + self.next_fresh += 1; + Some(port) + } else { + None + } + } + + pub(crate) fn free(&mut self, port: u16) { + if (MIN_VXLAN_PORT..=MAX_VXLAN_PORT).contains(&port) { + self.freed.insert(port); + } + } +} + +/// Generate a fresh random 32-byte AES-256 key for one tunnel. Called once +/// per net_id allocation; the same bytes are sent to both endpoints so they +/// share a single symmetric key for that tunnel only. +pub(crate) fn generate_key() -> [u8; 32] { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + key +} + #[cfg(test)] impl NetIdPool { /// Number of IDs currently in use (allocated but not freed). @@ -187,4 +244,56 @@ mod tests { let free = total - in_use; assert_eq!(total, in_use + free); } + + #[test] + fn test_udp_port_pool_allocate_sequential() { + let mut pool = UdpPortPool::new(); + assert_eq!(pool.allocate(), Some(MIN_VXLAN_PORT)); + assert_eq!(pool.allocate(), Some(MIN_VXLAN_PORT + 1)); + assert_eq!(pool.allocate(), Some(MIN_VXLAN_PORT + 2)); + } + + #[test] + fn test_udp_port_pool_reuse_freed() { + let mut pool = UdpPortPool::new(); + let p1 = pool.allocate().unwrap(); + let p2 = pool.allocate().unwrap(); + pool.allocate(); + + pool.free(p2); + pool.free(p1); + + assert_eq!(pool.allocate(), Some(p1)); + assert_eq!(pool.allocate(), Some(p2)); + } + + #[test] + fn test_udp_port_pool_exhaustion() { + let mut pool = UdpPortPool::new(); + pool.next_fresh = MAX_VXLAN_PORT; + + assert_eq!(pool.allocate(), Some(MAX_VXLAN_PORT)); + assert_eq!(pool.allocate(), None); + + pool.free(MAX_VXLAN_PORT); + assert_eq!(pool.allocate(), Some(MAX_VXLAN_PORT)); + assert_eq!(pool.allocate(), None); + } + + #[test] + fn test_udp_port_pool_free_ignores_out_of_range() { + let mut pool = UdpPortPool::new(); + pool.free(0); + pool.free(MIN_VXLAN_PORT - 1); + pool.free(MAX_VXLAN_PORT + 1); + assert!(pool.freed.is_empty()); + } + + #[test] + fn test_generate_key_is_random_and_full_length() { + let k1 = generate_key(); + let k2 = generate_key(); + assert_eq!(k1.len(), 32); + assert_ne!(k1, k2); + } } diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 49b266c..12fd405 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -2,6 +2,7 @@ use crate::env::{NET_TYPE, PROXY_IP}; use crate::events::Event; use crate::graphviz::generate_graphviz; use crate::net::EgressRole; +use crate::net_id_pool::generate_key; use crate::orchestrator::Orchestrator; use crate::services::changes::{ apply_changes, collect_dep_chain_edges, detect_services_list_changes, @@ -993,6 +994,31 @@ impl NullnetGrpcImpl { .await; } + // One AES-256 key per tunnel, handed identically to both + // endpoints below. For VXLAN, also reserve a per-tunnel UDP + // dstport so the two hosts' XFRM policies can tell this + // tunnel apart from any other concurrent tunnel between the + // same physical host pair. + let encryption_key = generate_key(); + let dstport = if *NET_TYPE == Net::Vxlan { + match orchestrator.allocate_vxlan_port(net_id).await { + Some(port) => Some(u32::from(port)), + None => { + eprintln!("UDP port pool exhausted"); + orchestrator.free_net_id(net_id).await; + if let Some(stack_map) = services.write().await.get_mut(&stack) + && let Some(ServiceInfo::Registered(reg)) = + stack_map.get_mut(server.name()) + { + reg.remove_client(&client); + } + return EdgeOutcome::Failed; + } + } + } else { + None + }; + let orch = orchestrator.clone(); let cd = client_docker.clone(); let sd = server_docker.clone(); @@ -1003,6 +1029,8 @@ impl NullnetGrpcImpl { client_ethernet, (cd, sd), None, + encryption_key, + dstport, server_egress, ); let orch2 = orchestrator.clone(); @@ -1015,6 +1043,8 @@ impl NullnetGrpcImpl { server_ethernet, (cd, sd), backend_entry_port, + encryption_key, + dstport, client_egress, ); diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index ab07b2b..7def464 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -1,11 +1,11 @@ use crate::env::NET_TYPE; use crate::events::{Event, EventStore}; use crate::net::{EgressRole, NetExt}; -use crate::net_id_pool::NetIdPool; +use crate::net_id_pool::{NetIdPool, UdpPortPool, generate_key}; use crate::services::changes::{apply_changes, detect_node_disconnect_changes}; use crate::services::input::StackMap; use nullnet_grpc_lib::nullnet_grpc::{ - ContainerResume, ContainerSuspend, MsgId, NetMessage, net_message, + ContainerResume, ContainerSuspend, MsgId, Net, NetMessage, net_message, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::HashMap; @@ -45,6 +45,12 @@ pub struct Orchestrator { clients: Arc>>, pending: Arc>>>, net_id_pool: Arc>, + /// Per-tunnel VXLAN UDP dstport pool. Unused in VLAN mode. + udp_port_pool: Arc>, + /// net_id -> allocated dstport, for VXLAN tunnels only. Lets + /// `send_net_teardown` free the port without every call site having to + /// thread it through. + net_id_ports: Arc>>, /// Live egress edges, keyed by initiator replica. Separate from the service /// StackMap because the proxy end is infrastructure, not a registered service. egress_edges: Arc>>, @@ -57,6 +63,8 @@ impl Orchestrator { clients: Arc::new(RwLock::new(HashMap::new())), pending: Arc::new(Mutex::new(HashMap::new())), net_id_pool: Arc::new(Mutex::new(NetIdPool::new())), + udp_port_pool: Arc::new(Mutex::new(UdpPortPool::new())), + net_id_ports: Arc::new(Mutex::new(HashMap::new())), egress_edges: Arc::new(RwLock::new(HashMap::new())), events: EventStore::new(), } @@ -165,6 +173,24 @@ impl Orchestrator { return Err("NET ID pool exhausted").handle_err(location!()); }; + // One AES-256 key per tunnel, shared by both ends, same as any other + // chain edge. For VXLAN, also reserve a per-tunnel UDP dstport so + // XFRM policies can tell concurrent tunnels between the same host + // pair apart (mirrors net_chain_setup's setup in nullnet_grpc_impl.rs). + let encryption_key = generate_key(); + let dstport = if *NET_TYPE == Net::Vxlan { + match self.allocate_vxlan_port(net_id).await { + Some(port) => Some(u32::from(port)), + None => { + self.free_net_id(net_id).await; + self.egress_edges.write().await.remove(&key); + return Err("UDP port pool exhausted").handle_err(location!()); + } + } + } else { + None + }; + // Gateway is the server side (Intercept -> forward/MASQUERADE); initiator // is the client side (Steer -> policy-route + SNAT). docker tuple is (client, server). let dockers = (initiator_docker.clone(), None); @@ -175,6 +201,8 @@ impl Orchestrator { initiator_ip, dockers.clone(), None, + encryption_key, + dstport, EgressRole::Intercept, ); let init_res = self.send_net_setup( @@ -184,6 +212,8 @@ impl Orchestrator { proxy_ip, dockers, None, + encryption_key, + dstport, EgressRole::Steer, ); let (proxy_ok, init_ok) = tokio::join!(proxy_res, init_res); @@ -312,6 +342,8 @@ impl Orchestrator { remote: IpAddr, docker_containers: (Option, Option), dnat_port: Option, + encryption_key: [u8; 32], + dstport: Option, egress: EgressRole, ) -> Option { let outbound = self.clients.read().await.get(&dest).cloned(); @@ -328,6 +360,8 @@ impl Orchestrator { remote, docker_containers, dnat_port, + encryption_key, + dstport, egress, )?; @@ -404,6 +438,22 @@ impl Orchestrator { self.net_id_pool.lock().await.allocate() } + /// Release a `net_id` that was allocated but never dispatched to either + /// endpoint (e.g. a follow-up allocation failed). No teardown messages + /// are sent — nothing was ever set up on either client. + pub(crate) async fn free_net_id(&self, net_id: u32) { + self.net_id_pool.lock().await.free(net_id); + } + + /// Allocate a per-tunnel VXLAN dstport and remember it against `net_id` + /// so `send_net_teardown` can free it later without the caller having to + /// carry it around. Only meaningful when `NET_TYPE == Net::Vxlan`. + pub(crate) async fn allocate_vxlan_port(&self, net_id: u32) -> Option { + let port = self.udp_port_pool.lock().await.allocate()?; + self.net_id_ports.lock().await.insert(net_id, port); + Some(port) + } + pub(crate) async fn connected_node_ips(&self) -> Vec { self.clients.read().await.keys().copied().collect() } @@ -420,17 +470,27 @@ impl Orchestrator { server_docker: Option, net_id: u32, ) { - for (dest, side, docker) in [(client, "c", client_docker), (server, "s", server_docker)] { + // Peeked (not removed yet) so both teardown messages can carry the + // same dstport that was used to install this tunnel's XFRM state; + // the pool slot itself is freed below, after both sides are notified. + let dstport = self.net_id_ports.lock().await.get(&net_id).copied(); + for (dest, remote, side, docker) in [ + (client, server, "c", client_docker), + (server, client, "s", server_docker), + ] { let outbound = self.clients.read().await.get(&dest).cloned(); if let Some(outbound) = outbound { println!("Sending network {net_id} teardown to client {dest}"); - let message = NET_TYPE.teardown(net_id, side, docker); + let message = NET_TYPE.teardown(net_id, side, docker, dest, remote, dstport); let _ = outbound.send(Ok(message)).await.handle_err(location!()); } } self.net_id_pool.lock().await.free(net_id); + if let Some(port) = self.net_id_ports.lock().await.remove(&net_id) { + self.udp_port_pool.lock().await.free(port); + } } } From 3406b28901292ddffb7a76dd001b692015e34979 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 15 Jul 2026 17:54:48 -0400 Subject: [PATCH 2/9] Make VLAN/VXLAN tunnel encryption optional via ENCRYPTION_ENABLED The encryption feature's kernel-level overhead (VXLAN + ESP) needed the overlay MTU pushed down to 1080 in this environment, and that cost isn't worth paying everywhere. Add a server-side env var, ENCRYPTION_ENABLED (default: enabled, preserving current behavior), threaded through the existing per-edge setup path exactly like dstport already is - both regular chain edges and egress-gateway edges respect it. When disabled: the server generates no real key (placeholder zero bytes, which the client never parses), VLAN falls back to plain userspace forwarding via a new VlanCipher::Plaintext state (distinct from "unknown tunnel, drop"), and vxlan-setup.sh skips both the same-host MACsec wrap and the cross-host XFRM state/policy install, leaving a bare vxlan/veth link. Teardown needed no changes - its cleanup commands are already safe no-ops whether or not encryption was ever installed. Wire protocol gains one field per direction: VlanSetup.encrypted and VxlanSetup.encrypted, so each endpoint learns the tunnel's encryption state independently of the server's own env var. --- members/nullnet-client/src/control_channel.rs | 91 ++++++---- members/nullnet-client/src/crypto.rs | 23 ++- members/nullnet-client/src/forward/receive.rs | 17 +- members/nullnet-client/src/forward/send.rs | 16 +- members/nullnet-client/src/peers/peer.rs | 34 +++- .../vxlan_scripts/vxlan-setup.sh | 171 ++++++++++-------- .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 16 +- .../src/proto/nullnet_grpc.rs | 18 +- members/nullnet-server/src/env.rs | 13 ++ members/nullnet-server/src/net.rs | 8 + .../nullnet-server/src/nullnet_grpc_impl.rs | 17 +- members/nullnet-server/src/orchestrator.rs | 16 +- 12 files changed, 291 insertions(+), 149 deletions(-) diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index 9cf3ca0..2a4b1b1 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -183,22 +183,30 @@ async fn handle_vlan_setup( })?; // Fail before touching the network if the key is malformed — running // this tunnel without a valid key would mean forwarding traffic in the - // clear instead of encrypted. - let encryption_key: [u8; 32] = message - .encryption_key - .try_into() - .map_err(|_| "VLAN setup message carried a malformed encryption key") - .handle_err(location!()) - .inspect_err(|e| { - fire_event( - &grpc, - AgentEventKind::VlanSetupFailed(AgentVlanSetupFailed { - vlan_id: message.vlan_id, - local_veth: local_veth.to_string(), - error_reason: e.to_str().to_string(), - }), - ); - })?; + // clear instead of encrypted. Skipped entirely when the server had + // encryption disabled (ENCRYPTION_ENABLED=false) — `encryption_key` is + // just placeholder zero bytes in that case, not a real key to validate. + let encryption_key: Option<[u8; 32]> = if message.encrypted { + Some( + message + .encryption_key + .try_into() + .map_err(|_| "VLAN setup message carried a malformed encryption key") + .handle_err(location!()) + .inspect_err(|e| { + fire_event( + &grpc, + AgentEventKind::VlanSetupFailed(AgentVlanSetupFailed { + vlan_id: message.vlan_id, + local_veth: local_veth.to_string(), + error_reason: e.to_str().to_string(), + }), + ); + })?, + ) + } else { + None + }; // setup VLAN on this machine let init_t = std::time::Instant::now(); @@ -213,11 +221,14 @@ async fn handle_vlan_setup( init_t.elapsed().as_millis() ); - // register peer + this tunnel's encryption key + // register peer + this tunnel's encryption state { let mut peers = peers.write().await; peers.insert(VethKey::new(remote_veth, vlan_id), remote_ip); - peers.insert_key(vlan_id, &encryption_key); + match encryption_key { + Some(key) => peers.insert_key(vlan_id, &key), + None => peers.insert_plaintext(vlan_id), + } } // allow this peer's data-plane traffic through the host firewall @@ -341,23 +352,30 @@ async fn handle_vxlan_setup( .handle_err(location!())?; // Fail before touching the network if the key is malformed — running // this tunnel without a valid key would mean forwarding traffic in the - // clear instead of encrypted. - let encryption_key: [u8; 32] = message - .encryption_key - .try_into() - .map_err(|_| "VXLAN setup message carried a malformed encryption key") - .handle_err(location!()) - .inspect_err(|e| { - fire_event( - &grpc, - AgentEventKind::VxlanSetupFailed(AgentVxlanSetupFailed { - vxlan_id, - ns_name: ns_name.clone(), - error_code: -1, - }), - ); - eprintln!("[vxlan_setup] {}", e.to_str()); - })?; + // clear instead of encrypted. Skipped entirely when the server had + // encryption disabled (ENCRYPTION_ENABLED=false) — `encryption_key` is + // just placeholder zero bytes in that case, and vxlan-setup.sh ignores + // it (see the `encrypted` arg passed below). + let encryption_key: [u8; 32] = if message.encrypted { + message + .encryption_key + .try_into() + .map_err(|_| "VXLAN setup message carried a malformed encryption key") + .handle_err(location!()) + .inspect_err(|e| { + fire_event( + &grpc, + AgentEventKind::VxlanSetupFailed(AgentVxlanSetupFailed { + vxlan_id, + ns_name: ns_name.clone(), + error_code: -1, + }), + ); + eprintln!("[vxlan_setup] {}", e.to_str()); + })? + } else { + [0u8; 32] + }; // allow this peer's data-plane (VXLAN underlay) traffic through the host // firewall before the tunnel comes up, so the first packets aren't dropped. @@ -385,7 +403,8 @@ async fn handle_vxlan_setup( .arg(local_ip.to_string()) .arg(remote_ip.to_string()) .arg(hex_encode(&encryption_key)) - .arg(message.dstport.to_string()); + .arg(message.dstport.to_string()) + .arg(if message.encrypted { "true" } else { "false" }); // Egress-steer edges keep their tunnel endpoint in the host root namespace // (no `docker_container` arg) so the initiator container's *forwarded* // external traffic can be policy-routed into the bridge. Other edges attach diff --git a/members/nullnet-client/src/crypto.rs b/members/nullnet-client/src/crypto.rs index 537bc36..32a9e1b 100644 --- a/members/nullnet-client/src/crypto.rs +++ b/members/nullnet-client/src/crypto.rs @@ -52,6 +52,17 @@ pub(crate) fn seal(vlan_id: u16, cipher: &TunnelCipher, plaintext: &[u8]) -> Opt Some(out) } +/// Wire framing for a VLAN tunnel with encryption disabled: the same +/// `vlan_id[2, big-endian] || ...` prefix as `seal`, but the frame follows +/// as-is afterward — no nonce, no AEAD transform. Both ends agree on which +/// framing applies to a given `vlan_id` from the same `VlanSetup.encrypted` +/// flag, so there's no ambiguity despite the shorter format. +pub(crate) fn seal_plain(vlan_id: u16, plaintext: &[u8]) -> Vec { + let mut out = vlan_id.to_be_bytes().to_vec(); + out.extend_from_slice(plaintext); + out +} + /// Splits a raw forward-socket datagram into its cleartext `vlan_id` and the /// remaining `nonce || ciphertext+tag` slice, ready for `TunnelCipher::decrypt`. pub(crate) fn open_vlan_id(datagram: &[u8]) -> Option<(u16, &[u8])> { @@ -67,7 +78,7 @@ pub(crate) fn open_vlan_id(datagram: &[u8]) -> Option<(u16, &[u8])> { #[cfg(test)] mod tests { - use super::{TunnelCipher, open_vlan_id, seal}; + use super::{TunnelCipher, open_vlan_id, seal, seal_plain}; #[test] fn round_trip() { @@ -110,6 +121,16 @@ mod tests { assert_eq!(cipher.decrypt(rest).unwrap(), frame); } + #[test] + fn seal_plain_then_open_round_trips_vlan_id_and_frame_untransformed() { + let frame = b"ethernet frame payload"; + let datagram = seal_plain(4242, frame); + + let (vlan_id, rest) = open_vlan_id(&datagram).unwrap(); + assert_eq!(vlan_id, 4242); + assert_eq!(rest, frame); + } + #[test] fn open_vlan_id_rejects_short_datagrams() { assert!(open_vlan_id(&[0u8]).is_none()); diff --git a/members/nullnet-client/src/forward/receive.rs b/members/nullnet-client/src/forward/receive.rs index f7cca1c..8f11de6 100644 --- a/members/nullnet-client/src/forward/receive.rs +++ b/members/nullnet-client/src/forward/receive.rs @@ -6,7 +6,7 @@ use tun_rs::AsyncDevice; use crate::crypto; use crate::forward::frame::Frame; -use crate::peers::peer::Peers; +use crate::peers::peer::{Peers, VlanCipher}; /// Handles incoming network packets (receives packets from the socket and /// writes them to the TAP interface). @@ -30,13 +30,20 @@ pub async fn receive( let Some((vlan_id, sealed)) = crypto::open_vlan_id(datagram) else { continue; }; - let Some(cipher) = peers.read().await.get_key(vlan_id) else { + let Some(vlan_key) = peers.read().await.get_key(vlan_id) else { continue; }; // decrypt as the packet exits the tunnel; auth failure (wrong - // key, corrupted/spoofed datagram) drops it here - let Some(pkt_data) = cipher.decrypt(sealed) else { - continue; + // key, corrupted/spoofed datagram) drops it here. Plaintext + // tunnels have nothing to authenticate — the bytes are the frame. + let pkt_data = match vlan_key { + VlanCipher::Encrypted(cipher) => { + let Some(data) = cipher.decrypt(sealed) else { + continue; + }; + data + } + VlanCipher::Plaintext => sealed.to_vec(), }; // write packet to the kernel diff --git a/members/nullnet-client/src/forward/send.rs b/members/nullnet-client/src/forward/send.rs index c055f25..0563386 100644 --- a/members/nullnet-client/src/forward/send.rs +++ b/members/nullnet-client/src/forward/send.rs @@ -8,7 +8,7 @@ use tun_rs::AsyncDevice; use crate::crypto; use crate::forward::frame::Frame; -use crate::peers::peer::{Peers, VethKey}; +use crate::peers::peer::{Peers, VethKey, VlanCipher}; /// Handles outgoing network packets (receives packets from the TAP interface /// and sends them to the socket). @@ -24,13 +24,17 @@ pub async fn send(device: &Arc, socket: &Arc, peers: Arc let Ok((dst_socket, vlan_id)) = get_dst_socket(pkt_data, &peers).await else { continue; }; - // encrypt as the packet enters the tunnel: without a key for - // this vlan_id (tunnel torn down mid-flight, or setup never - // landed) there is nothing safe to send. - let Some(cipher) = peers.read().await.get_key(vlan_id) else { + // encrypt (or frame plain) as the packet enters the tunnel: + // without a registered state for this vlan_id (tunnel torn down + // mid-flight, or setup never landed) there is nothing safe to send. + let Some(vlan_key) = peers.read().await.get_key(vlan_id) else { continue; }; - if let Some(datagram) = crypto::seal(vlan_id, &cipher, pkt_data) { + let datagram = match vlan_key { + VlanCipher::Encrypted(cipher) => crypto::seal(vlan_id, &cipher, pkt_data), + VlanCipher::Plaintext => Some(crypto::seal_plain(vlan_id, pkt_data)), + }; + if let Some(datagram) = datagram { socket.send_to(&datagram, dst_socket).await.unwrap_or(0); } } diff --git a/members/nullnet-client/src/peers/peer.rs b/members/nullnet-client/src/peers/peer.rs index 7f12f7a..53d2bd1 100644 --- a/members/nullnet-client/src/peers/peer.rs +++ b/members/nullnet-client/src/peers/peer.rs @@ -8,17 +8,28 @@ use crate::FORWARD_PORT; use crate::crypto::TunnelCipher; use serde::{Deserialize, Serialize}; +/// A VLAN tunnel's traffic is either AES-256-GCM encrypted (server's +/// `ENCRYPTION_ENABLED` was on when the tunnel was set up) or forwarded as-is. +/// Distinct from "unknown vlan_id" (`Peers::get_key` returning `None`), which +/// still means drop — this only ever comes from an explicit `VlanSetup`. +#[derive(Clone)] +pub enum VlanCipher { + Encrypted(Arc), + Plaintext, +} + /// Struct containing all peers information. #[derive(Default)] pub struct Peers { /// Mapping from veth addresses to Ethernet IPs. ips: HashMap, - /// Per-tunnel AES-256-GCM cipher, keyed by `vlan_id`. Populated from the - /// key the server hands out in `VlanSetup` and used by the userspace - /// forwarder (`forward/send.rs`, `forward/receive.rs`) to encrypt/decrypt - /// this tunnel's traffic. `Arc`-wrapped so callers can clone a handle out - /// without holding the `Peers` lock across the actual crypto work. - keys: HashMap>, + /// Per-tunnel encryption state, keyed by `vlan_id`. Populated from the + /// `VlanSetup` the server hands out and used by the userspace forwarder + /// (`forward/send.rs`, `forward/receive.rs`) to encrypt/decrypt (or pass + /// through) this tunnel's traffic. `TunnelCipher` is `Arc`-wrapped so + /// callers can clone a handle out without holding the `Peers` lock across + /// the actual crypto work. + keys: HashMap, } impl Peers { @@ -33,10 +44,17 @@ impl Peers { } pub fn insert_key(&mut self, vlan_id: u16, key: &[u8; 32]) { - self.keys.insert(vlan_id, Arc::new(TunnelCipher::new(key))); + self.keys + .insert(vlan_id, VlanCipher::Encrypted(Arc::new(TunnelCipher::new(key)))); + } + + /// Register `vlan_id` as deliberately unencrypted (server's + /// `ENCRYPTION_ENABLED` was off for this tunnel). + pub fn insert_plaintext(&mut self, vlan_id: u16) { + self.keys.insert(vlan_id, VlanCipher::Plaintext); } - pub fn get_key(&self, vlan_id: u16) -> Option> { + pub fn get_key(&self, vlan_id: u16) -> Option { self.keys.get(&vlan_id).cloned() } diff --git a/members/nullnet-client/vxlan_scripts/vxlan-setup.sh b/members/nullnet-client/vxlan_scripts/vxlan-setup.sh index ef8ae1f..339a39f 100755 --- a/members/nullnet-client/vxlan_scripts/vxlan-setup.sh +++ b/members/nullnet-client/vxlan_scripts/vxlan-setup.sh @@ -1,10 +1,10 @@ #!/bin/bash # Read CLI arguments: -if [ "$#" -lt 9 ] || [ "$#" -gt 10 ]; then - echo "Usage: $0 [docker_container]" - echo "Example (standalone): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104 <64 hex chars> 20100" - echo "Example (docker): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104 <64 hex chars> 20100 my_container" +if [ "$#" -lt 10 ] || [ "$#" -gt 11 ]; then + echo "Usage: $0 [docker_container]" + echo "Example (standalone): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104 <64 hex chars> 20100 true" + echo "Example (docker): $0 100 ns_100_s 10.0.0.1/29 br_100_s 10.0.0.2/29 192.168.1.102 192.168.1.104 <64 hex chars> 20100 true my_container" exit 1 fi @@ -17,7 +17,8 @@ LOCAL_IP=$6 REMOTE_IP=$7 KEY_HEX=$8 DSTPORT=$9 -DOCKER_CONTAINER=${10} +ENCRYPTED=${10} +DOCKER_CONTAINER=${11} BR_IP=$(echo $BR_NET | cut -d'/' -f1) @@ -91,34 +92,43 @@ if [ "$LOCAL_IP" == "$REMOTE_IP" ]; then MACSEC_IF="macsec-${VXLAN_ID}-c" fi - # The peer's MAC is available immediately: `ip link add ... peer name - # ...` creates both ends atomically in one kernel call, whether this - # invocation won the race above or lost it to the sibling script. - PEER_MAC=$(cat /sys/class/net/$PEER_VETH/address) - KEY_ID=$(printf '%032x' $VXLAN_ID) - - # MACsec adds up to 32 bytes of overhead (SecTAG + ICV for GCM-AES-256). - # Give the underlying veth the extra room — it's a virtual, host-only - # link with no physical MTU constraint — so the macsec interface on - # top of it can still carry a full OVERLAY_MTU-sized frame. - sudo ip link set "$LOCAL_VETH" mtu $((OVERLAY_MTU + 32)) up - - # Note the argument order: `port` (part of this device's own SCI) has - # to come before `cipher` — iproute2's macsec option parser is - # positional here, not a free-order keyword scanner, and silently - # rejects `port` if it comes after `cipher` ("unknown command - # \"port\"?"). Unlike the veth-pair creation above, none of these four - # commands race against the sibling script invocation (each side only - # ever touches its own uniquely-named macsec interface), so their - # stderr is deliberately left unsuppressed — a real failure here - # should be loud, not silently swallowed. - sudo ip link add link "$LOCAL_VETH" "$MACSEC_IF" type macsec port 1 cipher gcm-aes-256 encrypt on - sudo ip macsec add "$MACSEC_IF" tx sa 0 pn 1 on key "$KEY_ID" "$KEY_HEX" - sudo ip macsec add "$MACSEC_IF" rx port 1 address "$PEER_MAC" on - sudo ip macsec add "$MACSEC_IF" rx port 1 address "$PEER_MAC" sa 0 pn 1 on key "$KEY_ID" "$KEY_HEX" - - sudo ip link set "$MACSEC_IF" master "$BR_NAME" - sudo ip link set "$MACSEC_IF" mtu $OVERLAY_MTU up + if [ "$ENCRYPTED" == "true" ]; then + # The peer's MAC is available immediately: `ip link add ... peer + # name ...` creates both ends atomically in one kernel call, + # whether this invocation won the race above or lost it to the + # sibling script. + PEER_MAC=$(cat /sys/class/net/$PEER_VETH/address) + KEY_ID=$(printf '%032x' $VXLAN_ID) + + # MACsec adds up to 32 bytes of overhead (SecTAG + ICV for + # GCM-AES-256). Give the underlying veth the extra room — it's a + # virtual, host-only link with no physical MTU constraint — so the + # macsec interface on top of it can still carry a full + # OVERLAY_MTU-sized frame. + sudo ip link set "$LOCAL_VETH" mtu $((OVERLAY_MTU + 32)) up + + # Note the argument order: `port` (part of this device's own SCI) + # has to come before `cipher` — iproute2's macsec option parser is + # positional here, not a free-order keyword scanner, and silently + # rejects `port` if it comes after `cipher` ("unknown command + # \"port\"?"). Unlike the veth-pair creation above, none of these + # four commands race against the sibling script invocation (each + # side only ever touches its own uniquely-named macsec interface), + # so their stderr is deliberately left unsuppressed — a real + # failure here should be loud, not silently swallowed. + sudo ip link add link "$LOCAL_VETH" "$MACSEC_IF" type macsec port 1 cipher gcm-aes-256 encrypt on + sudo ip macsec add "$MACSEC_IF" tx sa 0 pn 1 on key "$KEY_ID" "$KEY_HEX" + sudo ip macsec add "$MACSEC_IF" rx port 1 address "$PEER_MAC" on + sudo ip macsec add "$MACSEC_IF" rx port 1 address "$PEER_MAC" sa 0 pn 1 on key "$KEY_ID" "$KEY_HEX" + + sudo ip link set "$MACSEC_IF" master "$BR_NAME" + sudo ip link set "$MACSEC_IF" mtu $OVERLAY_MTU up + else + # Encryption disabled: attach the veth straight to the bridge, no + # macsec interface in between. + sudo ip link set "$LOCAL_VETH" master "$BR_NAME" + sudo ip link set "$LOCAL_VETH" mtu $OVERLAY_MTU up + fi else # Create the VXLAN tunnel using your physical IP and interface. Each # tunnel gets its own dstport (instead of the IANA-standard 4789) so @@ -129,51 +139,56 @@ if [ "$LOCAL_IP" == "$REMOTE_IP" ]; then sudo ip link set vxlan-$NS_NAME master $BR_NAME sudo ip link set vxlan-$NS_NAME mtu $OVERLAY_MTU up - # Encrypt this tunnel's traffic at the kernel level (AES-256-GCM via - # IPsec/ESP, transport mode) between the two hosts' physical IPs, - # scoped to this tunnel's dstport so it doesn't collide with any other - # concurrent VXLAN tunnel between the same host pair. - # - # RFC4106 GCM keys are "AES key || 4-byte salt". The server only hands - # out a 32-byte AES key (shared verbatim by both VLAN's software AEAD - # and this XFRM SA), so the salt is derived here, identically on both - # ends, from that same key — it doesn't need to be secret on its own, - # only reproducible from the shared secret both sides already have. - SALT_HEX=$(printf '%s' "$KEY_HEX" | sha256sum | cut -c1-8) - # `ip xfrm state add`'s ALGO-KEYMAT requires a "0x" prefix — a bare hex - # string is rejected outright with a bare "RTNETLINK answers: Invalid - # argument", confirmed by extensive live testing (see commit history). - AEAD_KEY_HEX="0x${KEY_HEX}${SALT_HEX}" - # SPI values 1-255 are IANA-reserved (RFC 4301) and the kernel's XFRM - # code rejects them outright ("Invalid argument"). vxlan_id starts at - # 101 (see net_id_pool.rs), which falls straight into that reserved - # range — offset it well clear of 255 rather than using the raw ID. - SPI=$(printf '0x%08x' $((VXLAN_ID + 1000))) - - # Outbound: this host -> remote. - # Note the argument order in both commands below — same lesson as - # the macsec argument-order bug, `ip xfrm` is positional, not a - # free-order keyword scanner: - # - `state add`: the ALGO-LIST (`aead ...`) must come before - # `mode`, not after — "ID [ALGO-LIST] [mode MODE] ..." per - # `ip xfrm state help`. Reversed, it fails with a bare - # "RTNETLINK answers: Invalid argument". - # - `policy add`: the selector (src/dst/proto/dport) must stay - # contiguous, with `dir` only appearing after it's complete — - # "SELECTOR dir DIR ..." per `ip xfrm policy help`. Splitting it - # by putting `dir` in the middle confuses the parser into - # thinking `proto` was given twice ("duplicate \"unknown\": - # \"proto\" is the second value"). - sudo ip xfrm state add src $LOCAL_IP dst $REMOTE_IP proto esp spi $SPI \ - aead 'rfc4106(gcm(aes))' $AEAD_KEY_HEX 128 mode transport - sudo ip xfrm policy add src $LOCAL_IP dst $REMOTE_IP proto udp dport $DSTPORT dir out \ - tmpl src $LOCAL_IP dst $REMOTE_IP proto esp spi $SPI mode transport - - # Inbound: remote -> this host. - sudo ip xfrm state add src $REMOTE_IP dst $LOCAL_IP proto esp spi $SPI \ - aead 'rfc4106(gcm(aes))' $AEAD_KEY_HEX 128 mode transport - sudo ip xfrm policy add src $REMOTE_IP dst $LOCAL_IP proto udp dport $DSTPORT dir in \ - tmpl src $REMOTE_IP dst $LOCAL_IP proto esp spi $SPI mode transport + if [ "$ENCRYPTED" == "true" ]; then + # Encrypt this tunnel's traffic at the kernel level (AES-256-GCM via + # IPsec/ESP, transport mode) between the two hosts' physical IPs, + # scoped to this tunnel's dstport so it doesn't collide with any + # other concurrent VXLAN tunnel between the same host pair. + # + # RFC4106 GCM keys are "AES key || 4-byte salt". The server only + # hands out a 32-byte AES key (shared verbatim by both VLAN's + # software AEAD and this XFRM SA), so the salt is derived here, + # identically on both ends, from that same key — it doesn't need + # to be secret on its own, only reproducible from the shared + # secret both sides already have. + SALT_HEX=$(printf '%s' "$KEY_HEX" | sha256sum | cut -c1-8) + # `ip xfrm state add`'s ALGO-KEYMAT requires a "0x" prefix — a bare + # hex string is rejected outright with a bare "RTNETLINK answers: + # Invalid argument", confirmed by extensive live testing (see + # commit history). + AEAD_KEY_HEX="0x${KEY_HEX}${SALT_HEX}" + # SPI values 1-255 are IANA-reserved (RFC 4301) and the kernel's + # XFRM code rejects them outright ("Invalid argument"). vxlan_id + # starts at 101 (see net_id_pool.rs), which falls straight into + # that reserved range — offset it well clear of 255 rather than + # using the raw ID. + SPI=$(printf '0x%08x' $((VXLAN_ID + 1000))) + + # Outbound: this host -> remote. + # Note the argument order in both commands below — same lesson as + # the macsec argument-order bug, `ip xfrm` is positional, not a + # free-order keyword scanner: + # - `state add`: the ALGO-LIST (`aead ...`) must come before + # `mode`, not after — "ID [ALGO-LIST] [mode MODE] ..." per + # `ip xfrm state help`. Reversed, it fails with a bare + # "RTNETLINK answers: Invalid argument". + # - `policy add`: the selector (src/dst/proto/dport) must stay + # contiguous, with `dir` only appearing after it's complete — + # "SELECTOR dir DIR ..." per `ip xfrm policy help`. Splitting + # it by putting `dir` in the middle confuses the parser into + # thinking `proto` was given twice ("duplicate \"unknown\": + # \"proto\" is the second value"). + sudo ip xfrm state add src $LOCAL_IP dst $REMOTE_IP proto esp spi $SPI \ + aead 'rfc4106(gcm(aes))' $AEAD_KEY_HEX 128 mode transport + sudo ip xfrm policy add src $LOCAL_IP dst $REMOTE_IP proto udp dport $DSTPORT dir out \ + tmpl src $LOCAL_IP dst $REMOTE_IP proto esp spi $SPI mode transport + + # Inbound: remote -> this host. + sudo ip xfrm state add src $REMOTE_IP dst $LOCAL_IP proto esp spi $SPI \ + aead 'rfc4106(gcm(aes))' $AEAD_KEY_HEX 128 mode transport + sudo ip xfrm policy add src $REMOTE_IP dst $LOCAL_IP proto udp dport $DSTPORT dir in \ + tmpl src $REMOTE_IP dst $LOCAL_IP proto esp spi $SPI mode transport + fi fi # Enable IP forwarding: diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 48909f5..3d6d501 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -97,7 +97,12 @@ message VlanSetup { // Per-tunnel AES-256 key (32 raw bytes), generated once by the server and // sent identically to both endpoints. Used to encrypt/decrypt traffic in // the client's userspace VLAN forwarder (see forward/send.rs, forward/receive.rs). + // Meaningless (placeholder zero bytes) when `encrypted` is false. bytes encryption_key = 8; + // Whether this tunnel is encrypted, mirroring the server's ENCRYPTION_ENABLED + // env var at the time this edge was set up. When false, the client skips + // `encryption_key` entirely and forwards this VLAN's traffic in the clear. + bool encrypted = 9; } message VlanTeardown { @@ -129,13 +134,20 @@ message VxlanSetup { optional bool egress_intercept = 13; // Per-tunnel AES-256 key (32 raw bytes), generated once by the server and // sent identically to both endpoints. Used as the XFRM/ESP SA key that - // encrypts this tunnel's traffic at the kernel level. + // encrypts this tunnel's traffic at the kernel level. Meaningless + // (placeholder zero bytes) when `encrypted` is false. bytes encryption_key = 14; // Per-tunnel VXLAN UDP destination port (replaces the IANA-standard 4789 // default). Each tunnel gets a distinct port so an XFRM policy — which // selects by src/dst IP and port, not by VNI — can tell concurrent - // tunnels between the same host pair apart. + // tunnels between the same host pair apart. Allocated regardless of + // `encrypted`, for simplicity. uint32 dstport = 15; + // Whether this tunnel is encrypted, mirroring the server's ENCRYPTION_ENABLED + // env var at the time this edge was set up. When false, the client skips + // `encryption_key` and vxlan-setup.sh sets up a bare vxlan/veth link with no + // XFRM SA/policy or MACsec. + bool encrypted = 16; } message VxlanTeardown { diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index 8af9131..b55b070 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -66,8 +66,14 @@ pub struct VlanSetup { /// Per-tunnel AES-256 key (32 raw bytes), generated once by the server and /// sent identically to both endpoints. Used to encrypt/decrypt traffic in /// the client's userspace VLAN forwarder (see forward/send.rs, forward/receive.rs). + /// Meaningless (placeholder zero bytes) when `encrypted` is false. #[prost(bytes = "vec", tag = "8")] pub encryption_key: ::prost::alloc::vec::Vec, + /// Whether this tunnel is encrypted, mirroring the server's ENCRYPTION_ENABLED + /// env var at the time this edge was set up. When false, the client skips + /// `encryption_key` entirely and forwards this VLAN's traffic in the clear. + #[prost(bool, tag = "9")] + pub encrypted: bool, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct VlanTeardown { @@ -113,15 +119,23 @@ pub struct VxlanSetup { pub egress_intercept: ::core::option::Option, /// Per-tunnel AES-256 key (32 raw bytes), generated once by the server and /// sent identically to both endpoints. Used as the XFRM/ESP SA key that - /// encrypts this tunnel's traffic at the kernel level. + /// encrypts this tunnel's traffic at the kernel level. Meaningless + /// (placeholder zero bytes) when `encrypted` is false. #[prost(bytes = "vec", tag = "14")] pub encryption_key: ::prost::alloc::vec::Vec, /// Per-tunnel VXLAN UDP destination port (replaces the IANA-standard 4789 /// default). Each tunnel gets a distinct port so an XFRM policy — which /// selects by src/dst IP and port, not by VNI — can tell concurrent - /// tunnels between the same host pair apart. + /// tunnels between the same host pair apart. Allocated regardless of + /// `encrypted`, for simplicity. #[prost(uint32, tag = "15")] pub dstport: u32, + /// Whether this tunnel is encrypted, mirroring the server's ENCRYPTION_ENABLED + /// env var at the time this edge was set up. When false, the client skips + /// `encryption_key` and vxlan-setup.sh sets up a bare vxlan/veth link with no + /// XFRM SA/policy or MACsec. + #[prost(bool, tag = "16")] + pub encrypted: bool, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct VxlanTeardown { diff --git a/members/nullnet-server/src/env.rs b/members/nullnet-server/src/env.rs index 5b765fb..3ce80c0 100644 --- a/members/nullnet-server/src/env.rs +++ b/members/nullnet-server/src/env.rs @@ -27,3 +27,16 @@ pub static NET_TYPE: std::sync::LazyLock = std::sync::LazyLock::new(|| { _ => Net::default(), } }); + +/// Set to `false`/`0`/`no` to disable per-tunnel VLAN/VXLAN encryption +/// entirely: VLAN falls back to plain (unencrypted) userspace forwarding, +/// VXLAN falls back to a bare vxlan/veth link with no XFRM SA/policy or +/// MACsec. Defaults to enabled, preserving existing behavior when unset — +/// this is an opt-out, not an opt-in, so it needs its own parser rather than +/// nullnet-client's `env_bool` (which defaults false/opt-in). +pub static ENCRYPTION_ENABLED: std::sync::LazyLock = std::sync::LazyLock::new(|| { + match std::env::var("ENCRYPTION_ENABLED") { + Ok(s) => !matches!(s.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no"), + Err(_) => true, + } +}); diff --git a/members/nullnet-server/src/net.rs b/members/nullnet-server/src/net.rs index 2e2b488..4c107ab 100644 --- a/members/nullnet-server/src/net.rs +++ b/members/nullnet-server/src/net.rs @@ -34,6 +34,7 @@ pub(crate) trait NetExt { dnat_port: Option, encryption_key: [u8; 32], dstport: Option, + encrypted: bool, egress: EgressRole, ) -> Option<(Ipv4Addr, NetMessage)>; @@ -61,6 +62,7 @@ impl NetExt for Net { dnat_port: Option, encryption_key: [u8; 32], dstport: Option, + encrypted: bool, egress: EgressRole, ) -> Option<(Ipv4Addr, NetMessage)> { match self { @@ -71,6 +73,7 @@ impl NetExt for Net { net_id, remote, encryption_key, + encrypted, ), Net::Vxlan => vxlan_setup( msg_id, @@ -82,6 +85,7 @@ impl NetExt for Net { dnat_port, encryption_key, dstport.unwrap_or(0), + encrypted, egress, ), } @@ -125,6 +129,7 @@ fn vlan_setup( vlan_id: u32, remote: IpAddr, encryption_key: [u8; 32], + encrypted: bool, ) -> Option<(Ipv4Addr, NetMessage)> { // Map vlan_id to a /30 block within 10.0.0.0/8. // Each ID gets 4 IPs (2 usable), with 2 IPs used for server/client veth. @@ -159,6 +164,7 @@ fn vlan_setup( remote_ip: remote.to_string(), host_mapping, encryption_key: encryption_key.to_vec(), + encrypted, })), }, )) @@ -175,6 +181,7 @@ fn vxlan_setup( dnat_port: Option, encryption_key: [u8; 32], dstport: u32, + encrypted: bool, egress: EgressRole, ) -> Option<(Ipv4Addr, NetMessage)> { // Map vxlan_id to a /29 block within 10.0.0.0/8. @@ -245,6 +252,7 @@ fn vxlan_setup( dstport, egress_steer, egress_intercept, + encrypted, })), }, )) diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 12fd405..1e6d305 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -1,4 +1,4 @@ -use crate::env::{NET_TYPE, PROXY_IP}; +use crate::env::{ENCRYPTION_ENABLED, NET_TYPE, PROXY_IP}; use crate::events::Event; use crate::graphviz::generate_graphviz; use crate::net::EgressRole; @@ -995,11 +995,14 @@ impl NullnetGrpcImpl { } // One AES-256 key per tunnel, handed identically to both - // endpoints below. For VXLAN, also reserve a per-tunnel UDP - // dstport so the two hosts' XFRM policies can tell this - // tunnel apart from any other concurrent tunnel between the - // same physical host pair. - let encryption_key = generate_key(); + // endpoints below (skipped when encryption is globally + // disabled). For VXLAN, also reserve a per-tunnel UDP dstport + // so the two hosts' XFRM policies can tell this tunnel apart + // from any other concurrent tunnel between the same physical + // host pair — allocated regardless of encryption, for + // simplicity. + let encrypted = *ENCRYPTION_ENABLED; + let encryption_key = if encrypted { generate_key() } else { [0u8; 32] }; let dstport = if *NET_TYPE == Net::Vxlan { match orchestrator.allocate_vxlan_port(net_id).await { Some(port) => Some(u32::from(port)), @@ -1031,6 +1034,7 @@ impl NullnetGrpcImpl { None, encryption_key, dstport, + encrypted, server_egress, ); let orch2 = orchestrator.clone(); @@ -1045,6 +1049,7 @@ impl NullnetGrpcImpl { backend_entry_port, encryption_key, dstport, + encrypted, client_egress, ); diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index 7def464..efb9333 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -1,4 +1,4 @@ -use crate::env::NET_TYPE; +use crate::env::{ENCRYPTION_ENABLED, NET_TYPE}; use crate::events::{Event, EventStore}; use crate::net::{EgressRole, NetExt}; use crate::net_id_pool::{NetIdPool, UdpPortPool, generate_key}; @@ -174,10 +174,12 @@ impl Orchestrator { }; // One AES-256 key per tunnel, shared by both ends, same as any other - // chain edge. For VXLAN, also reserve a per-tunnel UDP dstport so - // XFRM policies can tell concurrent tunnels between the same host - // pair apart (mirrors net_chain_setup's setup in nullnet_grpc_impl.rs). - let encryption_key = generate_key(); + // chain edge (skipped when encryption is globally disabled). For + // VXLAN, also reserve a per-tunnel UDP dstport so XFRM policies can + // tell concurrent tunnels between the same host pair apart (mirrors + // net_chain_setup's setup in nullnet_grpc_impl.rs). + let encrypted = *ENCRYPTION_ENABLED; + let encryption_key = if encrypted { generate_key() } else { [0u8; 32] }; let dstport = if *NET_TYPE == Net::Vxlan { match self.allocate_vxlan_port(net_id).await { Some(port) => Some(u32::from(port)), @@ -203,6 +205,7 @@ impl Orchestrator { None, encryption_key, dstport, + encrypted, EgressRole::Intercept, ); let init_res = self.send_net_setup( @@ -214,6 +217,7 @@ impl Orchestrator { None, encryption_key, dstport, + encrypted, EgressRole::Steer, ); let (proxy_ok, init_ok) = tokio::join!(proxy_res, init_res); @@ -344,6 +348,7 @@ impl Orchestrator { dnat_port: Option, encryption_key: [u8; 32], dstport: Option, + encrypted: bool, egress: EgressRole, ) -> Option { let outbound = self.clients.read().await.get(&dest).cloned(); @@ -362,6 +367,7 @@ impl Orchestrator { dnat_port, encryption_key, dstport, + encrypted, egress, )?; From 767b547f9565b15c20d3dc0f374e7dad9949599b Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 15 Jul 2026 19:41:42 -0400 Subject: [PATCH 3/9] eBPF firewall: pair VXLAN_PORTS entries to their specific peer VXLAN_PORTS and PEERS were two independent flat sets, checked with a plain AND - a packet just needed a known port AND a known peer, not necessarily the SAME tunnel's port and peer. With multiple concurrent tunnels to different peers, this meant tunnel B's peer IP could satisfy tunnel A's port. XFRM's own policy lookup masks this when encryption is on (it's scoped to the exact src/dst/port/SPI tuple and needs the right key), but with ENCRYPTION_ENABLED=false the firewall is the only gate, so the gap becomes real. VXLAN_PORTS now maps port -> the specific peer it was allocated to (was port -> marker), and data_plane() checks that pairing for a tunnel's dynamic dstport instead of independent set membership. The two fixed shared ports (4789 legacy, 9999 forward-socket) still check "any known peer" - they aren't tunnel-exclusive, so pairing doesn't apply there. This narrows cross-tunnel confusion at the firewall layer; it doesn't add cryptographic anti-spoofing (only XFRM/MACsec do that) - an attacker who can already forge a peer's real source IP can still hit that peer's own correctly-paired port. --- ebpf/src/main.rs | 49 ++++++++++++------- members/nullnet-client/src/control_channel.rs | 5 +- members/nullnet-client/src/ebpf/mod.rs | 35 +++++++------ 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/ebpf/src/main.rs b/ebpf/src/main.rs index 8d0ec51..2b2a243 100644 --- a/ebpf/src/main.rs +++ b/ebpf/src/main.rs @@ -25,10 +25,13 @@ use network_types::{ // - ARP (next-hop resolution) // - TCP to/from SERVER_IP:PORT (nullnet control plane / gRPC) // - UDP 4789/9999 to/from a peer (nullnet data plane: VXLAN / forward) -// - UDP on a live tunnel's per-tunnel VXLAN dstport, to/from a peer (each -// VXLAN tunnel gets its own dynamically-allocated dstport instead of the -// shared 4789 default; VXLAN_PORTS is populated/depopulated by the control -// channel alongside PEERS as tunnels come and go) +// - UDP on a live tunnel's per-tunnel VXLAN dstport, to/from the SPECIFIC +// peer that port was allocated to (each VXLAN tunnel gets its own +// dynamically-allocated dstport instead of the shared 4789 default; +// VXLAN_PORTS maps port -> peer IP, populated/depopulated by the control +// channel alongside PEERS as tunnels come and go). Paired rather than two +// independent sets, so one tunnel's port can't be satisfied by a +// different concurrent tunnel's peer IP. // - ESP (proto 50) to/from a peer: cross-host VXLAN tunnels are wrapped in // kernel IPsec/ESP (see vxlan-setup.sh); ESP is portless like ICMP, so it's // scoped to known peers instead of a port check @@ -58,12 +61,15 @@ const PROTO_ESP: u8 = 50; #[map] static PEERS: HashMap = HashMap::with_max_entries(4096, 0); -// Per-tunnel VXLAN dstports currently in use. Each live tunnel gets its own -// dynamically-allocated dstport (see nullnet-server's `UdpPortPool`) instead -// of the shared 4789 default, so this is what lets `data_plane()` recognize -// that traffic without widening the static ALLOW_PORTS operator policy. +// Per-tunnel VXLAN dstport -> the specific peer underlay IP it was allocated +// to (host-order `u32::from(Ipv4Addr)`, same encoding as PEERS). Each live +// tunnel gets its own dynamically-allocated dstport (see nullnet-server's +// `UdpPortPool`) instead of the shared 4789 default, so this is what lets +// `data_plane()` recognize that traffic without widening the static +// ALLOW_PORTS operator policy — keyed by port -> peer (not two independent +// sets) so a packet must match the port its OWN tunnel was assigned. #[map] -static VXLAN_PORTS: HashMap = HashMap::with_max_entries(4096, 0); +static VXLAN_PORTS: HashMap = HashMap::with_max_entries(4096, 0); // Per-direction, per-proto destination-port allowlist. Key packs direction and // protocol alongside the port (see `allow_key`) so ingress/egress and TCP/UDP of @@ -215,17 +221,24 @@ fn control_plane(src: u32, dst: u32, src_port: u16, dst_port: u16) -> bool { (dst == server && dst_port == ctrl_port) || (src == server && src_port == ctrl_port) } -// Data plane: UDP on the VXLAN (4789), forward (9999), or a live tunnel's own -// per-tunnel dstport, with a known peer. +// Data plane: UDP on the VXLAN (4789) or forward (9999) shared ports with any +// known peer, OR on a live tunnel's own per-tunnel dstport with the SPECIFIC +// peer that port was allocated to. The shared ports aren't tunnel-exclusive +// (FORWARD_PORT is one listening socket for every VLAN tunnel on this host), +// so "any known peer" is correct there; a per-tunnel dstport is exclusive to +// one tunnel, so it's paired with that tunnel's own peer instead of checked +// against the flat peer set — otherwise a packet spoofing a *different* +// concurrent tunnel's peer IP would satisfy this tunnel's port. #[inline] fn data_plane(src: u32, dst: u32, src_port: u16, dst_port: u16) -> bool { - let on_data_port = dst_port == VXLAN_PORT + let on_fixed_port = dst_port == VXLAN_PORT || src_port == VXLAN_PORT || dst_port == FORWARD_PORT - || src_port == FORWARD_PORT - || is_vxlan_port(dst_port) - || is_vxlan_port(src_port); - on_data_port && (is_peer(src) || is_peer(dst)) + || src_port == FORWARD_PORT; + if on_fixed_port && (is_peer(src) || is_peer(dst)) { + return true; + } + vxlan_port_peer(dst_port) == Some(src) || vxlan_port_peer(src_port) == Some(dst) } #[inline] @@ -234,8 +247,8 @@ fn is_peer(ip: u32) -> bool { } #[inline] -fn is_vxlan_port(port: u16) -> bool { - unsafe { VXLAN_PORTS.get(&port) }.is_some() +fn vxlan_port_peer(port: u16) -> Option { + unsafe { VXLAN_PORTS.get(&port) }.copied() } // Is `port` allowed as a destination in this direction/proto? (ALLOW_PORTS key diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index 2a4b1b1..4c7f625 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -387,9 +387,10 @@ async fn handle_vxlan_setup( // nullnet-server's `UdpPortPool`), so without this every packet on a real // tunnel — cross-host VXLAN encapsulation, including the overlay ARP that // has to succeed before any TCP connection can be routed — is silently - // dropped at this host's NIC. + // dropped at this host's NIC. Paired with `remote_ip` specifically, so a + // different concurrent tunnel's peer can't satisfy this port. if let Ok(dstport) = u16::try_from(message.dstport) { - firewall_vxlan_ports.add(vxlan_id, dstport); + firewall_vxlan_ports.add(vxlan_id, dstport, remote_ip); } // setup VXLAN on this machine (optionally attaching a Docker container) diff --git a/members/nullnet-client/src/ebpf/mod.rs b/members/nullnet-client/src/ebpf/mod.rs index bd83998..cd71a6b 100644 --- a/members/nullnet-client/src/ebpf/mod.rs +++ b/members/nullnet-client/src/ebpf/mod.rs @@ -8,8 +8,8 @@ //! explicit, env-driven allow: the four `{INGRESS,EGRESS}_ALLOW_{TCP,UDP}_PORTS` //! lists (→ `ALLOW_PORTS` map). On the egress-gateway host all outbound is //! additionally allowed and tracked. Peers are added/removed from the `PEERS` map, -//! and each VXLAN tunnel's per-tunnel dstport from the `VXLAN_PORTS` map, by the -//! control channel as edges come and go. +//! and each VXLAN tunnel's per-tunnel dstport (paired with its specific peer) from +//! the `VXLAN_PORTS` map, by the control channel as edges come and go. //! //! Loading: raise the memlock rlimit, `EbpfLoader` with `set_global`, ensure a //! clsact qdisc, load+attach each `SchedClassifier` to its hook, then populate @@ -136,24 +136,26 @@ impl FirewallPeers { } } -/// Live per-tunnel VXLAN dstport allowlist backing the `VXLAN_PORTS` BPF map, -/// added/removed by the control channel alongside `FirewallPeers` as VXLAN -/// edges come and go. Unlike peers (which can be shared by multiple edges), -/// the server's `UdpPortPool` guarantees a dstport belongs to at most one live -/// tunnel at a time, so this needs no refcounting — just insert on setup, -/// remove on teardown, keyed by `vxlan_id` so a re-point to a different port -/// doesn't leak the old one. +/// Live per-tunnel VXLAN dstport -> peer allowlist backing the `VXLAN_PORTS` +/// BPF map, added/removed by the control channel alongside `FirewallPeers` as +/// VXLAN edges come and go. Maps port -> the specific peer it was allocated +/// to (not just a marker), so the eBPF side can reject a packet that reuses a +/// live port but claims a *different* concurrent tunnel's peer IP. Unlike +/// peers (which can be shared by multiple edges), the server's `UdpPortPool` +/// guarantees a dstport belongs to at most one live tunnel at a time, so this +/// needs no refcounting — just insert on setup, remove on teardown, keyed by +/// `vxlan_id` so a re-point to a different port doesn't leak the old one. pub struct FirewallVxlanPorts { inner: Mutex, } struct PortInner { - map: AyaHashMap, + map: AyaHashMap, by_id: HashMap, } impl FirewallVxlanPorts { - fn new(map: AyaHashMap) -> Self { + fn new(map: AyaHashMap) -> Self { Self { inner: Mutex::new(PortInner { map, @@ -162,15 +164,18 @@ impl FirewallVxlanPorts { } } - /// Allow data-plane traffic on `port` for tunnel `vxlan_id`. - pub fn add(&self, vxlan_id: u32, port: u16) { + /// Allow data-plane traffic on `port` for tunnel `vxlan_id`, scoped to + /// `peer` — the eBPF side checks this port's packets against the actual + /// peer address, not just "any known peer," so a different concurrent + /// tunnel's peer can't satisfy this one's port. + pub fn add(&self, vxlan_id: u32, port: u16, peer: Ipv4Addr) { let mut inner = self.inner.lock().unwrap(); if let Some(old_port) = inner.by_id.insert(vxlan_id, port) && old_port != port { let _ = inner.map.remove(&old_port); } - let _ = inner.map.insert(port, 0u8, 0); + let _ = inner.map.insert(port, u32::from(peer), 0); } /// Drop tunnel `vxlan_id`'s dstport allowance. @@ -228,7 +233,7 @@ pub fn enable(iface: &str, cfg: &FirewallConfig) -> Result { .try_into() .handle_err(location!())?; - let vxlan_ports_map: AyaHashMap = bpf + let vxlan_ports_map: AyaHashMap = bpf .take_map("VXLAN_PORTS") .ok_or("VXLAN_PORTS map not found in bytecode") .handle_err(location!())? From d10eb3ae9fc75afd4033a3488d7273ce9107905a Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 15 Jul 2026 19:51:38 -0400 Subject: [PATCH 4/9] fmt: apply rustfmt to peer.rs and env.rs Both were hand-written without running rustfmt at the time. No behavior change - cargo fmt --all --check now passes clean across the workspace, and the standalone ebpf crate was already clean. clippy -D warnings is clean on nullnet-client, nullnet-ebpf, nullnet-grpc-lib, and xtask. Could not run it on nullnet-server in this sandbox - its build.rs shells out to `npm` to build the UI, and npm isn't installed here (pre-existing gap, unrelated to this repo). Confirmed nullnet-server still builds and its 91 tests still pass. --- members/nullnet-client/src/peers/peer.rs | 6 ++++-- members/nullnet-server/src/env.rs | 7 +++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/members/nullnet-client/src/peers/peer.rs b/members/nullnet-client/src/peers/peer.rs index 53d2bd1..b7ffab0 100644 --- a/members/nullnet-client/src/peers/peer.rs +++ b/members/nullnet-client/src/peers/peer.rs @@ -44,8 +44,10 @@ impl Peers { } pub fn insert_key(&mut self, vlan_id: u16, key: &[u8; 32]) { - self.keys - .insert(vlan_id, VlanCipher::Encrypted(Arc::new(TunnelCipher::new(key)))); + self.keys.insert( + vlan_id, + VlanCipher::Encrypted(Arc::new(TunnelCipher::new(key))), + ); } /// Register `vlan_id` as deliberately unencrypted (server's diff --git a/members/nullnet-server/src/env.rs b/members/nullnet-server/src/env.rs index 3ce80c0..443d38f 100644 --- a/members/nullnet-server/src/env.rs +++ b/members/nullnet-server/src/env.rs @@ -34,9 +34,8 @@ pub static NET_TYPE: std::sync::LazyLock = std::sync::LazyLock::new(|| { /// MACsec. Defaults to enabled, preserving existing behavior when unset — /// this is an opt-out, not an opt-in, so it needs its own parser rather than /// nullnet-client's `env_bool` (which defaults false/opt-in). -pub static ENCRYPTION_ENABLED: std::sync::LazyLock = std::sync::LazyLock::new(|| { - match std::env::var("ENCRYPTION_ENABLED") { +pub static ENCRYPTION_ENABLED: std::sync::LazyLock = + std::sync::LazyLock::new(|| match std::env::var("ENCRYPTION_ENABLED") { Ok(s) => !matches!(s.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no"), Err(_) => true, - } -}); + }); From 4783515d71ea2aea895f9451ad62fa0aea9532a2 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Thu, 16 Jul 2026 19:12:15 -0400 Subject: [PATCH 5/9] VXLAN dstport: scope per host pair instead of one global pool, plus README PR #118 review raised a real blocker: making dedicated dstport allocation conditional on encryption (previous commit) still capped total concurrent encrypted VXLAN tunnels at 40k system-wide, down from the ~2M the NetIdPool actually supports - the UdpPortPool was a single pool shared across every edge regardless of which two hosts it was between. XFRM policies already select on the full (src, dst, proto, dport) tuple (confirmed directly in vxlan-setup.sh's `ip xfrm policy add` calls) - so two different host pairs reusing the same port number never collide; only concurrent encrypted tunnels between the *same* two hosts actually need distinct ports. Orchestrator now keeps one UdpPortPool per (host_a, host_b) pair (order-independent key) instead of a single global one, so the 40k ceiling only applies per host pair - for any deployment with more than a handful of distinct host pairs, this removes the practical cap entirely. allocate_vxlan_port(net_id, host_a, host_b) replaces the old net_id-only signature; net_id_ports now remembers which pair's pool to free back into on teardown. Added direct unit tests for the pairing behavior (distinct within a pair, reusable across different pairs, order-independent, freed correctly on teardown). Also finishes the other review comment: documents ENCRYPTION_ENABLED in README.md (server env var section). --- README.md | 5 + members/nullnet-client/src/control_channel.rs | 10 +- members/nullnet-client/src/main.rs | 4 + members/nullnet-server/src/net.rs | 5 +- members/nullnet-server/src/net_id_pool.rs | 13 ++ .../nullnet-server/src/nullnet_grpc_impl.rs | 23 ++- members/nullnet-server/src/orchestrator.rs | 152 +++++++++++++++--- 7 files changed, 180 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 1f712ee..fe39902 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip NET_TYPE=VXLAN CERT_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars> PROXY_IP=192.168.1.100 + ENCRYPTION_ENABLED=true ``` `CERT_ENCRYPTION_KEY` is **required** — the server refuses to start without it. It encrypts TLS certificate private keys (and the DNS-provider credentials of ACME-issued certs) at rest; @@ -50,6 +51,10 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip a per-initiator egress edge to this host. If unset, egress is disabled (the trigger is rejected with "PROXY_IP is not configured") — ingress still works. + `ENCRYPTION_ENABLED` toggles per-tunnel VLAN/VXLAN encryption (AES-256-GCM for VLAN, XFRM/MACsec + for VXLAN) and **defaults to `true`** — omit it to keep encryption on. Set it to `false`/`0`/`no` + to run tunnels unencrypted instead (a bare vxlan/veth link, no XFRM SA/policy or MACsec). + - TLS certificates are issued from Let's Encrypt via a DNS-01 challenge (UI: *Certificates* page). Each cert stores its DNS-provider credentials encrypted at rest and is **renewed automatically** before expiry. The renewal scan is tunable via optional env vars (defaults shown): diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index 4c7f625..64c6110 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -389,7 +389,15 @@ async fn handle_vxlan_setup( // has to succeed before any TCP connection can be routed — is silently // dropped at this host's NIC. Paired with `remote_ip` specifically, so a // different concurrent tunnel's peer can't satisfy this port. - if let Ok(dstport) = u16::try_from(message.dstport) { + // + // Skipped for the shared default port (same-host or unencrypted edges — + // see nullnet-server's DEFAULT_VXLAN_DSTPORT doc comment): that port is + // legitimately reused by many concurrent tunnels at once, so it can't be + // paired to one specific peer here — it's already allowed via the eBPF + // firewall's own VXLAN_PORT constant check instead (any known peer). + if let Ok(dstport) = u16::try_from(message.dstport) + && dstport != crate::DEFAULT_VXLAN_DSTPORT + { firewall_vxlan_ports.add(vxlan_id, dstport, remote_ip); } diff --git a/members/nullnet-client/src/main.rs b/members/nullnet-client/src/main.rs index 45c26d0..bcc62c8 100644 --- a/members/nullnet-client/src/main.rs +++ b/members/nullnet-client/src/main.rs @@ -43,6 +43,10 @@ mod triggers; pub const FORWARD_PORT: u16 = 9999; pub const TAP_NAME: &str = "nullnet0"; +/// Shared VXLAN dstport for a tunnel that doesn't need a dedicated one — must +/// match nullnet-server's `DEFAULT_VXLAN_DSTPORT` (net_id_pool.rs) and the +/// eBPF firewall's `VXLAN_PORT` constant (ebpf/src/main.rs). +pub const DEFAULT_VXLAN_DSTPORT: u16 = 4789; #[tokio::main] async fn main() -> Result<(), Error> { diff --git a/members/nullnet-server/src/net.rs b/members/nullnet-server/src/net.rs index 4c107ab..6c86306 100644 --- a/members/nullnet-server/src/net.rs +++ b/members/nullnet-server/src/net.rs @@ -1,3 +1,4 @@ +use crate::net_id_pool::DEFAULT_VXLAN_DSTPORT; use ipnetwork::Ipv4Network; use nullnet_grpc_lib::nullnet_grpc::{ HostMapping, MsgId, Net, NetMessage, VlanSetup, VlanTeardown, VxlanSetup, VxlanTeardown, @@ -84,7 +85,7 @@ impl NetExt for Net { docker_containers, dnat_port, encryption_key, - dstport.unwrap_or(0), + dstport.unwrap_or(u32::from(DEFAULT_VXLAN_DSTPORT)), encrypted, egress, ), @@ -114,7 +115,7 @@ impl NetExt for Net { docker_container, local_ip: local_ip.to_string(), remote_ip: remote_ip.to_string(), - dstport: u32::from(dstport.unwrap_or(0)), + dstport: u32::from(dstport.unwrap_or(DEFAULT_VXLAN_DSTPORT)), })), }, } diff --git a/members/nullnet-server/src/net_id_pool.rs b/members/nullnet-server/src/net_id_pool.rs index e17f507..1c0afac 100644 --- a/members/nullnet-server/src/net_id_pool.rs +++ b/members/nullnet-server/src/net_id_pool.rs @@ -70,6 +70,19 @@ impl NetIdPool { } } +/// Shared VXLAN dstport for a tunnel that doesn't need a dedicated one from +/// `UdpPortPool` below — same-host tunnels (MACsec on a veth pair, no XFRM at +/// all) and unencrypted cross-host tunnels (no XFRM either). A dedicated port +/// only exists to let an XFRM policy — which selects by IP + port, not VNI — +/// tell concurrent *encrypted* tunnels between the same host pair apart; the +/// VNI alone already disambiguates tunnels sharing this port otherwise, so +/// falling back to it keeps `UdpPortPool`'s 40k entries scoped to only the +/// tunnels that actually need one, instead of capping total concurrent VXLAN +/// tunnels at 40k regardless of encryption. Matches the IANA default and the +/// eBPF firewall's own `VXLAN_PORT` constant (`ebpf/src/main.rs`), which +/// structurally allows this exact port for any known peer. +pub(crate) const DEFAULT_VXLAN_DSTPORT: u16 = 4789; + /// Minimum/maximum allocatable UDP port for per-tunnel VXLAN dstports. /// Kept out of the IANA ephemeral range (32768-60999) and away from 4789 /// (the VXLAN default) to avoid colliding with unrelated local sockets. diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 1e6d305..a3cad79 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -996,15 +996,24 @@ impl NullnetGrpcImpl { // One AES-256 key per tunnel, handed identically to both // endpoints below (skipped when encryption is globally - // disabled). For VXLAN, also reserve a per-tunnel UDP dstport - // so the two hosts' XFRM policies can tell this tunnel apart - // from any other concurrent tunnel between the same physical - // host pair — allocated regardless of encryption, for - // simplicity. + // disabled). A dedicated per-tunnel UDP dstport is only + // needed so the two hosts' XFRM policies can tell this + // tunnel apart from other concurrent *encrypted* tunnels + // between the same host pair — same-host tunnels (MACsec on + // a veth, no XFRM) and unencrypted ones (no XFRM either) fall + // back to the shared default port instead. The 40k-entry pool + // is also scoped per host pair (see `Orchestrator::allocate_vxlan_port`), + // not global, so it only actually caps concurrent encrypted + // tunnels between the same two hosts. let encrypted = *ENCRYPTION_ENABLED; let encryption_key = if encrypted { generate_key() } else { [0u8; 32] }; - let dstport = if *NET_TYPE == Net::Vxlan { - match orchestrator.allocate_vxlan_port(net_id).await { + let needs_dedicated_port = + *NET_TYPE == Net::Vxlan && encrypted && server_ethernet != client_ethernet; + let dstport = if needs_dedicated_port { + match orchestrator + .allocate_vxlan_port(net_id, server_ethernet, client_ethernet) + .await + { Some(port) => Some(u32::from(port)), None => { eprintln!("UDP port pool exhausted"); diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index efb9333..e91e042 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -45,12 +45,16 @@ pub struct Orchestrator { clients: Arc>>, pending: Arc>>>, net_id_pool: Arc>, - /// Per-tunnel VXLAN UDP dstport pool. Unused in VLAN mode. - udp_port_pool: Arc>, - /// net_id -> allocated dstport, for VXLAN tunnels only. Lets - /// `send_net_teardown` free the port without every call site having to - /// thread it through. - net_id_ports: Arc>>, + /// Per-tunnel VXLAN UDP dstport pools, one per host pair rather than one + /// global pool — XFRM policies already select by the full (src, dst, + /// proto, dport) tuple, so two different host pairs can safely reuse the + /// same port number; only concurrent tunnels *between the same two hosts* + /// need distinct ports. Unused in VLAN mode. + udp_port_pools: Arc>>, + /// net_id -> (host pair, allocated dstport), for VXLAN tunnels only. Lets + /// `send_net_teardown` free the port back into the right pair's pool + /// without every call site having to carry it around. + net_id_ports: Arc>>, /// Live egress edges, keyed by initiator replica. Separate from the service /// StackMap because the proxy end is infrastructure, not a registered service. egress_edges: Arc>>, @@ -63,7 +67,7 @@ impl Orchestrator { clients: Arc::new(RwLock::new(HashMap::new())), pending: Arc::new(Mutex::new(HashMap::new())), net_id_pool: Arc::new(Mutex::new(NetIdPool::new())), - udp_port_pool: Arc::new(Mutex::new(UdpPortPool::new())), + udp_port_pools: Arc::new(Mutex::new(HashMap::new())), net_id_ports: Arc::new(Mutex::new(HashMap::new())), egress_edges: Arc::new(RwLock::new(HashMap::new())), events: EventStore::new(), @@ -174,14 +178,20 @@ impl Orchestrator { }; // One AES-256 key per tunnel, shared by both ends, same as any other - // chain edge (skipped when encryption is globally disabled). For - // VXLAN, also reserve a per-tunnel UDP dstport so XFRM policies can - // tell concurrent tunnels between the same host pair apart (mirrors - // net_chain_setup's setup in nullnet_grpc_impl.rs). + // chain edge (skipped when encryption is globally disabled). A + // dedicated per-tunnel UDP dstport is only needed for XFRM + // disambiguation between concurrent *encrypted* tunnels sharing a + // host pair; same-host and unencrypted edges fall back to the shared + // default port instead (mirrors net_chain_setup's gating in + // nullnet_grpc_impl.rs — see DEFAULT_VXLAN_DSTPORT's doc comment). let encrypted = *ENCRYPTION_ENABLED; let encryption_key = if encrypted { generate_key() } else { [0u8; 32] }; - let dstport = if *NET_TYPE == Net::Vxlan { - match self.allocate_vxlan_port(net_id).await { + let needs_dedicated_port = *NET_TYPE == Net::Vxlan && encrypted && proxy_ip != initiator_ip; + let dstport = if needs_dedicated_port { + match self + .allocate_vxlan_port(net_id, proxy_ip, initiator_ip) + .await + { Some(port) => Some(u32::from(port)), None => { self.free_net_id(net_id).await; @@ -451,12 +461,27 @@ impl Orchestrator { self.net_id_pool.lock().await.free(net_id); } - /// Allocate a per-tunnel VXLAN dstport and remember it against `net_id` - /// so `send_net_teardown` can free it later without the caller having to - /// carry it around. Only meaningful when `NET_TYPE == Net::Vxlan`. - pub(crate) async fn allocate_vxlan_port(&self, net_id: u32) -> Option { - let port = self.udp_port_pool.lock().await.allocate()?; - self.net_id_ports.lock().await.insert(net_id, port); + /// Allocate a per-tunnel VXLAN dstport from the pool scoped to this + /// specific host pair (`host_a`/`host_b`, order-independent — not a + /// global pool, see the field doc on `udp_port_pools`), and remember it + /// against `net_id` so `send_net_teardown` can free it later without the + /// caller having to carry it around. Only meaningful when + /// `NET_TYPE == Net::Vxlan`. + pub(crate) async fn allocate_vxlan_port( + &self, + net_id: u32, + host_a: IpAddr, + host_b: IpAddr, + ) -> Option { + let pair = host_pair(host_a, host_b); + let port = self + .udp_port_pools + .lock() + .await + .entry(pair) + .or_insert_with(UdpPortPool::new) + .allocate()?; + self.net_id_ports.lock().await.insert(net_id, (pair, port)); Some(port) } @@ -479,7 +504,12 @@ impl Orchestrator { // Peeked (not removed yet) so both teardown messages can carry the // same dstport that was used to install this tunnel's XFRM state; // the pool slot itself is freed below, after both sides are notified. - let dstport = self.net_id_ports.lock().await.get(&net_id).copied(); + let dstport = self + .net_id_ports + .lock() + .await + .get(&net_id) + .map(|(_pair, port)| *port); for (dest, remote, side, docker) in [ (client, server, "c", client_docker), (server, client, "s", server_docker), @@ -494,12 +524,20 @@ impl Orchestrator { } } self.net_id_pool.lock().await.free(net_id); - if let Some(port) = self.net_id_ports.lock().await.remove(&net_id) { - self.udp_port_pool.lock().await.free(port); + if let Some((pair, port)) = self.net_id_ports.lock().await.remove(&net_id) + && let Some(pool) = self.udp_port_pools.lock().await.get_mut(&pair) + { + pool.free(port); } } } +/// Normalize a host pair so both call orders (A, B) and (B, A) land on the +/// same per-pair port pool. +fn host_pair(a: IpAddr, b: IpAddr) -> (IpAddr, IpAddr) { + if a <= b { (a, b) } else { (b, a) } +} + #[cfg(test)] impl Orchestrator { pub(crate) async fn net_ids_in_use(&self) -> u32 { @@ -552,3 +590,73 @@ impl Orchestrator { log } } + +#[cfg(test)] +mod udp_port_pool_tests { + use super::*; + + fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + #[tokio::test] + async fn allocations_within_one_pair_stay_distinct() { + let orch = Orchestrator::new(); + let (host_a, host_b) = (ip(10, 0, 0, 1), ip(10, 0, 0, 2)); + + let p1 = orch.allocate_vxlan_port(101, host_a, host_b).await.unwrap(); + let p2 = orch.allocate_vxlan_port(102, host_a, host_b).await.unwrap(); + + assert_ne!(p1, p2); + } + + #[tokio::test] + async fn different_pairs_can_reuse_the_same_port_number() { + let orch = Orchestrator::new(); + + // Two entirely separate host pairs, each allocating for the first + // time, should each get their own pool's first port - proving the + // pools are actually scoped per pair rather than drawn from one + // global pool (which would force the second call to skip ahead). + let p1 = orch + .allocate_vxlan_port(101, ip(10, 0, 0, 1), ip(10, 0, 0, 2)) + .await + .unwrap(); + let p2 = orch + .allocate_vxlan_port(102, ip(10, 0, 0, 3), ip(10, 0, 0, 4)) + .await + .unwrap(); + + assert_eq!(p1, p2); + } + + #[tokio::test] + async fn pair_lookup_is_order_independent() { + let orch = Orchestrator::new(); + let (host_a, host_b) = (ip(10, 0, 0, 1), ip(10, 0, 0, 2)); + + // Same two hosts, opposite argument order (as happens naturally: one + // edge's setup calls with (server, client), the other with + // (proxy, initiator) - either could be first) - must land on the + // same pool, not two independent ones. + let p1 = orch.allocate_vxlan_port(101, host_a, host_b).await.unwrap(); + let p2 = orch.allocate_vxlan_port(102, host_b, host_a).await.unwrap(); + + assert_ne!(p1, p2); + } + + #[tokio::test] + async fn teardown_frees_the_port_back_to_its_own_pair_pool() { + let orch = Orchestrator::new(); + let (host_a, host_b) = (ip(10, 0, 0, 1), ip(10, 0, 0, 2)); + + let port = orch.allocate_vxlan_port(101, host_a, host_b).await.unwrap(); + orch.send_net_teardown(host_a, None, host_b, None, 101) + .await; + + // The freed port is the lowest available again, so the next + // allocation for the same pair reuses it rather than advancing. + let reused = orch.allocate_vxlan_port(102, host_a, host_b).await.unwrap(); + assert_eq!(port, reused); + } +} From 2c27d7fdedff21eb75f2ab29b357c8e831f7e00f Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Thu, 16 Jul 2026 21:19:34 -0400 Subject: [PATCH 6/9] clippy: factor HostPair/AllocatedPort type aliases, fix type_complexity clippy -D warnings flagged net_id_ports's nested tuple type (HashMap) as too complex. Factored into HostPair and AllocatedPort type aliases, also used for udp_port_pools and host_pair()'s return type. --- members/nullnet-server/src/orchestrator.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index e91e042..e73c6c6 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -40,6 +40,15 @@ pub(crate) struct EgressEdgeInfo { pub(crate) proxy_ip: IpAddr, } +/// An order-independent pair of underlay host IPs, used to scope per-tunnel +/// VXLAN dstport allocation (see `Orchestrator::udp_port_pools`) — always +/// produced via `host_pair()` so both call orders land on the same key. +type HostPair = (IpAddr, IpAddr); + +/// An allocated VXLAN dstport, tagged with which host pair's pool it came +/// from, so `send_net_teardown` can free it back into the right pool. +type AllocatedPort = (HostPair, u16); + #[derive(Debug, Clone)] pub struct Orchestrator { clients: Arc>>, @@ -50,11 +59,11 @@ pub struct Orchestrator { /// proto, dport) tuple, so two different host pairs can safely reuse the /// same port number; only concurrent tunnels *between the same two hosts* /// need distinct ports. Unused in VLAN mode. - udp_port_pools: Arc>>, - /// net_id -> (host pair, allocated dstport), for VXLAN tunnels only. Lets - /// `send_net_teardown` free the port back into the right pair's pool - /// without every call site having to carry it around. - net_id_ports: Arc>>, + udp_port_pools: Arc>>, + /// net_id -> allocated dstport (with its host pair), for VXLAN tunnels + /// only. Lets `send_net_teardown` free the port back into the right + /// pair's pool without every call site having to carry it around. + net_id_ports: Arc>>, /// Live egress edges, keyed by initiator replica. Separate from the service /// StackMap because the proxy end is infrastructure, not a registered service. egress_edges: Arc>>, @@ -534,7 +543,7 @@ impl Orchestrator { /// Normalize a host pair so both call orders (A, B) and (B, A) land on the /// same per-pair port pool. -fn host_pair(a: IpAddr, b: IpAddr) -> (IpAddr, IpAddr) { +fn host_pair(a: IpAddr, b: IpAddr) -> HostPair { if a <= b { (a, b) } else { (b, a) } } From d03e10c657ae59c5085547ff560365688ea3fb5d Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Fri, 17 Jul 2026 14:39:05 -0400 Subject: [PATCH 7/9] Remove Pool UI page and /api/pool endpoint Pool capacity (net_id allocation) is no longer a meaningful stat now that VXLAN dstports are scoped per host pair instead of one global 40k-port pool. Drops the Pool.tsx page, its nav entry, the dashboard tile, the /api/pool route/handler, and the now-dead NetIdPool::stats() plus its dedicated tests. --- members/nullnet-server/src/http_server/mod.rs | 2 - .../nullnet-server/src/http_server/pool.rs | 20 ----- members/nullnet-server/src/net_id_pool.rs | 64 ------------- members/nullnet-server/src/orchestrator.rs | 4 - members/nullnet-server/ui/src/App.tsx | 2 - .../ui/src/components/Layout.tsx | 3 +- members/nullnet-server/ui/src/index.css | 12 +-- .../nullnet-server/ui/src/pages/Dashboard.tsx | 11 +-- members/nullnet-server/ui/src/pages/Pool.tsx | 89 ------------------- members/nullnet-server/ui/src/types.ts | 6 -- 10 files changed, 3 insertions(+), 210 deletions(-) delete mode 100644 members/nullnet-server/src/http_server/pool.rs delete mode 100644 members/nullnet-server/ui/src/pages/Pool.tsx diff --git a/members/nullnet-server/src/http_server/mod.rs b/members/nullnet-server/src/http_server/mod.rs index 3d1ed61..88fb7e5 100644 --- a/members/nullnet-server/src/http_server/mod.rs +++ b/members/nullnet-server/src/http_server/mod.rs @@ -15,7 +15,6 @@ mod events_stream; mod graph; mod health; mod nodes; -mod pool; mod services; mod sessions; mod stacks; @@ -36,7 +35,6 @@ pub async fn serve(state: AppState) { .route("/api/stacks", get(stacks::stacks_handler)) .route("/api/services/{stack}", get(services::services_handler)) .route("/api/nodes/{stack}", get(nodes::nodes_handler)) - .route("/api/pool", get(pool::pool_handler)) .route("/api/config/{stack}", get(config::config_handler)) .route("/api/graph/{stack}", get(graph::graph_handler)) .route("/api/sessions/{stack}", get(sessions::list_handler)) diff --git a/members/nullnet-server/src/http_server/pool.rs b/members/nullnet-server/src/http_server/pool.rs deleted file mode 100644 index 5181295..0000000 --- a/members/nullnet-server/src/http_server/pool.rs +++ /dev/null @@ -1,20 +0,0 @@ -use super::AppState; -use axum::extract::State; -use axum::response::IntoResponse; -use serde::Serialize; - -#[derive(Serialize)] -struct PoolJson { - total: u32, - in_use: u32, - free: u32, -} - -pub(super) async fn pool_handler(State(state): State) -> impl IntoResponse { - let (total, in_use) = state.orchestrator.pool_stats().await; - axum::Json(PoolJson { - total, - in_use, - free: total - in_use, - }) -} diff --git a/members/nullnet-server/src/net_id_pool.rs b/members/nullnet-server/src/net_id_pool.rs index 1c0afac..94d297f 100644 --- a/members/nullnet-server/src/net_id_pool.rs +++ b/members/nullnet-server/src/net_id_pool.rs @@ -61,13 +61,6 @@ impl NetIdPool { self.freed.insert(id); } } - - /// Returns (total_capacity, in_use). - pub(crate) fn stats(&self) -> (u32, u32) { - let capacity = *MAX_NET_ID - MIN_NET_ID + 1; - let in_use = (self.next_fresh - MIN_NET_ID) - self.freed.len() as u32; - (capacity, in_use) - } } /// Shared VXLAN dstport for a tunnel that doesn't need a dedicated one from @@ -201,63 +194,6 @@ mod tests { assert!(pool.freed.is_empty()); } - #[test] - fn test_stats_fresh_pool() { - let pool = NetIdPool::new(); - let (total, in_use) = pool.stats(); - let free = total - in_use; - assert!(total > 0); - assert_eq!(in_use, 0); - assert_eq!(free, total); - } - - #[test] - fn test_stats_after_allocations() { - let mut pool = NetIdPool::new(); - pool.allocate(); - pool.allocate(); - pool.allocate(); - let (total, in_use) = pool.stats(); - let free = total - in_use; - assert_eq!(in_use, 3); - assert_eq!(free, total - 3); - } - - #[test] - fn test_stats_free_reduces_in_use() { - let mut pool = NetIdPool::new(); - let id = pool.allocate().unwrap(); - pool.allocate(); - pool.allocate(); - pool.free(id); - let (total, in_use) = pool.stats(); - let free = total - in_use; - assert_eq!(in_use, 2); - assert_eq!(free, total - 2); - } - - #[test] - fn test_stats_exhausted_pool() { - let mut pool = NetIdPool::new(); - pool.next_fresh = *MAX_NET_ID + 1; - let (total, in_use) = pool.stats(); - let free = total - in_use; - assert_eq!(in_use, total); - assert_eq!(free, 0); - } - - #[test] - fn test_stats_total_equals_in_use_plus_free() { - let mut pool = NetIdPool::new(); - pool.allocate(); - let id = pool.allocate().unwrap(); - pool.allocate(); - pool.free(id); - let (total, in_use) = pool.stats(); - let free = total - in_use; - assert_eq!(total, in_use + free); - } - #[test] fn test_udp_port_pool_allocate_sequential() { let mut pool = UdpPortPool::new(); diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index e73c6c6..e0e91bb 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -498,10 +498,6 @@ impl Orchestrator { self.clients.read().await.keys().copied().collect() } - pub(crate) async fn pool_stats(&self) -> (u32, u32) { - self.net_id_pool.lock().await.stats() - } - pub(crate) async fn send_net_teardown( &self, client: IpAddr, diff --git a/members/nullnet-server/ui/src/App.tsx b/members/nullnet-server/ui/src/App.tsx index 6054465..d24ea9c 100644 --- a/members/nullnet-server/ui/src/App.tsx +++ b/members/nullnet-server/ui/src/App.tsx @@ -4,7 +4,6 @@ import Dashboard from './pages/Dashboard'; import Services from './pages/Services'; import Nodes from './pages/Nodes'; import Sessions from './pages/Sessions'; -import Pool from './pages/Pool'; import Config from './pages/Config'; import Events from './pages/Events'; import Certificates from './pages/Certificates'; @@ -19,7 +18,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/members/nullnet-server/ui/src/components/Layout.tsx b/members/nullnet-server/ui/src/components/Layout.tsx index 91369a9..57aba69 100644 --- a/members/nullnet-server/ui/src/components/Layout.tsx +++ b/members/nullnet-server/ui/src/components/Layout.tsx @@ -4,7 +4,7 @@ import { useApi } from '../hooks/useApi'; import type { SessionJson } from '../types'; import { useRef, useState, useEffect } from 'react'; -type Page = 'dashboard' | 'topology' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events'; +type Page = 'dashboard' | 'topology' | 'services' | 'nodes' | 'sessions' | 'config' | 'certificates' | 'events'; interface Props { page: Page; @@ -26,7 +26,6 @@ const NAV = [ { id: 'services', icon: '◈', label: 'Services', to: '/services' }, { id: 'sessions', icon: '⌾', label: 'Sessions', to: '/sessions', live: true }, { id: 'nodes', icon: '◉', label: 'Nodes', to: '/nodes' }, - { id: 'pool', icon: '▦', label: 'Pool', to: '/pool' }, ], }, { diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index 387586a..7235096 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -189,7 +189,7 @@ body { .filter-chip.on { background: rgba(91,156,246,.1); border-color: rgba(91,156,246,.25); color: var(--blue); } /* ── Dashboard stats ── */ -.stats { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; margin-bottom: 24px; } +.stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 24px; } .stat { border-radius: 12px; padding: 20px 22px; position: relative; overflow: hidden; } .stat-label { font-size: 10px; color: var(--t2); letter-spacing: .06em; margin-bottom: 10px; font-weight: 500; } .stat-value { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 28px; letter-spacing: -.03em; line-height: 1; color: var(--t0); } @@ -267,16 +267,6 @@ body { .empty-dp { display: flex; align-items: center; justify-content: center; flex: 1; flex-direction: column; gap: 10px; color: var(--t2); } /* ── Pool ── */ -.pool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; } -.pool-card { background: var(--g1); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--gb); box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 8px 32px rgba(0,0,0,.3); border-radius: 14px; padding: 22px 24px; } -.pc-label { font-size: 10px; color: var(--t2); letter-spacing: .06em; font-weight: 600; text-transform: uppercase; margin-bottom: 16px; } -.pc-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; } -.pc-num { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 30px; letter-spacing: -.04em; } -.pc-total { font-size: 13px; color: var(--t2); } -.pc-pct { font-size: 12px; color: var(--t1); } -.track { height: 6px; background: rgba(255,255,255,.06); border-radius: 4px; overflow: hidden; margin-bottom: 10px; } -.fill { height: 100%; border-radius: 4px; transition: width .5s ease; } -.pc-foot { font-size: 11px; color: var(--t2); } /* ── Config ── */ .cfg-pre { font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.7; color: var(--t1); background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 12px; padding: 20px 24px; overflow-x: auto; white-space: pre; } diff --git a/members/nullnet-server/ui/src/pages/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx index 09f3abc..dd02c65 100644 --- a/members/nullnet-server/ui/src/pages/Dashboard.tsx +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; import { useStack } from '../StackContext'; -import type { NodeJson, PoolJson } from '../types'; +import type { NodeJson } from '../types'; import { TopologyProvider, useTopologyData, useTopologyUI } from '../components/topology/TopologyContext'; import TopologyGraph from '../components/topology/TopologyGraph'; import TopologyPanel from '../components/topology/TopologyPanel'; @@ -13,7 +13,6 @@ function DashboardView() { const { panel, dispatch } = useTopologyUI(); const { data: nodes } = useApi(`/api/nodes/${stack}`, 5000); - const { data: pool } = useApi('/api/pool', 5000); const chainByProxyNetId = useMemo(() => { const m = new Map(); @@ -35,7 +34,6 @@ function DashboardView() { const proxyCount = graph ? new Set(graph.edges.filter(e => e.via_proxy).map(e => e.via_proxy!)).size : 0; - const poolPct = pool ? ((pool.in_use / pool.total) * 100).toFixed(1) : null; // sorted: registered first, then alpha const sortedNodes = useMemo(() => { @@ -73,13 +71,6 @@ function DashboardView() {
{nodeCountG - registeredCount} unregistered
-
-
Pool
-
80 ? 'var(--amber)' : 'var(--t0)' }}> - {poolPct !== null ? `${poolPct}%` : '—'} -
-
{pool ? `${pool.in_use} / ${pool.total} in use` : 'loading…'}
-
Nodes
{nodeCount}
diff --git a/members/nullnet-server/ui/src/pages/Pool.tsx b/members/nullnet-server/ui/src/pages/Pool.tsx deleted file mode 100644 index 951538d..0000000 --- a/members/nullnet-server/ui/src/pages/Pool.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import Layout from '../components/Layout'; -import { useApi } from '../hooks/useApi'; -import type { PoolJson } from '../types'; - -export default function Pool() { - const { data: pool, loading } = useApi('/api/pool', 5000); - - const pct = pool ? (pool.in_use / pool.total) * 100 : 0; - const pctStr = pct.toFixed(1); - const warn = pct >= 80; - - return ( - live · 5s - } - > -
-
Network ID Pool
-
Network ID allocation status
- - {loading &&
Loading…
} - - {pool && ( -
-
-
ID Pool
-
-
- - {pool.in_use.toLocaleString()} - - / {pool.total.toLocaleString()} -
- {pctStr}% -
-
-
-
-
- {pool.free.toLocaleString()} free - {warn && ⚠ above 80% threshold} -
-
- -
-
Breakdown
- - - - - - - - - - - - - - - - - - - -
Total capacity - {pool.total.toLocaleString()} -
In use - {pool.in_use.toLocaleString()} -
Free - {pool.free.toLocaleString()} -
Utilization - {pctStr}% -
-
-
- )} -
- - ); -} diff --git a/members/nullnet-server/ui/src/types.ts b/members/nullnet-server/ui/src/types.ts index 0bf3e5a..16f8388 100644 --- a/members/nullnet-server/ui/src/types.ts +++ b/members/nullnet-server/ui/src/types.ts @@ -30,12 +30,6 @@ export interface NodeJson { hosted_services: HostedServiceJson[]; } -export interface PoolJson { - total: number; - in_use: number; - free: number; -} - export interface SessionJson { id: number; network_id: number; From f4a08fdc1900de636b2e3ff513ef865facf2f1e5 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Fri, 17 Jul 2026 15:37:59 -0400 Subject: [PATCH 8/9] VLAN: force OpenFlow13 for the access-port redirect flow ovs-ofctl defaults to negotiating OpenFlow10/NXM, which can't express the push_vlan action this flow needs. That made AddAccessRedirectFlow silently fail to install (its exit status was never checked), leaving cross-host VLAN tunnels stuck on the generic priority=0 actions=normal fallback instead of being redirected to nullnet0 with their VLAN tag re-added. --- members/nullnet-client/src/commands/ovs.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/members/nullnet-client/src/commands/ovs.rs b/members/nullnet-client/src/commands/ovs.rs index 51d2c69..2bab949 100644 --- a/members/nullnet-client/src/commands/ovs.rs +++ b/members/nullnet-client/src/commands/ovs.rs @@ -85,6 +85,8 @@ impl OvsCommand<'_> { .map(ToString::to_string) .collect(), OvsCommand::AddAccessRedirectFlow(dev, vlan) => [ + "-O", + "OpenFlow13", "add-flow", "br0", &format!( From cf8c8fa90a58b14a8904c811e658ff2e1fc680b0 Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Fri, 17 Jul 2026 16:12:37 -0400 Subject: [PATCH 9/9] VLAN: shrink veth pair MTU to 1080 to match the physical underlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither the veth pair nor the TAP ever had an explicit MTU, so they sat at the default 1500. Once VLAN-tagged and AEAD-sealed for the forward socket, a full-size frame exceeded this environment's measured ~1104-byte physical ceiling (same constraint vxlan-setup.sh's OVERLAY_MTU works around) and got silently dropped — symptomatically identical to the earlier VXLAN MTU black hole, e.g. SSH hanging right after the KEX reply. Confirmed fixed by manually setting the veth's MTU to 1080 before wiring it up in code. --- .../nullnet-client/src/commands/netlink.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/members/nullnet-client/src/commands/netlink.rs b/members/nullnet-client/src/commands/netlink.rs index 3bd7d3f..259735b 100644 --- a/members/nullnet-client/src/commands/netlink.rs +++ b/members/nullnet-client/src/commands/netlink.rs @@ -8,6 +8,15 @@ use rtnetlink::packet_route::route::{RouteAttribute, RouteHeader}; use rtnetlink::{Handle, LinkUnspec, LinkVeth, RouteMessageBuilder}; use std::net::{IpAddr, Ipv4Addr}; +/// Matches `vxlan-setup.sh`'s `OVERLAY_MTU`: this environment's physical +/// underlay (likely the host's own SDN/overlay layer) adds enough hidden +/// overhead that a full 1500-byte frame gets silently dropped past a +/// measured ~1104-byte ceiling. VLAN tunnels share the same physical path, +/// so the veth pair carrying a tunnel's traffic needs the same shrink — +/// otherwise a large enough frame (e.g. an SSH KEX packet) vanishes with no +/// error on either end. +const VLAN_VETH_MTU: u32 = 1080; + #[derive(Debug)] pub(super) enum NetLinkCommand<'a> { HandleVethPairCreation(Ipv4Network, &'a str, &'a str), @@ -68,9 +77,9 @@ async fn handle_veth_pair_creation( let veth = get_link_by_name(handle, veth_name).await?; let veth_peer = get_link_by_name(handle, veth_peer_name).await?; - // set both ends of the veth pair up + // shrink both ends to VLAN_VETH_MTU and set them up for link in [&veth, &veth_peer] { - set_link_up(handle, link).await?; + set_link_mtu_up(handle, link, VLAN_VETH_MTU).await?; } // assign the IP address to veth_name @@ -214,6 +223,21 @@ async fn set_link_up(handle: &Handle, link: &LinkMessage) -> Result<(), Error> { Ok(()) } +async fn set_link_mtu_up(handle: &Handle, link: &LinkMessage, mtu: u32) -> Result<(), Error> { + let req = LinkUnspec::new_with_index(link.header.index) + .mtu(mtu) + .up() + .build(); + handle + .link() + .set(req) + .execute() + .await + .handle_err(location!())?; + + Ok(()) +} + async fn delete_link(handle: &Handle, link: LinkMessage) -> Result<(), Error> { handle .link()