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