mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
feat(linux-sandbox): implement proxy-only egress via TCP-UDS-TCP bridge (#11293)
## Summary - Implement Linux proxy-only routing in `codex-rs/linux-sandbox` with a two-stage bridge: host namespace `loopback TCP proxy endpoint -> UDS`, then bwrap netns `loopback TCP listener -> host UDS`. - Add hidden `--proxy-route-spec` plumbing for outer-to-inner stage handoff. - Fail closed in proxy mode when no valid loopback proxy endpoints can be routed. - Introduce explicit network seccomp modes: `Restricted` (legacy restricted networking) and `ProxyRouted` (allow INET/INET6 for routed proxy access, deny `AF_UNIX` and `socketpair`). - Enforce that proxy bridge/routing is bwrap-only by validating `--apply-seccomp-then-exec` requires `--use-bwrap-sandbox`. - Keep landlock-only flows unchanged (no proxy bridge behavior outside bwrap). --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e7b6f38b58
commit
b3202cbd58
11 files changed
+1501
-149
No files matched your search
Generated
+2
@@ -1921,9 +1921,11 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"seccompiler",
|
"seccompiler",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ codex-utils-absolute-path = { workspace = true }
|
|||||||
landlock = { workspace = true }
|
landlock = { workspace = true }
|
||||||
libc = { workspace = true }
|
libc = { workspace = true }
|
||||||
seccompiler = { workspace = true }
|
seccompiler = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ into this binary.
|
|||||||
- When enabled, the helper isolates the PID namespace via `--unshare-pid`.
|
- When enabled, the helper isolates the PID namespace via `--unshare-pid`.
|
||||||
- When enabled and network is restricted without proxy routing, the helper also
|
- When enabled and network is restricted without proxy routing, the helper also
|
||||||
isolates the network namespace via `--unshare-net`.
|
isolates the network namespace via `--unshare-net`.
|
||||||
|
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
|
||||||
|
TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy
|
||||||
|
endpoints.
|
||||||
|
- In managed proxy mode, after the bridge is live, seccomp blocks new
|
||||||
|
AF_UNIX/socketpair creation for the user command.
|
||||||
- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but
|
- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but
|
||||||
you can skip this in restrictive container environments with `--no-proc`.
|
you can skip this in restrictive container environments with `--no-proc`.
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ pub(crate) enum BwrapNetworkMode {
|
|||||||
Isolated,
|
Isolated,
|
||||||
/// Intended proxy-only mode.
|
/// Intended proxy-only mode.
|
||||||
///
|
///
|
||||||
/// Bubblewrap does not currently enforce proxy-only egress, so this is
|
/// Bubblewrap enforces this by unsharing the network namespace. The
|
||||||
/// treated as isolated for fail-closed behavior.
|
/// proxy-routing bridge is established by the helper process after startup.
|
||||||
ProxyOnly,
|
ProxyOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +323,7 @@ fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use codex_protocol::protocol::SandboxPolicy;
|
use codex_protocol::protocol::SandboxPolicy;
|
||||||
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -373,4 +374,33 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mounts_dev_before_writable_dev_binds() {
|
||||||
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||||
|
writable_roots: vec![AbsolutePathBuf::try_from(Path::new("/dev")).expect("/dev path")],
|
||||||
|
read_only_access: Default::default(),
|
||||||
|
network_access: false,
|
||||||
|
exclude_tmpdir_env_var: true,
|
||||||
|
exclude_slash_tmp: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = create_filesystem_args(&sandbox_policy, Path::new("/")).expect("bwrap fs args");
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"--ro-bind".to_string(),
|
||||||
|
"/".to_string(),
|
||||||
|
"/".to_string(),
|
||||||
|
"--dev".to_string(),
|
||||||
|
"/dev".to_string(),
|
||||||
|
"--bind".to_string(),
|
||||||
|
"/dev".to_string(),
|
||||||
|
"/dev".to_string(),
|
||||||
|
"--bind".to_string(),
|
||||||
|
"/".to_string(),
|
||||||
|
"/".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -43,22 +43,26 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
|||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
apply_landlock_fs: bool,
|
apply_landlock_fs: bool,
|
||||||
allow_network_for_proxy: bool,
|
allow_network_for_proxy: bool,
|
||||||
|
proxy_routed_network: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let install_network_seccomp =
|
let network_seccomp_mode = network_seccomp_mode(
|
||||||
should_install_network_seccomp(sandbox_policy, allow_network_for_proxy);
|
sandbox_policy,
|
||||||
|
allow_network_for_proxy,
|
||||||
|
proxy_routed_network,
|
||||||
|
);
|
||||||
|
|
||||||
// `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents
|
// `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents
|
||||||
// setuid privilege elevation. Many `bwrap` deployments rely on setuid, so
|
// setuid privilege elevation. Many `bwrap` deployments rely on setuid, so
|
||||||
// we avoid this unless we need seccomp or we are explicitly using the
|
// we avoid this unless we need seccomp or we are explicitly using the
|
||||||
// legacy Landlock filesystem pipeline.
|
// legacy Landlock filesystem pipeline.
|
||||||
if install_network_seccomp
|
if network_seccomp_mode.is_some()
|
||||||
|| (apply_landlock_fs && !sandbox_policy.has_full_disk_write_access())
|
|| (apply_landlock_fs && !sandbox_policy.has_full_disk_write_access())
|
||||||
{
|
{
|
||||||
set_no_new_privs()?;
|
set_no_new_privs()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if install_network_seccomp {
|
if let Some(mode) = network_seccomp_mode {
|
||||||
install_network_seccomp_filter_on_current_thread()?;
|
install_network_seccomp_filter_on_current_thread(mode)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if apply_landlock_fs && !sandbox_policy.has_full_disk_write_access() {
|
if apply_landlock_fs && !sandbox_policy.has_full_disk_write_access() {
|
||||||
@@ -80,6 +84,12 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum NetworkSeccompMode {
|
||||||
|
Restricted,
|
||||||
|
ProxyRouted,
|
||||||
|
}
|
||||||
|
|
||||||
fn should_install_network_seccomp(
|
fn should_install_network_seccomp(
|
||||||
sandbox_policy: &SandboxPolicy,
|
sandbox_policy: &SandboxPolicy,
|
||||||
allow_network_for_proxy: bool,
|
allow_network_for_proxy: bool,
|
||||||
@@ -89,6 +99,20 @@ fn should_install_network_seccomp(
|
|||||||
!sandbox_policy.has_full_network_access() || allow_network_for_proxy
|
!sandbox_policy.has_full_network_access() || allow_network_for_proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn network_seccomp_mode(
|
||||||
|
sandbox_policy: &SandboxPolicy,
|
||||||
|
allow_network_for_proxy: bool,
|
||||||
|
proxy_routed_network: bool,
|
||||||
|
) -> Option<NetworkSeccompMode> {
|
||||||
|
if !should_install_network_seccomp(sandbox_policy, allow_network_for_proxy) {
|
||||||
|
None
|
||||||
|
} else if proxy_routed_network {
|
||||||
|
Some(NetworkSeccompMode::ProxyRouted)
|
||||||
|
} else {
|
||||||
|
Some(NetworkSeccompMode::Restricted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Enable `PR_SET_NO_NEW_PRIVS` so seccomp can be applied safely.
|
/// Enable `PR_SET_NO_NEW_PRIVS` so seccomp can be applied safely.
|
||||||
fn set_no_new_privs() -> Result<()> {
|
fn set_no_new_privs() -> Result<()> {
|
||||||
let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
|
let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
|
||||||
@@ -135,51 +159,87 @@ fn install_filesystem_landlock_rules_on_current_thread(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs a seccomp filter that blocks outbound network access except for
|
/// Installs a seccomp filter for Linux network sandboxing.
|
||||||
/// AF_UNIX domain sockets.
|
|
||||||
///
|
///
|
||||||
/// The filter is applied to the current thread so only the sandboxed child
|
/// The filter is applied to the current thread so only the sandboxed child
|
||||||
/// inherits it.
|
/// inherits it.
|
||||||
fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
|
fn install_network_seccomp_filter_on_current_thread(
|
||||||
|
mode: NetworkSeccompMode,
|
||||||
|
) -> std::result::Result<(), SandboxErr> {
|
||||||
|
fn deny_syscall(rules: &mut BTreeMap<i64, Vec<SeccompRule>>, nr: i64) {
|
||||||
|
rules.insert(nr, vec![]); // empty rule vec = unconditional match
|
||||||
|
}
|
||||||
|
|
||||||
// Build rule map.
|
// Build rule map.
|
||||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||||
|
|
||||||
// Helper – insert unconditional deny rule for syscall number.
|
deny_syscall(&mut rules, libc::SYS_ptrace);
|
||||||
let mut deny_syscall = |nr: i64| {
|
deny_syscall(&mut rules, libc::SYS_io_uring_setup);
|
||||||
rules.insert(nr, vec![]); // empty rule vec = unconditional match
|
deny_syscall(&mut rules, libc::SYS_io_uring_enter);
|
||||||
};
|
deny_syscall(&mut rules, libc::SYS_io_uring_register);
|
||||||
|
|
||||||
deny_syscall(libc::SYS_connect);
|
match mode {
|
||||||
deny_syscall(libc::SYS_accept);
|
NetworkSeccompMode::Restricted => {
|
||||||
deny_syscall(libc::SYS_accept4);
|
deny_syscall(&mut rules, libc::SYS_connect);
|
||||||
deny_syscall(libc::SYS_bind);
|
deny_syscall(&mut rules, libc::SYS_accept);
|
||||||
deny_syscall(libc::SYS_listen);
|
deny_syscall(&mut rules, libc::SYS_accept4);
|
||||||
deny_syscall(libc::SYS_getpeername);
|
deny_syscall(&mut rules, libc::SYS_bind);
|
||||||
deny_syscall(libc::SYS_getsockname);
|
deny_syscall(&mut rules, libc::SYS_listen);
|
||||||
deny_syscall(libc::SYS_shutdown);
|
deny_syscall(&mut rules, libc::SYS_getpeername);
|
||||||
deny_syscall(libc::SYS_sendto);
|
deny_syscall(&mut rules, libc::SYS_getsockname);
|
||||||
deny_syscall(libc::SYS_sendmmsg);
|
deny_syscall(&mut rules, libc::SYS_shutdown);
|
||||||
// NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run
|
deny_syscall(&mut rules, libc::SYS_sendto);
|
||||||
// with their socketpair + child processes for sub-proc management
|
deny_syscall(&mut rules, libc::SYS_sendmmsg);
|
||||||
// deny_syscall(libc::SYS_recvfrom);
|
// NOTE: allowing recvfrom allows some tools like: `cargo clippy`
|
||||||
deny_syscall(libc::SYS_recvmmsg);
|
// to run with their socketpair + child processes for sub-proc
|
||||||
deny_syscall(libc::SYS_getsockopt);
|
// management.
|
||||||
deny_syscall(libc::SYS_setsockopt);
|
// deny_syscall(&mut rules, libc::SYS_recvfrom);
|
||||||
deny_syscall(libc::SYS_ptrace);
|
deny_syscall(&mut rules, libc::SYS_recvmmsg);
|
||||||
deny_syscall(libc::SYS_io_uring_setup);
|
deny_syscall(&mut rules, libc::SYS_getsockopt);
|
||||||
deny_syscall(libc::SYS_io_uring_enter);
|
deny_syscall(&mut rules, libc::SYS_setsockopt);
|
||||||
deny_syscall(libc::SYS_io_uring_register);
|
|
||||||
|
|
||||||
// For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny everything else.
|
// For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny
|
||||||
let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new(
|
// everything else.
|
||||||
0, // first argument (domain)
|
let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new(
|
||||||
SeccompCmpArgLen::Dword,
|
0, // first argument (domain)
|
||||||
SeccompCmpOp::Ne,
|
SeccompCmpArgLen::Dword,
|
||||||
libc::AF_UNIX as u64,
|
SeccompCmpOp::Ne,
|
||||||
)?])?;
|
libc::AF_UNIX as u64,
|
||||||
|
)?])?;
|
||||||
|
|
||||||
rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
|
rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
|
||||||
rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); // always deny (Unix can use socketpair but fine, keep open?)
|
rules.insert(libc::SYS_socketpair, vec![unix_only_rule]);
|
||||||
|
}
|
||||||
|
NetworkSeccompMode::ProxyRouted => {
|
||||||
|
// In proxy-routed mode we allow IP sockets in the isolated
|
||||||
|
// namespace (used to reach the local TCP bridge) but deny all
|
||||||
|
// other socket families, including AF_UNIX. This prevents
|
||||||
|
// bypassing the routed bridge via new Unix sockets and narrows the
|
||||||
|
// socket surface in proxy-only mode.
|
||||||
|
let deny_non_ip_socket = SeccompRule::new(vec![
|
||||||
|
SeccompCondition::new(
|
||||||
|
0,
|
||||||
|
SeccompCmpArgLen::Dword,
|
||||||
|
SeccompCmpOp::Ne,
|
||||||
|
libc::AF_INET as u64,
|
||||||
|
)?,
|
||||||
|
SeccompCondition::new(
|
||||||
|
0,
|
||||||
|
SeccompCmpArgLen::Dword,
|
||||||
|
SeccompCmpOp::Ne,
|
||||||
|
libc::AF_INET6 as u64,
|
||||||
|
)?,
|
||||||
|
])?;
|
||||||
|
let deny_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new(
|
||||||
|
0,
|
||||||
|
SeccompCmpArgLen::Dword,
|
||||||
|
SeccompCmpOp::Eq,
|
||||||
|
libc::AF_UNIX as u64,
|
||||||
|
)?])?;
|
||||||
|
rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]);
|
||||||
|
rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let filter = SeccompFilter::new(
|
let filter = SeccompFilter::new(
|
||||||
rules,
|
rules,
|
||||||
@@ -203,6 +263,8 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(),
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::NetworkSeccompMode;
|
||||||
|
use super::network_seccomp_mode;
|
||||||
use super::should_install_network_seccomp;
|
use super::should_install_network_seccomp;
|
||||||
use codex_protocol::protocol::SandboxPolicy;
|
use codex_protocol::protocol::SandboxPolicy;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
@@ -234,4 +296,28 @@ mod tests {
|
|||||||
true
|
true
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_proxy_routes_use_proxy_routed_seccomp_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
network_seccomp_mode(&SandboxPolicy::DangerFullAccess, true, true),
|
||||||
|
Some(NetworkSeccompMode::ProxyRouted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restricted_network_without_proxy_routing_uses_restricted_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
network_seccomp_mode(&SandboxPolicy::new_read_only_policy(), false, false),
|
||||||
|
Some(NetworkSeccompMode::Restricted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_network_without_managed_proxy_skips_network_seccomp_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
network_seccomp_mode(&SandboxPolicy::DangerFullAccess, false, false),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ mod landlock;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux_run_main;
|
mod linux_run_main;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
mod proxy_routing;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
mod vendored_bwrap;
|
mod vendored_bwrap;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use crate::bwrap::BwrapNetworkMode;
|
|||||||
use crate::bwrap::BwrapOptions;
|
use crate::bwrap::BwrapOptions;
|
||||||
use crate::bwrap::create_bwrap_command_args;
|
use crate::bwrap::create_bwrap_command_args;
|
||||||
use crate::landlock::apply_sandbox_policy_to_current_thread;
|
use crate::landlock::apply_sandbox_policy_to_current_thread;
|
||||||
|
use crate::proxy_routing::activate_proxy_routes_in_netns;
|
||||||
|
use crate::proxy_routing::prepare_host_proxy_route_spec;
|
||||||
use crate::vendored_bwrap::exec_vendored_bwrap;
|
use crate::vendored_bwrap::exec_vendored_bwrap;
|
||||||
use crate::vendored_bwrap::run_vendored_bwrap_main;
|
use crate::vendored_bwrap::run_vendored_bwrap_main;
|
||||||
|
|
||||||
@@ -44,11 +46,15 @@ pub struct LandlockCommand {
|
|||||||
/// Internal compatibility flag.
|
/// Internal compatibility flag.
|
||||||
///
|
///
|
||||||
/// By default, restricted-network sandboxing uses isolated networking.
|
/// By default, restricted-network sandboxing uses isolated networking.
|
||||||
/// If set, sandbox setup switches to proxy-only network mode
|
/// If set, sandbox setup switches to proxy-only network mode with
|
||||||
/// (currently enforced the same as isolated networking).
|
/// managed routing bridges.
|
||||||
#[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)]
|
#[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)]
|
||||||
pub allow_network_for_proxy: bool,
|
pub allow_network_for_proxy: bool,
|
||||||
|
|
||||||
|
/// Internal route spec used for managed proxy routing in bwrap mode.
|
||||||
|
#[arg(long = "proxy-route-spec", hide = true)]
|
||||||
|
pub proxy_route_spec: Option<String>,
|
||||||
|
|
||||||
/// When set, skip mounting a fresh `/proc` even though PID isolation is
|
/// When set, skip mounting a fresh `/proc` even though PID isolation is
|
||||||
/// still enabled. This is primarily intended for restrictive container
|
/// still enabled. This is primarily intended for restrictive container
|
||||||
/// environments that deny `--proc /proc`.
|
/// environments that deny `--proc /proc`.
|
||||||
@@ -74,6 +80,7 @@ pub fn run_main() -> ! {
|
|||||||
use_bwrap_sandbox,
|
use_bwrap_sandbox,
|
||||||
apply_seccomp_then_exec,
|
apply_seccomp_then_exec,
|
||||||
allow_network_for_proxy,
|
allow_network_for_proxy,
|
||||||
|
proxy_route_spec,
|
||||||
no_proc,
|
no_proc,
|
||||||
command,
|
command,
|
||||||
} = LandlockCommand::parse();
|
} = LandlockCommand::parse();
|
||||||
@@ -81,15 +88,26 @@ pub fn run_main() -> ! {
|
|||||||
if command.is_empty() {
|
if command.is_empty() {
|
||||||
panic!("No command specified to execute.");
|
panic!("No command specified to execute.");
|
||||||
}
|
}
|
||||||
|
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox);
|
||||||
|
|
||||||
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
|
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
|
||||||
// established the filesystem view.
|
// established the filesystem view.
|
||||||
if apply_seccomp_then_exec {
|
if apply_seccomp_then_exec {
|
||||||
|
if allow_network_for_proxy {
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let proxy_routing_active = allow_network_for_proxy;
|
||||||
if let Err(e) = apply_sandbox_policy_to_current_thread(
|
if let Err(e) = apply_sandbox_policy_to_current_thread(
|
||||||
&sandbox_policy,
|
&sandbox_policy,
|
||||||
&sandbox_policy_cwd,
|
&sandbox_policy_cwd,
|
||||||
false,
|
false,
|
||||||
allow_network_for_proxy,
|
allow_network_for_proxy,
|
||||||
|
proxy_routing_active,
|
||||||
) {
|
) {
|
||||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||||
}
|
}
|
||||||
@@ -102,6 +120,7 @@ pub fn run_main() -> ! {
|
|||||||
&sandbox_policy_cwd,
|
&sandbox_policy_cwd,
|
||||||
false,
|
false,
|
||||||
allow_network_for_proxy,
|
allow_network_for_proxy,
|
||||||
|
false,
|
||||||
) {
|
) {
|
||||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||||
}
|
}
|
||||||
@@ -112,11 +131,20 @@ pub fn run_main() -> ! {
|
|||||||
// Outer stage: bubblewrap first, then re-enter this binary in the
|
// Outer stage: bubblewrap first, then re-enter this binary in the
|
||||||
// sandboxed environment to apply seccomp. This path never falls back
|
// sandboxed environment to apply seccomp. This path never falls back
|
||||||
// to legacy Landlock on failure.
|
// to legacy Landlock on failure.
|
||||||
|
let proxy_route_spec =
|
||||||
|
if allow_network_for_proxy {
|
||||||
|
Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| {
|
||||||
|
panic!("failed to prepare host proxy routing bridge: {err}")
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let inner = build_inner_seccomp_command(
|
let inner = build_inner_seccomp_command(
|
||||||
&sandbox_policy_cwd,
|
&sandbox_policy_cwd,
|
||||||
&sandbox_policy,
|
&sandbox_policy,
|
||||||
use_bwrap_sandbox,
|
use_bwrap_sandbox,
|
||||||
allow_network_for_proxy,
|
allow_network_for_proxy,
|
||||||
|
proxy_route_spec,
|
||||||
command,
|
command,
|
||||||
);
|
);
|
||||||
run_bwrap_with_proc_fallback(
|
run_bwrap_with_proc_fallback(
|
||||||
@@ -134,12 +162,19 @@ pub fn run_main() -> ! {
|
|||||||
&sandbox_policy_cwd,
|
&sandbox_policy_cwd,
|
||||||
true,
|
true,
|
||||||
allow_network_for_proxy,
|
allow_network_for_proxy,
|
||||||
|
false,
|
||||||
) {
|
) {
|
||||||
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
|
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
|
||||||
}
|
}
|
||||||
exec_or_panic(command);
|
exec_or_panic(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) {
|
||||||
|
if apply_seccomp_then_exec && !use_bwrap_sandbox {
|
||||||
|
panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run_bwrap_with_proc_fallback(
|
fn run_bwrap_with_proc_fallback(
|
||||||
sandbox_policy_cwd: &Path,
|
sandbox_policy_cwd: &Path,
|
||||||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||||
@@ -147,14 +182,15 @@ fn run_bwrap_with_proc_fallback(
|
|||||||
mount_proc: bool,
|
mount_proc: bool,
|
||||||
allow_network_for_proxy: bool,
|
allow_network_for_proxy: bool,
|
||||||
) -> ! {
|
) -> ! {
|
||||||
|
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
|
||||||
let mut mount_proc = mount_proc;
|
let mut mount_proc = mount_proc;
|
||||||
|
|
||||||
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy) {
|
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy, network_mode)
|
||||||
|
{
|
||||||
eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc");
|
eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc");
|
||||||
mount_proc = false;
|
mount_proc = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
|
|
||||||
let options = BwrapOptions {
|
let options = BwrapOptions {
|
||||||
mount_proc,
|
mount_proc,
|
||||||
network_mode,
|
network_mode,
|
||||||
@@ -202,19 +238,29 @@ fn build_bwrap_argv(
|
|||||||
fn preflight_proc_mount_support(
|
fn preflight_proc_mount_support(
|
||||||
sandbox_policy_cwd: &Path,
|
sandbox_policy_cwd: &Path,
|
||||||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||||
|
network_mode: BwrapNetworkMode,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
let preflight_argv =
|
||||||
|
build_preflight_bwrap_argv(sandbox_policy_cwd, sandbox_policy, network_mode);
|
||||||
|
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
|
||||||
|
!is_proc_mount_failure(stderr.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_preflight_bwrap_argv(
|
||||||
|
sandbox_policy_cwd: &Path,
|
||||||
|
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||||
|
network_mode: BwrapNetworkMode,
|
||||||
|
) -> Vec<String> {
|
||||||
let preflight_command = vec![resolve_true_command()];
|
let preflight_command = vec![resolve_true_command()];
|
||||||
let preflight_argv = build_bwrap_argv(
|
build_bwrap_argv(
|
||||||
preflight_command,
|
preflight_command,
|
||||||
sandbox_policy,
|
sandbox_policy,
|
||||||
sandbox_policy_cwd,
|
sandbox_policy_cwd,
|
||||||
BwrapOptions {
|
BwrapOptions {
|
||||||
mount_proc: true,
|
mount_proc: true,
|
||||||
network_mode: BwrapNetworkMode::FullAccess,
|
network_mode,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
|
|
||||||
!is_proc_mount_failure(stderr.as_str())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_true_command() -> String {
|
fn resolve_true_command() -> String {
|
||||||
@@ -318,6 +364,7 @@ fn build_inner_seccomp_command(
|
|||||||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||||
use_bwrap_sandbox: bool,
|
use_bwrap_sandbox: bool,
|
||||||
allow_network_for_proxy: bool,
|
allow_network_for_proxy: bool,
|
||||||
|
proxy_route_spec: Option<String>,
|
||||||
command: Vec<String>,
|
command: Vec<String>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let current_exe = match std::env::current_exe() {
|
let current_exe = match std::env::current_exe() {
|
||||||
@@ -342,6 +389,10 @@ fn build_inner_seccomp_command(
|
|||||||
}
|
}
|
||||||
if allow_network_for_proxy {
|
if allow_network_for_proxy {
|
||||||
inner.push("--allow-network-for-proxy".to_string());
|
inner.push("--allow-network-for-proxy".to_string());
|
||||||
|
let proxy_route_spec = proxy_route_spec
|
||||||
|
.unwrap_or_else(|| panic!("managed proxy mode requires a proxy route spec"));
|
||||||
|
inner.push("--proxy-route-spec".to_string());
|
||||||
|
inner.push(proxy_route_spec);
|
||||||
}
|
}
|
||||||
inner.push("--".to_string());
|
inner.push("--".to_string());
|
||||||
inner.extend(command);
|
inner.extend(command);
|
||||||
@@ -371,100 +422,5 @@ fn exec_or_panic(command: Vec<String>) -> ! {
|
|||||||
panic!("Failed to execvp {}: {err}", command[0].as_str());
|
panic!("Failed to execvp {}: {err}", command[0].as_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[path = "linux_run_main_tests.rs"]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use codex_protocol::protocol::SandboxPolicy;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_proc_mount_invalid_argument_failure() {
|
|
||||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument";
|
|
||||||
assert_eq!(is_proc_mount_failure(stderr), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_proc_mount_operation_not_permitted_failure() {
|
|
||||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
|
|
||||||
assert_eq!(is_proc_mount_failure(stderr), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_proc_mount_permission_denied_failure() {
|
|
||||||
let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied";
|
|
||||||
assert_eq!(is_proc_mount_failure(stderr), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ignores_non_proc_mount_errors() {
|
|
||||||
let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted";
|
|
||||||
assert_eq!(is_proc_mount_failure(stderr), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inserts_bwrap_argv0_before_command_separator() {
|
|
||||||
let argv = build_bwrap_argv(
|
|
||||||
vec!["/bin/true".to_string()],
|
|
||||||
&SandboxPolicy::new_read_only_policy(),
|
|
||||||
Path::new("/"),
|
|
||||||
BwrapOptions {
|
|
||||||
mount_proc: true,
|
|
||||||
network_mode: BwrapNetworkMode::FullAccess,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
argv,
|
|
||||||
vec![
|
|
||||||
"bwrap".to_string(),
|
|
||||||
"--new-session".to_string(),
|
|
||||||
"--die-with-parent".to_string(),
|
|
||||||
"--ro-bind".to_string(),
|
|
||||||
"/".to_string(),
|
|
||||||
"/".to_string(),
|
|
||||||
"--dev".to_string(),
|
|
||||||
"/dev".to_string(),
|
|
||||||
"--unshare-pid".to_string(),
|
|
||||||
"--proc".to_string(),
|
|
||||||
"/proc".to_string(),
|
|
||||||
"--argv0".to_string(),
|
|
||||||
"codex-linux-sandbox".to_string(),
|
|
||||||
"--".to_string(),
|
|
||||||
"/bin/true".to_string(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inserts_unshare_net_when_network_isolation_requested() {
|
|
||||||
let argv = build_bwrap_argv(
|
|
||||||
vec!["/bin/true".to_string()],
|
|
||||||
&SandboxPolicy::new_read_only_policy(),
|
|
||||||
Path::new("/"),
|
|
||||||
BwrapOptions {
|
|
||||||
mount_proc: true,
|
|
||||||
network_mode: BwrapNetworkMode::Isolated,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(argv.contains(&"--unshare-net".to_string()), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
|
|
||||||
let argv = build_bwrap_argv(
|
|
||||||
vec!["/bin/true".to_string()],
|
|
||||||
&SandboxPolicy::new_read_only_policy(),
|
|
||||||
Path::new("/"),
|
|
||||||
BwrapOptions {
|
|
||||||
mount_proc: true,
|
|
||||||
network_mode: BwrapNetworkMode::ProxyOnly,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(argv.contains(&"--unshare-net".to_string()), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
|
|
||||||
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
|
|
||||||
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
use super::*;
|
||||||
|
#[cfg(test)]
|
||||||
|
use codex_protocol::protocol::SandboxPolicy;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_proc_mount_invalid_argument_failure() {
|
||||||
|
let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument";
|
||||||
|
assert!(is_proc_mount_failure(stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_proc_mount_operation_not_permitted_failure() {
|
||||||
|
let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
|
||||||
|
assert!(is_proc_mount_failure(stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_proc_mount_permission_denied_failure() {
|
||||||
|
let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied";
|
||||||
|
assert!(is_proc_mount_failure(stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_proc_mount_errors() {
|
||||||
|
let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted";
|
||||||
|
assert!(!is_proc_mount_failure(stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inserts_bwrap_argv0_before_command_separator() {
|
||||||
|
let argv = build_bwrap_argv(
|
||||||
|
vec!["/bin/true".to_string()],
|
||||||
|
&SandboxPolicy::new_read_only_policy(),
|
||||||
|
Path::new("/"),
|
||||||
|
BwrapOptions {
|
||||||
|
mount_proc: true,
|
||||||
|
network_mode: BwrapNetworkMode::FullAccess,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
argv,
|
||||||
|
vec![
|
||||||
|
"bwrap".to_string(),
|
||||||
|
"--new-session".to_string(),
|
||||||
|
"--die-with-parent".to_string(),
|
||||||
|
"--ro-bind".to_string(),
|
||||||
|
"/".to_string(),
|
||||||
|
"/".to_string(),
|
||||||
|
"--dev".to_string(),
|
||||||
|
"/dev".to_string(),
|
||||||
|
"--unshare-pid".to_string(),
|
||||||
|
"--proc".to_string(),
|
||||||
|
"/proc".to_string(),
|
||||||
|
"--argv0".to_string(),
|
||||||
|
"codex-linux-sandbox".to_string(),
|
||||||
|
"--".to_string(),
|
||||||
|
"/bin/true".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inserts_unshare_net_when_network_isolation_requested() {
|
||||||
|
let argv = build_bwrap_argv(
|
||||||
|
vec!["/bin/true".to_string()],
|
||||||
|
&SandboxPolicy::new_read_only_policy(),
|
||||||
|
Path::new("/"),
|
||||||
|
BwrapOptions {
|
||||||
|
mount_proc: true,
|
||||||
|
network_mode: BwrapNetworkMode::Isolated,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(argv.contains(&"--unshare-net".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
|
||||||
|
let argv = build_bwrap_argv(
|
||||||
|
vec!["/bin/true".to_string()],
|
||||||
|
&SandboxPolicy::new_read_only_policy(),
|
||||||
|
Path::new("/"),
|
||||||
|
BwrapOptions {
|
||||||
|
mount_proc: true,
|
||||||
|
network_mode: BwrapNetworkMode::ProxyOnly,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(argv.contains(&"--unshare-net".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
|
||||||
|
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
|
||||||
|
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
|
||||||
|
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
|
||||||
|
let argv = build_preflight_bwrap_argv(Path::new("/"), &SandboxPolicy::DangerFullAccess, mode);
|
||||||
|
assert!(argv.iter().any(|arg| arg == "--"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_proxy_inner_command_includes_route_spec() {
|
||||||
|
let args = build_inner_seccomp_command(
|
||||||
|
Path::new("/tmp"),
|
||||||
|
&SandboxPolicy::new_read_only_policy(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
Some("{\"routes\":[]}".to_string()),
|
||||||
|
vec!["/bin/true".to_string()],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(args.iter().any(|arg| arg == "--proxy-route-spec"));
|
||||||
|
assert!(args.iter().any(|arg| arg == "{\"routes\":[]}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_managed_inner_command_omits_route_spec() {
|
||||||
|
let args = build_inner_seccomp_command(
|
||||||
|
Path::new("/tmp"),
|
||||||
|
&SandboxPolicy::new_read_only_policy(),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
vec!["/bin/true".to_string()],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!args.iter().any(|arg| arg == "--proxy-route-spec"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_proxy_inner_command_requires_route_spec() {
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
build_inner_seccomp_command(
|
||||||
|
Path::new("/tmp"),
|
||||||
|
&SandboxPolicy::new_read_only_policy(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
vec!["/bin/true".to_string()],
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_seccomp_then_exec_without_bwrap_panics() {
|
||||||
|
let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_inner_stage_modes_do_not_panic() {
|
||||||
|
ensure_inner_stage_mode_is_valid(false, false);
|
||||||
|
ensure_inner_stage_mode_is_valid(false, true);
|
||||||
|
ensure_inner_stage_mode_is_valid(true, true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::DirBuilder;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::fs::Permissions;
|
||||||
|
use std::io;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::os::fd::FromRawFd;
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
const PROXY_ENV_KEYS: &[&str] = &[
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"FTP_PROXY",
|
||||||
|
"YARN_HTTP_PROXY",
|
||||||
|
"YARN_HTTPS_PROXY",
|
||||||
|
"NPM_CONFIG_HTTP_PROXY",
|
||||||
|
"NPM_CONFIG_HTTPS_PROXY",
|
||||||
|
"NPM_CONFIG_PROXY",
|
||||||
|
"BUNDLE_HTTP_PROXY",
|
||||||
|
"BUNDLE_HTTPS_PROXY",
|
||||||
|
"PIP_PROXY",
|
||||||
|
"DOCKER_HTTP_PROXY",
|
||||||
|
"DOCKER_HTTPS_PROXY",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-";
|
||||||
|
const HOST_BRIDGE_READY: u8 = 1;
|
||||||
|
const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ProxyRouteSpec {
|
||||||
|
routes: Vec<ProxyRouteEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
struct ProxyRouteEntry {
|
||||||
|
env_key: String,
|
||||||
|
uds_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct PlannedProxyRoute {
|
||||||
|
env_key: String,
|
||||||
|
endpoint: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ProxyRoutePlan {
|
||||||
|
routes: Vec<PlannedProxyRoute>,
|
||||||
|
has_proxy_config: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<String> {
|
||||||
|
let env: HashMap<String, String> = std::env::vars().collect();
|
||||||
|
let plan = plan_proxy_routes(&env);
|
||||||
|
|
||||||
|
if plan.routes.is_empty() {
|
||||||
|
let message = if plan.has_proxy_config {
|
||||||
|
"managed proxy mode requires parseable loopback proxy endpoints"
|
||||||
|
} else {
|
||||||
|
"managed proxy mode requires proxy environment variables"
|
||||||
|
};
|
||||||
|
return Err(io::Error::new(io::ErrorKind::InvalidInput, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
let socket_parent_dir = proxy_socket_parent_dir();
|
||||||
|
let _ = cleanup_stale_proxy_socket_dirs_in(socket_parent_dir.as_path());
|
||||||
|
|
||||||
|
let socket_dir = create_proxy_socket_dir()?;
|
||||||
|
let mut socket_by_endpoint: BTreeMap<SocketAddr, PathBuf> = BTreeMap::new();
|
||||||
|
let mut next_index = 0usize;
|
||||||
|
for route in &plan.routes {
|
||||||
|
if socket_by_endpoint.contains_key(&route.endpoint) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let socket_path = socket_dir.join(format!("proxy-route-{next_index}.sock"));
|
||||||
|
next_index += 1;
|
||||||
|
socket_by_endpoint.insert(route.endpoint, socket_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len());
|
||||||
|
for (endpoint, socket_path) in &socket_by_endpoint {
|
||||||
|
host_bridge_pids.push(spawn_host_bridge(*endpoint, socket_path)?);
|
||||||
|
}
|
||||||
|
spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?;
|
||||||
|
|
||||||
|
let mut routes = Vec::with_capacity(plan.routes.len());
|
||||||
|
for route in plan.routes {
|
||||||
|
let Some(uds_path) = socket_by_endpoint.get(&route.endpoint) else {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"missing UDS path for endpoint {}",
|
||||||
|
route.endpoint
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
routes.push(ProxyRouteEntry {
|
||||||
|
env_key: route.env_key,
|
||||||
|
uds_path: uds_path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> {
|
||||||
|
let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?;
|
||||||
|
|
||||||
|
if spec.routes.is_empty() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"proxy routing spec contained no routes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut local_port_by_uds_path: BTreeMap<PathBuf, u16> = BTreeMap::new();
|
||||||
|
for route in &spec.routes {
|
||||||
|
if local_port_by_uds_path.contains_key(&route.uds_path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let local_port = spawn_local_bridge(route.uds_path.as_path())?;
|
||||||
|
local_port_by_uds_path.insert(route.uds_path.clone(), local_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
for route in spec.routes {
|
||||||
|
let Some(local_port) = local_port_by_uds_path.get(&route.uds_path) else {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"missing local bridge port for UDS path {}",
|
||||||
|
route.uds_path.display()
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let original_value = std::env::var(&route.env_key).map_err(|_| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
format!("missing proxy env key {}", route.env_key),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let Some(rewritten) = rewrite_proxy_env_value(&original_value, *local_port) else {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
format!("could not rewrite proxy URL for env key {}", route.env_key),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
// SAFETY: this helper process is single-threaded at this point, and
|
||||||
|
// env mutation happens before execing the user command.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(route.env_key, rewritten);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
|
||||||
|
let mut routes = Vec::new();
|
||||||
|
let mut has_proxy_config = false;
|
||||||
|
|
||||||
|
for (key, value) in env {
|
||||||
|
if !is_proxy_env_key(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
has_proxy_config = true;
|
||||||
|
|
||||||
|
let Some(endpoint) = parse_loopback_proxy_endpoint(trimmed) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
routes.push(PlannedProxyRoute {
|
||||||
|
env_key: key.clone(),
|
||||||
|
endpoint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
routes.sort_by(|left, right| left.env_key.cmp(&right.env_key));
|
||||||
|
ProxyRoutePlan {
|
||||||
|
routes,
|
||||||
|
has_proxy_config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_proxy_env_key(key: &str) -> bool {
|
||||||
|
let upper = key.to_ascii_uppercase();
|
||||||
|
PROXY_ENV_KEYS.contains(&upper.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option<SocketAddr> {
|
||||||
|
let candidate = if proxy_url.contains("://") {
|
||||||
|
proxy_url.to_string()
|
||||||
|
} else {
|
||||||
|
format!("http://{proxy_url}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed = Url::parse(&candidate).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
if !is_loopback_host(host) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheme = parsed.scheme().to_ascii_lowercase();
|
||||||
|
let port = parsed
|
||||||
|
.port()
|
||||||
|
.unwrap_or_else(|| default_proxy_port(scheme.as_str()));
|
||||||
|
if port == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = if host.eq_ignore_ascii_case("localhost") {
|
||||||
|
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||||
|
} else {
|
||||||
|
host.parse::<IpAddr>().ok()?
|
||||||
|
};
|
||||||
|
if ip.is_loopback() {
|
||||||
|
Some(SocketAddr::new(ip, port))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_loopback_host(host: &str) -> bool {
|
||||||
|
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_proxy_port(scheme: &str) -> u16 {
|
||||||
|
match scheme {
|
||||||
|
"https" => 443,
|
||||||
|
"socks5" | "socks5h" | "socks4" | "socks4a" => 1080,
|
||||||
|
_ => 80,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option<String> {
|
||||||
|
let had_scheme = proxy_url.contains("://");
|
||||||
|
let candidate = if had_scheme {
|
||||||
|
proxy_url.to_string()
|
||||||
|
} else {
|
||||||
|
format!("http://{proxy_url}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parsed = Url::parse(&candidate).ok()?;
|
||||||
|
parsed.set_host(Some("127.0.0.1")).ok()?;
|
||||||
|
parsed.set_port(Some(local_port)).ok()?;
|
||||||
|
let mut rewritten = parsed.to_string();
|
||||||
|
if !had_scheme {
|
||||||
|
rewritten = rewritten
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.unwrap_or(rewritten.as_str())
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
if !proxy_url.ends_with('/')
|
||||||
|
&& !proxy_url.contains('?')
|
||||||
|
&& !proxy_url.contains('#')
|
||||||
|
&& rewritten.ends_with('/')
|
||||||
|
{
|
||||||
|
rewritten.pop();
|
||||||
|
}
|
||||||
|
Some(rewritten)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_proxy_socket_dir() -> io::Result<PathBuf> {
|
||||||
|
let temp_dir = proxy_socket_parent_dir();
|
||||||
|
let pid = std::process::id();
|
||||||
|
for attempt in 0..128 {
|
||||||
|
let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{attempt}"));
|
||||||
|
// The bridge UDS paths live under a shared temp root, so the per-run
|
||||||
|
// directory should not be traversable by other processes.
|
||||||
|
let mut dir_builder = DirBuilder::new();
|
||||||
|
dir_builder.mode(0o700);
|
||||||
|
match dir_builder.create(&candidate) {
|
||||||
|
Ok(()) => return Ok(candidate),
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
format!(
|
||||||
|
"failed to allocate proxy routing temp dir under {}",
|
||||||
|
temp_dir.display()
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_socket_parent_dir() -> PathBuf {
|
||||||
|
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
||||||
|
let candidate = PathBuf::from(codex_home).join("tmp");
|
||||||
|
if ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::env::temp_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> {
|
||||||
|
let mut dir_builder = DirBuilder::new();
|
||||||
|
dir_builder.recursive(true);
|
||||||
|
dir_builder.mode(0o700);
|
||||||
|
match dir_builder.create(path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
std::fs::set_permissions(path, Permissions::from_mode(0o700))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_stale_proxy_socket_dirs_in(temp_dir: &Path) -> io::Result<()> {
|
||||||
|
for entry in std::fs::read_dir(temp_dir)? {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(entry) => entry,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let file_type = match entry.file_type() {
|
||||||
|
Ok(file_type) => file_type,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !file_type.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let file_name = file_name.to_string_lossy();
|
||||||
|
let Some(owner_pid) = parse_proxy_socket_dir_owner_pid(file_name.as_ref()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_pid_alive(owner_pid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = cleanup_proxy_socket_dir(entry.path().as_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_proxy_socket_dir_owner_pid(file_name: &str) -> Option<u32> {
|
||||||
|
let suffix = file_name.strip_prefix(PROXY_SOCKET_DIR_PREFIX)?;
|
||||||
|
let (pid_raw, _) = suffix.split_once('-')?;
|
||||||
|
pid_raw.parse::<u32>().ok().filter(|pid| *pid != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pid_alive(pid: u32) -> bool {
|
||||||
|
let Ok(pid) = libc::pid_t::try_from(pid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
is_pid_alive_raw(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pid_alive_raw(pid: libc::pid_t) -> bool {
|
||||||
|
let status = unsafe { libc::kill(pid, 0) };
|
||||||
|
if status == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
!matches!(err.raw_os_error(), Some(libc::ESRCH))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_proxy_socket_dir_cleanup_worker(
|
||||||
|
socket_dir: PathBuf,
|
||||||
|
host_bridge_pids: Vec<libc::pid_t>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let pid = unsafe { libc::fork() };
|
||||||
|
if pid < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
if pid == 0 {
|
||||||
|
loop {
|
||||||
|
if host_bridge_pids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.all(|bridge_pid| !is_pid_alive_raw(bridge_pid))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = cleanup_proxy_socket_dir(socket_dir.as_path());
|
||||||
|
unsafe { libc::_exit(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> {
|
||||||
|
for _ in 0..20 {
|
||||||
|
match std::fs::remove_dir_all(socket_dir) {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(_) => std::thread::sleep(Duration::from_millis(100)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::remove_dir_all(socket_dir) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result<libc::pid_t> {
|
||||||
|
let (read_fd, write_fd) = create_ready_pipe()?;
|
||||||
|
let pid = unsafe { libc::fork() };
|
||||||
|
if pid < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
close_fd(read_fd)?;
|
||||||
|
close_fd(write_fd)?;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if pid == 0 {
|
||||||
|
if close_fd(read_fd).is_err() {
|
||||||
|
unsafe { libc::_exit(1) };
|
||||||
|
}
|
||||||
|
let result = run_host_bridge(endpoint, uds_path, write_fd);
|
||||||
|
if result.is_err() {
|
||||||
|
unsafe { libc::_exit(1) };
|
||||||
|
}
|
||||||
|
unsafe { libc::_exit(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
close_fd(write_fd)?;
|
||||||
|
let mut ready = [0_u8; 1];
|
||||||
|
let mut read_file = unsafe { File::from_raw_fd(read_fd) };
|
||||||
|
read_file.read_exact(&mut ready)?;
|
||||||
|
if ready[0] != HOST_BRIDGE_READY {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"host bridge did not acknowledge readiness",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
|
||||||
|
set_parent_death_signal()?;
|
||||||
|
if uds_path.exists() {
|
||||||
|
std::fs::remove_file(uds_path)?;
|
||||||
|
}
|
||||||
|
let listener = UnixListener::bind(uds_path)?;
|
||||||
|
|
||||||
|
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) };
|
||||||
|
ready_file.write_all(&[HOST_BRIDGE_READY])?;
|
||||||
|
drop(ready_file);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (unix_stream, _) = listener.accept()?;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let tcp_stream = match TcpStream::connect(endpoint) {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let _ = proxy_bidirectional(tcp_stream, unix_stream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
|
||||||
|
let (read_fd, write_fd) = create_ready_pipe()?;
|
||||||
|
let pid = unsafe { libc::fork() };
|
||||||
|
if pid < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
close_fd(read_fd)?;
|
||||||
|
close_fd(write_fd)?;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if pid == 0 {
|
||||||
|
if close_fd(read_fd).is_err() {
|
||||||
|
unsafe { libc::_exit(1) };
|
||||||
|
}
|
||||||
|
let result = run_local_bridge(uds_path, write_fd);
|
||||||
|
if result.is_err() {
|
||||||
|
unsafe { libc::_exit(1) };
|
||||||
|
}
|
||||||
|
unsafe { libc::_exit(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
close_fd(write_fd)?;
|
||||||
|
let mut port_bytes = [0_u8; 2];
|
||||||
|
let mut read_file = unsafe { File::from_raw_fd(read_fd) };
|
||||||
|
read_file.read_exact(&mut port_bytes)?;
|
||||||
|
Ok(u16::from_be_bytes(port_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_local_bridge(uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
|
||||||
|
set_parent_death_signal()?;
|
||||||
|
let listener = bind_local_loopback_listener()?;
|
||||||
|
let port = listener.local_addr()?.port();
|
||||||
|
|
||||||
|
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) };
|
||||||
|
ready_file.write_all(&port.to_be_bytes())?;
|
||||||
|
drop(ready_file);
|
||||||
|
|
||||||
|
let uds_path = uds_path.to_path_buf();
|
||||||
|
loop {
|
||||||
|
let (tcp_stream, _) = listener.accept()?;
|
||||||
|
let socket_path = uds_path.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let unix_stream = match UnixStream::connect(socket_path) {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let _ = proxy_bidirectional(tcp_stream, unix_stream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_local_loopback_listener() -> io::Result<TcpListener> {
|
||||||
|
match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) {
|
||||||
|
Ok(listener) => Ok(listener),
|
||||||
|
Err(bind_err) => {
|
||||||
|
let should_retry_after_lo_up = matches!(
|
||||||
|
bind_err.raw_os_error(),
|
||||||
|
Some(errno) if errno == libc::EADDRNOTAVAIL || errno == libc::ENETUNREACH
|
||||||
|
);
|
||||||
|
if !should_retry_after_lo_up {
|
||||||
|
return Err(bind_err);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_loopback_interface_up()?;
|
||||||
|
TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_loopback_interface_up() -> io::Result<()> {
|
||||||
|
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ifreq = unsafe { std::mem::zeroed::<libc::ifreq>() };
|
||||||
|
for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() {
|
||||||
|
ifreq.ifr_name[index] = byte as libc::c_char;
|
||||||
|
}
|
||||||
|
|
||||||
|
let read_flags_result =
|
||||||
|
unsafe { libc::ioctl(fd, libc::SIOCGIFFLAGS as libc::Ioctl, &mut ifreq) };
|
||||||
|
if read_flags_result < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
let _ = close_fd(fd);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_flags = unsafe { ifreq.ifr_ifru.ifru_flags };
|
||||||
|
let up_flag = libc::IFF_UP as libc::c_short;
|
||||||
|
if (current_flags & up_flag) != up_flag {
|
||||||
|
ifreq.ifr_ifru.ifru_flags = current_flags | up_flag;
|
||||||
|
let set_flags_result =
|
||||||
|
unsafe { libc::ioctl(fd, libc::SIOCSIFFLAGS as libc::Ioctl, &ifreq) };
|
||||||
|
if set_flags_result < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
let _ = close_fd(fd);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut addr_req = unsafe { std::mem::zeroed::<libc::ifreq>() };
|
||||||
|
for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() {
|
||||||
|
addr_req.ifr_name[index] = byte as libc::c_char;
|
||||||
|
}
|
||||||
|
let loopback_addr = libc::sockaddr_in {
|
||||||
|
sin_family: libc::AF_INET as libc::sa_family_t,
|
||||||
|
sin_port: 0,
|
||||||
|
sin_addr: libc::in_addr {
|
||||||
|
s_addr: libc::htonl(libc::INADDR_LOOPBACK),
|
||||||
|
},
|
||||||
|
sin_zero: [0; 8],
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
addr_req.ifr_ifru.ifru_addr =
|
||||||
|
*(&loopback_addr as *const libc::sockaddr_in as *const libc::sockaddr);
|
||||||
|
}
|
||||||
|
let set_addr_result = unsafe { libc::ioctl(fd, libc::SIOCSIFADDR as libc::Ioctl, &addr_req) };
|
||||||
|
if set_addr_result < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
let allow_existing_or_immutable_addr =
|
||||||
|
matches!(err.raw_os_error(), Some(libc::EEXIST | libc::EPERM));
|
||||||
|
if !allow_existing_or_immutable_addr {
|
||||||
|
let _ = close_fd(fd);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close_fd(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_parent_death_signal() -> io::Result<()> {
|
||||||
|
let res = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) };
|
||||||
|
if res != 0 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else if unsafe { libc::getppid() } == 1 {
|
||||||
|
Err(io::Error::other("parent process already exited"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_bidirectional(mut tcp_stream: TcpStream, mut unix_stream: UnixStream) -> io::Result<()> {
|
||||||
|
let mut tcp_reader = tcp_stream.try_clone()?;
|
||||||
|
let mut unix_writer = unix_stream.try_clone()?;
|
||||||
|
let tcp_to_unix = std::thread::spawn(move || std::io::copy(&mut tcp_reader, &mut unix_writer));
|
||||||
|
let unix_to_tcp = std::io::copy(&mut unix_stream, &mut tcp_stream);
|
||||||
|
let tcp_to_unix = tcp_to_unix
|
||||||
|
.join()
|
||||||
|
.map_err(|_| io::Error::other("bridge thread panicked"))?;
|
||||||
|
tcp_to_unix?;
|
||||||
|
unix_to_tcp?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> {
|
||||||
|
let mut pipe_fds = [0; 2];
|
||||||
|
let res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
|
||||||
|
if res != 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok((pipe_fds[0], pipe_fds[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_fd(fd: libc::c_int) -> io::Result<()> {
|
||||||
|
let res = unsafe { libc::close(fd) };
|
||||||
|
if res < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::PROXY_SOCKET_DIR_PREFIX;
|
||||||
|
use super::ProxyRouteEntry;
|
||||||
|
use super::ProxyRouteSpec;
|
||||||
|
use super::cleanup_proxy_socket_dir;
|
||||||
|
use super::cleanup_stale_proxy_socket_dirs_in;
|
||||||
|
use super::default_proxy_port;
|
||||||
|
use super::is_proxy_env_key;
|
||||||
|
use super::parse_loopback_proxy_endpoint;
|
||||||
|
use super::parse_proxy_socket_dir_owner_pid;
|
||||||
|
use super::plan_proxy_routes;
|
||||||
|
use super::rewrite_proxy_env_value;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognizes_proxy_env_keys_case_insensitively() {
|
||||||
|
assert_eq!(is_proxy_env_key("HTTP_PROXY"), true);
|
||||||
|
assert_eq!(is_proxy_env_key("http_proxy"), true);
|
||||||
|
assert_eq!(is_proxy_env_key("PATH"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_loopback_proxy_endpoint() {
|
||||||
|
let endpoint = parse_loopback_proxy_endpoint("http://127.0.0.1:43128");
|
||||||
|
assert_eq!(
|
||||||
|
endpoint,
|
||||||
|
Some(
|
||||||
|
"127.0.0.1:43128"
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.expect("valid socket")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_loopback_proxy_endpoint() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_loopback_proxy_endpoint("http://example.com:3128"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_proxy_routes_only_includes_valid_loopback_endpoints() {
|
||||||
|
let mut env = HashMap::new();
|
||||||
|
env.insert(
|
||||||
|
"HTTP_PROXY".to_string(),
|
||||||
|
"http://127.0.0.1:43128".to_string(),
|
||||||
|
);
|
||||||
|
env.insert(
|
||||||
|
"HTTPS_PROXY".to_string(),
|
||||||
|
"http://example.com:3128".to_string(),
|
||||||
|
);
|
||||||
|
env.insert("PATH".to_string(), "/usr/bin".to_string());
|
||||||
|
|
||||||
|
let plan = plan_proxy_routes(&env);
|
||||||
|
assert_eq!(plan.has_proxy_config, true);
|
||||||
|
assert_eq!(plan.routes.len(), 1);
|
||||||
|
assert_eq!(plan.routes[0].env_key, "HTTP_PROXY");
|
||||||
|
assert_eq!(
|
||||||
|
plan.routes[0].endpoint,
|
||||||
|
"127.0.0.1:43128"
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.expect("valid socket")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rewrites_proxy_url_to_local_loopback_port() {
|
||||||
|
let rewritten =
|
||||||
|
rewrite_proxy_env_value("socks5h://127.0.0.1:8081", 43210).expect("rewritten value");
|
||||||
|
assert_eq!(rewritten, "socks5h://127.0.0.1:43210");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_proxy_ports_match_expected_schemes() {
|
||||||
|
assert_eq!(default_proxy_port("http"), 80);
|
||||||
|
assert_eq!(default_proxy_port("https"), 443);
|
||||||
|
assert_eq!(default_proxy_port("socks5h"), 1080);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_proxy_socket_dir_removes_bridge_artifacts() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir should create");
|
||||||
|
let socket_dir = root.path().join("codex-linux-sandbox-proxy-test");
|
||||||
|
std::fs::create_dir(&socket_dir).expect("socket dir should create");
|
||||||
|
let marker = socket_dir.join("bridge.sock");
|
||||||
|
std::fs::write(&marker, b"test").expect("marker should write");
|
||||||
|
|
||||||
|
cleanup_proxy_socket_dir(socket_dir.as_path()).expect("cleanup should succeed");
|
||||||
|
|
||||||
|
assert_eq!(socket_dir.exists(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_route_spec_serialization_omits_proxy_urls() {
|
||||||
|
let spec = ProxyRouteSpec {
|
||||||
|
routes: vec![ProxyRouteEntry {
|
||||||
|
env_key: "HTTP_PROXY".to_string(),
|
||||||
|
uds_path: PathBuf::from("/tmp/proxy-route-0.sock"),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serialized,
|
||||||
|
r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}]}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_proxy_socket_dir_owner_pid_reads_owner_pid() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"),
|
||||||
|
Some(1234)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(parse_proxy_socket_dir_owner_pid("not-a-proxy-dir"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_stale_proxy_socket_dirs_removes_dead_pid_directories() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir should create");
|
||||||
|
let dead_dir = root
|
||||||
|
.path()
|
||||||
|
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-0", u32::MAX));
|
||||||
|
std::fs::create_dir(&dead_dir).expect("dead dir should create");
|
||||||
|
|
||||||
|
let alive_dir = root
|
||||||
|
.path()
|
||||||
|
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-1", std::process::id()));
|
||||||
|
std::fs::create_dir(&alive_dir).expect("alive dir should create");
|
||||||
|
|
||||||
|
let unrelated_dir = root.path().join("unrelated-proxy-dir");
|
||||||
|
std::fs::create_dir(&unrelated_dir).expect("unrelated dir should create");
|
||||||
|
|
||||||
|
cleanup_stale_proxy_socket_dirs_in(root.path()).expect("stale cleanup should succeed");
|
||||||
|
|
||||||
|
assert_eq!(dead_dir.exists(), false);
|
||||||
|
assert_eq!(alive_dir.exists(), true);
|
||||||
|
assert_eq!(unrelated_dir.exists(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
|
||||||
|
use codex_core::config::types::ShellEnvironmentPolicy;
|
||||||
|
use codex_core::exec_env::create_env;
|
||||||
|
use codex_protocol::protocol::SandboxPolicy;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::process::Output;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
|
||||||
|
const NETWORK_TIMEOUT_MS: u64 = 4_000;
|
||||||
|
const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[
|
||||||
|
"loopback: Failed RTM_NEWADDR",
|
||||||
|
"loopback: Failed RTM_NEWLINK",
|
||||||
|
"setting up uid map: Permission denied",
|
||||||
|
"No permissions to create a new namespace",
|
||||||
|
"error isolating Linux network namespace for proxy mode",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROXY_ENV_KEYS: &[&str] = &[
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"FTP_PROXY",
|
||||||
|
"YARN_HTTP_PROXY",
|
||||||
|
"YARN_HTTPS_PROXY",
|
||||||
|
"NPM_CONFIG_HTTP_PROXY",
|
||||||
|
"NPM_CONFIG_HTTPS_PROXY",
|
||||||
|
"NPM_CONFIG_PROXY",
|
||||||
|
"BUNDLE_HTTP_PROXY",
|
||||||
|
"BUNDLE_HTTPS_PROXY",
|
||||||
|
"PIP_PROXY",
|
||||||
|
"DOCKER_HTTP_PROXY",
|
||||||
|
"DOCKER_HTTPS_PROXY",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||||
|
let policy = ShellEnvironmentPolicy::default();
|
||||||
|
create_env(&policy, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_proxy_env(env: &mut HashMap<String, String>) {
|
||||||
|
for key in PROXY_ENV_KEYS {
|
||||||
|
env.remove(*key);
|
||||||
|
let lower = key.to_ascii_lowercase();
|
||||||
|
env.remove(lower.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_bwrap_unavailable_output(output: &Output) -> bool {
|
||||||
|
String::from_utf8_lossy(&output.stderr).contains(BWRAP_UNAVAILABLE_ERR)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn should_skip_bwrap_tests() -> bool {
|
||||||
|
let mut env = create_env_from_core_vars();
|
||||||
|
strip_proxy_env(&mut env);
|
||||||
|
|
||||||
|
let output = run_linux_sandbox_direct(
|
||||||
|
&["bash", "-c", "true"],
|
||||||
|
&SandboxPolicy::new_read_only_policy(),
|
||||||
|
false,
|
||||||
|
env,
|
||||||
|
NETWORK_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
is_bwrap_unavailable_output(&output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_managed_proxy_permission_error(stderr: &str) -> bool {
|
||||||
|
MANAGED_PROXY_PERMISSION_ERR_SNIPPETS
|
||||||
|
.iter()
|
||||||
|
.any(|snippet| stderr.contains(snippet))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn managed_proxy_skip_reason() -> Option<String> {
|
||||||
|
if should_skip_bwrap_tests().await {
|
||||||
|
return Some("vendored bwrap was not built in this environment".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut env = create_env_from_core_vars();
|
||||||
|
strip_proxy_env(&mut env);
|
||||||
|
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
|
||||||
|
|
||||||
|
let output = run_linux_sandbox_direct(
|
||||||
|
&["bash", "-c", "true"],
|
||||||
|
&SandboxPolicy::DangerFullAccess,
|
||||||
|
true,
|
||||||
|
env,
|
||||||
|
NETWORK_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if is_managed_proxy_permission_error(stderr.as_ref()) {
|
||||||
|
return Some(format!(
|
||||||
|
"managed proxy requires kernel namespace privileges unavailable here: {}",
|
||||||
|
stderr.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_linux_sandbox_direct(
|
||||||
|
command: &[&str],
|
||||||
|
sandbox_policy: &SandboxPolicy,
|
||||||
|
allow_network_for_proxy: bool,
|
||||||
|
env: HashMap<String, String>,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Output {
|
||||||
|
let cwd = match std::env::current_dir() {
|
||||||
|
Ok(cwd) => cwd,
|
||||||
|
Err(err) => panic!("cwd should exist: {err}"),
|
||||||
|
};
|
||||||
|
let policy_json = match serde_json::to_string(sandbox_policy) {
|
||||||
|
Ok(policy_json) => policy_json,
|
||||||
|
Err(err) => panic!("policy should serialize: {err}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut args = vec![
|
||||||
|
"--sandbox-policy-cwd".to_string(),
|
||||||
|
cwd.to_string_lossy().to_string(),
|
||||||
|
"--sandbox-policy".to_string(),
|
||||||
|
policy_json,
|
||||||
|
"--use-bwrap-sandbox".to_string(),
|
||||||
|
];
|
||||||
|
if allow_network_for_proxy {
|
||||||
|
args.push("--allow-network-for-proxy".to_string());
|
||||||
|
}
|
||||||
|
args.push("--".to_string());
|
||||||
|
args.extend(command.iter().map(|entry| (*entry).to_string()));
|
||||||
|
|
||||||
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_codex-linux-sandbox"));
|
||||||
|
cmd.args(args)
|
||||||
|
.current_dir(cwd)
|
||||||
|
.env_clear()
|
||||||
|
.envs(env)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()).await {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(err) => panic!("sandbox command should not time out: {err}"),
|
||||||
|
};
|
||||||
|
match output {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(err) => panic!("sandbox command should execute: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn managed_proxy_mode_fails_closed_without_proxy_env() {
|
||||||
|
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||||
|
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut env = create_env_from_core_vars();
|
||||||
|
strip_proxy_env(&mut env);
|
||||||
|
|
||||||
|
let output = run_linux_sandbox_direct(
|
||||||
|
&["bash", "-c", "true"],
|
||||||
|
&SandboxPolicy::DangerFullAccess,
|
||||||
|
true,
|
||||||
|
env,
|
||||||
|
NETWORK_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(output.status.success(), false);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("managed proxy mode requires proxy environment variables"),
|
||||||
|
"expected fail-closed managed-proxy message, got stderr: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
|
||||||
|
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||||
|
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind proxy listener");
|
||||||
|
let proxy_port = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("proxy listener local addr")
|
||||||
|
.port();
|
||||||
|
let (request_tx, request_rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("accept proxy connection");
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||||
|
.expect("set read timeout");
|
||||||
|
let mut buf = [0_u8; 4096];
|
||||||
|
let read = stream.read(&mut buf).expect("read proxy request");
|
||||||
|
let request = String::from_utf8_lossy(&buf[..read]).to_string();
|
||||||
|
request_tx.send(request).expect("send proxy request");
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
|
||||||
|
.expect("write proxy response");
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut env = create_env_from_core_vars();
|
||||||
|
strip_proxy_env(&mut env);
|
||||||
|
env.insert(
|
||||||
|
"HTTP_PROXY".to_string(),
|
||||||
|
format!("http://127.0.0.1:{proxy_port}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let routed_output = run_linux_sandbox_direct(
|
||||||
|
&[
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"proxy=\"${HTTP_PROXY#*://}\"; host=\"${proxy%%:*}\"; port=\"${proxy##*:}\"; exec 3<>/dev/tcp/${host}/${port}; printf 'GET http://example.com/ HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n' >&3; IFS= read -r line <&3; printf '%s\\n' \"$line\"",
|
||||||
|
],
|
||||||
|
&SandboxPolicy::DangerFullAccess,
|
||||||
|
true,
|
||||||
|
env.clone(),
|
||||||
|
NETWORK_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routed_output.status.success(),
|
||||||
|
true,
|
||||||
|
"expected routed command to execute successfully; status={:?}; stdout={}; stderr={}",
|
||||||
|
routed_output.status.code(),
|
||||||
|
String::from_utf8_lossy(&routed_output.stdout),
|
||||||
|
String::from_utf8_lossy(&routed_output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8_lossy(&routed_output.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("HTTP/1.1 200 OK"),
|
||||||
|
"expected bridge-routed proxy response, got stdout: {stdout}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = request_rx
|
||||||
|
.recv_timeout(Duration::from_secs(3))
|
||||||
|
.expect("expected proxy request");
|
||||||
|
assert!(
|
||||||
|
request.contains("GET http://example.com/ HTTP/1.1"),
|
||||||
|
"expected HTTP proxy absolute-form request, got request: {request}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let direct_egress_output = run_linux_sandbox_direct(
|
||||||
|
&["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"],
|
||||||
|
&SandboxPolicy::DangerFullAccess,
|
||||||
|
true,
|
||||||
|
env,
|
||||||
|
NETWORK_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(direct_egress_output.status.success(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() {
|
||||||
|
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||||
|
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let python_available = Command::new("bash")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("command -v python3 >/dev/null")
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.expect("python3 probe should execute")
|
||||||
|
.success();
|
||||||
|
if !python_available {
|
||||||
|
eprintln!("skipping managed proxy AF_UNIX test: python3 is unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut env = create_env_from_core_vars();
|
||||||
|
strip_proxy_env(&mut env);
|
||||||
|
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
|
||||||
|
|
||||||
|
let output = run_linux_sandbox_direct(
|
||||||
|
&[
|
||||||
|
"python3",
|
||||||
|
"-c",
|
||||||
|
"import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n",
|
||||||
|
],
|
||||||
|
&SandboxPolicy::DangerFullAccess,
|
||||||
|
true,
|
||||||
|
env,
|
||||||
|
NETWORK_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(0),
|
||||||
|
"expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}",
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
// Aggregates all former standalone integration tests as modules.
|
// Aggregates all former standalone integration tests as modules.
|
||||||
mod landlock;
|
mod landlock;
|
||||||
|
mod managed_proxy;
|
||||||
Reference in new issue
Block a user