From 1fe180f979b4bd52823a7c7b2d32e879854e6416 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <44449792+quickbeard@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:12:56 +0700 Subject: [PATCH] fix(sandbox): fall back to drop when the kernel refuses the nft reject rule The egress firewall's final `meta cgroup reject` rule is the one piece of the ruleset backed by a loadable kernel 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. The firewall failed closed, so every sandbox start died with that cryptic error until `wsl --shutdown`. Retry the ruleset with drop when the reject variant fails: confinement is identical, blocked connects merely hang until timeout instead of failing fast, and the boot log explains the state and names `wsl --shutdown` as the cure. If even the drop ruleset fails the boot still ends, now with an actionable message. A failed `nft -f` transaction is atomic, so the retry starts clean. Validated in the real guest dash with a PATH-shimmed nft across all three paths (reject ok / reject fails / both fail), and both rulesets against the real kernel's nft. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++ .../codev-sandbox/src/wsl/firewall.test.ts | 17 ++++++- packages/codev-sandbox/src/wsl/firewall.ts | 49 ++++++++++++++----- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78d4fdee5..f86c0a46a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/codev-sandbox/src/wsl/firewall.test.ts b/packages/codev-sandbox/src/wsl/firewall.test.ts index 40756fcb4c..1060f98cb4 100644 --- a/packages/codev-sandbox/src/wsl/firewall.test.ts +++ b/packages/codev-sandbox/src/wsl/firewall.test.ts @@ -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 - < /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") diff --git a/packages/codev-sandbox/src/wsl/firewall.ts b/packages/codev-sandbox/src/wsl/firewall.ts index 0f26f9dcef..ab77e24e9b 100644 --- a/packages/codev-sandbox/src/wsl/firewall.ts +++ b/packages/codev-sandbox/src/wsl/firewall.ts @@ -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 @@ -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 - < [ `table inet ${table} {}`, `delete table inet ${table}`, `table inet ${table} {`, @@ -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 - <&2`, + ` nft -f - <&2; exit 1; }`, + ...ruleset("drop"), + `CODEV_NFT`, + `}`, `echo $$ > ${cgroup}/cgroup.procs || exit 1`, ] }