From 2b34aeee6189ab6c05ba28ada716fcc365f1303f Mon Sep 17 00:00:00 2001 From: Cody Kickertz Date: Sun, 12 Apr 2026 07:05:56 -0500 Subject: [PATCH] feat(dictyon): add control protocol types and map response parser Implement Milestone 2 of the dictyon Tailscale client: - plegma-core/types.rs: RegisterRequest, RegisterResponse, MapRequest, MapResponse, Node, DnsConfig, DerpMap, Hostinfo with serde PascalCase field renaming to match Tailscale wire format - dictyon/control.rs: ControlClient with register request building, map response parsing (4B LE size + JSON framing), and netmap maintenance with full/delta/keepalive application - transport.rs: add ControlConnection::from_transport constructor 12 new tests covering serialization, deserialization, delta peer updates, peer removal, keepalive handling, and frame parsing. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 3 + crates/dictyon/Cargo.toml | 1 + crates/dictyon/src/control.rs | 668 ++++++++++++++++++++++++++++++++ crates/dictyon/src/lib.rs | 1 + crates/dictyon/src/transport.rs | 9 + crates/plegma-core/Cargo.toml | 1 + crates/plegma-core/src/lib.rs | 1 + crates/plegma-core/src/types.rs | 424 ++++++++++++++++++++ 8 files changed, 1108 insertions(+) create mode 100644 crates/dictyon/src/control.rs create mode 100644 crates/plegma-core/src/types.rs diff --git a/Cargo.toml b/Cargo.toml index 1bf4d50..9178bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Crypto primitives (placeholder — actual WireGuard uses boringtun) # boringtun = "0.6" # uncomment when dictyon data plane begins +# System info +hostname = "0.4" + # Noise protocol snow = "0.10" x25519-dalek = { version = "2", features = ["static_secrets"] } diff --git a/crates/dictyon/Cargo.toml b/crates/dictyon/Cargo.toml index d686187..215fd0d 100644 --- a/crates/dictyon/Cargo.toml +++ b/crates/dictyon/Cargo.toml @@ -20,6 +20,7 @@ tracing = { workspace = true } snow = { workspace = true } zeroize = { workspace = true } base64 = { workspace = true } +hostname = { workspace = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/dictyon/src/control.rs b/crates/dictyon/src/control.rs new file mode 100644 index 0000000..c0f42e6 --- /dev/null +++ b/crates/dictyon/src/control.rs @@ -0,0 +1,668 @@ +//! Control protocol client for the Tailscale control plane. +//! +//! Implements registration and map polling over an established +//! [`ControlConnection`]. The control client manages the node's identity, +//! sends registration and map requests, and maintains a local [`Netmap`] +//! by applying full and delta [`MapResponse`] updates. +//! +//! # Wire format +//! +//! Control plane messages are JSON, framed as `[4-byte LE size][payload]`. +//! Payloads may be zstd-compressed (indicated by `Compress: "zstd"` in the +//! request). This implementation handles uncompressed JSON; zstd support +//! is deferred. +//! +//! # References +//! +//! - `control/controlclient/direct.go` in the Tailscale Go source +//! - `tailcfg/tailcfg.go` for type definitions + +use plegma_core::keys::{DiscoPrivate, MachinePrivate, NodePrivate}; +use plegma_core::types::{ + DerpMap, DnsConfig, Hostinfo, MapRequest, MapResponse, Node, RegisterRequest, RegisterResponse, + AuthInfo, +}; +use snafu::Snafu; + +use crate::transport::ControlConnection; + +/// Errors from control protocol operations. +#[derive(Debug, Snafu)] +pub enum ControlError { + /// JSON serialization or deserialization failed. + #[snafu(display("json error: {message}"))] + Json { + /// Description of the JSON error. + message: String, + }, + + /// The transport layer returned an error. + #[snafu(display("transport error: {source}"))] + Transport { + /// The underlying transport error. + source: crate::transport::TransportError, + }, + + /// The response frame was malformed. + #[snafu(display("malformed response: {message}"))] + MalformedResponse { + /// Description of the framing error. + message: String, + }, +} + +impl From for ControlError { + fn from(source: crate::transport::TransportError) -> Self { + Self::Transport { source } + } +} + +impl From for ControlError { + fn from(err: serde_json::Error) -> Self { + Self::Json { + message: err.to_string(), + } + } +} + +/// The local view of the network map, maintained by applying +/// [`MapResponse`] updates. +/// +/// Starts empty and is populated by the first full map response. +/// Subsequent delta responses update it incrementally. +#[derive(Debug)] +pub struct Netmap { + /// This node's own information. + pub self_node: Node, + /// Known peers in the tailnet. + pub peers: Vec, + /// Current DNS configuration. + pub dns_config: Option, + /// Current DERP relay topology. + pub derp_map: Option, +} + +/// Client for the Tailscale control protocol. +/// +/// Wraps a [`ControlConnection`] and the node's identity keys. Provides +/// methods for registration, map polling, and netmap maintenance. +/// +/// # Usage +/// +/// ```ignore +/// let client = ControlClient::new(conn, machine_key, node_key, disco_key); +/// let reg_resp = client.register(None)?; +/// let map_resp = client.map_request()?; +/// client.apply_map_response(map_resp); +/// ``` +pub struct ControlClient { + transport: ControlConnection, + #[allow(dead_code)] + machine_key: MachinePrivate, + node_key: NodePrivate, + disco_key: DiscoPrivate, + netmap: Option, +} + +impl ControlClient { + /// Create a new control client. + /// + /// The `transport` must already have completed the Noise handshake. + /// The netmap starts empty and is populated by + /// [`apply_map_response`](Self::apply_map_response). + pub fn new( + transport: ControlConnection, + machine_key: MachinePrivate, + node_key: NodePrivate, + disco_key: DiscoPrivate, + ) -> Self { + Self { + transport, + machine_key, + node_key, + disco_key, + netmap: None, + } + } + + /// Build a [`RegisterRequest`] and serialize it to JSON. + /// + /// This produces the JSON payload for `POST /machine/register`. The + /// caller is responsible for framing and sending it over the + /// transport. + /// + /// # Arguments + /// + /// * `auth_key` - Optional pre-auth key for headless registration. + /// + /// # Errors + /// + /// Returns [`ControlError::Json`] if serialization fails. + pub fn build_register_request(&self, auth_key: Option<&str>) -> Result, ControlError> { + let req = RegisterRequest { + node_key: self.node_key.public_key().to_hex(), + old_node_key: String::new(), + auth: auth_key.map(|k| AuthInfo { + auth_key: Some(k.to_string()), + }), + hostinfo: self.hostinfo(), + followup: None, + }; + + let json = serde_json::to_vec(&req)?; + Ok(json) + } + + /// Register this node with the control server. + /// + /// Builds a [`RegisterRequest`], encrypts and frames it via the + /// transport, then parses the response as [`RegisterResponse`]. + /// + /// # Arguments + /// + /// * `auth_key` - Optional pre-auth key for headless registration. + /// + /// # Errors + /// + /// Returns [`ControlError`] on serialization, transport, or + /// deserialization failure. + pub fn register(&mut self, auth_key: Option<&str>) -> Result { + let payload = self.build_register_request(auth_key)?; + let encrypted = self.transport.send(&payload)?; + let decrypted = self.transport.receive(&encrypted)?; + let resp: RegisterResponse = serde_json::from_slice(&decrypted)?; + Ok(resp) + } + + /// Build a [`MapRequest`] and serialize it to JSON. + /// + /// This produces the JSON payload for `POST /machine/map`. The caller + /// is responsible for framing and sending it over the transport. + /// + /// # Errors + /// + /// Returns [`ControlError::Json`] if serialization fails. + pub fn build_map_request(&self) -> Result, ControlError> { + let req = MapRequest { + version: 68, + node_key: self.node_key.public_key().to_hex(), + disco_key: self.disco_key.public_key().to_hex(), + endpoints: Vec::new(), + stream: true, + hostinfo: self.hostinfo(), + }; + + let json = serde_json::to_vec(&req)?; + Ok(json) + } + + /// Parse a map response frame. + /// + /// The wire format is `[4-byte LE size][JSON payload]`. This method + /// extracts and deserializes the JSON payload. + /// + /// # Errors + /// + /// Returns [`ControlError::MalformedResponse`] if the frame is too + /// short or the declared size exceeds the available data, or + /// [`ControlError::Json`] if the payload is not valid JSON. + pub fn parse_map_response(frame: &[u8]) -> Result { + if frame.len() < 4 { + return Err(ControlError::MalformedResponse { + message: format!("frame too short: {} bytes, need at least 4", frame.len()), + }); + } + + let size = u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize; + + let payload = frame.get(4..4 + size).ok_or_else(|| { + ControlError::MalformedResponse { + message: format!( + "frame declares {size} bytes but only {} available", + frame.len() - 4 + ), + } + })?; + + let resp: MapResponse = serde_json::from_slice(payload)?; + Ok(resp) + } + + /// Apply a [`MapResponse`] to the local netmap. + /// + /// On the first response (when `netmap` is `None`), the full peer + /// list and self node are set. On subsequent delta responses: + /// + /// - `peers_changed`: each changed/added peer replaces the existing + /// entry with the same key, or is appended if new. + /// - `peers_removed`: peers with matching keys are removed. + /// - `node`: updates the self node if present. + /// - `dns_config` and `derp_map`: replace the previous values if + /// present. + pub fn apply_map_response(&mut self, resp: MapResponse) { + if resp.keep_alive == Some(true) { + return; + } + + match &mut self.netmap { + None => { + // First response: full initialization. + let self_node = resp.node.unwrap_or_else(|| Node { + id: 0, + key: String::new(), + name: String::new(), + addresses: Vec::new(), + allowed_ips: None, + endpoints: None, + derp: None, + disco_key: None, + online: None, + }); + + let peers = resp.peers.unwrap_or_default(); + + self.netmap = Some(Netmap { + self_node, + peers, + dns_config: resp.dns_config, + derp_map: resp.derp_map, + }); + } + Some(netmap) => { + // Delta update on existing netmap. + if let Some(node) = resp.node { + netmap.self_node = node; + } + + // Full peer replacement (if server sends full list again). + if let Some(peers) = resp.peers { + netmap.peers = peers; + } + + // Incremental peer additions/changes. + if let Some(changed) = resp.peers_changed { + for changed_peer in changed { + if let Some(existing) = netmap + .peers + .iter_mut() + .find(|p| p.key == changed_peer.key) + { + *existing = changed_peer; + } else { + netmap.peers.push(changed_peer); + } + } + } + + // Peer removals. + if let Some(removed_keys) = resp.peers_removed { + netmap + .peers + .retain(|p| !removed_keys.contains(&p.key)); + } + + if let Some(dns) = resp.dns_config { + netmap.dns_config = Some(dns); + } + + if let Some(derp) = resp.derp_map { + netmap.derp_map = Some(derp); + } + } + } + } + + /// Returns a slice of the current peers, or an empty slice if the + /// netmap has not been initialized. + pub fn peers(&self) -> &[Node] { + match &self.netmap { + Some(netmap) => &netmap.peers, + None => &[], + } + } + + /// Returns this node's own information, or `None` if the netmap has + /// not been initialized. + pub fn self_node(&self) -> Option<&Node> { + self.netmap.as_ref().map(|nm| &nm.self_node) + } + + /// Build the [`Hostinfo`] for requests. + /// + /// Takes `&self` because future versions will include machine-specific + /// data (backend log ID, capability version). + #[allow(clippy::unused_self)] + fn hostinfo(&self) -> Hostinfo { + let hostname = gethostname(); + Hostinfo { + backend_log_id: String::new(), + os: std::env::consts::OS.to_string(), + hostname, + go_version: "dictyon/0.1.0".to_string(), + } + } +} + +/// Returns the system hostname, falling back to `"unknown"`. +fn gethostname() -> String { + hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "unknown".to_string()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use plegma_core::keys::{DiscoPrivate, MachinePrivate, NodePrivate}; + use plegma_core::types::{DnsConfig, DnsResolver, MapResponse, Node}; + + use super::*; + + /// Build a `ControlClient` with a dummy transport for unit testing. + /// + /// The transport is not usable for actual communication -- these + /// tests exercise the request building and netmap application logic. + fn test_client() -> ControlClient { + let machine_key = MachinePrivate::generate(); + let node_key = NodePrivate::generate(); + let disco_key = DiscoPrivate::generate(); + + // Build a paired transport to get a valid ControlConnection. + let server_key = MachinePrivate::generate(); + let server_pub = server_key.public_key(); + + let params: snow::params::NoiseParams = "Noise_IK_25519_ChaChaPoly_BLAKE2s" + .parse() + .expect("params should parse"); + let prologue = b"Tailscale Control Protocol v1"; + + let mut initiator = snow::Builder::new(params) + .local_private_key(machine_key.as_bytes()) + .expect("set key") + .remote_public_key(server_pub.as_bytes()) + .expect("set remote key") + .prologue(prologue) + .expect("set prologue") + .build_initiator() + .expect("build initiator"); + + let params2: snow::params::NoiseParams = "Noise_IK_25519_ChaChaPoly_BLAKE2s" + .parse() + .expect("params should parse"); + + let mut responder = snow::Builder::new(params2) + .local_private_key(server_key.as_bytes()) + .expect("set key") + .prologue(prologue) + .expect("set prologue") + .build_responder() + .expect("build responder"); + + let mut buf = vec![0u8; 65535]; + let mut payload_buf = vec![0u8; 65535]; + + let len = initiator.write_message(&[], &mut buf).expect("write msg1"); + responder + .read_message(&buf[..len], &mut payload_buf) + .expect("read msg1"); + + let len = responder.write_message(&[], &mut buf).expect("write msg2"); + initiator + .read_message(&buf[..len], &mut payload_buf) + .expect("read msg2"); + + let client_transport = crate::noise::NoiseTransport::from_snow( + initiator + .into_transport_mode() + .expect("initiator transport"), + ); + + // Re-generate a fresh machine key for the client (the one above + // was consumed by the handshake builder). + let client_machine = MachinePrivate::generate(); + + let conn = ControlConnection::from_transport(client_transport); + + ControlClient::new(conn, client_machine, node_key, disco_key) + } + + fn sample_node(id: i64, key: &str, name: &str) -> Node { + Node { + id, + key: key.to_string(), + name: name.to_string(), + addresses: vec!["100.64.0.1/32".to_string()], + allowed_ips: None, + endpoints: None, + derp: None, + disco_key: None, + online: None, + } + } + + #[test] + fn netmap_starts_empty() { + let client = test_client(); + assert!(client.self_node().is_none()); + assert!(client.peers().is_empty()); + } + + #[test] + fn apply_map_response_sets_initial_peers() { + let mut client = test_client(); + + let resp = MapResponse { + node: Some(sample_node(1, "nodekey:self", "self.ts.net.")), + peers: Some(vec![ + sample_node(2, "nodekey:peer1", "peer1.ts.net."), + sample_node(3, "nodekey:peer2", "peer2.ts.net."), + ]), + peers_changed: None, + peers_removed: None, + dns_config: Some(DnsConfig { + resolvers: Some(vec![DnsResolver { + addr: "100.100.100.100".to_string(), + }]), + domains: Some(vec!["example.ts.net".to_string()]), + }), + derp_map: None, + keep_alive: None, + }; + + client.apply_map_response(resp); + + let self_node = client.self_node().expect("self_node should be set"); + assert_eq!(self_node.key, "nodekey:self"); + assert_eq!(client.peers().len(), 2); + assert_eq!(client.peers()[0].key, "nodekey:peer1"); + assert_eq!(client.peers()[1].key, "nodekey:peer2"); + + let netmap = client.netmap.as_ref().expect("netmap should exist"); + let dns = netmap.dns_config.as_ref().expect("dns_config should exist"); + let resolvers = dns.resolvers.as_ref().expect("resolvers should exist"); + assert_eq!(resolvers[0].addr, "100.100.100.100"); + } + + #[test] + fn apply_map_response_delta_adds_peers() { + let mut client = test_client(); + + // Initial full response. + let initial = MapResponse { + node: Some(sample_node(1, "nodekey:self", "self.ts.net.")), + peers: Some(vec![sample_node(2, "nodekey:peer1", "peer1.ts.net.")]), + peers_changed: None, + peers_removed: None, + dns_config: None, + derp_map: None, + keep_alive: None, + }; + client.apply_map_response(initial); + assert_eq!(client.peers().len(), 1); + + // Delta: add a new peer and update existing one. + let mut updated_peer1 = sample_node(2, "nodekey:peer1", "peer1-updated.ts.net."); + updated_peer1.online = Some(true); + + let delta = MapResponse { + node: None, + peers: None, + peers_changed: Some(vec![ + updated_peer1, + sample_node(4, "nodekey:peer3", "peer3.ts.net."), + ]), + peers_removed: None, + dns_config: None, + derp_map: None, + keep_alive: None, + }; + client.apply_map_response(delta); + + assert_eq!(client.peers().len(), 2); + // Existing peer should be updated. + assert_eq!(client.peers()[0].name, "peer1-updated.ts.net."); + assert_eq!(client.peers()[0].online, Some(true)); + // New peer should be appended. + assert_eq!(client.peers()[1].key, "nodekey:peer3"); + } + + #[test] + fn apply_map_response_removes_peers() { + let mut client = test_client(); + + // Initial full response with three peers. + let initial = MapResponse { + node: Some(sample_node(1, "nodekey:self", "self.ts.net.")), + peers: Some(vec![ + sample_node(2, "nodekey:peer1", "peer1.ts.net."), + sample_node(3, "nodekey:peer2", "peer2.ts.net."), + sample_node(4, "nodekey:peer3", "peer3.ts.net."), + ]), + peers_changed: None, + peers_removed: None, + dns_config: None, + derp_map: None, + keep_alive: None, + }; + client.apply_map_response(initial); + assert_eq!(client.peers().len(), 3); + + // Delta: remove peer2. + let delta = MapResponse { + node: None, + peers: None, + peers_changed: None, + peers_removed: Some(vec!["nodekey:peer2".to_string()]), + dns_config: None, + derp_map: None, + keep_alive: None, + }; + client.apply_map_response(delta); + + assert_eq!(client.peers().len(), 2); + assert_eq!(client.peers()[0].key, "nodekey:peer1"); + assert_eq!(client.peers()[1].key, "nodekey:peer3"); + } + + #[test] + fn register_builds_correct_json() { + let client = test_client(); + let payload = client + .build_register_request(Some("tskey-auth-test123")) + .expect("build should succeed"); + + let json: serde_json::Value = + serde_json::from_slice(&payload).expect("payload should be valid JSON"); + + // Check required fields exist with correct PascalCase names. + assert!(json.get("NodeKey").is_some(), "missing NodeKey"); + assert!(json.get("OldNodeKey").is_some(), "missing OldNodeKey"); + assert!(json.get("Hostinfo").is_some(), "missing Hostinfo"); + + // NodeKey should be a proper nodekey: prefixed string. + let node_key = json["NodeKey"].as_str().expect("NodeKey should be string"); + assert!( + node_key.starts_with("nodekey:"), + "NodeKey should have nodekey: prefix: {node_key}" + ); + + // Auth key should be nested. + let auth = json.get("Auth").expect("Auth should be present"); + let auth_key = auth["AuthKey"] + .as_str() + .expect("AuthKey should be string"); + assert_eq!(auth_key, "tskey-auth-test123"); + + // Hostinfo should have GoVersion set to dictyon. + let hostinfo = &json["Hostinfo"]; + assert_eq!( + hostinfo["GoVersion"].as_str(), + Some("dictyon/0.1.0"), + "GoVersion should identify dictyon" + ); + } + + #[test] + fn parse_map_response_extracts_json() { + let json_body = br#"{"KeepAlive":true}"#; + let size = u32::try_from(json_body.len()).expect("test payload fits u32"); + + let mut frame = Vec::new(); + frame.extend_from_slice(&size.to_le_bytes()); + frame.extend_from_slice(json_body); + + let resp = + ControlClient::parse_map_response(&frame).expect("parse should succeed"); + + assert_eq!(resp.keep_alive, Some(true)); + } + + #[test] + fn parse_map_response_rejects_truncated_frame() { + // Frame header says 100 bytes but only 10 available. + let mut frame = Vec::new(); + frame.extend_from_slice(&100u32.to_le_bytes()); + frame.extend_from_slice(&[0u8; 10]); + + let result = ControlClient::parse_map_response(&frame); + assert!(result.is_err()); + } + + #[test] + fn keepalive_does_not_modify_netmap() { + let mut client = test_client(); + + // Initialize with a peer. + let initial = MapResponse { + node: Some(sample_node(1, "nodekey:self", "self.ts.net.")), + peers: Some(vec![sample_node(2, "nodekey:peer1", "peer1.ts.net.")]), + peers_changed: None, + peers_removed: None, + dns_config: None, + derp_map: None, + keep_alive: None, + }; + client.apply_map_response(initial); + assert_eq!(client.peers().len(), 1); + + // Keepalive should not change anything. + let keepalive = MapResponse { + node: None, + peers: None, + peers_changed: None, + peers_removed: None, + dns_config: None, + derp_map: None, + keep_alive: Some(true), + }; + client.apply_map_response(keepalive); + + assert_eq!(client.peers().len(), 1); + assert_eq!(client.peers()[0].key, "nodekey:peer1"); + } +} diff --git a/crates/dictyon/src/lib.rs b/crates/dictyon/src/lib.rs index 1a7f05f..a4aba0f 100644 --- a/crates/dictyon/src/lib.rs +++ b/crates/dictyon/src/lib.rs @@ -27,5 +27,6 @@ #![deny(missing_docs)] +pub mod control; pub mod noise; pub mod transport; diff --git a/crates/dictyon/src/transport.rs b/crates/dictyon/src/transport.rs index c64b598..d91ca23 100644 --- a/crates/dictyon/src/transport.rs +++ b/crates/dictyon/src/transport.rs @@ -72,6 +72,15 @@ pub struct UpgradeRequest { } impl ControlConnection { + /// Create a `ControlConnection` directly from a [`NoiseTransport`]. + /// + /// The caller is responsible for ensuring the transport has completed + /// the Noise handshake. This is the constructor used after manually + /// completing the handshake outside the HTTP upgrade flow. + pub fn from_transport(noise: NoiseTransport) -> Self { + Self { noise } + } + /// Build the HTTP upgrade request for `/ts2021`. /// /// Generates the Noise initiation message, base64-encodes it, and diff --git a/crates/plegma-core/Cargo.toml b/crates/plegma-core/Cargo.toml index 879eace..b4d1788 100644 --- a/crates/plegma-core/Cargo.toml +++ b/crates/plegma-core/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] snafu = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } x25519-dalek = { workspace = true } zeroize = { workspace = true } rand = { workspace = true } diff --git a/crates/plegma-core/src/lib.rs b/crates/plegma-core/src/lib.rs index 21d490b..8f9c9a2 100644 --- a/crates/plegma-core/src/lib.rs +++ b/crates/plegma-core/src/lib.rs @@ -11,3 +11,4 @@ #![deny(missing_docs)] pub mod keys; +pub mod types; diff --git a/crates/plegma-core/src/types.rs b/crates/plegma-core/src/types.rs new file mode 100644 index 0000000..d302893 --- /dev/null +++ b/crates/plegma-core/src/types.rs @@ -0,0 +1,424 @@ +//! Control protocol types for the Tailscale control plane. +//! +//! These structures match the JSON wire format used by the tailscale.com +//! control server. Field names use `serde(rename)` to match Tailscale's +//! `PascalCase` convention while keeping Rust-idiomatic `snake_case` locally. +//! +//! References: `tailcfg/tailcfg.go` in the Tailscale Go source. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +/// Request sent to `POST /machine/register` to register this node with +/// the control plane. +/// +/// The control server associates the node key with the machine key +/// (established during the Noise handshake) and either authorizes +/// immediately or returns an [`RegisterResponse::auth_url`] for +/// interactive login. +#[derive(Debug, Serialize)] +pub struct RegisterRequest { + /// The current node key, serialized as `"nodekey:hex..."`. + #[serde(rename = "NodeKey")] + pub node_key: String, + + /// The previous node key, if rotating due to expiry. Empty string on + /// first registration. + #[serde(rename = "OldNodeKey")] + pub old_node_key: String, + + /// Pre-authentication key for headless registration. `None` triggers + /// the interactive auth flow. + #[serde(rename = "Auth", skip_serializing_if = "Option::is_none")] + pub auth: Option, + + /// Host information describing this machine. + #[serde(rename = "Hostinfo")] + pub hostinfo: Hostinfo, + + /// Follow-up URL for long-polling after the user visits the auth URL. + /// Set to the `auth_url` from the initial [`RegisterResponse`]. + #[serde(rename = "Followup", skip_serializing_if = "Option::is_none")] + pub followup: Option, +} + +/// Authentication information included in [`RegisterRequest`]. +#[derive(Debug, Serialize)] +pub struct AuthInfo { + /// Pre-auth key value (e.g. `tskey-auth-...`). + #[serde(rename = "AuthKey", skip_serializing_if = "Option::is_none")] + pub auth_key: Option, +} + +/// Host information describing this machine to the control server. +/// +/// The control server uses this for display in the admin console and +/// for capability negotiation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hostinfo { + /// Opaque identifier for correlating backend logs. + #[serde(rename = "BackendLogID")] + pub backend_log_id: String, + + /// Operating system name (e.g. `"linux"`, `"darwin"`). + #[serde(rename = "OS")] + pub os: String, + + /// Machine hostname. + #[serde(rename = "Hostname")] + pub hostname: String, + + /// Client implementation version. Tailscale sends a Go version string; + /// dictyon sends `"dictyon/0.1.0"`. + #[serde(rename = "GoVersion")] + pub go_version: String, +} + +/// Response from `POST /machine/register`. +#[derive(Debug, Deserialize)] +pub struct RegisterResponse { + /// URL the user must visit to authorize this machine. `None` if the + /// machine is already authorized (e.g. via pre-auth key). + #[serde(rename = "AuthURL")] + pub auth_url: Option, + + /// Whether the machine is now authorized. + #[serde(rename = "MachineAuthorized")] + pub machine_authorized: bool, + + /// ISO 8601 expiry timestamp for the node key. `None` if the key does + /// not expire. + #[serde(rename = "NodeKeyExpiry")] + pub node_key_expiry: Option, +} + +// --------------------------------------------------------------------------- +// Map request / response +// --------------------------------------------------------------------------- + +/// Request sent to `POST /machine/map` to receive the network map. +/// +/// When `stream` is true the server holds the connection open and pushes +/// delta updates. +#[derive(Debug, Serialize)] +pub struct MapRequest { + /// Protocol capability version. + #[serde(rename = "Version")] + pub version: u64, + + /// This node's public key. + #[serde(rename = "NodeKey")] + pub node_key: String, + + /// This node's disco key. + #[serde(rename = "DiscoKey")] + pub disco_key: String, + + /// Locally discovered endpoints. + #[serde(rename = "Endpoints")] + pub endpoints: Vec, + + /// Whether to hold the connection open for streaming updates. + #[serde(rename = "Stream")] + pub stream: bool, + + /// Host information. + #[serde(rename = "Hostinfo")] + pub hostinfo: Hostinfo, +} + +/// A response frame from the `/machine/map` streaming endpoint. +/// +/// The first response contains the full network map (`node` + `peers`). +/// Subsequent responses are deltas: `peers_changed` and/or +/// `peers_removed` indicate incremental updates. If `keep_alive` is +/// `true`, all other fields should be ignored (liveness probe). +#[derive(Debug, Deserialize)] +pub struct MapResponse { + /// This node's own information. `None` means unchanged from the + /// previous response. + #[serde(rename = "Node")] + pub node: Option, + + /// Full peer list (first response only). `None` on deltas. + #[serde(rename = "Peers")] + pub peers: Option>, + + /// Peers that were added or changed since the last response. + #[serde(rename = "PeersChanged")] + pub peers_changed: Option>, + + /// Node key strings of peers that were removed. + #[serde(rename = "PeersRemoved")] + pub peers_removed: Option>, + + /// DNS configuration for `MagicDNS` and split DNS. + #[serde(rename = "DNSConfig")] + pub dns_config: Option, + + /// DERP relay server topology. + #[serde(rename = "DERPMap")] + pub derp_map: Option, + + /// If `true`, this is a keep-alive probe. All other fields should be + /// ignored. + #[serde(rename = "KeepAlive")] + pub keep_alive: Option, +} + +// --------------------------------------------------------------------------- +// Node +// --------------------------------------------------------------------------- + +/// A node in the tailnet, representing either this machine or a peer. +/// +/// Fields are optional where the control server may omit them (e.g. on +/// delta updates or for peers with limited visibility). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Node { + /// Server-assigned numeric identifier. + #[serde(rename = "ID")] + pub id: i64, + + /// The node's public key, serialized as `"nodekey:hex..."`. + #[serde(rename = "Key")] + pub key: String, + + /// The node's FQDN (trailing dot in Tailscale convention). + #[serde(rename = "Name")] + pub name: String, + + /// Assigned IP addresses in CIDR notation (e.g. `"100.64.0.1/32"`). + #[serde(rename = "Addresses")] + pub addresses: Vec, + + /// Routable CIDRs for this node (may include subnet routes). + #[serde(rename = "AllowedIPs", skip_serializing_if = "Option::is_none")] + pub allowed_ips: Option>, + + /// Network endpoints where this node can be reached directly. + #[serde(rename = "Endpoints", skip_serializing_if = "Option::is_none")] + pub endpoints: Option>, + + /// DERP home region in `"127.3.3.40:N"` format, where N is the + /// region ID. + #[serde(rename = "DERP", skip_serializing_if = "Option::is_none")] + pub derp: Option, + + /// The node's disco key for NAT traversal. + #[serde(rename = "DiscoKey", skip_serializing_if = "Option::is_none")] + pub disco_key: Option, + + /// Whether the node is currently online according to the control + /// server. + #[serde(rename = "Online", skip_serializing_if = "Option::is_none")] + pub online: Option, +} + +// --------------------------------------------------------------------------- +// DNS +// --------------------------------------------------------------------------- + +/// DNS configuration received in [`MapResponse`]. +/// +/// Controls `MagicDNS` behavior, split DNS routes, and upstream resolvers. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DnsConfig { + /// Upstream DNS resolvers. + #[serde(rename = "Resolvers", skip_serializing_if = "Option::is_none")] + pub resolvers: Option>, + + /// DNS search domains. + #[serde(rename = "Domains", skip_serializing_if = "Option::is_none")] + pub domains: Option>, +} + +/// A single DNS resolver entry. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DnsResolver { + /// Resolver address (e.g. `"1.1.1.1:53"` or `"100.100.100.100"`). + #[serde(rename = "Addr")] + pub addr: String, +} + +// --------------------------------------------------------------------------- +// DERP +// --------------------------------------------------------------------------- + +/// DERP relay server topology received in [`MapResponse`]. +/// +/// The `regions` field contains the full DERP region map, which is a +/// complex nested structure. We defer full typing and parse it as +/// opaque JSON for now. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DerpMap { + /// Region definitions. Complex nested structure; parsed as opaque + /// JSON until full typing is needed. + #[serde(rename = "Regions", skip_serializing_if = "Option::is_none")] + pub regions: Option, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn register_request_serializes_to_json() { + let req = RegisterRequest { + node_key: "nodekey:abc123".to_string(), + old_node_key: String::new(), + auth: Some(AuthInfo { + auth_key: Some("tskey-auth-test".to_string()), + }), + hostinfo: Hostinfo { + backend_log_id: "log123".to_string(), + os: "linux".to_string(), + hostname: "testhost".to_string(), + go_version: "dictyon/0.1.0".to_string(), + }, + followup: None, + }; + + let json = serde_json::to_string(&req).expect("serialization should succeed"); + + // Verify PascalCase field names from the Tailscale protocol + assert!(json.contains("\"NodeKey\""), "missing NodeKey: {json}"); + assert!( + json.contains("\"OldNodeKey\""), + "missing OldNodeKey: {json}" + ); + assert!( + json.contains("\"BackendLogID\""), + "missing BackendLogID: {json}" + ); + assert!(json.contains("\"OS\""), "missing OS: {json}"); + assert!(json.contains("\"Hostname\""), "missing Hostname: {json}"); + assert!(json.contains("\"GoVersion\""), "missing GoVersion: {json}"); + + // Verify values round-trip + assert!( + json.contains("\"nodekey:abc123\""), + "NodeKey value wrong: {json}" + ); + assert!( + json.contains("\"dictyon/0.1.0\""), + "GoVersion value wrong: {json}" + ); + + // Followup should be omitted when None + assert!( + !json.contains("\"Followup\""), + "Followup should be omitted when None: {json}" + ); + } + + #[test] + fn map_response_deserializes_full() { + let json = r#"{ + "Node": { + "ID": 12345, + "Key": "nodekey:self000", + "Name": "myhost.tail1234.ts.net.", + "Addresses": ["100.64.0.1/32", "fd7a:115c:a1e0::1/128"], + "DERP": "127.3.3.40:1", + "DiscoKey": "discokey:abc123", + "Online": true + }, + "Peers": [ + { + "ID": 67890, + "Key": "nodekey:peer001", + "Name": "peerhost.tail1234.ts.net.", + "Addresses": ["100.64.0.2/32"], + "AllowedIPs": ["100.64.0.2/32"], + "Endpoints": ["1.2.3.4:41641"], + "DERP": "127.3.3.40:2", + "DiscoKey": "discokey:def456", + "Online": true + } + ], + "DNSConfig": { + "Resolvers": [{"Addr": "100.100.100.100"}], + "Domains": ["tail1234.ts.net"] + }, + "DERPMap": { + "Regions": {"1": {"RegionID": 1, "RegionCode": "nyc"}} + } + }"#; + + let resp: MapResponse = serde_json::from_str(json).expect("deserialization should succeed"); + + let node = resp.node.as_ref().expect("node should be present"); + assert_eq!(node.id, 12345); + assert_eq!(node.key, "nodekey:self000"); + assert_eq!(node.name, "myhost.tail1234.ts.net."); + assert_eq!(node.addresses.len(), 2); + assert_eq!(node.derp.as_deref(), Some("127.3.3.40:1")); + assert_eq!(node.online, Some(true)); + + let peers = resp.peers.as_ref().expect("peers should be present"); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].id, 67890); + assert_eq!(peers[0].key, "nodekey:peer001"); + assert_eq!( + peers[0].endpoints.as_ref().expect("endpoints present"), + &["1.2.3.4:41641"] + ); + + let dns = resp + .dns_config + .as_ref() + .expect("dns_config should be present"); + let resolvers = dns.resolvers.as_ref().expect("resolvers present"); + assert_eq!(resolvers[0].addr, "100.100.100.100"); + let domains = dns.domains.as_ref().expect("domains present"); + assert_eq!(domains[0], "tail1234.ts.net"); + + assert!(resp.derp_map.is_some()); + assert!(resp.keep_alive.is_none()); + } + + #[test] + fn map_response_deserializes_keepalive() { + let json = r#"{"KeepAlive": true}"#; + let resp: MapResponse = serde_json::from_str(json).expect("keepalive should parse"); + + assert_eq!(resp.keep_alive, Some(true)); + assert!(resp.node.is_none()); + assert!(resp.peers.is_none()); + assert!(resp.peers_changed.is_none()); + assert!(resp.peers_removed.is_none()); + assert!(resp.dns_config.is_none()); + assert!(resp.derp_map.is_none()); + } + + #[test] + fn node_deserializes_with_optional_fields() { + let json = r#"{ + "ID": 1, + "Key": "nodekey:minimal", + "Name": "bare.example.ts.net.", + "Addresses": ["100.64.0.99/32"] + }"#; + + let node: Node = serde_json::from_str(json).expect("minimal node should parse"); + + assert_eq!(node.id, 1); + assert_eq!(node.key, "nodekey:minimal"); + assert_eq!(node.name, "bare.example.ts.net."); + assert_eq!(node.addresses, vec!["100.64.0.99/32"]); + assert!(node.allowed_ips.is_none()); + assert!(node.endpoints.is_none()); + assert!(node.derp.is_none()); + assert!(node.disco_key.is_none()); + assert!(node.online.is_none()); + } +}