use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SidecarCommand {
pub command: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StartCommandData {
pub hostname: String,
pub state_dir: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_key: Option<String>,
pub bridge_port: u16,
pub session_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ephemeral: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idle_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DialCommandData {
pub request_id: String,
pub target: String,
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListenCommandData {
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UnlistenCommandData {
pub port: u16,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PingCommandData {
pub target: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ping_type: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WatchPeersCommandData {
#[serde(skip_serializing_if = "Option::is_none")]
pub include_all: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListenPacketCommandData {
pub port: u16,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProxyAddCommandData {
pub id: String,
pub name: String,
pub listen_port: u16,
pub target_host: String,
pub target_port: u16,
pub target_scheme: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProxyRemoveCommandData {
pub id: String,
}
pub(crate) mod command_type {
pub const START: &str = "tsnet:start";
pub const STOP: &str = "tsnet:stop";
pub const GET_PEERS: &str = "tsnet:getPeers";
pub const DIAL: &str = "bridge:dial";
pub const LISTEN: &str = "tsnet:listen";
pub const UNLISTEN: &str = "tsnet:unlisten";
pub const PING: &str = "tsnet:ping";
pub const WATCH_PEERS: &str = "tsnet:watchPeers";
pub const LISTEN_PACKET: &str = "tsnet:listenPacket";
pub const PROXY_ADD: &str = "proxy:add";
pub const PROXY_REMOVE: &str = "proxy:remove";
pub const PROXY_LIST: &str = "proxy:list";
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SidecarEvent {
pub event: String,
#[serde(default)]
pub data: serde_json::Value,
}
pub(crate) mod event_type {
pub const STARTED: &str = "tsnet:started";
pub const STOPPED: &str = "tsnet:stopped";
pub const STATUS: &str = "tsnet:status";
pub const AUTH_REQUIRED: &str = "tsnet:authRequired";
pub const NEEDS_APPROVAL: &str = "tsnet:needsApproval";
pub const STATE_CHANGE: &str = "tsnet:stateChange";
pub const KEY_EXPIRING: &str = "tsnet:keyExpiring";
pub const HEALTH_WARNING: &str = "tsnet:healthWarning";
pub const PEERS: &str = "tsnet:peers";
pub const DIAL_RESULT: &str = "bridge:dialResult";
pub const ERROR: &str = "tsnet:error";
pub const LISTENING: &str = "tsnet:listening";
pub const UNLISTENED: &str = "tsnet:unlistened";
pub const PING_RESULT: &str = "tsnet:pingResult";
pub const PEER_CHANGED: &str = "tsnet:peerChanged";
pub const LISTENING_PACKET: &str = "tsnet:listeningPacket";
pub const PROXY_ADDED: &str = "proxy:added";
pub const PROXY_REMOVED: &str = "proxy:removed";
pub const PROXY_LIST_RESULT: &str = "proxy:list";
pub const PROXY_ERROR: &str = "proxy:error";
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StatusEventData {
pub state: String,
#[serde(default)]
pub hostname: String,
#[serde(default)]
pub dns_name: String,
#[serde(default, alias = "tailscaleIP")]
pub tailscale_ip: String,
#[serde(default)]
pub node_id: String,
#[serde(default)]
pub error: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AuthRequiredEventData {
pub auth_url: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StateChangeEventData {
pub state: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct KeyExpiringEventData {
pub expires_at: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HealthWarningEventData {
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SidecarPeer {
pub id: String,
pub hostname: String,
pub dns_name: String,
#[serde(rename = "tailscaleIPs")]
pub tailscale_ips: Vec<String>,
pub online: bool,
#[serde(default)]
pub os: String,
#[serde(default)]
pub cur_addr: String,
#[serde(default)]
pub relay: String,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub key_expiry: Option<String>,
#[serde(default)]
pub expired: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PeersEventData {
pub peers: Vec<SidecarPeer>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DialResultEventData {
pub request_id: String,
pub success: bool,
#[serde(default)]
pub error: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ErrorEventData {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListeningEventData {
pub port: u16,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UnlistenedEventData {
pub port: u16,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListeningPacketEventData {
pub port: u16,
pub local_port: u16,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PingResultEventData {
#[serde(default)]
pub target: String,
#[serde(default)]
pub latency_ms: f64,
#[serde(default)]
pub direct: bool,
#[serde(default)]
pub relay: String,
#[serde(default)]
pub peer_addr: String,
#[serde(default)]
pub error: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PeerChangedEventData {
pub change_type: String,
pub peer: Option<SidecarPeer>,
pub peer_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProxyAddedEventData {
pub id: String,
pub listen_port: u16,
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProxyRemovedEventData {
pub id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProxyListEventData {
pub proxies: Vec<ProxyInfoEventData>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProxyInfoEventData {
pub id: String,
pub name: String,
pub listen_port: u16,
pub target_host: String,
pub target_port: u16,
pub target_scheme: String,
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProxyErrorEventData {
pub id: String,
pub code: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_start_command() {
let data = StartCommandData {
hostname: "my-node".to_string(),
state_dir: "/tmp/tsnet".to_string(),
auth_key: None,
bridge_port: 12345,
session_token: "aa".repeat(32),
ephemeral: None,
tags: None,
idle_timeout_secs: None,
};
let cmd = SidecarCommand {
command: command_type::START,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"tsnet:start\""));
assert!(json.contains("\"hostname\":\"my-node\""));
assert!(json.contains("\"bridgePort\":12345"));
assert!(!json.contains("authKey"));
assert!(!json.contains("idleTimeoutSecs"));
}
#[test]
fn serialize_start_command_with_idle_timeout() {
let data = StartCommandData {
hostname: "my-node".to_string(),
state_dir: "/tmp/tsnet".to_string(),
auth_key: None,
bridge_port: 12345,
session_token: "aa".repeat(32),
ephemeral: None,
tags: None,
idle_timeout_secs: Some(300),
};
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains("\"idleTimeoutSecs\":300"));
}
#[test]
fn serialize_dial_command() {
let data = DialCommandData {
request_id: "req-123".to_string(),
target: "peer.tailnet.ts.net".to_string(),
port: 9417,
tls: None,
};
let cmd = SidecarCommand {
command: command_type::DIAL,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"bridge:dial\""));
assert!(json.contains("\"requestId\":\"req-123\""));
assert!(json.contains("\"port\":9417"));
assert!(!json.contains("tls"));
}
#[test]
fn serialize_dial_command_with_tls() {
let data = DialCommandData {
request_id: "req-123".to_string(),
target: "peer.tailnet.ts.net".to_string(),
port: 443,
tls: Some(false),
};
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains("\"tls\":false"));
let data = DialCommandData {
request_id: "req-124".to_string(),
target: "peer.tailnet.ts.net".to_string(),
port: 8080,
tls: Some(true),
};
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains("\"tls\":true"));
}
#[test]
fn serialize_listen_command() {
let data = ListenCommandData {
port: 8080,
tls: None,
};
let cmd = SidecarCommand {
command: command_type::LISTEN,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"tsnet:listen\""));
assert!(json.contains("\"port\":8080"));
assert!(!json.contains("tls"));
}
#[test]
fn serialize_ping_command() {
let data = PingCommandData {
target: "100.64.0.2".to_string(),
ping_type: Some("TSMP".to_string()),
};
let cmd = SidecarCommand {
command: command_type::PING,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"tsnet:ping\""));
assert!(json.contains("\"target\":\"100.64.0.2\""));
assert!(json.contains("\"pingType\":\"TSMP\""));
}
#[test]
fn serialize_watch_peers_command() {
let data = WatchPeersCommandData { include_all: None };
let cmd = SidecarCommand {
command: command_type::WATCH_PEERS,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"tsnet:watchPeers\""));
}
#[test]
fn deserialize_status_event() {
let json = r#"{"event":"tsnet:status","data":{"state":"running","hostname":"my-node","dnsName":"my-node.tailnet.ts.net","tailscaleIP":"100.64.0.1"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event, "tsnet:status");
let data: StatusEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.state, "running");
assert_eq!(data.hostname, "my-node");
assert_eq!(data.tailscale_ip, "100.64.0.1");
}
#[test]
fn deserialize_peers_event() {
let json = r#"{"event":"tsnet:peers","data":{"peers":[{"id":"node123","hostname":"truffle-cli-abc","dnsName":"truffle-cli-abc.tailnet.ts.net","tailscaleIPs":["100.64.0.2"],"online":true,"os":"linux","curAddr":"192.168.1.5:41641","relay":"","lastSeen":"2026-03-24T10:00:00Z","keyExpiry":"2026-06-24T10:00:00Z","expired":false}]}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event, "tsnet:peers");
let data: PeersEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.peers.len(), 1);
assert_eq!(data.peers[0].id, "node123");
assert_eq!(data.peers[0].hostname, "truffle-cli-abc");
assert!(data.peers[0].online);
}
#[test]
fn deserialize_dial_result_event() {
let json = r#"{"event":"bridge:dialResult","data":{"requestId":"req-456","success":true}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: DialResultEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.request_id, "req-456");
assert!(data.success);
assert!(data.error.is_empty());
}
#[test]
fn deserialize_dial_result_failure() {
let json = r#"{"event":"bridge:dialResult","data":{"requestId":"req-789","success":false,"error":"connection refused"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: DialResultEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.request_id, "req-789");
assert!(!data.success);
assert_eq!(data.error, "connection refused");
}
#[test]
fn deserialize_ping_result_event() {
let json = r#"{"event":"tsnet:pingResult","data":{"target":"100.64.0.2","latencyMs":12.5,"direct":true,"relay":"","peerAddr":"192.168.1.5:41641"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: PingResultEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.target, "100.64.0.2");
assert!((data.latency_ms - 12.5).abs() < f64::EPSILON);
assert!(data.direct);
}
#[test]
fn deserialize_peer_changed_event() {
let json = r#"{"event":"tsnet:peerChanged","data":{"changeType":"joined","peerId":"node123","peer":{"id":"node123","hostname":"truffle-cli-abc","dnsName":"truffle-cli-abc.tailnet.ts.net","tailscaleIPs":["100.64.0.2"],"online":true}}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: PeerChangedEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.change_type, "joined");
assert_eq!(data.peer_id, "node123");
assert!(data.peer.is_some());
}
#[test]
fn deserialize_peer_left_event() {
let json =
r#"{"event":"tsnet:peerChanged","data":{"changeType":"left","peerId":"node456"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: PeerChangedEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.change_type, "left");
assert_eq!(data.peer_id, "node456");
assert!(data.peer.is_none());
}
#[test]
fn deserialize_error_event() {
let json =
r#"{"event":"tsnet:error","data":{"code":"NOT_RUNNING","message":"node not running"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: ErrorEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.code, "NOT_RUNNING");
assert_eq!(data.message, "node not running");
}
#[test]
fn deserialize_auth_required_event() {
let json = r#"{"event":"tsnet:authRequired","data":{"authUrl":"https://login.tailscale.com/a/abc123"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: AuthRequiredEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.auth_url, "https://login.tailscale.com/a/abc123");
}
#[test]
fn deserialize_listening_event() {
let json = r#"{"event":"tsnet:listening","data":{"port":8080}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: ListeningEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.port, 8080);
}
#[test]
fn deserialize_health_warning_event() {
let json = r#"{"event":"tsnet:healthWarning","data":{"warnings":["DNS is failing","MagicDNS not working"]}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: HealthWarningEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.warnings.len(), 2);
}
#[test]
fn serialize_listen_packet_command() {
let data = ListenPacketCommandData { port: 19420 };
let cmd = SidecarCommand {
command: command_type::LISTEN_PACKET,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"tsnet:listenPacket\""));
assert!(json.contains("\"port\":19420"));
}
#[test]
fn deserialize_listening_packet_event() {
let json = r#"{"event":"tsnet:listeningPacket","data":{"port":19420,"localPort":54321}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event, "tsnet:listeningPacket");
let data: ListeningPacketEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.port, 19420);
assert_eq!(data.local_port, 54321);
}
#[test]
fn serialize_proxy_add_command() {
let data = ProxyAddCommandData {
id: "dev-server".to_string(),
name: "Dev Server".to_string(),
listen_port: 3001,
target_host: "localhost".to_string(),
target_port: 3000,
target_scheme: "http".to_string(),
};
let cmd = SidecarCommand {
command: command_type::PROXY_ADD,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"proxy:add\""));
assert!(json.contains("\"listenPort\":3001"));
assert!(json.contains("\"targetHost\":\"localhost\""));
assert!(json.contains("\"targetPort\":3000"));
}
#[test]
fn serialize_proxy_remove_command() {
let data = ProxyRemoveCommandData {
id: "dev-server".to_string(),
};
let cmd = SidecarCommand {
command: command_type::PROXY_REMOVE,
data: Some(serde_json::to_value(&data).unwrap()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"command\":\"proxy:remove\""));
assert!(json.contains("\"id\":\"dev-server\""));
}
#[test]
fn deserialize_proxy_added_event() {
let json = r#"{"event":"proxy:added","data":{"id":"dev-server","listenPort":3001,"url":"https://myhost.ts.net:3001"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event, "proxy:added");
let data: ProxyAddedEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.id, "dev-server");
assert_eq!(data.listen_port, 3001);
assert_eq!(data.url, "https://myhost.ts.net:3001");
}
#[test]
fn deserialize_proxy_removed_event() {
let json = r#"{"event":"proxy:removed","data":{"id":"dev-server"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: ProxyRemovedEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.id, "dev-server");
}
#[test]
fn deserialize_proxy_error_event() {
let json = r#"{"event":"proxy:error","data":{"id":"dev-server","code":"CONNECTION_REFUSED","message":"target localhost:3000 not reachable"}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: ProxyErrorEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.id, "dev-server");
assert_eq!(data.code, "CONNECTION_REFUSED");
}
#[test]
fn deserialize_proxy_list_event() {
let json = r#"{"event":"proxy:list","data":{"proxies":[{"id":"dev-server","name":"Dev Server","listenPort":3001,"targetHost":"localhost","targetPort":3000,"targetScheme":"http","url":"https://myhost.ts.net:3001"}]}}"#;
let event: SidecarEvent = serde_json::from_str(json).unwrap();
let data: ProxyListEventData = serde_json::from_value(event.data).unwrap();
assert_eq!(data.proxies.len(), 1);
assert_eq!(data.proxies[0].id, "dev-server");
assert_eq!(data.proxies[0].listen_port, 3001);
}
}