Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
96ad57b
Give a v0.8 container that asks for no network no interface at all
dhoehna Aug 25, 2026
3353de6
Revert the ingress header wording fix
dhoehna Aug 26, 2026
686a468
Stop enabling bridge netfilter in the SDK integration job
dhoehna Aug 26, 2026
f380e03
Split the LXC network decision on the schema before anything else
dhoehna Aug 26, 2026
846b6b1
Let a policy that permits nothing pass without a firewall chain
dhoehna Aug 27, 2026
c532a08
Stop loading br_netfilter for the LXC test lanes
dhoehna Aug 27, 2026
aa4d630
Enforce LXC egress in the container's namespace instead of host FORWARD
dhoehna Aug 27, 2026
ba9432e
Describe the enforcement point the LXC backend actually uses
dhoehna Aug 27, 2026
21a97e0
Confirm the OUTPUT hook in the log and assert the leak it closed
dhoehna Aug 27, 2026
16e4e8a
Let a filtered container reach its own loopback
dhoehna Aug 27, 2026
5cc9afa
Reflow the error return the egress move left unformatted
dhoehna Aug 27, 2026
f0c3f1f
Correct the LXC backend document where it no longer matches the code
dhoehna Aug 27, 2026
840750d
Apply this run's network policy to a container an earlier run left ru…
dhoehna Aug 27, 2026
2dd0ba6
Pin the schema 0.8 omitted-network-section case with an E2E test
dhoehna Aug 27, 2026
a926451
Honor legacy network fields on a schema 0.8 request
dhoehna Aug 27, 2026
e42227f
Give back the network interface a previous run took away
dhoehna Aug 27, 2026
8d19083
Name the rule the bridge resolver paragraph points back to
dhoehna Aug 28, 2026
4cbe9b2
Drop the two paragraphs that document the change instead of the behavior
dhoehna Aug 28, 2026
8cf8f97
Apply the no-interface network policy per run, not to the container
dhoehna Aug 28, 2026
e60b1b8
Kill a reused container instead of waiting a minute to restart it
dhoehna Aug 28, 2026
7d9c86a
Say why a running container must be restarted, not how it used to be
dhoehna Aug 28, 2026
6074222
Pin the rule that a no-interface container is never failed for its ad…
dhoehna Aug 28, 2026
9b13de8
Pin that lxc-start receives the topology as per-run defines
dhoehna Aug 28, 2026
d03e1d8
Name the start overrides for what they override
dhoehna Aug 28, 2026
6046e70
Name the start config for what it states, not what it replaces
dhoehna Aug 28, 2026
8a0b1b4
Give lxc-start the network choice as a type, not a string bag
dhoehna Aug 28, 2026
0410e99
Derive the tool name from the command instead of passing it alongside
dhoehna Aug 28, 2026
27ced7c
Assert the whole lxc-start argv, not that its pieces are present
dhoehna Aug 28, 2026
40e9f56
Plan LXC networking from the keys the config used, not its version
dhoehna Aug 28, 2026
aa45176
Enforce a legacy network policy that permits nothing
dhoehna Aug 28, 2026
0ded8ce
Declare the network bindings where they are used
dhoehna Aug 28, 2026
ae598a4
Derive the network schema from the policy instead of storing it
dhoehna Aug 28, 2026
eb82db7
Say what the network and stop bindings are for
dhoehna Aug 28, 2026
5cbda39
Format the network ingress and spec files
dhoehna Aug 28, 2026
83bde16
Document the no-network topology and correct the proxy loopback table
dhoehna Aug 29, 2026
b451b0b
Wait for an address whenever the container is given an interface
dhoehna Aug 29, 2026
98535b4
Take a failed OUTPUT hook back out before giving up its claim
dhoehna Aug 29, 2026
1fc4747
Let a container renew its DHCP lease through a filtered chain
Aug 31, 2026
a1f865e
Stop a reused container inheriting an earlier run's mounts
Aug 31, 2026
98fbc31
Scope the DHCP exemption so it cannot carry ordinary egress
Aug 31, 2026
b382cc0
Merge remote-tracking branch 'origin/main' into user/dahoehna/ci-lxc-…
dhoehna Aug 31, 2026
fff1ce6
Enforce a named host list whatever mode the policy carries
dhoehna Aug 31, 2026
5baabc0
Stop a reused container when network setup fails
dhoehna Aug 31, 2026
bf51b7c
Describe the egress hook point as OUTPUT, not FORWARD
dhoehna Aug 31, 2026
803d50b
Test that a deny-all 0.8 request keeps container loopback
dhoehna Sep 1, 2026
c7bb5bd
Report a container release that fails instead of discarding it
dhoehna Sep 1, 2026
60a2e73
Merge remote-tracking branch 'origin/main' into user/dahoehna/ci-lxc-…
dhoehna Sep 1, 2026
b69bd19
Stop a sandboxed workload reconfiguring its own network
dhoehna Sep 3, 2026
6f9ba70
Prove the network-capability confinement from a Rust E2E test
dhoehna Sep 5, 2026
5b02a3f
Assert only that the workload cannot reconfigure the network
dhoehna Sep 5, 2026
2d86239
Read the capability mask where it is asserted
dhoehna Sep 5, 2026
5a3b92a
Carry the capability as a yes or no
dhoehna Sep 5, 2026
293292d
Cut the capability comments back to what the code cannot say
dhoehna Sep 8, 2026
72f4201
Count blocked egress on the host instead of asking the container
dhoehna Sep 10, 2026
4909c45
Cut history and a banner out of two comments
dhoehna Sep 11, 2026
67d4cf4
Show what the network probe prints
dhoehna Sep 11, 2026
43ad590
Lead the resolution-failure section with its precondition
dhoehna Sep 11, 2026
3f664b2
Check every capability set, not just the effective one
dhoehna Sep 11, 2026
6fcd658
Tag the counter rules so the test only touches its own
dhoehna Sep 11, 2026
ed0f6e9
Name the three things a failed readiness probe reports
dhoehna Sep 11, 2026
43f65d2
Name the capability bypass where the egress limits are listed
dhoehna Sep 11, 2026
9ccc902
Keep the networking internals high level
dhoehna Sep 11, 2026
3f7248b
Measure the raw path instead of passing when it is absent
dhoehna Sep 11, 2026
bebe28e
Quarantine the raw egress test instead of gating on it
dhoehna Sep 11, 2026
41717c7
Drop the DHCP client exemption from the egress chain
dhoehna Sep 11, 2026
05ac955
Delete the unused set_config_item config-file writer
dhoehna Sep 11, 2026
bd54d1e
Name the mount-entry writer for what it gives the container
dhoehna Sep 11, 2026
e2b535c
Delete the test that gated on a bypass staying open
dhoehna Sep 11, 2026
0dbcf1e
Name the capability step for what it protects
dhoehna Sep 11, 2026
884e69d
Drop the dedicated capability test step
dhoehna Sep 11, 2026
f71a578
Cut the LXC backend doc to what a person needs to decide
dhoehna Sep 11, 2026
e6805a6
Delete comments that restate the code they sit above
dhoehna Sep 11, 2026
9829541
Delete comments that restate the code across the LXC backend
dhoehna Sep 12, 2026
ef9a137
Tear down egress firewall debris when an apply fails under preservePo…
dhoehna Sep 12, 2026
095bb0b
Give the kernel prerequisite a real floor
dhoehna Sep 12, 2026
5272ec6
Merge remote-tracking branch 'origin/main' into user/dahoehna/ci-lxc-…
SohamDas2021 Sep 15, 2026
24884f5
Addressed PR comments
SohamDas2021 Sep 15, 2026
0fb24b9
Confine the workload only when a firewall is installed
SohamDas2021 Sep 16, 2026
ccd09c1
Merge remote-tracking branch 'origin/main' into user/dahoehna/ci-lxc-…
SohamDas2021 Sep 16, 2026
429188c
Merge remote-tracking branch 'origin/main' into user/dahoehna/ci-lxc-…
SohamDas2021 Sep 16, 2026
86db660
Own mounts by marker, and run the capability test in CI
SohamDas2021 Sep 16, 2026
c851dfc
Addresses pr comments
SohamDas2021 Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions .github/workflows/SDK.Integration.Test.Job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,6 @@ jobs:
sudo systemctl start lxc-net
sudo systemctl is-active --quiet lxc-net

