Skip to content

net: let VMs talk to each other, but not to the host - #20

Merged
aljoscha merged 14 commits into
mainfrom
network-policy
Aug 11, 2026
Merged

net: let VMs talk to each other, but not to the host#20
aljoscha merged 14 commits into
mainfrom
network-policy

Conversation

@aljoscha

@aljoscha aljoscha commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Makes VM-to-VM reachability and host isolation contracts rather than accidents of the host's iptables policy.

Today nothing permits forwarding between two VM TAPs and nothing denies it, so whether two VMs can talk is decided by the host's FORWARD policy: it works on a box with no firewall and fails on one where docker or firewalld set FORWARD DROP. Host reachability is the same story in reverse. The guest's default gateway is a host address and there are no INPUT rules anywhere, so a guest can reach the host at every address it owns unless the host's own firewall happens to stop it.

Design and rationale: docs/NETWORK-POLICY-SPEC.md.

The contract

A VM reaches the internet and the other VMs of its own installation. It cannot reach the host.

Each installation owns two chains, entered from position 1 of the built-in chains:

-A INPUT   -j ember-<id>-input
-A FORWARD -j ember-<id>-forward

# ember-<id>-input
-i em<id>-+ -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-i em<id>-+ -j DROP

# ember-<id>-forward, install-wide
-i em<id>-+ -o em<id>-+ -j ACCEPT
-i em<id>-+ -j DROP

# ember-<id>-forward, per VM
-i <tap> -o <wan> -j ACCEPT
-i <wan> -o <tap> -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

Position 1 is what makes this a contract instead of a suggestion. Appending cannot work: a pre-existing -A INPUT -s 10.0.0.0/8 -j ACCEPT, a common "trust the LAN" rule that matches the default guest range, would match before the host block. Jumping in at the top is only acceptable because every rule in both chains matches an ember TAP, so anything else falls off the end and resumes in the built-in chain right after the jump.

The established-accept in the input chain is mandatory. Replies to host-initiated connections arrive on INPUT from the TAP, so without it ember ssh, exec and cp break.

One rule carries VM-to-VM in both directions: A to B matches with in=tapA out=tapB, B's reply with in=tapB out=tapA, so no conntrack state is needed. Scoped to the install's TAP prefix on both sides, so cross-install traffic stays out.

Rule order needs no bookkeeping. Each chain holds ACCEPTs plus exactly one terminal DROP, ACCEPTs are inserted at the front and the DROP is appended, so the DROP is always last whatever subset already exists.

Notable pieces

A Rule type behind every iptables call. One definition serves the add, check and delete paths. iptables compares full rule text on -D, so the previous duplicate spelling of each rule was a standing invitation to leak rules. Every invocation also takes the xtables lock, which was missing and could fail two concurrent VM starts.

IPv6 was an open hole. The kernel gives both ends of a TAP a v6 link-local address, so a guest could reach the host over IPv6 while the v4 block held. Every TAP on the development host had such an address. Closed with disable_ipv6 per device before the link comes up, rather than a second parallel set of ip6tables rules.

Running VMs are adopted, not broken. A VM running when the chains first appear has its rules in the built-in FORWARD chain, below the jump, so the terminal DROP would cut it off the instant another VM start creates the chain. Reconcile moves such a VM's rules into the chain and records where they went, adding before deleting so the VM is never ruleless. The masquerade rule is excluded from that move: its shape is identical in both modes, so shifting the full set would delete it right after re-adding it and leave the VM without NAT.

Host-run resolvers no longer break guest DNS. A host running dnsmasq, or a pihole bound to the LAN address, hands out a nameserver the guest can no longer reach. Host addresses join loopback and IPv6 in the unreachable-from-the-guest filter, falling through to the next detection source with a warning about what was dropped.

Legacy compatibility. NetworkInfo.firewall_chain records where a VM's rules live, so a VM started by an older binary is still torn down from the built-in FORWARD chain with its comment tag. Masquerade keeps its shape and its home in the shared nat table, so rules written before and after this change stay mutually deletable. Installs with no instance_id use ember-input / ember-forward and the em-+ wildcard.

Testing

Unit tests cover chain and wildcard derivation, both rule shapes rendered as exact iptables argument vectors, the established-before-DROP ordering, and that the forwarding set excludes masquerade.

