Skip to content

Commit 081ab50

Browse files
committed
feat(vmm): add DHCP lease PRPC API and refactor networking config
Replace guest-side IP reporting with a DHCP server-side ReportDhcpLease PRPC endpoint. The VMM resolves MAC→VM via deterministic MAC derivation and reconfigures port forwarding on lease events. Refactor Networking from a tagged enum to a flat struct with a NetworkingMode enum, so shared fields (bridge name, mac_prefix) are accessible regardless of mode. Add mac_prefix config (0-3 hex bytes) for deterministic MAC generation, now applied to all networking modes.
1 parent 21fa320 commit 081ab50

7 files changed

Lines changed: 211 additions & 150 deletions

File tree

basefiles/dstack-prepare.sh

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,13 +278,6 @@ echo "============================"
278278

279279
cd /dstack
280280

281-
# Report eth0 IP address to VMM for bridge-mode port forwarding
282-
ETH0_IP=$(ip -4 -o addr show eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 || true)
283-
if [ -n "$ETH0_IP" ]; then
284-
log "Reporting eth0 IP: $ETH0_IP"
285-
dstack-util notify-host -e "network.eth0" -d "$ETH0_IP" || true
286-
fi
287-
288281
if [ $(jq 'has("init_script")' app-compose.json) == true ]; then
289282
log "Running init script"
290283
dstack-util notify-host -e "boot.progress" -d "init-script" || true

vmm/rpc/proto/vmm_rpc.proto

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,4 +339,16 @@ service Vmm {
339339

340340
// Reload VMs directory and sync with memory state
341341
rpc ReloadVms(google.protobuf.Empty) returns (ReloadVmsResponse);
342+
343+
// Report a DHCP lease event (called by the DHCP server, e.g. dnsmasq --dhcp-script).
344+
// The VMM resolves the MAC address to a VM and reconfigures port forwarding.
345+
rpc ReportDhcpLease(DhcpLeaseRequest) returns (google.protobuf.Empty);
346+
}
347+
348+
// DHCP lease event reported by the host DHCP server.
349+
message DhcpLeaseRequest {
350+
// MAC address of the guest NIC (e.g. "02:ab:cd:ef:01:23")
351+
string mac = 1;
352+
// IPv4 address assigned by DHCP (e.g. "192.168.122.100")
353+
string ip = 2;
342354
}

vmm/src/app.rs

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
2727
use std::sync::{Arc, Mutex, MutexGuard};
2828
use std::time::SystemTime;
2929
use supervisor_client::SupervisorClient;
30-
use tracing::{error, info, warn};
30+
use tracing::{debug, error, info, warn};
3131

3232
pub use image::{Image, ImageInfo};
3333
pub use qemu::{VmConfig, VmWorkDir};
@@ -311,6 +311,33 @@ impl App {
311311
Ok(())
312312
}
313313

314+
/// Handle a DHCP lease notification: look up VM by MAC address, persist
315+
/// the guest IP, and reconfigure port forwarding.
316+
pub async fn report_dhcp_lease(&self, mac: &str, ip: &str) {
317+
use crate::app::qemu::mac_address_for_vm;
318+
319+
let vm_id = {
320+
let mut state = self.lock();
321+
let prefix = self.config.cvm.networking.mac_prefix_bytes();
322+
let found = state.vms.iter_mut().find(|(id, _)| {
323+
mac_address_for_vm(id, &prefix) == mac
324+
});
325+
let Some((id, vm)) = found else {
326+
debug!(mac, ip, "DHCP lease for unknown MAC, ignoring");
327+
return;
328+
};
329+
let vm_id = id.clone();
330+
let workdir = VmWorkDir::new(vm.config.workdir.clone());
331+
if let Err(e) = workdir.set_guest_ip(ip) {
332+
error!(mac, ip, "failed to persist guest IP: {e}");
333+
}
334+
vm.state.guest_ip = ip.to_string();
335+
info!(mac, ip, id = %vm_id, "DHCP lease updated");
336+
vm_id
337+
};
338+
self.reconfigure_port_forward(&vm_id).await;
339+
}
340+
314341
/// Reconfigure port forwarding for a bridge-mode VM.
315342
///
316343
/// Computes desired rules from the VM's port_map and guest_ip, then diffs
@@ -722,17 +749,11 @@ impl App {
722749
Ok(Some(info))
723750
}
724751

