Skip to content

Commit 0aa1d02

Browse files
authored
Merge pull request #837 from Dstack-TEE/feat/libvirt-netfilter
feat(vmm): add optional libvirt network filtering
2 parents cb961ad + 39b8315 commit 0aa1d02

12 files changed

Lines changed: 1108 additions & 14 deletions

File tree

docs/libvirt-network-filter.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Optional libvirt network filtering
2+
3+
## Goal
4+
5+
Allow bridge-backed VMs to opt into an existing libvirt `nwfilter` without
6+
allowing libvirt to create or launch the QEMU domain. QEMU remains entirely
7+
owned by `dstack-vmm`, so its command line and attestation inputs do not change
8+
outside the explicitly selected network backend.
9+
10+
The measurable acceptance criteria are:
11+
12+
- `network_filter = "none"` preserves the existing QEMU `-netdev bridge`
13+
behavior and does not require `netd` or libvirt.
14+
- `network_filter = "libvirt"` creates the TAP and filter binding before QEMU
15+
is submitted to Supervisor, and uses QEMU `-netdev tap`.
16+
- A failed TAP or filter setup prevents QEMU from starting and rolls back all
17+
interfaces prepared for that VM.
18+
- Normal stop and removal delete the filter binding and TAP.
19+
- One host `netd` can serve multiple VMM instances. Resource names include a
20+
stable VMM instance namespace, VM ID, and NIC index.
21+
- Development builds can run the VMM and `netd` directly, with explicit socket
22+
and allowed-UID command-line options; systemd is not required.
23+
24+
## Configuration
25+
26+
Filtering is a VMM host policy, not a field accepted from a VM manifest:
27+
28+
```toml
29+
[cvm.network_filter]
30+
mode = "none" # or "libvirt"
31+
filter = "clean-traffic"
32+
parameters = {}
33+
34+
[netd]
35+
socket = "/run/dstack/netd.sock"
36+
allowed_uids = [] # empty means root only
37+
libvirt_uri = "qemu:///system"
38+
```
39+
40+
Each VMM instance also has an `instance_id`. It must be unique among VMMs that
41+
share a host. If omitted, the VMM derives a stable namespace from its absolute
42+
run directory.
43+
44+
## Architecture
45+
46+
`netd` is a host-level privilege broker. It accepts a small, bounded JSON
47+
protocol over a Unix stream socket and authorizes clients with `SO_PEERCRED`.
48+
A single process can serve multiple VMMs; a dedicated process can use another
49+
socket for development or isolation.
50+
51+
For libvirt mode, startup is:
52+
53+
1. Derive the TAP name from instance namespace, VM ID, and NIC index.
54+
2. Create the TAP for the configured QEMU UID and attach it to the bridge.
55+
3. Create a libvirt nwfilter binding for the TAP.
56+
4. Bring the TAP up and return success.
57+
5. Start QEMU directly with `-netdev tap,script=no,downscript=no`.
58+
59+
Teardown stops QEMU first, removes the binding, and deletes the TAP. Operations
60+
are serialized by `netd`. The design intentionally does not add ownership
61+
aliases; deployments must use unique instance IDs.
62+
63+
Every UID listed in `allowed_uids` is mutually trusted for network operations:
64+
the protocol does not pin an UID to an instance namespace, so any allowed UID
65+
can prepare, check, or remove any deterministic identity. Deploy VMM instances
66+
that do not share this trust boundary with dedicated netd sockets and distinct
67+
allowlists.
68+
69+
`netd` invokes fixed absolute `ip` and `virsh` executables with separate
70+
arguments. It never accepts a command, executable path, TAP name, or raw XML
71+
from a client. Filter XML is generated internally with XML escaping and is
72+
validated by libvirt.
73+
74+
## Deployment modes
75+
76+
Production should run one shared service. `netd` reads only the `[netd]`
77+
section, so its root-owned configuration can be small and independent of every
78+
VMM instance:
79+
80+
```toml
81+
# /etc/dstack/netd.toml
82+
[netd]
83+
socket = "/run/dstack/netd.sock"
84+
allowed_uids = [991, 992]
85+
libvirt_uri = "qemu:///system"
86+
```
87+
88+
```ini
89+
# /etc/systemd/system/dstack-netd.service
90+
[Unit]
91+
Description=dstack host networking service
92+
After=libvirtd.service
93+
94+
[Service]
95+
ExecStart=/usr/bin/dstack-vmm --config /etc/dstack/netd.toml netd
96+
Restart=on-failure
97+
98+
[Install]
99+
WantedBy=multi-user.target
100+
```
101+
102+
All VMM instance configurations point to the same socket and use distinct
103+
`cvm.instance_id` values. A dedicated netd uses a different socket. A
104+
host-wide lock serializes mutations made by shared and dedicated netd
105+
processes.
106+
107+
Development mode is two ordinary commands:
108+
109+
```bash
110+
sudo dstack-vmm --config ./vmm.toml netd \
111+
--socket /run/dstack-dev/netd.sock --allow-uid "$(id -u)"
112+
dstack-vmm --config ./vmm.toml \
113+
--netd-socket /run/dstack-dev/netd.sock
114+
```
115+
116+
User networking and bridge networking with `mode = "none"` never connect to
117+
`netd`. Libvirt mode fails closed if `netd` is unavailable.
118+
119+
Filtered TAP netdevs currently set `vhost=off`. This keeps the initial backend
120+
on the directly bound TAP path and avoids adding `/dev/vhost-net` permissions
121+
to the QEMU user. It is a deliberate security-first throughput tradeoff; a
122+
future configurable vhost mode requires equivalent filter integration tests.

