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
This commit is contained in:
2026-04-07 13:40:27 +01:00
parent cc2c3f7a3b
commit 13539e6e85
9 changed files with 1130 additions and 30 deletions

392
Cargo.lock generated
View File

@@ -318,6 +318,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -341,6 +347,12 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@@ -387,6 +399,30 @@ dependencies = [
"cipher", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -455,6 +491,19 @@ dependencies = [
"cpufeatures", "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]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -487,6 +536,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common",
"inout", "inout",
"zeroize",
] ]
[[package]] [[package]]
@@ -705,6 +755,35 @@ dependencies = [
"typenum", "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]] [[package]]
name = "ctr" name = "ctr"
version = "0.9.2" version = "0.9.2"
@@ -800,6 +879,47 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" 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]] [[package]]
name = "delegate" name = "delegate"
version = "0.13.5" version = "0.13.5"
@@ -885,6 +1005,12 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -1034,7 +1160,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"memchr", "memchr",
] ]
@@ -1428,6 +1554,15 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -1470,7 +1605,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"headers-core", "headers-core",
"http", "http",
@@ -1488,6 +1623,16 @@ dependencies = [
"http", "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]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -1676,7 +1821,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
@@ -1867,6 +2012,28 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -2000,7 +2167,7 @@ version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"chrono", "chrono",
"serde", "serde",
"serde-value", "serde-value",
@@ -2026,7 +2193,7 @@ version = "0.99.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"either", "either",
@@ -2140,7 +2307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64 0.22.1",
"chumsky", "chumsky",
"email-encoding", "email-encoding",
"email_address", "email_address",
@@ -2180,7 +2347,7 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"libc", "libc",
"plain", "plain",
"redox_syscall 0.7.3", "redox_syscall 0.7.3",
@@ -2230,6 +2397,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "managed"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -2294,6 +2467,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -2573,7 +2758,7 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"serde_core", "serde_core",
] ]
@@ -2770,6 +2955,16 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -2789,6 +2984,28 @@ dependencies = [
"elliptic-curve", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -2970,7 +3187,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
] ]
[[package]] [[package]]
@@ -2979,7 +3196,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
] ]
[[package]] [[package]]
@@ -3028,7 +3245,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -3068,7 +3285,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-core", "futures-core",
"http", "http",
@@ -3151,7 +3368,7 @@ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
"bitflags", "bitflags 2.11.0",
"byteorder", "byteorder",
"cbc", "cbc",
"chacha20", "chacha20",
@@ -3257,7 +3474,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"bytes", "bytes",
"chrono", "chrono",
"flurry", "flurry",
@@ -3344,7 +3561,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -3559,7 +3776,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -3572,7 +3789,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -3768,6 +3985,20 @@ dependencies = [
"serde", "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]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.3"
@@ -3816,7 +4047,7 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
@@ -3893,8 +4124,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags 2.11.0",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono", "chrono",
@@ -3937,8 +4168,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags 2.11.0",
"byteorder", "byteorder",
"chrono", "chrono",
"crc", "crc",
@@ -4095,7 +4326,7 @@ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",
"async-trait", "async-trait",
"base64", "base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
@@ -4120,6 +4351,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2", "sha2",
"sunbeam-sdk",
"tar", "tar",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -4135,13 +4367,44 @@ dependencies = [
"wiremock", "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]] [[package]]
name = "sunbeam-sdk" name = "sunbeam-sdk"
version = "1.1.2" version = "1.1.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",
"base64", "base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
@@ -4416,6 +4679,17 @@ dependencies = [
"tokio-util", "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]] [[package]]
name = "tokio-tungstenite" name = "tokio-tungstenite"
version = "0.26.2" version = "0.26.2"
@@ -4465,8 +4739,8 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bitflags", "bitflags 2.11.0",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -4865,7 +5139,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap",
"semver", "semver",
@@ -5406,7 +5680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [ dependencies = [
"assert-json-diff", "assert-json-diff",
"base64", "base64 0.22.1",
"deadpool", "deadpool",
"futures", "futures",
"http", "http",
@@ -5480,7 +5754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags", "bitflags 2.11.0",
"indexmap", "indexmap",
"log", "log",
"serde", "serde",
@@ -5516,6 +5790,18 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 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]] [[package]]
name = "x509-parser" name = "x509-parser"
version = "0.18.1" version = "0.18.1"
@@ -5568,6 +5854,12 @@ dependencies = [
"hashlink", "hashlink",
] ]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "yasna" name = "yasna"
version = "0.5.2" version = "0.5.2"
@@ -5646,6 +5938,20 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 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]] [[package]]
name = "zerotrie" name = "zerotrie"
@@ -5685,3 +5991,31 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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",
]

