Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/openshell-sandbox/src/sandbox/linux/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
215 changes: 215 additions & 0 deletions crates/openshell-sandbox/src/sandbox/linux/netns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IpAddr> {
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<IpAddr> {
if let Ok(val) = std::env::var("OPENSHELL_DNS_SERVER") {
if let Ok(addr) = val.parse::<IpAddr>() {
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::<IpAddr>() {
return Some(addr);
}
}
}
}

None
}

/// Handle to a network namespace with veth pair.
///
/// The namespace and veth interfaces are automatically cleaned up on drop.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading