[Bubblewrap] Run proxy mode in a private network namespace via rootless slirp4netns (schema 0.8.0+) - #930
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Introduces schema 0.8+ Bubblewrap proxy networking through a private namespace backed by rootless slirp4netns.
Changes:
- Centralizes Bubblewrap network-mode resolution.
- Adds supervisor, namespace, slirp startup, and teardown lifecycle.
- Adds dependencies, documentation, and namespace validation coverage.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
tests/scripts/run_bwrap_network_proxy_test.sh |
Adds private-namespace validation. |
tests/configs/bubblewrap_network_proxy_namespace.json |
Adds schema 0.8 proxy test configuration. |
src/Cargo.lock |
Records the URL dependency. |
src/backends/bubblewrap/common/src/proxy_network.rs |
Implements private proxy networking. |
src/backends/bubblewrap/common/src/lib.rs |
Registers the networking module. |
src/backends/bubblewrap/common/src/bwrap_runner.rs |
Integrates namespace lifecycle with execution. |
src/backends/bubblewrap/common/src/bwrap_command.rs |
Centralizes network-mode selection and arguments. |
src/backends/bubblewrap/common/Cargo.toml |
Adds runtime dependencies. |
docs/bwrap-support/bubblewrap-backend.md |
Documents requirements and behavior. |
.github/workflows/Build.Linux.Job.yml |
Installs slirp4netns in GitHub Actions. |
.github/copilot-instructions.md |
Updates backend architecture guidance. |
.azure-pipelines/templates/SDK.Integration.Test.Job.yml |
Installs slirp4netns in Azure Pipelines. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Branden Bonaby (bbonaby)
left a comment
There was a problem hiding this comment.
Adversarial Rust review: posting the two unique serious findings. Existing Copilot comments already cover inheritable FDs, the orphaned supervisor startup window, and automated lifecycle coverage, so I did not duplicate them.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/backends/bubblewrap/common/src/proxy_network.rs:45
- Schema 0.8 breaks valid IPv6 proxy endpoints. This launch omits
--enable-ipv6, so slirp configures only IPv4; non-loopback IPv6 URLs are passed through unchanged, while::1is rewritten to the IPv4-only10.0.2.2gateway. Such proxies were reachable in the legacy shared namespace but are unreachable here. Enable slirp IPv6 and translate IPv6 loopback to its IPv6 host gateway, or reject IPv6 endpoints before launch.
exec slirp4netns --configure --mtu=65520 \
--ready-fd "$ready_fd" --exit-fd "$exit_fd" \
"$child_pid" tap0
src/backends/bubblewrap/common/src/bwrap_command.rs:236
- This public builder documentation is incorrect for the compatibility path: a proxy address on schema 0.6/0.7 or an absent version deliberately omits
--unshare-netand keeps the host network namespace. Document both schema-dependent branches so callers do not assume they must always supply an external user namespace.
/// - emits `--unshare-net` and expects the runner to provide `--userns FD`
/// plus slirp-backed connectivity,
14d57f5 to
7c546c7
Compare
7c546c7 to
17f09e3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/backends/bubblewrap/common/src/proxy_network.rs:45
--configuresets up the TAP interface but does not replace the sandbox's/etc/resolv.conf. Bubblewrap still bind-mounts the host resolver configuration, so on common systemd-resolved/dnsmasq hosts it points at127.0.0.x, which is the sandbox's own loopback in this new namespace. Consequently a schema-0.8 BYO proxy specified by hostname cannot be resolved, even though non-loopback endpoints are intended to pass through unchanged. Mount a proxy-mode resolver file using slirp's DNS address (10.0.2.3), preserving any required search/options entries, and cover a hostname-form external proxy.
exec slirp4netns --configure --mtu=65520 \
--ready-fd "$ready_fd" --exit-fd "$exit_fd" \
"$child_pid" tap0
src/backends/bubblewrap/common/src/proxy_network.rs:45
- The private namespace is configured for IPv4 only.
ProxyAddressaccepts non-loopback IPv6 URL endpoints and this function deliberately leaves them unchanged, but slirp4netns only installs IPv6 connectivity when--enable-ipv6is supplied; such proxies therefore become unreachable in schema 0.8 despite working on the legacy shared-network path. Enable slirp IPv6 (or explicitly reject IPv6 proxy endpoints before startup).
exec slirp4netns --configure --mtu=65520 \
--ready-fd "$ready_fd" --exit-fd "$exit_fd" \
"$child_pid" tap0
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/scripts/run_bwrap_network_proxy_test.sh:206
- Being PID 1 of the new PID namespace only tears down descendants when
bwrapitself exits; it does not makebwrapexit whenlxc-execis SIGKILLed. Because the generated arguments do not include--die-with-parent, a real bwrap/workload also survives this scenario. Add parent-death handling and assert both the supervisor and bwrap are gone rather than manually exempting bwrap from the test.
# The stub bwrap outlives the SIGKILLed executor by design; the real backend
# runs it as pid 1 of a pid namespace, so only this stub needs reaping. It
# records its own pid because it `exec`s sleep, leaving nothing for pkill to
src/backends/bubblewrap/common/src/proxy_network.rs:363
- All loopback URLs are rewritten to the IPv4 gateway. A valid proxy bound only to
[::1]will not be reachable at10.0.2.2; slirp4netns exposes host IPv6 loopback throughfd00::2. Select the gateway from the original address family and update the IPv6 translation test accordingly.
url.set_host(Some(SLIRP_HOST_GATEWAY)).map_err(|_| {
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae9db9df-6439-46c3-9cb4-fd70e7cc43a3
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae9db9df-6439-46c3-9cb4-fd70e7cc43a3
17f09e3 to
2ea464b
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/backends/bubblewrap/common/src/bwrap_command.rs:282
- On hosts using systemd-resolved,
/etc/resolv.confcommonly points at127.0.0.53. After this branch creates a private netns, that address is the sandbox's loopback, not the host resolver, so hostname-based external proxy URLs cannot resolve. The current E2E only uses the rewritten numeric builtin-proxy address and misses this. Provide a per-namespace resolver configuration using slirp's DNS endpoint (normally10.0.2.3) and cover a hostname proxy endpoint.
if network_mode.uses_private_netns() {
args.push("--unshare-net".into());
src/backends/bubblewrap/common/src/bwrap_command.rs:203
- This warning also runs with
defaultPolicy='block'when host rules force the shared-network mode (as exercised bylocal_network_denied_with_host_rules_warns), so attributing sharing only todefaultPolicy='allow'gives users a false diagnosis. Mention host rules and update the isolation recommendation accordingly.
(false, false) => Some(
"WARNING: Bubblewrap: network.allowLocalNetwork=false is not enforced while the \
sandbox shares the host network namespace (defaultPolicy='allow'). \
The sandboxed process can still bind, listen and accept on host-local addresses. For \
an unreachable sandbox use defaultPolicy='block' with no proxy, which applies \
--unshare-net.",
tests/scripts/run_bwrap_network_proxy_test.sh:170
- The unscoped
pgrep -f/pkill -fchecks match every MXC proxy supervisor owned by the user, not the supervisor launched by this test. A concurrent sandbox can therefore make the test fail and then be killed by the cleanup paths. Capture this executor's supervisor PID (for example, while it is still a child of$ORPHAN_EXEC_PID) and poll/signal only that PID.
SUPERVISOR_PATTERN="mxc-bwrap-proxy-supervisor"
|
|
||
| const STARTUP_TIMEOUT: Duration = Duration::from_secs(5); | ||
| const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); | ||
| const SLIRP_HOST_GATEWAY: &str = "10.0.2.2"; |
There was a problem hiding this comment.
question (non-blocking): Just so I completely understand the network traffic:
1. An app inside the container sends a request to 10.0.2.2:8080.
2. The Slirp engine intercepts these raw network packets in user space
3. Slirp strips away the virtual 10.0.2.2 IP header.
4. Slirp uses standard unprivileged system calls to re-transmit that data directly to 127.0.0.1:8080 on the host
Does this look like what should be happening?
| pub(crate) struct BwrapStartup { | ||
| info_reader: File, | ||
| info_writer: Option<OwnedFd>, | ||
| gate_reader: Option<OwnedFd>, | ||
| gate_writer: Option<OwnedFd>, | ||
| /// Descriptors bwrap must inherit, cleared of `FD_CLOEXEC` in the child | ||
| /// only. See [`inherit_descriptors`]. | ||
| inheritable: Vec<RawFd>, | ||
| } | ||
|
|
||
| impl BwrapStartup { |
There was a problem hiding this comment.
Note: Now that the Rust SDK is in-proc, we should consider whether operations like polling need to be asynchronous. This is likely fine if public APIs such as SpawnSandbox and Run expose async variants, allowing callers to avoid blocking while our internal implementation remains synchronous.
| # Exercises the real binary against the real dependencies: launches the | ||
| # slirp4netns supervisor, joins its user namespace, and asserts the | ||
| # sandbox lands in a private network namespace. Needs unix-test-proxy | ||
| # (builtinTestServer) alongside lxc-exec, which the two build steps | ||
| # above place in the same target directory. | ||
| - name: Test Bubblewrap proxy networking (end-to-end) | ||
| working-directory: ${{ github.workspace }} | ||
| env: | ||
| LXC_EXEC: ${{ github.workspace }}/src/target/${{ matrix.target }}/release/lxc-exec | ||
| run: bash tests/scripts/run_bwrap_network_proxy_test.sh | ||
|
|
There was a problem hiding this comment.
note: FYI Elliot (@theelliotm) you might need to add slirp4netns to your prepare linux host script.
📖 Description
Moves Bubblewrap's proxy mode out of the host network namespace and into a private network namespace, routed by rootless
slirp4netns, on schema0.8.0-alpha+. This is the foundation that per-flow egress enforcement is built on; enforcement itself lands in a follow-up PR.Today
network.proxyon Bubblewrap runs the sandbox in the host's network namespace. Even setting aside egress policy, that means the sandbox shares the host's interfaces, loopback, and localhost-bound services — there is no network boundary to enforce anything against. This PR creates that boundary, without requiring root or elevation.Structure
f910778f— centralize network mode selection. Pure refactor, no behavior change. Bubblewrap previously decided "shared netns vs--unshare-net", "does this need iptables", and "is the cooperative proxy active" independently at three call sites. This collapses them into oneResolvedNetworkModecomputed once per request (LegacyProxy/ProxyOnly/ …) and removes the standaloneneeds_iptables_rules()helper. This is what makes the schema gate below a single decision point instead of three correlated booleans.229405ae— private network namespace + slirp lifecycle, behind the 0.8 gate.17f09e3d— PR feedback. ClearsFD_CLOEXECin the forked child rather than the parent, so the descriptors are not inheritable by concurrent spawns; adds legacy-path, capability-drop and orphan-reaping E2E cases; wires the proxy E2E into CI.How it works
For schema 0.8+ proxy requests:
unshare --user --map-current-user --keep-caps) — a helper process holding a user namespace in which it hasCAP_NET_ADMIN, even thoughlxc-execitself does not. This is what makes the design work unprivileged.slirp4netnsto provide userspace routing into a fresh network namespace, and signals readiness only once routing is up.bwrapjoins the supervisor's user namespace (rather than creating its own with--unshare-user) and drops capabilities.10.0.2.2), preserving the original URL's scheme and credentials. Non-loopback endpoints are passed through unchanged.Dropcovers the error paths.Limitations
slirp4netnsroutes all outbound traffic, so a workload that ignoresHTTP_PROXY/HTTPS_PROXYcan still reach the network directly. The proxy remains cooperative at this commit; the namespace is the boundary that makes enforcement possible. Deny-all-except-proxy (AB#62864253 §2) follows in the next PR.slirp4netns, plus a util-linuxunsharesupporting--map-current-userand--keep-caps. Both are probed at validate time and fail fast with an actionable message. Neither requires root. CI installs them and runs the E2E.network.hostRules) is not wired into this mode; the existing proxy/firewall mutual exclusivity in the config parser still applies.🔍 Validation
Automated:
cargo test -p bwrap_common— pass, incl. 12 new tests (network-mode resolution across schema versions, proxy-address translation for loopback / non-loopback / URL-form endpoints, and supervisor lifecycle).cargo clippy -p bwrap_common --all-targets -- -D warnings,cargo fmt --check— clean.node scripts/versioning/validate-configs.js.E2E —
tests/scripts/run_bwrap_network_proxy_test.sh, 7/7 pass. Runs in GitHub Actions (Build.Linux.Job.yml) against the real binary and real dependencies, and locally on Ubuntu 24.04 (WSL2) unprivileged, no sudo:/proc/self/ns/netinside the sandbox and fails if it matches the host's identity, then confirms a proxied request completes. A silent fallback to the host netns fails the test rather than passes it.slirp4netnsunavailable, proving 0.6/0.7 neither probe nor use it.slirp4netns/bwrapprocesses after teardown.run_bwrap_basic_test.sh,run_bwrap_network_test.sh— pass.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes📋 Issue Type
GitHub Actions runs the PR validation build automatically. The ADO pipeline
(
MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with the GitHubActions build; it runs on merge to
main, and Microsoft reviewers with write access can trigger iton a PR with
/azp run. See docs/pull-requests.md.If the
dependency-feed-checkcheck fails on a new dependency, the crate must be added tothe feed before the PR can pass. See docs/pull-requests.md
for the steps.
Microsoft Reviewers: Open in CodeFlow