Skip to content
12 changes: 3 additions & 9 deletions tinycloud-node-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::{
#[command(name = "tinycloud", version, disable_help_subcommand = true)]
pub struct Cli {
#[command(subcommand)]
command: Option<Commands>,
command: Commands,
}

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -184,9 +184,8 @@ struct BackupArgs {
pub fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
None => block_on(run_legacy_server()),
Some(Commands::Serve(args)) => block_on(run_serve(args)),
Some(Commands::Node(args)) => run_node(args),
Commands::Serve(args) => block_on(run_serve(args)),
Commands::Node(args) => run_node(args),
}
}

Expand All @@ -198,11 +197,6 @@ fn block_on<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
.block_on(future)
}

async fn run_legacy_server() -> Result<()> {
let config_path = std::env::current_dir()?.join("tinycloud.toml");
runtime::launch_with_figment(runtime::legacy_config_figment(), config_path).await
}

async fn run_serve(args: ServeArgs) -> Result<()> {
let config_path = args
.config
Expand Down
111 changes: 92 additions & 19 deletions tinycloud-node-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use std::{
pub mod allow_list;
pub mod auth_guards;
pub mod authorization;
pub mod cli;
pub mod config;
pub mod dstack;
pub mod egress;
Expand Down Expand Up @@ -106,6 +107,7 @@ use config::{BlockStorage, Config, Keys, StagingStorage};
use hooks::HookRuntime;
use invocation_replay::InvocationReplayCache;
use node_control::control::ControlPlaneHandle;
use node_control::key_provider::{self, IdentityPurpose};
use policy_v3::{
authorize_delivery as authorize_policy_v3_delivery, challenge as policy_v3_challenge,
get_status as get_policy_v3_status, issue_enforcer_binding as policy_v3_enforcer_binding,
Expand Down Expand Up @@ -554,7 +556,28 @@ pub async fn app_with_control(
tc_bench_health,
]);

let key_setup: StaticSecret = resolve_keys(&tinycloud_config.keys).await?;
let key_setup: StaticSecret =
match resolve_keys(&tinycloud_config.keys, &tinycloud_config.tee).await? {
Some(key_setup) => key_setup,
None => {
// The node's stored identity, generated on its first start. The
// control plane only probed it at spawn, so publish what serve
// resolved.
let identity = key_provider::resolve_identity_state(
Some(&tinycloud_config.keys),
&tinycloud_config.storage.datadir,
IdentityPurpose::Serve,
)?;
if let Some(control) = control.as_ref() {
control
.set_identity_snapshot(key_provider::identity_snapshot(&identity))
.await;
}
identity
.secret
.ok_or_else(|| anyhow::anyhow!("node identity is not ready"))?
}
};
let pseudonymizer =
std::sync::Arc::new(tinycloud_core::telemetry::Pseudonymizer::new(&key_setup));
let tracing_fairing = tracing::TracingFairing::new(
Expand Down Expand Up @@ -1270,13 +1293,23 @@ async fn run_retention_sweep<C: tinycloud_core::sea_orm::ConnectionTrait>(
}
}

pub(crate) async fn resolve_keys(keys: &Keys) -> Result<StaticSecret> {
/// The node key from a Static or dstack key source. `None` means the key is
/// the node's stored identity: provider keys, and Auto outside a TEE. Where
/// the environment indicates a dstack TEE, provider keys always refuse and
/// Auto refuses when no dstack key is available, so a TEE node never runs
/// under a local DID. For Auto, `TINYCLOUD_TEE_MODE=off` is checked first and
/// opts out of TEE detection.
pub(crate) async fn resolve_keys(
keys: &Keys,
tee: &config::TeeConfig,
) -> Result<Option<StaticSecret>> {
match keys {
Keys::Static(s) => Ok(s.clone().try_into()?),
Keys::Static(s) => Ok(Some(s.clone().try_into()?)),
#[cfg(feature = "dstack")]
Keys::Dstack => {
let key_bytes = dstack::get_key("tinycloud/keys/primary").await?;
StaticSecret::new(key_bytes)
.map(Some)
.map_err(|v| anyhow::anyhow!("dstack key too short: {} bytes", v.len()))
}
Keys::Auto => {
Expand All @@ -1286,36 +1319,76 @@ pub(crate) async fn resolve_keys(keys: &Keys) -> Result<StaticSecret> {
Some("dstack") => {
let key_bytes = dstack::get_key("tinycloud/keys/primary").await?;
StaticSecret::new(key_bytes)
.map(Some)
.map_err(|v| anyhow::anyhow!("dstack key too short: {} bytes", v.len()))
}
Some("off") => {
anyhow::bail!(
"TEE mode disabled but no static key configured. \
Set TINYCLOUD_KEYS_SECRET or configure [keys] in config."
)
}
Some("off") => Ok(None),
_ => {
// Auto-detect: check for dstack socket
#[cfg(feature = "dstack")]
if dstack::is_available() {
::tracing::info!("dstack socket detected, using TEE key derivation");
let key_bytes = dstack::get_key("tinycloud/keys/primary").await?;
return StaticSecret::new(key_bytes).map_err(|v| {
return StaticSecret::new(key_bytes).map(Some).map_err(|v| {
anyhow::anyhow!("dstack key too short: {} bytes", v.len())
});
}
anyhow::bail!(
"No key source configured. Either:\n \
- Set TINYCLOUD_KEYS_SECRET environment variable\n \
- Configure [keys] section in tinycloud.toml\n \
- Run inside a dstack TEE (with 'dstack' feature enabled)"
)
if let Some(hint) = dstack_indication(tee) {
anyhow::bail!(
"refusing to create a new node identity: {hint} indicates a dstack TEE, \
but no dstack key is available (socket {}{}). Start the node with its \
dstack socket, or remove {hint} if this host is not a TEE.",
dstack::socket_path(),
if cfg!(feature = "dstack") {
""
} else {
"; this build has no dstack support"
},
);
}
Ok(None)
}
}
}
Keys::Provider => anyhow::bail!(
"provider key mode must be resolved through the node control key-provider path"
),
Keys::Provider => {
if let Some(hint) = dstack_indication(tee) {
anyhow::bail!(
"refusing keys.type = provider: {hint} indicates a dstack TEE, where the node \
key must come from dstack. Use keys.type = Dstack, or remove {hint} if this \
host is not a TEE."
);
}
Ok(None)
}
}
}

/// Why the environment looks like a dstack deployment, if it does: the TEE
/// mode the node reads, the key type dstack deployments set, an explicit
/// dstack socket, or a dstack socket this build cannot use. Variable names
/// match case-insensitively, as figment's `TINYCLOUD_` prefix does.
fn dstack_indication(tee: &config::TeeConfig) -> Option<&'static str> {
let env_is_dstack = |name: &str| {
std::env::vars_os().any(|(key, value)| {
key.to_str()
.is_some_and(|key| key.eq_ignore_ascii_case(name))
&& value
.to_str()
.is_some_and(|value| value.eq_ignore_ascii_case("dstack"))
})
};
if env_is_dstack("TINYCLOUD_TEE_MODE") {
Some("TINYCLOUD_TEE_MODE=dstack")
} else if tee.mode == config::TeeMode::Dstack {
Some("tee.mode = \"Dstack\"")
} else if env_is_dstack("TINYCLOUD_KEYS_TYPE") || env_is_dstack("TINYCLOUD_KEYS__TYPE") {
Some("TINYCLOUD_KEYS_TYPE=Dstack")
} else if std::env::var_os("DSTACK_SIMULATOR_ENDPOINT").is_some() {
Some("DSTACK_SIMULATOR_ENDPOINT")
} else if dstack::is_available() {
Some("the dstack socket")
} else {
None
}
}

Expand Down
32 changes: 30 additions & 2 deletions tinycloud-node-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,37 @@ fn install_panic_hook() {
}));
}

#[rocket::main]
async fn main() {
/// The server start path is only for the two invocations that exist outside
/// the CLI: no arguments (the container entrypoint, `cargo run`) and exactly
/// `--validate-config` (the deploy preflight). Every other argv goes to clap,
/// which also rejects anything it does not recognise instead of booting.
fn starts_server(args: &[std::ffi::OsString]) -> bool {
match args {
[] => true,
[only] => only == "--validate-config",
_ => false,
}
}

fn main() {
install_panic_hook();
let args: Vec<_> = std::env::args_os().skip(1).collect();
if !starts_server(&args) {
if let Err(e) = tinycloud::cli::run() {
eprintln!("\n✗ tinycloud-node failed:\n");
for cause in e.chain() {
eprintln!(" {cause}");
}
std::process::exit(1);
}
return;
}
// The runtime `#[rocket::main]` used to set up: worker and blocking
// thread counts come from ROCKET_WORKERS / ROCKET_MAX_BLOCKING.
rocket::execute(start_server());
}

async fn start_server() {
let config = build_config_figment(); // That's just for easy access to ROCKET_LOG_LEVEL
let tinycloud_config = config.extract::<config::Config>().unwrap();

Expand Down
18 changes: 14 additions & 4 deletions tinycloud-node-server/src/node_control/key_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ pub struct BackupResult {

pub trait KeyProvider: Send + Sync {
fn backend(&self) -> KeyBackend;
/// Where the identity is stored, for operators. Never key material.
fn location(&self) -> String;
fn load_secret(&self) -> Result<Option<Vec<u8>>>;
fn store_secret(&self, secret: &[u8]) -> Result<()>;
fn delete_secret(&self) -> Result<()>;
Expand Down Expand Up @@ -417,10 +419,10 @@ fn resolve_provider_state(data_root: &Path, purpose: IdentityPurpose) -> Result<
let secret = StaticSecret::new(secret.to_vec())
.expect("generated identity secret is always 32 bytes");
let node_did = secret.node_did();
tracing::info!(
backend = ?backend,
data_path = %data_root.display(),
"generated node identity secret on first run"
// Once per identity: a restart loads it and logs nothing here.
tracing::warn!(
"created a new node identity at {} ({node_did})",
provider.location()
);
Ok(identity_state(
IdentitySourceKind::Provider,
Expand Down Expand Up @@ -702,6 +704,10 @@ impl KeyProvider for EncryptedFileProvider {
KeyBackend::EncryptedFile
}

fn location(&self) -> String {
self.identity_path.display().to_string()
}

fn load_secret(&self) -> Result<Option<Vec<u8>>> {
if !self.identity_path.exists() {
return Ok(None);
Expand Down Expand Up @@ -840,6 +846,10 @@ impl KeyProvider for MacosKeychainProvider {
KeyBackend::MacosKeychain
}

fn location(&self) -> String {
format!("macOS keychain item {} / {}", self.service, self.account)
}

fn load_secret(&self) -> Result<Option<Vec<u8>>> {
match self.load_secret_in_tier(MacosKeychainTier::DataProtection)? {
MacosLookupOutcome::Found(secret) => return validate_loaded_secret(secret),
Expand Down
10 changes: 7 additions & 3 deletions tinycloud-node-server/src/routes/node_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,13 @@ mod tests {
socket.display().to_string(),
);

let resolved = crate::resolve_keys(&crate::config::Keys::Dstack)
.await
.expect("dstack key resolution");
let resolved = crate::resolve_keys(
&crate::config::Keys::Dstack,
&crate::config::TeeConfig::default(),
)
.await
.expect("dstack key resolution")
.expect("dstack yields a key");
responder.await.expect("dstack responder");

let rocket = rocket::build()
Expand Down
Loading
Loading