Skip to main content

nucleus/network/
config.rs

1use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4};
2use std::str::FromStr;
3
4/// Network mode for container
5#[derive(Debug, Clone)]
6pub enum NetworkMode {
7    /// No networking (default, fully isolated)
8    None,
9    /// Native runtime host network namespace sharing.
10    Host,
11    /// gVisor hostinet mode; omits the OCI network namespace and passes
12    /// `--network host` to runsc.
13    GVisorHost,
14    /// Bridge network with NAT
15    Bridge(BridgeConfig),
16}
17
18/// NAT backend for native bridge-style networking.
19///
20/// `Auto` preserves the historical behavior for privileged callers while
21/// enabling a userspace NAT path for rootless/native containers.
22#[derive(
23    Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize,
24)]
25#[serde(rename_all = "lowercase")]
26pub enum NatBackend {
27    /// Select kernel bridge + iptables when privileged, otherwise userspace NAT.
28    #[value(name = "auto")]
29    Auto,
30    /// Require the kernel bridge/veth/iptables backend.
31    #[value(name = "kernel")]
32    Kernel,
33    /// Require the userspace NAT backend.
34    #[value(name = "userspace")]
35    Userspace,
36}
37
38impl NatBackend {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Auto => "auto",
42            Self::Kernel => "kernel",
43            Self::Userspace => "userspace",
44        }
45    }
46}
47
48/// Configuration for bridge networking
49#[derive(Debug, Clone)]
50pub struct BridgeConfig {
51    /// Bridge interface name
52    pub bridge_name: String,
53    /// Subnet (e.g., "10.0.42.0/24")
54    pub subnet: String,
55    /// Container IP address (auto-assigned from subnet)
56    pub container_ip: Option<String>,
57    /// DNS servers
58    pub dns: Vec<String>,
59    /// Port forwarding rules
60    pub port_forwards: Vec<PortForward>,
61    /// NAT backend selection for the native runtime.
62    pub nat_backend: NatBackend,
63}
64
65impl Default for BridgeConfig {
66    fn default() -> Self {
67        Self {
68            bridge_name: "nucleus0".to_string(),
69            subnet: "10.0.42.0/24".to_string(),
70            container_ip: None,
71            // Empty by default – production services must configure DNS explicitly.
72            // Agent mode callers can use BridgeConfig::with_public_dns() for convenience.
73            dns: Vec::new(),
74            port_forwards: Vec::new(),
75            nat_backend: NatBackend::Auto,
76        }
77    }
78}
79
80impl BridgeConfig {
81    /// Convenience: populate with public Google DNS resolvers.
82    /// Suitable for agent/sandbox workloads, NOT for production services.
83    pub fn with_public_dns(mut self) -> Self {
84        self.dns = vec!["8.8.8.8".to_string(), "8.8.4.4".to_string()];
85        self
86    }
87
88    pub fn with_dns(mut self, servers: Vec<String>) -> Self {
89        self.dns = servers;
90        self
91    }
92
93    pub fn with_nat_backend(mut self, backend: NatBackend) -> Self {
94        self.nat_backend = backend;
95        self
96    }
97
98    pub fn selected_nat_backend(&self, host_is_root: bool, rootless: bool) -> NatBackend {
99        match self.nat_backend {
100            NatBackend::Auto if host_is_root && !rootless => NatBackend::Kernel,
101            NatBackend::Auto => NatBackend::Userspace,
102            explicit => explicit,
103        }
104    }
105
106    pub fn contains_ipv4(&self, ip: Ipv4Addr) -> Result<bool, String> {
107        let (network, prefix) = parse_ipv4_cidr(&self.subnet)?;
108        Ok(ipv4_in_cidr(ip, network, prefix))
109    }
110
111    /// Return the host-side bridge gateway address for this subnet.
112    pub fn gateway_ipv4(&self) -> Result<Ipv4Addr, String> {
113        let (network, prefix) = parse_ipv4_cidr(&self.subnet)?;
114        let network_u32 = u32::from(network) & ipv4_mask(prefix);
115        let gateway = if prefix >= 31 {
116            network_u32
117        } else {
118            network_u32
119                .checked_add(1)
120                .ok_or_else(|| format!("CIDR '{}' overflowed", self.subnet))?
121        };
122        Ok(Ipv4Addr::from(gateway))
123    }
124
125    /// Validate all fields to prevent argument injection into ip/iptables commands.
126    pub fn validate(&self) -> crate::error::Result<()> {
127        // Bridge name: alphanumeric, dash, underscore; max 15 chars (Linux IFNAMSIZ)
128        if self.bridge_name.is_empty() || self.bridge_name.len() > 15 {
129            return Err(crate::error::NucleusError::NetworkError(format!(
130                "Bridge name must be 1-15 characters, got '{}'",
131                self.bridge_name
132            )));
133        }
134        if !self
135            .bridge_name
136            .chars()
137            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
138        {
139            return Err(crate::error::NucleusError::NetworkError(format!(
140                "Bridge name contains invalid characters (allowed: a-zA-Z0-9_-): '{}'",
141                self.bridge_name
142            )));
143        }
144
145        // Subnet: must be valid IPv4 CIDR
146        validate_ipv4_cidr(&self.subnet).map_err(crate::error::NucleusError::NetworkError)?;
147
148        // Container IP (if specified)
149        if let Some(ref ip) = self.container_ip {
150            validate_ipv4_addr(ip).map_err(crate::error::NucleusError::NetworkError)?;
151        }
152
153        // DNS servers
154        for dns in &self.dns {
155            validate_ipv4_addr(dns).map_err(crate::error::NucleusError::NetworkError)?;
156        }
157
158        Ok(())
159    }
160}
161
162/// Validate that a string is a valid IPv4 address (no leading dashes, proper octets).
163fn validate_ipv4_addr(s: &str) -> Result<(), String> {
164    let parts: Vec<&str> = s.split('.').collect();
165    if parts.len() != 4 {
166        return Err(format!("Invalid IPv4 address: '{}'", s));
167    }
168    for part in &parts {
169        if part.is_empty() {
170            return Err(format!("Invalid IPv4 address: '{}'", s));
171        }
172        if part.len() > 1 && part.starts_with('0') {
173            return Err(format!(
174                "Invalid IPv4 address: '{}' – octet '{}' has leading zero",
175                s, part
176            ));
177        }
178        match part.parse::<u8>() {
179            Ok(_) => {}
180            Err(_) => return Err(format!("Invalid IPv4 address: '{}'", s)),
181        }
182    }
183    Ok(())
184}
185
186fn parse_ipv4_cidr(s: &str) -> Result<(Ipv4Addr, u8), String> {
187    let (addr, prefix) = s
188        .split_once('/')
189        .ok_or_else(|| format!("Invalid CIDR (missing /prefix): '{}'", s))?;
190    validate_ipv4_addr(addr)?;
191    let addr = addr
192        .parse::<Ipv4Addr>()
193        .map_err(|_| format!("Invalid IPv4 address: '{}'", addr))?;
194    let prefix: u8 = prefix
195        .parse()
196        .map_err(|_| format!("Invalid CIDR prefix: '{}'", s))?;
197    if prefix > 32 {
198        return Err(format!("CIDR prefix must be 0-32, got {}", prefix));
199    }
200    Ok((addr, prefix))
201}
202
203fn ipv4_mask(prefix: u8) -> u32 {
204    if prefix == 0 {
205        0
206    } else {
207        u32::MAX << (32 - prefix)
208    }
209}
210
211fn ipv4_in_cidr(ip: Ipv4Addr, network: Ipv4Addr, prefix: u8) -> bool {
212    let mask = ipv4_mask(prefix);
213    (u32::from(ip) & mask) == (u32::from(network) & mask)
214}
215
216/// Validate that a string is a valid IPv4 CIDR (e.g., "10.0.42.0/24").
217fn validate_ipv4_cidr(s: &str) -> Result<(), String> {
218    parse_ipv4_cidr(s).map(|_| ())
219}
220
221/// Validate that a string is a valid IPv4 CIDR for egress rules.
222pub fn validate_egress_cidr(s: &str) -> Result<(), String> {
223    validate_ipv4_cidr(s)
224}
225
226/// Validate that a string is an exact DNS name for egress domain rules.
227pub fn validate_egress_domain(s: &str) -> Result<(), String> {
228    if s.is_empty() {
229        return Err("Egress domain cannot be empty".to_string());
230    }
231    if s.parse::<Ipv4Addr>().is_ok() || s.parse::<Ipv6Addr>().is_ok() {
232        return Err(format!(
233            "Egress domain '{}' is an IP address; use --egress-allow with a CIDR",
234            s
235        ));
236    }
237
238    let domain = s.strip_suffix('.').unwrap_or(s);
239    if domain.is_empty() {
240        return Err("Egress domain cannot be only '.'".to_string());
241    }
242    if domain.len() > 253 {
243        return Err(format!("Egress domain '{}' is longer than 253 bytes", s));
244    }
245    if !domain.contains('.') {
246        return Err(format!(
247            "Egress domain '{}' must be a fully-qualified name with at least one dot",
248            s
249        ));
250    }
251
252    for label in domain.split('.') {
253        if label.is_empty() {
254            return Err(format!("Egress domain '{}' contains an empty label", s));
255        }
256        if label.len() > 63 {
257            return Err(format!(
258                "Egress domain '{}' contains a label longer than 63 bytes",
259                s
260            ));
261        }
262        if label.starts_with('-') || label.ends_with('-') {
263            return Err(format!(
264                "Egress domain '{}' contains a label starting or ending with '-'",
265                s
266            ));
267        }
268        if !label
269            .bytes()
270            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
271        {
272            return Err(format!("Egress domain '{}' contains invalid characters", s));
273        }
274    }
275
276    Ok(())
277}
278
279/// Egress policy for audited outbound network access.
280///
281/// When set, iptables OUTPUT chain rules restrict which destinations the
282/// container process can connect to. Use [`EgressPolicy::deny_all`] when no
283/// outbound connections, including DNS, should be permitted.
284#[derive(Debug, Clone)]
285pub struct EgressPolicy {
286    /// Allowed destination CIDRs (e.g., "10.0.0.0/8", "192.168.1.0/24").
287    pub allowed_cidrs: Vec<String>,
288    /// Allowed exact DNS names. These are resolved to IPv4 /32 rules at startup.
289    pub allowed_domains: Vec<String>,
290    /// Allowed destination TCP ports. Empty means all ports on allowed CIDRs.
291    pub allowed_tcp_ports: Vec<u16>,
292    /// Allowed destination UDP ports.
293    pub allowed_udp_ports: Vec<u16>,
294    /// Whether to log denied egress attempts (rate-limited).
295    pub log_denied: bool,
296    /// Whether to add implicit DNS (port 53 UDP/TCP) allow rules for configured
297    /// resolvers. Defaults to `true` for explicit allowlist usability.
298    pub allow_dns: bool,
299}
300
301impl Default for EgressPolicy {
302    fn default() -> Self {
303        Self {
304            allowed_cidrs: Vec::new(),
305            allowed_domains: Vec::new(),
306            allowed_tcp_ports: Vec::new(),
307            allowed_udp_ports: Vec::new(),
308            log_denied: true,
309            allow_dns: true,
310        }
311    }
312}
313
314impl EgressPolicy {
315    /// Create a strict deny-all egress policy, including DNS.
316    pub fn deny_all() -> Self {
317        Self {
318            allow_dns: false,
319            ..Self::default()
320        }
321    }
322
323    /// Allow egress to the given CIDRs on any port.
324    pub fn with_allowed_cidrs(mut self, cidrs: Vec<String>) -> Self {
325        self.allowed_cidrs = cidrs;
326        self
327    }
328
329    /// Allow egress to the given exact DNS names.
330    pub fn with_allowed_domains(mut self, domains: Vec<String>) -> Self {
331        self.allowed_domains = domains;
332        self
333    }
334
335    pub fn with_allowed_tcp_ports(mut self, ports: Vec<u16>) -> Self {
336        self.allowed_tcp_ports = ports;
337        self
338    }
339
340    pub fn with_allowed_udp_ports(mut self, ports: Vec<u16>) -> Self {
341        self.allowed_udp_ports = ports;
342        self
343    }
344
345    /// Create a deny-by-default policy that only permits TCP egress to the
346    /// configured host-side credential broker.
347    pub fn credential_broker_only(broker: &CredentialBrokerConfig) -> Self {
348        Self::deny_all()
349            .with_allowed_cidrs(vec![broker.broker_cidr()])
350            .with_allowed_tcp_ports(vec![broker.broker_port])
351    }
352
353    /// Return true when this policy permits no direct outbound route except
354    /// the configured credential broker endpoint.
355    pub fn is_credential_broker_only(&self, broker: &CredentialBrokerConfig) -> bool {
356        self.allowed_cidrs == vec![broker.broker_cidr()]
357            && self.allowed_domains.is_empty()
358            && self.allowed_tcp_ports == vec![broker.broker_port]
359            && self.allowed_udp_ports.is_empty()
360            && !self.allow_dns
361    }
362}
363
364/// Host-side credential broker endpoint for broker-only egress.
365///
366/// The broker process runs outside the sandbox and holds the real credential.
367/// Nucleus only allows the sandbox to reach this endpoint; the broker is
368/// responsible for injecting credentials, constraining upstream destinations,
369/// rate limiting, and auditing authenticated requests.
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct CredentialBrokerConfig {
372    /// Host-side bridge IP reachable from the container namespace.
373    pub broker_ip: Ipv4Addr,
374    /// TCP port where the host-side broker listens.
375    pub broker_port: u16,
376    /// Whether Nucleus should inject standard proxy environment variables
377    /// pointing at the broker. Disable this when using a provider-specific
378    /// base URL environment variable instead.
379    pub inject_proxy_env: bool,
380}
381
382impl CredentialBrokerConfig {
383    /// Create a credential broker config from a host-side bridge IP and port.
384    pub fn new(broker_ip: Ipv4Addr, broker_port: u16) -> Self {
385        Self {
386            broker_ip,
387            broker_port,
388            inject_proxy_env: true,
389        }
390    }
391
392    /// Parse an endpoint in `IPv4:PORT` form.
393    pub fn parse_endpoint(endpoint: &str) -> Result<Self, String> {
394        let socket = SocketAddrV4::from_str(endpoint).map_err(|_| {
395            format!(
396                "Invalid credential broker endpoint '{}', expected IPv4:PORT",
397                endpoint
398            )
399        })?;
400        let config = Self::new(*socket.ip(), socket.port());
401        config.validate()?;
402        Ok(config)
403    }
404
405    pub fn with_proxy_env(mut self, inject_proxy_env: bool) -> Self {
406        self.inject_proxy_env = inject_proxy_env;
407        self
408    }
409
410    pub fn validate(&self) -> Result<(), String> {
411        if self.broker_port == 0 {
412            return Err("Credential broker port must be non-zero".to_string());
413        }
414        if self.broker_ip.is_unspecified() {
415            return Err("Credential broker IP must not be 0.0.0.0".to_string());
416        }
417        if self.broker_ip.is_loopback() {
418            return Err(
419                "Credential broker IP must be reachable on the bridge, not container loopback"
420                    .to_string(),
421            );
422        }
423        if self.broker_ip.is_multicast() || self.broker_ip == Ipv4Addr::BROADCAST {
424            return Err("Credential broker IP must be a unicast IPv4 address".to_string());
425        }
426        Ok(())
427    }
428
429    pub fn validate_for_bridge(&self, bridge: &BridgeConfig) -> Result<(), String> {
430        self.validate()?;
431        let gateway = bridge.gateway_ipv4()?;
432        if self.broker_ip != gateway {
433            return Err(format!(
434                "Credential broker IP must be the host-side bridge address {} for subnet {}, got {}",
435                gateway, bridge.subnet, self.broker_ip
436            ));
437        }
438        Ok(())
439    }
440
441    pub fn broker_cidr(&self) -> String {
442        format!("{}/32", self.broker_ip)
443    }
444
445    pub fn proxy_url(&self) -> String {
446        format!("http://{}:{}", self.broker_ip, self.broker_port)
447    }
448
449    /// Standard proxy variables for HTTP API CLIs that honor proxy settings.
450    ///
451    /// These values are not secrets; they only point at the broker endpoint.
452    pub fn proxy_environment(&self) -> Vec<(String, String)> {
453        if !self.inject_proxy_env {
454            return Vec::new();
455        }
456
457        let url = self.proxy_url();
458        ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]
459            .into_iter()
460            .map(|key| (key.to_string(), url.clone()))
461            .collect()
462    }
463
464    pub fn egress_policy(&self) -> EgressPolicy {
465        EgressPolicy::credential_broker_only(self)
466    }
467}
468
469/// Network protocol for port forwarding rules.
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub enum Protocol {
472    Tcp,
473    Udp,
474}
475
476impl Protocol {
477    pub fn as_str(self) -> &'static str {
478        match self {
479            Self::Tcp => "tcp",
480            Self::Udp => "udp",
481        }
482    }
483}
484
485impl std::fmt::Display for Protocol {
486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487        f.write_str(self.as_str())
488    }
489}
490
491/// Port forwarding rule
492#[derive(Debug, Clone)]
493pub struct PortForward {
494    /// Optional host bind IP address. When omitted, match all local addresses.
495    pub host_ip: Option<Ipv4Addr>,
496    /// Host port
497    pub host_port: u16,
498    /// Container port
499    pub container_port: u16,
500    /// Protocol (tcp/udp)
501    pub protocol: Protocol,
502}
503
504impl PortForward {
505    /// Parse a port forward spec like:
506    /// - "8080:80"
507    /// - "8080:80/udp"
508    /// - "127.0.0.1:8080:80"
509    /// - "127.0.0.1:8080:80/udp"
510    pub fn parse(spec: &str) -> crate::error::Result<Self> {
511        let (ports, protocol) = if let Some((p, proto)) = spec.rsplit_once('/') {
512            let protocol = match proto {
513                "tcp" => Protocol::Tcp,
514                "udp" => Protocol::Udp,
515                _ => {
516                    return Err(crate::error::NucleusError::ConfigError(format!(
517                        "Invalid protocol '{}', must be tcp or udp",
518                        proto
519                    )))
520                }
521            };
522            (p, protocol)
523        } else {
524            (spec, Protocol::Tcp)
525        };
526
527        let parts: Vec<&str> = ports.split(':').collect();
528        let (host_ip, host_port, container_port) = match parts.as_slice() {
529            [host_port, container_port] => (None, *host_port, *container_port),
530            [host_ip, host_port, container_port] => {
531                validate_ipv4_addr(host_ip).map_err(crate::error::NucleusError::ConfigError)?;
532                let host_ip = host_ip.parse::<Ipv4Addr>().map_err(|_| {
533                    crate::error::NucleusError::ConfigError(format!(
534                        "Invalid host IP address: {}",
535                        host_ip
536                    ))
537                })?;
538                (Some(host_ip), *host_port, *container_port)
539            }
540            _ => {
541                return Err(crate::error::NucleusError::ConfigError(format!(
542                    "Invalid port forward format '{}', expected HOST:CONTAINER or HOST_IP:HOST:CONTAINER",
543                    spec
544                )))
545            }
546        };
547
548        let host_port: u16 = host_port.parse().map_err(|_| {
549            crate::error::NucleusError::ConfigError(format!("Invalid host port: {}", host_port))
550        })?;
551        let container_port: u16 = container_port.parse().map_err(|_| {
552            crate::error::NucleusError::ConfigError(format!(
553                "Invalid container port: {}",
554                container_port
555            ))
556        })?;
557
558        Ok(Self {
559            host_ip,
560            host_port,
561            container_port,
562            protocol,
563        })
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn test_port_forward_parse() {
573        let pf = PortForward::parse("8080:80").unwrap();
574        assert_eq!(pf.host_ip, None);
575        assert_eq!(pf.host_port, 8080);
576        assert_eq!(pf.container_port, 80);
577        assert_eq!(pf.protocol, Protocol::Tcp);
578
579        let pf = PortForward::parse("5353:53/udp").unwrap();
580        assert_eq!(pf.host_ip, None);
581        assert_eq!(pf.host_port, 5353);
582        assert_eq!(pf.container_port, 53);
583        assert_eq!(pf.protocol, Protocol::Udp);
584
585        let pf = PortForward::parse("127.0.0.1:8080:80").unwrap();
586        assert_eq!(pf.host_ip, Some(Ipv4Addr::new(127, 0, 0, 1)));
587        assert_eq!(pf.host_port, 8080);
588        assert_eq!(pf.container_port, 80);
589        assert_eq!(pf.protocol, Protocol::Tcp);
590
591        let pf = PortForward::parse("10.0.0.5:5353:53/udp").unwrap();
592        assert_eq!(pf.host_ip, Some(Ipv4Addr::new(10, 0, 0, 5)));
593        assert_eq!(pf.host_port, 5353);
594        assert_eq!(pf.container_port, 53);
595        assert_eq!(pf.protocol, Protocol::Udp);
596    }
597
598    #[test]
599    fn test_port_forward_parse_invalid() {
600        assert!(PortForward::parse("8080").is_err());
601        assert!(PortForward::parse("abc:80").is_err());
602        assert!(PortForward::parse("8080:abc").is_err());
603        assert!(PortForward::parse("127.0.0.1:abc:80").is_err());
604        assert!(PortForward::parse("999.0.0.1:8080:80").is_err());
605    }
606
607    #[test]
608    fn test_validate_ipv4_addr_rejects_leading_zeros() {
609        assert!(validate_ipv4_addr("10.0.42.1").is_ok());
610        assert!(validate_ipv4_addr("0.0.0.0").is_ok());
611        assert!(
612            validate_ipv4_addr("010.0.0.1").is_err(),
613            "leading zero in first octet must be rejected"
614        );
615        assert!(
616            validate_ipv4_addr("10.01.0.1").is_err(),
617            "leading zero in second octet must be rejected"
618        );
619        assert!(
620            validate_ipv4_addr("10.0.01.1").is_err(),
621            "leading zero in third octet must be rejected"
622        );
623        assert!(
624            validate_ipv4_addr("10.0.0.01").is_err(),
625            "leading zero in fourth octet must be rejected"
626        );
627    }
628}