diff --git a/dstack/dstack-attest/src/attestation.rs b/dstack/dstack-attest/src/attestation.rs index a3b5b323c..6d597d87f 100644 --- a/dstack/dstack-attest/src/attestation.rs +++ b/dstack/dstack-attest/src/attestation.rs @@ -33,6 +33,7 @@ use tpm_qvl::verify::VerifiedReport as TpmVerifiedReport; /// File paths for attestation trust anchors. Empty fields retain the vendor /// production roots. Paths are read by the verifier, never by the attester. #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RootCaPaths { pub tdx: Option, pub gcp_tpm: Option, @@ -44,6 +45,7 @@ pub struct RootCaPaths { } #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AttestationVerifierConfig { #[serde(default)] pub insecure_allow_external_trust_anchors: bool, diff --git a/dstack/dstack-types/src/lib.rs b/dstack/dstack-types/src/lib.rs index 141ed3bb9..10df03667 100644 --- a/dstack/dstack-types/src/lib.rs +++ b/dstack/dstack-types/src/lib.rs @@ -905,6 +905,7 @@ pub struct SysConfig { } #[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct CollateralUrls { #[serde(default, skip_serializing_if = "Option::is_none")] pub pccs: Option, diff --git a/dstack/verifier/src/main.rs b/dstack/verifier/src/main.rs index c8758ac99..72861a742 100644 --- a/dstack/verifier/src/main.rs +++ b/dstack/verifier/src/main.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::sync::Arc; +use std::{net::IpAddr, path::Path, sync::Arc}; use anyhow::{Context, Result}; use clap::Parser; @@ -36,6 +36,7 @@ struct Cli { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct Config { pub address: String, pub port: u16, @@ -46,6 +47,51 @@ pub struct Config { pub image_download_timeout_secs: u64, } +impl Config { + fn validate(&self) -> Result<()> { + self.address + .parse::() + .with_context(|| format!("invalid verifier address: {}", self.address))?; + anyhow::ensure!(self.port != 0, "verifier port must not be zero"); + anyhow::ensure!( + !self.image_cache_dir.trim().is_empty(), + "image_cache_dir must not be empty" + ); + anyhow::ensure!( + self.image_download_timeout_secs > 0, + "image_download_timeout_secs must be greater than zero" + ); + anyhow::ensure!( + self.image_download_url.contains("{OS_IMAGE_HASH}"), + "image_download_url must contain {{OS_IMAGE_HASH}}" + ); + anyhow::ensure!( + self.image_download_url.starts_with("http://") + || self.image_download_url.starts_with("https://"), + "image_download_url must use http or https" + ); + let probe_url = self + .image_download_url + .replace("{OS_IMAGE_HASH}", &"00".repeat(32)); + let parsed = reqwest::Url::parse(&probe_url).context("invalid image_download_url")?; + debug_assert!(matches!(parsed.scheme(), "http" | "https")); + Ok(()) + } +} + +fn config_figment(config_path: &Path) -> Figment { + Figment::new() + .merge(Toml::string(include_str!("../dstack-verifier.toml"))) + .merge(Toml::file(config_path)) + .merge(Env::prefixed("DSTACK_VERIFIER_").split("__")) +} + +fn load_config(figment: &Figment) -> Result { + let config: Config = figment.extract().context("Failed to load configuration")?; + config.validate()?; + Ok(config) +} + #[post("/verify", data = "")] async fn verify_cvm( verifier: &State>, @@ -267,14 +313,9 @@ async fn main() -> Result<()> { let cli = Cli::parse(); - let default_config_str = include_str!("../dstack-verifier.toml"); - - let figment = Figment::from(rocket::Config::default()) - .merge(Toml::string(default_config_str)) - .merge(Toml::file(&cli.config)) - .merge(Env::prefixed("DSTACK_VERIFIER_")); - - let config: Config = figment.extract().context("Failed to load configuration")?; + let config_path = Path::new(&cli.config); + let config_figment = config_figment(config_path); + let config = load_config(&config_figment)?; // Check for oneshot modes if let Some(file_path) = cli.verify { if let Err(e) = run_oneshot(&file_path, &config).await { @@ -298,7 +339,8 @@ async fn main() -> Result<()> { Arc::new(AttestationVerifier::load(&config.attestation)?), )); - rocket::custom(figment) + let rocket_figment = Figment::from(rocket::Config::default()).merge(config_figment); + rocket::custom(rocket_figment) .mount("/", rocket::routes![verify_cvm, health]) .manage(verifier) .attach(AdHoc::on_liftoff("Startup", |_| { @@ -311,3 +353,71 @@ async fn main() -> Result<()> { .map_err(|err| anyhow::anyhow!("launch rocket failed: {err:?}"))?; Ok(()) } + +#[cfg(test)] +mod config_tests { + use super::*; + + fn valid_config(extra: &str) -> String { + format!( + r#"address = "127.0.0.1" +port = 18080 +image_cache_dir = "/tmp/dstack-verifier-config-test" +image_download_url = "http://127.0.0.1:18081/{{OS_IMAGE_HASH}}.tar.gz" +image_download_timeout_secs = 7 +{extra} +"# + ) + } + + #[test] + fn config_file_precedence_validation_and_unknown_field_matrix() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("verifier.toml"); + std::fs::write(&path, valid_config("")).unwrap(); + let loaded = load_config(&config_figment(&path)).unwrap(); + assert_eq!(loaded.address, "127.0.0.1"); + assert_eq!(loaded.port, 18080); + assert_eq!(loaded.image_download_timeout_secs, 7); + + for (name, body, expected) in [ + ( + "zero-port", + valid_config("").replace("port = 18080", "port = 0"), + "port must not be zero", + ), + ( + "empty-cache", + valid_config("").replace("/tmp/dstack-verifier-config-test", ""), + "image_cache_dir must not be empty", + ), + ( + "zero-timeout", + valid_config("").replace("timeout_secs = 7", "timeout_secs = 0"), + "must be greater than zero", + ), + ( + "missing-placeholder", + valid_config("").replace("/{OS_IMAGE_HASH}.tar.gz", "/image.tar.gz"), + "must contain {OS_IMAGE_HASH}", + ), + ( + "invalid-scheme", + valid_config("").replace("http://", "file://"), + "must use http or https", + ), + ( + "unknown-field", + valid_config("unknown_policy = true"), + "unknown field", + ), + ] { + std::fs::write(&path, body).unwrap(); + let error = load_config(&config_figment(&path)).expect_err(name); + assert!(format!("{error:#}").contains(expected), "{name}: {error:#}"); + } + + std::fs::write(&path, valid_config("")).unwrap(); + assert_eq!(load_config(&config_figment(&path)).unwrap().port, 18080); + } +}