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:
38
sunbeam-net/Cargo.toml
Normal file
38
sunbeam-net/Cargo.toml
Normal 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
26
sunbeam-net/src/config.rs
Normal 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
80
sunbeam-net/src/error.rs
Normal 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
178
sunbeam-net/src/keys.rs
Normal 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
9
sunbeam-net/src/lib.rs
Normal 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};
|
||||
1
sunbeam-net/src/proto/mod.rs
Normal file
1
sunbeam-net/src/proto/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod types;
|
||||
434
sunbeam-net/src/proto/types.rs
Normal file
434
sunbeam-net/src/proto/types.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user