Skip to content

[Bubblewrap] Run proxy mode in a private network namespace via rootless slirp4netns (schema 0.8.0+) - #930

Open
Soham Das (SohamDas2021) wants to merge 3 commits into
mainfrom
user/sodas/bwrap-network-model2
Open

[Bubblewrap] Run proxy mode in a private network namespace via rootless slirp4netns (schema 0.8.0+)#930
Soham Das (SohamDas2021) wants to merge 3 commits into
mainfrom
user/sodas/bwrap-network-model2

Conversation

@SohamDas2021

@SohamDas2021 Soham Das (SohamDas2021) commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📖 Description

Moves Bubblewrap's proxy mode out of the host network namespace and into a private network namespace, routed by rootless slirp4netns, on schema 0.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.proxy on 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 one ResolvedNetworkMode computed once per request (LegacyProxy / ProxyOnly / …) and removes the standalone needs_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. Clears FD_CLOEXEC in 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:

  1. MXC spawns a supervisor (unshare --user --map-current-user --keep-caps) — a helper process holding a user namespace in which it has CAP_NET_ADMIN, even though lxc-exec itself does not. This is what makes the design work unprivileged.
  2. The supervisor starts slirp4netns to provide userspace routing into a fresh network namespace, and signals readiness only once routing is up.
  3. bwrap joins the supervisor's user namespace (rather than creating its own with --unshare-user) and drops capabilities.
  4. The configured proxy endpoint is translated for in-namespace reachability: a loopback proxy on the host becomes slirp's host gateway (10.0.2.2), preserving the original URL's scheme and credentials. Non-loopback endpoints are passed through unchanged.
  5. Teardown stops slirp and reaps the supervisor; Drop covers the error paths.

Limitations

  • Egress is not yet restricted. Inside the namespace, slirp4netns routes all outbound traffic, so a workload that ignores HTTP_PROXY/HTTPS_PROXY can 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.
  • New host prerequisite for this mode: slirp4netns, plus a util-linux unshare supporting --map-current-user and --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.
  • Per-host firewall filtering (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:

  • builtin proxy, proxy allowlist, proxy blocklist.
  • private proxy namespace — reads /proc/self/ns/net inside 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.
  • capability drop — asserts the workload does not retain the supervisor's user-namespace capabilities.
  • legacy (schema 0.7) compatibility — exercises the legacy path with slirp4netns unavailable, proving 0.6/0.7 neither probe nor use it.
  • supervisor orphan reaping — no leaked slirp4netns / bwrap processes after teardown.
  • run_bwrap_basic_test.sh, run_bwrap_network_test.sh — pass.

✅ Checklist

  • Signed the Contributor License Agreement
  • Linked to an issue
  • Updated documentation (if applicable)
  • Updated Copilot instructions (if build, architecture, or conventions changed)
  • If this PR changes Cargo.lock, the dependency-feed-check check passes

📋 Issue Type

  • Bug fix
  • Feature
  • Task

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 GitHub
Actions build; it runs on merge to main, and Microsoft reviewers with write access can trigger it
on a PR with /azp run. See docs/pull-requests.md.

If the dependency-feed-check check fails on a new dependency, the crate must be added to
the feed before the PR can pass. See docs/pull-requests.md
for the steps.

Microsoft Reviewers: Open in CodeFlow

@SohamDas2021
Soham Das (SohamDas2021) requested a review from a team as a code owner August 18, 2026 06:35
Copilot AI balanced review requested due to automatic review settings August 18, 2026 06:35
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the Copilot-Instructions PR modifies Copilot instruction files (.github/copilot-instructions.md or .github/instructions/) label Aug 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/backends/bubblewrap/common/src/proxy_network.rs Outdated
Comment thread src/backends/bubblewrap/common/src/proxy_network.rs Outdated
Comment thread src/backends/bubblewrap/common/src/proxy_network.rs Outdated
Comment thread tests/scripts/run_bwrap_network_proxy_test.sh

@bbonaby Branden Bonaby (bbonaby) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/backends/bubblewrap/common/src/proxy_network.rs
Comment thread src/backends/bubblewrap/common/src/bwrap_runner.rs Outdated
Copilot AI review requested due to automatic review settings August 18, 2026 18:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ::1 is rewritten to the IPv4-only 10.0.2.2 gateway. 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-net and 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,

Copilot AI review requested due to automatic review settings August 18, 2026 18:42
@SohamDas2021
Soham Das (SohamDas2021) force-pushed the user/sodas/bwrap-network-model2 branch from 14d57f5 to 7c546c7 Compare August 18, 2026 18:42
@SohamDas2021
Soham Das (SohamDas2021) force-pushed the user/sodas/bwrap-network-model2 branch from 7c546c7 to 17f09e3 Compare August 18, 2026 18:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • --configure sets 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 at 127.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. ProxyAddress accepts non-loopback IPv6 URL endpoints and this function deliberately leaves them unchanged, but slirp4netns only installs IPv6 connectivity when --enable-ipv6 is 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

Copilot AI review requested due to automatic review settings August 18, 2026 18:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bwrap itself exits; it does not make bwrap exit when lxc-exec is 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 at 10.0.2.2; slirp4netns exposes host IPv6 loopback through fd00::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
Copilot AI review requested due to automatic review settings August 19, 2026 03:39
@SohamDas2021
Soham Das (SohamDas2021) force-pushed the user/sodas/bwrap-network-model2 branch from 17f09e3 to 2ea464b Compare August 19, 2026 03:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.conf commonly points at 127.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 (normally 10.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 by local_network_denied_with_host_rules_warns), so attributing sharing only to defaultPolicy='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 -f checks 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";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +49 to +59
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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +122 to +132
# 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: FYI Elliot (@theelliotm) you might need to add slirp4netns to your prepare linux host script.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Copilot-Instructions PR modifies Copilot instruction files (.github/copilot-instructions.md or .github/instructions/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants