From b050bba298568af285990345cd0fc47eb11973fc Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Jul 2026 19:26:14 -0700 Subject: [PATCH 1/3] feat(os): add extensible dstack-tee-simulator for no-TEE dev VMs Add a FUSE-based TEE simulator (default TDX backend) that exposes the configfs-tsm quote, RTMR, and CCEL interfaces used by the existing guest path. Packaged only into development images. --- CONTRIBUTING.md | 35 ++ dstack/Cargo.lock | 41 ++ dstack/Cargo.toml | 2 + dstack/tee-simulator/Cargo.toml | 28 + dstack/tee-simulator/src/main.rs | 105 ++++ dstack/tee-simulator/src/tdx.rs | 491 ++++++++++++++++++ .../tee-simulator.conf | 3 + os/common/rootfs/dstack-tee-simulator.service | 22 + .../dstack-tee-simulator.bb | 65 +++ .../recipes-core/images/dstack-rootfs-dev.inc | 2 +- 10 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 dstack/tee-simulator/Cargo.toml create mode 100644 dstack/tee-simulator/src/main.rs create mode 100644 dstack/tee-simulator/src/tdx.rs create mode 100644 os/common/rootfs/dstack-prepare.service.d/tee-simulator.conf create mode 100644 os/common/rootfs/dstack-tee-simulator.service create mode 100644 os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c5df8bd5..5b69bce63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,41 @@ Use the narrowest owning directory. Fixture explanations and component READMEs should stay with their fixtures/components; general guides should be linked from the root README and live under `docs/`. +## Test a guest without TEE hardware + +Development images include the extensible `dstack-tee-simulator`. Its default +platform backend is TDX. On a VM launched with `no_tee`, that backend exposes +the Linux interfaces used by the normal guest software: + +- configfs-tsm quote generation under `/sys/kernel/config/tsm/report`; +- RTMR extension files used by `tdx-attest`; +- a CCEL boot-event fixture. + +This keeps `dstack-prepare`, measurement, encrypted-storage setup, the guest +agent, and attestation APIs on their production code paths. The generated quote +has an intentionally invalid signature, so a production verifier or KMS must +reject it. + +The service uses the default TDX backend. For direct simulator development, the +same selection can be made explicitly with `dstack-tee-simulator --platform +tdx`. New TEE platforms are implemented as separate backends behind the shared +simulator lifecycle. + +Build or install a `dstack-dev-*` image, then deploy without KMS or gateway: + +```bash +dstack deploy ./docker-compose.yml \ + --name no-tee-dev \ + --image dstack-dev-VERSION \ + --no-kms \ + --no-tee +``` + +The simulator package is installed only in development images. This mode +provides no hardware isolation and must never be used with production +workloads or secrets. Real quote generation, hardware isolation, and KMS +authorization still require TDX or another supported TEE. + ## Commit Convention This project uses [Conventional Commits](https://www.conventionalcommits.org/). Please format your commit messages as: diff --git a/dstack/Cargo.lock b/dstack/Cargo.lock index b60ffca35..4d0bc72ca 100644 --- a/dstack/Cargo.lock +++ b/dstack/Cargo.lock @@ -1894,6 +1894,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "dstack-tee-simulator" +version = "0.6.0" +dependencies = [ + "anyhow", + "cc-eventlog", + "clap", + "dcap-qvl", + "fuser", + "libc", + "sd-notify", + "sha2 0.10.9", + "tracing", + "tracing-subscriber", +] + [[package]] name = "dstack-types" version = "0.6.0" @@ -2443,6 +2459,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "fuser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb29a3ae32279fe3e79a958fe01899f5fb23eadccee919cf88e145b54ed9367" +dependencies = [ + "libc", + "log", + "memchr", + "nix 0.29.0", + "page_size", + "smallvec", + "zerocopy", +] + [[package]] name = "futures" version = "0.3.32" @@ -4311,6 +4342,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parcelona" version = "0.4.3" diff --git a/dstack/Cargo.toml b/dstack/Cargo.toml index 39240f39d..8ba2df060 100644 --- a/dstack/Cargo.toml +++ b/dstack/Cargo.toml @@ -33,6 +33,7 @@ members = [ "guest-agent", "guest-agent/rpc", "guest-agent-simulator", + "tee-simulator", "vmm", "vmm/rpc", "gateway", @@ -118,6 +119,7 @@ chrono = "0.4.40" clap = { version = "4.5.32", features = ["derive", "string"] } dashmap = "6.1.0" fs-err = "3.1.0" +fuser = "0.16.0" path-absolutize = "3.1.1" futures = "0.3.31" git-version = "0.3.9" diff --git a/dstack/tee-simulator/Cargo.toml b/dstack/tee-simulator/Cargo.toml new file mode 100644 index 000000000..1a7ce39db --- /dev/null +++ b/dstack/tee-simulator/Cargo.toml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: © 2026 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "dstack-tee-simulator" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "dstack-tee-simulator" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +cc-eventlog.workspace = true +clap.workspace = true +fuser.workspace = true +libc.workspace = true +sd-notify.workspace = true +sha2.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +dcap-qvl.workspace = true diff --git a/dstack/tee-simulator/src/main.rs b/dstack/tee-simulator/src/main.rs new file mode 100644 index 000000000..0021ca16b --- /dev/null +++ b/dstack/tee-simulator/src/main.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use clap::{Parser, ValueEnum}; +use fuser::{Filesystem, MountOption, Session}; +use tracing::info; + +mod tdx; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +enum TeePlatform { + #[default] + Tdx, +} + +#[derive(Parser)] +#[command(about = "Development-only simulator for Linux TEE guest ABIs")] +struct Args { + /// TEE platform ABI to simulate. + #[arg(long, value_enum, default_value = "tdx")] + platform: TeePlatform, + + /// Override the platform backend's default mountpoint. + #[arg(long)] + mountpoint: Option, +} + +/// Platform-specific simulator backend. +/// +/// Adding another TEE platform only requires a backend implementation and a +/// CLI enum variant; the FUSE lifecycle and safety checks remain shared. +trait TeeBackend { + type Fs: Filesystem; + + const PLATFORM: &'static str; + const DEFAULT_MOUNTPOINT: &'static str; + + fn create_filesystem() -> Result; + fn prepare_mountpoint(mountpoint: &Path) -> Result<()>; + fn real_tee_available() -> bool; +} + +fn run_backend(mountpoint_override: Option) -> Result<()> { + let default_mountpoint = Path::new(B::DEFAULT_MOUNTPOINT); + let mountpoint = mountpoint_override.as_deref().unwrap_or(default_mountpoint); + + if mountpoint == default_mountpoint && B::real_tee_available() { + bail!( + "refusing to start the {} simulator when a real TEE device is present", + B::PLATFORM + ); + } + B::prepare_mountpoint(mountpoint)?; + + let fs = B::create_filesystem()?; + let mut session = Session::new( + fs, + mountpoint, + &[ + MountOption::FSName(format!("dstack-tee-simulator-{}", B::PLATFORM)), + MountOption::DefaultPermissions, + MountOption::NoSuid, + MountOption::NoDev, + MountOption::NoExec, + ], + ) + .with_context(|| format!("failed to mount FUSE at {}", mountpoint.display()))?; + + info!( + platform = B::PLATFORM, + mountpoint = %mountpoint.display(), + "development TEE simulator ready" + ); + sd_notify::notify(true, &[sd_notify::NotifyState::Ready]) + .context("failed to notify systemd")?; + session.run().context("fuse session failed")?; + Ok(()) +} + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + let args = Args::parse(); + + match args.platform { + TeePlatform::Tdx => run_backend::(args.mountpoint), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_tdx() { + let args = Args::try_parse_from(["dstack-tee-simulator"]).unwrap(); + assert_eq!(args.platform, TeePlatform::Tdx); + assert!(args.mountpoint.is_none()); + } +} diff --git a/dstack/tee-simulator/src/tdx.rs b/dstack/tee-simulator/src/tdx.rs new file mode 100644 index 000000000..1756cea14 --- /dev/null +++ b/dstack/tee-simulator/src/tdx.rs @@ -0,0 +1,491 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{CString, OsStr}; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use anyhow::{bail, Context, Result}; +use fuser::{ + FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, + ReplyOpen, ReplyWrite, Request, TimeOrNow, +}; +use sha2::{Digest, Sha384}; + +use crate::TeeBackend; + +const TDX_DEFAULT_MOUNTPOINT: &str = "/sys/kernel/config/tsm/report"; +const PROVIDER_NAME: &str = "com.intel.dcap"; +const PROVIDER_VALUE: &[u8] = b"tdx_guest_sim\n"; +const DIRECT_IO: u32 = 1; +const TTL: Duration = Duration::ZERO; + +const ROOT_INO: u64 = 1; +const PROVIDER_DIR_INO: u64 = 2; +const PROVIDER_INO: u64 = 3; +const INBLOB_INO: u64 = 4; +const OUTBLOB_INO: u64 = 5; +const GENERATION_INO: u64 = 6; +const MEASUREMENTS_DIR_INO: u64 = 7; +const RTMR0_INO: u64 = 8; +const CCEL_INO: u64 = 12; + +const TDX_QUOTE_MIN_SIZE: usize = 632; +const MR_CONFIG_ID_RANGE: std::ops::Range = 232..280; +const RTMR0_OFFSET: usize = 376; +const REPORT_DATA_RANGE: std::ops::Range = 568..632; + +const QUOTE_FIXTURE: &[u8] = include_bytes!("../../ra-tls/assets/tdx_quote"); +const CCEL_FIXTURE: &[u8] = include_bytes!("../../cc-eventlog/samples/ccel.bin"); + +#[derive(Clone)] +struct SimulatorState { + base_quote: Vec, + ccel: Vec, + rtmrs: [[u8; 48]; 4], + outblob: Vec, + generation: i64, +} + +impl SimulatorState { + fn new(base_quote: &[u8], ccel: &[u8]) -> Result { + if base_quote.len() < TDX_QUOTE_MIN_SIZE { + bail!("tdx quote fixture is too short: {}", base_quote.len()); + } + + let mut state = Self { + base_quote: base_quote.to_vec(), + ccel: ccel.to_vec(), + rtmrs: replay_boot_rtmrs(ccel)?, + outblob: Vec::new(), + generation: 0, + }; + state.outblob = state.make_quote([0u8; 64]); + Ok(state) + } + + fn make_quote(&self, report_data: [u8; 64]) -> Vec { + let mut quote = self.base_quote.clone(); + quote[MR_CONFIG_ID_RANGE].fill(0); + for (index, rtmr) in self.rtmrs.iter().enumerate() { + let start = RTMR0_OFFSET + index * 48; + quote[start..start + 48].copy_from_slice(rtmr); + } + quote[REPORT_DATA_RANGE].copy_from_slice(&report_data); + quote + } + + fn request_quote(&mut self, report_data: &[u8]) -> Result<()> { + let report_data: [u8; 64] = report_data + .try_into() + .map_err(|_| anyhow::anyhow!("inblob must be exactly 64 bytes"))?; + self.outblob = self.make_quote(report_data); + self.generation = self + .generation + .checked_add(1) + .context("tsm generation overflow")?; + Ok(()) + } + + fn extend_rtmr(&mut self, index: usize, digest: &[u8]) -> Result<()> { + if !(2..=3).contains(&index) { + bail!("rtmr{index} is not userspace extensible"); + } + if digest.len() != 48 { + bail!("rtmr digest must be exactly 48 bytes"); + } + let mut hasher = Sha384::new(); + hasher.update(self.rtmrs[index]); + hasher.update(digest); + self.rtmrs[index] = hasher.finalize().into(); + Ok(()) + } + + fn content(&self, ino: u64) -> Option> { + match ino { + PROVIDER_INO => Some(PROVIDER_VALUE.to_vec()), + INBLOB_INO => Some(Vec::new()), + OUTBLOB_INO => Some(self.outblob.clone()), + GENERATION_INO => Some(format!("{}\n", self.generation).into_bytes()), + CCEL_INO => Some(self.ccel.clone()), + RTMR0_INO..=11 => Some(self.rtmrs[(ino - RTMR0_INO) as usize].to_vec()), + _ => None, + } + } +} + +fn replay_boot_rtmrs(ccel: &[u8]) -> Result<[[u8; 48]; 4]> { + let events = cc_eventlog::tdx::decode_ccel(ccel).context("failed to decode CCEL fixture")?; + let mut rtmrs = [[0u8; 48]; 4]; + for event in events { + let index = usize::try_from(event.imr).context("invalid CCEL IMR index")?; + let rtmr = rtmrs + .get_mut(index) + .with_context(|| format!("ccel event references unsupported RTMR{index}"))?; + let digest = event.digest(); + if digest.len() != 48 { + bail!( + "ccel RTMR{index} digest has invalid length {}", + digest.len() + ); + } + let mut hasher = Sha384::new(); + hasher.update(*rtmr); + hasher.update(digest); + *rtmr = hasher.finalize().into(); + } + Ok(rtmrs) +} + +pub(crate) struct TdxSimulatorFs { + state: SimulatorState, + uid: u32, + gid: u32, +} + +impl TdxSimulatorFs { + fn new() -> Result { + Ok(Self { + state: SimulatorState::new(QUOTE_FIXTURE, CCEL_FIXTURE)?, + uid: unsafe { libc::geteuid() }, + gid: unsafe { libc::getegid() }, + }) + } + + fn attr(&self, ino: u64) -> Option { + let (kind, perm, size) = match ino { + ROOT_INO | PROVIDER_DIR_INO | MEASUREMENTS_DIR_INO => (FileType::Directory, 0o755, 0), + PROVIDER_INO | OUTBLOB_INO | GENERATION_INO | CCEL_INO => ( + FileType::RegularFile, + 0o400, + self.state.content(ino)?.len() as u64, + ), + INBLOB_INO => (FileType::RegularFile, 0o200, 0), + RTMR0_INO..=11 => (FileType::RegularFile, 0o600, 48), + _ => return None, + }; + Some(FileAttr { + ino, + size, + blocks: 0, + atime: SystemTime::UNIX_EPOCH, + mtime: SystemTime::UNIX_EPOCH, + ctime: SystemTime::UNIX_EPOCH, + crtime: SystemTime::UNIX_EPOCH, + kind, + perm, + nlink: if kind == FileType::Directory { 2 } else { 1 }, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: 4096, + flags: 0, + }) + } + + fn lookup_ino(parent: u64, name: &OsStr) -> Option { + match (parent, name.to_str()?) { + (ROOT_INO, PROVIDER_NAME) => Some(PROVIDER_DIR_INO), + (PROVIDER_DIR_INO, "provider") => Some(PROVIDER_INO), + (PROVIDER_DIR_INO, "inblob") => Some(INBLOB_INO), + (PROVIDER_DIR_INO, "outblob") => Some(OUTBLOB_INO), + (PROVIDER_DIR_INO, "generation") => Some(GENERATION_INO), + (PROVIDER_DIR_INO, "measurements") => Some(MEASUREMENTS_DIR_INO), + (PROVIDER_DIR_INO, "ccel") => Some(CCEL_INO), + (MEASUREMENTS_DIR_INO, "rtmr0:sha384") => Some(RTMR0_INO), + (MEASUREMENTS_DIR_INO, "rtmr1:sha384") => Some(RTMR0_INO + 1), + (MEASUREMENTS_DIR_INO, "rtmr2:sha384") => Some(RTMR0_INO + 2), + (MEASUREMENTS_DIR_INO, "rtmr3:sha384") => Some(RTMR0_INO + 3), + _ => None, + } + } +} + +impl Filesystem for TdxSimulatorFs { + fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { + let Some(ino) = Self::lookup_ino(parent, name) else { + reply.error(libc::ENOENT); + return; + }; + let Some(attr) = self.attr(ino) else { + reply.error(libc::ENOENT); + return; + }; + reply.entry(&TTL, &attr, 0); + } + + fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option, reply: ReplyAttr) { + match self.attr(ino) { + Some(attr) => reply.attr(&TTL, &attr), + None => reply.error(libc::ENOENT), + } + } + + #[allow(clippy::too_many_arguments)] + fn setattr( + &mut self, + _req: &Request<'_>, + ino: u64, + mode: Option, + uid: Option, + gid: Option, + size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + _fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + flags: Option, + reply: ReplyAttr, + ) { + let truncatable = ino == INBLOB_INO || (RTMR0_INO..=11).contains(&ino); + if truncatable + && size == Some(0) + && mode.is_none() + && uid.is_none() + && gid.is_none() + && flags.is_none() + { + if let Some(attr) = self.attr(ino) { + reply.attr(&TTL, &attr); + } else { + reply.error(libc::ENOENT); + } + } else { + reply.error(libc::EACCES); + } + } + + fn readdir( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + mut reply: ReplyDirectory, + ) { + let entries: Vec<(u64, FileType, &str)> = match ino { + ROOT_INO => vec![ + (ROOT_INO, FileType::Directory, "."), + (ROOT_INO, FileType::Directory, ".."), + (PROVIDER_DIR_INO, FileType::Directory, PROVIDER_NAME), + ], + PROVIDER_DIR_INO => vec![ + (PROVIDER_DIR_INO, FileType::Directory, "."), + (ROOT_INO, FileType::Directory, ".."), + (PROVIDER_INO, FileType::RegularFile, "provider"), + (INBLOB_INO, FileType::RegularFile, "inblob"), + (OUTBLOB_INO, FileType::RegularFile, "outblob"), + (GENERATION_INO, FileType::RegularFile, "generation"), + (MEASUREMENTS_DIR_INO, FileType::Directory, "measurements"), + (CCEL_INO, FileType::RegularFile, "ccel"), + ], + MEASUREMENTS_DIR_INO => vec![ + (MEASUREMENTS_DIR_INO, FileType::Directory, "."), + (PROVIDER_DIR_INO, FileType::Directory, ".."), + (RTMR0_INO, FileType::RegularFile, "rtmr0:sha384"), + (RTMR0_INO + 1, FileType::RegularFile, "rtmr1:sha384"), + (RTMR0_INO + 2, FileType::RegularFile, "rtmr2:sha384"), + (RTMR0_INO + 3, FileType::RegularFile, "rtmr3:sha384"), + ], + _ => { + reply.error(libc::ENOTDIR); + return; + } + }; + + for (index, (entry_ino, kind, name)) in + entries.into_iter().enumerate().skip(offset.max(0) as usize) + { + if reply.add(entry_ino, (index + 1) as i64, kind, name) { + break; + } + } + reply.ok(); + } + + fn open(&mut self, _req: &Request<'_>, ino: u64, _flags: i32, reply: ReplyOpen) { + if self.attr(ino).is_none() { + reply.error(libc::ENOENT); + } else { + reply.opened(0, DIRECT_IO); + } + } + + fn read( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + let Some(data) = self.state.content(ino) else { + reply.error(libc::EISDIR); + return; + }; + let start = offset.max(0) as usize; + if start >= data.len() { + reply.data(&[]); + return; + } + let end = start.saturating_add(size as usize).min(data.len()); + reply.data(&data[start..end]); + } + + fn write( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + data: &[u8], + _write_flags: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyWrite, + ) { + if offset != 0 { + reply.error(libc::EINVAL); + return; + } + let result = match ino { + INBLOB_INO => self.state.request_quote(data), + RTMR0_INO..=11 => self.state.extend_rtmr((ino - RTMR0_INO) as usize, data), + _ => { + reply.error(libc::EACCES); + return; + } + }; + match result { + Ok(()) => reply.written(data.len() as u32), + Err(_) => reply.error(libc::EINVAL), + } + } + + fn flush( + &mut self, + _req: &Request<'_>, + _ino: u64, + _fh: u64, + _lock_owner: u64, + reply: ReplyEmpty, + ) { + reply.ok(); + } +} + +fn ensure_configfs_mount(mountpoint: &Path) -> Result<()> { + if mountpoint != Path::new(TDX_DEFAULT_MOUNTPOINT) { + std::fs::create_dir_all(mountpoint) + .with_context(|| format!("failed to create {}", mountpoint.display()))?; + return Ok(()); + } + + if !mountpoint.is_dir() { + let source = CString::new("configfs")?; + let target = CString::new("/sys/kernel/config")?; + let fstype = CString::new("configfs")?; + let rc = unsafe { + libc::mount( + source.as_ptr(), + target.as_ptr(), + fstype.as_ptr(), + libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC, + std::ptr::null(), + ) + }; + if rc != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EBUSY) { + return Err(error).context("failed to mount configfs"); + } + } + } + if !mountpoint.is_dir() { + bail!( + "tsm report mountpoint is unavailable: {}", + mountpoint.display() + ); + } + Ok(()) +} + +fn mounted_provider_exists() -> bool { + ["/dev/tdx_guest", "/dev/sev-guest"] + .iter() + .any(|path| Path::new(path).exists()) +} + +/// Linux TDX/TSM ABI backend for the generic TEE simulator lifecycle. +pub(crate) struct TdxBackend; + +impl TeeBackend for TdxBackend { + type Fs = TdxSimulatorFs; + + const PLATFORM: &'static str = "tdx"; + const DEFAULT_MOUNTPOINT: &'static str = TDX_DEFAULT_MOUNTPOINT; + + fn create_filesystem() -> Result { + TdxSimulatorFs::new() + } + + fn prepare_mountpoint(mountpoint: &Path) -> Result<()> { + ensure_configfs_mount(mountpoint) + } + + fn real_tee_available() -> bool { + mounted_provider_exists() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dcap_qvl::quote::Quote; + + #[test] + fn quote_tracks_report_data_and_rtmr_extensions() { + let mut state = SimulatorState::new(QUOTE_FIXTURE, CCEL_FIXTURE).unwrap(); + let original_rtmr3 = state.rtmrs[3]; + let digest = [0x42; 48]; + state.extend_rtmr(3, &digest).unwrap(); + assert_ne!(state.rtmrs[3], original_rtmr3); + + let report_data = [0x5a; 64]; + state.request_quote(&report_data).unwrap(); + assert_eq!(state.generation, 1); + + let quote = Quote::parse(&state.outblob).unwrap(); + let report = quote.report.as_td10().unwrap(); + let fixture = Quote::parse(QUOTE_FIXTURE).unwrap(); + let fixture_report = fixture.report.as_td10().unwrap(); + assert_eq!(report.report_data, report_data); + assert_eq!(report.mr_config_id, [0u8; 48]); + assert_eq!(report.rt_mr3, state.rtmrs[3]); + assert_eq!(report.mr_owner, fixture_report.mr_owner); + } + + #[test] + fn only_rtmr_two_and_three_are_extensible() { + let mut state = SimulatorState::new(QUOTE_FIXTURE, CCEL_FIXTURE).unwrap(); + assert!(state.extend_rtmr(0, &[0u8; 48]).is_err()); + assert!(state.extend_rtmr(2, &[0u8; 48]).is_ok()); + assert!(state.extend_rtmr(3, &[0u8; 48]).is_ok()); + assert!(state.extend_rtmr(4, &[0u8; 48]).is_err()); + assert!(state.extend_rtmr(3, &[0u8; 47]).is_err()); + } + + #[test] + fn boot_rtmrs_replay_the_bundled_ccel() { + let rtmrs = replay_boot_rtmrs(CCEL_FIXTURE).unwrap(); + assert!(rtmrs[..2].iter().all(|rtmr| *rtmr != [0u8; 48])); + assert_eq!(rtmrs[3], [0u8; 48]); + } +} diff --git a/os/common/rootfs/dstack-prepare.service.d/tee-simulator.conf b/os/common/rootfs/dstack-prepare.service.d/tee-simulator.conf new file mode 100644 index 000000000..cb55eb822 --- /dev/null +++ b/os/common/rootfs/dstack-prepare.service.d/tee-simulator.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=dstack-tee-simulator.service +After=dstack-tee-simulator.service diff --git a/os/common/rootfs/dstack-tee-simulator.service b/os/common/rootfs/dstack-tee-simulator.service new file mode 100644 index 000000000..10777a77a --- /dev/null +++ b/os/common/rootfs/dstack-tee-simulator.service @@ -0,0 +1,22 @@ +[Unit] +Description=dstack development TEE ABI simulator (TDX backend) +Documentation=https://github.com/Dstack-TEE/dstack/blob/master/CONTRIBUTING.md +Before=dstack-prepare.service +ConditionPathExists=!/dev/tdx_guest +ConditionPathExists=!/dev/sev-guest + +[Service] +Type=notify +ExecCondition=/bin/sh -c '! grep -qE "^(tdx_guest|sev_guest)" /sys/kernel/config/tsm/report/*/provider 2>/dev/null' +ExecStart=/usr/bin/dstack-tee-simulator +ExecStartPost=/bin/systemctl set-environment DCAP_TDX_RTMR_SYSFS_PATH=/sys/kernel/config/tsm/report/com.intel.dcap/measurements DSTACK_CCEL_FILE=/sys/kernel/config/tsm/report/com.intel.dcap/ccel +ExecStop=-/bin/fusermount3 -u /sys/kernel/config/tsm/report +ExecStopPost=-/bin/fusermount3 -uz /sys/kernel/config/tsm/report +ExecStopPost=/bin/systemctl unset-environment DCAP_TDX_RTMR_SYSFS_PATH DSTACK_CCEL_FILE +Restart=on-failure +RestartSec=1s +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=multi-user.target diff --git a/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb new file mode 100644 index 000000000..d6896c499 --- /dev/null +++ b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb @@ -0,0 +1,65 @@ +SUMMARY = "Development-only TEE ABI simulator for dstack" +DESCRIPTION = "Extensible TEE-platform simulator whose default TDX backend lets the development guest image exercise the production TDX userspace path on a non-TEE QEMU VM" +LICENSE = "Apache-2.0" +LIC_FILES_CHKSUM = "file://${COREBASE}/meta/files/common-licenses/Apache-2.0;md5=89aea4e17d99a7cacdbeed46a0096b10" + +inherit systemd + +DSTACK_MONOREPO_ROOT ?= "${@os.path.realpath(os.path.join(d.getVar('THISDIR'), '../../../../../..'))}" +DSTACK_CORE_SRC ?= "${DSTACK_MONOREPO_ROOT}/dstack" +DSTACK_RUST_SDK_SRC ?= "${DSTACK_MONOREPO_ROOT}/sdk/rust" +DSTACK_ROOTFS_SRC ?= "${DSTACK_MONOREPO_ROOT}/os/common/rootfs" + +S = "${UNPACKDIR}/repo/dstack" +DSTACK_ROOTFS_FILES = "${UNPACKDIR}/repo/os/common/rootfs" + +DEPENDS += "rsync-native" +RDEPENDS:${PN} += "fuse3-utils kernel-module-fuse" +do_unpack[depends] += "rsync-native:do_populate_sysroot" + +SYSTEMD_SERVICE:${PN} = "dstack-tee-simulator.service" +SYSTEMD_AUTO_ENABLE:${PN} = "enable" +EXTRA_CARGO_FLAGS = "-p dstack-tee-simulator" + +inherit cargo_bin + +do_unpack() { + install -d "${S}" "${UNPACKDIR}/repo/sdk/rust" "${DSTACK_ROOTFS_FILES}" + rsync -a --exclude=".git" --exclude=".worktrees" --exclude="target" \ + "${DSTACK_CORE_SRC}/" "${S}/" + rsync -a --exclude=".git" --exclude="target" \ + "${DSTACK_RUST_SDK_SRC}/" "${UNPACKDIR}/repo/sdk/rust/" + rsync -a "${DSTACK_ROOTFS_SRC}/" "${DSTACK_ROOTFS_FILES}/" +} + +do_unpack[cleandirs] = "${UNPACKDIR}/repo" +do_unpack[nostamp] = "1" +do_unpack[vardeps] += "DSTACK_CORE_SRC DSTACK_RUST_SDK_SRC DSTACK_ROOTFS_SRC" + +do_configure() { + cargo_bin_do_configure +} + +do_compile() { + cargo_bin_do_compile +} + +do_compile[network] = "1" + +do_install() { + install -d ${D}${bindir} ${D}${systemd_system_unitdir} + install -m 0755 ${CARGO_BINDIR}/dstack-tee-simulator ${D}${bindir} + install -m 0644 ${DSTACK_ROOTFS_FILES}/dstack-tee-simulator.service \ + ${D}${systemd_system_unitdir} + + install -d ${D}${sysconfdir}/systemd/system/dstack-prepare.service.d + install -m 0644 \ + ${DSTACK_ROOTFS_FILES}/dstack-prepare.service.d/tee-simulator.conf \ + ${D}${sysconfdir}/systemd/system/dstack-prepare.service.d/tee-simulator.conf +} + +FILES:${PN} += "${sysconfdir}/systemd/system/dstack-prepare.service.d/tee-simulator.conf" + +# Cargo embeds build paths into binaries; allow TMPDIR references. +INSANE_SKIP:${PN} += "buildpaths" +INSANE_SKIP:${PN}-dbg += "buildpaths" diff --git a/os/yocto/layers/meta-dstack/recipes-core/images/dstack-rootfs-dev.inc b/os/yocto/layers/meta-dstack/recipes-core/images/dstack-rootfs-dev.inc index 1481a98ac..70f33a64e 100644 --- a/os/yocto/layers/meta-dstack/recipes-core/images/dstack-rootfs-dev.inc +++ b/os/yocto/layers/meta-dstack/recipes-core/images/dstack-rootfs-dev.inc @@ -1,2 +1,2 @@ -IMAGE_INSTALL += "packagegroup-core-ssh-openssh strace tcpdump gdb gdbserver vim" +IMAGE_INSTALL += "packagegroup-core-ssh-openssh strace tcpdump gdb gdbserver vim dstack-tee-simulator" EXTRA_IMAGE_FEATURES += "allow-root-login post-install-logging" From 006b210d0c4b4b9175b80a860d28307c4bea9c7e Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Jul 2026 20:15:09 -0700 Subject: [PATCH 2/3] refactor(simulator): match real provider and colocate unit files Use provider value tdx_guest like the kernel TSM interface, and keep the systemd unit plus prepare drop-in under the Yocto recipe files/ tree instead of os/common/rootfs. --- dstack/tee-simulator/src/tdx.rs | 2 +- .../dstack-tee-simulator.bb | 18 ++++++++++-------- .../files}/dstack-tee-simulator.service | 0 .../files}/tee-simulator.conf | 0 4 files changed, 11 insertions(+), 9 deletions(-) rename os/{common/rootfs => yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files}/dstack-tee-simulator.service (100%) rename os/{common/rootfs/dstack-prepare.service.d => yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files}/tee-simulator.conf (100%) diff --git a/dstack/tee-simulator/src/tdx.rs b/dstack/tee-simulator/src/tdx.rs index 1756cea14..60e0603dd 100644 --- a/dstack/tee-simulator/src/tdx.rs +++ b/dstack/tee-simulator/src/tdx.rs @@ -17,7 +17,7 @@ use crate::TeeBackend; const TDX_DEFAULT_MOUNTPOINT: &str = "/sys/kernel/config/tsm/report"; const PROVIDER_NAME: &str = "com.intel.dcap"; -const PROVIDER_VALUE: &[u8] = b"tdx_guest_sim\n"; +const PROVIDER_VALUE: &[u8] = b"tdx_guest\n"; const DIRECT_IO: u32 = 1; const TTL: Duration = Duration::ZERO; diff --git a/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb index d6896c499..f69d88f24 100644 --- a/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb +++ b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb @@ -8,10 +8,8 @@ inherit systemd DSTACK_MONOREPO_ROOT ?= "${@os.path.realpath(os.path.join(d.getVar('THISDIR'), '../../../../../..'))}" DSTACK_CORE_SRC ?= "${DSTACK_MONOREPO_ROOT}/dstack" DSTACK_RUST_SDK_SRC ?= "${DSTACK_MONOREPO_ROOT}/sdk/rust" -DSTACK_ROOTFS_SRC ?= "${DSTACK_MONOREPO_ROOT}/os/common/rootfs" S = "${UNPACKDIR}/repo/dstack" -DSTACK_ROOTFS_FILES = "${UNPACKDIR}/repo/os/common/rootfs" DEPENDS += "rsync-native" RDEPENDS:${PN} += "fuse3-utils kernel-module-fuse" @@ -24,17 +22,16 @@ EXTRA_CARGO_FLAGS = "-p dstack-tee-simulator" inherit cargo_bin do_unpack() { - install -d "${S}" "${UNPACKDIR}/repo/sdk/rust" "${DSTACK_ROOTFS_FILES}" + install -d "${S}" "${UNPACKDIR}/repo/sdk/rust" rsync -a --exclude=".git" --exclude=".worktrees" --exclude="target" \ "${DSTACK_CORE_SRC}/" "${S}/" rsync -a --exclude=".git" --exclude="target" \ "${DSTACK_RUST_SDK_SRC}/" "${UNPACKDIR}/repo/sdk/rust/" - rsync -a "${DSTACK_ROOTFS_SRC}/" "${DSTACK_ROOTFS_FILES}/" } do_unpack[cleandirs] = "${UNPACKDIR}/repo" do_unpack[nostamp] = "1" -do_unpack[vardeps] += "DSTACK_CORE_SRC DSTACK_RUST_SDK_SRC DSTACK_ROOTFS_SRC" +do_unpack[vardeps] += "DSTACK_CORE_SRC DSTACK_RUST_SDK_SRC" do_configure() { cargo_bin_do_configure @@ -49,15 +46,20 @@ do_compile[network] = "1" do_install() { install -d ${D}${bindir} ${D}${systemd_system_unitdir} install -m 0755 ${CARGO_BINDIR}/dstack-tee-simulator ${D}${bindir} - install -m 0644 ${DSTACK_ROOTFS_FILES}/dstack-tee-simulator.service \ + install -m 0644 ${THISDIR}/files/dstack-tee-simulator.service \ ${D}${systemd_system_unitdir} install -d ${D}${sysconfdir}/systemd/system/dstack-prepare.service.d - install -m 0644 \ - ${DSTACK_ROOTFS_FILES}/dstack-prepare.service.d/tee-simulator.conf \ + install -m 0644 ${THISDIR}/files/tee-simulator.conf \ ${D}${sysconfdir}/systemd/system/dstack-prepare.service.d/tee-simulator.conf } +# Unit/drop-in live next to this recipe; include them in task checksums. +do_install[file-checksums] += "\ + ${THISDIR}/files/dstack-tee-simulator.service:True \ + ${THISDIR}/files/tee-simulator.conf:True \ +" + FILES:${PN} += "${sysconfdir}/systemd/system/dstack-prepare.service.d/tee-simulator.conf" # Cargo embeds build paths into binaries; allow TMPDIR references. diff --git a/os/common/rootfs/dstack-tee-simulator.service b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/dstack-tee-simulator.service similarity index 100% rename from os/common/rootfs/dstack-tee-simulator.service rename to os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/dstack-tee-simulator.service diff --git a/os/common/rootfs/dstack-prepare.service.d/tee-simulator.conf b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/tee-simulator.conf similarity index 100% rename from os/common/rootfs/dstack-prepare.service.d/tee-simulator.conf rename to os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/files/tee-simulator.conf From 4732d50534426518fddb3aeb5ea5f1ca7108c1fc Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 15 Jul 2026 21:01:13 -0700 Subject: [PATCH 3/3] fix(os): drop unused sdk/rust rsync from tee-simulator recipe After the SDK workspace split, dstack-tee-simulator only builds from the core workspace, so staging sdk/rust is unnecessary (same as dstack-guest). --- .../dstack-tee-simulator/dstack-tee-simulator.bb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb index f69d88f24..032c12490 100644 --- a/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb +++ b/os/yocto/layers/meta-dstack/recipes-core/dstack-tee-simulator/dstack-tee-simulator.bb @@ -5,9 +5,10 @@ LIC_FILES_CHKSUM = "file://${COREBASE}/meta/files/common-licenses/Apache-2.0;md5 inherit systemd +# Stage the monorepo core workspace only. The public Rust SDK lives in its own +# Cargo workspace and is not needed to build dstack-tee-simulator. DSTACK_MONOREPO_ROOT ?= "${@os.path.realpath(os.path.join(d.getVar('THISDIR'), '../../../../../..'))}" DSTACK_CORE_SRC ?= "${DSTACK_MONOREPO_ROOT}/dstack" -DSTACK_RUST_SDK_SRC ?= "${DSTACK_MONOREPO_ROOT}/sdk/rust" S = "${UNPACKDIR}/repo/dstack" @@ -22,16 +23,14 @@ EXTRA_CARGO_FLAGS = "-p dstack-tee-simulator" inherit cargo_bin do_unpack() { - install -d "${S}" "${UNPACKDIR}/repo/sdk/rust" + install -d "${S}" rsync -a --exclude=".git" --exclude=".worktrees" --exclude="target" \ "${DSTACK_CORE_SRC}/" "${S}/" - rsync -a --exclude=".git" --exclude="target" \ - "${DSTACK_RUST_SDK_SRC}/" "${UNPACKDIR}/repo/sdk/rust/" } do_unpack[cleandirs] = "${UNPACKDIR}/repo" do_unpack[nostamp] = "1" -do_unpack[vardeps] += "DSTACK_CORE_SRC DSTACK_RUST_SDK_SRC" +do_unpack[vardeps] += "DSTACK_CORE_SRC" do_configure() { cargo_bin_do_configure