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

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"));
}
}