1mod appliance;2mod audio;3mod authorization;4mod clock;5mod config;6mod config_management;7mod cursor;8mod identity;9mod input;10mod moq_catalog;11mod secure_state;12mod server;13mod source;1415use std::fs::OpenOptions;16use std::io::Write as _;17use std::path::{Path, PathBuf};18use std::process::ExitCode;19use std::sync::Arc;20use std::time::{Duration, Instant};212223use std::os::unix::fs::OpenOptionsExt;2425use anyhow::{Context, Result, ensure};26use clap::{Args, Parser, Subcommand, ValueEnum};27use iroh::endpoint::{QuicTransportConfig, presets};28use iroh::protocol::Router;29use iroh::{Endpoint, EndpointId};30use moq_net::Origin;31use sigil_protocol::{32 AUDIO_ALPN_V1, CONTROL_ALPN_V1, INPUT_ALPN_V1, InvitationGrants, MAX_INVITATION_TTL_SECS,33 MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, SignedInvitation,34};35use tracing::{info, warn};36use tracing_subscriber::EnvFilter;3738use crate::authorization::{39 AuthorizationPolicy, AuthorizationStore, grant_names, unix_timestamp_now,40};41use crate::config::{ConfigRevision, HostConfig, InputMode, VideoSource};42use crate::cursor::PointerPositionTracker;43use crate::input::InputBackend;44use crate::server::{45 AudioHandler, AuthorizedMoqHandler, ControlHandler, InputHandler, MediaFeedbackHandler,46 MediaV3Handler, SessionRegistry,47};4849const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5);50const CONNECTION_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(1);51525354struct Cli {55 56 command: Command,57}585960enum Command {61 /// Inspect or manage the local Sigil appliance service.62 Appliance {63 64 command: ApplianceCommand,65 },66 /// Create or inspect the persistent Iroh host identity.67 Identity {68 69 command: IdentityCommand,70 },71 /// Validate host configuration without starting network services.72 Config {73 74 command: ConfigCommand,75 },76 /// Run a bounded capture probe.77 Capture {78 79 command: CaptureCommand,80 },81 /// Create a short-lived, peer-bound Portal enrollment invitation.82 Invitation {83 84 command: InvitationCommand,85 },86 /// Inspect or revoke the one enrolled Portal peer.87 Enrollment {88 89 command: EnrollmentCommand,90 },91 /// Run the headless host daemon in the foreground.92 Serve(ServeArgs),93}949596enum ApplianceCommand {97 /// Print a versioned, redacted daemon and enrollment snapshot.98 Status {99 100 config: PathBuf,101 /// Emit the stable machine-readable schema.102 103 json: bool,104 /// Public status schema. Version 1 remains the compatibility default.105 106 schema_version: u16,107 },108 /// Revoke the enrolled Portal and invalidate every outstanding invitation.109 EnrollmentReset {110 111 config: PathBuf,112 /// Redacted host fingerprint from `appliance status`.113 114 expected_host_fingerprint: String,115 /// Emit the stable machine-readable schema.116 117 json: bool,118 },119 /// Inspect or transactionally update the validated host configuration.120 Config {121 122 command: ApplianceConfigCommand,123 },124}125126127enum ApplianceConfigCommand {128 /// Show only the controller-editable configuration projection.129 Show {130 131 config: PathBuf,132 133 json: bool,134 },135 /// Validate a bounded JSON request from stdin without writing.136 Validate {137 138 config: PathBuf,139 140 json: bool,141 },142 /// Stage and atomically install a validated candidate while Sigil is stopped.143 Set {144 145 config: PathBuf,146 /// XDG runtime root used by the stopped service; Sigil appends `sigil-spark`.147 148 runtime_dir: Option<PathBuf>,149 150 json: bool,151 },152 /// Commit a candidate proven by one exact stopped daemon instance.153 Commit {154 155 config: PathBuf,156 157 transaction: String,158 159 expected_instance: String,160 /// XDG runtime root used by the candidate service; Sigil appends `sigil-spark`.161 162 runtime_dir: Option<PathBuf>,163 164 json: bool,165 },166 /// Restore the byte-exact prior configuration while Sigil is stopped.167 Rollback {168 169 config: PathBuf,170 171 transaction: String,172 173 json: bool,174 },175}176177178enum IdentityCommand {179 /// Create a new identity using create-new semantics.180 Init {181 182 output: PathBuf,183 },184 /// Print the public node ID for an existing identity.185 Show {186 187 identity: PathBuf,188 },189}190191192enum ConfigCommand {193 /// Parse, validate, and check the referenced identity.194 Check {195 196 config: PathBuf,197 },198}199200201enum CaptureCommand {202 /// Consume a finite number of frames without starting iroh.203 Probe(CaptureProbeArgs),204 /// Validate one GstVA encoder against an exact render node.205 EncoderPreflight(EncoderPreflightArgs),206}207208209struct EncoderPreflightArgs {210 211 vaapi_encoder: String,212 213 vaapi_render_node: PathBuf,214 215 rate_control: Vec<RateControlArg>,216}217218219enum RateControlArg {220 Cbr,221 Cqp,222}223224225enum InvitationCommand {226 /// Create a signed invitation file using create-new semantics.227 Create {228 229 config: PathBuf,230 231 peer: EndpointId,232 233 expires_in_seconds: u64,234 235 pointer_keyboard: bool,236 237 gamepad: bool,238 239 output: PathBuf,240 /// Also print the short-lived invitation as a goq:// deep link.241 242 print_deep_link: bool,243 },244}245246247enum EnrollmentCommand {248 /// Print the current enrollment without exposing secret material.249 Show {250 251 config: PathBuf,252 },253 /// Remove the enrolled peer and invalidate every outstanding invitation.254 Revoke {255 256 config: PathBuf,257 /// Redacted host fingerprint from `appliance status`.258 259 expected_host_fingerprint: String,260 /// Emit the stable machine-readable schema.261 262 json: bool,263 },264}265266267enum SourceArg {268 TestPattern,269 GamescopePipewire,270}271272impl From<SourceArg> for VideoSource {273 fn from(value: SourceArg) -> Self {274 match value {275 SourceArg::TestPattern => Self::TestPattern,276 SourceArg::GamescopePipewire => Self::GamescopePipewire,277 }278 }279}280281282struct CaptureProbeArgs {283 284 source: SourceArg,285 286 frames: u32,287 288 expect_size: Option<(u32, u32)>,289 /// Fail unless the bounded proof sustains at least this encoded-frame rate.290 291 minimum_fps: Option<f64>,292 /// Strict host configuration; required for gamescope-pipewire.293 294 config: Option<PathBuf>,295}296297298299300301)302))]303struct ServeArgs {304 /// Strict TOML host configuration.305 )]306 config: Option<PathBuf>,307 /// INSECURE: admit an un-enrolled Portal peer for configured-host testing.308 309 dev_allow_unauthorized: bool,310 /// Host identity for direct proof-mode configuration.311 312 identity: Option<PathBuf>,313 /// Video source for direct proof-mode configuration.314 315 source: Option<SourceArg>,316 /// Writable state directory for direct proof mode.317 318 state_path: Option<PathBuf>,319 320 width: u32,321 322 height: u32,323 324 framerate: u32,325 326 ffmpeg: PathBuf,327 /// Exit after this many seconds; intended for bounded automation.328 329 max_runtime_seconds: Option<u64>,330}331332const MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS: u64 = 3_600;333334enum CliFailure {335 General(anyhow::Error),336 Management(config_management::ManagementError),337}338339impl From<anyhow::Error> for CliFailure {340 fn from(error: anyhow::Error) -> Self {341 Self::General(error)342 }343}344345type CliResult<T> = std::result::Result<T, CliFailure>;346347348async fn main() -> ExitCode {349 tracing_subscriber::fmt()350 .with_env_filter(351 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),352 )353 .with_target(false)354 .init();355 server::install_panic_hook();356357 match run().await {358 Ok(()) => ExitCode::SUCCESS,359 Err(CliFailure::Management(error)) => {360 let response = serde_json::to_string(&error.response())361 .expect("serializing a fixed management error cannot fail");362 eprintln!("{response}");363 ExitCode::FAILURE364 }365 Err(CliFailure::General(error)) => {366 eprintln!("error: {error:#}");367 ExitCode::FAILURE368 }369 }370}371372async fn run() -> CliResult<()> {373 match Cli::parse().command {374 Command::Appliance { command } => appliance_command(command),375 Command::Identity { command } => identity_command(command).map_err(Into::into),376 Command::Config { command } => config_command(command).await.map_err(Into::into),377 Command::Capture { command } => capture_command(command).await.map_err(Into::into),378 Command::Invitation { command } => invitation_command(command).map_err(Into::into),379 Command::Enrollment { command } => enrollment_command(command).map_err(Into::into),380 Command::Serve(args) => serve_command(args).await.map_err(Into::into),381 }382}383384fn appliance_command(command: ApplianceCommand) -> CliResult<()> {385 match command {386 ApplianceCommand::Status {387 config,388 json: _,389 schema_version,390 } => {391 let status = appliance::collect_status(&config).map_err(CliFailure::from)?;392 println!("{}", appliance::status_json(&status, schema_version)?);393 }394 ApplianceCommand::EnrollmentReset {395 config,396 expected_host_fingerprint,397 json: _,398 } => {399 let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)400 .map_err(CliFailure::from)?;401 println!(402 "{}",403 serde_json::to_string(&result).map_err(anyhow::Error::from)?404 );405 }406 ApplianceCommand::Config { command } => {407 let result = match command {408 ApplianceConfigCommand::Show { config, json: _ } => serde_json::to_value(409 config_management::show(&config).map_err(CliFailure::Management)?,410 ),411 ApplianceConfigCommand::Validate { config, json: _ } => {412 let request = config_management::read_request(std::io::stdin().lock())413 .map_err(CliFailure::Management)?;414 serde_json::to_value(415 config_management::validate(&config, &request)416 .map_err(CliFailure::Management)?,417 )418 }419 ApplianceConfigCommand::Set {420 config,421 runtime_dir,422 json: _,423 } => {424 let request = config_management::read_request(std::io::stdin().lock())425 .map_err(CliFailure::Management)?;426 serde_json::to_value(427 config_management::set(&config, &request, runtime_dir.as_deref())428 .map_err(CliFailure::Management)?,429 )430 }431 ApplianceConfigCommand::Commit {432 config,433 transaction,434 expected_instance,435 runtime_dir,436 json: _,437 } => serde_json::to_value(438 config_management::commit(439 &config,440 &transaction,441 &expected_instance,442 runtime_dir.as_deref(),443 )444 .map_err(CliFailure::Management)?,445 ),446 ApplianceConfigCommand::Rollback {447 config,448 transaction,449 json: _,450 } => serde_json::to_value(451 config_management::rollback(&config, &transaction)452 .map_err(CliFailure::Management)?,453 ),454 }455 .map_err(anyhow::Error::from)?;456 println!("{result}");457 }458 }459 Ok(())460}461462fn authorization_snapshot_from_config(463 path: &Path,464) -> Result<(465 HostConfig,466 iroh::SecretKey,467 authorization::AuthorizationSnapshot,468)> {469 let config = HostConfig::load(path)?;470 config.ensure_runtime_directory()?;471 let secret = identity::load(&config.identity_path)?;472 let inspection =473 AuthorizationStore::inspect_existing(config.state_path.clone(), secret.public())?;474 Ok((config, secret, inspection.snapshot))475}476477fn invitation_command(command: InvitationCommand) -> Result<()> {478 match command {479 InvitationCommand::Create {480 config,481 peer,482 expires_in_seconds,483 pointer_keyboard,484 gamepad,485 output,486 print_deep_link,487 } => {488 let (_config, secret, snapshot) = authorization_snapshot_from_config(&config)?;489 let mut grants = InvitationGrants::VIEW;490 if pointer_keyboard {491 grants = grants.union(InvitationGrants::POINTER_KEYBOARD);492 }493 if gamepad {494 grants = grants.union(InvitationGrants::GAMEPAD);495 }496 let mut nonce = [0_u8; 32];497 getrandom::fill(&mut nonce).context("generating invitation nonce")?;498 let now = unix_timestamp_now()?;499 let claims = AuthorizationStore::issue_claims_from_snapshot(500 secret.public(),501 snapshot,502 peer,503 grants,504 expires_in_seconds,505 now,506 nonce,507 )?;508 let expires_at = claims.expires_at_unix;509 let invitation = SignedInvitation::issue(claims, &secret.to_bytes())?;510 let token = invitation.encode();511 write_invitation_file(&output, &token)?;512 println!("invitation={}", output.display());513 println!("host_node_id={}", secret.public());514 println!("peer_node_id={peer}");515 println!("grants={}", grant_names(grants));516 println!("expires_at_unix={expires_at}");517 if print_deep_link {518 println!("deep_link=goq://invite/{token}");519 }520 }521 }522 Ok(())523}524525fn enrollment_command(command: EnrollmentCommand) -> Result<()> {526 match command {527 EnrollmentCommand::Show { config } => {528 let (_config, _secret, snapshot) = authorization_snapshot_from_config(&config)?;529 println!("enrollment_epoch={}", snapshot.epoch);530 match (snapshot.peer, snapshot.grants) {531 (Some(peer), Some(grants)) => {532 println!("enrollment=active");533 println!("peer_node_id={peer}");534 println!("grants={}", grant_names(grants));535 }536 (None, None) => println!("enrollment=none"),537 _ => unreachable!("validated authorization state is internally consistent"),538 }539 }540 EnrollmentCommand::Revoke {541 config,542 expected_host_fingerprint,543 json: _,544 } => {545 let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;546 println!("{}", serde_json::to_string(&result)?);547 }548 }549 Ok(())550}551552fn write_invitation_file(path: &Path, token: &str) -> Result<()> {553 let mut options = OpenOptions::new();554 options.write(true).create_new(true);555 556 options557 .mode(0o600)558 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);559 let mut file = options560 .open(path)561 .with_context(|| format!("creating invitation {}", path.display()))?;562 file.write_all(token.as_bytes())?;563 file.write_all(b"\n")?;564 file.sync_all()?;565 Ok(())566}567568fn identity_command(command: IdentityCommand) -> Result<()> {569 match command {570 IdentityCommand::Init { output } => {571 let secret = identity::init(&output)?;572 println!("node_id={}", secret.public());573 println!("identity={}", identity::display_path(&output).display());574 }575 IdentityCommand::Show { identity: path } => {576 let secret = identity::load(&path)?;577 println!("node_id={}", secret.public());578 }579 }580 Ok(())581}582583async fn config_command(command: ConfigCommand) -> Result<()> {584 match command {585 ConfigCommand::Check { config: path } => {586 let config = HostConfig::load(&path)?;587 let secret = identity::load(&config.identity_path)?;588 let _input_backend = InputBackend::initialize(&config)?;589 println!("config=ok");590 println!("node_id={}", secret.public());591 println!("source={:?}", config.source);592 println!("input={:?}", config.input_mode);593 if config.source == VideoSource::GamescopePipewire {594 let preflight = source::preflight_gamescope_pipewire(&config).await?;595 println!("pipewire_target_object={}", preflight.target_object);596 println!(597 "pointer_surface={}x{}",598 preflight.pointer_surface_dimensions.width,599 preflight.pointer_surface_dimensions.height600 );601 println!(602 "encoded_mode={}x{}@{}",603 preflight.video_mode.width,604 preflight.video_mode.height,605 preflight.video_mode.framerate606 );607 println!("capture_preflight=ok");608 if config.audio.is_some() {609 let target = audio::preflight_audio(&config).await?;610 println!("audio_pipewire_target_object={target}");611 println!("audio_capture_preflight=ok");612 }613 }614 }615 }616 Ok(())617}618619async fn capture_command(command: CaptureCommand) -> Result<()> {620 match command {621 CaptureCommand::EncoderPreflight(args) => {622 let rate_controls = args623 .rate_control624 .into_iter()625 .map(|rate_control| match rate_control {626 RateControlArg::Cbr => crate::config::VaapiRateControl::Cbr,627 RateControlArg::Cqp => crate::config::VaapiRateControl::Cqp,628 })629 .collect::<Vec<_>>();630 source::probe_encoder_properties(631 &args.vaapi_encoder,632 &args.vaapi_render_node,633 &rate_controls,634 )?;635 println!("encoder_preflight=ok");636 Ok(())637 }638 CaptureCommand::Probe(args) => {639 ensure!(640 (1..=36_000).contains(&args.frames),641 "--frames must be between 1 and 36000"642 );643 if let Some((width, height)) = args.expect_size {644 ensure!(width > 0 && height > 0, "expected size must be non-zero");645 }646 match args.source {647 SourceArg::TestPattern => probe_test_pattern(args).await,648 SourceArg::GamescopePipewire => probe_gamescope_pipewire(args).await,649 }650 }651 }652}653654async fn probe_test_pattern(args: CaptureProbeArgs) -> Result<()> {655 ensure!(656 args.config.is_none(),657 "--config is only accepted for gamescope-pipewire capture"658 );659 let (width, height) = args.expect_size.unwrap_or((1280, 800));660 let config = HostConfig {661 identity_path: PathBuf::from("unused-by-capture-probe"),662 state_path: PathBuf::from("."),663 source: VideoSource::TestPattern,664 width: Some(width),665 height: Some(height),666 framerate: 60,667 codec: "h264".into(),668 input_mode: InputMode::Disabled,669 uinput: None,670 ffmpeg_path: PathBuf::from("ffmpeg"),671 gamescope_pipewire: None,672 audio: None,673 };674 config.validate()?;675676 let configured_framerate = config.framerate;677 validate_capture_probe_args(&args, configured_framerate)?;678 let source = source::spawn_test_pattern(config, clock::SessionClock::start());679 consume_capture_probe(args, source, None, "test-pattern", configured_framerate).await680}681682async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {683 let config_path = args684 .config685 .as_ref()686 .context("--config is required for gamescope-pipewire capture")?;687 let config = HostConfig::load(config_path)?;688 ensure!(689 config.source == VideoSource::GamescopePipewire,690 "capture config source must be gamescope-pipewire"691 );692 config.ensure_runtime_directory()?;693 let _lifecycle = appliance::LifecycleGuard::acquire(&config.state_path, true)694 .context("acquiring exclusive Gamescope capture lifecycle")?;695 let preflight = source::preflight_gamescope_pipewire(&config).await?;696 let observed = (697 u32::from(preflight.video_mode.width),698 u32::from(preflight.video_mode.height),699 );700 if let Some(expected) = args.expect_size {701 ensure!(702 expected == observed,703 "expected size {}x{} does not match resolved encoded size {}x{}",704 expected.0,705 expected.1,706 observed.0,707 observed.1708 );709 }710 println!(711 "resolved_encoded_mode={}x{}@{}",712 observed.0, observed.1, preflight.video_mode.framerate713 );714 let configured_framerate = preflight.video_mode.framerate;715 validate_capture_probe_args(&args, configured_framerate)?;716 let source::ProbeEncodedSource { source, shutdown } =717 source::spawn_gamescope_pipewire_for_probe(718 config,719 clock::SessionClock::start(),720 preflight,721 )?;722 consume_capture_probe(723 args,724 source,725 shutdown,726 "Gamescope PipeWire",727 configured_framerate,728 )729 .await730}731732async fn consume_capture_probe(733 args: CaptureProbeArgs,734 source: source::EncodedSource,735 shutdown: Option<tokio::sync::oneshot::Sender<()>>,736 source_name: &str,737 configured_framerate: u32,738) -> Result<()> {739 debug_assert!(validate_capture_probe_args(&args, configured_framerate).is_ok());740 let source::EncodedSource {741 frames: mut receiver,742 current_gop,743 task,744 pointer_surface_dimensions: _,745 encoder_control,746 } = source;747 drop(current_gop);748 drop(encoder_control);749 let started = Instant::now();750 let mut received = 0_u32;751 let mut keyframes = 0_u32;752 let mut decodable_keyframes = 0_u32;753 let mut encoded_bytes = 0_u64;754 let mut last_sequence = None;755 let mut dropped = 0_u64;756 let mut max_post_encode_queue_age_micros = 0_u128;757758 let capture_result: Result<()> = async {759 while received < args.frames {760 tokio::time::timeout(Duration::from_secs(5), receiver.changed())761 .await762 .with_context(|| format!("timed out waiting for encoded {source_name} frame"))?763 .with_context(|| format!("{source_name} encoder stopped"))?;764 let Some(frame) = receiver.borrow_and_update().clone() else {765 continue;766 };767 max_post_encode_queue_age_micros =768 max_post_encode_queue_age_micros.max(frame.observed_at.elapsed().as_micros());769 if let Some(previous) = last_sequence {770 dropped += frame.sequence.saturating_sub(previous + 1);771 }772 last_sequence = Some(frame.sequence);773 keyframes += u32::from(frame.keyframe);774 decodable_keyframes += u32::from(frame.keyframe && frame.codec_config);775 encoded_bytes = encoded_bytes.saturating_add(frame.data.len() as u64);776 received += 1;777 }778 Ok(())779 }780 .await;781782 let elapsed = started.elapsed();783 drop(receiver);784 if let Some(shutdown) = shutdown {785 let _ = shutdown.send(());786 }787 let source_result = task788 .await789 .with_context(|| format!("joining {source_name} capture task"))?;790 match (capture_result, source_result) {791 (Ok(()), Ok(())) => {}792 (Err(error), Ok(())) => return Err(error),793 (Ok(()), Err(error)) => return Err(error),794 (Err(error), Err(source_error)) => {795 return Err(error.context(format!(796 "{source_name} capture teardown also failed: {source_error:#}"797 )));798 }799 }800 ensure!(keyframes > 0, "capture probe produced no H.264 keyframe");801 ensure!(802 decodable_keyframes > 0,803 "capture probe produced no keyframe with SPS/PPS codec configuration"804 );805 ensure!(806 dropped == 0,807 "capture probe dropped {dropped} frames after encode and before its consumer"808 );809 let observed_fps = f64::from(received) / elapsed.as_secs_f64();810 if let Some(minimum_fps) = args.minimum_fps {811 ensure!(812 observed_fps >= minimum_fps,813 "capture probe sustained only {observed_fps:.3} fps; required at least {minimum_fps:.3} fps"814 );815 }816 println!("probe=ok");817 println!("frames={received}");818 println!("keyframes={keyframes}");819 println!("decodable_keyframes={decodable_keyframes}");820 println!("dropped_after_encode_before_probe_consumer={dropped}");821 println!("observed_fps={observed_fps:.3}");822 println!("max_post_encode_queue_age_micros={max_post_encode_queue_age_micros}");823 println!("encoded_bytes={encoded_bytes}");824 println!("elapsed_ms={}", elapsed.as_millis());825 Ok(())826}827828fn validate_capture_probe_args(args: &CaptureProbeArgs, configured_framerate: u32) -> Result<()> {829 if let Some(minimum_fps) = args.minimum_fps {830 ensure!(831 minimum_fps.is_finite()832 && minimum_fps > 0.0833 && minimum_fps <= f64::from(configured_framerate),834 "--minimum-fps must be finite, positive, and no greater than configured framerate {configured_framerate}"835 );836 }837 Ok(())838}839840/// Report the failing startup/shutdown step as a degraded appliance status841/// before surfacing the error. Every fallible step between publisher creation842/// and clean shutdown must flow through this helper so no failure class can843/// exit the daemon without updating the published status.844async fn degrade_on_error<T>(845 publisher: Option<&appliance::RuntimePublisher>,846 code: appliance::RuntimeErrorCode,847 result: Result<T>,848) -> Result<T> {849 if result.is_err()850 && let Some(publisher) = publisher851 {852 let _ = publisher.mark_degraded(code).await;853 }854 result855}856857async fn serve_command(args: ServeArgs) -> Result<()> {858 validate_development_auth_bypass(args.dev_allow_unauthorized, args.max_runtime_seconds)?;859 let configured_service = args.config.is_some();860 let (config, loaded_config_revision, _lifecycle) = if let Some(config_path) = &args.config {861 let (loaded, lifecycle) =862 config_management::prepare_service(config_path).map_err(anyhow::Error::new)?;863 (loaded.config, Some(loaded.revision), lifecycle)864 } else {865 let loaded = load_serve_config(&args)?;866 loaded.config.validate()?;867 loaded.config.ensure_runtime_directory()?;868 let lifecycle = appliance::LifecycleGuard::acquire(&loaded.config.state_path, false)?;869 (loaded.config, loaded.revision, lifecycle)870 };871 let secret = identity::load(&config.identity_path)?;872 let sessions = Arc::new(SessionRegistry::default());873 let publisher = appliance::RuntimePublisher::start(874 secret.public(),875 Arc::clone(&sessions),876 configured_service,877 loaded_config_revision,878 )?;879 let authorization_result = select_authorization_policy(880 configured_service,881 args.dev_allow_unauthorized,882 &config.state_path,883 secret.public(),884 );885 if args.dev_allow_unauthorized {886 warn!(887 max_runtime_seconds = args.max_runtime_seconds,888 "DEVELOPMENT AUTHORIZATION BYPASS ACTIVE: any peer with this host node ID may request a session"889 );890 eprintln!(891 "warning=development-authorization-bypass-active; do not expose or ship this host"892 );893 }894 let authorization = degrade_on_error(895 publisher.as_ref(),896 appliance::RuntimeErrorCode::AuthorizationState,897 authorization_result,898 )899 .await?;900901 let preflight_result: Result<(InputBackend, Option<PointerPositionTracker>)> = async {902 let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {903 config.test_pattern_dimensions()?;904 let status = tokio::process::Command::new(&config.ffmpeg_path)905 .arg("-version")906 .stdin(std::process::Stdio::null())907 .stdout(std::process::Stdio::null())908 .stderr(std::process::Stdio::null())909 .status()910 .await911 .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;912 ensure!(913 status.success(),914 "ffmpeg version probe failed with {status}"915 );916 None917 } else {918 let preflight = source::preflight_gamescope_pipewire(&config).await?;919 info!(920 target_object = %preflight.target_object,921 pointer_surface_width = preflight.pointer_surface_dimensions.width,922 pointer_surface_height = preflight.pointer_surface_dimensions.height,923 encoded_width = preflight.video_mode.width,924 encoded_height = preflight.video_mode.height,925 encoded_framerate = preflight.video_mode.framerate,926 "Gamescope PipeWire capture preflight passed"927 );928 if config.audio.is_some() {929 audio::preflight_audio_static(&config).await?;930 info!("PipeWire Opus audio static preflight passed");931 }932 Some(preflight.pointer_surface_dimensions)933 };934 let input_backend = InputBackend::initialize(&config)?;935936 let pointer_positions = if config.source == VideoSource::GamescopePipewire937 && config.input_mode == InputMode::Uinput938 {939 let configured_display = config940 .gamescope_pipewire941 .as_ref()942 .and_then(|gamescope| gamescope.xwayland_display.as_deref());943 let pointer_surface_dimensions = pointer_surface_dimensions.context(944 "Gamescope capture preflight did not provide pointer surface dimensions",945 )?;946 match PointerPositionTracker::try_initialize(947 configured_display,948 pointer_surface_dimensions,949 ) {950 Ok(tracker) => {951 info!("Gamescope Xwayland pointer feedback ready");952 Some(tracker)953 }954 Err(error) => {955 warn!(%error, "Gamescope Xwayland pointer feedback unavailable");956 None957 }958 }959 } else {960 None961 };962 Ok((input_backend, pointer_positions))963 }964 .await;965 let (input_backend, pointer_positions) = degrade_on_error(966 publisher.as_ref(),967 appliance::RuntimeErrorCode::Preflight,968 preflight_result,969 )970 .await?;971972 let idle_timeout = degrade_on_error(973 publisher.as_ref(),974 appliance::RuntimeErrorCode::EndpointBind,975 CONNECTION_IDLE_TIMEOUT976 .try_into()977 .context("converting bounded QUIC idle timeout"),978 )979 .await?;980 let transport_config = QuicTransportConfig::builder()981 .max_idle_timeout(Some(idle_timeout))982 .keep_alive_interval(CONNECTION_KEEP_ALIVE_INTERVAL)983 .build();984 let endpoint = degrade_on_error(985 publisher.as_ref(),986 appliance::RuntimeErrorCode::EndpointBind,987 Endpoint::builder(presets::N0)988 .secret_key(secret)989 .transport_config(transport_config)990 .bind()991 .await992 .context("binding iroh endpoint"),993 )994 .await?;995 let node_id = endpoint.id();996 match tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await {997 Ok(()) => info!(%node_id, "iroh endpoint is online"),998 Err(_) => {999 warn!(%node_id, "iroh endpoint online check timed out; continuing offline-capable")1000 }1001 }10021003 let moq_origin = Origin::random();1004 let router = Router::builder(endpoint)1005 .accept(1006 CONTROL_ALPN_V1,1007 ControlHandler {1008 config: config.clone(),1009 sessions: Arc::clone(&sessions),1010 authorization: authorization.clone(),1011 },1012 )1013 .accept(1014 iroh_moq::ALPN,1015 AuthorizedMoqHandler {1016 sessions: Arc::clone(&sessions),1017 origin: moq_origin,1018 },1019 )1020 .accept(1021 MEDIA_ALPN_V3,1022 MediaV3Handler {1023 config: config.clone(),1024 sessions: Arc::clone(&sessions),1025 authorization: authorization.clone(),1026 },1027 )1028 .accept(1029 MEDIA_FEEDBACK_ALPN_V1,1030 MediaFeedbackHandler {1031 config: config.clone(),1032 sessions: Arc::clone(&sessions),1033 authorization,1034 },1035 )1036 .accept(1037 INPUT_ALPN_V1,1038 InputHandler {1039 backend: input_backend,1040 pointer_positions,1041 sessions: Arc::clone(&sessions),1042 },1043 )1044 .accept(1045 AUDIO_ALPN_V1,1046 AudioHandler {1047 config: config.clone(),1048 sessions: Arc::clone(&sessions),1049 },1050 )1051 .spawn();10521053 if let Some(publisher) = publisher.as_ref()1054 && let Err(error) = publisher.mark_ready().await1055 {1056 warn!(%error, "publishing ready appliance status failed; continuing service");1057 }10581059 println!("node_id={node_id}");1060 println!("status=ready");1061 info!(%node_id, source = ?config.source, "sigil host ready");10621063 if let Some(seconds) = args.max_runtime_seconds {1064 tokio::time::sleep(Duration::from_secs(seconds)).await;1065 info!(seconds, "maximum runtime reached");1066 } else {1067 let signal = degrade_on_error(1068 publisher.as_ref(),1069 appliance::RuntimeErrorCode::ShutdownSignal,1070 wait_for_shutdown_signal().await,1071 )1072 .await?;1073 info!(signal, "shutdown signal received");1074 }10751076 if let Some(publisher) = publisher.as_ref()1077 && let Err(error) = publisher.mark_stopping().await1078 {1079 warn!(%error, "publishing stopping appliance status failed; continuing shutdown");1080 }1081 degrade_on_error(1082 publisher.as_ref(),1083 appliance::RuntimeErrorCode::RouterShutdown,1084 router.shutdown().await.context("shutting down iroh router"),1085 )1086 .await?;1087 if let Some(publisher) = publisher1088 && let Err(error) = publisher.finish_clean().await1089 {1090 warn!(%error, "publishing stopped appliance status failed");1091 }1092 Ok(())1093}10941095const fn development_auth_bypass_available() -> bool {1096 cfg!(feature = "demo-auth-bypass")1097}10981099fn validate_development_auth_bypass(1100 requested: bool,1101 max_runtime_seconds: Option<u64>,1102) -> Result<()> {1103 ensure!(1104 !requested || development_auth_bypass_available(),1105 "--dev-allow-unauthorized requires the explicit demo-auth-bypass feature; ordinary builds always require enrollment"1106 );1107 if requested {1108 ensure!(1109 matches!(1110 max_runtime_seconds,1111 Some(1..=MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS)1112 ),1113 "--dev-allow-unauthorized requires --max-runtime-seconds between 1 and {MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS}"1114 );1115 }1116 Ok(())1117}11181119fn select_authorization_policy(1120 configured_service: bool,1121 development_bypass: bool,1122 state_path: &Path,1123 host: EndpointId,1124) -> Result<AuthorizationPolicy> {1125 if development_bypass {1126 Ok(AuthorizationPolicy::DevelopmentBypass)1127 } else if configured_service {1128 AuthorizationStore::open(state_path.to_owned(), host).map(AuthorizationPolicy::Required)1129 } else {1130 Ok(AuthorizationPolicy::TestPatternProof)1131 }1132}11331134async fn wait_for_shutdown_signal() -> Result<&'static str> {1135 1136 {1137 let mut terminate =1138 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())1139 .context("installing SIGTERM handler")?;1140 tokio::select! {1141 result = tokio::signal::ctrl_c() => {1142 result.context("waiting for SIGINT")?;1143 Ok("SIGINT")1144 }1145 _ = terminate.recv() => Ok("SIGTERM"),1146 }1147 }1148 1149 {1150 tokio::signal::ctrl_c()1151 .await1152 .context("waiting for shutdown signal")?;1153 Ok("interrupt")1154 }1155}11561157struct LoadedServeConfig {1158 config: HostConfig,1159 revision: Option<ConfigRevision>,1160}11611162fn load_serve_config(args: &ServeArgs) -> Result<LoadedServeConfig> {1163 ensure!(1164 args.config.is_none(),1165 "configured service loading must hold its lifecycle transaction locks"1166 );1167 let identity_path = args1168 .identity1169 .clone()1170 .context("either --config or --identity/--source is required")?;1171 let source = args1172 .source1173 .context("either --config or --identity/--source is required")?;1174 ensure!(1175 matches!(source, SourceArg::TestPattern),1176 "direct proof mode supports only test-pattern; gamescope-pipewire requires --config"1177 );1178 let state_path = args.state_path.clone().unwrap_or_else(|| {1179 identity_path1180 .parent()1181 .unwrap_or_else(|| Path::new("."))1182 .join("runtime")1183 });1184 Ok(LoadedServeConfig {1185 config: HostConfig {1186 identity_path,1187 state_path,1188 source: source.into(),1189 width: Some(args.width),1190 height: Some(args.height),1191 framerate: args.framerate,1192 codec: "h264".into(),1193 input_mode: InputMode::Log,1194 uinput: None,1195 ffmpeg_path: args.ffmpeg.clone(),1196 gamescope_pipewire: None,1197 audio: None,1198 },1199 revision: None,1200 })1201}12021203fn parse_size(value: &str) -> std::result::Result<(u32, u32), String> {1204 let (width, height) = value1205 .split_once('x')1206 .ok_or_else(|| "size must be WIDTHxHEIGHT".to_owned())?;1207 let width = width.parse().map_err(|_| "invalid width".to_owned())?;1208 let height = height.parse().map_err(|_| "invalid height".to_owned())?;1209 Ok((width, height))1210}121112121213mod tests {1214 use super::*;1215 use std::fs;1216 1217 use std::os::unix::fs::PermissionsExt;12181219 1220 fn parses_expected_size() {1221 assert_eq!(parse_size("1280x800").unwrap(), (1280, 800));1222 assert!(parse_size("1280").is_err());1223 }12241225 1226 fn capture_probe_threshold_is_validated_before_source_start() {1227 let args = |minimum_fps| CaptureProbeArgs {1228 source: SourceArg::TestPattern,1229 frames: 1,1230 expect_size: None,1231 minimum_fps,1232 config: None,1233 };1234 assert!(validate_capture_probe_args(&args(None), 60).is_ok());1235 assert!(validate_capture_probe_args(&args(Some(55.0)), 60).is_ok());1236 assert!(validate_capture_probe_args(&args(Some(0.0)), 60).is_err());1237 assert!(validate_capture_probe_args(&args(Some(61.0)), 60).is_err());1238 assert!(validate_capture_probe_args(&args(Some(f64::NAN)), 60).is_err());1239 }12401241 1242 fn encoder_preflight_requires_an_exact_pair_and_rate_control() {1243 let valid = [1244 "sigil",1245 "capture",1246 "encoder-preflight",1247 "--vaapi-encoder",1248 "varenderD129h264enc",1249 "--vaapi-render-node",1250 "/dev/dri/renderD129",1251 "--rate-control",1252 "cbr",1253 "--rate-control",1254 "cqp",1255 ];1256 assert!(Cli::try_parse_from(valid).is_ok());1257 assert!(1258 Cli::try_parse_from([1259 "sigil",1260 "capture",1261 "encoder-preflight",1262 "--vaapi-encoder",1263 "vah264enc",1264 "--vaapi-render-node",1265 "/dev/dri/renderD128",1266 ])1267 .is_err()1268 );1269 }12701271 1272 fn direct_serve_requires_identity_and_source() {1273 assert!(Cli::try_parse_from(["sigil", "serve"]).is_err());1274 assert!(1275 Cli::try_parse_from([1276 "sigil",1277 "serve",1278 "--identity",1279 "/tmp/host.key",1280 "--source",1281 "test-pattern"1282 ])1283 .is_ok()1284 );1285 }12861287 1288 fn development_auth_bypass_requires_configured_host_syntax() {1289 assert!(1290 Cli::try_parse_from([1291 "sigil",1292 "serve",1293 "--config",1294 "/tmp/host.toml",1295 "--dev-allow-unauthorized",1296 "--max-runtime-seconds",1297 "600",1298 ])1299 .is_ok()1300 );1301 assert!(1302 Cli::try_parse_from([1303 "sigil",1304 "serve",1305 "--identity",1306 "/tmp/host.key",1307 "--source",1308 "test-pattern",1309 "--dev-allow-unauthorized",1310 "--max-runtime-seconds",1311 "600",1312 ])1313 .is_err()1314 );1315 assert!(1316 Cli::try_parse_from([1317 "sigil",1318 "serve",1319 "--config",1320 "/tmp/host.toml",1321 "--dev-allow-unauthorized",1322 ])1323 .is_ok()1324 );1325 assert!(validate_development_auth_bypass(true, None).is_err());1326 assert!(validate_development_auth_bypass(true, Some(0)).is_err());1327 assert!(1328 validate_development_auth_bypass(true, Some(MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS + 1))1329 .is_err()1330 );1331 }13321333 1334 fn ordinary_release_excludes_configured_host_auth_bypass() {1335 if cfg!(not(feature = "demo-auth-bypass")) {1336 assert!(!development_auth_bypass_available());1337 assert!(validate_development_auth_bypass(true, Some(60)).is_err());1338 }1339 }13401341 1342 fn configured_host_auth_bypass_is_explicitly_build_contained() {1343 assert_eq!(1344 validate_development_auth_bypass(true, Some(60)).is_ok(),1345 development_auth_bypass_available()1346 );1347 }13481349 1350 fn development_auth_bypass_does_not_open_persistent_authorization_state() {1351 let directory = tempfile::tempdir().unwrap();1352 let state_path = directory.path();1353 let state_file = state_path.join("authorization-v1.json");1354 fs::write(&state_file, b"malformed-sentinel").unwrap();1355 1356 fs::set_permissions(&state_file, fs::Permissions::from_mode(0o600)).unwrap();1357 let before = fs::read(&state_file).unwrap();1358 let host = iroh::SecretKey::from_bytes(&[31; 32]).public();13591360 assert!(matches!(1361 select_authorization_policy(true, true, state_path, host).unwrap(),1362 AuthorizationPolicy::DevelopmentBypass1363 ));1364 assert_eq!(fs::read(&state_file).unwrap(), before);1365 assert!(!state_path.join("authorization-v1.lock").exists());13661367 let required_directory = tempfile::tempdir().unwrap();1368 let required_state = required_directory.path().join("authorization-v1.json");1369 fs::write(&required_state, b"malformed-sentinel").unwrap();1370 1371 fs::set_permissions(&required_state, fs::Permissions::from_mode(0o600)).unwrap();1372 assert!(select_authorization_policy(true, false, required_directory.path(), host).is_err());1373 }13741375 1376 fn invitation_and_enrollment_commands_parse_strictly() {1377 let peer = iroh::SecretKey::from_bytes(&[19; 32]).public().to_string();1378 assert!(1379 Cli::try_parse_from([1380 "sigil",1381 "invitation",1382 "create",1383 "--config",1384 "/tmp/host.toml",1385 "--peer",1386 &peer,1387 "--pointer-keyboard",1388 "--gamepad",1389 "--print-deep-link",1390 "--output",1391 "/tmp/portal.goq-invite",1392 ])1393 .is_ok()1394 );1395 assert!(1396 Cli::try_parse_from([1397 "sigil",1398 "invitation",1399 "create",1400 "--config",1401 "/tmp/host.toml",1402 "--peer",1403 &peer,1404 "--expires-in-seconds",1405 "901",1406 "--output",1407 "/tmp/portal.goq-invite",1408 ])1409 .is_err()1410 );1411 assert!(1412 Cli::try_parse_from([1413 "sigil",1414 "enrollment",1415 "revoke",1416 "--config",1417 "/tmp/host.toml",1418 "--expected-host-fingerprint",1419 "aaaaaaaa…bbbbbbbb",1420 "--json",1421 ])1422 .is_ok()1423 );1424 assert!(1425 Cli::try_parse_from([1426 "sigil",1427 "enrollment",1428 "revoke",1429 "--config",1430 "/tmp/host.toml"1431 ])1432 .is_err()1433 );1434 }14351436 1437 fn appliance_status_requires_explicit_json_output() {1438 assert!(1439 Cli::try_parse_from([1440 "sigil",1441 "appliance",1442 "status",1443 "--config",1444 "/tmp/host.toml",1445 "--json",1446 ])1447 .is_ok()1448 );1449 assert!(1450 Cli::try_parse_from(["sigil", "appliance", "status", "--config", "/tmp/host.toml"])1451 .is_err()1452 );1453 assert!(1454 Cli::try_parse_from([1455 "sigil",1456 "appliance",1457 "status",1458 "--config",1459 "/tmp/host.toml",1460 "--json",1461 "--schema-version",1462 "2",1463 ])1464 .is_ok()1465 );1466 assert!(1467 Cli::try_parse_from([1468 "sigil",1469 "appliance",1470 "status",1471 "--config",1472 "/tmp/host.toml",1473 "--json",1474 "--schema-version",1475 "3",1476 ])1477 .is_err()1478 );1479 }14801481 1482 fn appliance_enrollment_reset_requires_confirmation_and_json() {1483 let valid = [1484 "sigil",1485 "appliance",1486 "enrollment-reset",1487 "--config",1488 "/tmp/host.toml",1489 "--expected-host-fingerprint",1490 "aaaaaaaa…bbbbbbbb",1491 "--json",1492 ];1493 assert!(Cli::try_parse_from(valid).is_ok());1494 assert!(1495 Cli::try_parse_from([1496 "sigil",1497 "appliance",1498 "enrollment-reset",1499 "--config",1500 "/tmp/host.toml",1501 "--json",1502 ])1503 .is_err()1504 );1505 }15061507 1508 fn appliance_config_commands_require_explicit_machine_inputs() {1509 assert!(1510 Cli::try_parse_from([1511 "sigil",1512 "appliance",1513 "config",1514 "show",1515 "--config",1516 "/tmp/host.toml",1517 "--json",1518 ])1519 .is_ok()1520 );1521 assert!(1522 Cli::try_parse_from([1523 "sigil",1524 "appliance",1525 "config",1526 "set",1527 "--config",1528 "/tmp/host.toml",1529 ])1530 .is_err()1531 );1532 assert!(1533 Cli::try_parse_from([1534 "sigil",1535 "appliance",1536 "config",1537 "commit",1538 "--config",1539 "/tmp/host.toml",1540 "--transaction",1541 "0123456789abcdef0123456789abcdef",1542 "--expected-instance",1543 "fedcba9876543210fedcba9876543210",1544 "--json",1545 ])1546 .is_ok()1547 );1548 assert!(1549 Cli::try_parse_from([1550 "sigil",1551 "appliance",1552 "config",1553 "commit",1554 "--config",1555 "/tmp/host.toml",1556 "--transaction",1557 "0123456789abcdef0123456789abcdef",1558 "--json",1559 ])1560 .is_err()1561 );1562 }15631564 1565 fn invitation_file_uses_create_new_semantics() {1566 let directory = tempfile::tempdir().unwrap();1567 let path = directory.path().join("portal.goq-invite");1568 write_invitation_file(&path, "token").unwrap();1569 assert_eq!(std::fs::read_to_string(&path).unwrap(), "token\n");1570 assert!(write_invitation_file(&path, "replacement").is_err());1571 1572 assert_eq!(1573 std::fs::metadata(path).unwrap().permissions().mode() & 0o777,1574 0o6001575 );1576 }15771578 1579 fn static_media_transport_liveness_is_bounded() {1580 assert_eq!(CONNECTION_IDLE_TIMEOUT, Duration::from_secs(5));1581 assert_eq!(CONNECTION_KEEP_ALIVE_INTERVAL, Duration::from_secs(1));1582 assert!(CONNECTION_KEEP_ALIVE_INTERVAL < CONNECTION_IDLE_TIMEOUT);1583 assert!(CONNECTION_IDLE_TIMEOUT <= Duration::from_secs(6));1584 }1585}