Uh oh!
There was an error while loading. Please reload this page.
feat(vmm): give port mappings a NIC, and carry them to netd - #1154
Open
kvinwang wants to merge 2 commits into
Open
feat(vmm): give port mappings a NIC, and carry them to netd#1154kvinwang wants to merge 2 commits into
kvinwang wants to merge 2 commits into
Conversation
`port_map` is implemented as QEMU `hostfwd=` entries on a user-mode netdev. A bridge NIC has none, so `configure_networking` finds no interface to hang them on and emits none -- with no warning, no validation, and `GetInfo` still reporting the ports as though they worked. A VM moved from user mode to a bridge silently loses every published port. The VMM cannot fix that itself: it runs without CAP_NET_ADMIN by design, and the userspace forwarder that used to cover this was removed in e2e607f because proxying on the host hands back the per-packet cost that leaving user mode was meant to escape. netd is privileged and is the only component that sees every VMM instance on a host, so it is also the only one that can arbitrate a host port between them. So state the requirement rather than assume it is met. `ingress` on `prepare_bridge` carries protocol, host address, host port and guest port. The host address is not decoration -- an admin port bound to loopback and a published one differ only there. Every field is caller-named, which is what `bridge`, `mac` and `queues` already get and the opposite of `filtered`. The line is whether netd can check what it is handed: an nwfilter name cannot be checked for whether it filters anything, while a host port is a closed space a node policy can be stated over. Naming is not deciding; which ports may be handed out stays netd's own configuration, exactly as `allowed_bridges` governs the bridge a caller names. The netd here builds interfaces and does not forward ports. It says so by leaving `ingress` out of its response, which is the reading `queues` already has: absent separates "this netd does not do that" from "nothing was asked for". The VMM warns on that rather than failing, because a VM deployed before this has been running with its ports dropped and refusing to launch it now would turn a silent misconfiguration into an outage on upgrade. Also add `workdir` to both prepare operations. Untrusted and never read for a decision, it is there so an operator reading netd's log can get from an opaque TAP name back to the VM that asked for it. No new operations, and nothing removed.
kvinwangforce-pushed
the
feat/netd-ingress
branch
from
August 28, 2026 14:24
7e1e688 to
104b526CompareThree things that are one thing: a port mapping had no way to say which NIC it uses, the NIC it silently got was often the wrong one, and on a bridge it got nothing at all. `port_map` is implemented as QEMU `hostfwd=` entries, and those need a user-mode netdev: let hostfwd_index = self.prepared.networks.iter() .position(|n| n.mode == NetworkingMode::User); Multi-NIC (#756) made that a choice, and it has been made silently ever since. A bridge NIC for external traffic beside a user-mode NIC for management -- the topology multi-NIC was added for -- puts every published port on the *management* NIC: the traffic reaches the guest, but over slirp, bypassing whatever the bridge NIC's nwfilter was there to enforce and hiding the client's address behind the slirp gateway. A second user-mode NIC can never publish anything, because `position` returns the first. And with no user-mode NIC at all there is no `hostfwd_index`, so every mapping is dropped with no warning while `GetInfo` keeps reporting the ports as though they worked. So say it. `PortMapping.nic_index` names the NIC a mapping enters through, `@<nic>` on the CLI, unset resolving to the first user-mode NIC and failing that the first bridge NIC. Existing VMs keep their behaviour exactly wherever a user-mode NIC exists; where none does, ports now have somewhere to go instead of nowhere. One mapping resolves to exactly one NIC, and that NIC's backend decides the mechanism: `hostfwd=` for user mode, netd for a bridge. Both sides read the same resolution, so no host port can be claimed twice. The VMM cannot forward a bridge NIC's ports itself -- it runs without CAP_NET_ADMIN by design, and the userspace forwarder that used to cover this was removed in e2e607f because proxying on the host hands back the per-packet cost that leaving user mode was meant to escape. netd is privileged and is the only component that sees every VMM instance on a host, so it is also the only one that can arbitrate a host port between them. `ingress` on `prepare_bridge` carries the requirement there. Every field of it is caller-named, which is what `bridge`, `mac` and `queues` already get and the opposite of `filtered`. The line is whether netd can check what it is handed: an nwfilter name cannot be checked for whether it filters anything, while a host port is a closed space a node policy can be stated over. Naming is not deciding. The netd here builds interfaces and does not forward ports. It says so by leaving `ingress` out of its response -- the reading `queues` already has, where absent separates "this netd does not do that" from "nothing was asked for". The VMM warns rather than failing, because a VM deployed before this has been running with its ports dropped and refusing to launch it now would turn a silent misconfiguration into an outage. Also add `remove_all`. Teardown by identity only reaches the NIC indices its caller still has a record of, and that record is written after the interface exists: a VMM killed in between leaves a TAP nothing on disk points at, a lost record reads as "nothing to remove", and a manifest that lost a NIC leaves an index the list no longer reaches. netd derives every name a VM could occupy instead, bounded by what an identity may say. The VMM sweeps before preparing a launch as well as on stop, so a launch is self-healing regardless of what the record says, and an unreachable netd no longer fails a stop. And `workdir` on both prepare operations: untrusted, never read for a decision, there so an operator reading netd's log can get from an opaque TAP name back to the VM that asked for it.
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 freeto 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.
Stacked on #1145 — that PR reshaped these structs (
filtered: bool,queues), so this lands on top of it.Three things that are one thing: a port mapping had no way to say which NIC it uses, the NIC it silently got was often the wrong one, and on a bridge it got nothing at all.
The bug
port_mapis implemented as QEMUhostfwd=entries, and those need a user-mode netdev:Multi-NIC (#756) turned that into a choice, and it has been made silently ever since:
positionreturns the first.GetInfokeeps reporting the ports as though they worked. A VM moved from user mode to a bridge silently loses every published port.I hit the third one planning a production gateway's move off slirp: five mappings, including the WireGuard endpoint 100+ CVMs depend on, would have evaporated on first boot with nothing in the log to say so.
nic_indexPortMapping.nic_indexnames the NIC a mapping enters through;@<nic>on the CLI:Unset resolves to the first user-mode NIC — where
hostfwd=has always gone — and failing that the first bridge NIC. Existing VMs keep their behaviour exactly wherever a user-mode NIC exists; where none does, ports now have somewhere to go instead of nowhere.One mapping resolves to exactly one NIC, and that NIC's backend decides the mechanism:
hostfwd=for user mode, netd for a bridge. Both sides read the same resolution, so no host port can be claimed twice. There is a test for that property specifically.Deployment refuses a
nic_indexthe VM does not have. Whether the named NIC's backend can carry a port is left to launch, where the node configuration that decides it is the one in force — and where an existing VM gets a warning rather than a refusal.ingressThe VMM cannot forward a bridge NIC's ports itself: it runs without
CAP_NET_ADMINby design, and the userspace forwarder that used to cover this was removed in e2e607f — correctly, since proxying on the host hands back the per-packet cost that leaving user mode was meant to escape. netd is privileged and is the only component that sees every VMM instance on a host, so it is the only one that can arbitrate a host port between them.So
ingressonprepare_bridgecarries the requirement there. Every field is caller-named, which is whatbridge,macandqueuesalready get and the opposite offiltered. The line is not policy-vs-mechanism; it is whether netd can check what it is handed. An nwfilter name cannot be checked for whether it filters anything (allow-arpsatisfies "some filter" and drops nothing), so naming one is excluded. A host port is a closed, enumerable space a node policy can be stated over. Naming is not deciding.The netd in this repo builds interfaces and does not forward ports. It says so by leaving
ingressout of its response — the readingqueuesalready has, where absent separates "this netd does not do that" from "nothing was asked for". The VMM warns rather than failing: a VM deployed before this has been running with its ports dropped, and refusing to launch it now would turn a silent misconfiguration into an outage on upgrade.remove_allTeardown by identity only reaches the NIC indices its caller still has a record of — and that record is written after the interface exists. So:
The first one is not hypothetical: on a production host running a third-party netd, a failed prepare from two days earlier was still sitting in its state, half-built, with nothing able to find it.
remove_allnames a VM instead of an interface and derives every name that VM could occupy — bounded by whatvalidate_identitylets an identity say, so it is 256stats that usually miss. No state, no record. The VMM sweeps before preparing a launch as well as on stop, which makes a launch self-healing regardless of what the record says.Two follow-on fixes fall out: an unreachable netd no longer fails a
stop_vm(a VM's teardown should not depend on a daemon being up, andfinish_removealready only warned), and teardown no longer depends on the runtime record being accurate.workdirUntrusted, never read for a decision, present so an operator reading netd's log can get from an opaque TAP name back to the VM that asked for it.
Compatibility
Additive.
nic_index,workdirandingressare all optional and default to today's behaviour; the response field is optional and skipped when absent.PortMappinggains proto tag 5,NetworkInterfaceStatusis untouched.The one behaviour change is deliberate: a VM with ports and no user-mode NIC used to drop them silently and now says so.
Testing
cargo test -p dstack-vmm— 189 pass, 7 new.cargo clippy -- -D warningsclean. CLI parsing exercised directly for the@<nic>suffix and its rejections.New cases cover the unpinned resolution order (including that it still lands where
hostfwdalways put it), pinning and out-of-range pinning, the one-mapping-one-NIC property, the request shapes forworkdirandingress, decoding a prepare carrying neither, the absent-vs-present reading ofingress, and the sweep's shape and bound.Not covered by unit tests: the sysfs enumeration in the sweep, and an actual forwarding netd — which by construction is not in this repository.