Skip to main content

zlayer_overlayd/
network_state.rs

1//! Persistent marker for host-level networks `ZLayer` creates.
2//!
3//! Some network objects `ZLayer` provisions live at the **host** level, not the
4//! daemon-process level — most notably the Windows HCN overlay network, which
5//! the HCS runtime creates and HCN keeps alive until an explicit
6//! `HcnDeleteNetwork`. Such objects must:
7//!
8//!   * be **reused** across daemon restarts / binary updates / reinstalls
9//!     (look them up by their recorded id instead of blindly recreating), and
10//!   * be torn down **only** on a full uninstall (`daemon uninstall --purge`),
11//!     never on a routine stop/restart.
12//!
13//! This module is the on-disk record that makes that lifecycle possible. It is
14//! intentionally backend-agnostic (pure `serde` + `std::fs`) so the same marker
15//! file can track HCN networks on Windows, bridges on Linux, etc. The file
16//! lives at [`zlayer_paths::ZLayerDirs::agent_network_state`]
17//! (`{data_dir}/agent_network.json`).
18
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23/// Schema version for [`NetworkState`]. Bump on a breaking layout change.
24const CURRENT_VERSION: u32 = 1;
25
26/// `owner` value for the node's single shared base overlay network.
27pub const OWNER_BASE: &str = "base";
28
29/// `owner` value for the node's single shared HCN NAT network backing every
30/// `OverlayMode::Shared` service on Windows (one per node, reused across all
31/// Shared services). Distinct from [`OWNER_BASE`] so the two coexist.
32pub const OWNER_SHARED_NAT: &str = "shared-nat";
33
34/// Build the `owner` value for a dedicated per-service network.
35#[must_use]
36pub fn owner_for_service(service: &str) -> String {
37    format!("service:{service}")
38}
39
40/// Build the `owner` value for a per-isolation-network HCN network. A `ZLayer`
41/// "isolated" network gets its OWN HCN Internal vSwitch (mutually isolated from
42/// other vSwitches by default); members of the same isolation network share it
43/// and reach each other + egress + the node, while different isolation networks
44/// land on separate vSwitches and cannot reach each other. Distinct namespace
45/// from [`owner_for_service`] so an isolation network and a service of the same
46/// name never collide on the same marker key.
47#[must_use]
48pub fn owner_for_isolation_network(net: &str) -> String {
49    format!("iso:{net}")
50}
51
52/// One host-level network `ZLayer` is responsible for.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ManagedNetwork {
55    /// Logical owner: [`OWNER_BASE`] for the node's shared overlay, or
56    /// `service:<name>` (see [`owner_for_service`]) for a dedicated per-service
57    /// network. Used as the upsert key.
58    pub owner: String,
59    /// Backend-specific kind, e.g. `"hcn-internal"`. Lets a reader (and the
60    /// uninstall path) know which API to use to delete the object.
61    pub kind: String,
62    /// Human-readable network name (e.g. `"zlayer-overlay"`).
63    pub name: String,
64    /// Host-addressable id — for HCN this is the network GUID string.
65    pub id: String,
66    /// CIDR the network was created with (informational / diagnostics).
67    pub subnet: String,
68    /// Dedicated-overlay `WireGuard` listen port (per-service transports only).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub wg_port: Option<u16>,
71    /// Dedicated-overlay `WireGuard` private key, base64 (per-service only).
72    /// Persisted so the device identity survives overlayd restarts (no
73    /// per-service republish loop exists, so a stable key avoids a re-peer storm).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub wg_private_key: Option<String>,
76    /// Dedicated-overlay public key, base64.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub wg_public_key: Option<String>,
79    /// Dedicated-overlay interface name.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub interface: Option<String>,
82}
83
84/// The full marker file: every host-level network this node manages.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct NetworkState {
87    /// On-disk schema version.
88    #[serde(default = "default_version")]
89    pub version: u32,
90    /// Managed networks, keyed in practice by [`ManagedNetwork::owner`].
91    #[serde(default)]
92    pub networks: Vec<ManagedNetwork>,
93}
94
95fn default_version() -> u32 {
96    CURRENT_VERSION
97}
98
99impl Default for NetworkState {
100    fn default() -> Self {
101        Self {
102            version: CURRENT_VERSION,
103            networks: Vec::new(),
104        }
105    }
106}
107
108impl NetworkState {
109    /// Load the marker file. A missing or unparseable file yields an empty
110    /// state rather than an error — the marker is a best-effort cache that the
111    /// live-host enumeration paths can always rebuild.
112    #[must_use]
113    pub fn load(path: &Path) -> Self {
114        match std::fs::read(path) {
115            Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
116            Err(_) => Self::default(),
117        }
118    }
119
120    /// Persist the marker file, creating the parent directory if needed. Writes
121    /// to a sibling temp file and renames so a crash mid-write can't leave a
122    /// truncated marker.
123    ///
124    /// # Errors
125    ///
126    /// Returns any I/O error from creating the directory, writing, or renaming.
127    pub fn save(&self, path: &Path) -> std::io::Result<()> {
128        if let Some(parent) = path.parent() {
129            std::fs::create_dir_all(parent)?;
130        }
131        let json = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
132        let tmp = path.with_extension("json.tmp");
133        std::fs::write(&tmp, &json)?;
134        std::fs::rename(&tmp, path)?;
135        Ok(())
136    }
137
138    /// Look up a managed network by owner.
139    #[must_use]
140    pub fn get(&self, owner: &str) -> Option<&ManagedNetwork> {
141        self.networks.iter().find(|n| n.owner == owner)
142    }
143
144    /// Insert or replace the entry for `net.owner`.
145    pub fn upsert(&mut self, net: ManagedNetwork) {
146        if let Some(existing) = self.networks.iter_mut().find(|n| n.owner == net.owner) {
147            *existing = net;
148        } else {
149            self.networks.push(net);
150        }
151    }
152
153    /// Remove and return the entry for `owner`, if present.
154    pub fn remove(&mut self, owner: &str) -> Option<ManagedNetwork> {
155        self.networks
156            .iter()
157            .position(|n| n.owner == owner)
158            .map(|pos| self.networks.remove(pos))
159    }
160}
161
162/// Width of the dedicated-overlay listen-port band scanned by
163/// [`DedicatedPortAllocator`]. Ports are handed out from `base+1 ..= base+MAX`,
164/// so a default base of `51820` yields the range `51821..=52076` — 256 distinct
165/// per-service `WireGuard` transports, comfortably more than any single node is
166/// expected to host while staying well clear of the ephemeral range.
167pub const DEDICATED_PORT_BAND: u16 = 256;
168
169/// Deterministic allocator for dedicated-overlay `WireGuard` listen ports.
170///
171/// Each per-service [`OverlayMode::Dedicated`] overlay needs its own UDP listen
172/// port distinct from the node's shared base-overlay port. This allocator hands
173/// out the lowest free port in the band `base+1 ..= base+`[`DEDICATED_PORT_BAND`]
174/// by scanning ascending — no RNG, fully reproducible across restarts.
175///
176/// On startup, callers rehydrate the in-use set from the marker (the persisted
177/// [`ManagedNetwork::wg_port`] of each dedicated service) via [`Self::reserve`]
178/// so a service re-binds the exact port it had before.
179///
180/// [`OverlayMode::Dedicated`]: # "consumed by a later task"
181#[derive(Debug, Clone)]
182pub struct DedicatedPortAllocator {
183    base: u16,
184    used: std::collections::BTreeSet<u16>,
185}
186
187impl DedicatedPortAllocator {
188    /// Build an allocator over `base+1 ..= base+`[`DEDICATED_PORT_BAND`], seeding
189    /// the in-use set from `in_use` (e.g. ports already recorded in the marker).
190    ///
191    /// Ports in `in_use` that fall outside the band are kept in the used set —
192    /// they never collide with [`allocate`](Self::allocate) results and reserving
193    /// an out-of-band port is harmless — but they can never be re-allocated.
194    pub fn new(base: u16, in_use: impl IntoIterator<Item = u16>) -> Self {
195        Self {
196            base,
197            used: in_use.into_iter().collect(),
198        }
199    }
200
201    /// Lowest port in the band, i.e. `base + 1` (saturating).
202    fn band_start(&self) -> u16 {
203        self.base.saturating_add(1)
204    }
205
206    /// Highest port in the band, i.e. `base + `[`DEDICATED_PORT_BAND`] (saturating).
207    fn band_end(&self) -> u16 {
208        self.base.saturating_add(DEDICATED_PORT_BAND)
209    }
210
211    /// Allocate the lowest free port in the band, recording it as used.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`OverlaydError::Other`] if every port in the band is taken.
216    pub fn allocate(&mut self) -> crate::error::Result<u16> {
217        for port in self.band_start()..=self.band_end() {
218            if !self.used.contains(&port) {
219                self.used.insert(port);
220                return Ok(port);
221            }
222        }
223        Err(crate::error::OverlaydError::Other(format!(
224            "dedicated-overlay port band exhausted ({}..={}, {} ports)",
225            self.band_start(),
226            self.band_end(),
227            DEDICATED_PORT_BAND
228        )))
229    }
230
231    /// Free a previously allocated port so it can be handed out again.
232    pub fn release(&mut self, port: u16) {
233        self.used.remove(&port);
234    }
235
236    /// Mark a specific port used without scanning — used to rehydrate the
237    /// allocator from persisted marker state so a service re-binds its port.
238    pub fn reserve(&mut self, port: u16) {
239        self.used.insert(port);
240    }
241
242    /// Whether `port` is currently recorded as in use.
243    #[must_use]
244    pub fn is_used(&self, port: u16) -> bool {
245        self.used.contains(&port)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn sample(owner: &str, id: &str) -> ManagedNetwork {
254        ManagedNetwork {
255            owner: owner.to_string(),
256            kind: "hcn-internal".to_string(),
257            name: "zlayer-overlay".to_string(),
258            id: id.to_string(),
259            subnet: "10.200.0.0/28".to_string(),
260            wg_port: None,
261            wg_private_key: None,
262            wg_public_key: None,
263            interface: None,
264        }
265    }
266
267    #[test]
268    fn upsert_replaces_same_owner_and_get_finds_it() {
269        let mut st = NetworkState::default();
270        st.upsert(sample(OWNER_BASE, "guid-1"));
271        st.upsert(sample(OWNER_BASE, "guid-2")); // same owner -> replace
272        assert_eq!(st.networks.len(), 1);
273        assert_eq!(st.get(OWNER_BASE).unwrap().id, "guid-2");
274    }
275
276    #[test]
277    fn distinct_owners_coexist_and_remove_targets_one() {
278        let mut st = NetworkState::default();
279        st.upsert(sample(OWNER_BASE, "base-guid"));
280        st.upsert(sample(&owner_for_service("web"), "web-guid"));
281        assert_eq!(st.networks.len(), 2);
282
283        let removed = st.remove(OWNER_BASE).expect("base entry present");
284        assert_eq!(removed.id, "base-guid");
285        assert_eq!(st.networks.len(), 1);
286        assert!(st.get(OWNER_BASE).is_none());
287        assert_eq!(st.get(&owner_for_service("web")).unwrap().id, "web-guid");
288        assert!(st.remove("service:nope").is_none());
289    }
290
291    #[test]
292    fn save_then_load_roundtrips() {
293        let dir = std::env::temp_dir().join(format!("zlayer-netstate-test-{}", std::process::id()));
294        let path = dir.join("agent_network.json");
295        let _ = std::fs::remove_dir_all(&dir);
296
297        let mut st = NetworkState::default();
298        st.upsert(sample(OWNER_BASE, "guid-rt"));
299        st.save(&path).expect("save must succeed");
300
301        let loaded = NetworkState::load(&path);
302        assert_eq!(loaded.version, CURRENT_VERSION);
303        assert_eq!(loaded.networks, st.networks);
304
305        let _ = std::fs::remove_dir_all(&dir);
306    }
307
308    #[test]
309    fn load_missing_file_is_empty_default() {
310        let path = std::env::temp_dir().join("zlayer-netstate-does-not-exist-xyz.json");
311        let _ = std::fs::remove_file(&path);
312        let st = NetworkState::load(&path);
313        assert_eq!(st.version, CURRENT_VERSION);
314        assert!(st.networks.is_empty());
315    }
316
317    #[test]
318    fn dedicated_fields_survive_save_load_roundtrip() {
319        let dir = std::env::temp_dir().join(format!("zlayer-netstate-ded-{}", std::process::id()));
320        let path = dir.join("agent_network.json");
321        let _ = std::fs::remove_dir_all(&dir);
322
323        let mut net = sample(&owner_for_service("web"), "ded-guid");
324        net.wg_port = Some(51823);
325        net.wg_private_key = Some("cHJpdmF0ZS1rZXktYjY0".to_string());
326        net.wg_public_key = Some("cHVibGljLWtleS1iNjQ=".to_string());
327        net.interface = Some("zl-web0".to_string());
328
329        let mut st = NetworkState::default();
330        st.upsert(net.clone());
331        st.save(&path).expect("save must succeed");
332
333        let loaded = NetworkState::load(&path);
334        let got = loaded
335            .get(&owner_for_service("web"))
336            .expect("service entry present");
337        assert_eq!(got.wg_port, Some(51823));
338        assert_eq!(got.wg_private_key.as_deref(), Some("cHJpdmF0ZS1rZXktYjY0"));
339        assert_eq!(got.wg_public_key.as_deref(), Some("cHVibGljLWtleS1iNjQ="));
340        assert_eq!(got.interface.as_deref(), Some("zl-web0"));
341        assert_eq!(got, &net);
342
343        let _ = std::fs::remove_dir_all(&dir);
344    }
345
346    #[test]
347    fn older_marker_without_dedicated_fields_still_loads() {
348        // Hand-written marker JSON from before the dedicated-overlay fields
349        // existed: it must deserialize with the new fields defaulting to None.
350        let dir = std::env::temp_dir().join(format!("zlayer-netstate-bc-{}", std::process::id()));
351        let path = dir.join("agent_network.json");
352        let _ = std::fs::remove_dir_all(&dir);
353        std::fs::create_dir_all(&dir).expect("mkdir");
354
355        let legacy = r#"{
356            "version": 1,
357            "networks": [
358                {
359                    "owner": "base",
360                    "kind": "hcn-internal",
361                    "name": "zlayer-overlay",
362                    "id": "legacy-guid",
363                    "subnet": "10.200.0.0/28"
364                }
365            ]
366        }"#;
367        std::fs::write(&path, legacy).expect("write legacy marker");
368
369        let loaded = NetworkState::load(&path);
370        let got = loaded.get(OWNER_BASE).expect("base entry present");
371        assert_eq!(got.id, "legacy-guid");
372        assert_eq!(got.wg_port, None);
373        assert_eq!(got.wg_private_key, None);
374        assert_eq!(got.wg_public_key, None);
375        assert_eq!(got.interface, None);
376
377        let _ = std::fs::remove_dir_all(&dir);
378    }
379
380    #[test]
381    fn allocate_returns_distinct_ascending_ports() {
382        let mut alloc = DedicatedPortAllocator::new(51820, std::iter::empty());
383        let a = alloc.allocate().expect("port a");
384        let b = alloc.allocate().expect("port b");
385        let c = alloc.allocate().expect("port c");
386        assert_eq!(a, 51821);
387        assert_eq!(b, 51822);
388        assert_eq!(c, 51823);
389    }
390
391    #[test]
392    fn release_then_allocate_reuses_freed_port() {
393        let mut alloc = DedicatedPortAllocator::new(51820, std::iter::empty());
394        let a = alloc.allocate().expect("port a");
395        let b = alloc.allocate().expect("port b");
396        assert_eq!(a, 51821);
397        assert_eq!(b, 51822);
398
399        alloc.release(a);
400        // Lowest free is now the released port again.
401        let reused = alloc.allocate().expect("reused port");
402        assert_eq!(reused, 51821);
403    }
404
405    #[test]
406    fn reserved_port_is_skipped_by_allocate() {
407        // Rehydrate as if 51821 was persisted in the marker for another service.
408        let mut alloc = DedicatedPortAllocator::new(51820, [51821]);
409        assert!(alloc.is_used(51821));
410        let first = alloc.allocate().expect("first allocation");
411        assert_eq!(first, 51822);
412
413        // Explicit reserve mid-flight is also honored.
414        alloc.reserve(51823);
415        let next = alloc.allocate().expect("next allocation");
416        assert_eq!(next, 51824);
417    }
418
419    #[test]
420    fn band_exhaustion_errors() {
421        // Pre-reserve every port in the band so allocate has nothing left.
422        let base = 51820u16;
423        let full: Vec<u16> = (base + 1..=base + DEDICATED_PORT_BAND).collect();
424        let mut alloc = DedicatedPortAllocator::new(base, full);
425        let err = alloc.allocate().expect_err("band must be exhausted");
426        assert!(
427            matches!(err, crate::error::OverlaydError::Other(ref m) if m.contains("exhausted")),
428            "unexpected error: {err:?}"
429        );
430    }
431}