diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index e276840d..b6921560 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -79,7 +79,7 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { policy.landlock.compatibility, LandlockCompatibility::BestEffort ) { - warn!( + debug!( error = %err, "Landlock filesystem sandbox is UNAVAILABLE — running WITHOUT filesystem restrictions. \ Set landlock.compatibility to 'hard_requirement' to make this a fatal error." diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index 095ed86c..2c53b7ff 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -19,6 +19,76 @@ const SUBNET_PREFIX: &str = "10.200.0"; const HOST_IP_SUFFIX: u8 = 1; const SANDBOX_IP_SUFFIX: u8 = 2; +/// Resolve hostnames that require direct TCP access (bypassing the HTTP proxy). +/// +/// Returns resolved IPv4 addresses for hosts listed in `OPENSHELL_DIRECT_TCP_HOSTS` +/// (comma-separated hostnames). These hosts are resolved via the system DNS and +/// get iptables ACCEPT rules for TCP port 443 in the sandbox netns, plus +/// MASQUERADE on the host side so responses can return. +/// +/// This is needed for libraries (e.g. Node.js `ws`) that make direct TCP +/// connections after resolving DNS, ignoring HTTP_PROXY settings. +fn resolve_direct_tcp_hosts() -> Vec { + let hosts = match std::env::var("OPENSHELL_DIRECT_TCP_HOSTS") { + Ok(val) if !val.is_empty() => val, + _ => return Vec::new(), + }; + + let mut addrs = Vec::new(); + for host in hosts.split(',') { + let host = host.trim(); + if host.is_empty() { + continue; + } + // Use std::net to resolve — this runs in the pod netns (not sandbox) + // so cluster DNS works normally. + match std::net::ToSocketAddrs::to_socket_addrs(&(host, 443_u16)) { + Ok(iter) => { + for sa in iter { + if sa.is_ipv4() && !addrs.contains(&sa.ip()) { + addrs.push(sa.ip()); + } + } + info!(host = %host, count = addrs.len(), "Resolved direct TCP host"); + } + Err(e) => { + warn!(host = %host, error = %e, "Failed to resolve direct TCP host"); + } + } + } + addrs +} + +/// Resolve the cluster DNS server IP for the iptables ACCEPT rule. +/// +/// Priority: +/// 1. `OPENSHELL_DNS_SERVER` environment variable (operator override) +/// 2. First `nameserver` entry in `/etc/resolv.conf` +/// +/// Returns `None` if neither source provides a valid IP, in which case +/// no DNS ACCEPT rule will be installed and UDP DNS remains blocked. +fn resolve_dns_server() -> Option { + if let Ok(val) = std::env::var("OPENSHELL_DNS_SERVER") { + if let Ok(addr) = val.parse::() { + return Some(addr); + } + warn!(value = %val, "OPENSHELL_DNS_SERVER is not a valid IP address, ignoring"); + } + + if let Ok(contents) = std::fs::read_to_string("/etc/resolv.conf") { + for line in contents.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("nameserver") { + if let Ok(addr) = rest.trim().parse::() { + return Some(addr); + } + } + } + } + + None +} + /// Handle to a network namespace with veth pair. /// /// The namespace and veth interfaces are automatically cleaned up on drop. @@ -294,6 +364,101 @@ impl NetworkNamespace { } } + // Enable IP forwarding and NAT on the host side of the veth for DNS. + // + // The sandbox netns routes all traffic via 10.200.0.1 (host veth). + // The DNS ACCEPT rule in the sandbox allows UDP 53 to leave the + // sandbox, but the packet arrives on the host side with src=10.200.0.2 + // which the pod network doesn't know how to route back to. + // Enable forwarding on the veth and MASQUERADE the DNS traffic + // so CoreDNS sees it as coming from the pod IP. + if let Some(dns_ip) = resolve_dns_server() { + let dns_ip_str = dns_ip.to_string(); + let sandbox_ip_str = self.sandbox_ip.to_string(); + + // Enable forwarding on the host veth interface + let forwarding_path = format!( + "/proc/sys/net/ipv4/conf/{}/forwarding", + self.veth_host + ); + if let Err(e) = std::fs::write(&forwarding_path, "1") { + warn!( + error = %e, + path = %forwarding_path, + "Failed to enable IP forwarding on host veth (DNS may not work)" + ); + } + // Also enable global forwarding + let _ = std::fs::write("/proc/sys/net/ipv4/ip_forward", "1"); + + // MASQUERADE DNS packets from sandbox IP to CoreDNS + let dns_cidr = format!("{dns_ip_str}/32"); + let sandbox_cidr = format!("{sandbox_ip_str}/32"); + let _ = Command::new(&iptables_path) + .args([ + "-t", "nat", "-A", "POSTROUTING", + "-s", &sandbox_cidr, "-d", &dns_cidr, + "-p", "udp", "--dport", "53", + "-j", "MASQUERADE", + ]) + .output(); + + // ACCEPT forwarded DNS packets + let _ = Command::new(&iptables_path) + .args([ + "-A", "FORWARD", + "-s", &sandbox_cidr, "-d", &dns_cidr, + "-p", "udp", "--dport", "53", + "-j", "ACCEPT", + ]) + .output(); + + // ACCEPT return traffic + let _ = Command::new(&iptables_path) + .args([ + "-A", "FORWARD", + "-m", "state", "--state", "ESTABLISHED,RELATED", + "-j", "ACCEPT", + ]) + .output(); + + info!( + dns_server = %dns_ip_str, + veth = %self.veth_host, + "Enabled DNS forwarding from sandbox to cluster nameserver" + ); + } + + // Host-side forwarding for direct TCP 443 (OPENSHELL_DIRECT_TCP_HOSTS). + // Same pattern as DNS: MASQUERADE so the destination sees the pod IP. + let direct_tcp_ips = resolve_direct_tcp_hosts(); + if !direct_tcp_ips.is_empty() { + let sandbox_cidr = format!("{}/32", self.sandbox_ip); + for ip in &direct_tcp_ips { + let ip_cidr = format!("{ip}/32"); + let _ = Command::new(&iptables_path) + .args([ + "-t", "nat", "-A", "POSTROUTING", + "-s", &sandbox_cidr, "-d", &ip_cidr, + "-p", "tcp", "--dport", "443", + "-j", "MASQUERADE", + ]) + .output(); + let _ = Command::new(&iptables_path) + .args([ + "-A", "FORWARD", + "-s", &sandbox_cidr, "-d", &ip_cidr, + "-p", "tcp", "--dport", "443", + "-j", "ACCEPT", + ]) + .output(); + } + info!( + count = direct_tcp_ips.len(), + "Enabled direct TCP 443 forwarding for OPENSHELL_DIRECT_TCP_HOSTS" + ); + } + info!( namespace = %self.name, "Bypass detection rules installed" @@ -382,6 +547,29 @@ impl NetworkNamespace { ); } + // Rule 4.5: ACCEPT direct TCP 443 to hosts listed in OPENSHELL_DIRECT_TCP_HOSTS. + // + // Some libraries (e.g. Node.js `ws`, used by @slack/socket-mode) resolve + // DNS and then connect directly to the resolved IP, ignoring HTTP_PROXY. + // For these hosts, allow TCP 443 through and rely on host-side MASQUERADE + // (set up in install_bypass_rules) to route the traffic. + for direct_ip in resolve_direct_tcp_hosts() { + let ip_cidr = format!("{direct_ip}/32"); + if let Err(e) = run_iptables_netns( + &self.name, + iptables_cmd, + &[ + "-A", "OUTPUT", "-d", &ip_cidr, "-p", "tcp", "--dport", "443", "-j", "ACCEPT", + ], + ) { + warn!( + error = %e, + ip = %direct_ip, + "Failed to install direct TCP ACCEPT rule" + ); + } + } + // Rule 5: REJECT TCP bypass attempts (fast-fail) run_iptables_netns( &self.name, @@ -398,6 +586,33 @@ impl NetworkNamespace { ], )?; + // Rule 5.5: ACCEPT DNS (UDP port 53) to the cluster nameserver. + // + // Some libraries (e.g. Node.js `ws`, used by @slack/socket-mode) + // resolve hostnames directly via the system resolver, bypassing + // HTTP_PROXY / HTTPS_PROXY. Allow UDP DNS to the nameserver + // configured in /etc/resolv.conf so that resolution succeeds + // without opening a broad UDP hole. + if let Some(dns_ip) = resolve_dns_server() { + let dns_ip_cidr = format!("{dns_ip}/32"); + if let Err(e) = run_iptables_netns( + &self.name, + iptables_cmd, + &[ + "-A", "OUTPUT", "-d", &dns_ip_cidr, "-p", "udp", "--dport", "53", "-j", + "ACCEPT", + ], + ) { + warn!( + error = %e, + dns_server = %dns_ip, + "Failed to install DNS ACCEPT rule (non-fatal, UDP DNS will be rejected)" + ); + } else { + info!(dns_server = %dns_ip, "Installed DNS ACCEPT rule for UDP port 53"); + } + } + // Rule 6: LOG UDP bypass attempts (rate-limited, covers DNS bypass) if let Err(e) = run_iptables_netns( &self.name,