Skip to main content

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}