cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/architecture-review-findings
goq / crates / sigil-host / src / appliance.rs
appliance.rs 44.4K
1use std::fs;2use std::path::{Path, PathBuf};3use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};4use std::sync::{Arc, Mutex};5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};67use anyhow::{Context, Result, ensure};8use iroh::EndpointId;9use serde::{Deserialize, Serialize};10use sigil_protocol::InvitationGrants;1112use crate::authorization::{AuthorizationInspection, AuthorizationStore};13use crate::config::{ConfigRevision, HostConfig};14use crate::config_management::ConfigTransactionSummaryV1;15use crate::secure_state;16use crate::server::SessionRegistry;1718const LIFECYCLE_LOCK_FILE: &str = "daemon-v1.lock";19const GLOBAL_LIFECYCLE_LOCK_FILE: &str = "daemon-global-v1.lock";20const RUNTIME_STATUS_FILE: &str = "daemon-status-v1.json";21// Frozen local-management ABI consumed by Sigil commands and the Decky22// plugin. A successor needs an explicit migration; never rename this in place.23const RUNTIME_DIRECTORY_COMPONENT: &str = "sigil-spark";24const RUNTIME_STATUS_VERSION: u16 = 2;25const MAX_RUNTIME_STATUS_BYTES: u64 = 16 * 1024;26const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);27const STALE_AFTER: Duration = Duration::from_secs(10);28const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_secs(5);2930const DAEMON_STARTING: u8 = 0;31const DAEMON_READY: u8 = 1;32const DAEMON_STOPPING: u8 = 2;33const DAEMON_DEGRADED: u8 = 3;34const DAEMON_STOPPED: u8 = 4;3536pub struct LifecycleGuard {37 _files: Vec<fs::File>,38}3940impl LifecycleGuard {41 pub fn acquire(state_directory: &Path, require_global: bool) -> Result<Self> {42 Self::try_acquire(state_directory, require_global).map_err(anyhow::Error::new)43 }4445 pub(crate) fn try_acquire(46 state_directory: &Path,47 require_global: bool,48 ) -> std::result::Result<Self, secure_state::LockAcquireError> {49 let runtime_directory =50 configured_runtime_directory().map_err(secure_state::LockAcquireError::Unsafe)?;51 if require_global && runtime_directory.is_none() {52 return Err(secure_state::LockAcquireError::Unsafe(anyhow::anyhow!(53 "configured Sigil service requires a valid XDG_RUNTIME_DIR"54 )));55 }56 Self::try_acquire_at(state_directory, runtime_directory.as_deref())57 }5859 #[cfg(test)]60 fn acquire_at(state_directory: &Path, runtime_directory: Option<&Path>) -> Result<Self> {61 Self::try_acquire_at(state_directory, runtime_directory).map_err(anyhow::Error::new)62 }6364 pub(crate) fn try_acquire_at(65 state_directory: &Path,66 runtime_directory: Option<&Path>,67 ) -> std::result::Result<Self, secure_state::LockAcquireError> {68 let mut files = vec![secure_state::try_open_lifetime_lock(69 state_directory,70 LIFECYCLE_LOCK_FILE,71 )?];72 if let Some(runtime_directory) = runtime_directory {73 secure_state::ensure_private_directory(runtime_directory)74 .map_err(secure_state::LockAcquireError::Unsafe)?;75 files.push(secure_state::try_open_lifetime_lock(76 runtime_directory,77 GLOBAL_LIFECYCLE_LOCK_FILE,78 )?);79 }80 Ok(Self { _files: files })81 }82}8384#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]85#[serde(rename_all = "snake_case")]86enum DaemonState {87 Starting,88 Ready,89 Stopping,90 Degraded,91 Stopped,92 Offline,93 Unknown,94}9596#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]97#[serde(rename_all = "snake_case")]98enum RuntimeState {99 Fresh,100 Absent,101 Stale,102 Unavailable,103}104105#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]106#[serde(rename_all = "snake_case")]107enum SessionState {108 Active,109 Inactive,110 Unknown,111}112113#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]114#[serde(rename_all = "snake_case")]115enum OverallState {116 Ready,117 Active,118 Degraded,119 Unavailable,120}121122#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]123#[serde(rename_all = "snake_case")]124pub enum RuntimeErrorCode {125 AuthorizationState,126 Preflight,127 EndpointBind,128 RouterShutdown,129 ShutdownSignal,130}131132#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]133#[serde(deny_unknown_fields)]134pub struct RuntimeErrorV1 {135 code: RuntimeErrorCode,136 occurred_at_unix_ms: u64,137}138139#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]140#[serde(deny_unknown_fields)]141struct RuntimeStatusV1 {142 version: u16,143 instance_id: String,144 host_node_id: String,145 updated_at_unix_ms: u64,146 uptime_ms: u64,147 daemon: DaemonState,148 session_active: bool,149 last_error: Option<RuntimeErrorV1>,150}151152#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]153#[serde(deny_unknown_fields)]154struct RuntimeStatusV2 {155 version: u16,156 instance_id: String,157 host_node_id: String,158 loaded_config_revision: Option<ConfigRevision>,159 reached_ready: bool,160 updated_at_unix_ms: u64,161 uptime_ms: u64,162 daemon: DaemonState,163 session_active: bool,164 last_error: Option<RuntimeErrorV1>,165}166167#[derive(Debug, Serialize)]168pub struct ApplianceStatusV2 {169 schema_version: u16,170 sigil_version: &'static str,171 overall: OverallState,172 config: ConfigStatus,173 identity: IdentityStatus,174 enrollment: EnrollmentStatus,175 runtime: RuntimeStatus,176}177178#[derive(Debug, Serialize)]179pub struct EnrollmentResetV1 {180 schema_version: u16,181 operation: &'static str,182 host_fingerprint: String,183 had_enrollment: bool,184 previous_epoch: u64,185 current_epoch: u64,186 invitations_invalidated: bool,187}188189#[derive(Debug, Serialize)]190struct ConfigStatus {191 state: &'static str,192 revision: ConfigRevision,193 pending_transaction: Option<ConfigTransactionSummaryV1>,194}195196#[derive(Debug, Serialize)]197struct IdentityStatus {198 state: &'static str,199 host_fingerprint: String,200}201202#[derive(Debug, Serialize)]203struct EnrollmentStatus {204 state: &'static str,205 storage: &'static str,206 peer_fingerprint: Option<String>,207 grants: Vec<&'static str>,208 epoch: u64,209 enrolled_at_unix: Option<u64>,210}211212#[derive(Debug, Serialize)]213struct RuntimeStatus {214 state: RuntimeState,215 daemon: DaemonState,216 uptime_ms: Option<u64>,217 heartbeat_age_ms: Option<u64>,218 session: SessionState,219 instance_id: Option<String>,220 loaded_config_revision: Option<ConfigRevision>,221 reached_ready: bool,222 last_error: Option<RuntimeErrorV1>,223}224225pub struct RuntimePublisher {226 state_directory: PathBuf,227 instance_id: String,228 host: EndpointId,229 loaded_config_revision: Option<ConfigRevision>,230 started: Instant,231 daemon_state: Arc<AtomicU8>,232 reached_ready: Arc<AtomicBool>,233 last_error: Arc<Mutex<Option<RuntimeErrorV1>>>,234 sessions: Arc<SessionRegistry>,235 write_lock: Arc<tokio::sync::Mutex<()>>,236 task: Option<tokio::task::JoinHandle<()>>,237}238239impl RuntimePublisher {240 pub fn start(241 host: EndpointId,242 sessions: Arc<SessionRegistry>,243 require_runtime: bool,244 loaded_config_revision: Option<ConfigRevision>,245 ) -> Result<Option<Self>> {246 let Some(state_directory) = configured_runtime_directory()? else {247 ensure!(248 !require_runtime,249 "configured Sigil service requires a valid XDG_RUNTIME_DIR"250 );251 tracing::warn!("XDG_RUNTIME_DIR is unavailable; appliance heartbeat is disabled");252 return Ok(None);253 };254 secure_state::ensure_private_directory(&state_directory)?;255 ensure!(256 !require_runtime || loaded_config_revision.is_some(),257 "configured Sigil service requires a loaded config revision"258 );259 Self::start_at(&state_directory, host, sessions, loaded_config_revision).map(Some)260 }261262 fn start_at(263 state_directory: &Path,264 host: EndpointId,265 sessions: Arc<SessionRegistry>,266 loaded_config_revision: Option<ConfigRevision>,267 ) -> Result<Self> {268 let instance_id = random_instance_id()?;269 let daemon_state = Arc::new(AtomicU8::new(DAEMON_STARTING));270 let reached_ready = Arc::new(AtomicBool::new(false));271 let last_error = Arc::new(Mutex::new(None));272 let write_lock = Arc::new(tokio::sync::Mutex::new(()));273 let mut publisher = Self {274 state_directory: state_directory.to_path_buf(),275 instance_id,276 host,277 loaded_config_revision,278 started: Instant::now(),279 daemon_state,280 reached_ready,281 last_error,282 sessions,283 write_lock,284 task: None,285 };286 publisher.write_snapshot()?;287288 let state_directory = publisher.state_directory.clone();289 let instance_id = publisher.instance_id.clone();290 let host = publisher.host;291 let loaded_config_revision = publisher.loaded_config_revision.clone();292 let started = publisher.started;293 let daemon_state = Arc::clone(&publisher.daemon_state);294 let reached_ready = Arc::clone(&publisher.reached_ready);295 let last_error = Arc::clone(&publisher.last_error);296 let sessions = Arc::clone(&publisher.sessions);297 let write_lock = Arc::clone(&publisher.write_lock);298 let task = tokio::spawn(async move {299 let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);300 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);301 interval.tick().await;302 loop {303 interval.tick().await;304 let _write_guard = write_lock.lock().await;305 let state_directory = state_directory.clone();306 let instance_id = instance_id.clone();307 let loaded_config_revision = loaded_config_revision.clone();308 let daemon = decode_daemon_state(daemon_state.load(Ordering::Relaxed));309 let reached_ready = reached_ready.load(Ordering::Relaxed);310 let session_active = sessions.has_session();311 let last_error = last_error312 .lock()313 .expect("runtime status error state poisoned")314 .clone();315 match tokio::task::spawn_blocking(move || {316 write_runtime_snapshot(RuntimeSnapshotInput {317 state_directory,318 instance_id,319 host,320 loaded_config_revision,321 started,322 daemon,323 reached_ready,324 session_active,325 last_error,326 })327 })328 .await329 {330 Ok(Ok(())) => {}331 Ok(Err(error)) => {332 tracing::warn!(%error, "writing bounded Sigil runtime status failed");333 }334 Err(error) => {335 tracing::warn!(%error, "Sigil runtime status writer task failed");336 break;337 }338 }339 }340 });341 publisher.task = Some(task);342 Ok(publisher)343 }344345 pub async fn mark_ready(&self) -> Result<()> {346 self.reached_ready.store(true, Ordering::Relaxed);347 self.daemon_state.store(DAEMON_READY, Ordering::Relaxed);348 self.write_snapshot_async().await349 }350351 pub async fn mark_stopping(&self) -> Result<()> {352 self.daemon_state.store(DAEMON_STOPPING, Ordering::Relaxed);353 self.write_snapshot_async().await354 }355356 pub async fn mark_degraded(&self, code: RuntimeErrorCode) -> Result<()> {357 self.daemon_state.store(DAEMON_DEGRADED, Ordering::Relaxed);358 *self359 .last_error360 .lock()361 .expect("runtime status error state poisoned") = Some(RuntimeErrorV1 {362 code,363 occurred_at_unix_ms: unix_timestamp_millis()?,364 });365 self.write_snapshot_async().await366 }367368 pub async fn finish_clean(mut self) -> Result<()> {369 let write_lock = Arc::clone(&self.write_lock);370 let _write_guard = write_lock.lock().await;371 if let Some(task) = self.task.take() {372 task.abort();373 let _ = task.await;374 }375 self.daemon_state.store(DAEMON_STOPPED, Ordering::Relaxed);376 let state_directory = self.state_directory.clone();377 let instance_id = self.instance_id.clone();378 let host = self.host;379 let loaded_config_revision = self.loaded_config_revision.clone();380 let started = self.started;381 let reached_ready = self.reached_ready.load(Ordering::Relaxed);382 let session_active = self.sessions.has_session();383 let last_error = self384 .last_error385 .lock()386 .expect("runtime status error state poisoned")387 .clone();388 tokio::task::spawn_blocking(move || {389 write_runtime_snapshot(RuntimeSnapshotInput {390 state_directory,391 instance_id,392 host,393 loaded_config_revision,394 started,395 daemon: DaemonState::Stopped,396 reached_ready,397 session_active,398 last_error,399 })400 })401 .await402 .context("joining stopped runtime status write")?403 }404405 fn write_snapshot(&self) -> Result<()> {406 write_runtime_snapshot(RuntimeSnapshotInput {407 state_directory: self.state_directory.clone(),408 instance_id: self.instance_id.clone(),409 host: self.host,410 loaded_config_revision: self.loaded_config_revision.clone(),411 started: self.started,412 daemon: decode_daemon_state(self.daemon_state.load(Ordering::Relaxed)),413 reached_ready: self.reached_ready.load(Ordering::Relaxed),414 session_active: self.sessions.has_session(),415 last_error: self416 .last_error417 .lock()418 .expect("runtime status error state poisoned")419 .clone(),420 })421 }422423 async fn write_snapshot_async(&self) -> Result<()> {424 let write_lock = Arc::clone(&self.write_lock);425 let _write_guard = write_lock.lock().await;426 let state_directory = self.state_directory.clone();427 let instance_id = self.instance_id.clone();428 let host = self.host;429 let loaded_config_revision = self.loaded_config_revision.clone();430 let started = self.started;431 let daemon = decode_daemon_state(self.daemon_state.load(Ordering::Relaxed));432 let reached_ready = self.reached_ready.load(Ordering::Relaxed);433 let session_active = self.sessions.has_session();434 let last_error = self435 .last_error436 .lock()437 .expect("runtime status error state poisoned")438 .clone();439 tokio::task::spawn_blocking(move || {440 write_runtime_snapshot(RuntimeSnapshotInput {441 state_directory,442 instance_id,443 host,444 loaded_config_revision,445 started,446 daemon,447 reached_ready,448 session_active,449 last_error,450 })451 })452 .await453 .context("joining runtime status write")?454 }455}456457impl Drop for RuntimePublisher {458 fn drop(&mut self) {459 if let Some(task) = self.task.take() {460 task.abort();461 }462 }463}464465fn configured_runtime_directory() -> Result<Option<PathBuf>> {466 let Some(root) = std::env::var_os("XDG_RUNTIME_DIR") else {467 return Ok(None);468 };469 let root = PathBuf::from(root);470 ensure!(root.is_absolute(), "XDG_RUNTIME_DIR must be absolute");471 secure_state::validate_private_directory(&root)472 .context("validating XDG_RUNTIME_DIR for Sigil appliance status")?;473 Ok(Some(root.join(RUNTIME_DIRECTORY_COMPONENT)))474}475476pub(crate) fn resolve_management_runtime_directory(477 explicit_root: Option<&Path>,478) -> Result<PathBuf> {479 if let Some(root) = explicit_root {480 ensure!(root.is_absolute(), "--runtime-dir must be absolute");481 secure_state::validate_private_directory(root)482 .context("validating --runtime-dir for Sigil appliance management")?;483 #[cfg(unix)]484 {485 use std::os::unix::fs::PermissionsExt as _;486 ensure!(487 fs::symlink_metadata(root)?.permissions().mode() & 0o7777 == 0o700,488 "--runtime-dir must have mode 0700"489 );490 }491 return Ok(root.join(RUNTIME_DIRECTORY_COMPONENT));492 }493 configured_runtime_directory()?494 .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")495}496497pub fn collect_status(config_path: &Path) -> Result<ApplianceStatusV2> {498 let loaded = HostConfig::load_document(config_path)?;499 let config = &loaded.config;500 let secret = crate::identity::load(&config.identity_path)?;501 let host = secret.public();502 let authorization = AuthorizationStore::inspect_existing(&config.state_path, host)?;503 let pending_transaction = crate::config_management::inspect_transaction(&loaded, config_path)504 .map_err(anyhow::Error::from)?;505 let runtime = match configured_runtime_directory()? {506 Some(runtime_directory) => {507 read_runtime_status(&runtime_directory, host, unix_timestamp_millis()?)?508 }509 None => unavailable_runtime(),510 };511 Ok(assemble_status(512 host,513 authorization,514 runtime,515 loaded.revision,516 pending_transaction,517 ))518}519520pub fn status_json(status: &ApplianceStatusV2, schema_version: u16) -> Result<serde_json::Value> {521 let mut value = serde_json::to_value(status)?;522 match schema_version {523 1 => {524 value["schema_version"] = serde_json::json!(1);525 let config = value["config"]526 .as_object_mut()527 .context("appliance config status is not an object")?;528 config.remove("revision");529 config.remove("pending_transaction");530 let runtime = value["runtime"]531 .as_object_mut()532 .context("appliance runtime status is not an object")?;533 runtime.remove("instance_id");534 runtime.remove("loaded_config_revision");535 runtime.remove("reached_ready");536 }537 2 => {}538 _ => anyhow::bail!("unsupported appliance status schema"),539 }540 Ok(value)541}542543pub fn reset_enrollment(544 config_path: &Path,545 expected_host_fingerprint: &str,546) -> Result<EnrollmentResetV1> {547 let config = HostConfig::load(config_path)?;548 config.ensure_runtime_directory()?;549 let _lifecycle = LifecycleGuard::acquire(&config.state_path, false)?;550 let secret = crate::identity::load(&config.identity_path)?;551 let host = secret.public();552 let host_fingerprint = fingerprint(host);553 ensure!(554 expected_host_fingerprint == host_fingerprint,555 "expected host fingerprint does not match this Sigil appliance"556 );557558 let store = AuthorizationStore::open(config.state_path, host)?;559 let outcome = store.revoke_with_outcome(crate::authorization::unix_timestamp_now()?)?;560 ensure!(561 outcome.current_epoch562 == outcome563 .previous_epoch564 .checked_add(1)565 .context("authorization epoch exhausted")?,566 "enrollment reset did not advance the authorization epoch"567 );568569 Ok(EnrollmentResetV1 {570 schema_version: 1,571 operation: "enrollment_reset",572 host_fingerprint,573 had_enrollment: outcome.had_enrollment,574 previous_epoch: outcome.previous_epoch,575 current_epoch: outcome.current_epoch,576 invitations_invalidated: true,577 })578}579580fn assemble_status(581 host: EndpointId,582 authorization: AuthorizationInspection,583 runtime: RuntimeStatus,584 config_revision: ConfigRevision,585 pending_transaction: Option<ConfigTransactionSummaryV1>,586) -> ApplianceStatusV2 {587 let snapshot = authorization.snapshot;588 let grants = snapshot.grants.map(grant_list).unwrap_or_default();589 let enrollment_active = snapshot.peer.is_some();590 let overall = match (runtime.daemon, runtime.session) {591 (DaemonState::Ready, SessionState::Active) => OverallState::Active,592 (DaemonState::Ready, _) => OverallState::Ready,593 (DaemonState::Degraded, _) => OverallState::Degraded,594 _ => OverallState::Unavailable,595 };596 ApplianceStatusV2 {597 schema_version: 2,598 sigil_version: env!("CARGO_PKG_VERSION"),599 overall,600 config: ConfigStatus {601 state: "valid",602 revision: config_revision,603 pending_transaction,604 },605 identity: IdentityStatus {606 state: "ready",607 host_fingerprint: fingerprint(host),608 },609 enrollment: EnrollmentStatus {610 state: if enrollment_active { "active" } else { "none" },611 storage: if authorization.storage_present {612 "present"613 } else {614 "absent"615 },616 peer_fingerprint: snapshot.peer.map(fingerprint),617 grants,618 epoch: snapshot.epoch,619 enrolled_at_unix: snapshot.enrolled_at_unix,620 },621 runtime,622 }623}624625fn read_runtime_status(626 state_directory: &Path,627 expected_host: EndpointId,628 now_unix_ms: u64,629) -> Result<RuntimeStatus> {630 match fs::symlink_metadata(state_directory) {631 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {632 return Ok(absent_runtime());633 }634 Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),635 Ok(_) => {}636 }637 let Some(persisted) = read_persisted_runtime(state_directory)? else {638 return Ok(absent_runtime());639 };640 ensure!(641 persisted.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),642 "Sigil runtime status belongs to a different host identity"643 );644 ensure!(645 persisted.instance_id.len() == 32646 && persisted647 .instance_id648 .bytes()649 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),650 "invalid Sigil runtime instance ID"651 );652 if let Some(revision) = &persisted.loaded_config_revision {653 ConfigRevision::parse(revision.as_str())?;654 }655 if let Some(error) = &persisted.last_error {656 ensure!(657 error.occurred_at_unix_ms <= persisted.updated_at_unix_ms,658 "Sigil runtime error timestamp follows its status update"659 );660 }661 let age_ms = match now_unix_ms.checked_sub(persisted.updated_at_unix_ms) {662 Some(age_ms) => age_ms,663 None if persisted.updated_at_unix_ms - now_unix_ms664 <= CLOCK_SKEW_TOLERANCE.as_millis() as u64 =>665 {666 0667 }668 None => return Ok(stale_runtime(persisted, None)),669 };670 if age_ms > STALE_AFTER.as_millis() as u64 {671 return Ok(stale_runtime(persisted, Some(age_ms)));672 }673 ensure!(674 !matches!(675 persisted.daemon,676 DaemonState::Offline | DaemonState::Unknown677 ),678 "runtime publisher wrote an invalid daemon state"679 );680 Ok(RuntimeStatus {681 state: RuntimeState::Fresh,682 daemon: persisted.daemon,683 uptime_ms: (!matches!(persisted.daemon, DaemonState::Stopped))684 .then_some(persisted.uptime_ms.saturating_add(age_ms)),685 heartbeat_age_ms: Some(age_ms),686 session: if persisted.daemon == DaemonState::Stopped {687 SessionState::Inactive688 } else if persisted.session_active {689 SessionState::Active690 } else {691 SessionState::Inactive692 },693 instance_id: Some(persisted.instance_id),694 loaded_config_revision: persisted.loaded_config_revision,695 reached_ready: persisted.reached_ready,696 last_error: persisted.last_error,697 })698}699700fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatusV2>> {701 let Some(bytes) = secure_state::read_bounded(702 state_directory,703 RUNTIME_STATUS_FILE,704 MAX_RUNTIME_STATUS_BYTES,705 )?706 else {707 return Ok(None);708 };709 let value: serde_json::Value =710 serde_json::from_slice(&bytes).context("parsing Sigil runtime status")?;711 let version = value712 .get("version")713 .and_then(serde_json::Value::as_u64)714 .context("Sigil runtime status version is missing")?;715 match version {716 1 => {717 let legacy: RuntimeStatusV1 =718 serde_json::from_value(value).context("parsing legacy Sigil runtime status")?;719 Ok(Some(RuntimeStatusV2 {720 version: legacy.version,721 instance_id: legacy.instance_id,722 host_node_id: legacy.host_node_id,723 loaded_config_revision: None,724 reached_ready: false,725 updated_at_unix_ms: legacy.updated_at_unix_ms,726 uptime_ms: legacy.uptime_ms,727 daemon: legacy.daemon,728 session_active: legacy.session_active,729 last_error: legacy.last_error,730 }))731 }732 2 => {733 let current: RuntimeStatusV2 =734 serde_json::from_value(value).context("parsing Sigil runtime status v2")?;735 ensure!(736 current.version == RUNTIME_STATUS_VERSION,737 "unsupported Sigil runtime status version"738 );739 Ok(Some(current))740 }741 _ => anyhow::bail!("unsupported Sigil runtime status version"),742 }743}744745pub(crate) fn latest_runtime_instance(746 state_directory: &Path,747 expected_host: EndpointId,748) -> Result<Option<String>> {749 match fs::symlink_metadata(state_directory) {750 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),751 Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),752 Ok(_) => {}753 }754 let Some(persisted) = read_persisted_runtime(state_directory)? else {755 return Ok(None);756 };757 ensure!(758 persisted.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),759 "Sigil runtime status belongs to a different host identity"760 );761 ensure!(762 persisted.instance_id.len() == 32763 && persisted764 .instance_id765 .bytes()766 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),767 "invalid Sigil runtime instance ID"768 );769 Ok(Some(persisted.instance_id))770}771772fn stale_runtime(persisted: RuntimeStatusV2, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {773 let stopped = persisted.daemon == DaemonState::Stopped;774 RuntimeStatus {775 state: RuntimeState::Stale,776 daemon: if stopped {777 DaemonState::Stopped778 } else {779 DaemonState::Unknown780 },781 uptime_ms: None,782 heartbeat_age_ms,783 session: if stopped {784 SessionState::Inactive785 } else {786 SessionState::Unknown787 },788 instance_id: None,789 loaded_config_revision: None,790 reached_ready: false,791 last_error: persisted.last_error,792 }793}794795fn absent_runtime() -> RuntimeStatus {796 RuntimeStatus {797 state: RuntimeState::Absent,798 daemon: DaemonState::Offline,799 uptime_ms: None,800 heartbeat_age_ms: None,801 session: SessionState::Unknown,802 instance_id: None,803 loaded_config_revision: None,804 reached_ready: false,805 last_error: None,806 }807}808809fn unavailable_runtime() -> RuntimeStatus {810 RuntimeStatus {811 state: RuntimeState::Unavailable,812 daemon: DaemonState::Unknown,813 uptime_ms: None,814 heartbeat_age_ms: None,815 session: SessionState::Unknown,816 instance_id: None,817 loaded_config_revision: None,818 reached_ready: false,819 last_error: None,820 }821}822823pub(crate) fn validate_candidate_runtime(824 state_directory: &Path,825 expected_host: EndpointId,826 expected_revision: &ConfigRevision,827 expected_instance: &str,828) -> Result<()> {829 validate_candidate_runtime_at(830 state_directory,831 expected_host,832 expected_revision,833 expected_instance,834 unix_timestamp_millis()?,835 )836}837838fn validate_candidate_runtime_at(839 state_directory: &Path,840 expected_host: EndpointId,841 expected_revision: &ConfigRevision,842 expected_instance: &str,843 now_unix_ms: u64,844) -> Result<()> {845 let status = read_runtime_status(state_directory, expected_host, now_unix_ms)?;846 ensure!(847 status.state == RuntimeState::Fresh,848 "runtime evidence is not fresh"849 );850 ensure!(851 status.daemon == DaemonState::Stopped,852 "candidate daemon has not stopped cleanly"853 );854 ensure!(855 status.instance_id.as_deref() == Some(expected_instance),856 "runtime evidence belongs to a different daemon instance"857 );858 ensure!(859 status.loaded_config_revision.as_ref() == Some(expected_revision),860 "runtime evidence belongs to a different configuration revision"861 );862 ensure!(status.reached_ready, "candidate daemon never reached ready");863 ensure!(864 status.last_error.is_none(),865 "candidate daemon reported an error"866 );867 Ok(())868}869870struct RuntimeSnapshotInput {871 state_directory: PathBuf,872 instance_id: String,873 host: EndpointId,874 loaded_config_revision: Option<ConfigRevision>,875 started: Instant,876 daemon: DaemonState,877 reached_ready: bool,878 session_active: bool,879 last_error: Option<RuntimeErrorV1>,880}881882fn write_runtime_snapshot(input: RuntimeSnapshotInput) -> Result<()> {883 let status = RuntimeStatusV2 {884 version: RUNTIME_STATUS_VERSION,885 instance_id: input.instance_id,886 host_node_id: input.host.to_string(),887 loaded_config_revision: input.loaded_config_revision,888 reached_ready: input.reached_ready,889 updated_at_unix_ms: unix_timestamp_millis()?,890 uptime_ms: u64::try_from(input.started.elapsed().as_millis()).unwrap_or(u64::MAX),891 daemon: input.daemon,892 session_active: input.session_active,893 last_error: input.last_error,894 };895 let bytes = serde_json::to_vec(&status)?;896 secure_state::atomic_write(897 &input.state_directory,898 RUNTIME_STATUS_FILE,899 &bytes,900 MAX_RUNTIME_STATUS_BYTES,901 )902}903904fn decode_daemon_state(value: u8) -> DaemonState {905 match value {906 DAEMON_READY => DaemonState::Ready,907 DAEMON_STOPPING => DaemonState::Stopping,908 DAEMON_DEGRADED => DaemonState::Degraded,909 DAEMON_STOPPED => DaemonState::Stopped,910 _ => DaemonState::Starting,911 }912}913914fn random_instance_id() -> Result<String> {915 let mut bytes = [0_u8; 16];916 getrandom::fill(&mut bytes).context("generating runtime instance ID")?;917 let mut output = String::with_capacity(32);918 for byte in bytes {919 use std::fmt::Write as _;920 write!(&mut output, "{byte:02x}").expect("writing into a String cannot fail");921 }922 Ok(output)923}924925fn unix_timestamp_millis() -> Result<u64> {926 Ok(u64::try_from(927 SystemTime::now()928 .duration_since(UNIX_EPOCH)929 .context("system clock is before the Unix epoch")?930 .as_millis(),931 )932 .unwrap_or(u64::MAX))933}934935pub(crate) fn fingerprint(id: EndpointId) -> String {936 let full = id.to_string();937 debug_assert_eq!(full.len(), 64);938 format!("{}…{}", &full[..8], &full[full.len() - 8..])939}940941fn grant_list(grants: InvitationGrants) -> Vec<&'static str> {942 let mut names = vec!["view"];943 if grants.contains(InvitationGrants::POINTER_KEYBOARD) {944 names.push("pointer_keyboard");945 }946 if grants.contains(InvitationGrants::GAMEPAD) {947 names.push("gamepad");948 }949 names950}951952#[cfg(test)]953mod tests {954 use super::*;955 use iroh::SecretKey;956957 #[cfg(unix)]958 use std::os::unix::fs::PermissionsExt;959960 fn private_directory() -> tempfile::TempDir {961 let directory = tempfile::tempdir().unwrap();962 #[cfg(unix)]963 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();964 directory965 }966967 fn runtime_document(updated_at_unix_ms: u64) -> RuntimeStatusV1 {968 let host = SecretKey::from_bytes(&[7; 32]).public();969 RuntimeStatusV1 {970 version: 1,971 instance_id: "0123456789abcdef0123456789abcdef".to_owned(),972 host_node_id: host.to_string(),973 updated_at_unix_ms,974 uptime_ms: 1_000,975 daemon: DaemonState::Ready,976 session_active: true,977 last_error: None,978 }979 }980981 fn current_runtime_document(982 updated_at_unix_ms: u64,983 revision: ConfigRevision,984 ) -> RuntimeStatusV2 {985 let host = SecretKey::from_bytes(&[7; 32]).public();986 RuntimeStatusV2 {987 version: RUNTIME_STATUS_VERSION,988 instance_id: "0123456789abcdef0123456789abcdef".to_owned(),989 host_node_id: host.to_string(),990 loaded_config_revision: Some(revision),991 reached_ready: true,992 updated_at_unix_ms,993 uptime_ms: 1_000,994 daemon: DaemonState::Stopped,995 session_active: false,996 last_error: None,997 }998 }9991000 #[test]1001 fn lifecycle_lock_is_global_across_distinct_state_directories() {1002 let first_state = private_directory();1003 let second_state = private_directory();1004 let runtime = private_directory();1005 let first = LifecycleGuard::acquire_at(first_state.path(), Some(runtime.path())).unwrap();1006 assert!(LifecycleGuard::acquire_at(second_state.path(), Some(runtime.path())).is_err());1007 drop(first);1008 LifecycleGuard::acquire_at(second_state.path(), Some(runtime.path())).unwrap();1009 }10101011 #[test]1012 fn lifecycle_lock_without_runtime_remains_state_scoped() {1013 let first_state = private_directory();1014 let second_state = private_directory();1015 let first = LifecycleGuard::acquire_at(first_state.path(), None).unwrap();1016 assert!(LifecycleGuard::acquire_at(first_state.path(), None).is_err());1017 LifecycleGuard::acquire_at(second_state.path(), None).unwrap();1018 drop(first);1019 LifecycleGuard::acquire_at(first_state.path(), None).unwrap();1020 }10211022 #[cfg(unix)]1023 #[test]1024 fn explicit_management_runtime_root_is_strict_and_appends_one_child() {1025 use std::os::unix::fs::{PermissionsExt as _, symlink};10261027 assert_eq!(RUNTIME_DIRECTORY_COMPONENT, "sigil-spark");1028 let root = private_directory();1029 assert_eq!(1030 resolve_management_runtime_directory(Some(root.path())).unwrap(),1031 root.path().join(RUNTIME_DIRECTORY_COMPONENT)1032 );1033 assert!(resolve_management_runtime_directory(Some(Path::new("relative"))).is_err());10341035 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o750)).unwrap();1036 assert!(resolve_management_runtime_directory(Some(root.path())).is_err());1037 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o1700)).unwrap();1038 assert!(resolve_management_runtime_directory(Some(root.path())).is_err());1039 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap();10401041 let link_parent = private_directory();1042 let link = link_parent.path().join("runtime-link");1043 symlink(root.path(), &link).unwrap();1044 assert!(resolve_management_runtime_directory(Some(&link)).is_err());1045 }10461047 #[test]1048 fn missing_runtime_is_offline_without_creating_state() {1049 let directory = private_directory();1050 let missing = directory.path().join("missing");1051 let host = SecretKey::from_bytes(&[7; 32]).public();1052 let status = read_runtime_status(&missing, host, 20_000).unwrap();1053 assert_eq!(status.state, RuntimeState::Absent);1054 assert_eq!(status.daemon, DaemonState::Offline);1055 assert_eq!(status.session, SessionState::Unknown);1056 assert!(!missing.exists());1057 }10581059 #[test]1060 fn runtime_freshness_suppresses_stale_session_claims() {1061 let directory = private_directory();1062 let fresh = serde_json::to_vec(&runtime_document(19_000)).unwrap();1063 secure_state::atomic_write(1064 directory.path(),1065 RUNTIME_STATUS_FILE,1066 &fresh,1067 MAX_RUNTIME_STATUS_BYTES,1068 )1069 .unwrap();1070 let host = SecretKey::from_bytes(&[7; 32]).public();1071 let status = read_runtime_status(directory.path(), host, 20_000).unwrap();1072 assert_eq!(status.state, RuntimeState::Fresh);1073 assert_eq!(status.session, SessionState::Active);1074 assert_eq!(status.uptime_ms, Some(2_000));10751076 let status = read_runtime_status(directory.path(), host, 30_001).unwrap();1077 assert_eq!(status.state, RuntimeState::Stale);1078 assert_eq!(status.daemon, DaemonState::Unknown);1079 assert_eq!(status.session, SessionState::Unknown);1080 assert_eq!(status.uptime_ms, None);1081 }10821083 #[test]1084 fn clock_skew_is_bounded_and_unknown_or_cross_host_documents_fail() {1085 let directory = private_directory();1086 let host = SecretKey::from_bytes(&[7; 32]).public();1087 let future = serde_json::to_vec(&runtime_document(20_001)).unwrap();1088 secure_state::atomic_write(1089 directory.path(),1090 RUNTIME_STATUS_FILE,1091 &future,1092 MAX_RUNTIME_STATUS_BYTES,1093 )1094 .unwrap();1095 assert_eq!(1096 read_runtime_status(directory.path(), host, 20_000)1097 .unwrap()1098 .state,1099 RuntimeState::Fresh1100 );11011102 let far_future = serde_json::to_vec(&runtime_document(26_000)).unwrap();1103 secure_state::atomic_write(1104 directory.path(),1105 RUNTIME_STATUS_FILE,1106 &far_future,1107 MAX_RUNTIME_STATUS_BYTES,1108 )1109 .unwrap();1110 assert_eq!(1111 read_runtime_status(directory.path(), host, 20_000)1112 .unwrap()1113 .state,1114 RuntimeState::Stale1115 );11161117 let mut unknown = runtime_document(20_000);1118 unknown.version = 99;1119 let unknown = serde_json::to_vec(&unknown).unwrap();1120 secure_state::atomic_write(1121 directory.path(),1122 RUNTIME_STATUS_FILE,1123 &unknown,1124 MAX_RUNTIME_STATUS_BYTES,1125 )1126 .unwrap();1127 assert!(read_runtime_status(directory.path(), host, 20_000).is_err());11281129 let other_host = SecretKey::from_bytes(&[8; 32]).public();1130 let cross_host = serde_json::to_vec(&runtime_document(20_000)).unwrap();1131 secure_state::atomic_write(1132 directory.path(),1133 RUNTIME_STATUS_FILE,1134 &cross_host,1135 MAX_RUNTIME_STATUS_BYTES,1136 )1137 .unwrap();1138 assert!(read_runtime_status(directory.path(), other_host, 20_000).is_err());1139 }11401141 #[test]1142 fn candidate_health_requires_exact_fresh_stopped_ready_instance_and_revision() {1143 let directory = private_directory();1144 let host = SecretKey::from_bytes(&[7; 32]).public();1145 let revision = ConfigRevision::from_bytes(b"candidate");1146 let instance = "0123456789abcdef0123456789abcdef";1147 let valid = current_runtime_document(20_000, revision.clone());1148 let write = |document: &RuntimeStatusV2| {1149 secure_state::atomic_write(1150 directory.path(),1151 RUNTIME_STATUS_FILE,1152 &serde_json::to_vec(document).unwrap(),1153 MAX_RUNTIME_STATUS_BYTES,1154 )1155 .unwrap();1156 };1157 write(&valid);1158 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000).unwrap();11591160 let mut invalid = valid.clone();1161 invalid.reached_ready = false;1162 write(&invalid);1163 assert!(1164 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000)1165 .is_err()1166 );11671168 let mut invalid = valid.clone();1169 invalid.daemon = DaemonState::Ready;1170 write(&invalid);1171 assert!(1172 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 20_000)1173 .is_err()1174 );11751176 write(&valid);1177 assert!(1178 validate_candidate_runtime_at(1179 directory.path(),1180 host,1181 &ConfigRevision::from_bytes(b"other"),1182 instance,1183 20_000,1184 )1185 .is_err()1186 );1187 assert!(1188 validate_candidate_runtime_at(1189 directory.path(),1190 host,1191 &revision,1192 "fedcba9876543210fedcba9876543210",1193 20_000,1194 )1195 .is_err()1196 );1197 assert!(1198 validate_candidate_runtime_at(directory.path(), host, &revision, instance, 40_001)1199 .is_err()1200 );1201 }12021203 #[test]1204 fn assembled_status_redacts_endpoint_ids_and_orders_grants() {1205 let host = SecretKey::from_bytes(&[7; 32]).public();1206 let peer = SecretKey::from_bytes(&[9; 32]).public();1207 let status = assemble_status(1208 host,1209 AuthorizationInspection {1210 snapshot: crate::authorization::AuthorizationSnapshot {1211 epoch: 4,1212 peer: Some(peer),1213 grants: Some(InvitationGrants::ALL),1214 enrolled_at_unix: Some(123),1215 },1216 storage_present: true,1217 },1218 RuntimeStatus {1219 state: RuntimeState::Fresh,1220 daemon: DaemonState::Ready,1221 uptime_ms: Some(50),1222 heartbeat_age_ms: Some(1),1223 session: SessionState::Active,1224 instance_id: Some("0123456789abcdef0123456789abcdef".to_owned()),1225 loaded_config_revision: Some(ConfigRevision::from_bytes(b"config")),1226 reached_ready: true,1227 last_error: None,1228 },1229 ConfigRevision::from_bytes(b"config"),1230 None,1231 );1232 let json = serde_json::to_string(&status).unwrap();1233 assert!(!json.contains(&host.to_string()));1234 assert!(!json.contains(&peer.to_string()));1235 assert!(json.contains(&fingerprint(host)));1236 assert!(json.contains(&fingerprint(peer)));1237 assert!(json.contains("\"grants\":[\"view\",\"pointer_keyboard\",\"gamepad\"]"));1238 }12391240 #[test]1241 fn public_status_v1_shape_remains_compatible_and_v2_is_explicit() {1242 let host = SecretKey::from_bytes(&[7; 32]).public();1243 let revision = ConfigRevision::from_bytes(b"config");1244 let status = assemble_status(1245 host,1246 AuthorizationInspection {1247 snapshot: crate::authorization::AuthorizationSnapshot {1248 epoch: 0,1249 peer: None,1250 grants: None,1251 enrolled_at_unix: None,1252 },1253 storage_present: false,1254 },1255 absent_runtime(),1256 revision,1257 None,1258 );1259 let legacy = status_json(&status, 1).unwrap();1260 assert_eq!(legacy["schema_version"], 1);1261 assert!(legacy["config"].get("revision").is_none());1262 assert!(legacy["config"].get("pending_transaction").is_none());1263 assert!(legacy["runtime"].get("instance_id").is_none());1264 assert!(legacy["runtime"].get("loaded_config_revision").is_none());1265 assert!(legacy["runtime"].get("reached_ready").is_none());12661267 let current = status_json(&status, 2).unwrap();1268 assert_eq!(current["schema_version"], 2);1269 assert!(current["config"].get("revision").is_some());1270 assert!(current["runtime"].get("reached_ready").is_some());1271 }12721273 #[tokio::test]1274 async fn publisher_writes_transitions_and_retains_an_explicit_stopped_state() {1275 let directory = private_directory();1276 let sessions = Arc::new(SessionRegistry::default());1277 let host = SecretKey::from_bytes(&[7; 32]).public();1278 let revision = ConfigRevision::from_bytes(b"config");1279 let publisher =1280 RuntimePublisher::start_at(directory.path(), host, sessions, Some(revision.clone()))1281 .unwrap();1282 publisher.mark_ready().await.unwrap();1283 publisher.write_snapshot().unwrap();1284 let status =1285 read_runtime_status(directory.path(), host, unix_timestamp_millis().unwrap()).unwrap();1286 assert_eq!(status.daemon, DaemonState::Ready);1287 publisher.mark_stopping().await.unwrap();1288 publisher.finish_clean().await.unwrap();1289 let status =1290 read_runtime_status(directory.path(), host, unix_timestamp_millis().unwrap()).unwrap();1291 assert_eq!(status.daemon, DaemonState::Stopped);1292 assert_eq!(status.session, SessionState::Inactive);1293 assert_eq!(status.loaded_config_revision, Some(revision));1294 assert!(status.reached_ready);1295 }1296}