network: isolate guest VMs from each other on the shared bridge - #452
network: isolate guest VMs from each other on the shared bridge#452DarkaMaul wants to merge 2 commits into
Conversation
Marking each TAP isolated blocks the L2 path between guests but not the L3 one: a frame addressed to the bridge is local delivery, so the flag never applies, and ip_forward sends it back out br0 from the bridge device, which has no isolated source port either. Nothing in the existing ruleset matches br0 -> br0, so a root guest could reach a peer with `ip route add <peer>/32 via <bridge ip>` on any host whose FORWARD policy is ACCEPT — the kernel default. Add the matching L3 rule, removed in teardown_bridge alongside the other three. Assert it on every setup_tap rather than in ensure_bridge, which returns early on a pre-existing bridge and would leave it silently absent. Read the isolated flag back after setting it. IFLA_BRPORT_ISOLATED is attribute 33; a kernel below 4.18 caps the bridge-port policy at 32 and silently drops out-of-range attributes, so the set exits 0 having done nothing. Fail the VM start instead, naming the floor — which is iproute2 >= 4.19, not 4.18: the kernel side landed in 4.18 but the `isolated` keyword only appears in iproute2 from 4.19.0. Cover both paths in the multi-instance integration phase. The routed probe installs the /32 on both guests: with only the initiator's route, the peer answers over its connected /24 — two isolated ports — so the reply dies at L2 and the ping fails whether or not the rule exists. It also runs a positive control first, since every assertion reads a ping failure as success, and skips when the host FORWARD policy is not ACCEPT, where the result would not be attributable to coop's rule. Record the invariant and its residuals in trust-model.md: isolation is pairwise, so one VM still running from before the upgrade leaves the whole bridge unisolated until restarted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hbrodin
left a comment
There was a problem hiding this comment.
Requesting changes for the firewall-precedence bug and the integration test's inability to distinguish the L2 control. Three inline findings total, including one minor correction to the mutation-coverage rationale. The basic two-control design is sound in a disposable Linux network-namespace reproduction.
Validation: real veth/bridge namespace probes confirmed direct isolation, preserved gateway access, the routed bypass without DROP, and the bypass when ACCEPT precedes an existing DROP even after rerunning the startup check. The extracted readback predicate test passed; constant-true and constant-false mutants failed. Shell syntax passed. The bridge-netfilter test blind spot was checked against kernel documentation and an extracted boundary model, not a live br_netfilter run (the module was absent).
Coverage: correctness, design, conventions, security, API usage, tests, docs, and comments; none skipped. No broader structural or diff-noise concern. Current CI is green, but neither Firecracker nor Lima VM integration was run, and CI has no VM integration jobs. The new phase requires --full, skips Lima, and skips its routed assertion when FORWARD policy is not ACCEPT. Run the required VM integration gates after fixing the findings.
This is partial progress toward #5, not completion of guest isolation or #2's egress restrictions. The documented impersonation, IPv6, legacy-port, firewall-reload, and bridge-ownership residuals remain follow-ups. The added FORWARD rule also requires the repository's merge-time human confirmation.
| .sudo() | ||
| .capture() | ||
| .is_ok(); | ||
| if !present { |
There was a problem hiding this comment.
Reassert rule precedence, not just existence. iptables -C succeeds if the matching DROP exists anywhere in FORWARD. If a firewall configuration puts an ACCEPT ahead of it, this branch skips insertion and startup succeeds while routed guest-to-guest traffic remains possible.
I reproduced this in a disposable network namespace with two isolated bridge ports and reciprocal /32 routes through the gateway: DROP blocked the ping; inserting ACCEPT ahead of it and rerunning this exact check/conditional-insert logic left the ping working. Unlike the documented case where a reload removes the rule, another start does not repair this state. Please verify or safely restore the rule's required precedence and cover the shadowed-rule case.
| pass "guest A still reaches the host gateway" | ||
|
|
||
| # L2: direct on-link path, blocked by the isolated bridge port. | ||
| if guest_exec ping -c1 -W2 "$ip_b" >/dev/null 2>&1; then |
There was a problem hiding this comment.
Make this assertion discriminate the bridge-port control. When br_netfilter filters bridged IPv4 traffic, the new FORWARD br0-to-br0 DROP also blocks this direct ping. Removing isolate_tap_port therefore leaves the gateway positive control and both negative ping assertions green, so the test can miss removal of the L2 protection.
The kernel documents the bridge-to-IPv4-netfilter behavior here: https://docs.kernel.org/networking/bridge.html#netfilter . An extracted boundary model confirmed that the assertions accept this case; this was not a live br_netfilter reproduction because the module is absent in the review environment.
Please assert the actual isolation flags on both TAPs and exercise L2 behavior in an isolated environment where the L3 rule cannot mask it. Deliberately removing the L2 control should fail its regression check.
| # the flag stuck or the rule landed; tests/integration.sh asserts the behavior | ||
| # with two running instances. The decision logic they were carved around stays | ||
| # IN scope and is unit-tested — port_is_isolated (the readback predicate), | ||
| # guest_isolation_spec (subnet scoping + rule tag), and guest_subnet_cidr (the |
There was a problem hiding this comment.
Please update this coverage rationale to match the implementation. guest_isolation_spec and guest_subnet_cidr do not exist in this revision; their names occur only in this comment. The rule is a six-argument GUEST_ISOLATION_SPEC constant without subnet scoping or a rule tag, and port_is_isolated is the only new pure helper. The exclusions themselves are appropriate, but the comment currently promises tests and protections that are absent.
Firecracker instances on one host share
br0and a single subnet, so any guestcould reach any other guest's SSH and forwarded ports.
What this does
Two controls, both asserted on every
setup_tapand both fail-closed:forward frames between two guest ports. This is the only control that can
cover this path: bridged frames reach the
FORWARDchain only whenbr_netfilteris loaded.iptables -I FORWARD 1 -i br0 -o br0 -j DROP, removed inteardown_bridgealongside the other three rules. Port isolation cannotcover this path: a frame a guest addresses to the bridge is local delivery,
not port-to-port forwarding, so the flag never applies, and
ip_forwardsends it back out
br0from the bridge device, which has no isolated sourceport either. Without it,
ip route add <peer>/32 via <bridge ip>in a rootguest reaches the peer on any host whose
FORWARDpolicy isACCEPT— thekernel default.
The rule is asserted per
setup_taprather than inensure_bridge, whichreturns early on a pre-existing bridge and would otherwise leave it absent.
Guest→host and guest→internet are unaffected: the DROP matches neither local
delivery nor
br0 → host_iface.Readback
bridge link set … isolated oncan succeed while doing nothing.IFLA_BRPORT_ISOLATEDis attribute 33, and a kernel below 4.18 caps thebridge-port policy at 32 and silently drops out-of-range attributes, so the
command exits 0 on a port that is not isolated. The flag is read back and the
VM start fails if it did not take.
The floor is Linux ≥ 4.18 and iproute2 ≥ 4.19 — the kernel side landed in
4.18, but the
isolatedkeyword is absent frombridge/link.cat the v4.18.0tag and first appears in v4.19.0.
Tests
A Firecracker-gated phase in
test_multi_instanceasserts, with two liveinstances: A cannot ping B directly (L2), A cannot reach B with
/32routes viathe gateway (L3), and A still reaches the host gateway.
The routed probe installs the
/32on both guests deliberately. With onlythe initiator's route, B answers over its connected
/24— tap_b to tap_a, twoisolated ports — so the reply dies at L2 and the ping fails whether or not the
FORWARDrule exists. Routing the return leg makes the assertion depend on therule alone. The gateway ping runs first as a positive control, since every other
assertion reads a ping failure as success.
port_is_isolatedis a pure predicate with unit tests over realbridge -d link showoutput, including the kernel-too-old case where theattribute is absent rather than
off. Only the two shell-out wrappers are addedto
.cargo/mutants.toml.Residuals (documented in
docs/trust-model.md, not closed here)isolated, so a VM still running from before the upgrade leaves the whole
bridge unisolated until restarted, not just itself. Restart running VMs
after upgrading.
coop reaches guests by IP with host-key checking deliberately disabled, so a
connection meant for a peer could land on an impostor with its
SendEnvpayload. Pinning MAC/IP (
ebtables, staticip neigh) is not implemented.iptablesis touched, and anyiptables-restorediscards the rule until the nextcoop up.br0—ensure_bridgeadopts a bridge it did not createwithout inspecting its members or installing its other rules; on such a host
the DROP applies to that bridge's traffic too. Making
ensure_bridgeassertall four of its rules idempotently is a follow-up, kept out of this PR to
avoid mixing a networking refactor into a security fix.
macOS is unaffected: the generated Lima templates declare no
networks:stanza,so each guest gets its own per-instance usernet stack.
Verification
Run and passing:
cargo fmt -- --check,cargo clippy --all-targets --all-features -- -D warnings, the unit suite (1112 passing),bash -nandshellcheckon the integration script.The integration suite was not run.
mod networkis dead code on macOS, sothis code path cannot execute on the machine it was written on, and no CI
workflow runs
tests/integration.sh. The new phase is additionally--full-gated and Firecracker-only. It needs./tests/run-integration.sh --remote user@host --fullon a Linux/KVM hostbefore the assertions here can be trusted.
🤖 Generated with Claude Code