From 9a5d8ca8ba540f856ea87484742b22f6d4819d3c Mon Sep 17 00:00:00 2001 From: Soma Nakamura Date: Fri, 13 Feb 2026 17:08:17 +0900 Subject: [PATCH] Initial commit --- .gitignore | 3 + Cargo.lock | 2695 +++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 35 + README.md | 287 +++++ flake.lock | 61 + flake.nix | 28 + src/config.rs | 60 + src/control.rs | 573 +++++++++ src/dns_server.rs | 148 +++ src/firewall.rs | 272 +++++ src/keys.rs | 28 + src/l2_relay.rs | 108 ++ src/main.rs | 2774 +++++++++++++++++++++++++++++++++++++++++++ src/model.rs | 314 +++++ src/netlink.rs | 486 ++++++++ src/relay_tunnel.rs | 178 +++ src/router.rs | 167 +++ src/routes.rs | 420 +++++++ src/state.rs | 49 + src/stream_relay.rs | 96 ++ src/stun.rs | 170 +++ src/turn.rs | 495 ++++++++ src/udp_relay.rs | 73 ++ src/wg.rs | 529 +++++++++ 24 files changed, 10049 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/config.rs create mode 100644 src/control.rs create mode 100644 src/dns_server.rs create mode 100644 src/firewall.rs create mode 100644 src/keys.rs create mode 100644 src/l2_relay.rs create mode 100644 src/main.rs create mode 100644 src/model.rs create mode 100644 src/netlink.rs create mode 100644 src/relay_tunnel.rs create mode 100644 src/router.rs create mode 100644 src/routes.rs create mode 100644 src/state.rs create mode 100644 src/stream_relay.rs create mode 100644 src/stun.rs create mode 100644 src/turn.rs create mode 100644 src/udp_relay.rs create mode 100644 src/wg.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..136eccf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +state.json +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..64a83ef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2695 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "boringtun" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc4267b0c97985d9b089b19ff965b959e61870640d2f0842a97552e030fa43f" +dependencies = [ + "aead", + "base64 0.13.1", + "blake2", + "chacha20poly1305", + "hex", + "hmac", + "ip_network", + "ip_network_table", + "libc", + "nix 0.25.1", + "parking_lot", + "rand_core 0.6.4", + "ring", + "socket2 0.4.10", + "thiserror 1.0.69", + "tracing", + "untrusted", + "x25519-dalek", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.5", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.5", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ip_network" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2f047c0a98b2f299aa5d6d7088443570faae494e9ae1305e48be000c9e0eb1" + +[[package]] +name = "ip_network_table" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4099b7cfc5c5e2fe8c5edf3f6f7adf7a714c9cc697534f63a5a5da30397cb2c0" +dependencies = [ + "ip_network", + "ip_network_table-deps-treebitmap", +] + +[[package]] +name = "ip_network_table-deps-treebitmap" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "lightscale-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "boringtun", + "clap", + "dirs", + "ed25519-dalek", + "futures-util", + "hex", + "hickory-proto", + "hmac", + "ipnet", + "md-5", + "netlink-packet-route 0.28.0", + "rand 0.8.5", + "reqwest", + "rtnetlink", + "rustls", + "serde", + "serde_json", + "sha1", + "sha2", + "socket2 0.5.10", + "time", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 0.26.11", + "wireguard-control", + "x25519-dalek", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-generic" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd7eb8ad331c84c6b8cb7f685b448133e5ad82e1ffd5acafac374af4a5a308b" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-core 0.7.0", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483325d4bfef65699214858f097d504eb812c38ce7077d165f301ec406c3066e" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "byteorder", + "libc", + "log", + "netlink-packet-core 0.7.0", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.10.0", + "libc", + "log", + "netlink-packet-core 0.8.1", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror 1.0.69", +] + +[[package]] +name = "netlink-packet-wireguard" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b25b050ff1f6a1e23c6777b72db22790fe5b6b5ccfd3858672587a79876c8f" +dependencies = [ + "anyhow", + "byteorder", + "libc", + "log", + "netlink-packet-generic", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core 0.8.1", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-request" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d83636acdf5aba609ff27a66f6a235f1bcfafe20ccdaad6a5bf9d7dfa64a811" +dependencies = [ + "netlink-packet-core 0.7.0", + "netlink-packet-generic", + "netlink-packet-route 0.21.0", + "netlink-packet-utils", + "netlink-sys", + "nix 0.25.1", + "once_cell", +] + +[[package]] +name = "netlink-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +dependencies = [ + "bytes", + "futures", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.5", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core 0.8.1", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wireguard-control" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a1ff6cde8cce93098564c1020e59da217c87e37b9f1bac3f539a6261a9af128" +dependencies = [ + "base64 0.13.1", + "hex", + "libc", + "log", + "netlink-packet-core 0.7.0", + "netlink-packet-generic", + "netlink-packet-route 0.21.0", + "netlink-packet-utils", + "netlink-packet-wireguard", + "netlink-request", + "netlink-sys", + "nix 0.30.1", + "rand_core 0.6.4", + "x25519-dalek", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5d397ab --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "lightscale-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +base64 = "0.22" +boringtun = { version = "0.7", features = ["device"] } +clap = { version = "4", features = ["derive", "env"] } +dirs = "5" +ed25519-dalek = { version = "2", features = ["rand_core"] } +futures-util = "0.3" +hmac = "0.12" +hickory-proto = "0.24" +hex = "0.4" +ipnet = "2" +md-5 = "0.10" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rustls = "0.23" +rtnetlink = "0.20" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sha1 = "0.10" +socket2 = "0.5" +time = { version = "0.3", features = ["serde", "formatting"] } +tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread", "signal"] } +tokio-rustls = "0.26" +url = "2" +webpki-roots = "0.26" +wireguard-control = "1.7" +x25519-dalek = { version = "2", features = ["static_secrets"] } +netlink-packet-route = "0.28" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0175127 --- /dev/null +++ b/README.md @@ -0,0 +1,287 @@ +# lightscale-client + +Minimal control-plane client for Lightscale. It registers nodes, sends heartbeats, fetches +netmaps, and can manage the WireGuard data plane (kernel or userspace) with basic NAT traversal. + +This client already uses profile-scoped state files so multiple networks can be supported later by +running separate profiles. + +## Configure a profile + +```sh +cargo run -- --profile default init http://127.0.0.1:8080 +``` + +Multiple control URLs (failover): + +```sh +cargo run -- --profile default init http://10.0.0.1:8080,http://10.0.0.1:8081 +``` + +## Register a node + +```sh +cargo run -- --profile default register --node-name laptop +``` + +Register a node with an auth URL flow: + +```sh +cargo run -- --profile default register-url --node-name laptop +``` + +The command prints a one-time approval URL. Open it in a browser (or curl it) to approve the node. + +## Admin actions + +Set an admin token when the control plane is protected (CLI flag or env var): + +```sh +export LIGHTSCALE_ADMIN_TOKEN= +``` + +List nodes in a network (use `--pending` to show only unapproved nodes): + +```sh +cargo run -- --profile default admin nodes --pending +``` + +Update a node's name or tags (admin): + +```sh +cargo run -- --profile default admin node update --name laptop --tags dev,lab +``` + +Clear tags: + +```sh +cargo run -- --profile default admin node update --clear-tags +``` + +Approve a node by ID: + +```sh +cargo run -- --profile default admin approve +``` + +Create an enrollment token: + +```sh +cargo run -- --profile default admin token create --ttl-seconds 3600 --uses 1 --tags lab +``` + +Revoke an enrollment token: + +```sh +cargo run -- --profile default admin token revoke +``` + +## Heartbeat + +```sh +cargo run -- --profile default heartbeat \ + --endpoint 203.0.113.1:51820 \ + --route 192.168.10.0/24 +``` + +Optionally include your WireGuard listen port so the server can add the observed +public endpoint from the heartbeat connection: + +```sh +cargo run -- --profile default heartbeat --listen-port 51820 +``` + +Use STUN to discover a public endpoint (best effort): + +```sh +cargo run -- --profile default heartbeat --stun --stun-server stun.l.google.com:19302 +``` + +Advertise exit node routes: + +```sh +cargo run -- --profile default heartbeat --exit-node +``` + +## Fetch netmap + +```sh +cargo run -- --profile default netmap +``` + +## Show status + +```sh +cargo run -- --profile default status +``` + +Include WireGuard peer info (handshake age + endpoint): + +```sh +cargo run -- --profile default status --wg +``` + +## Configure WireGuard (Linux) + +Bring up an interface using the latest netmap: + +```sh +sudo cargo run -- --profile default wg-up --listen-port 51820 +``` + +Use boringtun (userspace WireGuard) instead of the kernel module: + +```sh +sudo cargo run -- --profile default wg-up --listen-port 51820 --backend boringtun +``` + +This runs the userspace tunnel inside the client process. Keep the command +running (or use `agent`) to keep the tunnel alive. + +Apply advertised subnet/exit routes at the same time: + +```sh +sudo cargo run -- --profile default wg-up --listen-port 51820 --apply-routes --accept-exit-node +``` + +Optionally probe peers to trigger NAT traversal (UDP probe, no ICMP): + +```sh +sudo cargo run -- --profile default wg-up --listen-port 51820 --probe-peers +``` + +Conflicting routes are skipped by default; use `--allow-route-conflicts` to force them. + +Select a specific exit node by ID or name: + +```sh +sudo cargo run -- --profile default wg-up --listen-port 51820 --apply-routes --accept-exit-node \ + --exit-node-id +``` + +Remove the interface: + +```sh +sudo cargo run -- --profile default wg-down +``` + +If you used the boringtun backend, stop the process that created the tunnel +(for example `Ctrl+C` in the foreground or stopping the agent). The command +below attempts to remove the interface if needed. + +```sh +sudo cargo run -- --profile default wg-down --backend boringtun +``` + +## Run the agent loop + +Keep WireGuard and routes updated using long-polling + periodic heartbeats: + +```sh +sudo cargo run -- --profile default agent --listen-port 51820 --apply-routes --accept-exit-node \ + --heartbeat-interval 30 --longpoll-timeout 30 +``` + +Tune endpoint rotation (stale seconds + max rotations before relay fallback): + +```sh +sudo cargo run -- --profile default agent --listen-port 51820 \ + --endpoint-stale-after 15 --endpoint-max-rotations 2 +``` + +Use boringtun backend in the agent: + +```sh +sudo cargo run -- --profile default agent --listen-port 51820 --backend boringtun +``` + +Enable STUN discovery in the agent: + +```sh +sudo cargo run -- --profile default agent --listen-port 51820 --stun \ + --stun-server stun.l.google.com:19302 +``` + +Enable stream relay signaling (peer probe via relay): + +```sh +sudo cargo run -- --profile default agent --listen-port 51820 --stream-relay +``` + +With `--stream-relay`, the agent also maintains local relay tunnels that can be +used as a fallback when direct endpoints stop handshaking. + +Probe peers when netmap updates arrive (UDP probe to endpoints, no ICMP): + +```sh +sudo cargo run -- --profile default agent --listen-port 51820 --probe-peers +``` + +## Enable subnet/exit routing (Linux) + +Configure IP forwarding and (optionally) SNAT for a subnet router or exit node. +This uses nftables via `libmnl`/`libnftnl` (the Nix dev shell installs them): + +```sh +sudo cargo run -- --profile default router enable --interface ls-default --out-interface eth0 +``` + +Disable SNAT to require return routes on the LAN: + +```sh +sudo cargo run -- --profile default router enable --interface ls-default --out-interface eth0 --no-snat +``` + +Remove forwarding/NAT rules: + +```sh +sudo cargo run -- --profile default router disable --interface ls-default --out-interface eth0 +``` + +## DNS and relay info + +Export host-style DNS entries: + +```sh +cargo run -- --profile default dns +``` + +Export DNS info as JSON (debug output): + +```sh +cargo run -- --profile default dns --format json --output /tmp/lightscale-dns.json +``` + +Show relay configuration (STUN/TURN/stream relay/UDP relay): + +```sh +cargo run -- --profile default relay +``` + +## UDP relay (best effort) + +Send a test message via the UDP relay: + +```sh +cargo run -- --profile default relay-udp send "hello" +``` + +Listen for relay messages: + +```sh +cargo run -- --profile default relay-udp listen +``` + +## Stream relay (best effort) + +Send a test message via the stream relay: + +```sh +cargo run -- --profile default relay-stream send "hello" +``` + +Listen for relay messages: + +```sh +cargo run -- --profile default relay-stream listen +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5a9df81 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1768564909, + "narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6d934e7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,28 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = [ + pkgs.rustc + pkgs.cargo + pkgs.rustfmt + pkgs.clippy + pkgs.rust-analyzer + pkgs.iproute2 + pkgs.iputils + pkgs.libmnl + pkgs.libnftnl + pkgs.pkg-config + ]; + }; + }); +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a4681b3 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct ClientConfig { + pub profiles: HashMap, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ProfileConfig { + #[serde(default, deserialize_with = "deserialize_control_urls", alias = "control_url")] + pub control_urls: Vec, + #[serde(default)] + pub tls_pinned_sha256: Option, +} + +fn deserialize_control_urls<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum ControlUrls { + One(String), + Many(Vec), + } + + let raw = Option::::deserialize(deserializer)?; + let mut urls = match raw { + Some(ControlUrls::One(url)) => vec![url], + Some(ControlUrls::Many(urls)) => urls, + None => Vec::new(), + }; + urls.retain(|url| !url.trim().is_empty()); + Ok(urls) +} + +pub fn default_config_path() -> Option { + dirs::config_dir().map(|dir| dir.join("lightscale").join("config.json")) +} + +pub fn load_config(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(contents) => Ok(serde_json::from_str(&contents)?), + Err(_) => Ok(ClientConfig::default()), + } +} + +pub fn save_config(path: &Path, config: &ClientConfig) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let json = serde_json::to_string_pretty(config)?; + std::fs::write(path, json)?; + Ok(()) +} diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..f696c71 --- /dev/null +++ b/src/control.rs @@ -0,0 +1,573 @@ +use crate::model::{ + AclPolicy, AdminNodesResponse, ApproveNodeResponse, AuditLogResponse, CreateTokenRequest, + CreateTokenResponse, EnrollmentToken, HeartbeatRequest, HeartbeatResponse, KeyHistoryResponse, + KeyPolicyResponse, KeyRotationPolicy, KeyRotationRequest, KeyRotationResponse, NetMap, + RegisterRequest, RegisterResponse, RegisterUrlRequest, RegisterUrlResponse, RevokeNodeResponse, + UpdateAclRequest, UpdateAclResponse, UpdateNodeRequest, UpdateNodeResponse, +}; +use anyhow::{anyhow, Context, Result}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::client::WebPkiServerVerifier; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{RootCertStore, SignatureScheme}; +use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +pub struct ControlClient { + base_urls: Vec, + client: reqwest::Client, + next_index: AtomicUsize, + node_token: Option, + admin_token: Option, +} + +impl ControlClient { + pub fn new( + base_urls: Vec, + tls_pin: Option, + node_token: Option, + admin_token: Option, + ) -> Result { + let client = build_http_client(tls_pin)?; + let base_urls = normalize_base_urls(base_urls); + if base_urls.is_empty() { + return Err(anyhow!("no control URL configured")); + } + Ok(Self { + base_urls, + client, + next_index: AtomicUsize::new(0), + node_token, + admin_token, + }) + } + + async fn send_with_failover(&self, build: F) -> Result + where + F: Fn(&reqwest::Client, &str) -> reqwest::RequestBuilder, + { + let total = self.base_urls.len(); + let start = self.next_index.load(Ordering::Relaxed) % total; + let mut last_err: Option = None; + + for offset in 0..total { + let index = (start + offset) % total; + let base = &self.base_urls[index]; + let response = build(&self.client, base).send().await; + match response { + Ok(resp) => { + if resp.status().is_server_error() { + last_err = Some(anyhow!( + "control {} returned {}", + base, + resp.status() + )); + continue; + } + self.next_index.store(index, Ordering::Relaxed); + return Ok(resp); + } + Err(err) => { + if should_retry(&err) { + last_err = Some(anyhow!("control {} request failed: {}", base, err)); + continue; + } + return Err(anyhow!(err).context(format!( + "control {} request failed", + base + ))); + } + } + } + + Err(last_err.unwrap_or_else(|| anyhow!("no control servers available"))) + } + + fn endpoint_at(base: &str, path: &str) -> String { + let base = base.trim_end_matches('/'); + format!("{}{}", base, path) + } + + fn node_auth(&self) -> Option<&str> { + self.node_token.as_deref() + } + + fn admin_auth(&self) -> Option<&str> { + self.admin_token.as_deref() + } + + fn node_or_admin_auth(&self) -> Option<&str> { + self.node_token + .as_deref() + .or_else(|| self.admin_token.as_deref()) + } + + pub async fn register(&self, request: RegisterRequest) -> Result { + let response = self + .send_with_failover(|client, base| { + client + .post(Self::endpoint_at(base, "/v1/register")) + .json(&request) + }) + .await? + .error_for_status() + .context("register request failed")?; + Ok(response.json().await?) + } + + pub async fn register_url(&self, request: RegisterUrlRequest) -> Result { + let response = self + .send_with_failover(|client, base| { + client + .post(Self::endpoint_at(base, "/v1/register-url")) + .json(&request) + }) + .await? + .error_for_status() + .context("register-url request failed")?; + Ok(response.json().await?) + } + + pub async fn create_token( + &self, + network_id: &str, + request: CreateTokenRequest, + ) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .post(Self::endpoint_at( + base, + &format!("/v1/networks/{}/tokens", network_id), + )) + .json(&request), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("create token request failed")?; + Ok(response.json().await?) + } + + pub async fn revoke_token(&self, token_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.post(Self::endpoint_at( + base, + &format!("/v1/tokens/{}/revoke", token_id), + )), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("revoke token request failed")?; + Ok(response.json().await?) + } + + pub async fn approve_node(&self, node_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.post(Self::endpoint_at( + base, + &format!("/v1/admin/nodes/{}/approve", node_id), + )), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("approve node request failed")?; + Ok(response.json().await?) + } + + pub async fn admin_nodes(&self, network_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.get(Self::endpoint_at( + base, + &format!("/v1/admin/networks/{}/nodes", network_id), + )), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("admin nodes request failed")?; + Ok(response.json().await?) + } + + pub async fn update_node( + &self, + node_id: &str, + request: UpdateNodeRequest, + ) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .put(Self::endpoint_at( + base, + &format!("/v1/admin/nodes/{}", node_id), + )) + .json(&request), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("update node request failed")?; + Ok(response.json().await?) + } + + pub async fn get_acl(&self, network_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.get(Self::endpoint_at( + base, + &format!("/v1/networks/{}/acl", network_id), + )), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("acl policy request failed")?; + Ok(response.json().await?) + } + + pub async fn update_acl( + &self, + network_id: &str, + request: UpdateAclRequest, + ) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .put(Self::endpoint_at( + base, + &format!("/v1/networks/{}/acl", network_id), + )) + .json(&request), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("acl policy update failed")?; + Ok(response.json().await?) + } + + pub async fn get_key_policy(&self, network_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.get(Self::endpoint_at( + base, + &format!("/v1/networks/{}/key-policy", network_id), + )), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("key policy request failed")?; + Ok(response.json().await?) + } + + pub async fn update_key_policy( + &self, + network_id: &str, + request: KeyRotationPolicy, + ) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .put(Self::endpoint_at( + base, + &format!("/v1/networks/{}/key-policy", network_id), + )) + .json(&request), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("key policy update failed")?; + Ok(response.json().await?) + } + + pub async fn rotate_keys( + &self, + node_id: &str, + request: KeyRotationRequest, + ) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .post(Self::endpoint_at( + base, + &format!("/v1/nodes/{}/rotate-keys", node_id), + )) + .json(&request), + self.node_or_admin_auth(), + ) + }) + .await? + .error_for_status() + .context("key rotation failed")?; + Ok(response.json().await?) + } + + pub async fn revoke_node(&self, node_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.post(Self::endpoint_at( + base, + &format!("/v1/nodes/{}/revoke", node_id), + )), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("revoke node failed")?; + Ok(response.json().await?) + } + + pub async fn node_keys(&self, node_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.get(Self::endpoint_at( + base, + &format!("/v1/nodes/{}/keys", node_id), + )), + self.node_or_admin_auth(), + ) + }) + .await? + .error_for_status() + .context("key history request failed")?; + Ok(response.json().await?) + } + + pub async fn audit_log( + &self, + network_id: Option<&str>, + node_id: Option<&str>, + limit: Option, + ) -> Result { + let mut params = Vec::new(); + if let Some(network_id) = network_id { + params.push(("network_id", network_id.to_string())); + } + if let Some(node_id) = node_id { + params.push(("node_id", node_id.to_string())); + } + if let Some(limit) = limit { + params.push(("limit", limit.to_string())); + } + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .get(Self::endpoint_at(base, "/v1/audit")) + .query(¶ms), + self.admin_auth(), + ) + }) + .await? + .error_for_status() + .context("audit log request failed")?; + Ok(response.json().await?) + } + + pub async fn heartbeat(&self, request: HeartbeatRequest) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .post(Self::endpoint_at(base, "/v1/heartbeat")) + .json(&request), + self.node_or_admin_auth(), + ) + }) + .await? + .error_for_status() + .context("heartbeat request failed")?; + Ok(response.json().await?) + } + + pub async fn netmap(&self, node_id: &str) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client.get(Self::endpoint_at( + base, + &format!("/v1/netmap/{}", node_id), + )), + self.node_or_admin_auth(), + ) + }) + .await? + .error_for_status() + .context("netmap request failed")?; + Ok(response.json().await?) + } + + pub async fn netmap_longpoll( + &self, + node_id: &str, + since: u64, + timeout_seconds: u64, + ) -> Result { + let response = self + .send_with_failover(|client, base| { + with_bearer( + client + .get(Self::endpoint_at( + base, + &format!("/v1/netmap/{}/longpoll", node_id), + )) + .query(&[ + ("since", since.to_string()), + ("timeout_seconds", timeout_seconds.to_string()), + ]), + self.node_or_admin_auth(), + ) + }) + .await? + .error_for_status() + .context("netmap longpoll request failed")?; + Ok(response.json().await?) + } +} + +fn normalize_base_urls(urls: Vec) -> Vec { + let mut unique = Vec::new(); + for url in urls { + let trimmed = url.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if !unique.contains(&trimmed) { + unique.push(trimmed); + } + } + unique +} + +fn should_retry(err: &reqwest::Error) -> bool { + err.is_connect() || err.is_timeout() || err.is_request() +} + +fn with_bearer( + request: reqwest::RequestBuilder, + token: Option<&str>, +) -> reqwest::RequestBuilder { + if let Some(token) = token { + request.bearer_auth(token) + } else { + request + } +} + +#[derive(Debug)] +struct PinnedServerCertVerifier { + inner: Arc, + pin: Vec, +} + +impl ServerCertVerifier for PinnedServerCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + let verified = self + .inner + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)?; + let mut hasher = Sha256::new(); + hasher.update(end_entity.as_ref()); + let digest = hasher.finalize(); + if digest.as_slice() != self.pin.as_slice() { + return Err(rustls::Error::General("tls pin mismatch".to_string())); + } + Ok(verified) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +fn build_http_client(tls_pin: Option) -> Result { + if let Some(pin) = tls_pin { + let expected = decode_pin(&pin)?; + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let verifier = WebPkiServerVerifier::builder(Arc::new(roots.clone())) + .build() + .map_err(|err| anyhow!("failed to build tls verifier: {}", err))?; + let config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut config = config; + config + .dangerous() + .set_certificate_verifier(Arc::new(PinnedServerCertVerifier { + inner: verifier, + pin: expected, + })); + Ok(reqwest::Client::builder() + .use_preconfigured_tls(config) + .build()?) + } else { + Ok(reqwest::Client::new()) + } +} + +fn decode_pin(pin: &str) -> Result> { + let normalized: String = pin + .chars() + .filter(|ch| !ch.is_whitespace() && *ch != ':') + .collect(); + let bytes = hex::decode(normalized).map_err(|_| anyhow!("invalid tls pin hex"))?; + if bytes.len() != 32 { + return Err(anyhow!("tls pin must be 32 bytes (sha256)")); + } + Ok(bytes) +} diff --git a/src/dns_server.rs b/src/dns_server.rs new file mode 100644 index 0000000..429f201 --- /dev/null +++ b/src/dns_server.rs @@ -0,0 +1,148 @@ +use crate::model::NetMap; +use anyhow::{anyhow, Context, Result}; +use hickory_proto::op::{Message, MessageType, ResponseCode}; +use hickory_proto::rr::rdata::{A, AAAA}; +use hickory_proto::rr::{Name, RData, Record, RecordType}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use tokio::net::UdpSocket; + +const DNS_TTL_SECONDS: u32 = 30; + +pub fn spawn(addr: SocketAddr, netmap: NetMap) -> Result>> { + let state = Arc::new(Mutex::new(netmap)); + let state_task = Arc::clone(&state); + tokio::spawn(async move { + if let Err(err) = serve(addr, state_task).await { + eprintln!("dns server stopped: {}", err); + } + }); + Ok(state) +} + +pub async fn serve(addr: SocketAddr, state: Arc>) -> Result<()> { + let socket = UdpSocket::bind(addr) + .await + .with_context(|| format!("dns listen {} failed", addr))?; + let mut buf = vec![0u8; 512]; + loop { + let (len, peer) = socket.recv_from(&mut buf).await?; + let request = match Message::from_vec(&buf[..len]) { + Ok(msg) => msg, + Err(_) => continue, + }; + let response = build_response(&request, &state)?; + let out = response.to_vec()?; + let _ = socket.send_to(&out, peer).await; + } +} + +pub fn apply_resolver(interface: &str, domain: &str, server: IpAddr) -> Result<()> { + let domain = domain.trim_end_matches('.'); + let routed_domain = format!("~{}", domain); + run_resolvectl(&["dns", interface, &server.to_string()])?; + run_resolvectl(&["domain", interface, &routed_domain])?; + Ok(()) +} + +fn run_resolvectl(args: &[&str]) -> Result<()> { + let output = std::process::Command::new("resolvectl").args(args).output(); + let output = match output { + Ok(output) => output, + Err(err) => return Err(anyhow!("resolvectl failed: {}", err)), + }; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow!("resolvectl failed: {}", stderr.trim())) +} + +fn build_response(request: &Message, state: &Arc>) -> Result { + let mut response = Message::new(); + response.set_id(request.id()); + response.set_message_type(MessageType::Response); + response.set_op_code(request.op_code()); + response.set_recursion_desired(request.recursion_desired()); + response.set_recursion_available(false); + + let netmap = state.lock().map_err(|_| anyhow!("dns state poisoned"))?; + let domain = normalize_name(&netmap.network.dns_domain); + let mut answered = false; + let mut any_within_domain = false; + for query in request.queries() { + response.add_query(query.clone()); + let name = normalize_name(&query.name().to_ascii()); + let within_domain = + name == domain || name.ends_with(&format!(".{}", domain)); + if within_domain { + any_within_domain = true; + } + let Some(addrs) = lookup_name(&netmap, &name) else { + continue; + }; + for addr in addrs { + match (query.query_type(), addr) { + (RecordType::A, IpAddr::V4(_)) => { + response.add_answer(build_record(query.name(), addr)); + answered = true; + } + (RecordType::AAAA, IpAddr::V6(_)) => { + response.add_answer(build_record(query.name(), addr)); + answered = true; + } + (RecordType::ANY, IpAddr::V4(_)) => { + response.add_answer(build_record(query.name(), addr)); + answered = true; + } + (RecordType::ANY, IpAddr::V6(_)) => { + response.add_answer(build_record(query.name(), addr)); + answered = true; + } + _ => {} + } + } + } + let response_code = if answered { + ResponseCode::NoError + } else if any_within_domain { + ResponseCode::NXDomain + } else { + ResponseCode::Refused + }; + response.set_response_code(response_code); + response.set_authoritative(true); + Ok(response) +} + +fn build_record(name: &Name, addr: IpAddr) -> Record { + let rdata = match addr { + IpAddr::V4(v4) => RData::A(A(v4)), + IpAddr::V6(v6) => RData::AAAA(AAAA(v6)), + }; + Record::from_rdata(name.clone(), DNS_TTL_SECONDS, rdata) +} + +fn lookup_name(netmap: &NetMap, name: &str) -> Option> { + let node_name = normalize_name(&netmap.node.dns_name); + if name == node_name { + return Some(vec![ + netmap.node.ipv4.parse().ok()?, + netmap.node.ipv6.parse().ok()?, + ]); + } + for peer in &netmap.peers { + let peer_name = normalize_name(&peer.dns_name); + if name == peer_name { + return Some(vec![ + peer.ipv4.parse().ok()?, + peer.ipv6.parse().ok()?, + ]); + } + } + None +} + +fn normalize_name(name: &str) -> String { + name.trim_end_matches('.').to_lowercase() +} diff --git a/src/firewall.rs b/src/firewall.rs new file mode 100644 index 0000000..fa66ccc --- /dev/null +++ b/src/firewall.rs @@ -0,0 +1,272 @@ +use anyhow::{anyhow, Result}; + +#[cfg(target_os = "linux")] +mod imp { + use super::*; + use ipnet::IpNet; + use std::process::Command; + + const FILTER_TABLE: &str = "lightscale"; + const FILTER_CHAIN: &str = "ls-forward"; + const NAT_TABLE: &str = "lightscale-nat"; + const NAT_CHAIN: &str = "ls-postrouting"; + const MAP_PREROUTING_CHAIN: &str = "ls-map-prerouting"; + const MAP_POSTROUTING_CHAIN: &str = "ls-map-postrouting"; + pub fn reset_tables() -> Result<()> { + if run_nft(&["list", "table", "inet", FILTER_TABLE]).is_ok() { + run_nft(&["delete", "table", "inet", FILTER_TABLE])?; + } + if run_nft(&["list", "table", "ip", NAT_TABLE]).is_ok() { + run_nft(&["delete", "table", "ip", NAT_TABLE])?; + } + Ok(()) + } + + pub fn apply_forwarding_rules(wg_interface: &str, out_interface: &str) -> Result<()> { + ensure_filter_table()?; + ensure_filter_chain()?; + run_nft(&["flush", "chain", "inet", FILTER_TABLE, FILTER_CHAIN])?; + run_nft(&[ + "add", + "rule", + "inet", + FILTER_TABLE, + FILTER_CHAIN, + "iifname", + wg_interface, + "oifname", + out_interface, + "accept", + ])?; + run_nft(&[ + "add", + "rule", + "inet", + FILTER_TABLE, + FILTER_CHAIN, + "iifname", + out_interface, + "oifname", + wg_interface, + "ct", + "state", + "established,related", + "accept", + ])?; + Ok(()) + } + + pub fn apply_snat(out_interface: &str) -> Result<()> { + ensure_nat_table()?; + ensure_nat_chain(NAT_CHAIN, "postrouting", "100")?; + run_nft(&["flush", "chain", "ip", NAT_TABLE, NAT_CHAIN])?; + run_nft(&[ + "add", + "rule", + "ip", + NAT_TABLE, + NAT_CHAIN, + "oifname", + out_interface, + "masquerade", + ])?; + Ok(()) + } + + pub fn apply_netmap( + wg_interface: &str, + _out_interface: &str, + maps: &[(IpNet, IpNet)], + ) -> Result<()> { + if maps.is_empty() { + return Ok(()); + } + ensure_nat_table()?; + ensure_nat_chain(MAP_PREROUTING_CHAIN, "prerouting", "-100")?; + ensure_nat_chain(MAP_POSTROUTING_CHAIN, "postrouting", "90")?; + run_nft(&["flush", "chain", "ip", NAT_TABLE, MAP_PREROUTING_CHAIN])?; + run_nft(&["flush", "chain", "ip", NAT_TABLE, MAP_POSTROUTING_CHAIN])?; + for (real, mapped) in maps { + let (real, mapped) = match (real, mapped) { + (IpNet::V4(real), IpNet::V4(mapped)) => (real, mapped), + _ => { + return Err(anyhow!( + "netmap only supports IPv4 prefixes in this build" + )) + } + }; + let prefix_len = mapped.prefix_len(); + let host_mask = ipv4_host_mask(prefix_len); + let mapped_base = mapped.network(); + let real_base = real.network(); + run_nft(&[ + "add", + "rule", + "ip", + NAT_TABLE, + MAP_PREROUTING_CHAIN, + "iifname", + wg_interface, + "ip", + "daddr", + &mapped.to_string(), + "dnat", + "to", + "ip", + "daddr", + "&", + &host_mask.to_string(), + "|", + &real_base.to_string(), + ])?; + run_nft(&[ + "add", + "rule", + "ip", + NAT_TABLE, + MAP_POSTROUTING_CHAIN, + "oifname", + wg_interface, + "ip", + "saddr", + &real.to_string(), + "snat", + "to", + "ip", + "saddr", + "&", + &host_mask.to_string(), + "|", + &mapped_base.to_string(), + ])?; + } + Ok(()) + } + + fn ensure_filter_table() -> Result<()> { + if run_nft(&["list", "table", "inet", FILTER_TABLE]).is_ok() { + return Ok(()); + } + run_nft(&["add", "table", "inet", FILTER_TABLE])?; + Ok(()) + } + + fn ensure_filter_chain() -> Result<()> { + if run_nft(&["list", "chain", "inet", FILTER_TABLE, FILTER_CHAIN]).is_ok() { + return Ok(()); + } + run_nft(&[ + "add", + "chain", + "inet", + FILTER_TABLE, + FILTER_CHAIN, + "{", + "type", + "filter", + "hook", + "forward", + "priority", + "10", + ";", + "policy", + "drop", + ";", + "}", + ])?; + Ok(()) + } + + fn ensure_nat_table() -> Result<()> { + if run_nft(&["list", "table", "ip", NAT_TABLE]).is_ok() { + return Ok(()); + } + run_nft(&["add", "table", "ip", NAT_TABLE])?; + Ok(()) + } + + fn ensure_nat_chain(name: &str, hook: &str, priority: &str) -> Result<()> { + if run_nft(&["list", "chain", "ip", NAT_TABLE, name]).is_ok() { + return Ok(()); + } + run_nft(&[ + "add", + "chain", + "ip", + NAT_TABLE, + name, + "{", + "type", + "nat", + "hook", + hook, + "priority", + priority, + ";", + "policy", + "accept", + ";", + "}", + ])?; + Ok(()) + } + + fn ipv4_host_mask(prefix_len: u8) -> std::net::Ipv4Addr { + if prefix_len >= 32 { + return std::net::Ipv4Addr::from(0); + } + let mask = if prefix_len == 0 { + u32::MAX + } else { + u32::MAX >> prefix_len + }; + std::net::Ipv4Addr::from(mask) + } + + fn run_nft(args: &[&str]) -> Result<()> { + let output = Command::new("nft").args(args).output(); + let output = match output { + Ok(output) => output, + Err(err) => return Err(anyhow!("nft command failed: {}", err)), + }; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow!( + "nft command failed: {}", + stderr.trim().to_string() + )) + } +} + +#[cfg(target_os = "linux")] +pub use imp::{apply_forwarding_rules, apply_netmap, apply_snat, reset_tables}; + +#[cfg(not(target_os = "linux"))] +mod imp { + use super::*; + + pub fn reset_tables() -> Result<()> { + Err(anyhow!("router firewall is only supported on linux")) + } + + pub fn apply_forwarding_rules(_wg_interface: &str, _out_interface: &str) -> Result<()> { + Err(anyhow!("router firewall is only supported on linux")) + } + + pub fn apply_snat(_out_interface: &str) -> Result<()> { + Err(anyhow!("router firewall is only supported on linux")) + } + + pub fn apply_netmap( + _wg_interface: &str, + _out_interface: &str, + _maps: &[(ipnet::IpNet, ipnet::IpNet)], + ) -> Result<()> { + Err(anyhow!("router firewall is only supported on linux")) + } +} + +#[cfg(not(target_os = "linux"))] +pub use imp::{apply_forwarding_rules, apply_netmap, apply_snat, reset_tables}; diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..a9b596f --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,28 @@ +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use ed25519_dalek::SigningKey; +use rand::rngs::OsRng; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub struct KeyPair { + pub private_key: String, + pub public_key: String, +} + +pub fn generate_machine_keys() -> KeyPair { + let signing = SigningKey::generate(&mut OsRng); + let verifying = signing.verifying_key(); + KeyPair { + private_key: STANDARD.encode(signing.to_bytes()), + public_key: STANDARD.encode(verifying.to_bytes()), + } +} + +pub fn generate_wg_keys() -> KeyPair { + let secret = StaticSecret::random_from_rng(&mut OsRng); + let public = PublicKey::from(&secret); + KeyPair { + private_key: STANDARD.encode(secret.to_bytes()), + public_key: STANDARD.encode(public.to_bytes()), + } +} diff --git a/src/l2_relay.rs b/src/l2_relay.rs new file mode 100644 index 0000000..67f0129 --- /dev/null +++ b/src/l2_relay.rs @@ -0,0 +1,108 @@ +use crate::model::NetMap; +use anyhow::{anyhow, Context, Result}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use tokio::net::UdpSocket; + +const MDNS_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); +const MDNS_PORT: u16 = 5353; +const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); +const SSDP_PORT: u16 = 1900; +const RELAY_OFFSET: u16 = 10000; + +pub fn spawn(wg_ipv4: Ipv4Addr, netmap: NetMap) -> Result>> { + let state = Arc::new(Mutex::new(netmap)); + let mdns_state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(err) = relay_group(MDNS_GROUP, MDNS_PORT, wg_ipv4, mdns_state).await { + eprintln!("l2 relay mdns stopped: {}", err); + } + }); + let ssdp_state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(err) = relay_group(SSDP_GROUP, SSDP_PORT, wg_ipv4, ssdp_state).await { + eprintln!("l2 relay ssdp stopped: {}", err); + } + }); + Ok(state) +} + +async fn relay_group( + group: Ipv4Addr, + port: u16, + wg_ipv4: Ipv4Addr, + state: Arc>, +) -> Result<()> { + let relay_port = port.saturating_add(RELAY_OFFSET); + let local = build_multicast_socket(port, group, wg_ipv4)?; + let relay = UdpSocket::bind(SocketAddr::new( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + relay_port, + )) + .await + .with_context(|| format!("l2 relay bind {} failed", relay_port))?; + let mut buf_local = vec![0u8; 2048]; + let mut buf_relay = vec![0u8; 2048]; + loop { + tokio::select! { + recv = local.recv_from(&mut buf_local) => { + let (len, src) = recv?; + if src.port() == relay_port { + continue; + } + let peers = peers_from_state(&state, wg_ipv4); + for peer in peers { + let target = SocketAddr::new(IpAddr::V4(peer), relay_port); + let _ = relay.send_to(&buf_local[..len], target).await; + } + } + recv = relay.recv_from(&mut buf_relay) => { + let (len, _) = recv?; + let target = SocketAddr::new(IpAddr::V4(group), port); + let _ = local.send_to(&buf_relay[..len], target).await; + } + } + } +} + +fn peers_from_state(state: &Arc>, self_ip: Ipv4Addr) -> Vec { + let guard = match state.lock() { + Ok(guard) => guard, + Err(_) => return Vec::new(), + }; + guard + .peers + .iter() + .filter_map(|peer| peer.ipv4.parse().ok()) + .filter(|ip| *ip != self_ip) + .collect() +} + +fn build_multicast_socket(port: u16, group: Ipv4Addr, iface: Ipv4Addr) -> Result { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) + .context("l2 relay socket create failed")?; + socket + .set_reuse_address(true) + .context("l2 relay reuseaddr failed")?; + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port); + socket.bind(&addr.into()).context("l2 relay bind failed")?; + socket + .join_multicast_v4(&group, &iface) + .context("l2 relay multicast join failed")?; + socket + .set_multicast_loop_v4(true) + .context("l2 relay multicast loop failed")?; + socket + .set_nonblocking(true) + .context("l2 relay socket nonblocking failed")?; + let std_socket: std::net::UdpSocket = socket.into(); + UdpSocket::from_std(std_socket).context("l2 relay tokio socket failed") +} + +#[allow(dead_code)] +fn ensure_ipv4(value: &str) -> Result { + value + .parse() + .map_err(|_| anyhow!("invalid ipv4 address {}", value)) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f169003 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,2774 @@ +mod config; +mod control; +mod dns_server; +mod firewall; +mod keys; +mod l2_relay; +mod model; +mod netlink; +mod relay_tunnel; +mod router; +mod routes; +mod state; +mod stun; +mod stream_relay; +mod turn; +mod udp_relay; +mod wg; + +use anyhow::{anyhow, Context, Result}; +use clap::{ArgAction, Parser, Subcommand}; +use config::{default_config_path, load_config, save_config, ClientConfig, ProfileConfig}; +use control::ControlClient; +use ipnet::IpNet; +use model::{HeartbeatRequest, Route, RouteKind}; +use sha2::{Digest, Sha256}; +use state::{default_state_dir, load_state, save_state, state_path, ClientState}; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use time::OffsetDateTime; +use tokio::net::{TcpStream, UdpSocket}; +use tokio::time::sleep; + +#[derive(Parser, Debug)] +#[command(name = "lightscale-client")] +struct Args { + #[arg(long, default_value = "default")] + profile: String, + #[arg(long)] + config: Option, + #[arg(long)] + state_dir: Option, + #[arg(long, value_name = "URL", value_delimiter = ',', action = ArgAction::Append)] + control_url: Vec, + #[arg(long)] + tls_pin: Option, + #[arg(long, env = "LIGHTSCALE_ADMIN_TOKEN")] + admin_token: Option, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + Init { + #[arg(value_name = "URL", value_delimiter = ',')] + control_url: Vec, + }, + Pin { + #[arg(long)] + force: bool, + }, + Register { + token: String, + #[arg(long)] + node_name: Option, + }, + RegisterUrl { + network_id: String, + #[arg(long)] + node_name: Option, + #[arg(long)] + ttl_seconds: Option, + }, + Admin { + #[command(subcommand)] + command: AdminCommand, + }, + Heartbeat { + #[arg(long, value_name = "ENDPOINT")] + endpoint: Vec, + #[arg(long)] + listen_port: Option, + #[arg(long, value_name = "PREFIX")] + route: Vec, + #[arg(long, value_name = "REAL=MAPPED")] + route_map: Vec, + #[arg(long)] + exit_node: bool, + #[arg(long)] + stun: bool, + #[arg(long, value_name = "HOST:PORT", value_delimiter = ',')] + stun_server: Vec, + #[arg(long)] + stun_port: Option, + #[arg(long, default_value_t = 3)] + stun_timeout: u64, + }, + Netmap, + Status { + #[arg(long)] + wg: bool, + #[arg(long)] + interface: Option, + #[arg(long, value_enum, default_value = "kernel")] + backend: WgBackend, + }, + RotateKeys { + #[arg(long)] + machine: bool, + #[arg(long)] + wg: bool, + }, + WgUp { + #[arg(long)] + interface: Option, + #[arg(long, default_value_t = 51820)] + listen_port: u16, + #[arg(long, value_enum, default_value = "kernel")] + backend: WgBackend, + #[arg(long)] + apply_routes: bool, + #[arg(long)] + accept_exit_node: bool, + #[arg(long)] + exit_node_id: Option, + #[arg(long)] + exit_node_name: Option, + #[arg(long, value_enum, default_value = "first")] + exit_node_policy: ExitNodePolicyArg, + #[arg(long)] + exit_node_tag: Option, + #[arg(long)] + exit_node_metric_base: Option, + #[arg(long, value_name = "UID_OR_RANGE")] + exit_node_uid_range: Option, + #[arg(long)] + allow_route_conflicts: bool, + #[arg(long)] + route_table: Option, + #[arg(long)] + probe_peers: bool, + #[arg(long, default_value_t = 1)] + probe_timeout: u64, + }, + WgDown { + #[arg(long)] + interface: Option, + #[arg(long, value_enum, default_value = "kernel")] + backend: WgBackend, + }, + Agent { + #[arg(long)] + interface: Option, + #[arg(long, default_value_t = 51820)] + listen_port: u16, + #[arg(long)] + apply_routes: bool, + #[arg(long)] + accept_exit_node: bool, + #[arg(long)] + exit_node_id: Option, + #[arg(long)] + exit_node_name: Option, + #[arg(long, value_enum, default_value = "first")] + exit_node_policy: ExitNodePolicyArg, + #[arg(long)] + exit_node_tag: Option, + #[arg(long)] + exit_node_metric_base: Option, + #[arg(long, value_name = "UID_OR_RANGE")] + exit_node_uid_range: Option, + #[arg(long)] + allow_route_conflicts: bool, + #[arg(long)] + route_table: Option, + #[arg(long, value_name = "ENDPOINT")] + endpoint: Vec, + #[arg(long, value_name = "PREFIX")] + advertise_route: Vec, + #[arg(long, value_name = "REAL=MAPPED")] + advertise_map: Vec, + #[arg(long)] + advertise_exit_node: bool, + #[arg(long, default_value_t = 30)] + heartbeat_interval: u64, + #[arg(long, default_value_t = 30)] + longpoll_timeout: u64, + #[arg(long, value_enum, default_value = "kernel")] + backend: WgBackend, + #[arg(long)] + stun: bool, + #[arg(long, value_name = "HOST:PORT", value_delimiter = ',')] + stun_server: Vec, + #[arg(long)] + stun_port: Option, + #[arg(long, default_value_t = 3)] + stun_timeout: u64, + #[arg(long)] + probe_peers: bool, + #[arg(long, default_value_t = 1)] + probe_timeout: u64, + #[arg(long)] + stream_relay: bool, + #[arg(long, value_name = "HOST:PORT", value_delimiter = ',')] + stream_relay_server: Vec, + #[arg(long, default_value_t = 15)] + endpoint_stale_after: u64, + #[arg(long, default_value_t = 2)] + endpoint_max_rotations: u64, + #[arg(long)] + dns_hosts_path: Option, + #[arg(long)] + dns_serve: bool, + #[arg(long)] + dns_listen: Option, + #[arg(long)] + dns_apply_resolver: bool, + #[arg(long)] + l2_relay: bool, + }, + Router { + #[command(subcommand)] + command: RouterCommand, + }, + RelayUdp { + #[command(subcommand)] + command: RelayUdpCommand, + }, + RelayStream { + #[command(subcommand)] + command: RelayStreamCommand, + }, + RelayTurn { + #[command(subcommand)] + command: RelayTurnCommand, + }, + Dns { + #[arg(long, value_enum, default_value = "hosts")] + format: DnsFormat, + #[arg(long)] + output: Option, + #[arg(long)] + apply_hosts: bool, + #[arg(long)] + hosts_path: Option, + }, + DnsServe { + #[arg(long, default_value = "127.0.0.1:53")] + listen: String, + #[arg(long)] + apply_resolver: bool, + #[arg(long)] + interface: Option, + }, + Relay, +} + +#[derive(clap::ValueEnum, Debug, Clone)] +enum DnsFormat { + Hosts, + Json, +} + +#[derive(clap::ValueEnum, Debug, Clone, Copy)] +enum WgBackend { + Kernel, + Boringtun, +} + +impl From for wg::Backend { + fn from(value: WgBackend) -> Self { + match value { + WgBackend::Kernel => wg::Backend::Kernel, + WgBackend::Boringtun => wg::Backend::Boringtun, + } + } +} + +#[derive(clap::ValueEnum, Debug, Clone, Copy)] +enum ExitNodePolicyArg { + First, + Latest, + Multi, +} + +impl From for routes::ExitNodePolicy { + fn from(value: ExitNodePolicyArg) -> Self { + match value { + ExitNodePolicyArg::First => routes::ExitNodePolicy::First, + ExitNodePolicyArg::Latest => routes::ExitNodePolicy::Latest, + ExitNodePolicyArg::Multi => routes::ExitNodePolicy::Multi, + } + } +} + +#[derive(Subcommand, Debug)] +enum RouterCommand { + Enable { + #[arg(long)] + interface: Option, + #[arg(long)] + out_interface: Option, + #[arg(long, value_name = "REAL=MAPPED")] + map: Vec, + #[arg(long)] + no_snat: bool, + }, + Disable { + #[arg(long)] + interface: Option, + #[arg(long)] + out_interface: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum RelayUdpCommand { + Send { + peer_id: String, + message: String, + #[arg(long)] + server: Option, + #[arg(long, default_value_t = 3)] + timeout: u64, + }, + Listen { + #[arg(long)] + server: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum RelayStreamCommand { + Send { + peer_id: String, + message: String, + #[arg(long)] + server: Option, + }, + Listen { + #[arg(long)] + server: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum RelayTurnCommand { + Send { + peer_addr: String, + message: String, + #[arg(long)] + server: Option, + #[arg(long)] + username: Option, + #[arg(long)] + password: Option, + #[arg(long, default_value_t = 3)] + timeout: u64, + }, + Listen { + #[arg(long)] + server: Option, + #[arg(long)] + username: Option, + #[arg(long)] + password: Option, + #[arg(long)] + peer_addr: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum AdminCommand { + Node { + #[command(subcommand)] + command: AdminNodeCommand, + }, + Nodes { + network_id: String, + #[arg(long)] + pending: bool, + }, + Approve { + node_id: String, + }, + Token { + #[command(subcommand)] + command: AdminTokenCommand, + }, + Acl { + #[command(subcommand)] + command: AdminAclCommand, + }, + KeyPolicy { + #[command(subcommand)] + command: AdminKeyPolicyCommand, + }, + Keys { + #[command(subcommand)] + command: AdminKeysCommand, + }, + Audit { + #[arg(long)] + network_id: Option, + #[arg(long)] + node_id: Option, + #[arg(long)] + limit: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum AdminTokenCommand { + Create { + network_id: String, + #[arg(long, default_value_t = 3600)] + ttl_seconds: u64, + #[arg(long, default_value_t = 1)] + uses: u32, + #[arg(long, value_delimiter = ',')] + tags: Vec, + }, + Revoke { + token: String, + }, +} + +#[derive(Subcommand, Debug)] +enum AdminNodeCommand { + Update { + node_id: String, + #[arg(long)] + name: Option, + #[arg(long, value_delimiter = ',')] + tags: Vec, + #[arg(long)] + clear_tags: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum AdminAclCommand { + Get { + network_id: String, + }, + Set { + network_id: String, + #[arg(long)] + file: Option, + #[arg(long)] + json: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum AdminKeyPolicyCommand { + Get { + network_id: String, + }, + Set { + network_id: String, + #[arg(long)] + max_age_seconds: Option, + #[arg(long)] + clear: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum AdminKeysCommand { + Rotate { + node_id: String, + #[arg(long)] + machine_public_key: Option, + #[arg(long)] + wg_public_key: Option, + }, + History { + node_id: String, + }, + Revoke { + node_id: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + match &args.command { + Command::Init { control_url } => { + let config_path = resolve_config_path(&args)?; + let mut config = load_config(&config_path)?; + let control_urls = normalize_control_urls(control_url.clone()); + if control_urls.is_empty() { + return Err(anyhow!("control URL not set; provide at least one URL")); + } + config + .profiles + .insert( + args.profile.clone(), + ProfileConfig { + control_urls, + tls_pinned_sha256: None, + }, + ); + save_config(&config_path, &config)?; + println!("saved config for profile {}", args.profile); + } + Command::Pin { force } => { + let config_path = resolve_config_path(&args)?; + let mut config = load_config(&config_path)?; + let control_urls = resolve_control_urls(&args, Some(&config))?; + let profile = config + .profiles + .entry(args.profile.clone()) + .or_insert(ProfileConfig { + control_urls: control_urls.clone(), + tls_pinned_sha256: None, + }); + profile.control_urls = control_urls.clone(); + if profile.tls_pinned_sha256.is_some() && !*force { + return Err(anyhow!( + "tls pin already set; use --force to overwrite" + )); + } + let pin = fetch_server_fingerprint_any(&control_urls).await?; + profile.tls_pinned_sha256 = Some(pin.clone()); + save_config(&config_path, &config)?; + println!("tls pin saved: {}", pin); + } + Command::Register { token, node_name } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let admin_token = resolve_admin_token(&args); + let state_path = resolve_state_path(&args)?; + + if load_state(&state_path)?.is_some() { + return Err(anyhow!("state already exists for profile {}", args.profile)); + } + + let machine_keys = keys::generate_machine_keys(); + let wg_keys = keys::generate_wg_keys(); + let node_name = node_name.clone().unwrap_or_else(default_node_name); + + let client = + ControlClient::new(control_urls.clone(), tls_pin.clone(), None, admin_token)?; + let response = client + .register(model::RegisterRequest { + token: token.clone(), + node_name: node_name.clone(), + machine_public_key: machine_keys.public_key.clone(), + wg_public_key: wg_keys.public_key.clone(), + }) + .await + .context("register failed")?; + + let now = now_unix(); + let netmap = response.netmap; + let state = ClientState { + profile: args.profile.clone(), + network_id: netmap.network.id.clone(), + node_id: netmap.node.id.clone(), + node_name, + machine_private_key: machine_keys.private_key, + machine_public_key: machine_keys.public_key, + wg_private_key: wg_keys.private_key, + wg_public_key: wg_keys.public_key, + node_token: Some(response.node_token), + ipv4: netmap.node.ipv4.clone(), + ipv6: netmap.node.ipv6.clone(), + last_netmap: Some(netmap), + updated_at: now, + }; + + save_state(&state_path, &state)?; + if state + .last_netmap + .as_ref() + .map(|netmap| netmap.node.approved) + .unwrap_or(false) + { + println!( + "registered node {} on network {}", + state.node_id, state.network_id + ); + } else { + println!( + "registered node {} on network {} (pending approval)", + state.node_id, state.network_id + ); + } + } + Command::RegisterUrl { + network_id, + node_name, + ttl_seconds, + } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let admin_token = resolve_admin_token(&args); + let state_path = resolve_state_path(&args)?; + + if load_state(&state_path)?.is_some() { + return Err(anyhow!("state already exists for profile {}", args.profile)); + } + + let machine_keys = keys::generate_machine_keys(); + let wg_keys = keys::generate_wg_keys(); + let node_name = node_name.clone().unwrap_or_else(default_node_name); + + let client = ControlClient::new(control_urls.clone(), tls_pin, None, admin_token)?; + let response = client + .register_url(model::RegisterUrlRequest { + network_id: network_id.clone(), + node_name: node_name.clone(), + machine_public_key: machine_keys.public_key.clone(), + wg_public_key: wg_keys.public_key.clone(), + ttl_seconds: *ttl_seconds, + }) + .await + .context("register-url failed")?; + + let now = now_unix(); + let state = ClientState { + profile: args.profile.clone(), + network_id: response.network_id.clone(), + node_id: response.node_id.clone(), + node_name, + machine_private_key: machine_keys.private_key, + machine_public_key: machine_keys.public_key, + wg_private_key: wg_keys.private_key, + wg_public_key: wg_keys.public_key, + node_token: Some(response.node_token.clone()), + ipv4: response.ipv4.clone(), + ipv6: response.ipv6.clone(), + last_netmap: None, + updated_at: now, + }; + + save_state(&state_path, &state)?; + let auth_urls: Vec = control_urls + .iter() + .map(|base| format!("{}{}", base.trim_end_matches('/'), response.auth_path)) + .collect(); + println!("registered node {} (pending approval)", response.node_id); + if auth_urls.len() == 1 { + println!("open this URL to approve: {}", auth_urls[0]); + } else { + println!("open one of these URLs to approve:"); + for url in auth_urls { + println!(" {}", url); + } + } + } + Command::Admin { command } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let admin_token = resolve_admin_token(&args); + let client = + ControlClient::new(control_urls.clone(), tls_pin.clone(), None, admin_token)?; + match command { + AdminCommand::Node { command } => match command { + AdminNodeCommand::Update { + node_id, + name, + tags, + clear_tags, + } => { + let tags = if *clear_tags { + Some(Vec::new()) + } else if !tags.is_empty() { + Some(tags.clone()) + } else { + None + }; + if name.is_none() && tags.is_none() { + return Err(anyhow!( + "no fields specified; use --name, --tags, or --clear-tags" + )); + } + let response = client + .update_node( + node_id, + model::UpdateNodeRequest { + name: name.clone(), + tags, + }, + ) + .await + .context("update node failed")?; + let node = response.node; + println!( + "node {} name={} tags={:?}", + node.id, node.name, node.tags + ); + } + }, + AdminCommand::Nodes { network_id, pending } => { + let response = client + .admin_nodes(network_id) + .await + .context("admin nodes failed")?; + let now = now_unix(); + for node in response.nodes { + if *pending && node.approved { + continue; + } + let age = now.saturating_sub(node.last_seen); + println!( + "node {} name={} approved={} last_seen={}s", + node.id, node.name, node.approved, age + ); + } + } + AdminCommand::Approve { node_id } => { + let response = client + .approve_node(node_id) + .await + .context("approve node failed")?; + println!( + "node {} approved={} approved_at={}", + response.node_id, + response.approved, + response + .approved_at + .map(|ts| ts.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); + } + AdminCommand::Token { command } => match command { + AdminTokenCommand::Create { + network_id, + ttl_seconds, + uses, + tags, + } => { + let response = client + .create_token( + network_id, + model::CreateTokenRequest { + ttl_seconds: *ttl_seconds, + uses: *uses, + tags: tags.clone(), + }, + ) + .await + .context("create token failed")?; + println!("token: {}", response.token.token); + println!("expires_at: {}", response.token.expires_at); + println!("uses_left: {}", response.token.uses_left); + if !response.token.tags.is_empty() { + println!("tags: {}", response.token.tags.join(",")); + } + } + AdminTokenCommand::Revoke { token } => { + let response = client + .revoke_token(token) + .await + .context("revoke token failed")?; + println!("token: {}", response.token); + println!( + "revoked_at: {}", + response + .revoked_at + .map(|ts| ts.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); + } + }, + AdminCommand::Acl { command } => match command { + AdminAclCommand::Get { network_id } => { + let policy = client + .get_acl(network_id) + .await + .context("fetch acl policy failed")?; + println!("{}", serde_json::to_string_pretty(&policy)?); + } + AdminAclCommand::Set { + network_id, + file, + json, + } => { + let policy = load_acl_policy(file.as_ref(), json.as_ref())?; + let response = client + .update_acl( + network_id, + model::UpdateAclRequest { policy }, + ) + .await + .context("update acl policy failed")?; + println!("{}", serde_json::to_string_pretty(&response.policy)?); + } + }, + AdminCommand::KeyPolicy { command } => match command { + AdminKeyPolicyCommand::Get { network_id } => { + let response = client + .get_key_policy(network_id) + .await + .context("fetch key policy failed")?; + println!("{}", serde_json::to_string_pretty(&response.policy)?); + } + AdminKeyPolicyCommand::Set { + network_id, + max_age_seconds, + clear, + } => { + let policy = if *clear { + model::KeyRotationPolicy { max_age_seconds: None } + } else { + model::KeyRotationPolicy { + max_age_seconds: *max_age_seconds, + } + }; + let response = client + .update_key_policy(network_id, policy) + .await + .context("update key policy failed")?; + println!("{}", serde_json::to_string_pretty(&response.policy)?); + } + }, + AdminCommand::Keys { command } => match command { + AdminKeysCommand::Rotate { + node_id, + machine_public_key, + wg_public_key, + } => { + let response = client + .rotate_keys( + node_id, + model::KeyRotationRequest { + machine_public_key: machine_public_key.clone(), + wg_public_key: wg_public_key.clone(), + }, + ) + .await + .context("rotate keys failed")?; + println!("node_id: {}", response.node_id); + println!("machine_public_key: {}", response.machine_public_key); + println!("wg_public_key: {}", response.wg_public_key); + } + AdminKeysCommand::History { node_id } => { + let response = client + .node_keys(node_id) + .await + .context("fetch key history failed")?; + println!("{}", serde_json::to_string_pretty(&response.keys)?); + } + AdminKeysCommand::Revoke { node_id } => { + let response = client + .revoke_node(node_id) + .await + .context("revoke node failed")?; + println!( + "node {} revoked_at={}", + response.node_id, + response + .revoked_at + .map(|ts| ts.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); + } + }, + AdminCommand::Audit { + network_id, + node_id, + limit, + } => { + let response = client + .audit_log( + network_id.as_deref(), + node_id.as_deref(), + *limit, + ) + .await + .context("fetch audit log failed")?; + println!("{}", serde_json::to_string_pretty(&response.entries)?); + } + } + } + Command::Heartbeat { + endpoint, + listen_port, + route, + route_map, + exit_node, + stun, + stun_server, + stun_port, + stun_timeout, + } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let admin_token = resolve_admin_token(&args); + let admin_token = resolve_admin_token(&args); + + let mut endpoints = endpoint.clone(); + if *stun { + let stun_servers = + gather_stun_servers( + &control_urls, + tls_pin.clone(), + &mut state, + &state_path, + stun_server, + ) + .await?; + if let Some(stun_endpoint) = maybe_stun_endpoint( + &stun_servers, + stun_port.or(Some(0)), + Duration::from_secs(*stun_timeout), + ) + .await? + { + endpoints.push(stun_endpoint); + } + } + + let route_maps = parse_route_maps(route_map)?; + let routes = build_routes(route.clone(), route_maps, *exit_node); + let client = ControlClient::new( + control_urls.clone(), + tls_pin.clone(), + state.node_token.clone(), + admin_token, + )?; + let response = client + .heartbeat(HeartbeatRequest { + node_id: state.node_id.clone(), + endpoints, + listen_port: *listen_port, + routes, + probe: None, + }) + .await + .context("heartbeat failed")?; + + state.last_netmap = Some(response.netmap); + state.updated_at = now_unix(); + save_state(&state_path, &state)?; + println!("heartbeat ok for node {}", state.node_id); + } + Command::Netmap => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + + let admin_token = resolve_admin_token(&args); + let client = ControlClient::new( + control_urls.clone(), + tls_pin.clone(), + state.node_token.clone(), + admin_token, + )?; + let netmap = client + .netmap(&state.node_id) + .await + .context("netmap fetch failed")?; + + state.last_netmap = Some(netmap.clone()); + state.updated_at = now_unix(); + save_state(&state_path, &state)?; + + println!("network: {}", netmap.network.name); + println!("approved: {}", netmap.node.approved); + if netmap.node.key_rotation_required { + println!("key_rotation_required: true"); + } + if netmap.node.revoked { + println!("revoked: true"); + } + println!("peers: {}", netmap.peers.len()); + } + Command::Status { + wg, + interface, + backend, + } => { + let state_path = resolve_state_path(&args)?; + let state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + println!("profile: {}", state.profile); + println!("network: {}", state.network_id); + println!("node: {}", state.node_id); + println!("ipv4: {}", state.ipv4); + println!("ipv6: {}", state.ipv6); + if let Some(netmap) = state.last_netmap.as_ref() { + println!("approved: {}", netmap.node.approved); + if netmap.node.key_rotation_required { + println!("key_rotation_required: true"); + } + if netmap.node.revoked { + println!("revoked: true"); + } + println!("peers: {}", netmap.peers.len()); + } + if *wg { + let iface = interface + .clone() + .unwrap_or_else(|| default_interface_name(&args.profile)); + let backend = match backend { + WgBackend::Kernel => wireguard_control::Backend::Kernel, + WgBackend::Boringtun => wireguard_control::Backend::Userspace, + }; + match iface.parse::() { + Ok(iface_name) => match wireguard_control::Device::get(&iface_name, backend) { + Ok(device) => { + println!("wg interface: {}", iface); + let mut peers_by_key = HashMap::new(); + if let Some(netmap) = state.last_netmap.as_ref() { + for peer in &netmap.peers { + peers_by_key.insert(peer.wg_public_key.clone(), peer); + } + } + for peer in device.peers { + let key = peer.config.public_key.to_base64(); + let name = peers_by_key + .get(&key) + .map(|peer| peer.name.as_str()) + .unwrap_or(""); + let endpoint = peer + .config + .endpoint + .map(|ep| ep.to_string()) + .unwrap_or_else(|| "none".to_string()); + let handshake = format_handshake_age(peer.stats.last_handshake_time); + println!( + "peer {} {} handshake={} endpoint={}", + name, key, handshake, endpoint + ); + } + } + Err(err) => { + eprintln!("wg status unavailable for {}: {}", iface, err); + } + }, + Err(err) => { + eprintln!("invalid interface name {}: {}", iface, err); + } + } + } + } + Command::RotateKeys { machine, wg } => { + let rotate_machine = *machine || (!*machine && !*wg); + let rotate_wg = *wg || (!*machine && !*wg); + if !rotate_machine && !rotate_wg { + return Err(anyhow!("no keys selected for rotation")); + } + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let admin_token = resolve_admin_token(&args); + + let mut machine_keys: Option = None; + let mut wg_keys: Option = None; + let request = model::KeyRotationRequest { + machine_public_key: if rotate_machine { + let keys = keys::generate_machine_keys(); + let public_key = keys.public_key.clone(); + machine_keys = Some(keys); + Some(public_key) + } else { + None + }, + wg_public_key: if rotate_wg { + let keys = keys::generate_wg_keys(); + let public_key = keys.public_key.clone(); + wg_keys = Some(keys); + Some(public_key) + } else { + None + }, + }; + + let client = ControlClient::new( + control_urls, + tls_pin, + state.node_token.clone(), + admin_token, + )?; + let response = client + .rotate_keys(&state.node_id, request) + .await + .context("rotate keys failed")?; + + if let Some(keys) = machine_keys { + state.machine_private_key = keys.private_key; + state.machine_public_key = keys.public_key; + } + if let Some(keys) = wg_keys { + state.wg_private_key = keys.private_key; + state.wg_public_key = keys.public_key; + } + state.updated_at = now_unix(); + save_state(&state_path, &state)?; + + println!("rotated keys for node {}", response.node_id); + } + Command::WgUp { + interface, + listen_port, + backend, + apply_routes, + accept_exit_node, + exit_node_id, + exit_node_name, + exit_node_policy, + exit_node_tag, + exit_node_metric_base, + exit_node_uid_range, + allow_route_conflicts, + route_table, + probe_peers, + probe_timeout, + } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let admin_token = resolve_admin_token(&args); + + let client = ControlClient::new( + control_urls, + tls_pin, + state.node_token.clone(), + admin_token, + )?; + let netmap = client + .netmap(&state.node_id) + .await + .context("netmap fetch failed")?; + + state.last_netmap = Some(netmap.clone()); + state.updated_at = now_unix(); + save_state(&state_path, &state)?; + + let iface = interface + .clone() + .unwrap_or_else(|| default_interface_name(&args.profile)); + let cfg = wg::WgConfig { + interface: iface.clone(), + listen_port: *listen_port, + backend: (*backend).into(), + }; + let routes_cfg = if *apply_routes { + let uid_range = parse_uid_range(exit_node_uid_range.as_ref())?; + let route_table = resolve_route_table(*route_table, &args.profile); + let (route_rule_priority, exit_rule_priority, exit_uid_rule_priority) = + default_rule_priorities(&args.profile); + let exit_metric_base = + resolve_exit_metric_base(*exit_node_metric_base, &args.profile); + Some(routes::RouteApplyConfig { + interface: iface.clone(), + accept_exit_node: *accept_exit_node, + exit_node_id: exit_node_id.clone(), + exit_node_name: exit_node_name.clone(), + exit_node_policy: (*exit_node_policy).into(), + exit_node_tag: exit_node_tag.clone(), + exit_node_metric_base: exit_metric_base, + exit_node_uid_range: uid_range, + allow_conflicts: *allow_route_conflicts, + route_table, + route_rule_priority, + exit_rule_priority, + exit_uid_rule_priority, + }) + } else { + None + }; + wg::apply(&netmap, &state, &cfg, routes_cfg.as_ref()).await?; + if let Some(routes_cfg) = routes_cfg.as_ref() { + routes::apply_advertised_routes(&netmap, routes_cfg).await?; + } + if *probe_peers { + wg::probe_peers(&netmap, *probe_timeout)?; + } + println!("configured wireguard interface {}", iface); + if matches!(*backend, WgBackend::Boringtun) { + println!("boringtun backend running in foreground; press Ctrl+C to stop"); + tokio::signal::ctrl_c().await?; + } + } + Command::WgDown { interface, backend } => { + let iface = interface + .clone() + .unwrap_or_else(|| default_interface_name(&args.profile)); + wg::remove(&iface, (*backend).into()).await?; + println!("removed wireguard interface {}", iface); + } + Command::Agent { + interface, + listen_port, + apply_routes, + accept_exit_node, + exit_node_id, + exit_node_name, + exit_node_policy, + exit_node_tag, + exit_node_metric_base, + exit_node_uid_range, + allow_route_conflicts, + route_table, + endpoint, + advertise_route, + advertise_map, + advertise_exit_node, + heartbeat_interval, + longpoll_timeout, + backend, + stun, + stun_server, + stun_port, + stun_timeout, + probe_peers, + probe_timeout, + stream_relay, + stream_relay_server, + endpoint_stale_after, + endpoint_max_rotations, + dns_hosts_path, + dns_serve, + dns_listen, + dns_apply_resolver, + l2_relay, + } => { + if *heartbeat_interval == 0 { + return Err(anyhow!("heartbeat_interval must be > 0")); + } + if *longpoll_timeout == 0 { + return Err(anyhow!("longpoll_timeout must be > 0")); + } + if *endpoint_stale_after == 0 { + return Err(anyhow!("endpoint_stale_after must be > 0")); + } + if *endpoint_max_rotations == 0 { + return Err(anyhow!("endpoint_max_rotations must be > 0")); + } + + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let admin_token = resolve_admin_token(&args); + let profile = args.profile.clone(); + let stun_servers = gather_stun_servers( + &control_urls, + tls_pin.clone(), + &mut state, + &state_path, + stun_server, + ) + .await?; + let stream_servers = if *stream_relay { + if !stream_relay_server.is_empty() { + stream_relay_server.clone() + } else { + gather_stream_relay_servers( + &control_urls, + tls_pin.clone(), + &mut state, + &state_path, + ) + .await? + } + } else { + Vec::new() + }; + if *stream_relay && stream_servers.is_empty() { + eprintln!("stream relay enabled but no servers configured"); + } + if *stream_relay { + if let Err(err) = enable_route_localnet() { + eprintln!("failed to enable route_localnet: {}", err); + } + } + let mut endpoint_tracker = wg::EndpointTracker::default(); + let relay_ip = select_relay_ip(endpoint); + let mut relay_manager = if *stream_relay && !stream_servers.is_empty() { + Some(relay_tunnel::RelayTunnelManager::new( + state.node_id.clone(), + stream_servers.clone(), + *listen_port, + relay_ip, + )) + } else { + None + }; + let endpoint_stale_after = Duration::from_secs(*endpoint_stale_after); + let endpoint_max_rotations = (*endpoint_max_rotations) as usize; + + let iface = interface + .clone() + .unwrap_or_else(|| default_interface_name(&args.profile)); + let client = ControlClient::new( + control_urls.clone(), + tls_pin.clone(), + state.node_token.clone(), + admin_token, + )?; + let wg_cfg = wg::WgConfig { + interface: iface.clone(), + listen_port: *listen_port, + backend: (*backend).into(), + }; + let uid_range = parse_uid_range(exit_node_uid_range.as_ref())?; + let route_table = resolve_route_table(*route_table, &args.profile); + let (route_rule_priority, exit_rule_priority, exit_uid_rule_priority) = + default_rule_priorities(&args.profile); + let exit_metric_base = resolve_exit_metric_base(*exit_node_metric_base, &args.profile); + let routes_cfg = routes::RouteApplyConfig { + interface: iface.clone(), + accept_exit_node: *accept_exit_node, + exit_node_id: exit_node_id.clone(), + exit_node_name: exit_node_name.clone(), + exit_node_policy: (*exit_node_policy).into(), + exit_node_tag: exit_node_tag.clone(), + exit_node_metric_base: exit_metric_base, + exit_node_uid_range: uid_range, + allow_conflicts: *allow_route_conflicts, + route_table, + route_rule_priority, + exit_rule_priority, + exit_uid_rule_priority, + }; + let advertise_maps = parse_route_maps(advertise_map)?; + let advertise_routes = + build_routes(advertise_route.clone(), advertise_maps, *advertise_exit_node); + let mut dns_state: Option>> = None; + let mut dns_listen_addr: Option = None; + let mut l2_state: Option>> = None; + if *dns_serve { + let listen = dns_listen + .clone() + .unwrap_or_else(|| "127.0.0.1:53".to_string()); + let listen_addr: SocketAddr = + listen.parse().context("invalid dns listen address")?; + let netmap = ensure_netmap(&control_urls, tls_pin.clone(), &mut state, &state_path) + .await?; + dns_state = Some(dns_server::spawn(listen_addr, netmap.clone())?); + dns_listen_addr = Some(listen_addr); + if *dns_apply_resolver { + if listen_addr.port() != 53 { + eprintln!("dns listen port must be 53 to apply resolver"); + } else if let Err(err) = dns_server::apply_resolver( + &iface, + &netmap.network.dns_domain, + listen_addr.ip(), + ) { + eprintln!("failed to apply dns resolver: {}", err); + } + } + println!("dns server listening on {}", listen_addr); + } + let mut last_revision = state + .last_netmap + .as_ref() + .map(|netmap| netmap.revision) + .unwrap_or(0); + + let mut interval = tokio::time::interval(Duration::from_secs(*heartbeat_interval)); + println!( + "agent running for node {} on network {}", + state.node_id, state.network_id + ); + + loop { + tokio::select! { + _ = interval.tick() => { + let mut endpoints = endpoint.clone(); + if *stun { + if let Some(stun_endpoint) = maybe_stun_endpoint( + &stun_servers, + stun_port.or(Some(0)), + Duration::from_secs(*stun_timeout), + ) + .await? + { + endpoints.push(stun_endpoint); + } + } + let response = match client + .heartbeat(HeartbeatRequest { + node_id: state.node_id.clone(), + endpoints, + listen_port: Some(*listen_port), + routes: advertise_routes.clone(), + probe: Some(*stream_relay), + }) + .await + { + Ok(response) => response, + Err(err) => { + eprintln!("heartbeat failed: {}", err); + continue; + } + }; + let netmap = response.netmap; + if let Err(err) = handle_probe_requests(&netmap).await { + eprintln!("probe request handling failed: {}", err); + } + if netmap.revision > last_revision { + last_revision = netmap.revision; + apply_netmap_update( + &state_path, + &mut state, + netmap.clone(), + &wg_cfg, + *apply_routes, + &routes_cfg, + *probe_peers, + *probe_timeout, + &profile, + dns_hosts_path.as_ref(), + ) + .await?; + if let Some(state_handle) = dns_state.as_ref() { + if let Ok(mut guard) = state_handle.lock() { + *guard = netmap.clone(); + } + } + if *dns_apply_resolver { + if let Some(listen_addr) = dns_listen_addr.as_ref() { + if listen_addr.port() == 53 { + if let Err(err) = dns_server::apply_resolver( + &iface, + &netmap.network.dns_domain, + listen_addr.ip(), + ) { + eprintln!("failed to apply dns resolver: {}", err); + } + } + } + } + if *l2_relay { + match netmap.node.ipv4.parse() { + Ok(wg_ipv4) => { + if let Some(state_handle) = l2_state.as_ref() { + if let Ok(mut guard) = state_handle.lock() { + *guard = netmap.clone(); + } + } else { + match l2_relay::spawn(wg_ipv4, netmap.clone()) { + Ok(state_handle) => { + l2_state = Some(state_handle); + println!("l2 relay enabled"); + } + Err(err) => { + eprintln!("l2 relay failed: {}", err); + } + } + } + } + Err(_) => { + eprintln!("l2 relay skipped: invalid node ipv4"); + } + } + } + } + let relay_endpoints = if let Some(manager) = relay_manager.as_mut() { + match manager.ensure_for_peers(&netmap.peers).await { + Ok(map) => map, + Err(err) => { + eprintln!("stream relay tunnel init failed: {}", err); + HashMap::new() + } + } + } else { + HashMap::new() + }; + if let Err(err) = wg::refresh_peer_endpoints( + &netmap, + &wg_cfg, + &mut endpoint_tracker, + &relay_endpoints, + endpoint_stale_after, + endpoint_max_rotations, + ) { + eprintln!("endpoint refresh failed: {}", err); + } + } + result = client.netmap_longpoll(&state.node_id, last_revision, *longpoll_timeout) => { + let netmap = match result { + Ok(netmap) => netmap, + Err(err) => { + eprintln!("netmap longpoll failed: {}", err); + sleep(Duration::from_secs(1)).await; + continue; + } + }; + if let Err(err) = handle_probe_requests(&netmap).await { + eprintln!("probe request handling failed: {}", err); + } + if netmap.revision > last_revision { + last_revision = netmap.revision; + apply_netmap_update( + &state_path, + &mut state, + netmap.clone(), + &wg_cfg, + *apply_routes, + &routes_cfg, + *probe_peers, + *probe_timeout, + &profile, + dns_hosts_path.as_ref(), + ) + .await?; + } + let relay_endpoints = if let Some(manager) = relay_manager.as_mut() { + match manager.ensure_for_peers(&netmap.peers).await { + Ok(map) => map, + Err(err) => { + eprintln!("stream relay tunnel init failed: {}", err); + HashMap::new() + } + } + } else { + HashMap::new() + }; + if let Err(err) = wg::refresh_peer_endpoints( + &netmap, + &wg_cfg, + &mut endpoint_tracker, + &relay_endpoints, + endpoint_stale_after, + endpoint_max_rotations, + ) { + eprintln!("endpoint refresh failed: {}", err); + } + } + } + } + } + Command::Router { command } => match command { + RouterCommand::Enable { + interface, + out_interface, + map, + no_snat, + } => { + let iface = interface + .clone() + .unwrap_or_else(|| default_interface_name(&args.profile)); + let out_iface = router::resolve_out_interface(out_interface.clone()).await?; + router::enable_forwarding(&iface, &out_iface, !*no_snat).await?; + if !map.is_empty() { + let maps = parse_route_maps(map)?; + router::apply_route_maps(&iface, &out_iface, &maps).await?; + } + + if *no_snat { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let netmap = + ensure_netmap(&control_urls, tls_pin, &mut state, &state_path).await?; + let (lan_v4, lan_v6) = router::interface_ips(&out_iface).await?; + print_return_route_guidance(&netmap, &iface, &out_iface, lan_v4, lan_v6); + } + + println!("forwarding enabled for {} -> {}", iface, out_iface); + } + RouterCommand::Disable { + interface, + out_interface, + } => { + let iface = interface + .clone() + .unwrap_or_else(|| default_interface_name(&args.profile)); + let out_iface = router::resolve_out_interface(out_interface.clone()).await?; + router::disable_forwarding(&iface, &out_iface).await?; + println!("forwarding disabled for {} -> {}", iface, out_iface); + } + }, + Command::RelayUdp { command } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let servers = gather_udp_relay_servers( + &control_urls, + tls_pin, + &mut state, + &state_path, + ) + .await?; + + match command { + RelayUdpCommand::Send { + peer_id, + message, + server, + timeout, + } => { + let server = server + .clone() + .or_else(|| servers.first().cloned()) + .ok_or_else(|| anyhow!("no udp relay server configured"))?; + relay_udp_send(&state, &server, &peer_id, &message, *timeout).await?; + println!("relay message sent to {}", peer_id); + } + RelayUdpCommand::Listen { server } => { + let server = server + .clone() + .or_else(|| servers.first().cloned()) + .ok_or_else(|| anyhow!("no udp relay server configured"))?; + relay_udp_listen(&state, &server).await?; + } + } + } + Command::RelayStream { command } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let servers = gather_stream_relay_servers( + &control_urls, + tls_pin, + &mut state, + &state_path, + ) + .await?; + + match command { + RelayStreamCommand::Send { + peer_id, + message, + server, + } => { + if let Some(server) = server.clone() { + relay_stream_send(&state, &server, &peer_id, &message).await?; + } else { + relay_stream_send_raw_any(&state, &servers, &peer_id, message.as_bytes()) + .await?; + } + println!("stream relay message sent to {}", peer_id); + } + RelayStreamCommand::Listen { server } => { + if let Some(server) = server.clone() { + relay_stream_listen(&state, &server).await?; + } else { + relay_stream_listen_any(&state, &servers).await?; + } + } + } + } + Command::RelayTurn { command } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + let servers = + gather_turn_servers(&control_urls, tls_pin, &mut state, &state_path).await?; + let creds = build_turn_credentials(&command)?; + + match command { + RelayTurnCommand::Send { + peer_addr, + message, + server, + timeout, + .. + } => { + let server = server + .clone() + .or_else(|| servers.first().cloned()) + .ok_or_else(|| anyhow!("no turn server configured"))?; + let peer: SocketAddr = peer_addr.parse().context("invalid peer addr")?; + let mut allocation = turn::allocate( + &server, + creds.as_ref(), + Duration::from_secs(*timeout), + ) + .await?; + turn::create_permission(&mut allocation, peer, Duration::from_secs(*timeout)) + .await?; + turn::send_data(&mut allocation, peer, message.as_bytes()).await?; + println!("turn relay message sent to {}", peer); + } + RelayTurnCommand::Listen { + server, + peer_addr, + .. + } => { + let server = server + .clone() + .or_else(|| servers.first().cloned()) + .ok_or_else(|| anyhow!("no turn server configured"))?; + let mut allocation = + turn::allocate(&server, creds.as_ref(), Duration::from_secs(3)).await?; + if let Some(peer_addr) = peer_addr { + let peer: SocketAddr = + peer_addr.parse().context("invalid peer addr")?; + turn::create_permission(&mut allocation, peer, Duration::from_secs(3)) + .await?; + } else { + eprintln!("turn listen without --peer-addr may not receive traffic"); + } + println!("listening on turn relay {}", allocation.relay_addr); + loop { + if let Some((from, payload)) = + turn::recv_data(&mut allocation, None).await? + { + let text = String::from_utf8_lossy(&payload); + println!("from {}: {}", from, text); + } + } + } + } + } + Command::Dns { + format, + output, + apply_hosts, + hosts_path, + } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + + let admin_token = resolve_admin_token(&args); + let client = ControlClient::new( + control_urls, + tls_pin, + state.node_token.clone(), + admin_token, + )?; + let netmap = client + .netmap(&state.node_id) + .await + .context("netmap fetch failed")?; + + state.last_netmap = Some(netmap.clone()); + state.updated_at = now_unix(); + save_state(&state_path, &state)?; + + let text = match format { + DnsFormat::Hosts => format_dns_hosts(&netmap), + DnsFormat::Json => format_dns_json(&netmap), + }; + if let Some(path) = output { + std::fs::write(path, text)?; + } else { + print!("{}", text); + } + if *apply_hosts { + let path = hosts_path.clone().unwrap_or_else(default_hosts_path); + apply_hosts_file(&path, &args.profile, &netmap)?; + println!("updated hosts file {}", path.display()); + } + } + Command::DnsServe { + listen, + apply_resolver, + interface, + } => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + + let admin_token = resolve_admin_token(&args); + let client = ControlClient::new( + control_urls, + tls_pin, + state.node_token.clone(), + admin_token, + )?; + let netmap = client + .netmap(&state.node_id) + .await + .context("netmap fetch failed")?; + + state.last_netmap = Some(netmap.clone()); + state.updated_at = now_unix(); + save_state(&state_path, &state)?; + + let listen_addr: SocketAddr = listen.parse().context("invalid listen address")?; + let _state = dns_server::spawn(listen_addr, netmap.clone())?; + if *apply_resolver { + if listen_addr.port() != 53 { + return Err(anyhow!( + "dns listen port must be 53 to apply resolver" + )); + } + let iface = interface + .clone() + .unwrap_or_else(|| default_interface_name(&args.profile)); + dns_server::apply_resolver(&iface, &netmap.network.dns_domain, listen_addr.ip())?; + } + println!("dns server listening on {}", listen_addr); + tokio::signal::ctrl_c().await?; + } + Command::Relay => { + let config = load_optional_config(&args)?; + let control_urls = resolve_control_urls(&args, config.as_ref())?; + let tls_pin = resolve_tls_pin(&args, &config); + let state_path = resolve_state_path(&args)?; + let mut state = load_state(&state_path)? + .ok_or_else(|| anyhow!("state not found for profile {}", args.profile))?; + + let admin_token = resolve_admin_token(&args); + let client = ControlClient::new( + control_urls, + tls_pin, + state.node_token.clone(), + admin_token, + )?; + let netmap = client + .netmap(&state.node_id) + .await + .context("netmap fetch failed")?; + + state.last_netmap = Some(netmap.clone()); + state.updated_at = now_unix(); + save_state(&state_path, &state)?; + + print_relay_config(&netmap); + } + } + + Ok(()) +} + +fn resolve_config_path(args: &Args) -> Result { + if let Some(path) = &args.config { + return Ok(path.clone()); + } + default_config_path().ok_or_else(|| anyhow!("no default config path available")) +} + +fn load_optional_config(args: &Args) -> Result> { + let config_path = match &args.config { + Some(path) => path.clone(), + None => match default_config_path() { + Some(path) => path, + None => return Ok(None), + }, + }; + + Ok(Some(load_config(&config_path)?)) +} + +fn resolve_control_urls(args: &Args, config: Option<&ClientConfig>) -> Result> { + if !args.control_url.is_empty() { + let urls = normalize_control_urls(args.control_url.clone()); + if !urls.is_empty() { + return Ok(urls); + } + } + + if let Some(config) = config { + if let Some(profile) = config.profiles.get(&args.profile) { + let urls = normalize_control_urls(profile.control_urls.clone()); + if !urls.is_empty() { + return Ok(urls); + } + } + } + + Err(anyhow!( + "control URL not set; use --control-url or init the profile" + )) +} + +fn normalize_control_urls(urls: Vec) -> Vec { + let mut unique = Vec::new(); + for url in urls { + let trimmed = url.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if !unique.contains(&trimmed) { + unique.push(trimmed); + } + } + unique +} + +fn resolve_tls_pin(args: &Args, config: &Option) -> Option { + if let Some(pin) = &args.tls_pin { + return Some(pin.clone()); + } + if let Some(config) = config { + if let Some(profile) = config.profiles.get(&args.profile) { + return profile.tls_pinned_sha256.clone(); + } + } + None +} + +fn resolve_admin_token(args: &Args) -> Option { + args.admin_token.clone() +} + +fn resolve_state_path(args: &Args) -> Result { + let base = if let Some(dir) = &args.state_dir { + dir.clone() + } else { + default_state_dir(&args.profile) + .ok_or_else(|| anyhow!("no default state dir available"))? + }; + Ok(state_path(&base)) +} + +fn load_acl_policy(file: Option<&PathBuf>, json: Option<&String>) -> Result { + if let Some(json) = json { + return Ok(serde_json::from_str(json)?); + } + if let Some(path) = file { + let contents = std::fs::read_to_string(path)?; + return Ok(serde_json::from_str(&contents)?); + } + Err(anyhow!("provide --file or --json for acl policy")) +} + +fn build_turn_credentials(command: &RelayTurnCommand) -> Result> { + let (username, password) = match command { + RelayTurnCommand::Send { + username, password, .. + } => (username, password), + RelayTurnCommand::Listen { + username, password, .. + } => (username, password), + }; + match (username, password) { + (Some(user), Some(pass)) => Ok(Some(turn::TurnCredentials { + username: user.clone(), + password: pass.clone(), + })), + (None, None) => Ok(None), + _ => Err(anyhow!("turn username and password must be set together")), + } +} + +fn parse_route_maps(entries: &[String]) -> Result> { + let mut maps = Vec::new(); + let mut seen_real = HashMap::new(); + let mut seen_mapped = HashMap::new(); + for entry in entries { + let (real, mapped) = entry + .split_once('=') + .ok_or_else(|| anyhow!("route map must be REAL=MAPPED (got {})", entry))?; + let real = real.trim(); + let mapped = mapped.trim(); + if real.is_empty() || mapped.is_empty() { + return Err(anyhow!("route map must be REAL=MAPPED (got {})", entry)); + } + let real_net: IpNet = real.parse().with_context(|| { + format!("route map real prefix invalid: {}", real) + })?; + let mapped_net: IpNet = mapped.parse().with_context(|| { + format!("route map mapped prefix invalid: {}", mapped) + })?; + let real_v4 = matches!(real_net, IpNet::V4(_)); + let mapped_v4 = matches!(mapped_net, IpNet::V4(_)); + if real_v4 != mapped_v4 { + return Err(anyhow!( + "route map ip versions must match ({} vs {})", + real, + mapped + )); + } + if real_net.prefix_len() != mapped_net.prefix_len() { + return Err(anyhow!( + "route map prefix lengths must match ({} vs {})", + real, + mapped + )); + } + if let Some(existing) = seen_real.get(real) { + if existing != mapped { + return Err(anyhow!( + "route map for {} already set to {}", + real, + existing + )); + } + continue; + } + if let Some(existing) = seen_mapped.get(mapped) { + if existing != real { + return Err(anyhow!( + "route map {} already mapped from {}", + mapped, + existing + )); + } + continue; + } + seen_real.insert(real.to_string(), mapped.to_string()); + seen_mapped.insert(mapped.to_string(), real.to_string()); + maps.push((real.to_string(), mapped.to_string())); + } + Ok(maps) +} + +fn parse_uid_range(value: Option<&String>) -> Result> { + let Some(raw) = value else { + return Ok(None); + }; + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + let (start, end) = if let Some((start, end)) = raw.split_once('-') { + let start: u32 = start.trim().parse().context("invalid uid range start")?; + let end: u32 = end.trim().parse().context("invalid uid range end")?; + if end < start { + return Err(anyhow!("uid range end before start")); + } + (start, end) + } else { + let uid: u32 = raw.parse().context("invalid uid")?; + (uid, uid) + }; + Ok(Some(routes::UidRange { start, end })) +} + +fn resolve_route_table(route_table: Option, _profile: &str) -> Option { + match route_table { + Some(0) => None, + Some(value) => Some(value), + None => None, + } +} + +fn resolve_exit_metric_base(metric: Option, profile: &str) -> u32 { + metric.unwrap_or_else(|| 10 + (profile_hash(profile) % 10) as u32) +} + +fn default_rule_priorities(profile: &str) -> (u32, u32, u32) { + let offset = (profile_hash(profile) % 100) as u32; + let route_priority = 1000 + offset; + let exit_priority = 1100 + offset; + let exit_uid_priority = 900 + offset; + (route_priority, exit_priority, exit_uid_priority) +} + +fn profile_hash(profile: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + profile.hash(&mut hasher); + hasher.finish() +} + +fn build_routes( + prefixes: Vec, + maps: Vec<(String, String)>, + exit_node: bool, +) -> Vec { + let mut routes: Vec = prefixes + .into_iter() + .map(|prefix| Route { + prefix, + kind: RouteKind::Subnet, + enabled: true, + mapped_prefix: None, + }) + .collect(); + + for (real, mapped) in maps { + if let Some(route) = routes + .iter_mut() + .find(|route| route.prefix == real && matches!(route.kind, RouteKind::Subnet)) + { + route.mapped_prefix = Some(mapped); + } else { + routes.push(Route { + prefix: real, + kind: RouteKind::Subnet, + enabled: true, + mapped_prefix: Some(mapped), + }); + } + } + + if exit_node { + routes.push(Route { + prefix: "0.0.0.0/0".to_string(), + kind: RouteKind::Exit, + enabled: true, + mapped_prefix: None, + }); + routes.push(Route { + prefix: "::/0".to_string(), + kind: RouteKind::Exit, + enabled: true, + mapped_prefix: None, + }); + } + + routes +} + +fn default_node_name() -> String { + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "node".to_string()) +} + +fn default_interface_name(profile: &str) -> String { + let mut name = format!("ls-{}", profile); + if name.len() > 15 { + name.truncate(15); + } + name +} + +fn now_unix() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +fn format_handshake_age(when: Option) -> String { + match when { + Some(time) => match time.elapsed() { + Ok(age) => format!("{}s", age.as_secs()), + Err(_) => "unknown".to_string(), + }, + None => "never".to_string(), + } +} + +fn select_relay_ip(endpoints: &[String]) -> Option { + endpoints + .iter() + .filter_map(|endpoint| endpoint.parse::().ok()) + .map(|addr| addr.ip()) + .next() +} + +async fn fetch_server_fingerprint_any(control_urls: &[String]) -> Result { + if control_urls.is_empty() { + return Err(anyhow!("no control URL configured")); + } + let mut last_err: Option = None; + for control_url in control_urls { + match fetch_server_fingerprint(control_url).await { + Ok(pin) => return Ok(pin), + Err(err) => last_err = Some(err), + } + } + Err(last_err.unwrap_or_else(|| anyhow!("no control URL reachable"))) +} + +async fn fetch_server_fingerprint(control_url: &str) -> Result { + let url = url::Url::parse(control_url).context("invalid control URL")?; + if url.scheme() != "https" { + return Err(anyhow!("tls pin requires https control URL")); + } + let host = url + .host_str() + .ok_or_else(|| anyhow!("control URL missing host"))? + .to_string(); + let port = url.port_or_known_default().unwrap_or(443); + let addr = format!("{}:{}", host, port); + + let stream = TcpStream::connect(addr).await?; + let mut config = rustls::ClientConfig::builder() + .with_root_certificates(rustls::RootCertStore::empty()) + .with_no_client_auth(); + config + .dangerous() + .set_certificate_verifier(Arc::new(NoVerify)); + let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); + let server_name = rustls::pki_types::ServerName::try_from(host.clone()) + .map_err(|_| anyhow!("invalid server name"))?; + let stream = connector.connect(server_name, stream).await?; + let (_, session) = stream.get_ref(); + let certs = session + .peer_certificates() + .ok_or_else(|| anyhow!("server did not provide certificates"))?; + let leaf = certs + .first() + .ok_or_else(|| anyhow!("server did not provide certificates"))?; + let mut hasher = Sha256::new(); + hasher.update(leaf.as_ref()); + let digest = hasher.finalize(); + Ok(hex::encode(digest)) +} + +#[derive(Debug)] +struct NoVerify; + +impl rustls::client::danger::ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> std::result::Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +#[cfg(target_os = "linux")] +fn enable_route_localnet() -> Result<()> { + std::fs::write("/proc/sys/net/ipv4/conf/all/route_localnet", "1\n")?; + std::fs::write("/proc/sys/net/ipv4/conf/lo/route_localnet", "1\n")?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn enable_route_localnet() -> Result<()> { + Ok(()) +} + +fn format_dns_hosts(netmap: &model::NetMap) -> String { + let mut text = String::new(); + text.push_str(&format!( + "# {} {}\n", + netmap.network.name, netmap.network.dns_domain + )); + text.push_str(&format!("{} {}\n", netmap.node.ipv4, netmap.node.dns_name)); + text.push_str(&format!("{} {}\n", netmap.node.ipv6, netmap.node.dns_name)); + for peer in &netmap.peers { + text.push_str(&format!("{} {}\n", peer.ipv4, peer.dns_name)); + text.push_str(&format!("{} {}\n", peer.ipv6, peer.dns_name)); + } + text +} + +fn format_dns_json(netmap: &model::NetMap) -> String { + let mut records = Vec::new(); + records.push(serde_json::json!({ + "name": netmap.node.dns_name, + "node_id": netmap.node.id, + "ipv4": netmap.node.ipv4, + "ipv6": netmap.node.ipv6, + })); + for peer in &netmap.peers { + records.push(serde_json::json!({ + "name": peer.dns_name, + "node_id": peer.id, + "ipv4": peer.ipv4, + "ipv6": peer.ipv6, + })); + } + + serde_json::to_string_pretty(&serde_json::json!({ + "network": { + "id": netmap.network.id, + "name": netmap.network.name, + "dns_domain": netmap.network.dns_domain, + }, + "generated_at": netmap.generated_at, + "records": records, + })) + .unwrap_or_else(|_| "{}".to_string()) +} + +fn default_hosts_path() -> PathBuf { + #[cfg(target_os = "windows")] + { + PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts") + } + #[cfg(not(target_os = "windows"))] + { + PathBuf::from("/etc/hosts") + } +} + +fn apply_hosts_file(path: &PathBuf, profile: &str, netmap: &model::NetMap) -> Result<()> { + let start = format!("# lightscale:{} begin", profile); + let end = format!("# lightscale:{} end", profile); + let contents = std::fs::read_to_string(path).unwrap_or_default(); + let mut output = String::new(); + let mut in_block = false; + for line in contents.lines() { + if line.trim() == start { + in_block = true; + continue; + } + if line.trim() == end { + in_block = false; + continue; + } + if !in_block { + output.push_str(line); + output.push('\n'); + } + } + output.push_str(&start); + output.push('\n'); + output.push_str(&format!( + "# {} {}\n", + netmap.network.name, netmap.network.dns_domain + )); + output.push_str(&format!("{} {}\n", netmap.node.ipv4, netmap.node.dns_name)); + output.push_str(&format!("{} {}\n", netmap.node.ipv6, netmap.node.dns_name)); + for peer in &netmap.peers { + output.push_str(&format!("{} {}\n", peer.ipv4, peer.dns_name)); + output.push_str(&format!("{} {}\n", peer.ipv6, peer.dns_name)); + } + output.push_str(&end); + output.push('\n'); + std::fs::write(path, output)?; + Ok(()) +} + +fn print_relay_config(netmap: &model::NetMap) { + let relay = match &netmap.relay { + Some(relay) => relay, + None => { + println!("relay: none"); + return; + } + }; + println!("stun: {}", relay.stun_servers.join(", ")); + println!("turn: {}", relay.turn_servers.join(", ")); + println!("stream-relay: {}", relay.stream_relay_servers.join(", ")); + println!("udp-relay: {}", relay.udp_relay_servers.join(", ")); +} + +async fn apply_netmap_update( + state_path: &PathBuf, + state: &mut ClientState, + netmap: model::NetMap, + wg_cfg: &wg::WgConfig, + apply_routes: bool, + routes_cfg: &routes::RouteApplyConfig, + probe_peers: bool, + probe_timeout: u64, + profile: &str, + dns_hosts_path: Option<&PathBuf>, +) -> Result<()> { + state.ipv4 = netmap.node.ipv4.clone(); + state.ipv6 = netmap.node.ipv6.clone(); + state.last_netmap = Some(netmap.clone()); + state.updated_at = now_unix(); + save_state(state_path, state)?; + + if !netmap.node.approved { + if netmap.node.revoked { + println!("node {} revoked", state.node_id); + return Ok(()); + } + if netmap.node.key_rotation_required { + println!("node {} requires key rotation", state.node_id); + return Ok(()); + } + println!("node {} pending approval", state.node_id); + return Ok(()); + } + + let wg_routes_cfg = if apply_routes { Some(routes_cfg) } else { None }; + wg::apply(&netmap, state, wg_cfg, wg_routes_cfg).await?; + if apply_routes { + routes::apply_advertised_routes(&netmap, routes_cfg).await?; + } + if probe_peers { + wg::probe_peers(&netmap, probe_timeout)?; + } + if let Some(path) = dns_hosts_path { + apply_hosts_file(path, profile, &netmap)?; + } + Ok(()) +} + +async fn gather_stun_servers( + control_urls: &[String], + tls_pin: Option, + state: &mut ClientState, + state_path: &PathBuf, + overrides: &[String], +) -> Result> { + if !overrides.is_empty() { + return Ok(overrides.to_vec()); + } + + if let Some(netmap) = state.last_netmap.as_ref() { + if let Some(relay) = &netmap.relay { + if !relay.stun_servers.is_empty() { + return Ok(relay.stun_servers.clone()); + } + } + } + + let netmap = ensure_netmap(control_urls, tls_pin, state, state_path).await?; + if let Some(relay) = netmap.relay { + if !relay.stun_servers.is_empty() { + return Ok(relay.stun_servers); + } + } + + Ok(Vec::new()) +} + +async fn gather_udp_relay_servers( + control_urls: &[String], + tls_pin: Option, + state: &mut ClientState, + state_path: &PathBuf, +) -> Result> { + if let Some(netmap) = state.last_netmap.as_ref() { + if let Some(relay) = &netmap.relay { + if !relay.udp_relay_servers.is_empty() { + return Ok(relay.udp_relay_servers.clone()); + } + } + } + + let netmap = ensure_netmap(control_urls, tls_pin, state, state_path).await?; + if let Some(relay) = netmap.relay { + if !relay.udp_relay_servers.is_empty() { + return Ok(relay.udp_relay_servers); + } + } + + Ok(Vec::new()) +} + +async fn gather_stream_relay_servers( + control_urls: &[String], + tls_pin: Option, + state: &mut ClientState, + state_path: &PathBuf, +) -> Result> { + if let Some(netmap) = state.last_netmap.as_ref() { + if let Some(relay) = &netmap.relay { + if !relay.stream_relay_servers.is_empty() { + return Ok(relay.stream_relay_servers.clone()); + } + } + } + + let netmap = ensure_netmap(control_urls, tls_pin, state, state_path).await?; + if let Some(relay) = netmap.relay { + if !relay.stream_relay_servers.is_empty() { + return Ok(relay.stream_relay_servers); + } + } + + Ok(Vec::new()) +} + +async fn gather_turn_servers( + control_urls: &[String], + tls_pin: Option, + state: &mut ClientState, + state_path: &PathBuf, +) -> Result> { + if let Some(netmap) = state.last_netmap.as_ref() { + if let Some(relay) = &netmap.relay { + if !relay.turn_servers.is_empty() { + return Ok(relay.turn_servers.clone()); + } + } + } + + let netmap = ensure_netmap(control_urls, tls_pin, state, state_path).await?; + if let Some(relay) = netmap.relay { + if !relay.turn_servers.is_empty() { + return Ok(relay.turn_servers); + } + } + + Ok(Vec::new()) +} + +async fn maybe_stun_endpoint( + servers: &[String], + bind_port: Option, + timeout: Duration, +) -> Result> { + if servers.is_empty() { + eprintln!("stun: no servers configured"); + return Ok(None); + } + + let servers = servers.to_vec(); + let port = bind_port.unwrap_or(0); + let result = tokio::task::spawn_blocking(move || { + stun::discover_endpoint(&servers, port, timeout) + }) + .await + .map_err(|err| anyhow!("stun task failed: {}", err))?; + + match result { + Ok(addr) => Ok(Some(addr.to_string())), + Err(err) => { + eprintln!("stun failed: {}", err); + Ok(None) + } + } +} + +async fn relay_udp_send( + state: &ClientState, + server: &str, + peer_id: &str, + message: &str, + _timeout: u64, +) -> Result<()> { + let server_addr = udp_relay::resolve_server(server)?; + let bind_addr = udp_relay::bind_addr_for(&server_addr); + let socket = UdpSocket::bind(bind_addr).await?; + + let register = udp_relay::build_register(&state.node_id)?; + socket.send_to(®ister, server_addr).await?; + + let payload = message.as_bytes(); + let send = udp_relay::build_send(&state.node_id, peer_id, payload)?; + socket.send_to(&send, server_addr).await?; + Ok(()) +} + +async fn relay_udp_listen(state: &ClientState, server: &str) -> Result<()> { + let server_addr = udp_relay::resolve_server(server)?; + let bind_addr = udp_relay::bind_addr_for(&server_addr); + let socket = UdpSocket::bind(bind_addr).await?; + + let register = udp_relay::build_register(&state.node_id)?; + socket.send_to(®ister, server_addr).await?; + + let mut buf = vec![0u8; 2048]; + println!("listening on udp relay {} as {}", server, state.node_id); + loop { + let (len, _) = socket.recv_from(&mut buf).await?; + if let Some((from, payload)) = udp_relay::parse_deliver(&buf[..len]) { + let text = String::from_utf8_lossy(&payload); + println!("from {}: {}", from, text); + } + } +} + +async fn relay_stream_send( + state: &ClientState, + server: &str, + peer_id: &str, + message: &str, +) -> Result<()> { + relay_stream_send_raw(state, server, peer_id, message.as_bytes()).await +} + +async fn relay_stream_listen(state: &ClientState, server: &str) -> Result<()> { + let mut stream = TcpStream::connect(server).await?; + stream_relay::write_register(&mut stream, &state.node_id).await?; + println!("listening on stream relay {} as {}", server, state.node_id); + loop { + match stream_relay::read_deliver(&mut stream).await? { + Some((from, payload)) => { + let text = String::from_utf8_lossy(&payload); + println!("from {}: {}", from, text); + } + None => {} + } + } +} + +async fn relay_stream_listen_any(state: &ClientState, servers: &[String]) -> Result<()> { + if servers.is_empty() { + return Err(anyhow!("no stream relay server configured")); + } + let mut last_err = None; + for server in servers { + match relay_stream_listen(state, server).await { + Ok(()) => return Ok(()), + Err(err) => last_err = Some(err), + } + } + Err(last_err.unwrap_or_else(|| anyhow!("no stream relay server configured"))) +} + +async fn relay_stream_send_raw( + state: &ClientState, + server: &str, + peer_id: &str, + payload: &[u8], +) -> Result<()> { + let mut stream = TcpStream::connect(server).await?; + stream_relay::write_register(&mut stream, &state.node_id).await?; + stream_relay::write_send(&mut stream, &state.node_id, peer_id, payload).await?; + Ok(()) +} + +async fn relay_stream_send_raw_any( + state: &ClientState, + servers: &[String], + peer_id: &str, + payload: &[u8], +) -> Result<()> { + if servers.is_empty() { + return Err(anyhow!("no stream relay server configured")); + } + let mut last_err = None; + for server in servers { + match relay_stream_send_raw(state, server, peer_id, payload).await { + Ok(()) => return Ok(()), + Err(err) => last_err = Some(err), + } + } + Err(last_err.unwrap_or_else(|| anyhow!("no stream relay server configured"))) +} + +async fn handle_probe_requests(netmap: &model::NetMap) -> Result<()> { + if netmap.probe_requests.is_empty() { + return Ok(()); + } + let mut v4_socket: Option = None; + let mut v6_socket: Option = None; + for request in &netmap.probe_requests { + if request.endpoints.is_empty() { + probe_ip(&mut v4_socket, &mut v6_socket, &request.ipv4).await; + probe_ip(&mut v4_socket, &mut v6_socket, &request.ipv6).await; + continue; + } + for endpoint in &request.endpoints { + let addr: SocketAddr = match endpoint.parse() { + Ok(addr) => addr, + Err(_) => { + eprintln!( + "probe request from {} had invalid endpoint {}", + request.peer_id, endpoint + ); + continue; + } + }; + probe_addr(&mut v4_socket, &mut v6_socket, addr).await?; + } + } + Ok(()) +} + +async fn probe_ip( + v4_socket: &mut Option, + v6_socket: &mut Option, + address: &str, +) { + let ip: IpAddr = match address.parse() { + Ok(ip) => ip, + Err(_) => { + eprintln!("probe request skipped invalid address {}", address); + return; + } + }; + let target = SocketAddr::new(ip, 9); + if let Err(err) = probe_addr(v4_socket, v6_socket, target).await { + eprintln!("probe request failed for {}: {}", target, err); + } +} + +async fn probe_addr( + v4_socket: &mut Option, + v6_socket: &mut Option, + target: SocketAddr, +) -> Result<()> { + let socket = match target { + SocketAddr::V4(_) => { + if v4_socket.is_none() { + *v4_socket = Some(UdpSocket::bind("0.0.0.0:0").await?); + } + v4_socket.as_ref().unwrap() + } + SocketAddr::V6(_) => { + if v6_socket.is_none() { + *v6_socket = Some(UdpSocket::bind("[::]:0").await?); + } + v6_socket.as_ref().unwrap() + } + }; + let _ = socket.send_to(b"lightscale-probe", target).await; + Ok(()) +} + +async fn ensure_netmap( + control_urls: &[String], + tls_pin: Option, + state: &mut ClientState, + state_path: &PathBuf, +) -> Result { + if let Some(netmap) = state.last_netmap.clone() { + return Ok(netmap); + } + let client = ControlClient::new( + control_urls.to_vec(), + tls_pin, + state.node_token.clone(), + None, + )?; + let netmap = client + .netmap(&state.node_id) + .await + .context("netmap fetch failed")?; + state.last_netmap = Some(netmap.clone()); + state.updated_at = now_unix(); + save_state(state_path, state)?; + Ok(netmap) +} + +fn print_return_route_guidance( + netmap: &model::NetMap, + wg_interface: &str, + out_interface: &str, + lan_v4: Option, + lan_v6: Option, +) { + println!("snat disabled; ensure return routes exist on the LAN:"); + if let Some(lan_v4) = lan_v4 { + println!(" {} -> {}", netmap.network.overlay_v4, lan_v4); + } else { + println!(" {} -> ", netmap.network.overlay_v4); + } + + if let Some(lan_v6) = lan_v6 { + println!(" {} -> {}", netmap.network.overlay_v6, lan_v6); + } else { + println!(" {} -> ", netmap.network.overlay_v6); + } + println!( + " # traffic from {} will be forwarded out {}", + wg_interface, out_interface + ); +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..3d42b3a --- /dev/null +++ b/src/model.rs @@ -0,0 +1,314 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Route { + pub prefix: String, + pub kind: RouteKind, + pub enabled: bool, + #[serde(default)] + pub mapped_prefix: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RouteKind { + Subnet, + Exit, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct NetworkInfo { + pub id: String, + pub name: String, + pub overlay_v4: String, + pub overlay_v6: String, + pub dns_domain: String, + #[serde(default)] + pub requires_approval: bool, + #[serde(default)] + pub key_rotation_max_age_seconds: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct NodeInfo { + pub id: String, + pub name: String, + pub dns_name: String, + pub ipv4: String, + pub ipv6: String, + pub wg_public_key: String, + pub machine_public_key: String, + pub endpoints: Vec, + pub tags: Vec, + pub routes: Vec, + pub last_seen: i64, + #[serde(default = "default_true")] + pub approved: bool, + #[serde(default)] + pub key_rotation_required: bool, + #[serde(default)] + pub revoked: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct PeerInfo { + pub id: String, + pub name: String, + pub dns_name: String, + pub ipv4: String, + pub ipv6: String, + pub wg_public_key: String, + pub endpoints: Vec, + pub tags: Vec, + pub routes: Vec, + pub last_seen: i64, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct NetMap { + pub network: NetworkInfo, + pub node: NodeInfo, + pub peers: Vec, + pub relay: Option, + #[serde(default)] + pub probe_requests: Vec, + pub generated_at: i64, + #[serde(default)] + pub revision: u64, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ProbeRequest { + pub peer_id: String, + pub endpoints: Vec, + pub ipv4: String, + pub ipv6: String, + pub requested_at: i64, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct RelayConfig { + pub stun_servers: Vec, + pub turn_servers: Vec, + #[serde(default)] + pub stream_relay_servers: Vec, + #[serde(default)] + pub udp_relay_servers: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RegisterRequest { + pub token: String, + pub node_name: String, + pub machine_public_key: String, + pub wg_public_key: String, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RegisterResponse { + pub node_token: String, + pub netmap: NetMap, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RegisterUrlRequest { + pub network_id: String, + pub node_name: String, + pub machine_public_key: String, + pub wg_public_key: String, + pub ttl_seconds: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RegisterUrlResponse { + pub node_id: String, + pub network_id: String, + pub ipv4: String, + pub ipv6: String, + pub auth_path: String, + pub expires_at: i64, + pub node_token: String, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct EnrollmentToken { + pub token: String, + pub expires_at: i64, + pub uses_left: u32, + pub tags: Vec, + pub revoked_at: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CreateTokenRequest { + pub ttl_seconds: u64, + pub uses: u32, + pub tags: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CreateTokenResponse { + pub token: EnrollmentToken, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct AdminNodesResponse { + pub nodes: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ApproveNodeResponse { + pub node_id: String, + pub approved: bool, + pub approved_at: Option, +} + +fn default_true() -> bool { + true +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct HeartbeatRequest { + pub node_id: String, + pub endpoints: Vec, + pub listen_port: Option, + pub routes: Vec, + #[serde(default)] + pub probe: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct HeartbeatResponse { + pub netmap: NetMap, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct AclPolicy { + #[serde(default)] + pub default_action: AclAction, + #[serde(default)] + pub rules: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AclAction { + Allow, + Deny, +} + +impl Default for AclAction { + fn default() -> Self { + Self::Allow + } +} + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct AclSelector { + #[serde(default)] + pub any: bool, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub node_ids: Vec, + #[serde(default)] + pub names: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct AclRule { + pub action: AclAction, + #[serde(default)] + pub src: AclSelector, + #[serde(default)] + pub dst: AclSelector, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct UpdateAclRequest { + pub policy: AclPolicy, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct UpdateAclResponse { + pub policy: AclPolicy, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct UpdateNodeRequest { + pub name: Option, + pub tags: Option>, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct UpdateNodeResponse { + pub node: NodeInfo, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct KeyRotationPolicy { + #[serde(default)] + pub max_age_seconds: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct KeyPolicyResponse { + pub policy: KeyRotationPolicy, +} + +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum KeyType { + Machine, + WireGuard, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct KeyRecord { + pub key_type: KeyType, + pub public_key: String, + pub created_at: i64, + #[serde(default)] + pub revoked_at: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct KeyRotationRequest { + pub machine_public_key: Option, + pub wg_public_key: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct KeyRotationResponse { + pub node_id: String, + pub machine_public_key: String, + pub wg_public_key: String, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct KeyHistoryResponse { + pub node_id: String, + pub keys: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RevokeNodeResponse { + pub node_id: String, + pub revoked_at: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct AuditEntry { + pub id: String, + pub timestamp: i64, + pub network_id: Option, + pub node_id: Option, + pub action: String, + #[serde(default)] + pub detail: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct AuditLogResponse { + pub entries: Vec, +} diff --git a/src/netlink.rs b/src/netlink.rs new file mode 100644 index 0000000..a4b6e2c --- /dev/null +++ b/src/netlink.rs @@ -0,0 +1,486 @@ +use anyhow::{anyhow, Context, Result}; +use ipnet::IpNet; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::time::Duration; + +#[cfg(target_os = "linux")] +mod imp { + use super::*; + use futures_util::stream::TryStreamExt; + use netlink_packet_route::address::AddressAttribute; + use netlink_packet_route::{ + link::LinkAttribute, + route::{RouteAddress, RouteAttribute, RouteMessage}, + rule::{RuleAttribute, RuleUidRange}, + AddressFamily, + }; + use rtnetlink::{new_connection, Handle, LinkUnspec, RouteMessageBuilder}; + use std::time::Instant; + use tokio::time::sleep; + + #[derive(Clone)] + pub struct Netlink { + handle: Handle, + } + + #[derive(Debug, Clone)] + pub struct RouteEntry { + pub prefix: IpNet, + pub oif: Option, + } + + #[derive(Debug, Clone)] + pub struct InterfaceAddress { + pub addr: IpAddr, + #[allow(dead_code)] + pub prefix: u8, + } + + impl Netlink { + pub async fn new() -> Result { + let (connection, handle, _) = + new_connection().context("failed to open netlink connection")?; + tokio::spawn(connection); + Ok(Netlink { handle }) + } + + pub async fn link_index(&self, name: &str) -> Result> { + let mut links = self + .handle + .link() + .get() + .match_name(name.to_string()) + .execute(); + if let Some(link) = links.try_next().await? { + return Ok(Some(link.header.index)); + } + Ok(None) + } + + pub async fn link_name(&self, index: u32) -> Result> { + let mut links = self.handle.link().get().match_index(index).execute(); + if let Some(link) = links.try_next().await? { + for attr in link.attributes { + if let LinkAttribute::IfName(name) = attr { + return Ok(Some(name)); + } + } + } + Ok(None) + } + + pub async fn wait_for_link(&self, name: &str, timeout: Duration) -> Result { + let start = Instant::now(); + loop { + if let Some(index) = self.link_index(name).await? { + return Ok(index); + } + if start.elapsed() > timeout { + return Err(anyhow!("interface {} did not appear", name)); + } + sleep(Duration::from_millis(100)).await; + } + } + + pub async fn set_link_up(&self, index: u32) -> Result<()> { + let link = LinkUnspec::new_with_index(index).up().build(); + self.handle.link().set(link).execute().await?; + Ok(()) + } + + pub async fn interface_addresses(&self, index: u32) -> Result> { + let mut addresses = self + .handle + .address() + .get() + .set_link_index_filter(index) + .execute(); + let mut results = Vec::new(); + while let Some(msg) = addresses.try_next().await? { + let mut selected = None; + for attr in msg.attributes { + match attr { + AddressAttribute::Local(addr) => { + selected = Some(addr); + break; + } + AddressAttribute::Address(addr) => { + if selected.is_none() { + selected = Some(addr); + } + } + _ => {} + } + } + let Some(addr) = selected else { + continue; + }; + results.push(InterfaceAddress { + addr, + prefix: msg.header.prefix_len, + }); + } + Ok(results) + } + + pub async fn replace_address( + &self, + index: u32, + address: IpAddr, + prefix: u8, + ) -> Result<()> { + self.handle + .address() + .add(index, address, prefix) + .replace() + .execute() + .await?; + Ok(()) + } + + pub async fn replace_route(&self, prefix: IpNet, index: u32) -> Result<()> { + self.replace_route_with_metric(prefix, index, None).await + } + + pub async fn replace_route_with_metric( + &self, + prefix: IpNet, + index: u32, + metric: Option, + ) -> Result<()> { + match prefix { + IpNet::V4(net) => { + let mut builder = RouteMessageBuilder::::new() + .destination_prefix(net.network(), net.prefix_len()) + .output_interface(index); + if let Some(metric) = metric { + builder = builder.priority(metric); + } + let route = builder.build(); + self.handle.route().add(route).replace().execute().await?; + } + IpNet::V6(net) => { + let mut builder = RouteMessageBuilder::::new() + .destination_prefix(net.network(), net.prefix_len()) + .output_interface(index); + if let Some(metric) = metric { + builder = builder.priority(metric); + } + let route = builder.build(); + self.handle.route().add(route).replace().execute().await?; + } + } + Ok(()) + } + + pub async fn replace_route_with_metric_table( + &self, + prefix: IpNet, + index: u32, + metric: Option, + table: u32, + ) -> Result<()> { + match prefix { + IpNet::V4(net) => { + let mut builder = RouteMessageBuilder::::new() + .destination_prefix(net.network(), net.prefix_len()) + .output_interface(index) + .table_id(table); + if let Some(metric) = metric { + builder = builder.priority(metric); + } + let route = builder.build(); + self.handle.route().add(route).replace().execute().await?; + } + IpNet::V6(net) => { + let mut builder = RouteMessageBuilder::::new() + .destination_prefix(net.network(), net.prefix_len()) + .output_interface(index) + .table_id(table); + if let Some(metric) = metric { + builder = builder.priority(metric); + } + let route = builder.build(); + self.handle.route().add(route).replace().execute().await?; + } + } + Ok(()) + } + + pub async fn add_rule_for_prefix( + &self, + prefix: IpNet, + table: u32, + priority: u32, + ) -> Result<()> { + match prefix { + IpNet::V4(net) => { + self.handle + .rule() + .add() + .table_id(table) + .priority(priority) + .v4() + .destination_prefix(net.network(), net.prefix_len()) + .replace() + .execute() + .await?; + } + IpNet::V6(net) => { + self.handle + .rule() + .add() + .table_id(table) + .priority(priority) + .v6() + .destination_prefix(net.network(), net.prefix_len()) + .replace() + .execute() + .await?; + } + } + Ok(()) + } + + pub async fn add_uid_rule_v4( + &self, + table: u32, + priority: u32, + start: u32, + end: u32, + ) -> Result<()> { + let mut req = self + .handle + .rule() + .add() + .table_id(table) + .priority(priority) + .v4() + .replace(); + req.message_mut() + .attributes + .push(RuleAttribute::UidRange(RuleUidRange { start, end })); + req.execute().await?; + Ok(()) + } + + pub async fn add_uid_rule_v6( + &self, + table: u32, + priority: u32, + start: u32, + end: u32, + ) -> Result<()> { + let mut req = self + .handle + .rule() + .add() + .table_id(table) + .priority(priority) + .v6() + .replace(); + req.message_mut() + .attributes + .push(RuleAttribute::UidRange(RuleUidRange { start, end })); + req.execute().await?; + Ok(()) + } + + pub async fn delete_link(&self, name: &str) -> Result<()> { + let mut links = self + .handle + .link() + .get() + .match_name(name.to_string()) + .execute(); + if let Some(link) = links.try_next().await? { + self.handle.link().del(link.header.index).execute().await?; + } + Ok(()) + } + + pub async fn list_routes(&self) -> Result> { + let mut entries = Vec::new(); + entries.extend(self.list_routes_v4().await?); + entries.extend(self.list_routes_v6().await?); + Ok(entries) + } + + async fn list_routes_v4(&self) -> Result> { + let mut entries = Vec::new(); + let route = RouteMessageBuilder::::new().build(); + let mut routes = self.handle.route().get(route).execute(); + while let Some(route) = routes.try_next().await? { + if let Some(entry) = parse_route_message(route) { + entries.push(entry); + } + } + Ok(entries) + } + + async fn list_routes_v6(&self) -> Result> { + let mut entries = Vec::new(); + let route = RouteMessageBuilder::::new().build(); + let mut routes = self.handle.route().get(route).execute(); + while let Some(route) = routes.try_next().await? { + if let Some(entry) = parse_route_message(route) { + entries.push(entry); + } + } + Ok(entries) + } + } + + fn parse_route_message(route: RouteMessage) -> Option { + let family = route.header.address_family; + let prefix_len = route.header.destination_prefix_length; + let mut destination = None; + let mut oif = None; + + for attr in route.attributes { + match attr { + RouteAttribute::Destination(RouteAddress::Inet(addr)) => { + destination = Some(IpAddr::V4(addr)); + } + RouteAttribute::Destination(RouteAddress::Inet6(addr)) => { + destination = Some(IpAddr::V6(addr)); + } + RouteAttribute::Oif(index) => { + oif = Some(index); + } + _ => {} + } + } + + let addr = match (destination, family) { + (Some(addr), _) => addr, + (None, AddressFamily::Inet) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + (None, AddressFamily::Inet6) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + _ => return None, + }; + + let prefix = IpNet::new(addr, prefix_len).ok()?; + Some(RouteEntry { prefix, oif }) + } +} + +#[cfg(target_os = "linux")] +pub use imp::{InterfaceAddress, Netlink, RouteEntry}; + +#[cfg(not(target_os = "linux"))] +mod imp { + use super::*; + + #[derive(Clone)] + pub struct Netlink; + + #[derive(Debug, Clone)] + pub struct RouteEntry { + pub prefix: IpNet, + pub oif: Option, + } + + #[derive(Debug, Clone)] + pub struct InterfaceAddress { + pub addr: IpAddr, + pub prefix: u8, + } + + impl Netlink { + pub async fn new() -> Result { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn link_index(&self, _name: &str) -> Result> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn link_name(&self, _index: u32) -> Result> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn wait_for_link(&self, _name: &str, _timeout: Duration) -> Result { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn set_link_up(&self, _index: u32) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn interface_addresses(&self, _index: u32) -> Result> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn replace_address( + &self, + _index: u32, + _address: IpAddr, + _prefix: u8, + ) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn replace_route(&self, _prefix: IpNet, _index: u32) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn replace_route_with_metric( + &self, + _prefix: IpNet, + _index: u32, + _metric: Option, + ) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn replace_route_with_metric_table( + &self, + _prefix: IpNet, + _index: u32, + _metric: Option, + _table: u32, + ) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn add_rule_for_prefix( + &self, + _prefix: IpNet, + _table: u32, + _priority: u32, + ) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn add_uid_rule_v4( + &self, + _table: u32, + _priority: u32, + _start: u32, + _end: u32, + ) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn add_uid_rule_v6( + &self, + _table: u32, + _priority: u32, + _start: u32, + _end: u32, + ) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn delete_link(&self, _name: &str) -> Result<()> { + Err(anyhow!("netlink is only supported on linux")) + } + + pub async fn list_routes(&self) -> Result> { + Err(anyhow!("netlink is only supported on linux")) + } + } +} + +#[cfg(not(target_os = "linux"))] +pub use imp::{InterfaceAddress, Netlink, RouteEntry}; diff --git a/src/relay_tunnel.rs b/src/relay_tunnel.rs new file mode 100644 index 0000000..d1227be --- /dev/null +++ b/src/relay_tunnel.rs @@ -0,0 +1,178 @@ +use crate::model::PeerInfo; +use crate::stream_relay; +use anyhow::Result; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use tokio::net::{TcpStream, UdpSocket}; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +const DATA_MAGIC: &[u8; 4] = b"LSDP"; +const RECONNECT_DELAY: Duration = Duration::from_secs(2); + +pub struct RelayTunnelManager { + node_id: String, + servers: Vec, + wg_listen_port: u16, + relay_ip: Option, + tunnels: HashMap, +} + +struct RelayTunnel { + local_addr: SocketAddr, + _task: JoinHandle<()>, +} + +impl RelayTunnelManager { + pub fn new( + node_id: String, + servers: Vec, + wg_listen_port: u16, + relay_ip: Option, + ) -> Self { + Self { + node_id, + servers, + wg_listen_port, + relay_ip, + tunnels: HashMap::new(), + } + } + + pub async fn ensure_for_peers( + &mut self, + peers: &[PeerInfo], + ) -> Result> { + let mut endpoints = HashMap::new(); + for peer in peers { + let addr = self.ensure_peer(&peer.id).await?; + endpoints.insert(peer.id.clone(), addr); + } + Ok(endpoints) + } + + async fn ensure_peer(&mut self, peer_id: &str) -> Result { + if let Some(tunnel) = self.tunnels.get(peer_id) { + return Ok(tunnel.local_addr); + } + + let mut relay_ip = self + .relay_ip + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); + let socket = match UdpSocket::bind(SocketAddr::new(relay_ip, 0)).await { + Ok(socket) => socket, + Err(_) => { + relay_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + UdpSocket::bind(SocketAddr::new(relay_ip, 0)).await? + } + }; + let local_addr = SocketAddr::new(relay_ip, socket.local_addr()?.port()); + let node_id = self.node_id.clone(); + let servers = self.servers.clone(); + let peer_id_owned = peer_id.to_string(); + let wg_listen_port = self.wg_listen_port; + + let task = tokio::spawn(async move { + run_tunnel(node_id, peer_id_owned, servers, socket, wg_listen_port).await; + }); + + self.tunnels.insert( + peer_id.to_string(), + RelayTunnel { + local_addr, + _task: task, + }, + ); + + Ok(local_addr) + } +} + +async fn run_tunnel( + node_id: String, + peer_id: String, + servers: Vec, + socket: UdpSocket, + wg_listen_port: u16, +) { + if servers.is_empty() { + eprintln!("stream relay tunnel missing servers"); + return; + } + let wg_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), wg_listen_port); + let mut buf = vec![0u8; 65535]; + let mut server_index: usize = 0; + loop { + let server = servers[server_index % servers.len()].clone(); + match TcpStream::connect(&server).await { + Ok(mut stream) => { + if let Err(err) = stream_relay::write_register(&mut stream, &node_id).await { + eprintln!("stream relay register failed: {}", err); + sleep(RECONNECT_DELAY).await; + continue; + } + eprintln!("relay tunnel {} connected to {} for {}", node_id, server, peer_id); + let mut saw_send = false; + let mut saw_recv = false; + loop { + tokio::select! { + recv = socket.recv_from(&mut buf) => { + let (len, _) = match recv { + Ok(data) => data, + Err(_) => break, + }; + if !saw_send { + eprintln!("relay tunnel {} -> {} forwarding {} bytes", node_id, peer_id, len); + saw_send = true; + } + let payload = wrap_data(&buf[..len]); + if stream_relay::write_send(&mut stream, &node_id, &peer_id, &payload).await.is_err() { + break; + } + } + deliver = stream_relay::read_deliver(&mut stream) => { + let delivered = match deliver { + Ok(Some(data)) => data, + Ok(None) => continue, + Err(_) => break, + }; + if delivered.0 != peer_id { + continue; + } + let payload = match unwrap_data(&delivered.1) { + Some(payload) => payload, + None => continue, + }; + if !saw_recv { + eprintln!("relay tunnel {} <- {} received {} bytes", node_id, peer_id, payload.len()); + saw_recv = true; + } + let _ = socket.send_to(payload, wg_addr).await; + } + } + } + } + Err(err) => { + eprintln!("stream relay tunnel connect failed: {}", err); + } + } + + server_index = server_index.wrapping_add(1); + sleep(RECONNECT_DELAY).await; + } +} + +fn wrap_data(payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(DATA_MAGIC.len() + payload.len()); + out.extend_from_slice(DATA_MAGIC); + out.extend_from_slice(payload); + out +} + +fn unwrap_data(payload: &[u8]) -> Option<&[u8]> { + if payload.starts_with(DATA_MAGIC) { + Some(&payload[DATA_MAGIC.len()..]) + } else { + None + } +} diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..aec116d --- /dev/null +++ b/src/router.rs @@ -0,0 +1,167 @@ +use crate::firewall; +use crate::netlink::{InterfaceAddress, Netlink, RouteEntry}; +use anyhow::{anyhow, Context, Result}; +use ipnet::IpNet; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::path::Path; + +pub async fn resolve_out_interface(out_interface: Option) -> Result { + if let Some(name) = out_interface { + return Ok(name); + } + default_out_interface().await +} + +pub async fn enable_forwarding(wg_interface: &str, out_interface: &str, snat: bool) -> Result<()> { + write_sysctl("/proc/sys/net/ipv4/ip_forward", "1")?; + write_sysctl("/proc/sys/net/ipv6/conf/all/forwarding", "1")?; + + let netlink = Netlink::new().await?; + netlink + .link_index(wg_interface) + .await? + .ok_or_else(|| anyhow!("interface {} not found", wg_interface))?; + netlink + .link_index(out_interface) + .await? + .ok_or_else(|| anyhow!("interface {} not found", out_interface))?; + + firewall::reset_tables()?; + firewall::apply_forwarding_rules(wg_interface, out_interface)?; + if snat { + firewall::apply_snat(out_interface)?; + } + + Ok(()) +} + +pub async fn disable_forwarding(_wg_interface: &str, _out_interface: &str) -> Result<()> { + firewall::reset_tables() +} + +pub async fn apply_route_maps( + wg_interface: &str, + out_interface: &str, + maps: &[(String, String)], +) -> Result<()> { + let mut parsed = Vec::new(); + for (real, mapped) in maps { + let real_net: IpNet = real + .parse() + .with_context(|| format!("invalid route map prefix {}", real))?; + let mapped_net: IpNet = mapped + .parse() + .with_context(|| format!("invalid route map prefix {}", mapped))?; + let real_v4 = matches!(real_net, IpNet::V4(_)); + let mapped_v4 = matches!(mapped_net, IpNet::V4(_)); + if real_v4 != mapped_v4 { + return Err(anyhow!( + "route map ip versions must match ({} vs {})", + real, + mapped + )); + } + if real_net.prefix_len() != mapped_net.prefix_len() { + return Err(anyhow!( + "route map prefix lengths must match ({} vs {})", + real, + mapped + )); + } + parsed.push((real_net, mapped_net)); + } + firewall::apply_netmap(wg_interface, out_interface, &parsed)?; + Ok(()) +} + +pub async fn interface_ips(out_interface: &str) -> Result<(Option, Option)> { + let netlink = Netlink::new().await?; + let index = netlink + .link_index(out_interface) + .await? + .ok_or_else(|| anyhow!("interface {} not found", out_interface))?; + let addrs = netlink.interface_addresses(index).await?; + + let mut v4 = None; + let mut v6 = None; + for InterfaceAddress { addr, .. } in addrs { + match addr { + IpAddr::V4(ip) => { + if v4.is_none() && is_usable_ipv4(ip) { + v4 = Some(ip.to_string()); + } + } + IpAddr::V6(ip) => { + if v6.is_none() && is_usable_ipv6(ip) { + v6 = Some(ip.to_string()); + } + } + } + } + + Ok((v4, v6)) +} + +async fn default_out_interface() -> Result { + let netlink = Netlink::new().await?; + let routes = netlink.list_routes().await?; + let index = find_default_oif(&routes).ok_or_else(|| anyhow!("failed to detect default route interface"))?; + let name = netlink + .link_name(index) + .await? + .ok_or_else(|| anyhow!("default route interface not found"))?; + Ok(name) +} + +fn find_default_oif(routes: &[RouteEntry]) -> Option { + for entry in routes { + if let IpNet::V4(net) = entry.prefix { + if net.prefix_len() == 0 { + if let Some(oif) = entry.oif { + return Some(oif); + } + } + } + } + for entry in routes { + if let IpNet::V6(net) = entry.prefix { + if net.prefix_len() == 0 { + if let Some(oif) = entry.oif { + return Some(oif); + } + } + } + } + None +} + +fn write_sysctl(path: &str, value: &str) -> Result<()> { + if let Some(parent) = Path::new(path).parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(path, value) + .with_context(|| format!("failed to write sysctl {}", path))?; + Ok(()) +} + +fn is_usable_ipv4(ip: Ipv4Addr) -> bool { + if ip.is_loopback() { + return false; + } + let octets = ip.octets(); + if octets[0] == 169 && octets[1] == 254 { + return false; + } + true +} + +fn is_usable_ipv6(ip: Ipv6Addr) -> bool { + if ip.is_loopback() { + return false; + } + let seg0 = ip.segments()[0]; + if (seg0 & 0xffc0) == 0xfe80 { + return false; + } + true +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..76f9ebe --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,420 @@ +use crate::model::{NetMap, Route, RouteKind}; +use crate::netlink::{Netlink, RouteEntry}; +use anyhow::{anyhow, Result}; +use ipnet::IpNet; +use std::collections::{HashMap, HashSet}; + +pub struct RouteApplyConfig { + pub interface: String, + pub accept_exit_node: bool, + pub exit_node_id: Option, + pub exit_node_name: Option, + pub exit_node_policy: ExitNodePolicy, + pub exit_node_tag: Option, + pub exit_node_metric_base: u32, + pub exit_node_uid_range: Option, + pub allow_conflicts: bool, + pub route_table: Option, + pub route_rule_priority: u32, + pub exit_rule_priority: u32, + pub exit_uid_rule_priority: u32, +} + +#[derive(Clone, Copy, Debug)] +pub struct UidRange { + pub start: u32, + pub end: u32, +} + +#[derive(Clone, Copy, Debug)] +pub enum ExitNodePolicy { + First, + Latest, + Multi, +} + +pub async fn apply_advertised_routes(netmap: &NetMap, cfg: &RouteApplyConfig) -> Result<()> { + let netlink = Netlink::new().await?; + let interface_index = netlink + .link_index(&cfg.interface) + .await? + .ok_or_else(|| anyhow!("interface {} not found", cfg.interface))?; + let existing_routes = netlink.list_routes().await?; + let selected_exit_peers = select_exit_peers(netmap, cfg); + let selected_exit_ids: HashSet = + selected_exit_peers.iter().map(|peer| peer.peer_id.clone()).collect(); + let selected_exit_metrics: HashMap = selected_exit_peers + .iter() + .filter_map(|peer| peer.metric.map(|metric| (peer.peer_id.clone(), metric))) + .collect(); + let exit_requested = cfg.exit_node_id.is_some() || cfg.exit_node_name.is_some(); + let tag_filtered = cfg.exit_node_tag.is_some(); + if exit_requested && selected_exit_peers.is_empty() { + eprintln!("requested exit node not found; skipping exit routes"); + } + if tag_filtered && selected_exit_peers.is_empty() { + eprintln!("exit node tag filter matched no peers; skipping exit routes"); + } + let allow_exit_routes = if exit_requested || tag_filtered { + !selected_exit_peers.is_empty() + } else { + true + }; + let allow_multiple_exit = matches!(cfg.exit_node_policy, ExitNodePolicy::Multi); + let mut exit_v4_applied = false; + let mut exit_v6_applied = false; + let mut conflict_count = 0; + let mut skipped_exit = false; + let mut applied_routes: Vec = Vec::new(); + let mut exit_uid_rule_v4 = false; + let mut exit_uid_rule_v6 = false; + + for peer in &netmap.peers { + let is_exit_peer = selected_exit_ids.is_empty() || selected_exit_ids.contains(&peer.id); + let exit_metric = selected_exit_metrics.get(&peer.id).cloned(); + for route in &peer.routes { + if !route.enabled { + continue; + } + let apply_prefix = match route_apply_prefix(route) { + Ok(prefix) => prefix, + Err(err) => { + eprintln!( + "skipping route {} for peer {}: {}", + route.prefix, peer.id, err + ); + continue; + } + }; + match route.kind { + RouteKind::Subnet => { + if route_conflicts(apply_prefix, &existing_routes, interface_index) + || route_conflicts_with_applied(apply_prefix, &applied_routes) + { + conflict_count += 1; + if !cfg.allow_conflicts { + continue; + } + } + let net = apply_route( + apply_prefix, + interface_index, + &netlink, + None, + cfg.route_table, + ) + .await?; + applied_routes.push(net); + if let Some(table) = cfg.route_table { + netlink + .add_rule_for_prefix(net, table, cfg.route_rule_priority) + .await?; + } + } + RouteKind::Exit => { + if !cfg.accept_exit_node || !allow_exit_routes { + continue; + } + if !is_exit_peer { + skipped_exit = true; + continue; + } + if is_ipv6(apply_prefix) { + if exit_v6_applied && !allow_multiple_exit { + continue; + } + let net = apply_route( + apply_prefix, + interface_index, + &netlink, + exit_metric, + cfg.route_table, + ) + .await?; + applied_routes.push(net); + exit_v6_applied = true; + if let Some(table) = cfg.route_table { + if let Some(uid_range) = cfg.exit_node_uid_range { + if !exit_uid_rule_v6 { + netlink + .add_uid_rule_v6( + table, + cfg.exit_uid_rule_priority, + uid_range.start, + uid_range.end, + ) + .await?; + exit_uid_rule_v6 = true; + } + } else { + netlink + .add_rule_for_prefix(net, table, cfg.exit_rule_priority) + .await?; + } + } + } else { + if exit_v4_applied && !allow_multiple_exit { + continue; + } + let net = apply_route( + apply_prefix, + interface_index, + &netlink, + exit_metric, + cfg.route_table, + ) + .await?; + applied_routes.push(net); + exit_v4_applied = true; + if let Some(table) = cfg.route_table { + if let Some(uid_range) = cfg.exit_node_uid_range { + if !exit_uid_rule_v4 { + netlink + .add_uid_rule_v4( + table, + cfg.exit_uid_rule_priority, + uid_range.start, + uid_range.end, + ) + .await?; + exit_uid_rule_v4 = true; + } + } else { + netlink + .add_rule_for_prefix(net, table, cfg.exit_rule_priority) + .await?; + } + } + } + } + } + } + } + + if conflict_count > 0 { + eprintln!( + "skipped {} conflicting route(s) (use --allow-route-conflicts to force)", + conflict_count + ); + } + if skipped_exit { + eprintln!( + "exit node selection active; routes from other exit nodes were skipped" + ); + } + + Ok(()) +} + +pub fn selected_exit_peer_ids(netmap: &NetMap, cfg: &RouteApplyConfig) -> HashSet { + if !cfg.accept_exit_node { + return HashSet::new(); + } + let selected = select_exit_peers(netmap, cfg); + let exit_requested = cfg.exit_node_id.is_some() || cfg.exit_node_name.is_some(); + let tag_filtered = cfg.exit_node_tag.is_some(); + let allow_exit_routes = if exit_requested || tag_filtered { + !selected.is_empty() + } else { + true + }; + if !allow_exit_routes { + return HashSet::new(); + } + selected + .into_iter() + .map(|peer| peer.peer_id) + .collect() +} + +fn route_apply_prefix(route: &Route) -> Result<&str> { + let Some(mapped) = route.mapped_prefix.as_deref() else { + return Ok(&route.prefix); + }; + let real_net: IpNet = route.prefix.parse()?; + let mapped_net: IpNet = mapped.parse()?; + let real_v4 = matches!(real_net, IpNet::V4(_)); + let mapped_v4 = matches!(mapped_net, IpNet::V4(_)); + if real_v4 != mapped_v4 { + return Err(anyhow!("mapped prefix ip version mismatch")); + } + if real_net.prefix_len() != mapped_net.prefix_len() { + return Err(anyhow!("mapped prefix length mismatch")); + } + Ok(mapped) +} + +struct ExitPeerSelection { + peer_id: String, + metric: Option, +} + +fn select_exit_peers(netmap: &NetMap, cfg: &RouteApplyConfig) -> Vec { + let mut candidates: Vec<&crate::model::PeerInfo> = netmap + .peers + .iter() + .filter(|peer| { + peer.routes + .iter() + .any(|route| matches!(route.kind, RouteKind::Exit)) + }) + .collect(); + + if let Some(tag) = cfg.exit_node_tag.as_ref() { + candidates.retain(|peer| peer.tags.iter().any(|peer_tag| peer_tag == tag)); + } + + if let Some(id) = cfg.exit_node_id.as_ref() { + return candidates + .into_iter() + .find(|peer| &peer.id == id) + .map(|peer| vec![ExitPeerSelection { + peer_id: peer.id.clone(), + metric: None, + }]) + .unwrap_or_default(); + } + + if let Some(name) = cfg.exit_node_name.as_ref() { + return candidates + .into_iter() + .find(|peer| peer.name == *name) + .map(|peer| vec![ExitPeerSelection { + peer_id: peer.id.clone(), + metric: None, + }]) + .unwrap_or_default(); + } + + match cfg.exit_node_policy { + ExitNodePolicy::Latest => { + candidates.sort_by_key(|peer| peer.last_seen); + candidates + .last() + .map(|peer| ExitPeerSelection { + peer_id: peer.id.clone(), + metric: None, + }) + .into_iter() + .collect() + } + ExitNodePolicy::Multi => candidates + .into_iter() + .enumerate() + .map(|(idx, peer)| ExitPeerSelection { + peer_id: peer.id.clone(), + metric: Some(cfg.exit_node_metric_base.saturating_add(idx as u32)), + }) + .collect(), + ExitNodePolicy::First => candidates + .into_iter() + .next() + .map(|peer| ExitPeerSelection { + peer_id: peer.id.clone(), + metric: None, + }) + .into_iter() + .collect(), + } +} + +async fn apply_route( + prefix: &str, + interface_index: u32, + netlink: &Netlink, + metric: Option, + table: Option, +) -> Result { + let net: IpNet = prefix.parse()?; + match table { + Some(table) => { + netlink + .replace_route_with_metric_table(net, interface_index, metric, table) + .await?; + } + None => { + netlink + .replace_route_with_metric(net, interface_index, metric) + .await?; + } + } + Ok(net) +} + +fn route_conflicts(prefix: &str, existing: &[RouteEntry], interface_index: u32) -> bool { + let Ok(net) = prefix.parse::() else { + return false; + }; + existing.iter().any(|route| { + if route.oif == Some(interface_index) { + return false; + } + if route.prefix.prefix_len() == 0 { + return false; + } + nets_overlap(&net, &route.prefix) + }) +} + +fn nets_overlap(a: &IpNet, b: &IpNet) -> bool { + match (a, b) { + (IpNet::V4(a4), IpNet::V4(b4)) => ranges_overlap(v4_range(a4), v4_range(b4)), + (IpNet::V6(a6), IpNet::V6(b6)) => ranges_overlap(v6_range(a6), v6_range(b6)), + _ => false, + } +} + +fn v4_range(net: &ipnet::Ipv4Net) -> (u64, u64) { + let base = u64::from(u32::from(net.network())); + let host_bits = 32u32.saturating_sub(net.prefix_len() as u32); + let end = if host_bits == 32 { + u64::from(u32::MAX) + } else { + base + ((1u64 << host_bits) - 1) + }; + (base, end) +} + +fn v6_range(net: &ipnet::Ipv6Net) -> (u128, u128) { + let base = u128::from(net.network()); + let host_bits = 128u32.saturating_sub(net.prefix_len() as u32); + let end = if host_bits == 128 { + u128::MAX + } else { + base + ((1u128 << host_bits) - 1) + }; + (base, end) +} + +fn ranges_overlap(a: (T, T), b: (T, T)) -> bool { + a.0 <= b.1 && b.0 <= a.1 +} + +fn route_conflicts_with_applied(prefix: &str, applied: &[IpNet]) -> bool { + let Ok(net) = prefix.parse::() else { + return false; + }; + applied.iter().any(|other| nets_overlap(&net, other)) +} + +fn is_ipv6(prefix: &str) -> bool { + prefix.contains(':') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlaps_detected_for_subnets() { + let a: IpNet = "10.0.0.0/24".parse().unwrap(); + let b: IpNet = "10.0.0.128/25".parse().unwrap(); + assert!(nets_overlap(&a, &b)); + } + + #[test] + fn applied_conflict_detects_overlap() { + let applied: Vec = vec!["10.1.0.0/24".parse().unwrap()]; + assert!(route_conflicts_with_applied("10.1.0.128/25", &applied)); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..10de61a --- /dev/null +++ b/src/state.rs @@ -0,0 +1,49 @@ +use crate::model::NetMap; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct ClientState { + pub profile: String, + pub network_id: String, + pub node_id: String, + pub node_name: String, + pub machine_private_key: String, + pub machine_public_key: String, + pub wg_private_key: String, + pub wg_public_key: String, + #[serde(default)] + pub node_token: Option, + pub ipv4: String, + pub ipv6: String, + pub last_netmap: Option, + pub updated_at: i64, +} + +pub fn default_state_dir(profile: &str) -> Option { + // Keep state isolated per profile to allow future multi-network support. + dirs::data_dir().map(|dir| dir.join("lightscale").join(profile)) +} + +pub fn state_path(state_dir: &Path) -> PathBuf { + state_dir.join("state.json") +} + +pub fn load_state(path: &Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(contents) => Ok(Some(serde_json::from_str(&contents)?)), + Err(_) => Ok(None), + } +} + +pub fn save_state(path: &Path, state: &ClientState) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let json = serde_json::to_string_pretty(state)?; + std::fs::write(path, json)?; + Ok(()) +} diff --git a/src/stream_relay.rs b/src/stream_relay.rs new file mode 100644 index 0000000..d72a091 --- /dev/null +++ b/src/stream_relay.rs @@ -0,0 +1,96 @@ +use anyhow::{anyhow, Result}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const MAGIC: &[u8; 4] = b"LSR2"; +const TYPE_REGISTER: u8 = 1; +const TYPE_SEND: u8 = 2; +const TYPE_DELIVER: u8 = 3; +const HEADER_LEN: usize = 8; +const MAX_ID_LEN: usize = 64; +const MAX_FRAME_LEN: usize = 64 * 1024; + +pub async fn write_register(stream: &mut W, node_id: &str) -> Result<()> { + let packet = build_packet(TYPE_REGISTER, node_id, "", &[])?; + write_frame(stream, &packet).await +} + +pub async fn write_send( + stream: &mut W, + from_id: &str, + to_id: &str, + payload: &[u8], +) -> Result<()> { + let packet = build_packet(TYPE_SEND, from_id, to_id, payload)?; + write_frame(stream, &packet).await +} + +pub async fn read_deliver( + stream: &mut R, +) -> Result)>> { + let frame = read_frame(stream).await?; + Ok(parse_deliver(&frame)) +} + +async fn read_frame(stream: &mut R) -> Result> { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await?; + let len = u32::from_be_bytes(len_buf) as usize; + if len == 0 || len > MAX_FRAME_LEN { + return Err(anyhow!("invalid frame length {}", len)); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf).await?; + Ok(buf) +} + +async fn write_frame(stream: &mut W, body: &[u8]) -> Result<()> { + if body.is_empty() || body.len() > MAX_FRAME_LEN { + return Err(anyhow!("invalid frame length {}", body.len())); + } + let len = body.len() as u32; + stream.write_all(&len.to_be_bytes()).await?; + stream.write_all(body).await?; + Ok(()) +} + +fn parse_deliver(buf: &[u8]) -> Option<(String, Vec)> { + if buf.len() < HEADER_LEN { + return None; + } + if &buf[0..4] != MAGIC { + return None; + } + let msg_type = buf[4]; + if msg_type != TYPE_DELIVER { + return None; + } + let from_len = buf[5] as usize; + let to_len = buf[6] as usize; + if from_len > MAX_ID_LEN || to_len > MAX_ID_LEN || to_len != 0 { + return None; + } + let offset = HEADER_LEN; + if buf.len() < offset + from_len + to_len { + return None; + } + let from_end = offset + from_len; + let from_id = std::str::from_utf8(&buf[offset..from_end]).ok()?.to_string(); + let payload = buf[from_end..].to_vec(); + Some((from_id, payload)) +} + +fn build_packet(msg_type: u8, from_id: &str, to_id: &str, payload: &[u8]) -> Result> { + if from_id.len() > MAX_ID_LEN || to_id.len() > MAX_ID_LEN { + return Err(anyhow!("relay id too long")); + } + let mut buf = Vec::with_capacity(HEADER_LEN + from_id.len() + to_id.len() + payload.len()); + buf.extend_from_slice(MAGIC); + buf.push(msg_type); + buf.push(from_id.len() as u8); + buf.push(to_id.len() as u8); + buf.push(0); + buf.extend_from_slice(from_id.as_bytes()); + buf.extend_from_slice(to_id.as_bytes()); + buf.extend_from_slice(payload); + Ok(buf) +} diff --git a/src/stun.rs b/src/stun.rs new file mode 100644 index 0000000..d5b8dbc --- /dev/null +++ b/src/stun.rs @@ -0,0 +1,170 @@ +use anyhow::{anyhow, Context, Result}; +use rand::RngCore; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs, UdpSocket}; +use std::time::Duration; + +const MAGIC_COOKIE: u32 = 0x2112A442; +const BINDING_REQUEST: u16 = 0x0001; +const BINDING_SUCCESS: u16 = 0x0101; +const ATTR_MAPPED_ADDRESS: u16 = 0x0001; +const ATTR_XOR_MAPPED_ADDRESS: u16 = 0x0020; + +pub fn discover_endpoint(servers: &[String], bind_port: u16, timeout: Duration) -> Result { + let mut last_err: Option = None; + for server in servers { + match discover_endpoint_one(server, bind_port, timeout) { + Ok(addr) => return Ok(addr), + Err(err) => last_err = Some(err), + } + } + Err(last_err.unwrap_or_else(|| anyhow!("no stun servers provided"))) +} + +fn discover_endpoint_one(server: &str, bind_port: u16, timeout: Duration) -> Result { + let server_addr = resolve_server(server)?; + let bind_addr = match server_addr { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), bind_port), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), bind_port), + }; + + let socket = UdpSocket::bind(bind_addr).context("failed to bind stun socket")?; + socket + .set_read_timeout(Some(timeout)) + .context("failed to set stun timeout")?; + + let (transaction_id, request) = build_binding_request(); + socket + .send_to(&request, server_addr) + .context("failed to send stun request")?; + + let mut buf = [0u8; 1024]; + let (len, from) = socket.recv_from(&mut buf).context("stun recv failed")?; + if from != server_addr { + return Err(anyhow!("stun response from unexpected address")); + } + parse_binding_response(&buf[..len], &transaction_id) +} + +fn resolve_server(server: &str) -> Result { + server + .to_socket_addrs() + .context("failed to resolve stun server")? + .next() + .ok_or_else(|| anyhow!("stun server resolution returned no addresses")) +} + +fn build_binding_request() -> ([u8; 12], [u8; 20]) { + let mut transaction_id = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut transaction_id); + let mut buf = [0u8; 20]; + buf[0..2].copy_from_slice(&BINDING_REQUEST.to_be_bytes()); + buf[2..4].copy_from_slice(&0u16.to_be_bytes()); + buf[4..8].copy_from_slice(&MAGIC_COOKIE.to_be_bytes()); + buf[8..20].copy_from_slice(&transaction_id); + (transaction_id, buf) +} + +fn parse_binding_response(buf: &[u8], transaction_id: &[u8; 12]) -> Result { + if buf.len() < 20 { + return Err(anyhow!("stun response too short")); + } + + let msg_type = u16::from_be_bytes([buf[0], buf[1]]); + if msg_type != BINDING_SUCCESS { + return Err(anyhow!("unexpected stun response type {:04x}", msg_type)); + } + + let msg_len = u16::from_be_bytes([buf[2], buf[3]]) as usize; + if buf.len() < 20 + msg_len { + return Err(anyhow!("stun response length mismatch")); + } + + if buf[4..8] != MAGIC_COOKIE.to_be_bytes() { + return Err(anyhow!("stun response missing magic cookie")); + } + + if buf[8..20] != transaction_id[..] { + return Err(anyhow!("stun transaction id mismatch")); + } + + let mut offset = 20; + let end = 20 + msg_len; + while offset + 4 <= end { + let attr_type = u16::from_be_bytes([buf[offset], buf[offset + 1]]); + let attr_len = u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize; + offset += 4; + if offset + attr_len > end { + break; + } + let attr = &buf[offset..offset + attr_len]; + if attr_type == ATTR_XOR_MAPPED_ADDRESS { + if let Some(addr) = parse_xor_mapped(attr, transaction_id) { + return Ok(addr); + } + } else if attr_type == ATTR_MAPPED_ADDRESS { + if let Some(addr) = parse_mapped(attr) { + return Ok(addr); + } + } + offset += (attr_len + 3) & !3; + } + + Err(anyhow!("stun response missing mapped address")) +} + +fn parse_mapped(attr: &[u8]) -> Option { + if attr.len() < 4 { + return None; + } + let family = attr[1]; + let port = u16::from_be_bytes([attr[2], attr[3]]); + match family { + 0x01 => { + if attr.len() < 8 { + return None; + } + let addr = Ipv4Addr::new(attr[4], attr[5], attr[6], attr[7]); + Some(SocketAddr::new(IpAddr::V4(addr), port)) + } + 0x02 => { + if attr.len() < 20 { + return None; + } + let mut octets = [0u8; 16]; + octets.copy_from_slice(&attr[4..20]); + Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(octets)), port)) + } + _ => None, + } +} + +fn parse_xor_mapped(attr: &[u8], transaction_id: &[u8; 12]) -> Option { + if attr.len() < 4 { + return None; + } + let family = attr[1]; + let port = u16::from_be_bytes([attr[2], attr[3]]) ^ ((MAGIC_COOKIE >> 16) as u16); + match family { + 0x01 => { + if attr.len() < 8 { + return None; + } + let xaddr = u32::from_be_bytes([attr[4], attr[5], attr[6], attr[7]]) ^ MAGIC_COOKIE; + Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(xaddr)), port)) + } + 0x02 => { + if attr.len() < 20 { + return None; + } + let mut xor = [0u8; 16]; + xor[0..4].copy_from_slice(&MAGIC_COOKIE.to_be_bytes()); + xor[4..16].copy_from_slice(transaction_id); + let mut addr = [0u8; 16]; + for i in 0..16 { + addr[i] = attr[4 + i] ^ xor[i]; + } + Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(addr)), port)) + } + _ => None, + } +} diff --git a/src/turn.rs b/src/turn.rs new file mode 100644 index 0000000..d858114 --- /dev/null +++ b/src/turn.rs @@ -0,0 +1,495 @@ +use anyhow::{anyhow, Context, Result}; +use hmac::{Hmac, Mac}; +use md5::{Digest as Md5Digest, Md5}; +use rand::RngCore; +use sha1::Sha1; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; +use std::time::Duration; +use tokio::net::UdpSocket; +use tokio::time::timeout; + +type HmacSha1 = Hmac; + +const MAGIC_COOKIE: u32 = 0x2112A442; +const MSG_ALLOCATE_REQUEST: u16 = 0x0003; +const MSG_ALLOCATE_SUCCESS: u16 = 0x0103; +const MSG_ALLOCATE_ERROR: u16 = 0x0113; +const MSG_CREATE_PERMISSION_REQUEST: u16 = 0x0008; +const MSG_CREATE_PERMISSION_SUCCESS: u16 = 0x0108; +const MSG_SEND_INDICATION: u16 = 0x0016; +const MSG_DATA_INDICATION: u16 = 0x0017; + +const ATTR_USERNAME: u16 = 0x0006; +const ATTR_REALM: u16 = 0x0014; +const ATTR_NONCE: u16 = 0x0015; +const ATTR_REQUESTED_TRANSPORT: u16 = 0x0019; +const ATTR_XOR_RELAYED_ADDRESS: u16 = 0x0016; +const ATTR_XOR_MAPPED_ADDRESS: u16 = 0x0020; +const ATTR_XOR_PEER_ADDRESS: u16 = 0x0012; +const ATTR_DATA: u16 = 0x0013; +const ATTR_ERROR_CODE: u16 = 0x0009; +const ATTR_MESSAGE_INTEGRITY: u16 = 0x0008; + +#[derive(Clone, Debug)] +pub struct TurnCredentials { + pub username: String, + pub password: String, +} + +#[derive(Debug)] +pub struct TurnAllocation { + pub socket: UdpSocket, + pub server: SocketAddr, + pub relay_addr: SocketAddr, + #[allow(dead_code)] + pub mapped_addr: Option, + username: Option, + realm: Option, + nonce: Option, + key: Option>, +} + +pub async fn allocate( + server: &str, + creds: Option<&TurnCredentials>, + timeout_duration: Duration, +) -> Result { + let server_addr = resolve_server(server)?; + let bind_addr = match server_addr { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let socket = UdpSocket::bind(bind_addr) + .await + .context("failed to bind turn socket")?; + + let (transaction_id, request) = build_allocate_request(None, None, None); + socket.send_to(&request, server_addr).await?; + let response = recv_message(&socket, server_addr, timeout_duration).await?; + let parsed = parse_message(&response, Some(&transaction_id))?; + + match parsed.msg_type { + MSG_ALLOCATE_SUCCESS => { + let (relay_addr, mapped_addr) = extract_addresses(&parsed, &transaction_id)?; + return Ok(TurnAllocation { + socket, + server: server_addr, + relay_addr, + mapped_addr, + username: None, + realm: None, + nonce: None, + key: None, + }); + } + MSG_ALLOCATE_ERROR => { + let error_code = extract_error_code(&parsed); + if error_code == Some(401) || error_code == Some(438) { + let creds = creds.ok_or_else(|| anyhow!("turn auth required"))?; + let realm = extract_string(&parsed, ATTR_REALM) + .ok_or_else(|| anyhow!("turn realm missing"))?; + let nonce = extract_string(&parsed, ATTR_NONCE) + .ok_or_else(|| anyhow!("turn nonce missing"))?; + let key = build_long_term_key(creds, &realm)?; + let (transaction_id, request) = build_allocate_request( + Some(creds.username.as_str()), + Some(realm.as_str()), + Some(nonce.as_str()), + ); + let request = add_message_integrity(request, &key)?; + socket.send_to(&request, server_addr).await?; + let response = recv_message(&socket, server_addr, timeout_duration).await?; + let parsed = parse_message(&response, Some(&transaction_id))?; + if parsed.msg_type != MSG_ALLOCATE_SUCCESS { + return Err(anyhow!("turn allocate failed after auth")); + } + let (relay_addr, mapped_addr) = extract_addresses(&parsed, &transaction_id)?; + return Ok(TurnAllocation { + socket, + server: server_addr, + relay_addr, + mapped_addr, + username: Some(creds.username.clone()), + realm: Some(realm), + nonce: Some(nonce), + key: Some(key), + }); + } + } + _ => {} + } + + Err(anyhow!("turn allocate failed")) +} + +pub async fn create_permission( + allocation: &mut TurnAllocation, + peer: SocketAddr, + timeout_duration: Duration, +) -> Result<()> { + let (transaction_id, mut request) = build_create_permission_request(allocation, peer)?; + request = maybe_add_integrity(allocation, request)?; + allocation + .socket + .send_to(&request, allocation.server) + .await?; + let response = recv_message(&allocation.socket, allocation.server, timeout_duration).await?; + let parsed = parse_message(&response, Some(&transaction_id))?; + if parsed.msg_type == MSG_CREATE_PERMISSION_SUCCESS { + return Ok(()); + } + Err(anyhow!("turn create permission failed")) +} + +pub async fn send_data( + allocation: &mut TurnAllocation, + peer: SocketAddr, + data: &[u8], +) -> Result<()> { + let (transaction_id, request) = build_send_indication(peer, data)?; + let _ = transaction_id; + allocation + .socket + .send_to(&request, allocation.server) + .await?; + Ok(()) +} + +pub async fn recv_data( + allocation: &mut TurnAllocation, + timeout_duration: Option, +) -> Result)>> { + let mut buf = vec![0u8; 2048]; + let result = if let Some(timeout_duration) = timeout_duration { + timeout(timeout_duration, allocation.socket.recv_from(&mut buf)).await? + } else { + allocation.socket.recv_from(&mut buf).await + }; + + let (len, from) = result?; + if from != allocation.server { + return Ok(None); + } + let parsed = parse_message(&buf[..len], None)?; + if parsed.msg_type != MSG_DATA_INDICATION { + return Ok(None); + } + let transaction_id = parsed.transaction_id; + let peer = extract_xor_address(&parsed, ATTR_XOR_PEER_ADDRESS, &transaction_id) + .ok_or_else(|| anyhow!("turn data indication missing peer address"))?; + let data = extract_bytes(&parsed, ATTR_DATA).unwrap_or_default(); + Ok(Some((peer, data))) +} + +fn resolve_server(server: &str) -> Result { + server + .to_socket_addrs() + .context("failed to resolve turn server")? + .next() + .ok_or_else(|| anyhow!("turn server resolution returned no addresses")) +} + +fn random_transaction_id() -> [u8; 12] { + let mut id = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut id); + id +} + +fn build_allocate_request( + username: Option<&str>, + realm: Option<&str>, + nonce: Option<&str>, +) -> ([u8; 12], Vec) { + let transaction_id = random_transaction_id(); + let mut attrs = Vec::new(); + attrs.push(Attribute::new( + ATTR_REQUESTED_TRANSPORT, + vec![17, 0, 0, 0], + )); + if let Some(username) = username { + attrs.push(Attribute::new(ATTR_USERNAME, username.as_bytes().to_vec())); + } + if let Some(realm) = realm { + attrs.push(Attribute::new(ATTR_REALM, realm.as_bytes().to_vec())); + } + if let Some(nonce) = nonce { + attrs.push(Attribute::new(ATTR_NONCE, nonce.as_bytes().to_vec())); + } + let msg = build_message(MSG_ALLOCATE_REQUEST, &transaction_id, attrs); + (transaction_id, msg) +} + +fn build_create_permission_request( + allocation: &TurnAllocation, + peer: SocketAddr, +) -> Result<([u8; 12], Vec)> { + let transaction_id = random_transaction_id(); + let mut attrs = Vec::new(); + let xor_peer = encode_xor_address(peer, &transaction_id)?; + attrs.push(Attribute::new(ATTR_XOR_PEER_ADDRESS, xor_peer)); + if let Some(username) = allocation.username.as_ref() { + attrs.push(Attribute::new(ATTR_USERNAME, username.as_bytes().to_vec())); + } + if let Some(realm) = allocation.realm.as_ref() { + attrs.push(Attribute::new(ATTR_REALM, realm.as_bytes().to_vec())); + } + if let Some(nonce) = allocation.nonce.as_ref() { + attrs.push(Attribute::new(ATTR_NONCE, nonce.as_bytes().to_vec())); + } + let msg = build_message(MSG_CREATE_PERMISSION_REQUEST, &transaction_id, attrs); + Ok((transaction_id, msg)) +} + +fn build_send_indication(peer: SocketAddr, data: &[u8]) -> Result<([u8; 12], Vec)> { + let transaction_id = random_transaction_id(); + let xor_peer = encode_xor_address(peer, &transaction_id)?; + let attrs = vec![ + Attribute::new(ATTR_XOR_PEER_ADDRESS, xor_peer), + Attribute::new(ATTR_DATA, data.to_vec()), + ]; + let msg = build_message(MSG_SEND_INDICATION, &transaction_id, attrs); + Ok((transaction_id, msg)) +} + +fn build_message(msg_type: u16, transaction_id: &[u8; 12], attrs: Vec) -> Vec { + let mut body = Vec::new(); + for attr in attrs { + attr.write(&mut body); + } + let length = body.len() as u16; + let mut buf = Vec::with_capacity(20 + body.len()); + buf.extend_from_slice(&msg_type.to_be_bytes()); + buf.extend_from_slice(&length.to_be_bytes()); + buf.extend_from_slice(&MAGIC_COOKIE.to_be_bytes()); + buf.extend_from_slice(transaction_id); + buf.extend_from_slice(&body); + buf +} + +fn add_message_integrity(mut msg: Vec, key: &[u8]) -> Result> { + let current_len = u16::from_be_bytes([msg[2], msg[3]]); + let total_len = current_len.saturating_add(24); + msg[2..4].copy_from_slice(&total_len.to_be_bytes()); + let mi_offset = msg.len() + 4; + msg.extend_from_slice(&ATTR_MESSAGE_INTEGRITY.to_be_bytes()); + msg.extend_from_slice(&(20u16).to_be_bytes()); + msg.extend_from_slice(&vec![0u8; 20]); + + let mut mac = HmacSha1::new_from_slice(key).map_err(|_| anyhow!("invalid hmac key"))?; + mac.update(&msg); + let result = mac.finalize().into_bytes(); + msg[mi_offset..mi_offset + 20].copy_from_slice(&result); + Ok(msg) +} + +fn maybe_add_integrity(allocation: &TurnAllocation, msg: Vec) -> Result> { + if let Some(key) = allocation.key.as_ref() { + add_message_integrity(msg, key) + } else { + Ok(msg) + } +} + +fn build_long_term_key(creds: &TurnCredentials, realm: &str) -> Result> { + let mut hasher = Md5::new(); + let data = format!("{}:{}:{}", creds.username, realm, creds.password); + hasher.update(data.as_bytes()); + Ok(hasher.finalize().to_vec()) +} + +async fn recv_message( + socket: &UdpSocket, + server: SocketAddr, + timeout_duration: Duration, +) -> Result> { + let mut buf = vec![0u8; 2048]; + let (len, from) = timeout(timeout_duration, socket.recv_from(&mut buf)).await??; + if from != server { + return Err(anyhow!("unexpected turn response source")); + } + buf.truncate(len); + Ok(buf) +} + +#[derive(Clone)] +struct Attribute { + ty: u16, + value: Vec, +} + +impl Attribute { + fn new(ty: u16, value: Vec) -> Self { + Self { ty, value } + } + + fn write(&self, buf: &mut Vec) { + buf.extend_from_slice(&self.ty.to_be_bytes()); + buf.extend_from_slice(&(self.value.len() as u16).to_be_bytes()); + buf.extend_from_slice(&self.value); + let padding = (4 - (self.value.len() % 4)) % 4; + if padding > 0 { + buf.extend_from_slice(&vec![0u8; padding]); + } + } +} + +struct ParsedMessage { + msg_type: u16, + transaction_id: [u8; 12], + attrs: Vec, +} + +fn parse_message(buf: &[u8], expected_id: Option<&[u8; 12]>) -> Result { + if buf.len() < 20 { + return Err(anyhow!("turn message too short")); + } + let msg_type = u16::from_be_bytes([buf[0], buf[1]]); + let length = u16::from_be_bytes([buf[2], buf[3]]) as usize; + if buf[4..8] != MAGIC_COOKIE.to_be_bytes() { + return Err(anyhow!("turn message missing magic cookie")); + } + let mut transaction_id = [0u8; 12]; + transaction_id.copy_from_slice(&buf[8..20]); + if let Some(expected) = expected_id { + if &transaction_id != expected { + return Err(anyhow!("turn transaction id mismatch")); + } + } + if buf.len() < 20 + length { + return Err(anyhow!("turn message length mismatch")); + } + let mut attrs = Vec::new(); + let mut offset = 20; + let end = 20 + length; + while offset + 4 <= end { + let ty = u16::from_be_bytes([buf[offset], buf[offset + 1]]); + let len = u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize; + offset += 4; + if offset + len > end { + break; + } + let value = buf[offset..offset + len].to_vec(); + attrs.push(Attribute { ty, value }); + offset += (len + 3) & !3; + } + Ok(ParsedMessage { + msg_type, + transaction_id, + attrs, + }) +} + +fn extract_error_code(parsed: &ParsedMessage) -> Option { + for attr in &parsed.attrs { + if attr.ty != ATTR_ERROR_CODE || attr.value.len() < 4 { + continue; + } + let class = attr.value[2] & 0x07; + let number = attr.value[3]; + return Some((class as u16) * 100 + number as u16); + } + None +} + +fn extract_string(parsed: &ParsedMessage, attr_type: u16) -> Option { + for attr in &parsed.attrs { + if attr.ty == attr_type { + return String::from_utf8(attr.value.clone()).ok(); + } + } + None +} + +fn extract_bytes(parsed: &ParsedMessage, attr_type: u16) -> Option> { + for attr in &parsed.attrs { + if attr.ty == attr_type { + return Some(attr.value.clone()); + } + } + None +} + +fn extract_addresses( + parsed: &ParsedMessage, + transaction_id: &[u8; 12], +) -> Result<(SocketAddr, Option)> { + let relay = extract_xor_address(parsed, ATTR_XOR_RELAYED_ADDRESS, transaction_id) + .ok_or_else(|| anyhow!("turn allocate missing relay address"))?; + let mapped = extract_xor_address(parsed, ATTR_XOR_MAPPED_ADDRESS, transaction_id); + Ok((relay, mapped)) +} + +fn extract_xor_address( + parsed: &ParsedMessage, + attr_type: u16, + transaction_id: &[u8; 12], +) -> Option { + for attr in &parsed.attrs { + if attr.ty != attr_type { + continue; + } + if let Some(addr) = decode_xor_address(&attr.value, transaction_id) { + return Some(addr); + } + } + None +} + +fn decode_xor_address(value: &[u8], transaction_id: &[u8; 12]) -> Option { + if value.len() < 4 { + return None; + } + let family = value[1]; + let port = u16::from_be_bytes([value[2], value[3]]) ^ ((MAGIC_COOKIE >> 16) as u16); + match family { + 0x01 => { + if value.len() < 8 { + return None; + } + let xaddr = u32::from_be_bytes([value[4], value[5], value[6], value[7]]) ^ MAGIC_COOKIE; + Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(xaddr)), port)) + } + 0x02 => { + if value.len() < 20 { + return None; + } + let mut xor = [0u8; 16]; + xor[0..4].copy_from_slice(&MAGIC_COOKIE.to_be_bytes()); + xor[4..16].copy_from_slice(transaction_id); + let mut addr = [0u8; 16]; + for i in 0..16 { + addr[i] = value[4 + i] ^ xor[i]; + } + Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(addr)), port)) + } + _ => None, + } +} + +fn encode_xor_address(addr: SocketAddr, transaction_id: &[u8; 12]) -> Result> { + let mut value = Vec::new(); + value.push(0); + match addr { + SocketAddr::V4(addr) => { + value.push(0x01); + let port = addr.port() ^ ((MAGIC_COOKIE >> 16) as u16); + value.extend_from_slice(&port.to_be_bytes()); + let xaddr = u32::from(*addr.ip()) ^ MAGIC_COOKIE; + value.extend_from_slice(&xaddr.to_be_bytes()); + } + SocketAddr::V6(addr) => { + value.push(0x02); + let port = addr.port() ^ ((MAGIC_COOKIE >> 16) as u16); + value.extend_from_slice(&port.to_be_bytes()); + let mut xor = [0u8; 16]; + xor[0..4].copy_from_slice(&MAGIC_COOKIE.to_be_bytes()); + xor[4..16].copy_from_slice(transaction_id); + let mut ip_bytes = addr.ip().octets(); + for i in 0..16 { + ip_bytes[i] ^= xor[i]; + } + value.extend_from_slice(&ip_bytes); + } + } + Ok(value) +} diff --git a/src/udp_relay.rs b/src/udp_relay.rs new file mode 100644 index 0000000..57098e1 --- /dev/null +++ b/src/udp_relay.rs @@ -0,0 +1,73 @@ +use anyhow::{anyhow, Result}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; + +const MAGIC: &[u8; 4] = b"LSR1"; +const TYPE_REGISTER: u8 = 1; +const TYPE_SEND: u8 = 2; +const TYPE_DELIVER: u8 = 3; +const HEADER_LEN: usize = 8; +const MAX_ID_LEN: usize = 64; + +pub fn build_register(node_id: &str) -> Result> { + build_packet(TYPE_REGISTER, node_id, "", &[]) +} + +pub fn build_send(from_id: &str, to_id: &str, payload: &[u8]) -> Result> { + build_packet(TYPE_SEND, from_id, to_id, payload) +} + +pub fn parse_deliver(buf: &[u8]) -> Option<(String, Vec)> { + if buf.len() < HEADER_LEN { + return None; + } + if &buf[0..4] != MAGIC { + return None; + } + let msg_type = buf[4]; + if msg_type != TYPE_DELIVER { + return None; + } + let from_len = buf[5] as usize; + let to_len = buf[6] as usize; + if from_len > MAX_ID_LEN || to_len > MAX_ID_LEN || to_len != 0 { + return None; + } + let offset = HEADER_LEN; + if buf.len() < offset + from_len + to_len { + return None; + } + let from_end = offset + from_len; + let from_id = std::str::from_utf8(&buf[offset..from_end]).ok()?.to_string(); + let payload = buf[from_end..].to_vec(); + Some((from_id, payload)) +} + +pub fn resolve_server(server: &str) -> Result { + server + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow!("relay server resolution returned no addresses")) +} + +pub fn bind_addr_for(server: &SocketAddr) -> SocketAddr { + match server { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + } +} + +fn build_packet(msg_type: u8, from_id: &str, to_id: &str, payload: &[u8]) -> Result> { + if from_id.len() > MAX_ID_LEN || to_id.len() > MAX_ID_LEN { + return Err(anyhow!("relay id too long")); + } + let mut buf = Vec::with_capacity(HEADER_LEN + from_id.len() + to_id.len() + payload.len()); + buf.extend_from_slice(MAGIC); + buf.push(msg_type); + buf.push(from_id.len() as u8); + buf.push(to_id.len() as u8); + buf.push(0); + buf.extend_from_slice(from_id.as_bytes()); + buf.extend_from_slice(to_id.as_bytes()); + buf.extend_from_slice(payload); + Ok(buf) +} diff --git a/src/wg.rs b/src/wg.rs new file mode 100644 index 0000000..d5f378f --- /dev/null +++ b/src/wg.rs @@ -0,0 +1,529 @@ +use crate::model::{NetMap, Route, RouteKind}; +use crate::netlink::Netlink; +use crate::routes; +use crate::state::ClientState; +use anyhow::{anyhow, Context, Result}; +use boringtun::device::{DeviceConfig, DeviceHandle}; +use ipnet::IpNet; +use std::collections::{HashMap, HashSet}; +use std::net::{IpAddr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tokio::time::sleep; +use wireguard_control::{ + Backend as WgBackend, Device, DeviceUpdate, InterfaceName, Key, PeerConfigBuilder, +}; + +#[derive(Clone, Copy, Debug)] +pub enum Backend { + Kernel, + Boringtun, +} + +pub struct WgConfig { + pub interface: String, + pub listen_port: u16, + pub backend: Backend, +} + +static BORINGTUN_HANDLES: OnceLock>> = OnceLock::new(); + +#[derive(Default)] +pub struct EndpointTracker { + peers: HashMap, +} + +#[derive(Default)] +struct PeerEndpointState { + next_index: usize, + rotations: usize, + relay_active: bool, +} + +pub async fn apply( + netmap: &NetMap, + state: &ClientState, + cfg: &WgConfig, + routes_cfg: Option<&routes::RouteApplyConfig>, +) -> Result<()> { + let netlink = Netlink::new().await?; + let index = match cfg.backend { + Backend::Kernel => apply_kernel(netmap, state, cfg, routes_cfg, &netlink).await?, + Backend::Boringtun => apply_boringtun(netmap, state, cfg, routes_cfg, &netlink).await?, + }; + add_peer_routes(netmap, index, &netlink).await?; + Ok(()) +} + +pub async fn remove(interface: &str, backend: Backend) -> Result<()> { + let netlink = Netlink::new().await?; + match backend { + Backend::Kernel => { + netlink.delete_link(interface).await?; + } + Backend::Boringtun => { + stop_boringtun(interface); + let socket_path = userspace_socket_path(interface); + let _ = std::fs::remove_file(&socket_path); + netlink.delete_link(interface).await?; + } + } + Ok(()) +} + +pub fn probe_peers(netmap: &NetMap, timeout_seconds: u64) -> Result<()> { + let mut v4_socket: Option = None; + let mut v6_socket: Option = None; + for peer in &netmap.peers { + let mut probed = false; + for endpoint in &peer.endpoints { + match endpoint.parse::() { + Ok(addr) => { + probe_addr(&mut v4_socket, &mut v6_socket, addr, timeout_seconds); + probed = true; + } + Err(_) => { + eprintln!("probe skipped invalid endpoint {} for {}", endpoint, peer.id); + } + } + } + if !probed { + probe_ip(&mut v4_socket, &mut v6_socket, &peer.ipv4, timeout_seconds); + probe_ip(&mut v4_socket, &mut v6_socket, &peer.ipv6, timeout_seconds); + } + } + Ok(()) +} + +fn backend_for(backend: Backend) -> WgBackend { + match backend { + Backend::Kernel => WgBackend::Kernel, + Backend::Boringtun => WgBackend::Userspace, + } +} + +pub fn refresh_peer_endpoints( + netmap: &NetMap, + cfg: &WgConfig, + tracker: &mut EndpointTracker, + relay_endpoints: &HashMap, + stale_after: Duration, + max_rotations: usize, +) -> Result<()> { + let iface: InterfaceName = cfg + .interface + .parse() + .context("invalid interface name")?; + let backend = backend_for(cfg.backend); + let device = Device::get(&iface, backend).context("wireguard device query failed")?; + + let mut peer_info = HashMap::new(); + for info in device.peers { + peer_info.insert(info.config.public_key.to_base64(), info); + } + + let max_rotations = max_rotations.max(1); + let mut update = DeviceUpdate::new(); + let mut changed = false; + let mut desired_endpoints: HashMap = HashMap::new(); + + for peer in &netmap.peers { + let endpoints: Vec = peer + .endpoints + .iter() + .filter_map(|endpoint| endpoint.parse().ok()) + .collect(); + let info = peer_info.get(&peer.wg_public_key); + let handshake_stale = match info.and_then(|info| info.stats.last_handshake_time) { + Some(ts) => ts.elapsed().map(|age| age > stale_after).unwrap_or(true), + None => true, + }; + if !handshake_stale { + let state = tracker.peers.entry(peer.id.clone()).or_default(); + state.rotations = 0; + state.relay_active = false; + continue; + } + + let state = tracker.peers.entry(peer.id.clone()).or_default(); + let current_endpoint = info.and_then(|info| info.config.endpoint); + let mut desired_endpoint = None; + + if state.relay_active { + if let Some(relay) = relay_endpoints.get(&peer.id) { + desired_endpoint = Some(*relay); + } + } else if !endpoints.is_empty() { + let idx = state.next_index % endpoints.len(); + desired_endpoint = Some(endpoints[idx]); + state.next_index = (idx + 1) % endpoints.len(); + state.rotations = state.rotations.saturating_add(1); + if state.rotations >= max_rotations && relay_endpoints.contains_key(&peer.id) { + state.relay_active = true; + state.rotations = 0; + } + } else if let Some(relay) = relay_endpoints.get(&peer.id) { + state.relay_active = true; + state.rotations = 0; + desired_endpoint = Some(*relay); + } + + if let Some(desired) = desired_endpoint { + if Some(desired) != current_endpoint { + changed = true; + if backend == WgBackend::Userspace { + desired_endpoints.insert(peer.id.clone(), desired); + } else { + let peer_key = Key::from_base64(&peer.wg_public_key) + .with_context(|| format!("invalid peer public key {}", peer.id))?; + update = update.add_peer( + PeerConfigBuilder::new(&peer_key) + .set_endpoint(desired) + .set_persistent_keepalive_interval(25), + ); + } + } + } + } + + if changed { + if backend == WgBackend::Userspace { + let mut full_update = DeviceUpdate::new().replace_peers(); + for peer in &netmap.peers { + let info = peer_info.get(&peer.wg_public_key); + let mut builder = if let Some(info) = info { + PeerConfigBuilder::from_peer_config(info.config.clone()) + } else { + build_peer_builder_from_netmap(peer)? + }; + if let Some(desired) = desired_endpoints.get(&peer.id) { + builder = builder + .set_endpoint(*desired) + .set_persistent_keepalive_interval(25); + } + full_update = full_update.add_peer(builder); + } + full_update + .apply(&iface, backend) + .context("wireguard endpoint refresh failed")?; + } else { + update + .apply(&iface, backend) + .context("wireguard endpoint refresh failed")?; + } + } + + Ok(()) +} + +async fn apply_kernel( + netmap: &NetMap, + state: &ClientState, + cfg: &WgConfig, + routes_cfg: Option<&routes::RouteApplyConfig>, + netlink: &Netlink, +) -> Result { + apply_wireguard_config(netmap, state, cfg, routes_cfg, WgBackend::Kernel)?; + let index = netlink + .wait_for_link(&cfg.interface, Duration::from_secs(3)) + .await?; + configure_addresses(netlink, index, state).await?; + netlink.set_link_up(index).await?; + Ok(index) +} + +async fn apply_boringtun( + netmap: &NetMap, + state: &ClientState, + cfg: &WgConfig, + routes_cfg: Option<&routes::RouteApplyConfig>, + netlink: &Netlink, +) -> Result { + ensure_boringtun(&cfg.interface)?; + wait_for_userspace_socket(&cfg.interface, Duration::from_secs(3)).await?; + apply_wireguard_config(netmap, state, cfg, routes_cfg, WgBackend::Userspace)?; + let index = netlink + .wait_for_link(&cfg.interface, Duration::from_secs(3)) + .await?; + configure_addresses(netlink, index, state).await?; + netlink.set_link_up(index).await?; + Ok(index) +} + +async fn configure_addresses( + netlink: &Netlink, + index: u32, + state: &ClientState, +) -> Result<()> { + let ipv4 = parse_ip(&state.ipv4, "ipv4")?; + netlink.replace_address(index, ipv4, 32).await?; + let ipv6 = parse_ip(&state.ipv6, "ipv6")?; + netlink.replace_address(index, ipv6, 128).await?; + Ok(()) +} + +fn apply_wireguard_config( + netmap: &NetMap, + state: &ClientState, + cfg: &WgConfig, + routes_cfg: Option<&routes::RouteApplyConfig>, + backend: WgBackend, +) -> Result<()> { + let iface: InterfaceName = cfg + .interface + .parse() + .context("invalid interface name")?; + let update = build_device_update(netmap, state, cfg, routes_cfg)?; + update + .apply(&iface, backend) + .context("wireguard config apply failed")?; + Ok(()) +} + +fn build_device_update( + netmap: &NetMap, + state: &ClientState, + cfg: &WgConfig, + routes_cfg: Option<&routes::RouteApplyConfig>, +) -> Result { + let private_key = Key::from_base64(&state.wg_private_key) + .context("invalid wireguard private key")?; + let mut update = DeviceUpdate::new() + .set_private_key(private_key) + .set_listen_port(cfg.listen_port) + .replace_peers(); + let selected_exit_ids = routes_cfg + .map(|cfg| routes::selected_exit_peer_ids(netmap, cfg)) + .unwrap_or_default(); + + for peer in &netmap.peers { + let peer_key = + Key::from_base64(&peer.wg_public_key).context("invalid peer public key")?; + let ipv4: IpAddr = peer.ipv4.parse().context("invalid peer ipv4")?; + let ipv6: IpAddr = peer.ipv6.parse().context("invalid peer ipv6")?; + let mut allowed: HashSet = HashSet::new(); + allowed.insert(IpNet::new(ipv4, 32).context("invalid peer ipv4 prefix")?); + allowed.insert(IpNet::new(ipv6, 128).context("invalid peer ipv6 prefix")?); + let allow_exit = selected_exit_ids.contains(&peer.id); + for route in &peer.routes { + if !route.enabled { + continue; + } + let net = match route_allowed_prefix(route) { + Ok(net) => net, + Err(err) => { + eprintln!( + "skipping allowed ip for route {} on peer {}: {}", + route.prefix, peer.id, err + ); + continue; + } + }; + match route.kind { + RouteKind::Subnet => { + allowed.insert(net); + } + RouteKind::Exit => { + if allow_exit { + allowed.insert(net); + } + } + } + } + let mut builder = PeerConfigBuilder::new(&peer_key).replace_allowed_ips(); + for net in allowed { + builder = add_allowed_ip(builder, net); + } + if let Some(addr) = peer + .endpoints + .iter() + .find_map(|endpoint| endpoint.parse::().ok()) + { + builder = builder + .set_endpoint(addr) + .set_persistent_keepalive_interval(25); + } else if !peer.endpoints.is_empty() { + eprintln!("no valid endpoint for peer {}", peer.id); + } + update = update.add_peer(builder); + } + + Ok(update) +} + +async fn add_peer_routes(netmap: &NetMap, index: u32, netlink: &Netlink) -> Result<()> { + for peer in &netmap.peers { + let ipv4: IpAddr = peer.ipv4.parse().context("invalid peer ipv4")?; + let ipv6: IpAddr = peer.ipv6.parse().context("invalid peer ipv6")?; + let v4 = IpNet::new(ipv4, 32).context("invalid peer ipv4 prefix")?; + let v6 = IpNet::new(ipv6, 128).context("invalid peer ipv6 prefix")?; + netlink.replace_route(v4, index).await?; + netlink.replace_route(v6, index).await?; + } + Ok(()) +} + +fn route_allowed_prefix(route: &Route) -> Result { + let Some(mapped) = route.mapped_prefix.as_deref() else { + return route.prefix.parse().context("invalid route prefix"); + }; + let real_net: IpNet = route.prefix.parse().context("invalid route prefix")?; + let mapped_net: IpNet = mapped.parse().context("invalid mapped prefix")?; + let real_v4 = matches!(real_net, IpNet::V4(_)); + let mapped_v4 = matches!(mapped_net, IpNet::V4(_)); + if real_v4 != mapped_v4 { + return Err(anyhow!("mapped prefix ip version mismatch")); + } + if real_net.prefix_len() != mapped_net.prefix_len() { + return Err(anyhow!("mapped prefix length mismatch")); + } + Ok(mapped_net) +} + +fn add_allowed_ip(builder: PeerConfigBuilder, net: IpNet) -> PeerConfigBuilder { + match net { + IpNet::V4(v4) => builder.add_allowed_ip(IpAddr::V4(v4.network()), v4.prefix_len()), + IpNet::V6(v6) => builder.add_allowed_ip(IpAddr::V6(v6.network()), v6.prefix_len()), + } +} + +fn build_peer_builder_from_netmap(peer: &crate::model::PeerInfo) -> Result { + let peer_key = Key::from_base64(&peer.wg_public_key) + .with_context(|| format!("invalid peer public key {}", peer.id))?; + let ipv4: IpAddr = peer.ipv4.parse().context("invalid peer ipv4")?; + let ipv6: IpAddr = peer.ipv6.parse().context("invalid peer ipv6")?; + let mut allowed: HashSet = HashSet::new(); + allowed.insert(IpNet::new(ipv4, 32).context("invalid peer ipv4 prefix")?); + allowed.insert(IpNet::new(ipv6, 128).context("invalid peer ipv6 prefix")?); + for route in &peer.routes { + if !route.enabled { + continue; + } + if let RouteKind::Subnet = route.kind { + if let Ok(net) = route_allowed_prefix(route) { + allowed.insert(net); + } + } + } + let mut builder = PeerConfigBuilder::new(&peer_key).replace_allowed_ips(); + for net in allowed { + builder = add_allowed_ip(builder, net); + } + if let Some(addr) = peer + .endpoints + .iter() + .find_map(|endpoint| endpoint.parse::().ok()) + { + builder = builder + .set_endpoint(addr) + .set_persistent_keepalive_interval(25); + } + Ok(builder) +} + +fn ensure_boringtun(interface: &str) -> Result<()> { + let handles = BORINGTUN_HANDLES.get_or_init(|| Mutex::new(HashMap::new())); + let mut map = handles.lock().unwrap(); + if map.contains_key(interface) { + return Ok(()); + } + let config = DeviceConfig::default(); + let handle = DeviceHandle::new(interface, config).context("boringtun init failed")?; + map.insert(interface.to_string(), handle); + Ok(()) +} + +fn stop_boringtun(interface: &str) { + if let Some(handles) = BORINGTUN_HANDLES.get() { + let mut map = handles.lock().unwrap(); + map.remove(interface); + } +} + +async fn wait_for_userspace_socket(interface: &str, timeout: Duration) -> Result<()> { + let start = Instant::now(); + let path = userspace_socket_path(interface); + loop { + if path.exists() { + return Ok(()); + } + if start.elapsed() > timeout { + return Err(anyhow!("userspace wg socket {} did not appear", path.display())); + } + sleep(Duration::from_millis(100)).await; + } +} + +fn userspace_socket_path(interface: &str) -> PathBuf { + Path::new("/var/run/wireguard").join(format!("{interface}.sock")) +} + +fn parse_ip(address: &str, label: &str) -> Result { + let ip: IpAddr = address.parse().with_context(|| format!("invalid {}", label))?; + match (label, ip) { + ("ipv4", IpAddr::V4(_)) => Ok(ip), + ("ipv6", IpAddr::V6(_)) => Ok(ip), + _ => Err(anyhow!("unexpected {} address: {}", label, address)), + } +} + +fn probe_ip( + v4_socket: &mut Option, + v6_socket: &mut Option, + address: &str, + timeout_seconds: u64, +) { + let ip: IpAddr = match address.parse() { + Ok(ip) => ip, + Err(_) => { + eprintln!("probe failed for {} (invalid address)", address); + return; + } + }; + let target = std::net::SocketAddr::new(ip, 9); + probe_addr(v4_socket, v6_socket, target, timeout_seconds); +} + +fn probe_addr( + v4_socket: &mut Option, + v6_socket: &mut Option, + target: SocketAddr, + timeout_seconds: u64, +) { + let socket = match target { + SocketAddr::V4(_) => { + if v4_socket.is_none() { + match std::net::UdpSocket::bind("0.0.0.0:0") { + Ok(sock) => { + let _ = sock.set_write_timeout(Some(Duration::from_secs(timeout_seconds.max(1)))); + *v4_socket = Some(sock); + } + Err(_) => { + eprintln!("probe failed for {} (udp bind)", target); + return; + } + } + } + v4_socket.as_ref().unwrap() + } + SocketAddr::V6(_) => { + if v6_socket.is_none() { + match std::net::UdpSocket::bind("[::]:0") { + Ok(sock) => { + let _ = sock.set_write_timeout(Some(Duration::from_secs(timeout_seconds.max(1)))); + *v6_socket = Some(sock); + } + Err(_) => { + eprintln!("probe failed for {} (udp bind)", target); + return; + } + } + } + v6_socket.as_ref().unwrap() + } + }; + if socket.send_to(b"lightscale-probe", target).is_err() { + eprintln!("probe failed for {} (udp send)", target); + } +}