Skip to content

Commit 054e088

Browse files
committed
docs(vmm): update bridge networking guide with DHCP notification and firewall rules
Add dhcp-notify.sh script integration, nftables firewall rules for standalone bridges, and setup-bridge.sh improvements.
1 parent 081ab50 commit 054e088

3 files changed

Lines changed: 291 additions & 6 deletions

File tree

docs/bridge-networking.md

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Only the mode is per-VM; the bridge interface name always comes from the global
2929

3030
## Host setup
3131

32-
### Option A: Using libvirt default network (recommended)
32+
### Option A: Using libvirt default network
3333

3434
libvirt's default network provides a bridge (`virbr0`) with DHCP (dnsmasq) and NAT out of the box.
3535

@@ -88,20 +88,64 @@ sudo sysctl -p /etc/sysctl.d/99-dstack-bridge.conf
8888

8989
```bash
9090
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:
91101

102+
```ini
92103
# /etc/dnsmasq.d/dstack-br0.conf
93104
interface=dstack-br0
94105
bind-interfaces
95106
dhcp-range=10.0.100.10,10.0.100.254,255.255.255.0,12h
96107
dhcp-option=option:router,10.0.100.1
97108
dhcp-option=option:dns-server,8.8.8.8,1.1.1.1
109+
dhcp-script=/usr/local/bin/dhcp-notify.sh
98110
```
99111

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+
100114
```bash
101115
sudo systemctl restart dnsmasq
102116
```
103117

104-
**4. Update vmm.toml:**
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:**
105149

106150
```toml
107151
[cvm.networking]
@@ -127,10 +171,25 @@ sudo chmod u+s /usr/lib/qemu/qemu-bridge-helper
127171

128172
- VMM passes `-netdev bridge,id=net0,br=<bridge>` to QEMU
129173
- QEMU's bridge helper (setuid) creates a TAP device and attaches it to the bridge
130-
- Guest MAC address is derived from SHA256 of the VM ID (stable across restarts for DHCP IP consistency)
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
131177
- When QEMU exits, the TAP device is automatically destroyed
132178
- VMM does not need root or `CAP_NET_ADMIN`
133179

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+
134193
## Operational notes
135194

136195
### Do not restart the bridge while VMs are running
@@ -139,9 +198,10 @@ sudo chmod u+s /usr/lib/qemu/qemu-bridge-helper
139198

140199
### Firewall considerations
141200

142-
- libvirt injects nftables rules for NAT masquerade and forwarding automatically
143-
- If using a manual bridge, ensure your firewall allows forwarding for the bridge subnet and has masquerade rules for outbound NAT
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
144203
- 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
145205

146206
### Mixing networking modes
147207

scripts/dhcp-notify.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/bin/bash
2+
# SPDX-FileCopyrightText: © 2025 Phala Network <dstack@phala.network>
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# DHCP lease notification script for dnsmasq.
6+
#
7+
# Called by dnsmasq via --dhcp-script on lease events.
8+
# Notifies dstack-vmm of the guest's MAC and IP so that port
9+
# forwarding can be established automatically.
10+
#
11+
# Arguments (set by dnsmasq):
12+
# $1 action add | del | old
13+
# $2 mac MAC address of the guest NIC
14+
# $3 ip IPv4 address assigned by DHCP
15+
# $4 hostname (optional)
16+
#
17+
# Configuration:
18+
# VMM_URL Base URL of dstack-vmm (default: http://127.0.0.1:9080)
19+
20+
ACTION="$1"
21+
MAC="$2"
22+
IP="$3"
23+
24+
VMM_URL="${VMM_URL:-http://127.0.0.1:9080}"
25+
26+
logger -t dhcp-notify "action=$ACTION mac=$MAC ip=$IP"
27+
28+
case "$ACTION" in
29+
add|old)
30+
curl -s -X POST "${VMM_URL}/prpc/ReportDhcpLease" \
31+
-H 'Content-Type: application/json' \
32+
-d "{\"mac\":\"$MAC\",\"ip\":\"$IP\"}" \
33+
|| logger -t dhcp-notify "failed to notify VMM"
34+
;;
35+
del)
36+
# Could clear forwarding on lease expiry; not implemented yet.
37+
;;
38+
esac