# A bridged veth reaches the LXC firewall chain only while br_netfilter
# delivers bridged packets to iptables, and the backend refuses to
# report success for a policy it cannot enforce.
- name: Enable bridge netfilter
if: matrix.os_label == 'linux'
shell: bash
run: |
set -euo pipefail
sudo modprobe br_netfilter
sudo sysctl -w net.bridge.bridge-nf-call-iptables=1
sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1

- name: Restore execute permission on mxc-exec-mac
if: matrix.os_label == 'macos'
shell: bash
Expand Down
22 changes: 9 additions & 13 deletions .github/workflows/lxc-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,6 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
lxc lxc-templates lxc-utils iptables debootstrap uidmap bridge-utils

# A bridged veth only reaches the FORWARD chain while br_netfilter is
# delivering bridged packets to iptables. Without it the firewall rules
# install cleanly and never fire, so the network policy tests would pass
# against a firewall that filters nothing.
- name: Enable bridge netfilter
run: |
sudo modprobe br_netfilter
sudo sysctl -w net.bridge.bridge-nf-call-iptables=1
sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1
Comment thread
dhoehna marked this conversation as resolved.

# GitHub-hosted runners ship Docker, and Docker sets the IPv4 FORWARD
# policy to DROP. That breaks these tests twice over.
#
Expand Down Expand Up @@ -95,9 +85,6 @@ jobs:
echo "--- forward policy (must be ACCEPT, or deny cases pass vacuously) ---"
sudo iptables -S FORWARD | head -1
sudo ip6tables -S FORWARD | head -1
echo "--- bridge netfilter ---"
cat /proc/sys/net/bridge/bridge-nf-call-iptables || echo "MISSING bridge-nf-call-iptables"
cat /proc/sys/net/bridge/bridge-nf-call-ip6tables || echo "MISSING bridge-nf-call-ip6tables"
echo "--- host ipv6 ---"
cat /proc/net/if_inet6 || echo "no /proc/net/if_inet6 (IPv6 disabled)"

