From 13539e6e855a2dbb6274a5ae876fb769a5c374a1 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Tue, 7 Apr 2026 13:40:27 +0100 Subject: [PATCH] feat(net): scaffold sunbeam-net crate with foundations Add the workspace crate that will host a pure Rust Headscale/Tailscale- compatible VPN client. This first commit lands the crate skeleton plus the leaf modules that the rest of the stack builds on: - error: thiserror Error enum + Result alias - config: VpnConfig - keys: Curve25519 node/disco/wg key types with on-disk persistence - proto/types: PascalCase serde wire types matching Tailscale's JSON --- Cargo.lock | 392 ++++++++++++++++++++++++++--- Cargo.toml | 2 +- sunbeam-net/Cargo.toml | 38 +++ sunbeam-net/src/config.rs | 26 ++ sunbeam-net/src/error.rs | 80 ++++++ sunbeam-net/src/keys.rs | 178 ++++++++++++++ sunbeam-net/src/lib.rs | 9 + sunbeam-net/src/proto/mod.rs | 1 + sunbeam-net/src/proto/types.rs | 434 +++++++++++++++++++++++++++++++++ 9 files changed, 1130 insertions(+), 30 deletions(-) create mode 100644 sunbeam-net/Cargo.toml create mode 100644 sunbeam-net/src/config.rs create mode 100644 sunbeam-net/src/error.rs create mode 100644 sunbeam-net/src/keys.rs create mode 100644 sunbeam-net/src/lib.rs create mode 100644 sunbeam-net/src/proto/mod.rs create mode 100644 sunbeam-net/src/proto/types.rs diff --git a/Cargo.lock b/Cargo.lock index 9bc974a9..6e79fbaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -318,6 +318,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[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" @@ -341,6 +347,12 @@ dependencies = [ "sha2", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -387,6 +399,30 @@ dependencies = [ "cipher", ] +[[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", + "parking_lot", + "rand_core 0.6.4", + "ring", + "tracing", + "untrusted", + "x25519-dalek", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -455,6 +491,19 @@ dependencies = [ "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 = "chrono" version = "0.4.44" @@ -487,6 +536,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -705,6 +755,35 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto_box" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" +dependencies = [ + "aead", + "crypto_secretbox", + "curve25519-dalek", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + [[package]] name = "ctr" version = "0.9.2" @@ -800,6 +879,47 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "delegate" version = "0.13.5" @@ -885,6 +1005,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1034,7 +1160,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64", + "base64 0.22.1", "memchr", ] @@ -1428,6 +1554,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1470,7 +1605,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http", @@ -1488,6 +1623,16 @@ dependencies = [ "http", ] +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1676,7 +1821,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1867,6 +2012,28 @@ 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.12.0" @@ -2000,7 +2167,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "serde", "serde-value", @@ -2026,7 +2193,7 @@ version = "0.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "either", @@ -2140,7 +2307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chumsky", "email-encoding", "email_address", @@ -2180,7 +2347,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "plain", "redox_syscall 0.7.3", @@ -2230,6 +2397,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "matchers" version = "0.2.0" @@ -2294,6 +2467,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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", +] + [[package]] name = "nom" version = "7.1.3" @@ -2573,7 +2758,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2770,6 +2955,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2789,6 +2984,28 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2970,7 +3187,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2979,7 +3196,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -3028,7 +3245,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3068,7 +3285,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -3151,7 +3368,7 @@ dependencies = [ "aes", "aes-gcm", "async-trait", - "bitflags", + "bitflags 2.11.0", "byteorder", "cbc", "chacha20", @@ -3257,7 +3474,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "chrono", "flurry", @@ -3344,7 +3561,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3559,7 +3776,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3572,7 +3789,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3768,6 +3985,20 @@ dependencies = [ "serde", ] +[[package]] +name = "smoltcp" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac729b0a77bd092a3f06ddaddc59fe0d67f48ba0de45a9abe707c2842c7f8767" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "managed", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3816,7 +4047,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -3893,8 +4124,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -3937,8 +4168,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -4095,7 +4326,7 @@ dependencies = [ "aes-gcm", "argon2", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "clap", @@ -4120,6 +4351,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "sunbeam-sdk", "tar", "tempfile", "thiserror 2.0.18", @@ -4135,13 +4367,44 @@ dependencies = [ "wiremock", ] +[[package]] +name = "sunbeam-net" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "blake2", + "boringtun", + "bytes", + "chacha20poly1305", + "crypto_box", + "futures", + "h2", + "hkdf", + "hmac", + "http", + "ipnet", + "pretty_assertions", + "rand 0.8.5", + "serde", + "serde_json", + "smoltcp", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "tokio-util", + "tracing", + "x25519-dalek", + "zstd", +] + [[package]] name = "sunbeam-sdk" version = "1.1.2" dependencies = [ "aes-gcm", "argon2", - "base64", + "base64 0.22.1", "bytes", "chrono", "clap", @@ -4416,6 +4679,17 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -4465,8 +4739,8 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -4865,7 +5139,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -5406,7 +5680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http", @@ -5480,7 +5754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -5516,6 +5790,18 @@ 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 = "x509-parser" version = "0.18.1" @@ -5568,6 +5854,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yasna" version = "0.5.2" @@ -5646,6 +5938,20 @@ 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 2.0.117", +] [[package]] name = "zerotrie" @@ -5685,3 +5991,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index f61d1167..7b13f877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ name = "sunbeam" path = "src/main.rs" [workspace] -members = ["sunbeam-sdk"] +members = ["sunbeam-sdk", "sunbeam-net"] resolver = "3" [dependencies] diff --git a/sunbeam-net/Cargo.toml b/sunbeam-net/Cargo.toml new file mode 100644 index 00000000..8e02fafe --- /dev/null +++ b/sunbeam-net/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "sunbeam-net" +version = "0.1.0" +edition = "2024" +description = "Pure Rust Headscale/Tailscale-compatible VPN client" +license = "MIT" + +[dependencies] +tokio = { version = "1", features = ["full"] } +futures = "0.3" +blake2 = "0.10" +chacha20poly1305 = "0.10" +hkdf = "0.12" +hmac = "0.12" +h2 = "0.4" +http = "1" +boringtun = "0.7" +smoltcp = { version = "0.13", default-features = false, features = ["medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "std"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +crypto_box = "0.9" +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +zstd = "0.13" +bytes = "1" +tokio-util = { version = "0.7", features = ["codec"] } +base64 = "0.22" +tracing = "0.1" +thiserror = "2" +ipnet = "2" + +[features] +integration = [] + +[dev-dependencies] +tokio-test = "0.4" +pretty_assertions = "1" +tempfile = "3" diff --git a/sunbeam-net/src/config.rs b/sunbeam-net/src/config.rs new file mode 100644 index 00000000..c4f20822 --- /dev/null +++ b/sunbeam-net/src/config.rs @@ -0,0 +1,26 @@ +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; + +/// Top-level configuration for a sunbeam-net VPN instance. +#[derive(Debug, Clone)] +pub struct VpnConfig { + /// URL of the Headscale/Tailscale coordination server. + pub coordination_url: String, + /// Pre-auth key for automatic registration. + pub auth_key: String, + /// Directory for persisting keys and state. + pub state_dir: PathBuf, + /// Address to bind the SOCKS/TCP proxy on. + pub proxy_bind: SocketAddr, + /// Cluster API server IP (inside the VPN). + pub cluster_api_addr: IpAddr, + /// Cluster API server port. + pub cluster_api_port: u16, + /// Path for the daemon control socket. + pub control_socket: PathBuf, + /// Hostname to register with the coordination server. + pub hostname: String, + /// The coordination server's Noise public key (32 bytes). + /// If `None`, it will be fetched from the server's `/key` endpoint. + pub server_public_key: Option<[u8; 32]>, +} diff --git a/sunbeam-net/src/error.rs b/sunbeam-net/src/error.rs new file mode 100644 index 00000000..155effa1 --- /dev/null +++ b/sunbeam-net/src/error.rs @@ -0,0 +1,80 @@ +/// Errors produced by sunbeam-net. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("noise handshake failed: {0}")] + Noise(String), + + #[error("control protocol error: {0}")] + Control(String), + + #[error("wireguard error: {0}")] + WireGuard(String), + + #[error("DERP relay error: {0}")] + Derp(String), + + #[error("authentication failed: {0}")] + Auth(String), + + #[error("daemon error: {0}")] + Daemon(String), + + #[error("IPC error: {0}")] + Ipc(String), + + #[error("{context}: {source}")] + Io { + context: String, + #[source] + source: std::io::Error, + }, + + #[error("{0}")] + Json(#[from] serde_json::Error), + + #[error("connection closed")] + ConnectionClosed, + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +impl From for Error { + fn from(e: h2::Error) -> Self { + Error::Control(e.to_string()) + } +} + +/// Extension trait for adding context to `Result` types. +pub trait ResultExt { + fn ctx(self, context: &str) -> Result; + fn with_ctx String>(self, f: F) -> Result; +} + +impl ResultExt for std::result::Result { + fn ctx(self, context: &str) -> Result { + self.map_err(|source| Error::Io { + context: context.to_string(), + source, + }) + } + + fn with_ctx String>(self, f: F) -> Result { + self.map_err(|source| Error::Io { + context: f(), + source, + }) + } +} + +impl ResultExt for Result { + fn ctx(self, context: &str) -> Result { + self.map_err(|e| Error::Other(format!("{context}: {e}"))) + } + + fn with_ctx String>(self, f: F) -> Result { + self.map_err(|e| Error::Other(format!("{}: {e}", f()))) + } +} diff --git a/sunbeam-net/src/keys.rs b/sunbeam-net/src/keys.rs new file mode 100644 index 00000000..e00039da --- /dev/null +++ b/sunbeam-net/src/keys.rs @@ -0,0 +1,178 @@ +use std::path::Path; + +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::error::ResultExt; + +const KEYS_FILE: &str = "keys.json"; + +/// The three x25519 key pairs used by a node. +#[derive(Clone)] +pub struct NodeKeys { + pub node_private: StaticSecret, + pub node_public: PublicKey, + pub disco_private: StaticSecret, + pub disco_public: PublicKey, + pub wg_private: StaticSecret, + pub wg_public: PublicKey, +} + +impl std::fmt::Debug for NodeKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NodeKeys") + .field("node_public", &self.node_key_str()) + .field("disco_public", &self.disco_key_str()) + .field("wg_public", &hex::encode(self.wg_public.as_bytes())) + .finish() + } +} + +/// On-disk representation of persisted keys. +#[derive(Serialize, Deserialize)] +struct PersistedKeys { + node_private: String, + disco_private: String, + wg_private: String, +} + +// Hex helpers — we avoid pulling in a hex crate by implementing locally. +mod hex { + pub fn encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + pub fn decode(s: &str) -> Result, String> { + if s.len() % 2 != 0 { + return Err("odd length hex string".into()); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string())) + .collect() + } +} + +impl NodeKeys { + /// Generate fresh random key pairs. + pub fn generate() -> Self { + let node_private = StaticSecret::random_from_rng(OsRng); + let node_public = PublicKey::from(&node_private); + + let disco_private = StaticSecret::random_from_rng(OsRng); + let disco_public = PublicKey::from(&disco_private); + + let wg_private = StaticSecret::random_from_rng(OsRng); + let wg_public = PublicKey::from(&wg_private); + + Self { + node_private, + node_public, + disco_private, + disco_public, + wg_private, + wg_public, + } + } + + /// Load keys from `state_dir/keys.json`, or generate and persist new ones. + pub fn load_or_generate(state_dir: &Path) -> crate::Result { + let path = state_dir.join(KEYS_FILE); + if path.exists() { + let data = std::fs::read_to_string(&path).ctx("reading keys file")?; + let persisted: PersistedKeys = serde_json::from_str(&data)?; + return Self::from_persisted(&persisted); + } + + let keys = Self::generate(); + std::fs::create_dir_all(state_dir).ctx("creating state directory")?; + let persisted = keys.to_persisted(); + let data = serde_json::to_string_pretty(&persisted)?; + std::fs::write(&path, data).ctx("writing keys file")?; + tracing::debug!("generated new node keys at {}", path.display()); + Ok(keys) + } + + /// Tailscale-style node key string: `nodekey:`. + pub fn node_key_str(&self) -> String { + format!("nodekey:{}", hex::encode(self.node_public.as_bytes())) + } + + /// Tailscale-style disco key string: `discokey:`. + pub fn disco_key_str(&self) -> String { + format!("discokey:{}", hex::encode(self.disco_public.as_bytes())) + } + + fn to_persisted(&self) -> PersistedKeys { + PersistedKeys { + node_private: hex::encode(self.node_private.as_bytes()), + disco_private: hex::encode(self.disco_private.as_bytes()), + wg_private: hex::encode(self.wg_private.as_bytes()), + } + } + + fn from_persisted(p: &PersistedKeys) -> crate::Result { + let node_bytes = parse_key_hex(&p.node_private, "node")?; + let disco_bytes = parse_key_hex(&p.disco_private, "disco")?; + let wg_bytes = parse_key_hex(&p.wg_private, "wg")?; + + let node_private = StaticSecret::from(node_bytes); + let node_public = PublicKey::from(&node_private); + + let disco_private = StaticSecret::from(disco_bytes); + let disco_public = PublicKey::from(&disco_private); + + let wg_private = StaticSecret::from(wg_bytes); + let wg_public = PublicKey::from(&wg_private); + + Ok(Self { + node_private, + node_public, + disco_private, + disco_public, + wg_private, + wg_public, + }) + } +} + +fn parse_key_hex(s: &str, name: &str) -> crate::Result<[u8; 32]> { + let bytes = hex::decode(s).map_err(|e| crate::Error::Other(format!("bad {name} key hex: {e}")))?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| crate::Error::Other(format!("{name} key must be 32 bytes")))?; + Ok(arr) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn generate_produces_valid_keys() { + let keys = NodeKeys::generate(); + assert!(keys.node_key_str().starts_with("nodekey:")); + assert!(keys.disco_key_str().starts_with("discokey:")); + assert_eq!(keys.node_key_str().len(), "nodekey:".len() + 64); + } + + #[test] + fn load_or_generate_round_trips() { + let dir = TempDir::new().unwrap(); + let keys1 = NodeKeys::load_or_generate(dir.path()).unwrap(); + let keys2 = NodeKeys::load_or_generate(dir.path()).unwrap(); + assert_eq!(keys1.node_key_str(), keys2.node_key_str()); + assert_eq!(keys1.disco_key_str(), keys2.disco_key_str()); + } + + #[test] + fn hex_round_trip() { + let input = [0xde, 0xad, 0xbe, 0xef]; + let encoded = super::hex::encode(&input); + assert_eq!(encoded, "deadbeef"); + let decoded = super::hex::decode(&encoded).unwrap(); + assert_eq!(decoded, input); + } +} diff --git a/sunbeam-net/src/lib.rs b/sunbeam-net/src/lib.rs new file mode 100644 index 00000000..4a66792d --- /dev/null +++ b/sunbeam-net/src/lib.rs @@ -0,0 +1,9 @@ +//! sunbeam-net: Pure Rust Headscale/Tailscale-compatible VPN client. + +pub mod config; +pub mod error; +pub mod keys; +pub(crate) mod proto; + +pub use config::VpnConfig; +pub use error::{Error, Result}; diff --git a/sunbeam-net/src/proto/mod.rs b/sunbeam-net/src/proto/mod.rs new file mode 100644 index 00000000..cd408564 --- /dev/null +++ b/sunbeam-net/src/proto/mod.rs @@ -0,0 +1 @@ +pub mod types; diff --git a/sunbeam-net/src/proto/types.rs b/sunbeam-net/src/proto/types.rs new file mode 100644 index 00000000..dd55120c --- /dev/null +++ b/sunbeam-net/src/proto/types.rs @@ -0,0 +1,434 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Registration request sent to POST /machine/register. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct RegisterRequest { + pub version: u16, + pub node_key: String, + pub old_node_key: String, + pub auth: Option, + pub hostinfo: HostInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub followup: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct AuthInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_key: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct HostInfo { + #[serde(rename = "GoArch")] + pub go_arch: String, + #[serde(rename = "GoOS")] + pub go_os: String, + #[serde(rename = "GoVersion")] + pub go_version: String, + pub hostname: String, + #[serde(rename = "OS")] + pub os: String, + #[serde(rename = "OSVersion")] + pub os_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub frontend_log_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub backend_log_id: Option, +} + +/// Registration response from POST /machine/register. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct RegisterResponse { + #[serde(default)] + pub user: User, + #[serde(default)] + pub login: Login, + #[serde(default)] + pub node_key_expired: bool, + #[serde(default)] + pub machine_authorized: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct User { + #[serde(rename = "ID", default)] + pub id: u64, + #[serde(default)] + pub login_name: String, + #[serde(default)] + pub display_name: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Login { + #[serde(rename = "ID", default)] + pub id: u64, + #[serde(default)] + pub login_name: String, + #[serde(default)] + pub display_name: String, +} + +/// Map request sent to POST /machine/map. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct MapRequest { + pub version: u16, + pub node_key: String, + pub disco_key: String, + pub stream: bool, + pub hostinfo: HostInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoints: Option>, +} + +/// Map response -- can be a full snapshot or a delta update. +/// Fields are all optional because deltas only include changed fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct MapResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub node: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub peers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub peers_changed: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub peers_removed: Option>, + #[serde(rename = "DERPMap")] + #[serde(skip_serializing_if = "Option::is_none")] + pub derp_map: Option, + #[serde(rename = "DNSConfig")] + #[serde(skip_serializing_if = "Option::is_none")] + pub dns_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub packet_filter: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub collection_name: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct Node { + #[serde(rename = "ID")] + pub id: u64, + pub key: String, + pub disco_key: String, + pub addresses: Vec, + #[serde(rename = "AllowedIPs")] + pub allowed_ips: Vec, + pub endpoints: Vec, + #[serde(rename = "DERP")] + pub derp: String, + pub hostinfo: HostInfo, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub online: Option, + pub machine_authorized: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct DerpMap { + pub regions: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct DerpRegion { + pub region_id: u16, + pub region_code: String, + pub region_name: String, + pub nodes: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct DerpNode { + pub name: String, + pub region_id: u16, + pub host_name: String, + #[serde(rename = "IPv4")] + pub ipv4: String, + #[serde(rename = "IPv6")] + pub ipv6: String, + pub derp_port: u16, + pub stun_port: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub stun_only: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct DnsConfig { + pub resolvers: Vec, + pub domains: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct DnsResolver { + pub addr: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct FilterRule { + #[serde(rename = "SrcIPs")] + pub src_ips: Vec, + #[serde(rename = "DstPorts")] + pub dst_ports: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct FilterPort { + #[serde(rename = "IP")] + pub ip: String, + pub ports: PortRange, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", default)] +pub struct PortRange { + pub first: u16, + pub last: u16, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_hostinfo() -> HostInfo { + HostInfo { + go_arch: "arm64".into(), + go_os: "linux".into(), + go_version: "sunbeam-net/0.1".into(), + hostname: "myhost".into(), + os: "linux".into(), + os_version: "6.1".into(), + device_model: None, + frontend_log_id: None, + backend_log_id: None, + } + } + + #[test] + fn test_register_request_serialize() { + let req = RegisterRequest { + version: 74, + node_key: "nodekey:aabb".into(), + old_node_key: "".into(), + auth: Some(AuthInfo { + auth_key: Some("tskey-abc".into()), + }), + hostinfo: sample_hostinfo(), + followup: None, + timestamp: None, + }; + let json = serde_json::to_string(&req).unwrap(); + // Verify PascalCase keys + assert!(json.contains("\"Version\"")); + assert!(json.contains("\"NodeKey\"")); + assert!(json.contains("\"OldNodeKey\"")); + assert!(json.contains("\"Hostinfo\"")); + assert!(json.contains("\"GoArch\"")); + assert!(json.contains("\"GoOS\"")); + assert!(json.contains("\"GoVersion\"")); + assert!(json.contains("\"AuthKey\"")); + // Optional None fields should be absent + assert!(!json.contains("\"Followup\"")); + assert!(!json.contains("\"Timestamp\"")); + } + + #[test] + fn test_register_response_deserialize() { + let json = r#"{ + "User": {"ID": 1, "LoginName": "user@example.com", "DisplayName": "User"}, + "Login": {"ID": 2, "LoginName": "user@example.com", "DisplayName": "User"}, + "NodeKeyExpired": false, + "MachineAuthorized": true, + "AuthUrl": "https://login.example.com/a/xyz" + }"#; + let resp: RegisterResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.user.id, 1); + assert_eq!(resp.login.id, 2); + assert!(!resp.node_key_expired); + assert!(resp.machine_authorized); + assert_eq!(resp.auth_url.as_deref(), Some("https://login.example.com/a/xyz")); + } + + #[test] + fn test_map_response_full_snapshot() { + let json = r#"{ + "Node": { + "ID": 1, + "Key": "nodekey:aa", + "DiscoKey": "discokey:bb", + "Addresses": ["100.64.0.1/32"], + "AllowedIPs": ["100.64.0.0/10"], + "Endpoints": [], + "DERP": "127.3.3.40:1", + "Hostinfo": { + "GoArch": "arm64", "GoOS": "linux", "GoVersion": "sunbeam-net/0.1", + "Hostname": "self", "OS": "linux", "OSVersion": "6.1" + }, + "Name": "self.example.com", + "Online": true, + "MachineAuthorized": true + }, + "Peers": [{ + "ID": 2, + "Key": "nodekey:cc", + "DiscoKey": "discokey:dd", + "Addresses": ["100.64.0.2/32"], + "AllowedIPs": ["100.64.0.2/32"], + "DERP": "127.3.3.40:1", + "Hostinfo": { + "GoArch": "amd64", "GoOS": "linux", "GoVersion": "sunbeam-net/0.1", + "Hostname": "peer", "OS": "linux", "OSVersion": "6.1" + }, + "Name": "peer.example.com", + "Online": true, + "MachineAuthorized": true + }], + "DERPMap": { + "Regions": { + "1": { + "RegionId": 1, + "RegionCode": "default", + "RegionName": "Default", + "Nodes": [{ + "Name": "1a", + "RegionId": 1, + "HostName": "derp.example.com", + "IPv4": "1.2.3.4", + "IPv6": "::1", + "DerpPort": 443, + "StunPort": 3478 + }] + } + } + }, + "DNSConfig": { + "Resolvers": [{"Addr": "100.64.0.1"}], + "Domains": ["example.com"] + }, + "Domain": "example.com" + }"#; + let resp: MapResponse = serde_json::from_str(json).unwrap(); + assert!(resp.node.is_some()); + let peers = resp.peers.as_ref().unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].key, "nodekey:cc"); + let derp_map = resp.derp_map.as_ref().unwrap(); + assert!(derp_map.regions.contains_key("1")); + let dns = resp.dns_config.as_ref().unwrap(); + assert_eq!(dns.resolvers.len(), 1); + assert_eq!(dns.domains, vec!["example.com"]); + } + + #[test] + fn test_map_response_delta() { + let json = r#"{ + "PeersChanged": [{ + "ID": 3, + "Key": "nodekey:ee", + "DiscoKey": "discokey:ff", + "Addresses": ["100.64.0.3/32"], + "AllowedIPs": ["100.64.0.3/32"], + "DERP": "127.3.3.40:1", + "Hostinfo": { + "GoArch": "amd64", "GoOS": "linux", "GoVersion": "sunbeam-net/0.1", + "Hostname": "new-peer", "OS": "linux", "OSVersion": "6.1" + }, + "Name": "new-peer.example.com", + "MachineAuthorized": true + }], + "PeersRemoved": ["nodekey:cc"] + }"#; + let resp: MapResponse = serde_json::from_str(json).unwrap(); + assert!(resp.node.is_none()); + assert!(resp.peers.is_none()); + let changed = resp.peers_changed.as_ref().unwrap(); + assert_eq!(changed.len(), 1); + assert_eq!(changed[0].key, "nodekey:ee"); + let removed = resp.peers_removed.as_ref().unwrap(); + assert_eq!(removed, &["nodekey:cc"]); + } + + #[test] + fn test_map_response_keepalive() { + let resp: MapResponse = serde_json::from_str("{}").unwrap(); + assert!(resp.node.is_none()); + assert!(resp.peers.is_none()); + assert!(resp.peers_changed.is_none()); + assert!(resp.peers_removed.is_none()); + assert!(resp.derp_map.is_none()); + assert!(resp.dns_config.is_none()); + assert!(resp.packet_filter.is_none()); + assert!(resp.domain.is_none()); + assert!(resp.collection_name.is_none()); + } + + #[test] + fn test_node_serde_round_trip() { + let node = Node { + id: 42, + key: "nodekey:abcd".into(), + disco_key: "discokey:1234".into(), + addresses: vec!["100.64.0.5/32".into()], + allowed_ips: vec!["100.64.0.0/10".into()], + endpoints: vec!["1.2.3.4:41641".into()], + derp: "127.3.3.40:1".into(), + hostinfo: sample_hostinfo(), + name: "test.example.com".into(), + online: Some(true), + machine_authorized: true, + }; + let json = serde_json::to_string(&node).unwrap(); + let back: Node = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, 42); + assert_eq!(back.key, "nodekey:abcd"); + assert_eq!(back.disco_key, "discokey:1234"); + assert_eq!(back.derp, "127.3.3.40:1"); + assert_eq!(back.name, "test.example.com"); + assert_eq!(back.online, Some(true)); + } + + #[test] + fn test_hostinfo_platform_fields() { + let hi = sample_hostinfo(); + let json = serde_json::to_string(&hi).unwrap(); + // Verify the explicit renames + assert!(json.contains("\"GoArch\"")); + assert!(json.contains("\"GoOS\"")); + assert!(json.contains("\"GoVersion\"")); + assert!(json.contains("\"OS\"")); + assert!(json.contains("\"OSVersion\"")); + // Verify PascalCase on normal fields + assert!(json.contains("\"Hostname\"")); + // Should not contain snake_case + assert!(!json.contains("go_arch")); + assert!(!json.contains("go_os")); + assert!(!json.contains("os_version")); + } +}