scripts/setup-bridge.sh

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,47 @@ check_dhcp() {
192192
check_info "Run: $(basename "$0") setup --mode standalone --bridge $BRIDGE"
193193
}
194194

195+
check_dhcp_notify() {
196+
echo
197+
bold "DHCP lease notification"
198+
199+
local provider
200+
provider=$(detect_bridge_provider)
201+
202+
if [[ "$provider" == libvirt:* ]]; then
203+
check_warn "libvirt DHCP does not support dhcp-script callback"
204+
check_info "port forwarding requires manual PRPC call or alternative notification"
205+
return
206+
fi
207+
208+
# Check dnsmasq config for dhcp-script
209+
local conf_files=(/etc/dnsmasq.d/*"$BRIDGE"* /etc/dnsmasq.d/*.conf)
210+
local found_script=""
211+
for f in "${conf_files[@]}"; do
212+
[[ -f "$f" ]] || continue
213+
local script_path
214+
script_path=$(grep -oP '^dhcp-script=\K.*' "$f" 2>/dev/null || true)
215+
if [[ -n "$script_path" ]]; then
216+
found_script="$script_path"
217+
break
218+
fi
219+
done
220+
221+
if [[ -z "$found_script" ]]; then
222+
check_warn "no dhcp-script configured in dnsmasq"
223+
check_info "port forwarding will not be set up automatically"
224+
check_info "add 'dhcp-script=/usr/local/bin/dhcp-notify.sh' to dnsmasq config"
225+
return
226+
fi
227+
228+
if [[ -x "$found_script" ]]; then
229+
check_pass "dhcp-script configured: $found_script"
230+
else
231+
check_fail "dhcp-script $found_script is not executable or missing"
232+
check_info "Fix: sudo chmod +x $found_script"
233+
fi
234+
}
235+
195236
check_ip_forward() {
196237
echo
197238
bold "IP forwarding"
@@ -254,6 +295,45 @@ print(iface.network)
254295
check_info "For standalone: systemd-networkd IPMasquerade=both or manual nftables rules."
255296
}
256297

298+
check_dhcp_firewall() {
299+
echo
300+
bold "DHCP/DNS firewall rules"
301+
302+
local nft_rules=""
303+
if command -v nft &>/dev/null; then
304+
nft_rules=$(sudo nft list ruleset 2>/dev/null || true)
305+
fi
306+
307+
if [[ -z "$nft_rules" ]]; then
308+
check_warn "nft not available, cannot check firewall rules"
309+
return
310+
fi
311+
312+
# Check INPUT policy
313+
local input_policy
314+
input_policy=$(echo "$nft_rules" | grep -A1 'chain INPUT' | grep -oP 'policy \K\w+' | head -1 || echo "")
315+
316+
if [[ "$input_policy" == "accept" ]]; then
317+
check_pass "INPUT policy is accept (DHCP/DNS allowed by default)"
318+
return
319+
fi
320+
321+
# Restrictive INPUT policy — need explicit rules
322+
if echo "$nft_rules" | grep -q "iifname \"$BRIDGE\".*udp dport 67.*accept"; then
323+
check_pass "DHCP input rule for $BRIDGE"
324+
else
325+
check_fail "no DHCP input rule for $BRIDGE (INPUT policy: ${input_policy:-unknown})"
326+
check_info "VMs will not get DHCP leases without this rule"
327+
check_info "Fix: sudo nft add rule ip filter INPUT iifname \"$BRIDGE\" udp dport 67 counter accept"
328+
fi
329+
330+
if echo "$nft_rules" | grep -q "iifname \"$BRIDGE\".*udp dport 53.*accept"; then
331+
check_pass "DNS input rule for $BRIDGE"
332+
else
333+
check_warn "no DNS input rule for $BRIDGE"
334+
fi
335+
}
336+
257337
check_forward_rules() {
258338
echo
259339
bold "forwarding rules"
@@ -462,21 +542,126 @@ sudo mv /tmp/.dstack-br-network $network"
462542
dhcp_end="${prefix}.254"
463543
fi
464544

545+
# Install dhcp-notify.sh if present
546+
local notify_script="/usr/local/bin/dhcp-notify.sh"
547+
local dhcp_script_line=""
548+
local src_notify
549+
src_notify="$(cd "$(dirname "$0")" && pwd)/dhcp-notify.sh"
550+
if [[ -f "$src_notify" ]]; then
551+
run_cmd sudo cp "$src_notify" "$notify_script"
552+
run_cmd sudo chmod +x "$notify_script"
553+
dhcp_script_line="dhcp-script=${notify_script}"
554+
echo " installed $notify_script"
555+
else
556+
echo " $(yellow '[WARN]') dhcp-notify.sh not found at $src_notify"
557+
echo " VM port forwarding will not be set up automatically"
558+
fi
559+
465560
run_cmd bash -c "cat > /tmp/.dstack-dnsmasq <<HEREDOC
466561
interface=$BRIDGE
467562
bind-interfaces
468563
dhcp-range=${dhcp_start},${dhcp_end},255.255.255.0,12h
469564
dhcp-option=option:router,${bridge_ip}
470565
dhcp-option=option:dns-server,8.8.8.8,1.1.1.1
566+
${dhcp_script_line}
471567
HEREDOC
472568
sudo mv /tmp/.dstack-dnsmasq $conf"
473569
echo " created $conf"
474570
run_cmd sudo systemctl restart dnsmasq
475571
echo " restarted dnsmasq"
476572
fi
477573

574+
# 3. Firewall rules for standalone bridge
575+
setup_standalone_firewall
576+
577+
echo
578+
echo " standalone provides: bridge, DHCP (dnsmasq), NAT, firewall rules"
579+
}
580+
581+
# --- Setup: standalone firewall ---
582+
583+
setup_standalone_firewall() {
478584
echo
479-
echo " standalone provides: bridge, DHCP (dnsmasq), NAT (systemd-networkd IPMasquerade)"
585+
bold "Setting up firewall rules for $BRIDGE"
586+
587+
local subnet
588+
subnet=$(ip -4 -o addr show "$BRIDGE" 2>/dev/null | awk '{print $4}' | head -1)
589+
if [[ -z "$subnet" ]]; then
590+
echo " $(red 'ERROR'): bridge $BRIDGE has no IP, cannot configure firewall"
591+
return 1
592+
fi
593+
594+
local net_cidr
595+
net_cidr=$(python3 -c "
596+
import ipaddress
597+
iface = ipaddress.ip_interface('$subnet')
598+
print(iface.network)
599+
" 2>/dev/null || echo "")
600+
601+
if [[ -z "$net_cidr" ]]; then
602+
echo " $(red 'ERROR'): cannot parse subnet $subnet"
603+
return 1
604+
fi
605+
606+
if ! command -v nft &>/dev/null; then
607+
echo " $(yellow '[WARN]') nft not found, skipping firewall setup"
608+
echo " you may need to configure iptables manually"
609+
return 0
610+
fi
611+
612+
# Detect whether to use libvirt chains or default chains
613+
local inp_chain="INPUT"
614+
local out_chain="OUTPUT"
615+
local fwd_chain="FORWARD"
616+
local nat_chain="POSTROUTING"
617+
618+
if sudo nft list chain ip filter LIBVIRT_INP &>/dev/null 2>&1; then
619+
inp_chain="LIBVIRT_INP"
620+
out_chain="LIBVIRT_OUT"
621+
echo " using libvirt filter chains (LIBVIRT_INP/LIBVIRT_OUT)"
622+
fi
623+
if sudo nft list chain ip filter LIBVIRT_FWO &>/dev/null 2>&1; then
624+
fwd_chain="" # use individual libvirt chains
625+
echo " using libvirt forward chains (LIBVIRT_FWO/FWI/FWX)"
626+
fi
627+
if sudo nft list chain ip nat LIBVIRT_PRT &>/dev/null 2>&1; then
628+
nat_chain="LIBVIRT_PRT"
629+
echo " using libvirt NAT chain (LIBVIRT_PRT)"
630+
fi
631+
632+
# Check if rules already exist (simple heuristic: look for bridge name in ruleset)
633+
local existing
634+
existing=$(sudo nft list ruleset 2>/dev/null || true)
635+
if echo "$existing" | grep -q "iifname \"$BRIDGE\".*udp dport 67.*accept"; then
636+
echo " firewall rules for $BRIDGE already present, skipping"
637+
return 0
638+
fi
639+
640+
echo " adding INPUT/OUTPUT rules for DHCP and DNS"
641+
run_cmd sudo nft add rule ip filter "$inp_chain" iifname "$BRIDGE" udp dport 67 counter accept
642+
run_cmd sudo nft add rule ip filter "$inp_chain" iifname "$BRIDGE" udp dport 53 counter accept
643+
run_cmd sudo nft add rule ip filter "$inp_chain" iifname "$BRIDGE" tcp dport 53 counter accept
644+
run_cmd sudo nft add rule ip filter "$out_chain" oifname "$BRIDGE" udp dport 68 counter accept
645+
run_cmd sudo nft add rule ip filter "$out_chain" oifname "$BRIDGE" udp dport 53 counter accept
646+
647+
echo " adding FORWARD rules for $net_cidr"
648+
if [[ -z "$fwd_chain" ]]; then
649+
# libvirt forward chains
650+
run_cmd sudo nft add rule ip filter LIBVIRT_FWO ip saddr "$net_cidr" iifname "$BRIDGE" counter accept
651+
run_cmd sudo nft add rule ip filter LIBVIRT_FWI ip daddr "$net_cidr" oifname "$BRIDGE" ct state related,established counter accept
652+
run_cmd sudo nft add rule ip filter LIBVIRT_FWX iifname "$BRIDGE" oifname "$BRIDGE" counter accept
653+
else
654+
run_cmd sudo nft add rule ip filter FORWARD ip saddr "$net_cidr" iifname "$BRIDGE" counter accept
655+
run_cmd sudo nft add rule ip filter FORWARD ip daddr "$net_cidr" oifname "$BRIDGE" ct state related,established counter accept
656+
run_cmd sudo nft add rule ip filter FORWARD iifname "$BRIDGE" oifname "$BRIDGE" counter accept
657+
fi
658+
659+
echo " adding NAT masquerade for $net_cidr"
660+
run_cmd sudo nft add rule ip nat "$nat_chain" ip saddr "$net_cidr" ip daddr 224.0.0.0/24 counter return
661+
run_cmd sudo nft add rule ip nat "$nat_chain" ip saddr "$net_cidr" ip daddr 255.255.255.255 counter return
662+
run_cmd sudo nft add rule ip nat "$nat_chain" ip saddr "$net_cidr" ip daddr != "$net_cidr" counter masquerade
663+
664+
echo " firewall rules configured"
480665
}
481666

482667
# --- Main ---
@@ -557,6 +742,8 @@ cmd_check() {
557742
check_bridge_conf
558743
check_bridge_interface
559744
check_dhcp
745+
check_dhcp_notify
746+
check_dhcp_firewall
560747
check_ip_forward
561748
check_nat_rules
562749
check_forward_rules

0 commit comments

Comments
 (0)