mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
Add opt-in transparent TCP routing for Linux sandboxes (#61372)
## Why Native TCP clients need to reach managed network destinations without implementing proxy support themselves. ## What changed - Add `--transparent-tcp` to the Linux sandbox command, using a bundled TCP stack, private TUN interface, and DNS stub to route native IPv4 and IPv6 connections through the attributed managed SOCKS proxy. - Preserve original hostnames for proxy policy checks and cancel pending approval connections when their native sockets close. - Expose `--linux-sandbox-transparent-tcp` and `--tcp-upstream-proxy` on `codex exec-server` for transparent routing and a mandatory HTTP CONNECT next hop without direct fallback. - Require private network and PID namespaces, private procfs, system `bwrap`, and access to `/dev/net/tun`; reject incompatible launcher options and untrusted proxy ports. ## Testing Add integration coverage for native hostname and IPv6 connections, SOCKS attribution, pending connection cancellation, policy denials, and filesystem and host-loopback isolation. Extend launcher and executor tests for option validation and managed-policy propagation. GitOrigin-RevId: 96bd7726e5369efceb65320face56aa85ae3deb8
This commit is contained in:
@@ -537,6 +537,10 @@ crate.annotation(
|
||||
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
managed_tcp_test_bwrap = use_repo_rule("//codex-rs/linux-sandbox:managed_tcp_test_bwrap.bzl", "managed_tcp_test_bwrap")
|
||||
|
||||
managed_tcp_test_bwrap(name = "managed_tcp_test_bubblewrap_noble_amd64")
|
||||
|
||||
# Cargo keeps vendored OpenSSL for musl; Bazel already supplies its own OpenSSL.
|
||||
# Replace this crate's generated build, not Cargo's feature resolution.
|
||||
http_archive(
|
||||
|
||||
Generated
+4
File diff suppressed because one or more lines are too long
Generated
+44
@@ -4055,6 +4055,7 @@ dependencies = [
|
||||
name = "codex-linux-sandbox"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"clap",
|
||||
"codex-core",
|
||||
"codex-install-context",
|
||||
@@ -4065,17 +4066,20 @@ dependencies = [
|
||||
"codex-uds",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"etherparse",
|
||||
"hickory-proto 0.26.3",
|
||||
"ignore",
|
||||
"indexmap 2.14.0",
|
||||
"landlock",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.3",
|
||||
"rustix 1.1.4",
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"smoltcp",
|
||||
"socks5-protocol",
|
||||
"tempfile",
|
||||
"test-case",
|
||||
@@ -7455,6 +7459,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "etherparse"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17304d06addb3283cdc4bd528e42dd95e73c8ee2d6492ffce415e93660885449"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.14"
|
||||
@@ -9120,6 +9133,16 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
|
||||
dependencies = [
|
||||
"hash32 0.3.1",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -10560,6 +10583,12 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||
|
||||
[[package]]
|
||||
name = "managed"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
@@ -14797,6 +14826,21 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"byteorder",
|
||||
"cfg-if",
|
||||
"defmt 1.1.1",
|
||||
"heapless 0.9.3",
|
||||
"libc",
|
||||
"managed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
|
||||
@@ -101,6 +101,7 @@ pub async fn run_command_under_landlock(
|
||||
loader_overrides: LoaderOverrides,
|
||||
) -> anyhow::Result<()> {
|
||||
let LandlockCommand {
|
||||
transparent_tcp,
|
||||
sandbox_state,
|
||||
permissions_profile,
|
||||
config_profile: _,
|
||||
@@ -124,7 +125,7 @@ pub async fn run_command_under_landlock(
|
||||
command,
|
||||
config_overrides,
|
||||
codex_linux_sandbox_exe,
|
||||
SandboxType::Landlock,
|
||||
SandboxType::Landlock { transparent_tcp },
|
||||
/*log_denials*/ false,
|
||||
&[],
|
||||
)
|
||||
@@ -170,7 +171,9 @@ pub async fn run_command_under_windows_sandbox(
|
||||
enum SandboxType {
|
||||
#[cfg(target_os = "macos")]
|
||||
Seatbelt,
|
||||
Landlock,
|
||||
Landlock {
|
||||
transparent_tcp: bool,
|
||||
},
|
||||
Windows,
|
||||
}
|
||||
|
||||
@@ -304,12 +307,21 @@ async fn run_command_under_sandbox(
|
||||
config.effective_local_windows_sandbox_type()
|
||||
};
|
||||
|
||||
let transparent_tcp = matches!(
|
||||
sandbox_type,
|
||||
SandboxType::Landlock {
|
||||
transparent_tcp: true
|
||||
}
|
||||
);
|
||||
let sandbox = match permission_profile.enforcement() {
|
||||
SandboxEnforcement::Disabled | SandboxEnforcement::External if transparent_tcp => {
|
||||
anyhow::bail!("--transparent-tcp requires a managed Linux sandbox");
|
||||
}
|
||||
SandboxEnforcement::Disabled | SandboxEnforcement::External => SandboxBackend::None,
|
||||
SandboxEnforcement::Managed => match sandbox_type {
|
||||
#[cfg(target_os = "macos")]
|
||||
SandboxType::Seatbelt => SandboxBackend::MacosSeatbelt,
|
||||
SandboxType::Landlock => SandboxBackend::LinuxSeccomp,
|
||||
SandboxType::Landlock { .. } => SandboxBackend::LinuxSeccomp,
|
||||
#[cfg(windows)]
|
||||
SandboxType::Windows if windows_sandbox_type == WindowsMxc => WindowsMxc,
|
||||
#[cfg(windows)]
|
||||
@@ -386,6 +398,10 @@ async fn run_command_under_sandbox(
|
||||
let manager = SandboxManager::for_inherited_terminal(allow_unix_sockets);
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
let manager = SandboxManager::new();
|
||||
if transparent_tcp && network.is_none() {
|
||||
anyhow::bail!("--transparent-tcp requires a managed network proxy");
|
||||
}
|
||||
let manager = manager.with_linux_sandbox_transparent_tcp(transparent_tcp);
|
||||
let workspace_roots = sandbox_state.is_none().then(|| {
|
||||
config
|
||||
.permissions
|
||||
|
||||
@@ -109,6 +109,10 @@ fn parse_absolute_path(raw: &str) -> Result<AbsolutePathBuf, String> {
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct LandlockCommand {
|
||||
/// Route outbound TCP through the managed SOCKS proxy.
|
||||
#[arg(long)]
|
||||
pub transparent_tcp: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
pub sandbox_state: SandboxStateArgs,
|
||||
|
||||
|
||||
+7
@@ -39,6 +39,13 @@ Options:
|
||||
|
||||
[default: isolate]
|
||||
|
||||
--linux-sandbox-transparent-tcp
|
||||
Route managed Linux sandbox TCP through the SOCKS proxy
|
||||
|
||||
--tcp-upstream-proxy <HOST:PORT>
|
||||
Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS
|
||||
listener
|
||||
|
||||
--proxy-private-ips-via-upstream
|
||||
Allow permitted private IP destinations to use the configured upstream
|
||||
proxy. If no valid upstream proxy applies to the request protocol,
|
||||
|
||||
+3
-1
@@ -187,7 +187,9 @@ confidence-threshold = 0.8
|
||||
exceptions = [
|
||||
# Each entry is the crate and version constraint, and its specific allow
|
||||
# list
|
||||
#{ allow = ["Zlib"], crate = "adler32" },
|
||||
# The bundled Linux TCP stack and its storage library are both 0BSD.
|
||||
{ allow = ["0BSD"], crate = "managed@0.8.0" },
|
||||
{ allow = ["0BSD"], crate = "smoltcp@0.14.0" },
|
||||
]
|
||||
|
||||
# Some crates don't have (easily) machine readable licensing information,
|
||||
|
||||
@@ -41,6 +41,14 @@ pub struct ExecServerCommand {
|
||||
#[arg(long, value_name = "MODE", default_value = "isolate", global = true)]
|
||||
linux_sandbox_pid_namespace: codex_sandboxing::LinuxSandboxPidNamespace,
|
||||
|
||||
/// Route managed Linux sandbox TCP through the SOCKS proxy.
|
||||
#[arg(long, global = true)]
|
||||
linux_sandbox_transparent_tcp: bool,
|
||||
|
||||
/// Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS listener.
|
||||
#[arg(long, value_name = "HOST:PORT", global = true)]
|
||||
tcp_upstream_proxy: Option<codex_exec_server::TcpUpstreamProxy>,
|
||||
|
||||
/// Allow permitted private IP destinations to use the configured upstream proxy.
|
||||
/// If no valid upstream proxy applies to the request protocol, connect directly.
|
||||
/// Loopback stays local. This flag does not require upstream routing.
|
||||
@@ -203,7 +211,9 @@ impl ExecServerCommand {
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?
|
||||
.with_linux_sandbox_pid_namespace(self.linux_sandbox_pid_namespace)
|
||||
.with_linux_sandbox_transparent_tcp(self.linux_sandbox_transparent_tcp)
|
||||
.with_proxy_private_ips_via_upstream(self.proxy_private_ips_via_upstream);
|
||||
runtime_paths.tcp_upstream_proxy = self.tcp_upstream_proxy.take();
|
||||
// Config loading below owns error handling (non-strict stdio can proceed
|
||||
// without config). Never retain other startup flags in the executor.
|
||||
if let Ok(overrides) = root_config_overrides.parse_overrides() {
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: exec-server-cli/src/exec_server_args_tests.rs
|
||||
assertion_line: 318
|
||||
assertion_line: 338
|
||||
expression: help
|
||||
---
|
||||
[EXPERIMENTAL] Run the standalone exec-server service
|
||||
@@ -33,6 +33,13 @@ Options:
|
||||
|
||||
[default: isolate]
|
||||
|
||||
--linux-sandbox-transparent-tcp
|
||||
Route managed Linux sandbox TCP through the SOCKS proxy
|
||||
|
||||
--tcp-upstream-proxy <HOST:PORT>
|
||||
Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS
|
||||
listener
|
||||
|
||||
--proxy-private-ips-via-upstream
|
||||
Allow permitted private IP destinations to use the configured upstream
|
||||
proxy. If no valid upstream proxy applies to the request protocol,
|
||||
|
||||
@@ -537,6 +537,19 @@ Destination policy still applies to every route. Enforcing mandatory upstream ro
|
||||
would require a separate fail-closed mode. A connection failure after selecting an
|
||||
upstream proxy is returned as an error, without retrying directly.
|
||||
|
||||
### Transparent TCP
|
||||
|
||||
```sh
|
||||
codex exec-server --linux-sandbox-transparent-tcp --tcp-upstream-proxy proxy:8088
|
||||
```
|
||||
|
||||
The executor chooses the mandatory plaintext HTTP CONNECT next hop for opaque SOCKS traffic.
|
||||
HTTP clients retain their existing proxy configuration. A failed CONNECT never retries directly.
|
||||
The upstream resolves hostnames; named tunnels are refused if local binding is disabled or
|
||||
an IP deny/review rule requires local resolution. Private IP literals additionally require
|
||||
`--proxy-private-ips-via-upstream`; loopback, link-local and the upstream itself are rejected.
|
||||
The upstream must enforce policy on the address it dials. See the [Linux host requirements](../linux-sandbox/README.md#transparent-tcp).
|
||||
|
||||
## Example session
|
||||
|
||||
Initialize:
|
||||
|
||||
@@ -110,6 +110,7 @@ pub use codex_file_system::WalkOptions;
|
||||
pub use codex_file_system::WalkOutcome;
|
||||
pub use codex_file_system::WindowsSandboxSelection;
|
||||
pub use codex_file_system::WriteFileOptions;
|
||||
pub use codex_network_proxy::TcpUpstreamProxy;
|
||||
pub use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR;
|
||||
pub use environment::CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR;
|
||||
pub use environment::CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR;
|
||||
|
||||
@@ -135,7 +135,7 @@ pub(crate) async fn prepare_exec_request_with_telemetry(
|
||||
prepare_managed_network(
|
||||
params,
|
||||
credential_brokers,
|
||||
runtime_paths.is_some_and(|paths| paths.proxy_private_ips_via_upstream),
|
||||
runtime_paths,
|
||||
env,
|
||||
network_policy_decider,
|
||||
network_policy_audit_observer,
|
||||
@@ -182,7 +182,8 @@ pub(crate) async fn prepare_exec_request_with_telemetry(
|
||||
});
|
||||
runtime_readable_paths.extend(managed_mitm_ca_trust_bundle_path);
|
||||
let sandbox_manager = SandboxManager::new()
|
||||
.with_linux_sandbox_pid_namespace(runtime_paths.linux_sandbox_pid_namespace);
|
||||
.with_linux_sandbox_pid_namespace(runtime_paths.linux_sandbox_pid_namespace)
|
||||
.with_linux_sandbox_transparent_tcp(runtime_paths.linux_sandbox_transparent_tcp);
|
||||
#[cfg(target_os = "macos")]
|
||||
let sandbox_manager = sandbox_manager
|
||||
.with_allowed_symlinked_codex_home(runtime_paths.allowed_symlinked_codex_home.clone());
|
||||
@@ -304,7 +305,7 @@ pub(crate) async fn prepare_exec_request_with_telemetry(
|
||||
async fn prepare_managed_network(
|
||||
params: &ExecParams,
|
||||
credential_brokers: &RemoteCredentialBrokerCache,
|
||||
proxy_private_ips_via_upstream: bool,
|
||||
runtime_paths: Option<&ExecServerRuntimeOptions>,
|
||||
env: HashMap<String, String>,
|
||||
network_policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
|
||||
network_policy_audit_observer: Option<NetworkPolicyAuditObserver>,
|
||||
@@ -318,8 +319,18 @@ async fn prepare_managed_network(
|
||||
),
|
||||
JSONRPCErrorError,
|
||||
> {
|
||||
let without_executor_proxy = |env| {
|
||||
if (params.enforce_managed_network || params.managed_network.is_some())
|
||||
&& runtime_paths.is_some_and(|paths| paths.tcp_upstream_proxy.is_some())
|
||||
{
|
||||
return Err(invalid_params(
|
||||
"a mandatory TCP upstream requires an executor-local managed proxy".to_string(),
|
||||
));
|
||||
}
|
||||
Ok((env, params.managed_network.clone(), None, None))
|
||||
};
|
||||
let Some(launch) = ¶ms.network_proxy else {
|
||||
return Ok((env, params.managed_network.clone(), None, None));
|
||||
return without_executor_proxy(env);
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let routing = match params
|
||||
@@ -331,7 +342,7 @@ async fn prepare_managed_network(
|
||||
// Native Windows commands have no sandbox token SID for shared ingress.
|
||||
let mut native_env = env;
|
||||
codex_network_proxy::strip_managed_proxy_env(&mut native_env);
|
||||
return Ok((native_env, params.managed_network.clone(), None, None));
|
||||
return without_executor_proxy(native_env);
|
||||
}
|
||||
Some(WindowsSandboxSelection::Mxc) => ManagedProxyRouting::DedicatedListeners,
|
||||
Some(
|
||||
@@ -358,7 +369,12 @@ async fn prepare_managed_network(
|
||||
))
|
||||
})?
|
||||
.credential_broker;
|
||||
state.set_proxy_private_ips_via_upstream(proxy_private_ips_via_upstream);
|
||||
if let Some(paths) = runtime_paths {
|
||||
state.set_proxy_private_ips_via_upstream(paths.proxy_private_ips_via_upstream);
|
||||
if let Some(proxy) = &paths.tcp_upstream_proxy {
|
||||
state.set_tcp_upstream_proxy(proxy.clone());
|
||||
}
|
||||
}
|
||||
if let Some(observer) = network_policy_audit_observer {
|
||||
state.set_policy_audit_observer(observer);
|
||||
}
|
||||
|
||||
@@ -500,6 +500,26 @@ async fn native_request_preserves_native_launch_fields() {
|
||||
assert_eq!(prepared.cwd, cwd);
|
||||
assert_eq!(prepared.env, env);
|
||||
assert_eq!(prepared.arg0, params.arg0);
|
||||
|
||||
let mut legacy = params;
|
||||
legacy.enforce_managed_network = true;
|
||||
let mut runtime = ExecServerRuntimeOptions::new(
|
||||
std::env::current_exe().unwrap(),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
runtime.tcp_upstream_proxy = Some("127.0.0.1:8088".parse().unwrap());
|
||||
let error = prepare_exec_request(
|
||||
&legacy,
|
||||
env,
|
||||
Some(&runtime),
|
||||
/*network_policy_decider*/ None,
|
||||
/*network_policy_audit_observer*/ None,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("legacy enforcement must require the mandatory upstream");
|
||||
assert!(error.message.contains("executor-local managed proxy"));
|
||||
}
|
||||
|
||||
#[test_case(false, None, &["-c", "true"]; "unmasked")]
|
||||
|
||||
@@ -15,8 +15,12 @@ pub struct ExecServerRuntimeOptions {
|
||||
pub codex_linux_sandbox_exe: Option<AbsolutePathBuf>,
|
||||
/// Trusted startup policy; requests and repository config cannot change PID isolation.
|
||||
pub linux_sandbox_pid_namespace: LinuxSandboxPidNamespace,
|
||||
/// Enable transparent TCP for managed Linux command launches.
|
||||
pub linux_sandbox_transparent_tcp: bool,
|
||||
/// Trusted startup routing; request policy still controls destination access.
|
||||
pub proxy_private_ips_via_upstream: bool,
|
||||
/// Mandatory next hop for opaque SOCKS TCP when set by provisioning.
|
||||
pub tcp_upstream_proxy: Option<codex_network_proxy::TcpUpstreamProxy>,
|
||||
/// Only the sandbox preference from CLI flags, never other startup config or secrets.
|
||||
pub prefer_mxc: Option<bool>,
|
||||
/// User-config opt-out of writable-root symlink checks beneath this host's home.
|
||||
@@ -44,7 +48,9 @@ impl ExecServerRuntimeOptions {
|
||||
) -> std::io::Result<Self> {
|
||||
Ok(Self {
|
||||
linux_sandbox_pid_namespace: LinuxSandboxPidNamespace::default(),
|
||||
linux_sandbox_transparent_tcp: false,
|
||||
proxy_private_ips_via_upstream: false,
|
||||
tcp_upstream_proxy: None,
|
||||
prefer_mxc: None,
|
||||
codex_self_exe: absolute_path(codex_self_exe)?,
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.map(absolute_path).transpose()?,
|
||||
@@ -59,6 +65,11 @@ impl ExecServerRuntimeOptions {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_linux_sandbox_transparent_tcp(mut self, enabled: bool) -> Self {
|
||||
self.linux_sandbox_transparent_tcp = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Routes permitted private IPs through the executor's configured upstream proxy.
|
||||
pub fn with_proxy_private_ips_via_upstream(mut self, enabled: bool) -> Self {
|
||||
self.proxy_private_ips_via_upstream = enabled;
|
||||
|
||||
@@ -10,4 +10,22 @@ codex_rust_crate(
|
||||
"@platforms//os:linux": ["//codex-rs/bwrap:bwrap-sha256-env"],
|
||||
"//conditions:default": [],
|
||||
}),
|
||||
test_data_extra = [":managed-tcp-test-data"],
|
||||
)
|
||||
|
||||
config_setting(
|
||||
name = "linux-x86_64",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:x86_64",
|
||||
"@platforms//os:linux",
|
||||
],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "managed-tcp-test-data",
|
||||
testonly = True,
|
||||
srcs = select({
|
||||
":linux-x86_64": ["@managed_tcp_test_bubblewrap_noble_amd64//:usr/bin/bwrap"],
|
||||
"//conditions:default": [],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ doctest = false
|
||||
workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
async-lock = "3"
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
@@ -25,24 +26,26 @@ codex-protocol = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-uds = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
etherparse = { version = "=0.21.0", default-features = false }
|
||||
hickory-proto = "=0.26.3"
|
||||
ignore = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
landlock = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
rustix = { workspace = true }
|
||||
rustix = { workspace = true, features = ["process", "thread"] }
|
||||
seccompiler = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
smoltcp = { version = "=0.14.0", default-features = false, features = ["std", "phy-tuntap_interface", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp"] }
|
||||
socks5-protocol = { version = "=0.3.5", features = ["sync"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
codex-core = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
hickory-proto = "=0.26.3"
|
||||
pretty_assertions = { workspace = true }
|
||||
socks5-protocol = { version = "=0.3.5", features = ["sync"] }
|
||||
tempfile = { workspace = true }
|
||||
test-case = "3.3.1"
|
||||
tokio = { workspace = true, features = [
|
||||
@@ -51,4 +54,5 @@ tokio = { workspace = true, features = [
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"sync",
|
||||
] }
|
||||
|
||||
@@ -13,7 +13,29 @@ seccomp filters; it also retains the legacy Landlock filesystem implementation.
|
||||
`src/linux_run_main.rs` coordinates these stages through `LinuxSandboxCommand`.
|
||||
End-to-end coverage lives in `tests/suite/sandbox.rs` and the other suite modules.
|
||||
|
||||
On Linux, Codex prefers the first `bwrap` found on `PATH`
|
||||
### Transparent TCP
|
||||
|
||||
```sh
|
||||
codex sandbox --transparent-tcp --permission-profile db -- psql -h pg.example
|
||||
```
|
||||
|
||||
The Rust sandbox helper includes the TCP stack. Codex starts it automatically.
|
||||
The Linux host needs distro `bwrap` on `PATH`, user and network namespaces, IPv6,
|
||||
and read/write access to `/dev/net/tun`.
|
||||
Use full managed SOCKS5 mode for native database ports and an isolated PID namespace.
|
||||
For executor-wide capture and HTTP CONNECT routing, see the [exec-server setup](../exec-server/README.md#transparent-tcp).
|
||||
|
||||
The private DNS stub maps up to 8,192 names to stable addresses in `198.18.0.0/15`
|
||||
and `fdc0:de00::/96`. The relay sends the original name and port to the attributed proxy;
|
||||
literal addresses, including entries from a hosts file, are checked as IPs. Unknown
|
||||
synthetic addresses are rejected. Loopback stays local. Each bridge permits 128 concurrent
|
||||
connections. When the TCP stack is full, clients retry their opening SYN. The relay identifies each
|
||||
native socket by both endpoints and its inode in the private namespace TCP tables. Closing that
|
||||
socket cancels its pending approval connection, including when other clients use the same
|
||||
destination. A client that shuts down only its write side can still receive a reply. General UDP,
|
||||
QUIC, ICMP, legacy Landlock, host PID inheritance, and procfs fallback are outside this mode.
|
||||
|
||||
With transparent TCP off, Codex prefers the first `bwrap` found on `PATH`
|
||||
outside the current working directory whenever it is available. If `bwrap` is
|
||||
present but too old to support
|
||||
`--argv0`, the helper keeps using system bubblewrap and switches to a
|
||||
@@ -100,7 +122,8 @@ commands that would enter the bubblewrap path.
|
||||
sockets unless `dangerously_allow_all_unix_sockets` is granted. Unix socket
|
||||
pairs remain allowed for communication between related processes.
|
||||
- When bubblewrap is active, it mounts a fresh `/proc` via `--proc /proc` by default.
|
||||
If that mount is denied, it retains the inherited `/proc` and still creates a
|
||||
Transparent TCP requires this mount and aborts the launch if it fails.
|
||||
In other modes, if that mount is denied, it retains the inherited `/proc` and still creates a
|
||||
PID namespace, preserving the existing fallback. `--no-proc` also retains the
|
||||
inherited `/proc` without disabling PID isolation. In these cases, process IDs
|
||||
inside the sandbox can differ from those exposed by `/proc`. Default invocations
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Expose the Ubuntu 24.04 bubblewrap executable as a Bazel test fixture."""
|
||||
|
||||
def _extract(ctx):
|
||||
ctx.download_and_extract(
|
||||
url = "https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_0.9.0-1ubuntu0.3_amd64.deb",
|
||||
sha256 = "2461f1beee9cb04c8942739fe1a2b37e7b7c2a3d518f0779dc75f9245baa3094",
|
||||
type = "deb",
|
||||
)
|
||||
ctx.extract("data.tar.zst")
|
||||
ctx.file("BUILD.bazel", 'exports_files(["usr/bin/bwrap"])')
|
||||
return ctx.repo_metadata(reproducible = True)
|
||||
|
||||
managed_tcp_test_bwrap = repository_rule(implementation = _extract)
|
||||
@@ -45,7 +45,7 @@ use ignore::overrides::OverrideBuilder;
|
||||
///
|
||||
/// These are intentionally system-level paths only (plus Nix store roots) so
|
||||
/// `include_platform_defaults` does not silently widen access to user data.
|
||||
const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
|
||||
pub(super) const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/usr",
|
||||
@@ -107,11 +107,13 @@ pub(crate) enum BwrapNetworkMode {
|
||||
/// Bubblewrap enforces this by unsharing the network namespace. The
|
||||
/// proxy-routing bridge is established by the helper process after startup.
|
||||
ProxyOnly,
|
||||
/// The transparent TCP setup already isolated this command's network namespace.
|
||||
PreparedProxy,
|
||||
}
|
||||
|
||||
impl BwrapNetworkMode {
|
||||
fn should_unshare_network(self) -> bool {
|
||||
!matches!(self, Self::FullAccess)
|
||||
matches!(self, Self::Isolated | Self::ProxyOnly)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ fn translate_legacy_bwrap_fd_mounts(argv: &mut Vec<String>) -> Result<(), String
|
||||
pub(crate) fn initialize_bwrap_launcher(
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
require_system: bool,
|
||||
) {
|
||||
LAUNCHER.get_or_init(|| {
|
||||
if let Some(path) = find_system_bwrap_in_path(file_system_policy, sandbox_policy_cwd)
|
||||
@@ -143,6 +144,9 @@ pub(crate) fn initialize_bwrap_launcher(
|
||||
return BubblewrapLauncher::System(launcher);
|
||||
}
|
||||
|
||||
if require_system {
|
||||
panic!("transparent TCP requires a supported distro bubblewrap on PATH");
|
||||
}
|
||||
match bundled_bwrap::launcher() {
|
||||
Some(launcher) => BubblewrapLauncher::Bundled(launcher),
|
||||
None => BubblewrapLauncher::Unavailable,
|
||||
|
||||
@@ -19,9 +19,9 @@ mod fd_mount;
|
||||
mod launcher;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_run_main;
|
||||
#[cfg(all(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
mod managed_tcp;
|
||||
#[cfg(all(target_os = "linux", test))]
|
||||
#[cfg(target_os = "linux")]
|
||||
mod managed_tcp_dns;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod proxy_lifecycle;
|
||||
@@ -30,6 +30,10 @@ mod proxy_routing;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod seccomp;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod tcp_namespace;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod tcp_net_setup;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wslg;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -22,8 +22,10 @@ use crate::bwrap::create_bwrap_command_args;
|
||||
use crate::launcher::exec_bwrap;
|
||||
use crate::launcher::initialize_bwrap_launcher;
|
||||
use crate::launcher::preferred_bwrap_supports_argv0;
|
||||
use crate::managed_tcp::Relay;
|
||||
use crate::proxy_routing::activate_proxy_routes_in_netns;
|
||||
use crate::proxy_routing::prepare_host_proxy_route_spec;
|
||||
use crate::seccomp::ProxyRouting;
|
||||
use crate::seccomp::apply_sandbox_policies_to_current_thread;
|
||||
use codex_network_proxy::ManagedNetworkSandboxContext;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
@@ -41,7 +43,6 @@ static PENDING_FORWARDED_SIGNAL: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
const FORWARDED_SIGNALS: &[libc::c_int] =
|
||||
&[libc::SIGHUP, libc::SIGINT, libc::SIGQUIT, libc::SIGTERM];
|
||||
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
|
||||
const SYNTHETIC_MOUNT_MARKER_SYNTHETIC: &[u8] = b"synthetic\n";
|
||||
const SYNTHETIC_MOUNT_MARKER_EXISTING: &[u8] = b"existing\n";
|
||||
|
||||
@@ -105,6 +106,14 @@ pub struct LinuxSandboxCommand {
|
||||
#[arg(long = "proxy-route-spec", hide = true)]
|
||||
pub proxy_route_spec: Option<String>,
|
||||
|
||||
/// Transparently route outbound TCP through the managed SOCKS proxy.
|
||||
#[arg(long)]
|
||||
pub transparent_tcp: bool,
|
||||
|
||||
/// Private TUN and resolver handoff from the trusted outer helper.
|
||||
#[arg(long, hide = true, value_delimiter = ',')]
|
||||
transparent_tcp_fd: Vec<std::os::fd::RawFd>,
|
||||
|
||||
/// Inherited fallback mounts that must be authenticated before sandboxed code runs.
|
||||
#[arg(long = "verify-fd-mount", hide = true)]
|
||||
pub verify_fd_mounts: Vec<String>,
|
||||
@@ -141,12 +150,27 @@ pub fn run_main() -> ! {
|
||||
apply_seccomp_then_exec,
|
||||
managed_network,
|
||||
proxy_route_spec,
|
||||
transparent_tcp,
|
||||
transparent_tcp_fd,
|
||||
verify_fd_mounts,
|
||||
no_proc,
|
||||
inherit_pid_namespace,
|
||||
command,
|
||||
} = LinuxSandboxCommand::parse();
|
||||
let allow_network_for_proxy = managed_network.is_some();
|
||||
let routing = if transparent_tcp {
|
||||
ProxyRouting::TransparentTcp
|
||||
} else {
|
||||
ProxyRouting::Explicit
|
||||
};
|
||||
if transparent_tcp
|
||||
&& (!allow_network_for_proxy || use_legacy_landlock || inherit_pid_namespace || no_proc)
|
||||
{
|
||||
panic!("transparent TCP requires managed networking and private bubblewrap namespaces");
|
||||
}
|
||||
if transparent_tcp_fd.is_empty() == (transparent_tcp && apply_seccomp_then_exec) {
|
||||
panic!("invalid transparent TCP namespace handoff");
|
||||
}
|
||||
|
||||
if command.is_empty() {
|
||||
panic!("No command specified to execute.");
|
||||
@@ -169,50 +193,38 @@ pub fn run_main() -> ! {
|
||||
panic!("failed to verify descriptor-backed bubblewrap mount: {err}");
|
||||
}
|
||||
|
||||
let mut capability_header = [LINUX_CAPABILITY_VERSION_3, 0];
|
||||
let mut capability_sets = [[0_u32; 3]; 2];
|
||||
// SAFETY: capability ABI version 3 uses a [version, pid] header and
|
||||
// two [effective, permitted, inheritable] capability-set entries.
|
||||
let result = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_capget,
|
||||
capability_header.as_mut_ptr(),
|
||||
capability_sets.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
if result < 0 {
|
||||
panic!(
|
||||
"failed to verify Linux sandbox capabilities: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
if capability_sets
|
||||
.into_iter()
|
||||
.any(|[effective, permitted, _]| effective != 0 || permitted != 0)
|
||||
{
|
||||
let caps = rustix::thread::capabilities(None)
|
||||
.unwrap_or_else(|err| panic!("failed to verify Linux sandbox capabilities: {err}"));
|
||||
if !caps.effective.is_empty() || !caps.permitted.is_empty() {
|
||||
panic!("Linux sandbox retained effective or permitted capabilities");
|
||||
}
|
||||
|
||||
if allow_network_for_proxy {
|
||||
let socks_bridge = 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;
|
||||
activate_proxy_routes_in_netns(spec)
|
||||
.unwrap_or_else(|err| panic!("error activating Linux proxy routing bridge: {err}"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = apply_sandbox_policies_to_current_thread(
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
&sandbox_policy_cwd,
|
||||
/*apply_landlock_fs*/ false,
|
||||
managed_network.as_ref(),
|
||||
proxy_routing_active,
|
||||
allow_network_for_proxy.then_some(routing),
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
|
||||
// Keep the TUN and resolver descriptors in the reaper. Start networking
|
||||
// threads only after the single-threaded workload fork.
|
||||
let transport = transparent_tcp.then(|| {
|
||||
Relay::prepare(transparent_tcp_fd, socks_bridge)
|
||||
.unwrap_or_else(|err| panic!("failed to prepare transparent TCP: {err}"))
|
||||
});
|
||||
let signal_mask = ForwardedSignalMask::block();
|
||||
let command_pid = unsafe { libc::fork() };
|
||||
if command_pid < 0 {
|
||||
@@ -221,12 +233,24 @@ pub fn run_main() -> ! {
|
||||
}
|
||||
|
||||
if command_pid == 0 {
|
||||
if let Some((relay, mut reader)) = transport {
|
||||
drop(relay);
|
||||
reader
|
||||
.read_exact(&mut [0])
|
||||
.unwrap_or_else(|err| panic!("transparent TCP relay failed to start: {err}"));
|
||||
}
|
||||
reset_forwarded_signal_handlers_to_default();
|
||||
signal_mask.restore();
|
||||
exec_or_panic(command);
|
||||
}
|
||||
|
||||
let signal_forwarders = install_bwrap_signal_forwarders(command_pid);
|
||||
if let Some((relay, reader)) = transport {
|
||||
drop(reader);
|
||||
relay
|
||||
.serve()
|
||||
.unwrap_or_else(|err| panic!("failed to start transparent TCP relay: {err}"));
|
||||
}
|
||||
signal_mask.restore();
|
||||
loop {
|
||||
let mut status = 0;
|
||||
@@ -255,7 +279,7 @@ pub fn run_main() -> ! {
|
||||
&sandbox_policy_cwd,
|
||||
/*apply_landlock_fs*/ false,
|
||||
managed_network.as_ref(),
|
||||
/*proxy_routing_active*/ false,
|
||||
/*proxy_routing*/ None,
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
@@ -267,10 +291,11 @@ pub fn run_main() -> ! {
|
||||
// sandboxed environment to apply seccomp. This path never falls back
|
||||
// to legacy Landlock on failure.
|
||||
let (proxy_route_spec, proxy_controls) = if let Some(context) = managed_network.as_ref() {
|
||||
// Older explicit-proxy callers may omit the port list.
|
||||
let ports =
|
||||
(!context.loopback_ports.is_empty()).then_some(context.loopback_ports.as_slice());
|
||||
let (proxy_route_spec, controls) = prepare_host_proxy_route_spec(ports)
|
||||
// Older explicit-proxy callers may omit ports. The new transparent
|
||||
// switch always requires the executor's trusted ingress port list.
|
||||
let ports = (transparent_tcp || !context.loopback_ports.is_empty())
|
||||
.then_some(context.loopback_ports.as_slice());
|
||||
let (proxy_route_spec, controls) = prepare_host_proxy_route_spec(ports, routing)
|
||||
.unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}"));
|
||||
(Some(proxy_route_spec), controls)
|
||||
} else {
|
||||
@@ -317,6 +342,7 @@ pub fn run_main() -> ! {
|
||||
options,
|
||||
inner,
|
||||
proxy_controls,
|
||||
transparent_tcp,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,7 +353,7 @@ pub fn run_main() -> ! {
|
||||
&sandbox_policy_cwd,
|
||||
/*apply_landlock_fs*/ true,
|
||||
managed_network.as_ref(),
|
||||
/*proxy_routing_active*/ false,
|
||||
/*proxy_routing*/ None,
|
||||
) {
|
||||
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
@@ -399,13 +425,21 @@ fn run_bwrap_with_proc_fallback(
|
||||
mut options: BwrapOptions,
|
||||
inner: Vec<String>,
|
||||
proxy_controls: Vec<File>,
|
||||
transparent_tcp: bool,
|
||||
) -> ! {
|
||||
let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd);
|
||||
|
||||
// Select before the preflight, whose synthetic policy omits writable roots.
|
||||
initialize_bwrap_launcher(file_system_sandbox_policy, sandbox_policy_cwd);
|
||||
initialize_bwrap_launcher(
|
||||
file_system_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
transparent_tcp,
|
||||
);
|
||||
|
||||
if options.mount_proc
|
||||
// Transparent TCP must mount procfs for the private PID namespace. Let the
|
||||
// real bwrap launch fail if it cannot, since a root bind can expose host procfs.
|
||||
if !transparent_tcp
|
||||
&& options.mount_proc
|
||||
&& !preflight_proc_mount_support(options)
|
||||
.unwrap_or_else(|err| exit_with_bwrap_build_error(err))
|
||||
{
|
||||
@@ -414,6 +448,12 @@ fn run_bwrap_with_proc_fallback(
|
||||
options.mount_proc = false;
|
||||
}
|
||||
|
||||
let tcp_files = transparent_tcp.then(|| {
|
||||
let files = crate::tcp_namespace::create()
|
||||
.unwrap_or_else(|err| panic!("failed to create private TCP namespace: {err}"));
|
||||
options.network_mode = BwrapNetworkMode::PreparedProxy;
|
||||
files
|
||||
});
|
||||
let mut bwrap_args = build_bwrap_argv(
|
||||
inner,
|
||||
file_system_sandbox_policy,
|
||||
@@ -422,6 +462,15 @@ fn run_bwrap_with_proc_fallback(
|
||||
options,
|
||||
)
|
||||
.unwrap_or_else(|err| exit_with_bwrap_build_error(err));
|
||||
if let Some(files) = tcp_files {
|
||||
crate::tcp_net_setup::prepare(
|
||||
files,
|
||||
&mut bwrap_args,
|
||||
file_system_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("failed to prepare transparent TCP namespace: {err}"));
|
||||
}
|
||||
bwrap_args.preserved_files.extend(proxy_controls);
|
||||
apply_inner_command_argv0(&mut bwrap_args.args);
|
||||
run_or_exec_bwrap(bwrap_args);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
//! Translate a TUN worker’s TCP destination to the original hostname or literal IP
|
||||
//! for the managed SOCKS5 bridge.
|
||||
|
||||
//! Run the bundled TCP stack in the command's private network namespace.
|
||||
use crate::managed_tcp_dns::Names;
|
||||
use crate::proxy_routing::proxy_bidirectional;
|
||||
use rustix::net::sockopt;
|
||||
use socks5_protocol::Address;
|
||||
use socks5_protocol::AuthMethod;
|
||||
use socks5_protocol::AuthRequest;
|
||||
use socks5_protocol::AuthResponse;
|
||||
use socks5_protocol::Command as SocksCommand;
|
||||
use socks5_protocol::CommandReply;
|
||||
use socks5_protocol::CommandRequest;
|
||||
use socks5_protocol::CommandResponse;
|
||||
@@ -16,53 +13,102 @@ use socks5_protocol::sync::FromIO;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::net::UdpSocket;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[path = "managed_tcp_pending.rs"]
|
||||
mod pending;
|
||||
#[path = "managed_tcp_stack.rs"]
|
||||
mod stack;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn relay(
|
||||
mut client: TcpStream,
|
||||
pub(super) struct Relay {
|
||||
tun: OwnedFd,
|
||||
ready: std::io::PipeWriter,
|
||||
proxy: SocketAddr,
|
||||
names: &Mutex<Names>,
|
||||
) -> socks5_protocol::Result<()> {
|
||||
client.set_read_timeout(Some(TIMEOUT))?;
|
||||
client.set_write_timeout(Some(TIMEOUT))?;
|
||||
Version::read_from(&mut client)?;
|
||||
let offered = AuthRequest::read_from(&mut client)?;
|
||||
if !offered.0.contains(&AuthMethod::Noauth) {
|
||||
return Err(io::Error::other("unsupported SOCKS greeting").into());
|
||||
}
|
||||
Version::V5.write_to(&mut client)?;
|
||||
AuthResponse::new(AuthMethod::Noauth).write_to(&mut client)?;
|
||||
dns_udp: UdpSocket,
|
||||
dns_tcp: TcpListener,
|
||||
}
|
||||
|
||||
let upstream = match connect(&mut client, proxy, names) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
let denied = CommandResponse::reply_error(CommandReply::ConnectionNotAllowedByRuleset);
|
||||
let _ = write_frame(&mut client, &denied);
|
||||
return Err(err);
|
||||
impl Relay {
|
||||
pub(super) fn prepare(
|
||||
fds: Vec<std::os::fd::RawFd>,
|
||||
proxy: Option<SocketAddr>,
|
||||
) -> io::Result<(Self, std::io::PipeReader)> {
|
||||
let proxy =
|
||||
proxy.ok_or_else(|| io::Error::other("transparent TCP requires managed SOCKS5"))?;
|
||||
let [tun, udp, tcp] = crate::tcp_net_setup::receive(fds)?;
|
||||
let (reader, ready) = std::io::pipe()?;
|
||||
Ok((
|
||||
Self {
|
||||
tun,
|
||||
ready,
|
||||
proxy,
|
||||
dns_udp: udp.into(),
|
||||
dns_tcp: tcp.into(),
|
||||
},
|
||||
reader,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn serve(mut self) -> io::Result<()> {
|
||||
let names = Arc::new(Mutex::new(Names::default()));
|
||||
crate::managed_tcp_dns::serve(Arc::clone(&names), self.dns_udp, self.dns_tcp)?;
|
||||
let (started, readiness) = std::sync::mpsc::sync_channel(1);
|
||||
thread::Builder::new().spawn(move || {
|
||||
// This is the namespace reaper. Losing the data path must end the workload.
|
||||
if let Ok(Err(error)) =
|
||||
std::panic::catch_unwind(|| stack::run(self.tun, self.proxy, names, started))
|
||||
{
|
||||
eprintln!("transparent TCP stack exited: {error}");
|
||||
}
|
||||
std::process::exit(1);
|
||||
})?;
|
||||
|
||||
// The workload is blocked on the pipe. Complete a native handshake through
|
||||
// the TUN and check that its socket identity is visible before releasing it.
|
||||
// This reserved address is never issued by DNS or forwarded upstream.
|
||||
let probe_target = SocketAddr::from(([198, 18, 0, 0], 9));
|
||||
let probe = TcpStream::connect_timeout(&probe_target, TIMEOUT)?;
|
||||
let source = readiness
|
||||
.recv_timeout(TIMEOUT)
|
||||
.map_err(io::Error::other)??;
|
||||
if source != probe.local_addr()? {
|
||||
return Err(io::Error::other("unexpected readiness source"));
|
||||
}
|
||||
};
|
||||
write_frame(&mut client, &CommandResponse::success(Address::default()))?;
|
||||
client.set_read_timeout(None)?;
|
||||
client.set_write_timeout(None)?;
|
||||
upstream.set_read_timeout(None)?;
|
||||
upstream.set_write_timeout(None)?;
|
||||
Ok(proxy_bidirectional(upstream, client)?)
|
||||
self.ready.write_all(&[1])
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn accept_with_backoff(listener: &TcpListener) -> io::Result<TcpStream> {
|
||||
loop {
|
||||
let error = match listener.accept() {
|
||||
Ok((stream, _)) => return Ok(stream),
|
||||
Err(error) => error,
|
||||
};
|
||||
match error.raw_os_error() {
|
||||
Some(libc::EINTR | libc::ECONNABORTED) => continue,
|
||||
Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM) => {
|
||||
thread::sleep(Duration::from_millis(100))
|
||||
}
|
||||
_ => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(
|
||||
client: &mut impl io::Read,
|
||||
source: SocketAddr,
|
||||
target: SocketAddr,
|
||||
proxy: SocketAddr,
|
||||
names: &Mutex<Names>,
|
||||
) -> socks5_protocol::Result<TcpStream> {
|
||||
let request = CommandRequest::read_from(client)?;
|
||||
let (SocksCommand::Connect, Address::SocketAddr(target)) = (request.command, request.address)
|
||||
else {
|
||||
return Err(io::Error::other("expected TCP CONNECT to a TUN destination IP").into());
|
||||
};
|
||||
let destination = match names
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("DNS name table is unavailable"))?
|
||||
@@ -72,6 +118,7 @@ fn connect(
|
||||
None => Address::SocketAddr(target),
|
||||
};
|
||||
|
||||
let owner = pending::Owner::capture(source, target)?;
|
||||
let mut upstream = TcpStream::connect_timeout(&proxy, TIMEOUT)?;
|
||||
upstream.set_read_timeout(Some(TIMEOUT))?;
|
||||
upstream.set_write_timeout(Some(TIMEOUT))?;
|
||||
@@ -84,18 +131,23 @@ fn connect(
|
||||
write_frame(&mut upstream, &CommandRequest::connect(destination))?;
|
||||
// The trusted proxy owns the approval deadline. The relay's handshake
|
||||
// timeout applies to framing and negotiation, not the human decision.
|
||||
upstream.set_read_timeout(None)?;
|
||||
if let Err(error) = owner.wait_for_reply(&upstream) {
|
||||
// An abort wakes both copies in the host bridge while approval is outstanding.
|
||||
let _ = sockopt::set_socket_linger(&upstream, Some(Duration::ZERO));
|
||||
return Err(error.into());
|
||||
}
|
||||
upstream.set_read_timeout(Some(TIMEOUT))?;
|
||||
let reply = CommandResponse::read_from(&mut upstream)?;
|
||||
if reply.reply != CommandReply::Succeeded
|
||||
|| matches!(reply.address, Address::Domain(ref name, _) if name.is_empty())
|
||||
{
|
||||
return Err(io::Error::other("invalid managed SOCKS5 success reply").into());
|
||||
}
|
||||
upstream.set_nonblocking(true)?;
|
||||
Ok(upstream)
|
||||
}
|
||||
|
||||
// Buffer the codec's field-by-field output before calling write_all. A denial
|
||||
// reply is best effort; the authorization decision does not depend on its delivery.
|
||||
// Emit the codec's field-by-field CONNECT frame in one write.
|
||||
fn write_frame(stream: &mut TcpStream, message: &impl FromIO) -> socks5_protocol::Result<()> {
|
||||
let mut bytes = Vec::new();
|
||||
message.write_to(&mut bytes)?;
|
||||
|
||||
@@ -16,9 +16,18 @@ use hickory_proto::serialize::binary::BinDecoder;
|
||||
use hickory_proto::serialize::binary::BinEncodable;
|
||||
use indexmap::IndexSet;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_NAMES: usize = 8192;
|
||||
const V4_BASE: u32 = u32::from_be_bytes([198, 18, 0, 0]);
|
||||
@@ -112,6 +121,57 @@ impl Names {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn serve(names: Arc<Mutex<Names>>, udp: UdpSocket, tcp: TcpListener) -> io::Result<()> {
|
||||
let udp_names = Arc::clone(&names);
|
||||
thread::Builder::new().spawn(move || {
|
||||
let mut buf = [0; 4096];
|
||||
while let Ok((n, peer)) = udp.recv_from(&mut buf) {
|
||||
let reply = udp_names
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut table| table.answer(&buf[..n]));
|
||||
if let Some(reply) = reply {
|
||||
let _ = udp.send_to(&reply, peer);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
// Fixed workers bound concurrent DNS-over-TCP exchanges. Some resolvers
|
||||
// pipeline A and AAAA queries on the same connection.
|
||||
for _ in 0..4 {
|
||||
let listener = tcp.try_clone()?;
|
||||
let names = Arc::clone(&names);
|
||||
thread::Builder::new().spawn(move || {
|
||||
while let Ok(stream) = crate::managed_tcp::accept_with_backoff(&listener) {
|
||||
let _ = answer_tcp(stream, &names);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn answer_tcp(mut stream: TcpStream, names: &Mutex<Names>) -> io::Result<()> {
|
||||
let timeout = Some(Duration::from_secs(2));
|
||||
stream.set_read_timeout(timeout)?;
|
||||
stream.set_write_timeout(timeout)?;
|
||||
let mut buf = [0; 4096];
|
||||
for _ in 0..32 {
|
||||
let mut size = [0; 2];
|
||||
stream.read_exact(&mut size)?;
|
||||
let query = buf
|
||||
.get_mut(..usize::from(u16::from_be_bytes(size)))
|
||||
.ok_or_else(|| io::Error::other("oversized DNS query"))?;
|
||||
stream.read_exact(query)?;
|
||||
let reply = names
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("DNS name table is unavailable"))?
|
||||
.answer(query)
|
||||
.ok_or_else(|| io::Error::other("invalid DNS query"))?;
|
||||
stream.write_all(&(reply.len() as u16).to_be_bytes())?;
|
||||
stream.write_all(&reply)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "managed_tcp_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Pin the native socket inode before asking for approval. Linux keeps the inode
|
||||
//! for a live reader after shutdown(SHUT_WR), and clears it on the last close.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
const INTERVAL: Duration = Duration::from_millis(500);
|
||||
const TCP4: &str = "/proc/self/net/tcp";
|
||||
const TCP6: &str = "/proc/self/net/tcp6";
|
||||
|
||||
pub(super) struct Owner {
|
||||
inode: u64,
|
||||
captured: Instant,
|
||||
table: &'static str,
|
||||
}
|
||||
|
||||
fn find_inode(
|
||||
table: &str,
|
||||
mut matches: impl FnMut(&str, &str, u64) -> bool,
|
||||
) -> io::Result<Option<u64>> {
|
||||
let mut reader = BufReader::new(File::open(table)?);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
if !line.contains("local_address") || !line.contains("inode") {
|
||||
return Err(io::Error::other("missing native TCP socket table"));
|
||||
}
|
||||
loop {
|
||||
line.clear();
|
||||
if reader.read_line(&mut line)? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut fields = line.split_ascii_whitespace().skip(1);
|
||||
let (Some(local), Some(remote), Some(inode)) =
|
||||
(fields.next(), fields.next(), fields.nth(6))
|
||||
else {
|
||||
return Err(io::Error::other("unexpected native TCP socket record"));
|
||||
};
|
||||
let inode = inode.parse::<u64>().map_err(io::Error::other)?;
|
||||
if inode != 0 && matches(local, remote, inode) {
|
||||
return Ok(Some(inode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn v6_key(addr: SocketAddr) -> String {
|
||||
let ip: Ipv6Addr = match addr.ip() {
|
||||
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
|
||||
IpAddr::V6(v6) => v6,
|
||||
};
|
||||
// procfs renders each 32-bit word in native byte order.
|
||||
let address: String = ip
|
||||
.octets()
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|word| format!("{:08X}", u32::from_ne_bytes(*word)))
|
||||
.collect();
|
||||
format!("{address}:{:04X}", addr.port())
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
pub(super) fn capture(source: SocketAddr, target: SocketAddr) -> io::Result<Self> {
|
||||
let lookup = |table, local: String, remote: String| {
|
||||
let inode = find_inode(table, |from, to, _| from == local && to == remote)?;
|
||||
Ok::<_, io::Error>(inode.map(|inode| Self {
|
||||
inode,
|
||||
table,
|
||||
captured: Instant::now(),
|
||||
}))
|
||||
};
|
||||
if let (SocketAddr::V4(src), SocketAddr::V4(dst)) = (source, target) {
|
||||
let key = |addr: std::net::SocketAddrV4| {
|
||||
format!(
|
||||
"{:08X}:{:04X}",
|
||||
u32::from_ne_bytes(addr.ip().octets()),
|
||||
addr.port()
|
||||
)
|
||||
};
|
||||
if let Some(owner) = lookup(TCP4, key(src), key(dst))? {
|
||||
return Ok(owner);
|
||||
}
|
||||
}
|
||||
lookup(TCP6, v6_key(source), v6_key(target))?.ok_or(io::ErrorKind::ConnectionAborted.into())
|
||||
}
|
||||
|
||||
fn still_owned(&self) -> io::Result<bool> {
|
||||
// Only slow approvals need polling. Share one bounded snapshot per family.
|
||||
type Snapshot = Mutex<Option<(Instant, Option<HashSet<u64>>)>>;
|
||||
static V4: Snapshot = Mutex::new(None);
|
||||
static V6: Snapshot = Mutex::new(None);
|
||||
let cache = if self.table == TCP4 { &V4 } else { &V6 };
|
||||
let mut cache = cache
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache
|
||||
.as_ref()
|
||||
.is_none_or(|(sampled, _)| *sampled < self.captured || sampled.elapsed() >= INTERVAL)
|
||||
{
|
||||
let sampled = Instant::now();
|
||||
let mut inodes = HashSet::new();
|
||||
let overflow = find_inode(self.table, |_, _, inode| {
|
||||
inodes.insert(inode);
|
||||
inodes.len() > 16_384
|
||||
})?;
|
||||
*cache = Some((sampled, overflow.is_none().then_some(inodes)));
|
||||
}
|
||||
if let Some((_, Some(inodes))) = &*cache {
|
||||
return Ok(inodes.contains(&self.inode));
|
||||
}
|
||||
drop(cache);
|
||||
Ok(find_inode(self.table, |_, _, inode| inode == self.inode)?.is_some())
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_reply(&self, stream: &TcpStream) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(INTERVAL))?;
|
||||
loop {
|
||||
match stream.peek(&mut [0]) {
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
) => {}
|
||||
result => return result.map(|_| ()),
|
||||
}
|
||||
if !self.still_owned()? {
|
||||
return Err(io::ErrorKind::ConnectionAborted.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,38 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Verify rejected TUN requests cannot open an upstream connection.
|
||||
//! A denied managed CONNECT never yields a stream for the TCP stack.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn invalid_tun_requests_never_open_upstream() {
|
||||
let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let addr = proxy.local_addr().unwrap();
|
||||
for request in [
|
||||
[5, 3, 0, 1, 127, 0, 0, 1, 0, 53],
|
||||
[5, 2, 0, 1, 127, 0, 0, 1, 0, 80],
|
||||
[5, 1, 1, 1, 127, 0, 0, 1, 0, 80],
|
||||
[5, 1, 0, 3, 3, b'd', b'b', b'x', 0, 80],
|
||||
[5, 1, 0, 1, 198, 18, 0, 99, 1, 187],
|
||||
] {
|
||||
assert!(connect(&mut request.as_slice(), addr, &Mutex::new(Names::default())).is_err());
|
||||
}
|
||||
proxy.set_nonblocking(true).unwrap();
|
||||
assert_eq!(
|
||||
proxy.accept().unwrap_err().kind(),
|
||||
io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_socks_connect_never_forwards_pipelined_payload() {
|
||||
fn denied_socks_connect_never_returns_an_upstream() {
|
||||
let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let proxy_addr = proxy.local_addr().unwrap();
|
||||
let native_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let target = native_listener.local_addr().unwrap();
|
||||
let native = TcpStream::connect(target).unwrap();
|
||||
let source = native.local_addr().unwrap();
|
||||
let task =
|
||||
thread::spawn(move || connect(source, target, proxy_addr, &Mutex::new(Names::default())));
|
||||
rustix::net::sockopt::set_socket_timeout(
|
||||
&proxy,
|
||||
rustix::net::sockopt::Timeout::Recv,
|
||||
Some(TIMEOUT),
|
||||
)
|
||||
.unwrap();
|
||||
let proxy_addr = proxy.local_addr().unwrap();
|
||||
let incoming = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let mut client = TcpStream::connect(incoming.local_addr().unwrap()).unwrap();
|
||||
let (server, _) = incoming.accept().unwrap();
|
||||
let task = thread::spawn(move || relay(server, proxy_addr, &Mutex::new(Names::default())));
|
||||
|
||||
// A malicious client can send application data before either handshake completes.
|
||||
let connect = [5, 1, 0, 1, 192, 0, 2, 7, 0x15, 0x38];
|
||||
client.write_all(&[5, 2, 2, 0]).unwrap();
|
||||
client.write_all(&connect).unwrap();
|
||||
client.write_all(b"private marker").unwrap();
|
||||
client.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
|
||||
let (mut upstream, _) = proxy.accept().unwrap();
|
||||
upstream.set_read_timeout(Some(TIMEOUT)).unwrap();
|
||||
let mut greeting = [0; 3];
|
||||
upstream.read_exact(&mut greeting).unwrap();
|
||||
assert_eq!(greeting, [5, 1, 0]);
|
||||
upstream.write_all(&[5, 0]).unwrap();
|
||||
let mut request = [0; 10];
|
||||
upstream.read_exact(&mut request).unwrap();
|
||||
assert_eq!(request, connect);
|
||||
let request = CommandRequest::read_from(&mut upstream).unwrap();
|
||||
assert!(matches!(request.address, Address::SocketAddr(address) if address == target));
|
||||
upstream.write_all(&[5, 2, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap();
|
||||
|
||||
let mut response = [0; 12]; // greeting and refusal sent to the client
|
||||
client.read_exact(&mut response).unwrap();
|
||||
assert_eq!(&response[..4], &[5, 0, 5, 2]);
|
||||
let mut byte = [0];
|
||||
assert_eq!(upstream.read(&mut byte).unwrap(), 0);
|
||||
assert_eq!(upstream.read(&mut [0]).unwrap(), 0);
|
||||
assert!(task.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Drive a bounded set of smoltcp sockets on the TUN inherited from namespace setup.
|
||||
//! The kernel still owns the native client sockets. Only the managed SOCKS bridge
|
||||
//! can supply an upstream stream, and only after it has approved the destination.
|
||||
use super::Names;
|
||||
use etherparse::IpNumber;
|
||||
use etherparse::IpSlice;
|
||||
use etherparse::Ipv6ExtensionSlice;
|
||||
use etherparse::TcpSlice;
|
||||
use smoltcp::iface::Config;
|
||||
use smoltcp::iface::Interface;
|
||||
use smoltcp::iface::PollResult;
|
||||
use smoltcp::iface::SocketHandle;
|
||||
use smoltcp::iface::SocketSet;
|
||||
use smoltcp::phy::Device;
|
||||
use smoltcp::phy::DeviceCapabilities;
|
||||
use smoltcp::phy::Medium;
|
||||
use smoltcp::phy::RxToken;
|
||||
use smoltcp::phy::TunTapInterface;
|
||||
use smoltcp::socket::tcp::Socket;
|
||||
use smoltcp::socket::tcp::SocketBuffer;
|
||||
use smoltcp::socket::tcp::State;
|
||||
use smoltcp::time::Instant;
|
||||
use smoltcp::wire::HardwareAddress;
|
||||
use smoltcp::wire::IpCidr;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::net::Shutdown;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpStream;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::fd::IntoRawFd;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
const MTU: usize = 1500;
|
||||
const MAX_FLOWS: usize = 128;
|
||||
const WINDOW: usize = 32 * 1024;
|
||||
|
||||
struct Packet<'a>(&'a [u8]);
|
||||
impl RxToken for Packet<'_> {
|
||||
fn consume<R, F: FnOnce(&[u8]) -> R>(self, f: F) -> R {
|
||||
f(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Give smoltcp exactly the frame admitted below. Its ordinary TUN device
|
||||
// owns transmission, including backpressure and MTU handling.
|
||||
struct Ingress<'a> {
|
||||
device: &'a mut TunTapInterface,
|
||||
packet: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
impl Device for Ingress<'_> {
|
||||
type RxToken<'a>
|
||||
= Packet<'a>
|
||||
where
|
||||
Self: 'a;
|
||||
type TxToken<'a>
|
||||
= <TunTapInterface as Device>::TxToken<'a>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn receive(&mut self, now: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
|
||||
Some((Packet(self.packet.take()?), self.device.transmit(now)?))
|
||||
}
|
||||
fn transmit(&mut self, now: Instant) -> Option<Self::TxToken<'_>> {
|
||||
self.device.transmit(now)
|
||||
}
|
||||
fn capabilities(&self) -> DeviceCapabilities {
|
||||
self.device.capabilities()
|
||||
}
|
||||
}
|
||||
|
||||
type Endpoints = (SocketAddr, SocketAddr);
|
||||
|
||||
// Allocate a listener only for an opening SYN. smoltcp validates the complete
|
||||
// packet, tracks sequence numbers, and owns retransmission and flow control.
|
||||
fn tcp_endpoints(packet: &[u8]) -> Option<(Endpoints, bool)> {
|
||||
let ip = IpSlice::from_slice(packet).ok()?;
|
||||
if ip.is_fragmenting_payload() || ip.payload_ip_number() != IpNumber::TCP {
|
||||
return None;
|
||||
}
|
||||
// Match the extensions supported by the stack before allocating a listener.
|
||||
let supported = match &ip {
|
||||
IpSlice::Ipv4(v4) => v4.extensions().auth.is_none(),
|
||||
IpSlice::Ipv6(v6) => v6.extensions().clone().into_iter().all(|ext| {
|
||||
matches!(ext, Ipv6ExtensionSlice::HopByHop(header) if header.next_header() == IpNumber::TCP)
|
||||
}),
|
||||
};
|
||||
if !supported {
|
||||
return None;
|
||||
}
|
||||
let tcp = TcpSlice::from_slice(ip.payload().payload).ok()?;
|
||||
let endpoints = (
|
||||
SocketAddr::new(ip.source_addr(), tcp.source_port()),
|
||||
SocketAddr::new(ip.destination_addr(), tcp.destination_port()),
|
||||
);
|
||||
Some((
|
||||
endpoints,
|
||||
tcp.syn() && !tcp.ack() && !tcp.rst() && !tcp.fin() && tcp.destination_port() != 0,
|
||||
))
|
||||
}
|
||||
|
||||
enum Session {
|
||||
Handshake,
|
||||
Pending(mpsc::Receiver<TcpStream>),
|
||||
Open {
|
||||
upstream: TcpStream,
|
||||
write_closed: bool,
|
||||
read_closed: bool,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn pump(&mut self, socket: &mut Socket<'_>, poll: &mut Vec<libc::pollfd>) -> io::Result<()> {
|
||||
if let Self::Pending(result) = self {
|
||||
match result.try_recv() {
|
||||
Ok(upstream) => {
|
||||
*self = Self::Open {
|
||||
upstream,
|
||||
write_closed: false,
|
||||
read_closed: false,
|
||||
}
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
return Err(io::ErrorKind::ConnectionAborted.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Self::Open {
|
||||
upstream,
|
||||
write_closed,
|
||||
read_closed,
|
||||
} = self
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if socket.can_recv() {
|
||||
socket
|
||||
.recv(|bytes| transfer(upstream.write(bytes)))
|
||||
.map_err(io::Error::other)??;
|
||||
}
|
||||
if !socket.may_recv() && !*write_closed {
|
||||
upstream.shutdown(Shutdown::Write)?;
|
||||
*write_closed = true;
|
||||
}
|
||||
if !*read_closed && socket.can_send() {
|
||||
let n = socket
|
||||
.send(|space| transfer(upstream.read(space)))
|
||||
.map_err(io::Error::other)??;
|
||||
if n == Some(0) {
|
||||
socket.close();
|
||||
*read_closed = true;
|
||||
}
|
||||
}
|
||||
let mut events = 0;
|
||||
if !*read_closed && socket.can_send() {
|
||||
events |= libc::POLLIN;
|
||||
}
|
||||
if socket.can_recv() {
|
||||
events |= libc::POLLOUT;
|
||||
}
|
||||
if events != 0 {
|
||||
poll.push(libc::pollfd {
|
||||
fd: upstream.as_raw_fd(),
|
||||
events,
|
||||
revents: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer(result: io::Result<usize>) -> (usize, io::Result<Option<usize>>) {
|
||||
match result {
|
||||
Ok(n) => (n, Ok(Some(n))),
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
|
||||
) =>
|
||||
{
|
||||
(0, Ok(None))
|
||||
}
|
||||
Err(e) => (0, Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn interface(tun: &mut TunTapInterface) -> io::Result<Interface> {
|
||||
let mut config = Config::new(HardwareAddress::Ip);
|
||||
config.random_seed = rand::random();
|
||||
let mut iface = Interface::new(config, tun, Instant::ZERO);
|
||||
let v4 = Ipv4Addr::new(192, 0, 2, 2);
|
||||
let v6 = Ipv6Addr::LOCALHOST;
|
||||
let mut addresses_fit = true;
|
||||
iface.update_ip_addrs(|ips| {
|
||||
for ip in [IpAddr::V4(v4), IpAddr::V6(v6)] {
|
||||
addresses_fit &= ips
|
||||
.push(IpCidr::new(ip.into(), if ip.is_ipv4() { 32 } else { 128 }))
|
||||
.is_ok();
|
||||
}
|
||||
});
|
||||
if !addresses_fit {
|
||||
return Err(io::Error::other("insufficient TCP interface addresses"));
|
||||
}
|
||||
iface
|
||||
.routes_mut()
|
||||
.add_default_ipv4_route(v4)
|
||||
.map_err(io::Error::other)?;
|
||||
iface
|
||||
.routes_mut()
|
||||
.add_default_ipv6_route(v6)
|
||||
.map_err(io::Error::other)?;
|
||||
iface.set_any_ip(true);
|
||||
Ok(iface)
|
||||
}
|
||||
|
||||
pub(super) fn run(
|
||||
fd: OwnedFd,
|
||||
proxy: SocketAddr,
|
||||
names: Arc<Mutex<Names>>,
|
||||
started: mpsc::SyncSender<io::Result<SocketAddr>>,
|
||||
) -> io::Result<()> {
|
||||
use rustix::fs::OFlags;
|
||||
use rustix::fs::fcntl_getfl;
|
||||
use rustix::fs::fcntl_setfl;
|
||||
fcntl_setfl(&fd, fcntl_getfl(&fd)? | OFlags::NONBLOCK)?;
|
||||
// from_fd takes ownership of the descriptor opened in the private namespace.
|
||||
let mut tun = TunTapInterface::from_fd(fd.into_raw_fd(), Medium::Ip, MTU)?;
|
||||
let mut iface = interface(&mut tun)?;
|
||||
let clock = std::time::Instant::now();
|
||||
let mut started = Some(started);
|
||||
let mut sockets = SocketSet::new(Vec::new());
|
||||
let mut flows: HashMap<Endpoints, (SocketHandle, Session)> = HashMap::new();
|
||||
let permits = Arc::new(async_lock::Semaphore::new(MAX_FLOWS));
|
||||
let mut poll = Vec::with_capacity(MAX_FLOWS + 1);
|
||||
loop {
|
||||
let now = Instant::from_micros(clock.elapsed().as_micros() as i64);
|
||||
iface.poll_maintenance(now);
|
||||
if let Some((rx, _)) = tun.receive(now) {
|
||||
rx.consume(|packet| -> io::Result<()> {
|
||||
if packet.is_empty() {
|
||||
return Err(io::ErrorKind::UnexpectedEof.into());
|
||||
}
|
||||
let Some((key, syn)) = tcp_endpoints(packet) else {
|
||||
return Ok(());
|
||||
};
|
||||
if syn && !flows.contains_key(&key) {
|
||||
if flows.len() == MAX_FLOWS {
|
||||
return Ok(()); // The kernel retries when a slot opens.
|
||||
}
|
||||
let buffer = || SocketBuffer::new(vec![0; WINDOW]);
|
||||
let mut native = Socket::new(buffer(), buffer());
|
||||
native.set_timeout(Some(super::TIMEOUT.into()));
|
||||
native.listen(key.1).map_err(io::Error::other)?;
|
||||
flows.insert(key, (sockets.add(native), Session::Handshake));
|
||||
}
|
||||
iface.poll_ingress_single(
|
||||
now,
|
||||
&mut Ingress {
|
||||
device: &mut tun,
|
||||
packet: Some(packet),
|
||||
},
|
||||
&mut sockets,
|
||||
);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
iface.poll_egress(now, &mut tun, &mut sockets);
|
||||
poll.clear();
|
||||
poll.push(libc::pollfd {
|
||||
fd: tun.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
});
|
||||
let mut pending = false;
|
||||
flows.retain(|&(src, dst), (handle, session)| {
|
||||
let native = sockets.get_mut::<Socket>(*handle);
|
||||
if !native.is_active() {
|
||||
sockets.remove(*handle);
|
||||
return false;
|
||||
}
|
||||
if matches!(session, Session::Handshake)
|
||||
&& matches!(native.state(), State::Established | State::CloseWait)
|
||||
{
|
||||
native.set_timeout(None);
|
||||
if let Some(started) = started.take() {
|
||||
let result = if dst == SocketAddr::from(([198, 18, 0, 0], 9)) {
|
||||
super::pending::Owner::capture(src, dst).map(|_| src)
|
||||
} else {
|
||||
Err(io::Error::other("unexpected readiness destination"))
|
||||
};
|
||||
let _ = started.send(result);
|
||||
native.close();
|
||||
*session = Session::Done;
|
||||
return true;
|
||||
}
|
||||
let Some(permit) = permits.try_acquire_arc() else {
|
||||
pending = true;
|
||||
return true;
|
||||
};
|
||||
let (reply, result) = mpsc::sync_channel(1);
|
||||
let names = Arc::clone(&names);
|
||||
let _ = thread::Builder::new().spawn(move || {
|
||||
let _permit = permit;
|
||||
if let Ok(upstream) = super::connect(src, dst, proxy, &names) {
|
||||
let _ = reply.send(upstream);
|
||||
}
|
||||
});
|
||||
*session = Session::Pending(result);
|
||||
}
|
||||
if session.pump(native, &mut poll).is_err() {
|
||||
native.abort();
|
||||
*session = Session::Done;
|
||||
}
|
||||
pending |= matches!(session, Session::Pending(_));
|
||||
true
|
||||
});
|
||||
while iface.poll_egress(now, &mut tun, &mut sockets) == PollResult::SocketStateChanged {}
|
||||
let delay = iface
|
||||
.poll_delay(now, &sockets)
|
||||
.map(|d| d.total_millis().min(i32::MAX as u64) as i32);
|
||||
let timeout = if pending {
|
||||
delay.unwrap_or(25).min(25)
|
||||
} else {
|
||||
delay.unwrap_or(-1)
|
||||
};
|
||||
// SAFETY: all descriptors in poll are owned by tun or live entries in flows.
|
||||
let result = unsafe { libc::poll(poll.as_mut_ptr(), poll.len() as _, timeout) };
|
||||
if result < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use crate::proxy_lifecycle::harden_bridge_process;
|
||||
use crate::proxy_lifecycle::move_fd_above_stdio;
|
||||
use crate::proxy_lifecycle::receive_listener;
|
||||
use crate::proxy_lifecycle::send_listener;
|
||||
use crate::seccomp::ProxyRouting;
|
||||
use async_lock::Semaphore;
|
||||
use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
|
||||
use codex_network_proxy::write_attribution_frame;
|
||||
use serde::Deserialize;
|
||||
@@ -26,7 +28,6 @@ use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
use url::Url;
|
||||
|
||||
const HOST_BRIDGE_READY: u8 = 1;
|
||||
@@ -58,6 +59,7 @@ struct ProxyRoutePlan {
|
||||
|
||||
pub(crate) fn prepare_host_proxy_route_spec(
|
||||
port_allowlist: Option<&[u16]>,
|
||||
routing: ProxyRouting,
|
||||
) -> io::Result<(String, Vec<File>)> {
|
||||
let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect());
|
||||
// SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or
|
||||
@@ -96,6 +98,7 @@ pub(crate) fn prepare_host_proxy_route_spec(
|
||||
}
|
||||
let control = spawn_host_bridge(
|
||||
route.endpoint,
|
||||
routing,
|
||||
attribution_token.as_deref(),
|
||||
&mut control_by_endpoint,
|
||||
)?;
|
||||
@@ -128,7 +131,9 @@ fn extract_attribution_token_and_plan(
|
||||
(attribution_token, plan)
|
||||
}
|
||||
|
||||
pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> {
|
||||
pub(crate) fn activate_proxy_routes_in_netns(
|
||||
serialized_spec: &str,
|
||||
) -> io::Result<Option<SocketAddr>> {
|
||||
let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?;
|
||||
|
||||
if spec.routes.is_empty() {
|
||||
@@ -179,6 +184,7 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
|
||||
local_port_by_control.insert(route.control_fd, local_port);
|
||||
}
|
||||
|
||||
let mut socks_bridge = None;
|
||||
for route in spec.routes {
|
||||
let Some(local_port) = local_port_by_control.get(&route.control_fd) else {
|
||||
return Err(io::Error::other("missing proxy listener port"));
|
||||
@@ -195,6 +201,11 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
|
||||
format!("could not rewrite proxy URL for env key {}", route.env_key),
|
||||
));
|
||||
};
|
||||
if route.env_key == "ALL_PROXY"
|
||||
&& Url::parse(&rewritten).is_ok_and(|url| matches!(url.scheme(), "socks5h" | "socks5"))
|
||||
{
|
||||
socks_bridge = Some(SocketAddr::from((Ipv4Addr::LOCALHOST, *local_port)));
|
||||
}
|
||||
// SAFETY: this helper process is single-threaded at this point, and
|
||||
// env mutation happens before execing the user command.
|
||||
unsafe {
|
||||
@@ -202,7 +213,7 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(socks_bridge)
|
||||
}
|
||||
|
||||
fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
|
||||
@@ -320,6 +331,7 @@ fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option<String> {
|
||||
|
||||
fn spawn_host_bridge(
|
||||
endpoint: SocketAddr,
|
||||
routing: ProxyRouting,
|
||||
attribution_token: Option<&str>,
|
||||
inherited_controls: &mut BTreeMap<SocketAddr, File>,
|
||||
) -> io::Result<File> {
|
||||
@@ -338,7 +350,13 @@ fn spawn_host_bridge(
|
||||
// A worker must not retain another route's inner bootstrap endpoint.
|
||||
// Drop this child's inherited owners; the parent's copies are unaffected by fork.
|
||||
inherited_controls.clear();
|
||||
let result = run_host_bridge(endpoint, host_control, attribution_token, parent_pid);
|
||||
let result = run_host_bridge(
|
||||
endpoint,
|
||||
routing,
|
||||
host_control,
|
||||
attribution_token,
|
||||
parent_pid,
|
||||
);
|
||||
if result.is_err() {
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
@@ -365,9 +383,14 @@ impl ConnectionSlots {
|
||||
Self(Arc::new(Semaphore::new(MAX_CONNECTIONS)))
|
||||
}
|
||||
|
||||
pub(super) fn spawn(&self, serve: impl FnOnce() + Send + 'static) {
|
||||
let Ok(permit) = Arc::clone(&self.0).try_acquire_owned() else {
|
||||
return;
|
||||
pub(super) fn spawn(&self, routing: ProxyRouting, serve: impl FnOnce() + Send + 'static) {
|
||||
// Wait in the accept loop, before allocating another thread or upstream socket.
|
||||
let permit = match routing {
|
||||
ProxyRouting::TransparentTcp => self.0.acquire_arc_blocking(),
|
||||
ProxyRouting::Explicit => match self.0.try_acquire_arc() {
|
||||
Some(permit) => permit,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _permit = permit;
|
||||
@@ -378,6 +401,7 @@ impl ConnectionSlots {
|
||||
|
||||
fn run_host_bridge(
|
||||
endpoint: SocketAddr,
|
||||
routing: ProxyRouting,
|
||||
mut control: UnixStream,
|
||||
attribution_token: Option<&str>,
|
||||
parent_pid: libc::pid_t,
|
||||
@@ -396,7 +420,7 @@ fn run_host_bridge(
|
||||
loop {
|
||||
let (client_stream, _) = listener.accept()?;
|
||||
let attribution_token = attribution_token.clone();
|
||||
slots.spawn(move || {
|
||||
slots.spawn(routing, move || {
|
||||
let mut tcp_stream = match TcpStream::connect(endpoint) {
|
||||
Ok(stream) => stream,
|
||||
Err(_) => return,
|
||||
@@ -431,7 +455,7 @@ fn bind_local_loopback_listener() -> io::Result<TcpListener> {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_loopback_interface_up() -> io::Result<()> {
|
||||
pub(super) 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());
|
||||
|
||||
@@ -11,6 +11,8 @@ use super::parse_loopback_proxy_endpoint;
|
||||
use super::plan_proxy_routes;
|
||||
use super::proxy_bidirectional;
|
||||
use super::rewrite_proxy_env_value;
|
||||
use crate::seccomp::ProxyRouting;
|
||||
use async_lock::Semaphore;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
@@ -22,7 +24,6 @@ use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reset_on_either_side_releases_the_bridge_permit() {
|
||||
@@ -41,7 +42,7 @@ async fn a_reset_on_either_side_releases_the_bridge_permit() {
|
||||
(idle_socket, reset_socket)
|
||||
};
|
||||
let (done, result) = tokio::sync::oneshot::channel();
|
||||
slots.spawn(move || {
|
||||
slots.spawn(ProxyRouting::Explicit, move || {
|
||||
let _ = done.send(proxy_bidirectional(upstream, client));
|
||||
});
|
||||
rustix::net::sockopt::set_socket_linger(&reset_peer, Some(Duration::ZERO)).unwrap();
|
||||
@@ -49,8 +50,7 @@ async fn a_reset_on_either_side_releases_the_bridge_permit() {
|
||||
|
||||
let _permit = tokio::time::timeout(Duration::from_secs(5), slots.0.acquire())
|
||||
.await
|
||||
.expect("bridge must release its permit while the other peer is still open")
|
||||
.unwrap();
|
||||
.expect("bridge must release its permit while the other peer is still open");
|
||||
assert!(result.await.unwrap().is_err());
|
||||
drop(idle_peer);
|
||||
}
|
||||
|
||||
@@ -48,12 +48,12 @@ pub(crate) fn apply_sandbox_policies_to_current_thread(
|
||||
cwd: &Path,
|
||||
apply_landlock_fs: bool,
|
||||
managed_network: Option<&ManagedNetworkSandboxContext>,
|
||||
proxy_routing_active: bool,
|
||||
proxy_routing: Option<ProxyRouting>,
|
||||
) -> Result<()> {
|
||||
let network_seccomp_mode = network_seccomp_mode(
|
||||
network_sandbox_policy,
|
||||
managed_network.is_some(),
|
||||
proxy_routing_active,
|
||||
proxy_routing,
|
||||
)
|
||||
.or_else(|| {
|
||||
// VM sockets can reach host services outside the filesystem sandbox.
|
||||
@@ -100,10 +100,16 @@ pub(crate) fn apply_sandbox_policies_to_current_thread(
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NetworkSeccompMode {
|
||||
Restricted,
|
||||
ProxyRouted,
|
||||
ProxyRouted(ProxyRouting),
|
||||
VmSocketRestricted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ProxyRouting {
|
||||
Explicit,
|
||||
TransparentTcp,
|
||||
}
|
||||
|
||||
fn should_install_network_seccomp(
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
@@ -116,12 +122,12 @@ fn should_install_network_seccomp(
|
||||
fn network_seccomp_mode(
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_routed_network: bool,
|
||||
proxy_routed_network: Option<ProxyRouting>,
|
||||
) -> Option<NetworkSeccompMode> {
|
||||
if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) {
|
||||
None
|
||||
} else if proxy_routed_network {
|
||||
Some(NetworkSeccompMode::ProxyRouted)
|
||||
} else if let Some(routing) = proxy_routed_network {
|
||||
Some(NetworkSeccompMode::ProxyRouted(routing))
|
||||
} else {
|
||||
Some(NetworkSeccompMode::Restricted)
|
||||
}
|
||||
@@ -267,11 +273,11 @@ fn install_network_seccomp_filter_on_current_thread(
|
||||
add_syscall_rules(&mut rules, libc::SYS_socket, vec![unix_only_rule.clone()]);
|
||||
add_syscall_rules(&mut rules, libc::SYS_socketpair, vec![unix_only_rule]);
|
||||
}
|
||||
NetworkSeccompMode::ProxyRouted => {
|
||||
NetworkSeccompMode::ProxyRouted(routing) => {
|
||||
// In proxy-routed mode we allow IP sockets in the isolated
|
||||
// namespace (used to reach the local TCP bridge). Standalone Unix
|
||||
// sockets require an explicit managed-policy grant; all other
|
||||
// socket families remain denied.
|
||||
// sockets require an explicit managed-policy grant. Transparent TCP
|
||||
// also permits route enumeration in its private network namespace.
|
||||
let mut denied_socket_conditions = vec![
|
||||
SeccompCondition::new(
|
||||
0,
|
||||
@@ -294,14 +300,40 @@ fn install_network_seccomp_filter_on_current_thread(
|
||||
libc::AF_UNIX as u64,
|
||||
)?);
|
||||
}
|
||||
let deny_non_ip_socket = SeccompRule::new(denied_socket_conditions)?;
|
||||
let mut socket_rules = Vec::new();
|
||||
if routing == ProxyRouting::TransparentTcp {
|
||||
// Native clients use getifaddrs to enumerate their interfaces. Route
|
||||
// mutation requires CAP_NET_ADMIN in the parent user namespace that
|
||||
// owns the TUN. The workload has no capabilities there.
|
||||
denied_socket_conditions.push(SeccompCondition::new(
|
||||
0,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
libc::AF_NETLINK as u64,
|
||||
)?);
|
||||
socket_rules.push(SeccompRule::new(vec![
|
||||
SeccompCondition::new(
|
||||
0,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Eq,
|
||||
libc::AF_NETLINK as u64,
|
||||
)?,
|
||||
SeccompCondition::new(
|
||||
2,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
libc::NETLINK_ROUTE as u64,
|
||||
)?,
|
||||
])?);
|
||||
}
|
||||
socket_rules.push(SeccompRule::new(denied_socket_conditions)?);
|
||||
let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new(
|
||||
0,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Ne,
|
||||
libc::AF_UNIX as u64,
|
||||
)?])?;
|
||||
add_syscall_rules(&mut rules, libc::SYS_socket, vec![deny_non_ip_socket]);
|
||||
add_syscall_rules(&mut rules, libc::SYS_socket, socket_rules);
|
||||
add_syscall_rules(
|
||||
&mut rules,
|
||||
libc::SYS_socketpair,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use super::NetworkSeccompMode;
|
||||
use super::ProxyRouting;
|
||||
use super::install_network_seccomp_filter_on_current_thread;
|
||||
use super::network_seccomp_mode;
|
||||
use super::set_no_new_privs;
|
||||
@@ -17,7 +18,8 @@ use std::collections::BTreeMap;
|
||||
use test_case::test_case;
|
||||
|
||||
#[test_case(NetworkSeccompMode::Restricted, libc::EPERM; "restricted")]
|
||||
#[test_case(NetworkSeccompMode::ProxyRouted, libc::EPERM; "proxy_routed")]
|
||||
#[test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit), libc::EPERM; "explicit_proxy")]
|
||||
#[test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp), libc::EPERM; "transparent_tcp")]
|
||||
#[test_case(NetworkSeccompMode::VmSocketRestricted, libc::ENOSYS; "vm_socket_only")]
|
||||
fn request_key_respects_network_policy(mode: NetworkSeccompMode, expected_errno: i32) {
|
||||
// Filters apply only to this thread, leaving the test runner unrestricted.
|
||||
@@ -103,9 +105,9 @@ fn managed_proxy_routes_use_proxy_routed_seccomp_mode() {
|
||||
network_seccomp_mode(
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*proxy_routed_network*/ true,
|
||||
Some(ProxyRouting::Explicit),
|
||||
),
|
||||
Some(NetworkSeccompMode::ProxyRouted)
|
||||
Some(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +117,7 @@ fn restricted_network_without_proxy_routing_uses_restricted_mode() {
|
||||
network_seccomp_mode(
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*proxy_routed_network*/ false,
|
||||
/*proxy_routed_network*/ None,
|
||||
),
|
||||
Some(NetworkSeccompMode::Restricted)
|
||||
);
|
||||
@@ -127,7 +129,7 @@ fn full_network_without_managed_proxy_skips_network_seccomp_mode() {
|
||||
network_seccomp_mode(
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*proxy_routed_network*/ false,
|
||||
/*proxy_routed_network*/ None,
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -149,7 +151,8 @@ fn syscall_result(nr: i64, arg0: i64) -> std::result::Result<i64, i32> {
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[test_case::test_case(NetworkSeccompMode::Restricted; "restricted")]
|
||||
#[test_case::test_case(NetworkSeccompMode::ProxyRouted; "proxy_routed")]
|
||||
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit); "explicit_proxy")]
|
||||
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp); "transparent_tcp")]
|
||||
#[test_case::test_case(NetworkSeccompMode::VmSocketRestricted; "vm_socket_restricted")]
|
||||
fn x32_restricted_syscalls_are_denied(mode: NetworkSeccompMode) {
|
||||
std::thread::spawn(move || {
|
||||
@@ -207,7 +210,8 @@ fn x32_restricted_syscalls_are_denied(mode: NetworkSeccompMode) {
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[test_case::test_case(NetworkSeccompMode::Restricted; "restricted")]
|
||||
#[test_case::test_case(NetworkSeccompMode::ProxyRouted; "proxy_routed")]
|
||||
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit); "explicit_proxy")]
|
||||
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp); "transparent_tcp")]
|
||||
#[test_case::test_case(NetworkSeccompMode::VmSocketRestricted; "vm_socket_restricted")]
|
||||
fn x32_preserves_allowed_calls_and_socket_conditions(mode: NetworkSeccompMode) {
|
||||
std::thread::spawn(move || {
|
||||
@@ -215,13 +219,17 @@ fn x32_preserves_allowed_calls_and_socket_conditions(mode: NetworkSeccompMode) {
|
||||
NetworkSeccompMode::Restricted | NetworkSeccompMode::VmSocketRestricted => {
|
||||
libc::AF_UNIX
|
||||
}
|
||||
NetworkSeccompMode::ProxyRouted => libc::AF_INET,
|
||||
NetworkSeccompMode::ProxyRouted(_) => libc::AF_INET,
|
||||
};
|
||||
let allowed = [
|
||||
let mut allowed = vec![
|
||||
(libc::SYS_getpid, 0),
|
||||
(libc::SYS_socket, i64::from(socket_domain)),
|
||||
(libc::SYS_socketpair, i64::from(libc::AF_UNIX)),
|
||||
];
|
||||
if mode == NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp) {
|
||||
// syscall_result supplies protocol zero (NETLINK_ROUTE).
|
||||
allowed.push((libc::SYS_socket, i64::from(libc::AF_NETLINK)));
|
||||
}
|
||||
let probes = allowed
|
||||
.into_iter()
|
||||
.flat_map(|(nr, arg0)| [(nr, arg0), (nr | 0x4000_0000, arg0)])
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Create a private user and network namespace before invoking stock bubblewrap.
|
||||
//! Only the trusted launcher configures the TUN and the resolver sockets.
|
||||
|
||||
use rustix::thread::CapabilitySet;
|
||||
use rustix::thread::CapabilitySets;
|
||||
use rustix::thread::UnshareFlags;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::net::UdpSocket;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::fd::RawFd;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// linux/ipv6_route.h. libc's in6_rtmsg fields are private.
|
||||
#[repr(C)]
|
||||
struct Ipv6Route {
|
||||
dst: libc::in6_addr,
|
||||
src: libc::in6_addr,
|
||||
gateway: libc::in6_addr,
|
||||
kind: u32,
|
||||
dst_len: u16,
|
||||
src_len: u16,
|
||||
metric: u32,
|
||||
info: libc::c_ulong,
|
||||
flags: u32,
|
||||
ifindex: libc::c_int,
|
||||
}
|
||||
const _: () = assert!(std::mem::size_of::<Ipv6Route>() == std::mem::size_of::<libc::in6_rtmsg>());
|
||||
|
||||
pub(super) fn create() -> io::Result<[File; 3]> {
|
||||
let uid = rustix::process::geteuid().as_raw();
|
||||
let gid = rustix::process::getegid().as_raw();
|
||||
// SAFETY: this launcher is single-threaded and keeps its file descriptor table.
|
||||
unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWUSER | UnshareFlags::NEWNET)? };
|
||||
fs::write("/proc/self/uid_map", format!("{uid} {uid} 1\n"))?;
|
||||
fs::write("/proc/self/setgroups", "deny")?;
|
||||
fs::write("/proc/self/gid_map", format!("{gid} {gid} 1\n"))?;
|
||||
let tun = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open("/dev/net/tun")?;
|
||||
crate::proxy_routing::ensure_loopback_interface_up()?;
|
||||
// SAFETY: these Linux ioctl records consist of integers, byte arrays and
|
||||
// nullable pointers. Start with zeroed padding and set the active union
|
||||
// member to the type required by each specific ioctl before invoking it.
|
||||
unsafe {
|
||||
let name = c"codextun";
|
||||
let mut iface: libc::ifreq = std::mem::zeroed();
|
||||
std::ptr::copy_nonoverlapping(
|
||||
name.as_ptr(),
|
||||
iface.ifr_name.as_mut_ptr(),
|
||||
name.to_bytes_with_nul().len(),
|
||||
);
|
||||
iface.ifr_ifru.ifru_flags = (libc::IFF_TUN | libc::IFF_NO_PI) as i16;
|
||||
ioctl(tun.as_raw_fd(), libc::TUNSETIFF, &mut iface)?;
|
||||
let v4 = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||
let v6 = UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))?;
|
||||
ioctl(v4.as_raw_fd(), libc::SIOCGIFFLAGS as _, &mut iface)?;
|
||||
iface.ifr_ifru.ifru_flags |= libc::IFF_UP as i16;
|
||||
ioctl(v4.as_raw_fd(), libc::SIOCSIFFLAGS as _, &mut iface)?;
|
||||
let index = libc::if_nametoindex(name.as_ptr()) as i32;
|
||||
if index == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let mut addr: libc::sockaddr_in = std::mem::zeroed();
|
||||
addr.sin_family = libc::AF_INET as _;
|
||||
addr.sin_addr.s_addr = u32::from_ne_bytes([192, 0, 2, 1]);
|
||||
iface.ifr_ifru.ifru_addr = std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr);
|
||||
ioctl(v4.as_raw_fd(), libc::SIOCSIFADDR as _, &mut iface)?;
|
||||
addr.sin_addr.s_addr = u32::MAX;
|
||||
iface.ifr_ifru.ifru_netmask =
|
||||
std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr);
|
||||
ioctl(v4.as_raw_fd(), libc::SIOCSIFNETMASK as _, &mut iface)?;
|
||||
let mut addr6: libc::in6_ifreq = std::mem::zeroed();
|
||||
addr6.ifr6_addr.s6_addr = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1).octets();
|
||||
addr6.ifr6_prefixlen = 128;
|
||||
addr6.ifr6_ifindex = index;
|
||||
ioctl(v6.as_raw_fd(), libc::SIOCSIFADDR as _, &mut addr6)?;
|
||||
let mut route: libc::rtentry = std::mem::zeroed();
|
||||
route.rt_dst.sa_family = libc::AF_INET as _;
|
||||
route.rt_genmask.sa_family = libc::AF_INET as _;
|
||||
route.rt_gateway.sa_family = libc::AF_INET as _;
|
||||
route.rt_flags = libc::RTF_UP;
|
||||
route.rt_dev = iface.ifr_name.as_mut_ptr();
|
||||
ioctl(v4.as_raw_fd(), libc::SIOCADDRT as _, &mut route)?;
|
||||
let mut route6: Ipv6Route = std::mem::zeroed();
|
||||
route6.ifindex = index;
|
||||
route6.flags = u32::from(libc::RTF_UP);
|
||||
route6.metric = 1;
|
||||
ioctl(v6.as_raw_fd(), libc::SIOCADDRT as _, &mut route6)?;
|
||||
}
|
||||
let dns = (Ipv4Addr::new(127, 0, 0, 53), 53);
|
||||
let udp = UdpSocket::bind(dns)?;
|
||||
let tcp = TcpListener::bind(dns)?;
|
||||
// Linux 5.12+ requires SETFCAP to map parent UID 0 into bwrap's nested
|
||||
// namespace. The inner stage verifies that bwrap dropped this final capability.
|
||||
let keep = if uid == 0 {
|
||||
CapabilitySet::SETFCAP
|
||||
} else {
|
||||
CapabilitySet::empty()
|
||||
};
|
||||
rustix::thread::set_no_new_privs(true)?;
|
||||
rustix::thread::set_capabilities(
|
||||
None,
|
||||
CapabilitySets {
|
||||
effective: keep,
|
||||
permitted: keep,
|
||||
inheritable: CapabilitySet::empty(),
|
||||
},
|
||||
)?;
|
||||
let [tun, udp, tcp] = [OwnedFd::from(tun), udp.into(), tcp.into()];
|
||||
Ok([
|
||||
File::from(crate::proxy_lifecycle::move_fd_above_stdio(tun)?),
|
||||
File::from(crate::proxy_lifecycle::move_fd_above_stdio(udp)?),
|
||||
File::from(crate::proxy_lifecycle::move_fd_above_stdio(tcp)?),
|
||||
])
|
||||
}
|
||||
|
||||
// SAFETY: the caller pairs each request number with its exact Linux ABI record.
|
||||
unsafe fn ioctl<T>(fd: RawFd, request: libc::Ioctl, record: &mut T) -> io::Result<()> {
|
||||
if unsafe { libc::ioctl(fd, request, record) } < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Pass the private network descriptors and mount the command-local resolver. The inner stage
|
||||
//! takes ownership of all three received descriptors before any workload exec.
|
||||
|
||||
use crate::bwrap::BwrapArgs;
|
||||
use crate::proxy_lifecycle::move_fd_above_stdio;
|
||||
use codex_protocol::protocol::FileSystemSandboxPolicy;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::Seek;
|
||||
use std::io::Write;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn prepare(
|
||||
files: [File; 3],
|
||||
bwrap: &mut BwrapArgs,
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
cwd: &Path,
|
||||
) -> io::Result<()> {
|
||||
let path = Path::new("/etc/resolv.conf");
|
||||
// Bind onto the existing target so ordinary systemd-resolved symlinks work.
|
||||
// Reject policies masking either the logical name or the resolved location.
|
||||
let target = path.canonicalize()?;
|
||||
if !resolver_readable(policy, cwd, path, &target)? || !target.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"transparent TCP requires readable /etc/resolv.conf",
|
||||
));
|
||||
}
|
||||
let fd = rustix::fs::memfd_create("codex-resolver", rustix::fs::MemfdFlags::CLOEXEC)?;
|
||||
let mut resolv = File::from(move_fd_above_stdio(fd)?);
|
||||
resolv.write_all(b"nameserver 127.0.0.53\n")?;
|
||||
resolv.rewind()?;
|
||||
let numbers = files
|
||||
.each_ref()
|
||||
.map(|f| f.as_raw_fd().to_string())
|
||||
.join(",");
|
||||
let separator = bwrap
|
||||
.args
|
||||
.iter()
|
||||
.position(|arg| arg == "--")
|
||||
.ok_or_else(|| io::Error::other("missing inner command"))?;
|
||||
bwrap.args.splice(
|
||||
separator + 2..separator + 2,
|
||||
[
|
||||
"--transparent-tcp".into(),
|
||||
"--transparent-tcp-fd".into(),
|
||||
numbers,
|
||||
],
|
||||
);
|
||||
bwrap.args.splice(
|
||||
separator..separator,
|
||||
[
|
||||
"--perms".into(),
|
||||
"0444".into(),
|
||||
"--ro-bind-data".into(),
|
||||
resolv.as_raw_fd().to_string(),
|
||||
target
|
||||
.to_str()
|
||||
.ok_or_else(|| io::Error::other("non-UTF-8 resolver path"))?
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
bwrap.preserved_files.push(resolv);
|
||||
bwrap.preserved_files.extend(files);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolver_readable(
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
cwd: &Path,
|
||||
logical: &Path,
|
||||
target: &Path,
|
||||
) -> io::Result<bool> {
|
||||
let denied = codex_protocol::permissions::ReadDenyMatcher::try_new_for_local_paths(policy, cwd)
|
||||
.map_err(io::Error::other)?;
|
||||
Ok([logical, target].into_iter().all(|path| {
|
||||
!denied
|
||||
.as_ref()
|
||||
.is_some_and(|matcher| matcher.is_local_path_read_denied(path))
|
||||
&& (policy.can_read_local_path_with_cwd(path, cwd)
|
||||
|| (policy.include_platform_defaults()
|
||||
&& crate::bwrap::LINUX_PLATFORM_DEFAULT_READ_ROOTS
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root))))
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn receive(fds: Vec<std::os::fd::RawFd>) -> io::Result<[OwnedFd; 3]> {
|
||||
let invalid = || io::Error::other("invalid transparent TCP descriptors");
|
||||
let fds: [i32; 3] = fds.try_into().map_err(|_| invalid())?;
|
||||
for (i, &fd) in fds.iter().enumerate() {
|
||||
if fd <= 2 || fds[..i].contains(&fd) {
|
||||
return Err(invalid());
|
||||
}
|
||||
if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
// SAFETY: the launcher handed off three distinct, live descriptors above stdio.
|
||||
Ok(fds.map(|fd| unsafe { OwnedFd::from_raw_fd(fd) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::FileSystemAccessMode;
|
||||
use codex_protocol::protocol::FileSystemPath;
|
||||
use codex_protocol::protocol::FileSystemSandboxEntry;
|
||||
use codex_protocol::protocol::FileSystemSpecialPath;
|
||||
|
||||
#[test]
|
||||
fn minimal_resolver_mount_respects_explicit_denials_and_symlink_targets() {
|
||||
let mut policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new(
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Minimal,
|
||||
},
|
||||
FileSystemAccessMode::Read,
|
||||
)]);
|
||||
let path = Path::new("/etc/resolv.conf");
|
||||
let cwd = Path::new("/");
|
||||
assert!(resolver_readable(&policy, cwd, path, path).unwrap());
|
||||
assert!(!resolver_readable(&policy, cwd, path, Path::new("/private/resolv.conf")).unwrap());
|
||||
policy.entries.push(FileSystemSandboxEntry::new(
|
||||
codex_utils_absolute_path::AbsolutePathBuf::try_from(path)
|
||||
.unwrap()
|
||||
.into(),
|
||||
FileSystemAccessMode::Deny,
|
||||
));
|
||||
assert!(!resolver_readable(&policy, cwd, path, path).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ use tempfile::NamedTempFile;
|
||||
use tokio::process::Command;
|
||||
use url::Url;
|
||||
|
||||
#[path = "managed_tcp.rs"]
|
||||
mod managed_tcp;
|
||||
#[path = "managed_proxy_unix_sockets_tests.rs"]
|
||||
mod unix_sockets;
|
||||
|
||||
@@ -89,7 +91,10 @@ fn is_bwrap_unavailable_output(output: &Output) -> bool {
|
||||
}
|
||||
|
||||
async fn should_skip_bwrap_tests() -> bool {
|
||||
let mut env = create_env_from_core_vars();
|
||||
should_skip_bwrap_tests_with_env(create_env_from_core_vars()).await
|
||||
}
|
||||
|
||||
async fn should_skip_bwrap_tests_with_env(mut env: HashMap<String, String>) -> bool {
|
||||
strip_proxy_env(&mut env);
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
@@ -112,11 +117,14 @@ fn is_managed_proxy_permission_error(stderr: &str) -> bool {
|
||||
}
|
||||
|
||||
async fn managed_proxy_skip_reason() -> Option<String> {
|
||||
if should_skip_bwrap_tests().await {
|
||||
managed_proxy_skip_reason_with_env(create_env_from_core_vars()).await
|
||||
}
|
||||
|
||||
async fn managed_proxy_skip_reason_with_env(mut env: HashMap<String, String>) -> Option<String> {
|
||||
if should_skip_bwrap_tests_with_env(env.clone()).await {
|
||||
return Some("bubblewrap is unavailable 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());
|
||||
|
||||
@@ -163,6 +171,22 @@ fn linux_sandbox_command(
|
||||
permission_profile: &PermissionProfile,
|
||||
allow_network_for_proxy: bool,
|
||||
env: HashMap<String, String>,
|
||||
) -> Command {
|
||||
linux_sandbox_command_with_options(
|
||||
command,
|
||||
permission_profile,
|
||||
allow_network_for_proxy.then_some("{}"),
|
||||
env,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
fn linux_sandbox_command_with_options(
|
||||
command: &[&str],
|
||||
permission_profile: &PermissionProfile,
|
||||
managed_network: Option<&str>,
|
||||
env: HashMap<String, String>,
|
||||
options: &[&str],
|
||||
) -> Command {
|
||||
let cwd = std::env::current_dir().expect("current directory should exist");
|
||||
let permission_profile_json =
|
||||
@@ -174,10 +198,11 @@ fn linux_sandbox_command(
|
||||
"--permission-profile".to_string(),
|
||||
permission_profile_json,
|
||||
];
|
||||
if allow_network_for_proxy {
|
||||
if let Some(context) = managed_network {
|
||||
args.push("--managed-network".to_string());
|
||||
args.push("{}".to_string());
|
||||
args.push(context.to_string());
|
||||
}
|
||||
args.extend(options.iter().map(|arg| (*arg).to_string()));
|
||||
args.push("--".to_string());
|
||||
args.extend(command.iter().map(|entry| (*entry).to_string()));
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Exercise native name resolution and ordinary TCP through the two-stage Linux launcher.
|
||||
|
||||
use super::*;
|
||||
use codex_utils_cargo_bin::runfiles;
|
||||
use pretty_assertions::assert_eq;
|
||||
use socks5_protocol::CommandRequest;
|
||||
use socks5_protocol::sync::FromIO;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn transparent_tcp_rejects_incompatible_options_and_untrusted_proxy_ports() {
|
||||
let incompatible =
|
||||
"transparent TCP requires managed networking and private bubblewrap namespaces";
|
||||
let untrusted = "outside the managed sandbox context";
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert("ALL_PROXY".into(), "socks5h://127.0.0.1:32101".into());
|
||||
for (context, extra, reason) in [
|
||||
(None, None, incompatible),
|
||||
(Some("{}"), Some("--use-legacy-landlock"), incompatible),
|
||||
(Some("{}"), Some("--inherit-pid-namespace"), incompatible),
|
||||
(Some("{}"), Some("--no-proc"), incompatible),
|
||||
(Some("{}"), None, untrusted),
|
||||
(Some(r#"{"loopbackPorts":[32102]}"#), None, untrusted),
|
||||
] {
|
||||
let mut options = vec!["--transparent-tcp"];
|
||||
options.extend(extra);
|
||||
let output = linux_sandbox_command_with_options(
|
||||
&["true"],
|
||||
&PermissionProfile::read_only(),
|
||||
context,
|
||||
env.clone(),
|
||||
&options,
|
||||
)
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains(reason),
|
||||
"{output:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_tcp_native_client_uses_attributed_socks_and_preserves_restrictions() {
|
||||
// Container device policies can expose the node but deny opening it.
|
||||
if let Err(err) = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/net/tun")
|
||||
{
|
||||
if matches!(
|
||||
err.kind(),
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
|
||||
) || err.raw_os_error() == Some(libc::ENODEV)
|
||||
{
|
||||
eprintln!("skipping transparent TCP integration test: /dev/net/tun: {err}");
|
||||
return;
|
||||
}
|
||||
panic!("failed to open /dev/net/tun: {err}");
|
||||
}
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
// Keep distro bwrap private to this child. Other tests exercise bundled discovery.
|
||||
// Stage outside the runfiles cwd, which the production launcher rejects for executables.
|
||||
let _bwrap_dir = if cfg!(target_arch = "x86_64") && option_env!("BAZEL_PACKAGE").is_some() {
|
||||
let bin = tempfile::tempdir().unwrap();
|
||||
let files = runfiles::Runfiles::create().expect("managed TCP test runfiles");
|
||||
let bwrap = runfiles::rlocation!(
|
||||
files,
|
||||
"managed_tcp_test_bubblewrap_noble_amd64/usr/bin/bwrap"
|
||||
)
|
||||
.expect("Ubuntu bubblewrap test fixture");
|
||||
codex_utils_cargo_bin::copy_executable(&bwrap, &bin.path().join("bwrap")).unwrap();
|
||||
let path = env.get("PATH").map(String::as_str).unwrap_or_default();
|
||||
env.insert("PATH".into(), format!("{path}:{}", bin.path().display()));
|
||||
Some(bin)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = managed_proxy_skip_reason_with_env(env.clone()).await {
|
||||
eprintln!("skipping managed TCP sandbox test: {reason}");
|
||||
return;
|
||||
}
|
||||
let proxy = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let proxy_addr = proxy.local_addr().unwrap();
|
||||
let host_only = TcpListener::bind((Ipv4Addr::new(127, 0, 0, 2), 0)).unwrap();
|
||||
let host_port = host_only.local_addr().unwrap().port().to_string();
|
||||
let read_only = NamedTempFile::new_in(std::env::current_dir().unwrap()).unwrap();
|
||||
env.insert("ALL_PROXY".into(), format!("socks5h://{proxy_addr}"));
|
||||
env.insert(
|
||||
PROXY_ATTRIBUTION_TOKEN_ENV_KEY.into(),
|
||||
"native-client".into(),
|
||||
);
|
||||
let serve = |mut stdin: tokio::process::ChildStdin| async move {
|
||||
let mut kept = None;
|
||||
for (host, allow) in [
|
||||
("pg.example:5432", true),
|
||||
("pg.example:5432", true),
|
||||
("[2001:db8::93]:5432", true),
|
||||
("v6.example:5432", true),
|
||||
("blocked.example:5432", false),
|
||||
] {
|
||||
let (mut stream, _) = proxy.accept().await.unwrap();
|
||||
let mut preface = Vec::new();
|
||||
write_attribution_frame(&mut preface, "native-client").unwrap();
|
||||
preface.extend_from_slice(&[5, 1, 0]);
|
||||
let mut buf = vec![0; preface.len()];
|
||||
stream.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, preface);
|
||||
stream.write_all(&[5, 0]).await.unwrap();
|
||||
let mut expected = Vec::new();
|
||||
CommandRequest::connect(host.parse().unwrap())
|
||||
.write_to(&mut expected)
|
||||
.unwrap();
|
||||
let mut request = vec![0; expected.len()];
|
||||
stream.read_exact(&mut request).await.unwrap();
|
||||
assert_eq!(request, expected);
|
||||
if host == "pg.example:5432" {
|
||||
let Some(first) = kept.take() else {
|
||||
stdin.write_all(b"open\n").await.unwrap();
|
||||
kept = Some(stream);
|
||||
continue;
|
||||
};
|
||||
stdin.write_all(b"close\n").await.unwrap();
|
||||
assert_eq!(stream.read(&mut [0]).await.unwrap(), 0);
|
||||
stream = first;
|
||||
}
|
||||
let status = if allow { 0 } else { 2 };
|
||||
stream
|
||||
.write_all(&[5, status, 0, 1, 0, 0, 0, 0, 0, 0])
|
||||
.await
|
||||
.unwrap();
|
||||
if allow {
|
||||
let mut payload = [0; 4];
|
||||
stream.read_exact(&mut payload).await.unwrap();
|
||||
assert_eq!(&payload, b"ping");
|
||||
stream.write_all(b"pong\n").await.unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let script = r#"
|
||||
unset ALL_PROXY all_proxy
|
||||
if command -v ip >/dev/null; then
|
||||
ip link show codextun >/dev/null
|
||||
if ip link set codextun down 2>/dev/null; then exit 15; fi
|
||||
fi
|
||||
exec 3<>/dev/tcp/pg.example/5432
|
||||
printf ping >&3
|
||||
read -r open
|
||||
exec 4<>/dev/tcp/pg.example/5432
|
||||
printf queued >&4
|
||||
read -r release
|
||||
exec 4>&-
|
||||
IFS= read -r reply <&3 && test "$reply" = pong || exit 11
|
||||
exec 3>&-
|
||||
exec 3<>/dev/tcp/2001:db8::93/5432
|
||||
printf ping >&3
|
||||
IFS= read -r reply <&3 && test "$reply" = pong || exit 11
|
||||
exec 3>&-
|
||||
# Force an AAAA lookup so IPv4 fallback cannot satisfy the hostname check.
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as client:
|
||||
client.connect(("v6.example", 5432))
|
||||
client.sendall(b"ping")
|
||||
assert client.makefile("rb").readline() == b"pong\n"
|
||||
PY
|
||||
if { exec 4<>/dev/tcp/blocked.example/5432; } 2>/dev/null; then
|
||||
if IFS= read -r -t 2 reply <&4; then exit 12; fi
|
||||
fi
|
||||
if (exec 5<>/dev/tcp/127.0.0.2/"$1") 2>/dev/null; then exit 13; fi
|
||||
if (exec 5>>"$2") 2>/dev/null; then exit 14; fi
|
||||
"#;
|
||||
let mut command = linux_sandbox_command_with_options(
|
||||
&[
|
||||
"bash",
|
||||
"-ec",
|
||||
script,
|
||||
"probe",
|
||||
&host_port,
|
||||
read_only.path().to_str().unwrap(),
|
||||
],
|
||||
&PermissionProfile::read_only(),
|
||||
Some(&format!(r#"{{"loopbackPorts":[{}]}}"#, proxy_addr.port())),
|
||||
env,
|
||||
&["--transparent-tcp"],
|
||||
);
|
||||
let mut child = command
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let peer = tokio::spawn(serve(child.stdin.take().unwrap()));
|
||||
let output = tokio::time::timeout(SANDBOX_TIMEOUT, child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
tokio::time::timeout(OPERATION_TIMEOUT, peer)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -61,7 +61,10 @@ fn policy_ctx(
|
||||
#[tokio::test]
|
||||
async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() {
|
||||
let app_state = Arc::new(network_proxy_state_for_policy({
|
||||
let mut network = NetworkProxyConfig::default();
|
||||
let mut network = NetworkProxyConfig {
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
network.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
network
|
||||
}));
|
||||
@@ -211,6 +214,7 @@ async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Full,
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
network.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
@@ -392,6 +396,7 @@ async fn mitm_policy_blocks_encoded_path_traversal_for_repository_allowlist() {
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Full,
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
network.set_allowed_domains(vec!["github.com".to_string()]);
|
||||
@@ -460,6 +465,7 @@ async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() {
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Limited,
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
network.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
@@ -509,6 +515,7 @@ async fn mitm_policy_records_hook_denials_in_full_mode() {
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Full,
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
network.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
|
||||
@@ -2528,6 +2528,14 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_launch_config_carries_execution_scope() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let ca = crate::certs::ManagedMitmCa::for_test(dir.path())?;
|
||||
crate::certs::TEST_MITM_CA
|
||||
.scope(ca, check_remote_launch_config_execution_scope())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_remote_launch_config_execution_scope() -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap();
|
||||
let mut config = NetworkProxyConfig {
|
||||
|
||||
@@ -1008,6 +1008,7 @@ mod tests {
|
||||
enabled: true,
|
||||
mode: NetworkMode::Limited,
|
||||
mitm: true,
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
settings.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
@@ -1076,6 +1077,7 @@ mod tests {
|
||||
mode: NetworkMode::Full,
|
||||
mitm: true,
|
||||
credential_broker: true,
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
settings.set_allowed_domains(vec!["api.openai.com".to_string()]);
|
||||
@@ -1104,6 +1106,7 @@ mod tests {
|
||||
let mut settings = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
mode: NetworkMode::Limited,
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
settings.set_allowed_domains(vec!["example.com".to_string()]);
|
||||
@@ -1139,6 +1142,7 @@ mod tests {
|
||||
},
|
||||
..MitmHookConfig::default()
|
||||
}],
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
settings.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
@@ -1171,6 +1175,7 @@ mod tests {
|
||||
},
|
||||
..MitmHookConfig::default()
|
||||
}],
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
settings.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
|
||||
@@ -280,6 +280,7 @@ impl std::error::Error for SandboxTransformError {
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SandboxManager {
|
||||
linux_sandbox_pid_namespace: LinuxSandboxPidNamespace,
|
||||
linux_sandbox_transparent_tcp: bool,
|
||||
operation: SandboxOperation,
|
||||
#[cfg(target_os = "macos")]
|
||||
allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
|
||||
@@ -318,6 +319,12 @@ impl SandboxManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Route managed Linux command traffic through the transparent TCP adapter.
|
||||
pub fn with_linux_sandbox_transparent_tcp(mut self, enabled: bool) -> Self {
|
||||
self.linux_sandbox_transparent_tcp = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Allows otherwise-authorized writable roots beneath the opted-in user home
|
||||
/// to follow symlinks, including targets outside that home.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -663,6 +670,9 @@ impl SandboxManager {
|
||||
if self.linux_sandbox_pid_namespace == LinuxSandboxPidNamespace::Inherit {
|
||||
args.insert(0, "--inherit-pid-namespace".to_owned());
|
||||
}
|
||||
if self.linux_sandbox_transparent_tcp && managed_network.is_some() {
|
||||
args.insert(0, "--transparent-tcp".to_owned());
|
||||
}
|
||||
exec.command = std::iter::once(program).chain(args).collect();
|
||||
// Without bwrap --argv0, the Linux helper uses its own argv[0] as the inner executable.
|
||||
let arg0 = if let Some(path) = exe.to_str()
|
||||
|
||||
@@ -593,7 +593,15 @@ fn inherited_terminal_preserves_native_linux_helper_and_uses_its_dispatch_name()
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Result<()> {
|
||||
async fn linux_helper_uses_effective_managed_policy() -> anyhow::Result<()> {
|
||||
for transparent_tcp in [false, true] {
|
||||
check_linux_managed_policy(transparent_tcp).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn check_linux_managed_policy(transparent_tcp: bool) -> anyhow::Result<()> {
|
||||
use codex_network_proxy::ConfigReloader;
|
||||
use codex_network_proxy::ConfigReloaderFuture;
|
||||
use codex_network_proxy::ConfigState;
|
||||
@@ -647,7 +655,7 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu
|
||||
let denied = ManagedNetworkSandboxContext::default();
|
||||
let cwd = AbsolutePathBuf::current_dir()?;
|
||||
let cwd_uri = PathUri::from_abs_path(&cwd);
|
||||
let manager = SandboxManager::new();
|
||||
let manager = SandboxManager::new().with_linux_sandbox_transparent_tcp(transparent_tcp);
|
||||
for (label, context, live_proxy, enforce_managed_network, expected) in [
|
||||
(
|
||||
"missing policy stays managed with restrictive defaults",
|
||||
@@ -707,9 +715,13 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu
|
||||
use_legacy_landlock: false,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})?;
|
||||
let transported_context = prepared
|
||||
.into_command_request()
|
||||
.command
|
||||
let command = prepared.into_command_request().command;
|
||||
assert_eq!(
|
||||
command.iter().any(|arg| arg == "--transparent-tcp"),
|
||||
transparent_tcp && expected.is_some(),
|
||||
"{label}"
|
||||
);
|
||||
let transported_context = command
|
||||
.windows(2)
|
||||
.find(|args| args[0] == "--managed-network")
|
||||
.map(|args| serde_json::from_str::<ManagedNetworkSandboxContext>(&args[1]))
|
||||
|
||||
Reference in New Issue
Block a user