Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .azure-pipelines/templates/SDK.Integration.Test.Job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ jobs:
- ${{ if eq(target.os, 'linux') }}:
- script: |
sudo apt-get update -qq
sudo apt-get install -y -qq lxc lxc-utils dnsmasq-base iptables
displayName: Install LXC
sudo apt-get install -y -qq lxc lxc-utils dnsmasq-base iptables bubblewrap
displayName: Install LXC and Bubblewrap

- script: sudo MXC_SKIP_LXC_NETWORK_TESTS=1 MXC_DEBUG=${{ parameters.debug }} npm test
workingDirectory: $(integrationDirectory)
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/microvm-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
workflow_dispatch:

permissions:
actions: write
contents: read

jobs:
Expand Down
63 changes: 50 additions & 13 deletions sdk/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ function buildWslcContainerConfig(
return config;
}

/**
* Applies iptables-based network enforcement to `config.network`:
* when host filtering is requested, force `enforcementMode = 'firewall'`
* so the Linux runner actually applies iptables rules. Without this,
* `network_enforcement_mode` falls back to the parser default of
* `Capabilities` and the runner silently skips iptables, dropping
* `allowedHosts` / `blockedHosts` on the floor.
*
* Shared between the explicit `'bubblewrap'` / `'lxc'` builders and the
* abstract `'process'` branch on Linux (which resolves to Bubblewrap
* server-side) so the wire-format `network` block is identical regardless
* of which intent the caller used.
*/
function applyIptablesNetworkEnforcement(config: ContainerConfig): void {
if (config.network) {
if (config.network.allowedHosts?.length || config.network.blockedHosts?.length) {
config.network.enforcementMode = 'firewall';
}
}
}

