Skip to content
Open
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
100 changes: 98 additions & 2 deletions crates/openshell-supervisor-network/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +23 to +24

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the linter failures I think you can remove both these lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually the import is needed — it's used by resolve_from_proxy_hosts (line 2846) and resolve_from_hosts_file (line 2856), both behind the same #[cfg(any(target_os = "linux", test))] guard.

The linter failure is the inverse: lines 9900 and 10036 used fully-qualified std::path::Path::new(...) while the import already exists, triggering unnecessary_qualification. Fixed by dropping the std::path:: prefix on those two call sites.

use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
Expand Down Expand Up @@ -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<Vec<SocketAddr>> {
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<Vec<SocketAddr>> {
None
}

#[cfg(any(target_os = "linux", test))]
async fn resolve_from_hosts_file(path: &Path, host: &str, port: u16) -> Option<Vec<SocketAddr>> {
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,
Expand All @@ -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<SocketAddr> = tokio::net::lookup_host((lookup_host, port))
.await
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -9801,7 +9897,7 @@ network_policies:
use std::time::Duration;

// Skip if /bin/bash is not present (e.g. minimal containers).
if !std::path::Path::new("/bin/bash").exists() {
if !Path::new("/bin/bash").exists() {
eprintln!("skipping: /bin/bash not available");
return;
}
Expand Down Expand Up @@ -9937,7 +10033,7 @@ network_policies:
}
}

if !std::path::Path::new("/bin/sleep").exists() {
if !Path::new("/bin/sleep").exists() {
eprintln!("skipping: /bin/sleep not available");
return;
}
Expand Down
Loading