Add owner-only probe identities and bounded invitation-file preflight without changing the ephemeral default. Prove redemption, ticket-free reconnect, compatibility transport reuse, InputAck, and replay rejection against configured Sigil.
- commit
- deefbce2cbce57f92ec1667cab9d3b7e3dcbc66c
- parent
- f36cd090117752cd0e41b042a0d4f547cb345e1e
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | crates/ | 399 | +++++++++++++++++++++++++++++- |
| M | docs/ | 53 | ++++ |
| A | scripts/ | 181 | ++++++++++++++ |
@@ -1,6 +1,9 @@
11 use std::{22 collections::BTreeMap,3- time::{Duration, Instant},3+ fs::OpenOptions,4+ io::Read,5+ path::{Path, PathBuf},6+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},47 };58 69 use anyhow::{Context, Result, bail, ensure};@@ -10,14 +13,22 @@ use iroh_moq::{Moq, MoqSession};
1013 use moq_net::{BroadcastConsumer, GroupConsumer, TrackConsumer};1114 use sigil_protocol::{1215 CONTROL_ALPN_V1, Capability, ClientHello, FrameFlags, GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN,13- GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, InputEvent, KeyframeRequestReasonV3,14- MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3,15- MediaCodec, MediaControlRequestV3, MediaFrame, MediaObjectV3, PointerSurfaceDimensions,16- ProtocolError, decode_media_frame_object, media_moq_broadcast_name, read_host_hello,16+ GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INVITATION_CLOCK_SKEW_SECS, InputEvent,17+ InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN, MAX_MEDIA_GROUP_BYTES_V3,18+ MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MediaCodec,19+ MediaControlRequestV3, MediaFrame, MediaObjectV3, PointerSurfaceDimensions, ProtocolError,20+ SignedInvitation, decode_media_frame_object, media_moq_broadcast_name, read_host_hello,1721 read_input_ack, read_media_frame, read_media_object, read_media_object_v3, write_client_hello,1822 write_input_event, write_media_control_request_v3,1923 };2024 25+26+use std::os::unix::fs::{MetadataExt, OpenOptionsExt};27+28+29+30+mod identity;31+2132 mod moq_catalog;2233 2334 use moq_catalog::{MoqCatalogMode, subscribe_goq_video_track};@@ -29,6 +40,13 @@ const MEDIA_OBJECT_CAPACITY: usize = 4;
2940 struct Args {3041 3142 node_id: EndpointId,43+ /// Owner-only 32-byte Iroh identity. Omit for the existing ephemeral probe.44+ 45+ identity: Option<PathBuf>,46+ /// Owner-only one-time Sigil invitation file. Sent only on the first media47+ /// handshake and requires a persistent identity bound to that invitation.48+ 49+ invitation: Option<PathBuf>,3250 3351 frames: u32,3452 @@ -110,6 +128,7 @@ impl MediaTransport {
110128 111129 const KEYFRAME_SMOKE_REQUEST_AFTER_FRAMES: u32 = 3;112130 const KEYFRAME_SMOKE_MINIMUM_FRAMES: u32 = KEYFRAME_SMOKE_REQUEST_AFTER_FRAMES + 1;131+const MAX_INVITATION_FILE_BYTES: u64 = (MAX_INVITATION_TOKEN_LEN + 2) as u64;113132 114133 115134 enum MediaObjectOutcome {@@ -955,7 +974,22 @@ async fn main() -> Result<()> {
955974 "--keyframe-smoke requires at least {KEYFRAME_SMOKE_MINIMUM_FRAMES} accepted frames"956975 );957976 958- let secret = SecretKey::generate();977+ let secret = match args.identity.as_deref() {978+ Some(path) => identity::load(path).context("loading persistent probe identity")?,979+ None => SecretKey::generate(),980+ };981+ let mut required_grants = InvitationGrants::VIEW;982+ if args.pointer_smoke {983+ required_grants = required_grants.union(InvitationGrants::POINTER_KEYBOARD);984+ }985+ if args.gamepad_smoke {986+ required_grants = required_grants.union(InvitationGrants::GAMEPAD);987+ }988+ let invitation = args989+ .invitation990+ .as_deref()991+ .map(|path| load_invitation_file(path, args.node_id, secret.public(), required_grants))992+ .transpose()?;959993 let mut nonce = [0_u8; 16];960994 getrandom::fill(&mut nonce).context("generating handshake nonce")?;961995 let endpoint = Endpoint::builder(presets::N0)@@ -995,6 +1029,7 @@ async fn main() -> Result<()> {
9951029 vec![Capability::VideoH264],9961030 Capability::VideoH264,9971031 handshake_name,1032+ invitation.as_deref(),9981033 )9991034 .await?;10001035 let session_id = media_negotiation.session_id;@@ -1053,6 +1088,7 @@ async fn main() -> Result<()> {
10531088 input_offers,10541089 Capability::InputAck,10551090 "input",1091+ None,10561092 )10571093 .await?;10581094 ensure!(@@ -1683,6 +1719,118 @@ fn parse_minimum_fps(value: &str) -> std::result::Result<f64, String> {
16831719 Ok(minimum_fps)16841720 }16851721 1722+fn load_invitation_file(1723+ path: &Path,1724+ expected_host: EndpointId,1725+ expected_peer: EndpointId,1726+ required_grants: InvitationGrants,1727+) -> Result<String> {1728+ let mut options = OpenOptions::new();1729+ options.read(true);1730+ 1731+ options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);1732+ let file = options.open(path).with_context(|| {1733+ format!(1734+ "opening invitation {} without following symlinks",1735+ path.display()1736+ )1737+ })?;1738+ let metadata = file1739+ .metadata()1740+ .with_context(|| format!("inspecting invitation {}", path.display()))?;1741+ ensure!(metadata.is_file(), "invitation must be a regular file");1742+ ensure!(1743+ metadata.len() <= MAX_INVITATION_FILE_BYTES,1744+ "invitation {} exceeds the bounded file size",1745+ path.display()1746+ );1747+ 1748+ {1749+ ensure!(1750+ metadata.mode() & 0o077 == 0,1751+ "invitation {} must not be readable or writable by group or other users",1752+ path.display()1753+ );1754+ ensure!(1755+ metadata.uid() == unsafe { libc::geteuid() },1756+ "invitation {} is not owned by the current user",1757+ path.display()1758+ );1759+ }1760+ let mut bytes = Vec::with_capacity(metadata.len() as usize);1761+ file.take(MAX_INVITATION_FILE_BYTES + 1)1762+ .read_to_end(&mut bytes)1763+ .with_context(|| format!("reading invitation {}", path.display()))?;1764+ ensure!(1765+ bytes.len() as u64 <= MAX_INVITATION_FILE_BYTES,1766+ "invitation {} grew beyond the bounded file size while being read",1767+ path.display()1768+ );1769+ let token_bytes = bytes1770+ .strip_suffix(b"\r\n")1771+ .or_else(|| bytes.strip_suffix(b"\n"))1772+ .unwrap_or(&bytes);1773+ ensure!(1774+ !token_bytes.is_empty() && token_bytes.len() <= MAX_INVITATION_TOKEN_LEN,1775+ "invitation token has an invalid length"1776+ );1777+ ensure!(1778+ !token_bytes.contains(&b'\n') && !token_bytes.contains(&b'\r'),1779+ "invitation file contains unexpected line breaks"1780+ );1781+ let token = std::str::from_utf8(token_bytes).context("invitation file is not UTF-8")?;1782+ let invitation =1783+ SignedInvitation::decode(token).context("decoding and verifying invitation file")?;1784+ ensure!(1785+ invitation.claims.host_node_id == *expected_host.as_bytes(),1786+ "invitation belongs to a different Sigil host"1787+ );1788+ ensure!(1789+ invitation.claims.intended_peer_id == *expected_peer.as_bytes(),1790+ "invitation is bound to a different probe identity"1791+ );1792+ ensure!(1793+ invitation.claims.grants.contains(required_grants),1794+ "invitation does not grant every requested probe capability"1795+ );1796+ ensure!(1797+ invitation1798+ .claims1799+ .grants1800+ .contains(InvitationGrants::POINTER_KEYBOARD)1801+ || invitation.claims.grants.contains(InvitationGrants::GAMEPAD),1802+ "invitation must grant pointer/keyboard or gamepad for the probe input acknowledgment"1803+ );1804+ let now = SystemTime::now()1805+ .duration_since(UNIX_EPOCH)1806+ .context("system clock is before the Unix epoch")?1807+ .as_secs();1808+ ensure!(1809+ now >= invitation1810+ .claims1811+ .issued_at_unix1812+ .saturating_sub(INVITATION_CLOCK_SKEW_SECS),1813+ "invitation was issued too far in the future"1814+ );1815+ ensure!(1816+ now <= invitation.claims.expires_at_unix,1817+ "invitation has expired"1818+ );1819+ Ok(token.to_owned())1820+}1821+1822+fn probe_client_hello(1823+ nonce: [u8; 16],1824+ capabilities: Vec<Capability>,1825+ invitation: Option<&str>,1826+) -> ClientHello {1827+ let hello = ClientHello::new("sigil-probe/0.1.0", nonce, capabilities);1828+ match invitation {1829+ Some(invitation) => hello.with_invitation(invitation),1830+ None => hello,1831+ }1832+}1833+16861834 async fn negotiate(16871835 send: &mut iroh::endpoint::SendStream,16881836 recv: &mut iroh::endpoint::RecvStream,@@ -1690,13 +1838,11 @@ async fn negotiate(
16901838 capabilities: Vec<Capability>,16911839 required: Capability,16921840 name: &str,1841+ invitation: Option<&str>,16931842 ) -> Result<Negotiated> {1694- write_client_hello(1695- send,1696- &ClientHello::new("sigil-probe/0.1.0", nonce, capabilities),1697- )1698- .await1699- .with_context(|| format!("writing {name} hello"))?;1843+ write_client_hello(send, &probe_client_hello(nonce, capabilities, invitation))1844+ .await1845+ .with_context(|| format!("writing {name} hello"))?;17001846 let response = tokio::time::timeout(Duration::from_secs(10), read_host_hello(recv))17011847 .await17021848 .with_context(|| format!("timed out waiting for {name} hello"))??@@ -2174,6 +2320,233 @@ mod tests {
21742320 );21752321 }21762322 2323+ fn test_invitation(2324+ host: &SecretKey,2325+ peer: &SecretKey,2326+ grants: InvitationGrants,2327+ issued_at_unix: u64,2328+ expires_at_unix: u64,2329+ ) -> String {2330+ let claims = sigil_protocol::InvitationClaims::new(2331+ *host.public().as_bytes(),2332+ *peer.public().as_bytes(),2333+ issued_at_unix,2334+ expires_at_unix,2335+ 1,2336+ [0x55; 32],2337+ grants,2338+ )2339+ .unwrap();2340+ SignedInvitation::issue(claims, &host.to_bytes())2341+ .unwrap()2342+ .encode()2343+ }2344+2345+ fn write_private_invitation(path: &Path, token: &str, ending: &[u8]) {2346+ let mut bytes = token.as_bytes().to_vec();2347+ bytes.extend_from_slice(ending);2348+ std::fs::write(path, bytes).unwrap();2349+ 2350+ {2351+ use std::os::unix::fs::PermissionsExt as _;2352+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();2353+ }2354+ }2355+2356+ 2357+ fn persistent_probe_credentials_are_explicit_and_paired() {2358+ let node_id = SecretKey::from_bytes(&[0x2a; 32]).public().to_string();2359+ assert!(2360+ Args::try_parse_from([2361+ "sigil-probe",2362+ "--node-id",2363+ &node_id,2364+ "--invitation",2365+ "probe.goq-invite",2366+ ])2367+ .is_err()2368+ );2369+ let persistent = Args::try_parse_from([2370+ "sigil-probe",2371+ "--node-id",2372+ &node_id,2373+ "--identity",2374+ "probe.key",2375+ "--invitation",2376+ "probe.goq-invite",2377+ ])2378+ .unwrap();2379+ assert_eq!(persistent.identity, Some(PathBuf::from("probe.key")));2380+ assert_eq!(2381+ persistent.invitation,2382+ Some(PathBuf::from("probe.goq-invite"))2383+ );2384+ }2385+2386+ 2387+ fn private_invitation_preflight_accepts_only_the_bound_current_grants() {2388+ let temp = tempfile::tempdir().unwrap();2389+ let path = temp.path().join("probe.goq-invite");2390+ let host = SecretKey::from_bytes(&[0x31; 32]);2391+ let peer = SecretKey::from_bytes(&[0x32; 32]);2392+ let other = SecretKey::from_bytes(&[0x33; 32]);2393+ let now = SystemTime::now()2394+ .duration_since(UNIX_EPOCH)2395+ .unwrap()2396+ .as_secs();2397+ let token = test_invitation(2398+ &host,2399+ &peer,2400+ InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),2401+ now.saturating_sub(1),2402+ now + 600,2403+ );2404+2405+ write_private_invitation(&path, &token, b"\n");2406+ assert_eq!(2407+ load_invitation_file(2408+ &path,2409+ host.public(),2410+ peer.public(),2411+ InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),2412+ )2413+ .unwrap(),2414+ token2415+ );2416+ write_private_invitation(&path, &token, b"\r\n");2417+ assert!(2418+ load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)2419+ .is_ok()2420+ );2421+ assert!(2422+ load_invitation_file(&path, other.public(), peer.public(), InvitationGrants::VIEW,)2423+ .is_err()2424+ );2425+ assert!(2426+ load_invitation_file(&path, host.public(), other.public(), InvitationGrants::VIEW,)2427+ .is_err()2428+ );2429+ assert!(2430+ load_invitation_file(2431+ &path,2432+ host.public(),2433+ peer.public(),2434+ InvitationGrants::POINTER_KEYBOARD,2435+ )2436+ .is_err()2437+ );2438+2439+ let view_only = test_invitation(2440+ &host,2441+ &peer,2442+ InvitationGrants::VIEW,2443+ now.saturating_sub(1),2444+ now + 600,2445+ );2446+ write_private_invitation(&path, &view_only, b"\n");2447+ assert!(2448+ load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)2449+ .unwrap_err()2450+ .to_string()2451+ .contains("input acknowledgment")2452+ );2453+2454+ let expired = test_invitation(2455+ &host,2456+ &peer,2457+ InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),2458+ now.saturating_sub(120),2459+ now.saturating_sub(60),2460+ );2461+ write_private_invitation(&path, &expired, b"\n");2462+ assert!(2463+ load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)2464+ .unwrap_err()2465+ .to_string()2466+ .contains("expired")2467+ );2468+ write_private_invitation(&path, &token, b"\n\n");2469+ assert!(2470+ load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)2471+ .is_err()2472+ );2473+ let future = test_invitation(2474+ &host,2475+ &peer,2476+ InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),2477+ now + INVITATION_CLOCK_SKEW_SECS + 1,2478+ now + INVITATION_CLOCK_SKEW_SECS + 61,2479+ );2480+ write_private_invitation(&path, &future, b"\n");2481+ assert!(2482+ load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)2483+ .unwrap_err()2484+ .to_string()2485+ .contains("future")2486+ );2487+ }2488+2489+ 2490+ 2491+ fn invitation_reader_rejects_unsafe_files() {2492+ use std::os::unix::fs::{PermissionsExt as _, symlink};2493+2494+ let temp = tempfile::tempdir().unwrap();2495+ let path = temp.path().join("probe.goq-invite");2496+ let link = temp.path().join("linked.goq-invite");2497+ let host = SecretKey::from_bytes(&[0x41; 32]);2498+ let peer = SecretKey::from_bytes(&[0x42; 32]);2499+ let now = SystemTime::now()2500+ .duration_since(UNIX_EPOCH)2501+ .unwrap()2502+ .as_secs();2503+ let token = test_invitation(2504+ &host,2505+ &peer,2506+ InvitationGrants::VIEW,2507+ now.saturating_sub(1),2508+ now + 60,2509+ );2510+ write_private_invitation(&path, &token, b"\n");2511+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();2512+ assert!(2513+ load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)2514+ .is_err()2515+ );2516+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();2517+ symlink(&path, &link).unwrap();2518+ assert!(2519+ load_invitation_file(&link, host.public(), peer.public(), InvitationGrants::VIEW,)2520+ .is_err()2521+ );2522+ std::fs::write(&path, vec![b'x'; MAX_INVITATION_FILE_BYTES as usize + 1]).unwrap();2523+ assert!(2524+ load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)2525+ .is_err()2526+ );2527+ }2528+2529+ 2530+ fn invitation_is_attached_only_when_the_caller_selects_the_media_hello() {2531+ let host = SecretKey::from_bytes(&[0x51; 32]);2532+ let peer = SecretKey::from_bytes(&[0x52; 32]);2533+ let now = SystemTime::now()2534+ .duration_since(UNIX_EPOCH)2535+ .unwrap()2536+ .as_secs();2537+ let token = test_invitation(2538+ &host,2539+ &peer,2540+ InvitationGrants::VIEW,2541+ now.saturating_sub(1),2542+ now + 60,2543+ );2544+ let media = probe_client_hello([7; 16], vec![Capability::VideoH264], Some(&token));2545+ let input = probe_client_hello([7; 16], vec![Capability::InputAck], None);2546+ assert_eq!(media.invitation.as_deref(), Some(token.as_str()));2547+ assert_eq!(input.invitation, None);2548+ }2549+21772550 21782551 fn media_compatibility_flags_are_mutually_exclusive() {21792552 let node_id = SecretKey::from_bytes(&[0x2a; 32]).public().to_string();@@ -2181,6 +2554,8 @@ mod tests {
21812554 assert!(!default.media_v1);21822555 assert!(!default.media_v2);21832556 assert!(!default.media_v3);2557+ assert_eq!(default.identity, None);2558+ assert_eq!(default.invitation, None);21842559 assert_eq!(default.expect_size, None);21852560 let strict = Args::try_parse_from([21862561 "sigil-probe",@@ -33,6 +33,8 @@ sigil capture probe --source gamescope-pipewire --config <path> --frames <count>
3333 sigil serve --identity <path> --source test-pattern3434 sigil serve --config <path>3535 sigil-probe --node-id <host-node-id> --frames <count>36+sigil-probe --node-id <host-node-id> --identity <probe.key> \37+ [--invitation <probe.goq-invite>] --frames <count>3638 portal --dev-connect <host-node-id>3739 ```3840 @@ -110,6 +112,57 @@ profile.
110112 Do not put an identity seed in an environment variable or command-line111113 argument. Store it in a mode `0600` file and pass only its path.112114 115+For an authenticated headless hardware probe, create a dedicated persistent116+probe identity on the client and bind a short-lived invitation to its printed117+public ID. This enrollment is intentionally separate from the ordinary Portal118+profile and therefore belongs only in an isolated temporary host state:119+120+```bash121+# On the client:122+umask 077123+probe_state="$HOME/.local/state/goq-probe"124+install -d -m 0700 "$probe_state"125+sigil identity init --output "$probe_state/probe.key"126+127+# On the host's isolated test state/config, using the peer ID printed above:128+proof_state="$HOME/.local/state/sigil-spark-hardware-proof"129+sigil invitation create \130+ --config "$proof_state/host.toml" \131+ --peer PROBE_PEER_ID \132+ --pointer-keyboard \133+ --output "$proof_state/probe.goq-invite"134+135+# From the client, copy the exact invitation over the authenticated SSH path.136+scp tank@umpc:.local/state/sigil-spark-hardware-proof/probe.goq-invite \137+ "$probe_state/probe.goq-invite"138+chmod 0600 "$probe_state/probe.goq-invite"139+140+# First connection redeems the one-time invitation.141+sigil-probe --node-id HOST_NODE_ID \142+ --identity "$probe_state/probe.key" \143+ --invitation "$probe_state/probe.goq-invite" \144+ --keyframe-smoke --frames 120145+146+# Later connections use the same identity without replaying the invitation.147+sigil-probe --node-id HOST_NODE_ID \148+ --identity "$probe_state/probe.key" \149+ --keyframe-smoke --frames 120150+```151+152+Both credential files must be regular files owned by the current user with no153+group or other permissions. The probe verifies the invitation signature,154+host/peer binding, expiry, and requested grants before opening a network155+connection, sends it only on the initial media handshake, and never prints its156+contents. Delete the exact invitation file only after enrollment is confirmed;157+if a later session step fails after redemption, retry ticket-free with the same158+identity. After `sigil enrollment show` confirms the temporary probe peer,159+remove both exact copies:160+161+```bash162+rm -- "$HOME/.local/state/goq-probe/probe.goq-invite"163+ssh tank@umpc 'rm -- "$HOME/.local/state/sigil-spark-hardware-proof/probe.goq-invite"'164+```165+113166 ## 1. Choose and install Bazzite114167 115168 Use the Bazzite download picker with:@@ -0,0 +1,181 @@
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+temp_parent="${TMPDIR:-/tmp}"8+temp_parent="${temp_parent%/}"9+temp_root="$(mktemp -d "$temp_parent/sigil-auth-probe.XXXXXX")"10+11+host_pid=""12+cleanup() {13+ local status=$?14+ trap - EXIT INT TERM HUP15+ if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then16+ kill -TERM "$host_pid" 2>/dev/null || true17+ wait "$host_pid" 2>/dev/null || true18+ fi19+ case "$temp_root" in20+ "$temp_parent"/sigil-auth-probe.??????) rm -rf -- "$temp_root" ;;21+ esac22+ exit "$status"23+}24+trap cleanup EXIT INT TERM HUP25+26+die() {27+ printf 'authenticated probe proof failed: %s\n' "$*" >&228+ exit 129+}30+31+wait_for_ready() {32+ local deadline=$((SECONDS + 30))33+ while (( SECONDS < deadline )); do34+ grep -Fxq 'status=ready' "$host_log" 2>/dev/null && return 035+ kill -0 "$host_pid" 2>/dev/null || return 136+ sleep 0.137+ done38+ return 139+}40+41+wait_for_log_count() {42+ local literal="$1"43+ local expected="$2"44+ local deadline=$((SECONDS + 15))45+ local observed46+ while (( SECONDS < deadline )); do47+ observed="$(grep -Fc -- "$literal" "$host_log" 2>/dev/null || true)"48+ [[ "$observed" -ge "$expected" ]] && return 049+ kill -0 "$host_pid" 2>/dev/null || return 150+ sleep 0.151+ done52+ return 153+}54+55+cd "$repo_dir"56+if [[ -f "${HOME}/.cargo/env" ]]; then57+ # shellcheck source=/dev/null58+ source "${HOME}/.cargo/env"59+fi60+cargo build --locked -p sigil-host --bins61+62+target_root="${CARGO_TARGET_DIR:-$repo_dir/target}"63+case "$target_root" in64+ /*) ;;65+ *) target_root="$repo_dir/$target_root" ;;66+esac67+host_bin="$target_root/debug/sigil"68+probe_bin="$target_root/debug/sigil-probe"69+ffmpeg_bin="$(command -v ffmpeg)"70+[[ -x "$host_bin" && -x "$probe_bin" ]] || die 'host/probe binaries are missing'71+[[ -x "$ffmpeg_bin" ]] || die 'ffmpeg is unavailable'72+73+export XDG_RUNTIME_DIR="$temp_root/xdg"74+install -d -m 0700 "$XDG_RUNTIME_DIR"75+host_identity="$temp_root/host.key"76+probe_identity="$temp_root/probe.key"77+host_config="$temp_root/host.toml"78+invitation="$temp_root/probe.goq-invite"79+host_log="$temp_root/host.log"80+first_log="$temp_root/first.log"81+reconnect_log="$temp_root/reconnect.log"82+compat_log="$temp_root/compat.log"83+replay_log="$temp_root/replay.log"84+85+"$host_bin" identity init --output "$host_identity" >"$temp_root/host-identity.log"86+"$host_bin" identity init --output "$probe_identity" >"$temp_root/probe-identity.log"87+host_node_id="$(sed -n 's/^node_id=//p' "$temp_root/host-identity.log")"88+probe_node_id="$(sed -n 's/^node_id=//p' "$temp_root/probe-identity.log")"89+[[ -n "$host_node_id" && -n "$probe_node_id" ]] || die 'identity output is incomplete'90+91+printf '%s\n' \92+ "identity_path = \"$host_identity\"" \93+ "state_path = \"$temp_root/state\"" \94+ 'source = "test-pattern"' \95+ 'width = 1280' \96+ 'height = 800' \97+ 'framerate = 60' \98+ 'codec = "h264"' \99+ 'input_mode = "log"' \100+ "ffmpeg_path = \"$ffmpeg_bin\"" \101+ >"$host_config"102+chmod 0600 "$host_config"103+"$host_bin" config check --config "$host_config" >/dev/null104+"$host_bin" invitation create \105+ --config "$host_config" \106+ --peer "$probe_node_id" \107+ --pointer-keyboard \108+ --output "$invitation" \109+ >/dev/null110+111+RUST_LOG='info,sigil::server=debug' "$host_bin" serve \112+ --config "$host_config" \113+ --max-runtime-seconds 90 \114+ >"$host_log" 2>&1 &115+host_pid=$!116+wait_for_ready || {117+ sed -n '1,160p' "$host_log" >&2 || true118+ die 'configured host did not become ready'119+}120+121+"$probe_bin" \122+ --node-id "$host_node_id" \123+ --identity "$probe_identity" \124+ --invitation "$invitation" \125+ --frames 1 \126+ >"$first_log"127+wait_for_log_count 'MoQ control client released' 1 \128+ || die 'first authenticated session did not drain'129+130+"$probe_bin" \131+ --node-id "$host_node_id" \132+ --identity "$probe_identity" \133+ --frames 1 \134+ >"$reconnect_log"135+wait_for_log_count 'MoQ control client released' 2 \136+ || die 'ticket-free session did not drain'137+138+"$probe_bin" \139+ --node-id "$host_node_id" \140+ --identity "$probe_identity" \141+ --media-v3 \142+ --frames 1 \143+ >"$compat_log"144+wait_for_log_count 'media v3 client released' 1 \145+ || die 'grouped-v3 session did not drain'146+147+for proof_log in "$first_log" "$reconnect_log" "$compat_log"; do148+ grep -Fxq 'probe=ok' "$proof_log" || die "probe evidence is missing from $proof_log"149+ grep -Fxq 'sequence_gaps=0' "$proof_log" || die "sequence gap in $proof_log"150+ awk -F= '$1 == "input_ack_micros" && $2 ~ /^[0-9]+$/ { found=1 } END { exit !found }' \151+ "$proof_log" || die "input acknowledgment is missing from $proof_log"152+done153+grep -Fxq 'transport=iroh-moq' "$first_log" || die 'first transport is not upstream MoQ'154+grep -Fxq 'transport=iroh-moq' "$reconnect_log" || die 'reconnect transport is not upstream MoQ'155+grep -Fxq 'transport=grouped-v3' "$compat_log" || die 'compatibility transport is not grouped-v3'156+157+if "$probe_bin" \158+ --node-id "$host_node_id" \159+ --identity "$probe_identity" \160+ --invitation "$invitation" \161+ --frames 4 \162+ >"$replay_log" 2>&1; then163+ die 'redeemed invitation replay was accepted'164+fi165+grep -Fq 'host rejected control stream: Portal peer is not authorized' "$replay_log" \166+ || {167+ sed -n '1,80p' "$replay_log" >&2 || true168+ die 'redeemed invitation replay returned the wrong rejection'169+ }170+enrollment="$($host_bin enrollment show --config "$host_config")"171+grep -Fxq 'enrollment=active' <<<"$enrollment" || die 'probe enrollment is not active'172+grep -Fxq "peer_node_id=$probe_node_id" <<<"$enrollment" \173+ || die 'enrollment belongs to the wrong peer'174+grep -Fxq 'grants=view,pointer-keyboard' <<<"$enrollment" \175+ || die 'enrollment grants changed'176+177+printf 'authenticated_probe_proof=ok\n'178+printf 'invitation_redemption=ok\n'179+printf 'ticket_free_reconnect=ok\n'180+printf 'grouped_v3_reconnect=ok\n'181+printf 'invitation_replay=rejected\n'