diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 69245e11e..545178d22 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -20,6 +20,8 @@ use openshell_ocsf::{ NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; use std::net::{IpAddr, SocketAddr}; +#[cfg(any(target_os = "linux", test))] +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; @@ -2832,6 +2834,41 @@ async fn resolve_from_sandbox_hosts( None } +/// Resolve a hostname using the proxy's own `/etc/hosts` file. +/// +/// Used as a fallback for host-gateway aliases when the sandbox's `/etc/hosts` +/// (read via `/proc/{pid}/root/etc/hosts`) does not contain the entry — e.g. +/// because the sandbox runs in a separate mount namespace. The proxy's +/// `/etc/hosts` is the container-level file populated by the compute driver's +/// `--add-host` flag. +#[cfg(any(target_os = "linux", test))] +async fn resolve_from_proxy_hosts(host: &str, port: u16) -> Option> { + resolve_from_hosts_file(Path::new("/etc/hosts"), host, port).await +} + +#[cfg(not(any(target_os = "linux", test)))] +#[allow(clippy::unused_async)] +async fn resolve_from_proxy_hosts(_host: &str, _port: u16) -> Option> { + None +} + +#[cfg(any(target_os = "linux", test))] +async fn resolve_from_hosts_file(path: &Path, host: &str, port: u16) -> Option> { + let contents = match tokio::fs::read_to_string(path).await { + Ok(contents) => contents, + Err(error) => { + debug!( + path = %path.display(), + host, + "Falling back to DNS; failed to read hosts file: {error}" + ); + return None; + } + }; + let addrs = resolve_from_hosts_file_contents(&contents, host, port); + if addrs.is_empty() { None } else { Some(addrs) } +} + async fn resolve_socket_addrs( host: &str, port: u16, @@ -2845,6 +2882,18 @@ async fn resolve_socket_addrs( return Ok(addrs); } + // Host-gateway aliases (host.openshell.internal, etc.) are synthetic + // hostnames injected by compute drivers into the container's /etc/hosts. + // The sandbox process may run in a separate mount namespace whose + // /etc/hosts does not contain the entry. Fall back to the proxy's own + // /etc/hosts (the container's) before trying system DNS, which will + // also fail for these synthetic names. + if is_host_gateway_alias(host) + && let Some(addrs) = resolve_from_proxy_hosts(host, port).await + { + return Ok(addrs); + } + let lookup_host = normalize_host_lookup_key(host); let addrs: Vec = tokio::net::lookup_host((lookup_host, port)) .await @@ -7163,6 +7212,53 @@ network_policies: ); } + // -- resolve_from_proxy_hosts -- + + #[tokio::test] + async fn test_resolve_from_hosts_file_reads_host_gateway_alias_from_disk() { + // The proxy's /etc/hosts is the container-level file populated by + // --add-host. resolve_from_proxy_hosts reads it directly, bypassing + // the system resolver, so host-gateway aliases resolve even when the + // sandbox's mount namespace has a different /etc/hosts. + let dir = tempfile::tempdir().unwrap(); + let hosts_path = dir.path().join("hosts"); + std::fs::write( + &hosts_path, + "172.17.0.1\thost.openshell.internal host.containers.internal\n\ + 127.0.0.1\tlocalhost\n", + ) + .unwrap(); + + let addrs = resolve_from_hosts_file(&hosts_path, "host.openshell.internal", 9318).await; + let addrs = addrs.expect("alias should resolve from hosts file"); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1))); + assert_eq!(addrs[0].port(), 9318); + } + + #[tokio::test] + async fn test_resolve_from_hosts_file_returns_none_when_alias_missing() { + let dir = tempfile::tempdir().unwrap(); + let hosts_path = dir.path().join("hosts"); + std::fs::write(&hosts_path, "127.0.0.1\tlocalhost\n").unwrap(); + + let addrs = resolve_from_hosts_file(&hosts_path, "host.openshell.internal", 9318).await; + assert!(addrs.is_none(), "missing alias should yield None"); + } + + #[tokio::test] + async fn test_resolve_from_hosts_file_returns_none_when_file_unreadable() { + // Missing file path: read fails, function returns None (logged at debug) + // rather than panicking or surfacing a DNS error. + let addrs = resolve_from_hosts_file( + Path::new("/nonexistent/openshell-test-hosts-9f3a2c"), + "host.openshell.internal", + 9318, + ) + .await; + assert!(addrs.is_none(), "unreadable hosts file should yield None"); + } + #[tokio::test] async fn inference_interception_applies_router_header_allowlist() { use tokio::io::{AsyncReadExt, AsyncWriteExt};