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