View File

@@ -11,7 +11,7 @@ name = "sunbeam"
path = "src/main.rs" path = "src/main.rs"
[workspace] [workspace]
members = ["sunbeam-sdk"] members = ["sunbeam-sdk", "sunbeam-net"]
resolver = "3" resolver = "3"
[dependencies] [dependencies]

38
sunbeam-net/Cargo.toml Normal file
View File

@@ -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"

26
sunbeam-net/src/config.rs Normal file
View File

@@ -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]>,
}

80
sunbeam-net/src/error.rs Normal file
View File

@@ -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<T> = std::result::Result<T, Error>;
impl From<h2::Error> for Error {
fn from(e: h2::Error) -> Self {
Error::Control(e.to_string())
}
}
/// Extension trait for adding context to `Result` types.
pub trait ResultExt<T> {
fn ctx(self, context: &str) -> Result<T>;
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T>;
}
impl<T> ResultExt<T> for std::result::Result<T, std::io::Error> {
fn ctx(self, context: &str) -> Result<T> {
self.map_err(|source| Error::Io {
context: context.to_string(),
source,
})
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.map_err(|source| Error::Io {
context: f(),
source,
})
}
}
impl<T> ResultExt<T> for Result<T> {
fn ctx(self, context: &str) -> Result<T> {
self.map_err(|e| Error::Other(format!("{context}: {e}")))
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.map_err(|e| Error::Other(format!("{}: {e}", f())))
}
}

178
sunbeam-net/src/keys.rs Normal file
View File

@@ -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<Vec<u8>, 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<Self> {
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:<hex>`.
pub fn node_key_str(&self) -> String {
format!("nodekey:{}", hex::encode(self.node_public.as_bytes()))
}
/// Tailscale-style disco key string: `discokey:<hex>`.
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<Self> {
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);
}
}

9
sunbeam-net/src/lib.rs Normal file
View File

@@ -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};

View File

@@ -0,0 +1 @@
pub mod types;

View File

@@ -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<AuthInfo>,
pub hostinfo: HostInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub followup: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct AuthInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_key: Option<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frontend_log_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_log_id: Option<String>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[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<Vec<String>>,
}
/// 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<Node>,
#[serde(skip_serializing_if = "Option::is_none")]
pub peers: Option<Vec<Node>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub peers_changed: Option<Vec<Node>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub peers_removed: Option<Vec<String>>,
#[serde(rename = "DERPMap")]
#[serde(skip_serializing_if = "Option::is_none")]
pub derp_map: Option<DerpMap>,
#[serde(rename = "DNSConfig")]
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_config: Option<DnsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub packet_filter: Option<Vec<FilterRule>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub collection_name: Option<String>,
}
#[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<String>,
#[serde(rename = "AllowedIPs")]
pub allowed_ips: Vec<String>,
pub endpoints: Vec<String>,
#[serde(rename = "DERP")]
pub derp: String,
pub hostinfo: HostInfo,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub online: Option<bool>,
pub machine_authorized: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase", default)]
pub struct DerpMap {
pub regions: HashMap<String, DerpRegion>,
}
#[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<DerpNode>,
}
#[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<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase", default)]
pub struct DnsConfig {
pub resolvers: Vec<DnsResolver>,
pub domains: Vec<String>,
}
#[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<String>,
#[serde(rename = "DstPorts")]
pub dst_ports: Vec<FilterPort>,
}
#[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"));
}
}