Skip to main content

nucleus/network/
config.rs

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