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}