Expand All @@ -118,6 +105,15 @@ jobs:
MXC_LXC_TESTS_REQUIRE_EXECUTION: "1"
run: sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION bash tests/scripts/run_lxc_all_tests.sh

# The shell suite above does not cover the capability confinement, and the
# Linux build lane runs this crate without LXC installed, so these tests
# skip everywhere else.
- name: Run LXC Rust E2E tests
working-directory: src
env:
MXC_LXC_TESTS_REQUIRE_EXECUTION: "1"
run: sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION,PATH,HOME "$(command -v cargo)" test -p wxc_e2e_tests --test e2e_lxc_network_capability

- name: Show leftover firewall state on failure
if: failure()
run: |
Expand Down
569 changes: 163 additions & 406 deletions docs/lxc-support/lxc-backend.md

Large diffs are not rendered by default.

20 changes: 0 additions & 20 deletions scripts/ci/prepare-linux-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -221,26 +221,7 @@ ensure_bridge_nat() {
fi
}

# Container network policy is programmed as iptables rules reached from
# FORWARD, which only sees bridged traffic when br_netfilter is loaded and
# bridge-nf-call-iptables is enabled. Neither is guaranteed on a fresh image,
# and without them the backend refuses to report success for a policy it
# cannot enforce.
enable_bridge_netfilter() {
if ! sudo modprobe br_netfilter 2>/dev/null; then
echo "WARNING: could not load br_netfilter; bridged traffic may bypass iptables." >&2
fi

local knob
for knob in bridge-nf-call-iptables bridge-nf-call-ip6tables; do
if [[ -e "/proc/sys/net/bridge/$knob" ]]; then
sudo sysctl -w "net.bridge.$knob=1" >/dev/null ||
echo "WARNING: could not enable net.bridge.$knob." >&2
else
echo "WARNING: /proc/sys/net/bridge/$knob is absent; container network policy cannot be enforced." >&2
fi
done
}

# Verifies the interpreters test suites drive inside the sandbox and reports
# what the image actually provides, so a tool the image was built without stays
Expand Down Expand Up @@ -333,7 +314,6 @@ case "$backend" in
sudo apparmor_parser -rT /etc/apparmor.d/lxc* 2>/dev/null || true
fi
start_lxc_bridge
enable_bridge_netfilter
ensure_bridge_nat
;;
microvm)
Expand Down
41 changes: 38 additions & 3 deletions sdk/node/tests/integration/linux-process-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ async function runLxc(
return spawnFromConfigAsync(config, debugSpawnOptions);
}

