Skip to main content

nucleus/network/
config.rs

1use std::net::Ipv4Addr;
2
3/// Network mode for container
4#[derive(Debug, Clone)]
5pub enum NetworkMode {
6    /// No networking (default, fully isolated)
7    None,
8    /// Share host network namespace
9    Host,
10    /// Bridge network with NAT
11    Bridge(BridgeConfig),
12}
13
14/// Configuration for bridge networking
15#[derive(Debug, Clone)]
16pub struct BridgeConfig {
17    /// Bridge interface name
18    pub bridge_name: String,
19    /// Subnet (e.g., "10.0.42.0/24")
20    pub subnet: String,
21    /// Container IP address (auto-assigned from subnet)
22    pub container_ip: Option<String>,
23    /// DNS servers
24    pub dns: Vec<String>,
25    /// Port forwarding rules
26    pub port_forwards: Vec<PortForward>,
27}
28
29impl Default for BridgeConfig {
30    fn default() -> Self {
31        Self {
32            bridge_name: "nucleus0".to_string(),
33            subnet: "10.0.42.0/24".to_string(),
34            container_ip: None,
35            // Empty by default — production services must configure DNS explicitly.
36            // Agent mode callers can use BridgeConfig::with_public_dns() for convenience.
37            dns: Vec::new(),
38            port_forwards: Vec::new(),
39        }
40    }
41}
42
43impl BridgeConfig {
44    /// Convenience: populate with public Google DNS resolvers.
45    /// Suitable for agent/sandbox workloads, NOT for production services.
46    pub fn with_public_dns(mut self) -> Self {
47        self.dns = vec!["8.8.8.8".to_string(), "8.8.4.4".to_string()];
48        self
49    }
50
51    pub fn with_dns(mut self, servers: Vec<String>) -> Self {
52        self.dns = servers;
53        self
54    }
55
56    /// Validate all fields to prevent argument injection into ip/iptables commands.
57    pub fn validate(&self) -> crate::error::Result<()> {
58        // Bridge name: alphanumeric, dash, underscore; max 15 chars (Linux IFNAMSIZ)
59        if self.bridge_name.is_empty() || self.bridge_name.len() > 15 {
60            return Err(crate::error::NucleusError::NetworkError(format!(
61                "Bridge name must be 1-15 characters, got '{}'",
62                self.bridge_name
63            )));
64        }
65        if !self
66            .bridge_name
67            .chars()
68            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
69        {
70            return Err(crate::error::NucleusError::NetworkError(format!(
71                "Bridge name contains invalid characters (allowed: a-zA-Z0-9_-): '{}'",
72                self.bridge_name
73            )));
74        }
75
76        // Subnet: must be valid IPv4 CIDR
77        validate_ipv4_cidr(&self.subnet)
78            .map_err(|e| crate::error::NucleusError::NetworkError(e))?;
79
80        // Container IP (if specified)
81        if let Some(ref ip) = self.container_ip {
82            validate_ipv4_addr(ip).map_err(|e| crate::error::NucleusError::NetworkError(e))?;
83        }
84
85        // DNS servers
86        for dns in &self.dns {
87            validate_ipv4_addr(dns).map_err(|e| crate::error::NucleusError::NetworkError(e))?;
88        }
89
90        Ok(())
91    }
92}
93
94/// Validate that a string is a valid IPv4 address (no leading dashes, proper octets).
95fn validate_ipv4_addr(s: &str) -> Result<(), String> {
96    let parts: Vec<&str> = s.split('.').collect();
97    if parts.len() != 4 {
98        return Err(format!("Invalid IPv4 address: '{}'", s));
99    }
100    for part in &parts {
101        if part.is_empty() {
102            return Err(format!("Invalid IPv4 address: '{}'", s));
103        }
104        if part.len() > 1 && part.starts_with('0') {
105            return Err(format!(
106                "Invalid IPv4 address: '{}' — octet '{}' has leading zero",
107                s, part
108            ));
109        }
110        match part.parse::<u8>() {
111            Ok(_) => {}
112            Err(_) => return Err(format!("Invalid IPv4 address: '{}'", s)),
113        }
114    }
115    Ok(())
116}
117
118/// Validate that a string is a valid IPv4 CIDR (e.g., "10.0.42.0/24").
119fn validate_ipv4_cidr(s: &str) -> Result<(), String> {
120    let (addr, prefix) = s
121        .split_once('/')
122        .ok_or_else(|| format!("Invalid CIDR (missing /prefix): '{}'", s))?;
123    validate_ipv4_addr(addr)?;
124    let prefix: u8 = prefix
125        .parse()
126        .map_err(|_| format!("Invalid CIDR prefix: '{}'", s))?;
127    if prefix > 32 {
128        return Err(format!("CIDR prefix must be 0-32, got {}", prefix));
129    }
130    Ok(())
131}
132
133/// Validate that a string is a valid IPv4 CIDR for egress rules.
134pub fn validate_egress_cidr(s: &str) -> Result<(), String> {
135    validate_ipv4_cidr(s)
136}
137
138/// Egress policy for audited outbound network access.
139///
140/// When set, iptables OUTPUT chain rules restrict which destinations the
141/// container process can connect to. An empty allowed list means no
142/// outbound connections are permitted (deny-all egress).
143#[derive(Debug, Clone)]
144pub struct EgressPolicy {
145    /// Allowed destination CIDRs (e.g., "10.0.0.0/8", "192.168.1.0/24").
146    pub allowed_cidrs: Vec<String>,
147    /// Allowed destination TCP ports. Empty means all ports on allowed CIDRs.
148    pub allowed_tcp_ports: Vec<u16>,
149    /// Allowed destination UDP ports.
150    pub allowed_udp_ports: Vec<u16>,
151    /// Whether to log denied egress attempts (rate-limited).
152    pub log_denied: bool,
153    /// Whether to allow DNS (port 53 UDP/TCP) to configured resolvers even in
154    /// deny-all mode. Defaults to `true` for usability; set to `false` for
155    /// strict deny-all egress (containers must use pre-resolved addresses).
156    pub allow_dns: bool,
157}
158
159impl Default for EgressPolicy {
160    fn default() -> Self {
161        Self {
162            allowed_cidrs: Vec::new(),
163            allowed_tcp_ports: Vec::new(),
164            allowed_udp_ports: Vec::new(),
165            log_denied: true,
166            allow_dns: true,
167        }
168    }
169}
170
171impl EgressPolicy {
172    /// Create a deny-all egress policy. DNS is still permitted by default
173    /// so containers can resolve names; use `allow_dns = false` for strict
174    /// deny-all egress.
175    pub fn deny_all() -> Self {
176        Self::default()
177    }
178
179    /// Allow egress to the given CIDRs on any port.
180    pub fn with_allowed_cidrs(mut self, cidrs: Vec<String>) -> Self {
181        self.allowed_cidrs = cidrs;
182        self
183    }
184
185    pub fn with_allowed_tcp_ports(mut self, ports: Vec<u16>) -> Self {
186        self.allowed_tcp_ports = ports;
187        self
188    }
189
190    pub fn with_allowed_udp_ports(mut self, ports: Vec<u16>) -> Self {
191        self.allowed_udp_ports = ports;
192        self
193    }
194}
195
196/// Network protocol for port forwarding rules.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum Protocol {
199    Tcp,
200    Udp,
201}
202
203impl Protocol {
204    pub fn as_str(self) -> &'static str {
205        match self {
206            Self::Tcp => "tcp",
207            Self::Udp => "udp",
208        }
209    }
210}
211
212impl std::fmt::Display for Protocol {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        f.write_str(self.as_str())
215    }
216}
217
218/// Port forwarding rule
219#[derive(Debug, Clone)]
220pub struct PortForward {
221    /// Optional host bind IP address. When omitted, match all local addresses.
222    pub host_ip: Option<Ipv4Addr>,
223    /// Host port
224    pub host_port: u16,
225    /// Container port
226    pub container_port: u16,
227    /// Protocol (tcp/udp)
228    pub protocol: Protocol,
229}
230
231impl PortForward {
232    /// Parse a port forward spec like:
233    /// - "8080:80"
234    /// - "8080:80/udp"
235    /// - "127.0.0.1:8080:80"
236    /// - "127.0.0.1:8080:80/udp"
237    pub fn parse(spec: &str) -> crate::error::Result<Self> {
238        let (ports, protocol) = if let Some((p, proto)) = spec.rsplit_once('/') {
239            let protocol = match proto {
240                "tcp" => Protocol::Tcp,
241                "udp" => Protocol::Udp,
242                _ => {
243                    return Err(crate::error::NucleusError::ConfigError(format!(
244                        "Invalid protocol '{}', must be tcp or udp",
245                        proto
246                    )))
247                }
248            };
249            (p, protocol)
250        } else {
251            (spec, Protocol::Tcp)
252        };
253
254        let parts: Vec<&str> = ports.split(':').collect();
255        let (host_ip, host_port, container_port) = match parts.as_slice() {
256            [host_port, container_port] => (None, *host_port, *container_port),
257            [host_ip, host_port, container_port] => {
258                validate_ipv4_addr(host_ip).map_err(crate::error::NucleusError::ConfigError)?;
259                let host_ip = host_ip.parse::<Ipv4Addr>().map_err(|_| {
260                    crate::error::NucleusError::ConfigError(format!(
261                        "Invalid host IP address: {}",
262                        host_ip
263                    ))
264                })?;
265                (Some(host_ip), *host_port, *container_port)
266            }
267            _ => {
268                return Err(crate::error::NucleusError::ConfigError(format!(
269                    "Invalid port forward format '{}', expected HOST:CONTAINER or HOST_IP:HOST:CONTAINER",
270                    spec
271                )))
272            }
273        };
274
275        let host_port: u16 = host_port.parse().map_err(|_| {
276            crate::error::NucleusError::ConfigError(format!("Invalid host port: {}", host_port))
277        })?;
278        let container_port: u16 = container_port.parse().map_err(|_| {
279            crate::error::NucleusError::ConfigError(format!(
280                "Invalid container port: {}",
281                container_port
282            ))
283        })?;
284
285        Ok(Self {
286            host_ip,
287            host_port,
288            container_port,
289            protocol,
290        })
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_port_forward_parse() {
300        let pf = PortForward::parse("8080:80").unwrap();
301        assert_eq!(pf.host_ip, None);
302        assert_eq!(pf.host_port, 8080);
303        assert_eq!(pf.container_port, 80);
304        assert_eq!(pf.protocol, Protocol::Tcp);
305
306        let pf = PortForward::parse("5353:53/udp").unwrap();
307        assert_eq!(pf.host_ip, None);
308        assert_eq!(pf.host_port, 5353);
309        assert_eq!(pf.container_port, 53);
310        assert_eq!(pf.protocol, Protocol::Udp);
311
312        let pf = PortForward::parse("127.0.0.1:8080:80").unwrap();
313        assert_eq!(pf.host_ip, Some(Ipv4Addr::new(127, 0, 0, 1)));
314        assert_eq!(pf.host_port, 8080);
315        assert_eq!(pf.container_port, 80);
316        assert_eq!(pf.protocol, Protocol::Tcp);
317
318        let pf = PortForward::parse("10.0.0.5:5353:53/udp").unwrap();
319        assert_eq!(pf.host_ip, Some(Ipv4Addr::new(10, 0, 0, 5)));
320        assert_eq!(pf.host_port, 5353);
321        assert_eq!(pf.container_port, 53);
322        assert_eq!(pf.protocol, Protocol::Udp);
323    }
324
325    #[test]
326    fn test_port_forward_parse_invalid() {
327        assert!(PortForward::parse("8080").is_err());
328        assert!(PortForward::parse("abc:80").is_err());
329        assert!(PortForward::parse("8080:abc").is_err());
330        assert!(PortForward::parse("127.0.0.1:abc:80").is_err());
331        assert!(PortForward::parse("999.0.0.1:8080:80").is_err());
332    }
333
334    #[test]
335    fn test_validate_ipv4_addr_rejects_leading_zeros() {
336        assert!(validate_ipv4_addr("10.0.42.1").is_ok());
337        assert!(validate_ipv4_addr("0.0.0.0").is_ok());
338        assert!(
339            validate_ipv4_addr("010.0.0.1").is_err(),
340            "leading zero in first octet must be rejected"
341        );
342        assert!(
343            validate_ipv4_addr("10.01.0.1").is_err(),
344            "leading zero in second octet must be rejected"
345        );
346        assert!(
347            validate_ipv4_addr("10.0.01.1").is_err(),
348            "leading zero in third octet must be rejected"
349        );
350        assert!(
351            validate_ipv4_addr("10.0.0.01").is_err(),
352            "leading zero in fourth octet must be rejected"
353        );
354    }
355}