Skip to content
Merged
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
9 changes: 7 additions & 2 deletions dstack/vmm/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ use supervisor_client::SupervisorClient;
use tracing::{debug, error, info, warn};

pub use image::{Image, ImageInfo};
pub use qemu::{VmConfig, VmWorkDir};
pub use qemu::VmConfig;
pub use workdir::VmWorkDir;

mod host_share;
mod id_pool;
mod image;
mod mr_config;
mod qemu;
pub(crate) mod registry;
mod vm_info;
mod workdir;

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct PortMapping {
Expand Down Expand Up @@ -512,7 +517,7 @@ impl App {
/// Handle a DHCP lease notification: look up VM by MAC address, persist
/// the guest IP, and reconfigure port forwarding.
pub async fn report_dhcp_lease(&self, mac: &str, ip: &str) {
use crate::app::qemu::mac_address_for_vm;
use crate::app::mr_config::mac_address_for_vm;

let vm_id = {
let mut state = self.lock();
Expand Down
78 changes: 78 additions & 0 deletions dstack/vmm/src/app/host_share.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: © 2024-2025 Phala Network <dstack@phala.network>
//
// SPDX-License-Identifier: Apache-2.0

//! Host-to-guest shared-file disk creation.

use std::io::{Seek, SeekFrom, Write};
use std::path::Path;

use anyhow::{Context, Result};
use dstack_types::shared_filenames::HOST_SHARED_DISK_LABEL;
use fatfs::{FileSystem, FormatVolumeOptions, FsOptions};
use fs_err as fs;

/// Creates a FAT32 disk image containing the files in `shared_dir`.
pub(super) fn create_shared_disk(
disk_path: impl AsRef<Path>,
shared_dir: impl AsRef<Path>,
) -> Result<()> {
let disk_path = disk_path.as_ref();
let shared_dir = shared_dir.as_ref();

// Must be large enough to hold all host-shared files (app-compose.json and
// .user-config can each be up to 50 MiB, see HostShared::copy) plus FAT32 overhead.
const DISK_SIZE: u64 = 128 * 1024 * 1024;

// Back the image by a file (sparse until written) and stream files into it so
// peak memory stays bounded regardless of DISK_SIZE or input file sizes.
let mut image = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(disk_path)
.with_context(|| format!("failed to create disk image at {}", disk_path.display()))?;
image
.set_len(DISK_SIZE)
.context("failed to size disk image")?;

{
let mut label_bytes = [b' '; 11];
let label = HOST_SHARED_DISK_LABEL.as_bytes();
let copy_len = label.len().min(label_bytes.len());
label_bytes[..copy_len].copy_from_slice(&label[..copy_len]);
let format_opts = FormatVolumeOptions::new()
.fat_type(fatfs::FatType::Fat32)
.volume_label(label_bytes);
fatfs::format_volume(&mut image, format_opts).context("failed to format disk as FAT32")?;
}

image
.seek(SeekFrom::Start(0))
.context("failed to seek to start")?;
let filesystem =
FileSystem::new(&mut image, FsOptions::new()).context("failed to open FAT32 filesystem")?;
let root_dir = filesystem.root_dir();

for entry in fs::read_dir(shared_dir).context("failed to read shared directory")? {
let entry = entry.context("failed to read directory entry")?;
let path = entry.path();
if !path.is_file() {
continue;
}

let filename = entry.file_name();
let filename = filename.to_string_lossy();
let mut source = fs::File::open(&path)
.with_context(|| format!("failed to open file {}", path.display()))?;
let mut destination = root_dir
.create_file(&filename)
.with_context(|| format!("failed to create file {filename} in FAT32"))?;
std::io::copy(&mut source, &mut destination)
.with_context(|| format!("failed to write file {filename} to FAT32"))?;
destination.flush().context("failed to flush FAT32 file")?;
}

Ok(())
}
148 changes: 148 additions & 0 deletions dstack/vmm/src/app/mr_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: © 2024-2025 Phala Network <dstack@phala.network>
//
// SPDX-License-Identifier: Apache-2.0

//! Application identity and VM measurement-configuration derivation.

use anyhow::{bail, Context, Result};
use base64::prelude::*;
use dstack_types::mr_config::{MrConfig, MrConfigV3};
use dstack_types::AppCompose;
use fs_err as fs;
use sha2::{Digest, Sha256};

use super::VmWorkDir;

/// Derives a deterministic, locally administered unicast MAC address from a VM ID.
///
/// `prefix` may contain zero to three fixed bytes. Remaining bytes are filled
/// from SHA-256 of `vm_id`.
pub(super) fn mac_address_for_vm(vm_id: &str, prefix: &[u8]) -> String {
let hash = Sha256::digest(vm_id.as_bytes());
let prefix_len = prefix.len().min(3);
let mut bytes = [0_u8; 6];
bytes[..prefix_len].copy_from_slice(&prefix[..prefix_len]);
for index in prefix_len..bytes.len() {
bytes[index] = hash[index - prefix_len];
}
bytes[0] = (bytes[0] & 0xfe) | 0x02;
format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
)
}

pub(super) fn tdx_mr_config_id(workdir: &VmWorkDir, app_compose: &AppCompose) -> Result<String> {
if let Some(document) = workdir
.sys_config()
.context("failed to read sys config for tdx mrconfigid")?
.mr_config
{
MrConfigV3::from_document(&document).context("invalid mr_config document")?;
return Ok(BASE64_STANDARD.encode(MrConfigV3::tdx_mr_config_id_from_document(&document)));
}

let compose_hash = workdir
.app_compose_hash()
.context("failed to get compose hash")?;
let mr_config = if app_compose.key_provider_id.is_empty() {
MrConfig::V1 {
compose_hash: &compose_hash,
}
} else {
let instance_info = workdir
.instance_info()
.context("failed to get instance info")?;
let app_id = if instance_info.app_id.is_empty() {
&compose_hash[..20]
} else {
&instance_info.app_id
};
MrConfig::V2 {
compose_hash: &compose_hash,
app_id: &app_id.try_into().context("invalid app ID")?,
key_provider: app_compose.key_provider(),
key_provider_id: &app_compose.key_provider_id,
}
};
Ok(BASE64_STANDARD.encode(mr_config.to_mr_config_id()))
}

pub(super) fn snp_host_data(workdir: &VmWorkDir) -> Result<String> {
let document = workdir
.sys_config()
.context("failed to read sys config for amd sev-snp host-data")?
.mr_config
.context("mr_config is required for amd sev-snp host-data")?;
MrConfigV3::from_document(&document).context("invalid mr_config document")?;
Ok(BASE64_STANDARD.encode(MrConfigV3::snp_host_data_from_document(&document)))
}

impl VmWorkDir {
pub fn prepare_mr_config_v3(&self, app_compose: &AppCompose) -> Result<String> {
let compose_hash = self
.app_compose_hash()
.context("failed to get compose hash")?;
let mut instance_info = self
.instance_info_or_default()
.context("failed to get instance info")?;
let app_id = if instance_info.app_id.is_empty() {
compose_hash[..20].to_vec()
} else {
instance_info.app_id.clone()
};
if app_id.len() != 20 {
bail!(
"invalid app ID length: expected 20 bytes, got {}",
app_id.len()
);
}

let disk_reusable = !app_compose.key_provider().is_none();
if !disk_reusable || instance_info.instance_id_seed.is_empty() {
instance_info.instance_id_seed = {
let mut seed = vec![0_u8; 20];
getrandom::fill(&mut seed).context("failed to generate instance ID seed")?;
seed
};
}

let instance_id = if app_compose.no_instance_id {
Vec::new()
} else {
let mut id_path = instance_info.instance_id_seed.clone();
id_path.extend_from_slice(&app_id);
Sha256::digest(id_path)[..20].to_vec()
};
instance_info.app_id = app_id.clone();
instance_info.instance_id = instance_id.clone();
fs::write(
self.instance_info_path(),
serde_json::to_string(&instance_info).context("failed to serialize instance info")?,
)
.context("failed to write instance info")?;

Ok(MrConfigV3::new(
app_id,
compose_hash.to_vec(),
app_compose.key_provider(),
app_compose.key_provider_id.clone(),
instance_id,
)
.to_canonical_json())
}
}

#[cfg(test)]
mod tests {
use super::mac_address_for_vm;

#[test]
fn mac_address_is_deterministic_and_locally_administered() {
let mac = mac_address_for_vm("vm-1", &[0xaa, 0xbb, 0xcc]);
assert_eq!(&mac[..8], "aa:bb:cc");
assert_eq!(mac, mac_address_for_vm("vm-1", &[0xaa, 0xbb, 0xcc]));
let first = u8::from_str_radix(&mac[..2], 16).unwrap();
assert_eq!(first & 0x03, 0x02);
}
}
Loading
Loading