feat(linux-sandbox): route DNS through managed proxy

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-07-09 12:38:27 -07:00
co-authored by Codex noreply@openai.com
parent 6b69ac60bd
commit 2307518a48
13 changed files with 784 additions and 2 deletions
+1
View File
@@ -3309,6 +3309,7 @@ dependencies = [
"landlock",
"libc",
"pretty_assertions",
"rustix 1.1.4",
"seccompiler",
"serde",
"serde_json",
+1
View File
@@ -388,6 +388,7 @@ rustls = { version = "0.23", default-features = false, features = [
] }
rustls-native-certs = "0.8.3"
rustls-pki-types = "1.14.0"
rustix = { version = "1.1.4", features = ["thread"] }
schemars = "0.8.22"
seccompiler = "0.5.0"
semver = "1.0"
+1
View File
@@ -31,6 +31,7 @@ seccompiler = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
rustix = { workspace = true }
url = { workspace = true }
[target.'cfg(target_os = "linux")'.dev-dependencies]
+173
View File
@@ -0,0 +1,173 @@
use codex_network_proxy::DNS_PROXY_ENV_KEY;
use codex_network_proxy::DNS_PROXY_SESSION_PREFACE;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::net::UdpSocket;
use std::time::Duration;
use url::Url;
const SANDBOX_DNS_LISTEN_ADDR: (Ipv4Addr, u16) = (Ipv4Addr::LOCALHOST, 53);
const DNS_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_DNS_MESSAGE_BYTES: usize = usize::from(u16::MAX);
pub(crate) struct BoundDnsStub {
udp_socket: UdpSocket,
tcp_listener: TcpListener,
}
pub(crate) fn bind_netns_dns_stub() -> io::Result<BoundDnsStub> {
let udp_socket = UdpSocket::bind(SANDBOX_DNS_LISTEN_ADDR)?;
let tcp_listener = TcpListener::bind(SANDBOX_DNS_LISTEN_ADDR)?;
Ok(BoundDnsStub {
udp_socket,
tcp_listener,
})
}
pub(crate) fn spawn_netns_dns_stub(bound: BoundDnsStub) -> io::Result<()> {
let endpoint = take_dns_proxy_endpoint_from_env()?.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"DNS bridge expected a proxy endpoint",
)
})?;
let pid = unsafe { libc::fork() };
if pid < 0 {
return Err(io::Error::last_os_error());
}
if pid == 0 {
let status = i32::from(
crate::proxy_routing::harden_bridge_process()
.and_then(|_| run_netns_dns_stub(bound, endpoint))
.is_err(),
);
unsafe { libc::_exit(status) };
}
Ok(())
}
fn take_dns_proxy_endpoint_from_env() -> io::Result<Option<SocketAddr>> {
let Some(value) = std::env::var_os(DNS_PROXY_ENV_KEY) else {
return Ok(None);
};
// SAFETY: the helper process is single-threaded at this point, before execing
// the user command.
unsafe { std::env::remove_var(DNS_PROXY_ENV_KEY) };
let value = value
.into_string()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "DNS proxy URL is not UTF-8"))?;
parse_dns_proxy_endpoint(value.as_str()).map(Some)
}
fn parse_dns_proxy_endpoint(value: &str) -> io::Result<SocketAddr> {
let parsed = Url::parse(value).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid DNS proxy URL: {err}"),
)
})?;
if parsed.scheme() != "tcp" {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"DNS proxy URL must use tcp scheme",
));
}
let Some(host) = parsed.host_str() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"DNS proxy URL is missing host",
));
};
if host != "127.0.0.1" {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"DNS proxy URL must point at 127.0.0.1",
));
}
let Some(port) = parsed.port() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"DNS proxy URL is missing port",
));
};
Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))
}
fn run_netns_dns_stub(bound: BoundDnsStub, endpoint: SocketAddr) -> io::Result<()> {
let udp_endpoint = endpoint;
std::thread::Builder::new().spawn(move || netns_udp_loop(bound.udp_socket, udp_endpoint))?;
netns_tcp_loop(bound.tcp_listener, endpoint)
}
fn netns_udp_loop(socket: UdpSocket, endpoint: SocketAddr) -> io::Result<()> {
let mut query = vec![0; MAX_DNS_MESSAGE_BYTES];
loop {
let (len, peer) = socket.recv_from(&mut query)?;
let response = resolve_through_proxy(endpoint, &query[..len])?;
socket.send_to(&response, peer)?;
}
}
fn netns_tcp_loop(listener: TcpListener, endpoint: SocketAddr) -> io::Result<()> {
loop {
let (mut client, _) = listener.accept()?;
client.set_read_timeout(Some(DNS_IO_TIMEOUT))?;
client.set_write_timeout(Some(DNS_IO_TIMEOUT))?;
std::thread::spawn(move || {
let _ = serve_tcp_client(&mut client, endpoint);
});
}
}
fn serve_tcp_client(client: &mut TcpStream, endpoint: SocketAddr) -> io::Result<()> {
while let Ok(query) = read_frame(client) {
let response = resolve_through_proxy(endpoint, &query)?;
write_frame(client, &response)?;
}
Ok(())
}
fn resolve_through_proxy(endpoint: SocketAddr, query: &[u8]) -> io::Result<Vec<u8>> {
let mut stream = TcpStream::connect(endpoint)?;
stream.set_read_timeout(Some(DNS_IO_TIMEOUT))?;
stream.set_write_timeout(Some(DNS_IO_TIMEOUT))?;
stream.write_all(DNS_PROXY_SESSION_PREFACE)?;
let mut ack = [0_u8; DNS_PROXY_SESSION_PREFACE.len()];
stream.read_exact(&mut ack)?;
if &ack != DNS_PROXY_SESSION_PREFACE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"DNS proxy rejected session preface",
));
}
write_frame(&mut stream, query)?;
read_frame(&mut stream)
}
fn write_frame(writer: &mut impl Write, payload: &[u8]) -> io::Result<()> {
let len = u16::try_from(payload.len()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"DNS message exceeds maximum wire length",
)
})?;
writer.write_all(&len.to_be_bytes())?;
writer.write_all(payload)
}
fn read_frame(reader: &mut impl Read) -> io::Result<Vec<u8>> {
let mut len = [0; 2];
reader.read_exact(&mut len)?;
let mut payload = vec![0; usize::from(u16::from_be_bytes(len))];
reader.read_exact(&mut payload)?;
Ok(payload)
}
#[cfg(test)]
#[path = "dns_routing_tests.rs"]
mod tests;
@@ -0,0 +1,45 @@
use super::*;
use pretty_assertions::assert_eq;
use std::net::TcpListener;
use std::sync::mpsc;
#[test]
fn parses_loopback_dns_proxy_endpoint() {
assert_eq!(
parse_dns_proxy_endpoint("tcp://127.0.0.1:43128").expect("endpoint"),
SocketAddr::from((Ipv4Addr::LOCALHOST, 43128))
);
}
#[test]
fn rejects_non_loopback_dns_proxy_endpoint() {
let err =
parse_dns_proxy_endpoint("tcp://192.0.2.10:43128").expect_err("non-loopback endpoint");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn forwards_dns_query_to_private_proxy_session() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind DNS proxy fixture");
let endpoint = listener.local_addr().expect("fixture local addr");
let (received_tx, received_rx) = mpsc::channel();
let expected_response = b"dns-response".to_vec();
let response = expected_response.clone();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept DNS proxy session");
let mut preface = [0_u8; DNS_PROXY_SESSION_PREFACE.len()];
stream.read_exact(&mut preface).expect("read preface");
assert_eq!(&preface, DNS_PROXY_SESSION_PREFACE);
stream
.write_all(DNS_PROXY_SESSION_PREFACE)
.expect("write preface ack");
let query = read_frame(&mut stream).expect("read query");
received_tx.send(query).expect("send query");
write_frame(&mut stream, &response).expect("write response");
});
let response = resolve_through_proxy(endpoint, b"dns-query").expect("proxy response");
assert_eq!(received_rx.recv().expect("query"), b"dns-query");
assert_eq!(response, expected_response);
}
+163
View File
@@ -0,0 +1,163 @@
use crate::bwrap::BwrapArgs;
use codex_protocol::permissions::ReadDenyMatcher;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use rustix::thread::CapabilitySet;
use rustix::thread::CapabilitySets;
use rustix::thread::capabilities;
use rustix::thread::capability_is_in_ambient_set;
use rustix::thread::capability_is_in_bounding_set;
use rustix::thread::clear_ambient_capability_set;
use rustix::thread::remove_capability_from_bounding_set;
use rustix::thread::set_capabilities;
use std::fs::File;
use std::io;
use std::io::Seek;
use std::io::SeekFrom;
use std::io::Write;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
use std::path::Path;
use std::path::PathBuf;
const RESOLV_CONF_PATH: &str = "/etc/resolv.conf";
const LOADER_ENV_KEYS: &[&str] = &["LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD"];
pub(crate) type LoaderEnvironment = Vec<(String, String)>;
#[derive(Debug)]
pub(crate) struct ResolvConfMount {
pub(crate) file: File,
pub(crate) path: PathBuf,
}
pub(crate) fn create_resolv_conf_file() -> io::Result<File> {
let fd = unsafe { libc::memfd_create(c"codex-resolv-conf".as_ptr(), libc::MFD_CLOEXEC) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
let mut file = unsafe { File::from_raw_fd(fd) };
file.write_all(b"nameserver 127.0.0.1\n")?;
file.seek(SeekFrom::Start(0))?;
Ok(file)
}
pub(crate) fn resolv_conf_mount_path(
policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> io::Result<PathBuf> {
let logical_path = Path::new(RESOLV_CONF_PATH);
let target_path = logical_path.canonicalize()?;
ensure_resolver_paths_allowed(policy, cwd, logical_path, &target_path)?;
Ok(target_path)
}
fn ensure_resolver_paths_allowed(
policy: &FileSystemSandboxPolicy,
cwd: &Path,
logical_path: &Path,
target_path: &Path,
) -> io::Result<()> {
let deny_matcher = ReadDenyMatcher::new(policy, cwd);
if [logical_path, target_path].into_iter().any(|path| {
!policy.can_read_path_with_cwd(path, cwd)
|| deny_matcher
.as_ref()
.is_some_and(|deny| deny.is_read_denied(path))
}) {
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
}
Ok(())
}
pub(crate) fn capture_loader_environment() -> LoaderEnvironment {
LOADER_ENV_KEYS
.iter()
.filter_map(|&key| {
std::env::var_os(key).map(|value| {
(
key.to_string(),
value
.into_string()
.unwrap_or_else(|_| panic!("{key} must contain valid UTF-8")),
)
})
})
.collect()
}
pub(crate) fn restore_loader_environment(environment: LoaderEnvironment) {
for (key, value) in environment {
// SAFETY: the setup process is single-threaded and has dropped all capabilities.
unsafe { std::env::set_var(key, value) };
}
}
pub(crate) fn append_bwrap_args(bwrap_args: &mut BwrapArgs, mount: ResolvConfMount) {
let separator = bwrap_args
.args
.iter()
.position(|arg| arg == "--")
.unwrap_or_else(|| panic!("bubblewrap argv is missing command separator '--'"));
let Some(parent) = mount.path.parent() else {
panic!("resolver configuration path has no parent");
};
let fd = mount.file.as_raw_fd().to_string();
bwrap_args.args.splice(
separator..separator,
LOADER_ENV_KEYS
.iter()
.flat_map(|key| ["--unsetenv".to_string(), (*key).to_string()])
.chain([
"--cap-drop".to_string(),
"ALL".to_string(),
"--cap-add".to_string(),
"CAP_NET_BIND_SERVICE".to_string(),
"--cap-add".to_string(),
"CAP_SETPCAP".to_string(),
"--dir".to_string(),
parent.to_string_lossy().into_owned(),
"--perms".to_string(),
"444".to_string(),
"--ro-bind-data".to_string(),
fd,
mount.path.to_string_lossy().into_owned(),
]),
);
bwrap_args.preserved_files.push(mount.file);
}
pub(crate) fn drop_and_verify_capabilities() -> io::Result<()> {
let ignore_unknown = |result: rustix::io::Result<()>| match result {
Ok(()) | Err(rustix::io::Errno::INVAL) => Ok(()),
Err(err) => Err(io::Error::from_raw_os_error(err.raw_os_error())),
};
ignore_unknown(clear_ambient_capability_set())?;
for bit in 0..u64::BITS {
let capability = CapabilitySet::from_bits_retain(1_u64 << bit);
match capability_is_in_ambient_set(capability) {
Ok(false) | Err(rustix::io::Errno::INVAL) => {}
Ok(true) => return Err(io::Error::other("capability remained in ambient set")),
Err(err) => return Err(io::Error::from_raw_os_error(err.raw_os_error())),
}
ignore_unknown(remove_capability_from_bounding_set(capability))?;
match capability_is_in_bounding_set(capability) {
Ok(false) | Err(rustix::io::Errno::INVAL) => {}
Ok(true) => return Err(io::Error::other("capability remained in bounding set")),
Err(err) => return Err(io::Error::from_raw_os_error(err.raw_os_error())),
}
}
let empty = CapabilitySets {
effective: CapabilitySet::empty(),
permitted: CapabilitySet::empty(),
inheritable: CapabilitySet::empty(),
};
set_capabilities(None, empty).map_err(io::Error::from)?;
if capabilities(None).map_err(io::Error::from)? != empty {
return Err(io::Error::other("capabilities remained after DNS setup"));
}
Ok(())
}
#[cfg(test)]
#[path = "dns_setup_tests.rs"]
mod tests;
@@ -0,0 +1,76 @@
use super::*;
use crate::bwrap::BwrapArgs;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
fn unsets_loader_environment_before_inner_command() {
let mut bwrap_args = BwrapArgs {
args: vec![
"bwrap".to_string(),
"--".to_string(),
"/bin/true".to_string(),
],
preserved_files: Vec::new(),
synthetic_mount_targets: Vec::new(),
protected_create_targets: Vec::new(),
};
append_bwrap_args(
&mut bwrap_args,
ResolvConfMount {
file: tempfile::tempfile().expect("temporary resolver configuration"),
path: PathBuf::from("/etc/resolv.conf"),
},
);
let separator = bwrap_args
.args
.iter()
.position(|arg| arg == "--")
.expect("command separator");
let setup_args = &bwrap_args.args[..separator];
for key in ["LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD"] {
assert!(
setup_args
.windows(2)
.any(|args| args == ["--unsetenv", key]),
"{key} must be unset before the privileged DNS setup command"
);
}
}
#[test]
fn rejects_read_denied_resolver_paths() {
let logical_path = PathBuf::from("/etc/resolv.conf");
let target_path = PathBuf::from("/run/systemd/resolve/stub-resolv.conf");
for denied_path in [&logical_path, &target_path] {
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::from_absolute_path(denied_path.clone())
.expect("absolute resolver configuration"),
},
access: FileSystemAccessMode::Deny,
},
]);
let err = ensure_resolver_paths_allowed(
&file_system_sandbox_policy,
Path::new("/"),
&logical_path,
&target_path,
)
.expect_err("read-denied resolver configuration must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
}
}
+68 -1
View File
@@ -3,6 +3,7 @@ use std::ffi::CString;
use std::fs::File;
use std::os::raw::c_char;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
@@ -25,16 +26,19 @@ enum BubblewrapLauncher {
struct SystemBwrapLauncher {
program: AbsolutePathBuf,
supports_argv0: bool,
supports_cap_add: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SystemBwrapCapabilities {
supports_argv0: bool,
supports_cap_add: bool,
supports_perms: bool,
is_setuid: bool,
}
pub(crate) fn exec_bwrap(argv: Vec<String>, preserved_files: Vec<File>) -> ! {
match preferred_bwrap_launcher() {
match preferred_bwrap_launcher_for_argv(&argv) {
BubblewrapLauncher::System(launcher) => {
exec_system_bwrap(&launcher.program, argv, preserved_files)
}
@@ -48,6 +52,13 @@ pub(crate) fn exec_bwrap(argv: Vec<String>, preserved_files: Vec<File>) -> ! {
}
}
fn preferred_bwrap_launcher_for_argv(argv: &[String]) -> BubblewrapLauncher {
if argv.iter().any(|arg| arg == "--cap-add") {
return preferred_bwrap_launcher_supporting_cap_add();
}
preferred_bwrap_launcher()
}
fn preferred_bwrap_launcher() -> BubblewrapLauncher {
static LAUNCHER: OnceLock<BubblewrapLauncher> = OnceLock::new();
LAUNCHER
@@ -66,6 +77,21 @@ fn preferred_bwrap_launcher() -> BubblewrapLauncher {
.clone()
}
fn preferred_bwrap_launcher_supporting_cap_add() -> BubblewrapLauncher {
match preferred_bwrap_launcher() {
BubblewrapLauncher::System(launcher) if launcher.supports_cap_add => {
BubblewrapLauncher::System(launcher)
}
BubblewrapLauncher::System(_) | BubblewrapLauncher::Unavailable => {
match bundled_bwrap::launcher() {
Some(launcher) => BubblewrapLauncher::Bundled(launcher),
None => BubblewrapLauncher::Unavailable,
}
}
bundled @ BubblewrapLauncher::Bundled(_) => bundled,
}
}
fn system_bwrap_launcher_for_path(system_bwrap_path: &Path) -> Option<SystemBwrapLauncher> {
system_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities)
}
@@ -80,7 +106,9 @@ fn system_bwrap_launcher_for_path_with_probe(
let Some(SystemBwrapCapabilities {
supports_argv0,
supports_cap_add,
supports_perms: true,
is_setuid,
}) = system_bwrap_capabilities(system_bwrap_path)
else {
return None;
@@ -95,6 +123,7 @@ fn system_bwrap_launcher_for_path_with_probe(
Some(SystemBwrapLauncher {
program: system_bwrap_path,
supports_argv0,
supports_cap_add: supports_cap_add && !is_setuid,
})
}
@@ -105,6 +134,14 @@ pub(crate) fn preferred_bwrap_supports_argv0() -> bool {
}
}
pub(crate) fn preferred_bwrap_supports_cap_add() -> bool {
match preferred_bwrap_launcher_supporting_cap_add() {
BubblewrapLauncher::System(launcher) => launcher.supports_cap_add,
BubblewrapLauncher::Bundled(_) => true,
BubblewrapLauncher::Unavailable => false,
}
}
fn system_bwrap_capabilities(system_bwrap_path: &Path) -> Option<SystemBwrapCapabilities> {
// bubblewrap added `--argv0` in v0.9.0:
// https://github.com/containers/bubblewrap/releases/tag/v0.9.0
@@ -117,9 +154,12 @@ fn system_bwrap_capabilities(system_bwrap_path: &Path) -> Option<SystemBwrapCapa
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let metadata = system_bwrap_path.metadata().ok()?;
Some(SystemBwrapCapabilities {
supports_argv0: stdout.contains("--argv0") || stderr.contains("--argv0"),
supports_cap_add: stdout.contains("--cap-add") || stderr.contains("--cap-add"),
supports_perms: stdout.contains("--perms") || stderr.contains("--perms"),
is_setuid: metadata.permissions().mode() & libc::S_ISUID != 0,
})
}
@@ -167,12 +207,15 @@ mod tests {
system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| {
Some(SystemBwrapCapabilities {
supports_argv0: true,
supports_cap_add: false,
supports_perms: true,
is_setuid: false,
})
}),
Some(SystemBwrapLauncher {
program: expected,
supports_argv0: true,
supports_cap_add: false,
})
);
}
@@ -186,12 +229,15 @@ mod tests {
system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| {
Some(SystemBwrapCapabilities {
supports_argv0: false,
supports_cap_add: false,
supports_perms: true,
is_setuid: false,
})
}),
Some(SystemBwrapLauncher {
program: AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"),
supports_argv0: false,
supports_cap_add: false,
})
);
}
@@ -204,13 +250,34 @@ mod tests {
system_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| {
Some(SystemBwrapCapabilities {
supports_argv0: false,
supports_cap_add: false,
supports_perms: false,
is_setuid: false,
})
}),
None
);
}
#[test]
fn disables_system_cap_add_when_system_bwrap_is_setuid() {
let fake_bwrap = NamedTempFile::new().expect("temp file");
assert_eq!(
system_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| {
Some(SystemBwrapCapabilities {
supports_argv0: true,
supports_cap_add: true,
supports_perms: true,
is_setuid: true,
})
})
.expect("system launcher")
.supports_cap_add,
false
);
}
#[test]
fn ignores_system_bwrap_when_system_bwrap_is_missing() {
assert_eq!(
+4
View File
@@ -10,6 +10,10 @@ mod bundled_bwrap;
#[cfg(target_os = "linux")]
mod bwrap;
#[cfg(target_os = "linux")]
mod dns_routing;
#[cfg(target_os = "linux")]
mod dns_setup;
#[cfg(target_os = "linux")]
mod exec_util;
#[cfg(target_os = "linux")]
mod landlock;
@@ -20,11 +20,23 @@ use std::time::Duration;
use crate::bwrap::BwrapNetworkMode;
use crate::bwrap::BwrapOptions;
use crate::bwrap::create_bwrap_command_args;
use crate::dns_routing::bind_netns_dns_stub;
use crate::dns_routing::spawn_netns_dns_stub;
use crate::dns_setup::LoaderEnvironment;
use crate::dns_setup::ResolvConfMount;
use crate::dns_setup::append_bwrap_args as append_dns_bwrap_args;
use crate::dns_setup::capture_loader_environment;
use crate::dns_setup::create_resolv_conf_file;
use crate::dns_setup::drop_and_verify_capabilities;
use crate::dns_setup::resolv_conf_mount_path;
use crate::dns_setup::restore_loader_environment;
use crate::landlock::apply_permission_profile_to_current_thread;
use crate::launcher::exec_bwrap;
use crate::launcher::preferred_bwrap_supports_argv0;
use crate::launcher::preferred_bwrap_supports_cap_add;
use crate::proxy_routing::activate_proxy_routes_in_netns;
use crate::proxy_routing::prepare_host_proxy_route_spec;
use codex_network_proxy::DNS_PROXY_ENV_KEY;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::FileSystemSandboxPolicy;
@@ -126,6 +138,10 @@ pub struct LandlockCommand {
#[arg(long = "proxy-route-spec", hide = true)]
pub proxy_route_spec: Option<String>,
/// Internal loader environment restored after temporary DNS setup capabilities are dropped.
#[arg(long = "loader-environment", hide = true)]
pub loader_environment: Option<String>,
/// When set, skip mounting a fresh `/proc` even though PID isolation is
/// still enabled. This is primarily intended for restrictive container
/// environments that deny `--proc /proc`.
@@ -153,6 +169,7 @@ pub fn run_main() -> ! {
apply_seccomp_then_exec,
allow_network_for_proxy,
proxy_route_spec,
loader_environment,
no_proc,
command,
} = LandlockCommand::parse();
@@ -177,12 +194,35 @@ pub fn run_main() -> ! {
// established the filesystem view.
if apply_seccomp_then_exec {
if allow_network_for_proxy {
let bound_dns = if std::env::var_os(DNS_PROXY_ENV_KEY).is_some() {
Some(
bind_netns_dns_stub()
.unwrap_or_else(|err| panic!("error binding Linux DNS bridge: {err}")),
)
} else {
None
};
let loader_environment = parse_loader_environment(loader_environment.as_deref())
.unwrap_or_else(|err| panic!("invalid loader environment: {err}"));
if bound_dns.is_some() {
drop_and_verify_capabilities().unwrap_or_else(|err| {
panic!("error dropping Linux DNS setup capabilities: {err}")
});
if let Some(loader_environment) = loader_environment {
restore_loader_environment(loader_environment);
}
}
let spec = proxy_route_spec
.as_deref()
.unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec"));
if let Err(err) = activate_proxy_routes_in_netns(spec) {
panic!("error activating Linux proxy routing bridge: {err}");
}
if let Some(bound_dns) = bound_dns
&& let Err(err) = spawn_netns_dns_stub(bound_dns)
{
panic!("error activating Linux DNS bridge: {err}");
}
}
let proxy_routing_active = allow_network_for_proxy;
if let Err(e) = apply_permission_profile_to_current_thread(
@@ -214,6 +254,11 @@ pub fn run_main() -> ! {
// Outer stage: bubblewrap first, then re-enter this binary in the
// sandboxed environment to apply seccomp. This path never falls back
// to legacy Landlock on failure.
let dns_setup = prepare_dns_setup(
allow_network_for_proxy,
&file_system_sandbox_policy,
&sandbox_policy_cwd,
);
let proxy_route_spec =
if allow_network_for_proxy {
Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| {
@@ -228,6 +273,9 @@ pub fn run_main() -> ! {
permission_profile: &permission_profile,
allow_network_for_proxy,
proxy_route_spec,
loader_environment: dns_setup
.as_ref()
.map(|setup| serialize_loader_environment(&setup.loader_environment)),
command,
});
run_bwrap_with_proc_fallback(
@@ -238,6 +286,7 @@ pub fn run_main() -> ! {
inner,
!no_proc,
allow_network_for_proxy,
dns_setup.map(|setup| setup.resolv_conf),
);
}
@@ -254,6 +303,11 @@ pub fn run_main() -> ! {
exec_or_panic(command);
}
struct DnsSetup {
resolv_conf: ResolvConfMount,
loader_environment: LoaderEnvironment,
}
#[derive(Debug, Clone)]
struct EffectivePermissions {
permission_profile: PermissionProfile,
@@ -278,6 +332,45 @@ fn parse_permission_profile(value: &str) -> std::result::Result<PermissionProfil
serde_json::from_str(value).map_err(|err| format!("invalid permission profile JSON: {err}"))
}
fn parse_loader_environment(
value: Option<&str>,
) -> std::result::Result<Option<LoaderEnvironment>, String> {
value
.map(serde_json::from_str)
.transpose()
.map_err(|err| format!("invalid loader environment JSON: {err}"))
}
fn serialize_loader_environment(environment: &LoaderEnvironment) -> String {
serde_json::to_string(environment)
.unwrap_or_else(|err| panic!("failed to serialize loader environment: {err}"))
}
fn prepare_dns_setup(
allow_network_for_proxy: bool,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_policy_cwd: &Path,
) -> Option<DnsSetup> {
if !allow_network_for_proxy || std::env::var_os(DNS_PROXY_ENV_KEY).is_none() {
return None;
}
if !preferred_bwrap_supports_cap_add() {
// SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or
// executes the user command.
unsafe { std::env::remove_var(DNS_PROXY_ENV_KEY) };
return None;
}
Some(DnsSetup {
resolv_conf: ResolvConfMount {
file: create_resolv_conf_file()
.unwrap_or_else(|err| panic!("failed to create resolver configuration: {err}")),
path: resolv_conf_mount_path(file_system_sandbox_policy, sandbox_policy_cwd)
.unwrap_or_else(|err| panic!("failed to prepare resolver configuration: {err}")),
},
loader_environment: capture_loader_environment(),
})
}
fn resolve_permission_profile(
permission_profile: Option<PermissionProfile>,
) -> Result<EffectivePermissions, ResolvePermissionProfileError> {
@@ -322,6 +415,7 @@ fn run_bwrap_with_proc_fallback(
inner: Vec<String>,
mount_proc: bool,
allow_network_for_proxy: bool,
dns_resolv_conf: Option<ResolvConfMount>,
) -> ! {
let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy);
let mut mount_proc = mount_proc;
@@ -354,6 +448,9 @@ fn run_bwrap_with_proc_fallback(
options,
)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err));
if let Some(dns_resolv_conf) = dns_resolv_conf {
append_dns_bwrap_args(&mut bwrap_args, dns_resolv_conf);
}
apply_inner_command_argv0(&mut bwrap_args.args);
run_or_exec_bwrap(bwrap_args);
}
@@ -1394,6 +1491,7 @@ struct InnerSeccompCommandArgs<'a> {
permission_profile: &'a PermissionProfile,
allow_network_for_proxy: bool,
proxy_route_spec: Option<String>,
loader_environment: Option<String>,
command: Vec<String>,
}
@@ -1405,6 +1503,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
permission_profile,
allow_network_for_proxy,
proxy_route_spec,
loader_environment,
command,
} = args;
let current_exe = match std::env::current_exe() {
@@ -1436,6 +1535,10 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
.unwrap_or_else(|| panic!("managed proxy mode requires a proxy route spec"));
inner.push("--proxy-route-spec".to_string());
inner.push(proxy_route_spec);
if let Some(loader_environment) = loader_environment {
inner.push("--loader-environment".to_string());
inner.push(loader_environment);
}
}
inner.push("--".to_string());
inner.extend(command);
@@ -468,6 +468,7 @@ fn managed_proxy_inner_command_includes_route_spec() {
permission_profile: &permission_profile,
allow_network_for_proxy: true,
proxy_route_spec: Some("{\"routes\":[]}".to_string()),
loader_environment: None,
command: vec!["/bin/true".to_string()],
});
@@ -484,6 +485,7 @@ fn inner_command_includes_permission_profile_flag() {
permission_profile: &permission_profile,
allow_network_for_proxy: false,
proxy_route_spec: None,
loader_environment: None,
command: vec!["/bin/true".to_string()],
});
@@ -503,6 +505,7 @@ fn non_managed_inner_command_omits_route_spec() {
permission_profile: &permission_profile,
allow_network_for_proxy: false,
proxy_route_spec: None,
loader_environment: None,
command: vec!["/bin/true".to_string()],
});
@@ -519,6 +522,7 @@ fn managed_proxy_inner_command_requires_route_spec() {
permission_profile: &permission_profile,
allow_network_for_proxy: true,
proxy_route_spec: None,
loader_environment: None,
command: vec!["/bin/true".to_string()],
})
});
+3 -1
View File
@@ -1,3 +1,4 @@
use codex_network_proxy::DNS_PROXY_ENV_KEY;
use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
use codex_network_proxy::write_attribution_frame;
use serde::Deserialize;
@@ -41,6 +42,7 @@ const PROXY_ENV_KEYS: &[&str] = &[
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
DNS_PROXY_ENV_KEY,
];
const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-";
@@ -672,7 +674,7 @@ fn set_parent_death_signal() -> io::Result<()> {
}
}
fn harden_bridge_process() -> io::Result<()> {
pub(crate) fn harden_bridge_process() -> io::Result<()> {
set_parent_death_signal()?;
codex_process_hardening::disable_process_dumping()
}
@@ -2,6 +2,8 @@
#![allow(clippy::unwrap_used)]
use codex_core::exec_env::create_env;
use codex_network_proxy::DNS_PROXY_ENV_KEY;
use codex_network_proxy::DNS_PROXY_SESSION_PREFACE;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
@@ -40,6 +42,7 @@ const PROXY_ENV_KEYS: &[&str] = &[
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
DNS_PROXY_ENV_KEY,
];
fn create_env_from_core_vars() -> HashMap<String, String> {
@@ -301,3 +304,142 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() {
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test]
async fn managed_proxy_mode_routes_native_dns_through_bridge() {
if let Some(skip_reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {skip_reason}");
return;
}
if !command_available("python3").await {
eprintln!("skipping managed proxy DNS test: python3 is unavailable");
return;
}
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind DNS proxy listener");
let proxy_port = listener.local_addr().expect("DNS proxy local addr").port();
let (query_tx, query_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept DNS proxy connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("set DNS proxy read timeout");
let mut preface = [0_u8; DNS_PROXY_SESSION_PREFACE.len()];
stream.read_exact(&mut preface).expect("read DNS preface");
assert_eq!(&preface, DNS_PROXY_SESSION_PREFACE);
stream
.write_all(DNS_PROXY_SESSION_PREFACE)
.expect("write DNS preface ack");
let query = read_frame(&mut stream).expect("read DNS query");
let response = dns_a_response(&query, [203, 0, 113, 9]);
query_tx.send(query).expect("send DNS query");
write_frame(&mut stream, &response).expect("write DNS response");
});
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.insert(
DNS_PROXY_ENV_KEY.to_string(),
format!("tcp://127.0.0.1:{proxy_port}"),
);
let output = run_linux_sandbox_direct(
&[
"python3",
"-c",
"import os,socket,sys\nif 'CODEX_NETWORK_PROXY_DNS' in os.environ:\n sys.exit(3)\ninfo = socket.getaddrinfo('fixture.test', 80, socket.AF_INET, socket.SOCK_STREAM)\nprint(info[0][4][0])\n",
],
&PermissionProfile::Disabled,
/*allow_network_for_proxy*/ true,
env,
NETWORK_TIMEOUT_MS,
)
.await;
assert_eq!(
output.status.success(),
true,
"expected native DNS to resolve through bridge; status={:?}; stdout={}; stderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"203.0.113.9"
);
let query = query_rx
.recv_timeout(Duration::from_secs(3))
.expect("expected DNS query through proxy bridge");
assert!(
dns_query_name(&query).is_some_and(|name| name == "fixture.test"),
"expected fixture.test DNS query, got wire bytes: {query:?}"
);
}
fn dns_query_name(query: &[u8]) -> Option<String> {
let mut offset = 12usize;
let mut labels = Vec::new();
loop {
let len = *query.get(offset)? as usize;
offset += 1;
if len == 0 {
break;
}
let label = query.get(offset..offset.checked_add(len)?)?;
labels.push(std::str::from_utf8(label).ok()?);
offset += len;
}
Some(labels.join("."))
}
fn dns_a_response(query: &[u8], address: [u8; 4]) -> Vec<u8> {
let question_end = dns_question_end(query).expect("DNS question end");
let mut response = Vec::new();
response.extend_from_slice(&query[..2]);
response.extend_from_slice(&[0x81, 0x80]);
response.extend_from_slice(&[0x00, 0x01]);
response.extend_from_slice(&[0x00, 0x01]);
response.extend_from_slice(&[0x00, 0x00]);
response.extend_from_slice(&[0x00, 0x00]);
response.extend_from_slice(&query[12..question_end]);
response.extend_from_slice(&[0xc0, 0x0c]);
response.extend_from_slice(&[0x00, 0x01]);
response.extend_from_slice(&[0x00, 0x01]);
response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
response.extend_from_slice(&[0x00, 0x04]);
response.extend_from_slice(&address);
response
}
fn dns_question_end(query: &[u8]) -> Option<usize> {
let mut offset = 12usize;
loop {
let len = *query.get(offset)? as usize;
offset += 1;
if len == 0 {
return offset.checked_add(4).filter(|end| *end <= query.len());
}
offset = offset.checked_add(len)?;
}
}
fn write_frame(writer: &mut impl Write, payload: &[u8]) -> std::io::Result<()> {
let len = u16::try_from(payload.len()).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"DNS message exceeds maximum wire length",
)
})?;
writer.write_all(&len.to_be_bytes())?;
writer.write_all(payload)
}
fn read_frame(reader: &mut impl Read) -> std::io::Result<Vec<u8>> {
let mut len = [0; 2];
reader.read_exact(&mut len)?;
let mut payload = vec![0; usize::from(u16::from_be_bytes(len))];
reader.read_exact(&mut payload)?;
Ok(payload)
}