Skip to content

Commit 95993ae

Browse files
authored
Merge pull request #487 from Dstack-TEE/feat/vmm-bridge
feat(vmm): add bridge networking support
2 parents a092b00 + 9b8e9cb commit 95993ae

22 files changed

Lines changed: 2729 additions & 69 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ members = [
5252
"verifier",
5353
"no_std_check",
5454
"size-parser",
55+
"port-forward",
5556
]
5657
resolver = "2"
5758

@@ -64,6 +65,7 @@ dstack-gateway-rpc = { path = "gateway/rpc" }
6465
dstack-kms-rpc = { path = "kms/rpc" }
6566
dstack-guest-agent-rpc = { path = "guest-agent/rpc" }
6667
dstack-vmm-rpc = { path = "vmm/rpc" }
68+
dstack-port-forward = { path = "port-forward" }
6769
cc-eventlog = { path = "cc-eventlog" }
6870
supervisor = { path = "supervisor" }
6971
supervisor-client = { path = "supervisor/client" }

docs/bridge-networking.md

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# Bridge Networking for VMM
2+
3+
By default, dstack-vmm uses **user** networking (QEMU's built-in SLIRP stack, no host setup required). Bridge networking is an alternative that provides better performance for high-connection workloads by using kernel-level bridging with TAP devices.
4+
5+
## When to use bridge networking
6+
7+
- High connection concurrency (passt becomes CPU-bound at ~25K+ concurrent connections)
8+
- Workloads that need full L2 network access
9+
- Environments where VMs need to be directly reachable on the LAN
10+
11+
## Configuration
12+
13+
### VMM global config (`vmm.toml`)
14+
15+
```toml
16+
[cvm.networking]
17+
mode = "bridge"
18+
bridge = "virbr0"
19+
```
20+
21+
### Per-VM override
22+
23+
Individual VMs can override the global networking mode via:
24+
- **CLI**: `vmm-cli.py deploy --net bridge` or `--net passt`
25+
- **Web UI**: Networking dropdown in the deploy dialog
26+
- **API**: `networking: { mode: "bridge" }` in `VmConfiguration`
27+
28+
Only the mode is per-VM; the bridge interface name always comes from the global config.
29+
30+
## Host setup
31+
32+
### Option A: Using libvirt default network
33+
34+
libvirt's default network provides a bridge (`virbr0`) with DHCP (dnsmasq) and NAT out of the box.
35+
36+
```bash
37+
# Install libvirt (if not already present)
38+
sudo apt install -y libvirt-daemon-system
39+
40+
# Ensure default network is active
41+
sudo virsh net-start default 2>/dev/null
42+
sudo virsh net-autostart default
43+
```
44+
45+
Verify:
46+
```bash
47+
ip addr show virbr0
48+
# Should show 192.168.122.1/24
49+
50+
virsh net-dhcp-leases default
51+
# Lists DHCP leases for connected VMs
52+
```
53+
54+
### Option B: Manual bridge without libvirt
55+
56+
Create a bridge with systemd-networkd and run a standalone DHCP server.
57+
58+
**1. Create the bridge:**
59+
60+
```bash
61+
# /etc/systemd/network/10-dstack-br.netdev
62+
[NetDev]
63+
Name=dstack-br0
64+
Kind=bridge
65+
66+
# /etc/systemd/network/11-dstack-br.network
67+
[Match]
68+
Name=dstack-br0
69+
70+
[Network]
71+
Address=10.0.100.1/24
72+
ConfigureWithoutCarrier=yes
73+
IPMasquerade=both
74+
```
75+
76+
```bash
77+
sudo systemctl restart systemd-networkd
78+
```
79+
80+
**2. Enable IP forwarding:**
81+
82+
```bash
83+
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-dstack-bridge.conf
84+
sudo sysctl -p /etc/sysctl.d/99-dstack-bridge.conf
85+
```
86+
87+
**3. Run a DHCP server (dnsmasq):**
88+
89+
```bash
90+
sudo apt install -y dnsmasq
91+
```
92+
93+
Install the DHCP notification script (notifies VMM when a VM gets an IP so port forwarding can be established):
94+
95+
```bash
96+
sudo cp scripts/dhcp-notify.sh /usr/local/bin/dhcp-notify.sh
97+
sudo chmod +x /usr/local/bin/dhcp-notify.sh
98+
```
99+
100+
Create dnsmasq config:
101+
102+
```ini
103+
# /etc/dnsmasq.d/dstack-br0.conf
104+
interface=dstack-br0
105+
bind-interfaces
106+
dhcp-range=10.0.100.10,10.0.100.254,255.255.255.0,12h
107+
dhcp-option=option:router,10.0.100.1
108+
dhcp-option=option:dns-server,8.8.8.8,1.1.1.1
109+
dhcp-script=/usr/local/bin/dhcp-notify.sh
110+
```
111+
112+
The `dhcp-script` option tells dnsmasq to call the notification script on every lease event. The script sends the MAC and IP to VMM's `ReportDhcpLease` RPC, which triggers automatic port forwarding for the VM.
113+
114+
```bash
115+
sudo systemctl restart dnsmasq
116+
```
117+
118+
**4. Firewall rules (nftables):**
119+
120+
When the host firewall has a restrictive INPUT policy (e.g. `drop`), the bridge's DHCP and DNS traffic will be silently blocked. libvirt handles this automatically for virbr0, but a standalone bridge needs explicit rules.
121+
122+
```bash
123+
BRIDGE=dstack-br0
124+
SUBNET=10.0.100.0/24
125+
126+
# Allow DHCP and DNS from VMs (INPUT/OUTPUT)
127+
sudo nft add rule ip filter INPUT iifname "$BRIDGE" udp dport 67 counter accept
128+
sudo nft add rule ip filter INPUT iifname "$BRIDGE" udp dport 53 counter accept
129+
sudo nft add rule ip filter INPUT iifname "$BRIDGE" tcp dport 53 counter accept
130+
sudo nft add rule ip filter OUTPUT oifname "$BRIDGE" udp dport 68 counter accept
131+
sudo nft add rule ip filter OUTPUT oifname "$BRIDGE" udp dport 53 counter accept
132+
133+
# Allow forwarding for VM traffic
134+
sudo nft add rule ip filter FORWARD ip saddr "$SUBNET" iifname "$BRIDGE" counter accept
135+
sudo nft add rule ip filter FORWARD ip daddr "$SUBNET" oifname "$BRIDGE" ct state related,established counter accept
136+
sudo nft add rule ip filter FORWARD iifname "$BRIDGE" oifname "$BRIDGE" counter accept
137+
138+
# NAT masquerade for outbound traffic
139+
sudo nft add rule ip nat POSTROUTING ip saddr "$SUBNET" ip daddr 224.0.0.0/24 counter return
140+
sudo nft add rule ip nat POSTROUTING ip saddr "$SUBNET" ip daddr 255.255.255.255 counter return
141+
sudo nft add rule ip nat POSTROUTING ip saddr "$SUBNET" ip daddr != "$SUBNET" counter masquerade
142+
```
143+
144+
If the host uses libvirt, nftables rules may be in custom chains (`LIBVIRT_INP`, `LIBVIRT_FWO`, etc.) instead of the default `INPUT`/`FORWARD` chains. Adjust the chain names accordingly.
145+
146+
To make these rules persistent across reboots, save them with `nft list ruleset > /etc/nftables.conf` or add them to a systemd service.
147+
148+
**5. Update vmm.toml:**
149+
150+
```toml
151+
[cvm.networking]
152+
mode = "bridge"
153+
bridge = "dstack-br0"
154+
```
155+
156+
### QEMU bridge helper setup (required for both options)
157+
158+
The bridge helper allows QEMU to create and attach TAP devices without VMM needing root privileges.
159+
160+
```bash
161+
# Allow QEMU to use the bridge
162+
sudo mkdir -p /etc/qemu
163+
echo "allow virbr0" | sudo tee /etc/qemu/bridge.conf
164+
# Or for manual bridge: echo "allow dstack-br0" | sudo tee /etc/qemu/bridge.conf
165+
166+
# Set setuid on bridge helper
167+
sudo chmod u+s /usr/lib/qemu/qemu-bridge-helper
168+
```
169+
170+
## How it works
171+
172+
- VMM passes `-netdev bridge,id=net0,br=<bridge>` to QEMU
173+
- QEMU's bridge helper (setuid) creates a TAP device and attaches it to the bridge
174+
- Guest MAC address is derived from SHA256 of the VM ID, with an optional configurable prefix (stable across restarts for DHCP IP consistency)
175+
- The host DHCP server (dnsmasq) assigns an IP and calls `dhcp-notify.sh`, which notifies VMM via the `ReportDhcpLease` RPC
176+
- VMM matches the MAC address to identify the VM and establishes port forwarding rules
177+
- When QEMU exits, the TAP device is automatically destroyed
178+
- VMM does not need root or `CAP_NET_ADMIN`
179+
180+
### MAC address prefix
181+
182+
You can configure a fixed MAC address prefix (0–3 bytes) in vmm.toml:
183+
184+
```toml
185+
[cvm.networking]
186+
mode = "bridge"
187+
bridge = "dstack-br0"
188+
mac_prefix = "52:54:00"
189+
```
190+
191+
The remaining bytes are derived from the VM ID hash. The prefix applies to all networking modes, not just bridge. The locally-administered bit is always set on the first byte.
192+
193+
## Operational notes
194+
195+
### Do not restart the bridge while VMs are running
196+
197+
`virsh net-destroy`/`net-start` (or removing/recreating the bridge) will detach all TAP interfaces from the bridge, breaking VM networking. If this happens, affected VMs must be restarted.
198+
199+
### Firewall considerations
200+
201+
- libvirt automatically injects nftables rules for INPUT (DHCP/DNS), FORWARD, and NAT masquerade into its own chains (`LIBVIRT_INP`, `LIBVIRT_FWO`, `LIBVIRT_FWI`, `LIBVIRT_PRT`)
202+
- A standalone bridge requires **all** of these rules to be added manually (see Option B step 4 above). The most common failure mode is a restrictive INPUT policy silently dropping DHCP requests from VMs — if VMs on a custom bridge don't get an IP, check `sudo nft list chain ip filter INPUT` first
203+
- Docker's nftables chains (`DOCKER-FORWARD`) run before libvirt's but do not block virbr0 traffic
204+
- Use `setup-bridge.sh check --bridge <name>` to diagnose missing rules
205+
206+
### Mixing networking modes
207+
208+
Bridge and passt VMs can coexist. Set the global default in `vmm.toml` and override per-VM as needed:
209+
210+
```bash
211+
# Global default is bridge, but deploy this VM with passt
212+
vmm-cli.py deploy --name my-vm --image dstack-0.5.6 --compose app.yaml --net passt
213+
```
214+
215+
### vhost-net and TDX
216+
217+
vhost-net (kernel data plane offload for virtio-net) is **not enabled** for bridge mode. TDX encrypts guest memory, which prevents the host kernel from performing DMA-based packet offload. The default QEMU userspace virtio backend is used instead.

gateway/dstack-app/deploy-to-vmm.sh

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ PUBLIC_IP=$(curl -s4 ifconfig.me)
5959
# Whether to use ACME staging (yes/no)
6060
ACME_STAGING=no
6161
62+
# Networking mode: bridge or user (default: user)
63+
# NET_MODE=bridge
64+
6265
# Subnet index. 0~15
6366
SUBNET_INDEX=0
6467
@@ -219,17 +222,27 @@ fi
219222

220223
echo "Deploying dstack-gateway to dstack-vmm..."
221224

222-
$CLI deploy \
223-
--name dstack-gateway \
224-
--app-id "$GATEWAY_APP_ID" \
225-
--compose .app-compose.json \
226-
--env-file .app_env \
227-
--image $OS_IMAGE \
228-
--port tcp:$GATEWAY_RPC_ADDR:8000 \
229-
--port tcp:$GATEWAY_ADMIN_RPC_ADDR:8001 \
230-
--port tcp:$GATEWAY_SERVING_ADDR:443 \
231-
--port tcp:$GUEST_AGENT_ADDR:8090 \
232-
--port udp:$WG_ADDR:51820 \
233-
--vcpu 32 \
234-
--memory 32G \
225+
DEPLOY_ARGS=(
226+
--name dstack-gateway
227+
--app-id "$GATEWAY_APP_ID"
228+
--compose .app-compose.json
229+
--env-file .app_env
230+
--image "$OS_IMAGE"
231+
--vcpu 32
232+
--memory 32G
233+
)
234+
235+
if [ "${NET_MODE:-}" = "bridge" ]; then
236+
DEPLOY_ARGS+=(--net bridge)
237+
else
238+
DEPLOY_ARGS+=(
239+
--port "tcp:$GATEWAY_RPC_ADDR:8000"
240+
--port "tcp:$GATEWAY_ADMIN_RPC_ADDR:8001"
241+
--port "tcp:$GATEWAY_SERVING_ADDR:443"
242+
--port "tcp:$GUEST_AGENT_ADDR:8090"
243+
--port "udp:$WG_ADDR:51820"
244+
)
245+
fi
246+
247+
$CLI deploy "${DEPLOY_ARGS[@]}"
235248

guest-agent/src/guest_api_service.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,11 @@ fn get_interfaces() -> Vec<Interface> {
145145
sysinfo::Networks::new_with_refreshed_list()
146146
.into_iter()
147147
.filter_map(|(interface_name, network)| {
148-
if !(interface_name == "dstack-wg0" || interface_name.starts_with("enp")) {
149-
// We only get dstack-wg0 and enp interfaces.
148+
if !(interface_name == "dstack-wg0"
149+
|| interface_name.starts_with("enp")
150+
|| interface_name.starts_with("eth"))
151+
{
152+
// We only get dstack-wg0, enp and eth interfaces.
150153
// Docker bridge is not included due to privacy concerns.
151154
return None;
152155
}

port-forward/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# SPDX-FileCopyrightText: © 2024-2025 Phala Network <dstack@phala.network>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
[package]
6+
name = "dstack-port-forward"
7+
version.workspace = true
8+
edition.workspace = true
9+
10+
[dependencies]
11+
anyhow.workspace = true
12+
tracing.workspace = true
13+
tokio = { workspace = true, features = ["net", "rt", "io-util", "macros", "sync", "time"] }
14+
tokio-util = { version = "0.7", features = ["rt"] }
15+
nix = { workspace = true, features = ["fs", "net", "zerocopy"] }
16+
17+
[dev-dependencies]
18+
tokio = { workspace = true, features = ["full"] }

0 commit comments

Comments
 (0)