From ca8436d3b76acc74ff5ea8afd9006e68fb33c38f Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Mon, 30 Mar 2026 19:08:27 +0900 Subject: [PATCH 1/4] fix: allow UDP DNS to cluster nameserver in sandbox netns The sandbox iptables rules unconditionally REJECT all UDP traffic, which blocks DNS resolution for libraries that bypass HTTP_PROXY (e.g. Node.js ws used by @slack/socket-mode). Add an ACCEPT rule for UDP port 53 to the nameserver from /etc/resolv.conf (or OPENSHELL_DNS_SERVER env override) before the blanket UDP REJECT, so sandboxed processes can resolve external hostnames without opening a broad UDP hole. Fixes: NVIDIA/NemoClaw#409 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sandbox/linux/netns.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index 095ed86c..01aea63c 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -19,6 +19,36 @@ const SUBNET_PREFIX: &str = "10.200.0"; const HOST_IP_SUFFIX: u8 = 1; const SANDBOX_IP_SUFFIX: u8 = 2; +/// 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. @@ -398,6 +428,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, From 90932b557a560ae30ea3f1d3dcc1f752e65951a7 Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Mon, 30 Mar 2026 19:30:01 +0900 Subject: [PATCH 2/4] fix: add IP forwarding and NAT for DNS through sandbox veth The DNS ACCEPT iptables rule alone is insufficient because the sandbox netns routes everything via 10.200.0.1 (host veth). DNS UDP packets reach the host side but the pod network cannot route responses back to 10.200.0.2 (sandbox IP). Enable IP forwarding on the host veth and add MASQUERADE so DNS packets appear to come from the pod IP, allowing CoreDNS to respond correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sandbox/linux/netns.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index 01aea63c..fe220333 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -324,6 +324,71 @@ 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" + ); + } + info!( namespace = %self.name, "Bypass detection rules installed" From 7c7f4d5ad06adedde541005b914ace91087ba55f Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Mon, 30 Mar 2026 20:03:31 +0900 Subject: [PATCH 3/4] fix: downgrade Landlock unavailable warning to debug level The Landlock WARN log is emitted to stderr with ANSI color codes on every kubectl exec invocation. When SSH or kubectl exec pipes stdin to sandbox commands, the ANSI output corrupts file contents (e.g., openclaw.json config). Downgrading to debug prevents this pollution in default log levels while keeping the diagnostic available with RUST_LOG=debug. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openshell-sandbox/src/sandbox/linux/landlock.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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." From c773b9be086ce6d9849fe2c13f07677cf158cf2c Mon Sep 17 00:00:00 2001 From: kosaku-sim Date: Mon, 30 Mar 2026 20:35:25 +0900 Subject: [PATCH 4/4] feat: allow direct TCP 443 for OPENSHELL_DIRECT_TCP_HOSTS Libraries like Node.js ws (used by @slack/socket-mode) resolve DNS then connect directly to the resolved IP on TCP 443, ignoring HTTP_PROXY. The sandbox iptables REJECT all bypass TCP, breaking these connections even after DNS resolution succeeds. Add OPENSHELL_DIRECT_TCP_HOSTS env var (comma-separated hostnames). At sandbox netns setup, resolve these hosts and install: - iptables ACCEPT for TCP 443 to resolved IPs (sandbox side) - MASQUERADE + FORWARD rules (host side) for return routing This pairs with the DNS ACCEPT rule from the previous commit to provide full direct connectivity for proxy-unaware libraries. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sandbox/linux/netns.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/openshell-sandbox/src/sandbox/linux/netns.rs b/crates/openshell-sandbox/src/sandbox/linux/netns.rs index fe220333..2c53b7ff 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/netns.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/netns.rs @@ -19,6 +19,46 @@ 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: @@ -389,6 +429,36 @@ impl NetworkNamespace { ); } + // 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" @@ -477,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,