diff --git a/.azure-pipelines/templates/Package.NpmSdk.Job.yml b/.azure-pipelines/templates/Package.NpmSdk.Job.yml index 042825d79..183d366a2 100644 --- a/.azure-pipelines/templates/Package.NpmSdk.Job.yml +++ b/.azure-pipelines/templates/Package.NpmSdk.Job.yml @@ -63,8 +63,8 @@ jobs: - script: | set -euo pipefail - find $(sdkDirectory)/bin -name linux-test-proxy -exec chmod +x {} + - displayName: Restore execute permission on linux-test-proxy + find $(sdkDirectory)/bin -name unix-test-proxy -exec chmod +x {} + + displayName: Restore execute permission on unix-test-proxy # Drop symbol files from the npm package — symbols ship separately. - script: rm -rf $(sdkDirectory)/bin/*/symbols diff --git a/.azure-pipelines/templates/Rust.Build.Job.yml b/.azure-pipelines/templates/Rust.Build.Job.yml index fc260be13..d5e85908d 100644 --- a/.azure-pipelines/templates/Rust.Build.Job.yml +++ b/.azure-pipelines/templates/Rust.Build.Job.yml @@ -63,7 +63,7 @@ jobs: cargoFeatures: hyperlight signPattern: | lxc-exec - linux-test-proxy + unix-test-proxy # Paths targetTriple: $(triplet) @@ -124,13 +124,13 @@ jobs: ${{ else }}: enableTest: 'false' - # linux_test_proxy is a separate workspace member (not a dep of lxc), so + # unix_test_proxy is a separate workspace member (not a dep of lxc), so # the workspace-member-scoped build above doesn't pick it up. Build it # explicitly here so it lands alongside lxc-exec in the target dir and # gets copied into the artifact via signPattern. - script: | - cargo build --release --target $(triplet) -p linux_test_proxy --manifest-path $(Build.SourcesDirectory)/src/Cargo.toml - displayName: Build linux-test-proxy + cargo build --release --target $(triplet) -p unix_test_proxy --manifest-path $(Build.SourcesDirectory)/src/Cargo.toml + displayName: Build unix-test-proxy condition: eq('${{ item.os }}', 'linux') # plm (Permissive Learning Mode) is functionally Windows-only, but it diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e4d7fec95..de2f9522a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -193,7 +193,7 @@ The workspace is organized into six top-level directories under `src/`: | `backends/` | Backend-specific code (one subfolder per containment backend) | `appcontainer/common`, `windows_sandbox/{daemon,guest,common,lifecycle}`, `isolation_session/{bindings,common}`, `hyperlight/common`, `nanvix/{common,build_common,binaries,runner}`, `lxc/common`, `bubblewrap/common`, `wslc/common`, `seatbelt/common` | | `ffi/` | Foreign-function-interface crates (C ABI for language bindings) | `mxc_ffi/` | | `host/` | Host-side utilities | `wxc_host_prep/`, `wxc_winhttp_proxy_shim/` | -| `testing/` | Test infrastructure crates | `wxc_e2e_tests/`, `wxc_test_driver/`, `wxc_test_proxy/`, `linux_test_proxy/`, `wxc_ui_probe/`, `fuzz/` | +| `testing/` | Test infrastructure crates | `wxc_e2e_tests/`, `wxc_test_driver/`, `wxc_test_proxy/`, `unix_test_proxy/`, `wxc_ui_probe/`, `fuzz/` | | `tools/` | Developer/diagnostic tools | `mxc_diagnostic_console/` | - `wxc_common` is the **cross-platform foundation**: config parsing, models, errors, logger, `ScriptRunner` / `StatefulSandboxBackend` traits, state-aware dispatch helpers, validators, ids, ui-policy, encoding. Plus a few thin Windows API helpers shared by host tools and backends (`process_util`, `string_util`, `filesystem_dacl`, `diagnostic`). It must not depend on any `backends/*` crate. diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index d4f29dd7b..cdb4108b2 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -97,11 +97,11 @@ jobs: working-directory: src run: cargo test --locked --release --target ${{ matrix.target }} -p plm - # linux_test_proxy is a separate workspace member, not a dep of lxc. - - name: Build linux-test-proxy + # unix_test_proxy is a separate workspace member, not a dep of lxc. + - name: Build unix-test-proxy working-directory: src run: cargo build --locked --release --target ${{ matrix.target }} - -p linux_test_proxy + -p unix_test_proxy - name: Upload binaries uses: actions/upload-artifact@v4 @@ -109,6 +109,6 @@ jobs: name: lxc-binaries-${{ matrix.target }} path: | src/target/${{ matrix.target }}/release/lxc-exec - src/target/${{ matrix.target }}/release/linux-test-proxy + src/target/${{ matrix.target }}/release/unix-test-proxy if-no-files-found: error retention-days: 1 diff --git a/.github/workflows/Package.NpmSdk.Job.yml b/.github/workflows/Package.NpmSdk.Job.yml index 72ad351f0..ee927d133 100644 --- a/.github/workflows/Package.NpmSdk.Job.yml +++ b/.github/workflows/Package.NpmSdk.Job.yml @@ -54,7 +54,7 @@ jobs: shell: bash run: | set -euo pipefail - for name in lxc-exec linux-test-proxy mxc-exec-mac; do + for name in lxc-exec unix-test-proxy mxc-exec-mac; do find bin -name "$name" -exec chmod +x {} + done diff --git a/README.md b/README.md index 3aed32c4f..3ad93940c 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ cargo clippy --workspace --all-targets -- -D warnings # Linux Rust (from src/; matches build.sh's platform-compatible crate set) -cargo clippy -p lxc -p lxc_common -p wxc_common -p bwrap_common -p linux_test_proxy --all-targets -- -D warnings +cargo clippy -p lxc -p lxc_common -p wxc_common -p bwrap_common -p unix_test_proxy --all-targets -- -D warnings # macOS Rust (from src/) diff --git a/build.sh b/build.sh index 92171cda8..16f8f99e7 100644 --- a/build.sh +++ b/build.sh @@ -70,7 +70,7 @@ echo "=== Building Rust binaries ($BUILD_TYPE) ===" cd "$SRC_DIR" # Packages to build and lint — kept in one place so build and clippy stay in sync. -LXC_PACKAGES=(-p lxc -p lxc_common -p wxc_common -p bwrap_common -p linux_test_proxy) +LXC_PACKAGES=(-p lxc -p lxc_common -p wxc_common -p bwrap_common -p unix_test_proxy) CARGO_FEATURES=() FEATURES_LIST=() @@ -126,16 +126,16 @@ if [ -n "$TARGET_TRIPLE" ]; then cp "$SRC_DIR/target/release/lxc-exec" "$BIN_DIR/" 2>/dev/null || \ cp "$SRC_DIR/target/$TARGET_TRIPLE/release/lxc-exec" "$BIN_DIR/" 2>/dev/null || \ echo "Warning: Could not find lxc-exec binary to copy" - cp "$SRC_DIR/target/release/linux-test-proxy" "$BIN_DIR/" 2>/dev/null || \ - cp "$SRC_DIR/target/$TARGET_TRIPLE/release/linux-test-proxy" "$BIN_DIR/" 2>/dev/null || \ - echo "Warning: Could not find linux-test-proxy binary to copy" + cp "$SRC_DIR/target/release/unix-test-proxy" "$BIN_DIR/" 2>/dev/null || \ + cp "$SRC_DIR/target/$TARGET_TRIPLE/release/unix-test-proxy" "$BIN_DIR/" 2>/dev/null || \ + echo "Warning: Could not find unix-test-proxy binary to copy" else cp "$SRC_DIR/target/debug/lxc-exec" "$BIN_DIR/" 2>/dev/null || \ cp "$SRC_DIR/target/$TARGET_TRIPLE/debug/lxc-exec" "$BIN_DIR/" 2>/dev/null || \ echo "Warning: Could not find lxc-exec binary to copy" - cp "$SRC_DIR/target/debug/linux-test-proxy" "$BIN_DIR/" 2>/dev/null || \ - cp "$SRC_DIR/target/$TARGET_TRIPLE/debug/linux-test-proxy" "$BIN_DIR/" 2>/dev/null || \ - echo "Warning: Could not find linux-test-proxy binary to copy" + cp "$SRC_DIR/target/debug/unix-test-proxy" "$BIN_DIR/" 2>/dev/null || \ + cp "$SRC_DIR/target/$TARGET_TRIPLE/debug/unix-test-proxy" "$BIN_DIR/" 2>/dev/null || \ + echo "Warning: Could not find unix-test-proxy binary to copy" fi fi diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 584cdc9d6..0c2b79df4 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -227,7 +227,7 @@ Bubblewrap because it requires **no root and no `CAP_NET_ADMIN`**. 1. When `network.proxy` is set, the runner launches an unprivileged HTTP proxy on loopback (`127.0.0.1:N`). For tests, the bundled - `linux-test-proxy` binary is used (`builtinTestServer: true`, + `unix-test-proxy` binary is used (`builtinTestServer: true`, testing-only and gated behind `--allow-testing-features`); in production callers supply their own proxy via `localhost: ` or `url: `. 2. The sandbox is then started **without** `--unshare-net` so the sandbox diff --git a/sdk/node/tests/integration/linux-bubblewrap.test.ts b/sdk/node/tests/integration/linux-bubblewrap.test.ts index 28ab3724c..b3ab39620 100644 --- a/sdk/node/tests/integration/linux-bubblewrap.test.ts +++ b/sdk/node/tests/integration/linux-bubblewrap.test.ts @@ -13,7 +13,7 @@ import { isLinuxBubblewrap, debugSpawnOptions, spawnFromConfigAsync, - startLinuxTestProxy, + startUnixTestProxy, } from './test-helpers.js'; import type { ChildProcess } from 'node:child_process'; @@ -102,8 +102,8 @@ describe('Linux Bubblewrap network proxy (schema 0.6.0-alpha)', { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } }); - it('should route HTTPS traffic through an externally launched linux-test-proxy', async () => { - const { port, proxyProcess } = startLinuxTestProxy(tmpDir); + it('should route HTTPS traffic through an externally launched unix-test-proxy', async () => { + const { port, proxyProcess } = startUnixTestProxy(tmpDir); proxies.push(proxyProcess); const config = sdk.createConfigFromPolicy( diff --git a/sdk/node/tests/integration/test-helpers.ts b/sdk/node/tests/integration/test-helpers.ts index deae57225..45ede83d6 100644 --- a/sdk/node/tests/integration/test-helpers.ts +++ b/sdk/node/tests/integration/test-helpers.ts @@ -56,7 +56,7 @@ export const EXPECTED_WINDOWS_BINARIES = [ export const EXPECTED_LINUX_BINARIES = [ 'lxc-exec', - 'linux-test-proxy', + 'unix-test-proxy', ]; export const EXPECTED_MACOS_BINARIES = [ @@ -383,31 +383,34 @@ export function startTestProxy(dir: string): { port: number; proxyProcess: Child return { port, proxyProcess }; } -// Linux-only: linux-test-proxy helpers +// Linux-only: unix-test-proxy helpers -/** Locate linux-test-proxy in the SDK package bin directory. */ -function findLinuxTestProxyBinary(): string { +/** Locate unix-test-proxy in the SDK package bin directory. */ +function findUnixTestProxyBinary(): string { const binDir = getSdkBinDir(); - const proxyPath = path.join(binDir, 'linux-test-proxy'); + const proxyPath = path.join(binDir, 'unix-test-proxy'); if (fs.existsSync(proxyPath)) { return proxyPath; } - throw new Error(`linux-test-proxy not found at expected SDK package location: ${proxyPath}`); + throw new Error(`unix-test-proxy not found at expected SDK package location: ${proxyPath}`); } /** - * Start linux-test-proxy in a child process. + * Start unix-test-proxy in a child process. * * Binds to an OS-assigned port on `127.0.0.1` and writes it atomically to a - * ready file. The proxy uses `PR_SET_PDEATHSIG` so it dies when the test - * process exits — no cleanup-event mechanism is needed on Linux. + * ready file. The proxy watches its stdin for EOF as a cross-platform + * parent-death signal, so it must be spawned with a piped stdin that this + * process keeps open: when the test process exits the pipe closes, the proxy + * reads EOF and shuts down. An ignored/inherited `/dev/null` stdin would + * signal EOF immediately and make the proxy exit right after binding. */ -export function startLinuxTestProxy( +export function startUnixTestProxy( dir: string, opts: { allowHosts?: string[]; blockHosts?: string[] } = {}, ): { port: number; proxyProcess: ChildProcess } { - const proxyPath = findLinuxTestProxyBinary(); - const readyFile = path.join(dir, 'linux-proxy-ready.txt'); + const proxyPath = findUnixTestProxyBinary(); + const readyFile = path.join(dir, 'unix-proxy-ready.txt'); const args: string[] = ['--ready-file', readyFile, '--bind-address', '127.0.0.1']; for (const host of opts.allowHosts ?? []) { @@ -417,7 +420,10 @@ export function startLinuxTestProxy( args.push('--block-host', host); } - const proxyProcess = spawn(proxyPath, args, { stdio: 'ignore' }); + // stdin must stay open (piped, held by this process) so the proxy's + // stdin-EOF parent-death watcher only fires when the test process exits; + // `stdio: 'ignore'` would give it a `/dev/null` stdin that EOFs instantly. + const proxyProcess = spawn(proxyPath, args, { stdio: ['pipe', 'ignore', 'ignore'] }); const deadline = Date.now() + 15000; while (!fs.existsSync(readyFile) && Date.now() < deadline) { @@ -426,7 +432,7 @@ export function startLinuxTestProxy( if (!fs.existsSync(readyFile)) { proxyProcess.kill('SIGTERM'); - throw new Error('linux-test-proxy did not write ready file within 15 seconds'); + throw new Error('unix-test-proxy did not write ready file within 15 seconds'); } const portStr = fs.readFileSync(readyFile, 'utf-8').trim(); diff --git a/src/Cargo.lock b/src/Cargo.lock index 61afbfa6b..da866c140 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1425,19 +1425,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "linux_test_proxy" -version = "0.1.0" -dependencies = [ - "bytes", - "clap", - "http-body-util", - "hyper", - "hyper-util", - "libc", - "tokio", -] - [[package]] name = "litemap" version = "0.8.2" @@ -2603,6 +2590,19 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unix_test_proxy" +version = "0.1.0" +dependencies = [ + "bytes", + "clap", + "http-body-util", + "hyper", + "hyper-util", + "tempfile", + "tokio", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src/Cargo.toml b/src/Cargo.toml index f4ef321e3..cc540b9ce 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -30,7 +30,7 @@ members = [ "testing/wxc_e2e_tests", "testing/wxc_test_driver", "testing/wxc_test_proxy", - "testing/linux_test_proxy", + "testing/unix_test_proxy", "testing/wxc_ui_probe", "tools/mxc_diagnostic_console", "tools/mxc_schema_gen", diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index 5a8636279..86baabd87 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -13,7 +13,7 @@ //! supports two paths: //! - **Cooperative env-var proxy** (default, no privilege required): when //! `network.proxy` is configured the runner launches an unprivileged HTTP -//! proxy via [`wxc_common::linux_proxy_coordinator::LinuxProxyCoordinator`] +//! proxy via [`wxc_common::unix_proxy_coordinator::UnixProxyCoordinator`] //! and the command builder injects `HTTP_PROXY` / `HTTPS_PROXY` / //! `NO_PROXY` env vars into the sandbox. //! - **iptables firewall** (requires `CAP_NET_ADMIN` / root): when @@ -34,7 +34,6 @@ use std::time::Duration; use lxc_common::network_iptables::NetworkIptablesManager; use wxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller}; -use wxc_common::linux_proxy_coordinator::LinuxProxyCoordinator; use wxc_common::logger::Logger; use wxc_common::models::{ExecutionRequest, NetworkEnforcementMode, ScriptResponse}; use wxc_common::sandbox_process::{ @@ -42,6 +41,7 @@ use wxc_common::sandbox_process::{ take_boxed_write, wait_with_timeout, SandboxBackend, SandboxProcess, StdioMode, StreamCloser, WaitError, }; +use wxc_common::unix_proxy_coordinator::UnixProxyCoordinator; use wxc_common::validator::validate_common; use crate::bwrap_command; @@ -171,7 +171,7 @@ impl BubblewrapScriptRunner { // 1. Start the network proxy if configured. Must happen before // arg-building so the proxy's loopback address can be injected as // HTTP_PROXY / HTTPS_PROXY into the sandbox environment. - let mut proxy = LinuxProxyCoordinator::new(); + let mut proxy = UnixProxyCoordinator::new(); if request.policy.network_proxy.is_enabled() { if let Err(err) = proxy.start( &request.policy.network_proxy, @@ -340,7 +340,7 @@ struct BwrapChild { /// termination signals the whole group; `false` for `Inherit` mode, where /// killing bwrap (pid 1 of the namespace) alone tears the sandbox down. group: bool, - proxy: LinuxProxyCoordinator, + proxy: UnixProxyCoordinator, fw_manager: Option, timeout: Option, } diff --git a/src/core/wxc_common/src/lib.rs b/src/core/wxc_common/src/lib.rs index 80c44e578..853a3f765 100644 --- a/src/core/wxc_common/src/lib.rs +++ b/src/core/wxc_common/src/lib.rs @@ -53,9 +53,10 @@ pub mod string_util; #[cfg(unix)] pub mod interruptible_reader; -// Linux-specific modules -#[cfg(target_os = "linux")] -pub mod linux_proxy_coordinator; +// Unix cooperative network proxy coordinator, used by the Bubblewrap (Linux) +// and Seatbelt (macOS) backends. +#[cfg(unix)] +pub mod unix_proxy_coordinator; /// Test-only helper for env-var serialization within this crate's /// `filesystem_dacl` tests. The same shape lives in diff --git a/src/core/wxc_common/src/linux_proxy_coordinator.rs b/src/core/wxc_common/src/unix_proxy_coordinator.rs similarity index 85% rename from src/core/wxc_common/src/linux_proxy_coordinator.rs rename to src/core/wxc_common/src/unix_proxy_coordinator.rs index ef7d3b5c5..bf840db49 100644 --- a/src/core/wxc_common/src/linux_proxy_coordinator.rs +++ b/src/core/wxc_common/src/unix_proxy_coordinator.rs @@ -1,20 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Coordinator for the Linux network proxy used by the Bubblewrap backend. +//! Coordinator for the cooperative network proxy used by the Bubblewrap +//! (Linux) and Seatbelt (macOS) backends. //! //! # Why this exists //! -//! The Bubblewrap backend wants to enforce `allowedHosts` / `blockedHosts` +//! Both backends want to route sandboxed traffic through an HTTP proxy //! **without** root or `CAP_NET_ADMIN`. The Windows AppContainer proxy //! coordinator achieves enforcement through WinHTTP policy (set by an -//! elevated shim). On Linux, the equivalent without privilege is a +//! elevated shim). On Unix the equivalent without privilege is a //! **cooperative env-var proxy**: //! //! 1. The coordinator launches an unprivileged HTTP proxy process (either a -//! user-supplied address, or the bundled `linux-test-proxy` binary). -//! 2. The Bubblewrap command builder sets `HTTP_PROXY` / `HTTPS_PROXY` / -//! `NO_PROXY` env vars inside the sandbox. +//! user-supplied address, or the bundled `unix-test-proxy` binary). +//! 2. The backend's command builder / runner sets `HTTP_PROXY` / +//! `HTTPS_PROXY` env vars inside the sandbox (Bubblewrap via `--setenv`, +//! Seatbelt via the child's cleared-then-populated environment). //! 3. Cooperative apps (curl, requests, etc.) honor the env vars and the //! proxy applies allow/block filtering; non-cooperative apps reaching //! out via raw sockets bypass enforcement (documented limitation). @@ -24,17 +26,18 @@ //! - **Privilege-free**: no iptables, no namespaces, no setuid binaries. //! - **Bind address is configurable** to allow future LXC reuse (LXC has //! its own netns, so the proxy needs to bind on the bridge gateway IP). -//! Bubblewrap shares the host netns and passes `"127.0.0.1"`. -//! - **Atomic ready-file**: `linux-test-proxy` writes `.tmp` and +//! Bubblewrap and Seatbelt share the host netns and pass `"127.0.0.1"`. +//! - **Atomic ready-file**: `unix-test-proxy` writes `.tmp` and //! renames into place to eliminate partial-read races. -//! - **PR_SET_PDEATHSIG** in the child ensures the proxy dies if -//! `lxc-exec` crashes (orphan reaping safety net). +//! - **Parent pipe**: the child watches stdin for EOF so it exits if the +//! executor crashes, on both Linux and macOS. //! - **`Drop` is silent**: never writes to stderr or `Logger`, because //! destructors can run at unpredictable times (e.g. during panic -//! propagation) and noisy drops would corrupt the JSON envelope on -//! `lxc-exec`'s stderr. +//! propagation) and noisy drops would corrupt the JSON envelope on the +//! executor's stderr. use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -81,11 +84,7 @@ fn create_private_temp_dir(unique_id: &str) -> Result { })?; // Best-effort 0700 chmod so other users cannot snoop the ready file. - #[cfg(target_os = "linux")] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); - } + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); Ok(dir) } @@ -116,7 +115,7 @@ fn resolve_sibling_binary(name: &str) -> Result { } } -/// Bookkeeping for a running `linux-test-proxy` child process. +/// Bookkeeping for a running `unix-test-proxy` child process. struct TestProxyChild { child: Child, ready_file: PathBuf, @@ -157,26 +156,24 @@ fn wait_with_timeout(child: &mut Child, timeout: Duration) -> bool { } } -/// Coordinator for the network proxy used by the Bubblewrap backend. +/// Coordinator for the network proxy used by Unix backends. /// /// Cooperative model: launches an unprivileged HTTP proxy (either external -/// or the bundled `linux-test-proxy`), and the caller is responsible for +/// or the bundled `unix-test-proxy`), and the caller is responsible for /// setting `HTTP_PROXY` / `HTTPS_PROXY` env vars inside the sandbox. /// /// The coordinator is **not** active until [`start`](Self::start) succeeds, /// and is automatically cleaned up by [`stop`](Self::stop) or `Drop`. -pub struct LinuxProxyCoordinator { +#[derive(Default)] +pub struct UnixProxyCoordinator { proxy_address: Option, test_proxy: Option, } -impl LinuxProxyCoordinator { +impl UnixProxyCoordinator { /// Create an inactive coordinator. Call [`start`](Self::start) to launch. pub fn new() -> Self { - Self { - proxy_address: None, - test_proxy: None, - } + Self::default() } /// Returns `true` once a proxy has been started. @@ -192,7 +189,7 @@ impl LinuxProxyCoordinator { /// Activate the proxy. /// /// - If `proxy_config.builtin_test_server` is `true`, launches the - /// bundled `linux-test-proxy` binary on `bind_address:0` and reads + /// bundled `unix-test-proxy` binary on `bind_address:0` and reads /// the assigned port from the proxy's ready file. `allowed_hosts`, /// `blocked_hosts`, and `default_policy` are passed to the test /// proxy as `--allow-host` / `--block-host` / `--default-policy` @@ -218,7 +215,7 @@ impl LinuxProxyCoordinator { ) -> Result<(), WxcError> { if self.is_active() { return Err(WxcError::NetworkProxy( - "Linux network proxy is already active".into(), + "Unix network proxy is already active".into(), )); } @@ -245,17 +242,13 @@ impl LinuxProxyCoordinator { )); }; + logger.log_line(&format!("Unix network proxy active: {}", address.to_url(),)); self.proxy_address = Some(address); - logger.log_line(&format!( - "Linux network proxy active: {}", - self.proxy_address.as_ref().unwrap().to_url(), - )); - Ok(()) } - /// Spawn `linux-test-proxy` and read its port from the ready file. + /// Spawn `unix-test-proxy` and read its port from the ready file. /// /// On any post-spawn error this method kills the child, waits briefly, /// and removes the temp directory before returning -- callers must @@ -269,7 +262,7 @@ impl LinuxProxyCoordinator { logger: &mut Logger, ) -> Result { logger.log_line( - "WARNING: Starting builtin linux-test-proxy -- this is for integration \ + "WARNING: Starting builtin unix-test-proxy -- this is for integration \ testing only, NOT for production use.", ); @@ -277,7 +270,7 @@ impl LinuxProxyCoordinator { let temp_dir = create_private_temp_dir(&unique_id)?; let ready_file = temp_dir.join("ready.port"); - let proxy_exe = match resolve_sibling_binary("linux-test-proxy") { + let proxy_exe = match resolve_sibling_binary("unix-test-proxy") { Ok(path) => path, Err(err) => { remove_temp_dir(&temp_dir); @@ -303,10 +296,11 @@ impl LinuxProxyCoordinator { for host in block_hosts { cmd.arg("--block-host").arg(host); } - // Use a null stderr to avoid corrupting the JSON envelope that - // lxc-exec writes on its own stderr stream (the proxy may emit - // diagnostic lines). - cmd.stdin(Stdio::null()) + // Keep a private stdin pipe open for the child's parent-lifetime + // watcher. If the executor exits unexpectedly, EOF tells the proxy to + // shut down. Null stdout/stderr avoid corrupting the executor's JSON + // envelope with proxy diagnostics. + cmd.stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()); @@ -315,7 +309,7 @@ impl LinuxProxyCoordinator { Err(err) => { remove_temp_dir(&temp_dir); return Err(WxcError::NetworkProxy(format!( - "Failed to launch linux-test-proxy: {}", + "Failed to launch unix-test-proxy: {}", err ))); } @@ -342,7 +336,7 @@ impl LinuxProxyCoordinator { }); logger.log_line(&format!( - "linux-test-proxy listening on {}:{}", + "unix-test-proxy listening on {}:{}", bind_address, port )); @@ -354,13 +348,13 @@ impl LinuxProxyCoordinator { pub fn stop(&mut self, logger: &mut Logger) { if let Some(mut tp) = self.test_proxy.take() { let pid = tp.child.id(); - logger.log_line("Stopping linux-test-proxy..."); + logger.log_line("Stopping unix-test-proxy..."); send_sigterm(pid); if wait_with_timeout(&mut tp.child, STOP_TIMEOUT) { - logger.log_line("linux-test-proxy exited."); + logger.log_line("unix-test-proxy exited."); } else { logger - .log_line("Warning: linux-test-proxy did not exit within 5s; sending SIGKILL."); + .log_line("Warning: unix-test-proxy did not exit within 5s; sending SIGKILL."); send_sigkill(pid); let _ = tp.child.wait(); } @@ -371,13 +365,7 @@ impl LinuxProxyCoordinator { } } -impl Default for LinuxProxyCoordinator { - fn default() -> Self { - Self::new() - } -} - -impl Drop for LinuxProxyCoordinator { +impl Drop for UnixProxyCoordinator { /// Silent best-effort cleanup if the coordinator is still active at /// drop time. **Never** writes to stderr or `Logger` because the drop /// may run during panic unwinding and we must not corrupt the JSON @@ -408,14 +396,14 @@ fn poll_for_port(ready_file: &Path, child: &mut Child) -> Result match child.try_wait() { Ok(Some(status)) => { return Err(WxcError::NetworkProxy(format!( - "linux-test-proxy exited before becoming ready (status: {:?})", + "unix-test-proxy exited before becoming ready (status: {:?})", status ))); } Ok(None) => {} Err(err) => { return Err(WxcError::NetworkProxy(format!( - "Failed to query linux-test-proxy status: {}", + "Failed to query unix-test-proxy status: {}", err ))); } @@ -428,7 +416,7 @@ fn poll_for_port(ready_file: &Path, child: &mut Child) -> Result if Instant::now() >= deadline { return Err(WxcError::NetworkProxy(format!( - "Timed out waiting for linux-test-proxy ready file ({:?})", + "Timed out waiting for unix-test-proxy ready file ({:?})", READY_TIMEOUT ))); } @@ -449,12 +437,12 @@ fn poll_for_port(ready_file: &Path, child: &mut Child) -> Result // port is useless to the caller and must surface as an error. match child.try_wait() { Ok(Some(status)) => Err(WxcError::NetworkProxy(format!( - "linux-test-proxy exited immediately after publishing port (status: {:?})", + "unix-test-proxy exited immediately after publishing port (status: {:?})", status ))), Ok(None) => Ok(port), Err(err) => Err(WxcError::NetworkProxy(format!( - "Failed to re-check linux-test-proxy status: {}", + "Failed to re-check unix-test-proxy status: {}", err ))), } @@ -470,20 +458,20 @@ mod tests { #[test] fn new_coordinator_is_inactive() { - let c = LinuxProxyCoordinator::new(); + let c = UnixProxyCoordinator::new(); assert!(!c.is_active()); assert!(c.address().is_none()); } #[test] fn default_coordinator_is_inactive() { - let c = LinuxProxyCoordinator::default(); + let c = UnixProxyCoordinator::default(); assert!(!c.is_active()); } #[test] fn start_with_disabled_proxy_is_noop() { - let mut c = LinuxProxyCoordinator::new(); + let mut c = UnixProxyCoordinator::new(); let mut logger = make_logger(); let cfg = ProxyConfig::default(); assert!(!cfg.is_enabled()); @@ -501,7 +489,7 @@ mod tests { #[test] fn start_with_external_address_activates() { - let mut c = LinuxProxyCoordinator::new(); + let mut c = UnixProxyCoordinator::new(); let mut logger = make_logger(); let cfg = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".into(), 8888)), @@ -528,7 +516,7 @@ mod tests { #[test] fn start_is_rejected_if_already_active() { - let mut c = LinuxProxyCoordinator::new(); + let mut c = UnixProxyCoordinator::new(); let mut logger = make_logger(); let cfg = ProxyConfig { address: Some(ProxyAddress::new("127.0.0.1".into(), 9001)), @@ -562,7 +550,7 @@ mod tests { #[test] fn stop_is_idempotent() { - let mut c = LinuxProxyCoordinator::new(); + let mut c = UnixProxyCoordinator::new(); let mut logger = make_logger(); c.stop(&mut logger); c.stop(&mut logger); @@ -606,7 +594,7 @@ mod tests { .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .expect("sleep is available on Linux test hosts"); + .expect("sleep is available on Unix test hosts"); // Override the timeout: use a short window for the test by // temporarily inlining the polling logic. @@ -651,7 +639,7 @@ mod tests { .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .expect("true is available on Linux test hosts"); + .expect("true is available on Unix test hosts"); // Give the child a moment to exit. std::thread::sleep(Duration::from_millis(100)); diff --git a/src/testing/linux_test_proxy/src/main.rs b/src/testing/linux_test_proxy/src/main.rs deleted file mode 100644 index 32247dfe0..000000000 --- a/src/testing/linux_test_proxy/src/main.rs +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Standalone binary for the Linux builtin test proxy. -//! -//! **Testing-only tool.** Launches a minimal HTTP CONNECT proxy on an -//! OS-assigned port, atomically writes the port to a ready file, then waits -//! for SIGTERM or parent death before shutting down. -//! -//! Designed to be spawned by `wxc_common::linux_proxy_coordinator` to provide -//! cooperative, unprivileged proxy-based enforcement of `allowedHosts` / -//! `blockedHosts` for the Bubblewrap backend. - -#[cfg(target_os = "linux")] -mod proxy; - -#[cfg(target_os = "linux")] -mod linux_main { - use std::fs; - use std::path::PathBuf; - use std::sync::Arc; - - use clap::{Parser, ValueEnum}; - - use crate::proxy; - - #[derive(Copy, Clone, Debug, ValueEnum)] - pub enum DefaultPolicyArg { - Allow, - Block, - } - - impl From for proxy::DefaultPolicy { - fn from(value: DefaultPolicyArg) -> Self { - match value { - DefaultPolicyArg::Allow => proxy::DefaultPolicy::Allow, - DefaultPolicyArg::Block => proxy::DefaultPolicy::Block, - } - } - } - - #[derive(Parser)] - #[command( - name = "linux-test-proxy", - about = "Builtin test proxy for MXC Bubblewrap integration testing (NOT for production use)" - )] - pub struct Cli { - /// Path where the proxy atomically writes its port number once ready. - #[arg(long = "ready-file")] - pub ready_file: PathBuf, - - /// Address to bind on. Defaults to loopback. Future LXC/Seatbelt - /// callers can pass the bridge gateway IP so the proxy is reachable - /// from inside a separate netns. - #[arg(long = "bind-address", default_value = "127.0.0.1")] - pub bind_address: String, - - /// Hosts permitted by the proxy. May be repeated. When empty, the - /// default policy (see `--default-policy`) decides. - #[arg(long = "allow-host")] - pub allow_host: Vec, - - /// Hosts denied by the proxy. May be repeated. Block takes precedence - /// over allow. - #[arg(long = "block-host")] - pub block_host: Vec, - - /// Policy applied when the allow list is empty. - /// - /// - `allow` — permit any host that isn't explicitly blocked. - /// - `block` — deny any host that isn't explicitly allowed. - /// - /// Ignored when `--allow-host` is non-empty (only listed hosts pass). - #[arg(long = "default-policy", value_enum, default_value_t = DefaultPolicyArg::Allow)] - pub default_policy: DefaultPolicyArg, - } - - pub async fn run() -> std::process::ExitCode { - // 1. Tie our lifetime to the parent so a crash of `lxc-exec` cannot - // leave us behind. Must happen before any work — and we must check - // for the parent-already-dead race immediately after. - // - // NOTE: `PR_SET_PDEATHSIG` is a per-thread setting and applies to - // the thread that invokes `prctl`. We rely on `#[tokio::main]` - // keeping THIS thread (the runtime's driver) alive for the whole - // process lifetime. Do NOT move this prctl call into a spawned - // tokio task; that would silently break the parent-death guarantee - // when the spawning thread parks or migrates. - unsafe { - libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0); - if libc::getppid() == 1 { - // Parent died before prctl took effect. Bail before binding - // anything to avoid leaking a listening socket. - return std::process::ExitCode::from(0); - } - } - - eprintln!( - "[linux-test-proxy] *** SECURITY WARNING ***: testing-only proxy. Do NOT use in production." - ); - - let cli = Cli::parse(); - - let filter = Arc::new(proxy::HostFilter::new( - cli.allow_host.clone(), - cli.block_host.clone(), - cli.default_policy.into(), - )); - - let port = match proxy::start(&cli.bind_address, filter).await { - Ok(port) => port, - Err(err) => { - eprintln!( - "[linux-test-proxy] failed to bind {}: {}", - cli.bind_address, err - ); - return std::process::ExitCode::from(1); - } - }; - - eprintln!( - "[linux-test-proxy] Listening on {}:{}", - cli.bind_address, port - ); - - // 2. Atomic ready-file: write to `.tmp`, then rename. This - // eliminates partial-read windows when the parent polls the file. - let tmp_path = cli.ready_file.with_extension("tmp"); - if let Err(err) = fs::write(&tmp_path, port.to_string()) { - eprintln!( - "[linux-test-proxy] Failed to write ready tmp file {}: {}", - tmp_path.display(), - err - ); - return std::process::ExitCode::from(1); - } - if let Err(err) = fs::rename(&tmp_path, &cli.ready_file) { - eprintln!( - "[linux-test-proxy] Failed to rename ready file to {}: {}", - cli.ready_file.display(), - err - ); - let _ = fs::remove_file(&tmp_path); - return std::process::ExitCode::from(1); - } - - // 3. Wait for SIGTERM (parent's explicit stop signal) or SIGINT - // (ctrl-C during manual testing). PR_SET_PDEATHSIG above also - // delivers SIGTERM if the parent dies. - let mut term = - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { - Ok(s) => s, - Err(err) => { - eprintln!( - "[linux-test-proxy] failed to install SIGTERM handler: {}", - err - ); - return std::process::ExitCode::from(1); - } - }; - let mut interrupt = - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) { - Ok(s) => s, - Err(err) => { - eprintln!( - "[linux-test-proxy] failed to install SIGINT handler: {}", - err - ); - return std::process::ExitCode::from(1); - } - }; - - tokio::select! { - _ = term.recv() => eprintln!("[linux-test-proxy] received SIGTERM, shutting down"), - _ = interrupt.recv() => eprintln!("[linux-test-proxy] received SIGINT, shutting down"), - } - - std::process::ExitCode::from(0) - } -} - -#[cfg(target_os = "linux")] -#[tokio::main] -async fn main() -> std::process::ExitCode { - linux_main::run().await -} - -#[cfg(not(target_os = "linux"))] -fn main() { - eprintln!( - "linux-test-proxy: this binary is only supported on Linux. Use wxc-test-proxy on Windows." - ); - std::process::exit(1); -} diff --git a/src/testing/linux_test_proxy/Cargo.toml b/src/testing/unix_test_proxy/Cargo.toml similarity index 75% rename from src/testing/linux_test_proxy/Cargo.toml rename to src/testing/unix_test_proxy/Cargo.toml index e8d0a2248..e8f261fec 100644 --- a/src/testing/linux_test_proxy/Cargo.toml +++ b/src/testing/unix_test_proxy/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "linux_test_proxy" +name = "unix_test_proxy" version = "0.1.0" edition.workspace = true license.workspace = true [[bin]] -name = "linux-test-proxy" +name = "unix-test-proxy" path = "src/main.rs" [dependencies] @@ -16,5 +16,5 @@ http-body-util = "0.1" bytes = "1" clap.workspace = true -[target.'cfg(unix)'.dependencies] -libc.workspace = true +[dev-dependencies] +tempfile.workspace = true diff --git a/src/testing/unix_test_proxy/src/main.rs b/src/testing/unix_test_proxy/src/main.rs new file mode 100644 index 000000000..7314c8dcf --- /dev/null +++ b/src/testing/unix_test_proxy/src/main.rs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Standalone binary for the Unix builtin test proxy. +//! +//! **Testing-only tool.** Launches a minimal HTTP CONNECT proxy on an +//! OS-assigned port, atomically writes the port to a ready file, then waits +//! for SIGTERM, SIGINT, or EOF on its parent-lifetime pipe before shutting +//! down. +//! +//! Designed to be spawned by `wxc_common::unix_proxy_coordinator` to provide +//! cooperative, unprivileged proxy-based enforcement of `allowedHosts` / +//! `blockedHosts`. Used by the Bubblewrap backend on Linux and the Seatbelt +//! backend on macOS. It builds and runs on any Unix; the CONNECT proxy itself +//! (`proxy`) is platform-neutral. + +#[cfg(unix)] +mod proxy; + +#[cfg(unix)] +mod unix_main { + use std::fs; + use std::path::PathBuf; + use std::sync::Arc; + + use clap::{Parser, ValueEnum}; + + use crate::proxy; + + #[derive(Copy, Clone, Debug, ValueEnum)] + pub enum DefaultPolicyArg { + Allow, + Block, + } + + impl From for proxy::DefaultPolicy { + fn from(value: DefaultPolicyArg) -> Self { + match value { + DefaultPolicyArg::Allow => proxy::DefaultPolicy::Allow, + DefaultPolicyArg::Block => proxy::DefaultPolicy::Block, + } + } + } + + #[derive(Parser)] + #[command( + name = "unix-test-proxy", + about = "Builtin test proxy for MXC Bubblewrap/Seatbelt integration testing (NOT for production use)" + )] + pub struct Cli { + /// Path where the proxy atomically writes its port number once ready. + #[arg(long = "ready-file")] + pub ready_file: PathBuf, + + /// Address to bind on. Bubblewrap and Seatbelt use loopback; an LXC + /// caller can pass its bridge gateway to cross a separate netns. + #[arg(long = "bind-address", default_value = "127.0.0.1")] + pub bind_address: String, + + /// Hosts permitted by the proxy. May be repeated. When empty, the + /// default policy (see `--default-policy`) decides. + #[arg(long = "allow-host")] + pub allow_host: Vec, + + /// Hosts denied by the proxy. May be repeated. Block takes precedence + /// over allow. + #[arg(long = "block-host")] + pub block_host: Vec, + + /// Policy applied when the allow list is empty. + /// + /// - `allow` — permit any host that isn't explicitly blocked. + /// - `block` — deny any host that isn't explicitly allowed. + /// + /// Ignored when `--allow-host` is non-empty (only listed hosts pass). + #[arg(long = "default-policy", value_enum, default_value_t = DefaultPolicyArg::Allow)] + pub default_policy: DefaultPolicyArg, + } + + /// Spawn a background thread that fires the returned receiver when the + /// parent process disconnects. + /// + /// The proxy inherits a pipe on stdin whose write end is held open by the + /// coordinator (its parent). When the parent exits — normally, on a crash, + /// or on `SIGKILL` — the kernel closes that write end, the watcher's + /// `read` returns EOF (`Ok(0)`), and the receiver is signalled so [`run`] + /// can shut down. This is the portable replacement for Linux's + /// `PR_SET_PDEATHSIG` and behaves identically on Linux and macOS. + /// + /// # Caveats versus `PR_SET_PDEATHSIG` + /// + /// - **fd inheritance**: the signal is tied to the *write end of the stdin + /// pipe*, not to the parent pid. If that fd is duplicated into another + /// process that outlives the parent, killing the direct parent will not + /// produce EOF and the proxy keeps running. The coordinator spawns the + /// child with `Stdio::piped()`, whose parent-side handle is `CLOEXEC`, + /// so sibling processes do not inherit it — but callers that dup stdin + /// must be aware of this. + /// - **startup race**: the watcher runs on its own thread, so there is a + /// brief window in which the child can bind and publish its port before + /// an already-dead parent's EOF is observed. [`run`] narrows this with a + /// non-blocking [`exit_if_parent_disconnected`] check before binding and + /// before publishing; the `select!` closes any residual window. + fn parent_disconnect_signal() -> Result, std::io::Error> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + std::thread::Builder::new() + .name("mxc-proxy-parent-watch".into()) + .spawn(move || { + // Drain stdin until the parent closes its write end (EOF) or + // the read hard-errors; either way the parent is gone. `copy` + // retries `Interrupted` and discards any bytes to the sink. + let _ = std::io::copy(&mut std::io::stdin(), &mut std::io::sink()); + let _ = sender.send(()); + })?; + Ok(receiver) + } + + /// If the parent has already disconnected — stdin EOF observed by the + /// watcher thread — return a success exit code so [`run`] can bail out at + /// `stage` (before binding a socket or publishing the ready file) instead + /// of leaving an orphaned listener behind. + /// + /// This is the portable analog of the old Linux-only `getppid() == 1` + /// guard. It is inherently racy — the watcher thread may not have observed + /// EOF yet — so the authoritative shutdown still happens on the + /// `parent_disconnected` arm of the run loop's `select!`. + fn exit_if_parent_disconnected( + receiver: &mut tokio::sync::oneshot::Receiver<()>, + stage: &str, + ) -> Option { + if !matches!(receiver.try_recv(), Ok(())) { + return None; + } + eprintln!("[unix-test-proxy] parent disconnected before {stage}, exiting"); + Some(std::process::ExitCode::from(0)) + } + + pub async fn run() -> std::process::ExitCode { + let mut parent_disconnected = match parent_disconnect_signal() { + Ok(signal) => signal, + Err(error) => { + eprintln!("[unix-test-proxy] failed to start parent watcher: {error}"); + return std::process::ExitCode::from(1); + } + }; + + eprintln!( + "[unix-test-proxy] *** SECURITY WARNING ***: testing-only proxy. Do NOT use in production." + ); + + let Cli { + ready_file, + bind_address, + allow_host, + block_host, + default_policy, + } = Cli::parse(); + + let filter = Arc::new(proxy::HostFilter::new( + allow_host, + block_host, + default_policy.into(), + )); + + if let Some(code) = exit_if_parent_disconnected(&mut parent_disconnected, "bind") { + return code; + } + + let port = match proxy::start(&bind_address, filter).await { + Ok(port) => port, + Err(err) => { + eprintln!("[unix-test-proxy] failed to bind {}: {}", bind_address, err); + return std::process::ExitCode::from(1); + } + }; + + eprintln!("[unix-test-proxy] Listening on {}:{}", bind_address, port); + + // 2. Atomic ready-file: write to `.tmp`, then rename. This + // eliminates partial-read windows when the parent polls the file. + // Re-check the parent first so a parent that died during bind does + // not get a published port for a proxy that is about to exit. + if let Some(code) = exit_if_parent_disconnected(&mut parent_disconnected, "publish") { + return code; + } + let tmp_path = ready_file.with_extension("tmp"); + if let Err(err) = fs::write(&tmp_path, port.to_string()) { + eprintln!( + "[unix-test-proxy] Failed to write ready tmp file {}: {}", + tmp_path.display(), + err + ); + return std::process::ExitCode::from(1); + } + if let Err(err) = fs::rename(&tmp_path, &ready_file) { + eprintln!( + "[unix-test-proxy] Failed to rename ready file to {}: {}", + ready_file.display(), + err + ); + let _ = fs::remove_file(&tmp_path); + return std::process::ExitCode::from(1); + } + + // 3. Wait for the parent's explicit stop signal, ctrl-C during manual + // testing, or EOF if the parent exits without running cleanup. + let mut term = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(s) => s, + Err(err) => { + eprintln!( + "[unix-test-proxy] failed to install SIGTERM handler: {}", + err + ); + return std::process::ExitCode::from(1); + } + }; + let mut interrupt = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) { + Ok(s) => s, + Err(err) => { + eprintln!( + "[unix-test-proxy] failed to install SIGINT handler: {}", + err + ); + return std::process::ExitCode::from(1); + } + }; + + tokio::select! { + _ = term.recv() => eprintln!("[unix-test-proxy] received SIGTERM, shutting down"), + _ = interrupt.recv() => eprintln!("[unix-test-proxy] received SIGINT, shutting down"), + _ = parent_disconnected => { + eprintln!("[unix-test-proxy] parent disconnected, shutting down") + }, + } + + std::process::ExitCode::from(0) + } +} + +#[cfg(unix)] +#[tokio::main] +async fn main() -> std::process::ExitCode { + unix_main::run().await +} + +#[cfg(not(unix))] +fn main() { + eprintln!( + "unix-test-proxy: this binary is only supported on Unix (Linux/macOS). Use wxc-test-proxy on Windows." + ); + std::process::exit(1); +} diff --git a/src/testing/linux_test_proxy/src/proxy.rs b/src/testing/unix_test_proxy/src/proxy.rs similarity index 91% rename from src/testing/linux_test_proxy/src/proxy.rs rename to src/testing/unix_test_proxy/src/proxy.rs index 43585664e..f5e2bb4c5 100644 --- a/src/testing/linux_test_proxy/src/proxy.rs +++ b/src/testing/unix_test_proxy/src/proxy.rs @@ -5,9 +5,8 @@ //! forwarding, with optional allow / block host filtering enforced at the //! proxy. //! -//! Adapted from `wxc_test_proxy::proxy` and extended with host filtering so -//! the Bubblewrap backend can enforce `allowedHosts` / `blockedHosts` at the -//! proxy layer instead of via privileged iptables rules. +//! Adapted from `wxc_test_proxy::proxy` and extended with host filtering for +//! the cooperative proxy used by Bubblewrap and Seatbelt. use std::sync::Arc; @@ -127,7 +126,7 @@ pub async fn start(bind_addr: &str, filter: Arc) -> std::io::Result< }); } Err(err) => { - eprintln!("[linux-test-proxy] accept error: {}", err); + eprintln!("[unix-test-proxy] accept error: {}", err); } } } @@ -156,19 +155,15 @@ async fn handle_connect( .ok_or("CONNECT missing authority")? .to_string(); - let host = strip_port(&authority); - if !filter.permits(host) { - eprintln!("[linux-test-proxy] BLOCK CONNECT {}", authority); + if !filter.permits(&authority) { + eprintln!("[unix-test-proxy] BLOCK CONNECT {}", authority); return Ok(empty_response(StatusCode::FORBIDDEN)); } - eprintln!("[linux-test-proxy] CONNECT {}", authority); + eprintln!("[unix-test-proxy] CONNECT {}", authority); let server = TcpStream::connect(&authority).await.map_err(|err| { - eprintln!( - "[linux-test-proxy] connect error for {}: {}", - authority, err - ); + eprintln!("[unix-test-proxy] connect error for {}: {}", authority, err); err })?; @@ -177,7 +172,7 @@ async fn handle_connect( let upgraded = match hyper::upgrade::on(req).await { Ok(upgraded) => upgraded, Err(err) => { - eprintln!("[linux-test-proxy] upgrade failed for {}: {}", target, err); + eprintln!("[unix-test-proxy] upgrade failed for {}: {}", target, err); return; } }; @@ -188,7 +183,7 @@ async fn handle_connect( tokio::io::copy_bidirectional(&mut client, &mut server).await { eprintln!( - "[linux-test-proxy] tunnel closed {} (client: {} bytes, server: {} bytes)", + "[unix-test-proxy] tunnel closed {} (client: {} bytes, server: {} bytes)", target, from_client, from_server ); } @@ -206,18 +201,18 @@ async fn handle_forward( let host = uri.host().ok_or("missing host in URI")?; if !filter.permits(host) { - eprintln!("[linux-test-proxy] BLOCK {} {}", method, uri); + eprintln!("[unix-test-proxy] BLOCK {} {}", method, uri); return Ok(empty_response(StatusCode::FORBIDDEN)); } let port = uri.port_u16().unwrap_or(80); let addr = format!("{}:{}", host, port); - eprintln!("[linux-test-proxy] {} {}", method, uri); + eprintln!("[unix-test-proxy] {} {}", method, uri); let stream = TcpStream::connect(&addr).await.map_err(|err| { eprintln!( - "[linux-test-proxy] forward connect error for {}: {}", + "[unix-test-proxy] forward connect error for {}: {}", addr, err ); err @@ -228,7 +223,7 @@ async fn handle_forward( tokio::spawn(async move { if let Err(err) = conn.await { - eprintln!("[linux-test-proxy] forward connection error: {}", err); + eprintln!("[unix-test-proxy] forward connection error: {}", err); } }); diff --git a/src/testing/unix_test_proxy/tests/parent_disconnect.rs b/src/testing/unix_test_proxy/tests/parent_disconnect.rs new file mode 100644 index 000000000..9da5ff30e --- /dev/null +++ b/src/testing/unix_test_proxy/tests/parent_disconnect.rs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Regression test for the stdin-EOF parent-lifetime watcher that replaced +//! Linux's `PR_SET_PDEATHSIG`. +//! +//! The proxy inherits a piped stdin whose write end the parent holds open; +//! when the parent disconnects (closes that write end) the watcher must read +//! EOF and shut the proxy down. Nothing else in the suite exercises this +//! path — the integration tests tear the proxy down with an explicit +//! `SIGTERM` — so a watcher that never fired would otherwise pass unnoticed. + +#![cfg(unix)] + +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +/// Poll `path` until it exists or the deadline passes, failing fast if the +/// proxy exits early. +fn wait_for_ready_file(child: &mut Child, path: &Path, deadline: Instant) { + while !path.exists() { + if let Some(status) = child.try_wait().expect("poll proxy status") { + panic!("proxy exited before writing ready file: {status}"); + } + assert!( + Instant::now() < deadline, + "proxy did not write ready file before deadline" + ); + sleep(Duration::from_millis(25)); + } +} + +/// Poll for the child to exit or the deadline to pass. +fn wait_for_exit(child: &mut Child, deadline: Instant) -> Option { + loop { + if let Some(status) = child.try_wait().expect("poll proxy status") { + return Some(status); + } + if Instant::now() >= deadline { + return None; + } + sleep(Duration::from_millis(25)); + } +} + +#[test] +fn exits_when_parent_closes_stdin() { + let dir = tempfile::tempdir().expect("create temp dir"); + let ready_file = dir.path().join("ready.port"); + + // Spawn with a piped stdin we hold open — mirroring the coordinator — so + // the watcher only observes EOF when we drop the handle below. + let mut child = Command::new(env!("CARGO_BIN_EXE_unix-test-proxy")) + .arg("--ready-file") + .arg(&ready_file) + .arg("--bind-address") + .arg("127.0.0.1") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn unix-test-proxy"); + + wait_for_ready_file( + &mut child, + &ready_file, + Instant::now() + Duration::from_secs(15), + ); + + // Simulate parent disconnect: closing the write end of the child's stdin + // sends EOF, which must drive the proxy through its `parent_disconnected` + // shutdown arm. + drop(child.stdin.take().expect("child stdin handle present")); + + match wait_for_exit(&mut child, Instant::now() + Duration::from_secs(10)) { + Some(status) => assert!( + status.success(), + "proxy exited unsuccessfully after stdin EOF: {status}" + ), + None => { + let _ = child.kill(); + let _ = child.wait(); + panic!("proxy did not exit within 10s of stdin EOF"); + } + } +}