Skip to main content

a3s_box_core/
network.rs

1//! Network types for container-to-container communication.
2//!
3//! Provides network configuration, endpoint tracking, and IP address
4//! management (IPAM) for connecting boxes via passt-based virtio-net.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::net::Ipv4Addr;
10
11/// Network mode for a box.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum NetworkMode {
15    /// TSI mode (default) — no network interfaces, socket syscalls proxied via vsock.
16    #[default]
17    Tsi,
18
19    /// Bridge mode — real eth0 via passt, container-to-container communication.
20    Bridge {
21        /// Network name to join.
22        network: String,
23    },
24
25    /// No networking at all.
26    None,
27}
28
29impl fmt::Display for NetworkMode {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            NetworkMode::Tsi => write!(f, "tsi"),
33            NetworkMode::Bridge { network } => write!(f, "bridge:{}", network),
34            NetworkMode::None => write!(f, "none"),
35        }
36    }
37}
38
39/// Configuration for a user-defined network.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct NetworkConfig {
42    /// Network name (unique identifier).
43    pub name: String,
44
45    /// Subnet in CIDR notation (e.g., "10.88.0.0/24").
46    pub subnet: String,
47
48    /// Gateway IP address (e.g., "10.88.0.1").
49    pub gateway: Ipv4Addr,
50
51    /// Network driver (currently only "bridge" is supported).
52    #[serde(default = "default_driver")]
53    pub driver: String,
54
55    /// User-defined labels.
56    #[serde(default)]
57    pub labels: HashMap<String, String>,
58
59    /// Connected endpoints (box_id → endpoint).
60    #[serde(default)]
61    pub endpoints: HashMap<String, NetworkEndpoint>,
62
63    /// Creation timestamp (RFC 3339).
64    pub created_at: String,
65
66    /// Network isolation policy.
67    #[serde(default)]
68    pub policy: NetworkPolicy,
69}
70
71fn default_driver() -> String {
72    "bridge".to_string()
73}
74
75/// A box's connection to a network.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct NetworkEndpoint {
78    /// Box ID.
79    pub box_id: String,
80
81    /// Box name (for DNS resolution).
82    pub box_name: String,
83
84    /// Additional DNS names that also resolve to this endpoint's IP (e.g. the
85    /// bare Compose service name alongside the `{project}-{service}` box name).
86    #[serde(default)]
87    pub aliases: Vec<String>,
88
89    /// Assigned IPv4 address.
90    pub ip_address: Ipv4Addr,
91
92    /// Assigned MAC address (hex string, e.g., "02:42:0a:58:00:02").
93    pub mac_address: String,
94}
95
96/// Network isolation policy.
97///
98/// Controls which boxes can communicate with each other on a network.
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct NetworkPolicy {
101    /// Isolation mode (default: None — all boxes can communicate).
102    #[serde(default)]
103    pub isolation: IsolationMode,
104
105    /// Ingress rules (who can receive traffic from whom).
106    /// Only used when isolation is `Custom`.
107    #[serde(default)]
108    pub ingress: Vec<PolicyRule>,
109
110    /// Egress rules (who can send traffic to whom).
111    /// Only used when isolation is `Custom`.
112    #[serde(default)]
113    pub egress: Vec<PolicyRule>,
114}
115
116/// Network isolation mode.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
118#[serde(rename_all = "snake_case")]
119pub enum IsolationMode {
120    /// No isolation — all boxes on the network can communicate freely (default).
121    #[default]
122    None,
123    /// Strict isolation — no box-to-box communication allowed (only gateway/external).
124    Strict,
125    /// Custom rules — use ingress/egress rules to control traffic.
126    Custom,
127}
128
129/// A network policy rule that allows traffic between specific boxes or ports.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PolicyRule {
132    /// Source box name pattern (e.g., "web", "*" for any).
133    #[serde(default = "wildcard")]
134    pub from: String,
135
136    /// Destination box name pattern (e.g., "db", "*" for any).
137    #[serde(default = "wildcard")]
138    pub to: String,
139
140    /// Allowed ports (empty = all ports).
141    #[serde(default)]
142    pub ports: Vec<u16>,
143
144    /// Protocol: "tcp", "udp", or "any" (default).
145    #[serde(default = "default_protocol")]
146    pub protocol: String,
147
148    /// Rule action: allow or deny.
149    #[serde(default)]
150    pub action: PolicyAction,
151}
152
153/// Policy rule action.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
155#[serde(rename_all = "snake_case")]
156pub enum PolicyAction {
157    /// Allow the traffic (default).
158    #[default]
159    Allow,
160    /// Deny the traffic.
161    Deny,
162}
163
164fn wildcard() -> String {
165    "*".to_string()
166}
167
168fn default_protocol() -> String {
169    "any".to_string()
170}
171
172impl NetworkPolicy {
173    /// Validate that the policy can be enforced at runtime.
174    ///
175    /// Currently only `IsolationMode::None` is supported. Strict and Custom
176    /// modes require iptables/nftables integration which is not yet implemented.
177    /// Rejecting early prevents a false sense of security.
178    pub fn validate(&self) -> Result<(), String> {
179        match self.isolation {
180            IsolationMode::None => Ok(()),
181            IsolationMode::Strict => Err(
182                "network policy isolation mode 'strict' is not yet enforced at runtime; \
183                 packets will NOT be filtered. Remove the policy or use isolation=none"
184                    .to_string(),
185            ),
186            IsolationMode::Custom => Err(
187                "network policy isolation mode 'custom' is not yet enforced at runtime; \
188                 ingress/egress rules will NOT be applied. Remove the policy or use isolation=none"
189                    .to_string(),
190            ),
191        }
192    }
193
194    /// Check if a box is allowed to communicate with a peer.
195    pub fn is_peer_allowed(&self, box_name: &str, peer_name: &str) -> bool {
196        match self.isolation {
197            IsolationMode::None => true,
198            IsolationMode::Strict => false,
199            IsolationMode::Custom => {
200                // Check egress rules (from box_name to peer_name)
201                self.evaluate_rules(&self.egress, box_name, peer_name)
202            }
203        }
204    }
205
206    /// Evaluate a set of rules. Default-deny: if no rule matches, deny.
207    fn evaluate_rules(&self, rules: &[PolicyRule], from: &str, to: &str) -> bool {
208        for rule in rules {
209            if matches_pattern(&rule.from, from) && matches_pattern(&rule.to, to) {
210                return rule.action == PolicyAction::Allow;
211            }
212        }
213        // No matching rule → deny in Custom mode
214        false
215    }
216
217    /// Get the list of allowed peers for a box, given all peer names.
218    pub fn allowed_peers<'a>(
219        &self,
220        box_name: &str,
221        peers: &'a [(String, String)],
222    ) -> Vec<&'a (String, String)> {
223        peers
224            .iter()
225            .filter(|(_, peer_name)| self.is_peer_allowed(box_name, peer_name))
226            .collect()
227    }
228}
229
230/// Simple wildcard pattern matching: "*" matches anything, otherwise exact match.
231fn matches_pattern(pattern: &str, name: &str) -> bool {
232    pattern == "*" || pattern == name
233}
234
235/// Simple sequential IPAM (IP Address Management) for a subnet.
236#[derive(Debug)]
237pub struct Ipam {
238    /// Network address (e.g., 10.88.0.0).
239    network: Ipv4Addr,
240    /// Prefix length (e.g., 24).
241    prefix_len: u8,
242    /// Gateway (first usable, e.g., 10.88.0.1).
243    gateway: Ipv4Addr,
244}
245
246impl Ipam {
247    /// Create a new IPAM from a CIDR string (e.g., "10.88.0.0/24").
248    pub fn new(cidr: &str) -> Result<Self, String> {
249        let parts: Vec<&str> = cidr.split('/').collect();
250        if parts.len() != 2 {
251            return Err(format!("invalid CIDR notation: {}", cidr));
252        }
253
254        let network: Ipv4Addr = parts[0]
255            .parse()
256            .map_err(|e| format!("invalid network address '{}': {}", parts[0], e))?;
257        let prefix_len: u8 = parts[1]
258            .parse()
259            .map_err(|e| format!("invalid prefix length '{}': {}", parts[1], e))?;
260
261        if prefix_len == 0 || prefix_len > 30 {
262            return Err(format!(
263                "prefix length {} out of range (must be 1-30 for a usable subnet)",
264                prefix_len
265            ));
266        }
267
268        // Gateway is network + 1. Use checked arithmetic so a network address of
269        // 255.255.255.255 cannot overflow (panic in debug / wrap in release).
270        let net_u32 = u32::from(network);
271        let gateway =
272            Ipv4Addr::from(net_u32.checked_add(1).ok_or_else(|| {
273                format!("network address '{}' has no room for a gateway", network)
274            })?);
275
276        Ok(Self {
277            network,
278            prefix_len,
279            gateway,
280        })
281    }
282
283    /// Get the gateway address.
284    pub fn gateway(&self) -> Ipv4Addr {
285        self.gateway
286    }
287
288    /// Get the subnet CIDR string.
289    pub fn cidr(&self) -> String {
290        format!("{}/{}", self.network, self.prefix_len)
291    }
292
293    /// Calculate the broadcast address.
294    pub fn broadcast(&self) -> Ipv4Addr {
295        let net_u32 = u32::from(self.network);
296        let host_bits = 32 - self.prefix_len as u32;
297        let broadcast = net_u32 | ((1u32 << host_bits) - 1);
298        Ipv4Addr::from(broadcast)
299    }
300
301    /// Total number of usable host addresses (excluding network, gateway, broadcast).
302    pub fn capacity(&self) -> u32 {
303        let host_bits = 32 - self.prefix_len as u32;
304        let total = (1u32 << host_bits) - 1; // exclude network address
305        total.saturating_sub(2) // exclude gateway and broadcast
306    }
307
308    /// Allocate the next available IP, given a set of already-used IPs.
309    pub fn allocate(&self, used: &[Ipv4Addr]) -> Result<Ipv4Addr, String> {
310        let net_u32 = u32::from(self.network);
311        let broadcast_u32 = u32::from(self.broadcast());
312        let gateway_u32 = u32::from(self.gateway);
313
314        // Start from network + 2 (skip network and gateway). Checked arithmetic:
315        // a subnet near the top of the u32 range must report exhaustion, not
316        // overflow-panic (debug) or wrap to a garbage IP (release).
317        let mut candidate = match net_u32.checked_add(2) {
318            Some(c) => c,
319            None => return Err("no available IP addresses in subnet".to_string()),
320        };
321        while candidate < broadcast_u32 {
322            if candidate != gateway_u32 {
323                let ip = Ipv4Addr::from(candidate);
324                if !used.contains(&ip) {
325                    return Ok(ip);
326                }
327            }
328            candidate = match candidate.checked_add(1) {
329                Some(c) => c,
330                None => break,
331            };
332        }
333
334        Err("no available IP addresses in subnet".to_string())
335    }
336
337    /// Generate a deterministic MAC address from an IPv4 address.
338    /// Uses the locally-administered prefix 02:42 (same as Docker).
339    pub fn mac_from_ip(ip: &Ipv4Addr) -> String {
340        let octets = ip.octets();
341        format!(
342            "02:42:{:02x}:{:02x}:{:02x}:{:02x}",
343            octets[0], octets[1], octets[2], octets[3]
344        )
345    }
346}
347
348/// Simple sequential IPAM for IPv6 subnets.
349///
350/// Supports /64 to /120 prefix lengths. Allocates addresses sequentially
351/// starting from network::1 (gateway) + 1.
352#[derive(Debug)]
353pub struct Ipam6 {
354    /// Network address (e.g., fd00::0).
355    network: std::net::Ipv6Addr,
356    /// Prefix length (e.g., 64).
357    prefix_len: u8,
358    /// Gateway (network::1).
359    gateway: std::net::Ipv6Addr,
360}
361
362impl Ipam6 {
363    /// Create a new IPv6 IPAM from a CIDR string (e.g., "fd00::/64").
364    pub fn new(cidr: &str) -> Result<Self, String> {
365        let parts: Vec<&str> = cidr.split('/').collect();
366        if parts.len() != 2 {
367            return Err(format!("invalid IPv6 CIDR notation: {}", cidr));
368        }
369
370        let network: std::net::Ipv6Addr = parts[0]
371            .parse()
372            .map_err(|e| format!("invalid IPv6 network address '{}': {}", parts[0], e))?;
373        let prefix_len: u8 = parts[1]
374            .parse()
375            .map_err(|e| format!("invalid prefix length '{}': {}", parts[1], e))?;
376
377        if !(64..=120).contains(&prefix_len) {
378            return Err(format!(
379                "IPv6 prefix length {} out of range (64..=120)",
380                prefix_len
381            ));
382        }
383
384        // Gateway is network + 1
385        let net_u128 = u128::from(network);
386        let gateway = std::net::Ipv6Addr::from(net_u128 + 1);
387
388        Ok(Self {
389            network,
390            prefix_len,
391            gateway,
392        })
393    }
394
395    /// Get the gateway address.
396    pub fn gateway(&self) -> std::net::Ipv6Addr {
397        self.gateway
398    }
399
400    /// Get the subnet CIDR string.
401    pub fn cidr(&self) -> String {
402        format!("{}/{}", self.network, self.prefix_len)
403    }
404
405    /// Allocate the next available IPv6 address, given a set of already-used IPs.
406    pub fn allocate(&self, used: &[std::net::Ipv6Addr]) -> Result<std::net::Ipv6Addr, String> {
407        let net_u128 = u128::from(self.network);
408        let host_bits = 128 - self.prefix_len as u32;
409        let max_host = (1u128 << host_bits) - 1; // broadcast equivalent
410        let gateway_u128 = u128::from(self.gateway);
411
412        // Start from network + 2 (skip network and gateway)
413        let mut offset = 2u128;
414        while offset < max_host {
415            let candidate_u128 = net_u128 + offset;
416            if candidate_u128 != gateway_u128 {
417                let ip = std::net::Ipv6Addr::from(candidate_u128);
418                if !used.contains(&ip) {
419                    return Ok(ip);
420                }
421            }
422            offset += 1;
423        }
424
425        Err("no available IPv6 addresses in subnet".to_string())
426    }
427}
428
429impl NetworkConfig {
430    /// Create a new network with the given name and subnet.
431    pub fn new(name: &str, subnet: &str) -> Result<Self, String> {
432        let ipam = Ipam::new(subnet)?;
433
434        Ok(Self {
435            name: name.to_string(),
436            subnet: ipam.cidr(),
437            gateway: ipam.gateway(),
438            driver: "bridge".to_string(),
439            labels: HashMap::new(),
440            endpoints: HashMap::new(),
441            created_at: chrono::Utc::now().to_rfc3339(),
442            policy: NetworkPolicy::default(),
443        })
444    }
445
446    /// Validate the driver and policy that the runtime can enforce today.
447    pub fn validate_runtime(&self) -> Result<(), String> {
448        if self.driver != "bridge" {
449            return Err(format!(
450                "Unsupported network driver '{}'. Only 'bridge' is currently supported",
451                self.driver
452            ));
453        }
454        self.policy
455            .validate()
456            .map_err(|error| format!("Unsupported network isolation mode: {error}"))
457    }
458
459    /// Allocate an IP and register a new endpoint for a box.
460    pub fn connect(&mut self, box_id: &str, box_name: &str) -> Result<NetworkEndpoint, String> {
461        self.connect_with_aliases(box_id, box_name, &[])
462    }
463
464    /// Connect a box, also registering extra DNS aliases that resolve to its IP
465    /// (e.g. the bare Compose service name in addition to the box name).
466    pub fn connect_with_aliases(
467        &mut self,
468        box_id: &str,
469        box_name: &str,
470        aliases: &[String],
471    ) -> Result<NetworkEndpoint, String> {
472        if self.endpoints.contains_key(box_id) {
473            return Err(format!(
474                "box '{}' is already connected to network '{}'",
475                box_id, self.name
476            ));
477        }
478
479        let ipam = Ipam::new(&self.subnet)?;
480        let used: Vec<Ipv4Addr> = self.endpoints.values().map(|e| e.ip_address).collect();
481        let ip = ipam.allocate(&used)?;
482        let mac = Ipam::mac_from_ip(&ip);
483
484        let endpoint = NetworkEndpoint {
485            box_id: box_id.to_string(),
486            box_name: box_name.to_string(),
487            aliases: aliases
488                .iter()
489                .filter(|a| !a.is_empty() && *a != box_name)
490                .cloned()
491                .collect(),
492            ip_address: ip,
493            mac_address: mac,
494        };
495
496        self.endpoints.insert(box_id.to_string(), endpoint.clone());
497        Ok(endpoint)
498    }
499
500    /// Remove a box from this network.
501    pub fn disconnect(&mut self, box_id: &str) -> Result<NetworkEndpoint, String> {
502        self.endpoints.remove(box_id).ok_or_else(|| {
503            format!(
504                "box '{}' is not connected to network '{}'",
505                box_id, self.name
506            )
507        })
508    }
509
510    /// Set the network policy, validating that it can be enforced.
511    ///
512    /// Returns an error if the policy uses an isolation mode that is not
513    /// yet implemented at the packet-filtering level.
514    pub fn set_policy(&mut self, policy: NetworkPolicy) -> Result<(), String> {
515        policy.validate()?;
516        self.policy = policy;
517        Ok(())
518    }
519
520    /// Get all connected endpoints.
521    pub fn connected_boxes(&self) -> Vec<&NetworkEndpoint> {
522        self.endpoints.values().collect()
523    }
524
525    /// Get peer endpoints for DNS discovery (all endpoints except the given box).
526    ///
527    /// Returns `(ip_address, box_name)` pairs for all endpoints other than `exclude_box_id`.
528    pub fn peer_endpoints(&self, exclude_box_id: &str) -> Vec<(String, String)> {
529        self.endpoints
530            .values()
531            .filter(|ep| ep.box_id != exclude_box_id)
532            .flat_map(|ep| {
533                let ip = ep.ip_address.to_string();
534                // The box name plus any aliases (e.g. the bare service name) all
535                // resolve to this peer's IP.
536                std::iter::once((ip.clone(), ep.box_name.clone()))
537                    .chain(ep.aliases.iter().map(move |a| (ip.clone(), a.clone())))
538            })
539            .collect()
540    }
541
542    /// Get peer endpoints filtered by the network's isolation policy.
543    ///
544    /// Like `peer_endpoints`, but only returns peers that the given box
545    /// is allowed to communicate with according to the network policy.
546    pub fn allowed_peer_endpoints(&self, exclude_box_id: &str) -> Vec<(String, String)> {
547        let box_name = self
548            .endpoints
549            .get(exclude_box_id)
550            .map(|ep| ep.box_name.as_str())
551            .unwrap_or("");
552
553        let all_peers = self.peer_endpoints(exclude_box_id);
554        self.policy
555            .allowed_peers(box_name, &all_peers)
556            .into_iter()
557            .cloned()
558            .collect()
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    // --- NetworkMode tests ---
567
568    #[test]
569    fn test_network_mode_default_is_tsi() {
570        let mode = NetworkMode::default();
571        assert_eq!(mode, NetworkMode::Tsi);
572    }
573
574    #[test]
575    fn test_network_mode_display() {
576        assert_eq!(NetworkMode::Tsi.to_string(), "tsi");
577        assert_eq!(NetworkMode::None.to_string(), "none");
578        assert_eq!(
579            NetworkMode::Bridge {
580                network: "mynet".to_string()
581            }
582            .to_string(),
583            "bridge:mynet"
584        );
585    }
586
587    #[test]
588    fn test_network_mode_serialization() {
589        let mode = NetworkMode::Bridge {
590            network: "test-net".to_string(),
591        };
592        let json = serde_json::to_string(&mode).unwrap();
593        let parsed: NetworkMode = serde_json::from_str(&json).unwrap();
594        assert_eq!(parsed, mode);
595    }
596
597    #[test]
598    fn test_network_mode_tsi_serialization() {
599        let mode = NetworkMode::Tsi;
600        let json = serde_json::to_string(&mode).unwrap();
601        let parsed: NetworkMode = serde_json::from_str(&json).unwrap();
602        assert_eq!(parsed, NetworkMode::Tsi);
603    }
604
605    // --- IPAM tests ---
606
607    #[test]
608    fn test_ipam_new_valid() {
609        let ipam = Ipam::new("10.88.0.0/24").unwrap();
610        assert_eq!(ipam.gateway(), Ipv4Addr::new(10, 88, 0, 1));
611        assert_eq!(ipam.cidr(), "10.88.0.0/24");
612    }
613
614    #[test]
615    fn test_ipam_new_slash16() {
616        let ipam = Ipam::new("172.20.0.0/16").unwrap();
617        assert_eq!(ipam.gateway(), Ipv4Addr::new(172, 20, 0, 1));
618    }
619
620    #[test]
621    fn test_ipam_invalid_cidr() {
622        assert!(Ipam::new("10.88.0.0").is_err());
623        assert!(Ipam::new("not-an-ip/24").is_err());
624        assert!(Ipam::new("10.88.0.0/33").is_err());
625        assert!(Ipam::new("10.88.0.0/31").is_err());
626    }
627
628    #[test]
629    fn test_ipam_broadcast() {
630        let ipam = Ipam::new("10.88.0.0/24").unwrap();
631        assert_eq!(ipam.broadcast(), Ipv4Addr::new(10, 88, 0, 255));
632
633        let ipam16 = Ipam::new("172.20.0.0/16").unwrap();
634        assert_eq!(ipam16.broadcast(), Ipv4Addr::new(172, 20, 255, 255));
635    }
636
637    #[test]
638    fn test_ipam_rejects_zero_and_oversized_prefix() {
639        // /0 previously caused a shift-overflow panic in broadcast()/capacity().
640        assert!(Ipam::new("0.0.0.0/0").is_err());
641        assert!(Ipam::new("10.0.0.0/31").is_err());
642        assert!(Ipam::new("10.0.0.0/32").is_err());
643        // Valid bounds still parse.
644        assert!(Ipam::new("10.0.0.0/1").is_ok());
645        assert!(Ipam::new("10.0.0.0/30").is_ok());
646    }
647
648    #[test]
649    fn test_ipam_gateway_overflow_is_rejected_not_panic() {
650        // 255.255.255.255 + 1 would overflow; must error, not panic.
651        assert!(Ipam::new("255.255.255.255/30").is_err());
652    }
653
654    #[test]
655    fn test_ipam_capacity() {
656        let ipam = Ipam::new("10.88.0.0/24").unwrap();
657        // /24 = 256 total, minus network(1) minus gateway(1) minus broadcast(1) = 253
658        assert_eq!(ipam.capacity(), 253);
659
660        let ipam28 = Ipam::new("10.88.0.0/28").unwrap();
661        // /28 = 16 total, minus network(1) = 15, minus gateway(1) minus broadcast(1) = 13
662        assert_eq!(ipam28.capacity(), 13);
663    }
664
665    #[test]
666    fn test_ipam_allocate_first() {
667        let ipam = Ipam::new("10.88.0.0/24").unwrap();
668        let ip = ipam.allocate(&[]).unwrap();
669        // First allocation: network+2 (skip network and gateway)
670        assert_eq!(ip, Ipv4Addr::new(10, 88, 0, 2));
671    }
672
673    #[test]
674    fn test_ipam_allocate_sequential() {
675        let ipam = Ipam::new("10.88.0.0/24").unwrap();
676        let ip1 = ipam.allocate(&[]).unwrap();
677        let ip2 = ipam.allocate(&[ip1]).unwrap();
678        let ip3 = ipam.allocate(&[ip1, ip2]).unwrap();
679
680        assert_eq!(ip1, Ipv4Addr::new(10, 88, 0, 2));
681        assert_eq!(ip2, Ipv4Addr::new(10, 88, 0, 3));
682        assert_eq!(ip3, Ipv4Addr::new(10, 88, 0, 4));
683    }
684
685    #[test]
686    fn test_ipam_allocate_skips_gateway() {
687        let ipam = Ipam::new("10.88.0.0/24").unwrap();
688        // Gateway is 10.88.0.1, first alloc should be .2
689        let ip = ipam.allocate(&[]).unwrap();
690        assert_ne!(ip, ipam.gateway());
691    }
692
693    #[test]
694    fn test_ipam_allocate_exhausted() {
695        let ipam = Ipam::new("10.88.0.0/30").unwrap();
696        // /30 = 4 total: .0 (network), .1 (gateway), .2 (host), .3 (broadcast)
697        // Only 1 usable host
698        let ip1 = ipam.allocate(&[]).unwrap();
699        assert_eq!(ip1, Ipv4Addr::new(10, 88, 0, 2));
700
701        let result = ipam.allocate(&[ip1]);
702        assert!(result.is_err());
703    }
704
705    #[test]
706    fn test_ipam_allocate_top_of_range_no_overflow() {
707        // A subnet at the very top of the u32 range must report exhaustion via
708        // checked arithmetic, not overflow-panic (debug) or a wrapped garbage IP.
709        let ipam = Ipam::new("255.255.255.252/30").unwrap();
710        // net=...252, gateway=...253, host=...254, broadcast=...255 -> 1 usable.
711        let ip1 = ipam.allocate(&[]).unwrap();
712        assert_eq!(ip1, Ipv4Addr::new(255, 255, 255, 254));
713        assert!(ipam.allocate(&[ip1]).is_err());
714    }
715
716    #[test]
717    fn test_ipam_mac_from_ip() {
718        let ip = Ipv4Addr::new(10, 88, 0, 2);
719        assert_eq!(Ipam::mac_from_ip(&ip), "02:42:0a:58:00:02");
720
721        let ip2 = Ipv4Addr::new(192, 168, 1, 100);
722        assert_eq!(Ipam::mac_from_ip(&ip2), "02:42:c0:a8:01:64");
723    }
724
725    // --- NetworkConfig tests ---
726
727    #[test]
728    fn test_network_config_new() {
729        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
730        assert_eq!(net.name, "mynet");
731        assert_eq!(net.subnet, "10.88.0.0/24");
732        assert_eq!(net.gateway, Ipv4Addr::new(10, 88, 0, 1));
733        assert_eq!(net.driver, "bridge");
734        assert!(net.endpoints.is_empty());
735    }
736
737    #[test]
738    fn test_network_config_invalid_subnet() {
739        assert!(NetworkConfig::new("bad", "invalid").is_err());
740    }
741
742    #[test]
743    fn test_network_config_connect() {
744        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
745        let ep = net.connect("box-1", "web").unwrap();
746
747        assert_eq!(ep.box_id, "box-1");
748        assert_eq!(ep.box_name, "web");
749        assert_eq!(ep.ip_address, Ipv4Addr::new(10, 88, 0, 2));
750        assert_eq!(ep.mac_address, "02:42:0a:58:00:02");
751        assert_eq!(net.endpoints.len(), 1);
752    }
753
754    #[test]
755    fn test_network_config_connect_multiple() {
756        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
757        let ep1 = net.connect("box-1", "web").unwrap();
758        let ep2 = net.connect("box-2", "api").unwrap();
759
760        assert_eq!(ep1.ip_address, Ipv4Addr::new(10, 88, 0, 2));
761        assert_eq!(ep2.ip_address, Ipv4Addr::new(10, 88, 0, 3));
762        assert_eq!(net.endpoints.len(), 2);
763    }
764
765    #[test]
766    fn test_network_config_connect_duplicate() {
767        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
768        net.connect("box-1", "web").unwrap();
769        let result = net.connect("box-1", "web");
770        assert!(result.is_err());
771    }
772
773    #[test]
774    fn test_network_config_disconnect() {
775        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
776        net.connect("box-1", "web").unwrap();
777
778        let ep = net.disconnect("box-1").unwrap();
779        assert_eq!(ep.box_id, "box-1");
780        assert!(net.endpoints.is_empty());
781    }
782
783    #[test]
784    fn test_network_config_disconnect_not_connected() {
785        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
786        let result = net.disconnect("box-1");
787        assert!(result.is_err());
788    }
789
790    #[test]
791    fn test_network_config_connected_boxes() {
792        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
793        net.connect("box-1", "web").unwrap();
794        net.connect("box-2", "api").unwrap();
795
796        let boxes = net.connected_boxes();
797        assert_eq!(boxes.len(), 2);
798    }
799
800    #[test]
801    fn test_network_config_ip_reuse_after_disconnect() {
802        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
803        let ep1 = net.connect("box-1", "web").unwrap();
804        assert_eq!(ep1.ip_address, Ipv4Addr::new(10, 88, 0, 2));
805
806        net.disconnect("box-1").unwrap();
807
808        // After disconnect, the IP should be reusable
809        let ep2 = net.connect("box-2", "api").unwrap();
810        assert_eq!(ep2.ip_address, Ipv4Addr::new(10, 88, 0, 2));
811    }
812
813    #[test]
814    fn test_network_config_serialization() {
815        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
816        net.connect("box-1", "web").unwrap();
817
818        let json = serde_json::to_string(&net).unwrap();
819        let parsed: NetworkConfig = serde_json::from_str(&json).unwrap();
820
821        assert_eq!(parsed.name, "mynet");
822        assert_eq!(parsed.subnet, "10.88.0.0/24");
823        assert_eq!(parsed.endpoints.len(), 1);
824        assert!(parsed.endpoints.contains_key("box-1"));
825    }
826
827    // --- NetworkEndpoint tests ---
828
829    // --- peer_endpoints tests ---
830
831    #[test]
832    fn test_peer_endpoints_excludes_self() {
833        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
834        net.connect("box-1", "web").unwrap();
835        net.connect("box-2", "api").unwrap();
836        net.connect("box-3", "db").unwrap();
837
838        let peers = net.peer_endpoints("box-1");
839        assert_eq!(peers.len(), 2);
840        assert!(peers.iter().all(|(_, name)| name != "web"));
841        assert!(peers.iter().any(|(_, name)| name == "api"));
842        assert!(peers.iter().any(|(_, name)| name == "db"));
843    }
844
845    #[test]
846    fn test_connect_with_aliases_resolvable_as_peer() {
847        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
848        // Compose-style: box name is "proj-db", bare service name "db" is an alias.
849        let ep = net
850            .connect_with_aliases("box-db", "proj-db", &["db".to_string()])
851            .unwrap();
852        assert_eq!(ep.aliases, vec!["db".to_string()]);
853        net.connect("box-web", "proj-web").unwrap();
854
855        let peers = net.peer_endpoints("box-web");
856        let db_ip = ep.ip_address.to_string();
857        // Both the box name AND the bare alias resolve to db's IP.
858        assert!(peers
859            .iter()
860            .any(|(ip, name)| ip == &db_ip && name == "proj-db"));
861        assert!(peers.iter().any(|(ip, name)| ip == &db_ip && name == "db"));
862    }
863
864    #[test]
865    fn test_connect_alias_skips_empty_and_self_duplicate() {
866        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
867        // An alias equal to the box name or empty is dropped.
868        let ep = net
869            .connect_with_aliases("b", "web", &["".to_string(), "web".to_string()])
870            .unwrap();
871        assert!(ep.aliases.is_empty());
872    }
873
874    #[test]
875    fn test_peer_endpoints_empty_when_alone() {
876        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
877        net.connect("box-1", "web").unwrap();
878
879        let peers = net.peer_endpoints("box-1");
880        assert!(peers.is_empty());
881    }
882
883    #[test]
884    fn test_peer_endpoints_returns_all_others() {
885        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
886        net.connect("box-1", "web").unwrap();
887        net.connect("box-2", "api").unwrap();
888
889        let peers = net.peer_endpoints("box-1");
890        assert_eq!(peers.len(), 1);
891        assert_eq!(peers[0].0, "10.88.0.3");
892        assert_eq!(peers[0].1, "api");
893    }
894
895    #[test]
896    fn test_peer_endpoints_nonexistent_excludes_nothing() {
897        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
898        net.connect("box-1", "web").unwrap();
899        net.connect("box-2", "api").unwrap();
900
901        let peers = net.peer_endpoints("nonexistent");
902        assert_eq!(peers.len(), 2);
903    }
904
905    // --- NetworkEndpoint tests ---
906
907    #[test]
908    fn test_network_endpoint_serialization() {
909        let ep = NetworkEndpoint {
910            box_id: "abc123".to_string(),
911            box_name: "web".to_string(),
912            aliases: vec!["app".to_string()],
913            ip_address: Ipv4Addr::new(10, 88, 0, 2),
914            mac_address: "02:42:0a:58:00:02".to_string(),
915        };
916
917        let json = serde_json::to_string(&ep).unwrap();
918        let parsed: NetworkEndpoint = serde_json::from_str(&json).unwrap();
919        assert_eq!(parsed, ep);
920
921        // Backward compat: older stored endpoints without `aliases` deserialize.
922        let legacy = r#"{"box_id":"x","box_name":"web","ip_address":"10.88.0.3","mac_address":"02:42:0a:58:00:03"}"#;
923        let parsed_legacy: NetworkEndpoint = serde_json::from_str(legacy).unwrap();
924        assert!(parsed_legacy.aliases.is_empty());
925    }
926
927    // --- NetworkPolicy tests ---
928
929    #[test]
930    fn test_network_policy_default_allows_all() {
931        let policy = NetworkPolicy::default();
932        assert_eq!(policy.isolation, IsolationMode::None);
933        assert!(policy.is_peer_allowed("web", "db"));
934        assert!(policy.is_peer_allowed("any", "any"));
935    }
936
937    #[test]
938    fn test_network_policy_strict_denies_all() {
939        let policy = NetworkPolicy {
940            isolation: IsolationMode::Strict,
941            ..Default::default()
942        };
943        assert!(!policy.is_peer_allowed("web", "db"));
944        assert!(!policy.is_peer_allowed("any", "any"));
945    }
946
947    #[test]
948    fn test_network_policy_custom_allow_rule() {
949        let policy = NetworkPolicy {
950            isolation: IsolationMode::Custom,
951            egress: vec![PolicyRule {
952                from: "web".to_string(),
953                to: "db".to_string(),
954                ports: vec![],
955                protocol: "any".to_string(),
956                action: PolicyAction::Allow,
957            }],
958            ..Default::default()
959        };
960        assert!(policy.is_peer_allowed("web", "db"));
961        assert!(!policy.is_peer_allowed("web", "redis")); // no rule → deny
962        assert!(!policy.is_peer_allowed("api", "db")); // from doesn't match
963    }
964
965    #[test]
966    fn test_network_policy_custom_wildcard_from() {
967        let policy = NetworkPolicy {
968            isolation: IsolationMode::Custom,
969            egress: vec![PolicyRule {
970                from: "*".to_string(),
971                to: "db".to_string(),
972                ports: vec![],
973                protocol: "any".to_string(),
974                action: PolicyAction::Allow,
975            }],
976            ..Default::default()
977        };
978        assert!(policy.is_peer_allowed("web", "db"));
979        assert!(policy.is_peer_allowed("api", "db"));
980        assert!(!policy.is_peer_allowed("web", "redis"));
981    }
982
983    #[test]
984    fn test_network_policy_custom_wildcard_to() {
985        let policy = NetworkPolicy {
986            isolation: IsolationMode::Custom,
987            egress: vec![PolicyRule {
988                from: "web".to_string(),
989                to: "*".to_string(),
990                ports: vec![],
991                protocol: "any".to_string(),
992                action: PolicyAction::Allow,
993            }],
994            ..Default::default()
995        };
996        assert!(policy.is_peer_allowed("web", "db"));
997        assert!(policy.is_peer_allowed("web", "redis"));
998        assert!(!policy.is_peer_allowed("api", "db"));
999    }
1000
1001    #[test]
1002    fn test_network_policy_custom_deny_rule() {
1003        let policy = NetworkPolicy {
1004            isolation: IsolationMode::Custom,
1005            egress: vec![
1006                PolicyRule {
1007                    from: "web".to_string(),
1008                    to: "db".to_string(),
1009                    ports: vec![],
1010                    protocol: "any".to_string(),
1011                    action: PolicyAction::Deny,
1012                },
1013                PolicyRule {
1014                    from: "web".to_string(),
1015                    to: "*".to_string(),
1016                    ports: vec![],
1017                    protocol: "any".to_string(),
1018                    action: PolicyAction::Allow,
1019                },
1020            ],
1021            ..Default::default()
1022        };
1023        // First matching rule wins: web→db is denied
1024        assert!(!policy.is_peer_allowed("web", "db"));
1025        // web→redis matches the wildcard allow
1026        assert!(policy.is_peer_allowed("web", "redis"));
1027    }
1028
1029    #[test]
1030    fn test_network_policy_custom_no_rules_denies() {
1031        let policy = NetworkPolicy {
1032            isolation: IsolationMode::Custom,
1033            egress: vec![],
1034            ..Default::default()
1035        };
1036        assert!(!policy.is_peer_allowed("web", "db"));
1037    }
1038
1039    #[test]
1040    fn test_network_policy_allowed_peers() {
1041        let policy = NetworkPolicy {
1042            isolation: IsolationMode::Custom,
1043            egress: vec![PolicyRule {
1044                from: "web".to_string(),
1045                to: "db".to_string(),
1046                ports: vec![],
1047                protocol: "any".to_string(),
1048                action: PolicyAction::Allow,
1049            }],
1050            ..Default::default()
1051        };
1052
1053        let peers = vec![
1054            ("10.88.0.3".to_string(), "db".to_string()),
1055            ("10.88.0.4".to_string(), "redis".to_string()),
1056        ];
1057
1058        let allowed = policy.allowed_peers("web", &peers);
1059        assert_eq!(allowed.len(), 1);
1060        assert_eq!(allowed[0].1, "db");
1061    }
1062
1063    #[test]
1064    fn test_network_policy_serde_roundtrip() {
1065        let policy = NetworkPolicy {
1066            isolation: IsolationMode::Custom,
1067            egress: vec![PolicyRule {
1068                from: "web".to_string(),
1069                to: "db".to_string(),
1070                ports: vec![5432],
1071                protocol: "tcp".to_string(),
1072                action: PolicyAction::Allow,
1073            }],
1074            ingress: vec![],
1075        };
1076        let json = serde_json::to_string(&policy).unwrap();
1077        let parsed: NetworkPolicy = serde_json::from_str(&json).unwrap();
1078        assert_eq!(parsed.isolation, IsolationMode::Custom);
1079        assert_eq!(parsed.egress.len(), 1);
1080        assert_eq!(parsed.egress[0].ports, vec![5432]);
1081    }
1082
1083    #[test]
1084    fn test_isolation_mode_serde() {
1085        let modes = vec![
1086            (IsolationMode::None, "\"none\""),
1087            (IsolationMode::Strict, "\"strict\""),
1088            (IsolationMode::Custom, "\"custom\""),
1089        ];
1090        for (mode, expected) in modes {
1091            let json = serde_json::to_string(&mode).unwrap();
1092            assert_eq!(json, expected);
1093            let parsed: IsolationMode = serde_json::from_str(&json).unwrap();
1094            assert_eq!(parsed, mode);
1095        }
1096    }
1097
1098    #[test]
1099    fn test_allowed_peer_endpoints_none_policy() {
1100        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
1101        net.connect("box-1", "web").unwrap();
1102        net.connect("box-2", "db").unwrap();
1103        net.connect("box-3", "redis").unwrap();
1104
1105        // Default policy (None) → all peers visible
1106        let peers = net.allowed_peer_endpoints("box-1");
1107        assert_eq!(peers.len(), 2);
1108    }
1109
1110    #[test]
1111    fn test_allowed_peer_endpoints_strict_policy() {
1112        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
1113        net.policy = NetworkPolicy {
1114            isolation: IsolationMode::Strict,
1115            ..Default::default()
1116        };
1117        net.connect("box-1", "web").unwrap();
1118        net.connect("box-2", "db").unwrap();
1119
1120        let peers = net.allowed_peer_endpoints("box-1");
1121        assert!(peers.is_empty());
1122    }
1123
1124    #[test]
1125    fn test_allowed_peer_endpoints_custom_policy() {
1126        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
1127        net.policy = NetworkPolicy {
1128            isolation: IsolationMode::Custom,
1129            egress: vec![PolicyRule {
1130                from: "web".to_string(),
1131                to: "db".to_string(),
1132                ports: vec![],
1133                protocol: "any".to_string(),
1134                action: PolicyAction::Allow,
1135            }],
1136            ..Default::default()
1137        };
1138        net.connect("box-1", "web").unwrap();
1139        net.connect("box-2", "db").unwrap();
1140        net.connect("box-3", "redis").unwrap();
1141
1142        let peers = net.allowed_peer_endpoints("box-1");
1143        assert_eq!(peers.len(), 1);
1144        assert_eq!(peers[0].1, "db");
1145    }
1146
1147    #[test]
1148    fn test_matches_pattern() {
1149        assert!(matches_pattern("*", "anything"));
1150        assert!(matches_pattern("web", "web"));
1151        assert!(!matches_pattern("web", "api"));
1152    }
1153
1154    #[test]
1155    fn test_policy_action_default() {
1156        assert_eq!(PolicyAction::default(), PolicyAction::Allow);
1157    }
1158
1159    // --- NetworkPolicy::validate tests ---
1160
1161    #[test]
1162    fn test_policy_validate_none_ok() {
1163        let policy = NetworkPolicy::default();
1164        assert!(policy.validate().is_ok());
1165    }
1166
1167    #[test]
1168    fn test_policy_validate_strict_rejected() {
1169        let policy = NetworkPolicy {
1170            isolation: IsolationMode::Strict,
1171            ..Default::default()
1172        };
1173        let err = policy.validate().unwrap_err();
1174        assert!(err.contains("strict"));
1175        assert!(err.contains("not yet enforced"));
1176    }
1177
1178    #[test]
1179    fn test_policy_validate_custom_rejected() {
1180        let policy = NetworkPolicy {
1181            isolation: IsolationMode::Custom,
1182            egress: vec![PolicyRule {
1183                from: "web".to_string(),
1184                to: "db".to_string(),
1185                ports: vec![],
1186                protocol: "any".to_string(),
1187                action: PolicyAction::Allow,
1188            }],
1189            ..Default::default()
1190        };
1191        let err = policy.validate().unwrap_err();
1192        assert!(err.contains("custom"));
1193        assert!(err.contains("not yet enforced"));
1194    }
1195
1196    #[test]
1197    fn test_network_config_runtime_validation_accepts_supported_configuration() {
1198        let network = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
1199
1200        assert!(network.validate_runtime().is_ok());
1201    }
1202
1203    #[test]
1204    fn test_network_config_runtime_validation_rejects_unsupported_driver() {
1205        let mut network = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
1206        network.driver = "overlay".to_string();
1207
1208        let error = network.validate_runtime().unwrap_err();
1209
1210        assert!(error.contains("Unsupported network driver"));
1211    }
1212
1213    // --- NetworkConfig::set_policy tests ---
1214
1215    #[test]
1216    fn test_set_policy_none_ok() {
1217        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
1218        assert!(net.set_policy(NetworkPolicy::default()).is_ok());
1219    }
1220
1221    #[test]
1222    fn test_set_policy_strict_rejected() {
1223        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
1224        let result = net.set_policy(NetworkPolicy {
1225            isolation: IsolationMode::Strict,
1226            ..Default::default()
1227        });
1228        assert!(result.is_err());
1229    }
1230
1231    // --- Ipam6 tests ---
1232
1233    #[test]
1234    fn test_ipam6_new_valid() {
1235        let ipam = Ipam6::new("fd00::/64").unwrap();
1236        assert_eq!(
1237            ipam.gateway(),
1238            "fd00::1".parse::<std::net::Ipv6Addr>().unwrap()
1239        );
1240        assert_eq!(ipam.cidr(), "fd00::/64");
1241    }
1242
1243    #[test]
1244    fn test_ipam6_invalid_cidr() {
1245        assert!(Ipam6::new("fd00::").is_err());
1246        assert!(Ipam6::new("not-an-ip/64").is_err());
1247        assert!(Ipam6::new("fd00::/63").is_err()); // below 64
1248        assert!(Ipam6::new("fd00::/121").is_err()); // above 120
1249    }
1250
1251    #[test]
1252    fn test_ipam6_allocate_first() {
1253        let ipam = Ipam6::new("fd00::/64").unwrap();
1254        let ip = ipam.allocate(&[]).unwrap();
1255        assert_eq!(ip, "fd00::2".parse::<std::net::Ipv6Addr>().unwrap());
1256    }
1257
1258    #[test]
1259    fn test_ipam6_allocate_sequential() {
1260        let ipam = Ipam6::new("fd00::/64").unwrap();
1261        let ip1 = ipam.allocate(&[]).unwrap();
1262        let ip2 = ipam.allocate(&[ip1]).unwrap();
1263        let ip3 = ipam.allocate(&[ip1, ip2]).unwrap();
1264
1265        assert_eq!(ip1, "fd00::2".parse::<std::net::Ipv6Addr>().unwrap());
1266        assert_eq!(ip2, "fd00::3".parse::<std::net::Ipv6Addr>().unwrap());
1267        assert_eq!(ip3, "fd00::4".parse::<std::net::Ipv6Addr>().unwrap());
1268    }
1269
1270    #[test]
1271    fn test_ipam6_allocate_skips_gateway() {
1272        let ipam = Ipam6::new("fd00::/64").unwrap();
1273        let ip = ipam.allocate(&[]).unwrap();
1274        assert_ne!(ip, ipam.gateway());
1275    }
1276
1277    #[test]
1278    fn test_ipam6_slash120() {
1279        let ipam = Ipam6::new("fd00::/120").unwrap();
1280        let ip = ipam.allocate(&[]).unwrap();
1281        assert_eq!(ip, "fd00::2".parse::<std::net::Ipv6Addr>().unwrap());
1282    }
1283}