diff --git a/crates/openshell-sandbox/src/procfs.rs b/crates/openshell-sandbox/src/procfs.rs index ece16c82..49b61898 100644 --- a/crates/openshell-sandbox/src/procfs.rs +++ b/crates/openshell-sandbox/src/procfs.rs @@ -163,6 +163,12 @@ pub fn collect_cmdline_paths(pid: u32, stop_pid: u32, exclude: &[PathBuf]) -> Ve /// Checks both IPv4 and IPv6 tables because some clients (notably gRPC C-core) /// use `AF_INET6` sockets with IPv4-mapped addresses even for IPv4 connections. /// +/// Falls back to `/proc/net/tcp{,6}` (the init-namespace global view) when the +/// per-PID tables do not contain the connection. This handles container runtimes +/// (notably Docker Desktop on WSL2) where iptables REDIRECT/DNAT from the +/// sandbox network namespace creates connections that are visible in the global +/// table but not in `/proc//net/tcp`. +/// /// Format of `/proc/net/tcp`: /// ```text /// sl local_address rem_address st tx_queue:rx_queue ... inode @@ -173,48 +179,56 @@ pub fn collect_cmdline_paths(pid: u32, stop_pid: u32, exclude: &[PathBuf]) -> Ve /// - Inode is field index 9 (0-indexed) #[cfg(target_os = "linux")] fn parse_proc_net_tcp(pid: u32, peer_port: u16) -> Result { - // Check IPv4 first (most common), then IPv6. - for suffix in &["tcp", "tcp6"] { - let path = format!("/proc/{pid}/net/{suffix}"); - let Ok(content) = std::fs::read_to_string(&path) else { - continue; - }; - - for line in content.lines().skip(1) { - let fields: Vec<&str> = line.split_whitespace().collect(); - if fields.len() < 10 { + // Try per-PID first (most accurate), then fall back to global. + let pid_prefixes: &[String] = &[ + format!("/proc/{pid}"), + "/proc".to_string(), + ]; + + for prefix in pid_prefixes { + for suffix in &["net/tcp", "net/tcp6"] { + let path = format!("{prefix}/{suffix}"); + let Ok(content) = std::fs::read_to_string(&path) else { continue; - } - - // Parse local_address to extract port. - // IPv4 format: AABBCCDD:PORT - // IPv6 format: 00000000000000000000000000000000:PORT - let local_addr = fields[1]; - let local_port = match local_addr.rsplit_once(':') { - Some((_, port_hex)) => u16::from_str_radix(port_hex, 16).unwrap_or(0), - None => continue, }; - // Check state is ESTABLISHED (01) - let state = fields[3]; - if state != "01" { - continue; - } + for line in content.lines().skip(1) { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 10 { + continue; + } - if local_port == peer_port { - let inode: u64 = fields[9] - .parse() - .map_err(|_| miette::miette!("Failed to parse inode from {}", fields[9]))?; - if inode == 0 { + // Parse local_address to extract port. + // IPv4 format: AABBCCDD:PORT + // IPv6 format: 00000000000000000000000000000000:PORT + let local_addr = fields[1]; + let local_port = match local_addr.rsplit_once(':') { + Some((_, port_hex)) => u16::from_str_radix(port_hex, 16).unwrap_or(0), + None => continue, + }; + + // Check state is ESTABLISHED (01) + let state = fields[3]; + if state != "01" { continue; } - return Ok(inode); + + if local_port == peer_port { + let inode: u64 = fields[9] + .parse() + .map_err(|_| miette::miette!("Failed to parse inode from {}", fields[9]))?; + if inode == 0 { + continue; + } + return Ok(inode); + } } } } Err(miette::miette!( - "No ESTABLISHED TCP connection found for port {} in /proc/{}/net/tcp{{,6}}", + "No ESTABLISHED TCP connection found for port {} in /proc/{}/net/tcp{{,6}} \ + or /proc/net/tcp{{,6}}", peer_port, pid ))