725-
/// Process a guest event. Returns the VM ID if port forwarding needs reconfiguration.
726-
pub(crate) fn vm_event_report(
727-
&self,
728-
cid: u32,
729-
event: &str,
730-
body: String,
731-
) -> Result<Option<String>> {
752+
pub(crate) fn vm_event_report(&self, cid: u32, event: &str, body: String) -> Result<()> {
732753
info!(cid, event, "VM event");
733754
if body.len() > 1024 * 4 {
734755
error!("Event body too large, skipping");
735-
return Ok(None);
756+
return Ok(());
736757
}
737758
let mut state = self.lock();
738759
let Some(vm) = state.vms.values_mut().find(|vm| vm.config.cid == cid) else {
@@ -749,7 +770,6 @@ impl App {
749770
while vm.state.events.len() > self.config.event_buffer_size {
750771
vm.state.events.pop_front();
751772
}
752-
let mut needs_reconfigure = None;
753773
match event {
754774
"boot.progress" => {
755775
vm.state.boot_progress = body;
@@ -768,21 +788,11 @@ impl App {
768788
let instancd_info_path = workdir.instance_info_path();
769789
safe_write::safe_write(&instancd_info_path, &body)?;
770790
}
771-
"network.eth0" => {
772-
info!(cid, ip = %body, "guest reported eth0 IP");
773-
let vm_id = vm.config.manifest.id.clone();
774-
let workdir = VmWorkDir::new(vm.config.workdir.clone());
775-
if let Err(e) = workdir.set_guest_ip(&body) {
776-
error!("failed to persist guest IP: {e}");
777-
}
778-
vm.state.guest_ip = body;
779-
needs_reconfigure = Some(vm_id);
780-
}
781791
_ => {
782792
error!("Guest reported unknown event: {event}");
783793
}
784794
}
785-
Ok(needs_reconfigure)
795+
Ok(())
786796
}
787797

788798
pub(crate) fn compose_file_path(&self, id: &str) -> PathBuf {

vmm/src/app/qemu.rs

Lines changed: 67 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
use crate::{
77
app::Manifest,
88
config::{
9-
BridgeNetworking, CvmConfig, GatewayConfig, Networking, PasstNetworking,
10-
ProcessAnnotation, Protocol,
9+
CvmConfig, GatewayConfig, Networking, NetworkingMode, ProcessAnnotation, Protocol,
1110
},
1211
};
1312
use std::{collections::HashMap, os::unix::fs::PermissionsExt};
@@ -31,17 +30,43 @@ use dstack_types::{
3130
AppCompose, KeyProviderKind,
3231
};
3332
use dstack_vmm_rpc as pb;
33+
use sha2::{Digest, Sha256};
34+
35+
/// Derive a deterministic MAC address from a VM ID using SHA256.
36+
/// Sets locally-administered + unicast bits (0x02) per IEEE 802.
37+
/// Derive a deterministic MAC address from a VM ID.
38+
///
39+
/// `prefix` may contain 0-3 fixed bytes. The first byte always has the
40+
/// locally-administered + unicast bits set (0x02). Remaining bytes are
41+
/// filled from SHA256(vm_id).
42+
pub fn mac_address_for_vm(vm_id: &str, prefix: &[u8]) -> String {
43+
let hash = Sha256::digest(vm_id.as_bytes());
44+
let prefix_len = prefix.len().min(3);
45+
let mut bytes = [0u8; 6];
46+
// Fill prefix bytes
47+
bytes[..prefix_len].copy_from_slice(&prefix[..prefix_len]);
48+
// Fill remaining bytes from hash
49+
for i in prefix_len..6 {
50+
bytes[i] = hash[i - prefix_len];
51+
}
52+
// Ensure locally-administered + unicast on first byte
53+
bytes[0] = (bytes[0] & 0xfe) | 0x02;
54+
format!(
55+
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
56+
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
57+
)
58+
}
3459
use fs_err as fs;
3560
use serde::{Deserialize, Serialize};
3661
use serde_human_bytes as hex_bytes;
3762
use supervisor_client::supervisor::{ProcessConfig, ProcessInfo};
3863

3964
fn networking_to_proto(n: &Networking) -> pb::NetworkingConfig {
40-
let mode = match n {
41-
Networking::Bridge(_) => "bridge",
42-
Networking::User(_) => "user",
43-
Networking::Passt(_) => "passt",
44-
Networking::Custom(_) => "custom",
65+
let mode = match n.mode {
66+
NetworkingMode::Bridge => "bridge",
67+
NetworkingMode::User => "user",
68+
NetworkingMode::Passt => "passt",
69+
NetworkingMode::Custom => "custom",
4570
};
4671
pb::NetworkingConfig {
4772
mode: mode.into(),
@@ -332,8 +357,8 @@ impl VmState {
332357
}
333358

334359
impl VmConfig {
335-
fn config_passt(&self, workdir: &VmWorkDir, netcfg: &PasstNetworking) -> Result<ProcessConfig> {
336-
let PasstNetworking {
360+
fn config_passt(&self, workdir: &VmWorkDir, netcfg: &Networking) -> Result<ProcessConfig> {
361+
let Networking {
337362
passt_exec,
338363
interface,
339364
address,
@@ -344,6 +369,7 @@ impl VmConfig {
344369
map_guest_addr,
345370
no_map_gw,
346371
ipv4_only,
372+
..
347373
} = netcfg;
348374

349375
let passt_socket = workdir.passt_socket();
@@ -438,46 +464,6 @@ impl VmConfig {
438464
Ok(process_config)
439465
}
440466

441-
/// Configure bridge networking using QEMU's built-in bridge helper.
442-
/// The helper (qemu-bridge-helper) creates and manages TAP devices automatically,
443-
/// so VMM does not need root or CAP_NET_ADMIN. Requires /etc/qemu/bridge.conf
444-
/// to allow the bridge (e.g., "allow virbr0").
445-
/// Guest IP is assigned by an external DHCP server on the bridge.
446-
/// Returns (netdev_arg, device_arg).
447-
fn config_bridge(
448-
&self,
449-
_workdir: &VmWorkDir,
450-
netcfg: &BridgeNetworking,
451-
) -> Result<(String, String)> {
452-
use sha2::{Digest, Sha256};
453-
454-
let vm_id = &self.manifest.id;
455-
456-
// Derive MAC from VM ID. Set locally-administered + unicast bits (0x02).
457-
let hash = Sha256::digest(vm_id.as_bytes());
458-
let mac = format!(
459-
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
460-
(hash[0] & 0xfe) | 0x02,
461-
hash[1],
462-
hash[2],
463-
hash[3],
464-
hash[4],
465-
hash[5]
466-
);
467-
468-
// Use QEMU's bridge netdev: QEMU calls qemu-bridge-helper to create/attach
469-
// the TAP device. TAP is automatically destroyed when QEMU exits.
470-
let netdev = format!("bridge,id=net0,br={}", netcfg.bridge);
471-
let device = format!("virtio-net-pci,netdev=net0,mac={mac}");
472-
473-
tracing::info!(
474-
"bridge networking: mac={mac} bridge={}",
475-
netcfg.bridge
476-
);
477-
478-
Ok((netdev, device))
479-
}
480-
481467
pub fn config_qemu(
482468
&self,
483469
workdir: impl AsRef<Path>,
@@ -569,30 +555,37 @@ impl VmConfig {
569555
.arg(format!("file={},if=none,id=hd1", hda_path.display()))
570556
.arg("-device")
571557
.arg("virtio-blk-pci,drive=hd1");
572-
let mut net_device = "virtio-net-pci,netdev=net0".to_string();
558+
// Resolve per-VM networking override against global config.
559+
// Per-VM only sets mode; shared fields (bridge name, mac_prefix, etc.)
560+
// are merged from global config.
573561
let resolved_networking;
574562
let networking = match self.manifest.networking.as_ref() {
575-
Some(Networking::Bridge(vm_br)) if vm_br.bridge.is_empty() => {
576-
// per-VM selected bridge mode but bridge name comes from global config
577-
if let Networking::Bridge(global_br) = &cfg.networking {
578-
resolved_networking = Networking::Bridge(global_br.clone());
579-
} else {
580-
resolved_networking = Networking::Bridge(BridgeNetworking {
581-
bridge: "virbr0".to_string(),
582-
});
583-
}
563+
Some(vm_net) => {
564+
// Per-VM override: take mode from VM, fill other fields from global
565+
resolved_networking = Networking {
566+
mode: vm_net.mode,
567+
bridge: if vm_net.bridge.is_empty() {
568+
cfg.networking.bridge.clone()
569+
} else {
570+
vm_net.bridge.clone()
571+
},
572+
..cfg.networking.clone()
573+
};
584574
&resolved_networking
585575
}
586-
Some(n) => n,
587576
None => &cfg.networking,
588577
};
589-
let netdev = match networking {
590-
Networking::User(netcfg) => {
578+
// Generate deterministic MAC for all networking modes
579+
let prefix = networking.mac_prefix_bytes();
580+
let mac = mac_address_for_vm(&self.manifest.id, &prefix);
581+
let net_device = format!("virtio-net-pci,netdev=net0,mac={mac}");
582+
let netdev = match networking.mode {
583+
NetworkingMode::User => {
591584
let mut netdev = format!(
592585
"user,id=net0,net={},dhcpstart={},restrict={}",
593-
netcfg.net,
594-
netcfg.dhcp_start,
595-
if netcfg.restrict { "yes" } else { "no" }
586+
networking.net,
587+
networking.dhcp_start,
588+
if networking.restrict { "yes" } else { "no" }
596589
);
597590
for pm in &self.manifest.port_map {
598591
netdev.push_str(&format!(
@@ -605,24 +598,24 @@ impl VmConfig {
605598
}
606599
netdev
607600
}
608-
Networking::Passt(netcfg) => {
601+
NetworkingMode::Passt => {
609602
processes.push(
610-
self.config_passt(&workdir, netcfg)
603+
self.config_passt(&workdir, networking)
611604
.context("Failed to configure passt")?,
612605
);
613606
format!(
614607
"stream,id=net0,server=off,addr.type=unix,addr.path={}",
615608
workdir.passt_socket().display()
616609
)
617610
}
618-
Networking::Bridge(netcfg) => {
619-
let (netdev, device) = self
620-
.config_bridge(&workdir, netcfg)
621-
.context("failed to configure bridge networking")?;
622-
net_device = device;
623-
netdev
611+
NetworkingMode::Bridge => {
612+
tracing::info!(
613+
"bridge networking: mac={mac} bridge={}",
614+
networking.bridge
615+
);
616+
format!("bridge,id=net0,br={}", networking.bridge)
624617
}
625-
Networking::Custom(netcfg) => netcfg.netdev.clone(),
618+
NetworkingMode::Custom => networking.netdev.clone(),
626619
};
627620
command.arg("-netdev").arg(netdev);
628621
command.arg("-device").arg(net_device);

0 commit comments

Comments
 (0)