diff --git a/Cargo.lock b/Cargo.lock index 24ca1ad..707fa39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -334,7 +340,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -425,6 +431,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.6.1" @@ -462,6 +481,26 @@ dependencies = [ "serde", ] +[[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.62" @@ -515,7 +554,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1076,6 +1115,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1142,6 +1190,9 @@ name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "fiat-crypto" @@ -1177,6 +1228,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1379,8 +1442,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link", - "windows-result", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -1407,6 +1470,7 @@ dependencies = [ "geth-iroh", "geth-keychain", "geth-node", + "geth-overlay", "geth-ssh-identity", "geth-store", "geth-types", @@ -1637,12 +1701,14 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "tun-rs", ] [[package]] name = "geth-overlay" version = "0.1.0" dependencies = [ + "blake3", "geth-types", "serde", "thiserror 2.0.18", @@ -1734,6 +1800,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "getifaddrs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a542e1b7ac1f3d62c5777d430d66eca9cb59e813c46b86e29fa9ce94ff9a4810" +dependencies = [ + "bitflags", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2083,7 +2160,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2275,7 +2352,7 @@ dependencies = [ "socket2 0.6.3", "widestring", "windows-registry", - "windows-result", + "windows-result 0.4.1", "windows-sys 0.61.2", ] @@ -2712,7 +2789,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2802,6 +2879,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.16" @@ -2898,6 +2985,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3021,6 +3117,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "netconfig-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733ad7a1352f0748a620368f2ea5cd94f0e449c5108fbac86ace31df1601bf6f" +dependencies = [ + "cfg-if", + "core-foundation 0.10.1", + "ipnet", + "libc", + "netlink-packet-core", + "netlink-packet-route 0.25.1", + "netlink-sys", + "nix 0.30.1", + "scopeguard", + "system-configuration-sys", + "thiserror 2.0.18", + "widestring", + "windows 0.61.3", +] + [[package]] name = "netdev" version = "0.38.2" @@ -3031,7 +3148,7 @@ dependencies = [ "ipnet", "libc", "netlink-packet-core", - "netlink-packet-route", + "netlink-packet-route 0.25.1", "netlink-sys", "once_cell", "system-configuration", @@ -3059,6 +3176,18 @@ dependencies = [ "netlink-packet-core", ] +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + [[package]] name = "netlink-proto" version = "0.12.0" @@ -3104,7 +3233,7 @@ dependencies = [ "n0-watcher", "netdev", "netlink-packet-core", - "netlink-packet-route", + "netlink-packet-route 0.25.1", "netlink-proto", "netlink-sys", "pin-project-lite", @@ -3115,11 +3244,37 @@ dependencies = [ "tokio-util", "tracing", "web-sys", - "windows", - "windows-result", + "windows 0.62.2", + "windows-result 0.4.1", "wmi", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -3282,7 +3437,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3352,6 +3507,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkarr" version = "5.0.3" @@ -3795,7 +3961,7 @@ dependencies = [ "cfg-if", "libc", "rustix", - "windows", + "windows 0.62.2", ] [[package]] @@ -3911,6 +4077,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "route_manager" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319bb478ff9aae1dc7a544fa599e9eb47e2521621dc3921fba6abcaaa312092c" +dependencies = [ + "flume", + "libc", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -5001,6 +5181,32 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tun-rs" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88c6aea62111e48fb67b733d6bbdc2019272e6d6f7108c9b0f4cd25d715d38a" +dependencies = [ + "blocking", + "byteorder", + "bytes", + "c2rust-bitfields", + "encoding_rs", + "getifaddrs", + "ipnet", + "libc", + "libloading", + "log", + "netconfig-rs", + "nix 0.31.3", + "route_manager", + "scopeguard", + "tokio", + "widestring", + "windows-sys 0.61.2", + "winreg", +] + [[package]] name = "typenum" version = "1.20.0" @@ -5324,16 +5530,38 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -5342,7 +5570,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -5353,9 +5594,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -5364,9 +5616,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5391,20 +5643,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", ] [[package]] @@ -5413,9 +5681,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -5424,7 +5701,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -5433,7 +5719,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5487,7 +5773,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5542,7 +5828,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5553,13 +5839,22 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5751,6 +6046,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -5856,8 +6161,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a3098ca..d0a83d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi toml_edit = "0.25.11" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tun-rs = { version = "2.7.7", features = ["async"] } url = "2" [workspace.lints.rust] diff --git a/README.md b/README.md index 478fb17..3e1b247 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,22 @@ The bootstrap implementation provides: `geth overlay status`, `geth overlay plan [--cidr 172.22.0.0/24]`, `geth overlay join --secret [--cidr 172.22.0.0/24]`, - and `geth overlay leave `. These commands expose the resource, - capability, and ALPN shape for a future Iroh-carried packet overlay inspired - by iroh-lan. The prototype does not create TUN/Wintun interfaces or route - packets yet. + `geth overlay interface-plan [--platform linux|macos|windows]`, + `geth overlay up [--bearer-secret ] [--mtu 1280]`, + `geth overlay down `, + `geth overlay peers `, `geth overlay send + --packet-base64 `, `geth overlay recv `, and + `geth overlay leave `. Join persists local overlay membership, creates + the overlay resource when needed, assigns a deterministic virtual IP, and + stores only a BLAKE3 fingerprint of the supplied secret. If the overlay + resource already has bearer invites, join requires a bearer token with + `overlay.join`. Packet send validates IPv4 packets and carries them over the + dedicated `/geth/overlay/1` Iroh ALPN after `overlay.route` authorization. + `overlay up` creates a real L3 TUN/Wintun-style interface through `tun-rs`, + reads IPv4 packets from that interface, maps destination overlay IPs to + imported peer cards, and routes packets over `/geth/overlay/1`. Creating the + interface is explicit opt-in and may require `CAP_NET_ADMIN`, sudo, or + platform-specific network entitlements. - `geth resource list` - `geth resource create ` - `geth keychain init [--admin-key ] [--signing-key ]` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 4b5a31f..4abb094 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -189,14 +189,25 @@ Current prototype commands: geth overlay plan home geth overlay plan home --cidr 172.22.0.0/24 geth overlay join home --secret + geth overlay interface-plan home --platform linux + geth overlay up home + geth overlay down home + geth overlay send home --packet-base64 + geth overlay recv home geth overlay leave home Current limits: - - join/leave are planning stubs; no TUN/Wintun interface is created yet - - host network changes must remain explicit opt-in in future versions + - overlay up creates a real TUN/Wintun-style L3 device and usually needs + privileges or host network entitlements + - host network changes are explicit opt-in only - discovery can suggest peers, but never grants overlay access - overlay access must be resource-authorized with overlay.join/overlay.route - all overlay packets must be carried over Iroh, not SSH or another transport + +Bearer invite flow: + geth resource create overlay home + geth secret bearer create resource:overlay:home --capability overlay.join + geth overlay join home --secret "#; const GUIDE_SMOKE_TEST: &str = r#"Minimal smoke test: @@ -540,6 +551,37 @@ pub enum OverlayCommand { Leave { name: String, }, + InterfacePlan { + name: String, + #[arg(long)] + platform: Option, + }, + Up { + name: String, + #[arg(long)] + bearer_secret: Option, + #[arg(long)] + mtu: Option, + }, + Down { + name: String, + }, + Peers { + name: String, + }, + Send { + name: String, + node: String, + #[arg(long)] + packet_base64: String, + #[arg(long)] + bearer_secret: Option, + }, + Recv { + name: String, + #[arg(long)] + peek: bool, + }, } #[derive(Debug, Subcommand)] @@ -1297,6 +1339,32 @@ fn request_for_command(command: Command) -> Result { ControlRequest::OverlayJoin { name, secret, cidr } } OverlayCommand::Leave { name } => ControlRequest::OverlayLeave { name }, + OverlayCommand::InterfacePlan { name, platform } => { + ControlRequest::OverlayInterfacePlan { name, platform } + } + OverlayCommand::Up { + name, + bearer_secret, + mtu, + } => ControlRequest::OverlayUp { + name, + bearer_secret, + mtu, + }, + OverlayCommand::Down { name } => ControlRequest::OverlayDown { name }, + OverlayCommand::Peers { name } => ControlRequest::OverlayPeers { name }, + OverlayCommand::Send { + name, + node, + packet_base64, + bearer_secret, + } => ControlRequest::OverlaySend { + name, + node, + packet_base64, + bearer_secret, + }, + OverlayCommand::Recv { name, peek } => ControlRequest::OverlayRecv { name, peek }, }, Command::Resource { command: ResourceCommand::List, @@ -1989,6 +2057,12 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { ControlResponse::OverlayJoined { join } => { println!("overlay: {}", join.plan.name); println!("resource: {}", join.plan.resource); + println!("cidr: {}", join.network.cidr); + println!( + "virtual_ip: {}", + join.network.virtual_ip.as_deref().unwrap_or("unassigned") + ); + println!("state: {:?}", join.network.state); println!("enabled: {}", join.enabled); println!("note: {}", join.note); } @@ -2001,6 +2075,99 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("stopped: {stopped}"); println!("note: {note}"); } + ControlResponse::OverlayInterfacePlanned { plan } => { + println!("overlay: {}", plan.name); + println!("platform: {}", plan.platform); + println!("interface: {}", plan.interface_name); + println!("cidr: {}", plan.cidr); + println!( + "virtual_ip: {}", + plan.virtual_ip.as_deref().unwrap_or("unassigned") + ); + println!("requires_privileges: {}", plan.requires_privileges); + for command in plan.commands { + println!("command: {command}"); + } + for note in plan.notes { + println!("note: {note}"); + } + } + ControlResponse::OverlayRuntimeStarted { status } => { + println!("overlay: {}", status.name); + println!("interface: {}", status.interface_name); + println!("virtual_ip: {}", status.virtual_ip); + println!("cidr: {}", status.cidr); + println!("mtu: {}", status.mtu); + println!("packets_from_tun: {}", status.packets_from_tun); + println!("packets_to_tun: {}", status.packets_to_tun); + println!("packets_to_peers: {}", status.packets_to_peers); + if let Some(error) = status.last_error { + println!("last_error: {error}"); + } + println!("note: {}", status.note); + } + ControlResponse::OverlayRuntimeStopped { + name, + stopped, + note, + } => { + println!("overlay: {name}"); + println!("stopped: {stopped}"); + println!("note: {note}"); + } + ControlResponse::OverlayPeers { name, peers, note } => { + println!("overlay: {name}"); + if peers.is_empty() { + println!("no overlay peer candidates"); + } else { + for peer in peers { + println!( + "{}\t{}\t{}\t{}", + peer.node_id, + peer.endpoint_id.as_deref().unwrap_or("no-endpoint"), + peer.virtual_ip.as_deref().unwrap_or("no-virtual-ip"), + peer.state + ); + } + } + println!("note: {note}"); + } + ControlResponse::OverlayPacketSent { + peer_node_id, + peer_agent_id, + endpoint_id, + packet, + allowed, + reason, + note, + } => { + println!("peer: {peer_node_id}"); + println!("agent: {peer_agent_id}"); + println!("endpoint: {endpoint_id}"); + println!("allowed: {allowed}"); + println!("reason: {reason}"); + if let Some(packet) = packet { + println!("packet: {}", packet.id); + println!("size_bytes: {}", packet.size_bytes); + } + println!("note: {note}"); + } + ControlResponse::OverlayPackets { + name, + packets, + drained, + note, + } => { + println!("overlay: {name}"); + println!("drained: {drained}"); + for packet in packets { + println!( + "{}\t{}\t{}\t{} bytes", + packet.id, packet.source_node, packet.destination_node, packet.size_bytes + ); + } + println!("note: {note}"); + } ControlResponse::CasAdded { hash, size_bytes } => { println!("{hash} {size_bytes} bytes"); } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 9eadfd6..d87766c 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -5,7 +5,10 @@ use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_document::{DocumentResource, DocumentState}; use geth_keychain::{KeychainOp, KeychainOpSignature, NodeEnrollmentRequest, NodeRecord}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; -use geth_overlay::{OverlayJoinPlan, OverlayNetworkStatus, OverlayPlan}; +use geth_overlay::{ + OverlayInterfacePlan, OverlayJoinPlan, OverlayNetworkStatus, OverlayPacket, OverlayPlan, + OverlayRuntimeStatus, +}; use geth_pipe::{PipeConnection, PipeListener, PipeMessage}; use geth_pubsub::PubsubMessage; use geth_resource::ResourceDescriptor; @@ -57,6 +60,31 @@ pub enum ControlRequest { OverlayLeave { name: String, }, + OverlayInterfacePlan { + name: String, + platform: Option, + }, + OverlayUp { + name: String, + bearer_secret: Option, + mtu: Option, + }, + OverlayDown { + name: String, + }, + OverlayPeers { + name: String, + }, + OverlaySend { + name: String, + node: String, + packet_base64: String, + bearer_secret: Option, + }, + OverlayRecv { + name: String, + peek: bool, + }, CasAdd { path: PathBuf, }, @@ -501,6 +529,37 @@ pub enum ControlResponse { stopped: bool, note: String, }, + OverlayInterfacePlanned { + plan: OverlayInterfacePlan, + }, + OverlayRuntimeStarted { + status: OverlayRuntimeStatus, + }, + OverlayRuntimeStopped { + name: String, + stopped: bool, + note: String, + }, + OverlayPeers { + name: String, + peers: Vec, + note: String, + }, + OverlayPacketSent { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + packet: Option, + allowed: bool, + reason: String, + note: String, + }, + OverlayPackets { + name: String, + packets: Vec, + drained: bool, + note: String, + }, CasAdded { hash: BlobHash, size_bytes: u64, @@ -990,6 +1049,14 @@ pub struct CasProvider { pub last_seen_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayPeer { + pub node_id: String, + pub endpoint_id: Option, + pub virtual_ip: Option, + pub state: String, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum PeerControlRequest { @@ -1408,6 +1475,38 @@ pub enum PipeWireResponse { }, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum OverlayWireRequest { + Packet { + peer_card: PeerCard, + network: String, + packet_base64: String, + nonce: String, + bearer_proof: Option, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum OverlayWireResponse { + PacketAccepted { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + packet: Option>, + allowed: bool, + reason: String, + evaluated_ops: usize, + nonce: String, + note: String, + }, + Error { + message: String, + }, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SyncWatermark { pub stream: String, @@ -1518,6 +1617,28 @@ pub fn decode_pipe_wire_response(line: &str) -> Result Result { + let mut line = serde_json::to_string(request)?; + line.push('\n'); + Ok(line) +} + +pub fn decode_overlay_wire_request(line: &str) -> Result { + serde_json::from_str(line).map_err(ControlError::from) +} + +pub fn encode_overlay_wire_response( + response: &OverlayWireResponse, +) -> Result { + let mut line = serde_json::to_string(response)?; + line.push('\n'); + Ok(line) +} + +pub fn decode_overlay_wire_response(line: &str) -> Result { + serde_json::from_str(line).map_err(ControlError::from) +} + #[cfg(test)] mod tests { use super::*; @@ -1587,6 +1708,27 @@ mod tests { request ); + let request = ControlRequest::OverlayUp { + name: "home-lan".to_owned(), + bearer_secret: Some("gbt_route".to_owned()), + mtu: Some(1280), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + + let request = ControlRequest::OverlaySend { + name: "home-lan".to_owned(), + node: "node:peer".to_owned(), + packet_base64: "RQAAFAAAQABAAQAAqBYAAawWAAI=".to_owned(), + bearer_secret: Some("gbt_invite".to_owned()), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let response = ControlResponse::CasHas { hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), present: true, @@ -1605,6 +1747,69 @@ mod tests { response ); + let response = ControlResponse::OverlayJoined { + join: geth_overlay::OverlayJoinPlan { + plan: geth_overlay::plan_overlay("home-lan", None, geth_overlay::OVERLAY_ALPN) + .expect("overlay plan"), + network: geth_overlay::membership_status( + &geth_overlay::joined_overlay_membership( + "home-lan", + None, + "node:local", + "invite", + 10, + ) + .expect("overlay membership"), + ), + enabled: true, + note: "membership persisted".to_owned(), + }, + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + + let response = ControlResponse::OverlayRuntimeStarted { + status: geth_overlay::OverlayRuntimeStatus { + name: "home-lan".to_owned(), + interface_name: "geth-homelan".to_owned(), + virtual_ip: "172.22.0.10".to_owned(), + cidr: "172.22.0.0/24".to_owned(), + mtu: 1280, + started_at_ms: 12, + packets_from_tun: 1, + packets_to_tun: 2, + packets_to_peers: 3, + last_error: None, + note: "active".to_owned(), + }, + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + + let response = ControlResponse::OverlayPackets { + name: "home-lan".to_owned(), + packets: vec![geth_overlay::OverlayPacket { + id: "overlay-packet:test".to_owned(), + network: "home-lan".to_owned(), + source_node: "node:peer".to_owned(), + destination_node: "node:local".to_owned(), + packet_base64: "RQAAFAAAQABAAQAAqBYAAawWAAI=".to_owned(), + size_bytes: 20, + received_at_ms: 11, + note: "received".to_owned(), + }], + drained: true, + note: "packets".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let response = ControlResponse::NodeList { nodes: vec![NodeRecord { id: geth_types::NodeId::new("node:local"), @@ -2641,6 +2846,63 @@ mod tests { response ); + let request = OverlayWireRequest::Packet { + peer_card: PeerCard { + node_id: "node:caller".into(), + agent_id: "agent:caller".into(), + endpoints: Vec::new(), + issued_at: geth_types::UnixMillis(1), + signature: geth_discovery::SignatureMetadata { + namespace: "geth.peer-card.v1@geth.local".to_owned(), + signer: "agent:caller".to_owned(), + public_key: "key".to_owned(), + signature: "sig".to_owned(), + }, + }, + network: "home".to_owned(), + packet_base64: "RQAAFAAAQABAAQAAqBYAAawWAAI=".to_owned(), + nonce: "nonce".to_owned(), + bearer_proof: Some(BearerProof { + secret: "bearer:test".into(), + resource: "resource:overlay:home".into(), + capabilities: vec!["overlay.route".into()], + nonce: "nonce".to_owned(), + response: "response".to_owned(), + }), + }; + assert_eq!( + decode_overlay_wire_request(&encode_overlay_wire_request(&request).expect("encode")) + .expect("decode"), + request + ); + + let response = OverlayWireResponse::PacketAccepted { + node_id: "node:peer".to_owned(), + agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + remote_endpoint_id: "endpoint:caller".to_owned(), + packet: Some(Box::new(geth_overlay::OverlayPacket { + id: "overlay-packet:test".to_owned(), + network: "home".to_owned(), + source_node: "node:caller".to_owned(), + destination_node: "node:peer".to_owned(), + packet_base64: "RQAAFAAAQABAAQAAqBYAAawWAAI=".to_owned(), + size_bytes: 20, + received_at_ms: 12, + note: "overlay".to_owned(), + })), + allowed: true, + reason: "direct grant".to_owned(), + evaluated_ops: 1, + nonce: "nonce".to_owned(), + note: "overlay wire".to_owned(), + }; + assert_eq!( + decode_overlay_wire_response(&encode_overlay_wire_response(&response).expect("encode")) + .expect("decode"), + response + ); + let request = PipeWireRequest::TcpConnect { peer_card: PeerCard { node_id: "node:caller".into(), diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 8a939be..54a0b54 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -42,6 +42,7 @@ iroh-blobs.workspace = true iroh-docs.workspace = true iroh-gossip.workspace = true swarm-discovery.workspace = true +tun-rs.workspace = true [dev-dependencies] rusqlite.workspace = true diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index df27b5b..fb0a3cc 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -10,9 +10,9 @@ use geth_cas::{ use geth_config::{GethConfig, GethPaths, RelayMode}; use geth_control::{ CasBlob, CasProvider, ControlRequest, ControlResponse, KeychainStatusResponse, - NativeBackendStatus, NodeIdResponse, PeerControlRequest, PeerControlResponse, PipeWireRequest, - PipeWireResponse, StatusResponse, SyncPeerRun, SyncPeerStatus, SyncStreamRun, SyncStreamStatus, - SyncWatermark, + NativeBackendStatus, NodeIdResponse, OverlayPeer, OverlayWireRequest, OverlayWireResponse, + PeerControlRequest, PeerControlResponse, PipeWireRequest, PipeWireResponse, StatusResponse, + SyncPeerRun, SyncPeerStatus, SyncStreamRun, SyncStreamStatus, SyncWatermark, }; use geth_crypto::AgentKey; use geth_db::DbResource; @@ -60,6 +60,8 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream}; +use tokio::sync::{mpsc, oneshot}; +use tun_rs::{DeviceBuilder, Layer}; #[derive(Debug, thiserror::Error)] pub enum NodeError { @@ -153,6 +155,8 @@ pub enum NodeError { PeerNotFound(String), #[error("iroh peer error: {0}")] IrohPeer(String), + #[error("overlay runtime error: {0}")] + OverlayRuntime(String), } #[derive(Clone, Debug)] @@ -172,6 +176,7 @@ pub struct LocalNode { struct NodeRuntime { pubsub: Mutex, pipes: Mutex, + overlays: Mutex>, } #[derive(Debug, Default)] @@ -192,6 +197,27 @@ struct PipeRuntime { messages: VecDeque, } +#[derive(Debug)] +struct OverlayTunRuntime { + name: String, + interface_name: String, + virtual_ip: String, + cidr: String, + mtu: u16, + started_at_ms: i64, + inject_tx: mpsc::Sender>, + stop_tx: Option>, + counters: Arc>, +} + +#[derive(Debug, Default)] +struct OverlayTunCounters { + packets_from_tun: u64, + packets_to_tun: u64, + packets_to_peers: u64, + last_error: Option, +} + const PUBSUB_RING_LIMIT: usize = 256; const PIPE_CONNECTION_RING_LIMIT: usize = 256; const PIPE_MESSAGE_RING_LIMIT: usize = 1024; @@ -293,6 +319,7 @@ pub fn init_node(paths: &GethPaths) -> Result { runtime: Arc::new(NodeRuntime { pubsub: Mutex::new(PubsubRuntime::default()), pipes: Mutex::new(PipeRuntime::default()), + overlays: Mutex::new(BTreeMap::new()), }), }) } @@ -773,6 +800,18 @@ pub async fn handle_request_async( name, bearer_secret, } => document_sync_from_peer(node, &peer_node, &name, bearer_secret).await, + ControlRequest::OverlaySend { + name, + node: peer_node, + packet_base64, + bearer_secret, + } => overlay_send_to_peer(node, &peer_node, name, packet_base64, bearer_secret).await, + ControlRequest::OverlayUp { + name, + bearer_secret, + mtu, + } => overlay_runtime_up(node, name, bearer_secret, mtu).await, + ControlRequest::OverlayDown { name } => overlay_runtime_down(node, &name), other => { let response = handle_request(node, other)?; match &response { @@ -2444,6 +2483,281 @@ async fn pipe_send_to_peer( } } +async fn overlay_send_to_peer( + node: &LocalNode, + peer_node: &str, + name: String, + packet_base64: String, + bearer_secret: Option, +) -> Result { + geth_overlay::validate_overlay_name(&name)?; + let packet = base64::engine::general_purpose::STANDARD + .decode(&packet_base64) + .map_err(|error| NodeError::IrohPeer(format!("invalid overlay packet base64: {error}")))?; + geth_overlay::validate_ipv4_packet(&packet)?; + let resource = geth_overlay::overlay_resource_id(&name).to_string(); + let response = request_overlay_wire(node, peer_node, "overlay-send", |peer_card, nonce| { + OverlayWireRequest::Packet { + peer_card, + network: name.clone(), + packet_base64: packet_base64.clone(), + nonce: nonce.clone(), + bearer_proof: bearer_proof(bearer_secret, &resource, "overlay.route", &nonce), + } + }) + .await?; + match response { + OverlayWireResponse::PacketAccepted { + node_id, + agent_id, + endpoint_id, + packet, + allowed, + reason, + note, + .. + } => Ok(ControlResponse::OverlayPacketSent { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + packet: packet.map(|packet| *packet), + allowed, + reason, + note, + }), + OverlayWireResponse::Error { message } => Err(NodeError::IrohPeer(message)), + } +} + +async fn overlay_runtime_up( + node: &LocalNode, + name: String, + bearer_secret: Option, + mtu: Option, +) -> Result { + geth_overlay::validate_overlay_name(&name)?; + let store = Store::open(&node.paths.metadata_db())?; + let membership = overlay_membership(&store, &name)?.ok_or_else(|| { + NodeError::OverlayRuntime(format!( + "overlay `{name}` is not joined; run `geth overlay join {name} --secret ` first" + )) + })?; + if membership.state != geth_overlay::OverlayState::Joined { + return Err(NodeError::OverlayRuntime(format!( + "overlay `{name}` is not joined; current state is {:?}", + membership.state + ))); + } + let virtual_ip = membership.virtual_ip.clone().ok_or_else(|| { + NodeError::OverlayRuntime(format!("overlay `{name}` does not have a virtual IP")) + })?; + let plan = geth_overlay::plan_overlay( + &name, + Some(&membership.cidr), + &String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY), + )?; + let interface = geth_overlay::interface_plan(&plan, None, membership.virtual_ip.as_deref())?; + let mtu = mtu.unwrap_or(1280); + + { + let overlays = node + .runtime + .overlays + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)?; + if let Some(runtime) = overlays.get(&name) { + return Ok(ControlResponse::OverlayRuntimeStarted { + status: overlay_runtime_status(runtime)?, + }); + } + } + + let device = DeviceBuilder::new() + .name(interface.interface_name.clone()) + .ipv4( + virtual_ip.as_str(), + geth_overlay::cidr_prefix(&membership.cidr)?, + None, + ) + .layer(Layer::L3) + .mtu(mtu) + .build_async() + .map_err(|error| { + NodeError::OverlayRuntime(format!( + "failed to create TUN/Wintun device `{}`: {error}. On Linux this usually means /dev/net/tun is unavailable or CAP_NET_ADMIN/sudo is required", + interface.interface_name + )) + })?; + let (inject_tx, inject_rx) = mpsc::channel(1024); + let (stop_tx, stop_rx) = oneshot::channel(); + let counters = Arc::new(Mutex::new(OverlayTunCounters::default())); + let started_at_ms = geth_store::now_ms(); + let runtime = OverlayTunRuntime { + name: name.clone(), + interface_name: interface.interface_name.clone(), + virtual_ip: virtual_ip.clone(), + cidr: membership.cidr.clone(), + mtu, + started_at_ms, + inject_tx, + stop_tx: Some(stop_tx), + counters: counters.clone(), + }; + { + let mut overlays = node + .runtime + .overlays + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)?; + overlays.insert(name.clone(), runtime); + } + spawn_overlay_tun_task( + node.clone(), + OverlayTunTask { + name: name.clone(), + cidr: membership.cidr, + local_node_id: node.node_id.clone(), + bearer_secret, + device, + inject_rx, + stop_rx, + counters, + }, + ); + let overlays = node + .runtime + .overlays + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)?; + let runtime = overlays.get(&name).ok_or_else(|| { + NodeError::OverlayRuntime(format!("overlay `{name}` runtime did not start")) + })?; + Ok(ControlResponse::OverlayRuntimeStarted { + status: overlay_runtime_status(runtime)?, + }) +} + +fn overlay_runtime_down(node: &LocalNode, name: &str) -> Result { + geth_overlay::validate_overlay_name(name)?; + let mut runtime = node + .runtime + .overlays + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? + .remove(name); + let stopped = if let Some(runtime) = runtime.as_mut() { + if let Some(stop_tx) = runtime.stop_tx.take() { + let _ = stop_tx.send(()); + } + true + } else { + false + }; + Ok(ControlResponse::OverlayRuntimeStopped { + name: name.to_owned(), + stopped, + note: if stopped { + "overlay runtime stop requested; the TUN/Wintun device is dropped by the daemon task" + } else { + "overlay runtime was not active" + } + .to_owned(), + }) +} + +struct OverlayTunTask { + name: String, + cidr: String, + local_node_id: String, + bearer_secret: Option, + device: tun_rs::AsyncDevice, + inject_rx: mpsc::Receiver>, + stop_rx: oneshot::Receiver<()>, + counters: Arc>, +} + +fn spawn_overlay_tun_task(node: LocalNode, mut task: OverlayTunTask) { + tokio::spawn(async move { + let mut buf = vec![0_u8; 65_535]; + loop { + tokio::select! { + _ = &mut task.stop_rx => { + break; + } + maybe_packet = task.inject_rx.recv() => { + match maybe_packet { + Some(packet) => { + match task.device.send(&packet).await { + Ok(_) => overlay_counter(&task.counters, |counters| counters.packets_to_tun += 1), + Err(error) => overlay_counter(&task.counters, |counters| counters.last_error = Some(format!("write to TUN failed: {error}"))), + } + } + None => break, + } + } + result = task.device.recv(&mut buf) => { + match result { + Ok(len) => { + let packet = buf[..len].to_vec(); + if let Err(error) = geth_overlay::validate_ipv4_packet(&packet) { + overlay_counter(&task.counters, |counters| counters.last_error = Some(error.to_string())); + continue; + } + overlay_counter(&task.counters, |counters| counters.packets_from_tun += 1); + match route_overlay_tun_packet(&node, &task, packet).await { + Ok(routed) if routed => overlay_counter(&task.counters, |counters| counters.packets_to_peers += 1), + Ok(_) => {} + Err(error) => overlay_counter(&task.counters, |counters| counters.last_error = Some(error.to_string())), + } + } + Err(error) => { + overlay_counter(&task.counters, |counters| counters.last_error = Some(format!("read from TUN failed: {error}"))); + break; + } + } + } + } + } + if let Ok(mut overlays) = node.runtime.overlays.lock() { + overlays.remove(&task.name); + } + }); +} + +async fn route_overlay_tun_packet( + node: &LocalNode, + task: &OverlayTunTask, + packet: Vec, +) -> Result { + let (_, destination) = geth_overlay::ipv4_source_destination(&packet)?; + let local_ip = geth_overlay::deterministic_virtual_ip(&task.cidr, &task.local_node_id)?; + if destination == local_ip { + return Ok(false); + } + let store = Store::open(&node.paths.metadata_db())?; + let Some(peer_node) = overlay_peer_for_virtual_ip(&store, &task.cidr, &destination)? else { + return Ok(false); + }; + let packet_base64 = base64::engine::general_purpose::STANDARD.encode(packet); + let response = overlay_send_to_peer( + node, + &peer_node, + task.name.clone(), + packet_base64, + task.bearer_secret.clone(), + ) + .await?; + match response { + ControlResponse::OverlayPacketSent { allowed: true, .. } => Ok(true), + ControlResponse::OverlayPacketSent { reason, .. } => Err(NodeError::Unauthorized(format!( + "overlay route denied: {reason}" + ))), + other => Err(NodeError::OverlayRuntime(format!( + "unexpected overlay send response: {other:?}" + ))), + } +} + async fn handle_local_pipe_tcp_stream( node: LocalNode, peer_node: &str, @@ -4200,6 +4514,76 @@ async fn request_pipe_wire( } } +async fn request_overlay_wire( + node: &LocalNode, + peer_node: &str, + operation: &str, + build_request: impl FnOnce(PeerCard, String) -> OverlayWireRequest, +) -> Result { + let store = Store::open(&node.paths.metadata_db())?; + let peer_node = resolve_peer_node_for_control(&store, peer_node); + let stored = store + .get_peer_card(&peer_node)? + .ok_or_else(|| NodeError::PeerNotFound(peer_node.clone()))?; + let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?; + peer_card.validate_candidate()?; + let candidate = peer_card + .endpoints + .first() + .ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?; + ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?; + let node_addr = iroh_node_addr_from_candidate(candidate)?; + let endpoint = node + .iroh_endpoint + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? + .clone() + .ok_or(NodeError::IrohEndpointUnavailable)?; + let self_card = local_peer_card(node, DiscoverySource::PeerExchange, true).await?; + let nonce = geth_crypto::blake3_hex( + format!( + "{}\0{}\0{}\0{}", + node.node_id, + peer_node, + operation, + geth_store::now_ms() + ) + .as_bytes(), + ); + let request = build_request(self_card, nonce.clone()); + let conn = endpoint + .endpoint() + .connect(node_addr, geth_iroh::ALPN_OVERLAY) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let (mut send, mut recv) = conn + .open_bi() + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.write_all(geth_control::encode_overlay_wire_request(&request)?.as_bytes()) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.finish() + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let bytes = recv + .read_to_end(16 * 1024 * 1024) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let text = + std::str::from_utf8(&bytes).map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let response = geth_control::decode_overlay_wire_response(text)?; + match &response { + OverlayWireResponse::PacketAccepted { + nonce: response_nonce, + .. + } if response_nonce == &nonce => Ok(response), + OverlayWireResponse::Error { .. } => Ok(response), + _ => Err(NodeError::IrohPeer(format!( + "peer {operation} response did not match request" + ))), + } +} + fn spawn_iroh_control_accept_loop(node: LocalNode, endpoint: GethIrohEndpoint) { let raw_endpoint = endpoint.endpoint(); tokio::spawn(async move { @@ -4862,6 +5246,9 @@ async fn handle_iroh_control_connection( if alpn == display_alpn(geth_iroh::ALPN_PIPE) { return handle_pipe_wire_connection(node, remote_endpoint_id, send, recv).await; } + if alpn == display_alpn(geth_iroh::ALPN_OVERLAY) { + return handle_overlay_wire_connection(node, remote_endpoint_id, send, recv).await; + } let bytes = recv .read_to_end(64 * 1024) .await @@ -6101,6 +6488,115 @@ async fn handle_pipe_wire_connection( } } +async fn handle_overlay_wire_connection( + node: LocalNode, + remote_endpoint_id: String, + mut send: iroh::endpoint::SendStream, + recv: iroh::endpoint::RecvStream, +) -> Result<(), NodeError> { + let mut reader = BufReader::new(recv); + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let response = match geth_control::decode_overlay_wire_request(&line)? { + OverlayWireRequest::Packet { + peer_card, + network, + packet_base64, + nonce, + bearer_proof, + } => { + handle_overlay_packet_wire_request( + &node, + &remote_endpoint_id, + peer_card, + network, + packet_base64, + nonce, + bearer_proof, + ) + .await? + } + }; + send.write_all(geth_control::encode_overlay_wire_response(&response)?.as_bytes()) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.finish() + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + Ok(()) +} + +async fn handle_overlay_packet_wire_request( + node: &LocalNode, + remote_endpoint_id: &str, + peer_card: PeerCard, + network: String, + packet_base64: String, + nonce: String, + bearer_proof: Option, +) -> Result { + geth_overlay::validate_overlay_name(&network)?; + let packet = base64::engine::general_purpose::STANDARD + .decode(&packet_base64) + .map_err(|error| NodeError::IrohPeer(format!("invalid overlay packet base64: {error}")))?; + geth_overlay::validate_ipv4_packet(&packet)?; + peer_card.validate_candidate()?; + ensure_peer_card_matches_endpoint(&peer_card, remote_endpoint_id)?; + let discovered = DiscoveredPeer::candidate( + peer_card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::PeerExchange, + )?; + let store = Store::open(&node.paths.metadata_db())?; + store.upsert_peer_card(&StoredPeerCard { + peer_id: peer_card.node_id.to_string(), + card_json: serde_json::to_string(&peer_card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + let resource = geth_overlay::overlay_resource_id(&network).to_string(); + let capability = "overlay.route".to_owned(); + let explanation = explain_peer_or_bearer( + &store, + peer_card.node_id.as_str(), + &resource, + &capability, + &nonce, + bearer_proof.as_ref(), + )?; + let packet = if explanation.allowed { + let injected = overlay_runtime_inject(node, &network, packet.clone())?; + let mut packet = record_overlay_packet( + &store, + &network, + peer_card.node_id.as_str(), + &node.node_id, + packet_base64, + packet.len(), + )?; + if injected { + packet.note = + "received over /geth/overlay/1 and queued to active TUN/Wintun runtime".to_owned(); + } + Some(Box::new(packet)) + } else { + None + }; + Ok(OverlayWireResponse::PacketAccepted { + node_id: node.node_id.clone(), + agent_id: node.agent_id.clone(), + endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), + remote_endpoint_id: remote_endpoint_id.to_owned(), + packet, + allowed: explanation.allowed, + reason: explanation.reason, + evaluated_ops: explanation.evaluated_ops, + nonce, + note: "overlay packet authenticated endpoint/card binding and required overlay.route on the overlay resource; payload used /geth/overlay/1".to_owned(), + }) +} + fn handle_pipe_send_wire_request( node: &LocalNode, remote_endpoint_id: &str, @@ -6594,7 +7090,7 @@ pub fn handle_request( }) } ControlRequest::OverlayStatus => Ok(ControlResponse::OverlayStatus { - networks: Vec::new(), + networks: overlay_networks(&store)?, note: geth_overlay::overlay_status_note().to_owned(), }), ControlRequest::OverlayPlan { name, cidr } => { @@ -6607,27 +7103,125 @@ pub fn handle_request( } ControlRequest::OverlayJoin { name, secret, cidr } => { geth_overlay::validate_overlay_secret(&secret)?; + let resource_id = geth_overlay::overlay_resource_id(&name).to_string(); + let bearer_required = + bearer_access_exists_for_resource_capability(&store, &resource_id, "overlay.join")?; + if bearer_required + && !bearer_token_grants_resource_capability( + &store, + &secret, + &resource_id, + "overlay.join", + )? + { + return Err(NodeError::Unauthorized( + "overlay join requires a valid bearer secret with overlay.join on the overlay resource".to_owned(), + )); + } let plan = geth_overlay::plan_overlay( &name, cidr.as_deref(), &String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY), )?; + store.insert_resource(&StoredResource { + resource_id: plan.resource.to_string(), + kind: ResourceKind::Overlay.to_string(), + name: name.clone(), + status: "active".to_owned(), + })?; + let now = geth_store::now_ms(); + let membership = geth_overlay::joined_overlay_membership( + &name, + Some(&plan.cidr), + &node.node_id, + &secret, + now, + )?; + let network = geth_overlay::membership_status(&membership); + store.put_module_state(&StoredModuleState { + module: geth_overlay::overlay_state_key(&name), + state_json: serde_json::to_string(&membership)?, + updated_at_ms: now, + })?; + let note = if bearer_required { + "overlay membership persisted after bearer overlay.join authorization; run `geth overlay up ` to start the explicit TUN/Wintun runtime" + } else { + "overlay membership persisted as a local bootstrap overlay; create a bearer invite with overlay.join before sharing this resource with other nodes" + }; Ok(ControlResponse::OverlayJoined { join: geth_overlay::OverlayJoinPlan { plan, - enabled: false, - note: "overlay join is recorded as an implementation plan only; this prototype does not create a TUN/Wintun interface or route packets".to_owned(), + network, + enabled: true, + note: note.to_owned(), }, }) } ControlRequest::OverlayLeave { name } => { geth_overlay::validate_overlay_name(&name)?; + let _ = overlay_runtime_down(node, &name)?; + let key = geth_overlay::overlay_state_key(&name); + let stopped = if let Some(state) = store.get_module_state(&key)? { + let membership: geth_overlay::OverlayMembership = + serde_json::from_str(&state.state_json)?; + let now = geth_store::now_ms(); + let membership = geth_overlay::stopped_overlay_membership(membership, now); + store.put_module_state(&StoredModuleState { + module: key, + state_json: serde_json::to_string(&membership)?, + updated_at_ms: now, + })?; + true + } else { + false + }; Ok(ControlResponse::OverlayLeft { name, - stopped: false, - note: "no overlay packet runtime is active in this prototype".to_owned(), + stopped, + note: "overlay membership stopped; run `geth overlay down ` first if the runtime is active" + .to_owned(), }) } + ControlRequest::OverlayInterfacePlan { name, platform } => { + let membership = overlay_membership(&store, &name)?; + let plan = geth_overlay::plan_overlay( + &name, + membership + .as_ref() + .map(|membership| membership.cidr.as_str()), + &String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY), + )?; + let virtual_ip = membership.and_then(|membership| membership.virtual_ip); + Ok(ControlResponse::OverlayInterfacePlanned { + plan: geth_overlay::interface_plan( + &plan, + platform.as_deref(), + virtual_ip.as_deref(), + )?, + }) + } + ControlRequest::OverlayPeers { name } => { + geth_overlay::validate_overlay_name(&name)?; + Ok(ControlResponse::OverlayPeers { + peers: overlay_peers(&store, &name)?, + name, + note: "overlay peers are candidate peer-card metadata; discovery remains untrusted" + .to_owned(), + }) + } + ControlRequest::OverlayRecv { name, peek } => { + geth_overlay::validate_overlay_name(&name)?; + let packets = overlay_packets(&store, &name, !peek)?; + Ok(ControlResponse::OverlayPackets { + name, + packets, + drained: !peek, + note: "overlay packets were received over /geth/overlay/1 and stored locally for inspection".to_owned(), + }) + } + ControlRequest::OverlaySend { .. } + | ControlRequest::OverlayUp { .. } + | ControlRequest::OverlayDown { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::CasAdd { path } => { let cas = LocalCas::new(node.paths.cas_dir()); let info = cas.add_path(&path)?; @@ -9425,6 +10019,214 @@ fn load_bearer_access(store: &Store) -> Result, NodeError> { .collect()) } +fn bearer_access_exists_for_resource_capability( + store: &Store, + resource: &str, + capability: &str, +) -> Result { + let resource = ResourceId::new(resource.to_owned()); + let capability = Capability::new(capability.to_owned()); + Ok(load_bearer_access(store)? + .into_iter() + .any(|access| access.resource == resource && access.capabilities.contains(&capability))) +} + +fn bearer_token_grants_resource_capability( + store: &Store, + token: &str, + resource: &str, + capability: &str, +) -> Result { + let token = geth_types::SecretId::new(token.to_owned()); + let secret = geth_secrets::bearer_id_for_token(&token); + let token_hash = geth_secrets::bearer_token_hash(&token); + let resource = ResourceId::new(resource.to_owned()); + let capability = Capability::new(capability.to_owned()); + Ok(load_bearer_access(store)?.into_iter().any(|access| { + access.secret == secret + && access.resource == resource + && access.capabilities.contains(&capability) + && access.token_hash.as_deref() == Some(token_hash.as_str()) + && access + .expires_at + .is_none_or(|expires_at| expires_at.0 >= geth_store::now_ms()) + })) +} + +fn overlay_networks(store: &Store) -> Result, NodeError> { + store + .list_module_states_with_prefix("overlay:")? + .into_iter() + .map(|state| { + let membership: geth_overlay::OverlayMembership = + serde_json::from_str(&state.state_json)?; + Ok(geth_overlay::membership_status(&membership)) + }) + .collect() +} + +fn overlay_membership( + store: &Store, + name: &str, +) -> Result, NodeError> { + store + .get_module_state(&geth_overlay::overlay_state_key(name))? + .map(|state| serde_json::from_str(&state.state_json).map_err(NodeError::from)) + .transpose() +} + +fn overlay_peers(store: &Store, network: &str) -> Result, NodeError> { + let cidr = overlay_membership(store, network)? + .map(|membership| membership.cidr) + .unwrap_or_else(|| geth_overlay::DEFAULT_OVERLAY_CIDR.to_owned()); + store + .list_peer_cards()? + .into_iter() + .map(|stored| { + let card: PeerCard = serde_json::from_str(&stored.card_json)?; + Ok(OverlayPeer { + node_id: card.node_id.to_string(), + endpoint_id: card + .endpoints + .first() + .map(|candidate| candidate.endpoint_id.clone()), + virtual_ip: Some(geth_overlay::deterministic_virtual_ip( + &cidr, + card.node_id.as_str(), + )?), + state: "candidate-only".to_owned(), + }) + }) + .collect() +} + +fn overlay_peer_for_virtual_ip( + store: &Store, + cidr: &str, + virtual_ip: &str, +) -> Result, NodeError> { + for stored in store.list_peer_cards()? { + let card: PeerCard = serde_json::from_str(&stored.card_json)?; + let peer_ip = geth_overlay::deterministic_virtual_ip(cidr, card.node_id.as_str())?; + if peer_ip == virtual_ip { + return Ok(Some(card.node_id.to_string())); + } + } + Ok(None) +} + +fn overlay_counter( + counters: &Arc>, + update: impl FnOnce(&mut OverlayTunCounters), +) { + if let Ok(mut counters) = counters.lock() { + update(&mut counters); + } +} + +fn overlay_runtime_status( + runtime: &OverlayTunRuntime, +) -> Result { + let counters = runtime + .counters + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)?; + Ok(geth_overlay::OverlayRuntimeStatus { + name: runtime.name.clone(), + interface_name: runtime.interface_name.clone(), + virtual_ip: runtime.virtual_ip.clone(), + cidr: runtime.cidr.clone(), + mtu: runtime.mtu, + started_at_ms: runtime.started_at_ms, + packets_from_tun: counters.packets_from_tun, + packets_to_tun: counters.packets_to_tun, + packets_to_peers: counters.packets_to_peers, + last_error: counters.last_error.clone(), + note: "real TUN/Wintun runtime is active; packets are routed over /geth/overlay/1" + .to_owned(), + }) +} + +fn overlay_runtime_inject( + node: &LocalNode, + network: &str, + packet: Vec, +) -> Result { + let overlays = node + .runtime + .overlays + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)?; + let Some(runtime) = overlays.get(network) else { + return Ok(false); + }; + match runtime.inject_tx.try_send(packet) { + Ok(()) => Ok(true), + Err(error) => { + overlay_counter(&runtime.counters, |counters| { + counters.last_error = Some(format!("queue remote packet for TUN failed: {error}")); + }); + Err(NodeError::OverlayRuntime(format!( + "queue remote packet for TUN failed: {error}" + ))) + } + } +} + +fn record_overlay_packet( + store: &Store, + network: &str, + source_node: &str, + destination_node: &str, + packet_base64: String, + size_bytes: usize, +) -> Result { + let received_at_ms = geth_store::now_ms(); + let packet_bytes = base64::engine::general_purpose::STANDARD + .decode(&packet_base64) + .map_err(|error| NodeError::IrohPeer(format!("invalid overlay packet base64: {error}")))?; + let id = geth_overlay::overlay_packet_id( + network, + source_node, + destination_node, + &packet_bytes, + received_at_ms, + ); + let packet = geth_overlay::OverlayPacket { + id: id.clone(), + network: network.to_owned(), + source_node: source_node.to_owned(), + destination_node: destination_node.to_owned(), + packet_base64, + size_bytes, + received_at_ms, + note: "received over /geth/overlay/1".to_owned(), + }; + store.put_module_state(&StoredModuleState { + module: geth_overlay::overlay_packet_key(network, received_at_ms, &id), + state_json: serde_json::to_string(&packet)?, + updated_at_ms: received_at_ms, + })?; + Ok(packet) +} + +fn overlay_packets( + store: &Store, + network: &str, + drain: bool, +) -> Result, NodeError> { + let states = store.list_module_states_with_prefix(&format!("overlay-packet:{network}:"))?; + let mut packets = Vec::new(); + for state in states { + let packet = serde_json::from_str(&state.state_json)?; + if drain { + store.delete_module_state(&state.module)?; + } + packets.push(packet); + } + Ok(packets) +} + fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> { store.insert_auth_op(&StoredAuthOp { op_id: op.id.to_string(), @@ -10884,6 +11686,13 @@ mod tests { resource: Some("resource:pipe-unix:"), capability: Some("pipe.forward"), }, + RemoteGuardAudit { + operation: "overlay.packet.route", + mutates_or_opens_service: true, + guard: RemoteGuardKind::Capability, + resource: Some("resource:overlay:"), + capability: Some("overlay.route"), + }, RemoteGuardAudit { operation: "ssh.proxy.connect", mutates_or_opens_service: true, @@ -10904,7 +11713,7 @@ mod tests { #[test] fn remote_authorization_audit_matrix_covers_mutating_and_service_operations() { let matrix = remote_guard_audit_matrix(); - assert_eq!(matrix.len(), 21); + assert_eq!(matrix.len(), 22); for item in matrix.iter().filter(|item| item.mutates_or_opens_service) { assert_ne!( item.guard, @@ -11005,6 +11814,13 @@ mod tests { .expect("store auth grant"); } + fn test_ipv4_packet_base64() -> String { + base64::engine::general_purpose::STANDARD.encode([ + 0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x40, 0x00, 64, 1, 0, 0, 172, 22, 0, 1, 172, 22, 0, + 2, + ]) + } + #[test] fn ssh_sync_import_rejects_conflicting_records() { let store = Store::open_memory().expect("open"); @@ -12982,6 +13798,139 @@ mod tests { right_endpoint.shutdown().await; } + #[tokio::test] + async fn overlay_packets_route_over_dedicated_iroh_alpn_with_authorization() { + let left_home = tempfile::tempdir().expect("left home"); + let right_home = tempfile::tempdir().expect("right home"); + let left_paths = GethPaths::from_home(left_home.path()); + let right_paths = GethPaths::from_home(right_home.path()); + let mut left = init_node(&left_paths).expect("init left"); + let mut right = init_node(&right_paths).expect("init right"); + write_offline_iroh_config(&left_paths); + write_offline_iroh_config(&right_paths); + + let Some(left_endpoint) = start_daemon_iroh_endpoint(&mut left) + .await + .expect("left iroh") + else { + eprintln!("skipping overlay packet assertion; left Iroh endpoint unavailable"); + return; + }; + let Some(right_endpoint) = start_daemon_iroh_endpoint(&mut right) + .await + .expect("right iroh") + else { + eprintln!("skipping overlay packet assertion; right Iroh endpoint unavailable"); + left_endpoint.shutdown().await; + return; + }; + spawn_iroh_control_accept_loop(right.clone(), right_endpoint.clone()); + + let exported = handle_request_async(&right, ControlRequest::PeerCardExport { out: None }) + .await + .expect("export right peer card"); + let right_card = match exported { + ControlResponse::PeerCardExported { card, .. } => card, + other => panic!("unexpected export response: {other:?}"), + }; + Store::open(&left_paths.metadata_db()) + .expect("open left store") + .upsert_peer_card(&StoredPeerCard { + peer_id: right_card.node_id.to_string(), + card_json: serde_json::to_string(&right_card).expect("card json"), + updated_at_ms: geth_store::now_ms(), + }) + .expect("insert right peer"); + + handle_request( + &right, + ControlRequest::OverlayJoin { + name: "home".to_owned(), + secret: "local-overlay".to_owned(), + cidr: None, + }, + ) + .expect("right overlay join"); + + let denied = handle_request_async( + &left, + ControlRequest::OverlaySend { + name: "home".to_owned(), + node: right_card.node_id.to_string(), + packet_base64: test_ipv4_packet_base64(), + bearer_secret: None, + }, + ) + .await + .expect("denied overlay packet"); + match denied { + ControlResponse::OverlayPacketSent { + allowed, packet, .. + } => { + assert!(!allowed); + assert!(packet.is_none()); + } + other => panic!("unexpected denied overlay response: {other:?}"), + } + + grant_test_capability( + &Store::open(&right_paths.metadata_db()).expect("open right store"), + &left.node_id, + "resource:overlay:home", + "overlay.route", + ); + let sent = handle_request_async( + &left, + ControlRequest::OverlaySend { + name: "home".to_owned(), + node: right_card.node_id.to_string(), + packet_base64: test_ipv4_packet_base64(), + bearer_secret: None, + }, + ) + .await + .expect("allowed overlay packet"); + match sent { + ControlResponse::OverlayPacketSent { + allowed, + packet, + reason, + note, + .. + } => { + assert!(allowed); + assert!(packet.is_some()); + assert!(reason.contains("direct grant")); + assert!(note.contains("/geth/overlay/1")); + } + other => panic!("unexpected allowed overlay response: {other:?}"), + } + + let received = handle_request( + &right, + ControlRequest::OverlayRecv { + name: "home".to_owned(), + peek: false, + }, + ) + .expect("overlay recv"); + match received { + ControlResponse::OverlayPackets { + packets, drained, .. + } => { + assert!(drained); + assert_eq!(packets.len(), 1); + assert_eq!(packets[0].source_node, left.node_id); + assert_eq!(packets[0].destination_node, right.node_id); + assert_eq!(packets[0].size_bytes, 20); + } + other => panic!("unexpected overlay recv response: {other:?}"), + } + + left_endpoint.shutdown().await; + right_endpoint.shutdown().await; + } + fn test_tree(entries: Vec) -> CasTreeObject { CasTreeObject { version: geth_cas::CAS_TREE_OBJECT_VERSION, diff --git a/crates/geth-overlay/Cargo.toml b/crates/geth-overlay/Cargo.toml index 2570a13..22d91b7 100644 --- a/crates/geth-overlay/Cargo.toml +++ b/crates/geth-overlay/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true [dependencies] +blake3.workspace = true serde.workspace = true thiserror.workspace = true geth-types = { path = "../geth-types" } diff --git a/crates/geth-overlay/src/lib.rs b/crates/geth-overlay/src/lib.rs index a0c6128..baf4636 100644 --- a/crates/geth-overlay/src/lib.rs +++ b/crates/geth-overlay/src/lib.rs @@ -34,6 +34,7 @@ pub struct OverlayPlan { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct OverlayJoinPlan { pub plan: OverlayPlan, + pub network: OverlayNetworkStatus, pub enabled: bool, pub note: String, } @@ -57,6 +58,59 @@ pub struct OverlayNetworkStatus { pub note: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayInterfacePlan { + pub name: String, + pub platform: String, + pub interface_name: String, + pub cidr: String, + pub virtual_ip: Option, + pub requires_privileges: bool, + pub commands: Vec, + pub notes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayRuntimeStatus { + pub name: String, + pub interface_name: String, + pub virtual_ip: String, + pub cidr: String, + pub mtu: u16, + pub started_at_ms: i64, + pub packets_from_tun: u64, + pub packets_to_tun: u64, + pub packets_to_peers: u64, + pub last_error: Option, + pub note: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayPacket { + pub id: String, + pub network: String, + pub source_node: String, + pub destination_node: String, + pub packet_base64: String, + pub size_bytes: usize, + pub received_at_ms: i64, + pub note: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayMembership { + pub name: String, + pub resource: ResourceId, + pub cidr: String, + pub state: OverlayState, + pub local_node_id: String, + pub virtual_ip: Option, + pub secret_fingerprint: String, + pub joined_at_ms: i64, + pub updated_at_ms: i64, + pub packet_runtime: String, +} + #[derive(Debug, thiserror::Error)] pub enum OverlayError { #[error("invalid overlay name `{0}`")] @@ -65,6 +119,12 @@ pub enum OverlayError { InvalidCidr(String), #[error("overlay join requires a non-empty resource secret")] EmptySecret, + #[error("overlay CIDR `{0}` has no usable host addresses")] + NoUsableHostAddress(String), + #[error("invalid IPv4 packet: {0}")] + InvalidIpv4Packet(String), + #[error("unsupported overlay platform `{0}`")] + UnsupportedPlatform(String), } pub fn validate_overlay_name(name: &str) -> Result<(), OverlayError> { @@ -104,11 +164,173 @@ pub fn validate_overlay_secret(secret: &str) -> Result<(), OverlayError> { } } +pub fn validate_ipv4_packet(packet: &[u8]) -> Result<(), OverlayError> { + if packet.len() < 20 { + return Err(OverlayError::InvalidIpv4Packet( + "packet shorter than IPv4 header".to_owned(), + )); + } + if packet[0] >> 4 != 4 { + return Err(OverlayError::InvalidIpv4Packet( + "packet version is not IPv4".to_owned(), + )); + } + let header_len = usize::from(packet[0] & 0x0f) * 4; + if header_len < 20 || header_len > packet.len() { + return Err(OverlayError::InvalidIpv4Packet( + "invalid IPv4 header length".to_owned(), + )); + } + let total_len = u16::from_be_bytes([packet[2], packet[3]]) as usize; + if total_len < header_len || total_len > packet.len() { + return Err(OverlayError::InvalidIpv4Packet( + "invalid IPv4 total length".to_owned(), + )); + } + Ok(()) +} + +pub fn ipv4_source_destination(packet: &[u8]) -> Result<(String, String), OverlayError> { + validate_ipv4_packet(packet)?; + Ok(( + Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]).to_string(), + Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]).to_string(), + )) +} + +#[must_use] +pub fn overlay_state_key(name: &str) -> String { + format!("overlay:{name}") +} + +#[must_use] +pub fn overlay_packet_key(network: &str, received_at_ms: i64, packet_id: &str) -> String { + format!("overlay-packet:{network}:{received_at_ms}:{packet_id}") +} + #[must_use] pub fn overlay_resource_id(name: &str) -> ResourceId { ResourceId::new(format!("resource:overlay:{name}")) } +#[must_use] +pub fn overlay_secret_fingerprint(secret: &str) -> String { + format!("blake3:{}", blake3::hash(secret.as_bytes())) +} + +#[must_use] +pub fn overlay_packet_id( + network: &str, + source_node: &str, + destination_node: &str, + packet: &[u8], + received_at_ms: i64, +) -> String { + format!( + "overlay-packet:{}", + blake3::hash( + format!("{network}\0{source_node}\0{destination_node}\0{received_at_ms}\0").as_bytes() + ) + .to_hex() + ) + &blake3::hash(packet).to_hex()[..16] +} + +pub fn joined_overlay_membership( + name: &str, + cidr: Option<&str>, + local_node_id: &str, + secret: &str, + joined_at_ms: i64, +) -> Result { + validate_overlay_secret(secret)?; + let cidr = cidr.unwrap_or(DEFAULT_OVERLAY_CIDR); + validate_overlay_name(name)?; + validate_overlay_cidr(cidr)?; + Ok(OverlayMembership { + name: name.to_owned(), + resource: overlay_resource_id(name), + cidr: cidr.to_owned(), + state: OverlayState::Joined, + local_node_id: local_node_id.to_owned(), + virtual_ip: Some(deterministic_virtual_ip(cidr, local_node_id)?), + secret_fingerprint: overlay_secret_fingerprint(secret), + joined_at_ms, + updated_at_ms: joined_at_ms, + packet_runtime: + "joined: run `geth overlay up ` to start the explicit TUN/Wintun runtime" + .to_owned(), + }) +} + +pub fn stopped_overlay_membership( + mut membership: OverlayMembership, + updated_at_ms: i64, +) -> OverlayMembership { + membership.state = OverlayState::Stopped; + membership.updated_at_ms = updated_at_ms; + membership.packet_runtime = "inactive: overlay membership stopped".to_owned(); + membership +} + +#[must_use] +pub fn membership_status(membership: &OverlayMembership) -> OverlayNetworkStatus { + OverlayNetworkStatus { + name: membership.name.clone(), + resource: membership.resource.clone(), + cidr: membership.cidr.clone(), + state: membership.state.clone(), + virtual_ip: membership.virtual_ip.clone(), + peers: Vec::new(), + note: membership.packet_runtime.clone(), + } +} + +pub fn interface_plan( + plan: &OverlayPlan, + platform: Option<&str>, + virtual_ip: Option<&str>, +) -> Result { + let platform = platform.unwrap_or(std::env::consts::OS); + let interface_name = format!("geth-{}", interface_slug(&plan.name)); + let prefix = cidr_prefix(&plan.cidr)?; + let virtual_ip = virtual_ip.map(ToOwned::to_owned); + let addr = virtual_ip + .as_ref() + .map(|ip| format!("{ip}/{prefix}")) + .unwrap_or_else(|| format!("/{prefix}")); + let commands = match platform { + "linux" => vec![ + format!("ip tuntap add dev {interface_name} mode tun user "), + format!("ip addr add {addr} dev {interface_name}"), + format!("ip link set {interface_name} up"), + ], + "macos" | "darwin" => vec![ + "open a utun device from the geth daemon process".to_owned(), + format!("ifconfig inet {} {} up", addr, plan.cidr), + ], + "windows" => vec![ + "install or open a Wintun adapter for the current user context".to_owned(), + format!("assign {addr} to the Wintun adapter"), + ], + other => return Err(OverlayError::UnsupportedPlatform(other.to_owned())), + }; + Ok(OverlayInterfacePlan { + name: plan.name.clone(), + platform: platform.to_owned(), + interface_name, + cidr: plan.cidr.clone(), + virtual_ip, + requires_privileges: true, + commands, + notes: vec![ + "generated plan only; run `geth overlay up ` to activate host networking" + .to_owned(), + "activation is explicit and user-scoped".to_owned(), + "all overlay packets remain carried over /geth/overlay/1".to_owned(), + ], + }) +} + pub fn plan_overlay( name: &str, cidr: Option<&str>, @@ -128,7 +350,7 @@ pub fn plan_overlay( CAPABILITY_ADMIN.to_owned(), ], discovery: "future overlay discovery may use mDNS, peer exchange, and resource metadata; discovery remains untrusted".to_owned(), - runtime: "planned only in this prototype; no TUN/Wintun interface is created".to_owned(), + runtime: "explicit opt-in runtime available with `geth overlay up `; creates a TUN/Wintun-style L3 interface".to_owned(), security: vec![ "all overlay packets must be carried over daemon-owned Iroh connections".to_owned(), "knowing an EndpointID or overlay name must not grant overlay access".to_owned(), @@ -137,15 +359,73 @@ pub fn plan_overlay( ], implementation_notes: vec![ "inspired by iroh-lan's Iroh-carried packet overlay".to_owned(), - "future packet runtime should register /geth/overlay/1 on the shared geth Iroh router".to_owned(), - "future host integration may need TUN/Wintun privileges and must remain explicitly opt-in".to_owned(), + "packet runtime registers /geth/overlay/1 on the shared geth Iroh router".to_owned(), + "host integration may need TUN/Wintun privileges and remains explicitly opt-in".to_owned(), ], }) } #[must_use] pub fn overlay_status_note() -> &'static str { - "overlay runtime is scaffolded but inactive; use `geth overlay plan ` to inspect the intended resource and capabilities" + "overlay membership is persisted locally; run `geth overlay up ` to start the explicit TUN/Wintun runtime" +} + +pub fn deterministic_virtual_ip(cidr: &str, seed: &str) -> Result { + let (network, prefix) = parse_ipv4_cidr(cidr)?; + let host_bits = 32_u8.saturating_sub(prefix); + let address = if host_bits == 0 { + network + } else { + let host_space = 1_u64 << host_bits; + if host_space <= 2 { + return Err(OverlayError::NoUsableHostAddress(cidr.to_owned())); + } + let hash = blake3::hash(seed.as_bytes()); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&hash.as_bytes()[..8]); + let value = u64::from_le_bytes(bytes); + let host_offset = 1 + (value % (host_space - 2)); + network + host_offset as u32 + }; + Ok(Ipv4Addr::from(address).to_string()) +} + +pub fn cidr_prefix(cidr: &str) -> Result { + parse_ipv4_cidr(cidr).map(|(_, prefix)| prefix) +} + +fn parse_ipv4_cidr(cidr: &str) -> Result<(u32, u8), OverlayError> { + let (addr, prefix) = cidr + .split_once('/') + .ok_or_else(|| OverlayError::InvalidCidr(cidr.to_owned()))?; + let addr = addr + .parse::() + .map_err(|_| OverlayError::InvalidCidr(cidr.to_owned()))?; + let prefix = prefix + .parse::() + .map_err(|_| OverlayError::InvalidCidr(cidr.to_owned()))?; + if prefix > 32 { + return Err(OverlayError::InvalidCidr(cidr.to_owned())); + } + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Ok((u32::from(addr) & mask, prefix)) +} + +fn interface_slug(name: &str) -> String { + let slug = name + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .take(8) + .collect::(); + if slug.is_empty() { + "net".to_owned() + } else { + slug + } } #[cfg(test)] @@ -177,6 +457,73 @@ mod tests { assert_eq!(plan.cidr, DEFAULT_OVERLAY_CIDR); assert!(plan.capabilities.contains(&CAPABILITY_JOIN.to_owned())); assert!(plan.security.iter().any(|note| note.contains("Iroh"))); - assert!(plan.runtime.contains("planned only")); + assert!(plan.runtime.contains("overlay up")); + } + + #[test] + fn joined_membership_derives_virtual_ip_without_storing_secret() { + let membership = joined_overlay_membership( + "home", + Some("172.22.0.0/24"), + "node:local", + "invite-token", + 42, + ) + .expect("membership"); + assert_eq!( + membership.resource, + ResourceId::new("resource:overlay:home") + ); + assert_eq!(membership.virtual_ip, Some("172.22.0.168".to_owned())); + assert_ne!(membership.secret_fingerprint, "invite-token"); + assert_eq!(membership.joined_at_ms, 42); + + let status = membership_status(&membership); + assert_eq!(status.state, OverlayState::Joined); + assert_eq!(status.virtual_ip, membership.virtual_ip); + } + + #[test] + fn tiny_overlay_cidrs_do_not_assign_hosts() { + assert!( + joined_overlay_membership("tiny", Some("10.0.0.0/31"), "node:local", "secret", 1) + .is_err() + ); + } + + #[test] + fn validates_ipv4_packets() { + let packet = [ + 0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x40, 0x00, 64, 1, 0, 0, 172, 22, 0, 1, 172, 22, 0, + 2, + ]; + validate_ipv4_packet(&packet).expect("valid packet"); + assert!(validate_ipv4_packet(&packet[..10]).is_err()); + let mut bad = packet; + bad[0] = 0x65; + assert!(validate_ipv4_packet(&bad).is_err()); + let (_, destination) = ipv4_source_destination(&packet).expect("addresses"); + assert_eq!(destination, "172.22.0.2"); + } + + #[test] + fn interface_plan_is_generated_without_mutating_host_networking() { + let plan = plan_overlay("home", None, OVERLAY_ALPN).expect("plan"); + let interface = + interface_plan(&plan, Some("linux"), Some("172.22.0.10")).expect("interface plan"); + assert_eq!(interface.interface_name, "geth-home"); + assert!(interface.requires_privileges); + assert!( + interface + .commands + .iter() + .any(|cmd| cmd.contains("ip tuntap")) + ); + assert!( + interface + .notes + .iter() + .any(|note| note.contains("generated")) + ); } } diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 918fc7f..0792150 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -905,6 +905,14 @@ impl Store { } } + pub fn delete_module_state(&self, module: &str) -> Result<(), StoreError> { + self.conn.execute( + "DELETE FROM module_state WHERE module = ?1", + params![module], + )?; + Ok(()) + } + pub fn list_module_states_with_prefix( &self, prefix: &str, @@ -1714,6 +1722,15 @@ mod tests { .len(), 1 ); + store + .delete_module_state("live-sync:node:laptop:ssh-certs") + .expect("delete state"); + assert_eq!( + store + .get_module_state("live-sync:node:laptop:ssh-certs") + .expect("get deleted state"), + None + ); } #[test] diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index ce1438b..0ecd6f7 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -24,6 +24,7 @@ geth-discovery = { path = "../geth-discovery" } geth-iroh = { path = "../geth-iroh" } geth-keychain = { path = "../geth-keychain" } geth-node = { path = "../geth-node" } +geth-overlay = { path = "../geth-overlay" } geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 1043b7f..2799f85 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -1446,7 +1446,135 @@ fn overlay_plan_is_available_without_starting_packet_runtime() { assert_eq!(plan.resource.to_string(), "resource:overlay:home-lan"); assert_eq!(plan.alpn, "/geth/overlay/1"); assert!(plan.capabilities.contains(&"overlay.join".to_owned())); - assert!(plan.runtime.contains("no TUN/Wintun")); + assert!(plan.runtime.contains("overlay up")); + } + other => panic!("unexpected response: {other:?}"), + } +} + +#[test] +fn overlay_join_persists_membership_resource_and_leave_state() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + + let joined = geth_node::handle_request( + &node, + geth_control::ControlRequest::OverlayJoin { + name: "home-lan".to_owned(), + secret: "local-bootstrap-secret".to_owned(), + cidr: Some("172.22.0.0/24".to_owned()), + }, + ) + .expect("overlay join"); + match joined { + geth_control::ControlResponse::OverlayJoined { join } => { + assert!(join.enabled); + assert_eq!(join.plan.resource.to_string(), "resource:overlay:home-lan"); + assert_eq!(join.network.state, geth_overlay::OverlayState::Joined); + assert!(join.network.virtual_ip.is_some()); + assert!(join.note.contains("local bootstrap overlay")); + } + other => panic!("unexpected response: {other:?}"), + } + + let resources = geth_node::handle_request(&node, geth_control::ControlRequest::ResourceList) + .expect("resource list"); + match resources { + geth_control::ControlResponse::ResourceList { resources } => { + assert!(resources.iter().any(|resource| { + resource.id.to_string() == "resource:overlay:home-lan" + && resource.kind == geth_types::ResourceKind::Overlay + })); + } + other => panic!("unexpected response: {other:?}"), + } + + let status = geth_node::handle_request(&node, geth_control::ControlRequest::OverlayStatus) + .expect("overlay status"); + match status { + geth_control::ControlResponse::OverlayStatus { networks, note } => { + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].state, geth_overlay::OverlayState::Joined); + assert!(note.contains("persisted locally")); + } + other => panic!("unexpected response: {other:?}"), + } + + geth_node::handle_request( + &node, + geth_control::ControlRequest::OverlayLeave { + name: "home-lan".to_owned(), + }, + ) + .expect("overlay leave"); + let status = geth_node::handle_request(&node, geth_control::ControlRequest::OverlayStatus) + .expect("overlay status after leave"); + match status { + geth_control::ControlResponse::OverlayStatus { networks, .. } => { + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].state, geth_overlay::OverlayState::Stopped); + } + other => panic!("unexpected response: {other:?}"), + } +} + +#[test] +fn overlay_join_uses_bearer_invite_when_one_exists() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + let resource = "resource:overlay:locked".to_owned(); + + geth_node::handle_request( + &node, + geth_control::ControlRequest::ResourceCreate { + kind: "overlay".to_owned(), + name: "locked".to_owned(), + }, + ) + .expect("create overlay resource"); + let access = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerCreate { + resource: resource.clone(), + capabilities: vec!["overlay.join".to_owned()], + expires_at_ms: None, + }, + ) + .expect("create bearer invite"); + let token = match access { + geth_control::ControlResponse::SecretBearerCreated { access } => { + access.token.expect("private bearer token").to_string() + } + other => panic!("unexpected response: {other:?}"), + }; + + let denied = geth_node::handle_request( + &node, + geth_control::ControlRequest::OverlayJoin { + name: "locked".to_owned(), + secret: "wrong-token".to_owned(), + cidr: None, + }, + ) + .expect_err("bad bearer should be rejected"); + assert!(denied.to_string().contains("overlay join requires")); + + let joined = geth_node::handle_request( + &node, + geth_control::ControlRequest::OverlayJoin { + name: "locked".to_owned(), + secret: token, + cidr: None, + }, + ) + .expect("authorized overlay join"); + match joined { + geth_control::ControlResponse::OverlayJoined { join } => { + assert!(join.enabled); + assert_eq!(join.network.state, geth_overlay::OverlayState::Joined); + assert!(join.note.contains("bearer overlay.join authorization")); } other => panic!("unexpected response: {other:?}"), } diff --git a/docs/adr/0017-optional-iroh-overlay-network.md b/docs/adr/0017-optional-iroh-overlay-network.md index 25d85e6..8ff75f9 100644 --- a/docs/adr/0017-optional-iroh-overlay-network.md +++ b/docs/adr/0017-optional-iroh-overlay-network.md @@ -2,7 +2,7 @@ ## Status -Accepted for prototype scaffolding. +Accepted for prototype implementation. ## Context @@ -25,18 +25,32 @@ misconfigured. ## Decision -Add an optional `overlay` resource kind and reserve `/geth/overlay/1` for a -future Iroh-carried packet overlay. The initial implementation is a typed model -and CLI/control planning surface: +Add an optional `overlay` resource kind and reserve `/geth/overlay/1` for an +Iroh-carried packet overlay. The prototype implementation is a typed model, +CLI/control surface, persisted local membership record, and explicit opt-in +TUN/Wintun-style runtime: - `geth overlay status` - `geth overlay plan [--cidr ]` - `geth overlay join --secret [--cidr ]` +- `geth overlay interface-plan ` +- `geth overlay up [--bearer-secret ] [--mtu ]` +- `geth overlay down ` - `geth overlay leave ` -The prototype does not create TUN/Wintun interfaces, assign virtual IPs, or -route packets. Join/leave are explicit stubs that describe the intended -resource, capabilities, ALPN, and security boundaries. +Join creates or reuses `resource:overlay:`, persists local membership in +`module_state`, assigns a deterministic virtual IP from the overlay CIDR, and +stores only a BLAKE3 fingerprint of the supplied secret. If the resource already +has bearer access with `overlay.join`, join requires a matching bearer token. +This keeps bearer access resource-scoped and does not enroll the caller as a +trusted node. + +`overlay up` creates a real L3 TUN/Wintun-style device through `tun-rs`, assigns +the local virtual IP, reads IPv4 packets from the device, maps destination +overlay IPs to imported peer cards, and routes packets over `/geth/overlay/1`. +The serving daemon requires `overlay.route` before injecting received packets +into an active overlay runtime. If no runtime is active, received packets are +stored for inspection with `overlay recv`. Overlay resources use capabilities: @@ -49,18 +63,15 @@ must not create a second endpoint or non-Iroh transport. ## Consequences -The CLI can now document and test the intended overlay shape without changing -host networking. This keeps the prototype safe to run on development machines -while preserving a clear path toward an iroh-lan-inspired overlay. +The CLI can now document, persist, test, and activate overlay membership. +Activating an overlay mutates host networking and can require privileges, so it +is never automatic daemon startup behavior. Future work must add: -- persisted overlay membership/resource metadata -- resource-secret-backed join authorization -- platform-specific opt-in TUN/Wintun management -- packet routing over `/geth/overlay/1` - peer/IP coordination through trusted resource metadata and untrusted discovery candidates +- stronger platform packaging for Wintun/macOS entitlements where needed No current code claims VPN-grade isolation, forward secrecy, or automatic host network security. diff --git a/docs/architecture.md b/docs/architecture.md index 0cdc526..8334267 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -253,12 +253,26 @@ TUN/Wintun interface are carried over the daemon-owned Iroh endpoint on with `overlay.join`, `overlay.route`, and `overlay.admin` capabilities. The prototype exposes `geth overlay status`, `geth overlay plan `, `geth overlay join --secret `, and `geth overlay leave -` as planning/control stubs only. They validate names, CIDRs, resource -IDs, capabilities, and security notes, but they do not create host network -interfaces, assign virtual IPs, or route packets yet. Future implementation must -remain explicitly opt-in because TUN/Wintun setup may need platform-specific -privileges. Overlay discovery can use mDNS, peer exchange, and resource -metadata, but discovery remains untrusted and cannot grant overlay access. +`. Join creates or reuses `resource:overlay:`, persists local +membership in `module_state`, assigns a deterministic virtual IP from the CIDR, +and stores only a BLAKE3 fingerprint of the supplied secret. If bearer access +already exists for that overlay resource, join requires the supplied secret to +be a bearer token with `overlay.join`; this lets operators create explicit +resource-scoped overlay invites through `geth secret bearer create`. The +`geth overlay interface-plan` generates Linux, macOS, or Windows host-interface +plans for review. `geth overlay up ` is the explicit opt-in that creates a +real L3 TUN/Wintun-style interface through `tun-rs`, assigns the local +deterministic overlay IP, reads IPv4 packets from the interface, maps +destination overlay IPs to imported peer cards, and carries those packets over +the dedicated `/geth/overlay/1` Iroh ALPN. `geth overlay down ` stops the +runtime and drops the device handle. The serving daemon validates the signed +peer card against the observed EndpointID and requires `overlay.route` on +`resource:overlay:` before queuing a received packet into the active +interface; if no runtime is active, the packet is persisted for `geth overlay +recv`. Interface setup may require `CAP_NET_ADMIN`, sudo, a preconfigured +`/dev/net/tun`, Wintun availability, or platform-specific network entitlements. +Overlay discovery can use mDNS, peer exchange, and resource metadata, but +discovery remains untrusted and cannot grant overlay access. `geth-pipe` currently supports `pipe listen/connect/send/recv` against a daemon-lifetime runtime. `geth pipe connect --node ` sends an diff --git a/docs/roadmap.md b/docs/roadmap.md index c114741..d960484 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -584,19 +584,34 @@ Goal: add authorized stream-oriented management workflows over Iroh. `overlay.join`, `overlay.route`, and `overlay.admin`. - `[x]` Add CLI/control commands for `geth overlay status`, `plan`, `join`, and `leave`. - - `[x]` Prototype commands make clear that no TUN/Wintun interface is created - and no packets are routed yet. + - `[x]` Prototype commands make clear that host networking changes require + explicit `overlay up`. - `[x]` Tests cover overlay validation and control serialization. - - `[ ]` Persist overlay network configuration and membership state as + - `[x]` Persist overlay network configuration and membership state as resource metadata. - - `[ ]` Implement resource-authorized overlay join using resource secrets + - `[x]` Implement resource-authorized overlay join using resource secrets without granting node identity. - - `[ ]` Add platform-specific, opt-in TUN/Wintun interface management with + - `[x]` Store only a secret fingerprint in overlay membership state. + - `[x]` Tests cover persisted overlay join/status/leave and bearer-token + enforcement for `overlay.join`. + - `[x]` Add platform-specific, opt-in TUN/Wintun interface plans with generated-definition tests and no privileged test requirements. - - `[ ]` Route IPv4 packets over `/geth/overlay/1` using the shared daemon + - `[x]` Route IPv4 packets over `/geth/overlay/1` using the shared daemon Iroh endpoint. + - `[x]` Validate overlay packet routing with two-node Iroh tests and + `overlay.route` authorization checks. + - `[x]` Add candidate peer listing from untrusted peer-card metadata. + - `[x]` Implement actual TUN/Wintun-style activation as an explicit user + opt-in through `geth overlay up/down`. + - `[x]` Runtime reads validated IPv4 packets from the TUN device, routes them + to imported peer cards by deterministic overlay IP, and injects authorized + remote packets back into the device. + - `[x]` Runtime activation surfaces privilege/setup errors clearly instead of + silently falling back to a non-overlay transport. - `[ ]` Add live peer/IP coordination over trusted resource metadata and untrusted discovery candidates. + - `[ ]` Add packaged Windows Wintun deployment and macOS entitlement guidance + for release builds. - `[~]` Unix socket forwarding where supported. Acceptance criteria: