Skip to content

Probe the firewall under the runtime the run will select - #102

Merged
Amitgb14 merged 7 commits into
feat/prod-demands-a-kernelfrom
fix/doctor-probes-the-selected-runtime
Aug 22, 2026
Merged

Probe the firewall under the runtime the run will select#102
Amitgb14 merged 7 commits into
feat/prod-demands-a-kernelfrom
fix/doctor-probes-the-selected-runtime

Conversation

@Amitgb14

@Amitgb14 Amitgb14 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #88 — base is feat/prod-demands-a-kernel, so the diff here is just
this change.

doctor asked whether a container can program iptables by starting one on the
engine's default runtime. Whether it can is a property of the kernel the
container gets, and runtimes differ: gVisor serves only the legacy backend, and
only when installed with --net-raw.

Measured on the Rocky Linux host in #89, with gVisor registered:

ok  egress firewall  a container here can program the nat, redirect, owner
                     and conntrack rules the firewall needs

…from a runc probe, while --runtime runsc could not program a single rule. The
preflight passing and the launch failing is exactly what ClassifyRuntimeGap
was centralised to prevent; this check simply was never centralised with it.

FirewallProgrammable now takes the runtime and renders --runtime on the
probe container, and doctor passes the same selectedRuntime it already
resolves for the kernel-boundary check. So doctor --runtime runsc asks about
runsc, and a config naming one asks about that.

Empty stays empty: with nothing selected the probe runs on the engine's default,
which is the right thing to answer for.

Why here and not on main

The runtime is already plumbed into RunChecks on this branch. Doing it on main
would have meant adding that plumbing a second time and conflicting with #88 in
the code #88 rewrites.

The egress allowlist cannot be programmed under gVisor, so `--profile prod
--runtime runsc` refuses to start:

    ip6tables: Failed to initialize nft: Protocol not supported
    sandbox-cli: egress firewall setup failed; refusing to run without the requested allowlist

Debian points `iptables` at the nft variant, which is right on an ordinary host.
gVisor implements only the older setsockopt interface, so the nft binaries fail
before touching a rule while the `-legacy` ones work. Measured inside this image
on Rocky Linux 10.2:

    iptables: FAILS          ip6tables: FAILS
    iptables-legacy: ok      ip6tables-legacy: ok

That made prod's two demands mutually exclusive on a gVisor host: it requires a
kernel of its own *and* an egress allowlist, and the allowlist could not be
programmed inside the kernel it was demanding.

pick_iptables tries the nft binaries first and falls back to legacy, and every
call site now goes through what it chose. Order matters: an ordinary runc host
keeps the backend its distribution selected and nothing about those runs
changes. Chosen by trying rather than by detecting the runtime, for the reason
the host-side probe already gives — a runtime name says what somebody installed,
and what matters here is what the kernel will answer.

Still fails closed. A container that can serve neither backend refuses, because
running without the requested allowlist is the one thing this script must never
do; the message names `runsc install -- --net-raw`, which is the other half of
this on a gVisor host. Without that flag gVisor serves neither backend, and no
change here can fix it — the container simply cannot filter.

Found by #89 phase 3/4 on a host with gVisor actually installed. The Dockerfile
is content-addressed into the image tag, so the first run after this rebuilds.
Six review findings, and the first two say the fallback was added in the wrong
shape: only the container script learned it, while everything else that touches
iptables kept hardcoding the bare binary.

**doctor and the launch now disagreed on the hosts this fixes.**
FirewallProgrammable's probe ran bare `iptables`, which resolves to the nft
variant — the one gVisor cannot serve. On a runsc-default host the preflight
reported "a container here cannot program the firewall" and advised
`--network default`, while the run itself worked. Before this branch they at
least agreed; the fix made them disagree, which CLAUDE.md names as the failure
to avoid.

**The only end-to-end firewall assertion read the wrong store.** The integration
test execs `iptables -S INPUT`; on the legacy path the rules live elsewhere, so
it would report "the INPUT chain was never programmed" for a container that is
fully default-deny. The ~42 rewritten call sites had no test that could observe
them on the backend being added.

So the choice moves into the image as `sandbox-iptables`, and all three callers
ask it: the entrypoint programs with it, doctor's probe uses it, the suite reads
back through it. Three copies of "which binary works" would drift, and the drift
is a preflight that answers differently from the launch.

**It writes rather than lists.** `-L -n` only proves the table can be read —
docker_cli.go already argues this, which is why the host probe creates rules —
and today's measurements prove it: legacy passes `-L` under gVisor and then
fails on `-m conntrack`. Creating and deleting one empty chain is the cheapest
thing that proves the kernel accepts a write.

**The kernel's own error survives.** The first version swallowed stderr from
both candidates and offered only a gVisor remedy, so a rootless daemon — whose
real error is "Permission denied (you must be root)" — was told to install
runsc. The last candidate's stderr is now ours.

Also: the chosen backend is logged, because IPv4 and IPv6 are picked
independently and an operator reading back with the wrong binary sees an empty
chain; and a CHANGELOG entry, which says plainly that this removes one barrier
to gVisor and not the last one — the allowlist still cannot be enforced there,
because gVisor has no connection tracking at all.
Pick the iptables backend the kernel will serve
doctor asked whether a container can program iptables by starting one on the
engine's *default* runtime. Whether it can is a property of the kernel the
container gets, and runtimes differ: gVisor serves only the legacy backend, and
only when installed with --net-raw.

Measured on a Rocky Linux host with gVisor registered — doctor reported

    ok  egress firewall  a container here can program the nat, redirect, owner
                         and conntrack rules the firewall needs

from a runc probe, while `--runtime runsc` could not program a single rule. The
preflight passing and the launch failing is what ClassifyRuntimeGap was
centralised to prevent; this check was simply never centralised with it.

FirewallProgrammable now takes the runtime and renders --runtime on the probe
container, and doctor passes the same selectedRuntime it already resolves for
the kernel-boundary check — so `doctor --runtime runsc` asks about runsc, and a
config naming one asks about that.

Empty stays empty: with nothing selected the probe runs on the engine's default,
which is what it should answer for.

Found by #89 phase 4. It is on this branch rather than main because the runtime
is already plumbed into RunChecks here; doing it on main would have duplicated
that and conflicted.
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sandbox-cli Ready Ready Preview Aug 21, 2026 5:57pm

Five review findings, and the first two are the bug this commit set out to fix,
inverted.

**The probe sent the user's spelling, not the engine's.** The run path
translates a runtime through resolveRuntime — a containerd daemon lists
`io.containerd.runc.v2` while a config says `runtime: runc` — and the probe did
not. So on that host the probe died with "unknown or invalid runtime name" and
doctor reported that a machine whose runs program the firewall perfectly well
cannot. Scoping the probe to a runtime and then naming it differently from the
run reintroduced the disagreement it exists to prevent.

**A runtime the engine does not have was reported as a host defect.** That is
FirewallUnknown, not FirewallBlocked: the probe never ran, so it learned nothing
about whether this host can filter. As Blocked it produced StatusWeak, blamed
the daemon, and offered `--network default` — telling a prod operator to switch
the egress allowlist off to fix a misspelled --runtime, while checkRuntimes
reported the real GapMissing one line below.

**The remedy still named only daemon causes.** Once the probe runs under a
selected runtime, "rootless or userns-remapped" is not the only explanation and
on gVisor it is not the explanation at all — iptables is gated behind
`runsc install -- --net-raw`. The remedy now says so when a runtime was
selected, and is unchanged when none was, which is the case it was written for.

Also: the probe gets its own half of doctor's budget, because it is the only
check that starts a container and under a stronger runtime that means booting a
micro-VM — sharing one deadline turned a slow probe into two prod failures. And
the argv moves into a pure firewallProbeArgs so it can be asserted: it is built
outside BuildArgs, so the golden --dry-run test never saw it, and deleting the
--runtime append left everything green. The new test also pins the ordering,
since a flag after the image reference is handed to the container instead.
The review of this PR found five things, all in the seams where the new
runtime dimension meets messaging that predates it.

"Unknown" now covers three causes — an unbuilt image, a host too busy to
answer, and a runtime the engine does not have — and one canned remedy
told every one of them to build the image. Under prod that is
unactionable advice attached to a failure. The cause is now a **value**
carried beside the verdict (`FirewallReport`), for the reason its
neighbour gives: the alternative is the caller matching on reason prose,
which is the substring-across-a-package-boundary the enum replaced.

The blocked remedy keyed on the runtime a *flag* named, so a daemon whose
default-runtime is runsc — nothing selected, gVisor nonetheless what runs
— was told "rootless or userns-remapped daemons often cannot; use
--network default". That is the one instruction the comment three lines
above says a mis-identified check must never give, and it does not fix
gVisor: its netstack has no conntrack, which the rules need, so the
`runsc install -- --net-raw` this used to lead with sends an operator
away and back to a byte-identical failure.

An engine refusing the runtime *name* is not a container failing to
program rules — the container never started. resolveRuntime is non-fatal
in two paths by design, so an unaccepted spelling still reaches the
launch, and reading the refusal as Blocked blamed the daemon while the
runtime check one line below reported that same runtime as fine.

The probe's half-budget is gone, and the ordering is what replaces it:
the runtime facts come from one `info` call made *before* the probe, so
the expensive check can take the time a micro-VM needs without starving
the cheap one. That also removes a second `info` call, and two dead
parameters from checkRuntimes, which no longer asks anything.

Not fixed here, deliberately: the `sandbox-iptables` block splits the
sandbox-egress-setup comment mid-sentence. This branch changes nothing
else in the Dockerfile, and image.Ref hashes it — so rejoining a sentence
would invalidate every cached base image and cost every user a
multi-minute rebuild. It should ride along with the next change that
alters the image for a reason.
@Amitgb14

Copy link
Copy Markdown
Owner Author

Review addressed — five of six fixed, one deliberately deferred with a reason.

# finding what changed
1 Unknown remedy hardcoded "build the image" the cause is now a value (FirewallReport{Probe, Cause, Reason}), and each of the three causes gets the step that fits
2 an unaccepted runtime name reaches the launch and is reported as Blocked an engine refusal is FirewallUnknown + CauseRuntimeRefused; the container never started, so it says nothing about iptables
3 blocked remedy keyed on selectedRuntime == "" keys on EffectiveRuntime, so a daemon defaulting to runsc is no longer told to drop the allowlist
4 runsc install -- --net-raw cannot work the gVisor remedy says the netstack has no connection tracking and names the fix that exists
5 Timeout/2 gave the slowest check the smallest budget removed — the runtime facts are read before the probe, so the expensive check can't starve the cheap one
6 Dockerfile comment split mid-sentence deferred — see below

On finding 1, the cause is carried as a value rather than sniffed from the reason string, which is what the FirewallProbe comment already argues for ("Returned as a value rather than sniffed out of the reason string, which used to be a substring match across a package boundary"). The one place engine prose is matched is runtimeRefused, confined to a single function and documented: docker and podman both exit 125 for "could not start" generally, so the message is the only thing separating a rejected name from a container that ran and failed.

On finding 5, ordering replaces the cap: runtimeSupport is one info call, now made once in RunChecks and handed to both checks, so whatever the probe spends, the runtime verdict has already been read. That also removed a duplicate info call and two dead parameters from checkRuntimes, which no longer asks the engine anything.

On finding 6 — not fixed, and this is the trade. This branch changes nothing else in the Dockerfile, and image.Ref hashes it, so rejoining a sentence in a comment would invalidate every cached base image and cost every user a multi-minute rebuild. It should ride along with the next change that alters the image for a substantive reason. (Confirmed: git diff main...HEAD -- internal/image/assets/Dockerfile is empty.)

Tests. TestBlockedRemedyFollowsTheEffectiveRuntime reproduces finding 3 exactly — reverted to the old keying, it fails with "a gVisor host must not be told to drop the allowlist: rootless or userns-remapped daemons often cannot; use --network default…". Plus TestUnknownRemedyNamesTheCauseItFound (four causes, and silence when none was established) and TestRuntimeRefusalIsNotAFirewallVerdict (four engine refusals classified Unknown; two real iptables failures still Blocked). Full go test ./... green, and doctor checked by hand on this host.

@Amitgb14
Amitgb14 merged commit 7cb47cc into feat/prod-demands-a-kernel Aug 22, 2026
10 checks passed
@Amitgb14
Amitgb14 deleted the fix/doctor-probes-the-selected-runtime branch August 22, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant