Skip to main content

zlayer_overlay/
bootstrap.rs

1//! Overlay network bootstrap functionality
2//!
3//! Provides initialization and joining capabilities for overlay networks,
4//! including keypair generation, interface creation, and peer management.
5
6use crate::allocator::IpAllocator;
7use crate::allocator::{NodeSliceAllocator, NodeSliceAllocatorSnapshot};
8use crate::config::PeerInfo;
9use crate::dns::{peer_hostname, DnsConfig, DnsHandle, DnsServer, DEFAULT_DNS_PORT};
10use crate::error::{OverlayError, Result};
11#[cfg(feature = "nat")]
12use crate::nat::{Candidate, ConnectionType, NatTraversal, RelayServer};
13use crate::transport::OverlayTransport;
14use ipnet::IpNet;
15use serde::{Deserialize, Serialize};
16use std::net::{IpAddr, SocketAddr};
17use std::path::{Path, PathBuf};
18use std::time::Duration;
19use tracing::{debug, info, warn};
20
21/// Default overlay interface name for `ZLayer`
22///
23/// On macOS, this is `"utun"` which tells boringtun to let the kernel
24/// auto-assign a `utunN` device. On Linux, a custom name is used.
25#[cfg(target_os = "macos")]
26pub const DEFAULT_INTERFACE_NAME: &str = "utun";
27#[cfg(not(target_os = "macos"))]
28pub const DEFAULT_INTERFACE_NAME: &str = "zl-overlay0";
29
30/// Default overlay listen port (re-exported from `zlayer-core`).
31pub use zlayer_core::DEFAULT_WG_PORT;
32
33/// Default overlay network CIDR (IPv4)
34pub const DEFAULT_OVERLAY_CIDR: &str = "10.200.0.0/16";
35
36/// Default overlay network CIDR (IPv6)
37///
38/// Uses a ULA (Unique Local Address) prefix in the `fd00::/8` range.
39/// The `fd00:200::/48` prefix mirrors the IPv4 `10.200.0.0/16` convention.
40pub const DEFAULT_OVERLAY_CIDR_V6: &str = "fd00:200::/48";
41
42/// Default persistent keepalive interval (seconds)
43pub const DEFAULT_KEEPALIVE_SECS: u16 = 25;
44
45/// Default per-node slice prefix length for carving the cluster CIDR into
46/// per-node allocation slices. A `/28` gives each node 16 addresses
47/// (14 usable hosts) from the cluster `/16`, supporting up to 4096 nodes.
48pub const DEFAULT_SLICE_PREFIX: u8 = 28;
49
50/// Serde helper for `Option<IpNet>`: serialize as a CIDR string (e.g.
51/// `"10.200.7.0/28"`), so we don't depend on `ipnet`'s optional `serde`
52/// feature flag. Keeps persisted state compact and human-readable.
53mod option_ipnet_str {
54    use ipnet::IpNet;
55    use serde::{Deserialize, Deserializer, Serialize, Serializer};
56
57    #[allow(clippy::ref_option)]
58    pub fn serialize<S>(value: &Option<IpNet>, serializer: S) -> Result<S::Ok, S::Error>
59    where
60        S: Serializer,
61    {
62        value.map(|v| v.to_string()).serialize(serializer)
63    }
64
65    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<IpNet>, D::Error>
66    where
67        D: Deserializer<'de>,
68    {
69        let opt = Option::<String>::deserialize(deserializer)?;
70        match opt {
71            None => Ok(None),
72            Some(s) => s
73                .parse::<IpNet>()
74                .map(Some)
75                .map_err(serde::de::Error::custom),
76        }
77    }
78}
79
80/// Overlay network bootstrap configuration
81///
82/// Contains all configuration needed to initialize and manage
83/// an overlay network on a node.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct BootstrapConfig {
86    /// Network CIDR (e.g., "10.200.0.0/16")
87    pub cidr: String,
88
89    /// This node's overlay IP address (IPv4 or IPv6)
90    pub node_ip: IpAddr,
91
92    /// Overlay interface name
93    pub interface: String,
94
95    /// Overlay listen port
96    pub port: u16,
97
98    /// This node's overlay private key
99    pub private_key: String,
100
101    /// This node's overlay public key
102    pub public_key: String,
103
104    /// Whether this node is the cluster leader
105    pub is_leader: bool,
106
107    /// Creation timestamp (Unix epoch seconds)
108    pub created_at: u64,
109
110    /// Per-node slice of the cluster CIDR that this node draws container
111    /// IPs from. `None` for pre-slice-aware configs (back-compat). When set,
112    /// this replaces `node_ip/32` in `allowed_ip()`.
113    #[serde(default, with = "option_ipnet_str")]
114    pub slice_cidr: Option<IpNet>,
115}
116
117impl BootstrapConfig {
118    /// Get the overlay IP with host prefix for allowed IPs
119    ///
120    /// When `slice_cidr` is set, returns the slice CIDR string unchanged so
121    /// the overlay accepts the full per-node slice of container IPs. Otherwise
122    /// falls back to the legacy `/32` (IPv4) or `/128` (IPv6) single-host
123    /// representation of `node_ip` for back-compat with pre-slice configs.
124    #[must_use]
125    pub fn allowed_ip(&self) -> String {
126        if let Some(slice) = self.slice_cidr {
127            return slice.to_string();
128        }
129        let prefix = match self.node_ip {
130            IpAddr::V4(_) => 32,
131            IpAddr::V6(_) => 128,
132        };
133        format!("{}/{}", self.node_ip, prefix)
134    }
135}
136
137/// Peer configuration for overlay network
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct PeerConfig {
140    /// Peer's node ID (for identification)
141    pub node_id: String,
142
143    /// Peer's overlay public key
144    pub public_key: String,
145
146    /// Peer's public endpoint (host:port)
147    pub endpoint: String,
148
149    /// Peer's overlay IP address (IPv4 or IPv6)
150    pub overlay_ip: IpAddr,
151
152    /// Optional persistent keepalive interval in seconds
153    #[serde(default)]
154    pub keepalive: Option<u16>,
155
156    /// Optional custom DNS hostname for this peer (without zone suffix)
157    /// If provided, the peer will be registered with this name in addition
158    /// to the auto-generated IP-based hostname.
159    #[serde(default)]
160    pub hostname: Option<String>,
161
162    /// NAT traversal candidates for this peer
163    #[serde(default)]
164    #[cfg(feature = "nat")]
165    pub candidates: Vec<Candidate>,
166
167    /// How this peer is currently connected
168    #[serde(default)]
169    #[cfg(feature = "nat")]
170    pub connection_type: ConnectionType,
171
172    /// Peer's per-node slice CIDR. `None` on old configs; when set, used as
173    /// the peer's `allowed_ips` instead of `overlay_ip/32`.
174    #[serde(default, with = "option_ipnet_str")]
175    pub slice_cidr: Option<IpNet>,
176}
177
178impl PeerConfig {
179    /// Create a new peer configuration
180    #[must_use]
181    pub fn new(node_id: String, public_key: String, endpoint: String, overlay_ip: IpAddr) -> Self {
182        Self {
183            node_id,
184            public_key,
185            endpoint,
186            overlay_ip,
187            keepalive: Some(DEFAULT_KEEPALIVE_SECS),
188            hostname: None,
189            #[cfg(feature = "nat")]
190            candidates: Vec::new(),
191            #[cfg(feature = "nat")]
192            connection_type: ConnectionType::default(),
193            slice_cidr: None,
194        }
195    }
196
197    /// Set a custom DNS hostname for this peer
198    #[must_use]
199    pub fn with_hostname(mut self, hostname: impl Into<String>) -> Self {
200        self.hostname = Some(hostname.into());
201        self
202    }
203
204    /// Set the per-node slice CIDR for this peer.
205    ///
206    /// When set, `to_peer_info()` uses the slice CIDR as the peer's
207    /// `allowed_ips` (so the overlay accepts the peer's full slice of
208    /// container IPs) instead of the legacy single-host `overlay_ip/32`.
209    #[must_use]
210    pub fn with_slice_cidr(mut self, cidr: IpNet) -> Self {
211        self.slice_cidr = Some(cidr);
212        self
213    }
214
215    /// Convert to `PeerInfo` for overlay transport configuration
216    ///
217    /// When `slice_cidr` is set, the peer's `allowed_ips` is the slice CIDR
218    /// string. Otherwise, falls back to the legacy `overlay_ip/{32,128}`
219    /// host-prefix representation for back-compat.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the endpoint address cannot be parsed.
224    pub fn to_peer_info(&self) -> std::result::Result<PeerInfo, Box<dyn std::error::Error>> {
225        let endpoint: SocketAddr = self.endpoint.parse()?;
226        let keepalive =
227            Duration::from_secs(u64::from(self.keepalive.unwrap_or(DEFAULT_KEEPALIVE_SECS)));
228
229        let allowed_ips = if let Some(slice) = self.slice_cidr {
230            slice.to_string()
231        } else {
232            let prefix = match self.overlay_ip {
233                IpAddr::V4(_) => 32,
234                IpAddr::V6(_) => 128,
235            };
236            format!("{}/{}", self.overlay_ip, prefix)
237        };
238
239        Ok(PeerInfo::new(
240            self.public_key.clone(),
241            endpoint,
242            &allowed_ips,
243            keepalive,
244        ))
245    }
246}
247
248/// Persistent state for the overlay bootstrap
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct BootstrapState {
251    /// Bootstrap configuration
252    pub config: BootstrapConfig,
253
254    /// List of configured peers
255    pub peers: Vec<PeerConfig>,
256
257    /// IP allocator state (only for leader)
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub allocator_state: Option<crate::allocator::IpAllocatorState>,
260
261    /// Per-node slice allocator state (only for leader).
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub slice_allocator_state: Option<NodeSliceAllocatorSnapshot>,
264}
265
266/// Bootstrap manager for overlay network
267///
268/// Handles overlay network initialization, peer management,
269/// and overlay transport interface configuration.
270pub struct OverlayBootstrap {
271    /// Bootstrap configuration
272    config: BootstrapConfig,
273
274    /// Configured peers
275    peers: Vec<PeerConfig>,
276
277    /// Data directory for persistent state
278    data_dir: PathBuf,
279
280    /// IP allocator (only for leader nodes)
281    allocator: Option<IpAllocator>,
282
283    /// Per-node slice allocator (only for leader nodes) — carves the cluster
284    /// CIDR into `/28` slices assigned to each joining node. Added to fix the
285    /// latent IP-collision bug where every agent independently allocated
286    /// container IPs from the full cluster `/16`.
287    slice_allocator: Option<NodeSliceAllocator>,
288
289    /// DNS configuration (opt-in)
290    dns_config: Option<DnsConfig>,
291
292    /// DNS handle for managing records (available after `start()` if DNS enabled)
293    dns_handle: Option<DnsHandle>,
294
295    /// Overlay transport (boringtun device handle).
296    ///
297    /// Must be kept alive for the overlay network lifetime; dropping the
298    /// transport destroys the TUN device.
299    transport: Option<OverlayTransport>,
300
301    /// NAT traversal orchestrator (available after `start()` if NAT is enabled)
302    #[cfg(feature = "nat")]
303    nat_traversal: Option<NatTraversal>,
304
305    /// Built-in relay server (available after `start()` if relay is configured)
306    #[cfg(feature = "nat")]
307    relay_server: Option<RelayServer>,
308}
309
310impl OverlayBootstrap {
311    /// Initialize as cluster leader (first node in the overlay)
312    ///
313    /// This generates a new overlay keypair, allocates the first IP
314    /// in the CIDR range, and prepares the node as the overlay leader.
315    ///
316    /// # Arguments
317    /// * `cidr` - Overlay network CIDR (e.g., "10.200.0.0/16")
318    /// * `port` - Overlay listen port
319    /// * `data_dir` - Directory for persistent state
320    ///
321    /// # Example
322    /// ```ignore
323    /// let bootstrap = OverlayBootstrap::init_leader(
324    ///     "10.200.0.0/16",
325    ///     51820,
326    ///     Path::new("/var/lib/zlayer"),
327    /// ).await?;
328    /// ```
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if already initialized, key generation fails, or state cannot be saved.
333    pub async fn init_leader(cidr: &str, port: u16, data_dir: &Path) -> Result<Self> {
334        // Check if already initialized
335        let config_path = data_dir.join("overlay_bootstrap.json");
336        if config_path.exists() {
337            return Err(OverlayError::AlreadyInitialized(
338                config_path.display().to_string(),
339            ));
340        }
341
342        // Ensure data directory exists
343        tokio::fs::create_dir_all(data_dir).await?;
344
345        // Generate overlay keypair
346        info!("Generating overlay keypair for leader");
347        let (private_key, public_key) = OverlayTransport::generate_keys()
348            .await
349            .map_err(|e| OverlayError::TransportCommand(e.to_string()))?;
350
351        // Initialize IP allocator (legacy flat allocator, kept in sync for
352        // back-compat with callers that still read it) and allocate its
353        // first IP so its state tracks at least the leader.
354        let mut allocator = IpAllocator::new(cidr)?;
355        let _legacy_first = allocator.allocate_first()?;
356
357        // Initialize the per-node slice allocator and carve the leader's
358        // own slice. The leader's `node_ip` is taken as the first usable
359        // host of its slice so it always lives inside the slice it owns.
360        let cluster_cidr: IpNet = cidr
361            .parse()
362            .map_err(|e: ipnet::AddrParseError| OverlayError::InvalidCidr(e.to_string()))?;
363        let mut slice_allocator = NodeSliceAllocator::new(cluster_cidr, DEFAULT_SLICE_PREFIX)?;
364        let leader_slice = slice_allocator.assign("leader")?;
365        let node_ip = leader_slice.hosts().next().unwrap_or_else(|| {
366            // Fall back to network() + 1 for very small slices where
367            // `hosts()` can return empty. We still need a valid address
368            // to assign to the interface.
369            match leader_slice.network() {
370                IpAddr::V4(v4) => {
371                    let bits = u32::from(v4).saturating_add(1);
372                    IpAddr::V4(std::net::Ipv4Addr::from(bits))
373                }
374                IpAddr::V6(v6) => {
375                    let bits = u128::from(v6).saturating_add(1);
376                    IpAddr::V6(std::net::Ipv6Addr::from(bits))
377                }
378            }
379        });
380
381        info!(
382            node_ip = %node_ip,
383            cidr = cidr,
384            slice = %leader_slice,
385            "Allocated leader IP from slice"
386        );
387
388        // Create config
389        let config = BootstrapConfig {
390            cidr: cidr.to_string(),
391            node_ip,
392            interface: DEFAULT_INTERFACE_NAME.to_string(),
393            port,
394            private_key,
395            public_key,
396            is_leader: true,
397            created_at: current_timestamp(),
398            slice_cidr: Some(leader_slice),
399        };
400
401        let bootstrap = Self {
402            config,
403            peers: Vec::new(),
404            data_dir: data_dir.to_path_buf(),
405            allocator: Some(allocator),
406            slice_allocator: Some(slice_allocator),
407            dns_config: None,
408            dns_handle: None,
409            transport: None,
410            #[cfg(feature = "nat")]
411            nat_traversal: None,
412            #[cfg(feature = "nat")]
413            relay_server: None,
414        };
415
416        // Persist state
417        bootstrap.save().await?;
418
419        Ok(bootstrap)
420    }
421
422    /// Join an existing overlay network
423    ///
424    /// Generates a new overlay keypair and configures this node
425    /// to connect to an existing overlay network.
426    ///
427    /// # Arguments
428    /// * `leader_cidr` - Leader's overlay network CIDR
429    /// * `leader_endpoint` - Leader's public endpoint (host:port)
430    /// * `leader_public_key` - Leader's overlay public key
431    /// * `leader_overlay_ip` - Leader's overlay IP address
432    /// * `allocated_ip` - IP address allocated for this node by the leader
433    /// * `port` - Overlay listen port for this node
434    /// * `slice_cidr` - Per-node slice assigned by the leader (if any). When
435    ///   set, the worker uses this slice as its `allowed_ip`; `None` preserves
436    ///   the legacy `allocated_ip/32` behavior for pre-slice-aware clusters.
437    /// * `data_dir` - Directory for persistent state
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if already initialized, key generation fails, or state cannot be saved.
442    #[allow(clippy::too_many_arguments)]
443    pub async fn join(
444        leader_cidr: &str,
445        leader_endpoint: &str,
446        leader_public_key: &str,
447        leader_overlay_ip: IpAddr,
448        allocated_ip: IpAddr,
449        port: u16,
450        slice_cidr: Option<IpNet>,
451        data_dir: &Path,
452    ) -> Result<Self> {
453        // Check if already initialized
454        let config_path = data_dir.join("overlay_bootstrap.json");
455        if config_path.exists() {
456            return Err(OverlayError::AlreadyInitialized(
457                config_path.display().to_string(),
458            ));
459        }
460
461        // Ensure data directory exists
462        tokio::fs::create_dir_all(data_dir).await?;
463
464        // Generate overlay keypair for this node
465        info!("Generating overlay keypair for joining node");
466        let (private_key, public_key) = OverlayTransport::generate_keys()
467            .await
468            .map_err(|e| OverlayError::TransportCommand(e.to_string()))?;
469
470        // Create config
471        let config = BootstrapConfig {
472            cidr: leader_cidr.to_string(),
473            node_ip: allocated_ip,
474            interface: DEFAULT_INTERFACE_NAME.to_string(),
475            port,
476            private_key,
477            public_key,
478            is_leader: false,
479            created_at: current_timestamp(),
480            slice_cidr,
481        };
482
483        // Add leader as the first peer
484        let leader_peer = PeerConfig {
485            node_id: "leader".to_string(),
486            public_key: leader_public_key.to_string(),
487            endpoint: leader_endpoint.to_string(),
488            overlay_ip: leader_overlay_ip,
489            keepalive: Some(DEFAULT_KEEPALIVE_SECS),
490            hostname: None, // Leader gets its own DNS alias "leader.zone"
491            #[cfg(feature = "nat")]
492            candidates: Vec::new(),
493            #[cfg(feature = "nat")]
494            connection_type: ConnectionType::default(),
495            slice_cidr: None,
496        };
497
498        info!(
499            leader_endpoint = leader_endpoint,
500            overlay_ip = %allocated_ip,
501            "Configured leader as peer"
502        );
503
504        let bootstrap = Self {
505            config,
506            peers: vec![leader_peer],
507            data_dir: data_dir.to_path_buf(),
508            allocator: None, // Workers don't manage IP allocation
509            slice_allocator: None,
510            dns_config: None,
511            dns_handle: None,
512            transport: None,
513            #[cfg(feature = "nat")]
514            nat_traversal: None,
515            #[cfg(feature = "nat")]
516            relay_server: None,
517        };
518
519        // Persist state
520        bootstrap.save().await?;
521
522        Ok(bootstrap)
523    }
524
525    /// Load existing bootstrap state from disk
526    ///
527    /// # Errors
528    ///
529    /// Returns an error if the state file is missing, unreadable, or invalid.
530    pub async fn load(data_dir: &Path) -> Result<Self> {
531        let config_path = data_dir.join("overlay_bootstrap.json");
532
533        if !config_path.exists() {
534            return Err(OverlayError::NotInitialized);
535        }
536
537        let contents = tokio::fs::read_to_string(&config_path).await?;
538        let state: BootstrapState = serde_json::from_str(&contents)?;
539
540        let allocator = if let Some(alloc_state) = state.allocator_state {
541            Some(IpAllocator::from_state(alloc_state)?)
542        } else {
543            None
544        };
545
546        let slice_allocator = if let Some(snapshot) = state.slice_allocator_state {
547            Some(NodeSliceAllocator::restore(snapshot)?)
548        } else {
549            None
550        };
551
552        Ok(Self {
553            config: state.config,
554            peers: state.peers,
555            data_dir: data_dir.to_path_buf(),
556            allocator,
557            slice_allocator,
558            dns_config: None, // DNS config must be re-enabled after load
559            dns_handle: None,
560            transport: None,
561            #[cfg(feature = "nat")]
562            nat_traversal: None,
563            #[cfg(feature = "nat")]
564            relay_server: None,
565        })
566    }
567
568    /// Save bootstrap state to disk
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if serialization or file writing fails.
573    pub async fn save(&self) -> Result<()> {
574        let config_path = self.data_dir.join("overlay_bootstrap.json");
575
576        let state = BootstrapState {
577            config: self.config.clone(),
578            peers: self.peers.clone(),
579            allocator_state: self
580                .allocator
581                .as_ref()
582                .map(super::allocator::IpAllocator::to_state),
583            slice_allocator_state: self
584                .slice_allocator
585                .as_ref()
586                .map(NodeSliceAllocator::snapshot),
587        };
588
589        let contents = serde_json::to_string_pretty(&state)?;
590        tokio::fs::write(&config_path, contents).await?;
591
592        debug!(path = %config_path.display(), "Saved bootstrap state");
593        Ok(())
594    }
595
596    /// Enable DNS service discovery for the overlay network
597    ///
598    /// When DNS is enabled, peers are automatically registered with both:
599    /// - An IP-based hostname: `node-X-Y.zone` (e.g., `node-0-5.overlay.local`)
600    /// - A custom hostname if provided in `PeerConfig`
601    ///
602    /// The leader node additionally gets a `leader.zone` alias.
603    ///
604    /// # Arguments
605    /// * `zone` - DNS zone (e.g., "overlay.local.")
606    /// * `port` - DNS server port (default: 15353 to avoid conflicts)
607    ///
608    /// # Example
609    /// ```ignore
610    /// let bootstrap = OverlayBootstrap::init_leader(cidr, port, data_dir)
611    ///     .await?
612    ///     .with_dns("overlay.local.", 15353)?;
613    /// bootstrap.start().await?;
614    /// ```
615    ///
616    /// # Errors
617    ///
618    /// This method currently always succeeds but returns `Result` for API consistency.
619    pub fn with_dns(mut self, zone: &str, port: u16) -> Result<Self> {
620        self.dns_config = Some(DnsConfig {
621            zone: zone.to_string(),
622            port,
623            bind_addr: self.config.node_ip,
624        });
625        Ok(self)
626    }
627
628    /// Enable DNS with default port (15353)
629    ///
630    /// # Errors
631    ///
632    /// This method currently always succeeds but returns `Result` for API consistency.
633    pub fn with_dns_default(self, zone: &str) -> Result<Self> {
634        self.with_dns(zone, DEFAULT_DNS_PORT)
635    }
636
637    /// Get the DNS handle for managing records
638    ///
639    /// Returns None if DNS is not enabled or `start()` hasn't been called yet.
640    #[must_use]
641    pub fn dns_handle(&self) -> Option<&DnsHandle> {
642        self.dns_handle.as_ref()
643    }
644
645    /// Check if DNS is enabled
646    #[must_use]
647    pub fn dns_enabled(&self) -> bool {
648        self.dns_config.is_some()
649    }
650
651    /// Start the overlay network (create and configure overlay transport)
652    ///
653    /// This creates the boringtun TUN interface, assigns the overlay IP,
654    /// configures all known peers, and starts the DNS server if enabled.
655    ///
656    /// # Errors
657    ///
658    /// Returns an error if interface creation, peer configuration, or DNS startup fails.
659    pub async fn start(&mut self) -> Result<()> {
660        info!(
661            interface = %self.config.interface,
662            overlay_ip = %self.config.node_ip,
663            port = self.config.port,
664            dns_enabled = self.dns_config.is_some(),
665            "Starting overlay network"
666        );
667
668        // Convert our config to OverlayConfig
669        let overlay_config = crate::config::OverlayConfig {
670            local_endpoint: SocketAddr::new(
671                std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
672                self.config.port,
673            ),
674            private_key: self.config.private_key.clone(),
675            public_key: self.config.public_key.clone(),
676            overlay_cidr: self.config.allowed_ip(),
677            cluster_cidr: Some(self.config.cidr.clone()),
678            peer_discovery_interval: Duration::from_secs(30),
679            #[cfg(feature = "nat")]
680            nat: crate::nat::NatConfig::default(),
681        };
682
683        #[cfg(feature = "nat")]
684        let nat_config = overlay_config.nat.clone();
685
686        // Create overlay transport
687        let mut transport = OverlayTransport::new(overlay_config, self.config.interface.clone());
688
689        // Create the interface
690        transport
691            .create_interface()
692            .await
693            .map_err(|e| OverlayError::TransportCommand(e.to_string()))?;
694
695        // On macOS, the kernel assigns a utunN name that may differ from
696        // the requested name. Update our config to reflect the actual name.
697        let actual_name = transport.interface_name().to_string();
698        if actual_name != self.config.interface {
699            info!(
700                requested = %self.config.interface,
701                actual = %actual_name,
702                "Interface name resolved by kernel"
703            );
704            self.config.interface = actual_name;
705        }
706
707        // Convert peers to PeerInfo
708        let peer_infos: Vec<PeerInfo> = self
709            .peers
710            .iter()
711            .filter_map(|p| match p.to_peer_info() {
712                Ok(info) => Some(info),
713                Err(e) => {
714                    warn!(peer = %p.node_id, error = %e, "Failed to parse peer info");
715                    None
716                }
717            })
718            .collect();
719
720        // Configure transport with peers
721        transport
722            .configure(&peer_infos)
723            .await
724            .map_err(|e| OverlayError::TransportCommand(e.to_string()))?;
725
726        // Store the transport so the TUN device stays alive for the overlay
727        // lifetime. Dropping the OverlayTransport destroys the boringtun device.
728        self.transport = Some(transport);
729
730        // NAT traversal: gather candidates and connect to peers
731        #[cfg(feature = "nat")]
732        self.start_nat_traversal(nat_config).await;
733
734        // Start DNS server if configured
735        self.start_dns().await?;
736
737        info!("Overlay network started successfully");
738        Ok(())
739    }
740
741    /// Start the DNS server and register all known peers.
742    async fn start_dns(&mut self) -> Result<()> {
743        let Some(dns_config) = &self.dns_config else {
744            return Ok(());
745        };
746
747        info!(
748            zone = %dns_config.zone,
749            port = dns_config.port,
750            "Starting DNS server for overlay"
751        );
752
753        let dns_server =
754            DnsServer::from_config(dns_config).map_err(|e| OverlayError::Dns(e.to_string()))?;
755
756        // Register self with IP-based hostname
757        let self_hostname = peer_hostname(self.config.node_ip);
758        dns_server
759            .add_record(&self_hostname, self.config.node_ip)
760            .await
761            .map_err(|e| OverlayError::Dns(e.to_string()))?;
762
763        // If leader, also register "leader" alias
764        if self.config.is_leader {
765            dns_server
766                .add_record("leader", self.config.node_ip)
767                .await
768                .map_err(|e| OverlayError::Dns(e.to_string()))?;
769            debug!(ip = %self.config.node_ip, "Registered leader.{}", dns_config.zone);
770        }
771
772        // Register existing peers
773        for peer in &self.peers {
774            // Always register IP-based hostname
775            let hostname = peer_hostname(peer.overlay_ip);
776            dns_server
777                .add_record(&hostname, peer.overlay_ip)
778                .await
779                .map_err(|e| OverlayError::Dns(e.to_string()))?;
780
781            // Also register custom hostname if provided
782            if let Some(custom) = &peer.hostname {
783                dns_server
784                    .add_record(custom, peer.overlay_ip)
785                    .await
786                    .map_err(|e| OverlayError::Dns(e.to_string()))?;
787                debug!(
788                    hostname = custom,
789                    ip = %peer.overlay_ip,
790                    "Registered custom hostname"
791                );
792            }
793        }
794
795        // Start the DNS server and store the handle
796        let handle = dns_server
797            .start()
798            .await
799            .map_err(|e| OverlayError::Dns(e.to_string()))?;
800        self.dns_handle = Some(handle);
801
802        info!("DNS server started successfully");
803        Ok(())
804    }
805
806    /// Initialize NAT traversal, gather candidates, and connect to known peers.
807    #[cfg(feature = "nat")]
808    async fn start_nat_traversal(&mut self, nat_config: crate::nat::NatConfig) {
809        if !nat_config.enabled {
810            return;
811        }
812
813        // Optionally start built-in relay server
814        if let Some(ref relay_config) = nat_config.relay_server {
815            let relay = RelayServer::new(relay_config, &self.config.private_key);
816            match relay.start().await {
817                Ok(()) => {
818                    info!("Built-in relay server started");
819                    self.relay_server = Some(relay);
820                }
821                Err(e) => {
822                    warn!(error = %e, "Failed to start relay server");
823                }
824            }
825        }
826
827        let mut nat = NatTraversal::new(nat_config, self.config.port);
828        match nat.gather_candidates().await {
829            Ok(candidates) => {
830                info!(count = candidates.len(), "Gathered NAT candidates");
831                if let Some(ref transport) = self.transport {
832                    for peer in &mut self.peers {
833                        if !peer.candidates.is_empty() {
834                            match nat
835                                .connect_to_peer(transport, &peer.public_key, &peer.candidates)
836                                .await
837                            {
838                                Ok(ct) => {
839                                    peer.connection_type = ct;
840                                    info!(
841                                        peer = %peer.node_id,
842                                        connection = %ct,
843                                        "NAT traversal succeeded"
844                                    );
845                                }
846                                Err(e) => warn!(
847                                    peer = %peer.node_id,
848                                    error = %e,
849                                    "NAT traversal failed"
850                                ),
851                            }
852                        }
853                    }
854                }
855                self.nat_traversal = Some(nat);
856            }
857            Err(e) => warn!(error = %e, "NAT candidate gathering failed"),
858        }
859    }
860
861    /// Stop the overlay network (shut down the boringtun transport)
862    ///
863    /// # Errors
864    ///
865    /// This method currently always succeeds but returns `Result` for API consistency.
866    #[allow(clippy::unused_async)]
867    pub async fn stop(&mut self) -> Result<()> {
868        info!(interface = %self.config.interface, "Stopping overlay network");
869
870        if let Some(mut transport) = self.transport.take() {
871            transport.shutdown();
872        }
873
874        Ok(())
875    }
876
877    /// Replay assigned slices from persisted `PeerConfig.slice_cidr` values
878    /// back into the slice allocator. Call on leader startup after `load()`
879    /// so the in-memory allocator matches what's on disk.
880    ///
881    /// No-op for workers (who have no allocator).
882    ///
883    /// # Errors
884    ///
885    /// Returns an error if the snapshot rebuild fails.
886    pub fn reconcile_existing_peers(&mut self) -> Result<()> {
887        let Some(ref mut allocator) = self.slice_allocator else {
888            return Ok(());
889        };
890        // Collect into a snapshot so we can restore cleanly.
891        let mut assigned: Vec<(String, String)> = Vec::new();
892        for peer in &self.peers {
893            if let Some(slice) = peer.slice_cidr {
894                assigned.push((peer.node_id.clone(), slice.to_string()));
895            }
896        }
897        if assigned.is_empty() {
898            return Ok(());
899        }
900        let snapshot = NodeSliceAllocatorSnapshot {
901            cluster_cidr: allocator.cluster_cidr().to_string(),
902            slice_prefix: allocator.slice_prefix(),
903            assigned,
904        };
905        *allocator = NodeSliceAllocator::restore(snapshot)?;
906        Ok(())
907    }
908
909    /// Add a new peer to the overlay network
910    ///
911    /// For leader nodes, this also allocates an IP address for the peer.
912    ///
913    /// # Errors
914    ///
915    /// Returns an error if no IPs are available, DNS registration fails, or state cannot be saved.
916    pub async fn add_peer(&mut self, mut peer: PeerConfig) -> Result<IpAddr> {
917        // If we're the leader, allocate an IP for this peer
918        let overlay_ip = if let Some(ref mut allocator) = self.allocator {
919            let ip = allocator.allocate().ok_or(OverlayError::NoAvailableIps)?;
920            peer.overlay_ip = ip;
921            ip
922        } else {
923            peer.overlay_ip
924        };
925
926        // If we're the leader and have a slice allocator, assign a /28 slice
927        // to this peer. Peers track their slice so `to_peer_info()` can emit the
928        // correct `allowed_ips`.
929        if let Some(ref mut slice_allocator) = self.slice_allocator {
930            let slice = slice_allocator.assign(&peer.node_id)?;
931            peer.slice_cidr = Some(slice);
932            // The peer's overlay_ip (reallocated above from the flat allocator)
933            // is left as-is for compatibility with callers that still read it;
934            // the authoritative routing info is `slice_cidr`.
935        }
936
937        // Add peer to overlay transport via UAPI
938        if let Ok(peer_info) = peer.to_peer_info() {
939            // Prefer the stored transport; fall back to a temporary instance
940            // (UAPI calls work via the Unix socket regardless of DeviceHandle)
941            let transport_ref: Option<&OverlayTransport> = self.transport.as_ref();
942
943            let result = if let Some(t) = transport_ref {
944                t.add_peer(&peer_info).await
945            } else {
946                let overlay_config = crate::config::OverlayConfig {
947                    local_endpoint: SocketAddr::new(
948                        std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
949                        self.config.port,
950                    ),
951                    private_key: self.config.private_key.clone(),
952                    public_key: self.config.public_key.clone(),
953                    overlay_cidr: self.config.allowed_ip(),
954                    cluster_cidr: Some(self.config.cidr.clone()),
955                    peer_discovery_interval: Duration::from_secs(30),
956                    #[cfg(feature = "nat")]
957                    nat: crate::nat::NatConfig::default(),
958                };
959                let tmp = OverlayTransport::new(overlay_config, self.config.interface.clone());
960                tmp.add_peer(&peer_info).await
961            };
962
963            match result {
964                Ok(()) => debug!(peer = %peer.node_id, "Added peer to overlay"),
965                Err(e) => {
966                    warn!(peer = %peer.node_id, error = %e, "Failed to add peer to overlay (interface may not be up)");
967                }
968            }
969        }
970
971        // Register peer in DNS if enabled
972        if let Some(ref dns_handle) = self.dns_handle {
973            // IP-based hostname
974            let hostname = peer_hostname(overlay_ip);
975            dns_handle
976                .add_record(&hostname, overlay_ip)
977                .await
978                .map_err(|e| OverlayError::Dns(e.to_string()))?;
979            debug!(hostname = %hostname, ip = %overlay_ip, "Registered peer in DNS");
980
981            // Custom hostname alias if provided
982            if let Some(ref custom) = peer.hostname {
983                dns_handle
984                    .add_record(custom, overlay_ip)
985                    .await
986                    .map_err(|e| OverlayError::Dns(e.to_string()))?;
987                debug!(hostname = %custom, ip = %overlay_ip, "Registered custom hostname in DNS");
988            }
989        }
990
991        // NAT traversal for new peer
992        #[cfg(feature = "nat")]
993        {
994            if let (Some(ref nat), Some(ref transport)) = (&self.nat_traversal, &self.transport) {
995                if !peer.candidates.is_empty() {
996                    match nat
997                        .connect_to_peer(transport, &peer.public_key, &peer.candidates)
998                        .await
999                    {
1000                        Ok(ct) => {
1001                            peer.connection_type = ct;
1002                            info!(
1003                                peer = %peer.node_id,
1004                                connection = %ct,
1005                                "NAT traversal for new peer"
1006                            );
1007                        }
1008                        Err(e) => warn!(
1009                            peer = %peer.node_id,
1010                            error = %e,
1011                            "NAT failed for new peer"
1012                        ),
1013                    }
1014                }
1015            }
1016        }
1017
1018        // Add to peer list
1019        self.peers.push(peer);
1020
1021        // Persist state
1022        self.save().await?;
1023
1024        info!(peer_ip = %overlay_ip, "Added peer to overlay");
1025        Ok(overlay_ip)
1026    }
1027
1028    /// Remove a peer from the overlay network
1029    ///
1030    /// # Errors
1031    ///
1032    /// Returns an error if the peer is not found, DNS removal fails, or state cannot be saved.
1033    pub async fn remove_peer(&mut self, public_key: &str) -> Result<()> {
1034        // Find the peer
1035        let peer_idx = self
1036            .peers
1037            .iter()
1038            .position(|p| p.public_key == public_key)
1039            .ok_or_else(|| OverlayError::PeerNotFound(public_key.to_string()))?;
1040
1041        let peer = &self.peers[peer_idx];
1042
1043        // Capture peer info for DNS removal before we lose the reference
1044        let peer_overlay_ip = peer.overlay_ip;
1045        let peer_custom_hostname = peer.hostname.clone();
1046
1047        // Release IP if we're managing allocation
1048        if let Some(ref mut allocator) = self.allocator {
1049            allocator.release(peer_overlay_ip);
1050        }
1051
1052        // Remove from DNS if enabled
1053        if let Some(ref dns_handle) = self.dns_handle {
1054            // Remove IP-based hostname
1055            let hostname = peer_hostname(peer_overlay_ip);
1056            dns_handle
1057                .remove_record(&hostname)
1058                .await
1059                .map_err(|e| OverlayError::Dns(e.to_string()))?;
1060            debug!(hostname = %hostname, "Removed peer from DNS");
1061
1062            // Remove custom hostname if it was set
1063            if let Some(ref custom) = peer_custom_hostname {
1064                dns_handle
1065                    .remove_record(custom)
1066                    .await
1067                    .map_err(|e| OverlayError::Dns(e.to_string()))?;
1068                debug!(hostname = %custom, "Removed custom hostname from DNS");
1069            }
1070        }
1071
1072        // Remove peer from overlay transport via UAPI
1073        let transport_ref: Option<&OverlayTransport> = self.transport.as_ref();
1074
1075        let result = if let Some(t) = transport_ref {
1076            t.remove_peer(public_key).await
1077        } else {
1078            let overlay_config = crate::config::OverlayConfig {
1079                local_endpoint: SocketAddr::new(
1080                    std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
1081                    self.config.port,
1082                ),
1083                private_key: self.config.private_key.clone(),
1084                public_key: self.config.public_key.clone(),
1085                overlay_cidr: self.config.allowed_ip(),
1086                cluster_cidr: Some(self.config.cidr.clone()),
1087                peer_discovery_interval: Duration::from_secs(30),
1088                #[cfg(feature = "nat")]
1089                nat: crate::nat::NatConfig::default(),
1090            };
1091            let tmp = OverlayTransport::new(overlay_config, self.config.interface.clone());
1092            tmp.remove_peer(public_key).await
1093        };
1094
1095        match result {
1096            Ok(()) => debug!(public_key = public_key, "Removed peer from overlay"),
1097            Err(e) => {
1098                warn!(public_key = public_key, error = %e, "Failed to remove peer from overlay");
1099            }
1100        }
1101
1102        // Remove from peer list
1103        self.peers.remove(peer_idx);
1104
1105        // Persist state
1106        self.save().await?;
1107
1108        info!(public_key = public_key, "Removed peer from overlay");
1109        Ok(())
1110    }
1111
1112    /// Get this node's public key
1113    #[must_use]
1114    pub fn public_key(&self) -> &str {
1115        &self.config.public_key
1116    }
1117
1118    /// Get this node's overlay IP (IPv4 or IPv6)
1119    #[must_use]
1120    pub fn node_ip(&self) -> IpAddr {
1121        self.config.node_ip
1122    }
1123
1124    /// Get the overlay CIDR
1125    #[must_use]
1126    pub fn cidr(&self) -> &str {
1127        &self.config.cidr
1128    }
1129
1130    /// Get the overlay interface name
1131    #[must_use]
1132    pub fn interface(&self) -> &str {
1133        &self.config.interface
1134    }
1135
1136    /// Get the overlay listen port
1137    #[must_use]
1138    pub fn port(&self) -> u16 {
1139        self.config.port
1140    }
1141
1142    /// Check if this node is the leader
1143    #[must_use]
1144    pub fn is_leader(&self) -> bool {
1145        self.config.is_leader
1146    }
1147
1148    /// Get configured peers
1149    #[must_use]
1150    pub fn peers(&self) -> &[PeerConfig] {
1151        &self.peers
1152    }
1153
1154    /// Get the bootstrap config
1155    #[must_use]
1156    pub fn config(&self) -> &BootstrapConfig {
1157        &self.config
1158    }
1159
1160    /// Allocate an IP for a new peer (leader only)
1161    ///
1162    /// This is used by the control plane when processing join requests.
1163    ///
1164    /// # Errors
1165    ///
1166    /// Returns an error if this node is not a leader or no IPs are available.
1167    pub fn allocate_peer_ip(&mut self) -> Result<IpAddr> {
1168        let allocator = self
1169            .allocator
1170            .as_mut()
1171            .ok_or(OverlayError::Config("Not a leader node".to_string()))?;
1172
1173        allocator.allocate().ok_or(OverlayError::NoAvailableIps)
1174    }
1175
1176    /// Get IP allocation statistics (leader only)
1177    #[must_use]
1178    #[allow(clippy::cast_possible_truncation)]
1179    pub fn allocation_stats(&self) -> Option<(u32, u32)> {
1180        self.allocator
1181            .as_ref()
1182            .map(|a| (a.allocated_count() as u32, a.total_hosts()))
1183    }
1184
1185    /// Perform NAT maintenance: refresh STUN, attempt relay upgrades.
1186    ///
1187    /// Call this periodically from the runtime's main loop. Re-probes
1188    /// STUN servers to detect reflexive address changes and attempts
1189    /// to upgrade relayed connections to direct or hole-punched.
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns an error if STUN refresh fails.
1194    #[cfg(feature = "nat")]
1195    pub async fn nat_maintenance_tick(&mut self) -> Result<()> {
1196        let (Some(nat), Some(transport)) = (&mut self.nat_traversal, &self.transport) else {
1197            return Ok(());
1198        };
1199
1200        if nat.refresh().await? {
1201            info!("Reflexive address changed");
1202        }
1203
1204        for peer in &mut self.peers {
1205            if peer.connection_type == ConnectionType::Relayed && !peer.candidates.is_empty() {
1206                if let Ok(Some(upgraded)) = nat
1207                    .attempt_upgrade(transport, &peer.public_key, &peer.candidates)
1208                    .await
1209                {
1210                    peer.connection_type = upgraded;
1211                    info!(
1212                        peer = %peer.node_id,
1213                        connection = %upgraded,
1214                        "Upgraded relayed connection"
1215                    );
1216                }
1217            }
1218        }
1219
1220        Ok(())
1221    }
1222
1223    /// Get this node's NAT candidates for sharing with peers.
1224    ///
1225    /// Returns an empty vec if NAT traversal has not been initialized
1226    /// or no candidates were gathered.
1227    #[cfg(feature = "nat")]
1228    #[must_use]
1229    pub fn nat_candidates(&self) -> Vec<Candidate> {
1230        self.nat_traversal
1231            .as_ref()
1232            .map(|n| n.local_candidates().to_vec())
1233            .unwrap_or_default()
1234    }
1235}
1236
1237/// Get current Unix timestamp
1238fn current_timestamp() -> u64 {
1239    std::time::SystemTime::now()
1240        .duration_since(std::time::UNIX_EPOCH)
1241        .unwrap_or_default()
1242        .as_secs()
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use std::net::Ipv4Addr;
1249
1250    #[test]
1251    fn test_bootstrap_config_allowed_ip_v4() {
1252        let config = BootstrapConfig {
1253            cidr: "10.200.0.0/16".to_string(),
1254            node_ip: IpAddr::V4(Ipv4Addr::new(10, 200, 0, 1)),
1255            interface: DEFAULT_INTERFACE_NAME.to_string(),
1256            port: DEFAULT_WG_PORT,
1257            private_key: "test_private".to_string(),
1258            public_key: "test_public".to_string(),
1259            is_leader: true,
1260            created_at: 0,
1261            slice_cidr: None,
1262        };
1263
1264        assert_eq!(config.allowed_ip(), "10.200.0.1/32");
1265    }
1266
1267    #[test]
1268    fn test_bootstrap_config_allowed_ip_v6() {
1269        let config = BootstrapConfig {
1270            cidr: "fd00:200::/48".to_string(),
1271            node_ip: "fd00:200::1".parse::<IpAddr>().unwrap(),
1272            interface: DEFAULT_INTERFACE_NAME.to_string(),
1273            port: DEFAULT_WG_PORT,
1274            private_key: "test_private".to_string(),
1275            public_key: "test_public".to_string(),
1276            is_leader: true,
1277            created_at: 0,
1278            slice_cidr: None,
1279        };
1280
1281        assert_eq!(config.allowed_ip(), "fd00:200::1/128");
1282    }
1283
1284    #[test]
1285    fn test_peer_config_new_v4() {
1286        let peer = PeerConfig::new(
1287            "node-1".to_string(),
1288            "pubkey123".to_string(),
1289            "192.168.1.100:51820".to_string(),
1290            IpAddr::V4(Ipv4Addr::new(10, 200, 0, 5)),
1291        );
1292
1293        assert_eq!(peer.node_id, "node-1");
1294        assert_eq!(peer.keepalive, Some(DEFAULT_KEEPALIVE_SECS));
1295        assert_eq!(peer.hostname, None);
1296    }
1297
1298    #[test]
1299    fn test_peer_config_new_v6() {
1300        let peer = PeerConfig::new(
1301            "node-1".to_string(),
1302            "pubkey123".to_string(),
1303            "[::1]:51820".to_string(),
1304            "fd00:200::5".parse::<IpAddr>().unwrap(),
1305        );
1306
1307        assert_eq!(peer.node_id, "node-1");
1308        assert_eq!(peer.keepalive, Some(DEFAULT_KEEPALIVE_SECS));
1309        assert_eq!(peer.hostname, None);
1310    }
1311
1312    #[test]
1313    fn test_peer_config_with_hostname() {
1314        let peer = PeerConfig::new(
1315            "node-1".to_string(),
1316            "pubkey123".to_string(),
1317            "192.168.1.100:51820".to_string(),
1318            IpAddr::V4(Ipv4Addr::new(10, 200, 0, 5)),
1319        )
1320        .with_hostname("web-server");
1321
1322        assert_eq!(peer.hostname, Some("web-server".to_string()));
1323    }
1324
1325    #[test]
1326    fn test_peer_config_to_peer_info_v4() {
1327        let peer = PeerConfig::new(
1328            "node-1".to_string(),
1329            "pubkey123".to_string(),
1330            "192.168.1.100:51820".to_string(),
1331            IpAddr::V4(Ipv4Addr::new(10, 200, 0, 5)),
1332        );
1333
1334        let peer_info = peer.to_peer_info().unwrap();
1335        assert_eq!(peer_info.public_key, "pubkey123");
1336        assert_eq!(peer_info.allowed_ips, "10.200.0.5/32");
1337    }
1338
1339    #[test]
1340    fn test_peer_config_to_peer_info_v6() {
1341        let peer = PeerConfig::new(
1342            "node-1".to_string(),
1343            "pubkey123".to_string(),
1344            "[::1]:51820".to_string(),
1345            "fd00:200::5".parse::<IpAddr>().unwrap(),
1346        );
1347
1348        let peer_info = peer.to_peer_info().unwrap();
1349        assert_eq!(peer_info.public_key, "pubkey123");
1350        assert_eq!(peer_info.allowed_ips, "fd00:200::5/128");
1351    }
1352
1353    #[test]
1354    fn test_bootstrap_state_serialization_v4() {
1355        let config = BootstrapConfig {
1356            cidr: "10.200.0.0/16".to_string(),
1357            node_ip: IpAddr::V4(Ipv4Addr::new(10, 200, 0, 1)),
1358            interface: DEFAULT_INTERFACE_NAME.to_string(),
1359            port: DEFAULT_WG_PORT,
1360            private_key: "private".to_string(),
1361            public_key: "public".to_string(),
1362            is_leader: true,
1363            created_at: 1_234_567_890,
1364            slice_cidr: None,
1365        };
1366
1367        let state = BootstrapState {
1368            config,
1369            peers: vec![],
1370            allocator_state: None,
1371            slice_allocator_state: None,
1372        };
1373
1374        let json = serde_json::to_string_pretty(&state).unwrap();
1375        let deserialized: BootstrapState = serde_json::from_str(&json).unwrap();
1376
1377        assert_eq!(deserialized.config.cidr, "10.200.0.0/16");
1378        assert_eq!(deserialized.config.node_ip.to_string(), "10.200.0.1");
1379    }
1380
1381    #[test]
1382    fn test_bootstrap_state_serialization_v6() {
1383        let config = BootstrapConfig {
1384            cidr: "fd00:200::/48".to_string(),
1385            node_ip: "fd00:200::1".parse::<IpAddr>().unwrap(),
1386            interface: DEFAULT_INTERFACE_NAME.to_string(),
1387            port: DEFAULT_WG_PORT,
1388            private_key: "private".to_string(),
1389            public_key: "public".to_string(),
1390            is_leader: true,
1391            created_at: 1_234_567_890,
1392            slice_cidr: None,
1393        };
1394
1395        let state = BootstrapState {
1396            config,
1397            peers: vec![],
1398            allocator_state: None,
1399            slice_allocator_state: None,
1400        };
1401
1402        let json = serde_json::to_string_pretty(&state).unwrap();
1403        let deserialized: BootstrapState = serde_json::from_str(&json).unwrap();
1404
1405        assert_eq!(deserialized.config.cidr, "fd00:200::/48");
1406        assert_eq!(deserialized.config.node_ip.to_string(), "fd00:200::1");
1407    }
1408
1409    #[test]
1410    fn test_default_overlay_cidr_v6_constant() {
1411        // Verify the IPv6 CIDR constant is valid
1412        let net: ipnet::IpNet = DEFAULT_OVERLAY_CIDR_V6.parse().unwrap();
1413        assert!(matches!(net, ipnet::IpNet::V6(_)));
1414        assert_eq!(net.prefix_len(), 48);
1415    }
1416
1417    #[test]
1418    fn test_to_peer_info_uses_slice_when_set() {
1419        let peer = PeerConfig::new(
1420            "node-42".to_string(),
1421            "pubkey-xyz".to_string(),
1422            "192.168.1.100:51820".to_string(),
1423            IpAddr::V4(Ipv4Addr::new(10, 200, 42, 1)),
1424        )
1425        .with_slice_cidr("10.200.42.0/28".parse().unwrap());
1426
1427        let peer_info = peer.to_peer_info().unwrap();
1428        assert_eq!(peer_info.allowed_ips, "10.200.42.0/28");
1429    }
1430
1431    #[test]
1432    fn test_to_peer_info_falls_back_to_node_ip_when_no_slice() {
1433        let peer = PeerConfig::new(
1434            "node-5".to_string(),
1435            "pubkey-abc".to_string(),
1436            "192.168.1.100:51820".to_string(),
1437            "10.200.0.5".parse().unwrap(),
1438        );
1439
1440        let peer_info = peer.to_peer_info().unwrap();
1441        assert_eq!(peer_info.allowed_ips, "10.200.0.5/32");
1442    }
1443
1444    #[test]
1445    fn test_bootstrap_config_allowed_ip_prefers_slice() {
1446        let config = BootstrapConfig {
1447            cidr: "10.200.0.0/16".to_string(),
1448            node_ip: IpAddr::V4(Ipv4Addr::new(10, 200, 7, 1)),
1449            interface: DEFAULT_INTERFACE_NAME.to_string(),
1450            port: DEFAULT_WG_PORT,
1451            private_key: "private".to_string(),
1452            public_key: "public".to_string(),
1453            is_leader: false,
1454            created_at: 0,
1455            slice_cidr: Some("10.200.7.0/28".parse().unwrap()),
1456        };
1457
1458        assert_eq!(config.allowed_ip(), "10.200.7.0/28");
1459    }
1460
1461    #[test]
1462    fn test_bootstrap_config_allowed_ip_falls_back_to_node_ip() {
1463        let config = BootstrapConfig {
1464            cidr: "10.200.0.0/16".to_string(),
1465            node_ip: IpAddr::V4(Ipv4Addr::new(10, 200, 0, 9)),
1466            interface: DEFAULT_INTERFACE_NAME.to_string(),
1467            port: DEFAULT_WG_PORT,
1468            private_key: "private".to_string(),
1469            public_key: "public".to_string(),
1470            is_leader: false,
1471            created_at: 0,
1472            slice_cidr: None,
1473        };
1474
1475        assert_eq!(config.allowed_ip(), "10.200.0.9/32");
1476    }
1477}