zlayer_types/nat_wire.rs
1//! Wire-safe NAT-traversal DTOs shared across the daemon / overlayd boundary.
2//!
3//! The live NAT-traversal types (`NatConfig`, `Candidate`, `RelayServerConfig`,
4//! …) live in `zlayer-overlay` behind its `nat` cargo feature. They cannot be
5//! referenced from `zlayer-types` (a leaf crate that must not depend on
6//! `zlayer-overlay`, and must compile with NAT disabled), so this module
7//! mirrors the subset that crosses the overlayd IPC channel as plain,
8//! always-available, `serde`-friendly structs.
9//!
10//! The conversion `NatConfigSpec` ⇄ `zlayer_overlay::NatConfig` is performed in
11//! the crates that already depend on `zlayer-overlay` (the agent shim and the
12//! overlayd server) — never here — so the leaf-crate dependency rule holds.
13//!
14//! Every field is `#[serde(default)]` so an older daemon/overlayd that omits the
15//! `nat` object (or any sub-field) deserializes cleanly: a missing object means
16//! "no explicit NAT config supplied" and overlayd falls back to its built-in
17//! `NatConfig::default()`.
18
19use serde::{Deserialize, Serialize};
20
21/// Wire form of `zlayer_overlay::nat::NatConfig`.
22///
23/// Carried in [`crate::overlayd::OverlaydRequest::SetupGlobalOverlay`] so the
24/// operator-supplied `--stun-server` / `--turn-server` / `--relay-server-bind`
25/// flags (parsed by the main daemon) actually reach overlayd, which owns the
26/// live NAT orchestrator. Previously only an `enabled: bool` crossed the wire
27/// and overlayd reconstructed a *default* `NatConfig`, silently dropping every
28/// operator override.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
30pub struct NatConfigSpec {
31 /// Whether NAT traversal is enabled.
32 #[serde(default)]
33 pub enabled: bool,
34 /// STUN servers (`host:port`) for reflexive-address discovery.
35 #[serde(default)]
36 pub stun_servers: Vec<String>,
37 /// TURN/relay servers used as the last-resort fallback.
38 #[serde(default)]
39 pub turn_servers: Vec<TurnServerSpec>,
40 /// Seconds to attempt hole-punching before falling back to relay.
41 #[serde(default)]
42 pub hole_punch_timeout_secs: u64,
43 /// Seconds between STUN reflexive-address refreshes.
44 #[serde(default)]
45 pub stun_refresh_interval_secs: u64,
46 /// Maximum candidate pairs to test per peer.
47 #[serde(default)]
48 pub max_candidate_pairs: usize,
49 /// When `Some`, this node should run the built-in relay server.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub relay_server: Option<RelayServerSpec>,
52}
53
54/// Wire form of `zlayer_overlay::nat::TurnServerConfig`.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56pub struct TurnServerSpec {
57 /// TURN server address (`host:port`).
58 #[serde(default)]
59 pub addr: String,
60 /// Auth username.
61 #[serde(default)]
62 pub username: String,
63 /// Auth credential.
64 #[serde(default)]
65 pub credential: String,
66}
67
68/// Wire form of `zlayer_overlay::nat::RelayServerConfig`, plus the cluster-wide
69/// shared `auth_credential` clients use to derive the relay's `BLAKE2b` auth
70/// key.
71///
72/// The credential MUST be identical on every node (a client's
73/// `derive_auth_key(credential)` must match the server's), so it cannot be the
74/// node's per-node `WireGuard` key. The main daemon populates it with the
75/// cluster-shared HS256 secret (the same value behind `cluster_jwt_secret`).
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
77pub struct RelayServerSpec {
78 /// Port the relay server listens on (`0` = OS-assigned ephemeral).
79 #[serde(default)]
80 pub listen_port: u16,
81 /// External address other nodes use to reach this relay (`host:port`).
82 #[serde(default)]
83 pub external_addr: String,
84 /// Maximum concurrent relay sessions.
85 #[serde(default)]
86 pub max_sessions: usize,
87 /// Cluster-shared secret used to derive the relay auth key. `None` when the
88 /// daemon could not supply one (the relay still starts but rejects clients
89 /// that don't share the same — empty — credential).
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub auth_credential: Option<String>,
92}
93
94/// Wire form of a single `zlayer_overlay::nat::Candidate`.
95///
96/// Exchanged peer-to-peer so a joining node can hand the cluster the addresses
97/// it can be reached on; overlayd feeds the remote node's candidates into
98/// `NatTraversal::connect_to_peer`.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
100pub struct NatCandidateWire {
101 /// `"host"` / `"server-reflexive"` / `"relay"`.
102 pub candidate_type: String,
103 /// The candidate endpoint as a `host:port` string.
104 pub address: String,
105 /// Priority (higher = preferred).
106 pub priority: u32,
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn nat_config_spec_round_trips() {
115 let spec = NatConfigSpec {
116 enabled: true,
117 stun_servers: vec!["stun.l.google.com:19302".into()],
118 turn_servers: vec![TurnServerSpec {
119 addr: "turn.example.com:3478".into(),
120 username: "u".into(),
121 credential: "p".into(),
122 }],
123 hole_punch_timeout_secs: 15,
124 stun_refresh_interval_secs: 60,
125 max_candidate_pairs: 10,
126 relay_server: Some(RelayServerSpec {
127 listen_port: 3478,
128 external_addr: "1.2.3.4:3478".into(),
129 max_sessions: 100,
130 auth_credential: Some("cluster-secret".into()),
131 }),
132 };
133 let json = serde_json::to_string(&spec).expect("serialize");
134 let back: NatConfigSpec = serde_json::from_str(&json).expect("deserialize");
135 assert_eq!(spec, back);
136 }
137
138 /// A payload omitting every field (or the whole object) deserializes to the
139 /// all-default spec — the back-compat guarantee older senders rely on.
140 #[test]
141 fn nat_config_spec_empty_object_is_all_default() {
142 let spec: NatConfigSpec = serde_json::from_str("{}").expect("deserialize");
143 assert!(!spec.enabled);
144 assert!(spec.stun_servers.is_empty());
145 assert!(spec.turn_servers.is_empty());
146 assert_eq!(spec.hole_punch_timeout_secs, 0);
147 assert!(spec.relay_server.is_none());
148 assert_eq!(spec, NatConfigSpec::default());
149 }
150
151 #[test]
152 fn relay_server_spec_omits_none_credential() {
153 let spec = RelayServerSpec {
154 listen_port: 3478,
155 external_addr: "1.2.3.4:3478".into(),
156 max_sessions: 50,
157 auth_credential: None,
158 };
159 let json = serde_json::to_string(&spec).expect("serialize");
160 assert!(
161 !json.contains("auth_credential"),
162 "None credential must be skipped: {json}"
163 );
164 let back: RelayServerSpec = serde_json::from_str(&json).expect("deserialize");
165 assert_eq!(spec, back);
166 }
167
168 #[test]
169 fn nat_candidate_wire_round_trips() {
170 let c = NatCandidateWire {
171 candidate_type: "server-reflexive".into(),
172 address: "203.0.113.5:51820".into(),
173 priority: 50,
174 };
175 let json = serde_json::to_string(&c).expect("serialize");
176 let back: NatCandidateWire = serde_json::from_str(&json).expect("deserialize");
177 assert_eq!(c, back);
178 }
179}