/**
* Builds the Bubblewrap (bwrap) portion of a ContainerConfig.
* Bubblewrap is Linux-only and uses shared cross-backend fields only β€”
Expand All @@ -95,14 +116,7 @@ function buildBubblewrapConfig(
config: ContainerConfig,
): ContainerConfig {
config.containment = 'bubblewrap';

// Network enforcement: use firewall when host filtering is needed
if (config.network) {
if (config.network.allowedHosts?.length || config.network.blockedHosts?.length) {
config.network.enforcementMode = 'firewall';
}
}

applyIptablesNetworkEnforcement(config);
return config;
}

Expand All @@ -119,7 +133,7 @@ function buildLinuxProcessConfig(
release: '3.23',
destroyOnExit: true,
};

applyIptablesNetworkEnforcement(config);
return config;
}

Expand Down Expand Up @@ -297,10 +311,18 @@ export function createConfigFromPolicy(

// WSLC supports block + allowedHosts via iptables (Bridged networking
// with per-host filtering). macOS sandbox supports it natively via
// per-host Seatbelt rules. Bubblewrap supports it via iptables.
// per-host Seatbelt rules. Bubblewrap and LXC support it via iptables.
// Abstract `'process'` on Linux resolves to Bubblewrap server-side, so
// treat it the same as explicit `'bubblewrap'` here.
// Other backends require allowOutbound for host filtering since it
// maps to AppContainer capabilities.
if (containment !== 'wslc' && containment !== 'seatbelt' && containment !== 'bubblewrap') {
const resolvesToHostFilteringBackend =
containment === 'wslc' ||
containment === 'seatbelt' ||
containment === 'bubblewrap' ||
containment === 'lxc' ||
(containment === 'process' && platform === 'linux');
if (!resolvesToHostFilteringBackend) {
if ((policy.network.allowedHosts?.length || policy.network.blockedHosts?.length) && !policy.network.allowOutbound) {
throw new Error('allowedHosts/blockedHosts require allowOutbound to be true');
}
Expand Down Expand Up @@ -328,11 +350,26 @@ export function createConfigFromPolicy(
return buildBubblewrapConfig(config);
}

if (containment === 'lxc') {
diagLog(`createConfigFromPolicy: containment=lxc, id=${containerId}`);
config.containment = 'lxc';
return buildLinuxProcessConfig(config, containerId);
}

if (containment === 'process') {
config.containment = 'process';
if (platform === 'linux') {
diagLog(`createConfigFromPolicy: containment=lxc, id=${containerId}`);
return buildLinuxProcessConfig(config, containerId);
// Abstract `'process'` on Linux is resolved to Bubblewrap by the
// native binary (see `wxc_common::config_parser`). The wire-format
// payload intentionally omits any backend-specific block so the
// config reflects the abstract intent. Callers who explicitly want
// LXC must pass `containment: 'lxc'`.
//
// Network enforcement still needs the same iptables firewall mode
// as explicit `'bubblewrap'` when host filtering is in play.
applyIptablesNetworkEnforcement(config);
diagLog(`createConfigFromPolicy: containment=process (linux, resolves to bubblewrap), id=${containerId}`);
return config;
Comment thread
SohamDas2021 marked this conversation as resolved.
}
if (platform === 'darwin') {
// The seatbelt backend has no container abstraction
Expand Down
11 changes: 4 additions & 7 deletions sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,17 @@ export interface LifecycleConfig {
preservePolicy?: boolean;
}

/**
* Abstract containment intent. Names the *kind* of isolation the caller
* wants. The native binary (or the SDK as a fallback) resolves this to a
* concrete {@link ContainmentBackend} at run time based on what the host
* supports.
*
/**
* Abstract containment intent. Names the *kind* of isolation the caller
* wants; the native binary resolves it to a concrete
* {@link ContainmentBackend} per host capability.
*
* Today's intents:
* - "process": OS-native process-level isolation. Resolves to
* `processcontainer` (Windows), `lxc` (Linux), or `seatbelt` (macOS).
* `processcontainer` (Windows), `bubblewrap` (Linux), or `seatbelt`
* (macOS). On Linux, `lxc` remains available as an explicit concrete
* backend but is no longer the default for the abstract `"process"`
* intent.
* - "vm": full hardware-virtualised VM isolation. Resolves to
* `windows_sandbox` on Windows; no concrete VM backend exists on other
* platforms today.
Expand Down
76 changes: 76 additions & 0 deletions sdk/tests/integration/linux-bubblewrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { describe, it } from 'node:test';
import assert from 'node:assert';
import {
sdk,
supportedVersions,
isLinuxRoot,
debugSpawnOptions,
spawnFromConfigAsync,
} from './test-helpers.js';

// Bwrap fingerprint: when invoked with `--unshare-pid`, bubblewrap creates a
// new PID namespace and stays as PID 1 in that namespace, acting as init
// (reaping orphans, forwarding signals). It does NOT exec the child shell
// directly β€” the script runs as PID 2. So /proc/1/comm always reads "bwrap"
// from inside the sandbox, regardless of how bwrap is invoked or which user
// runs it. This is documented bubblewrap behavior (see bwrap(1)) and the
// most reliable cross-context signal β€” mount-count heuristics break under
// WSL2 where bind-mount propagation can produce 40+ entries.
const BWRAP_PROBE =
"PID1=$(cat /proc/1/comm 2>/dev/null || echo unknown); " +
"MOUNTS=$(wc -l </proc/self/mountinfo); " +
"echo \"pid1=$PID1 mountinfo_lines=$MOUNTS\"; " +
"[ \"$PID1\" = \"bwrap\" ] || { echo \"FAIL: not under bubblewrap (pid1=$PID1)\"; exit 1; }; " +
"echo 'OK: under bubblewrap (pid1=bwrap)'";
Comment thread
SohamDas2021 marked this conversation as resolved.

for (const schemaVersion of supportedVersions) {
describe(`Linux Bubblewrap (schema ${schemaVersion})`, {
skip: !isLinuxRoot ? 'Linux Bubblewrap tests require Linux with root privileges (sudo npm test)' : undefined,
}, () => {
it('should default to Bubblewrap when containment is omitted (silent default)', async () => {
// spawnSandboxAsync routes through abstract `containment: 'process'`,
// which on Linux resolves to Bubblewrap in the binary. No --experimental
// flag is required for this path.
const result = await sdk.spawnSandboxAsync(
BWRAP_PROBE,
{ version: schemaVersion.raw },
debugSpawnOptions,
undefined,
`bwrap-default-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] silent-default Bubblewrap probe failed: ${result.stdout}`);
assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
});

it('should select Bubblewrap for abstract containment="process"', async () => {
const config = sdk.createConfigFromPolicy(
{ version: schemaVersion.raw },
'process',
`bwrap-process-${schemaVersion}`,
);
config.process!.commandLine = BWRAP_PROBE;
assert.strictEqual(config.containment, 'process', 'wire-format containment should be "process"');
const result = await spawnFromConfigAsync(config, debugSpawnOptions);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] containment=process Bubblewrap probe failed: ${result.stdout}`);
assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
});

it('should select Bubblewrap for explicit containment="bubblewrap" with experimental flag', async () => {
// Explicit "bubblewrap" still requires `experimental: true` per the SDK
// gate in helper.ts (`ExperimentalBackends`).
const config = sdk.createConfigFromPolicy(
{ version: schemaVersion.raw },
'bubblewrap',
`bwrap-explicit-${schemaVersion}`,
);
config.process!.commandLine = BWRAP_PROBE;
assert.strictEqual(config.containment, 'bubblewrap', 'wire-format containment should be "bubblewrap"');
const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true });
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] explicit Bubblewrap probe failed: ${result.stdout}`);
assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
});
});
}
77 changes: 49 additions & 28 deletions sdk/tests/integration/linux-process-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import assert from 'node:assert';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { SandboxPolicy } from '@microsoft/mxc-sdk';
import {
sdk,
supportedVersions,
Expand All @@ -14,11 +15,26 @@ import {
NETWORK_TEST_URL,
lxcNetworkSkipReason,
debugSpawnOptions,
spawnFromConfigAsync,
} from './test-helpers.js';

// Route through explicit `containment: 'lxc'` so these tests genuinely exercise
// the LXC backend. spawnSandboxAsync internally routes through abstract
// `containment: 'process'`, which on Linux resolves to a different backend
// (Bubblewrap). The LXC backend is covered by an explicit opt-in only.
async function runLxc(
script: string,
policy: SandboxPolicy,
containerId: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const config = sdk.createConfigFromPolicy(policy, 'lxc', containerId);
config.process!.commandLine = script;
return spawnFromConfigAsync(config, debugSpawnOptions);
}

for (const schemaVersion of supportedVersions) {
describe(`Linux Process Container (schema ${schemaVersion})`, {
skip: !isLinuxRoot ? 'Linux Process Container tests require Linux with root privileges (sudo npm test)' : undefined,
describe(`Linux LXC Container (schema ${schemaVersion})`, {
skip: !isLinuxRoot ? 'Linux LXC Container tests require Linux with root privileges (sudo npm test)' : undefined,
}, () => {
let tempDir = '';

Expand All @@ -30,48 +46,59 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
});

it('should execute hello world in LXC container', async () => {
const result = await sdk.spawnSandboxAsync(
const result = await runLxc(
"echo 'Hello from LXC via CLI'",
{ version: schemaVersion.raw },
debugSpawnOptions,
undefined,
`lxc-hello-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
assert.ok(result.stdout.includes('Hello from LXC via CLI'));
});

it('should propagate exit code', async () => {
const result = await sdk.spawnSandboxAsync(
const result = await runLxc(
"echo 'about to exit' && exit 0",
{ version: schemaVersion.raw },
debugSpawnOptions,
undefined,
`lxc-exit-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
assert.ok(result.stdout.includes('about to exit'));
});

it('should report system info', async () => {
const result = await sdk.spawnSandboxAsync(
const result = await runLxc(
"uname -a && echo 'System info test passed'",
{ version: schemaVersion.raw },
debugSpawnOptions,
undefined,
`lxc-sysinfo-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
assert.ok(result.stdout.includes('System info test passed'));
});

it('should select LXC backend (not Bubblewrap) on explicit containment="lxc"', async () => {
// Probe PID 1 to distinguish backends:
// bwrap -> PID 1 comm == 'bwrap' (bwrap re-execs itself as init in the new pid namespace)
// LXC -> PID 1 comm is 'init' / 'systemd' (the container's init)
// We assert NOT bwrap by checking PID 1 is not 'bwrap'.
const probe =
"PID1=$(cat /proc/1/comm); " +
"echo \"pid1=$PID1\"; " +
"[ \"$PID1\" != bwrap ] || { echo 'FAIL: PID 1 is bwrap, looks like Bubblewrap, not LXC'; exit 1; }; " +
"echo 'OK: not under Bubblewrap'";
const result = await runLxc(
probe,
{ version: schemaVersion.raw },
`lxc-probe-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] LXC backend probe failed: ${result.stdout}`);
assert.ok(result.stdout.includes('OK: not under Bubblewrap'), `[${schemaVersion}] ${result.stdout}`);
});

it('should allow outbound network access', { skip: lxcNetworkSkipReason }, async () => {
const policy = { version: schemaVersion.raw, network: { allowOutbound: true } };
const result = await sdk.spawnSandboxAsync(
const result = await runLxc(
`wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'Network accessible'`,
policy,
debugSpawnOptions,
undefined,
`lxc-net-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
Expand All @@ -83,9 +110,7 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
fs.writeFileSync(path.join(tempDir, 'test.txt'), 'original');
const policy = { version: schemaVersion.raw, filesystem: { readwritePaths: [tempDir] } };
const script = `cat ${tempDir}/test.txt && echo 'overwritten' > ${tempDir}/test.txt && cat ${tempDir}/test.txt`;
const result = await sdk.spawnSandboxAsync(
script, policy, debugSpawnOptions, undefined, `lxc-rw-${schemaVersion}`,
);
const result = await runLxc(script, policy, `lxc-rw-${schemaVersion}`);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
assert.ok(result.stdout.includes('overwritten'));
});
Expand All @@ -94,11 +119,9 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
tempDir = createTempDir('mxc-lxc-test');
fs.writeFileSync(path.join(tempDir, 'data.txt'), 'readonly content');
const policy = { version: schemaVersion.raw, filesystem: { readonlyPaths: [tempDir] } };
const result = await sdk.spawnSandboxAsync(
const result = await runLxc(
`cat ${tempDir}/data.txt && echo 'Read succeeded'`,
policy,
debugSpawnOptions,
undefined,
`lxc-ro-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
Expand All @@ -115,20 +138,16 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {
const script =
`wget -q -T 10 -O ${tempDir}/download.json '${NETWORK_TEST_URL}'` +
` && test -s ${tempDir}/download.json && echo 'Combined test passed'`;
const result = await sdk.spawnSandboxAsync(
script, policy, debugSpawnOptions, undefined, `lxc-combined-${schemaVersion}`,
);
const result = await runLxc(script, policy, `lxc-combined-${schemaVersion}`);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
assert.ok(result.stdout.includes('Combined test passed'));
});

it('should access HTTPS endpoint', { skip: lxcNetworkSkipReason }, async () => {
const policy = { version: schemaVersion.raw, network: { allowOutbound: true } };
const result = await sdk.spawnSandboxAsync(
const result = await runLxc(
`wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'HTTPS endpoint accessible'`,
policy,
debugSpawnOptions,
undefined,
`lxc-https-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
Expand All @@ -137,8 +156,10 @@ describe(`Linux Process Container (schema ${schemaVersion})`, {

it('should run multi-command pipeline', async () => {
const script = "echo 'step 1' && ls / && echo 'step 2' && whoami && echo 'Multi-command passed'";
const result = await sdk.spawnSandboxAsync(
script, { version: schemaVersion.raw }, debugSpawnOptions, undefined, `lxc-pipeline-${schemaVersion}`,
const result = await runLxc(
script,
{ version: schemaVersion.raw },
`lxc-pipeline-${schemaVersion}`,
);
assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`);
assert.ok(result.stdout.includes('Multi-command passed'));
Expand Down
Loading
Loading