dstack/Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dstack/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ strip-ansi-escapes = "0.2.1"
287287
tailf = "0.1.2"
288288
time = "0.3.47"
289289
uuid = { version = "1.15.1", features = ["v4"] }
290+
wait-timeout = "0.2"
290291
which = "7.0.2"
291292
smallvec = "1.14.0"
292293
cmd_lib = "1.9.5"

dstack/vmm/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ reqwest.workspace = true
6363
flate2.workspace = true
6464
tar.workspace = true
6565
tempfile.workspace = true
66+
wait-timeout.workspace = true
6667

6768
[dev-dependencies]
6869
insta.workspace = true

dstack/vmm/src/app.rs

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5-
use crate::config::{Config, Networking, ProcessAnnotation, Protocol};
6-
use crate::logrotate;
5+
use crate::{
6+
config::{Config, NetworkFilterMode, Networking, NetworkingMode, ProcessAnnotation, Protocol},
7+
logrotate,
8+
netd::{self, InterfaceIdentity, PrepareRequest, Request as NetdRequest},
9+
};
710

811
use anyhow::{bail, Context, Result};
912
use bon::Builder;
@@ -18,6 +21,7 @@ use dstack_vmm_rpc::{
1821
use fs_err as fs;
1922
use guest_api::client::DefaultClient as GuestClient;
2023
use id_pool::IdPool;
24+
use nix::unistd::{Uid, User};
2125
use or_panic::ResultOrPanic;
2226
use ra_rpc::client::RaClient;
2327
use serde::{Deserialize, Serialize};
@@ -443,17 +447,30 @@ impl App {
443447
append_boot_separator(&path);
444448
}
445449

450+
let runtime_networks = resolved_networks(&vm_config.manifest, &self.config.cvm);
446451
let devices = self.try_allocate_gpus(&vm_config.manifest)?;
447452
let processes = vm_config.config_qemu(&work_dir, &self.config.cvm, &devices)?;
448-
let runtime_networks = resolved_networks(&vm_config.manifest, &self.config.cvm);
449453
work_dir.set_runtime_networks(&runtime_networks)?;
454+
if let Err(error) = self
455+
.prepare_filtered_networks(&vm_config, &runtime_networks)
456+
.await
457+
{
458+
let _ = work_dir.clear_runtime_networks();
459+
return Err(error);
460+
}
450461
{
451462
let mut state = self.lock();
452463
let vm_state = state.get_mut(id).context("VM not found")?;
453-
vm_state.state.runtime_networks = runtime_networks;
464+
vm_state.state.runtime_networks = runtime_networks.clone();
454465
}
455466
for process in processes {
456467
if let Err(err) = self.supervisor.deploy(&process).await {
468+
if let Err(cleanup_error) = self
469+
.remove_filtered_networks(&vm_config.manifest.id, &runtime_networks)
470+
.await
471+
{
472+
warn!(id, %cleanup_error, "failed to roll back filtered networking");
473+
}
457474
if let Err(clear_err) = work_dir.clear_runtime_networks() {
458475
warn!(
459476
id,
@@ -488,6 +505,108 @@ impl App {
488505
}
489506
self.set_started(id, false)?;
490507
self.stop_vm_process(id).await?;
508+
let networks = self.work_dir(id)?.runtime_networks();
509+
self.remove_filtered_networks(id, &networks).await?;
510+
Ok(())
511+
}
512+
513+
async fn prepare_filtered_networks(
514+
&self,
515+
vm: &VmConfig,
516+
networks: &[Networking],
517+
) -> Result<()> {
518+
if self.config.cvm.network_filter.mode == NetworkFilterMode::None {
519+
return Ok(());
520+
}
521+
let qemu_uid = if self.config.cvm.user.is_empty() {
522+
Uid::effective().as_raw()
523+
} else {
524+
User::from_name(&self.config.cvm.user)
525+
.context("failed to resolve QEMU user")?
526+
.with_context(|| format!("QEMU user {} does not exist", self.config.cvm.user))?
527+
.uid
528+
.as_raw()
529+
};
530+
let mut prepared = Vec::new();
531+
for (nic_index, network) in networks.iter().enumerate() {
532+
if network.mode != NetworkingMode::Bridge {
533+
continue;
534+
}
535+
let identity = InterfaceIdentity {
536+
instance_id: self.config.cvm.instance_id.clone(),
537+
vm_id: vm.manifest.id.clone(),
538+
nic_index,
539+
};
540+
let request = PrepareRequest {
541+
identity: identity.clone(),
542+
bridge: network.bridge.clone(),
543+
mac: network::mac_address_for_vm_index(
544+
&vm.manifest.id,
545+
&network.mac_prefix_bytes(),
546+
nic_index,
547+
),
548+
qemu_uid,
549+
filter: self.config.cvm.network_filter.filter.clone(),
550+
parameters: self.config.cvm.network_filter.parameters.clone(),
551+
};
552+
if let Err(error) =
553+
netd::request(&self.config.netd.socket, &NetdRequest::Prepare(request)).await
554+
{
555+
// The client may have timed out while netd was still finishing
556+
// this Prepare. Remove the in-flight identity first; netd's
557+
// serialized accept loop processes it after Prepare completes.
558+
if let Err(cleanup_error) = netd::request(
559+
&self.config.netd.socket,
560+
&NetdRequest::Remove {
561+
identity: identity.clone(),
562+
},
563+
)
564+
.await
565+
{
566+
warn!(%cleanup_error, "failed to roll back in-flight filtered network");
567+
}
568+
for identity in prepared.into_iter().rev() {
569+
if let Err(cleanup_error) =
570+
netd::request(&self.config.netd.socket, &NetdRequest::Remove { identity })
571+
.await
572+
{
573+
warn!(%cleanup_error, "failed to roll back prepared filtered network");
574+
}
575+
}
576+
return Err(error).context("failed to prepare libvirt-filtered networking");
577+
}
578+
prepared.push(identity);
579+
}
580+
Ok(())
581+
}
582+
583+
pub(crate) async fn remove_filtered_networks(
584+
&self,
585+
vm_id: &str,
586+
networks: &[Networking],
587+
) -> Result<()> {
588+
if self.config.cvm.network_filter.mode == NetworkFilterMode::None {
589+
return Ok(());
590+
}
591+
let mut first_error = None;
592+
for (nic_index, network) in networks.iter().enumerate().rev() {
593+
if network.mode != NetworkingMode::Bridge {
594+
continue;
595+
}
596+
let identity = InterfaceIdentity {
597+
instance_id: self.config.cvm.instance_id.clone(),
598+
vm_id: vm_id.to_string(),
599+
nic_index,
600+
};
601+
if let Err(error) =
602+
netd::request(&self.config.netd.socket, &NetdRequest::Remove { identity }).await
603+
{
604+
first_error.get_or_insert(error);
605+
}
606+
}
607+
if let Some(error) = first_error {
608+
return Err(error).context("failed to remove libvirt-filtered networking");
609+
}
491610
Ok(())
492611
}
493612

@@ -597,6 +716,11 @@ impl App {
597716
}
598717
}
599718

719+
let runtime_networks = self.work_dir(id)?.runtime_networks();
720+
if let Err(error) = self.remove_filtered_networks(id, &runtime_networks).await {
721+
warn!(id, %error, "failed to remove filtered networking during VM removal");
722+
}
723+
600724
// Only delete the workdir for user-initiated removal or if .removing marker exists.
601725
// Orphaned supervisor processes without the marker keep their data intact.
602726
let vm_path = self.work_dir(id)?;

0 commit comments

Comments
 (0)