cat / goq
clone

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

fix/hero-release-note
goq / crates / sigil-host / src / main.rs
main.rs 57.6K
1mod appliance;2mod audio;3mod authorization;4mod authorization_admin;5mod clock;6mod config;7mod config_management;8mod cursor;9mod identity;10mod input;11mod moq_catalog;12mod secure_state;13mod server;14mod source;1516use std::fs::OpenOptions;17use std::io::Write as _;18use std::path::{Path, PathBuf};19use std::process::ExitCode;20use std::sync::Arc;21use std::time::{Duration, Instant};2223#[cfg(unix)]24use std::os::unix::fs::OpenOptionsExt;2526use anyhow::{Context, Result, ensure};27use clap::{Args, Parser, Subcommand, ValueEnum};28use iroh::endpoint::{QuicTransportConfig, presets};29use iroh::protocol::Router;30use iroh::{Endpoint, EndpointId};31use moq_net::Origin;32use sigil_protocol::{33 AUDIO_ALPN_V1, CONTROL_ALPN_V1, CONTROL_ALPN_V2, INPUT_ALPN_V1, INPUT_ALPN_V2,34 InvitationGrants, MAX_INVITATION_TTL_SECS, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,35};36use tracing::{info, warn};37use tracing_subscriber::EnvFilter;3839use crate::authorization::{AuthorizationPolicy, AuthorizationStore, grant_names};40use crate::config::{ConfigRevision, HostConfig, InputMode, VideoSource};41use crate::cursor::PointerPositionTracker;42use crate::input::InputBackend;43use crate::server::{44 AudioHandler, AuthorizedMoqHandler, ControlHandler, ControlV2Handler, InputHandler,45 InputOperations, InputV2Handler, MediaFeedbackHandler, MediaGenerationManager, MediaV3Handler,46 SessionRegistry,47};4849const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5);50const CONNECTION_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(1);5152#[derive(Debug, Parser)]53#[command(name = "sigil", version, about)]54struct Cli {55 #[command(subcommand)]56 command: Command,57}5859#[derive(Debug, Subcommand)]60enum Command {61 /// Inspect or manage the local Sigil appliance service.62 Appliance {63 #[command(subcommand)]64 command: ApplianceCommand,65 },66 /// Create or inspect the persistent Iroh host identity.67 Identity {68 #[command(subcommand)]69 command: IdentityCommand,70 },71 /// Validate host configuration without starting network services.72 Config {73 #[command(subcommand)]74 command: ConfigCommand,75 },76 /// Run a bounded capture probe.77 Capture {78 #[command(subcommand)]79 command: CaptureCommand,80 },81 /// Create a short-lived, peer-bound Portal enrollment invitation.82 Invitation {83 #[command(subcommand)]84 command: InvitationCommand,85 },86 /// Inspect or administer enrolled Portal viewers.87 Enrollment {88 #[command(subcommand)]89 command: EnrollmentCommand,90 },91 /// Run the headless host daemon in the foreground.92 Serve(ServeArgs),93}9495#[derive(Debug, Subcommand)]96enum ApplianceCommand {97 /// Print a versioned, redacted daemon and enrollment snapshot.98 Status {99 #[arg(long)]100 config: PathBuf,101 /// Emit the stable machine-readable schema.102 #[arg(long, required = true)]103 json: bool,104 /// Public status schema. Version 1 remains the compatibility default.105 #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u16).range(1..=2))]106 schema_version: u16,107 },108 /// Revoke the enrolled Portal and invalidate every outstanding invitation.109 EnrollmentReset {110 #[arg(long)]111 config: PathBuf,112 /// Redacted host fingerprint from `appliance status`.113 #[arg(long)]114 expected_host_fingerprint: String,115 /// Emit the stable machine-readable schema.116 #[arg(long, required = true)]117 json: bool,118 },119 /// Inspect or transactionally update the validated host configuration.120 Config {121 #[command(subcommand)]122 command: ApplianceConfigCommand,123 },124}125126#[derive(Debug, Subcommand)]127enum ApplianceConfigCommand {128 /// Show only the controller-editable configuration projection.129 Show {130 #[arg(long)]131 config: PathBuf,132 #[arg(long, required = true)]133 json: bool,134 },135 /// Validate a bounded JSON request from stdin without writing.136 Validate {137 #[arg(long)]138 config: PathBuf,139 #[arg(long, required = true)]140 json: bool,141 },142 /// Stage and atomically install a validated candidate while Sigil is stopped.143 Set {144 #[arg(long)]145 config: PathBuf,146 /// XDG runtime root used by the stopped service; Sigil appends `sigil-spark`.147 #[arg(long)]148 runtime_dir: Option<PathBuf>,149 #[arg(long, required = true)]150 json: bool,151 },152 /// Commit a candidate proven by one exact stopped daemon instance.153 Commit {154 #[arg(long)]155 config: PathBuf,156 #[arg(long)]157 transaction: String,158 #[arg(long)]159 expected_instance: String,160 /// XDG runtime root used by the candidate service; Sigil appends `sigil-spark`.161 #[arg(long)]162 runtime_dir: Option<PathBuf>,163 #[arg(long, required = true)]164 json: bool,165 },166 /// Restore the byte-exact prior configuration while Sigil is stopped.167 Rollback {168 #[arg(long)]169 config: PathBuf,170 #[arg(long)]171 transaction: String,172 #[arg(long, required = true)]173 json: bool,174 },175}176177#[derive(Debug, Subcommand)]178enum IdentityCommand {179 /// Create a new identity using create-new semantics.180 Init {181 #[arg(long)]182 output: PathBuf,183 },184 /// Print the public node ID for an existing identity.185 Show {186 #[arg(long)]187 identity: PathBuf,188 },189}190191#[derive(Debug, Subcommand)]192enum ConfigCommand {193 /// Parse, validate, and check the referenced identity.194 Check {195 #[arg(long)]196 config: PathBuf,197 },198}199200#[derive(Debug, Subcommand)]201enum 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}207208#[derive(Debug, Args)]209struct EncoderPreflightArgs {210 #[arg(long)]211 vaapi_encoder: String,212 #[arg(long)]213 vaapi_render_node: PathBuf,214 #[arg(long, value_enum, required = true)]215 rate_control: Vec<RateControlArg>,216}217218#[derive(Clone, Copy, Debug, ValueEnum)]219enum RateControlArg {220 Cbr,221 Cqp,222}223224#[derive(Debug, Subcommand)]225enum InvitationCommand {226 /// Create a signed invitation file using create-new semantics.227 Create {228 #[arg(long)]229 config: PathBuf,230 #[arg(long)]231 peer: EndpointId,232 #[arg(long, default_value_t = 600, value_parser = clap::value_parser!(u64).range(1..=MAX_INVITATION_TTL_SECS))]233 expires_in_seconds: u64,234 #[arg(long)]235 pointer_keyboard: bool,236 #[arg(long)]237 gamepad: bool,238 /// Explicitly issue a view-only invitation.239 #[arg(long, conflicts_with_all = ["pointer_keyboard", "gamepad"])]240 view_only: bool,241 #[arg(long)]242 output: PathBuf,243 /// Also print the short-lived invitation as a goq:// deep link.244 #[arg(long)]245 print_deep_link: bool,246 },247}248249#[derive(Debug, Subcommand)]250enum EnrollmentCommand {251 /// Print the current enrollment without exposing secret material.252 Show {253 #[arg(long)]254 config: PathBuf,255 },256 /// List all enrolled viewers by stable opaque handle.257 List {258 #[arg(long)]259 config: PathBuf,260 },261 /// Remove the enrolled peer and invalidate every outstanding invitation.262 Revoke {263 #[arg(long)]264 config: PathBuf,265 /// Redacted host fingerprint from `appliance status`.266 #[arg(long)]267 expected_host_fingerprint: String,268 /// Emit the stable machine-readable schema.269 #[arg(long, required = true)]270 json: bool,271 /// Revoke one viewer through the running daemon. Without this option,272 /// the command retains the stopped-service reset-all compatibility path.273 #[arg(long)]274 handle: Option<String>,275 },276 /// Atomically replace one viewer's grants through the running daemon.277 SetCapabilities {278 #[arg(long)]279 config: PathBuf,280 #[arg(long)]281 handle: String,282 /// Remove view permission and revoke this viewer completely.283 #[arg(long, conflicts_with_all = ["pointer_keyboard", "gamepad"])]284 revoke_view: bool,285 #[arg(long)]286 pointer_keyboard: bool,287 #[arg(long)]288 gamepad: bool,289 },290}291292#[derive(Clone, Copy, Debug, ValueEnum)]293enum SourceArg {294 TestPattern,295 GamescopePipewire,296}297298impl From<SourceArg> for VideoSource {299 fn from(value: SourceArg) -> Self {300 match value {301 SourceArg::TestPattern => Self::TestPattern,302 SourceArg::GamescopePipewire => Self::GamescopePipewire,303 }304 }305}306307#[derive(Debug, Args)]308struct CaptureProbeArgs {309 #[arg(long, value_enum)]310 source: SourceArg,311 #[arg(long, default_value_t = 300)]312 frames: u32,313 #[arg(long, value_parser = parse_size)]314 expect_size: Option<(u32, u32)>,315 /// Fail unless the bounded proof sustains at least this encoded-frame rate.316 #[arg(long)]317 minimum_fps: Option<f64>,318 /// Strict host configuration; required for gamescope-pipewire.319 #[arg(long)]320 config: Option<PathBuf>,321}322323#[derive(Debug, Args)]324#[command(group(325 clap::ArgGroup::new("serve-source")326 .required(true)327 .args(["config", "identity"])328))]329struct ServeArgs {330 /// Strict TOML host configuration.331 #[arg(long, conflicts_with_all = ["identity", "source", "state_path"])]332 config: Option<PathBuf>,333 /// INSECURE: admit an un-enrolled Portal peer for configured-host testing.334 #[arg(long, requires = "config", conflicts_with = "identity")]335 dev_allow_unauthorized: bool,336 /// Host identity for direct proof-mode configuration.337 #[arg(long, requires = "source")]338 identity: Option<PathBuf>,339 /// Video source for direct proof-mode configuration.340 #[arg(long, value_enum, requires = "identity")]341 source: Option<SourceArg>,342 /// Writable state directory for direct proof mode.343 #[arg(long)]344 state_path: Option<PathBuf>,345 #[arg(long, default_value_t = 1280)]346 width: u32,347 #[arg(long, default_value_t = 800)]348 height: u32,349 #[arg(long, default_value_t = 60)]350 framerate: u32,351 /// Concurrent native-MoQ viewers in direct proof mode.352 #[arg(353 long,354 default_value_t = config::DEFAULT_MAX_VIEWERS,355 value_parser = clap::value_parser!(u8).range(1..=8),356 requires = "identity"357 )]358 max_viewers: u8,359 #[arg(long, default_value = "ffmpeg")]360 ffmpeg: PathBuf,361 /// Exit after this many seconds; intended for bounded automation.362 #[arg(long)]363 max_runtime_seconds: Option<u64>,364}365366const MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS: u64 = 3_600;367368enum CliFailure {369 General(anyhow::Error),370 Management(config_management::ManagementError),371}372373impl From<anyhow::Error> for CliFailure {374 fn from(error: anyhow::Error) -> Self {375 Self::General(error)376 }377}378379type CliResult<T> = std::result::Result<T, CliFailure>;380381#[tokio::main]382async fn main() -> ExitCode {383 tracing_subscriber::fmt()384 .with_env_filter(385 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),386 )387 .with_target(false)388 .init();389 server::install_panic_hook();390391 match run().await {392 Ok(()) => ExitCode::SUCCESS,393 Err(CliFailure::Management(error)) => {394 let response = serde_json::to_string(&error.response())395 .expect("serializing a fixed management error cannot fail");396 eprintln!("{response}");397 ExitCode::FAILURE398 }399 Err(CliFailure::General(error)) => {400 eprintln!("error: {error:#}");401 ExitCode::FAILURE402 }403 }404}405406async fn run() -> CliResult<()> {407 match Cli::parse().command {408 Command::Appliance { command } => appliance_command(command),409 Command::Identity { command } => identity_command(command).map_err(Into::into),410 Command::Config { command } => config_command(command).await.map_err(Into::into),411 Command::Capture { command } => capture_command(command).await.map_err(Into::into),412 Command::Invitation { command } => invitation_command(command).await.map_err(Into::into),413 Command::Enrollment { command } => enrollment_command(command).await.map_err(Into::into),414 Command::Serve(args) => serve_command(args).await.map_err(Into::into),415 }416}417418fn appliance_command(command: ApplianceCommand) -> CliResult<()> {419 match command {420 ApplianceCommand::Status {421 config,422 json: _,423 schema_version,424 } => {425 let status = appliance::collect_status(&config).map_err(CliFailure::from)?;426 println!("{}", appliance::status_json(&status, schema_version)?);427 }428 ApplianceCommand::EnrollmentReset {429 config,430 expected_host_fingerprint,431 json: _,432 } => {433 let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)434 .map_err(CliFailure::from)?;435 println!(436 "{}",437 serde_json::to_string(&result).map_err(anyhow::Error::from)?438 );439 }440 ApplianceCommand::Config { command } => {441 let result = match command {442 ApplianceConfigCommand::Show { config, json: _ } => serde_json::to_value(443 config_management::show(&config).map_err(CliFailure::Management)?,444 ),445 ApplianceConfigCommand::Validate { config, json: _ } => {446 let request = config_management::read_request(std::io::stdin().lock())447 .map_err(CliFailure::Management)?;448 serde_json::to_value(449 config_management::validate(&config, &request)450 .map_err(CliFailure::Management)?,451 )452 }453 ApplianceConfigCommand::Set {454 config,455 runtime_dir,456 json: _,457 } => {458 let request = config_management::read_request(std::io::stdin().lock())459 .map_err(CliFailure::Management)?;460 serde_json::to_value(461 config_management::set(&config, &request, runtime_dir.as_deref())462 .map_err(CliFailure::Management)?,463 )464 }465 ApplianceConfigCommand::Commit {466 config,467 transaction,468 expected_instance,469 runtime_dir,470 json: _,471 } => serde_json::to_value(472 config_management::commit(473 &config,474 &transaction,475 &expected_instance,476 runtime_dir.as_deref(),477 )478 .map_err(CliFailure::Management)?,479 ),480 ApplianceConfigCommand::Rollback {481 config,482 transaction,483 json: _,484 } => serde_json::to_value(485 config_management::rollback(&config, &transaction)486 .map_err(CliFailure::Management)?,487 ),488 }489 .map_err(anyhow::Error::from)?;490 println!("{result}");491 }492 }493 Ok(())494}495496fn authorization_snapshot_from_config(497 path: &Path,498) -> Result<(499 HostConfig,500 iroh::SecretKey,501 authorization::AuthorizationSnapshot,502)> {503 let config = HostConfig::load(path)?;504 config.ensure_runtime_directory()?;505 let secret = identity::load(&config.identity_path)?;506 let inspection =507 AuthorizationStore::inspect_existing(config.state_path.clone(), secret.public())?;508 Ok((config, secret, inspection.snapshot))509}510511async fn invitation_command(command: InvitationCommand) -> Result<()> {512 match command {513 InvitationCommand::Create {514 config,515 peer,516 expires_in_seconds,517 pointer_keyboard,518 gamepad,519 view_only: _,520 output,521 print_deep_link,522 } => {523 let config = HostConfig::load(&config)?;524 config.ensure_runtime_directory()?;525 let secret = identity::load(&config.identity_path)?;526 let mut grants = InvitationGrants::VIEW;527 if pointer_keyboard {528 grants = grants.union(InvitationGrants::POINTER_KEYBOARD);529 }530 if gamepad {531 grants = grants.union(InvitationGrants::GAMEPAD);532 }533 let result = authorization_admin::request(534 &config.state_path,535 &authorization_admin::AuthorizationAdminRequest::CreateInvitation {536 peer_node_id: peer.to_string(),537 grants: grants.bits(),538 expires_in_seconds,539 },540 )541 .await?;542 let authorization_admin::AuthorizationAdminResult::Invitation {543 token,544 expires_at_unix: expires_at,545 ..546 } = result547 else {548 anyhow::bail!("authorization daemon returned an unexpected invitation response");549 };550 write_invitation_file(&output, &token)?;551 println!("invitation={}", output.display());552 println!("host_node_id={}", secret.public());553 println!("peer_node_id={peer}");554 println!("grants={}", grant_names(grants));555 println!("expires_at_unix={expires_at}");556 if print_deep_link {557 println!("deep_link=goq://invite/{token}");558 }559 }560 }561 Ok(())562}563564async fn enrollment_command(command: EnrollmentCommand) -> Result<()> {565 match command {566 EnrollmentCommand::Show { config } => {567 let (_config, _secret, snapshot) = authorization_snapshot_from_config(&config)?;568 println!("enrollment_epoch={}", snapshot.epoch);569 println!("committed_revision={}", snapshot.committed_revision);570 println!("viewer_count={}", snapshot.viewers.len());571 if snapshot.viewers.is_empty() {572 println!("enrollment=none");573 } else {574 println!("enrollment=active");575 for viewer in snapshot.viewers {576 println!(577 "viewer={} grants={} authorization_revision={} enrolled_at_unix={}",578 viewer.handle,579 grant_names(viewer.grants),580 viewer.authorization_revision,581 viewer.enrolled_at_unix582 );583 }584 }585 }586 EnrollmentCommand::List { config } => {587 let config = HostConfig::load(&config)?;588 let result = authorization_admin::request(589 &config.state_path,590 &authorization_admin::AuthorizationAdminRequest::List,591 )592 .await?;593 let authorization_admin::AuthorizationAdminResult::Viewers {594 committed_revision,595 enrollment_epoch,596 viewers,597 } = result598 else {599 anyhow::bail!("authorization daemon returned an unexpected list response");600 };601 println!("committed_revision={committed_revision}");602 println!("enrollment_epoch={enrollment_epoch}");603 println!("viewer_count={}", viewers.len());604 for viewer in viewers {605 println!(606 "viewer={} grants={} authorization_revision={} enrolled_at_unix={}",607 viewer.handle,608 viewer.grants.join(","),609 viewer.authorization_revision,610 viewer.enrolled_at_unix611 );612 }613 }614 EnrollmentCommand::Revoke {615 config,616 expected_host_fingerprint,617 json: _,618 handle,619 } => {620 if let Some(handle) = handle {621 let config = HostConfig::load(&config)?;622 let secret = identity::load(&config.identity_path)?;623 ensure!(624 expected_host_fingerprint == appliance::fingerprint(secret.public()),625 "expected host fingerprint does not match this Sigil appliance"626 );627 let result = authorization_admin::request(628 &config.state_path,629 &authorization_admin::AuthorizationAdminRequest::RevokeViewer { handle },630 )631 .await?;632 println!("{}", serde_json::to_string(&result)?);633 } else {634 let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;635 println!("{}", serde_json::to_string(&result)?);636 }637 }638 EnrollmentCommand::SetCapabilities {639 config,640 handle,641 revoke_view,642 pointer_keyboard,643 gamepad,644 } => {645 let config = HostConfig::load(&config)?;646 let grants = if revoke_view {647 None648 } else {649 let mut grants = InvitationGrants::VIEW;650 if pointer_keyboard {651 grants = grants.union(InvitationGrants::POINTER_KEYBOARD);652 }653 if gamepad {654 grants = grants.union(InvitationGrants::GAMEPAD);655 }656 Some(grants.bits())657 };658 let result = authorization_admin::request(659 &config.state_path,660 &authorization_admin::AuthorizationAdminRequest::ReplaceGrants { handle, grants },661 )662 .await?;663 println!("{}", serde_json::to_string(&result)?);664 }665 }666 Ok(())667}668669fn write_invitation_file(path: &Path, token: &str) -> Result<()> {670 let mut options = OpenOptions::new();671 options.write(true).create_new(true);672 #[cfg(unix)]673 options674 .mode(0o600)675 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);676 let mut file = options677 .open(path)678 .with_context(|| format!("creating invitation {}", path.display()))?;679 file.write_all(token.as_bytes())?;680 file.write_all(b"\n")?;681 file.sync_all()?;682 Ok(())683}684685fn identity_command(command: IdentityCommand) -> Result<()> {686 match command {687 IdentityCommand::Init { output } => {688 let secret = identity::init(&output)?;689 println!("node_id={}", secret.public());690 println!("identity={}", identity::display_path(&output).display());691 }692 IdentityCommand::Show { identity: path } => {693 let secret = identity::load(&path)?;694 println!("node_id={}", secret.public());695 }696 }697 Ok(())698}699700async fn config_command(command: ConfigCommand) -> Result<()> {701 match command {702 ConfigCommand::Check { config: path } => {703 let config = HostConfig::load(&path)?;704 let secret = identity::load(&config.identity_path)?;705 let _input_backend = InputBackend::initialize(&config)?;706 println!("config=ok");707 println!("node_id={}", secret.public());708 println!("source={:?}", config.source);709 println!("input={:?}", config.input_mode);710 if config.source == VideoSource::GamescopePipewire {711 let preflight = source::preflight_gamescope_pipewire(&config).await?;712 println!("pipewire_target_object={}", preflight.target_object);713 println!(714 "pointer_surface={}x{}",715 preflight.pointer_surface_dimensions.width,716 preflight.pointer_surface_dimensions.height717 );718 println!(719 "encoded_mode={}x{}@{}",720 preflight.video_mode.width,721 preflight.video_mode.height,722 preflight.video_mode.framerate723 );724 println!("capture_preflight=ok");725 if config.audio.is_some() {726 let target = audio::preflight_audio(&config).await?;727 println!("audio_pipewire_target_object={target}");728 println!("audio_capture_preflight=ok");729 }730 }731 }732 }733 Ok(())734}735736async fn capture_command(command: CaptureCommand) -> Result<()> {737 match command {738 CaptureCommand::EncoderPreflight(args) => {739 let rate_controls = args740 .rate_control741 .into_iter()742 .map(|rate_control| match rate_control {743 RateControlArg::Cbr => crate::config::VaapiRateControl::Cbr,744 RateControlArg::Cqp => crate::config::VaapiRateControl::Cqp,745 })746 .collect::<Vec<_>>();747 source::probe_encoder_properties(748 &args.vaapi_encoder,749 &args.vaapi_render_node,750 &rate_controls,751 )?;752 println!("encoder_preflight=ok");753 Ok(())754 }755 CaptureCommand::Probe(args) => {756 ensure!(757 (1..=36_000).contains(&args.frames),758 "--frames must be between 1 and 36000"759 );760 if let Some((width, height)) = args.expect_size {761 ensure!(width > 0 && height > 0, "expected size must be non-zero");762 }763 match args.source {764 SourceArg::TestPattern => probe_test_pattern(args).await,765 SourceArg::GamescopePipewire => probe_gamescope_pipewire(args).await,766 }767 }768 }769}770771async fn probe_test_pattern(args: CaptureProbeArgs) -> Result<()> {772 ensure!(773 args.config.is_none(),774 "--config is only accepted for gamescope-pipewire capture"775 );776 let (width, height) = args.expect_size.unwrap_or((1280, 800));777 let config = HostConfig {778 identity_path: PathBuf::from("unused-by-capture-probe"),779 state_path: PathBuf::from("."),780 max_viewers: config::DEFAULT_MAX_VIEWERS,781 focus_owner: None,782 source: VideoSource::TestPattern,783 width: Some(width),784 height: Some(height),785 framerate: 60,786 codec: "h264".into(),787 input_mode: InputMode::Disabled,788 uinput: None,789 ffmpeg_path: PathBuf::from("ffmpeg"),790 gamescope_pipewire: None,791 audio: None,792 };793 config.validate()?;794795 let configured_framerate = config.framerate;796 validate_capture_probe_args(&args, configured_framerate)?;797 let source = source::spawn_test_pattern(config, clock::SessionClock::start());798 consume_capture_probe(args, source, None, "test-pattern", configured_framerate).await799}800801async fn probe_gamescope_pipewire(args: CaptureProbeArgs) -> Result<()> {802 let config_path = args803 .config804 .as_ref()805 .context("--config is required for gamescope-pipewire capture")?;806 let config = HostConfig::load(config_path)?;807 ensure!(808 config.source == VideoSource::GamescopePipewire,809 "capture config source must be gamescope-pipewire"810 );811 config.ensure_runtime_directory()?;812 let _lifecycle = appliance::LifecycleGuard::acquire(&config.state_path, true)813 .context("acquiring exclusive Gamescope capture lifecycle")?;814 let preflight = source::preflight_gamescope_pipewire(&config).await?;815 let observed = (816 u32::from(preflight.video_mode.width),817 u32::from(preflight.video_mode.height),818 );819 if let Some(expected) = args.expect_size {820 ensure!(821 expected == observed,822 "expected size {}x{} does not match resolved encoded size {}x{}",823 expected.0,824 expected.1,825 observed.0,826 observed.1827 );828 }829 println!(830 "resolved_encoded_mode={}x{}@{}",831 observed.0, observed.1, preflight.video_mode.framerate832 );833 let configured_framerate = preflight.video_mode.framerate;834 validate_capture_probe_args(&args, configured_framerate)?;835 let source::ProbeEncodedSource { source, shutdown } =836 source::spawn_gamescope_pipewire_for_probe(837 config,838 clock::SessionClock::start(),839 preflight,840 )?;841 consume_capture_probe(842 args,843 source,844 shutdown,845 "Gamescope PipeWire",846 configured_framerate,847 )848 .await849}850851async fn consume_capture_probe(852 args: CaptureProbeArgs,853 source: source::EncodedSource,854 shutdown: Option<tokio::sync::oneshot::Sender<()>>,855 source_name: &str,856 configured_framerate: u32,857) -> Result<()> {858 debug_assert!(validate_capture_probe_args(&args, configured_framerate).is_ok());859 let source::EncodedSource {860 frames: mut receiver,861 current_gop,862 task,863 pointer_surface_dimensions: _,864 encoder_control,865 } = source;866 drop(current_gop);867 drop(encoder_control);868 let started = Instant::now();869 let mut received = 0_u32;870 let mut keyframes = 0_u32;871 let mut decodable_keyframes = 0_u32;872 let mut encoded_bytes = 0_u64;873 let mut last_sequence = None;874 let mut dropped = 0_u64;875 let mut max_post_encode_queue_age_micros = 0_u128;876877 let capture_result: Result<()> = async {878 while received < args.frames {879 tokio::time::timeout(Duration::from_secs(5), receiver.changed())880 .await881 .with_context(|| format!("timed out waiting for encoded {source_name} frame"))?882 .with_context(|| format!("{source_name} encoder stopped"))?;883 let Some(frame) = receiver.borrow_and_update().clone() else {884 continue;885 };886 max_post_encode_queue_age_micros =887 max_post_encode_queue_age_micros.max(frame.observed_at.elapsed().as_micros());888 if let Some(previous) = last_sequence {889 dropped += frame.sequence.saturating_sub(previous + 1);890 }891 last_sequence = Some(frame.sequence);892 keyframes += u32::from(frame.keyframe);893 decodable_keyframes += u32::from(frame.keyframe && frame.codec_config);894 encoded_bytes = encoded_bytes.saturating_add(frame.data.len() as u64);895 received += 1;896 }897 Ok(())898 }899 .await;900901 let elapsed = started.elapsed();902 drop(receiver);903 if let Some(shutdown) = shutdown {904 let _ = shutdown.send(());905 }906 let source_result = task907 .await908 .with_context(|| format!("joining {source_name} capture task"))?;909 match (capture_result, source_result) {910 (Ok(()), Ok(())) => {}911 (Err(error), Ok(())) => return Err(error),912 (Ok(()), Err(error)) => return Err(error),913 (Err(error), Err(source_error)) => {914 return Err(error.context(format!(915 "{source_name} capture teardown also failed: {source_error:#}"916 )));917 }918 }919 ensure!(keyframes > 0, "capture probe produced no H.264 keyframe");920 ensure!(921 decodable_keyframes > 0,922 "capture probe produced no keyframe with SPS/PPS codec configuration"923 );924 ensure!(925 dropped == 0,926 "capture probe dropped {dropped} frames after encode and before its consumer"927 );928 let observed_fps = f64::from(received) / elapsed.as_secs_f64();929 if let Some(minimum_fps) = args.minimum_fps {930 ensure!(931 observed_fps >= minimum_fps,932 "capture probe sustained only {observed_fps:.3} fps; required at least {minimum_fps:.3} fps"933 );934 }935 println!("probe=ok");936 println!("frames={received}");937 println!("keyframes={keyframes}");938 println!("decodable_keyframes={decodable_keyframes}");939 println!("dropped_after_encode_before_probe_consumer={dropped}");940 println!("observed_fps={observed_fps:.3}");941 println!("max_post_encode_queue_age_micros={max_post_encode_queue_age_micros}");942 println!("encoded_bytes={encoded_bytes}");943 println!("elapsed_ms={}", elapsed.as_millis());944 Ok(())945}946947fn validate_capture_probe_args(args: &CaptureProbeArgs, configured_framerate: u32) -> Result<()> {948 if let Some(minimum_fps) = args.minimum_fps {949 ensure!(950 minimum_fps.is_finite()951 && minimum_fps > 0.0952 && minimum_fps <= f64::from(configured_framerate),953 "--minimum-fps must be finite, positive, and no greater than configured framerate {configured_framerate}"954 );955 }956 Ok(())957}958959/// Report the failing startup/shutdown step as a degraded appliance status960/// before surfacing the error. Every fallible step between publisher creation961/// and clean shutdown must flow through this helper so no failure class can962/// exit the daemon without updating the published status.963async fn degrade_on_error<T>(964 publisher: Option<&appliance::RuntimePublisher>,965 code: appliance::RuntimeErrorCode,966 result: Result<T>,967) -> Result<T> {968 if result.is_err()969 && let Some(publisher) = publisher970 {971 let _ = publisher.mark_degraded(code).await;972 }973 result974}975976async fn serve_command(args: ServeArgs) -> Result<()> {977 validate_development_auth_bypass(args.dev_allow_unauthorized, args.max_runtime_seconds)?;978 let configured_service = args.config.is_some();979 let (config, loaded_config_revision, _lifecycle) = if let Some(config_path) = &args.config {980 let (loaded, lifecycle) =981 config_management::prepare_service(config_path).map_err(anyhow::Error::new)?;982 (loaded.config, Some(loaded.revision), lifecycle)983 } else {984 let loaded = load_serve_config(&args)?;985 loaded.config.validate()?;986 loaded.config.ensure_runtime_directory()?;987 let lifecycle = appliance::LifecycleGuard::acquire(&loaded.config.state_path, false)?;988 (loaded.config, loaded.revision, lifecycle)989 };990 let secret = identity::load(&config.identity_path)?;991 let host_secret = secret.to_bytes();992 let sessions = Arc::new(SessionRegistry::new_with_focus_owner(993 config.max_viewers,994 config.focus_owner.clone(),995 ));996 let media_generations = MediaGenerationManager::new(config.clone(), host_secret);997 let publisher = appliance::RuntimePublisher::start(998 secret.public(),999 Arc::clone(&sessions),1000 Arc::clone(&media_generations),1001 configured_service,1002 loaded_config_revision,1003 )?;1004 let authorization_result = select_authorization_policy(1005 configured_service,1006 args.dev_allow_unauthorized,1007 &config.state_path,1008 secret.public(),1009 );1010 if args.dev_allow_unauthorized {1011 warn!(1012 max_runtime_seconds = args.max_runtime_seconds,1013 "DEVELOPMENT AUTHORIZATION BYPASS ACTIVE: any peer with this host node ID may request a session"1014 );1015 eprintln!(1016 "warning=development-authorization-bypass-active; do not expose or ship this host"1017 );1018 }1019 let authorization = degrade_on_error(1020 publisher.as_ref(),1021 appliance::RuntimeErrorCode::AuthorizationState,1022 authorization_result,1023 )1024 .await?;10251026 let preflight_result: Result<(InputBackend, Option<PointerPositionTracker>)> = async {1027 let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {1028 config.test_pattern_dimensions()?;1029 let status = tokio::process::Command::new(&config.ffmpeg_path)1030 .arg("-version")1031 .stdin(std::process::Stdio::null())1032 .stdout(std::process::Stdio::null())1033 .stderr(std::process::Stdio::null())1034 .status()1035 .await1036 .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;1037 ensure!(1038 status.success(),1039 "ffmpeg version probe failed with {status}"1040 );1041 None1042 } else {1043 let preflight = source::preflight_gamescope_pipewire(&config).await?;1044 info!(1045 target_object = %preflight.target_object,1046 pointer_surface_width = preflight.pointer_surface_dimensions.width,1047 pointer_surface_height = preflight.pointer_surface_dimensions.height,1048 encoded_width = preflight.video_mode.width,1049 encoded_height = preflight.video_mode.height,1050 encoded_framerate = preflight.video_mode.framerate,1051 "Gamescope PipeWire capture preflight passed"1052 );1053 if config.audio.is_some() {1054 audio::preflight_audio_static(&config).await?;1055 info!("PipeWire Opus audio static preflight passed");1056 }1057 Some(preflight.pointer_surface_dimensions)1058 };1059 let input_backend = InputBackend::initialize(&config)?;10601061 let pointer_positions = if config.source == VideoSource::GamescopePipewire1062 && config.input_mode == InputMode::Uinput1063 {1064 let configured_display = config1065 .gamescope_pipewire1066 .as_ref()1067 .and_then(|gamescope| gamescope.xwayland_display.as_deref());1068 let pointer_surface_dimensions = pointer_surface_dimensions.context(1069 "Gamescope capture preflight did not provide pointer surface dimensions",1070 )?;1071 match PointerPositionTracker::try_initialize(1072 configured_display,1073 pointer_surface_dimensions,1074 ) {1075 Ok(tracker) => {1076 info!("Gamescope Xwayland pointer feedback ready");1077 Some(tracker)1078 }1079 Err(error) => {1080 warn!(%error, "Gamescope Xwayland pointer feedback unavailable");1081 None1082 }1083 }1084 } else {1085 None1086 };1087 Ok((input_backend, pointer_positions))1088 }1089 .await;1090 let (input_backend, pointer_positions) = degrade_on_error(1091 publisher.as_ref(),1092 appliance::RuntimeErrorCode::Preflight,1093 preflight_result,1094 )1095 .await?;10961097 let idle_timeout = degrade_on_error(1098 publisher.as_ref(),1099 appliance::RuntimeErrorCode::EndpointBind,1100 CONNECTION_IDLE_TIMEOUT1101 .try_into()1102 .context("converting bounded QUIC idle timeout"),1103 )1104 .await?;1105 let transport_config = QuicTransportConfig::builder()1106 .max_idle_timeout(Some(idle_timeout))1107 .keep_alive_interval(CONNECTION_KEEP_ALIVE_INTERVAL)1108 .build();1109 let endpoint = degrade_on_error(1110 publisher.as_ref(),1111 appliance::RuntimeErrorCode::EndpointBind,1112 Endpoint::builder(presets::N0)1113 .secret_key(secret)1114 .transport_config(transport_config)1115 .bind()1116 .await1117 .context("binding iroh endpoint"),1118 )1119 .await?;1120 let node_id = endpoint.id();1121 match tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await {1122 Ok(()) => info!(%node_id, "iroh endpoint is online"),1123 Err(_) => {1124 warn!(%node_id, "iroh endpoint online check timed out; continuing offline-capable")1125 }1126 }11271128 let moq_origin = Origin::random();1129 let input_operations = Arc::new(InputOperations::new(input_backend.clone()));1130 let authorization_admin = match &authorization {1131 AuthorizationPolicy::Required(store) => Some(1132 authorization_admin::AuthorizationAdminServer::start(1133 &config.state_path,1134 store.clone(),1135 Arc::clone(&sessions),1136 Arc::clone(&input_operations),1137 host_secret,1138 )1139 .context("starting local authorization administration listener")?,1140 ),1141 AuthorizationPolicy::TestPatternProof | AuthorizationPolicy::DevelopmentBypass => None,1142 };1143 let router = Router::builder(endpoint)1144 .accept(1145 CONTROL_ALPN_V2,1146 ControlV2Handler {1147 sessions: Arc::clone(&sessions),1148 generations: Arc::clone(&media_generations),1149 authorization: authorization.clone(),1150 input_operations: Arc::clone(&input_operations),1151 host_secret,1152 },1153 )1154 .accept(1155 CONTROL_ALPN_V1,1156 ControlHandler {1157 config: config.clone(),1158 sessions: Arc::clone(&sessions),1159 authorization: authorization.clone(),1160 },1161 )1162 .accept(1163 iroh_moq::ALPN,1164 AuthorizedMoqHandler {1165 sessions: Arc::clone(&sessions),1166 origin: moq_origin,1167 },1168 )1169 .accept(1170 MEDIA_ALPN_V3,1171 MediaV3Handler {1172 config: config.clone(),1173 sessions: Arc::clone(&sessions),1174 authorization: authorization.clone(),1175 },1176 )1177 .accept(1178 MEDIA_FEEDBACK_ALPN_V1,1179 MediaFeedbackHandler {1180 config: config.clone(),1181 sessions: Arc::clone(&sessions),1182 generations: Arc::clone(&media_generations),1183 authorization: authorization.clone(),1184 input_operations: Arc::clone(&input_operations),1185 },1186 )1187 .accept(1188 INPUT_ALPN_V2,1189 InputV2Handler {1190 operations: Arc::clone(&input_operations),1191 pointer_positions: pointer_positions.clone(),1192 sessions: Arc::clone(&sessions),1193 },1194 )1195 .accept(1196 INPUT_ALPN_V1,1197 InputHandler {1198 backend: input_backend,1199 pointer_positions,1200 sessions: Arc::clone(&sessions),1201 },1202 )1203 .accept(1204 AUDIO_ALPN_V1,1205 AudioHandler {1206 config: config.clone(),1207 sessions: Arc::clone(&sessions),1208 },1209 )1210 .spawn();12111212 if let Some(publisher) = publisher.as_ref()1213 && let Err(error) = publisher.mark_ready().await1214 {1215 warn!(%error, "publishing ready appliance status failed; continuing service");1216 }12171218 println!("node_id={node_id}");1219 println!("status=ready");1220 info!(%node_id, source = ?config.source, "sigil host ready");12211222 if let Some(seconds) = args.max_runtime_seconds {1223 tokio::time::sleep(Duration::from_secs(seconds)).await;1224 info!(seconds, "maximum runtime reached");1225 } else {1226 let signal = degrade_on_error(1227 publisher.as_ref(),1228 appliance::RuntimeErrorCode::ShutdownSignal,1229 wait_for_shutdown_signal().await,1230 )1231 .await?;1232 info!(signal, "shutdown signal received");1233 }12341235 if let Some(publisher) = publisher.as_ref()1236 && let Err(error) = publisher.mark_stopping().await1237 {1238 warn!(%error, "publishing stopping appliance status failed; continuing shutdown");1239 }1240 if let Some(administration) = authorization_admin {1241 administration.shutdown().await;1242 }1243 degrade_on_error(1244 publisher.as_ref(),1245 appliance::RuntimeErrorCode::RouterShutdown,1246 router.shutdown().await.context("shutting down iroh router"),1247 )1248 .await?;1249 if let Some(publisher) = publisher1250 && let Err(error) = publisher.finish_clean().await1251 {1252 warn!(%error, "publishing stopped appliance status failed");1253 }1254 Ok(())1255}12561257const fn development_auth_bypass_available() -> bool {1258 cfg!(feature = "demo-auth-bypass")1259}12601261fn validate_development_auth_bypass(1262 requested: bool,1263 max_runtime_seconds: Option<u64>,1264) -> Result<()> {1265 ensure!(1266 !requested || development_auth_bypass_available(),1267 "--dev-allow-unauthorized requires the explicit demo-auth-bypass feature; ordinary builds always require enrollment"1268 );1269 if requested {1270 ensure!(1271 matches!(1272 max_runtime_seconds,1273 Some(1..=MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS)1274 ),1275 "--dev-allow-unauthorized requires --max-runtime-seconds between 1 and {MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS}"1276 );1277 }1278 Ok(())1279}12801281fn select_authorization_policy(1282 configured_service: bool,1283 development_bypass: bool,1284 state_path: &Path,1285 host: EndpointId,1286) -> Result<AuthorizationPolicy> {1287 if development_bypass {1288 Ok(AuthorizationPolicy::DevelopmentBypass)1289 } else if configured_service {1290 AuthorizationStore::open(state_path.to_owned(), host).map(AuthorizationPolicy::Required)1291 } else {1292 Ok(AuthorizationPolicy::TestPatternProof)1293 }1294}12951296async fn wait_for_shutdown_signal() -> Result<&'static str> {1297 #[cfg(unix)]1298 {1299 let mut terminate =1300 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())1301 .context("installing SIGTERM handler")?;1302 tokio::select! {1303 result = tokio::signal::ctrl_c() => {1304 result.context("waiting for SIGINT")?;1305 Ok("SIGINT")1306 }1307 _ = terminate.recv() => Ok("SIGTERM"),1308 }1309 }1310 #[cfg(not(unix))]1311 {1312 tokio::signal::ctrl_c()1313 .await1314 .context("waiting for shutdown signal")?;1315 Ok("interrupt")1316 }1317}13181319struct LoadedServeConfig {1320 config: HostConfig,1321 revision: Option<ConfigRevision>,1322}13231324fn load_serve_config(args: &ServeArgs) -> Result<LoadedServeConfig> {1325 ensure!(1326 args.config.is_none(),1327 "configured service loading must hold its lifecycle transaction locks"1328 );1329 let identity_path = args1330 .identity1331 .clone()1332 .context("either --config or --identity/--source is required")?;1333 let source = args1334 .source1335 .context("either --config or --identity/--source is required")?;1336 ensure!(1337 matches!(source, SourceArg::TestPattern),1338 "direct proof mode supports only test-pattern; gamescope-pipewire requires --config"1339 );1340 let state_path = args.state_path.clone().unwrap_or_else(|| {1341 identity_path1342 .parent()1343 .unwrap_or_else(|| Path::new("."))1344 .join("runtime")1345 });1346 Ok(LoadedServeConfig {1347 config: HostConfig {1348 identity_path,1349 state_path,1350 max_viewers: args.max_viewers,1351 focus_owner: None,1352 source: source.into(),1353 width: Some(args.width),1354 height: Some(args.height),1355 framerate: args.framerate,1356 codec: "h264".into(),1357 input_mode: InputMode::Log,1358 uinput: None,1359 ffmpeg_path: args.ffmpeg.clone(),1360 gamescope_pipewire: None,1361 audio: None,1362 },1363 revision: None,1364 })1365}13661367fn parse_size(value: &str) -> std::result::Result<(u32, u32), String> {1368 let (width, height) = value1369 .split_once('x')1370 .ok_or_else(|| "size must be WIDTHxHEIGHT".to_owned())?;1371 let width = width.parse().map_err(|_| "invalid width".to_owned())?;1372 let height = height.parse().map_err(|_| "invalid height".to_owned())?;1373 Ok((width, height))1374}13751376#[cfg(test)]1377mod tests {1378 use super::*;1379 use std::fs;1380 #[cfg(unix)]1381 use std::os::unix::fs::PermissionsExt;13821383 #[test]1384 fn parses_expected_size() {1385 assert_eq!(parse_size("1280x800").unwrap(), (1280, 800));1386 assert!(parse_size("1280").is_err());1387 }13881389 #[test]1390 fn capture_probe_threshold_is_validated_before_source_start() {1391 let args = |minimum_fps| CaptureProbeArgs {1392 source: SourceArg::TestPattern,1393 frames: 1,1394 expect_size: None,1395 minimum_fps,1396 config: None,1397 };1398 assert!(validate_capture_probe_args(&args(None), 60).is_ok());1399 assert!(validate_capture_probe_args(&args(Some(55.0)), 60).is_ok());1400 assert!(validate_capture_probe_args(&args(Some(0.0)), 60).is_err());1401 assert!(validate_capture_probe_args(&args(Some(61.0)), 60).is_err());1402 assert!(validate_capture_probe_args(&args(Some(f64::NAN)), 60).is_err());1403 }14041405 #[test]1406 fn encoder_preflight_requires_an_exact_pair_and_rate_control() {1407 let valid = [1408 "sigil",1409 "capture",1410 "encoder-preflight",1411 "--vaapi-encoder",1412 "varenderD129h264enc",1413 "--vaapi-render-node",1414 "/dev/dri/renderD129",1415 "--rate-control",1416 "cbr",1417 "--rate-control",1418 "cqp",1419 ];1420 assert!(Cli::try_parse_from(valid).is_ok());1421 assert!(1422 Cli::try_parse_from([1423 "sigil",1424 "capture",1425 "encoder-preflight",1426 "--vaapi-encoder",1427 "vah264enc",1428 "--vaapi-render-node",1429 "/dev/dri/renderD128",1430 ])1431 .is_err()1432 );1433 }14341435 #[test]1436 fn direct_serve_requires_identity_and_source() {1437 assert!(Cli::try_parse_from(["sigil", "serve"]).is_err());1438 assert!(1439 Cli::try_parse_from([1440 "sigil",1441 "serve",1442 "--identity",1443 "/tmp/host.key",1444 "--source",1445 "test-pattern"1446 ])1447 .is_ok()1448 );1449 }14501451 #[test]1452 fn development_auth_bypass_requires_configured_host_syntax() {1453 assert!(1454 Cli::try_parse_from([1455 "sigil",1456 "serve",1457 "--config",1458 "/tmp/host.toml",1459 "--dev-allow-unauthorized",1460 "--max-runtime-seconds",1461 "600",1462 ])1463 .is_ok()1464 );1465 assert!(1466 Cli::try_parse_from([1467 "sigil",1468 "serve",1469 "--identity",1470 "/tmp/host.key",1471 "--source",1472 "test-pattern",1473 "--dev-allow-unauthorized",1474 "--max-runtime-seconds",1475 "600",1476 ])1477 .is_err()1478 );1479 assert!(1480 Cli::try_parse_from([1481 "sigil",1482 "serve",1483 "--config",1484 "/tmp/host.toml",1485 "--dev-allow-unauthorized",1486 ])1487 .is_ok()1488 );1489 assert!(validate_development_auth_bypass(true, None).is_err());1490 assert!(validate_development_auth_bypass(true, Some(0)).is_err());1491 assert!(1492 validate_development_auth_bypass(true, Some(MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS + 1))1493 .is_err()1494 );1495 }14961497 #[test]1498 fn ordinary_release_excludes_configured_host_auth_bypass() {1499 if cfg!(not(feature = "demo-auth-bypass")) {1500 assert!(!development_auth_bypass_available());1501 assert!(validate_development_auth_bypass(true, Some(60)).is_err());1502 }1503 }15041505 #[test]1506 fn configured_host_auth_bypass_is_explicitly_build_contained() {1507 assert_eq!(1508 validate_development_auth_bypass(true, Some(60)).is_ok(),1509 development_auth_bypass_available()1510 );1511 }15121513 #[test]1514 fn development_auth_bypass_does_not_open_persistent_authorization_state() {1515 let directory = tempfile::tempdir().unwrap();1516 let state_path = directory.path();1517 let state_file = state_path.join("authorization-v1.json");1518 fs::write(&state_file, b"malformed-sentinel").unwrap();1519 #[cfg(unix)]1520 fs::set_permissions(&state_file, fs::Permissions::from_mode(0o600)).unwrap();1521 let before = fs::read(&state_file).unwrap();1522 let host = iroh::SecretKey::from_bytes(&[31; 32]).public();15231524 assert!(matches!(1525 select_authorization_policy(true, true, state_path, host).unwrap(),1526 AuthorizationPolicy::DevelopmentBypass1527 ));1528 assert_eq!(fs::read(&state_file).unwrap(), before);1529 assert!(!state_path.join("authorization-v1.lock").exists());15301531 let required_directory = tempfile::tempdir().unwrap();1532 let required_state = required_directory.path().join("authorization-v1.json");1533 fs::write(&required_state, b"malformed-sentinel").unwrap();1534 #[cfg(unix)]1535 fs::set_permissions(&required_state, fs::Permissions::from_mode(0o600)).unwrap();1536 assert!(select_authorization_policy(true, false, required_directory.path(), host).is_err());1537 }15381539 #[test]1540 fn invitation_and_enrollment_commands_parse_strictly() {1541 let peer = iroh::SecretKey::from_bytes(&[19; 32]).public().to_string();1542 assert!(1543 Cli::try_parse_from([1544 "sigil",1545 "invitation",1546 "create",1547 "--config",1548 "/tmp/host.toml",1549 "--peer",1550 &peer,1551 "--pointer-keyboard",1552 "--gamepad",1553 "--print-deep-link",1554 "--output",1555 "/tmp/portal.goq-invite",1556 ])1557 .is_ok()1558 );1559 assert!(1560 Cli::try_parse_from([1561 "sigil",1562 "invitation",1563 "create",1564 "--config",1565 "/tmp/host.toml",1566 "--peer",1567 &peer,1568 "--expires-in-seconds",1569 "901",1570 "--output",1571 "/tmp/portal.goq-invite",1572 ])1573 .is_err()1574 );1575 assert!(1576 Cli::try_parse_from([1577 "sigil",1578 "enrollment",1579 "revoke",1580 "--config",1581 "/tmp/host.toml",1582 "--expected-host-fingerprint",1583 "aaaaaaaa…bbbbbbbb",1584 "--json",1585 ])1586 .is_ok()1587 );1588 assert!(1589 Cli::try_parse_from([1590 "sigil",1591 "enrollment",1592 "revoke",1593 "--config",1594 "/tmp/host.toml"1595 ])1596 .is_err()1597 );1598 }15991600 #[test]1601 fn appliance_status_requires_explicit_json_output() {1602 assert!(1603 Cli::try_parse_from([1604 "sigil",1605 "appliance",1606 "status",1607 "--config",1608 "/tmp/host.toml",1609 "--json",1610 ])1611 .is_ok()1612 );1613 assert!(1614 Cli::try_parse_from(["sigil", "appliance", "status", "--config", "/tmp/host.toml"])1615 .is_err()1616 );1617 assert!(1618 Cli::try_parse_from([1619 "sigil",1620 "appliance",1621 "status",1622 "--config",1623 "/tmp/host.toml",1624 "--json",1625 "--schema-version",1626 "2",1627 ])1628 .is_ok()1629 );1630 assert!(1631 Cli::try_parse_from([1632 "sigil",1633 "appliance",1634 "status",1635 "--config",1636 "/tmp/host.toml",1637 "--json",1638 "--schema-version",1639 "3",1640 ])1641 .is_err()1642 );1643 }16441645 #[test]1646 fn appliance_enrollment_reset_requires_confirmation_and_json() {1647 let valid = [1648 "sigil",1649 "appliance",1650 "enrollment-reset",1651 "--config",1652 "/tmp/host.toml",1653 "--expected-host-fingerprint",1654 "aaaaaaaa…bbbbbbbb",1655 "--json",1656 ];1657 assert!(Cli::try_parse_from(valid).is_ok());1658 assert!(1659 Cli::try_parse_from([1660 "sigil",1661 "appliance",1662 "enrollment-reset",1663 "--config",1664 "/tmp/host.toml",1665 "--json",1666 ])1667 .is_err()1668 );1669 }16701671 #[test]1672 fn appliance_config_commands_require_explicit_machine_inputs() {1673 assert!(1674 Cli::try_parse_from([1675 "sigil",1676 "appliance",1677 "config",1678 "show",1679 "--config",1680 "/tmp/host.toml",1681 "--json",1682 ])1683 .is_ok()1684 );1685 assert!(1686 Cli::try_parse_from([1687 "sigil",1688 "appliance",1689 "config",1690 "set",1691 "--config",1692 "/tmp/host.toml",1693 ])1694 .is_err()1695 );1696 assert!(1697 Cli::try_parse_from([1698 "sigil",1699 "appliance",1700 "config",1701 "commit",1702 "--config",1703 "/tmp/host.toml",1704 "--transaction",1705 "0123456789abcdef0123456789abcdef",1706 "--expected-instance",1707 "fedcba9876543210fedcba9876543210",1708 "--json",1709 ])1710 .is_ok()1711 );1712 assert!(1713 Cli::try_parse_from([1714 "sigil",1715 "appliance",1716 "config",1717 "commit",1718 "--config",1719 "/tmp/host.toml",1720 "--transaction",1721 "0123456789abcdef0123456789abcdef",1722 "--json",1723 ])1724 .is_err()1725 );1726 }17271728 #[test]1729 fn invitation_file_uses_create_new_semantics() {1730 let directory = tempfile::tempdir().unwrap();1731 let path = directory.path().join("portal.goq-invite");1732 write_invitation_file(&path, "token").unwrap();1733 assert_eq!(std::fs::read_to_string(&path).unwrap(), "token\n");1734 assert!(write_invitation_file(&path, "replacement").is_err());1735 #[cfg(unix)]1736 assert_eq!(1737 std::fs::metadata(path).unwrap().permissions().mode() & 0o777,1738 0o6001739 );1740 }17411742 #[test]1743 fn static_media_transport_liveness_is_bounded() {1744 assert_eq!(CONNECTION_IDLE_TIMEOUT, Duration::from_secs(5));1745 assert_eq!(CONNECTION_KEEP_ALIVE_INTERVAL, Duration::from_secs(1));1746 assert!(CONNECTION_KEEP_ALIVE_INTERVAL < CONNECTION_IDLE_TIMEOUT);1747 assert!(CONNECTION_IDLE_TIMEOUT <= Duration::from_secs(6));1748 }1749}