net: let VMs talk to each other, but not to the host - #20
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
FORWARDpolicy: it works on a box with no firewall and fails on one where docker or firewalld setFORWARD DROP. Host reachability is the same story in reverse. The guest's default gateway is a host address and there are noINPUTrules 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
Each installation owns two chains, entered from position 1 of the built-in chains:
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
INPUTfrom the TAP, so without itember ssh,execandcpbreak.One rule carries VM-to-VM in both directions: A to B matches with
in=tapA out=tapB, B's reply within=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
Ruletype 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_ipv6per 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_chainrecords 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 sharednattable, so rules written before and after this change stay mutually deletable. Installs with noinstance_iduseember-input/ember-forwardand theem-+wildcard.Testing
Unit tests cover chain and wildcard derivation, both rule shapes rendered as exact
iptablesargument 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:tests/network_policy.rshas 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.rsgains a guard that one install's deinit leaves another install's chains alone.Decisions
ember init --allow-host-access.FORWARDat position 1 is accepted, with the consequence that a host admin's mid-chainDROPorREJECTno longer governs ember VM traffic. An admin who does want to restrain ember VMs still can:policy::ensureonly 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.isolatedopt-out.Consequences