Two integration test groups are added, both #[ignore] since they need root and a hypervisor. CI does not run them, so they were run by hand:

./run-integration-tests.sh network_policy   # pass
./run-integration-tests.sh ssh              # pass
./run-integration-tests.sh vm               # pass
./run-integration-tests.sh isolation        # pass
./run-integration-tests.sh dm_thin          # pass

tests/network_policy.rs has a structural test that pins rule placement, not just presence, since a terminal DROP that drifts above the per-VM ACCEPTs leaves every rule technically present and the contract broken. Plus a connectivity test that boots two VMs and checks a sibling is reachable, the host is not at any of its addresses, and egress still works. tests/isolation.rs gains a guard that one install's deinit leaves another install's chains alone.

Decisions

  • VM-to-host is not overridable. No escape hatch, by choice. If reaching a host service from a VM turns out to be needed, the shape would be an install-wide ember init --allow-host-access.
  • Entering FORWARD at position 1 is accepted, with the consequence that a host admin's mid-chain DROP or REJECT no longer governs ember VM traffic. An admin who does want to restrain ember VMs still can: policy::ensure only checks that its jump exists, not where it sits, so a rule inserted above the jump survives every subsequent VM start.

Two things left open and out of scope, both recorded in the spec: whether masquerade should move into its own chain, and a per-VM network.isolated opt-out.

Consequences

  • A guest pinging its default gateway now gets silence. Traffic is dropped rather than rejected, so guest connections to the host hang until timeout instead of failing fast.
  • A WAN interface change now breaks a running VM's egress explicitly rather than accidentally. Its egress rule and masquerade both name the old interface, so the terminal DROP stops the traffic that previously leaked out unSNATed and failed upstream anyway.
  • A host firewall flush while VMs run degrades the policy until the next VM start. Rules are asserted at start, not continuously.
  • Guests can still reach the rest of the host's LAN. Only the host itself becomes unreachable.

Specs the Linux firewall design that makes VM-to-VM reachability and
host isolation contracts rather than accidents of the host's iptables
policy. Two install-owned chains jumped from position 1, per-VM rules
moved inside them, and IPv6 closed on ember links.
VM-to-VM reachability and host isolation were both accidents of the
host's iptables policy: nothing permitted forwarding between two TAPs,
nothing denied it, and nothing stopped a guest reaching the host at its
gateway address. Which way it went depended on whether something else
on the host had set FORWARD DROP.

Each installation now owns 'ember-<id>-input' and
'ember-<id>-forward', jumped to from position 1 of the built-in chains,
holding one contract: a VM reaches the internet and its siblings, and
nothing else. Appending could not deliver that, a pre-existing
'trust the LAN' ACCEPT in INPUT or a mid-chain REJECT in FORWARD is
reached first. Jumping in at the top is safe because every rule in the
chains matches an ember TAP, so other traffic falls through unchanged.

Rule order needs no bookkeeping: the chains hold ACCEPTs plus one
terminal DROP, ACCEPTs are inserted at the front and the DROP is
appended, so the DROP stays last whatever subset already exists.

Per-VM forwarding rules move into the install's chain, where the chain
is the scope and the comment match becomes redundant. Masquerade stays
in the shared nat table with its comment, unchanged in shape, so rules
written before and after this change stay mutually deletable.
NetworkInfo records which chain a VM's rules went into, so a VM started
by an older binary is still torn down from the built-in FORWARD chain.

A Rule type now backs every iptables call, with one definition serving
the add, check and delete paths. iptables compares full rule text on
-D, so the previous duplicate spelling of each rule was a standing
invitation to leak rules. Every invocation also takes the xtables lock,
which was missing and could fail two concurrent VM starts.
ember configures IPv4 only, but the kernel gives both ends of a TAP
link a v6 link-local address, so a guest can reach the host over IPv6
while the v4 policy holds the host to be unreachable. Every TAP on this
host has such an address today.

Turning the stack off on the device closes that with one sysctl write
per TAP, instead of a second parallel set of ip6tables rules to keep in
sync. Done before the link comes up, so no link-local address is ever
assigned.
The install's chains outlive every VM, so something has to delete them
when the install goes away. NetworkBackend gains a per-install deinit
alongside the per-VM teardown, defaulting to a no-op for backends that
keep no host-wide state. Runs before storage teardown, best-effort, a
leftover chain is not worth refusing to tear the install down.

