Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions docs/experimental-systemd-vm-processes.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,20 @@ KillSignal=SIGTERM
SendSIGKILL=yes
TimeoutStopSec=<systemd.stop_timeout>
Restart=no
User=<cvm.user> # when cvm.user is set
OpenFile=/dev/tapN # one entry per NIC with networking.open_file
```

`User=` replaces the Supervisor `sudo -u` path. systemd opens each `OpenFile=`
path with the manager's privileges and hands the descriptors to the service in
declaration order starting at fd 3, which is what QEMU's generated
`-netdev tap,id=netN,fd=M` arguments expect. That combination needs systemd
253 or newer.

When both are set, the chardev stays root-owned on the host and the unit still
runs QEMU unprivileged. For software-TPM VMs the whole launcher unit runs as
`cvm.user`, so the VMM chowns the swtpm state directory before start.

The existing launcher remains responsible for swtpm readiness and graceful
child shutdown. systemd owns the final cgroup lifetime. A stop request is
submitted asynchronously so the VMM can report a VM as stopping while QEMU is
Expand Down Expand Up @@ -88,8 +100,10 @@ atomic property handling and event-driven state updates.

## Limitations

- The host must run systemd with support for `ExitType=cgroup` and
`StandardOutput=append:`.
- The host must run systemd 253+ with support for `OpenFile=`,
`ExitType=cgroup`, and `StandardOutput=append:`.
- `networking.open_file` is manifest-only, requires `mode = "custom"`, and is
rejected with Supervisor, one-shot execution, and swtpm-backed VMs.
- The VMM must be authorized to create and stop system services.
- Transient services inherit the systemd manager environment rather than the
VMM environment. Variables in `ProcessConfig.env` are forwarded; unrelated
Expand Down
2 changes: 2 additions & 0 deletions dstack/supervisor/client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ async fn main() -> Result<()> {
pidfile: String::new(),
cid: None,
note: String::new(),
user: String::new(),
open_files: Vec::new(),
};
print_json(&client.deploy(&config).await?)?;
}
Expand Down
19 changes: 19 additions & 0 deletions dstack/supervisor/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ pub struct ProcessConfig {
pub cid: Option<u32>,
#[serde(default)]
pub note: String,
/// User the process manager runs the process as.
///
/// Only the VMM's systemd backend implements this, by dropping privileges
/// in the transient unit. Supervisor rejects a config that sets it rather
/// than running the process with its own privileges. Skipped when empty so
/// existing records and requests keep serializing byte-identically.
#[serde(default, skip_serializing_if = "String::is_empty")]
#[builder(default)]
pub user: String,
/// Files the process manager opens before exec and passes to the process
/// as inherited file descriptors, in declaration order starting at fd 3.
///
/// Only the VMM's systemd backend implements this. Supervisor rejects a
/// config that sets it rather than starting a process without the file
/// descriptors it asked for. Skipped when empty so existing records and
/// requests keep serializing byte-identically.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub open_files: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
12 changes: 12 additions & 0 deletions dstack/supervisor/src/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ impl Supervisor {
if id.is_empty() {
return Err(anyhow::anyhow!("Process ID is empty"));
}
if !config.user.is_empty() {
// Supervisor runs processes with its own privileges. Starting the
// process anyway would run a VM as root that asked to be confined
// to an unprivileged user.
bail!("user is not supported by supervisor");
}
if !config.open_files.is_empty() {
// Supervisor spawns processes without pre-opened file descriptors,
// so honoring the rest of the config would start a process that is
// missing the files it depends on.
bail!("open_files is not supported by supervisor");
}
if self
.info(&id)
.is_some_and(|info| info.state.status.is_running())
Expand Down
2 changes: 1 addition & 1 deletion dstack/vmm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ sha2.workspace = true
hex.workspace = true
fs-err.workspace = true
getrandom = { workspace = true, features = ["std"] }
nix = { workspace = true, features = ["user"] }
nix = { workspace = true, features = ["fs", "user", "dir"] }
dirs.workspace = true
which.workspace = true
clap = { workspace = true, features = ["derive", "string"] }
Expand Down
10 changes: 4 additions & 6 deletions dstack/vmm/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use dstack_vmm_rpc::{
use fs_err as fs;
use guest_api::client::DefaultClient as GuestClient;
use id_pool::IdPool;
use nix::unistd::{Uid, User};
use nix::unistd::Uid;
use or_panic::ResultOrPanic;
use ra_rpc::client::RaClient;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -521,11 +521,7 @@ impl App {
let qemu_uid = if self.config.cvm.user.is_empty() {
Uid::effective().as_raw()
} else {
User::from_name(&self.config.cvm.user)
.context("failed to resolve QEMU user")?
.with_context(|| format!("QEMU user {} does not exist", self.config.cvm.user))?
.uid
.as_raw()
qemu::resolve_cvm_user(&self.config.cvm.user)?.uid.as_raw()
};
let mut prepared = Vec::new();
for (nic_index, network) in networks.iter().enumerate() {
Expand Down Expand Up @@ -2166,6 +2162,7 @@ mod tests {
dhcp_start: String::new(),
restrict: false,
netdev: String::new(),
open_file: String::new(),
}];

workdir.put_manifest(&manifest)?;
Expand Down Expand Up @@ -2428,6 +2425,7 @@ mod tests {
dhcp_start: String::new(),
restrict: false,
netdev: String::new(),
open_file: String::new(),
}];
let user_manifest = test_manifest(2048);
let image = test_tdx_image(true);
Expand Down
134 changes: 131 additions & 3 deletions dstack/vmm/src/app/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use anyhow::{bail, Result};
use sha2::{Digest, Sha256};

use super::Manifest;
use crate::config::{CvmConfig, Networking, NetworkingMode};
use crate::config::{
validate_open_file, CvmConfig, Networking, NetworkingMode, SD_LISTEN_FDS_START,
};

pub(crate) fn resolve_networking(networking: &Networking, cfg: &CvmConfig) -> Networking {
let mut resolved = cfg.networking.clone();
Expand All @@ -28,9 +30,21 @@ pub(crate) fn resolve_networking(networking: &Networking, cfg: &CvmConfig) -> Ne
if !networking.dhcp_start.is_empty() {
resolved.dhcp_start = networking.dhcp_start.clone();
}
if !networking.netdev.is_empty() {
// A pre-opened chardev names one host device and belongs to exactly one
// NIC, so it is taken from the NIC verbatim and never inherited from the
// host defaults.
resolved.open_file = networking.open_file.clone();
if !networking.open_file.is_empty() && networking.netdev.is_empty() {
// The chardev generates this NIC's netdev from the fd number later, so
// drop any inherited host netdev rather than leak a stale default.
resolved.netdev = String::new();
} else if !networking.netdev.is_empty() {
// An explicit NIC netdev overrides the host default. A netdev set
// together with open_file is kept here, not dropped, so that
// validate_resolved_network rejects the conflicting pair.
resolved.netdev = networking.netdev.clone();
}
// Neither set: inherit the host netdev unchanged.
resolved
}

Expand All @@ -47,6 +61,17 @@ pub(crate) fn resolved_networks(manifest: &Manifest, cfg: &CvmConfig) -> Vec<Net
}

pub(crate) fn validate_resolved_network(networking: &Networking) -> Result<()> {
if !networking.open_file.is_empty() {
validate_open_file("networking.open_file", &networking.open_file)?;
if networking.mode != NetworkingMode::Custom {
bail!("networking.open_file requires mode = \"custom\"");
}
if !networking.netdev.is_empty() {
// The netdev string is generated from the inherited fd number,
// which only the process manager knows.
bail!("networking.open_file and networking.netdev are mutually exclusive");
}
}
if networking.mode != NetworkingMode::Bridge {
return Ok(());
}
Expand All @@ -69,6 +94,32 @@ pub(crate) fn validate_resolved_networks(networks: &[Networking]) -> Result<()>
Ok(())
}

/// Chardev paths the process manager must open before exec, in NIC order.
///
/// The order is the contract: systemd hands the files to the service in
/// declaration order, so entry `i` of this list arrives as fd
/// `SD_LISTEN_FDS_START + i`.
pub(crate) fn open_files(networks: &[Networking]) -> Vec<String> {
networks
.iter()
.filter(|networking| !networking.open_file.is_empty())
.map(|networking| networking.open_file.clone())
.collect()
}

/// File descriptor the NIC at `index` receives, or `None` if it does not use a
/// pre-opened chardev.
pub(crate) fn open_file_fd(networks: &[Networking], index: usize) -> Option<u32> {
if networks.get(index)?.open_file.is_empty() {
return None;
}
let preceding = networks[..index]
.iter()
.filter(|networking| !networking.open_file.is_empty())
.count();
Some(SD_LISTEN_FDS_START + preceding as u32)
}

/// Derives a deterministic, locally administered unicast MAC address.
///
/// Index zero preserves the legacy single-NIC derivation. Later interfaces
Expand All @@ -95,7 +146,84 @@ pub(crate) fn mac_address_for_vm_index(vm_id: &str, prefix: &[u8], index: usize)

#[cfg(test)]
mod tests {
use super::mac_address_for_vm_index;
use super::{
mac_address_for_vm_index, open_file_fd, open_files, validate_resolved_network, Networking,
NetworkingMode,
};

fn open_file_network(path: &str) -> Networking {
Networking {
mode: NetworkingMode::Custom,
bridge: String::new(),
mac_prefix: String::new(),
net: String::new(),
dhcp_start: String::new(),
restrict: false,
netdev: String::new(),
open_file: path.into(),
}
}

#[test]
fn open_file_descriptors_are_numbered_in_nic_order() {
let mut networks = vec![
open_file_network(""),
open_file_network("/dev/tap10"),
open_file_network(""),
open_file_network("/dev/tap11"),
];
networks[0].mode = NetworkingMode::User;
networks[2].mode = NetworkingMode::Bridge;

assert_eq!(open_files(&networks), ["/dev/tap10", "/dev/tap11"]);
assert_eq!(open_file_fd(&networks, 0), None);
assert_eq!(open_file_fd(&networks, 1), Some(3));
assert_eq!(open_file_fd(&networks, 2), None);
assert_eq!(open_file_fd(&networks, 3), Some(4));
assert_eq!(open_file_fd(&networks, 4), None);
}

#[test]
fn open_file_networks_are_validated() {
validate_resolved_network(&open_file_network("/dev/tap7498")).unwrap();

for path in [
"dev/tap7498",
"/dev/tap 7498",
"/dev/tap7498:foo",
"/dev/tap7498,vhost=on",
"/dev/%i/tap7498",
] {
validate_resolved_network(&open_file_network(path)).unwrap_err();
}

let mut wrong_mode = open_file_network("/dev/tap7498");
wrong_mode.mode = NetworkingMode::Bridge;
wrong_mode.bridge = "br0".into();
validate_resolved_network(&wrong_mode).unwrap_err();

let mut with_netdev = open_file_network("/dev/tap7498");
with_netdev.netdev = "tap,id=net0,fd=3".into();
validate_resolved_network(&with_netdev).unwrap_err();
}

#[test]
fn open_file_does_not_inherit_host_custom_netdev() {
use rocket::figment::{providers::Format, providers::Toml, Figment};

let mut cfg: crate::config::Config =
Figment::from(Toml::string(crate::config::DEFAULT_CONFIG))
.extract()
.unwrap();
cfg.cvm.networking.mode = NetworkingMode::Custom;
cfg.cvm.networking.netdev = "tap,id=net0,ifname=legacy,script=no".into();

let nic = open_file_network("/dev/tap7498");
let resolved = super::resolve_networking(&nic, &cfg.cvm);
assert_eq!(resolved.open_file, "/dev/tap7498");
assert!(resolved.netdev.is_empty());
validate_resolved_network(&resolved).unwrap();
}

#[test]
fn primary_mac_keeps_legacy_derivation_and_later_nics_are_distinct() {
Expand Down
Loading