Add a versioned redacted status contract for future Decky management UI. Enforce one configured daemon per user, publish bounded runtime health, and harden read-only authorization inspection and crash-safe state handling. Cover the lifecycle with unit, integration, and full demo preflight tests.
- commit
- fae73f2c9fce7094ad036af30842d228c019c562
- parent
- 9816a4ef4989fbb84838cf64a67a7e37f2ee77ad
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 6 | + |
| A | crates/ | 829 | ++++++++++++++++++++++++++++++ |
| M | crates/ | 157 | ++++++- |
| M | crates/ | 262 | +++++++-- |
| A | crates/ | 239 | +++++++++ |
| M | crates/ | 10 | + |
| A | docs/ | 66 | ++ |
| A | scripts/ | 163 | ++++++ |
| M | scripts/ | 1 | + |
@@ -83,6 +83,10 @@ Steam and games.
8383 - The Bazzite host has an allowlisted deterministic runtime package with8484 checksum-bound release IDs, serialized install/upgrade, tamper-checked8585 rollback, and package-owned user assets that follow the active release.86+- Sigil exposes a versioned, redacted local appliance-status document and87+ daemon-lifetime lock as the foundation for a controller-first Decky Loader88+ management plugin. The plugin remains a UI over the user service, not a89+ second streaming host.8690 8791 The software JPEG client decode fallback and legacy wire compatibility remain.8892 The preferred video path authenticates the client over a dedicated control@@ -182,6 +186,8 @@ invitation never silently replaces a working enrollment.
182186 Provision a dedicated AMD host with the183187 [fresh Bazzite host runbook](docs/fresh-bazzite-host.md). The runbook also184188 defines the temporary `slate` stand-in used for protocol and daemon extraction.189+The [appliance status v1 contract](docs/sigil-appliance-status-v1.md) defines190+the local, redacted interface intended for the Decky management surface.185191 Run `scripts/bazzite-inventory.sh` on a candidate host for a read-only report;186192 add `--smoke` to exercise a bounded 1280×800/60 VA-API encode. On the first SSH187193 login after a physically headless cold boot, use `--cold-boot` for a strict,@@ -0,0 +1,829 @@
1+use std::fs;2+use std::path::{Path, PathBuf};3+use std::sync::atomic::{AtomicU8, Ordering};4+use std::sync::{Arc, Mutex};5+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};6+7+use anyhow::{Context, Result, ensure};8+use iroh::EndpointId;9+use serde::{Deserialize, Serialize};10+use sigil_protocol::InvitationGrants;11+12+use crate::authorization::{AuthorizationInspection, AuthorizationStore};13+use crate::config::HostConfig;14+use crate::secure_state;15+use crate::server::SessionRegistry;16+17+const LIFECYCLE_LOCK_FILE: &str = "daemon-v1.lock";18+const GLOBAL_LIFECYCLE_LOCK_FILE: &str = "daemon-global-v1.lock";19+const RUNTIME_STATUS_FILE: &str = "daemon-status-v1.json";20+const RUNTIME_STATUS_VERSION: u16 = 1;21+const MAX_RUNTIME_STATUS_BYTES: u64 = 16 * 1024;22+const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);23+const STALE_AFTER: Duration = Duration::from_secs(10);24+const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_secs(5);25+26+const DAEMON_STARTING: u8 = 0;27+const DAEMON_READY: u8 = 1;28+const DAEMON_STOPPING: u8 = 2;29+const DAEMON_DEGRADED: u8 = 3;30+const DAEMON_STOPPED: u8 = 4;31+32+pub struct LifecycleGuard {33+ _files: Vec<fs::File>,34+}35+36+impl LifecycleGuard {37+ pub fn acquire(state_directory: &Path, require_global: bool) -> Result<Self> {38+ let runtime_directory = configured_runtime_directory()?;39+ ensure!(40+ !require_global || runtime_directory.is_some(),41+ "configured Sigil service requires a valid XDG_RUNTIME_DIR"42+ );43+ Self::acquire_at(state_directory, runtime_directory.as_deref())44+ }45+46+ fn acquire_at(state_directory: &Path, runtime_directory: Option<&Path>) -> Result<Self> {47+ let mut files = vec![secure_state::open_lifetime_lock(48+ state_directory,49+ LIFECYCLE_LOCK_FILE,50+ )?];51+ if let Some(runtime_directory) = runtime_directory {52+ secure_state::ensure_private_directory(runtime_directory)?;53+ files.push(secure_state::open_lifetime_lock(54+ runtime_directory,55+ GLOBAL_LIFECYCLE_LOCK_FILE,56+ )?);57+ }58+ Ok(Self { _files: files })59+ }60+}61+62+63+64+enum DaemonState {65+ Starting,66+ Ready,67+ Stopping,68+ Degraded,69+ Stopped,70+ Offline,71+ Unknown,72+}73+74+75+76+enum RuntimeState {77+ Fresh,78+ Absent,79+ Stale,80+ Unavailable,81+}82+83+84+85+enum SessionState {86+ Active,87+ Inactive,88+ Unknown,89+}90+91+92+93+enum OverallState {94+ Ready,95+ Active,96+ Degraded,97+ Unavailable,98+}99+100+101+102+pub enum RuntimeErrorCode {103+ AuthorizationState,104+ Preflight,105+ EndpointBind,106+ RouterShutdown,107+ ShutdownSignal,108+}109+110+111+112+pub struct RuntimeErrorV1 {113+ code: RuntimeErrorCode,114+ occurred_at_unix_ms: u64,115+}116+117+118+119+struct RuntimeStatusV1 {120+ version: u16,121+ instance_id: String,122+ host_node_id: String,123+ updated_at_unix_ms: u64,124+ uptime_ms: u64,125+ daemon: DaemonState,126+ session_active: bool,127+ last_error: Option<RuntimeErrorV1>,128+}129+130+131+pub struct ApplianceStatusV1 {132+ schema_version: u16,133+ sigil_version: &'static str,134+ overall: OverallState,135+ config: ConfigStatus,136+ identity: IdentityStatus,137+ enrollment: EnrollmentStatus,138+ runtime: RuntimeStatus,139+}140+141+142+struct ConfigStatus {143+ state: &'static str,144+}145+146+147+struct IdentityStatus {148+ state: &'static str,149+ host_fingerprint: String,150+}151+152+153+struct EnrollmentStatus {154+ state: &'static str,155+ storage: &'static str,156+ peer_fingerprint: Option<String>,157+ grants: Vec<&'static str>,158+ epoch: u64,159+ enrolled_at_unix: Option<u64>,160+}161+162+163+struct RuntimeStatus {164+ state: RuntimeState,165+ daemon: DaemonState,166+ uptime_ms: Option<u64>,167+ heartbeat_age_ms: Option<u64>,168+ session: SessionState,169+ last_error: Option<RuntimeErrorV1>,170+}171+172+pub struct RuntimePublisher {173+ state_directory: PathBuf,174+ instance_id: String,175+ host: EndpointId,176+ started: Instant,177+ daemon_state: Arc<AtomicU8>,178+ last_error: Arc<Mutex<Option<RuntimeErrorV1>>>,179+ sessions: Arc<SessionRegistry>,180+ write_lock: Arc<tokio::sync::Mutex<()>>,181+ task: Option<tokio::task::JoinHandle<()>>,182+}183+184+impl RuntimePublisher {185+ pub fn start(186+ host: EndpointId,187+ sessions: Arc<SessionRegistry>,188+ require_runtime: bool,189+ ) -> Result<Option<Self>> {190+ let Some(state_directory) = configured_runtime_directory()? else {191+ ensure!(192+ !require_runtime,193+ "configured Sigil service requires a valid XDG_RUNTIME_DIR"194+ );195+ tracing::warn!("XDG_RUNTIME_DIR is unavailable; appliance heartbeat is disabled");196+ return Ok(None);197+ };198+ secure_state::ensure_private_directory(&state_directory)?;199+ Self::start_at(&state_directory, host, sessions).map(Some)200+ }201+202+ fn start_at(203+ state_directory: &Path,204+ host: EndpointId,205+ sessions: Arc<SessionRegistry>,206+ ) -> Result<Self> {207+ let instance_id = random_instance_id()?;208+ let daemon_state = Arc::new(AtomicU8::new(DAEMON_STARTING));209+ let last_error = Arc::new(Mutex::new(None));210+ let write_lock = Arc::new(tokio::sync::Mutex::new(()));211+ let mut publisher = Self {212+ state_directory: state_directory.to_path_buf(),213+ instance_id,214+ host,215+ started: Instant::now(),216+ daemon_state,217+ last_error,218+ sessions,219+ write_lock,220+ task: None,221+ };222+ publisher.write_snapshot()?;223+224+ let state_directory = publisher.state_directory.clone();225+ let instance_id = publisher.instance_id.clone();226+ let host = publisher.host;227+ let started = publisher.started;228+ let daemon_state = Arc::clone(&publisher.daemon_state);229+ let last_error = Arc::clone(&publisher.last_error);230+ let sessions = Arc::clone(&publisher.sessions);231+ let write_lock = Arc::clone(&publisher.write_lock);232+ let task = tokio::spawn(async move {233+ let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);234+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);235+ interval.tick().await;236+ loop {237+ interval.tick().await;238+ let _write_guard = write_lock.lock().await;239+ let state_directory = state_directory.clone();240+ let instance_id = instance_id.clone();241+ let daemon = decode_daemon_state(daemon_state.load(Ordering::Relaxed));242+ let session_active = sessions.has_session();243+ let last_error = last_error244+ .lock()245+ .expect("runtime status error state poisoned")246+ .clone();247+ match tokio::task::spawn_blocking(move || {248+ write_runtime_snapshot(249+ &state_directory,250+ &instance_id,251+ host,252+ started,253+ daemon,254+ session_active,255+ last_error,256+ )257+ })258+ .await259+ {260+ Ok(Ok(())) => {}261+ Ok(Err(error)) => {262+ tracing::warn!(%error, "writing bounded Sigil runtime status failed");263+ }264+ Err(error) => {265+ tracing::warn!(%error, "Sigil runtime status writer task failed");266+ break;267+ }268+ }269+ }270+ });271+ publisher.task = Some(task);272+ Ok(publisher)273+ }274+275+ pub async fn mark_ready(&self) -> Result<()> {276+ self.daemon_state.store(DAEMON_READY, Ordering::Relaxed);277+ self.write_snapshot_async().await278+ }279+280+ pub async fn mark_stopping(&self) -> Result<()> {281+ self.daemon_state.store(DAEMON_STOPPING, Ordering::Relaxed);282+ self.write_snapshot_async().await283+ }284+285+ pub async fn mark_degraded(&self, code: RuntimeErrorCode) -> Result<()> {286+ self.daemon_state.store(DAEMON_DEGRADED, Ordering::Relaxed);287+ *self288+ .last_error289+ .lock()290+ .expect("runtime status error state poisoned") = Some(RuntimeErrorV1 {291+ code,292+ occurred_at_unix_ms: unix_timestamp_millis()?,293+ });294+ self.write_snapshot_async().await295+ }296+297+ pub async fn finish_clean(mut self) -> Result<()> {298+ let write_lock = Arc::clone(&self.write_lock);299+ let _write_guard = write_lock.lock().await;300+ if let Some(task) = self.task.take() {301+ task.abort();302+ let _ = task.await;303+ }304+ self.daemon_state.store(DAEMON_STOPPED, Ordering::Relaxed);305+ let state_directory = self.state_directory.clone();306+ let instance_id = self.instance_id.clone();307+ let host = self.host;308+ let started = self.started;309+ let session_active = self.sessions.has_session();310+ let last_error = self311+ .last_error312+ .lock()313+ .expect("runtime status error state poisoned")314+ .clone();315+ tokio::task::spawn_blocking(move || {316+ write_runtime_snapshot(317+ &state_directory,318+ &instance_id,319+ host,320+ started,321+ DaemonState::Stopped,322+ session_active,323+ last_error,324+ )325+ })326+ .await327+ .context("joining stopped runtime status write")?328+ }329+330+ fn write_snapshot(&self) -> Result<()> {331+ write_runtime_snapshot(332+ &self.state_directory,333+ &self.instance_id,334+ self.host,335+ self.started,336+ decode_daemon_state(self.daemon_state.load(Ordering::Relaxed)),337+ self.sessions.has_session(),338+ self.last_error339+ .lock()340+ .expect("runtime status error state poisoned")341+ .clone(),342+ )343+ }344+345+ async fn write_snapshot_async(&self) -> Result<()> {346+ let write_lock = Arc::clone(&self.write_lock);347+ let _write_guard = write_lock.lock().await;348+ let state_directory = self.state_directory.clone();349+ let instance_id = self.instance_id.clone();350+ let host = self.host;351+ let started = self.started;352+ let daemon = decode_daemon_state(self.daemon_state.load(Ordering::Relaxed));353+ let session_active = self.sessions.has_session();354+ let last_error = self355+ .last_error356+ .lock()357+ .expect("runtime status error state poisoned")358+ .clone();359+ tokio::task::spawn_blocking(move || {360+ write_runtime_snapshot(361+ &state_directory,362+ &instance_id,363+ host,364+ started,365+ daemon,366+ session_active,367+ last_error,368+ )369+ })370+ .await371+ .context("joining runtime status write")?372+ }373+}374+375+impl Drop for RuntimePublisher {376+ fn drop(&mut self) {377+ if let Some(task) = self.task.take() {378+ task.abort();379+ }380+ }381+}382+383+fn configured_runtime_directory() -> Result<Option<PathBuf>> {384+ let Some(root) = std::env::var_os("XDG_RUNTIME_DIR") else {385+ return Ok(None);386+ };387+ let root = PathBuf::from(root);388+ ensure!(root.is_absolute(), "XDG_RUNTIME_DIR must be absolute");389+ secure_state::validate_private_directory(&root)390+ .context("validating XDG_RUNTIME_DIR for Sigil appliance status")?;391+ Ok(Some(root.join("sigil-spark")))392+}393+394+pub fn collect_status(config_path: &Path) -> Result<ApplianceStatusV1> {395+ let config = HostConfig::load(config_path)?;396+ let secret = crate::identity::load(&config.identity_path)?;397+ let host = secret.public();398+ let authorization = AuthorizationStore::inspect_existing(&config.state_path, host)?;399+ let runtime = match configured_runtime_directory()? {400+ Some(runtime_directory) => {401+ read_runtime_status(&runtime_directory, host, unix_timestamp_millis()?)?402+ }403+ None => unavailable_runtime(),404+ };405+ Ok(assemble_status(host, authorization, runtime))406+}407+408+fn assemble_status(409+ host: EndpointId,410+ authorization: AuthorizationInspection,411+ runtime: RuntimeStatus,412+) -> ApplianceStatusV1 {413+ let snapshot = authorization.snapshot;414+ let grants = snapshot.grants.map(grant_list).unwrap_or_default();415+ let enrollment_active = snapshot.peer.is_some();416+ let overall = match (runtime.daemon, runtime.session) {417+ (DaemonState::Ready, SessionState::Active) => OverallState::Active,418+ (DaemonState::Ready, _) => OverallState::Ready,419+ (DaemonState::Degraded, _) => OverallState::Degraded,420+ _ => OverallState::Unavailable,421+ };422+ ApplianceStatusV1 {423+ schema_version: 1,424+ sigil_version: env!("CARGO_PKG_VERSION"),425+ overall,426+ config: ConfigStatus { state: "valid" },427+ identity: IdentityStatus {428+ state: "ready",429+ host_fingerprint: fingerprint(host),430+ },431+ enrollment: EnrollmentStatus {432+ state: if enrollment_active { "active" } else { "none" },433+ storage: if authorization.storage_present {434+ "present"435+ } else {436+ "absent"437+ },438+ peer_fingerprint: snapshot.peer.map(fingerprint),439+ grants,440+ epoch: snapshot.epoch,441+ enrolled_at_unix: snapshot.enrolled_at_unix,442+ },443+ runtime,444+ }445+}446+447+fn read_runtime_status(448+ state_directory: &Path,449+ expected_host: EndpointId,450+ now_unix_ms: u64,451+) -> Result<RuntimeStatus> {452+ match fs::symlink_metadata(state_directory) {453+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {454+ return Ok(absent_runtime());455+ }456+ Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),457+ Ok(_) => {}458+ }459+ let Some(bytes) = secure_state::read_bounded(460+ state_directory,461+ RUNTIME_STATUS_FILE,462+ MAX_RUNTIME_STATUS_BYTES,463+ )?464+ else {465+ return Ok(absent_runtime());466+ };467+ let persisted: RuntimeStatusV1 =468+ serde_json::from_slice(&bytes).context("parsing Sigil runtime status")?;469+ ensure!(470+ persisted.version == RUNTIME_STATUS_VERSION,471+ "unsupported Sigil runtime status version"472+ );473+ ensure!(474+ persisted.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),475+ "Sigil runtime status belongs to a different host identity"476+ );477+ ensure!(478+ persisted.instance_id.len() == 32479+ && persisted480+ .instance_id481+ .bytes()482+ .all(|byte| byte.is_ascii_hexdigit()),483+ "invalid Sigil runtime instance ID"484+ );485+ if let Some(error) = &persisted.last_error {486+ ensure!(487+ error.occurred_at_unix_ms <= persisted.updated_at_unix_ms,488+ "Sigil runtime error timestamp follows its status update"489+ );490+ }491+ let age_ms = match now_unix_ms.checked_sub(persisted.updated_at_unix_ms) {492+ Some(age_ms) => age_ms,493+ None if persisted.updated_at_unix_ms - now_unix_ms494+ <= CLOCK_SKEW_TOLERANCE.as_millis() as u64 =>495+ {496+ 0497+ }498+ None => return Ok(stale_runtime(persisted, None)),499+ };500+ if age_ms > STALE_AFTER.as_millis() as u64 {501+ return Ok(stale_runtime(persisted, Some(age_ms)));502+ }503+ ensure!(504+ !matches!(505+ persisted.daemon,506+ DaemonState::Offline | DaemonState::Unknown507+ ),508+ "runtime publisher wrote an invalid daemon state"509+ );510+ Ok(RuntimeStatus {511+ state: RuntimeState::Fresh,512+ daemon: persisted.daemon,513+ uptime_ms: (!matches!(persisted.daemon, DaemonState::Stopped))514+ .then_some(persisted.uptime_ms.saturating_add(age_ms)),515+ heartbeat_age_ms: Some(age_ms),516+ session: if persisted.daemon == DaemonState::Stopped {517+ SessionState::Inactive518+ } else if persisted.session_active {519+ SessionState::Active520+ } else {521+ SessionState::Inactive522+ },523+ last_error: persisted.last_error,524+ })525+}526+527+fn stale_runtime(persisted: RuntimeStatusV1, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {528+ let stopped = persisted.daemon == DaemonState::Stopped;529+ RuntimeStatus {530+ state: RuntimeState::Stale,531+ daemon: if stopped {532+ DaemonState::Stopped533+ } else {534+ DaemonState::Unknown535+ },536+ uptime_ms: None,537+ heartbeat_age_ms,538+ session: if stopped {539+ SessionState::Inactive540+ } else {541+ SessionState::Unknown542+ },543+ last_error: persisted.last_error,544+ }545+}546+547+fn absent_runtime() -> RuntimeStatus {548+ RuntimeStatus {549+ state: RuntimeState::Absent,550+ daemon: DaemonState::Offline,551+ uptime_ms: None,552+ heartbeat_age_ms: None,553+ session: SessionState::Unknown,554+ last_error: None,555+ }556+}557+558+fn unavailable_runtime() -> RuntimeStatus {559+ RuntimeStatus {560+ state: RuntimeState::Unavailable,561+ daemon: DaemonState::Unknown,562+ uptime_ms: None,563+ heartbeat_age_ms: None,564+ session: SessionState::Unknown,565+ last_error: None,566+ }567+}568+569+fn write_runtime_snapshot(570+ state_directory: &Path,571+ instance_id: &str,572+ host: EndpointId,573+ started: Instant,574+ daemon: DaemonState,575+ session_active: bool,576+ last_error: Option<RuntimeErrorV1>,577+) -> Result<()> {578+ let status = RuntimeStatusV1 {579+ version: RUNTIME_STATUS_VERSION,580+ instance_id: instance_id.to_owned(),581+ host_node_id: host.to_string(),582+ updated_at_unix_ms: unix_timestamp_millis()?,583+ uptime_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),584+ daemon,585+ session_active,586+ last_error,587+ };588+ let bytes = serde_json::to_vec(&status)?;589+ secure_state::atomic_write(590+ state_directory,591+ RUNTIME_STATUS_FILE,592+ &bytes,593+ MAX_RUNTIME_STATUS_BYTES,594+ )595+}596+597+fn decode_daemon_state(value: u8) -> DaemonState {598+ match value {599+ DAEMON_READY => DaemonState::Ready,600+ DAEMON_STOPPING => DaemonState::Stopping,601+ DAEMON_DEGRADED => DaemonState::Degraded,602+ DAEMON_STOPPED => DaemonState::Stopped,603+ _ => DaemonState::Starting,604+ }605+}606+607+fn random_instance_id() -> Result<String> {608+ let mut bytes = [0_u8; 16];609+ getrandom::fill(&mut bytes).context("generating runtime instance ID")?;610+ let mut output = String::with_capacity(32);611+ for byte in bytes {612+ use std::fmt::Write as _;613+ write!(&mut output, "{byte:02x}").expect("writing into a String cannot fail");614+ }615+ Ok(output)616+}617+618+fn unix_timestamp_millis() -> Result<u64> {619+ Ok(u64::try_from(620+ SystemTime::now()621+ .duration_since(UNIX_EPOCH)622+ .context("system clock is before the Unix epoch")?623+ .as_millis(),624+ )625+ .unwrap_or(u64::MAX))626+}627+628+fn fingerprint(id: EndpointId) -> String {629+ let full = id.to_string();630+ debug_assert_eq!(full.len(), 64);631+ format!("{}…{}", &full[..8], &full[full.len() - 8..])632+}633+634+fn grant_list(grants: InvitationGrants) -> Vec<&'static str> {635+ let mut names = vec!["view"];636+ if grants.contains(InvitationGrants::POINTER_KEYBOARD) {637+ names.push("pointer_keyboard");638+ }639+ if grants.contains(InvitationGrants::GAMEPAD) {640+ names.push("gamepad");641+ }642+ names643+}644+645+646+mod tests {647+ use super::*;648+ use iroh::SecretKey;649+650+ 651+ use std::os::unix::fs::PermissionsExt;652+653+ fn private_directory() -> tempfile::TempDir {654+ let directory = tempfile::tempdir().unwrap();655+ 656+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();657+ directory658+ }659+660+ fn runtime_document(updated_at_unix_ms: u64) -> RuntimeStatusV1 {661+ let host = SecretKey::from_bytes(&[7; 32]).public();662+ RuntimeStatusV1 {663+ version: RUNTIME_STATUS_VERSION,664+ instance_id: "0123456789abcdef0123456789abcdef".to_owned(),665+ host_node_id: host.to_string(),666+ updated_at_unix_ms,667+ uptime_ms: 1_000,668+ daemon: DaemonState::Ready,669+ session_active: true,670+ last_error: None,671+ }672+ }673+674+ 675+ fn lifecycle_lock_is_global_across_distinct_state_directories() {676+ let first_state = private_directory();677+ let second_state = private_directory();678+ let runtime = private_directory();679+ let first = LifecycleGuard::acquire_at(first_state.path(), Some(runtime.path())).unwrap();680+ assert!(LifecycleGuard::acquire_at(second_state.path(), Some(runtime.path())).is_err());681+ drop(first);682+ LifecycleGuard::acquire_at(second_state.path(), Some(runtime.path())).unwrap();683+ }684+685+ 686+ fn missing_runtime_is_offline_without_creating_state() {687+ let directory = private_directory();688+ let missing = directory.path().join("missing");689+ let host = SecretKey::from_bytes(&[7; 32]).public();690+ let status = read_runtime_status(&missing, host, 20_000).unwrap();691+ assert_eq!(status.state, RuntimeState::Absent);692+ assert_eq!(status.daemon, DaemonState::Offline);693+ assert_eq!(status.session, SessionState::Unknown);694+ assert!(!missing.exists());695+ }696+697+ 698+ fn runtime_freshness_suppresses_stale_session_claims() {699+ let directory = private_directory();700+ let fresh = serde_json::to_vec(&runtime_document(19_000)).unwrap();701+ secure_state::atomic_write(702+ directory.path(),703+ RUNTIME_STATUS_FILE,704+ &fresh,705+ MAX_RUNTIME_STATUS_BYTES,706+ )707+ .unwrap();708+ let host = SecretKey::from_bytes(&[7; 32]).public();709+ let status = read_runtime_status(directory.path(), host, 20_000).unwrap();710+ assert_eq!(status.state, RuntimeState::Fresh);711+ assert_eq!(status.session, SessionState::Active);712+ assert_eq!(status.uptime_ms, Some(2_000));713+714+ let status = read_runtime_status(directory.path(), host, 30_001).unwrap();715+ assert_eq!(status.state, RuntimeState::Stale);716+ assert_eq!(status.daemon, DaemonState::Unknown);717+ assert_eq!(status.session, SessionState::Unknown);718+ assert_eq!(status.uptime_ms, None);719+ }720+721+ 722+ fn clock_skew_is_bounded_and_unknown_or_cross_host_documents_fail() {723+ let directory = private_directory();724+ let host = SecretKey::from_bytes(&[7; 32]).public();725+ let future = serde_json::to_vec(&runtime_document(20_001)).unwrap();726+ secure_state::atomic_write(727+ directory.path(),728+ RUNTIME_STATUS_FILE,729+ &future,730+ MAX_RUNTIME_STATUS_BYTES,731+ )732+ .unwrap();733+ assert_eq!(734+ read_runtime_status(directory.path(), host, 20_000)735+ .unwrap()736+ .state,737+ RuntimeState::Fresh738+ );739+740+ let far_future = serde_json::to_vec(&runtime_document(26_000)).unwrap();741+ secure_state::atomic_write(742+ directory.path(),743+ RUNTIME_STATUS_FILE,744+ &far_future,745+ MAX_RUNTIME_STATUS_BYTES,746+ )747+ .unwrap();748+ assert_eq!(749+ read_runtime_status(directory.path(), host, 20_000)750+ .unwrap()751+ .state,752+ RuntimeState::Stale753+ );754+755+ let mut unknown = runtime_document(20_000);756+ unknown.version = 2;757+ let unknown = serde_json::to_vec(&unknown).unwrap();758+ secure_state::atomic_write(759+ directory.path(),760+ RUNTIME_STATUS_FILE,761+ &unknown,762+ MAX_RUNTIME_STATUS_BYTES,763+ )764+ .unwrap();765+ assert!(read_runtime_status(directory.path(), host, 20_000).is_err());766+767+ let other_host = SecretKey::from_bytes(&[8; 32]).public();768+ let cross_host = serde_json::to_vec(&runtime_document(20_000)).unwrap();769+ secure_state::atomic_write(770+ directory.path(),771+ RUNTIME_STATUS_FILE,772+ &cross_host,773+ MAX_RUNTIME_STATUS_BYTES,774+ )775+ .unwrap();776+ assert!(read_runtime_status(directory.path(), other_host, 20_000).is_err());777+ }778+779+ 780+ fn assembled_status_redacts_endpoint_ids_and_orders_grants() {781+ let host = SecretKey::from_bytes(&[7; 32]).public();782+ let peer = SecretKey::from_bytes(&[9; 32]).public();783+ let status = assemble_status(784+ host,785+ AuthorizationInspection {786+ snapshot: crate::authorization::AuthorizationSnapshot {787+ epoch: 4,788+ peer: Some(peer),789+ grants: Some(InvitationGrants::ALL),790+ enrolled_at_unix: Some(123),791+ },792+ storage_present: true,793+ },794+ RuntimeStatus {795+ state: RuntimeState::Fresh,796+ daemon: DaemonState::Ready,797+ uptime_ms: Some(50),798+ heartbeat_age_ms: Some(1),799+ session: SessionState::Active,800+ last_error: None,801+ },802+ );803+ let json = serde_json::to_string(&status).unwrap();804+ assert!(!json.contains(&host.to_string()));805+ assert!(!json.contains(&peer.to_string()));806+ assert!(json.contains(&fingerprint(host)));807+ assert!(json.contains(&fingerprint(peer)));808+ assert!(json.contains("\"grants\":[\"view\",\"pointer_keyboard\",\"gamepad\"]"));809+ }810+811+ 812+ async fn publisher_writes_transitions_and_retains_an_explicit_stopped_state() {813+ let directory = private_directory();814+ let sessions = Arc::new(SessionRegistry::default());815+ let host = SecretKey::from_bytes(&[7; 32]).public();816+ let publisher = RuntimePublisher::start_at(directory.path(), host, sessions).unwrap();817+ publisher.mark_ready().await.unwrap();818+ publisher.write_snapshot().unwrap();819+ let status =820+ read_runtime_status(directory.path(), host, unix_timestamp_millis().unwrap()).unwrap();821+ assert_eq!(status.daemon, DaemonState::Ready);822+ publisher.mark_stopping().await.unwrap();823+ publisher.finish_clean().await.unwrap();824+ let status =825+ read_runtime_status(directory.path(), host, unix_timestamp_millis().unwrap()).unwrap();826+ assert_eq!(status.daemon, DaemonState::Stopped);827+ assert_eq!(status.session, SessionState::Inactive);828+ }829+}@@ -39,6 +39,13 @@ pub struct AuthorizationSnapshot {
3939 pub epoch: u64,4040 pub peer: Option<EndpointId>,4141 pub grants: Option<InvitationGrants>,42+ pub enrolled_at_unix: Option<u64>,43+}44+45+46+pub struct AuthorizationInspection {47+ pub snapshot: AuthorizationSnapshot,48+ pub storage_present: bool,4249 }4350 4451 @@ -273,17 +280,123 @@ impl AuthorizationStore {
273280 self.lock_exclusive(&lock)?;274281 let state = self.load_state()?;275282 state.validate(self.host)?;276- let (peer, grants) = match state.enrollment {283+ let (peer, grants, enrolled_at_unix) = match state.enrollment {277284 Some(enrollment) => (278285 Some(enrollment.peer_node_id.parse::<EndpointId>()?),279286 Some(enrollment.grants),287+ Some(enrollment.enrolled_at_unix),280288 ),281- None => (None, None),289+ None => (None, None, None),282290 };283291 Ok(AuthorizationSnapshot {284292 epoch: state.enrollment_epoch,285293 peer,286294 grants,295+ enrolled_at_unix,296+ })297+ }298+299+ /// Inspect durable enrollment without creating the state directory or lock.300+ /// The authorization writer uses atomic replacement, so an unlocked reader301+ /// observes either the previous complete document or the next one.302+ pub fn inspect_existing(303+ state_directory: impl Into<PathBuf>,304+ host: EndpointId,305+ ) -> Result<AuthorizationInspection> {306+ let store = Self {307+ state_directory: state_directory.into(),308+ host,309+ };310+ let directory_metadata = match fs::symlink_metadata(&store.state_directory) {311+ Ok(metadata) => metadata,312+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {313+ return Ok(AuthorizationInspection {314+ snapshot: AuthorizationSnapshot {315+ epoch: 1,316+ peer: None,317+ grants: None,318+ enrolled_at_unix: None,319+ },320+ storage_present: false,321+ });322+ }323+ Err(error) => return Err(error).context("inspecting authorization state directory"),324+ };325+ ensure!(326+ !directory_metadata.file_type().is_symlink(),327+ "authorization state directory is a symlink"328+ );329+ ensure!(330+ directory_metadata.is_dir(),331+ "authorization state path is not a directory"332+ );333+ 334+ {335+ ensure!(336+ directory_metadata.mode() & 0o077 == 0,337+ "authorization state directory is accessible by group or others"338+ );339+ ensure!(340+ directory_metadata.uid() == unsafe { libc::geteuid() },341+ "authorization state directory has the wrong owner"342+ );343+ }344+345+ let path = store.state_path();346+ let mut options = OpenOptions::new();347+ options.read(true);348+ 349+ options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);350+ let mut file = match options.open(&path) {351+ Ok(file) => file,352+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {353+ return Ok(AuthorizationInspection {354+ snapshot: AuthorizationSnapshot {355+ epoch: 1,356+ peer: None,357+ grants: None,358+ enrolled_at_unix: None,359+ },360+ storage_present: false,361+ });362+ }363+ Err(error) => return Err(error).context("opening authorization state"),364+ };365+ store.validate_secure_file(&file, &path)?;366+ let metadata = file.metadata()?;367+ ensure!(368+ metadata.len() <= MAX_STATE_FILE_LEN,369+ "authorization state exceeds its fixed size bound"370+ );371+ let mut bytes = Vec::with_capacity(metadata.len() as usize);372+ Read::by_ref(&mut file)373+ .take(MAX_STATE_FILE_LEN.saturating_add(1))374+ .read_to_end(&mut bytes)?;375+ ensure!(376+ bytes.len() as u64 <= MAX_STATE_FILE_LEN,377+ "authorization state exceeds its fixed size bound"378+ );379+ ensure!(!bytes.is_empty(), "authorization state is empty");380+ let state: AuthorizationState =381+ serde_json::from_slice(&bytes).context("parsing authorization state")?;382+ state.validate(host)?;383+ let snapshot = match state.enrollment {384+ Some(enrollment) => AuthorizationSnapshot {385+ epoch: state.enrollment_epoch,386+ peer: Some(enrollment.peer_node_id.parse::<EndpointId>()?),387+ grants: Some(enrollment.grants),388+ enrolled_at_unix: Some(enrollment.enrolled_at_unix),389+ },390+ None => AuthorizationSnapshot {391+ epoch: state.enrollment_epoch,392+ peer: None,393+ grants: None,394+ enrolled_at_unix: None,395+ },396+ };397+ Ok(AuthorizationInspection {398+ snapshot,399+ storage_present: true,287400 })288401 }289402 @@ -395,7 +508,13 @@ impl AuthorizationStore {
395508 "authorization state exceeds its fixed size bound"396509 );397510 let mut bytes = Vec::with_capacity(metadata.len() as usize);398- file.read_to_end(&mut bytes)?;511+ Read::by_ref(&mut file)512+ .take(MAX_STATE_FILE_LEN.saturating_add(1))513+ .read_to_end(&mut bytes)?;514+ ensure!(515+ bytes.len() as u64 <= MAX_STATE_FILE_LEN,516+ "authorization state exceeds its fixed size bound"517+ );399518 ensure!(!bytes.is_empty(), "authorization state is empty");400519 serde_json::from_slice(&bytes).context("parsing authorization state")401520 }@@ -566,6 +685,38 @@ mod tests {
566685 );567686 }568687 688+ 689+ fn read_only_inspection_never_creates_state_and_retains_enrollment_time() {690+ let root = tempfile::tempdir().unwrap();691+ 692+ fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap();693+ let missing = root.path().join("missing-state");694+ let host = SecretKey::from_bytes(&[7; 32]);695+ let inspection = AuthorizationStore::inspect_existing(&missing, host.public()).unwrap();696+ assert!(!inspection.storage_present);697+ assert_eq!(inspection.snapshot.epoch, 1);698+ assert!(inspection.snapshot.peer.is_none());699+ assert!(!missing.exists());700+701+ let (directory, host, peer, store) = store();702+ let token = token(703+ &store,704+ &host,705+ peer.public(),706+ 1_000,707+ [11; 32],708+ InvitationGrants::ALL,709+ );710+ store711+ .authorize_or_redeem(peer.public(), Some(&token), 1_001)712+ .unwrap();713+ let inspection =714+ AuthorizationStore::inspect_existing(directory.path(), host.public()).unwrap();715+ assert!(inspection.storage_present);716+ assert_eq!(inspection.snapshot.peer, Some(peer.public()));717+ assert_eq!(inspection.snapshot.enrolled_at_unix, Some(1_001));718+ }719+569720 570721 fn peer_host_expiry_and_future_time_fail_closed() {571722 let (_directory, host, peer, store) = store();@@ -1,3 +1,4 @@
1+mod appliance;12 mod audio;23 mod authorization;34 mod clock;@@ -6,6 +7,7 @@ mod cursor;
67 mod identity;78 mod input;89 mod moq_catalog;10+mod secure_state;911 mod server;1012 mod source;1113 @@ -54,6 +56,11 @@ struct Cli {
5456 5557 5658 enum Command {59+ /// Inspect or manage the local Sigil appliance service.60+ Appliance {61+ 62+ command: ApplianceCommand,63+ },5764 /// Create or inspect the persistent Iroh host identity.5865 Identity {5966 @@ -83,6 +90,18 @@ enum Command {
8390 Serve(ServeArgs),8491 }8592 93+94+enum ApplianceCommand {95+ /// Print a versioned, redacted daemon and enrollment snapshot.96+ Status {97+ 98+ config: PathBuf,99+ /// Emit the stable machine-readable schema.100+ 101+ json: bool,102+ },103+}104+86105 87106 enum IdentityCommand {88107 /// Create a new identity using create-new semantics.@@ -222,6 +241,7 @@ async fn main() -> Result<()> {
222241 server::install_panic_hook();223242 224243 match Cli::parse().command {244+ Command::Appliance { command } => appliance_command(command),225245 Command::Identity { command } => identity_command(command),226246 Command::Config { command } => config_command(command).await,227247 Command::Capture { command } => capture_command(command).await,@@ -231,6 +251,16 @@ async fn main() -> Result<()> {
231251 }232252 }233253 254+fn appliance_command(command: ApplianceCommand) -> Result<()> {255+ match command {256+ ApplianceCommand::Status { config, json: _ } => {257+ let status = appliance::collect_status(&config)?;258+ println!("{}", serde_json::to_string(&status)?);259+ }260+ }261+ Ok(())262+}263+234264 fn authorization_store_from_config(235265 path: &Path,236266 ) -> Result<(HostConfig, iroh::SecretKey, AuthorizationStore)> {@@ -571,75 +601,111 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
571601 config.validate()?;572602 config.ensure_runtime_directory()?;573603 let secret = identity::load(&config.identity_path)?;574- let authorization = if args.config.is_some() {575- AuthorizationPolicy::Required(AuthorizationStore::open(576- config.state_path.clone(),577- secret.public(),578- )?)604+ let configured_service = args.config.is_some();605+ let _lifecycle = appliance::LifecycleGuard::acquire(&config.state_path, configured_service)?;606+ let sessions = Arc::new(SessionRegistry::default());607+ let publisher = appliance::RuntimePublisher::start(608+ secret.public(),609+ Arc::clone(&sessions),610+ configured_service,611+ )?;612+ let authorization_result = if configured_service {613+ AuthorizationStore::open(config.state_path.clone(), secret.public())614+ .map(AuthorizationPolicy::Required)579615 } else {580- AuthorizationPolicy::TestPatternProof616+ Ok(AuthorizationPolicy::TestPatternProof)581617 };582- let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {583- let (width, height) = config.test_pattern_dimensions()?;584- config.apply_resolved_dimensions(width, height)?;585- let status = tokio::process::Command::new(&config.ffmpeg_path)586- .arg("-version")587- .stdin(std::process::Stdio::null())588- .stdout(std::process::Stdio::null())589- .stderr(std::process::Stdio::null())590- .status()591- .await592- .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;593- ensure!(594- status.success(),595- "ffmpeg version probe failed with {status}"596- );597- None598- } else {599- let preflight = source::preflight_gamescope_pipewire(&config).await?;600- info!(601- target_object = %preflight.target_object,602- pointer_surface_width = preflight.pointer_surface_dimensions.width,603- pointer_surface_height = preflight.pointer_surface_dimensions.height,604- encoded_width = preflight.video_mode.width,605- encoded_height = preflight.video_mode.height,606- encoded_framerate = preflight.video_mode.framerate,607- "Gamescope PipeWire capture preflight passed"608- );609- config.apply_resolved_dimensions(610- u32::from(preflight.video_mode.width),611- u32::from(preflight.video_mode.height),612- )?;613- if config.audio.is_some() {614- audio::preflight_audio_static(&config).await?;615- info!("PipeWire Opus audio static preflight passed");618+ let authorization = match authorization_result {619+ Ok(authorization) => authorization,620+ Err(error) => {621+ if let Some(publisher) = publisher.as_ref() {622+ let _ = publisher623+ .mark_degraded(appliance::RuntimeErrorCode::AuthorizationState)624+ .await;625+ }626+ return Err(error);616627 }617- Some(preflight.pointer_surface_dimensions)618628 };619- let input_backend = InputBackend::initialize(&config)?;620629 621- let pointer_positions = if config.source == VideoSource::GamescopePipewire622- && config.input_mode == InputMode::Uinput623- {624- let configured_display = config625- .gamescope_pipewire626- .as_ref()627- .and_then(|gamescope| gamescope.xwayland_display.as_deref());628- let pointer_surface_dimensions = pointer_surface_dimensions629- .context("Gamescope capture preflight did not provide pointer surface dimensions")?;630- match PointerPositionTracker::try_initialize(configured_display, pointer_surface_dimensions)630+ let preflight_result: Result<(InputBackend, Option<PointerPositionTracker>)> = async {631+ let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {632+ let (width, height) = config.test_pattern_dimensions()?;633+ config.apply_resolved_dimensions(width, height)?;634+ let status = tokio::process::Command::new(&config.ffmpeg_path)635+ .arg("-version")636+ .stdin(std::process::Stdio::null())637+ .stdout(std::process::Stdio::null())638+ .stderr(std::process::Stdio::null())639+ .status()640+ .await641+ .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;642+ ensure!(643+ status.success(),644+ "ffmpeg version probe failed with {status}"645+ );646+ None647+ } else {648+ let preflight = source::preflight_gamescope_pipewire(&config).await?;649+ info!(650+ target_object = %preflight.target_object,651+ pointer_surface_width = preflight.pointer_surface_dimensions.width,652+ pointer_surface_height = preflight.pointer_surface_dimensions.height,653+ encoded_width = preflight.video_mode.width,654+ encoded_height = preflight.video_mode.height,655+ encoded_framerate = preflight.video_mode.framerate,656+ "Gamescope PipeWire capture preflight passed"657+ );658+ config.apply_resolved_dimensions(659+ u32::from(preflight.video_mode.width),660+ u32::from(preflight.video_mode.height),661+ )?;662+ if config.audio.is_some() {663+ audio::preflight_audio_static(&config).await?;664+ info!("PipeWire Opus audio static preflight passed");665+ }666+ Some(preflight.pointer_surface_dimensions)667+ };668+ let input_backend = InputBackend::initialize(&config)?;669+670+ let pointer_positions = if config.source == VideoSource::GamescopePipewire671+ && config.input_mode == InputMode::Uinput631672 {632- Ok(tracker) => {633- info!("Gamescope Xwayland pointer feedback ready");634- Some(tracker)673+ let configured_display = config674+ .gamescope_pipewire675+ .as_ref()676+ .and_then(|gamescope| gamescope.xwayland_display.as_deref());677+ let pointer_surface_dimensions = pointer_surface_dimensions.context(678+ "Gamescope capture preflight did not provide pointer surface dimensions",679+ )?;680+ match PointerPositionTracker::try_initialize(681+ configured_display,682+ pointer_surface_dimensions,683+ ) {684+ Ok(tracker) => {685+ info!("Gamescope Xwayland pointer feedback ready");686+ Some(tracker)687+ }688+ Err(error) => {689+ warn!(%error, "Gamescope Xwayland pointer feedback unavailable");690+ None691+ }635692 }636- Err(error) => {637- warn!(%error, "Gamescope Xwayland pointer feedback unavailable");638- None693+ } else {694+ None695+ };696+ Ok((input_backend, pointer_positions))697+ }698+ .await;699+ let (input_backend, pointer_positions) = match preflight_result {700+ Ok(preflight) => preflight,701+ Err(error) => {702+ if let Some(publisher) = publisher.as_ref() {703+ let _ = publisher704+ .mark_degraded(appliance::RuntimeErrorCode::Preflight)705+ .await;639706 }707+ return Err(error);640708 }641- } else {642- None643709 };644710 645711 let idle_timeout = CONNECTION_IDLE_TIMEOUT@@ -649,12 +715,22 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
649715 .max_idle_timeout(Some(idle_timeout))650716 .keep_alive_interval(CONNECTION_KEEP_ALIVE_INTERVAL)651717 .build();652- let endpoint = Endpoint::builder(presets::N0)718+ let endpoint = match Endpoint::builder(presets::N0)653719 .secret_key(secret)654720 .transport_config(transport_config)655721 .bind()656722 .await657- .context("binding iroh endpoint")?;723+ {724+ Ok(endpoint) => endpoint,725+ Err(error) => {726+ if let Some(publisher) = publisher.as_ref() {727+ let _ = publisher728+ .mark_degraded(appliance::RuntimeErrorCode::EndpointBind)729+ .await;730+ }731+ return Err(error).context("binding iroh endpoint");732+ }733+ };658734 let node_id = endpoint.id();659735 match tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await {660736 Ok(()) => info!(%node_id, "iroh endpoint is online"),@@ -663,7 +739,6 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
663739 }664740 }665741 666- let sessions = Arc::new(SessionRegistry::default());667742 let moq_origin = Origin::random();668743 let router = Router::builder(endpoint)669744 .accept(@@ -725,11 +800,17 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
725800 AUDIO_ALPN_V1,726801 AudioHandler {727802 config: config.clone(),728- sessions,803+ sessions: Arc::clone(&sessions),729804 },730805 )731806 .spawn();732807 808+ if let Some(publisher) = publisher.as_ref()809+ && let Err(error) = publisher.mark_ready().await810+ {811+ warn!(%error, "publishing ready appliance status failed; continuing service");812+ }813+733814 println!("node_id={node_id}");734815 println!("status=ready");735816 info!(%node_id, source = ?config.source, "sigil host ready");@@ -738,14 +819,38 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
738819 tokio::time::sleep(Duration::from_secs(seconds)).await;739820 info!(seconds, "maximum runtime reached");740821 } else {741- let signal = wait_for_shutdown_signal().await?;822+ let signal = match wait_for_shutdown_signal().await {823+ Ok(signal) => signal,824+ Err(error) => {825+ if let Some(publisher) = publisher.as_ref() {826+ let _ = publisher827+ .mark_degraded(appliance::RuntimeErrorCode::ShutdownSignal)828+ .await;829+ }830+ return Err(error);831+ }832+ };742833 info!(signal, "shutdown signal received");743834 }744835 745- router746- .shutdown()747- .await748- .context("shutting down iroh router")?;836+ if let Some(publisher) = publisher.as_ref()837+ && let Err(error) = publisher.mark_stopping().await838+ {839+ warn!(%error, "publishing stopping appliance status failed; continuing shutdown");840+ }841+ if let Err(error) = router.shutdown().await {842+ if let Some(publisher) = publisher.as_ref() {843+ let _ = publisher844+ .mark_degraded(appliance::RuntimeErrorCode::RouterShutdown)845+ .await;846+ }847+ return Err(error).context("shutting down iroh router");848+ }849+ if let Some(publisher) = publisher850+ && let Err(error) = publisher.finish_clean().await851+ {852+ warn!(%error, "publishing stopped appliance status failed");853+ }749854 Ok(())750855 }751856 @@ -894,6 +999,25 @@ mod tests {
894999 );8951000 }8961001 1002+ 1003+ fn appliance_status_requires_explicit_json_output() {1004+ assert!(1005+ Cli::try_parse_from([1006+ "sigil",1007+ "appliance",1008+ "status",1009+ "--config",1010+ "/tmp/host.toml",1011+ "--json",1012+ ])1013+ .is_ok()1014+ );1015+ assert!(1016+ Cli::try_parse_from(["sigil", "appliance", "status", "--config", "/tmp/host.toml"])1017+ .is_err()1018+ );1019+ }1020+8971021 8981022 fn invitation_file_uses_create_new_semantics() {8991023 let directory = tempfile::tempdir().unwrap();@@ -0,0 +1,239 @@
1+use std::fs::{self, File, OpenOptions};2+use std::io::{Read, Write};3+use std::path::Path;4+5+use anyhow::{Context, Result, ensure};6+7+8+use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};9+10+pub fn ensure_private_directory(path: &Path) -> Result<()> {11+ match fs::symlink_metadata(path) {12+ Ok(_) => validate_private_directory(path),13+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {14+ let mut builder = fs::DirBuilder::new();15+ 16+ builder.mode(0o700);17+ builder18+ .create(path)19+ .with_context(|| format!("creating private state directory {}", path.display()))?;20+ validate_private_directory(path)21+ }22+ Err(error) => Err(error)23+ .with_context(|| format!("inspecting private state directory {}", path.display())),24+ }25+}26+27+pub fn validate_private_directory(path: &Path) -> Result<()> {28+ let metadata = fs::symlink_metadata(path)29+ .with_context(|| format!("inspecting private state directory {}", path.display()))?;30+ ensure!(31+ !metadata.file_type().is_symlink(),32+ "private state directory must not be a symlink"33+ );34+ ensure!(metadata.is_dir(), "private state path must be a directory");35+ 36+ {37+ ensure!(38+ metadata.mode() & 0o077 == 0,39+ "private state directory is accessible by group or others"40+ );41+ ensure!(42+ metadata.uid() == unsafe { libc::geteuid() },43+ "private state directory has the wrong owner"44+ );45+ }46+ Ok(())47+}48+49+pub fn open_lifetime_lock(directory: &Path, file_name: &str) -> Result<File> {50+ validate_private_directory(directory)?;51+ let path = directory.join(file_name);52+ let mut options = OpenOptions::new();53+ options.read(true).write(true).create(true);54+ 55+ options56+ .mode(0o600)57+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);58+ let file = options59+ .open(&path)60+ .with_context(|| format!("opening lifecycle lock {}", path.display()))?;61+ validate_private_file(&file, &path)?;62+63+ 64+ {65+ use std::os::fd::AsRawFd;66+67+ let status = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };68+ ensure!(69+ status == 0,70+ "another Sigil daemon already owns this state directory: {}",71+ std::io::Error::last_os_error()72+ );73+ }74+75+ Ok(file)76+}77+78+pub fn read_bounded(79+ directory: &Path,80+ file_name: &str,81+ maximum_bytes: u64,82+) -> Result<Option<Vec<u8>>> {83+ validate_private_directory(directory)?;84+ let path = directory.join(file_name);85+ let mut options = OpenOptions::new();86+ options.read(true);87+ 88+ options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);89+ let mut file = match options.open(&path) {90+ Ok(file) => file,91+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),92+ Err(error) => {93+ return Err(error).with_context(|| format!("opening private state {}", path.display()));94+ }95+ };96+ validate_private_file(&file, &path)?;97+ let length = file.metadata()?.len();98+ ensure!(99+ length <= maximum_bytes,100+ "private state file exceeds its fixed size bound"101+ );102+ let mut bytes = Vec::with_capacity(length as usize);103+ Read::by_ref(&mut file)104+ .take(maximum_bytes.saturating_add(1))105+ .read_to_end(&mut bytes)?;106+ ensure!(107+ bytes.len() as u64 <= maximum_bytes,108+ "private state file exceeds its fixed size bound"109+ );110+ ensure!(!bytes.is_empty(), "private state file is empty");111+ Ok(Some(bytes))112+}113+114+pub fn atomic_write(115+ directory: &Path,116+ file_name: &str,117+ bytes: &[u8],118+ maximum_bytes: u64,119+) -> Result<()> {120+ validate_private_directory(directory)?;121+ ensure!(122+ (bytes.len() as u64).saturating_add(1) <= maximum_bytes,123+ "private state file exceeds its fixed size bound"124+ );125+ let mut random = [0_u8; 8];126+ getrandom::fill(&mut random).context("generating private state temporary-file name")?;127+ let temporary = directory.join(format!(128+ ".{file_name}.{:016x}.tmp",129+ u64::from_be_bytes(random)130+ ));131+ let destination = directory.join(file_name);132+ let mut options = OpenOptions::new();133+ options.write(true).create_new(true);134+ 135+ options136+ .mode(0o600)137+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);138+ let mut file = options.open(&temporary).with_context(|| {139+ format!(140+ "creating private state temporary file {}",141+ temporary.display()142+ )143+ })?;144+ let result = (|| -> Result<()> {145+ file.write_all(bytes)?;146+ file.write_all(b"\n")?;147+ file.sync_all()?;148+ fs::rename(&temporary, &destination)?;149+ File::open(directory)?.sync_all()?;150+ Ok(())151+ })();152+ if result.is_err() {153+ let _ = fs::remove_file(&temporary);154+ }155+ result156+}157+158+fn validate_private_file(file: &File, path: &Path) -> Result<()> {159+ let metadata = file.metadata()?;160+ ensure!(161+ metadata.is_file(),162+ "{} is not a regular file",163+ path.display()164+ );165+ 166+ {167+ ensure!(168+ metadata.mode() & 0o077 == 0,169+ "{} is accessible by group or others",170+ path.display()171+ );172+ ensure!(173+ metadata.uid() == unsafe { libc::geteuid() },174+ "{} has the wrong owner",175+ path.display()176+ );177+ }178+ Ok(())179+}180+181+182+mod tests {183+ use super::*;184+185+ 186+ use std::os::unix::fs::{PermissionsExt, symlink};187+188+ fn private_directory() -> tempfile::TempDir {189+ let directory = tempfile::tempdir().unwrap();190+ 191+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();192+ directory193+ }194+195+ 196+ fn lifetime_lock_is_exclusive_and_reacquirable() {197+ let directory = private_directory();198+ let first = open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();199+ assert!(open_lifetime_lock(directory.path(), "daemon-v1.lock").is_err());200+ drop(first);201+ open_lifetime_lock(directory.path(), "daemon-v1.lock").unwrap();202+ }203+204+ 205+ fn atomic_state_round_trips_with_a_fixed_bound() {206+ let directory = private_directory();207+ atomic_write(directory.path(), "status.json", b"{\"ok\":true}", 64).unwrap();208+ assert_eq!(209+ read_bounded(directory.path(), "status.json", 64)210+ .unwrap()211+ .unwrap(),212+ b"{\"ok\":true}\n"213+ );214+ assert!(read_bounded(directory.path(), "status.json", 4).is_err());215+ 216+ assert_eq!(217+ fs::metadata(directory.path().join("status.json"))218+ .unwrap()219+ .permissions()220+ .mode()221+ & 0o777,222+ 0o600223+ );224+ }225+226+ 227+ 228+ fn rejects_symlink_and_permissive_state() {229+ let directory = private_directory();230+ let target = directory.path().join("target");231+ fs::write(&target, b"state").unwrap();232+ fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();233+ symlink(&target, directory.path().join("status.json")).unwrap();234+ assert!(read_bounded(directory.path(), "status.json", 64).is_err());235+236+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();237+ assert!(open_lifetime_lock(directory.path(), "daemon-v1.lock").is_err());238+ }239+}@@ -1048,6 +1048,13 @@ impl Default for SessionRegistry {
10481048 }10491049 10501050 impl SessionRegistry {1051+ pub fn has_session(&self) -> bool {1052+ self.active1053+ .lock()1054+ .expect("session registry poisoned")1055+ .is_some()1056+ }1057+10511058 fn claim(10521059 self: &Arc<Self>,10531060 remote: EndpointId,@@ -6242,10 +6249,12 @@ mod tests {
62426249 62436250 fn only_one_remote_can_hold_session() {62446251 let sessions = Arc::new(SessionRegistry::default());6252+ assert!(!sessions.has_session());62456253 let nonce = [7; 16];62466254 let first = sessions62476255 .claim(endpoint(1), nonce, InvitationGrants::ALL)62486256 .unwrap();6257+ assert!(sessions.has_session());62496258 assert!(62506259 sessions62516260 .claim(endpoint(2), nonce, InvitationGrants::ALL)@@ -6272,6 +6281,7 @@ mod tests {
62726281 .is_err()62736282 );62746283 drop(audio);6284+ assert!(!sessions.has_session());62756285 assert!(62766286 sessions62776287 .claim(endpoint(2), nonce, InvitationGrants::ALL)@@ -0,0 +1,66 @@
1+# Sigil appliance status v12+3+`sigil appliance status` is the local, read-only contract for appliance4+management surfaces such as the planned Decky Loader plugin. Decky remains an5+unprivileged controller over the existing `sigil-host.service`; it does not6+launch Sigil as a child, host streaming itself, or add a network listener.7+8+```bash9+sigil appliance status \10+ --config ~/.config/sigil-spark/host.toml \11+ --json12+```13+14+The command validates the owner-only configuration and identity, inspects the15+authorization document without creating files or taking its writer lock, and16+combines that durable state with Sigil's bounded runtime heartbeat. Output is a17+single JSON document with `schema_version: 1`.18+19+The public document includes only:20+21+- Sigil version and overall `ready`, `active`, `degraded`, or `unavailable`22+ state.23+- Redacted host and enrolled-Portal fingerprints.24+- Canonically ordered enrollment grants, epoch, and enrollment time.25+- Runtime freshness, daemon state, uptime, session state, and a closed set of26+ error codes.27+28+It never includes complete endpoint IDs, invitation material, replay-ledger29+entries, session nonces, addresses, PipeWire object names, filesystem paths, or30+free-form error text.31+32+## Runtime authority33+34+While serving, Sigil holds an exclusive nonblocking lock at35+`<state_path>/daemon-v1.lock`. A second daemon using the same state directory36+fails before capture, input, or network initialization. The zero-length lock37+file remains in place; the open file descriptor is the lifetime guard. When an38+XDG runtime directory is available, Sigil also holds39+`$XDG_RUNTIME_DIR/sigil-spark/daemon-global-v1.lock`. That per-user lock enforces40+the product's one-daemon boundary even if two commands reference different41+state directories. Configured production service mode requires a valid private42+`XDG_RUNTIME_DIR` and fails before capture or networking if it is unavailable.43+Only the explicit direct test-pattern proof may fall back to its per-state lock44+without publishing a heartbeat.45+46+Sigil atomically publishes47+`$XDG_RUNTIME_DIR/sigil-spark/daemon-status-v1.json` at most once per two-second48+heartbeat, plus explicit lifecycle transitions. Keeping the heartbeat in the49+per-user runtime directory prevents high-frequency process state from entering50+durable backups or surviving a reboot. The status file and its child directory51+must be regular, owner-only objects. Reads and writes are bounded, refuse52+symlinks, and use no-follow opens. Heartbeat I/O runs outside Tokio's async53+worker threads.54+55+The status command treats a missing runtime document as `offline`. A heartbeat56+older than ten seconds is `stale`; stale data never claims a live session or57+live uptime. Clean shutdown writes `stopping`, shuts down the Iroh router, and58+then writes `stopped`. A crash leaves a bounded document that naturally becomes59+stale, while the runtime directory itself is discarded at reboot. Every60+runtime document is bound to the current host identity before its state is61+combined with durable enrollment data.62+63+The Decky backend will merge this document with `systemctl --user` unit state.64+Service control, transactional configuration, enrollment reset, and identity65+factory reset are separate follow-up contracts; they are intentionally not66+part of status v1.@@ -0,0 +1,163 @@
1+2+3+set -euo pipefail4+5+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6+repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"7+sigil="${1:-$repo_dir/target/debug/sigil}"8+ffmpeg="$(command -v ffmpeg || true)"9+10+[[ -x "$sigil" ]] || {11+ printf 'sigil executable is missing: %s\n' "$sigil" >&212+ exit 113+}14+[[ -n "$ffmpeg" ]] || {15+ printf 'ffmpeg is required for the appliance status integration proof\n' >&216+ exit 117+}18+19+temp_parent="${TMPDIR:-/tmp}"20+temp_parent="${temp_parent%/}"21+temp_root="$(mktemp -d "$temp_parent/sigil-appliance-status.XXXXXX")"22+host_pid=''23+24+cleanup() {25+ local status=$?26+ trap - EXIT INT TERM HUP27+ if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then28+ kill "$host_pid" 2>/dev/null || true29+ wait "$host_pid" 2>/dev/null || true30+ fi31+ case "$temp_root" in32+ "$temp_parent"/sigil-appliance-status.??????) rm -rf -- "$temp_root" ;;33+ esac34+ exit "$status"35+}36+trap cleanup EXIT INT TERM HUP37+38+identity_directory="$temp_root/identity"39+state_directory="$temp_root/state"40+runtime_directory="$temp_root/runtime"41+identity_path="$identity_directory/host.key"42+config_path="$temp_root/host.toml"43+host_log="$temp_root/host.log"44+second_log="$temp_root/second.log"45+live_status="$temp_root/live-status.json"46+stopped_status="$temp_root/stopped-status.json"47+48+mkdir -m 0700 "$identity_directory" "$state_directory" "$runtime_directory"49+"$sigil" identity init --output "$identity_path" >"$temp_root/identity.log"50+host_node_id="$(sed -n 's/^node_id=//p' "$temp_root/identity.log")"51+[[ -n "$host_node_id" ]] || {52+ printf 'identity initialization did not return a node ID\n' >&253+ exit 154+}55+56+printf '%s\n' \57+ "identity_path = \"$identity_path\"" \58+ "state_path = \"$state_directory\"" \59+ 'source = "test-pattern"' \60+ 'framerate = 60' \61+ 'codec = "h264"' \62+ 'input_mode = "log"' \63+ "ffmpeg_path = \"$ffmpeg\"" \64+ >"$config_path"65+chmod 0600 "$config_path"66+67+XDG_RUNTIME_DIR="$runtime_directory" \68+ "$sigil" serve \69+ --config "$config_path" \70+ --max-runtime-seconds 4 \71+ >"$host_log" 2>&1 &72+host_pid=$!73+74+ready='false'75+for _ in $(seq 1 40); do76+ if grep -Fq 'status=ready' "$host_log"; then77+ ready='true'78+ break79+ fi80+ if ! kill -0 "$host_pid" 2>/dev/null; then81+ break82+ fi83+ sleep 0.584+done85+if [[ "$ready" != 'true' ]]; then86+ printf 'Sigil did not become ready during appliance status proof\n' >&287+ sed -n '1,160p' "$host_log" >&288+ exit 189+fi90+91+XDG_RUNTIME_DIR="$runtime_directory" \92+ "$sigil" appliance status --config "$config_path" --json >"$live_status"93+94+if XDG_RUNTIME_DIR="$runtime_directory" \95+ "$sigil" serve \96+ --identity "$identity_path" \97+ --source test-pattern \98+ --state-path "$temp_root/other-state" \99+ --ffmpeg "$ffmpeg" \100+ --max-runtime-seconds 1 \101+ >"$second_log" 2>&1; then102+ printf 'a second per-user Sigil daemon unexpectedly started\n' >&2103+ exit 1104+fi105+grep -Fq 'another Sigil daemon already owns this state directory' "$second_log"106+107+python3 - "$live_status" "$host_node_id" <<'PY'108+import json109+import pathlib110+import sys111+112+path = pathlib.Path(sys.argv[1])113+raw = path.read_text(encoding="utf-8")114+status = json.loads(raw)115+host_node_id = sys.argv[2]116+117+if status.get("schema_version") != 1:118+ raise SystemExit("unexpected appliance status schema")119+if status.get("overall") != "ready":120+ raise SystemExit(f"unexpected live overall state: {status.get('overall')}")121+if status.get("runtime", {}).get("daemon") != "ready":122+ raise SystemExit("live daemon state is not ready")123+if status.get("runtime", {}).get("session") != "inactive":124+ raise SystemExit("live proof unexpectedly reports a session")125+if status.get("enrollment", {}).get("state") != "none":126+ raise SystemExit("direct proof unexpectedly reports an enrollment")127+if host_node_id in raw:128+ raise SystemExit("appliance status leaked the complete host node ID")129+fingerprint = status.get("identity", {}).get("host_fingerprint", "")130+if len(fingerprint) != 17 or "…" not in fingerprint:131+ raise SystemExit("host fingerprint is not bounded and redacted")132+PY133+134+wait "$host_pid"135+host_pid=''136+XDG_RUNTIME_DIR="$runtime_directory" \137+ "$sigil" appliance status --config "$config_path" --json >"$stopped_status"138+139+python3 - "$stopped_status" <<'PY'140+import json141+import pathlib142+import sys143+144+status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))145+runtime = status.get("runtime", {})146+if runtime.get("daemon") != "stopped":147+ raise SystemExit(f"clean shutdown did not publish stopped: {runtime}")148+if runtime.get("session") != "inactive":149+ raise SystemExit("stopped daemon did not report an inactive session")150+if runtime.get("uptime_ms") is not None:151+ raise SystemExit("stopped daemon retained a live uptime")152+PY153+154+if env -u XDG_RUNTIME_DIR \155+ "$sigil" serve --config "$config_path" --max-runtime-seconds 1 \156+ >"$temp_root/missing-runtime.log" 2>&1; then157+ printf 'configured Sigil unexpectedly started without XDG_RUNTIME_DIR\n' >&2158+ exit 1159+fi160+grep -Fq 'configured Sigil service requires a valid XDG_RUNTIME_DIR' \161+ "$temp_root/missing-runtime.log"162+163+printf 'sigil appliance status integration proof passed\n'@@ -89,6 +89,7 @@ while IFS= read -r frontend_source; do
8989 done < <(find portal -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)9090 node --test portal/*.test.mjs9191 92+cargo build --locked -p sigil-host --bin sigil9293 find scripts -type f -name '*.sh' -exec shellcheck {} +9394 while IFS= read -r script_test; do9495 "$script_test"