use crate::error::AgentError;
use ipnetwork::IpNetwork;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use zlayer_overlay::{Candidate, NatConfig, NatPeerSnapshot, NatStatusSnapshot};
use zlayer_overlayd::OverlaydClient;
use zlayer_paths::ZLayerDirs;
use zlayer_types::nat_wire::{NatCandidateWire, NatConfigSpec, RelayServerSpec, TurnServerSpec};
use zlayer_types::overlayd::{
AttachHandle, NatStatusWire, OverlaydRequest, OverlaydResponse, PeerSpec, StatusSnapshot,
};
const MAX_IFNAME_LEN: usize = 15;
#[must_use]
pub fn make_interface_name(parts: &[&str], suffix: &str) -> String {
let base = format!("zl-{}", parts.join("-"));
let candidate = if suffix.is_empty() {
base
} else {
format!("{base}-{suffix}")
};
if candidate.len() <= MAX_IFNAME_LEN {
return candidate;
}
let mut hasher = DefaultHasher::new();
for part in parts {
part.hash(&mut hasher);
}
suffix.hash(&mut hasher);
let hash = format!("{:x}", hasher.finish());
if suffix.is_empty() {
let budget = MAX_IFNAME_LEN - 3;
format!("zl-{}", &hash[..budget.min(hash.len())])
} else {
let suffix_cost = 1 + suffix.len(); let hash_budget = MAX_IFNAME_LEN.saturating_sub(3 + suffix_cost);
if hash_budget == 0 {
let budget = MAX_IFNAME_LEN - 3;
format!("zl-{}", &hash[..budget.min(hash.len())])
} else {
format!("zl-{}-{}", &hash[..hash_budget.min(hash.len())], suffix)
}
}
}
fn map_overlayd_err(e: &zlayer_overlayd::OverlaydError) -> AgentError {
AgentError::Network(format!("overlayd: {e}"))
}
fn is_transport_error(e: &zlayer_overlayd::OverlaydError) -> bool {
use zlayer_overlayd::OverlaydError;
matches!(
e,
OverlaydError::Io(_)
| OverlaydError::Closed
| OverlaydError::Codec(_)
| OverlaydError::FrameTooLarge(_)
)
}
fn peer_spec_from(peer: &zlayer_overlay::PeerInfo, candidates: Vec<NatCandidateWire>) -> PeerSpec {
PeerSpec {
public_key: peer.public_key.clone(),
endpoint: peer.endpoint.to_string(),
allowed_ips: peer.allowed_ips.clone(),
persistent_keepalive_secs: peer.persistent_keepalive_interval.as_secs(),
candidates,
}
}
fn nat_config_to_spec(cfg: &NatConfig, relay_credential: Option<String>) -> NatConfigSpec {
NatConfigSpec {
enabled: cfg.enabled,
stun_servers: cfg.stun_servers.iter().map(|s| s.address.clone()).collect(),
turn_servers: cfg
.turn_servers
.iter()
.map(|t| TurnServerSpec {
addr: t.address.clone(),
username: t.username.clone(),
credential: t.credential.clone(),
})
.collect(),
hole_punch_timeout_secs: cfg.hole_punch_timeout_secs,
stun_refresh_interval_secs: cfg.stun_refresh_interval_secs,
max_candidate_pairs: cfg.max_candidate_pairs,
relay_server: cfg.relay_server.as_ref().map(|r| RelayServerSpec {
listen_port: r.listen_port,
external_addr: r.external_addr.clone(),
max_sessions: r.max_sessions,
auth_credential: relay_credential,
}),
}
}
fn nat_status_wire_to_snapshot(wire: NatStatusWire) -> NatStatusSnapshot {
let candidates: Vec<Candidate> = wire
.candidates
.iter()
.filter_map(nat_candidate_wire_to_candidate)
.collect();
let peers: Vec<NatPeerSnapshot> = wire
.peers
.into_iter()
.map(|p| NatPeerSnapshot {
node_id: p.node_id,
connection_type: p.connection_type,
remote_endpoint: p.remote_endpoint,
})
.collect();
NatStatusSnapshot {
candidates,
peers,
last_refresh: wire.last_refresh,
}
}
fn nat_candidate_wire_to_candidate(w: &NatCandidateWire) -> Option<Candidate> {
use zlayer_overlay::CandidateType;
let address: SocketAddr = w.address.parse().ok()?;
let candidate_type = match w.candidate_type.as_str() {
"host" => CandidateType::Host,
"server-reflexive" => CandidateType::ServerReflexive,
"relay" => CandidateType::Relay,
_ => return None,
};
let mut c = Candidate::new(candidate_type, address);
c.priority = w.priority;
Some(c)
}
pub struct OverlayManager {
deployment: String,
instance_id: String,
host_adapter_mandatory: bool,
data_dir: PathBuf,
client: Mutex<Option<Arc<Mutex<OverlaydClient>>>>,
local_node_id: u64,
local_wg_pubkey: Mutex<Option<String>>,
overlay_port: u16,
node_ip: Option<IpAddr>,
global_interface: Option<String>,
cluster_cidr: Option<IpNetwork>,
slice_cidr: Option<IpNetwork>,
dns_server_addr: Option<SocketAddr>,
dns_domain: Option<String>,
nat_config: Option<NatConfig>,
cluster_relay_credential: Option<String>,
uapi_sock_dir: Option<PathBuf>,
#[cfg(target_os = "windows")]
hcn_cleanup: std::sync::Arc<
tokio::sync::Mutex<
std::collections::HashMap<windows::core::GUID, (String, std::net::IpAddr)>,
>,
>,
}
#[must_use]
pub fn resolve_isolation_network(
mode: zlayer_types::overlay::OverlayMode,
service: &str,
explicit_named: Option<String>,
) -> Option<String> {
explicit_named.or_else(|| mode.uses_isolation_scope().then(|| service.to_string()))
}
impl OverlayManager {
#[allow(clippy::unused_async)]
pub async fn new(deployment: String, instance_id: String) -> Result<Self, AgentError> {
let data_dir = ZLayerDirs::system_default().data_dir().to_path_buf();
let default_cidr: IpNetwork = "10.200.0.0/16".parse().expect("compile-time constant CIDR");
Ok(Self {
deployment,
instance_id,
host_adapter_mandatory: false,
data_dir,
client: Mutex::new(None),
local_node_id: 0,
local_wg_pubkey: Mutex::new(None),
overlay_port: zlayer_core::DEFAULT_WG_PORT,
node_ip: None,
global_interface: None,
cluster_cidr: Some(default_cidr),
slice_cidr: None,
dns_server_addr: None,
dns_domain: None,
nat_config: None,
cluster_relay_credential: None,
uapi_sock_dir: None,
#[cfg(target_os = "windows")]
hcn_cleanup: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
})
}
#[must_use]
pub fn with_slice(
deployment: String,
cluster_cidr: IpNetwork,
slice_cidr: IpNetwork,
port: u16,
instance_id: String,
) -> Self {
let data_dir = ZLayerDirs::system_default().data_dir().to_path_buf();
Self {
deployment,
instance_id,
host_adapter_mandatory: false,
data_dir,
client: Mutex::new(None),
local_node_id: 0,
local_wg_pubkey: Mutex::new(None),
overlay_port: port,
node_ip: None,
global_interface: None,
cluster_cidr: Some(cluster_cidr),
slice_cidr: Some(slice_cidr),
dns_server_addr: None,
dns_domain: None,
nat_config: None,
cluster_relay_credential: None,
uapi_sock_dir: None,
#[cfg(target_os = "windows")]
hcn_cleanup: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
}
}
#[must_use]
pub fn with_overlay_port(mut self, port: u16) -> Self {
self.overlay_port = port;
self
}
#[must_use]
pub fn with_nat_config(mut self, nat: NatConfig) -> Self {
self.nat_config = Some(nat);
self
}
#[must_use]
pub fn with_relay_credential(mut self, credential: impl Into<String>) -> Self {
let credential = credential.into();
if credential.trim().is_empty() {
self.cluster_relay_credential = None;
} else {
self.cluster_relay_credential = Some(credential);
}
self
}
#[must_use]
pub fn with_uapi_sock_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.uapi_sock_dir = Some(dir.into());
self
}
#[must_use]
pub fn with_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.data_dir = dir.into();
self
}
#[must_use]
pub fn with_local_node_id(mut self, node_id: u64) -> Self {
self.local_node_id = node_id;
self
}
#[must_use]
pub fn with_host_adapter_mandatory(mut self, mandatory: bool) -> Self {
self.host_adapter_mandatory = mandatory;
self
}
async fn client(&self) -> Result<Arc<Mutex<OverlaydClient>>, AgentError> {
let mut guard = self.client.lock().await;
if let Some(c) = guard.as_ref() {
return Ok(Arc::clone(c));
}
let socket = ZLayerDirs::default_overlayd_socket_path_for(&self.data_dir);
let conn = OverlaydClient::connect_with_attempts(std::path::Path::new(&socket), 6)
.await
.map_err(|e| map_overlayd_err(&e))?;
let arc = Arc::new(Mutex::new(conn));
*guard = Some(Arc::clone(&arc));
Ok(arc)
}
async fn invalidate_client(&self) {
*self.client.lock().await = None;
}
async fn call(&self, req: OverlaydRequest) -> Result<OverlaydResponse, AgentError> {
let client = self.client().await?;
let first = {
let mut conn = client.lock().await;
conn.call(req.clone()).await
};
match first {
Ok(resp) => Ok(resp),
Err(e) if is_transport_error(&e) => {
tracing::warn!(error = %e, "overlayd connection broken; reconnecting and retrying once");
self.invalidate_client().await;
let fresh = self.client().await?;
let mut conn = fresh.lock().await;
self.reestablish_global_overlay_on(&mut conn).await;
conn.call(req).await.map_err(|e| map_overlayd_err(&e))
}
Err(e) => Err(map_overlayd_err(&e)),
}
}
async fn reestablish_global_overlay_on(&self, conn: &mut OverlaydClient) {
if self.global_interface.is_none() {
return;
}
let _ = conn
.call(OverlaydRequest::SetLocalNodeId {
node_id: self.local_node_id,
})
.await;
if let Ok(guard) = self.local_wg_pubkey.try_lock() {
if let Some(pubkey) = guard.clone() {
drop(guard);
let _ = conn
.call(OverlaydRequest::SetLocalWgPubkey { pubkey })
.await;
}
}
let cluster_cidr = self
.cluster_cidr
.map_or_else(|| "10.200.0.0/16".to_string(), |c| c.to_string());
let slice_cidr = self.slice_cidr.map(|c| c.to_string());
let nat = self
.nat_config
.as_ref()
.map(|cfg| nat_config_to_spec(cfg, self.cluster_relay_credential.clone()));
match conn
.call(OverlaydRequest::SetupGlobalOverlay {
deployment: self.deployment.clone(),
instance_id: self.instance_id.clone(),
cluster_cidr,
slice_cidr,
wg_port: self.overlay_port,
host_adapter_mandatory: self.host_adapter_mandatory,
nat,
})
.await
{
Ok(_) => tracing::info!(
"re-established global overlay after overlayd reconnect (recreated node adapter)"
),
Err(e) => tracing::warn!(
error = %e,
"failed to re-establish global overlay after overlayd reconnect"
),
}
}
pub fn set_local_node_id(&mut self, node_id: u64) {
self.local_node_id = node_id;
}
pub async fn set_local_wg_pubkey(&self, pubkey: String) {
*self.local_wg_pubkey.lock().await = Some(pubkey.clone());
if let Err(e) = self
.call(OverlaydRequest::SetLocalWgPubkey { pubkey })
.await
{
tracing::warn!(error = %e, "overlayd SetLocalWgPubkey failed");
}
}
pub async fn service_count(&self) -> usize {
match self.call(OverlaydRequest::Status).await {
Ok(OverlaydResponse::Status(snap)) => snap.service_count as usize,
_ => 0,
}
}
#[must_use]
pub fn nat_enabled(&self) -> bool {
self.nat_config
.as_ref()
.map_or_else(|| NatConfig::default().enabled, |c| c.enabled)
}
#[must_use]
pub fn nat_config(&self) -> Option<NatConfig> {
self.nat_config.clone()
}
#[allow(clippy::unused_async)]
pub async fn start_nat_traversal(&self) -> Result<bool, AgentError> {
Ok(self.nat_enabled())
}
pub async fn nat_maintenance_tick(&self) -> Result<(), AgentError> {
if !self.nat_enabled() {
return Ok(());
}
self.call(OverlaydRequest::NatTick).await?;
Ok(())
}
pub async fn nat_status_snapshot(&self) -> NatStatusSnapshot {
if !self.nat_enabled() {
return NatStatusSnapshot::empty();
}
match self.call(OverlaydRequest::NatStatus).await {
Ok(OverlaydResponse::NatStatus(wire)) => nat_status_wire_to_snapshot(wire),
Ok(other) => {
tracing::warn!(?other, "overlayd NatStatus returned unexpected response");
NatStatusSnapshot::empty()
}
Err(e) => {
tracing::warn!(error = %e, "overlayd NatStatus failed (non-fatal)");
NatStatusSnapshot::empty()
}
}
}
pub fn set_dns_config(&mut self, addr: Option<SocketAddr>, domain: Option<String>) {
self.dns_server_addr = addr;
self.dns_domain = domain;
}
#[must_use]
pub fn with_dns_config(mut self, addr: Option<SocketAddr>, domain: Option<String>) -> Self {
self.dns_server_addr = addr;
self.dns_domain = domain;
self
}
#[must_use]
pub fn dns_server_addr(&self) -> Option<SocketAddr> {
self.dns_server_addr
}
#[must_use]
pub fn dns_domain(&self) -> Option<&str> {
self.dns_domain.as_deref()
}
pub async fn setup_global_overlay(&mut self) -> Result<(), AgentError> {
self.client().await?;
let _ = self
.call(OverlaydRequest::SetLocalNodeId {
node_id: self.local_node_id,
})
.await;
if let Some(pubkey) = self.local_wg_pubkey.lock().await.clone() {
let _ = self
.call(OverlaydRequest::SetLocalWgPubkey { pubkey })
.await;
}
let cluster_cidr = self
.cluster_cidr
.map_or_else(|| "10.200.0.0/16".to_string(), |c| c.to_string());
let slice_cidr = self.slice_cidr.map(|c| c.to_string());
let nat = self
.nat_config
.as_ref()
.map(|cfg| nat_config_to_spec(cfg, self.cluster_relay_credential.clone()));
let resp = self
.call(OverlaydRequest::SetupGlobalOverlay {
deployment: self.deployment.clone(),
instance_id: self.instance_id.clone(),
cluster_cidr,
slice_cidr,
wg_port: self.overlay_port,
host_adapter_mandatory: self.host_adapter_mandatory,
nat,
})
.await?;
if let OverlaydResponse::BridgeName { name } = resp {
self.global_interface = Some(name);
}
self.refresh_status().await;
Ok(())
}
async fn refresh_status(&mut self) {
if let Ok(OverlaydResponse::Status(snap)) = self.call(OverlaydRequest::Status).await {
let StatusSnapshot {
interface,
node_ip,
overlay_cidr,
slice_cidr,
..
} = snap;
if let Some(iface) = interface {
self.global_interface = Some(iface);
}
if node_ip.is_some() {
self.node_ip = node_ip;
}
if let Some(c) = overlay_cidr.and_then(|s| s.parse().ok()) {
self.cluster_cidr = Some(c);
}
if let Some(s) = slice_cidr.and_then(|s| s.parse().ok()) {
self.slice_cidr = Some(s);
}
}
}
pub async fn setup_service_overlay(
&self,
service_name: &str,
mode: zlayer_types::overlay::OverlayMode,
) -> Result<zlayer_types::overlayd::ServiceOverlayInfo, AgentError> {
let resp = self
.call(OverlaydRequest::SetupServiceOverlay {
service: service_name.to_string(),
mode,
})
.await?;
match resp {
OverlaydResponse::BridgeName { name } => {
Ok(zlayer_types::overlayd::ServiceOverlayInfo {
name,
mode,
wg_public_key: None,
wg_port: None,
overlay_ip: None,
subnet: None,
})
}
OverlaydResponse::ServiceOverlay(info) => Ok(info),
other => Err(AgentError::Network(format!(
"overlayd SetupServiceOverlay returned unexpected response: {other:?}"
))),
}
}
pub async fn attach_container(
&self,
container_pid: u32,
service_name: &str,
join_global: bool,
ephemeral: bool,
isolation_network: Option<String>,
dns_domain_override: Option<String>,
) -> Result<IpAddr, AgentError> {
let resp = self
.call(OverlaydRequest::AttachContainer {
handle: AttachHandle::LinuxPid { pid: container_pid },
service: service_name.to_string(),
join_global,
ephemeral,
isolation_network,
dns_server: self.dns_server_addr.map(|sa| sa.ip()),
dns_domain: dns_domain_override.or_else(|| self.dns_domain.clone()),
})
.await?;
match resp {
OverlaydResponse::Attached(result) => Ok(result.ip),
other => Err(AgentError::Network(format!(
"overlayd AttachContainer returned unexpected response: {other:?}"
))),
}
}
pub async fn attach_container_guest(
&self,
id: &str,
service_name: &str,
join_global: bool,
isolation_network: Option<String>,
dns_domain_override: Option<String>,
) -> Result<zlayer_types::overlayd::GuestOverlayConfig, AgentError> {
let resp = self
.call(OverlaydRequest::AttachContainer {
handle: AttachHandle::GuestManaged { id: id.to_string() },
service: service_name.to_string(),
join_global,
ephemeral: false,
isolation_network,
dns_server: self.dns_server_addr.map(|sa| sa.ip()),
dns_domain: dns_domain_override.or_else(|| self.dns_domain.clone()),
})
.await?;
match resp {
OverlaydResponse::GuestConfig(cfg) => Ok(cfg),
other => Err(AgentError::Network(format!(
"overlayd AttachContainer(GuestManaged) returned unexpected response: {other:?}"
))),
}
}
pub async fn detach_container_guest(&self, id: &str) -> Result<(), AgentError> {
let resp = self
.call(OverlaydRequest::DetachContainer {
handle: AttachHandle::GuestManaged { id: id.to_string() },
})
.await?;
match resp {
OverlaydResponse::Ok => Ok(()),
other => Err(AgentError::Network(format!(
"overlayd DetachContainer(GuestManaged) returned unexpected response: {other:?}"
))),
}
}
pub async fn write_scoped_resolver(
&self,
zone: &str,
node_ip: std::net::IpAddr,
port: Option<u16>,
) -> Result<(), AgentError> {
self.call(OverlaydRequest::WriteScopedResolver {
zone: zone.to_string(),
node_ip,
port,
})
.await?;
Ok(())
}
pub async fn remove_scoped_resolver(&self, zone: &str) -> Result<(), AgentError> {
self.call(OverlaydRequest::RemoveScopedResolver {
zone: zone.to_string(),
})
.await?;
Ok(())
}
pub async fn attach_container_host_shared(
&self,
container_id: &str,
service_name: &str,
ephemeral: bool,
isolation_network: Option<String>,
dns_domain_override: Option<String>,
) -> Result<IpAddr, AgentError> {
let resp = self
.call(OverlaydRequest::AttachContainer {
handle: AttachHandle::HostShared {
id: container_id.to_string(),
},
service: service_name.to_string(),
join_global: true,
ephemeral,
isolation_network,
dns_server: self.dns_server_addr.map(|sa| sa.ip()),
dns_domain: dns_domain_override.or_else(|| self.dns_domain.clone()),
})
.await?;
match resp {
OverlaydResponse::Attached(result) => Ok(result.ip),
other => Err(AgentError::Network(format!(
"overlayd AttachContainer(HostShared) returned unexpected response: {other:?}"
))),
}
}
pub async fn detach_container_host_shared(&self, container_id: &str) -> Result<(), AgentError> {
let resp = self
.call(OverlaydRequest::DetachContainer {
handle: AttachHandle::HostShared {
id: container_id.to_string(),
},
})
.await?;
match resp {
OverlaydResponse::Ok => Ok(()),
other => Err(AgentError::Network(format!(
"overlayd DetachContainer(HostShared) returned unexpected response: {other:?}"
))),
}
}
#[cfg(target_os = "windows")]
#[allow(clippy::too_many_arguments)]
pub async fn attach_container_hcn(
&self,
container_id: &str,
service_name: &str,
ip_override: Option<std::net::IpAddr>,
autoclean: bool,
isolation_network: Option<String>,
dns_server: Option<std::net::IpAddr>,
dns_domain: Option<String>,
) -> Result<(std::net::IpAddr, Option<String>), AgentError> {
let resp = self
.call(OverlaydRequest::AttachContainer {
handle: AttachHandle::WindowsContainer {
container_id: container_id.to_string(),
ip: ip_override,
},
service: service_name.to_string(),
join_global: false,
ephemeral: false,
isolation_network,
dns_server: dns_server.or_else(|| self.dns_server_addr.map(|sa| sa.ip())),
dns_domain: dns_domain.or_else(|| self.dns_domain.clone()),
})
.await?;
match resp {
OverlaydResponse::Attached(result) => {
if autoclean {
if let Some(ns_str) = result.namespace_guid.as_deref() {
match windows::core::GUID::try_from(ns_str) {
Ok(ns_guid) => {
let mut cleanup = self.hcn_cleanup.lock().await;
cleanup.insert(ns_guid, (service_name.to_string(), result.ip));
}
Err(e) => {
tracing::warn!(
ns = %ns_str,
error = %e,
"overlayd returned a non-GUID namespace handle; skipping hcn_cleanup insert"
);
}
}
}
}
Ok((result.ip, result.namespace_guid))
}
other => Err(AgentError::Network(format!(
"overlayd AttachContainer(WindowsContainer) returned unexpected response: {other:?}"
))),
}
}
#[cfg(target_os = "windows")]
pub async fn detach_container_hcn(&self, namespace_guid: &str) -> Result<(), AgentError> {
match windows::core::GUID::try_from(namespace_guid) {
Ok(ns_guid) => {
let mut cleanup = self.hcn_cleanup.lock().await;
if let Some((service_name, ip)) = cleanup.remove(&ns_guid) {
tracing::info!(
ns = %namespace_guid,
service = %service_name,
ip = %ip,
"Released HCN overlay attachment (agent-side cleanup)"
);
}
}
Err(e) => {
tracing::warn!(
ns = %namespace_guid,
error = %e,
"detach_container_hcn called with non-GUID handle; skipping hcn_cleanup drain"
);
}
}
self.call(OverlaydRequest::DetachContainer {
handle: AttachHandle::WindowsContainer {
container_id: namespace_guid.to_string(),
ip: None,
},
})
.await?;
Ok(())
}
pub async fn detach_container(&self, pid: u32) -> Result<(), AgentError> {
self.call(OverlaydRequest::DetachContainer {
handle: AttachHandle::LinuxPid { pid },
})
.await?;
Ok(())
}
pub async fn prune_orphan_bridges(&self, live_bridge_names: Vec<String>) -> Vec<String> {
match self
.call(OverlaydRequest::PruneOrphanBridges { live_bridge_names })
.await
{
Ok(OverlaydResponse::PrunedBridges { reclaimed }) => {
if !reclaimed.is_empty() {
tracing::info!(
count = reclaimed.len(),
bridges = ?reclaimed,
"overlayd reclaimed orphaned service bridges"
);
}
reclaimed
}
Ok(other) => {
tracing::warn!(
?other,
"overlayd PruneOrphanBridges returned unexpected response"
);
Vec::new()
}
Err(e) => {
tracing::warn!(error = %e, "overlayd PruneOrphanBridges failed (non-fatal)");
Vec::new()
}
}
}
#[must_use]
pub fn service_bridge_name(&self, service: &str) -> String {
make_interface_name(&[&self.deployment, &self.instance_id, service], "b")
}
pub async fn teardown_service_overlay(&self, service_name: &str) {
if let Err(e) = self
.call(OverlaydRequest::TeardownServiceOverlay {
service: service_name.to_string(),
})
.await
{
tracing::warn!(service = %service_name, error = %e, "overlayd TeardownServiceOverlay failed");
}
}
pub async fn cleanup(&mut self) -> Result<(), AgentError> {
self.call(OverlaydRequest::TeardownGlobalOverlay).await?;
self.global_interface = None;
#[cfg(target_os = "windows")]
{
let mut cleanup = self.hcn_cleanup.lock().await;
cleanup.clear();
}
Ok(())
}
pub fn node_ip(&self) -> Option<IpAddr> {
self.node_ip
}
pub fn deployment(&self) -> &str {
&self.deployment
}
pub fn global_interface(&self) -> Option<&str> {
self.global_interface.as_deref()
}
pub fn overlay_port(&self) -> u16 {
self.overlay_port
}
pub fn has_global_transport(&self) -> bool {
self.global_interface.is_some()
}
pub async fn service_bridge_count(&self) -> usize {
match self.call(OverlaydRequest::Status).await {
Ok(OverlaydResponse::Status(snap)) => snap.service_count as usize,
_ => 0,
}
}
pub async fn add_global_peer(&self, peer: &zlayer_overlay::PeerInfo) -> Result<(), AgentError> {
self.add_global_peer_with_candidates(peer, Vec::new()).await
}
pub async fn add_global_peer_with_candidates(
&self,
peer: &zlayer_overlay::PeerInfo,
candidates: Vec<NatCandidateWire>,
) -> Result<(), AgentError> {
self.call(OverlaydRequest::AddPeer {
peer: peer_spec_from(peer, candidates),
scope: zlayer_types::overlayd::PeerScope::Global,
})
.await?;
Ok(())
}
pub async fn add_service_peer(
&self,
service: &str,
peer: &zlayer_overlay::PeerInfo,
subnet: &str,
) -> Result<(), AgentError> {
self.call(OverlaydRequest::AddPeer {
peer: peer_spec_from(peer, Vec::new()),
scope: zlayer_types::overlayd::PeerScope::Service {
service: service.to_string(),
},
})
.await?;
self.call(OverlaydRequest::AddAllowedIp {
pubkey: peer.public_key.clone(),
cidr: subnet.to_string(),
scope: zlayer_types::overlayd::PeerScope::Service {
service: service.to_string(),
},
})
.await?;
Ok(())
}
pub async fn remove_service_peer(&self, service: &str, pubkey: &str) -> Result<(), AgentError> {
self.call(OverlaydRequest::RemovePeer {
pubkey: pubkey.to_string(),
scope: zlayer_types::overlayd::PeerScope::Service {
service: service.to_string(),
},
})
.await?;
Ok(())
}
pub fn overlay_cidr(&self) -> String {
self.cluster_cidr
.map_or_else(|| "10.200.0.0/16".to_string(), |c| c.to_string())
}
pub fn slice_cidr(&self) -> Option<IpNetwork> {
self.slice_cidr
}
pub fn cluster_cidr(&self) -> Option<IpNetwork> {
self.cluster_cidr
}
#[allow(clippy::unused_async)]
pub async fn persist_ipam_state(&self, _path: &std::path::Path) -> Result<(), AgentError> {
Ok(())
}
#[allow(clippy::unused_async)]
pub async fn restore_ipam_state(&mut self, _path: &std::path::Path) -> Result<(), AgentError> {
Ok(())
}
pub fn ip_alloc_stats(&self) -> (u64, IpAddr) {
let base = self
.cluster_cidr
.map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.network());
(0, base)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_isolation_network_cases() {
use zlayer_types::overlay::OverlayMode;
assert_eq!(
resolve_isolation_network(OverlayMode::Isolated, "web", None),
Some("web".to_string())
);
assert_eq!(
resolve_isolation_network(OverlayMode::Auto, "web", None),
None
);
assert_eq!(
resolve_isolation_network(OverlayMode::Dedicated, "web", None),
None
);
assert_eq!(
resolve_isolation_network(OverlayMode::Auto, "web", Some("net1".into())),
Some("net1".into())
);
assert_eq!(
resolve_isolation_network(OverlayMode::Isolated, "web", Some("net1".into())),
Some("net1".into())
);
}
#[test]
fn transport_errors_trigger_reconnect_app_errors_do_not() {
use std::io::{Error as IoError, ErrorKind};
use zlayer_overlayd::OverlaydError;
assert!(is_transport_error(&OverlaydError::Io(IoError::new(
ErrorKind::BrokenPipe,
"Broken pipe (os error 32)",
))));
assert!(is_transport_error(&OverlaydError::Io(IoError::new(
ErrorKind::ConnectionReset,
"connection reset",
))));
assert!(is_transport_error(&OverlaydError::Closed));
assert!(is_transport_error(&OverlaydError::FrameTooLarge(99)));
assert!(!is_transport_error(&OverlaydError::Overlay(
"nat refresh failed".to_string()
)));
assert!(!is_transport_error(&OverlaydError::Other(
"protocol mismatch".to_string()
)));
}
#[test]
fn interface_name_never_exceeds_limit() {
let cases: Vec<(&[&str], &str)> = vec![
(&["a"], "g"),
(&["zlayer-manager"], "g"),
(&["my-very-long-deployment-name-that-goes-on-and-on"], "g"),
(&["zlayer", "manager"], "s"),
(&["zlayer-manager", "frontend-service"], "s"),
(&["a", "b"], "s"),
(
&["abcdefghijklmnopqrstuvwxyz", "abcdefghijklmnopqrstuvwxyz"],
"s",
),
(&["x"], ""),
(&["deployment"], ""),
(&["a-really-long-name-exceeding-everything"], "suffix"),
];
for (parts, suffix) in &cases {
let name = make_interface_name(parts, suffix);
assert!(
name.len() <= MAX_IFNAME_LEN,
"Name '{}' is {} chars (parts={:?}, suffix='{}')",
name,
name.len(),
parts,
suffix,
);
}
}
#[test]
fn interface_name_with_extreme_lengths() {
let long = "a".repeat(200);
let long_ref = long.as_str();
let name = make_interface_name(&[long_ref], "g");
assert!(name.len() <= MAX_IFNAME_LEN, "Name '{name}' too long");
let name = make_interface_name(&[long_ref, long_ref, long_ref], "s");
assert!(name.len() <= MAX_IFNAME_LEN, "Name '{name}' too long");
let name = make_interface_name(&[long_ref], "");
assert!(name.len() <= MAX_IFNAME_LEN, "Name '{name}' too long");
}
#[test]
fn interface_name_is_deterministic() {
let a = make_interface_name(&["zlayer-manager"], "g");
let b = make_interface_name(&["zlayer-manager"], "g");
assert_eq!(a, b);
}
#[test]
fn interface_name_uniqueness() {
let a = make_interface_name(&["deploy-a"], "g");
let b = make_interface_name(&["deploy-b"], "g");
assert_ne!(a, b);
let a = make_interface_name(&["deploy"], "g");
let b = make_interface_name(&["deploy"], "s");
assert_ne!(a, b);
}
#[test]
fn interface_name_short_inputs_are_readable() {
let name = make_interface_name(&["app"], "g");
assert_eq!(name, "zl-app-g");
let name = make_interface_name(&["my", "web"], "s");
assert_eq!(name, "zl-my-web-s");
}
#[test]
fn with_slice_stores_slice_cidr() {
let cluster: IpNetwork = "10.200.0.0/16".parse().unwrap();
let slice: IpNetwork = "10.200.42.0/28".parse().unwrap();
let om = OverlayManager::with_slice(
"test-deploy".to_string(),
cluster,
slice,
51820,
"test".to_string(),
);
assert_eq!(om.slice_cidr(), Some(slice));
assert_eq!(om.cluster_cidr(), Some(cluster));
assert_eq!(om.overlay_port(), 51820);
assert_eq!(om.deployment(), "test-deploy");
}
#[tokio::test]
async fn node_ip_none_before_setup() {
let om = OverlayManager::new("test-deploy".to_string(), "test".to_string())
.await
.unwrap();
assert!(om.node_ip().is_none());
}
#[tokio::test]
async fn reestablish_gate_is_off_before_setup() {
let om = OverlayManager::new("reestablish-gate".to_string(), "test".to_string())
.await
.unwrap();
assert!(
!om.has_global_transport(),
"global overlay must be considered absent before setup so the reconnect \
re-establish path is a no-op"
);
assert!(om.global_interface().is_none());
}
#[tokio::test]
async fn dns_config_set_and_round_trip() {
let mut om = OverlayManager::new("dns-roundtrip".to_string(), "test".to_string())
.await
.unwrap();
let addr: SocketAddr = "10.200.42.1:15353".parse().unwrap();
om.set_dns_config(Some(addr), Some("overlay.local".to_string()));
assert_eq!(om.dns_server_addr(), Some(addr));
assert_eq!(om.dns_domain(), Some("overlay.local"));
om.set_dns_config(None, None);
assert!(om.dns_server_addr().is_none());
assert!(om.dns_domain().is_none());
}
#[test]
fn peer_spec_from_copies_all_fields() {
let peer = zlayer_overlay::PeerInfo {
public_key: "base64key".to_string(),
endpoint: "1.2.3.4:51820".parse().unwrap(),
allowed_ips: "10.200.0.2/32".to_string(),
persistent_keepalive_interval: std::time::Duration::from_secs(25),
};
let spec = peer_spec_from(&peer, Vec::new());
assert_eq!(spec.public_key, "base64key");
assert_eq!(spec.endpoint, "1.2.3.4:51820");
assert_eq!(spec.allowed_ips, "10.200.0.2/32");
assert_eq!(spec.persistent_keepalive_secs, 25);
assert!(spec.candidates.is_empty());
let cands = vec![NatCandidateWire {
candidate_type: "server-reflexive".to_string(),
address: "203.0.113.5:51820".to_string(),
priority: 50,
}];
let spec = peer_spec_from(&peer, cands.clone());
assert_eq!(spec.candidates, cands);
}
#[test]
fn nat_config_to_spec_threads_credential_and_servers() {
use zlayer_overlay::nat::{RelayServerConfig, StunServerConfig, TurnServerConfig};
let cfg = NatConfig {
enabled: true,
stun_servers: vec![StunServerConfig {
address: "stun.example:3478".to_string(),
label: None,
}],
turn_servers: vec![TurnServerConfig {
address: "turn.example:3478".to_string(),
username: "u".to_string(),
credential: "p".to_string(),
region: None,
}],
hole_punch_timeout_secs: 7,
stun_refresh_interval_secs: 33,
max_candidate_pairs: 5,
relay_server: Some(RelayServerConfig {
listen_port: 3478,
external_addr: "1.2.3.4:3478".to_string(),
max_sessions: 42,
}),
};
let spec = nat_config_to_spec(&cfg, Some("cluster-secret".to_string()));
assert!(spec.enabled);
assert_eq!(spec.stun_servers, vec!["stun.example:3478".to_string()]);
assert_eq!(spec.turn_servers.len(), 1);
assert_eq!(spec.turn_servers[0].addr, "turn.example:3478");
assert_eq!(spec.hole_punch_timeout_secs, 7);
assert_eq!(spec.max_candidate_pairs, 5);
let relay = spec.relay_server.expect("relay spec present");
assert_eq!(relay.listen_port, 3478);
assert_eq!(relay.max_sessions, 42);
assert_eq!(relay.auth_credential.as_deref(), Some("cluster-secret"));
}
#[test]
fn nat_status_wire_to_snapshot_maps_fields() {
use zlayer_types::overlayd::NatPeerWire;
let wire = NatStatusWire {
candidates: vec![
NatCandidateWire {
candidate_type: "host".to_string(),
address: "192.168.1.5:51820".to_string(),
priority: 100,
},
NatCandidateWire {
candidate_type: "host".to_string(),
address: "not-an-addr".to_string(),
priority: 100,
},
],
peers: vec![NatPeerWire {
node_id: "k".to_string(),
connection_type: "hole-punched".to_string(),
remote_endpoint: Some("203.0.113.9:51820".to_string()),
}],
last_refresh: 1234,
};
let snap = nat_status_wire_to_snapshot(wire);
assert_eq!(snap.candidates.len(), 1);
assert_eq!(snap.peers.len(), 1);
assert_eq!(snap.peers[0].connection_type, "hole-punched");
assert_eq!(snap.last_refresh, 1234);
}
#[test]
fn setup_service_overlay_request_carries_dedicated_mode() {
let req = OverlaydRequest::SetupServiceOverlay {
service: "web".to_string(),
mode: zlayer_types::overlay::OverlayMode::Dedicated,
};
match req {
OverlaydRequest::SetupServiceOverlay { service, mode } => {
assert_eq!(service, "web");
assert_eq!(mode, zlayer_types::overlay::OverlayMode::Dedicated);
assert_ne!(mode, zlayer_types::overlay::OverlayMode::default());
}
other => panic!("expected SetupServiceOverlay, got {other:?}"),
}
}
#[test]
fn service_peer_ops_use_service_scope() {
let peer = zlayer_overlay::PeerInfo {
public_key: "k".to_string(),
endpoint: "1.2.3.4:51820".parse().unwrap(),
allowed_ips: "10.201.0.2/32".to_string(),
persistent_keepalive_interval: std::time::Duration::from_secs(0),
};
let svc_scope = zlayer_types::overlayd::PeerScope::Service {
service: "web".to_string(),
};
let add = OverlaydRequest::AddPeer {
peer: peer_spec_from(&peer, Vec::new()),
scope: svc_scope.clone(),
};
let allow = OverlaydRequest::AddAllowedIp {
pubkey: peer.public_key.clone(),
cidr: "10.201.0.0/24".to_string(),
scope: svc_scope.clone(),
};
let remove = OverlaydRequest::RemovePeer {
pubkey: peer.public_key.clone(),
scope: svc_scope,
};
match add {
OverlaydRequest::AddPeer { scope, peer } => {
assert_eq!(
scope,
zlayer_types::overlayd::PeerScope::Service {
service: "web".to_string()
}
);
assert_eq!(peer.public_key, "k");
}
other => panic!("expected AddPeer, got {other:?}"),
}
match allow {
OverlaydRequest::AddAllowedIp { scope, cidr, .. } => {
assert_eq!(cidr, "10.201.0.0/24");
assert_eq!(
scope,
zlayer_types::overlayd::PeerScope::Service {
service: "web".to_string()
}
);
}
other => panic!("expected AddAllowedIp, got {other:?}"),
}
match remove {
OverlaydRequest::RemovePeer { scope, pubkey } => {
assert_eq!(pubkey, "k");
assert_eq!(
scope,
zlayer_types::overlayd::PeerScope::Service {
service: "web".to_string()
}
);
}
other => panic!("expected RemovePeer, got {other:?}"),
}
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn hcn_cleanup_map_starts_empty() {
let om = OverlayManager::new("test-deploy".to_string(), "test".to_string())
.await
.unwrap();
{
let map = om.hcn_cleanup.lock().await;
assert!(
map.is_empty(),
"hcn_cleanup map must start empty from new()"
);
}
let cluster: IpNetwork = "10.200.0.0/16".parse().unwrap();
let slice: IpNetwork = "10.200.42.0/28".parse().unwrap();
let om = OverlayManager::with_slice(
"test-deploy".to_string(),
cluster,
slice,
51820,
"test".to_string(),
);
{
let map = om.hcn_cleanup.lock().await;
assert!(
map.is_empty(),
"hcn_cleanup map must start empty from with_slice()"
);
}
}
}