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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ file is the human-readable summary of what changed and why.

### Sandbox

- **A Windows sandbox starts again after WSL updates itself** — when WSL
installed an update while its VM was still running (Docker Desktop or another
distro keeps it alive), starting a sandbox failed with a cryptic nftables
error until the next `wsl --shutdown` or reboot. The egress firewall's
"reject everything else" rule is the one piece of it the kernel loads from a
module, and the update pulls the old modules out from under the running
kernel. The sandbox now falls back to an equally strict rule built into the
kernel itself — blocked connections hang until the next WSL restart instead
of failing fast — and boots normally, explaining the situation in its log
instead of dying on it.
- **The sandbox setup dialog explains what a sandbox buys you, and stops
asking for an image file** — the dialog Work opens when you have no sandbox
yet described the download and the wait, but never why any of it was
Expand Down
17 changes: 16 additions & 1 deletion packages/codev-sandbox/src/wsl/firewall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,25 @@ test("firewall script fails closed at every step", () => {
expect(script).toContain(`command -v nft >/dev/null 2>&1 || { echo "codev sandbox: nft is missing`)
expect(script).toContain("mount -t cgroup -o net_cls net_cls /sys/fs/cgroup/net_cls || exit 1")
expect(script).toContain("/sys/fs/cgroup/net_cls/codev-e2e/net_cls.classid || exit 1")
expect(script).toContain("nft -f - <<CODEV_NFT || exit 1")
expect(script).toContain("nft -f - <<CODEV_NFT || CODEV_FW_OK=")
expect(script).toContain(`nft -f - <<CODEV_NFT || { echo "codev sandbox: the kernel refused`)
expect(script).toContain("echo $$ > /sys/fs/cgroup/net_cls/codev-e2e/cgroup.procs || exit 1")
})

test("a kernel that cannot load the reject module degrades to drop, not to open", () => {
const classid = wslFirewallClassid(50000)
const lines = wslFirewallScript({ distro: "codev-e2e", serverPort: 50000, brokerPort: 8790 })
const script = lines.join("\n")
expect(script).toContain(`meta cgroup ${classid} reject`)
expect(script).toContain(`meta cgroup ${classid} drop`)
// The fallback ruleset keeps the same holes as the primary one.
expect(
lines.filter((line) => line === ` meta cgroup ${classid} ip daddr $HOST_IP tcp dport 8790 accept`),
).toHaveLength(2)
// Drop only runs when reject failed, and its own failure still ends the boot.
expect(lines.indexOf(` meta cgroup ${classid} drop`)).toBeGreaterThan(lines.indexOf(`[ -n "$CODEV_FW_OK" ] || {`))
})

test("firewall rules confine only the sandbox classid and keep loopback", () => {
const classid = wslFirewallClassid(50000)
const script = wslFirewallScript({ distro: "codev-e2e", serverPort: 50000, brokerPort: null }).join("\n")
Expand Down
49 changes: 36 additions & 13 deletions packages/codev-sandbox/src/wsl/firewall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@
// nftables, or a kernel without these matches, refuses to start the sandbox
// instead of starting it unfirewalled.
//
// The reject verdict is the one piece of the ruleset that lives in a loadable
// module (CONFIG_NFT_REJECT=m; accept/drop are nf_tables core). When WSL
// services itself while its utility VM keeps running, the old package's
// modules disappear from under the live kernel and loading nft_reject_inet
// fails with ENOENT — observed live 2026-08-25 as `Could not process rule: No
// such file or directory` on exactly the reject rule while both accept rules
// loaded. That state persists until `wsl --shutdown`, so instead of refusing
// to boot the script retries the ruleset with drop: confinement is identical,
// blocked connects merely hang until timeout instead of failing fast.
//
// Residual exposure, accepted and deliberate: the VM's loopback interface is
// shared by every distro, and the blanket loopback allow means the sandbox
// can reach 127.0.0.1 services owned by neighbour distros. It cannot be
Expand Down Expand Up @@ -60,18 +70,11 @@ export function wslFirewallScript(options: {
const classid = wslFirewallClassid(options.serverPort)
const cgroup = `/sys/fs/cgroup/net_cls/${options.distro}`
const hostIp = `$${options.hostIpVar ?? "HOST_IP"}`
return [
`command -v nft >/dev/null 2>&1 || { echo "codev sandbox: nft is missing from the guest image" >&2; exit 1; }`,
// The mount is per-distro (each distro has its own mount namespace); the
// classid and the rules live in the shared kernel, scoped by the marker.
`mkdir -p /sys/fs/cgroup/net_cls || exit 1`,
`mountpoint -q /sys/fs/cgroup/net_cls || mount -t cgroup -o net_cls net_cls /sys/fs/cgroup/net_cls || exit 1`,
`mkdir -p ${cgroup} || exit 1`,
`echo ${classid} > ${cgroup}/net_cls.classid || exit 1`,
// Declare-empty + delete + declare-full is nft's idempotent-reset idiom:
// one transaction, no error when the table does not exist yet, and a
// leftover table from a previous run of this sandbox is replaced whole.
`nft -f - <<CODEV_NFT || exit 1`,
// Declare-empty + delete + declare-full is nft's idempotent-reset idiom:
// one transaction, no error when the table does not exist yet, and a
// leftover table from a previous run of this sandbox is replaced whole.
// A failed transaction is atomic too, so the drop retry below starts clean.
const ruleset = (verdict: "reject" | "drop") => [
`table inet ${table} {}`,
`delete table inet ${table}`,
`table inet ${table} {`,
Expand All @@ -81,10 +84,30 @@ export function wslFirewallScript(options: {
...(options.brokerPort !== null
? [` meta cgroup ${classid} ip daddr ${hostIp} tcp dport ${options.brokerPort} accept`]
: []),
` meta cgroup ${classid} reject`,
` meta cgroup ${classid} ${verdict}`,
` }`,
`}`,
]
return [
`command -v nft >/dev/null 2>&1 || { echo "codev sandbox: nft is missing from the guest image" >&2; exit 1; }`,
// The mount is per-distro (each distro has its own mount namespace); the
// classid and the rules live in the shared kernel, scoped by the marker.
`mkdir -p /sys/fs/cgroup/net_cls || exit 1`,
`mountpoint -q /sys/fs/cgroup/net_cls || mount -t cgroup -o net_cls net_cls /sys/fs/cgroup/net_cls || exit 1`,
`mkdir -p ${cgroup} || exit 1`,
`echo ${classid} > ${cgroup}/net_cls.classid || exit 1`,
`CODEV_FW_OK=1`,
`nft -f - <<CODEV_NFT || CODEV_FW_OK=`,
...ruleset("reject"),
`CODEV_NFT`,
// Stale kernel modules (see header) fail only the reject rule; keep the
// sandbox bootable behind an equivalent drop rule until WSL restarts.
`[ -n "$CODEV_FW_OK" ] || {`,
` echo "codev sandbox: the kernel could not load the nftables reject module (a WSL update while its VM was running does this); blocked connections will hang instead of failing fast until 'wsl --shutdown'" >&2`,
` nft -f - <<CODEV_NFT || { echo "codev sandbox: the kernel refused the egress firewall rules; run 'wsl --shutdown' and retry" >&2; exit 1; }`,
...ruleset("drop"),
`CODEV_NFT`,
`}`,
`echo $$ > ${cgroup}/cgroup.procs || exit 1`,
]
}
Loading