Skip to main content

zlayer_agent/
overlay_manager.rs

1//! Thin overlayd client shim.
2//!
3//! Historically `OverlayManager` owned every mechanism touching the
4//! overlay/network plane (the cluster `WireGuard` transport, per-service Linux
5//! bridges, veth/netns attach, the Windows HCN Internal network + endpoints,
6//! IPAM, DNS, NAT). All of that machinery was migrated wholesale into the
7//! standalone `zlayer-overlayd` daemon (`crates/zlayer-overlayd/src/server.rs`).
8//!
9//! What remains here is a **client shim**: it keeps only cluster-brain / cached
10//! state (deployment name, instance id, local node id, local wg pubkey, and
11//! cached status values such as `node_ip`/`dns`/`cidr`) and forwards every
12//! mechanical operation to overlayd over the IPC client
13//! [`zlayer_overlayd::OverlaydClient`]. Every public method keeps the exact
14//! signature it had before the migration so existing callers compile unchanged;
15//! the body simply builds the matching [`OverlaydRequest`], issues
16//! `client.call(req)`, and maps the response.
17//!
18//! On Windows, the manager additionally maintains a small `hcn_cleanup` map
19//! (HCN namespace GUID -> (`service_name`, `allocated_ip`)) so that
20//! agent-side bookkeeping for autoclean attaches survives even though the
21//! authoritative HCN state lives in overlayd. The map is populated on
22//! `attach_container_hcn(autoclean = true)` and drained on
23//! `detach_container_hcn`.
24
25use crate::error::AgentError;
26use ipnetwork::IpNetwork;
27use std::collections::hash_map::DefaultHasher;
28use std::hash::{Hash, Hasher};
29use std::net::{IpAddr, SocketAddr};
30use std::path::PathBuf;
31use std::sync::Arc;
32use tokio::sync::Mutex;
33use zlayer_overlay::{Candidate, NatConfig, NatPeerSnapshot, NatStatusSnapshot};
34use zlayer_overlayd::OverlaydClient;
35use zlayer_paths::ZLayerDirs;
36use zlayer_types::nat_wire::{NatCandidateWire, NatConfigSpec, RelayServerSpec, TurnServerSpec};
37use zlayer_types::overlayd::{
38    AttachHandle, EdgeConfig, EdgePeerStatus, NatStatusWire, OverlaydRequest, OverlaydResponse,
39    PeerSpec, StatusSnapshot,
40};
41
42/// Maximum length for Linux network interface names (IFNAMSIZ - 1 for null terminator).
43const MAX_IFNAME_LEN: usize = 15;
44
45/// Generate a Linux-safe interface name guaranteed to be <= 15 chars.
46///
47/// Joins the `parts` with `-` after a `"zl-"` prefix and appends `-{suffix}` if non-empty.
48/// When the result exceeds 15 characters, a deterministic hash of all parts is used instead
49/// to keep the name unique and within the kernel limit.
50///
51/// Kept in the agent (and re-exported from the crate root) because callers
52/// outside the overlay machinery — notably `runtimes/wsl2_delegate.rs` — still
53/// use it for deterministic naming. overlayd has its own private copy for the
54/// names it generates server-side; the two are identical by construction.
55#[must_use]
56pub fn make_interface_name(parts: &[&str], suffix: &str) -> String {
57    let base = format!("zl-{}", parts.join("-"));
58    let candidate = if suffix.is_empty() {
59        base
60    } else {
61        format!("{base}-{suffix}")
62    };
63
64    if candidate.len() <= MAX_IFNAME_LEN {
65        return candidate;
66    }
67
68    // Name is too long -- produce a deterministic hash-based name.
69    let mut hasher = DefaultHasher::new();
70    for part in parts {
71        part.hash(&mut hasher);
72    }
73    suffix.hash(&mut hasher);
74    let hash = format!("{:x}", hasher.finish());
75
76    if suffix.is_empty() {
77        // "zl-" (3) + up to 12 hex chars = 15
78        let budget = MAX_IFNAME_LEN - 3;
79        format!("zl-{}", &hash[..budget.min(hash.len())])
80    } else {
81        // "zl-" (3) + hash + "-" (1) + suffix
82        let suffix_cost = 1 + suffix.len(); // "-" + suffix
83        let hash_budget = MAX_IFNAME_LEN.saturating_sub(3 + suffix_cost);
84        if hash_budget == 0 {
85            // Suffix itself is extremely long -- just hash everything
86            let budget = MAX_IFNAME_LEN - 3;
87            format!("zl-{}", &hash[..budget.min(hash.len())])
88        } else {
89            format!("zl-{}-{}", &hash[..hash_budget.min(hash.len())], suffix)
90        }
91    }
92}
93
94/// Map a `zlayer_overlayd` client error into the agent's error type.
95fn map_overlayd_err(e: &zlayer_overlayd::OverlaydError) -> AgentError {
96    AgentError::Network(format!("overlayd: {e}"))
97}
98
99/// Classify whether an overlayd error means the *connection itself* is dead
100/// (so the cached client must be dropped and re-dialed) versus a structured
101/// application-level failure (the socket is fine, overlayd just said "no").
102///
103/// `Io` (e.g. `Broken pipe`/`Connection reset`), `Closed`, `Codec` (framing
104/// desync), and `FrameTooLarge` all indicate the framed stream is no longer
105/// usable. `Overlay` is overlayd returning a well-formed error response over a
106/// healthy connection, and `Other` is a logical/protocol mismatch — neither of
107/// those should throw away a working socket.
108fn is_transport_error(e: &zlayer_overlayd::OverlaydError) -> bool {
109    use zlayer_overlayd::OverlaydError;
110    matches!(
111        e,
112        OverlaydError::Io(_)
113            | OverlaydError::Closed
114            | OverlaydError::Codec(_)
115            | OverlaydError::FrameTooLarge(_)
116    )
117}
118
119/// Convert a live [`zlayer_overlay::PeerInfo`] (plus any NAT candidates the peer
120/// advertised) into the wire-safe [`PeerSpec`] the overlayd IPC contract
121/// expects. Shared by every `add_*_peer` shim so the global and per-service
122/// paths build identical specs. `candidates` is empty for peers added without a
123/// candidate exchange (the common case before NAT, and every service-scoped
124/// add).
125fn peer_spec_from(peer: &zlayer_overlay::PeerInfo, candidates: Vec<NatCandidateWire>) -> PeerSpec {
126    PeerSpec {
127        public_key: peer.public_key.clone(),
128        endpoint: peer.endpoint.to_string(),
129        allowed_ips: peer.allowed_ips.clone(),
130        persistent_keepalive_secs: peer.persistent_keepalive_interval.as_secs(),
131        candidates,
132    }
133}
134
135/// Convert a wire [`NatConfigSpec`]-bound [`NatConfig`] back to its wire form for
136/// `SetupGlobalOverlay`. `relay_credential` is folded into the relay spec's
137/// `auth_credential` (the live `RelayServerConfig` has no credential field).
138fn nat_config_to_spec(cfg: &NatConfig, relay_credential: Option<String>) -> NatConfigSpec {
139    NatConfigSpec {
140        enabled: cfg.enabled,
141        stun_servers: cfg.stun_servers.iter().map(|s| s.address.clone()).collect(),
142        turn_servers: cfg
143            .turn_servers
144            .iter()
145            .map(|t| TurnServerSpec {
146                addr: t.address.clone(),
147                username: t.username.clone(),
148                credential: t.credential.clone(),
149            })
150            .collect(),
151        hole_punch_timeout_secs: cfg.hole_punch_timeout_secs,
152        stun_refresh_interval_secs: cfg.stun_refresh_interval_secs,
153        max_candidate_pairs: cfg.max_candidate_pairs,
154        relay_server: cfg.relay_server.as_ref().map(|r| RelayServerSpec {
155            listen_port: r.listen_port,
156            external_addr: r.external_addr.clone(),
157            max_sessions: r.max_sessions,
158            auth_credential: relay_credential,
159        }),
160    }
161}
162
163/// Convert overlayd's wire [`NatStatusWire`] into the
164/// [`NatStatusSnapshot`]/[`NatPeerSnapshot`] the API layer consumes. Candidates
165/// whose address fails to parse are dropped (best-effort display data).
166fn nat_status_wire_to_snapshot(wire: NatStatusWire) -> NatStatusSnapshot {
167    let candidates: Vec<Candidate> = wire
168        .candidates
169        .iter()
170        .filter_map(nat_candidate_wire_to_candidate)
171        .collect();
172    let peers: Vec<NatPeerSnapshot> = wire
173        .peers
174        .into_iter()
175        .map(|p| NatPeerSnapshot {
176            node_id: p.node_id,
177            connection_type: p.connection_type,
178            remote_endpoint: p.remote_endpoint,
179        })
180        .collect();
181    NatStatusSnapshot {
182        candidates,
183        peers,
184        last_refresh: wire.last_refresh,
185    }
186}
187
188/// Parse a wire [`NatCandidateWire`] into a live [`Candidate`]. Returns `None`
189/// when the address or type string is unparseable.
190fn nat_candidate_wire_to_candidate(w: &NatCandidateWire) -> Option<Candidate> {
191    use zlayer_overlay::CandidateType;
192    let address: SocketAddr = w.address.parse().ok()?;
193    let candidate_type = match w.candidate_type.as_str() {
194        "host" => CandidateType::Host,
195        "server-reflexive" => CandidateType::ServerReflexive,
196        "relay" => CandidateType::Relay,
197        _ => return None,
198    };
199    let mut c = Candidate::new(candidate_type, address);
200    c.priority = w.priority;
201    Some(c)
202}
203
204/// Manages overlay networks for a deployment by delegating all mechanics to the
205/// `zlayer-overlayd` daemon.
206///
207/// This struct holds only cluster-brain / cached state; the actual overlay
208/// machinery lives in overlayd and is reached through [`OverlayManager::client`].
209pub struct OverlayManager {
210    /// Deployment name (used for network naming).
211    deployment: String,
212    /// Per-daemon-process disambiguator included in overlay link names. Stable
213    /// for the daemon's lifetime; forwarded to overlayd in `SetupGlobalOverlay`.
214    instance_id: String,
215    /// When true, a host-adapter (utun/Wintun) bringup failure is FATAL instead
216    /// of a silent VM-only degrade. Forwarded to overlayd in
217    /// `SetupGlobalOverlay`; set by the daemon for host-shared macOS runtimes.
218    host_adapter_mandatory: bool,
219    /// Root data directory; used to resolve the overlayd IPC socket path.
220    data_dir: PathBuf,
221    /// Lazily-connected overlayd IPC client. Wrapped in an `Arc<Mutex<_>>` so
222    /// the manager can be shared behind an `Arc<RwLock<_>>` and still serialize
223    /// request/response round-trips on the single framed connection.
224    client: Mutex<Option<Arc<Mutex<OverlaydClient>>>>,
225    /// Local raft node id, forwarded to overlayd via `SetLocalNodeId`.
226    local_node_id: u64,
227    /// This node's cluster `WireGuard` public key (base64), forwarded to
228    /// overlayd via `SetLocalWgPubkey`. Behind a `Mutex` because the setter
229    /// takes `&self` (callers hold only a read guard at that point).
230    local_wg_pubkey: Mutex<Option<String>>,
231    /// `WireGuard` listen port for the overlay network.
232    overlay_port: u16,
233    /// Cached node overlay IP, populated from `SetupGlobalOverlay`/`Status`.
234    node_ip: Option<IpAddr>,
235    /// Cached global overlay interface name.
236    global_interface: Option<String>,
237    /// Cached full cluster CIDR.
238    cluster_cidr: Option<IpNetwork>,
239    /// Cached per-node slice CIDR.
240    slice_cidr: Option<IpNetwork>,
241    /// Cached overlay DNS server address.
242    dns_server_addr: Option<SocketAddr>,
243    /// Cached overlay DNS zone domain.
244    dns_domain: Option<String>,
245    /// NAT traversal configuration. overlayd owns the live NAT orchestrator;
246    /// this is cached so the daemon can decide whether to drive `NatTick` and so
247    /// the full config (STUN/TURN/relay) is forwarded to overlayd in
248    /// `SetupGlobalOverlay`.
249    nat_config: Option<NatConfig>,
250    /// Cluster-shared credential the built-in relay server uses to derive its
251    /// `BLAKE2b` auth key. `NatConfig::relay_server` (a `RelayServerConfig`) has
252    /// no credential field, so it is carried here and folded into
253    /// `NatConfigSpec.relay_server.auth_credential` when forwarding to overlayd.
254    /// Set by the daemon from the cluster HS256 secret so every node's relay
255    /// client derives the same key.
256    cluster_relay_credential: Option<String>,
257    /// Override for the `WireGuard` UAPI socket directory. overlayd owns the
258    /// real transport, so this is retained only for API/diagnostic parity.
259    uapi_sock_dir: Option<PathBuf>,
260    /// Map of HCN namespace GUID -> (`service_name`, `allocated_ip`) for autoclean.
261    /// When a Windows container is attached with `autoclean = true`, its entry
262    /// is inserted here; `detach_container_hcn` removes it. overlayd is the
263    /// authoritative owner of the HCN namespace/endpoint state, but the agent
264    /// keeps this side-map so it can answer "what attachments do I still need
265    /// to release on shutdown?" without an IPC round-trip per query.
266    #[cfg(target_os = "windows")]
267    hcn_cleanup: std::sync::Arc<
268        tokio::sync::Mutex<
269            std::collections::HashMap<windows::core::GUID, (String, std::net::IpAddr)>,
270        >,
271    >,
272}
273
274/// Resolve the effective isolation-network name for a container attach.
275///
276/// An explicit named isolated network (from the docker-compat bridge-network
277/// registry or the `com.zlayer.isolation_network` label) always wins. Otherwise
278/// [`OverlayMode::Isolated`] implicitly fences the service to a network named
279/// after the service itself (`service`). All other modes return `None` (flat
280/// cluster mesh). This is the single derivation every runtime's attach path
281/// uses so isolation behaves identically across platforms.
282#[must_use]
283pub fn resolve_isolation_network(
284    mode: zlayer_types::overlay::OverlayMode,
285    service: &str,
286    explicit_named: Option<String>,
287) -> Option<String> {
288    explicit_named.or_else(|| mode.uses_isolation_scope().then(|| service.to_string()))
289}
290
291impl OverlayManager {
292    /// Create a new overlay manager for a deployment (legacy single-node path).
293    ///
294    /// Uses the default cluster `/16`. Prefer [`OverlayManager::with_slice`] for
295    /// cluster deployments. The overlayd IPC client is connected lazily on first
296    /// use (via the socket under the system-default data dir).
297    ///
298    /// # Errors
299    /// Infallible today; the `Result` is preserved for ABI parity with callers.
300    ///
301    /// # Panics
302    /// Panics only if the compile-time-constant default CIDR `10.200.0.0/16`
303    /// fails to parse (impossible).
304    #[allow(clippy::unused_async)]
305    pub async fn new(deployment: String, instance_id: String) -> Result<Self, AgentError> {
306        let data_dir = ZLayerDirs::system_default().data_dir().to_path_buf();
307        let default_cidr: IpNetwork = "10.200.0.0/16".parse().expect("compile-time constant CIDR");
308        Ok(Self {
309            deployment,
310            instance_id,
311            host_adapter_mandatory: false,
312            data_dir,
313            client: Mutex::new(None),
314            local_node_id: 0,
315            local_wg_pubkey: Mutex::new(None),
316            overlay_port: zlayer_core::DEFAULT_WG_PORT,
317            node_ip: None,
318            global_interface: None,
319            cluster_cidr: Some(default_cidr),
320            slice_cidr: None,
321            dns_server_addr: None,
322            dns_domain: None,
323            nat_config: None,
324            cluster_relay_credential: None,
325            uapi_sock_dir: None,
326            #[cfg(target_os = "windows")]
327            hcn_cleanup: std::sync::Arc::new(tokio::sync::Mutex::new(
328                std::collections::HashMap::new(),
329            )),
330        })
331    }
332
333    /// Create an `OverlayManager` bound to a per-node slice.
334    ///
335    /// `slice_cidr` is the per-node slice owned by this node; `cluster_cidr` is
336    /// the full cluster CIDR. Both are forwarded to overlayd in
337    /// `SetupGlobalOverlay`.
338    #[must_use]
339    pub fn with_slice(
340        deployment: String,
341        cluster_cidr: IpNetwork,
342        slice_cidr: IpNetwork,
343        port: u16,
344        instance_id: String,
345    ) -> Self {
346        let data_dir = ZLayerDirs::system_default().data_dir().to_path_buf();
347        Self {
348            deployment,
349            instance_id,
350            host_adapter_mandatory: false,
351            data_dir,
352            client: Mutex::new(None),
353            local_node_id: 0,
354            local_wg_pubkey: Mutex::new(None),
355            overlay_port: port,
356            node_ip: None,
357            global_interface: None,
358            cluster_cidr: Some(cluster_cidr),
359            slice_cidr: Some(slice_cidr),
360            dns_server_addr: None,
361            dns_domain: None,
362            nat_config: None,
363            cluster_relay_credential: None,
364            uapi_sock_dir: None,
365            #[cfg(target_os = "windows")]
366            hcn_cleanup: std::sync::Arc::new(tokio::sync::Mutex::new(
367                std::collections::HashMap::new(),
368            )),
369        }
370    }
371
372    /// Set the `WireGuard` listen port for the overlay network.
373    #[must_use]
374    pub fn with_overlay_port(mut self, port: u16) -> Self {
375        self.overlay_port = port;
376        self
377    }
378
379    /// Set the NAT traversal configuration. overlayd owns the live NAT
380    /// orchestrator; this records the toggle so `SetupGlobalOverlay` can carry
381    /// `nat_enabled` and the daemon can decide whether to drive `NatTick`.
382    #[must_use]
383    pub fn with_nat_config(mut self, nat: NatConfig) -> Self {
384        self.nat_config = Some(nat);
385        self
386    }
387
388    /// Set the cluster-shared credential the built-in relay server derives its
389    /// auth key from. Folded into `NatConfigSpec.relay_server.auth_credential`
390    /// when `SetupGlobalOverlay` forwards the NAT config to overlayd, so every
391    /// node's relay client derives the same `BLAKE2b` key. An empty / whitespace
392    /// credential is ignored (treated as "none supplied").
393    #[must_use]
394    pub fn with_relay_credential(mut self, credential: impl Into<String>) -> Self {
395        let credential = credential.into();
396        if credential.trim().is_empty() {
397            self.cluster_relay_credential = None;
398        } else {
399            self.cluster_relay_credential = Some(credential);
400        }
401        self
402    }
403
404    /// Override the `WireGuard` UAPI socket directory. Retained for API parity;
405    /// overlayd owns the real transport's socket directory.
406    #[must_use]
407    pub fn with_uapi_sock_dir(mut self, dir: impl Into<PathBuf>) -> Self {
408        self.uapi_sock_dir = Some(dir.into());
409        self
410    }
411
412    /// Override the data directory used to resolve the overlayd IPC socket.
413    #[must_use]
414    pub fn with_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
415        self.data_dir = dir.into();
416        self
417    }
418
419    /// Set the local raft node id (builder-style).
420    #[must_use]
421    pub fn with_local_node_id(mut self, node_id: u64) -> Self {
422        self.local_node_id = node_id;
423        self
424    }
425
426    /// Mark the node's host overlay adapter (utun) as MANDATORY: a bringup
427    /// failure becomes a hard error instead of a silent degrade. Set by the
428    /// daemon for host-shared macOS runtimes where the utun is the data path.
429    #[must_use]
430    pub fn with_host_adapter_mandatory(mut self, mandatory: bool) -> Self {
431        self.host_adapter_mandatory = mandatory;
432        self
433    }
434
435    /// Get or lazily establish the overlayd IPC connection.
436    async fn client(&self) -> Result<Arc<Mutex<OverlaydClient>>, AgentError> {
437        let mut guard = self.client.lock().await;
438        if let Some(c) = guard.as_ref() {
439            return Ok(Arc::clone(c));
440        }
441        let socket = ZLayerDirs::default_overlayd_socket_path_for(&self.data_dir);
442        // Bounded dial (~2.5s worst case): overlay operations are non-fatal, so a
443        // dead/unreachable overlayd must degrade fast rather than hold the daemon's
444        // startup hostage. The overlayd supervisor (ensure_overlayd_running) owns
445        // the generous "wait for a freshly-spawned overlayd to bind" budget; once
446        // it has confirmed overlayd up (or fast-failed when the binary is missing),
447        // this lazy connector only needs a short retry window.
448        let conn = OverlaydClient::connect_with_attempts(std::path::Path::new(&socket), 6)
449            .await
450            .map_err(|e| map_overlayd_err(&e))?;
451        let arc = Arc::new(Mutex::new(conn));
452        *guard = Some(Arc::clone(&arc));
453        Ok(arc)
454    }
455
456    /// Drop the cached overlayd client so the next [`Self::client`] call
457    /// re-dials a fresh connection. Called when a request fails with a transport
458    /// error (broken pipe / closed socket / framing desync): the old connection
459    /// is unusable, and leaving it cached would make every subsequent request —
460    /// e.g. the ~60s `NatTick` maintenance loop — keep failing forever even
461    /// after overlayd has been restarted and is healthy again.
462    async fn invalidate_client(&self) {
463        *self.client.lock().await = None;
464    }
465
466    /// Issue a single overlayd request, folding `Err` responses into errors.
467    ///
468    /// Reconnect-on-error: if the request fails because the underlying socket is
469    /// dead (broken pipe, connection reset, peer closed, framing desync), the
470    /// cached client is dropped and the *same* request is retried exactly once
471    /// against a freshly dialed connection. This lets a single transient
472    /// overlayd restart self-heal within one tick instead of poisoning the
473    /// cached client forever. Application-level (`Overlay`) errors and connect
474    /// failures are returned as-is — they don't indicate a stale connection, so
475    /// there is nothing to reconnect, and we never loop more than once.
476    async fn call(&self, req: OverlaydRequest) -> Result<OverlaydResponse, AgentError> {
477        let client = self.client().await?;
478        let first = {
479            let mut conn = client.lock().await;
480            conn.call(req.clone()).await
481        };
482        match first {
483            Ok(resp) => Ok(resp),
484            Err(e) if is_transport_error(&e) => {
485                // The cached connection is dead. Drop it and re-dial once.
486                tracing::warn!(error = %e, "overlayd connection broken; reconnecting and retrying once");
487                self.invalidate_client().await;
488                let fresh = self.client().await?;
489                let mut conn = fresh.lock().await;
490                // A dead connection means overlayd bounced (restart / stale-unit
491                // reinstall). An overlayd bounce tears out the daemon-created
492                // GLOBAL node adapter — it rebuilds only per-service bridges on
493                // restart — so the node's overlay IP (the resolver address
494                // injected into every container's resolv.conf, e.g. 10.200.0.1)
495                // silently vanishes. Recreate the global adapter on the fresh
496                // connection BEFORE retrying so it (and the node DNS listener
497                // target) comes back. Best-effort and issued directly on `conn`
498                // (never via `call`), so it cannot recurse through this path.
499                self.reestablish_global_overlay_on(&mut conn).await;
500                conn.call(req).await.map_err(|e| map_overlayd_err(&e))
501            }
502            Err(e) => Err(map_overlayd_err(&e)),
503        }
504    }
505
506    /// Re-issue the global-overlay establishment requests on an already-locked,
507    /// freshly-dialed overlayd connection.
508    ///
509    /// Called from [`Self::call`]'s reconnect path: an overlayd bounce
510    /// (restart / stale-unit reinstall) tears out the daemon-created global node
511    /// adapter but rebuilds only per-service bridges, so the node's overlay IP —
512    /// the resolver injected into every container's resolv.conf — disappears.
513    /// Re-running `SetupGlobalOverlay` (plus the node-id / wg-pubkey brain
514    /// context overlayd also dropped on restart) recreates it.
515    ///
516    /// No-op until the global overlay has been set up at least once
517    /// (`global_interface` is `None`) — we must never spuriously create a global
518    /// adapter for a manager that never had one (e.g. a host-network daemon's
519    /// status-only client). Issued DIRECTLY on `conn` (never through
520    /// [`Self::call`]) so a transport error here cannot recurse back into this
521    /// same reconnect path; every sub-request is best-effort and failures are
522    /// logged, not propagated. Reads `local_wg_pubkey` with `try_lock` so it can
523    /// never deadlock against an outer caller already holding that lock across a
524    /// `call` (e.g. `setup_global_overlay`'s `if let` scrutinee).
525    async fn reestablish_global_overlay_on(&self, conn: &mut OverlaydClient) {
526        if self.global_interface.is_none() {
527            return;
528        }
529        // Brain context overlayd lost on restart (best-effort).
530        let _ = conn
531            .call(OverlaydRequest::SetLocalNodeId {
532                node_id: self.local_node_id,
533            })
534            .await;
535        if let Ok(guard) = self.local_wg_pubkey.try_lock() {
536            if let Some(pubkey) = guard.clone() {
537                drop(guard);
538                let _ = conn
539                    .call(OverlaydRequest::SetLocalWgPubkey { pubkey })
540                    .await;
541            }
542        }
543        let cluster_cidr = self
544            .cluster_cidr
545            .map_or_else(|| "10.200.0.0/16".to_string(), |c| c.to_string());
546        let slice_cidr = self.slice_cidr.map(|c| c.to_string());
547        let nat = self
548            .nat_config
549            .as_ref()
550            .map(|cfg| nat_config_to_spec(cfg, self.cluster_relay_credential.clone()));
551        match conn
552            .call(OverlaydRequest::SetupGlobalOverlay {
553                deployment: self.deployment.clone(),
554                instance_id: self.instance_id.clone(),
555                cluster_cidr,
556                slice_cidr,
557                wg_port: self.overlay_port,
558                host_adapter_mandatory: self.host_adapter_mandatory,
559                nat,
560            })
561            .await
562        {
563            Ok(_) => tracing::info!(
564                "re-established global overlay after overlayd reconnect (recreated node adapter)"
565            ),
566            Err(e) => tracing::warn!(
567                error = %e,
568                "failed to re-establish global overlay after overlayd reconnect"
569            ),
570        }
571    }
572
573    /// Post-construction setter for the local raft node id. Forwards
574    /// `SetLocalNodeId` to overlayd best-effort.
575    pub fn set_local_node_id(&mut self, node_id: u64) {
576        self.local_node_id = node_id;
577    }
578
579    /// Record this node's cluster `WireGuard` public key (base64) and forward it
580    /// to overlayd so service subnets can be added to the cluster transport's
581    /// local `AllowedIPs`.
582    pub async fn set_local_wg_pubkey(&self, pubkey: String) {
583        *self.local_wg_pubkey.lock().await = Some(pubkey.clone());
584        if let Err(e) = self
585            .call(OverlaydRequest::SetLocalWgPubkey { pubkey })
586            .await
587        {
588            tracing::warn!(error = %e, "overlayd SetLocalWgPubkey failed");
589        }
590    }
591
592    /// Returns the number of services currently registered (cached `Status`).
593    pub async fn service_count(&self) -> usize {
594        match self.call(OverlaydRequest::Status).await {
595            Ok(OverlaydResponse::Status(snap)) => snap.service_count as usize,
596            _ => 0,
597        }
598    }
599
600    /// Returns whether NAT traversal is enabled for this manager.
601    #[must_use]
602    pub fn nat_enabled(&self) -> bool {
603        self.nat_config
604            .as_ref()
605            .map_or_else(|| NatConfig::default().enabled, |c| c.enabled)
606    }
607
608    /// Returns a clone of the configured [`NatConfig`], or `None`.
609    #[must_use]
610    pub fn nat_config(&self) -> Option<NatConfig> {
611        self.nat_config.clone()
612    }
613
614    /// Bootstrap NAT traversal. overlayd starts NAT lazily on its first
615    /// `NatTick`, so this is a thin shim that reports whether NAT is enabled.
616    ///
617    /// # Errors
618    /// Infallible today; preserved for ABI parity.
619    #[allow(clippy::unused_async)]
620    pub async fn start_nat_traversal(&self) -> Result<bool, AgentError> {
621        Ok(self.nat_enabled())
622    }
623
624    /// Run one NAT-traversal maintenance tick by forwarding `NatTick` to overlayd.
625    ///
626    /// # Errors
627    /// Returns an error when overlayd reports a NAT refresh failure.
628    pub async fn nat_maintenance_tick(&self) -> Result<(), AgentError> {
629        if !self.nat_enabled() {
630            return Ok(());
631        }
632        self.call(OverlaydRequest::NatTick).await?;
633        Ok(())
634    }
635
636    /// Snapshot the current NAT traversal state for API consumers by asking
637    /// overlayd (which owns the live orchestrator) over the `NatStatus` IPC.
638    ///
639    /// Converts the wire [`NatStatusWire`] into the
640    /// [`NatStatusSnapshot`]/[`NatPeerSnapshot`] the API layer maps. Returns an
641    /// empty snapshot when NAT is disabled or overlayd is unreachable — callers
642    /// surface that as "NAT disabled / no data" rather than an error.
643    pub async fn nat_status_snapshot(&self) -> NatStatusSnapshot {
644        if !self.nat_enabled() {
645            return NatStatusSnapshot::empty();
646        }
647        match self.call(OverlaydRequest::NatStatus).await {
648            Ok(OverlaydResponse::NatStatus(wire)) => nat_status_wire_to_snapshot(wire),
649            Ok(other) => {
650                tracing::warn!(?other, "overlayd NatStatus returned unexpected response");
651                NatStatusSnapshot::empty()
652            }
653            Err(e) => {
654                tracing::warn!(error = %e, "overlayd NatStatus failed (non-fatal)");
655                NatStatusSnapshot::empty()
656            }
657        }
658    }
659
660    /// Record the overlay DNS server address and zone domain (cached locally;
661    /// forwarded to overlayd on each container attach).
662    pub fn set_dns_config(&mut self, addr: Option<SocketAddr>, domain: Option<String>) {
663        self.dns_server_addr = addr;
664        self.dns_domain = domain;
665    }
666
667    /// Builder-style variant of [`OverlayManager::set_dns_config`].
668    #[must_use]
669    pub fn with_dns_config(mut self, addr: Option<SocketAddr>, domain: Option<String>) -> Self {
670        self.dns_server_addr = addr;
671        self.dns_domain = domain;
672        self
673    }
674
675    /// Returns the overlay DNS server address if configured.
676    #[must_use]
677    pub fn dns_server_addr(&self) -> Option<SocketAddr> {
678        self.dns_server_addr
679    }
680
681    /// Returns the overlay DNS zone domain, if configured.
682    #[must_use]
683    pub fn dns_domain(&self) -> Option<&str> {
684        self.dns_domain.as_deref()
685    }
686
687    /// Setup the global overlay network by delegating to overlayd.
688    ///
689    /// Forwards the local node id and wg pubkey first (so overlayd has the
690    /// cluster-brain context), then issues `SetupGlobalOverlay` and caches the
691    /// returned interface name plus the node IP / CIDRs reported by `Status`.
692    ///
693    /// # Errors
694    /// Returns an error if overlayd fails to bring up the overlay.
695    pub async fn setup_global_overlay(&mut self) -> Result<(), AgentError> {
696        // Fast pre-flight: establish (and cache) the overlayd connection once with a
697        // bounded budget. If overlayd is unreachable this returns after a single
698        // ~2.5s dial instead of letting each of the calls below pay the full retry
699        // window (which previously stacked to ~35s of daemon-startup stall when the
700        // overlayd binary was missing). Overlay setup is non-fatal, so bailing here
701        // simply leaves cross-node networking degraded — handled by the caller.
702        self.client().await?;
703
704        // Push cluster-brain context first (best-effort).
705        let _ = self
706            .call(OverlaydRequest::SetLocalNodeId {
707                node_id: self.local_node_id,
708            })
709            .await;
710        if let Some(pubkey) = self.local_wg_pubkey.lock().await.clone() {
711            let _ = self
712                .call(OverlaydRequest::SetLocalWgPubkey { pubkey })
713                .await;
714        }
715
716        let cluster_cidr = self
717            .cluster_cidr
718            .map_or_else(|| "10.200.0.0/16".to_string(), |c| c.to_string());
719        let slice_cidr = self.slice_cidr.map(|c| c.to_string());
720
721        // Serialize the full NAT config (not just the enabled toggle) so the
722        // operator's STUN/TURN/relay settings actually reach overlayd. `None`
723        // when no config was supplied, letting overlayd keep its default.
724        let nat = self
725            .nat_config
726            .as_ref()
727            .map(|cfg| nat_config_to_spec(cfg, self.cluster_relay_credential.clone()));
728
729        let resp = self
730            .call(OverlaydRequest::SetupGlobalOverlay {
731                deployment: self.deployment.clone(),
732                instance_id: self.instance_id.clone(),
733                cluster_cidr,
734                slice_cidr,
735                wg_port: self.overlay_port,
736                host_adapter_mandatory: self.host_adapter_mandatory,
737                nat,
738            })
739            .await?;
740        if let OverlaydResponse::BridgeName { name } = resp {
741            self.global_interface = Some(name);
742        }
743
744        // Refresh cached status (node_ip, cidrs).
745        self.refresh_status().await;
746        Ok(())
747    }
748
749    /// Refresh cached status fields from overlayd (`node_ip`, interface, CIDRs).
750    async fn refresh_status(&mut self) {
751        if let Ok(OverlaydResponse::Status(snap)) = self.call(OverlaydRequest::Status).await {
752            let StatusSnapshot {
753                interface,
754                node_ip,
755                overlay_cidr,
756                slice_cidr,
757                ..
758            } = snap;
759            if let Some(iface) = interface {
760                self.global_interface = Some(iface);
761            }
762            if node_ip.is_some() {
763                self.node_ip = node_ip;
764            }
765            if let Some(c) = overlay_cidr.and_then(|s| s.parse().ok()) {
766                self.cluster_cidr = Some(c);
767            }
768            if let Some(s) = slice_cidr.and_then(|s| s.parse().ok()) {
769                self.slice_cidr = Some(s);
770            }
771        }
772    }
773
774    /// Set up the per-service overlay segment by delegating to overlayd.
775    ///
776    /// Returns a [`ServiceOverlayInfo`] describing the segment. The
777    /// container-attach handle (bridge name on Linux, interface elsewhere) is
778    /// `info.name`. In `Dedicated` mode the `wg_public_key`/`wg_port`/
779    /// `overlay_ip`/`subnet` fields carry the per-service `WireGuard`
780    /// transport's identity so the deploy path can publish it to Raft and mesh
781    /// with the other hosting nodes; in `Shared` mode those fields are `None`.
782    ///
783    /// `mode` is the service's resolved [`OverlayMode`], read from its spec at
784    /// the deploy call site. In `Shared` mode overlayd attaches the service to
785    /// the cluster transport via a per-node bridge; in `Dedicated` mode it
786    /// stands up a per-service `WireGuard` transport with its own crypto
787    /// context and reports its identity via
788    /// [`OverlaydResponse::ServiceOverlay`].
789    ///
790    /// # Errors
791    /// Returns an error if overlayd fails to create the segment.
792    pub async fn setup_service_overlay(
793        &self,
794        service_name: &str,
795        mode: zlayer_types::overlay::OverlayMode,
796    ) -> Result<zlayer_types::overlayd::ServiceOverlayInfo, AgentError> {
797        let resp = self
798            .call(OverlaydRequest::SetupServiceOverlay {
799                service: service_name.to_string(),
800                mode,
801            })
802            .await?;
803        match resp {
804            // Shared mode (and any server still on the legacy response shape)
805            // reports only the container-attach handle; synthesize a
806            // `ServiceOverlayInfo` whose Dedicated-only fields are `None`.
807            OverlaydResponse::BridgeName { name } => {
808                Ok(zlayer_types::overlayd::ServiceOverlayInfo {
809                    name,
810                    mode,
811                    wg_public_key: None,
812                    wg_port: None,
813                    overlay_ip: None,
814                    subnet: None,
815                })
816            }
817            // Dedicated mode reports the full device identity.
818            OverlaydResponse::ServiceOverlay(info) => Ok(info),
819            other => Err(AgentError::Network(format!(
820                "overlayd SetupServiceOverlay returned unexpected response: {other:?}"
821            ))),
822        }
823    }
824
825    /// Add a container to the appropriate overlay networks by delegating to
826    /// overlayd (`AttachContainer` with a `LinuxPid` handle).
827    ///
828    /// # Errors
829    /// Returns an error if overlayd cannot attach the container.
830    pub async fn attach_container(
831        &self,
832        container_pid: u32,
833        service_name: &str,
834        join_global: bool,
835        ephemeral: bool,
836        isolation_network: Option<String>,
837        dns_domain_override: Option<String>,
838    ) -> Result<IpAddr, AgentError> {
839        let resp = self
840            .call(OverlaydRequest::AttachContainer {
841                handle: AttachHandle::LinuxPid { pid: container_pid },
842                service: service_name.to_string(),
843                join_global,
844                ephemeral,
845                isolation_network,
846                dns_server: self.dns_server_addr.map(|sa| sa.ip()),
847                // Per-deployment search domain when the caller supplies one
848                // (so a guest's bare `<svc>` resolves to ITS deployment);
849                // otherwise the global zone domain.
850                dns_domain: dns_domain_override.or_else(|| self.dns_domain.clone()),
851            })
852            .await?;
853        match resp {
854            OverlaydResponse::Attached(result) => Ok(result.ip),
855            other => Err(AgentError::Network(format!(
856                "overlayd AttachContainer returned unexpected response: {other:?}"
857            ))),
858        }
859    }
860
861    /// Attach a guest-managed container (a VM with no host netns/PID) to the
862    /// overlay by asking overlayd to allocate the overlay identity (keypair +
863    /// address + the current peer set) and register the generated public key in
864    /// the mesh. The caller ships the returned [`GuestOverlayConfig`] into the
865    /// guest (over vsock) where it brings up its own `WireGuard` device.
866    ///
867    /// `id` is the opaque container id used to scope the allocation so a later
868    /// [`detach_container_guest`](OverlayManager::detach_container_guest) can
869    /// release the address + remove the peer.
870    ///
871    /// # Errors
872    /// Returns an error if overlayd cannot allocate/register the guest.
873    pub async fn attach_container_guest(
874        &self,
875        id: &str,
876        service_name: &str,
877        join_global: bool,
878        isolation_network: Option<String>,
879        dns_domain_override: Option<String>,
880    ) -> Result<zlayer_types::overlayd::GuestOverlayConfig, AgentError> {
881        let resp = self
882            .call(OverlaydRequest::AttachContainer {
883                handle: AttachHandle::GuestManaged { id: id.to_string() },
884                service: service_name.to_string(),
885                join_global,
886                // No host `-b` bridge on the guest path (the VZ guest owns its
887                // own WG device), so the ephemeral last-leaver reap is a no-op
888                // here — keep it false.
889                ephemeral: false,
890                isolation_network,
891                dns_server: self.dns_server_addr.map(|sa| sa.ip()),
892                // Per-deployment search domain when the caller supplies one
893                // (so a guest's bare `<svc>` resolves to ITS deployment);
894                // otherwise the global zone domain.
895                dns_domain: dns_domain_override.or_else(|| self.dns_domain.clone()),
896            })
897            .await?;
898        match resp {
899            OverlaydResponse::GuestConfig(cfg) => Ok(cfg),
900            other => Err(AgentError::Network(format!(
901                "overlayd AttachContainer(GuestManaged) returned unexpected response: {other:?}"
902            ))),
903        }
904    }
905
906    /// Detach a guest-managed container: release its overlay IP and remove its
907    /// registered mesh peer.
908    ///
909    /// # Errors
910    /// Returns an error if overlayd cannot detach the container.
911    pub async fn detach_container_guest(&self, id: &str) -> Result<(), AgentError> {
912        let resp = self
913            .call(OverlaydRequest::DetachContainer {
914                handle: AttachHandle::GuestManaged { id: id.to_string() },
915            })
916            .await?;
917        match resp {
918            OverlaydResponse::Ok => Ok(()),
919            other => Err(AgentError::Network(format!(
920                "overlayd DetachContainer(GuestManaged) returned unexpected response: {other:?}"
921            ))),
922        }
923    }
924
925    /// Mint a short-lived unprivileged `WireGuard` edge peer keyed by `name` and
926    /// return its portable [`EdgeConfig`]. `node_endpoint` is the EXTERNAL
927    /// `advertise_addr:overlay_port` the edge dials (never a local overlay IP);
928    /// `allow` bounds the overlay CIDRs the peer may reach; the peer is swept
929    /// `ttl_secs` after minting. Minted peers die with overlayd — re-mint rather
930    /// than persist the returned config.
931    ///
932    /// # Errors
933    /// Returns an error if overlayd cannot allocate/register the edge peer.
934    pub async fn mint_edge_peer(
935        &self,
936        name: String,
937        ttl_secs: u64,
938        allow: Vec<String>,
939        node_endpoint: String,
940    ) -> Result<EdgeConfig, AgentError> {
941        let resp = self
942            .call(OverlaydRequest::MintEdgePeer {
943                name,
944                ttl_secs,
945                allow,
946                node_endpoint,
947            })
948            .await?;
949        match resp {
950            OverlaydResponse::EdgeConfig(cfg) => Ok(cfg),
951            other => Err(AgentError::Network(format!(
952                "overlayd MintEdgePeer returned unexpected response: {other:?}"
953            ))),
954        }
955    }
956
957    /// Revoke a minted edge peer by `name`. Idempotent: revoking an unknown or
958    /// already-expired name succeeds.
959    ///
960    /// # Errors
961    /// Returns an error if overlayd cannot process the revoke.
962    pub async fn revoke_edge_peer(&self, name: String) -> Result<(), AgentError> {
963        let resp = self.call(OverlaydRequest::RevokeEdgePeer { name }).await?;
964        match resp {
965            OverlaydResponse::Ok => Ok(()),
966            other => Err(AgentError::Network(format!(
967                "overlayd RevokeEdgePeer returned unexpected response: {other:?}"
968            ))),
969        }
970    }
971
972    /// List the edge peers currently minted on this node.
973    ///
974    /// # Errors
975    /// Returns an error if overlayd cannot report the edge-peer set.
976    pub async fn list_edge_peers(&self) -> Result<Vec<EdgePeerStatus>, AgentError> {
977        let resp = self.call(OverlaydRequest::ListEdgePeers).await?;
978        match resp {
979            OverlaydResponse::EdgePeers { peers } => Ok(peers),
980            other => Err(AgentError::Network(format!(
981                "overlayd ListEdgePeers returned unexpected response: {other:?}"
982            ))),
983        }
984    }
985
986    /// Ask the ROOT overlayd to write a macOS `/etc/resolver/<zone>` scoped
987    /// resolver pointing at this node's overlay DNS (privileged path the
988    /// rootless daemon cannot perform itself).
989    ///
990    /// # Errors
991    /// Returns an error if overlayd cannot write the resolver file.
992    pub async fn write_scoped_resolver(
993        &self,
994        zone: &str,
995        node_ip: std::net::IpAddr,
996        port: Option<u16>,
997    ) -> Result<(), AgentError> {
998        self.call(OverlaydRequest::WriteScopedResolver {
999            zone: zone.to_string(),
1000            node_ip,
1001            port,
1002        })
1003        .await?;
1004        Ok(())
1005    }
1006
1007    /// Ask the ROOT overlayd to remove a macOS `/etc/resolver/<zone>` scoped
1008    /// resolver file.
1009    ///
1010    /// # Errors
1011    /// Returns an error if overlayd cannot remove the resolver file.
1012    pub async fn remove_scoped_resolver(&self, zone: &str) -> Result<(), AgentError> {
1013        self.call(OverlaydRequest::RemoveScopedResolver {
1014            zone: zone.to_string(),
1015        })
1016        .await?;
1017        Ok(())
1018    }
1019
1020    /// Attach a macOS host-shared / VM container (Seatbelt, native-VZ, libkrun)
1021    /// to the overlay as a FIRST-CLASS member: overlayd allocates a distinct
1022    /// overlay `/32` from the node slice, adds it as a `utun` alias so it is
1023    /// locally deliverable, and applies isolation membership. Returns the
1024    /// allocated overlay IP (the caller surfaces it for DNS + `zlayer ps`).
1025    /// NEVER the node IP. The caller then forwards `<overlay_ip>:port` to the
1026    /// container's local delivery address.
1027    ///
1028    /// # Errors
1029    /// Returns an error if overlayd cannot allocate/register the container.
1030    pub async fn attach_container_host_shared(
1031        &self,
1032        container_id: &str,
1033        service_name: &str,
1034        ephemeral: bool,
1035        isolation_network: Option<String>,
1036        dns_domain_override: Option<String>,
1037    ) -> Result<IpAddr, AgentError> {
1038        let resp = self
1039            .call(OverlaydRequest::AttachContainer {
1040                handle: AttachHandle::HostShared {
1041                    id: container_id.to_string(),
1042                },
1043                service: service_name.to_string(),
1044                // Host-shared containers ride the cluster slice.
1045                join_global: true,
1046                ephemeral,
1047                isolation_network,
1048                dns_server: self.dns_server_addr.map(|sa| sa.ip()),
1049                // Per-deployment search domain when the caller supplies one
1050                // (so a bare `<svc>` resolves to ITS deployment); otherwise the
1051                // global zone domain.
1052                dns_domain: dns_domain_override.or_else(|| self.dns_domain.clone()),
1053            })
1054            .await?;
1055        match resp {
1056            OverlaydResponse::Attached(result) => Ok(result.ip),
1057            other => Err(AgentError::Network(format!(
1058                "overlayd AttachContainer(HostShared) returned unexpected response: {other:?}"
1059            ))),
1060        }
1061    }
1062
1063    /// Detach a macOS host-shared / VM container: release its overlay IP and
1064    /// remove its `utun` alias + isolation membership.
1065    ///
1066    /// # Errors
1067    /// Returns an error if overlayd cannot detach the container.
1068    pub async fn detach_container_host_shared(&self, container_id: &str) -> Result<(), AgentError> {
1069        let resp = self
1070            .call(OverlaydRequest::DetachContainer {
1071                handle: AttachHandle::HostShared {
1072                    id: container_id.to_string(),
1073                },
1074            })
1075            .await?;
1076        match resp {
1077            OverlaydResponse::Ok => Ok(()),
1078            other => Err(AgentError::Network(format!(
1079                "overlayd DetachContainer(HostShared) returned unexpected response: {other:?}"
1080            ))),
1081        }
1082    }
1083
1084    /// Register a Windows HCN container with overlayd and return its overlay IP
1085    /// plus the overlayd-created namespace GUID.
1086    ///
1087    /// The return type gained the namespace GUID (vs. the pre-migration
1088    /// IP-only return) because the HCN network + endpoint + namespace are now
1089    /// created inside overlayd, and `HcsRuntime` needs that GUID to embed in the
1090    /// compute-system document.
1091    ///
1092    /// When `autoclean` is true and overlayd reports back a namespace GUID, an
1093    /// entry is recorded in [`OverlayManager::hcn_cleanup`] so a later
1094    /// [`OverlayManager::detach_container_hcn`] (or process teardown) can drain
1095    /// it. The cleanup map is purely agent-side bookkeeping; overlayd remains
1096    /// the authoritative owner of the HCN namespace/endpoint state.
1097    ///
1098    /// # Errors
1099    /// Returns an error if overlayd cannot attach the container.
1100    #[cfg(target_os = "windows")]
1101    #[allow(clippy::too_many_arguments)]
1102    pub async fn attach_container_hcn(
1103        &self,
1104        container_id: &str,
1105        service_name: &str,
1106        ip_override: Option<std::net::IpAddr>,
1107        autoclean: bool,
1108        isolation_network: Option<String>,
1109        dns_server: Option<std::net::IpAddr>,
1110        dns_domain: Option<String>,
1111    ) -> Result<(std::net::IpAddr, Option<String>), AgentError> {
1112        let resp = self
1113            .call(OverlaydRequest::AttachContainer {
1114                handle: AttachHandle::WindowsContainer {
1115                    container_id: container_id.to_string(),
1116                    ip: ip_override,
1117                },
1118                service: service_name.to_string(),
1119                join_global: false,
1120                // Windows uses HCN networks, not a host `-b` bridge, so the
1121                // ephemeral last-leaver reap (Linux veth path only) is a no-op
1122                // here — keep it false.
1123                ephemeral: false,
1124                isolation_network,
1125                dns_server: dns_server.or_else(|| self.dns_server_addr.map(|sa| sa.ip())),
1126                dns_domain: dns_domain.or_else(|| self.dns_domain.clone()),
1127            })
1128            .await?;
1129        match resp {
1130            OverlaydResponse::Attached(result) => {
1131                // Record agent-side autoclean bookkeeping. We key by the
1132                // overlayd-issued namespace GUID; if overlayd did not return
1133                // one (e.g. host-network attach), there is nothing to track.
1134                if autoclean {
1135                    if let Some(ns_str) = result.namespace_guid.as_deref() {
1136                        match windows::core::GUID::try_from(ns_str) {
1137                            Ok(ns_guid) => {
1138                                let mut cleanup = self.hcn_cleanup.lock().await;
1139                                cleanup.insert(ns_guid, (service_name.to_string(), result.ip));
1140                            }
1141                            Err(e) => {
1142                                tracing::warn!(
1143                                    ns = %ns_str,
1144                                    error = %e,
1145                                    "overlayd returned a non-GUID namespace handle; skipping hcn_cleanup insert"
1146                                );
1147                            }
1148                        }
1149                    }
1150                }
1151                Ok((result.ip, result.namespace_guid))
1152            }
1153            other => Err(AgentError::Network(format!(
1154                "overlayd AttachContainer(WindowsContainer) returned unexpected response: {other:?}"
1155            ))),
1156        }
1157    }
1158
1159    /// Detach and release a Windows HCN container by its bare namespace GUID.
1160    ///
1161    /// Drains the agent-side [`OverlayManager::hcn_cleanup`] entry (if any)
1162    /// before forwarding `DetachContainer` to overlayd. Safe to call with an
1163    /// unknown GUID — the map drain is a no-op in that case.
1164    ///
1165    /// # Errors
1166    /// Returns an error if overlayd reports a detach failure.
1167    #[cfg(target_os = "windows")]
1168    pub async fn detach_container_hcn(&self, namespace_guid: &str) -> Result<(), AgentError> {
1169        // Drain the agent-side cleanup map first so a later overlayd error does
1170        // not leave a stale entry behind.
1171        match windows::core::GUID::try_from(namespace_guid) {
1172            Ok(ns_guid) => {
1173                let mut cleanup = self.hcn_cleanup.lock().await;
1174                if let Some((service_name, ip)) = cleanup.remove(&ns_guid) {
1175                    tracing::info!(
1176                        ns = %namespace_guid,
1177                        service = %service_name,
1178                        ip = %ip,
1179                        "Released HCN overlay attachment (agent-side cleanup)"
1180                    );
1181                }
1182            }
1183            Err(e) => {
1184                tracing::warn!(
1185                    ns = %namespace_guid,
1186                    error = %e,
1187                    "detach_container_hcn called with non-GUID handle; skipping hcn_cleanup drain"
1188                );
1189            }
1190        }
1191
1192        self.call(OverlaydRequest::DetachContainer {
1193            handle: AttachHandle::WindowsContainer {
1194                container_id: namespace_guid.to_string(),
1195                ip: None,
1196            },
1197        })
1198        .await?;
1199        Ok(())
1200    }
1201
1202    /// Release the overlay resources held by a Linux container by delegating to
1203    /// overlayd (`DetachContainer` with a `LinuxPid` handle).
1204    ///
1205    /// # Errors
1206    /// Returns an error if overlayd reports a detach failure.
1207    pub async fn detach_container(&self, pid: u32) -> Result<(), AgentError> {
1208        self.call(OverlaydRequest::DetachContainer {
1209            handle: AttachHandle::LinuxPid { pid },
1210        })
1211        .await?;
1212        Ok(())
1213    }
1214
1215    /// Reclaim orphaned per-service host bridges (and stale device veths) that no
1216    /// live deployment still owns, by delegating to overlayd. `live_bridge_names`
1217    /// is the full set of `zl-…-b` bridge names every currently-restored service
1218    /// SHOULD own (computed by the daemon from storage via
1219    /// [`OverlayManager::service_bridge_name`]); overlayd deletes every matching
1220    /// `zl-…-b`/`-d` link NOT in that set and releases its subnet/`AllowedIPs`.
1221    ///
1222    /// Best-effort: a failure is logged, never propagated. Returns the names
1223    /// overlayd actually reclaimed (empty on failure or off Linux).
1224    pub async fn prune_orphan_bridges(&self, live_bridge_names: Vec<String>) -> Vec<String> {
1225        match self
1226            .call(OverlaydRequest::PruneOrphanBridges { live_bridge_names })
1227            .await
1228        {
1229            Ok(OverlaydResponse::PrunedBridges { reclaimed }) => {
1230                if !reclaimed.is_empty() {
1231                    tracing::info!(
1232                        count = reclaimed.len(),
1233                        bridges = ?reclaimed,
1234                        "overlayd reclaimed orphaned service bridges"
1235                    );
1236                }
1237                reclaimed
1238            }
1239            Ok(other) => {
1240                tracing::warn!(
1241                    ?other,
1242                    "overlayd PruneOrphanBridges returned unexpected response"
1243                );
1244                Vec::new()
1245            }
1246            Err(e) => {
1247                tracing::warn!(error = %e, "overlayd PruneOrphanBridges failed (non-fatal)");
1248                Vec::new()
1249            }
1250        }
1251    }
1252
1253    /// Deterministic per-service bridge name for `service`, identical by
1254    /// construction to the name overlayd creates server-side
1255    /// (`make_interface_name(&[deployment, instance_id, service], "b")`). The
1256    /// daemon uses this to compute the live-bridge set it hands
1257    /// [`OverlayManager::prune_orphan_bridges`].
1258    #[must_use]
1259    pub fn service_bridge_name(&self, service: &str) -> String {
1260        make_interface_name(&[&self.deployment, &self.instance_id, service], "b")
1261    }
1262
1263    /// Tear down the per-service overlay segment for `service_name`.
1264    pub async fn teardown_service_overlay(&self, service_name: &str) {
1265        if let Err(e) = self
1266            .call(OverlaydRequest::TeardownServiceOverlay {
1267                service: service_name.to_string(),
1268            })
1269            .await
1270        {
1271            tracing::warn!(service = %service_name, error = %e, "overlayd TeardownServiceOverlay failed");
1272        }
1273    }
1274
1275    /// Cleanup all overlay networks (tears down the global overlay in overlayd).
1276    ///
1277    /// # Errors
1278    /// Returns an error if overlayd reports a teardown failure.
1279    pub async fn cleanup(&mut self) -> Result<(), AgentError> {
1280        self.call(OverlaydRequest::TeardownGlobalOverlay).await?;
1281        self.global_interface = None;
1282        // Best-effort drain of any agent-side autoclean bookkeeping we still
1283        // hold on Windows. overlayd already tore down the HCN namespaces in
1284        // response to `TeardownGlobalOverlay`; this just empties the side-map
1285        // so a subsequent reuse of this manager starts clean.
1286        #[cfg(target_os = "windows")]
1287        {
1288            let mut cleanup = self.hcn_cleanup.lock().await;
1289            cleanup.clear();
1290        }
1291        Ok(())
1292    }
1293
1294    /// Returns this node's IP on the global overlay network (cached).
1295    pub fn node_ip(&self) -> Option<IpAddr> {
1296        self.node_ip
1297    }
1298
1299    /// Returns the deployment name this overlay manager was created for.
1300    pub fn deployment(&self) -> &str {
1301        &self.deployment
1302    }
1303
1304    /// Returns the global overlay interface name (cached).
1305    pub fn global_interface(&self) -> Option<&str> {
1306        self.global_interface.as_deref()
1307    }
1308
1309    /// Returns the `WireGuard` listen port for the overlay network.
1310    pub fn overlay_port(&self) -> u16 {
1311        self.overlay_port
1312    }
1313
1314    /// Returns `true` if the global overlay transport is active (cached: an
1315    /// interface name has been recorded).
1316    pub fn has_global_transport(&self) -> bool {
1317        self.global_interface.is_some()
1318    }
1319
1320    /// Returns the number of per-service overlay bridges currently active.
1321    pub async fn service_bridge_count(&self) -> usize {
1322        match self.call(OverlaydRequest::Status).await {
1323            Ok(OverlaydResponse::Status(snap)) => snap.service_count as usize,
1324            _ => 0,
1325        }
1326    }
1327
1328    /// Add a peer to the live global overlay transport by delegating to overlayd.
1329    ///
1330    /// The parameter type is preserved (`&zlayer_overlay::PeerInfo`) so the one
1331    /// caller (`zlayer-api`'s internal add-peer handler) compiles unchanged; the
1332    /// shim converts it to a wire-safe [`PeerSpec`].
1333    ///
1334    /// # Errors
1335    /// Returns an error if overlayd rejects the peer (e.g. overlay not yet up).
1336    pub async fn add_global_peer(&self, peer: &zlayer_overlay::PeerInfo) -> Result<(), AgentError> {
1337        self.add_global_peer_with_candidates(peer, Vec::new()).await
1338    }
1339
1340    /// Add a global-overlay peer along with the NAT candidates it advertised at
1341    /// join time. overlayd records the candidates and, on its next NAT tick,
1342    /// hole-punches / relays toward the peer when its direct endpoint does not
1343    /// establish a `WireGuard` handshake. The candidate-free
1344    /// [`OverlayManager::add_global_peer`] is the back-compat thin wrapper.
1345    ///
1346    /// # Errors
1347    /// Returns an error if overlayd rejects the peer (e.g. overlay not yet up).
1348    pub async fn add_global_peer_with_candidates(
1349        &self,
1350        peer: &zlayer_overlay::PeerInfo,
1351        candidates: Vec<NatCandidateWire>,
1352    ) -> Result<(), AgentError> {
1353        self.call(OverlaydRequest::AddPeer {
1354            peer: peer_spec_from(peer, candidates),
1355            scope: zlayer_types::overlayd::PeerScope::Global,
1356        })
1357        .await?;
1358        Ok(())
1359    }
1360
1361    /// Add a peer to a service's dedicated per-service overlay transport.
1362    ///
1363    /// Analogous to [`OverlayManager::add_global_peer`] but scoped to
1364    /// `service`'s [`OverlayMode::Dedicated`] device: first the peer itself
1365    /// (`AddPeer` with `scope: Service`), then the service `subnet` plumbed
1366    /// into that peer's `AllowedIPs` (`AddAllowedIp` with the same scope).
1367    ///
1368    /// # Errors
1369    /// Returns an error if overlayd rejects the peer or the allowed-IP add
1370    /// (e.g. the service's dedicated transport is not yet up).
1371    pub async fn add_service_peer(
1372        &self,
1373        service: &str,
1374        peer: &zlayer_overlay::PeerInfo,
1375        subnet: &str,
1376    ) -> Result<(), AgentError> {
1377        self.call(OverlaydRequest::AddPeer {
1378            peer: peer_spec_from(peer, Vec::new()),
1379            scope: zlayer_types::overlayd::PeerScope::Service {
1380                service: service.to_string(),
1381            },
1382        })
1383        .await?;
1384        self.call(OverlaydRequest::AddAllowedIp {
1385            pubkey: peer.public_key.clone(),
1386            cidr: subnet.to_string(),
1387            scope: zlayer_types::overlayd::PeerScope::Service {
1388                service: service.to_string(),
1389            },
1390        })
1391        .await?;
1392        Ok(())
1393    }
1394
1395    /// Remove a peer (by base64 public key) from a service's dedicated
1396    /// per-service overlay transport.
1397    ///
1398    /// # Errors
1399    /// Returns an error if overlayd reports the removal failed.
1400    pub async fn remove_service_peer(&self, service: &str, pubkey: &str) -> Result<(), AgentError> {
1401        self.call(OverlaydRequest::RemovePeer {
1402            pubkey: pubkey.to_string(),
1403            scope: zlayer_types::overlayd::PeerScope::Service {
1404                service: service.to_string(),
1405            },
1406        })
1407        .await?;
1408        Ok(())
1409    }
1410
1411    /// Returns the CIDR string for the overlay IP allocator (cached cluster CIDR).
1412    pub fn overlay_cidr(&self) -> String {
1413        self.cluster_cidr
1414            .map_or_else(|| "10.200.0.0/16".to_string(), |c| c.to_string())
1415    }
1416
1417    /// Returns the per-node slice CIDR this manager was built with, or `None`.
1418    pub fn slice_cidr(&self) -> Option<IpNetwork> {
1419        self.slice_cidr
1420    }
1421
1422    /// Returns the full cluster CIDR, if known.
1423    pub fn cluster_cidr(&self) -> Option<IpNetwork> {
1424        self.cluster_cidr
1425    }
1426
1427    /// Persist the IPAM allocator state. overlayd owns IPAM; this is a no-op
1428    /// retained for ABI parity with callers.
1429    ///
1430    /// # Errors
1431    /// Infallible today.
1432    #[allow(clippy::unused_async)]
1433    pub async fn persist_ipam_state(&self, _path: &std::path::Path) -> Result<(), AgentError> {
1434        Ok(())
1435    }
1436
1437    /// Restore IPAM allocator state. overlayd owns IPAM; this is a no-op
1438    /// retained for ABI parity with callers.
1439    ///
1440    /// # Errors
1441    /// Infallible today.
1442    #[allow(clippy::unused_async)]
1443    pub async fn restore_ipam_state(&mut self, _path: &std::path::Path) -> Result<(), AgentError> {
1444        Ok(())
1445    }
1446
1447    /// Returns IP allocation statistics: (`allocated_count`, `base_addr`).
1448    ///
1449    /// overlayd owns IPAM and does not surface allocation counters over IPC, so
1450    /// this reports `(0, base)` derived from the cached cluster CIDR.
1451    pub fn ip_alloc_stats(&self) -> (u64, IpAddr) {
1452        let base = self
1453            .cluster_cidr
1454            .map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.network());
1455        (0, base)
1456    }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::*;
1462
1463    /// `resolve_isolation_network` is the single derivation every runtime's
1464    /// attach path uses: an explicit named network always wins, and absent one,
1465    /// only [`OverlayMode::Isolated`] fences the service to a network named after
1466    /// itself. All other modes stay on the flat cluster mesh (`None`).
1467    #[test]
1468    fn resolve_isolation_network_cases() {
1469        use zlayer_types::overlay::OverlayMode;
1470
1471        // Isolated with no explicit name → fenced to a network named after the service.
1472        assert_eq!(
1473            resolve_isolation_network(OverlayMode::Isolated, "web", None),
1474            Some("web".to_string())
1475        );
1476        // Non-isolating modes with no explicit name → flat mesh.
1477        assert_eq!(
1478            resolve_isolation_network(OverlayMode::Auto, "web", None),
1479            None
1480        );
1481        assert_eq!(
1482            resolve_isolation_network(OverlayMode::Dedicated, "web", None),
1483            None
1484        );
1485        // An explicit named network always wins, regardless of mode.
1486        assert_eq!(
1487            resolve_isolation_network(OverlayMode::Auto, "web", Some("net1".into())),
1488            Some("net1".into())
1489        );
1490        assert_eq!(
1491            resolve_isolation_network(OverlayMode::Isolated, "web", Some("net1".into())),
1492            Some("net1".into())
1493        );
1494    }
1495
1496    /// Transport-level overlayd errors (dead/closed socket, framing desync) must
1497    /// be classified as reconnect-worthy so `call()` drops the cached client and
1498    /// re-dials; application-level (`Overlay`) and logical (`Other`) errors must
1499    /// NOT, since the connection is still healthy. The full reconnect-and-retry
1500    /// path in `call()` requires a live overlayd peer (the framed `ClientConn`
1501    /// has no in-process mock), so it is not unit-testable here; this guards the
1502    /// classification that drives it.
1503    #[test]
1504    fn transport_errors_trigger_reconnect_app_errors_do_not() {
1505        use std::io::{Error as IoError, ErrorKind};
1506        use zlayer_overlayd::OverlaydError;
1507
1508        // Broken pipe — the exact failure that previously poisoned the cache.
1509        assert!(is_transport_error(&OverlaydError::Io(IoError::new(
1510            ErrorKind::BrokenPipe,
1511            "Broken pipe (os error 32)",
1512        ))));
1513        assert!(is_transport_error(&OverlaydError::Io(IoError::new(
1514            ErrorKind::ConnectionReset,
1515            "connection reset",
1516        ))));
1517        assert!(is_transport_error(&OverlaydError::Closed));
1518        assert!(is_transport_error(&OverlaydError::FrameTooLarge(99)));
1519
1520        // overlayd answered over a healthy socket — do not reconnect.
1521        assert!(!is_transport_error(&OverlaydError::Overlay(
1522            "nat refresh failed".to_string()
1523        )));
1524        assert!(!is_transport_error(&OverlaydError::Other(
1525            "protocol mismatch".to_string()
1526        )));
1527    }
1528
1529    /// No generated name may ever exceed 15 characters.
1530    #[test]
1531    fn interface_name_never_exceeds_limit() {
1532        let cases: Vec<(&[&str], &str)> = vec![
1533            (&["a"], "g"),
1534            (&["zlayer-manager"], "g"),
1535            (&["my-very-long-deployment-name-that-goes-on-and-on"], "g"),
1536            (&["zlayer", "manager"], "s"),
1537            (&["zlayer-manager", "frontend-service"], "s"),
1538            (&["a", "b"], "s"),
1539            (
1540                &["abcdefghijklmnopqrstuvwxyz", "abcdefghijklmnopqrstuvwxyz"],
1541                "s",
1542            ),
1543            (&["x"], ""),
1544            (&["deployment"], ""),
1545            (&["a-really-long-name-exceeding-everything"], "suffix"),
1546        ];
1547
1548        for (parts, suffix) in &cases {
1549            let name = make_interface_name(parts, suffix);
1550            assert!(
1551                name.len() <= MAX_IFNAME_LEN,
1552                "Name '{}' is {} chars (parts={:?}, suffix='{}')",
1553                name,
1554                name.len(),
1555                parts,
1556                suffix,
1557            );
1558        }
1559    }
1560
1561    /// Very long and varied inputs must still respect the limit.
1562    #[test]
1563    fn interface_name_with_extreme_lengths() {
1564        let long = "a".repeat(200);
1565        let long_ref = long.as_str();
1566
1567        let name = make_interface_name(&[long_ref], "g");
1568        assert!(name.len() <= MAX_IFNAME_LEN, "Name '{name}' too long");
1569
1570        let name = make_interface_name(&[long_ref, long_ref, long_ref], "s");
1571        assert!(name.len() <= MAX_IFNAME_LEN, "Name '{name}' too long");
1572
1573        let name = make_interface_name(&[long_ref], "");
1574        assert!(name.len() <= MAX_IFNAME_LEN, "Name '{name}' too long");
1575    }
1576
1577    /// Same inputs must always produce the same output.
1578    #[test]
1579    fn interface_name_is_deterministic() {
1580        let a = make_interface_name(&["zlayer-manager"], "g");
1581        let b = make_interface_name(&["zlayer-manager"], "g");
1582        assert_eq!(a, b);
1583    }
1584
1585    /// Different inputs must produce different outputs.
1586    #[test]
1587    fn interface_name_uniqueness() {
1588        let a = make_interface_name(&["deploy-a"], "g");
1589        let b = make_interface_name(&["deploy-b"], "g");
1590        assert_ne!(a, b);
1591
1592        let a = make_interface_name(&["deploy"], "g");
1593        let b = make_interface_name(&["deploy"], "s");
1594        assert_ne!(a, b);
1595    }
1596
1597    /// Short names that fit should be returned as-is (human readable).
1598    #[test]
1599    fn interface_name_short_inputs_are_readable() {
1600        let name = make_interface_name(&["app"], "g");
1601        assert_eq!(name, "zl-app-g");
1602        let name = make_interface_name(&["my", "web"], "s");
1603        assert_eq!(name, "zl-my-web-s");
1604    }
1605
1606    /// `with_slice` must remember the slice it was built with.
1607    #[test]
1608    fn with_slice_stores_slice_cidr() {
1609        let cluster: IpNetwork = "10.200.0.0/16".parse().unwrap();
1610        let slice: IpNetwork = "10.200.42.0/28".parse().unwrap();
1611        let om = OverlayManager::with_slice(
1612            "test-deploy".to_string(),
1613            cluster,
1614            slice,
1615            51820,
1616            "test".to_string(),
1617        );
1618        assert_eq!(om.slice_cidr(), Some(slice));
1619        assert_eq!(om.cluster_cidr(), Some(cluster));
1620        assert_eq!(om.overlay_port(), 51820);
1621        assert_eq!(om.deployment(), "test-deploy");
1622    }
1623
1624    /// `node_ip()` is None before any setup.
1625    #[tokio::test]
1626    async fn node_ip_none_before_setup() {
1627        let om = OverlayManager::new("test-deploy".to_string(), "test".to_string())
1628            .await
1629            .unwrap();
1630        assert!(om.node_ip().is_none());
1631    }
1632
1633    /// Reconnect-time global-overlay re-establishment is gated on
1634    /// `global_interface` being `Some` (i.e. a global overlay was actually set
1635    /// up). A freshly-constructed manager has none, so the reconnect path's
1636    /// `reestablish_global_overlay_on` early-returns and never spuriously asks
1637    /// overlayd to create a global adapter for a status-only client. This guards
1638    /// the gate `reestablish_global_overlay_on` keys off (the method itself needs
1639    /// a live overlayd connection, for which there is no in-process fake).
1640    #[tokio::test]
1641    async fn reestablish_gate_is_off_before_setup() {
1642        let om = OverlayManager::new("reestablish-gate".to_string(), "test".to_string())
1643            .await
1644            .unwrap();
1645        assert!(
1646            !om.has_global_transport(),
1647            "global overlay must be considered absent before setup so the reconnect \
1648             re-establish path is a no-op"
1649        );
1650        assert!(om.global_interface().is_none());
1651    }
1652
1653    /// DNS config round-trips through the cache.
1654    #[tokio::test]
1655    async fn dns_config_set_and_round_trip() {
1656        let mut om = OverlayManager::new("dns-roundtrip".to_string(), "test".to_string())
1657            .await
1658            .unwrap();
1659        let addr: SocketAddr = "10.200.42.1:15353".parse().unwrap();
1660        om.set_dns_config(Some(addr), Some("overlay.local".to_string()));
1661        assert_eq!(om.dns_server_addr(), Some(addr));
1662        assert_eq!(om.dns_domain(), Some("overlay.local"));
1663
1664        om.set_dns_config(None, None);
1665        assert!(om.dns_server_addr().is_none());
1666        assert!(om.dns_domain().is_none());
1667    }
1668
1669    /// `peer_spec_from` must copy every `PeerInfo` field into the wire-safe
1670    /// `PeerSpec` exactly as the live overlayd transport expects (endpoint
1671    /// stringified, keepalive in whole seconds).
1672    #[test]
1673    fn peer_spec_from_copies_all_fields() {
1674        let peer = zlayer_overlay::PeerInfo {
1675            public_key: "base64key".to_string(),
1676            endpoint: "1.2.3.4:51820".parse().unwrap(),
1677            allowed_ips: "10.200.0.2/32".to_string(),
1678            persistent_keepalive_interval: std::time::Duration::from_secs(25),
1679        };
1680        let spec = peer_spec_from(&peer, Vec::new());
1681        assert_eq!(spec.public_key, "base64key");
1682        assert_eq!(spec.endpoint, "1.2.3.4:51820");
1683        assert_eq!(spec.allowed_ips, "10.200.0.2/32");
1684        assert_eq!(spec.persistent_keepalive_secs, 25);
1685        assert!(spec.candidates.is_empty());
1686
1687        // Candidates supplied at join time are threaded verbatim into the spec.
1688        let cands = vec![NatCandidateWire {
1689            candidate_type: "server-reflexive".to_string(),
1690            address: "203.0.113.5:51820".to_string(),
1691            priority: 50,
1692        }];
1693        let spec = peer_spec_from(&peer, cands.clone());
1694        assert_eq!(spec.candidates, cands);
1695    }
1696
1697    /// `nat_config_to_spec` must copy STUN/TURN/relay verbatim and fold the
1698    /// cluster relay credential into the relay spec's `auth_credential`.
1699    #[test]
1700    fn nat_config_to_spec_threads_credential_and_servers() {
1701        use zlayer_overlay::nat::{RelayServerConfig, StunServerConfig, TurnServerConfig};
1702        let cfg = NatConfig {
1703            enabled: true,
1704            stun_servers: vec![StunServerConfig {
1705                address: "stun.example:3478".to_string(),
1706                label: None,
1707            }],
1708            turn_servers: vec![TurnServerConfig {
1709                address: "turn.example:3478".to_string(),
1710                username: "u".to_string(),
1711                credential: "p".to_string(),
1712                region: None,
1713            }],
1714            hole_punch_timeout_secs: 7,
1715            stun_refresh_interval_secs: 33,
1716            max_candidate_pairs: 5,
1717            relay_server: Some(RelayServerConfig {
1718                listen_port: 3478,
1719                external_addr: "1.2.3.4:3478".to_string(),
1720                max_sessions: 42,
1721            }),
1722        };
1723        let spec = nat_config_to_spec(&cfg, Some("cluster-secret".to_string()));
1724        assert!(spec.enabled);
1725        assert_eq!(spec.stun_servers, vec!["stun.example:3478".to_string()]);
1726        assert_eq!(spec.turn_servers.len(), 1);
1727        assert_eq!(spec.turn_servers[0].addr, "turn.example:3478");
1728        assert_eq!(spec.hole_punch_timeout_secs, 7);
1729        assert_eq!(spec.max_candidate_pairs, 5);
1730        let relay = spec.relay_server.expect("relay spec present");
1731        assert_eq!(relay.listen_port, 3478);
1732        assert_eq!(relay.max_sessions, 42);
1733        assert_eq!(relay.auth_credential.as_deref(), Some("cluster-secret"));
1734    }
1735
1736    /// `nat_status_wire_to_snapshot` must map peers verbatim and parse candidate
1737    /// addresses, dropping unparseable ones.
1738    #[test]
1739    fn nat_status_wire_to_snapshot_maps_fields() {
1740        use zlayer_types::overlayd::NatPeerWire;
1741        let wire = NatStatusWire {
1742            candidates: vec![
1743                NatCandidateWire {
1744                    candidate_type: "host".to_string(),
1745                    address: "192.168.1.5:51820".to_string(),
1746                    priority: 100,
1747                },
1748                NatCandidateWire {
1749                    candidate_type: "host".to_string(),
1750                    address: "not-an-addr".to_string(),
1751                    priority: 100,
1752                },
1753            ],
1754            peers: vec![NatPeerWire {
1755                node_id: "k".to_string(),
1756                connection_type: "hole-punched".to_string(),
1757                remote_endpoint: Some("203.0.113.9:51820".to_string()),
1758            }],
1759            last_refresh: 1234,
1760        };
1761        let snap = nat_status_wire_to_snapshot(wire);
1762        // One candidate parsed, the bogus address dropped.
1763        assert_eq!(snap.candidates.len(), 1);
1764        assert_eq!(snap.peers.len(), 1);
1765        assert_eq!(snap.peers[0].connection_type, "hole-punched");
1766        assert_eq!(snap.last_refresh, 1234);
1767    }
1768
1769    /// `setup_service_overlay` must forward the caller-supplied mode verbatim
1770    /// (no more hardcoded `OverlayMode::default()`). Asserts the request the
1771    /// shim builds carries `Dedicated` when asked for `Dedicated`.
1772    #[test]
1773    fn setup_service_overlay_request_carries_dedicated_mode() {
1774        let req = OverlaydRequest::SetupServiceOverlay {
1775            service: "web".to_string(),
1776            mode: zlayer_types::overlay::OverlayMode::Dedicated,
1777        };
1778        match req {
1779            OverlaydRequest::SetupServiceOverlay { service, mode } => {
1780                assert_eq!(service, "web");
1781                assert_eq!(mode, zlayer_types::overlay::OverlayMode::Dedicated);
1782                assert_ne!(mode, zlayer_types::overlay::OverlayMode::default());
1783            }
1784            other => panic!("expected SetupServiceOverlay, got {other:?}"),
1785        }
1786    }
1787
1788    /// The service-scoped peer ops must target `PeerScope::Service { service }`,
1789    /// not `Global`, so dedicated transports stay isolated from the cluster
1790    /// transport.
1791    #[test]
1792    fn service_peer_ops_use_service_scope() {
1793        let peer = zlayer_overlay::PeerInfo {
1794            public_key: "k".to_string(),
1795            endpoint: "1.2.3.4:51820".parse().unwrap(),
1796            allowed_ips: "10.201.0.2/32".to_string(),
1797            persistent_keepalive_interval: std::time::Duration::from_secs(0),
1798        };
1799        let svc_scope = zlayer_types::overlayd::PeerScope::Service {
1800            service: "web".to_string(),
1801        };
1802
1803        let add = OverlaydRequest::AddPeer {
1804            peer: peer_spec_from(&peer, Vec::new()),
1805            scope: svc_scope.clone(),
1806        };
1807        let allow = OverlaydRequest::AddAllowedIp {
1808            pubkey: peer.public_key.clone(),
1809            cidr: "10.201.0.0/24".to_string(),
1810            scope: svc_scope.clone(),
1811        };
1812        let remove = OverlaydRequest::RemovePeer {
1813            pubkey: peer.public_key.clone(),
1814            scope: svc_scope,
1815        };
1816
1817        match add {
1818            OverlaydRequest::AddPeer { scope, peer } => {
1819                assert_eq!(
1820                    scope,
1821                    zlayer_types::overlayd::PeerScope::Service {
1822                        service: "web".to_string()
1823                    }
1824                );
1825                assert_eq!(peer.public_key, "k");
1826            }
1827            other => panic!("expected AddPeer, got {other:?}"),
1828        }
1829        match allow {
1830            OverlaydRequest::AddAllowedIp { scope, cidr, .. } => {
1831                assert_eq!(cidr, "10.201.0.0/24");
1832                assert_eq!(
1833                    scope,
1834                    zlayer_types::overlayd::PeerScope::Service {
1835                        service: "web".to_string()
1836                    }
1837                );
1838            }
1839            other => panic!("expected AddAllowedIp, got {other:?}"),
1840        }
1841        match remove {
1842            OverlaydRequest::RemovePeer { scope, pubkey } => {
1843                assert_eq!(pubkey, "k");
1844                assert_eq!(
1845                    scope,
1846                    zlayer_types::overlayd::PeerScope::Service {
1847                        service: "web".to_string()
1848                    }
1849                );
1850            }
1851            other => panic!("expected RemovePeer, got {other:?}"),
1852        }
1853    }
1854
1855    /// Windows-only: verify the `hcn_cleanup` side-map starts empty on both
1856    /// constructor paths. Live insert/drain coverage lives behind the overlayd
1857    /// IPC layer (which is exercised by the windows e2e tests), but this
1858    /// sanity-checks that the field is wired correctly through `new()` and
1859    /// `with_slice()`.
1860    #[cfg(target_os = "windows")]
1861    #[tokio::test]
1862    async fn hcn_cleanup_map_starts_empty() {
1863        let om = OverlayManager::new("test-deploy".to_string(), "test".to_string())
1864            .await
1865            .unwrap();
1866        {
1867            let map = om.hcn_cleanup.lock().await;
1868            assert!(
1869                map.is_empty(),
1870                "hcn_cleanup map must start empty from new()"
1871            );
1872        }
1873
1874        let cluster: IpNetwork = "10.200.0.0/16".parse().unwrap();
1875        let slice: IpNetwork = "10.200.42.0/28".parse().unwrap();
1876        let om = OverlayManager::with_slice(
1877            "test-deploy".to_string(),
1878            cluster,
1879            slice,
1880            51820,
1881            "test".to_string(),
1882        );
1883        {
1884            let map = om.hcn_cleanup.lock().await;
1885            assert!(
1886                map.is_empty(),
1887                "hcn_cleanup map must start empty from with_slice()"
1888            );
1889        }
1890    }
1891}