use std::path::Path;
use serde::{Deserialize, Serialize};
const CURRENT_VERSION: u32 = 1;
pub const OWNER_BASE: &str = "base";
#[must_use]
pub fn owner_for_service(service: &str) -> String {
format!("service:{service}")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedNetwork {
pub owner: String,
pub kind: String,
pub name: String,
pub id: String,
pub subnet: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wg_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wg_private_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wg_public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interface: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkState {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub networks: Vec<ManagedNetwork>,
}
fn default_version() -> u32 {
CURRENT_VERSION
}
impl Default for NetworkState {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
networks: Vec::new(),
}
}
}
impl NetworkState {
#[must_use]
pub fn load(path: &Path) -> Self {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[must_use]
pub fn get(&self, owner: &str) -> Option<&ManagedNetwork> {
self.networks.iter().find(|n| n.owner == owner)
}
pub fn upsert(&mut self, net: ManagedNetwork) {
if let Some(existing) = self.networks.iter_mut().find(|n| n.owner == net.owner) {
*existing = net;
} else {
self.networks.push(net);
}
}
pub fn remove(&mut self, owner: &str) -> Option<ManagedNetwork> {
self.networks
.iter()
.position(|n| n.owner == owner)
.map(|pos| self.networks.remove(pos))
}
}
pub const DEDICATED_PORT_BAND: u16 = 256;
#[derive(Debug, Clone)]
pub struct DedicatedPortAllocator {
base: u16,
used: std::collections::BTreeSet<u16>,
}
impl DedicatedPortAllocator {
pub fn new(base: u16, in_use: impl IntoIterator<Item = u16>) -> Self {
Self {
base,
used: in_use.into_iter().collect(),
}
}
fn band_start(&self) -> u16 {
self.base.saturating_add(1)
}
fn band_end(&self) -> u16 {
self.base.saturating_add(DEDICATED_PORT_BAND)
}
pub fn allocate(&mut self) -> crate::error::Result<u16> {
for port in self.band_start()..=self.band_end() {
if !self.used.contains(&port) {
self.used.insert(port);
return Ok(port);
}
}
Err(crate::error::OverlaydError::Other(format!(
"dedicated-overlay port band exhausted ({}..={}, {} ports)",
self.band_start(),
self.band_end(),
DEDICATED_PORT_BAND
)))
}
pub fn release(&mut self, port: u16) {
self.used.remove(&port);
}
pub fn reserve(&mut self, port: u16) {
self.used.insert(port);
}
#[must_use]
pub fn is_used(&self, port: u16) -> bool {
self.used.contains(&port)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(owner: &str, id: &str) -> ManagedNetwork {
ManagedNetwork {
owner: owner.to_string(),
kind: "hcn-internal".to_string(),
name: "zlayer-overlay".to_string(),
id: id.to_string(),
subnet: "10.200.0.0/28".to_string(),
wg_port: None,
wg_private_key: None,
wg_public_key: None,
interface: None,
}
}
#[test]
fn upsert_replaces_same_owner_and_get_finds_it() {
let mut st = NetworkState::default();
st.upsert(sample(OWNER_BASE, "guid-1"));
st.upsert(sample(OWNER_BASE, "guid-2")); assert_eq!(st.networks.len(), 1);
assert_eq!(st.get(OWNER_BASE).unwrap().id, "guid-2");
}
#[test]
fn distinct_owners_coexist_and_remove_targets_one() {
let mut st = NetworkState::default();
st.upsert(sample(OWNER_BASE, "base-guid"));
st.upsert(sample(&owner_for_service("web"), "web-guid"));
assert_eq!(st.networks.len(), 2);
let removed = st.remove(OWNER_BASE).expect("base entry present");
assert_eq!(removed.id, "base-guid");
assert_eq!(st.networks.len(), 1);
assert!(st.get(OWNER_BASE).is_none());
assert_eq!(st.get(&owner_for_service("web")).unwrap().id, "web-guid");
assert!(st.remove("service:nope").is_none());
}
#[test]
fn save_then_load_roundtrips() {
let dir = std::env::temp_dir().join(format!("zlayer-netstate-test-{}", std::process::id()));
let path = dir.join("agent_network.json");
let _ = std::fs::remove_dir_all(&dir);
let mut st = NetworkState::default();
st.upsert(sample(OWNER_BASE, "guid-rt"));
st.save(&path).expect("save must succeed");
let loaded = NetworkState::load(&path);
assert_eq!(loaded.version, CURRENT_VERSION);
assert_eq!(loaded.networks, st.networks);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_is_empty_default() {
let path = std::env::temp_dir().join("zlayer-netstate-does-not-exist-xyz.json");
let _ = std::fs::remove_file(&path);
let st = NetworkState::load(&path);
assert_eq!(st.version, CURRENT_VERSION);
assert!(st.networks.is_empty());
}
#[test]
fn dedicated_fields_survive_save_load_roundtrip() {
let dir = std::env::temp_dir().join(format!("zlayer-netstate-ded-{}", std::process::id()));
let path = dir.join("agent_network.json");
let _ = std::fs::remove_dir_all(&dir);
let mut net = sample(&owner_for_service("web"), "ded-guid");
net.wg_port = Some(51823);
net.wg_private_key = Some("cHJpdmF0ZS1rZXktYjY0".to_string());
net.wg_public_key = Some("cHVibGljLWtleS1iNjQ=".to_string());
net.interface = Some("zl-web0".to_string());
let mut st = NetworkState::default();
st.upsert(net.clone());
st.save(&path).expect("save must succeed");
let loaded = NetworkState::load(&path);
let got = loaded
.get(&owner_for_service("web"))
.expect("service entry present");
assert_eq!(got.wg_port, Some(51823));
assert_eq!(got.wg_private_key.as_deref(), Some("cHJpdmF0ZS1rZXktYjY0"));
assert_eq!(got.wg_public_key.as_deref(), Some("cHVibGljLWtleS1iNjQ="));
assert_eq!(got.interface.as_deref(), Some("zl-web0"));
assert_eq!(got, &net);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn older_marker_without_dedicated_fields_still_loads() {
let dir = std::env::temp_dir().join(format!("zlayer-netstate-bc-{}", std::process::id()));
let path = dir.join("agent_network.json");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let legacy = r#"{
"version": 1,
"networks": [
{
"owner": "base",
"kind": "hcn-internal",
"name": "zlayer-overlay",
"id": "legacy-guid",
"subnet": "10.200.0.0/28"
}
]
}"#;
std::fs::write(&path, legacy).expect("write legacy marker");
let loaded = NetworkState::load(&path);
let got = loaded.get(OWNER_BASE).expect("base entry present");
assert_eq!(got.id, "legacy-guid");
assert_eq!(got.wg_port, None);
assert_eq!(got.wg_private_key, None);
assert_eq!(got.wg_public_key, None);
assert_eq!(got.interface, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn allocate_returns_distinct_ascending_ports() {
let mut alloc = DedicatedPortAllocator::new(51820, std::iter::empty());
let a = alloc.allocate().expect("port a");
let b = alloc.allocate().expect("port b");
let c = alloc.allocate().expect("port c");
assert_eq!(a, 51821);
assert_eq!(b, 51822);
assert_eq!(c, 51823);
}
#[test]
fn release_then_allocate_reuses_freed_port() {
let mut alloc = DedicatedPortAllocator::new(51820, std::iter::empty());
let a = alloc.allocate().expect("port a");
let b = alloc.allocate().expect("port b");
assert_eq!(a, 51821);
assert_eq!(b, 51822);
alloc.release(a);
let reused = alloc.allocate().expect("reused port");
assert_eq!(reused, 51821);
}
#[test]
fn reserved_port_is_skipped_by_allocate() {
let mut alloc = DedicatedPortAllocator::new(51820, [51821]);
assert!(alloc.is_used(51821));
let first = alloc.allocate().expect("first allocation");
assert_eq!(first, 51822);
alloc.reserve(51823);
let next = alloc.allocate().expect("next allocation");
assert_eq!(next, 51824);
}
#[test]
fn band_exhaustion_errors() {
let base = 51820u16;
let full: Vec<u16> = (base + 1..=base + DEDICATED_PORT_BAND).collect();
let mut alloc = DedicatedPortAllocator::new(base, full);
let err = alloc.allocate().expect_err("band must be exhausted");
assert!(
matches!(err, crate::error::OverlaydError::Other(ref m) if m.contains("exhausted")),
"unexpected error: {err:?}"
);
}
}