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.
kvinwang
force-pushed
the
feat/netd-ingress
branch
from
August 28, 2026 14:24
7e1e688 to
104b526
Compare
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.
`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 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.
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.