'ember info' now names the two chains, so 'iptables -S <name>' is one
copy-paste away when the policy needs inspecting.
A host running its own resolver (dnsmasq, or a pihole bound to the LAN
address) hands out a nameserver that guests can no longer reach now that
VM-to-host traffic is blocked, so every guest query would time out.

Host addresses join loopback and IPv6 in the unreachable-from-the-guest
filter, which falls through to the next detection source and warns about
what it dropped. Working DNS via a public resolver beats a correct-looking
nameserver that answers nothing, and the warning says why the internal
resolver is not being used.
Placement is what the structural test pins down, not just presence: a
terminal DROP that drifts above the per-VM ACCEPTs, or a jump that stops
being first in INPUT, turns the contract back into a coin flip while
every rule is still technically there. It also checks that stopping a VM
leaves the install's policy standing and that deinit removes it.

The connectivity test boots two VMs and uses real packets: a sibling is
reachable, the host is not at any of its addresses, and egress still
works. An isolation test guards that one install's deinit leaves another
install's chains alone.

docs/SPEC.md gets the rule listing, the chain lifecycle, the IPv6 step
in VM start, and the host-address DNS filter.
A VM running when the chains first appear has its forwarding rules in
the built-in FORWARD chain, below the jump, so the chain's terminal DROP
cuts it off the instant another VM start creates the chain. The VM keeps
running and silently loses its network.

Reconcile now moves such a VM's rules into the chain and records where
they went, adding before deleting so the VM is never ruleless. The
masquerade rule is excluded from the move: its shape is identical in
both modes, so shifting the full set would delete it right after
re-adding it and leave the VM without NAT. VmRules exposes the
forwarding pair separately for exactly this reason.

One-shot per VM. Once every record names a chain, the check is free.
clippy 1.97 flags these four as useless_borrows_in_formatting, which
turns the CI lint step red on the current stable toolchain. Unrelated to
the surrounding branch, it just happens to be what is standing between
it and a green run.
No escape hatch for now. Keeps the shape of one on record in case
reaching a host service from a VM turns out to be needed.
The check scanned INPUT and FORWARD for the substring 'ember-', which
matches any install's jump, not just the one the test tore down. A
developer's own install has its chains in those same built-in chains and
they outlive its VMs by design, so the assertion failed on every machine
that actually runs ember.
Two ways the connectivity test wedged the machine rather than failing.

A Firecracker process that outlives the test holds its zvol open, so the
harness's pool teardown blocks in 'zpool destroy' in uninterruptible
sleep until someone kills the process by hand. A panic anywhere past VM
start hit this. Both tests now stop their VMs from a Drop guard, ordered
to run before the pool is destroyed.

The second VM also ran the ubuntu-slim image, while the harness sizes its
pool for exactly one such rootfs. It only has to answer pings, which the
guest kernel does by itself, so alpine at 128M does the job.
Two problems the vm suite surfaced.

The networking test asserted that the built-in FORWARD chain mentions the
VM's TAP. Per-VM rules now live in the install's own chain, and FORWARD
holds only the jump into it, so the assertion looked for them in the one
place they are deliberately absent. It now reads the chain name from the
VM's inspect output, and also checks the rules are gone from that chain
after a stop.

Every test that starts a VM creates the install's chains, and nothing
removed them: no test runs 'ember deinit', so each run left two chains
and two jumps on the developer's machine and they piled up one pair per
run. The pool cleanup guard now removes them, keyed off the instance id
in the install's own config. Installs with no instance id are skipped,
since their chain names are shared with the developer's real install and
there is no way to tell whose chains those are.
Most calls in the cleanup path are expected to fail, since a test that
never started a VM has no chains to remove. Letting iptables write to the
inherited stderr buried the actual test output under a screenful of
'No chain/target/match'.
Entering FORWARD at position 1 is accepted, along with its consequence
that a host admin's mid-chain DROP or REJECT no longer governs ember VM
traffic. Notes the escape hatch that remains: ensure only checks that its
jump exists, not where it sits, so a rule inserted above the jump
survives later VM starts.

Splits the section into what is decided and what is still open.
@aljoscha
aljoscha merged commit a3e6749 into main Aug 11, 2026
2 checks passed
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