1use std::collections::HashMap;
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
15#[cfg(target_os = "linux")]
16use std::os::fd::AsFd;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use ipnetwork::IpNetwork;
21use zlayer_overlay::{NatConfig, NatTraversal, OverlayConfig, OverlayTransport, PeerInfo};
22use zlayer_types::overlayd::{
23 AttachHandle, AttachResult, DedicatedServiceStatus, GuestOverlayConfig, OverlayMode,
24 OverlaydRequest, OverlaydResponse, PeerScope, PeerSpec, PeerStatus, ServiceOverlayInfo,
25 StatusSnapshot,
26};
27
28use crate::error::OverlaydError;
29use crate::network_state::{
30 owner_for_service, DedicatedPortAllocator, ManagedNetwork, NetworkState,
31};
32
33const MAX_IFNAME_LEN: usize = 15;
35
36#[must_use]
42pub fn make_interface_name(parts: &[&str], suffix: &str) -> String {
43 use std::collections::hash_map::DefaultHasher;
44 use std::hash::{Hash, Hasher};
45
46 let base = format!("zl-{}", parts.join("-"));
47 let candidate = if suffix.is_empty() {
48 base
49 } else {
50 format!("{base}-{suffix}")
51 };
52
53 if candidate.len() <= MAX_IFNAME_LEN {
54 return candidate;
55 }
56
57 let mut hasher = DefaultHasher::new();
59 for part in parts {
60 part.hash(&mut hasher);
61 }
62 suffix.hash(&mut hasher);
63 let hash = format!("{:x}", hasher.finish());
64
65 if suffix.is_empty() {
66 let budget = MAX_IFNAME_LEN - 3;
68 format!("zl-{}", &hash[..budget.min(hash.len())])
69 } else {
70 let suffix_cost = 1 + suffix.len(); let hash_budget = MAX_IFNAME_LEN.saturating_sub(3 + suffix_cost);
73 if hash_budget == 0 {
74 let budget = MAX_IFNAME_LEN - 3;
75 format!("zl-{}", &hash[..budget.min(hash.len())])
76 } else {
77 format!("zl-{}-{}", &hash[..hash_budget.min(hash.len())], suffix)
78 }
79 }
80}
81
82fn first_usable_ip(subnet: ipnet::IpNet) -> IpAddr {
87 match subnet {
88 ipnet::IpNet::V4(v4) => {
89 let net = u32::from(v4.network());
90 IpAddr::V4(Ipv4Addr::from(net.wrapping_add(1)))
91 }
92 ipnet::IpNet::V6(v6) => {
93 let net = u128::from(v6.network());
94 IpAddr::V6(Ipv6Addr::from(net.wrapping_add(1)))
95 }
96 }
97}
98
99#[cfg(target_os = "linux")]
102#[derive(Debug)]
103struct BridgeAttachParams<'a> {
104 bridge_name: &'a str,
106 gateway: IpAddr,
108 subnet_prefix_len: u8,
110}
111
112#[cfg(target_os = "linux")]
115#[derive(Debug, Clone)]
116struct AttachInfo {
117 service_ip: IpAddr,
119 service_name: Option<String>,
121 global_ip: Option<IpAddr>,
123 joined_global: bool,
125}
126
127#[derive(Debug, Clone)]
132struct GuestAttachInfo {
133 overlay_ip: IpAddr,
135 public_key: String,
138 service_name: Option<String>,
142}
143
144#[cfg(target_os = "linux")]
148#[derive(Debug)]
149struct ServiceBridge {
150 name: String,
152 subnet: ipnet::IpNet,
154 gateway: IpAddr,
156 ip_allocator: zlayer_overlay::allocator::IpAllocator,
158}
159
160struct ServiceTransport {
170 transport: OverlayTransport,
172 interface: String,
174 public_key: String,
176 listen_port: u16,
178 overlay_ip: std::net::IpAddr,
180 subnet: ipnet::IpNet,
182}
183
184pub struct OverlaydServer {
186 deployment: String,
188 instance_id: String,
191 data_dir: PathBuf,
193 global_interface: Option<String>,
195 global_transport: Option<OverlayTransport>,
199 service_interfaces: HashMap<String, String>,
201 service_transports: HashMap<String, ServiceTransport>,
204 dedicated_ports: DedicatedPortAllocator,
206 #[cfg(target_os = "linux")]
208 service_bridges: HashMap<String, ServiceBridge>,
209 service_subnet_registry: Option<zlayer_overlay::allocator::ServiceSubnetRegistry>,
213 local_node_id: u64,
215 local_wg_pubkey: Option<String>,
219 transport_public_key: Option<String>,
223 ip_allocator: IpAllocator,
225 node_ip: Option<IpAddr>,
227 overlay_port: u16,
229 cluster_cidr: Option<IpNetwork>,
231 slice_cidr: Option<IpNetwork>,
233 #[cfg(target_os = "windows")]
235 hcn_cleanup: HashMap<windows::core::GUID, (String, std::net::IpAddr)>,
236 #[cfg(target_os = "windows")]
241 service_ip_allocators: HashMap<String, IpAllocator>,
242 #[cfg(target_os = "linux")]
244 attached: HashMap<u32, AttachInfo>,
245 global_peers: HashMap<String, PeerSpec>,
251 guest_attachments: HashMap<String, GuestAttachInfo>,
256 dns_server_addr: Option<SocketAddr>,
258 dns_domain: Option<String>,
260 dns_records: HashMap<String, IpAddr>,
262 nat_config: Option<NatConfig>,
264 uapi_sock_dir: Option<PathBuf>,
266 nat_traversal: Option<NatTraversal>,
268 nat_last_refresh: AtomicU64,
270 shutdown_requested: bool,
272}
273
274impl OverlaydServer {
275 #[must_use]
283 pub fn new(data_dir: PathBuf) -> Self {
284 let default_cidr: IpNetwork = "10.200.0.0/16".parse().expect("compile-time constant CIDR");
287 let overlay_port = zlayer_core::DEFAULT_WG_PORT;
288
289 let marker_path = zlayer_paths::ZLayerDirs::new(data_dir.clone()).agent_network_state();
293 let recorded_dedicated_ports: Vec<u16> = NetworkState::load(&marker_path)
294 .networks
295 .iter()
296 .filter(|n| n.owner.starts_with("service:"))
297 .filter_map(|n| n.wg_port)
298 .collect();
299
300 Self {
301 deployment: String::new(),
302 instance_id: String::new(),
303 data_dir,
304 global_interface: None,
305 global_transport: None,
306 service_interfaces: HashMap::new(),
307 service_transports: HashMap::new(),
308 dedicated_ports: DedicatedPortAllocator::new(overlay_port, recorded_dedicated_ports),
309 #[cfg(target_os = "linux")]
310 service_bridges: HashMap::new(),
311 service_subnet_registry: None,
312 local_node_id: 0,
313 local_wg_pubkey: None,
314 transport_public_key: None,
315 ip_allocator: IpAllocator::new(default_cidr),
316 node_ip: None,
317 overlay_port,
318 cluster_cidr: Some(default_cidr),
319 slice_cidr: None,
320 #[cfg(target_os = "windows")]
321 hcn_cleanup: HashMap::new(),
322 #[cfg(target_os = "windows")]
323 service_ip_allocators: HashMap::new(),
324 #[cfg(target_os = "linux")]
325 attached: HashMap::new(),
326 global_peers: HashMap::new(),
327 guest_attachments: HashMap::new(),
328 dns_server_addr: None,
329 dns_domain: None,
330 dns_records: HashMap::new(),
331 nat_config: None,
332 uapi_sock_dir: None,
333 nat_traversal: None,
334 nat_last_refresh: AtomicU64::new(0),
335 shutdown_requested: false,
336 }
337 }
338
339 #[must_use]
342 pub fn with_uapi_sock_dir(mut self, dir: impl Into<PathBuf>) -> Self {
343 self.uapi_sock_dir = Some(dir.into());
344 self
345 }
346
347 #[must_use]
349 pub fn shutdown_requested(&self) -> bool {
350 self.shutdown_requested
351 }
352
353 #[must_use]
356 pub fn data_dir(&self) -> &Path {
357 &self.data_dir
358 }
359
360 pub async fn handle(&mut self, req: OverlaydRequest) -> OverlaydResponse {
366 match self.dispatch(req).await {
367 Ok(resp) => resp,
368 Err(e) => OverlaydResponse::Err {
369 message: e.to_string(),
370 },
371 }
372 }
373
374 #[allow(clippy::too_many_lines)]
375 async fn dispatch(&mut self, req: OverlaydRequest) -> Result<OverlaydResponse, OverlaydError> {
376 match req {
377 OverlaydRequest::SetLocalNodeId { node_id } => {
378 self.local_node_id = node_id;
379 Ok(OverlaydResponse::Ok)
380 }
381 OverlaydRequest::SetLocalWgPubkey { pubkey } => {
382 self.local_wg_pubkey = Some(pubkey);
383 Ok(OverlaydResponse::Ok)
384 }
385 OverlaydRequest::SetupGlobalOverlay {
386 deployment,
387 instance_id,
388 cluster_cidr,
389 slice_cidr,
390 wg_port,
391 nat_enabled,
392 } => {
393 let name = self
394 .setup_global_overlay(
395 deployment,
396 instance_id,
397 &cluster_cidr,
398 slice_cidr.as_deref(),
399 wg_port,
400 nat_enabled,
401 )
402 .await?;
403 Ok(OverlaydResponse::BridgeName { name })
404 }
405 OverlaydRequest::TeardownGlobalOverlay => {
406 self.teardown_global_overlay();
407 Ok(OverlaydResponse::Ok)
408 }
409 OverlaydRequest::SetupServiceOverlay { service, mode } => {
410 let info = self.setup_service_overlay(&service, mode).await?;
411 Ok(OverlaydResponse::ServiceOverlay(info))
412 }
413 OverlaydRequest::TeardownServiceOverlay { service } => {
414 self.teardown_service_overlay(&service).await;
415 Ok(OverlaydResponse::Ok)
416 }
417 OverlaydRequest::AllocateIp {
418 service,
419 join_global,
420 } => {
421 let ip = self.allocate_ip(&service, join_global)?;
422 Ok(OverlaydResponse::Ip { ip })
423 }
424 OverlaydRequest::ReleaseIp { ip } => {
425 self.release_ip(ip);
426 Ok(OverlaydResponse::Ok)
427 }
428 OverlaydRequest::AttachContainer {
429 handle,
430 service,
431 join_global,
432 dns_server,
433 dns_domain,
434 } => {
435 if let AttachHandle::GuestManaged { id } = handle {
441 if let Some(server) = dns_server {
445 self.dns_server_addr = Some(SocketAddr::new(server, 53));
446 }
447 if dns_domain.is_some() {
448 self.dns_domain.clone_from(&dns_domain);
449 }
450 let config = self
451 .attach_container_guest(&id, &service, join_global, dns_server, dns_domain)
452 .await?;
453 Ok(OverlaydResponse::GuestConfig(config))
454 } else {
455 let result = self
456 .attach_container(handle, &service, join_global, dns_server, dns_domain)
457 .await?;
458 Ok(OverlaydResponse::Attached(result))
459 }
460 }
461 OverlaydRequest::DetachContainer { handle } => {
462 if let AttachHandle::GuestManaged { id } = handle {
463 self.detach_container_guest(&id).await?;
464 } else {
465 self.detach_container(handle).await?;
466 }
467 Ok(OverlaydResponse::Ok)
468 }
469 OverlaydRequest::AddPeer { peer, scope } => {
473 let info = peer_spec_to_info(&peer)?;
474 let transport = self.transport_for_scope(&scope)?;
475 Self::add_peer_on(transport, &info).await?;
476 if matches!(scope, PeerScope::Global) {
479 self.global_peers.insert(peer.public_key.clone(), peer);
480 }
481 Ok(OverlaydResponse::Ok)
482 }
483 OverlaydRequest::RemovePeer { pubkey, scope } => {
484 let transport = self.transport_for_scope(&scope)?;
485 Self::remove_peer_on(transport, &pubkey).await?;
486 if matches!(scope, PeerScope::Global) {
487 self.global_peers.remove(&pubkey);
488 }
489 Ok(OverlaydResponse::Ok)
490 }
491 OverlaydRequest::AddAllowedIp {
492 pubkey,
493 cidr,
494 scope,
495 } => {
496 let transport = self.transport_for_scope(&scope)?;
497 Self::add_allowed_ip_on(transport, &pubkey, &cidr).await?;
498 Ok(OverlaydResponse::Ok)
499 }
500 OverlaydRequest::RemoveAllowedIp {
501 pubkey,
502 cidr,
503 scope,
504 } => {
505 let transport = self.transport_for_scope(&scope)?;
506 Self::remove_allowed_ip_on(transport, &pubkey, &cidr).await?;
507 Ok(OverlaydResponse::Ok)
508 }
509 OverlaydRequest::RegisterDns { name, ip } => {
510 self.register_dns(name, ip);
511 Ok(OverlaydResponse::Ok)
512 }
513 OverlaydRequest::UnregisterDns { name } => {
514 self.unregister_dns(&name);
515 Ok(OverlaydResponse::Ok)
516 }
517 OverlaydRequest::Status => Ok(OverlaydResponse::Status(self.status_snapshot().await)),
518 OverlaydRequest::NatTick => {
519 self.nat_maintenance_tick().await?;
520 Ok(OverlaydResponse::Ok)
521 }
522 OverlaydRequest::Shutdown => {
523 self.shutdown_requested = true;
524 self.teardown_global_overlay();
525 Ok(OverlaydResponse::Ok)
526 }
527 }
528 }
529
530 async fn setup_global_overlay(
542 &mut self,
543 deployment: String,
544 instance_id: String,
545 cluster_cidr: &str,
546 slice_cidr: Option<&str>,
547 wg_port: u16,
548 nat_enabled: bool,
549 ) -> Result<String, OverlaydError> {
550 self.deployment = deployment;
551 self.instance_id = instance_id;
552 self.overlay_port = wg_port;
553
554 let cluster: IpNetwork = cluster_cidr.parse().map_err(|e| {
555 OverlaydError::Other(format!("invalid cluster CIDR {cluster_cidr}: {e}"))
556 })?;
557 self.cluster_cidr = Some(cluster);
558 if let Some(slice) = slice_cidr {
559 let slice_net: IpNetwork = slice
560 .parse()
561 .map_err(|e| OverlaydError::Other(format!("invalid slice CIDR {slice}: {e}")))?;
562 self.slice_cidr = Some(slice_net);
563 self.ip_allocator = IpAllocator::new(slice_net);
564 }
565 if !nat_enabled {
568 self.nat_config = Some(NatConfig {
569 enabled: false,
570 ..NatConfig::default()
571 });
572 }
573
574 if let Some(name) = self.global_interface.clone() {
575 if self.global_transport.is_some() {
576 tracing::debug!(
577 deployment = %self.deployment,
578 "Global overlay already active, reusing existing transport"
579 );
580 return Ok(name);
581 }
582 }
583
584 let interface_name = make_interface_name(&[&self.deployment, &self.instance_id], "g");
585
586 let (private_key, public_key) = OverlayTransport::generate_keys()
587 .await
588 .map_err(|e| OverlaydError::Overlay(format!("Failed to generate keys: {e}")))?;
589
590 let node_ip = self.ip_allocator.allocate()?;
591 self.transport_public_key = Some(public_key.clone());
592 let physical_egress_ip = match zlayer_overlay::detect_physical_egress().await {
593 Ok(egress) => Some(egress.ip),
594 Err(e) => {
595 tracing::warn!(
596 error = %e,
597 "failed to detect physical egress; WireGuard local_endpoint \
598 will bind UNSPECIFIED for the global overlay"
599 );
600 None
601 }
602 };
603 let config = self.build_config(
604 private_key,
605 public_key,
606 node_ip,
607 16,
608 self.overlay_port,
609 physical_egress_ip,
610 );
611 let mut transport = OverlayTransport::new(config, interface_name);
612
613 transport
614 .create_interface()
615 .await
616 .map_err(|e| OverlaydError::Overlay(format!("Failed to create global overlay: {e}")))?;
617 transport.configure(&[]).await.map_err(|e| {
618 OverlaydError::Overlay(format!("Failed to configure global overlay: {e}"))
619 })?;
620
621 let actual_name = transport.interface_name().to_string();
623
624 self.node_ip = Some(node_ip);
625 self.global_interface = Some(actual_name.clone());
626 self.global_transport = Some(transport);
627 Ok(actual_name)
628 }
629
630 fn teardown_global_overlay(&mut self) {
632 if let Some(mut transport) = self.global_transport.take() {
633 tracing::info!("Shutting down global overlay");
634 transport.shutdown();
635 }
636 self.global_interface = None;
637 self.transport_public_key = None;
638 }
639
640 #[cfg(target_os = "linux")]
651 async fn setup_service_overlay(
652 &mut self,
653 service: &str,
654 mode: OverlayMode,
655 ) -> Result<ServiceOverlayInfo, OverlaydError> {
656 match mode.resolve() {
657 OverlayMode::Shared => self.setup_service_overlay_shared(service).await,
658 OverlayMode::Dedicated => self.setup_service_overlay_dedicated(service).await,
659 OverlayMode::Auto => unreachable!("OverlayMode::resolve never returns Auto"),
660 }
661 }
662
663 #[cfg(target_os = "linux")]
675 #[allow(clippy::too_many_lines)]
676 async fn setup_service_overlay_shared(
677 &mut self,
678 service: &str,
679 ) -> Result<ServiceOverlayInfo, OverlaydError> {
680 if let Some(existing) = self.service_bridges.get(service) {
682 let name = existing.name.clone();
683 tracing::debug!(service = %service, bridge = %name, "Service bridge already active, reusing");
684 return Ok(shared_overlay_info(name));
685 }
686
687 self.ensure_service_subnet_registry()?;
689 let subnet: ipnet::IpNet = {
690 let registry = self
691 .service_subnet_registry
692 .as_mut()
693 .expect("ensure_service_subnet_registry leaves Some");
694 let node_key = self.local_node_id.to_string();
695 registry.assign(service, &node_key).map_err(|e| {
696 OverlaydError::Overlay(format!(
697 "ServiceSubnetRegistry::assign({service}, {node_key}) failed: {e}"
698 ))
699 })?
700 };
701
702 let bridge_name = self.create_service_bridge(service, subnet).await?;
705
706 if let Some(ref cluster) = self.global_transport {
710 if let Some(ref pubkey) = self.local_wg_pubkey {
711 if let Err(e) = cluster.add_allowed_ip(pubkey, subnet).await {
712 tracing::warn!(
713 service = %service,
714 subnet = %subnet,
715 error = %e,
716 "Failed to add service subnet to cluster transport AllowedIPs (non-fatal)"
717 );
718 }
719 } else {
720 tracing::debug!(service = %service, "local_wg_pubkey not yet set; skipping cluster AllowedIPs update");
721 }
722 }
723
724 Ok(shared_overlay_info(bridge_name))
725 }
726
727 #[cfg(target_os = "linux")]
742 async fn create_service_bridge(
743 &mut self,
744 service: &str,
745 subnet: ipnet::IpNet,
746 ) -> Result<String, OverlaydError> {
747 use zlayer_overlay::allocator::IpAllocator as OverlayIpAllocator;
748
749 let bridge_name = make_interface_name(&[&self.deployment, &self.instance_id, service], "b");
750
751 if let Err(e) = crate::netlink::create_bridge(&bridge_name).await {
752 return Err(OverlaydError::Overlay(format!(
753 "create_bridge({bridge_name}) failed: {e}"
754 )));
755 }
756 if let Err(e) = crate::netlink::set_bridge_stp(&bridge_name, false) {
757 tracing::warn!(bridge = %bridge_name, error = %e, "set_bridge_stp(off) failed (non-fatal)");
758 }
759
760 let gateway = first_usable_ip(subnet);
762 if let Err(e) =
763 crate::netlink::add_address_to_link_by_name(&bridge_name, gateway, subnet.prefix_len())
764 .await
765 {
766 let _ = crate::netlink::delete_bridge(&bridge_name).await;
767 return Err(OverlaydError::Overlay(format!(
768 "add_address_to_link_by_name({bridge_name}, {gateway}/{}) failed: {e}",
769 subnet.prefix_len()
770 )));
771 }
772 if let Err(e) = crate::netlink::set_link_up_by_name(&bridge_name).await {
773 let _ = crate::netlink::delete_bridge(&bridge_name).await;
774 return Err(OverlaydError::Overlay(format!(
775 "set_link_up_by_name({bridge_name}) failed: {e}"
776 )));
777 }
778
779 let mut ip_allocator = OverlayIpAllocator::new(&subnet.to_string()).map_err(|e| {
781 OverlaydError::Overlay(format!("IpAllocator::new({subnet}) failed: {e}"))
782 })?;
783 let _ = ip_allocator.allocate_specific(gateway);
784
785 self.service_bridges.insert(
786 service.to_string(),
787 ServiceBridge {
788 name: bridge_name.clone(),
789 subnet,
790 gateway,
791 ip_allocator,
792 },
793 );
794 self.service_interfaces
795 .insert(service.to_string(), bridge_name.clone());
796
797 tracing::info!(service = %service, bridge = %bridge_name, subnet = %subnet, gateway = %gateway, "Service bridge created");
798 Ok(bridge_name)
799 }
800
801 #[cfg(not(target_os = "linux"))]
809 async fn setup_service_overlay(
810 &mut self,
811 service: &str,
812 mode: OverlayMode,
813 ) -> Result<ServiceOverlayInfo, OverlaydError> {
814 match mode.resolve() {
815 OverlayMode::Shared => self.setup_service_overlay_shared(service).await,
816 OverlayMode::Dedicated => self.setup_service_overlay_dedicated(service).await,
817 OverlayMode::Auto => unreachable!("OverlayMode::resolve never returns Auto"),
818 }
819 }
820
821 #[cfg(not(target_os = "linux"))]
829 #[allow(clippy::unused_async)]
830 async fn setup_service_overlay_shared(
831 &mut self,
832 service: &str,
833 ) -> Result<ServiceOverlayInfo, OverlaydError> {
834 let placeholder = make_interface_name(&[&self.deployment, &self.instance_id, service], "b");
835 self.service_interfaces
836 .insert(service.to_string(), placeholder.clone());
837 tracing::debug!(service = %service, "Service overlay bridge setup is Linux-only; using direct networking placeholder");
838 Ok(shared_overlay_info(placeholder))
839 }
840
841 #[allow(clippy::too_many_lines)]
858 async fn setup_service_overlay_dedicated(
859 &mut self,
860 service: &str,
861 ) -> Result<ServiceOverlayInfo, OverlaydError> {
862 if let Some(st) = self.service_transports.get(service) {
866 return Ok(dedicated_overlay_info(
867 st.interface.clone(),
868 &st.public_key,
869 st.listen_port,
870 st.overlay_ip,
871 st.subnet,
872 ));
873 }
874
875 let marker_path =
879 zlayer_paths::ZLayerDirs::new(self.data_dir.clone()).agent_network_state();
880 let recorded = NetworkState::load(&marker_path)
881 .get(&owner_for_service(service))
882 .cloned();
883
884 let (private_key, public_key, listen_port, iface_hint) = match recorded.as_ref() {
885 Some(entry)
886 if entry.wg_private_key.is_some()
887 && entry.wg_public_key.is_some()
888 && entry.wg_port.is_some()
889 && entry.interface.is_some() =>
890 {
891 let port = entry.wg_port.expect("checked above");
892 self.dedicated_ports.reserve(port);
893 (
894 entry.wg_private_key.clone().expect("checked above"),
895 entry.wg_public_key.clone().expect("checked above"),
896 port,
897 entry.interface.clone().expect("checked above"),
898 )
899 }
900 _ => {
901 let port = self.dedicated_ports.allocate()?;
902 let (priv_key, pub_key) = OverlayTransport::generate_keys()
903 .await
904 .map_err(|e| OverlaydError::Overlay(format!("Failed to generate keys: {e}")))?;
905 let iface =
906 make_interface_name(&[&self.deployment, &self.instance_id, service], "d");
907 (priv_key, pub_key, port, iface)
908 }
909 };
910
911 self.ensure_service_subnet_registry()?;
914 let subnet: ipnet::IpNet = {
915 let registry = self
916 .service_subnet_registry
917 .as_mut()
918 .expect("ensure_service_subnet_registry leaves Some");
919 let node_key = self.local_node_id.to_string();
920 registry.assign(service, &node_key).map_err(|e| {
921 OverlaydError::Overlay(format!(
922 "ServiceSubnetRegistry::assign({service}, {node_key}) failed: {e}"
923 ))
924 })?
925 };
926 let overlay_ip = first_usable_ip(subnet);
927
928 let physical_egress_ip = match zlayer_overlay::detect_physical_egress().await {
932 Ok(egress) => Some(egress.ip),
933 Err(e) => {
934 tracing::warn!(
935 error = %e,
936 service = %service,
937 "failed to detect physical egress; WireGuard local_endpoint \
938 will bind UNSPECIFIED for the dedicated overlay"
939 );
940 None
941 }
942 };
943 let config = self.build_config(
944 private_key.clone(),
945 public_key.clone(),
946 overlay_ip,
947 subnet.prefix_len(),
948 listen_port,
949 physical_egress_ip,
950 );
951 let mut transport = OverlayTransport::new(config, iface_hint);
952 transport.create_interface().await.map_err(|e| {
953 OverlaydError::Overlay(format!(
954 "Failed to create dedicated overlay for {service}: {e}"
955 ))
956 })?;
957 transport.configure(&[]).await.map_err(|e| {
958 OverlaydError::Overlay(format!(
959 "Failed to configure dedicated overlay for {service}: {e}"
960 ))
961 })?;
962 let actual_iface = transport.interface_name().to_string();
963
964 let mut marker = NetworkState::load(&marker_path);
968 marker.upsert(ManagedNetwork {
969 owner: owner_for_service(service),
970 kind: "wg-dedicated".to_string(),
971 name: actual_iface.clone(),
972 id: public_key.clone(),
973 subnet: subnet.to_string(),
974 wg_port: Some(listen_port),
975 wg_private_key: Some(private_key),
976 wg_public_key: Some(public_key.clone()),
977 interface: Some(actual_iface.clone()),
978 });
979 if let Err(e) = marker.save(&marker_path) {
980 tracing::warn!(service = %service, error = %e, path = %marker_path.display(), "failed to persist dedicated-overlay marker (device still live)");
981 }
982
983 self.service_transports.insert(
985 service.to_string(),
986 ServiceTransport {
987 transport,
988 interface: actual_iface.clone(),
989 public_key: public_key.clone(),
990 listen_port,
991 overlay_ip,
992 subnet,
993 },
994 );
995
996 tracing::info!(
997 service = %service,
998 interface = %actual_iface,
999 listen_port,
1000 subnet = %subnet,
1001 overlay_ip = %overlay_ip,
1002 "Dedicated per-service overlay device created"
1003 );
1004
1005 let name = self
1009 .attach_dedicated_service(service, subnet, overlay_ip)
1010 .await?;
1011
1012 Ok(dedicated_overlay_info(
1013 name,
1014 &public_key,
1015 listen_port,
1016 overlay_ip,
1017 subnet,
1018 ))
1019 }
1020
1021 #[cfg(target_os = "linux")]
1034 async fn attach_dedicated_service(
1035 &mut self,
1036 service: &str,
1037 subnet: ipnet::IpNet,
1038 overlay_ip: IpAddr,
1039 ) -> Result<String, OverlaydError> {
1040 let _ = overlay_ip;
1041 let bridge_name = self.create_service_bridge(service, subnet).await?;
1042
1043 if let Some(st) = self.service_transports.get(service) {
1048 if let Some(ref pubkey) = self.local_wg_pubkey {
1049 if let Err(e) = st.transport.add_allowed_ip(pubkey, subnet).await {
1050 tracing::warn!(
1051 service = %service,
1052 subnet = %subnet,
1053 error = %e,
1054 "Failed to add service subnet to dedicated transport AllowedIPs (non-fatal)"
1055 );
1056 }
1057 } else {
1058 tracing::debug!(service = %service, "local_wg_pubkey not yet set; skipping dedicated AllowedIPs update");
1059 }
1060 }
1061
1062 Ok(bridge_name)
1063 }
1064
1065 #[cfg(target_os = "windows")]
1078 async fn attach_dedicated_service(
1079 &mut self,
1080 service: &str,
1081 subnet: ipnet::IpNet,
1082 _overlay_ip: IpAddr,
1083 ) -> Result<String, OverlaydError> {
1084 let _net_id = self.ensure_service_network(service, subnet).await?;
1088 let daemon_name = self.deployment_or_default();
1090 Ok(format!(
1091 "{}-svc-{service}",
1092 overlay_network_name(&daemon_name)
1093 ))
1094 }
1095
1096 #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
1100 #[allow(clippy::unused_async)]
1101 async fn attach_dedicated_service(
1102 &mut self,
1103 service: &str,
1104 _subnet: ipnet::IpNet,
1105 _overlay_ip: IpAddr,
1106 ) -> Result<String, OverlaydError> {
1107 let iface = self
1108 .service_transports
1109 .get(service)
1110 .map(|st| st.interface.clone())
1111 .unwrap_or_default();
1112 Ok(iface)
1113 }
1114
1115 #[cfg_attr(not(target_os = "linux"), allow(clippy::unused_async))]
1120 async fn teardown_service_overlay(&mut self, service: &str) {
1121 #[cfg(target_os = "linux")]
1123 {
1124 let removed = self.service_bridges.remove(service);
1125 self.service_interfaces.remove(service);
1126 if let Some(bridge) = removed {
1127 if let Some(ref cluster) = self.global_transport {
1128 if let Some(ref pubkey) = self.local_wg_pubkey {
1129 if let Err(e) = cluster.remove_allowed_ip(pubkey, bridge.subnet).await {
1130 tracing::warn!(
1131 service = %service,
1132 subnet = %bridge.subnet,
1133 error = %e,
1134 "Failed to remove service subnet from cluster AllowedIPs (non-fatal)"
1135 );
1136 }
1137 }
1138 }
1139
1140 if let Err(e) = crate::netlink::delete_bridge(&bridge.name).await {
1141 tracing::warn!(service = %service, bridge = %bridge.name, error = %e, "delete_bridge failed (non-fatal)");
1142 }
1143
1144 if let Some(registry) = self.service_subnet_registry.as_mut() {
1145 let node_key = self.local_node_id.to_string();
1146 let _ = registry.release(service, &node_key);
1147 }
1148
1149 tracing::info!(service = %service, bridge = %bridge.name, "Tore down service bridge");
1150 }
1151 }
1152 #[cfg(not(target_os = "linux"))]
1153 {
1154 if let Some(iface) = self.service_interfaces.remove(service) {
1155 tracing::info!(service = %service, interface = %iface, "Removed service overlay interface (placeholder, non-Linux)");
1156 }
1157 }
1158
1159 if let Some(mut st) = self.service_transports.remove(service) {
1163 st.transport.shutdown();
1164 self.dedicated_ports.release(st.listen_port);
1165
1166 if let Some(registry) = self.service_subnet_registry.as_mut() {
1170 let node_key = self.local_node_id.to_string();
1171 let _ = registry.release(service, &node_key);
1172 }
1173
1174 let marker_path =
1175 zlayer_paths::ZLayerDirs::new(self.data_dir.clone()).agent_network_state();
1176 let mut marker = NetworkState::load(&marker_path);
1177 let removed_entry = marker.remove(&owner_for_service(service));
1178 if removed_entry.is_some() {
1179 if let Err(e) = marker.save(&marker_path) {
1180 tracing::warn!(service = %service, error = %e, path = %marker_path.display(), "failed to persist dedicated-overlay marker removal");
1181 }
1182 }
1183
1184 #[cfg(target_os = "windows")]
1190 {
1191 self.service_ip_allocators.remove(service);
1192 if let Some(entry) = removed_entry.as_ref() {
1193 if entry.kind == "hcn-internal" {
1194 if let Ok(guid) = windows::core::GUID::try_from(entry.id.as_str()) {
1195 match zlayer_hns::network::Network::delete(guid) {
1196 Ok(()) => {
1197 tracing::info!(service = %service, id = %entry.id, "deleted per-service HCN network");
1198 }
1199 Err(e) => {
1200 tracing::warn!(service = %service, id = %entry.id, error = %e, "failed to delete per-service HCN network (may leak until uninstall)");
1201 }
1202 }
1203 } else {
1204 tracing::warn!(service = %service, id = %entry.id, "per-service marker has unparseable HCN GUID; skipping network delete");
1205 }
1206 }
1207 }
1208 }
1209 #[cfg(not(target_os = "windows"))]
1210 drop(removed_entry);
1211
1212 tracing::info!(
1213 service = %service,
1214 interface = %st.interface,
1215 listen_port = st.listen_port,
1216 "Tore down dedicated per-service overlay device"
1217 );
1218 }
1219 }
1220
1221 fn ensure_service_subnet_registry(&mut self) -> Result<(), OverlaydError> {
1228 use zlayer_overlay::allocator::ServiceSubnetRegistry;
1229
1230 if self.service_subnet_registry.is_some() {
1231 return Ok(());
1232 }
1233 let cluster_cidr = self.cluster_cidr.ok_or_else(|| {
1234 OverlaydError::Other(
1235 "service subnet registry needs a cluster CIDR (SetupGlobalOverlay first)"
1236 .to_string(),
1237 )
1238 })?;
1239 let cluster_ipnet: ipnet::IpNet = cluster_cidr.to_string().parse().map_err(|e| {
1240 OverlaydError::Other(format!(
1241 "failed to convert cluster CIDR {cluster_cidr} to ipnet::IpNet: {e}"
1242 ))
1243 })?;
1244 let slice_prefix: u8 = match cluster_ipnet {
1245 ipnet::IpNet::V4(_) => 28,
1246 ipnet::IpNet::V6(_) => 120,
1247 };
1248 let registry = ServiceSubnetRegistry::new(cluster_ipnet, slice_prefix).map_err(|e| {
1249 OverlaydError::Other(format!("failed to build ServiceSubnetRegistry: {e}"))
1250 })?;
1251 self.service_subnet_registry = Some(registry);
1252 Ok(())
1253 }
1254
1255 fn allocate_ip(&mut self, service: &str, join_global: bool) -> Result<IpAddr, OverlaydError> {
1264 let _ = join_global;
1268 #[cfg(target_os = "linux")]
1269 {
1270 if let Some(bridge) = self.service_bridges.get_mut(service) {
1271 return bridge.ip_allocator.allocate().ok_or_else(|| {
1272 OverlaydError::Overlay(format!(
1273 "service bridge {} subnet {} exhausted",
1274 bridge.name, bridge.subnet
1275 ))
1276 });
1277 }
1278 }
1279 let _ = service;
1280 self.ip_allocator.allocate()
1281 }
1282
1283 fn release_ip(&mut self, ip: IpAddr) {
1286 #[cfg(target_os = "linux")]
1287 {
1288 for bridge in self.service_bridges.values_mut() {
1289 if bridge.subnet.contains(&ip) {
1290 bridge.ip_allocator.release(ip);
1291 return;
1292 }
1293 }
1294 }
1295 self.ip_allocator.release(ip);
1296 }
1297
1298 async fn attach_container(
1305 &mut self,
1306 handle: AttachHandle,
1307 service: &str,
1308 join_global: bool,
1309 dns_server: Option<IpAddr>,
1310 dns_domain: Option<String>,
1311 ) -> Result<AttachResult, OverlaydError> {
1312 if let Some(server) = dns_server {
1316 self.dns_server_addr = Some(SocketAddr::new(server, 53));
1317 }
1318 if dns_domain.is_some() {
1319 self.dns_domain.clone_from(&dns_domain);
1320 }
1321 match handle {
1322 AttachHandle::LinuxPid { pid } => {
1323 let ip = self
1324 .attach_container_linux(pid, service, join_global)
1325 .await?;
1326 Ok(AttachResult {
1327 ip,
1328 namespace_guid: None,
1329 })
1330 }
1331 AttachHandle::WindowsContainer { container_id, ip } => {
1332 self.attach_container_windows(&container_id, service, ip, dns_server, dns_domain)
1333 .await
1334 }
1335 AttachHandle::GuestManaged { .. } => Err(OverlaydError::Other(
1336 "guest-managed attach must go through attach_container_guest, not attach_container"
1337 .to_string(),
1338 )),
1339 }
1340 }
1341
1342 async fn detach_container(&mut self, handle: AttachHandle) -> Result<(), OverlaydError> {
1348 match handle {
1349 AttachHandle::LinuxPid { pid } => self.detach_container_linux(pid).await,
1350 AttachHandle::WindowsContainer { container_id, .. } => {
1351 self.detach_container_windows(&container_id).await
1352 }
1353 AttachHandle::GuestManaged { .. } => Err(OverlaydError::Other(
1354 "guest-managed detach must go through detach_container_guest, not detach_container"
1355 .to_string(),
1356 )),
1357 }
1358 }
1359
1360 #[allow(clippy::cast_possible_truncation)]
1389 async fn attach_container_guest(
1390 &mut self,
1391 id: &str,
1392 service: &str,
1393 join_global: bool,
1394 dns_server: Option<IpAddr>,
1395 dns_domain: Option<String>,
1396 ) -> Result<GuestOverlayConfig, OverlaydError> {
1397 let node_public_key = self.transport_public_key.clone().ok_or_else(|| {
1401 OverlaydError::Other(
1402 "guest-managed attach requires the global overlay to be set up first \
1403 (no node WireGuard public key)"
1404 .to_string(),
1405 )
1406 })?;
1407 if self.global_transport.is_none() {
1408 return Err(OverlaydError::Other(
1409 "guest-managed attach requires the global overlay to be set up first \
1410 (no global transport)"
1411 .to_string(),
1412 ));
1413 }
1414
1415 let (overlay_ip, prefix_len, pool_service): (IpAddr, u8, Option<String>) = {
1421 #[cfg(target_os = "linux")]
1422 {
1423 if let Some(bridge) = self.service_bridges.get_mut(service) {
1424 let ip = bridge.ip_allocator.allocate().ok_or_else(|| {
1425 OverlaydError::Overlay(format!(
1426 "service bridge {} subnet {} exhausted",
1427 bridge.name, bridge.subnet
1428 ))
1429 })?;
1430 let prefix = bridge.subnet.prefix_len();
1431 (ip, prefix, Some(service.to_string()))
1432 } else {
1433 let ip = self.ip_allocator.allocate()?;
1434 (ip, self.slice_prefix_len(), None)
1435 }
1436 }
1437 #[cfg(not(target_os = "linux"))]
1438 {
1439 let _ = service;
1440 let ip = self.ip_allocator.allocate()?;
1441 (ip, self.slice_prefix_len(), None)
1442 }
1443 };
1444 let _ = join_global;
1449
1450 let (private_key, public_key) = OverlayTransport::generate_keys().await.map_err(|e| {
1453 self.release_guest_ip(overlay_ip, pool_service.as_deref());
1455 OverlaydError::Overlay(format!("failed to generate guest keys: {e}"))
1456 })?;
1457
1458 let node_allowed = self
1473 .cluster_cidr
1474 .or(self.slice_cidr)
1475 .map_or_else(|| String::from("0.0.0.0/0"), |c| c.to_string());
1476 let node_endpoint = self.node_endpoint_for_guest();
1477 let peers: Vec<PeerSpec> = vec![PeerSpec {
1478 public_key: node_public_key,
1479 endpoint: node_endpoint,
1480 allowed_ips: node_allowed,
1481 persistent_keepalive_secs: 25,
1482 }];
1483
1484 let host_route = format!(
1488 "{}/{}",
1489 overlay_ip,
1490 if overlay_ip.is_ipv6() { 128 } else { 32 }
1491 );
1492 let guest_peer = PeerSpec {
1493 public_key: public_key.clone(),
1494 endpoint: "0.0.0.0:0".to_string(),
1499 allowed_ips: host_route,
1500 persistent_keepalive_secs: 0,
1501 };
1502 let guest_peer_info = peer_spec_to_info(&guest_peer)?;
1503 {
1504 let transport = self.transport_for_scope(&PeerScope::Global)?;
1505 if let Err(e) = Self::add_peer_on(transport, &guest_peer_info).await {
1506 self.release_guest_ip(overlay_ip, pool_service.as_deref());
1507 return Err(e);
1508 }
1509 }
1510 self.global_peers
1513 .insert(public_key.clone(), guest_peer.clone());
1514 self.guest_attachments.insert(
1515 id.to_string(),
1516 GuestAttachInfo {
1517 overlay_ip,
1518 public_key: public_key.clone(),
1519 service_name: pool_service,
1520 },
1521 );
1522
1523 Ok(GuestOverlayConfig {
1525 overlay_ip,
1526 prefix_len,
1527 private_key,
1528 public_key,
1529 listen_port: self.overlay_port,
1532 peers,
1533 dns_server: dns_server.or_else(|| self.dns_server_addr.map(|s| s.ip())),
1534 dns_domain: dns_domain.or_else(|| self.dns_domain.clone()),
1535 })
1536 }
1537
1538 async fn detach_container_guest(&mut self, id: &str) -> Result<(), OverlaydError> {
1545 let Some(info) = self.guest_attachments.remove(id) else {
1546 return Ok(());
1547 };
1548 self.global_peers.remove(&info.public_key);
1550 if let Ok(transport) = self.transport_for_scope(&PeerScope::Global) {
1551 if let Err(e) = Self::remove_peer_on(transport, &info.public_key).await {
1552 tracing::warn!(
1553 guest = %id,
1554 pubkey = %info.public_key,
1555 error = %e,
1556 "failed to remove guest peer from global transport"
1557 );
1558 }
1559 }
1560 self.release_guest_ip(info.overlay_ip, info.service_name.as_deref());
1562 Ok(())
1563 }
1564
1565 fn release_guest_ip(&mut self, ip: IpAddr, service: Option<&str>) {
1569 #[cfg(target_os = "linux")]
1570 {
1571 if let Some(svc) = service {
1572 if let Some(bridge) = self.service_bridges.get_mut(svc) {
1573 bridge.ip_allocator.release(ip);
1574 return;
1575 }
1576 }
1577 }
1578 let _ = service;
1579 self.ip_allocator.release(ip);
1580 }
1581
1582 fn slice_prefix_len(&self) -> u8 {
1585 self.slice_cidr.or(self.cluster_cidr).map_or(
1586 if self.node_ip.is_some_and(|ip| ip.is_ipv6()) {
1587 64
1588 } else {
1589 24
1590 },
1591 |c| c.prefix(),
1592 )
1593 }
1594
1595 fn node_endpoint_for_guest(&self) -> String {
1602 let ip = self.node_ip.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
1603 SocketAddr::new(ip, self.overlay_port).to_string()
1604 }
1605
1606 #[cfg(target_os = "linux")]
1609 async fn attach_container_linux(
1610 &mut self,
1611 container_pid: u32,
1612 service: &str,
1613 join_global: bool,
1614 ) -> Result<IpAddr, OverlaydError> {
1615 let (bridge_name, bridge_subnet, bridge_gateway, container_ip) = {
1617 let bridge = self.service_bridges.get_mut(service).ok_or_else(|| {
1618 OverlaydError::Other(format!(
1619 "no service bridge for service {service}; call setup_service_overlay() first"
1620 ))
1621 })?;
1622 let ip = bridge.ip_allocator.allocate().ok_or_else(|| {
1623 OverlaydError::Overlay(format!(
1624 "service bridge {} subnet {} exhausted",
1625 bridge.name, bridge.subnet
1626 ))
1627 })?;
1628 (bridge.name.clone(), bridge.subnet, bridge.gateway, ip)
1629 };
1630
1631 let bridge_params = BridgeAttachParams {
1632 bridge_name: &bridge_name,
1633 gateway: bridge_gateway,
1634 subnet_prefix_len: bridge_subnet.prefix_len(),
1635 };
1636 if let Err(e) = self
1637 .attach_to_interface(
1638 container_pid,
1639 container_ip,
1640 "s",
1641 "eth0",
1642 Some(&bridge_params),
1643 )
1644 .await
1645 {
1646 if let Some(bridge) = self.service_bridges.get_mut(service) {
1647 bridge.ip_allocator.release(container_ip);
1648 }
1649 return Err(e);
1650 }
1651
1652 let mut global_ip: Option<IpAddr> = None;
1653 if join_global && self.global_interface.is_some() {
1654 let g_ip = self.ip_allocator.allocate()?;
1655 self.attach_to_interface(container_pid, g_ip, "g", "eth1", None)
1656 .await?;
1657 global_ip = Some(g_ip);
1658 }
1659
1660 self.attached.insert(
1661 container_pid,
1662 AttachInfo {
1663 service_ip: container_ip,
1664 service_name: Some(service.to_string()),
1665 global_ip,
1666 joined_global: global_ip.is_some(),
1667 },
1668 );
1669
1670 Ok(container_ip)
1671 }
1672
1673 #[cfg(not(target_os = "linux"))]
1676 #[allow(clippy::unused_async)]
1677 async fn attach_container_linux(
1678 &mut self,
1679 _container_pid: u32,
1680 service: &str,
1681 _join_global: bool,
1682 ) -> Result<IpAddr, OverlaydError> {
1683 tracing::debug!(service = %service, "LinuxPid attach is a no-op off Linux; using node overlay IP");
1684 Ok(self.node_ip.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)))
1685 }
1686
1687 #[cfg(target_os = "linux")]
1689 async fn detach_container_linux(&mut self, pid: u32) -> Result<(), OverlaydError> {
1690 let Some(info) = self.attached.remove(&pid) else {
1691 return Ok(());
1692 };
1693
1694 let veth_s = format!("veth-{pid}-s");
1695 if let Err(e) = crate::netlink::delete_link_by_name(&veth_s).await {
1696 tracing::warn!(link = %veth_s, pid, error = %e, "Failed to delete service veth");
1697 }
1698 if info.joined_global {
1699 let veth_g = format!("veth-{pid}-g");
1700 if let Err(e) = crate::netlink::delete_link_by_name(&veth_g).await {
1701 tracing::warn!(link = %veth_g, pid, error = %e, "Failed to delete global veth");
1702 }
1703 }
1704
1705 if let Some(svc) = info.service_name.as_deref() {
1706 if let Some(bridge) = self.service_bridges.get_mut(svc) {
1707 bridge.ip_allocator.release(info.service_ip);
1708 } else {
1709 tracing::debug!(service = %svc, ip = %info.service_ip, "detach: service bridge already torn down; dropping service IP release");
1710 }
1711 } else {
1712 self.ip_allocator.release(info.service_ip);
1713 }
1714 if let Some(g) = info.global_ip {
1715 self.ip_allocator.release(g);
1716 }
1717 Ok(())
1718 }
1719
1720 #[cfg(not(target_os = "linux"))]
1722 #[allow(clippy::unused_async)]
1723 async fn detach_container_linux(&mut self, _pid: u32) -> Result<(), OverlaydError> {
1724 Ok(())
1725 }
1726
1727 #[cfg(target_os = "linux")]
1731 async fn sweep_orphan_veths() {
1732 let links = match crate::netlink::list_all_links().await {
1733 Ok(links) => links,
1734 Err(e) => {
1735 tracing::warn!(error = %e, "Failed to list links for orphan sweep");
1736 return;
1737 }
1738 };
1739 for (_index, name) in links {
1740 let remainder = if let Some(r) = name.strip_prefix("veth-") {
1741 r
1742 } else if let Some(r) = name.strip_prefix("vc-") {
1743 r
1744 } else {
1745 continue;
1746 };
1747 let Some(pid_str) = remainder.split('-').next() else {
1748 continue;
1749 };
1750 let pid: u32 = match pid_str.parse() {
1751 Ok(p) => p,
1752 Err(_) => continue,
1753 };
1754 if Path::new(&format!("/proc/{pid}")).exists() {
1755 continue;
1756 }
1757 tracing::info!(link = %name, pid = pid, "Deleting orphan veth");
1758 if let Err(e) = crate::netlink::delete_link_by_name(&name).await {
1759 tracing::warn!(link = %name, error = %e, "Failed to delete orphan veth");
1760 }
1761 }
1762 }
1763
1764 #[cfg(target_os = "linux")]
1765 #[allow(clippy::too_many_lines)]
1766 async fn attach_to_interface(
1767 &self,
1768 container_pid: u32,
1769 ip: IpAddr,
1770 tag: &str,
1771 container_iface: &str,
1772 bridge: Option<&BridgeAttachParams<'_>>,
1773 ) -> Result<(), OverlaydError> {
1774 Self::sweep_orphan_veths().await;
1776
1777 let is_v6 = ip.is_ipv6();
1778 let prefix_len: u8 = if let Some(b) = bridge {
1779 b.subnet_prefix_len
1780 } else if is_v6 {
1781 64
1782 } else {
1783 24
1784 };
1785 let host_prefix: u8 = if is_v6 { 128 } else { 32 };
1786
1787 let veth_host = format!("veth-{container_pid}-{tag}");
1788 let veth_pending = format!("vc-{container_pid}-{tag}");
1789 let veth_container = container_iface.to_string();
1790
1791 let container_ns_fd = std::os::fd::OwnedFd::from(
1792 std::fs::File::open(format!("/proc/{container_pid}/ns/net")).map_err(|e| {
1793 OverlaydError::Overlay(format!("Failed to open /proc/{container_pid}/ns/net: {e}"))
1794 })?,
1795 );
1796
1797 crate::netlink::delete_link_by_name(&veth_host)
1798 .await
1799 .map_err(|e| OverlaydError::Overlay(format!("pre-cleanup delete {veth_host}: {e}")))?;
1800 crate::netlink::delete_link_by_name(&veth_pending)
1801 .await
1802 .map_err(|e| {
1803 OverlaydError::Overlay(format!("pre-cleanup delete {veth_pending}: {e}"))
1804 })?;
1805
1806 let bridge_gateway: Option<IpAddr> = bridge.map(|b| b.gateway);
1807 let bridge_name: Option<String> = bridge.map(|b| b.bridge_name.to_string());
1808 let node_ip = self.node_ip;
1809
1810 let result: Result<(), OverlaydError> = async {
1811 crate::netlink::create_veth_pair(&veth_host, &veth_pending)
1812 .await
1813 .map_err(|e| OverlaydError::Overlay(format!("create veth pair: {e}")))?;
1814
1815 crate::netlink::move_link_into_netns_fd_and_rename(
1816 &veth_pending,
1817 AsFd::as_fd(&container_ns_fd),
1818 &veth_container,
1819 )
1820 .map_err(|e| OverlaydError::Overlay(format!("move veth into netns: {e}")))?;
1821
1822 let vc = veth_container.clone();
1823 let bridge_gateway_for_netns = bridge_gateway;
1824 tokio::task::spawn_blocking(move || {
1825 crate::netlink::with_netns_fd_async(container_ns_fd, move || async move {
1826 crate::netlink::add_address_to_link_by_name(&vc, ip, prefix_len).await?;
1827 crate::netlink::set_link_up_by_name(&vc).await?;
1828 crate::netlink::set_link_up_by_name("lo").await?;
1829 if let Some(gw) = bridge_gateway_for_netns {
1830 crate::netlink::add_default_route_via_gateway(gw).await?;
1831 }
1832 Ok(())
1833 })
1834 })
1835 .await
1836 .map_err(|e| OverlaydError::Overlay(format!("container netns task panicked: {e}")))?
1837 .map_err(|e| OverlaydError::Overlay(format!("container netns ops: {e}")))?;
1838
1839 crate::netlink::set_link_up_by_name(&veth_host)
1840 .await
1841 .map_err(|e| OverlaydError::Overlay(format!("set {veth_host} up: {e}")))?;
1842
1843 if let Some(bname) = bridge_name.as_deref() {
1844 crate::netlink::add_link_to_bridge(&veth_host, bname)
1845 .await
1846 .map_err(|e| {
1847 OverlaydError::Overlay(format!(
1848 "enslave {veth_host} to bridge {bname}: {e}"
1849 ))
1850 })?;
1851 } else {
1852 crate::netlink::replace_route_via_dev(ip, host_prefix, &veth_host, node_ip)
1853 .await
1854 .map_err(|e| {
1855 OverlaydError::Overlay(format!("host route for {ip}/{host_prefix}: {e}"))
1856 })?;
1857 }
1858
1859 let _ = crate::netlink::set_sysctl("net.ipv4.ip_forward", "1");
1860 let _ = crate::netlink::set_sysctl("net.ipv6.conf.all.forwarding", "1");
1861
1862 Ok(())
1863 }
1864 .await;
1865
1866 if result.is_err() {
1867 let _ = crate::netlink::delete_link_by_name(&veth_host).await;
1868 let _ = crate::netlink::delete_link_by_name(&veth_pending).await;
1869 }
1870 result
1871 }
1872
1873 #[cfg(target_os = "windows")]
1884 async fn attach_container_windows(
1885 &mut self,
1886 container_id: &str,
1887 service: &str,
1888 ip_override: Option<IpAddr>,
1889 dns_server: Option<IpAddr>,
1890 dns_domain: Option<String>,
1891 ) -> Result<AttachResult, OverlaydError> {
1892 let dedicated_subnet = self.dedicated_service_subnet(service);
1900
1901 let (net_id, ip, prefix_length) = if let Some(svc_subnet) = dedicated_subnet {
1902 let net_id = self.ensure_service_network(service, svc_subnet).await?;
1904
1905 let svc_ipnetwork: IpNetwork = svc_subnet.to_string().parse().map_err(|e| {
1912 OverlaydError::Other(format!("failed to parse service subnet {svc_subnet}: {e}"))
1913 })?;
1914 let allocator = self
1915 .service_ip_allocators
1916 .entry(service.to_string())
1917 .or_insert_with(|| IpAllocator::new(svc_ipnetwork));
1918 let ip = match ip_override {
1919 Some(ip) if svc_subnet.contains(&ip) => ip,
1920 Some(ip) => {
1921 return Err(OverlaydError::Other(format!(
1922 "overridden IP {ip} is not inside dedicated service subnet {svc_subnet} for service {service}"
1923 )));
1924 }
1925 None => allocator.allocate()?,
1926 };
1927 (net_id, ip, svc_subnet.prefix_len())
1928 } else {
1929 let slice = self.slice_cidr.ok_or_else(|| {
1931 OverlaydError::Other(
1932 "no node slice assigned yet (SetupGlobalOverlay with slice_cidr first)"
1933 .to_string(),
1934 )
1935 })?;
1936 let slice_ipnet: ipnet::IpNet = slice.to_string().parse().map_err(|e| {
1937 OverlaydError::Other(format!("failed to parse slice CIDR {slice}: {e}"))
1938 })?;
1939 let net_id = self.ensure_overlay_network(slice_ipnet).await?;
1940 let ip = match ip_override {
1941 Some(ip) => ip,
1942 None => self.ip_allocator.allocate()?,
1943 };
1944 (net_id, ip, slice_ipnet.prefix_len())
1945 };
1946
1947 let dns_server_eff = dns_server.or_else(|| self.dns_server_addr.map(|a| a.ip()));
1949 let dns_domain_for_attach = dns_domain.or_else(|| self.dns_domain.clone());
1950 let cluster_cidr = self.cluster_cidr.map(|c| c.to_string()).unwrap_or_default();
1951 let owner_tag = owner_tag(&self.deployment_or_default());
1952 let cid = container_id.to_string();
1953
1954 let attachment = tokio::task::spawn_blocking(move || {
1955 zlayer_hns::attach::EndpointAttachment::create_overlay(
1956 net_id,
1957 &owner_tag,
1958 cid.as_str(),
1959 ip,
1960 prefix_length,
1961 &cluster_cidr,
1962 dns_server_eff,
1963 dns_domain_for_attach.as_deref(),
1964 )
1965 })
1966 .await
1967 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?
1968 .map_err(|e| OverlaydError::Overlay(format!("HCN overlay endpoint attach failed: {e}")))?;
1969
1970 let namespace_id = attachment.namespace_id();
1971 let bare_guid = format_guid_bare(namespace_id);
1972
1973 self.hcn_cleanup
1975 .insert(namespace_id, (service.to_string(), ip));
1976
1977 tracing::info!(
1978 ns = %bare_guid,
1979 service = %service,
1980 ip = %ip,
1981 "Attached container to HCN overlay"
1982 );
1983
1984 Ok(AttachResult {
1985 ip,
1986 namespace_guid: Some(bare_guid),
1987 })
1988 }
1989
1990 #[cfg(not(target_os = "windows"))]
1992 #[allow(clippy::unused_async)]
1993 async fn attach_container_windows(
1994 &mut self,
1995 _container_id: &str,
1996 _service: &str,
1997 _ip_override: Option<IpAddr>,
1998 _dns_server: Option<IpAddr>,
1999 _dns_domain: Option<String>,
2000 ) -> Result<AttachResult, OverlaydError> {
2001 Err(OverlaydError::Other(
2002 "WindowsContainer attach is only supported on Windows".to_string(),
2003 ))
2004 }
2005
2006 #[cfg(target_os = "windows")]
2009 #[allow(clippy::unused_async)]
2010 async fn detach_container_windows(
2011 &mut self,
2012 namespace_guid: &str,
2013 ) -> Result<(), OverlaydError> {
2014 use windows::core::GUID;
2015
2016 let Ok(guid) = GUID::try_from(namespace_guid) else {
2017 tracing::warn!(ns = %namespace_guid, "detach: unparseable namespace GUID");
2018 return Ok(());
2019 };
2020 if let Some((service, ip)) = self.hcn_cleanup.remove(&guid) {
2021 self.ip_allocator.release(ip);
2022 tracing::info!(ns = %namespace_guid, service = %service, ip = %ip, "Released HCN overlay attachment");
2023 }
2024 Ok(())
2025 }
2026
2027 #[cfg(not(target_os = "windows"))]
2029 #[allow(clippy::unused_async)]
2030 async fn detach_container_windows(
2031 &mut self,
2032 _namespace_guid: &str,
2033 ) -> Result<(), OverlaydError> {
2034 Ok(())
2035 }
2036
2037 #[cfg(target_os = "windows")]
2045 #[allow(clippy::too_many_lines)]
2046 async fn ensure_overlay_network(
2047 &mut self,
2048 slice_cidr: ipnet::IpNet,
2049 ) -> Result<windows::core::GUID, OverlaydError> {
2050 use windows::core::GUID;
2051
2052 let daemon_name = self.deployment_or_default();
2053 let net_name = overlay_network_name(&daemon_name);
2054 let marker_path =
2055 zlayer_paths::ZLayerDirs::new(self.data_dir.clone()).agent_network_state();
2056
2057 if let Some(recorded_id) = crate::network_state::NetworkState::load(&marker_path)
2059 .get(crate::network_state::OWNER_BASE)
2060 .and_then(|entry| GUID::try_from(entry.id.as_str()).ok())
2061 {
2062 let reopened = tokio::task::spawn_blocking(move || {
2063 zlayer_hns::network::Network::open(recorded_id).ok()
2064 })
2065 .await
2066 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?;
2067 if reopened.is_some() {
2068 tracing::info!(name = %net_name, "reusing HCN overlay network from marker");
2069 return Ok(recorded_id);
2070 }
2071 }
2072
2073 let target_name = net_name.clone();
2075 let existing = tokio::task::spawn_blocking(move || -> Option<GUID> {
2076 let guids = zlayer_hns::network::list("{}").ok()?;
2077 for guid in guids {
2078 let Ok(network) = zlayer_hns::network::Network::open(guid) else {
2079 continue;
2080 };
2081 if matches!(network.query("{}"), Ok(props) if props.name == target_name) {
2082 return Some(guid);
2083 }
2084 }
2085 None
2086 })
2087 .await
2088 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?;
2089
2090 if let Some(existing_id) = existing {
2091 tracing::info!(name = %net_name, "reusing existing HCN overlay network");
2092 return Ok(existing_id);
2093 }
2094
2095 let net_id = GUID::new()
2096 .map_err(|e| OverlaydError::Other(format!("GUID::new for overlay network: {e}")))?;
2097 let subnet_str = slice_cidr.to_string();
2098
2099 let use_transparent = std::env::var(zlayer_hns::adapter::ZLAYER_UPLINK_ENV)
2104 .ok()
2105 .is_some_and(|v| !v.trim().is_empty());
2106
2107 let net_name_for_create = net_name.clone();
2108 let subnet_for_create = subnet_str.clone();
2109 if use_transparent {
2110 let uplink = zlayer_hns::adapter::find_primary_adapter()
2111 .map_err(|e| OverlaydError::Other(format!("find_primary_adapter: {e}")))?;
2112 tracing::warn!(uplink = %uplink, "ZLAYER_HCN_UPLINK_ADAPTER set: creating HCN *Transparent* overlay bound to a physical NIC");
2113 tokio::task::spawn_blocking(move || {
2114 zlayer_hns::network::Network::create_transparent(
2115 net_id,
2116 &net_name_for_create,
2117 &subnet_for_create,
2118 &uplink,
2119 )
2120 })
2121 .await
2122 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?
2123 .map_err(|e| {
2124 OverlaydError::Overlay(format!("HcnCreateNetwork transparent ({net_name}): {e}"))
2125 })?;
2126 } else {
2127 tokio::task::spawn_blocking(move || {
2128 zlayer_hns::network::Network::create_internal(
2129 net_id,
2130 &net_name_for_create,
2131 &subnet_for_create,
2132 )
2133 })
2134 .await
2135 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?
2136 .map_err(|e| {
2137 OverlaydError::Overlay(format!("HcnCreateNetwork internal ({net_name}): {e}"))
2138 })?;
2139 }
2140
2141 tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
2145
2146 tracing::info!(
2147 subnet = %subnet_str,
2148 mode = if use_transparent { "Transparent" } else { "Internal" },
2149 "created HCN overlay network"
2150 );
2151
2152 let mut marker = crate::network_state::NetworkState::load(&marker_path);
2155 marker.upsert(crate::network_state::ManagedNetwork {
2156 owner: crate::network_state::OWNER_BASE.to_string(),
2157 kind: if use_transparent {
2158 "hcn-transparent"
2159 } else {
2160 "hcn-internal"
2161 }
2162 .to_string(),
2163 name: net_name.clone(),
2164 id: format_guid_bare(net_id),
2165 subnet: subnet_str.clone(),
2166 wg_port: None,
2168 wg_private_key: None,
2169 wg_public_key: None,
2170 interface: None,
2171 });
2172 if let Err(e) = marker.save(&marker_path) {
2173 tracing::warn!(error = %e, path = %marker_path.display(), "failed to persist agent network marker (network still reusable by name)");
2174 }
2175
2176 Ok(net_id)
2177 }
2178
2179 #[cfg(target_os = "windows")]
2198 #[allow(clippy::too_many_lines)]
2199 async fn ensure_service_network(
2200 &mut self,
2201 service: &str,
2202 subnet: ipnet::IpNet,
2203 ) -> Result<windows::core::GUID, OverlaydError> {
2204 use windows::core::GUID;
2205
2206 let daemon_name = self.deployment_or_default();
2207 let net_name = format!("{}-svc-{service}", overlay_network_name(&daemon_name));
2210 let owner = owner_for_service(service);
2211 let marker_path =
2212 zlayer_paths::ZLayerDirs::new(self.data_dir.clone()).agent_network_state();
2213
2214 let recorded_hcn_id = crate::network_state::NetworkState::load(&marker_path)
2220 .get(&owner)
2221 .filter(|entry| entry.kind == "hcn-internal")
2222 .and_then(|entry| GUID::try_from(entry.id.as_str()).ok());
2223 if let Some(recorded_id) = recorded_hcn_id {
2224 let reopened = tokio::task::spawn_blocking(move || {
2225 zlayer_hns::network::Network::open(recorded_id).ok()
2226 })
2227 .await
2228 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?;
2229 if reopened.is_some() {
2230 tracing::info!(name = %net_name, service = %service, "reusing per-service HCN network from marker");
2231 return Ok(recorded_id);
2232 }
2233 }
2234
2235 let target_name = net_name.clone();
2237 let existing = tokio::task::spawn_blocking(move || -> Option<GUID> {
2238 let guids = zlayer_hns::network::list("{}").ok()?;
2239 for guid in guids {
2240 let Ok(network) = zlayer_hns::network::Network::open(guid) else {
2241 continue;
2242 };
2243 if matches!(network.query("{}"), Ok(props) if props.name == target_name) {
2244 return Some(guid);
2245 }
2246 }
2247 None
2248 })
2249 .await
2250 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?;
2251
2252 if let Some(existing_id) = existing {
2253 tracing::info!(name = %net_name, service = %service, "reusing existing per-service HCN network");
2254 return Ok(existing_id);
2255 }
2256
2257 let net_id = GUID::new()
2258 .map_err(|e| OverlaydError::Other(format!("GUID::new for per-service network: {e}")))?;
2259 let subnet_str = subnet.to_string();
2260
2261 let net_name_for_create = net_name.clone();
2266 let subnet_for_create = subnet_str.clone();
2267 tokio::task::spawn_blocking(move || {
2268 zlayer_hns::network::Network::create_internal(
2269 net_id,
2270 &net_name_for_create,
2271 &subnet_for_create,
2272 )
2273 })
2274 .await
2275 .map_err(|e| OverlaydError::Other(format!("spawn_blocking join failed: {e}")))?
2276 .map_err(|e| {
2277 OverlaydError::Overlay(format!("HcnCreateNetwork internal ({net_name}): {e}"))
2278 })?;
2279
2280 tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
2284
2285 tracing::info!(
2286 service = %service,
2287 subnet = %subnet_str,
2288 "created per-service HCN Internal network"
2289 );
2290
2291 let mut marker = crate::network_state::NetworkState::load(&marker_path);
2307 let carried = marker.get(&owner).cloned();
2308 marker.upsert(crate::network_state::ManagedNetwork {
2309 owner,
2310 kind: "hcn-internal".to_string(),
2311 name: net_name.clone(),
2312 id: format_guid_bare(net_id),
2313 subnet: subnet_str.clone(),
2314 wg_port: carried.as_ref().and_then(|c| c.wg_port),
2315 wg_private_key: carried.as_ref().and_then(|c| c.wg_private_key.clone()),
2316 wg_public_key: carried.as_ref().and_then(|c| c.wg_public_key.clone()),
2317 interface: carried.as_ref().and_then(|c| c.interface.clone()),
2318 });
2319 if let Err(e) = marker.save(&marker_path) {
2320 tracing::warn!(service = %service, error = %e, path = %marker_path.display(), "failed to persist per-service network marker (network still reusable by name)");
2321 }
2322
2323 Ok(net_id)
2324 }
2325
2326 #[cfg(target_os = "windows")]
2338 fn dedicated_service_subnet(&self, service: &str) -> Option<ipnet::IpNet> {
2339 if let Some(st) = self.service_transports.get(service) {
2340 return Some(st.subnet);
2341 }
2342 let marker_path =
2343 zlayer_paths::ZLayerDirs::new(self.data_dir.clone()).agent_network_state();
2344 crate::network_state::NetworkState::load(&marker_path)
2345 .get(&owner_for_service(service))
2346 .filter(|entry| entry.kind == "hcn-internal")
2347 .and_then(|entry| entry.subnet.parse::<ipnet::IpNet>().ok())
2348 }
2349
2350 #[cfg(target_os = "windows")]
2353 fn deployment_or_default(&self) -> String {
2354 if self.deployment.is_empty() {
2355 "zlayer".to_string()
2356 } else {
2357 self.deployment.clone()
2358 }
2359 }
2360
2361 fn transport_for_scope(&self, scope: &PeerScope) -> Result<&OverlayTransport, OverlaydError> {
2372 match scope {
2373 PeerScope::Global => self
2374 .global_transport
2375 .as_ref()
2376 .ok_or_else(|| OverlaydError::Other("global overlay not set up".into())),
2377 PeerScope::Service { service } => self
2378 .service_transports
2379 .get(service)
2380 .map(|s| &s.transport)
2381 .ok_or_else(|| {
2382 OverlaydError::Other(format!("no dedicated overlay for service {service}"))
2383 }),
2384 }
2385 }
2386
2387 async fn add_peer_on(
2392 transport: &OverlayTransport,
2393 peer: &PeerInfo,
2394 ) -> Result<(), OverlaydError> {
2395 transport
2396 .add_peer(peer)
2397 .await
2398 .map_err(|e| OverlaydError::Overlay(format!("add_peer failed: {e}")))
2399 }
2400
2401 async fn remove_peer_on(
2406 transport: &OverlayTransport,
2407 pubkey: &str,
2408 ) -> Result<(), OverlaydError> {
2409 transport
2410 .remove_peer(pubkey)
2411 .await
2412 .map_err(|e| OverlaydError::Overlay(format!("remove_peer failed: {e}")))
2413 }
2414
2415 async fn add_allowed_ip_on(
2420 transport: &OverlayTransport,
2421 pubkey: &str,
2422 cidr: &str,
2423 ) -> Result<(), OverlaydError> {
2424 let net: ipnet::IpNet = cidr
2425 .parse()
2426 .map_err(|e| OverlaydError::Other(format!("invalid CIDR {cidr}: {e}")))?;
2427 transport
2428 .add_allowed_ip(pubkey, net)
2429 .await
2430 .map_err(|e| OverlaydError::Overlay(format!("add_allowed_ip failed: {e}")))
2431 }
2432
2433 async fn remove_allowed_ip_on(
2438 transport: &OverlayTransport,
2439 pubkey: &str,
2440 cidr: &str,
2441 ) -> Result<(), OverlaydError> {
2442 let net: ipnet::IpNet = cidr
2443 .parse()
2444 .map_err(|e| OverlaydError::Other(format!("invalid CIDR {cidr}: {e}")))?;
2445 transport
2446 .remove_allowed_ip(pubkey, net)
2447 .await
2448 .map_err(|e| OverlaydError::Overlay(format!("remove_allowed_ip failed: {e}")))
2449 }
2450
2451 fn register_dns(&mut self, name: String, ip: IpAddr) {
2455 self.dns_records.insert(name, ip);
2456 }
2457
2458 fn unregister_dns(&mut self, name: &str) {
2460 self.dns_records.remove(name);
2461 }
2462
2463 async fn nat_maintenance_tick(&mut self) -> Result<(), OverlaydError> {
2471 if self.nat_traversal.is_none() {
2473 let config = self.nat_config.clone().unwrap_or_default();
2474 if config.enabled {
2475 let mut nat = NatTraversal::new(config, self.overlay_port);
2476 match nat.gather_candidates().await {
2477 Ok(candidates) => {
2478 tracing::info!(count = candidates.len(), "Gathered NAT candidates");
2479 self.nat_last_refresh.store(now_unix(), Ordering::SeqCst);
2480 self.nat_traversal = Some(nat);
2481 }
2482 Err(e) => {
2483 tracing::warn!(error = %e, "NAT candidate gathering failed");
2484 return Ok(());
2485 }
2486 }
2487 } else {
2488 return Ok(());
2489 }
2490 }
2491
2492 let Some(nat) = self.nat_traversal.as_mut() else {
2493 return Ok(());
2494 };
2495 match nat.refresh().await {
2496 Ok(changed) => {
2497 if changed {
2498 tracing::info!("NAT reflexive address changed during refresh");
2499 }
2500 self.nat_last_refresh.store(now_unix(), Ordering::SeqCst);
2501 Ok(())
2502 }
2503 Err(e) => Err(OverlaydError::Overlay(format!(
2504 "NAT maintenance tick failed: {e}"
2505 ))),
2506 }
2507 }
2508
2509 async fn status_snapshot(&self) -> StatusSnapshot {
2513 let mut peers: Vec<PeerStatus> = Vec::new();
2514 let public_key = self.transport_public_key.clone();
2515
2516 if let Some(transport) = self.global_transport.as_ref() {
2517 if let Ok(dump) = transport.status().await {
2520 peers = parse_peer_status(&dump);
2521 }
2522 }
2523
2524 let service_count = u32::try_from(self.service_count()).unwrap_or(u32::MAX);
2525 let peer_count = u32::try_from(peers.len()).unwrap_or(u32::MAX);
2526
2527 let mut dedicated_services: Vec<DedicatedServiceStatus> = Vec::new();
2530 for (svc, st) in &self.service_transports {
2531 let peer_count = match st.transport.status().await {
2532 Ok(dump) => u32::try_from(parse_peer_status(&dump).len()).unwrap_or(u32::MAX),
2533 Err(_) => 0,
2534 };
2535 dedicated_services.push(DedicatedServiceStatus {
2536 service: svc.clone(),
2537 interface: st.interface.clone(),
2538 public_key: st.public_key.clone(),
2539 listen_port: st.listen_port,
2540 overlay_ip: st.overlay_ip,
2541 subnet: st.subnet.to_string(),
2542 peer_count,
2543 });
2544 }
2545
2546 StatusSnapshot {
2547 interface: self.global_interface.clone(),
2548 node_ip: self.node_ip,
2549 public_key,
2550 overlay_cidr: self.cluster_cidr.map(|c| c.to_string()),
2551 slice_cidr: self.slice_cidr.map(|c| c.to_string()),
2552 peer_count,
2553 service_count,
2554 peers,
2555 dedicated_services,
2556 }
2557 }
2558
2559 fn service_count(&self) -> usize {
2562 let extra_dedicated = self
2563 .service_transports
2564 .keys()
2565 .filter(|svc| !self.service_interfaces.contains_key(*svc))
2566 .count();
2567 self.service_interfaces.len() + extra_dedicated
2568 }
2569
2570 fn build_config(
2573 &self,
2574 private_key: String,
2575 public_key: String,
2576 ip: IpAddr,
2577 mask: u8,
2578 listen_port: u16,
2579 physical_egress_ip: Option<IpAddr>,
2580 ) -> OverlayConfig {
2581 let unspecified = match ip {
2600 IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
2601 IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
2602 };
2603 let local_addr = match physical_egress_ip {
2604 Some(egress) if egress.is_ipv4() == ip.is_ipv4() => egress,
2605 Some(egress) => {
2606 tracing::warn!(
2607 physical_egress_ip = %egress,
2608 overlay_ip = %ip,
2609 "physical egress IP family does not match overlay IP family; \
2610 falling back to UNSPECIFIED for WireGuard local_endpoint"
2611 );
2612 unspecified
2613 }
2614 None => unspecified,
2615 };
2616 let mut config = OverlayConfig {
2617 local_endpoint: SocketAddr::new(local_addr, listen_port),
2618 private_key,
2619 public_key,
2620 overlay_cidr: format!("{ip}/{mask}"),
2621 ..OverlayConfig::default()
2622 };
2623 if let Some(nat) = self.nat_config.clone() {
2624 config.nat = nat;
2625 }
2626 if let Some(dir) = self.uapi_sock_dir.clone() {
2627 config.uapi_sock_dir = dir;
2628 }
2629 config
2630 }
2631}
2632
2633fn shared_overlay_info(name: String) -> ServiceOverlayInfo {
2637 ServiceOverlayInfo {
2638 name,
2639 mode: OverlayMode::Shared,
2640 wg_public_key: None,
2641 wg_port: None,
2642 overlay_ip: None,
2643 subnet: None,
2644 }
2645}
2646
2647fn dedicated_overlay_info(
2651 name: String,
2652 public_key: &str,
2653 listen_port: u16,
2654 overlay_ip: IpAddr,
2655 subnet: ipnet::IpNet,
2656) -> ServiceOverlayInfo {
2657 ServiceOverlayInfo {
2658 name,
2659 mode: OverlayMode::Dedicated,
2660 wg_public_key: Some(public_key.to_string()),
2661 wg_port: Some(listen_port),
2662 overlay_ip: Some(overlay_ip),
2663 subnet: Some(subnet.to_string()),
2664 }
2665}
2666
2667pub fn peer_spec_to_info(spec: &PeerSpec) -> Result<PeerInfo, OverlaydError> {
2673 let endpoint: SocketAddr = spec.endpoint.parse().map_err(|e| {
2674 OverlaydError::Other(format!("invalid peer endpoint {}: {e}", spec.endpoint))
2675 })?;
2676 Ok(PeerInfo::new(
2677 spec.public_key.clone(),
2678 endpoint,
2679 &spec.allowed_ips,
2680 std::time::Duration::from_secs(spec.persistent_keepalive_secs),
2681 ))
2682}
2683
2684fn parse_peer_status(dump: &str) -> Vec<PeerStatus> {
2690 let mut peers: Vec<PeerStatus> = Vec::new();
2691 let mut current: Option<PeerStatus> = None;
2692 let mut allowed: Vec<String> = Vec::new();
2693
2694 let flush = |peers: &mut Vec<PeerStatus>,
2695 current: &mut Option<PeerStatus>,
2696 allowed: &mut Vec<String>| {
2697 if let Some(mut p) = current.take() {
2698 p.allowed_ips = allowed.join(",");
2699 peers.push(p);
2700 }
2701 allowed.clear();
2702 };
2703
2704 for line in dump.lines() {
2705 let line = line.trim();
2706 let Some((key, value)) = line.split_once('=') else {
2707 continue;
2708 };
2709 match key.trim() {
2710 "public_key" | "peer" => {
2711 flush(&mut peers, &mut current, &mut allowed);
2712 current = Some(PeerStatus {
2713 public_key: value.trim().to_string(),
2714 endpoint: String::new(),
2715 allowed_ips: String::new(),
2716 last_handshake_unix_secs: 0,
2717 });
2718 }
2719 "endpoint" => {
2720 if let Some(p) = current.as_mut() {
2721 p.endpoint = value.trim().to_string();
2722 }
2723 }
2724 "allowed_ip" | "allowed_ips" => {
2725 if current.is_some() {
2726 allowed.push(value.trim().to_string());
2727 }
2728 }
2729 "latest_handshake" | "last_handshake_time_sec" => {
2730 if let Some(p) = current.as_mut() {
2731 p.last_handshake_unix_secs = value.trim().parse().unwrap_or(0);
2732 }
2733 }
2734 _ => {}
2735 }
2736 }
2737 flush(&mut peers, &mut current, &mut allowed);
2738 peers
2739}
2740
2741fn now_unix() -> u64 {
2743 std::time::SystemTime::now()
2744 .duration_since(std::time::UNIX_EPOCH)
2745 .unwrap_or_default()
2746 .as_secs()
2747}
2748
2749struct IpAllocator {
2753 cidr: IpNetwork,
2755 base: IpAddr,
2757 next_offset: AtomicU64,
2759 released: parking_lot::Mutex<Vec<IpAddr>>,
2762}
2763
2764impl IpAllocator {
2765 fn new(cidr: IpNetwork) -> Self {
2766 Self {
2767 base: cidr.network(),
2768 cidr,
2769 next_offset: AtomicU64::new(1),
2770 released: parking_lot::Mutex::new(Vec::new()),
2771 }
2772 }
2773
2774 #[allow(clippy::cast_possible_truncation)]
2775 fn compute_addr(&self, offset: u64) -> IpAddr {
2776 match self.base {
2777 IpAddr::V4(base_v4) => {
2778 let base_u32 = u32::from_be_bytes(base_v4.octets());
2779 let addr = base_u32.wrapping_add(offset as u32);
2780 IpAddr::V4(Ipv4Addr::from(addr.to_be_bytes()))
2781 }
2782 IpAddr::V6(base_v6) => {
2783 let base_u128 = u128::from(base_v6);
2784 let addr = base_u128.wrapping_add(u128::from(offset));
2785 IpAddr::V6(Ipv6Addr::from(addr))
2786 }
2787 }
2788 }
2789
2790 fn allocate(&self) -> Result<IpAddr, OverlaydError> {
2795 if let Some(ip) = self.released.lock().pop() {
2796 return Ok(ip);
2797 }
2798 let offset = self.next_offset.fetch_add(1, Ordering::SeqCst);
2799 let addr = self.compute_addr(offset);
2800
2801 let in_cidr = self.cidr.contains(addr);
2802 let is_v4_broadcast = matches!(
2803 (&self.cidr, &addr),
2804 (IpNetwork::V4(v4), IpAddr::V4(a)) if *a == v4.broadcast()
2805 );
2806 if !in_cidr || is_v4_broadcast {
2807 return Err(OverlaydError::Overlay(format!(
2808 "IP allocator exhausted: next address {addr} is outside slice {}",
2809 self.cidr
2810 )));
2811 }
2812 Ok(addr)
2813 }
2814
2815 fn release(&self, ip: IpAddr) {
2817 let mut released = self.released.lock();
2818 if !released.contains(&ip) {
2819 released.push(ip);
2820 }
2821 }
2822}
2823
2824#[cfg(target_os = "windows")]
2830fn owner_tag(daemon_name: &str) -> String {
2831 if daemon_name == "zlayer" {
2832 "zlayer".to_string()
2833 } else {
2834 daemon_name.to_string()
2835 }
2836}
2837
2838#[cfg(target_os = "windows")]
2842fn overlay_network_name(daemon_name: &str) -> String {
2843 if daemon_name == "zlayer" {
2844 "zlayer-overlay".to_string()
2845 } else {
2846 format!("{daemon_name}-overlay")
2847 }
2848}
2849
2850#[cfg(target_os = "windows")]
2854fn format_guid_bare(id: windows::core::GUID) -> String {
2855 format!("{id:?}")
2856 .trim_matches(|c: char| c == '{' || c == '}')
2857 .to_ascii_lowercase()
2858}
2859
2860#[cfg(target_os = "windows")]
2864pub fn purge_managed_networks(data_dir: &Path, daemon_name: &str) {
2865 use windows::core::GUID;
2866
2867 let marker_path = zlayer_paths::ZLayerDirs::new(data_dir.to_path_buf()).agent_network_state();
2868 let state = crate::network_state::NetworkState::load(&marker_path);
2869
2870 for entry in &state.networks {
2872 if !entry.kind.starts_with("hcn") {
2873 continue;
2874 }
2875 match GUID::try_from(entry.id.as_str()) {
2876 Ok(guid) => match zlayer_hns::network::Network::delete(guid) {
2877 Ok(()) => {
2878 tracing::info!(name = %entry.name, id = %entry.id, "deleted managed HCN network");
2879 }
2880 Err(e) => {
2881 tracing::warn!(name = %entry.name, id = %entry.id, error = %e, "failed to delete managed HCN network");
2882 }
2883 },
2884 Err(e) => {
2885 tracing::warn!(id = %entry.id, error = %e, "managed network marker has unparseable GUID");
2886 }
2887 }
2888 }
2889
2890 let overlay_name = overlay_network_name(daemon_name);
2893 if let Ok(guids) = zlayer_hns::network::list("{}") {
2894 for guid in guids {
2895 let Ok(network) = zlayer_hns::network::Network::open(guid) else {
2896 continue;
2897 };
2898 let is_ours = matches!(network.query("{}"), Ok(props) if props.name == overlay_name);
2899 drop(network);
2900 if is_ours {
2901 match zlayer_hns::network::Network::delete(guid) {
2902 Ok(()) => {
2903 tracing::info!(name = %overlay_name, "deleted overlay HCN network (name sweep)");
2904 }
2905 Err(e) => {
2906 tracing::warn!(name = %overlay_name, error = %e, "failed to delete overlay network (name sweep)");
2907 }
2908 }
2909 }
2910 }
2911 }
2912
2913 if marker_path.exists() {
2914 if let Err(e) = std::fs::remove_file(&marker_path) {
2915 tracing::warn!(error = %e, path = %marker_path.display(), "failed to remove agent network marker");
2916 }
2917 }
2918}
2919
2920#[cfg(test)]
2921mod tests {
2922 use super::*;
2923
2924 #[test]
2925 fn peer_spec_to_info_parses_endpoint_and_keepalive() {
2926 let spec = PeerSpec {
2927 public_key: "base64key".to_string(),
2928 endpoint: "1.2.3.4:51820".to_string(),
2929 allowed_ips: "10.200.0.5/32,10.200.1.0/24".to_string(),
2930 persistent_keepalive_secs: 25,
2931 };
2932 let info = peer_spec_to_info(&spec).expect("valid spec");
2933 assert_eq!(info.public_key, "base64key");
2934 assert_eq!(info.endpoint, "1.2.3.4:51820".parse().unwrap());
2935 assert_eq!(info.allowed_ips, "10.200.0.5/32,10.200.1.0/24");
2936 assert_eq!(
2937 info.persistent_keepalive_interval,
2938 std::time::Duration::from_secs(25)
2939 );
2940 }
2941
2942 #[test]
2943 fn peer_spec_to_info_rejects_bad_endpoint() {
2944 let spec = PeerSpec {
2945 public_key: "k".to_string(),
2946 endpoint: "not-a-socket-addr".to_string(),
2947 allowed_ips: String::new(),
2948 persistent_keepalive_secs: 0,
2949 };
2950 assert!(peer_spec_to_info(&spec).is_err());
2951 }
2952
2953 #[test]
2954 fn interface_name_never_exceeds_limit() {
2955 let cases: Vec<(&[&str], &str)> = vec![
2956 (&["a"], "g"),
2957 (&["zlayer-manager"], "g"),
2958 (&["my-very-long-deployment-name-that-goes-on-and-on"], "g"),
2959 (&["zlayer", "manager"], "s"),
2960 (
2961 &["abcdefghijklmnopqrstuvwxyz", "abcdefghijklmnopqrstuvwxyz"],
2962 "s",
2963 ),
2964 (&["x"], ""),
2965 ];
2966 for (parts, suffix) in &cases {
2967 let name = make_interface_name(parts, suffix);
2968 assert!(name.len() <= MAX_IFNAME_LEN, "Name '{name}' too long");
2969 assert!(name.starts_with("zl-"));
2970 }
2971 }
2972
2973 #[test]
2974 fn interface_name_is_deterministic() {
2975 assert_eq!(
2976 make_interface_name(&["zlayer-manager"], "g"),
2977 make_interface_name(&["zlayer-manager"], "g")
2978 );
2979 }
2980
2981 #[test]
2982 fn parse_peer_status_splits_blocks() {
2983 let dump = "\
2984public_key=AAA
2985endpoint=1.2.3.4:51820
2986allowed_ip=10.200.0.2/32
2987allowed_ip=10.200.1.0/24
2988latest_handshake=1700000000
2989public_key=BBB
2990endpoint=5.6.7.8:51820
2991allowed_ip=10.200.0.3/32
2992latest_handshake=0
2993";
2994 let peers = parse_peer_status(dump);
2995 assert_eq!(peers.len(), 2);
2996 assert_eq!(peers[0].public_key, "AAA");
2997 assert_eq!(peers[0].endpoint, "1.2.3.4:51820");
2998 assert_eq!(peers[0].allowed_ips, "10.200.0.2/32,10.200.1.0/24");
2999 assert_eq!(peers[0].last_handshake_unix_secs, 1_700_000_000);
3000 assert_eq!(peers[1].public_key, "BBB");
3001 assert_eq!(peers[1].last_handshake_unix_secs, 0);
3002 }
3003
3004 #[tokio::test]
3005 async fn status_snapshot_before_setup_is_empty() {
3006 let server = OverlaydServer::new(std::path::PathBuf::from("/tmp/zlayer-overlayd-test"));
3007 let snap = server.status_snapshot().await;
3008 assert!(snap.interface.is_none());
3009 assert!(snap.node_ip.is_none());
3010 assert!(snap.public_key.is_none());
3011 assert_eq!(snap.peer_count, 0);
3012 assert_eq!(snap.service_count, 0);
3013 assert!(snap.peers.is_empty());
3014 }
3015
3016 #[tokio::test]
3017 async fn allocate_and_release_ip_round_trip() {
3018 let mut server = OverlaydServer::new(std::path::PathBuf::from("/tmp/zlayer-overlayd-test"));
3019 let a = server.allocate_ip("svc", false).expect("alloc a");
3020 let b = server.allocate_ip("svc", false).expect("alloc b");
3021 assert_ne!(a, b);
3022 server.release_ip(a);
3023 let c = server.allocate_ip("svc", false).expect("alloc c");
3025 assert_eq!(c, a);
3026 }
3027
3028 fn test_server() -> OverlaydServer {
3031 let dir = std::env::temp_dir().join(format!(
3032 "zlayer-overlayd-scope-{}-{}",
3033 std::process::id(),
3034 now_unix()
3035 ));
3036 OverlaydServer::new(dir)
3037 }
3038
3039 #[test]
3040 fn build_config_uses_matching_physical_egress_ipv4() {
3041 let server = test_server();
3042 let overlay_ip: IpAddr = "10.200.0.1".parse().unwrap();
3043 let egress: IpAddr = "192.0.2.10".parse().unwrap();
3044 let config = server.build_config(
3045 "priv".to_string(),
3046 "pub".to_string(),
3047 overlay_ip,
3048 16,
3049 51820,
3050 Some(egress),
3051 );
3052 assert_eq!(config.local_endpoint, SocketAddr::new(egress, 51820));
3053 }
3054
3055 #[test]
3056 fn build_config_falls_back_to_unspecified_when_none() {
3057 let server = test_server();
3058 let overlay_ip: IpAddr = "10.200.0.1".parse().unwrap();
3059 let config = server.build_config(
3060 "priv".to_string(),
3061 "pub".to_string(),
3062 overlay_ip,
3063 16,
3064 51820,
3065 None,
3066 );
3067 assert_eq!(
3068 config.local_endpoint,
3069 SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 51820)
3070 );
3071 }
3072
3073 #[test]
3074 fn build_config_falls_back_to_unspecified_on_family_mismatch() {
3075 let server = test_server();
3076 let overlay_ip: IpAddr = "fd00::1".parse().unwrap();
3079 let egress: IpAddr = "192.0.2.10".parse().unwrap();
3080 let config = server.build_config(
3081 "priv".to_string(),
3082 "pub".to_string(),
3083 overlay_ip,
3084 64,
3085 51820,
3086 Some(egress),
3087 );
3088 assert_eq!(
3089 config.local_endpoint,
3090 SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 51820)
3091 );
3092 }
3093
3094 #[tokio::test]
3095 async fn transport_for_scope_global_requires_setup() {
3096 let server = test_server();
3097 match server.transport_for_scope(&PeerScope::Global) {
3100 Ok(_) => panic!("global overlay should not be set up"),
3101 Err(OverlaydError::Other(m)) => {
3102 assert!(m.contains("global overlay not set up"), "got: {m}");
3103 }
3104 Err(other) => panic!("unexpected error: {other:?}"),
3105 }
3106 }
3107
3108 #[tokio::test]
3109 async fn transport_for_scope_unset_service_errors() {
3110 let server = test_server();
3111 match server.transport_for_scope(&PeerScope::Service {
3112 service: "x".to_string(),
3113 }) {
3114 Ok(_) => panic!("no dedicated overlay should exist for x"),
3115 Err(OverlaydError::Other(m)) => {
3116 assert_eq!(m, "no dedicated overlay for service x");
3117 }
3118 Err(other) => panic!("unexpected error: {other:?}"),
3119 }
3120 }
3121
3122 #[tokio::test]
3123 async fn add_peer_service_scope_before_setup_errors_via_dispatch() {
3124 let mut server = test_server();
3125 let resp = server
3126 .handle(OverlaydRequest::AddPeer {
3127 peer: PeerSpec {
3128 public_key: "k".to_string(),
3129 endpoint: "1.2.3.4:51820".to_string(),
3130 allowed_ips: "10.200.0.2/32".to_string(),
3131 persistent_keepalive_secs: 0,
3132 },
3133 scope: PeerScope::Service {
3134 service: "x".to_string(),
3135 },
3136 })
3137 .await;
3138 match resp {
3139 OverlaydResponse::Err { message } => {
3140 assert_eq!(message, "no dedicated overlay for service x");
3141 }
3142 other => panic!("expected Err response, got {other:?}"),
3143 }
3144 }
3145
3146 #[cfg(target_os = "linux")]
3150 #[tokio::test]
3151 #[ignore = "needs CAP_NET_ADMIN; run on a privileged Linux host"]
3152 async fn dedicated_setup_creates_distinct_device_and_routes_service_peer() {
3153 let mut server = test_server();
3154 let global_name = server
3157 .setup_global_overlay(
3158 "dep".to_string(),
3159 "i0".to_string(),
3160 "10.200.0.0/16",
3161 Some("10.200.0.0/28"),
3162 zlayer_core::DEFAULT_WG_PORT,
3163 false,
3164 )
3165 .await
3166 .expect("global overlay up");
3167 assert!(!global_name.is_empty());
3168
3169 let info = server
3171 .setup_service_overlay("web", OverlayMode::Dedicated)
3172 .await
3173 .expect("dedicated service overlay up");
3174 assert_eq!(info.mode, OverlayMode::Dedicated);
3175 let port = info.wg_port.expect("dedicated port");
3176 assert_ne!(
3177 port, server.overlay_port,
3178 "dedicated device must not share the global port"
3179 );
3180
3181 let st = server
3182 .service_transports
3183 .get("web")
3184 .expect("service transport recorded");
3185 assert_eq!(st.listen_port, port);
3186 assert_ne!(
3187 st.interface, global_name,
3188 "dedicated interface must differ from global"
3189 );
3190 assert_eq!(
3191 Some(st.public_key.clone()),
3192 info.wg_public_key,
3193 "info pubkey matches recorded transport"
3194 );
3195 assert_ne!(
3196 Some(st.public_key.clone()),
3197 server.transport_public_key,
3198 "dedicated key must differ from global key"
3199 );
3200
3201 let resp = server
3204 .handle(OverlaydRequest::AddPeer {
3205 peer: PeerSpec {
3206 public_key: {
3207 let (_priv, pubk) = OverlayTransport::generate_keys().await.unwrap();
3208 pubk
3209 },
3210 endpoint: "5.6.7.8:51999".to_string(),
3211 allowed_ips: "10.201.0.2/32".to_string(),
3212 persistent_keepalive_secs: 25,
3213 },
3214 scope: PeerScope::Service {
3215 service: "web".to_string(),
3216 },
3217 })
3218 .await;
3219 assert!(
3220 matches!(resp, OverlaydResponse::Ok),
3221 "service-scoped add_peer should land on the dedicated device, got {resp:?}"
3222 );
3223 }
3224
3225 #[tokio::test]
3226 async fn guest_attach_requires_global_overlay() {
3227 let mut server = test_server();
3230 let resp = server
3231 .handle(OverlaydRequest::AttachContainer {
3232 handle: AttachHandle::GuestManaged {
3233 id: "vm-1".to_string(),
3234 },
3235 service: "web".to_string(),
3236 join_global: true,
3237 dns_server: None,
3238 dns_domain: None,
3239 })
3240 .await;
3241 match resp {
3242 OverlaydResponse::Err { message } => {
3243 assert!(
3244 message.contains("global overlay to be set up"),
3245 "got: {message}"
3246 );
3247 }
3248 other => panic!("expected Err response, got {other:?}"),
3249 }
3250 assert!(server.guest_attachments.is_empty());
3252 }
3253
3254 #[tokio::test]
3255 async fn detach_unknown_guest_is_idempotent() {
3256 let mut server = test_server();
3257 server
3259 .detach_container_guest("never-attached")
3260 .await
3261 .expect("detach of unknown guest is a no-op");
3262 }
3263
3264 #[cfg(target_os = "linux")]
3269 #[tokio::test]
3270 #[ignore = "needs CAP_NET_ADMIN; run on a privileged Linux host"]
3271 async fn guest_attach_allocates_config_and_detach_releases() {
3272 let mut server = test_server();
3273 server
3274 .setup_global_overlay(
3275 "dep".to_string(),
3276 "i0".to_string(),
3277 "10.200.0.0/16",
3278 Some("10.200.0.0/28"),
3279 zlayer_core::DEFAULT_WG_PORT,
3280 false,
3281 )
3282 .await
3283 .expect("global overlay up");
3284
3285 let (_p, other_pub) = OverlayTransport::generate_keys().await.unwrap();
3287 let add = server
3288 .handle(OverlaydRequest::AddPeer {
3289 peer: PeerSpec {
3290 public_key: other_pub.clone(),
3291 endpoint: "9.9.9.9:51820".to_string(),
3292 allowed_ips: "10.200.1.0/28".to_string(),
3293 persistent_keepalive_secs: 25,
3294 },
3295 scope: PeerScope::Global,
3296 })
3297 .await;
3298 assert!(
3299 matches!(add, OverlaydResponse::Ok),
3300 "seed peer add: {add:?}"
3301 );
3302
3303 let resp = server
3304 .handle(OverlaydRequest::AttachContainer {
3305 handle: AttachHandle::GuestManaged {
3306 id: "vm-1".to_string(),
3307 },
3308 service: "web".to_string(),
3309 join_global: true,
3310 dns_server: Some("10.200.0.1".parse().unwrap()),
3311 dns_domain: Some("overlay".to_string()),
3312 })
3313 .await;
3314 let config = match resp {
3315 OverlaydResponse::GuestConfig(c) => c,
3316 other => panic!("expected GuestConfig, got {other:?}"),
3317 };
3318 assert!(!config.private_key.is_empty());
3319 assert!(!config.public_key.is_empty());
3320 assert_ne!(config.private_key, config.public_key);
3321 assert_eq!(config.listen_port, server.overlay_port);
3322 assert_eq!(config.dns_server, Some("10.200.0.1".parse().unwrap()));
3323 assert!(
3325 config.peers.iter().any(|p| p.public_key == other_pub),
3326 "guest must learn the seeded global peer"
3327 );
3328 assert!(
3329 config
3330 .peers
3331 .iter()
3332 .any(|p| Some(&p.public_key) == server.transport_public_key.as_ref()),
3333 "guest must learn THIS node as a peer"
3334 );
3335 assert!(server.global_peers.contains_key(&config.public_key));
3337 let info = server
3338 .guest_attachments
3339 .get("vm-1")
3340 .expect("attachment recorded");
3341 assert_eq!(info.overlay_ip, config.overlay_ip);
3342
3343 let det = server
3345 .handle(OverlaydRequest::DetachContainer {
3346 handle: AttachHandle::GuestManaged {
3347 id: "vm-1".to_string(),
3348 },
3349 })
3350 .await;
3351 assert!(matches!(det, OverlaydResponse::Ok), "detach: {det:?}");
3352 assert!(!server.guest_attachments.contains_key("vm-1"));
3353 assert!(!server.global_peers.contains_key(&config.public_key));
3354 }
3355}