Skip to content

feat(vmm): give port mappings a NIC, and carry them to netd - #1154

Open
kvinwang wants to merge 2 commits into
feat/vmm-vhost-multiqueuefrom
feat/netd-ingress
Open

feat(vmm): give port mappings a NIC, and carry them to netd#1154
kvinwang wants to merge 2 commits into
feat/vmm-vhost-multiqueuefrom
feat/netd-ingress

Conversation

@kvinwang

@kvinwang kvinwang commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

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_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) turned that into a choice, and it has been made silently ever since:

  • A bridge NIC beside a user-mode NIC — 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.
  • With no user-mode NIC at all, every mapping is dropped — no warning, no validation, and GetInfo keeps 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_index

PortMapping.nic_index names the NIC a mapping enters through; @<nic> on the CLI:

vmm-cli.py deploy ... --port udp:0.0.0.0:7483:51820@0

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_index the 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.

ingress

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 — 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 ingress on prepare_bridge carries the requirement there. Every field is caller-named, which is what bridge, mac and queues already get and the opposite of filtered. 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-arp satisfies "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 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: 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_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. So:

  • a VMM killed in between leaves a TAP nothing on disk points at
  • a lost or unreadable record reads as an empty list, which meant "nothing to remove"
  • a manifest that lost a NIC leaves an index the list no longer reaches

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_all names a VM instead of an interface and derives every name that VM could occupy — bounded by what validate_identity lets an identity say, so it is 256 stats 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, and finish_remove already only warned), and teardown no longer depends on the runtime record being accurate.

workdir

Untrusted, 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, workdir and ingress are all optional and default to today's behaviour; the response field is optional and skipped when absent. PortMapping gains proto tag 5, NetworkInterfaceStatus is 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 warnings clean. CLI parsing exercised directly for the @<nic> suffix and its rejections.

New cases cover the unpinned resolution order (including that it still lands where hostfwd always put it), pinning and out-of-range pinning, the one-mapping-one-NIC property, the request shapes for workdir and ingress, decoding a prepare carrying neither, the absent-vs-present reading of ingress, 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.

Copilot AI lite review requested due to automatic review settings August 28, 2026 13:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

`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 kvinwang changed the title feat(netd): report what netd implements and what it still holds feat(netd): carry host port mappings and the VM workdir to netd Aug 28, 2026
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.
@kvinwang kvinwang changed the title feat(netd): carry host port mappings and the VM workdir to netd feat(vmm): give port mappings a NIC, and carry them to netd Aug 28, 2026
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.

2 participants