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