function outboundNetwork(version: (typeof supportedVersions)[number]) {
return version.compare('0.8.0-alpha') >= 0
? { egress: { default: 'allow' as const } }
: { allowOutbound: true };
Comment thread
dhoehna marked this conversation as resolved.
Comment thread
dhoehna marked this conversation as resolved.
}

for (const schemaVersion of supportedVersions) {
describe(`Linux LXC Container (schema ${schemaVersion})`, {
skip: lxcSkipReason,
Expand Down Expand Up @@ -109,7 +115,7 @@ describe(`Linux LXC Container (schema ${schemaVersion})`, {
});

it('should allow outbound network access', { skip: lxcNetworkSkipReason }, async () => {
const policy = { version: schemaVersion.raw, network: { allowOutbound: true } };
const policy = { version: schemaVersion.raw, network: outboundNetwork(schemaVersion) };
const result = await runLxc(
`wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'Network accessible'`,
policy,
Expand Down Expand Up @@ -147,7 +153,7 @@ describe(`Linux LXC Container (schema ${schemaVersion})`, {
const policy = {
version: schemaVersion.raw,
filesystem: { readwritePaths: [tempDir] },
network: { allowOutbound: true },
network: outboundNetwork(schemaVersion),
};
const script =
`wget -q -T 10 -O ${tempDir}/download.json '${NETWORK_TEST_URL}'` +
Expand All @@ -158,7 +164,7 @@ describe(`Linux LXC Container (schema ${schemaVersion})`, {
});

it('should access HTTPS endpoint', { skip: lxcNetworkSkipReason }, async () => {
const policy = { version: schemaVersion.raw, network: { allowOutbound: true } };
const policy = { version: schemaVersion.raw, network: outboundNetwork(schemaVersion) };
const result = await runLxc(
`wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'HTTPS endpoint accessible'`,
policy,
Expand All @@ -180,3 +186,32 @@ describe(`Linux LXC Container (schema ${schemaVersion})`, {
});
});
}

describe('Linux LXC Container default-deny network posture', {
skip: lxcSkipReason,
}, () => {
it('should give schema 0.8 no network interface when the policy names no network fields', async () => {
// `awk` takes the interface names out of /proc/net/dev and strips the
// trailing colon; the `ip` call reports whether loopback carries
// 127.0.0.1. The container prints two lines:
// ifaces=[eth0 lo ]
// loopback=up
const probe =
"echo \"ifaces=[$(awk 'NR>2 {sub(/:.*/, \"\", $1); print $1}' /proc/net/dev | sort | tr '\\n' ' ')]\"; " +
"ip -4 addr show lo 2>/dev/null | grep -q '127.0.0.1' && echo 'loopback=up' || echo 'loopback=down'";
Comment thread
dhoehna marked this conversation as resolved.
const result = await runLxc(probe, { version: '0.8.0-alpha' }, 'lxc-deny-080');

assert.strictEqual(result.exitCode, 0, `Expected the container to run: ${result.stderr}`);
assert.ok(
result.stdout.includes('ifaces=[lo ]'),
`Schema 0.8 promises no network access when the policy names no network fields` +
` (docs/sandbox-policy/0.8.0/policy.md), but the container was given more than` +
` loopback: ${result.stdout}`,
Comment thread
SohamDas2021 marked this conversation as resolved.
);
// Taking the network away must not take localhost with it.
assert.ok(
result.stdout.includes('loopback=up'),
`Expected loopback to carry 127.0.0.1: ${result.stdout}`,
);
});
});
34 changes: 15 additions & 19 deletions src/backends/bubblewrap/common/src/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::time::Duration;

use lxc_common::network_iptables::NetworkIptablesManager;
use lxc_common::network_iptables::{EgressHookPoint, NetworkIptablesManager};
use wxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller};
use wxc_common::logger::Logger;
use wxc_common::models::{ExecutionRequest, ScriptResponse};
Expand Down Expand Up @@ -878,17 +878,13 @@ impl Drop for BubblewrapSandboxProcess {

/// Build the iptables manager for a Bubblewrap sandbox.
///
/// Unprivileged bwrap has no veth: the sandbox either shares the host network
/// namespace or gets a private one, and neither leaves a host-side interface
/// for a chain to match on (see `local_network_diagnostic` in
/// `bwrap_command`). The absence is structural, not a lookup that failed, and
/// that distinction is the whole of what makes it safe to accept here: there
/// is no case in this backend where a veth was expected and went missing, so
/// accepting one cannot mask a discovery that broke.
/// Unprivileged bwrap has no container network namespace MXC can enforce in:
/// the sandbox either shares the host's, where a chain would filter the host
/// itself, or holds a private one bwrap created and MXC does not manage (see
/// `local_network_diagnostic` in `bwrap_command`). The chain is built and
/// never hooked, which leaves this backend's egress policy unenforced.
fn build_firewall_manager(container_name: &str) -> NetworkIptablesManager {
let mut mgr = NetworkIptablesManager::new(container_name);
mgr.allow_missing_veth_interface();
mgr
NetworkIptablesManager::new(container_name, EgressHookPoint::Unhooked)
Comment thread
SohamDas2021 marked this conversation as resolved.
}

/// Best-effort iptables cleanup. Called on both success and error paths.
Expand Down Expand Up @@ -1405,17 +1401,17 @@ mod tests {
}

#[test]
fn the_firewall_manager_tolerates_the_veth_bubblewrap_never_has() {
// bwrap never calls set_veth_interface, so the shared manager's
// fail-closed path would refuse every firewall-mode sandbox at startup.
// The manager this backend builds must therefore have declared the
// absence up front.
fn the_firewall_manager_this_backend_builds_has_nowhere_to_enforce() {
// Unprivileged bwrap leaves MXC no container namespace to hook, so the
// manager must say so at construction. A manager that claimed a
// namespace would hook an OUTPUT chain in whatever namespace the
// process happens to be in -- the host's -- and filter the host itself.
let mgr = build_firewall_manager("bwrap-cov");

assert!(
mgr.veth_scoping_is_optional(),
"Bubblewrap has no veth, so the manager it builds must declare that a \
missing one is expected -- otherwise firewall-mode sandboxes cannot start"
!mgr.is_hooked(),
"Bubblewrap has no namespace to enforce in, so the manager it builds \
must not claim one"
);
}

Expand Down
20 changes: 2 additions & 18 deletions src/backends/lxc/common/src/availability.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! LXC host-availability probe (ports the SDK's `isLxcAvailable()`).

use std::process::{Child, Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};

/// Upper bound on the `lxc-ls --version` probe. A version check returns almost
/// instantly; anything slower is treated as unavailable rather than allowed to
/// block discovery.
/// A version check returns almost instantly; anything slower than this should not block discovery.
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);

/// How often to poll the child while waiting for it to exit.
const POLL_INTERVAL: Duration = Duration::from_millis(25);

/// Outcome of running `lxc-ls --version`. Only `ExitedSuccess` means available;
/// the other variants are distinct for clarity but map to unavailable. The exit
/// code isn't retained — nothing reads it, and keeping it would be dead code.
#[derive(Debug, Clone, PartialEq, Eq)]
enum LxcLsOutcome {
ExitedSuccess,
Expand All @@ -26,11 +18,7 @@ enum LxcLsOutcome {
TimedOut,
}

/// Whether the LXC backend looks usable on this host.
///
/// Runs `lxc-ls --version`; only a clean exit counts as available. A shallow
/// check — it proves `lxc-ls` is on `PATH`, not that a container can start.
/// Probed once and cached for the process lifetime.
/// Runs `lxc-ls --version`; only a clean exit counts as available.
pub fn is_lxc_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| available_from(probe_lxc_ls()))
Expand All @@ -49,8 +37,6 @@ fn probe_lxc_ls() -> LxcLsOutcome {
}
}

/// Wait up to `timeout` for `child`; if it overruns, kill and reap it so a hung
/// `lxc-ls` can't block the probe (or leak a zombie) indefinitely.
fn wait_bounded(child: &mut Child, timeout: Duration) -> LxcLsOutcome {
let deadline = Instant::now() + timeout;
loop {
Expand All @@ -70,8 +56,6 @@ fn wait_bounded(child: &mut Child, timeout: Duration) -> LxcLsOutcome {
}
}

/// Split from the I/O half so the decision is testable without an `lxc-ls`
/// binary on the host.
fn available_from(outcome: LxcLsOutcome) -> bool {
matches!(outcome, LxcLsOutcome::ExitedSuccess)
}
Expand Down
Loading
Loading