diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index ec332967b280..c81d695f0d19 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -211,6 +211,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -468,6 +491,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex 2.0.1", ] @@ -573,6 +598,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.4" @@ -782,6 +816,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cmux-tui-iroh" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "cmux-remote", + "cmux-tui-machine-protocol", + "ed25519-dalek 2.2.0", + "getrandom 0.3.4", + "iroh", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "tempfile", + "time", + "tokio", + "tokio-util", + "url", + "uuid", + "zeroize", +] + [[package]] name = "cmux-tui-machine-agent-protocol" version = "0.1.0" @@ -840,6 +900,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1036,6 +1102,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -1137,13 +1204,23 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid", + "const-oid 0.10.2", "pem-rfc7468", "zeroize", ] @@ -1276,15 +1353,45 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] @@ -1294,11 +1401,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", - "ed25519", + "ed25519 3.0.0", "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -1517,6 +1624,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.33" @@ -2227,7 +2340,7 @@ dependencies = [ "ctutils", "data-encoding", "derive_more", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "getrandom 0.4.3", "hickory-resolver", @@ -2275,7 +2388,7 @@ dependencies = [ "data-encoding", "data-encoding-macro", "derive_more", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "getrandom 0.4.3", "n0-error", "rand 0.10.2", @@ -2478,6 +2591,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -3517,14 +3640,24 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -3737,6 +3870,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3803,6 +3993,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -4035,9 +4228,13 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4139,6 +4336,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4203,6 +4401,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4508,6 +4707,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -4626,6 +4834,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -4633,7 +4851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.1", ] [[package]] diff --git a/cmux-tui/Cargo.toml b/cmux-tui/Cargo.toml index 6d819ad37aba..408c467a6094 100644 --- a/cmux-tui/Cargo.toml +++ b/cmux-tui/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/cmux-relay", "crates/cmux-tui-machine-agent-protocol", "crates/cmux-tui-machine-protocol", + "crates/cmux-tui-iroh", "crates/cmux-tui", ] default-members = [ @@ -44,6 +45,7 @@ default-members = [ "crates/cmux-relay", "crates/cmux-tui-machine-agent-protocol", "crates/cmux-tui-machine-protocol", + "crates/cmux-tui-iroh", "crates/cmux-tui", ] exclude = ["vendor/crossterm"] @@ -74,6 +76,7 @@ cmux-remote-protocol = { path = "crates/cmux-remote-protocol" } cmux-remote = { path = "crates/cmux-remote" } cmux-tui-machine-agent-protocol = { path = "crates/cmux-tui-machine-agent-protocol" } cmux-tui-machine-protocol = { path = "crates/cmux-tui-machine-protocol" } +cmux-tui-iroh = { path = "crates/cmux-tui-iroh" } anyhow = "1" async-trait = "0.1" axum = { version = "0.8", features = ["ws"] } @@ -92,6 +95,7 @@ tungstenite = { version = "0.29", default-features = false, features = ["handsha uds_windows = "1.2" windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } regex = "1" +reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] } jsonschema = { version = "0.30", default-features = false } flate2 = "1" bindgen = "0.72" @@ -100,6 +104,7 @@ getrandom = "0.3" glob = "0.3" bytes = "1" diffy = "0.4" +ed25519-dalek = "2" fs4 = "1.1.0" terminput = "=0.5.11" terminput-crossterm = { version = "=0.4.7", default-features = false, features = ["crossterm_0_29"] } @@ -120,12 +125,14 @@ socket2 = { version = "0.5", features = ["all"] } snow = "0.10" tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "handshake", "rustls-tls-native-roots"] } +tokio-util = { version = "0.7", features = ["rt"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["timeout"] } url = "2" uuid = { version = "1", features = ["serde", "v4"] } x25519-dalek = { version = "2", features = ["static_secrets"] } zeroize = { version = "1", features = ["derive"] } +time = { version = "0.3", features = ["formatting", "parsing"] } [patch.crates-io] # Crossterm 0.29 discards Kitty's shifted key, base-layout key, and associated diff --git a/cmux-tui/crates/cmux-remote/src/identity.rs b/cmux-tui/crates/cmux-remote/src/identity.rs index 0069c5bd1bf1..0e262b4605c4 100644 --- a/cmux-tui/crates/cmux-remote/src/identity.rs +++ b/cmux-tui/crates/cmux-remote/src/identity.rs @@ -1999,6 +1999,27 @@ fn atomic_json(path: &Path, value: &impl Serialize) -> Result<(), IdentityError> result } +/// Atomically persists JSON in a managed owner-only directory. +/// +/// This is the same durability path used by the remote identity database: +/// create an owner-only temporary file, sync it, rename it, then sync the +/// parent directory. Callers should use [`crate::secret_file::read_owner_only`] +/// when loading the result so the read side retains the same ownership and +/// symlink checks. +pub fn write_owner_only_json(path: &Path, value: &impl Serialize) -> Result<(), IdentityError> { + atomic_json(path, value) +} + +/// Loads bounded JSON through the hardened owner-only secret-file reader. +pub fn read_owner_only_json( + path: &Path, + maximum_bytes: usize, +) -> Result { + let bytes = + crate::secret_file::read_owner_only(path, maximum_bytes).map_err(IdentityError::Io)?; + serde_json::from_slice(&bytes).map_err(IdentityError::Json) +} + fn sync_parent_directory(path: &Path) -> Result<(), IdentityError> { #[cfg(unix)] File::open(path).and_then(|directory| directory.sync_all()).map_err(IdentityError::Io)?; diff --git a/cmux-tui/crates/cmux-remote/src/lib.rs b/cmux-tui/crates/cmux-remote/src/lib.rs index 5e1010c91548..ec9dc2b797fc 100644 --- a/cmux-tui/crates/cmux-remote/src/lib.rs +++ b/cmux-tui/crates/cmux-remote/src/lib.rs @@ -24,7 +24,7 @@ mod mux_codec; mod mux_input; mod mux_lanes; pub mod observability; -mod owner_lock; +pub mod owner_lock; pub mod provider; pub mod secret_file; pub mod secure_directory; diff --git a/cmux-tui/crates/cmux-remote/src/owner_lock.rs b/cmux-tui/crates/cmux-remote/src/owner_lock.rs index f89d0e791b1e..bb5dfcf090f7 100644 --- a/cmux-tui/crates/cmux-remote/src/owner_lock.rs +++ b/cmux-tui/crates/cmux-remote/src/owner_lock.rs @@ -5,24 +5,24 @@ use std::path::{Path, PathBuf}; use fs4::FileExt; #[derive(Debug)] -pub(crate) struct OwnerFileLock { +pub struct OwnerFileLock { file: File, } impl OwnerFileLock { - pub(crate) fn acquire(path: &Path) -> io::Result { + pub fn acquire(path: &Path) -> io::Result { let file = open_private_lock(path)?; FileExt::lock(&file)?; Ok(Self { file }) } - pub(crate) fn try_acquire(path: &Path) -> io::Result { + pub fn try_acquire(path: &Path) -> io::Result { let file = open_private_lock(path)?; FileExt::try_lock(&file).map_err(io::Error::from)?; Ok(Self { file }) } - pub(crate) async fn acquire_async(path: PathBuf) -> io::Result { + pub async fn acquire_async(path: PathBuf) -> io::Result { tokio::task::spawn_blocking(move || Self::acquire(&path)) .await .map_err(|error| io::Error::other(format!("owner-file lock task failed: {error}")))? @@ -35,7 +35,7 @@ impl Drop for OwnerFileLock { } } -pub(crate) fn sibling_lock_path(path: &Path) -> io::Result { +pub fn sibling_lock_path(path: &Path) -> io::Result { let file_name = path.file_name().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "lock target has no file name") })?; diff --git a/cmux-tui/crates/cmux-remote/src/provider/iroh.rs b/cmux-tui/crates/cmux-remote/src/provider/iroh.rs index ae2cc93313a0..4404d093a516 100644 --- a/cmux-tui/crates/cmux-remote/src/provider/iroh.rs +++ b/cmux-tui/crates/cmux-remote/src/provider/iroh.rs @@ -335,16 +335,16 @@ impl IrohProviderConfig { } #[derive(Debug, Clone, Copy)] -struct IrohListenerLimits { - maximum_connections: usize, - maximum_connection_overflow: usize, - maximum_pending_streams: usize, - maximum_pending_stream_overflow: usize, - maximum_pending_streams_per_connection: usize, - connection_handshake_timeout: Duration, - first_stream_timeout: Duration, - unauthenticated_timeout: Duration, - pre_auth_timeout: Duration, +pub struct IrohListenerLimits { + pub maximum_connections: usize, + pub maximum_connection_overflow: usize, + pub maximum_pending_streams: usize, + pub maximum_pending_stream_overflow: usize, + pub maximum_pending_streams_per_connection: usize, + pub connection_handshake_timeout: Duration, + pub first_stream_timeout: Duration, + pub unauthenticated_timeout: Duration, + pub pre_auth_timeout: Duration, } impl Default for IrohListenerLimits { @@ -364,7 +364,7 @@ impl Default for IrohListenerLimits { } impl IrohListenerLimits { - fn validate(self) -> Result { + pub fn validate(self) -> Result { if self.maximum_connections == 0 || self.maximum_pending_streams == 0 || self.maximum_pending_streams_per_connection == 0 @@ -381,7 +381,7 @@ impl IrohListenerLimits { } } -struct IrohAdmission { +pub struct IrohAdmission { limits: IrohListenerLimits, connections: Arc, connection_overflow: Arc, @@ -390,7 +390,7 @@ struct IrohAdmission { } impl IrohAdmission { - fn new(limits: IrohListenerLimits) -> Self { + pub fn new(limits: IrohListenerLimits) -> Self { Self { limits, connections: Arc::new(Semaphore::new(limits.maximum_connections)), @@ -401,14 +401,40 @@ impl IrohAdmission { )), } } + + pub fn limits(&self) -> IrohListenerLimits { + self.limits + } + + pub fn try_reserve_connection(&self) -> Option { + try_pre_auth_admission(&self.connections, &self.connection_overflow) + } + + pub async fn acquire_connection( + &self, + reservation: IrohPreAuthAdmission, + ) -> OwnedSemaphorePermit { + reservation.acquire(self.connections.clone()).await + } + + pub fn try_reserve_pending_stream(&self) -> Option { + try_pre_auth_admission(&self.pending_streams, &self.pending_stream_overflow) + } + + pub async fn acquire_pending_stream( + &self, + reservation: IrohPreAuthAdmission, + ) -> OwnedSemaphorePermit { + reservation.acquire(self.pending_streams.clone()).await + } } -enum PreAuthAdmission { +pub enum IrohPreAuthAdmission { Ready(OwnedSemaphorePermit), Queued(OwnedSemaphorePermit), } -impl PreAuthAdmission { +impl IrohPreAuthAdmission { async fn acquire(self, capacity: Arc) -> OwnedSemaphorePermit { match self { Self::Ready(permit) => permit, @@ -455,10 +481,10 @@ impl PreAuthAdmission { fn try_pre_auth_admission( capacity: &Arc, overflow: &Arc, -) -> Option { +) -> Option { match capacity.clone().try_acquire_owned() { - Ok(permit) => Some(PreAuthAdmission::Ready(permit)), - Err(_) => overflow.clone().try_acquire_owned().ok().map(PreAuthAdmission::Queued), + Ok(permit) => Some(IrohPreAuthAdmission::Ready(permit)), + Err(_) => overflow.clone().try_acquire_owned().ok().map(IrohPreAuthAdmission::Queued), } } @@ -484,7 +510,7 @@ impl IrohProvider { } async fn endpoint(&self) -> Result<&Endpoint, ProviderError> { - self.endpoint.get_or_try_init(|| bind_endpoint(&self.config)).await + self.endpoint.get_or_try_init(|| bind_iroh_endpoint(&self.config)).await } pub async fn local_node_id(&self) -> Result { @@ -531,7 +557,8 @@ fn validate_config(config: &IrohProviderConfig) -> Result<(), ProviderError> { Ok(()) } -async fn bind_endpoint(config: &IrohProviderConfig) -> Result { +pub async fn bind_iroh_endpoint(config: &IrohProviderConfig) -> Result { + validate_config(config)?; use ::iroh::endpoint::presets; let builder = if config.discovery_n0 { @@ -553,7 +580,7 @@ async fn bind_endpoint(config: &IrohProviderConfig) -> Result Ok(connection), - Ok(Err(_)) | Err(_) => connect_iroh_connection(endpoint, node_addr, alpn).await, + Ok(Err(_)) | Err(_) => connect_iroh_endpoint(endpoint, node_addr, alpn).await, } } @@ -722,7 +749,7 @@ impl IrohListener { validate_config(&config)?; let admission = Arc::new(IrohAdmission::new(limits.validate()?)); let relay_enabled = !matches!(&config.relay_mode, RelayMode::Disabled); - let endpoint = bind_endpoint(&config).await?; + let endpoint = bind_iroh_endpoint(&config).await?; let (shutdown_tx, shutdown_rx) = oneshot::channel(); let task = tokio::spawn(run_iroh_listener( endpoint.clone(), @@ -1245,10 +1272,10 @@ mod tests { listener: &IrohListener, secret_key: SecretKey, ) -> (Endpoint, ::iroh::endpoint::Connection) { - let endpoint = bind_endpoint(&local_config(secret_key)).await.unwrap(); + let endpoint = bind_iroh_endpoint(&local_config(secret_key)).await.unwrap(); let route = listener.route().await.unwrap(); let connection = - connect_iroh_connection(&endpoint, route.node_addr(), CMUX_IROH_ALPN).await.unwrap(); + connect_iroh_endpoint(&endpoint, route.node_addr(), CMUX_IROH_ALPN).await.unwrap(); (endpoint, connection) } @@ -1659,10 +1686,10 @@ mod tests { alpn: CMUX_IROH_ALPN.to_vec(), maximum_frame_bytes: 32, }; - let endpoint = bind_endpoint(&config).await.unwrap(); + let endpoint = bind_iroh_endpoint(&config).await.unwrap(); let node_addr = NodeAddr::new(server_key.public()).with_ip_addr(direct); let connection = - connect_iroh_connection(&endpoint, &node_addr, CMUX_IROH_ALPN).await.unwrap(); + connect_iroh_endpoint(&endpoint, &node_addr, CMUX_IROH_ALPN).await.unwrap(); let observed_connection = connection.clone(); let description = format!("iroh://{}", server_key.public()); let transport = iroh_transport_snapshot(&description, &connection); @@ -1763,11 +1790,11 @@ mod tests { let (first_client, first_connection) = connect_test_client(&listener, secret(34)).await; wait_for_available_permits(&listener.admission.connections, 0).await; - let second_client = bind_endpoint(&local_config(secret(35))).await.unwrap(); + let second_client = bind_iroh_endpoint(&local_config(secret(35))).await.unwrap(); let route = listener.route().await.unwrap(); let second = tokio::time::timeout( Duration::from_secs(5), - connect_iroh_connection(&second_client, route.node_addr(), CMUX_IROH_ALPN), + connect_iroh_endpoint(&second_client, route.node_addr(), CMUX_IROH_ALPN), ) .await .expect("excess Iroh connection should be refused promptly"); diff --git a/cmux-tui/crates/cmux-remote/src/provider/mod.rs b/cmux-tui/crates/cmux-remote/src/provider/mod.rs index ec0c63c2f93c..bb2ac998a98a 100644 --- a/cmux-tui/crates/cmux-remote/src/provider/mod.rs +++ b/cmux-tui/crates/cmux-remote/src/provider/mod.rs @@ -27,8 +27,10 @@ use crate::observability::TransportSnapshot; #[cfg(feature = "iroh-transport")] pub use iroh::{ - CMUX_IROH_ALPN, IrohListener, IrohPathMode, IrohProvider, IrohProviderConfig, IrohRoute, - ROUTING_DIRECT_ADDRS, ROUTING_NODE_ID, ROUTING_RELAY_URL, load_or_create_iroh_secret, + CMUX_IROH_ALPN, IrohAdmission, IrohListener, IrohListenerLimits, IrohPathMode, + IrohPreAuthAdmission, IrohProvider, IrohProviderConfig, IrohRoute, ROUTING_DIRECT_ADDRS, + ROUTING_NODE_ID, ROUTING_RELAY_URL, bind_iroh_endpoint, connect_iroh_endpoint, + load_or_create_iroh_secret, }; pub use relay::{ RelayClientConfig, RelayCredentialSource, RelayDaemonConfig, RelayDaemonRegistration, diff --git a/cmux-tui/crates/cmux-tui-iroh/Cargo.toml b/cmux-tui/crates/cmux-tui-iroh/Cargo.toml new file mode 100644 index 000000000000..8a9c90c1a10c --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "cmux-tui-iroh" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +base64.workspace = true +cmux-remote.workspace = true +cmux-tui-machine-protocol.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +iroh.workspace = true +libc.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +subtle.workspace = true +time.workspace = true +tokio.workspace = true +tokio-util.workspace = true +url.workspace = true +uuid.workspace = true +zeroize.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/cmux-tui/crates/cmux-tui-iroh/src/broker.rs b/cmux-tui/crates/cmux-tui-iroh/src/broker.rs new file mode 100644 index 000000000000..6a8432436e8e --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/broker.rs @@ -0,0 +1,737 @@ +use std::collections::HashSet; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use iroh::SecretKey; +use reqwest::{Client, Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; +use url::Url; +use uuid::Uuid; + +use crate::identity::{BrokerCredential, EndpointMetadata}; + +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const DISCOVERY_PAGE_SIZE: usize = 128; +const MAX_DISCOVERY_PAGES: usize = 32; +const MAX_DISCOVERY_BINDINGS: usize = 4096; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + Mac, + Ios, + Linux, +} + +impl Platform { + pub fn current_frontend() -> Result { + if cfg!(target_os = "macos") { + Ok(Self::Mac) + } else if cfg!(target_os = "linux") { + Ok(Self::Linux) + } else { + bail!("the Stage 1 iroh provider supports macOS and Linux") + } + } +} + +#[derive(Clone)] +pub struct BrokerClient { + base_url: Url, + http: Client, +} + +impl std::fmt::Debug for BrokerClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("BrokerClient").field("base_url", &self.base_url).finish() + } +} + +impl BrokerClient { + pub fn new(mut base_url: Url) -> Result { + // `Url::join` drops the last path segment of a base without a trailing + // slash, which would silently escape a path-prefixed broker URL. + if !base_url.path().ends_with('/') { + let path = format!("{}/", base_url.path()); + base_url.set_path(&path); + } + validate_base_url(&base_url)?; + let http = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .context("cannot construct broker HTTP client")?; + Ok(Self { base_url, http }) + } + + pub fn base_url(&self) -> &Url { + &self.base_url + } + + pub async fn enroll(&self, token: &str) -> Result { + ensure!(safe_secret(token), "provisioning token is invalid"); + self.send_json( + Method::POST, + "api/devices/iroh/enroll", + None, + Some(&EnrollmentRequest { token }), + &[], + ) + .await + } + + pub async fn relay_access( + &self, + credential: &BrokerCredential, + endpoint_id: &str, + ) -> Result { + ensure!(canonical_endpoint_id(endpoint_id), "local EndpointID is invalid"); + self.send_json( + Method::POST, + "api/relay/token", + Some(credential), + Some(&EndpointRequest { endpoint_id }), + &[], + ) + .await + } + + pub async fn register_endpoint( + &self, + credential: &BrokerCredential, + secret_key: &SecretKey, + metadata: &EndpointMetadata, + platform: Platform, + display_name: Option<&str>, + pairing_enabled: bool, + ) -> Result { + if let Some(display_name) = display_name { + validate_display_name(display_name)?; + } + let endpoint_id = secret_key.public().to_string(); + let payload = RegistrationPayload { + route_contract_version: 1, + device_id: metadata.device_id, + app_instance_id: metadata.app_instance_id, + tag: &metadata.tag, + platform, + display_name, + endpoint_id: &endpoint_id, + identity_generation: metadata.identity_generation, + pairing_enabled, + capabilities: if platform == Platform::Linux { + vec!["cmux.tui.attach"] + } else { + Vec::new() + }, + path_hints: Vec::new(), + }; + let payload_bytes = serde_json::to_vec(&payload).context("cannot encode registration")?; + ensure!(payload_bytes.len() <= 32 * 1024, "registration payload is too large"); + let payload_sha256 = hex_sha256(&payload_bytes); + let challenge: ChallengeResponse = self + .send_json( + Method::POST, + "api/devices/iroh/challenge", + Some(credential), + Some(&ChallengeRequest { + device_id: metadata.device_id, + app_instance_id: metadata.app_instance_id, + tag: &metadata.tag, + endpoint_id: &endpoint_id, + identity_generation: metadata.identity_generation, + payload_sha256: &payload_sha256, + }), + &[], + ) + .await?; + ensure!(canonical_uuid(&challenge.challenge_id), "broker challenge ID is invalid"); + ensure!(canonical_base64url(&challenge.nonce, 32), "broker challenge nonce is invalid"); + let transcript = format!( + "cmux/iroh/device-registration/v1\n{}\n{}\n{}", + challenge.challenge_id, challenge.nonce, payload_sha256 + ); + let signature = URL_SAFE_NO_PAD.encode(secret_key.sign(transcript.as_bytes()).to_bytes()); + let response: RegistrationResponse = self + .send_json( + Method::POST, + "api/devices/iroh/register", + Some(credential), + Some(&RegisterRequest { + challenge_id: &challenge.challenge_id, + nonce: &challenge.nonce, + payload: &URL_SAFE_NO_PAD.encode(payload_bytes), + signature: &signature, + }), + &[], + ) + .await?; + response.binding.validate()?; + ensure!(response.binding.device_id == metadata.device_id, "broker changed device ID"); + ensure!( + response.binding.app_instance_id == metadata.app_instance_id, + "broker changed app instance ID" + ); + ensure!(response.binding.tag == metadata.tag, "broker changed registration tag"); + ensure!(response.binding.endpoint_id == endpoint_id, "broker changed EndpointID"); + ensure!( + response.binding.identity_generation == metadata.identity_generation, + "broker changed identity generation" + ); + ensure!(response.binding.platform == platform, "broker changed endpoint platform"); + Ok(response.binding) + } + + pub async fn discover(&self, credential: &BrokerCredential) -> Result { + let mut cursor: Option = None; + let mut cursors = HashSet::new(); + let mut binding_ids = HashSet::new(); + let mut bindings = Vec::new(); + let mut first: Option = None; + + for _ in 0..MAX_DISCOVERY_PAGES { + let page_size = DISCOVERY_PAGE_SIZE.to_string(); + let mut query = vec![("page_size", page_size.as_str())]; + if let Some(cursor) = cursor.as_deref() { + query.push(("cursor", cursor)); + } + let page: DiscoveryPage = self + .send_json::<(), _>(Method::GET, "api/devices/iroh", Some(credential), None, &query) + .await?; + let snapshot = page.discovery; + snapshot.validate()?; + if let Some(first) = &first { + ensure!(snapshot.same_header(first), "broker discovery changed between pages"); + } else { + first = Some(snapshot.clone_without_bindings()); + } + for binding in snapshot.bindings { + binding.validate()?; + ensure!(binding_ids.insert(binding.binding_id), "duplicate broker binding"); + ensure!(bindings.len() < MAX_DISCOVERY_BINDINGS, "too many broker bindings"); + bindings.push(binding); + } + match page.next_cursor { + Some(next) => { + ensure!(!next.is_empty() && next.len() <= 4096, "invalid discovery cursor"); + ensure!(cursors.insert(next.clone()), "repeated discovery cursor"); + cursor = Some(next); + } + None => { + let mut complete = first.context("broker returned no discovery snapshot")?; + complete.bindings = bindings; + return Ok(complete); + } + } + } + bail!("broker discovery exceeded the page limit") + } + + pub async fn issue_pair_grant( + &self, + credential: &BrokerCredential, + initiator_binding_id: Uuid, + acceptor_binding_id: Uuid, + ) -> Result { + ensure!(initiator_binding_id != acceptor_binding_id, "grant peers must differ"); + let response: PairGrantResponse = self + .send_json( + Method::POST, + "api/devices/iroh/pair-grants", + Some(credential), + Some(&PairGrantRequest { initiator_binding_id, acceptor_binding_id }), + &[], + ) + .await?; + ensure!(response.grant.len() <= 32 * 1024, "pair grant is too large"); + ensure!(response.grant.split('.').count() == 3, "pair grant is malformed"); + Ok(response) + } + + async fn send_json( + &self, + method: Method, + path: &str, + credential: Option<&BrokerCredential>, + body: Option<&B>, + query: &[(&str, &str)], + ) -> Result { + let mut url = self.base_url.join(path).context("cannot build broker URL")?; + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + let mut request = + self.http.request(method, url.clone()).header("accept", "application/json"); + if let Some(credential) = credential { + request = request + .bearer_auth(&credential.access_token) + .header("x-stack-refresh-token", &credential.refresh_token); + } + if let Some(body) = body { + request = request.json(body); + } + let mut response = request.send().await.context("broker request failed")?; + ensure!(response.url() == &url, "broker changed response URL"); + if let Some(length) = response.content_length() { + ensure!(length <= MAX_RESPONSE_BYTES as u64, "broker response is too large"); + } + let status = response.status(); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.context("cannot read broker response")? { + ensure!( + bytes.len() + chunk.len() <= MAX_RESPONSE_BYTES, + "broker response is too large" + ); + bytes.extend_from_slice(&chunk); + } + if !status.is_success() { + let code = serde_json::from_slice::(&bytes) + .ok() + .map(|error| error.error) + .filter(|code| safe_error_code(code)); + return Err(broker_rejection(status, code.as_deref())); + } + serde_json::from_slice(&bytes).context("broker returned invalid JSON") + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EnrollmentCredential { + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Serialize)] +struct EnrollmentRequest<'a> { + token: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EndpointRequest<'a> { + endpoint_id: &'a str, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayCredential { + pub relay_url: String, + pub token: String, + pub expires_at: i64, + pub refresh_after: i64, + pub ttl_seconds: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayAccessResponse { + pub endpoint_id: String, + #[serde(default)] + pub relay_credentials: Vec, + pub policy: String, + pub preference: Value, + pub preference_revision: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RegistrationPayload<'a> { + #[serde(rename = "route_contract_version")] + route_contract_version: u32, + device_id: Uuid, + app_instance_id: Uuid, + tag: &'a str, + platform: Platform, + #[serde(skip_serializing_if = "Option::is_none")] + display_name: Option<&'a str>, + endpoint_id: &'a str, + identity_generation: u32, + pairing_enabled: bool, + capabilities: Vec<&'static str>, + path_hints: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ChallengeRequest<'a> { + device_id: Uuid, + app_instance_id: Uuid, + tag: &'a str, + endpoint_id: &'a str, + identity_generation: u32, + payload_sha256: &'a str, +} + +#[derive(Deserialize)] +struct ChallengeResponse { + challenge_id: String, + nonce: String, + #[allow(dead_code)] + expires_at: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RegisterRequest<'a> { + challenge_id: &'a str, + nonce: &'a str, + payload: &'a str, + signature: &'a str, +} + +#[derive(Deserialize)] +struct RegistrationResponse { + binding: Binding, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Binding { + #[serde(deserialize_with = "deserialize_canonical_uuid")] + pub binding_id: Uuid, + #[serde(deserialize_with = "deserialize_canonical_uuid")] + pub device_id: Uuid, + #[serde(deserialize_with = "deserialize_canonical_uuid")] + pub app_instance_id: Uuid, + pub tag: String, + pub platform: Platform, + pub display_name: Option, + pub endpoint_id: String, + pub identity_generation: u32, + pub pairing_enabled: bool, + pub capabilities: Vec, + #[serde(default)] + pub path_hints: Vec, + pub last_seen_at: String, +} + +impl Binding { + /// Equality over the stable identity fields only. The derived `PartialEq` + /// also compares volatile broker metadata (`last_seen_at`, display name, + /// path hints), which the broker may update between registration and a + /// later discovery snapshot without any identity change. + pub fn same_identity(&self, other: &Self) -> bool { + self.binding_id == other.binding_id + && self.device_id == other.device_id + && self.app_instance_id == other.app_instance_id + && self.tag == other.tag + && self.platform == other.platform + && self.endpoint_id == other.endpoint_id + && self.identity_generation == other.identity_generation + } + + pub fn validate(&self) -> Result<()> { + ensure!(canonical_endpoint_id(&self.endpoint_id), "broker binding EndpointID is invalid"); + ensure!(self.identity_generation > 0, "broker binding generation is invalid"); + ensure!(safe_token(&self.tag), "broker binding tag is invalid"); + ensure!(self.capabilities.len() <= 32, "broker binding has too many capabilities"); + ensure!( + self.capabilities.iter().all(|value| safe_token(value)), + "broker binding capability is invalid" + ); + ensure!(self.path_hints.len() <= 16, "broker binding has too many path hints"); + if let Some(display_name) = self.display_name.as_deref() { + validate_display_name(display_name)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GrantVerificationKey { + pub kid: String, + pub alg: String, + pub spki_der_base64: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GrantVerificationKeySet { + pub version: u32, + pub current_kid: String, + pub keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DiscoverySnapshot { + pub route_contract_version: u32, + pub revision: u64, + pub bindings: Vec, + pub relay_fleet: Vec, + pub lan_rendezvous: Value, + pub grant_verification_keys: GrantVerificationKeySet, +} + +impl DiscoverySnapshot { + pub fn validate(&self) -> Result<()> { + ensure!(self.route_contract_version == 1, "unsupported broker route contract"); + ensure!(self.revision > 0, "broker discovery revision is invalid"); + ensure!((1..=16).contains(&self.relay_fleet.len()), "broker relay fleet is invalid"); + let mut relays = HashSet::new(); + for relay in &self.relay_fleet { + validate_root_https_url(relay)?; + ensure!(relays.insert(relay), "duplicate broker relay"); + } + ensure!(self.grant_verification_keys.version == 1, "unsupported grant key set"); + ensure!( + (1..=2).contains(&self.grant_verification_keys.keys.len()), + "broker grant key set is invalid" + ); + ensure!( + self.grant_verification_keys + .keys + .iter() + .any(|key| key.kid == self.grant_verification_keys.current_kid), + "broker current grant key is absent" + ); + Ok(()) + } + + fn same_header(&self, other: &Self) -> bool { + self.route_contract_version == other.route_contract_version + && self.revision == other.revision + && self.relay_fleet == other.relay_fleet + && self.lan_rendezvous == other.lan_rendezvous + && self.grant_verification_keys == other.grant_verification_keys + } + + fn clone_without_bindings(&self) -> Self { + let mut clone = self.clone(); + clone.bindings.clear(); + clone + } +} + +#[derive(Deserialize)] +struct DiscoveryPage { + #[serde(flatten)] + discovery: DiscoverySnapshot, + next_cursor: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PairGrantRequest { + initiator_binding_id: Uuid, + acceptor_binding_id: Uuid, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PairGrantResponse { + pub grant: String, + pub expires_at: String, +} + +#[derive(Deserialize)] +struct ErrorResponse { + error: String, +} + +fn broker_rejection(status: StatusCode, code: Option<&str>) -> anyhow::Error { + match code { + Some(code) => anyhow::anyhow!("broker rejected request with HTTP {status}: {code}"), + None => anyhow::anyhow!("broker rejected request with HTTP {status}"), + } +} + +fn validate_base_url(url: &Url) -> Result<()> { + ensure!(url.username().is_empty() && url.password().is_none(), "broker URL has user info"); + ensure!(url.query().is_none() && url.fragment().is_none(), "broker URL has query data"); + let allowed = url.scheme() == "https" + || (url.scheme() == "http" + && url + .host_str() + .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1"))); + ensure!(allowed, "broker URL must use HTTPS or loopback HTTP"); + Ok(()) +} + +pub fn validate_root_https_url(value: &str) -> Result<()> { + let parsed = Url::parse(value).context("invalid relay URL")?; + ensure!(parsed.scheme() == "https", "relay URL must use HTTPS"); + ensure!(parsed.username().is_empty() && parsed.password().is_none(), "relay URL has user info"); + ensure!(parsed.port().is_none(), "relay URL has a port"); + ensure!(parsed.query().is_none() && parsed.fragment().is_none(), "relay URL has query data"); + ensure!(parsed.path() == "/", "relay URL is not a root URL"); + let host = parsed.host_str().context("relay URL has no host")?; + ensure!(host == host.to_ascii_lowercase(), "relay host is not lowercase"); + ensure!(parsed.as_str() == value, "relay URL is not canonical"); + Ok(()) +} + +pub fn canonical_endpoint_id(value: &str) -> bool { + value.len() == 64 + && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +pub fn canonical_uuid(value: &str) -> bool { + Uuid::parse_str(value).is_ok_and(|uuid| uuid.to_string() == value) +} + +pub fn deserialize_canonical_uuid<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + ensure_canonical_uuid(&value).map_err(serde::de::Error::custom) +} + +fn ensure_canonical_uuid(value: &str) -> Result { + let uuid = Uuid::parse_str(value).context("UUID is invalid")?; + ensure!(uuid.to_string() == value, "UUID is not canonical"); + Ok(uuid) +} + +pub fn unix_time() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH).context("system clock is invalid")?.as_secs()) +} + +fn hex_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut result = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to String cannot fail"); + } + result +} + +fn canonical_base64url(value: &str, expected_bytes: usize) -> bool { + URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|bytes| bytes.len() == expected_bytes && URL_SAFE_NO_PAD.encode(bytes) == value) +} + +fn safe_secret(value: &str) -> bool { + !value.is_empty() + && value.len() <= 16 * 1024 + && !value.bytes().any(|byte| byte.is_ascii_control()) +} + +/// Shared validation for broker wire tokens: tags, capabilities, and key IDs +/// are bounded ASCII from `[A-Za-z0-9-.:_]`. Single definition for the crate +/// so the rules cannot diverge between modules. +pub(crate) fn safe_token(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b':' | b'_')) +} + +/// Order-independent relay fleet comparison. The broker pins uniqueness and +/// membership of `relay_fleet`, not the ordering relative to the signed +/// policy, so two orderings of the same set must compare equal. +pub fn same_relay_fleet(left: &[String], right: &[String]) -> bool { + left.len() == right.len() + && left.iter().collect::>() == right.iter().collect::>() +} + +fn safe_error_code(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn validate_display_name(value: &str) -> Result<()> { + ensure!(!value.is_empty() && value.chars().count() <= 128, "display name is invalid"); + ensure!(!value.chars().any(char::is_control), "display name contains a control byte"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn broker_url_rejects_non_loopback_cleartext() { + assert!(BrokerClient::new(Url::parse("http://example.com/").unwrap()).is_err()); + assert!(BrokerClient::new(Url::parse("http://127.0.0.1:3000/").unwrap()).is_ok()); + assert!(BrokerClient::new(Url::parse("https://example.com/").unwrap()).is_ok()); + } + + #[test] + fn broker_url_keeps_a_path_prefix_when_joining_routes() { + let client = + BrokerClient::new(Url::parse("https://broker.example.com/api-gateway").unwrap()) + .unwrap(); + let joined = client.base_url().join("api/devices/iroh/enroll").unwrap(); + assert_eq!( + joined.as_str(), + "https://broker.example.com/api-gateway/api/devices/iroh/enroll" + ); + } + + #[test] + fn binding_identity_ignores_volatile_broker_metadata() { + let binding = Binding { + binding_id: Uuid::nil(), + device_id: Uuid::nil(), + app_instance_id: Uuid::nil(), + tag: "tui-test".into(), + platform: Platform::Linux, + display_name: Some("docker-stage1".into()), + endpoint_id: "aa".repeat(32), + identity_generation: 1, + pairing_enabled: true, + capabilities: vec!["cmux.tui.attach".into()], + path_hints: Vec::new(), + last_seen_at: "2026-08-03T00:00:00.000Z".into(), + }; + let mut heartbeat = binding.clone(); + heartbeat.last_seen_at = "2026-08-04T12:34:56.000Z".into(); + heartbeat.display_name = Some("renamed".into()); + assert!(binding != heartbeat); + assert!(binding.same_identity(&heartbeat)); + let mut rekeyed = binding.clone(); + rekeyed.identity_generation = 2; + assert!(!binding.same_identity(&rekeyed)); + } + + #[test] + fn relay_fleet_comparison_is_order_independent() { + let one = vec!["https://a.example.com/".to_string(), "https://b.example.com/".into()]; + let two = vec!["https://b.example.com/".to_string(), "https://a.example.com/".into()]; + let three = vec!["https://a.example.com/".to_string()]; + assert!(same_relay_fleet(&one, &two)); + assert!(!same_relay_fleet(&one, &three)); + } + + #[test] + fn relay_urls_are_canonical_roots() { + assert!(validate_root_https_url("https://relay.example.com/").is_ok()); + assert!(validate_root_https_url("http://relay.example.com/").is_err()); + assert!(validate_root_https_url("https://relay.example.com/path").is_err()); + assert!(validate_root_https_url("https://Relay.example.com/").is_err()); + } + + #[test] + fn registration_preserves_the_mixed_broker_wire_names() { + let endpoint_id = "aa".repeat(32); + let value = serde_json::to_value(RegistrationPayload { + route_contract_version: 1, + device_id: Uuid::nil(), + app_instance_id: Uuid::nil(), + tag: "tui-test", + platform: Platform::Linux, + display_name: Some("docker-stage1"), + endpoint_id: &endpoint_id, + identity_generation: 1, + pairing_enabled: true, + capabilities: vec!["cmux.tui.attach"], + path_hints: Vec::new(), + }) + .unwrap(); + assert_eq!(value["route_contract_version"], 1); + assert!(value.get("routeContractVersion").is_none()); + assert_eq!(value["deviceId"], Uuid::nil().to_string()); + assert_eq!(value["endpointId"], endpoint_id); + } +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/grant.rs b/cmux-tui/crates/cmux-tui-iroh/src/grant.rs new file mode 100644 index 000000000000..60b59b1ef7e8 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/grant.rs @@ -0,0 +1,453 @@ +use std::collections::HashSet; + +use anyhow::{Context, Result, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signature, Verifier as _, VerifyingKey}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::broker::{ + Binding, DiscoverySnapshot, GrantVerificationKeySet, Platform, canonical_endpoint_id, + safe_token, same_relay_fleet, +}; +use crate::{CMUX_TUI_ALPN_TEXT, CMUX_TUI_PAIR_SCOPE}; + +const GRANT_TYP: &str = "cmux-pair-grant+jwt"; +const MAX_GRANT_BYTES: usize = 32 * 1024; +const MAX_GRANT_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60; +const CLOCK_SKEW_SECONDS: i64 = 30; +const ED25519_SPKI_PREFIX: &[u8] = + &[0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct GrantHeader { + alg: String, + typ: String, + kid: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GrantPeer { + #[serde(deserialize_with = "crate::broker::deserialize_canonical_uuid")] + pub binding_id: Uuid, + #[serde(deserialize_with = "crate::broker::deserialize_canonical_uuid")] + pub device_id: Uuid, + pub tag: String, + pub platform: Platform, + pub endpoint_id: String, + pub identity_generation: u32, +} + +impl GrantPeer { + fn validate(&self) -> Result<()> { + ensure!(canonical_endpoint_id(&self.endpoint_id), "grant EndpointID is invalid"); + ensure!(self.identity_generation > 0, "grant generation is invalid"); + ensure!(safe_token(&self.tag), "grant tag is invalid"); + Ok(()) + } + + fn matches_binding(&self, binding: &Binding) -> bool { + self.binding_id == binding.binding_id + && self.device_id == binding.device_id + && self.tag == binding.tag + && self.platform == binding.platform + && self.endpoint_id == binding.endpoint_id + && self.identity_generation == binding.identity_generation + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PairGrantClaims { + #[serde(deserialize_with = "crate::broker::deserialize_canonical_uuid")] + pub jti: Uuid, + pub iat: i64, + pub nbf: i64, + pub exp: i64, + pub alpn: String, + pub scope: String, + pub initiator: GrantPeer, + pub acceptor: GrantPeer, +} + +impl PairGrantClaims { + pub fn expires_at(&self) -> i64 { + self.exp + } +} + +pub fn verify_pair_grant( + compact: &str, + key_set: &GrantVerificationKeySet, + now: i64, +) -> Result { + ensure!((5..=MAX_GRANT_BYTES).contains(&compact.len()), "pair grant size is invalid"); + ensure!(!compact.bytes().any(|byte| byte.is_ascii_whitespace()), "pair grant has whitespace"); + let mut segments = compact.split('.'); + let encoded_header = segments.next().context("pair grant has no header")?; + let encoded_claims = segments.next().context("pair grant has no claims")?; + let encoded_signature = segments.next().context("pair grant has no signature")?; + ensure!(segments.next().is_none(), "pair grant is not a compact JWS"); + let header: GrantHeader = serde_json::from_slice(&canonical_decode(encoded_header, 4096)?) + .context("pair grant header is invalid")?; + ensure!(header.alg == "EdDSA", "pair grant algorithm is invalid"); + ensure!(header.typ == GRANT_TYP, "pair grant type is invalid"); + ensure!(safe_token(&header.kid), "pair grant key ID is invalid"); + let verifying_key = grant_verification_key(key_set, &header.kid)?; + let signature_bytes = canonical_decode(encoded_signature, 64)?; + ensure!(signature_bytes.len() == 64, "pair grant signature length is invalid"); + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| anyhow::anyhow!("pair grant signature is invalid"))?; + let signing_input = format!("{encoded_header}.{encoded_claims}"); + verifying_key + .verify(signing_input.as_bytes(), &signature) + .map_err(|_| anyhow::anyhow!("pair grant signature is invalid"))?; + + let claims: PairGrantClaims = + serde_json::from_slice(&canonical_decode(encoded_claims, MAX_GRANT_BYTES)?) + .context("pair grant claims are invalid")?; + validate_claims(&claims, now)?; + Ok(claims) +} + +pub fn verify_grant_pair( + claims: &PairGrantClaims, + initiator: &Binding, + acceptor: &Binding, +) -> Result<()> { + ensure!(claims.initiator.matches_binding(initiator), "grant initiator does not match binding"); + ensure!(claims.acceptor.matches_binding(acceptor), "grant acceptor does not match binding"); + ensure!(acceptor.platform == Platform::Linux, "grant acceptor is not a TUI server"); + ensure!(acceptor.pairing_enabled, "grant acceptor pairing is disabled"); + ensure!( + acceptor.capabilities.iter().any(|capability| capability == CMUX_TUI_PAIR_SCOPE), + "grant acceptor lacks the TUI attach capability" + ); + ensure!(initiator.device_id != acceptor.device_id, "grant peers use the same device"); + Ok(()) +} + +pub fn verify_server_preflight( + compact: &str, + tls_initiator_endpoint: &str, + local_acceptor: &Binding, + key_set: &GrantVerificationKeySet, + now: i64, +) -> Result { + ensure!(canonical_endpoint_id(tls_initiator_endpoint), "TLS initiator EndpointID is invalid"); + let claims = verify_pair_grant(compact, key_set, now)?; + ensure!( + claims.initiator.endpoint_id == tls_initiator_endpoint, + "grant initiator does not match TLS peer" + ); + ensure!( + claims.acceptor.matches_binding(local_acceptor), + "grant acceptor does not match local identity" + ); + ensure!(local_acceptor.platform == Platform::Linux, "local acceptor is not a TUI server"); + ensure!(local_acceptor.pairing_enabled, "local acceptor pairing is disabled"); + ensure!( + local_acceptor.capabilities.iter().any(|capability| capability == CMUX_TUI_PAIR_SCOPE), + "local acceptor lacks the TUI attach capability" + ); + ensure!( + claims.initiator.device_id != claims.acceptor.device_id, + "grant peers use the same device" + ); + Ok(claims) +} + +pub fn verify_server_admission( + compact: &str, + tls_initiator_endpoint: &str, + local_acceptor: &Binding, + snapshot: &DiscoverySnapshot, + installed_relay_fleet: &[String], + now: i64, +) -> Result { + ensure!(canonical_endpoint_id(tls_initiator_endpoint), "TLS initiator EndpointID is invalid"); + ensure!( + same_relay_fleet(&snapshot.relay_fleet, installed_relay_fleet), + "broker relay fleet does not match installed policy" + ); + let claims = verify_pair_grant(compact, &snapshot.grant_verification_keys, now)?; + ensure!( + claims.initiator.endpoint_id == tls_initiator_endpoint, + "grant initiator does not match TLS peer" + ); + ensure!( + claims.acceptor.matches_binding(local_acceptor), + "grant acceptor does not match local identity" + ); + let initiators = snapshot + .bindings + .iter() + .filter(|binding| binding.binding_id == claims.initiator.binding_id) + .collect::>(); + let acceptors = snapshot + .bindings + .iter() + .filter(|binding| binding.binding_id == claims.acceptor.binding_id) + .collect::>(); + ensure!(initiators.len() == 1, "grant initiator binding is missing or ambiguous"); + ensure!(acceptors.len() == 1, "grant acceptor binding is missing or ambiguous"); + verify_grant_pair(&claims, initiators[0], acceptors[0])?; + ensure!(acceptors[0].same_identity(local_acceptor), "local acceptor binding is stale"); + Ok(claims) +} + +fn validate_claims(claims: &PairGrantClaims, now: i64) -> Result<()> { + ensure!(claims.alpn == CMUX_TUI_ALPN_TEXT, "pair grant ALPN is invalid"); + ensure!(claims.scope == CMUX_TUI_PAIR_SCOPE, "pair grant scope is invalid"); + ensure!(claims.iat <= now + CLOCK_SKEW_SECONDS, "pair grant issued in the future"); + ensure!(claims.nbf <= now + CLOCK_SKEW_SECONDS, "pair grant is not active"); + ensure!(claims.exp > now - CLOCK_SKEW_SECONDS, "pair grant expired"); + ensure!(claims.nbf <= claims.exp, "pair grant times are invalid"); + ensure!(claims.iat <= claims.exp, "pair grant times are invalid"); + ensure!( + claims.exp - claims.iat <= MAX_GRANT_LIFETIME_SECONDS, + "pair grant lifetime is too long" + ); + claims.initiator.validate()?; + claims.acceptor.validate()?; + ensure!(claims.initiator.binding_id != claims.acceptor.binding_id, "grant peers are identical"); + Ok(()) +} + +fn grant_verification_key( + key_set: &GrantVerificationKeySet, + selected_kid: &str, +) -> Result { + ensure!(key_set.version == 1, "unsupported grant key-set version"); + ensure!((1..=2).contains(&key_set.keys.len()), "grant key set size is invalid"); + let mut kids = HashSet::new(); + let mut selected = None; + for key in &key_set.keys { + ensure!(safe_token(&key.kid), "grant key ID is invalid"); + ensure!(kids.insert(&key.kid), "duplicate grant key ID"); + ensure!(key.alg == "EdDSA", "grant key algorithm is invalid"); + let der = STANDARD.decode(&key.spki_der_base64).context("grant key is not base64")?; + ensure!(STANDARD.encode(&der) == key.spki_der_base64, "grant key base64 is not canonical"); + ensure!(der.len() == ED25519_SPKI_PREFIX.len() + 32, "grant key length is invalid"); + ensure!(der.starts_with(ED25519_SPKI_PREFIX), "grant key SPKI is invalid"); + let raw: [u8; 32] = + der[ED25519_SPKI_PREFIX.len()..].try_into().expect("grant key length checked"); + let verifying = VerifyingKey::from_bytes(&raw).context("grant key is invalid")?; + if key.kid == selected_kid { + selected = Some(verifying); + } + } + ensure!(kids.contains(&key_set.current_kid), "current grant key is absent"); + selected.context("pair grant uses an unknown key") +} + +fn canonical_decode(value: &str, maximum_bytes: usize) -> Result> { + ensure!(!value.is_empty(), "JWS segment is empty"); + ensure!( + value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')), + "JWS segment is not canonical base64url" + ); + let bytes = URL_SAFE_NO_PAD.decode(value).context("JWS segment is invalid")?; + ensure!(bytes.len() <= maximum_bytes, "JWS segment is too large"); + ensure!(URL_SAFE_NO_PAD.encode(&bytes) == value, "JWS segment is not canonical"); + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer as _, SigningKey}; + use serde_json::json; + + use crate::broker::GrantVerificationKey; + + use super::*; + + fn signed_grant(signing: &SigningKey, initiator_endpoint: &str, alpn: &str) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "EdDSA", + "typ": GRANT_TYP, + "kid": "grant-key" + })) + .unwrap(), + ); + let peer = |binding: &str, device: &str, platform: &str, endpoint: &str| { + json!({ + "bindingId": binding, + "deviceId": device, + "tag": "tui-test", + "platform": platform, + "endpointId": endpoint, + "identityGeneration": 1 + }) + }; + let claims = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "jti": "b848527f-c42f-4627-969a-a31fe2fd1c22", + "iat": 1_000, + "nbf": 995, + "exp": 2_000, + "alpn": alpn, + "scope": CMUX_TUI_PAIR_SCOPE, + "initiator": peer( + "ef64e442-52df-4b9f-97cc-fbe366911957", + "343eb618-7eba-4475-b880-966b55e40025", + "mac", + initiator_endpoint + ), + "acceptor": peer( + "cb7204f4-0416-4cd1-b8e9-cdff433fcd93", + "de4643b5-a926-4c1a-9b5c-03fa069ac9d0", + "linux", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) + })) + .unwrap(), + ); + let input = format!("{header}.{claims}"); + let signature = URL_SAFE_NO_PAD.encode(signing.sign(input.as_bytes()).to_bytes()); + format!("{input}.{signature}") + } + + fn key_set(signing: &SigningKey) -> GrantVerificationKeySet { + let mut der = ED25519_SPKI_PREFIX.to_vec(); + der.extend(signing.verifying_key().to_bytes()); + GrantVerificationKeySet { + version: 1, + current_kid: "grant-key".into(), + keys: vec![GrantVerificationKey { + kid: "grant-key".into(), + alg: "EdDSA".into(), + spki_der_base64: STANDARD.encode(der), + }], + } + } + + #[test] + fn verifies_tui_grant_and_tls_endpoint() { + let signing = SigningKey::from_bytes(&[3; 32]); + let endpoint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let compact = signed_grant(&signing, endpoint, CMUX_TUI_ALPN_TEXT); + let claims = verify_pair_grant(&compact, &key_set(&signing), 1_100).unwrap(); + assert_eq!(claims.initiator.endpoint_id, endpoint); + } + + #[test] + fn rejects_mobile_alpn_and_unknown_key() { + let signing = SigningKey::from_bytes(&[4; 32]); + let endpoint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let compact = signed_grant(&signing, endpoint, "cmux/mobile/1"); + assert!(verify_pair_grant(&compact, &key_set(&signing), 1_100).is_err()); + let other = SigningKey::from_bytes(&[5; 32]); + let compact = signed_grant(&other, endpoint, CMUX_TUI_ALPN_TEXT); + assert!(verify_pair_grant(&compact, &key_set(&signing), 1_100).is_err()); + } + + #[test] + fn admission_tolerates_heartbeat_timestamps_and_fleet_order() { + let signing = SigningKey::from_bytes(&[8; 32]); + let initiator_endpoint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let compact = signed_grant(&signing, initiator_endpoint, CMUX_TUI_ALPN_TEXT); + let local = Binding { + binding_id: Uuid::parse_str("cb7204f4-0416-4cd1-b8e9-cdff433fcd93").unwrap(), + device_id: Uuid::parse_str("de4643b5-a926-4c1a-9b5c-03fa069ac9d0").unwrap(), + app_instance_id: Uuid::parse_str("94197e10-4c15-4c8a-af35-8361ed360f1c").unwrap(), + tag: "tui-test".into(), + platform: Platform::Linux, + display_name: None, + endpoint_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), + identity_generation: 1, + pairing_enabled: true, + capabilities: vec![CMUX_TUI_PAIR_SCOPE.into()], + path_hints: Vec::new(), + last_seen_at: "2026-08-03T00:00:00.000Z".into(), + }; + // The broker moved the acceptor's heartbeat timestamp after our + // registration; identity is unchanged, so admission must succeed. + let mut published = local.clone(); + published.last_seen_at = "2026-08-04T09:00:00.000Z".into(); + let initiator_binding = Binding { + binding_id: Uuid::parse_str("ef64e442-52df-4b9f-97cc-fbe366911957").unwrap(), + device_id: Uuid::parse_str("343eb618-7eba-4475-b880-966b55e40025").unwrap(), + app_instance_id: Uuid::parse_str("11197e10-4c15-4c8a-af35-8361ed360f1c").unwrap(), + tag: "tui-test".into(), + platform: Platform::Mac, + display_name: None, + endpoint_id: initiator_endpoint.into(), + identity_generation: 1, + pairing_enabled: true, + capabilities: Vec::new(), + path_hints: Vec::new(), + last_seen_at: "2026-08-04T09:00:00.000Z".into(), + }; + let snapshot = DiscoverySnapshot { + route_contract_version: 1, + revision: 5, + bindings: vec![initiator_binding, published], + relay_fleet: vec![ + "https://b.relay.example.com/".into(), + "https://a.relay.example.com/".into(), + ], + lan_rendezvous: serde_json::json!({}), + grant_verification_keys: key_set(&signing), + }; + // Same fleet membership in a different order than the installed policy. + let installed = vec![ + "https://a.relay.example.com/".to_string(), + "https://b.relay.example.com/".to_string(), + ]; + verify_server_admission(&compact, initiator_endpoint, &local, &snapshot, &installed, 1_100) + .unwrap(); + // A genuine identity change must still be rejected as stale. + let mut rekeyed_snapshot = snapshot; + rekeyed_snapshot.bindings[1].identity_generation = 2; + assert!( + verify_server_admission( + &compact, + initiator_endpoint, + &local, + &rekeyed_snapshot, + &installed, + 1_100, + ) + .is_err() + ); + } + + #[test] + fn preflight_binds_tls_peer_and_local_acceptor_without_discovery() { + let signing = SigningKey::from_bytes(&[6; 32]); + let initiator = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let compact = signed_grant(&signing, initiator, CMUX_TUI_ALPN_TEXT); + let local = Binding { + binding_id: Uuid::parse_str("cb7204f4-0416-4cd1-b8e9-cdff433fcd93").unwrap(), + device_id: Uuid::parse_str("de4643b5-a926-4c1a-9b5c-03fa069ac9d0").unwrap(), + app_instance_id: Uuid::parse_str("94197e10-4c15-4c8a-af35-8361ed360f1c").unwrap(), + tag: "tui-test".into(), + platform: Platform::Linux, + display_name: None, + endpoint_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), + identity_generation: 1, + pairing_enabled: true, + capabilities: vec![CMUX_TUI_PAIR_SCOPE.into()], + path_hints: Vec::new(), + last_seen_at: "2026-08-03T00:00:00.000Z".into(), + }; + assert!( + verify_server_preflight(&compact, initiator, &local, &key_set(&signing), 1_100).is_ok() + ); + assert!( + verify_server_preflight( + &compact, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + &local, + &key_set(&signing), + 1_100, + ) + .is_err() + ); + } +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/identity.rs b/cmux-tui/crates/cmux-tui-iroh/src/identity.rs new file mode 100644 index 000000000000..5bb057fca280 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/identity.rs @@ -0,0 +1,301 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use cmux_remote::identity::{read_owner_only_json, write_owner_only_json}; +use cmux_remote::owner_lock::OwnerFileLock; +use cmux_remote::provider::load_or_create_iroh_secret; +use cmux_remote::secure_directory::{DirectoryAccess, ensure_secure_directory}; +use iroh::{EndpointId, SecretKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use uuid::Uuid; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::broker::safe_token; + +const IDENTITY_SCHEMA_VERSION: u32 = 1; +const CREDENTIAL_SCHEMA_VERSION: u32 = 1; +const MAX_IDENTITY_BYTES: usize = 8 * 1024; +const MAX_CREDENTIAL_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct EndpointMetadata { + pub version: u32, + #[serde(deserialize_with = "crate::broker::deserialize_canonical_uuid")] + pub device_id: Uuid, + #[serde(deserialize_with = "crate::broker::deserialize_canonical_uuid")] + pub app_instance_id: Uuid, + pub tag: String, + pub identity_generation: u32, +} + +impl EndpointMetadata { + fn generate() -> Self { + Self { + version: IDENTITY_SCHEMA_VERSION, + device_id: Uuid::new_v4(), + app_instance_id: Uuid::new_v4(), + tag: format!("tui-{}", Uuid::new_v4()), + identity_generation: 1, + } + } + + fn validate(&self) -> Result<()> { + ensure!(self.version == IDENTITY_SCHEMA_VERSION, "unsupported identity schema"); + ensure!(self.identity_generation > 0, "identity generation must be positive"); + ensure!(safe_token(&self.tag), "identity tag is invalid"); + Ok(()) + } +} + +#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +#[serde(deny_unknown_fields)] +pub struct BrokerCredential { + version: u32, + #[zeroize(skip)] + pub enrolled_at_unix: u64, + pub access_token: String, + pub refresh_token: String, +} + +impl std::fmt::Debug for BrokerCredential { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BrokerCredential") + .field("version", &self.version) + .field("enrolled_at_unix", &self.enrolled_at_unix) + .field("access_token", &"[REDACTED]") + .field("refresh_token", &"[REDACTED]") + .finish() + } +} + +impl BrokerCredential { + pub fn new(access_token: String, refresh_token: String, enrolled_at_unix: u64) -> Result { + validate_credential_value(&access_token)?; + validate_credential_value(&refresh_token)?; + Ok(Self { + version: CREDENTIAL_SCHEMA_VERSION, + enrolled_at_unix, + access_token, + refresh_token, + }) + } + + fn validate(&self) -> Result<()> { + ensure!(self.version == CREDENTIAL_SCHEMA_VERSION, "unsupported credential schema"); + validate_credential_value(&self.access_token)?; + validate_credential_value(&self.refresh_token) + } +} + +/// Read-only view of a persisted identity, produced without taking the state +/// lock so `status` can report on an identity a running server holds open. +pub struct IdentityReport { + pub endpoint_id: EndpointId, + pub metadata: EndpointMetadata, + pub credential_present: bool, +} + +/// Loads an existing identity without acquiring the exclusive state lock and +/// without creating any state. Fails if the identity was never enrolled. +pub fn read_identity_report(state_root: &Path, identity_name: &str) -> Result { + ensure!(safe_identity_name(identity_name), "identity name is invalid"); + let directory = state_root.join("iroh-tui").join(identity_name); + ensure!(directory.is_dir(), "identity {identity_name:?} does not exist"); + let key_path = directory.join("endpoint.key"); + ensure!(key_path.is_file(), "identity {identity_name:?} has no endpoint key"); + let secret_key = load_or_create_iroh_secret(&key_path) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let identity_path = directory.join("identity.json"); + let metadata = read_owner_only_json::(&identity_path, MAX_IDENTITY_BYTES) + .with_context(|| format!("cannot load {}", identity_path.display()))?; + metadata.validate()?; + Ok(IdentityReport { + endpoint_id: secret_key.public(), + metadata, + credential_present: directory.join("credential.json").exists(), + }) +} + +pub struct IdentityStore { + directory: PathBuf, + secret_key: SecretKey, + metadata: EndpointMetadata, + _lock: OwnerFileLock, +} + +impl std::fmt::Debug for IdentityStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IdentityStore") + .field("directory", &self.directory) + .field("endpoint", &self.endpoint_id().fmt_short().to_string()) + .field("device_id", &self.metadata.device_id) + .field("tag", &self.metadata.tag) + .finish_non_exhaustive() + } +} + +impl IdentityStore { + pub fn open(state_root: &Path, identity_name: &str) -> Result { + ensure!(safe_identity_name(identity_name), "identity name is invalid"); + let directory = state_root.join("iroh-tui").join(identity_name); + ensure_secure_directory(&directory, DirectoryAccess::ManagedOwnerOnly).with_context( + || format!("cannot secure iroh state directory {}", directory.display()), + )?; + let lock = OwnerFileLock::try_acquire(&directory.join("state.lock")) + .with_context(|| format!("iroh identity {identity_name:?} is already in use"))?; + let secret_key = load_or_create_iroh_secret(&directory.join("endpoint.key")) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let identity_path = directory.join("identity.json"); + let metadata = if identity_path.exists() { + read_owner_only_json::(&identity_path, MAX_IDENTITY_BYTES) + .with_context(|| format!("cannot load {}", identity_path.display()))? + } else { + let metadata = EndpointMetadata::generate(); + write_owner_only_json(&identity_path, &metadata) + .with_context(|| format!("cannot persist {}", identity_path.display()))?; + metadata + }; + metadata.validate()?; + Ok(Self { directory, secret_key, metadata, _lock: lock }) + } + + pub fn directory(&self) -> &Path { + &self.directory + } + + pub fn secret_key(&self) -> &SecretKey { + &self.secret_key + } + + pub fn endpoint_id(&self) -> EndpointId { + self.secret_key.public() + } + + pub fn metadata(&self) -> &EndpointMetadata { + &self.metadata + } + + pub fn identity_fingerprint(&self) -> String { + let mut digest = Sha256::new(); + digest.update(self.endpoint_id().as_bytes()); + digest.update(self.metadata.device_id.as_bytes()); + digest.update(self.metadata.app_instance_id.as_bytes()); + digest.update(self.metadata.tag.as_bytes()); + digest.update(self.metadata.identity_generation.to_be_bytes()); + let digest = digest.finalize(); + let mut value = String::with_capacity(16); + for byte in &digest[..8] { + use std::fmt::Write as _; + write!(&mut value, "{byte:02x}").expect("writing to String cannot fail"); + } + value + } + + pub fn load_credential(&self) -> Result { + let path = self.directory.join("credential.json"); + let credential = read_owner_only_json::(&path, MAX_CREDENTIAL_BYTES) + .with_context(|| format!("cannot load broker credential {}", path.display()))?; + credential.validate()?; + Ok(credential) + } + + pub fn credential_exists(&self) -> bool { + self.directory.join("credential.json").exists() + } + + pub fn save_credential(&self, credential: &BrokerCredential, replace: bool) -> Result<()> { + credential.validate()?; + let path = self.directory.join("credential.json"); + if path.exists() && !replace { + bail!("broker credential already exists; pass --replace-credential to replace it"); + } + write_owner_only_json(&path, credential) + .with_context(|| format!("cannot persist broker credential {}", path.display())) + } +} + +fn safe_identity_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn validate_credential_value(value: &str) -> Result<()> { + ensure!(!value.is_empty(), "broker credential is empty"); + ensure!(value.len() <= 16 * 1024, "broker credential is too large"); + ensure!( + !value.bytes().any(|byte| byte.is_ascii_control()), + "broker credential contains a control byte" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt as _; + + use super::*; + + #[test] + fn first_boot_identity_is_stable() { + let temp = tempfile::tempdir().unwrap(); + let (endpoint, metadata) = { + let first = IdentityStore::open(temp.path(), "server").unwrap(); + (first.endpoint_id(), first.metadata().clone()) + }; + let second = IdentityStore::open(temp.path(), "server").unwrap(); + assert_eq!(second.endpoint_id(), endpoint); + assert_eq!(second.metadata(), &metadata); + assert_eq!(second.identity_fingerprint().len(), 16); + assert_eq!( + std::fs::metadata(second.directory()).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(second.directory().join("endpoint.key")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + + #[test] + fn identity_lock_prevents_key_sharing_between_process_owners() { + let temp = tempfile::tempdir().unwrap(); + let _first = IdentityStore::open(temp.path(), "server").unwrap(); + let error = IdentityStore::open(temp.path(), "server").unwrap_err(); + assert!(error.to_string().contains("already in use")); + } + + #[test] + fn read_only_report_works_while_the_identity_is_locked() { + let temp = tempfile::tempdir().unwrap(); + let store = IdentityStore::open(temp.path(), "server").unwrap(); + let report = read_identity_report(temp.path(), "server").unwrap(); + assert_eq!(report.endpoint_id, store.endpoint_id()); + assert_eq!(&report.metadata, store.metadata()); + assert!(!report.credential_present); + assert!(read_identity_report(temp.path(), "absent").is_err()); + } + + #[test] + fn credential_debug_is_redacted_and_replace_is_explicit() { + let temp = tempfile::tempdir().unwrap(); + let store = IdentityStore::open(temp.path(), "provider").unwrap(); + let credential = + BrokerCredential::new("access-secret".into(), "refresh-secret".into(), 1).unwrap(); + let debug = format!("{credential:?}"); + assert!(!debug.contains("access-secret")); + assert!(!debug.contains("refresh-secret")); + store.save_credential(&credential, false).unwrap(); + assert!(store.save_credential(&credential, false).is_err()); + let loaded = store.load_credential().unwrap(); + assert_eq!(loaded.access_token, "access-secret"); + } +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/lib.rs b/cmux-tui/crates/cmux-tui-iroh/src/lib.rs new file mode 100644 index 000000000000..78afe1efcc69 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/lib.rs @@ -0,0 +1,12 @@ +pub mod broker; +pub mod grant; +pub mod identity; +pub mod policy; +pub mod probe; +pub mod provider; +pub mod server; +pub mod transport; + +pub const CMUX_TUI_ALPN: &[u8] = b"cmux/tui/1"; +pub const CMUX_TUI_ALPN_TEXT: &str = "cmux/tui/1"; +pub const CMUX_TUI_PAIR_SCOPE: &str = "cmux.tui.attach"; diff --git a/cmux-tui/crates/cmux-tui-iroh/src/main.rs b/cmux-tui/crates/cmux-tui-iroh/src/main.rs new file mode 100644 index 000000000000..cd9ab0fec451 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/main.rs @@ -0,0 +1,332 @@ +use std::collections::{HashMap, HashSet}; +use std::io::Read as _; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result, bail, ensure}; +use cmux_remote::secret_file::read_owner_only_string; +use cmux_tui_iroh::broker::{BrokerClient, Platform, unix_time}; +use cmux_tui_iroh::identity::{BrokerCredential, IdentityStore}; +use cmux_tui_iroh::policy::RelayEnvironment; +use cmux_tui_iroh::transport::{EndpointRuntime, EndpointRuntimeConfig}; +use tokio_util::sync::CancellationToken; +use url::Url; +use zeroize::Zeroizing; + +const USAGE: &str = "\ +cmux-tui-iroh Stage 1 transport + +USAGE + cmux-tui-iroh enroll --state-root PATH --identity NAME --broker URL (--token-file PATH | --token-stdin) [--replace-credential] + cmux-tui-iroh server --state-root PATH --identity NAME --broker URL --session-socket PATH [--relay-environment production|staging] [--display-name NAME] (Linux only) + cmux-tui-iroh provider --state-root PATH --identity NAME --broker URL --socket PATH [--relay-environment production|staging] [--display-name NAME] + cmux-tui-iroh probe --socket PATH [--machine-id UUID] [--marker-key UUID] + cmux-tui-iroh status --state-root PATH --identity NAME +"; + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("cmux-tui-iroh: {error:#}"); + std::process::exit(1); + } +} + +async fn run() -> Result<()> { + let mut args = std::env::args().skip(1); + let command = args.next().unwrap_or_else(|| "help".into()); + let parsed = ParsedArgs::parse(args)?; + match command.as_str() { + "enroll" => enroll(parsed).await, + "server" => run_server(parsed).await, + "provider" => run_provider(parsed).await, + "probe" => probe(parsed).await, + "status" => status(parsed), + "help" | "--help" | "-h" => { + print!("{USAGE}"); + Ok(()) + } + _ => bail!("unknown command {command:?}\n\n{USAGE}"), + } +} + +async fn enroll(mut args: ParsedArgs) -> Result<()> { + args.require_only(&[ + "state-root", + "identity", + "broker", + "token-file", + "token-stdin", + "replace-credential", + ])?; + let state_root = args.path("state-root")?; + let identity_name = args.value("identity")?.to_string(); + let broker_url = parse_url(args.value("broker")?)?; + let token_file = args.optional_path("token-file"); + let token_stdin = args.flag("token-stdin"); + ensure!( + token_file.is_some() ^ token_stdin, + "choose exactly one of --token-file or --token-stdin" + ); + let replace = args.flag("replace-credential"); + args.finish()?; + + let mut token = match token_file { + Some(path) => read_owner_only_string(&path, 16 * 1024) + .with_context(|| format!("cannot read provisioning token {}", path.display()))?, + None => read_token_stdin()?, + }; + while token.ends_with('\n') || token.ends_with('\r') { + token.pop(); + } + let store = IdentityStore::open(&state_root, &identity_name)?; + if store.credential_exists() && !replace { + bail!("broker credential already exists; pass --replace-credential to replace it"); + } + let broker = BrokerClient::new(broker_url)?; + let enrolled = broker.enroll(&token).await?; + let credential = + BrokerCredential::new(enrolled.access_token, enrolled.refresh_token, unix_time()?)?; + store.save_credential(&credential, replace).context( + "the provisioning token was already consumed by enrollment but the credential could \ + not be persisted; mint a new enrollment token and re-run enroll", + )?; + println!( + "enrolled endpoint={} device={} tag={}", + store.endpoint_id().fmt_short(), + store.metadata().device_id, + store.metadata().tag, + ); + Ok(()) +} + +async fn run_server(mut args: ParsedArgs) -> Result<()> { + args.require_only(&[ + "state-root", + "identity", + "broker", + "session-socket", + "relay-environment", + "display-name", + ])?; + let common = runtime_args(&mut args)?; + let session_socket = args.path("session-socket")?; + args.finish()?; + // Stage 1 registers the TUI server as a Linux machine; refuse to register + // that fact from any other host platform instead of publishing it falsely. + ensure!( + Platform::current_frontend()? == Platform::Linux, + "the Stage 1 TUI server registers as a Linux machine and must run on Linux" + ); + let runtime = Arc::new( + EndpointRuntime::start(EndpointRuntimeConfig { + state_root: &common.state_root, + identity_name: &common.identity, + broker_url: common.broker, + relay_environment: common.environment, + platform: Platform::Linux, + display_name: common.display_name.as_deref(), + pairing_enabled: true, + }) + .await?, + ); + println!( + "server ready endpoint={} identity={} binding={} relays={} inbound_ports=0 ip_transports=0", + runtime.endpoint_id().fmt_short(), + runtime.identity.identity_fingerprint(), + runtime.binding.binding_id, + runtime.relay_count().await, + ); + let shutdown = signal_cancellation()?; + cmux_tui_iroh::server::serve(runtime, session_socket, shutdown).await +} + +async fn run_provider(mut args: ParsedArgs) -> Result<()> { + args.require_only(&[ + "state-root", + "identity", + "broker", + "socket", + "relay-environment", + "display-name", + ])?; + let common = runtime_args(&mut args)?; + let socket = args.path("socket")?; + args.finish()?; + let platform = Platform::current_frontend()?; + let runtime = Arc::new( + EndpointRuntime::start(EndpointRuntimeConfig { + state_root: &common.state_root, + identity_name: &common.identity, + broker_url: common.broker, + relay_environment: common.environment, + platform, + display_name: common.display_name.as_deref(), + pairing_enabled: false, + }) + .await?, + ); + println!( + "provider ready endpoint={} identity={} binding={} relays={} ip_transports=0 socket={}", + runtime.endpoint_id().fmt_short(), + runtime.identity.identity_fingerprint(), + runtime.binding.binding_id, + runtime.relay_count().await, + socket.display(), + ); + let shutdown = signal_cancellation()?; + cmux_tui_iroh::provider::serve(runtime, socket, shutdown).await +} + +async fn probe(mut args: ParsedArgs) -> Result<()> { + args.require_only(&["socket", "machine-id", "marker-key"])?; + let socket = args.path("socket")?; + let machine_id = args.optional_value("machine-id").map(ToOwned::to_owned); + let marker_key = args + .optional_value("marker-key") + .unwrap_or("1f9c9c70-a083-4890-b3b3-336eb1df626b") + .to_string(); + ensure!( + uuid::Uuid::parse_str(&marker_key).is_ok_and(|value| value.to_string() == marker_key), + "marker key must be a canonical UUID" + ); + args.finish()?; + cmux_tui_iroh::probe::run(&socket, machine_id.as_deref(), &marker_key).await +} + +fn status(mut args: ParsedArgs) -> Result<()> { + args.require_only(&["state-root", "identity"])?; + let state_root = args.path("state-root")?; + let identity = args.value("identity")?.to_string(); + args.finish()?; + // Read-only and lock-free, so status works while a server or provider + // process holds the identity open. + let report = cmux_tui_iroh::identity::read_identity_report(&state_root, &identity)?; + println!( + "endpoint={} device={} app_instance={} tag={} generation={} credential={}", + report.endpoint_id.fmt_short(), + report.metadata.device_id, + report.metadata.app_instance_id, + report.metadata.tag, + report.metadata.identity_generation, + if report.credential_present { "present" } else { "absent" }, + ); + Ok(()) +} + +struct RuntimeArgs { + state_root: PathBuf, + identity: String, + broker: Url, + environment: RelayEnvironment, + display_name: Option, +} + +fn runtime_args(args: &mut ParsedArgs) -> Result { + Ok(RuntimeArgs { + state_root: args.path("state-root")?, + identity: args.value("identity")?.to_string(), + broker: parse_url(args.value("broker")?)?, + environment: args.optional_value("relay-environment").unwrap_or("production").parse()?, + display_name: args.optional_value("display-name").map(ToOwned::to_owned), + }) +} + +fn parse_url(value: &str) -> Result { + Url::parse(value).context("broker URL is invalid") +} + +fn read_token_stdin() -> Result> { + let mut bytes = Vec::new(); + std::io::stdin() + .take(16 * 1024 + 1) + .read_to_end(&mut bytes) + .context("cannot read provisioning token from stdin")?; + ensure!(bytes.len() <= 16 * 1024, "provisioning token is too large"); + String::from_utf8(bytes) + .map(Zeroizing::new) + .map_err(|_| anyhow::anyhow!("provisioning token is not UTF-8")) +} + +fn signal_cancellation() -> Result { + let cancellation = CancellationToken::new(); + let signal = cancellation.clone(); + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + tokio::spawn(async move { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + signal.cancel(); + }); + Ok(cancellation) +} + +struct ParsedArgs { + values: HashMap, + flags: HashSet, + seen: HashSet, +} + +impl ParsedArgs { + fn parse(args: impl IntoIterator) -> Result { + let mut values = HashMap::new(); + let mut flags = HashSet::new(); + let mut args = args.into_iter().peekable(); + while let Some(argument) = args.next() { + ensure!(argument.starts_with("--"), "unexpected positional argument {argument:?}"); + let name = argument.trim_start_matches("--").to_string(); + ensure!(!name.is_empty(), "empty option name"); + ensure!( + !values.contains_key(&name) && !flags.contains(&name), + "duplicate option --{name}" + ); + if matches!(name.as_str(), "token-stdin" | "replace-credential") { + flags.insert(name); + } else { + let value = args.next().with_context(|| format!("--{name} needs a value"))?; + ensure!(!value.starts_with("--"), "--{name} needs a value"); + values.insert(name, value); + } + } + Ok(Self { values, flags, seen: HashSet::new() }) + } + + fn require_only(&self, allowed: &[&str]) -> Result<()> { + let allowed = allowed.iter().copied().collect::>(); + for name in self.values.keys().chain(self.flags.iter()) { + ensure!(allowed.contains(name.as_str()), "unknown option --{name}"); + } + Ok(()) + } + + fn value(&mut self, name: &str) -> Result<&str> { + self.seen.insert(name.to_string()); + self.values.get(name).map(String::as_str).with_context(|| format!("--{name} is required")) + } + + fn optional_value(&mut self, name: &str) -> Option<&str> { + self.seen.insert(name.to_string()); + self.values.get(name).map(String::as_str) + } + + fn path(&mut self, name: &str) -> Result { + self.value(name).map(PathBuf::from) + } + + fn optional_path(&mut self, name: &str) -> Option { + self.optional_value(name).map(PathBuf::from) + } + + fn flag(&mut self, name: &str) -> bool { + self.seen.insert(name.to_string()); + self.flags.contains(name) + } + + fn finish(&self) -> Result<()> { + for name in self.values.keys().chain(self.flags.iter()) { + ensure!(self.seen.contains(name), "option --{name} was not consumed"); + } + Ok(()) + } +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/policy.rs b/cmux-tui/crates/cmux-tui-iroh/src/policy.rs new file mode 100644 index 000000000000..c3445fd521b0 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/policy.rs @@ -0,0 +1,455 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use cmux_remote::identity::{read_owner_only_json, write_owner_only_json}; +use ed25519_dalek::{Signature, Verifier as _, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::broker::validate_root_https_url; + +const POLICY_TYP: &str = "cmux-relay-policy-v1+jwt"; +const POLICY_AUDIENCE: &str = "cmux-iroh-relay-policy"; +const RELAY_PROTOCOL: &str = "iroh-relay-v1"; +const MAX_POLICY_BYTES: usize = 64 * 1024; +const MAX_CACHE_BYTES: usize = 96 * 1024; +const MAX_POLICY_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60; +const CLOCK_SKEW_SECONDS: i64 = 30; + +// Current + next key slots per environment, mirroring the Mac and iOS pins in +// config/IrohRelayPolicy{Production,Staging}.xcconfig (CMUX_IROH_RELAY_POLICY_KEY_ID +// and ..._NEXT_KEY_ID). The broker can rotate to the next kid without a new +// binary; rotating beyond it ships updated pins to every client, TUI included. +const PRODUCTION_KEYS: &[(&str, &str)] = &[ + ("cmux-production-relay-policy-2026-07", "qoBinRqX4TI1Ro6xAuOQxKUkeZT3pkFJuERP/+R+9aw="), + ("cmux-production-relay-policy-2026-08", "k+FND+WlELCkHs9QnWg1TfTuHXBwyv2907umX+mUOOU="), +]; +const STAGING_KEYS: &[(&str, &str)] = &[ + ("cmux-staging-relay-policy-2026-07", "Otx9S0B4d/tlwIKYRf5evJaqhjCltFLPjMfXrLFd6lk="), + ("cmux-staging-relay-policy-2026-08", "KnOZ6gKmH05Mrfan2tXgwRygBKxcSUue4bp34udiQFA="), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayEnvironment { + Production, + Staging, +} + +impl std::str::FromStr for RelayEnvironment { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "production" => Ok(Self::Production), + "staging" => Ok(Self::Staging), + _ => bail!("relay environment must be production or staging"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRelay { + pub id: String, + pub provider: String, + pub region: String, + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRelayPolicy { + pub jti: Uuid, + pub sequence: u64, + pub issued_at: i64, + pub not_before: i64, + pub expires_at: i64, + pub relays: Vec, + pub compact: String, +} + +impl VerifiedRelayPolicy { + pub fn relay_urls(&self) -> Vec { + self.relays.iter().map(|relay| relay.url.clone()).collect() + } +} + +pub struct RelayPolicyVerifier { + keys: HashMap, + cache_path: PathBuf, +} + +impl std::fmt::Debug for RelayPolicyVerifier { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RelayPolicyVerifier") + .field("key_ids", &self.keys.keys().collect::>()) + .field("cache_path", &self.cache_path) + .finish() + } +} + +impl RelayPolicyVerifier { + pub fn new(environment: RelayEnvironment, state_directory: &Path) -> Result { + let source = match environment { + RelayEnvironment::Production => PRODUCTION_KEYS, + RelayEnvironment::Staging => STAGING_KEYS, + }; + let mut keys = HashMap::new(); + for (kid, encoded) in source { + let bytes = STANDARD.decode(encoded).context("invalid built-in relay-policy key")?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("invalid built-in relay-policy key length"))?; + keys.insert((*kid).to_string(), VerifyingKey::from_bytes(&bytes)?); + } + Ok(Self { keys, cache_path: state_directory.join("relay-policy.json") }) + } + + #[cfg(test)] + fn with_keys(keys: HashMap, cache_path: PathBuf) -> Self { + Self { keys, cache_path } + } + + pub fn verify(&self, compact: &str, now: i64) -> Result { + ensure!((5..=MAX_POLICY_BYTES).contains(&compact.len()), "relay policy size is invalid"); + ensure!( + !compact.bytes().any(|byte| byte.is_ascii_whitespace()), + "relay policy has whitespace" + ); + let mut segments = compact.split('.'); + let encoded_header = segments.next().context("relay policy has no header")?; + let encoded_claims = segments.next().context("relay policy has no claims")?; + let encoded_signature = segments.next().context("relay policy has no signature")?; + ensure!(segments.next().is_none(), "relay policy is not a compact JWS"); + let header_bytes = canonical_decode(encoded_header, 4 * 1024)?; + let claims_bytes = canonical_decode(encoded_claims, MAX_POLICY_BYTES)?; + let signature_bytes = canonical_decode(encoded_signature, 64)?; + ensure!(signature_bytes.len() == 64, "relay policy signature length is invalid"); + let header: PolicyHeader = + serde_json::from_slice(&header_bytes).context("relay policy header is invalid")?; + ensure!(header.alg == "EdDSA", "relay policy algorithm is invalid"); + ensure!(header.typ == POLICY_TYP, "relay policy type is invalid"); + ensure!(safe_key_id(&header.kid), "relay policy key ID is invalid"); + let key = self.keys.get(&header.kid).context("relay policy key is not pinned")?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| anyhow::anyhow!("relay policy signature is invalid"))?; + let signing_input = format!("{encoded_header}.{encoded_claims}"); + key.verify(signing_input.as_bytes(), &signature) + .map_err(|_| anyhow::anyhow!("relay policy signature is invalid"))?; + + let claims: PolicyClaims = + serde_json::from_slice(&claims_bytes).context("relay policy claims are invalid")?; + let policy = validate_claims(claims, compact, now)?; + self.check_rollback_fence(&policy)?; + Ok(policy) + } + + pub fn record(&self, policy: &VerifiedRelayPolicy) -> Result<()> { + self.check_rollback_fence(policy)?; + write_owner_only_json( + &self.cache_path, + &CachedPolicy { + version: 1, + sequence: policy.sequence, + issued_at: policy.issued_at, + expires_at: policy.expires_at, + relays: policy.relays.iter().map(CachedRelay::from).collect(), + compact: policy.compact.clone(), + }, + ) + .context("cannot persist verified relay policy")?; + Ok(()) + } + + pub fn verify_and_record(&self, compact: &str, now: i64) -> Result { + let policy = self.verify(compact, now)?; + self.record(&policy)?; + Ok(policy) + } + + fn check_rollback_fence(&self, policy: &VerifiedRelayPolicy) -> Result<()> { + if let Some(previous) = self.load_cache()? { + ensure!(policy.sequence >= previous.sequence, "relay policy sequence rolled back"); + if policy.sequence == previous.sequence { + ensure!( + policy.relays.iter().map(CachedRelay::from).collect::>() + == previous.relays, + "relay policy changed the catalog without advancing its sequence" + ); + ensure!( + policy.issued_at >= previous.issued_at, + "relay policy issuance rolled back" + ); + ensure!( + policy.expires_at >= previous.expires_at, + "relay policy expiry rolled back" + ); + } + } + Ok(()) + } + + fn load_cache(&self) -> Result> { + if !self.cache_path.exists() { + return Ok(None); + } + let cache: CachedPolicy = read_owner_only_json(&self.cache_path, MAX_CACHE_BYTES) + .context("cannot load relay-policy rollback fence")?; + ensure!(cache.version == 1 && cache.sequence > 0, "relay-policy cache is invalid"); + ensure!( + cache.issued_at >= 0 && cache.expires_at > cache.issued_at, + "relay-policy cache times are invalid" + ); + ensure!((1..=16).contains(&cache.relays.len()), "relay-policy cache fleet is invalid"); + let mut ids = HashSet::new(); + let mut urls = HashSet::new(); + for relay in &cache.relays { + ensure!(safe_relay_id(&relay.id), "cached relay ID is invalid"); + ensure!(safe_relay_label(&relay.provider), "cached relay provider is invalid"); + ensure!(safe_relay_label(&relay.region), "cached relay region is invalid"); + validate_root_https_url(&relay.url)?; + ensure!(ids.insert(&relay.id), "cached relay ID is duplicated"); + ensure!(urls.insert(&relay.url), "cached relay URL is duplicated"); + } + ensure!(cache.compact.len() <= MAX_POLICY_BYTES, "relay-policy cache is too large"); + Ok(Some(cache)) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyHeader { + alg: String, + typ: String, + kid: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyClaims { + version: u32, + jti: String, + sequence: u64, + iat: i64, + nbf: i64, + exp: i64, + aud: String, + relay_protocol: String, + relays: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RelayClaim { + id: String, + provider: String, + region: String, + url: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CachedPolicy { + version: u32, + sequence: u64, + issued_at: i64, + expires_at: i64, + relays: Vec, + compact: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct CachedRelay { + id: String, + provider: String, + region: String, + url: String, +} + +impl From<&VerifiedRelay> for CachedRelay { + fn from(relay: &VerifiedRelay) -> Self { + Self { + id: relay.id.clone(), + provider: relay.provider.clone(), + region: relay.region.clone(), + url: relay.url.clone(), + } + } +} + +fn validate_claims(claims: PolicyClaims, compact: &str, now: i64) -> Result { + ensure!(claims.version == 1, "unsupported relay policy version"); + let jti = Uuid::parse_str(&claims.jti).context("relay policy JTI is invalid")?; + ensure!(jti.to_string() == claims.jti, "relay policy JTI is not canonical"); + ensure!(claims.sequence > 0, "relay policy sequence is invalid"); + ensure!(claims.aud == POLICY_AUDIENCE, "relay policy audience is invalid"); + ensure!(claims.relay_protocol == RELAY_PROTOCOL, "relay policy protocol is invalid"); + ensure!(claims.iat <= claims.nbf + CLOCK_SKEW_SECONDS, "relay policy times are invalid"); + ensure!(claims.iat <= claims.exp, "relay policy times are invalid"); + ensure!(claims.nbf <= claims.exp, "relay policy times are invalid"); + ensure!( + claims.exp - claims.iat <= MAX_POLICY_LIFETIME_SECONDS, + "relay policy lifetime is too long" + ); + ensure!(now + CLOCK_SKEW_SECONDS >= claims.nbf, "relay policy is not active"); + ensure!(now - CLOCK_SKEW_SECONDS < claims.exp, "relay policy expired"); + ensure!((1..=16).contains(&claims.relays.len()), "relay policy fleet size is invalid"); + let mut ids = HashSet::new(); + let mut urls = HashSet::new(); + let mut relays = Vec::with_capacity(claims.relays.len()); + for relay in claims.relays { + ensure!(safe_relay_id(&relay.id), "relay ID is invalid"); + ensure!(safe_relay_label(&relay.provider), "relay provider is invalid"); + ensure!(safe_relay_label(&relay.region), "relay region is invalid"); + validate_root_https_url(&relay.url)?; + ensure!(ids.insert(relay.id.clone()), "duplicate relay ID"); + ensure!(urls.insert(relay.url.clone()), "duplicate relay URL"); + relays.push(VerifiedRelay { + id: relay.id, + provider: relay.provider, + region: relay.region, + url: relay.url, + }); + } + Ok(VerifiedRelayPolicy { + jti, + sequence: claims.sequence, + issued_at: claims.iat, + not_before: claims.nbf, + expires_at: claims.exp, + relays, + compact: compact.to_string(), + }) +} + +fn canonical_decode(value: &str, maximum_bytes: usize) -> Result> { + ensure!(!value.is_empty(), "JWS segment is empty"); + ensure!( + value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')), + "JWS segment is not canonical base64url" + ); + let bytes = URL_SAFE_NO_PAD.decode(value).context("JWS segment is invalid")?; + ensure!(bytes.len() <= maximum_bytes, "JWS segment is too large"); + ensure!(URL_SAFE_NO_PAD.encode(&bytes) == value, "JWS segment is not canonical"); + Ok(bytes) +} + +fn safe_key_id(value: &str) -> bool { + bounded_ascii_label(value, 64, |byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_') + }) +} + +fn safe_relay_id(value: &str) -> bool { + bounded_ascii_label(value, 64, |byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.' | b'_') + }) +} + +fn safe_relay_label(value: &str) -> bool { + bounded_ascii_label(value, 80, |byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b' ' | b'-' | b'.' | b'_') + }) +} + +fn bounded_ascii_label(value: &str, maximum_bytes: usize, allowed: impl Fn(u8) -> bool) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= maximum_bytes + && bytes[0].is_ascii_alphanumeric() + && bytes[bytes.len() - 1].is_ascii_alphanumeric() + && bytes.iter().copied().all(allowed) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use ed25519_dalek::{Signer as _, SigningKey}; + use serde_json::json; + + use super::*; + + fn compact(signing: &SigningKey, sequence: u64, exp: i64, url: &str) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "EdDSA", + "typ": POLICY_TYP, + "kid": "test-key" + })) + .unwrap(), + ); + let claims = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "version": 1, + "jti": "8e671cec-b7f5-4e31-a931-506021a868a2", + "sequence": sequence, + "iat": 1_000, + "nbf": 1_000, + "exp": exp, + "aud": POLICY_AUDIENCE, + "relay_protocol": RELAY_PROTOCOL, + "relays": [{ + "id": "test", + "provider": "cmux", + "region": "US Central", + "url": url + }] + })) + .unwrap(), + ); + let input = format!("{header}.{claims}"); + let signature = URL_SAFE_NO_PAD.encode(signing.sign(input.as_bytes()).to_bytes()); + format!("{input}.{signature}") + } + + #[test] + fn verifies_policy_and_rejects_sequence_rollback() { + let temp = tempfile::tempdir().unwrap(); + let signing = SigningKey::from_bytes(&[7; 32]); + let verifier = RelayPolicyVerifier::with_keys( + HashMap::from([("test-key".into(), signing.verifying_key())]), + temp.path().join("policy.json"), + ); + let first = compact(&signing, 2, 2_000, "https://relay.example.com/"); + assert_eq!(verifier.verify_and_record(&first, 1_100).unwrap().sequence, 2); + let renewal = compact(&signing, 2, 2_100, "https://relay.example.com/"); + assert_eq!(verifier.verify_and_record(&renewal, 1_100).unwrap().sequence, 2); + let shorter = compact(&signing, 2, 2_050, "https://relay.example.com/"); + assert!(verifier.verify_and_record(&shorter, 1_100).is_err()); + let equivocation = compact(&signing, 2, 2_100, "https://other.example.com/"); + assert!(verifier.verify_and_record(&equivocation, 1_100).is_err()); + let rollback = compact(&signing, 1, 2_000, "https://relay.example.com/"); + assert!(verifier.verify_and_record(&rollback, 1_100).is_err()); + } + + #[test] + fn rejects_non_root_and_expired_policy() { + let temp = tempfile::tempdir().unwrap(); + let signing = SigningKey::from_bytes(&[9; 32]); + let verifier = RelayPolicyVerifier::with_keys( + HashMap::from([("test-key".into(), signing.verifying_key())]), + temp.path().join("policy.json"), + ); + let path = compact(&signing, 1, 2_000, "https://relay.example.com/path"); + assert!(verifier.verify_and_record(&path, 1_100).is_err()); + let expired = compact(&signing, 1, 1_050, "https://relay.example.com/"); + assert!(verifier.verify_and_record(&expired, 1_100).is_err()); + } + + #[test] + fn relay_metadata_matches_the_broker_catalog_schema() { + assert!(safe_key_id("cmux-staging-relay-policy-2026-08")); + assert!(safe_relay_id("usc1")); + assert!(!safe_relay_id("US Central")); + assert!(safe_relay_label("Asia Pacific Northeast")); + assert!(safe_relay_label("a")); + assert!(!safe_relay_label(" Asia Pacific Northeast")); + assert!(!safe_relay_label("Asia Pacific Northeast ")); + assert!(!safe_relay_label("Asia/Pacific")); + assert!(!safe_relay_label(&"a".repeat(81))); + } +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/probe.rs b/cmux-tui/crates/cmux-tui-iroh/src/probe.rs new file mode 100644 index 000000000000..817580b1f1b1 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/probe.rs @@ -0,0 +1,239 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use cmux_tui_machine_protocol::{ + BearerToken, ClientDescriptor, CloseMachineParams, CloseMachineResult, HelloParams, + HelloResult, OpaqueId, OpenMachineParams, OpenMachineResult, Protocol, ProviderRequest, + ProviderResponse, RequestEnvelope, ResponseEnvelope, SnapshotParams, SnapshotResult, + TransportDescriptor, TransportHandshake, TransportHandshakeResult, TransportRole, Version, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use tokio::io::BufReader; +use tokio::net::UnixStream; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; + +use crate::transport::{read_json_line, write_bounded_json_line}; + +const CONTROL_FRAME_BYTES: usize = 1024 * 1024; +const TRANSPORT_FRAME_BYTES: usize = 64 * 1024; +/// Deadline for every probe read so a silent peer fails the acceptance run +/// with a clear message instead of hanging until the outer job timeout. +const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(30); +/// The v10 protocol multiplexes events with responses on one stream; bound +/// how many unsolicited lines a single request will skip. +const MAX_SKIPPED_LINES: usize = 256; + +/// One probe connection: a persistent buffered reader (bytes after each +/// newline stay available) plus the write half. +struct Wire { + reader: BufReader, + writer: OwnedWriteHalf, +} + +impl Wire { + async fn connect(socket: &Path, purpose: &str) -> Result { + let stream = UnixStream::connect(socket) + .await + .with_context(|| format!("cannot connect {purpose} to {}", socket.display()))?; + let (read_half, writer) = stream.into_split(); + Ok(Self { reader: BufReader::new(read_half), writer }) + } + + async fn write(&mut self, value: &T, maximum_bytes: usize) -> Result<()> { + write_bounded_json_line(&mut self.writer, value, maximum_bytes).await + } + + async fn read(&mut self, maximum_bytes: usize) -> Result { + tokio::time::timeout(PROBE_READ_TIMEOUT, read_json_line(&mut self.reader, maximum_bytes)) + .await + .context("probe read timed out")? + } +} + +pub async fn run(socket: &Path, machine_id: Option<&str>, marker_key: &str) -> Result<()> { + let mut control = Wire::connect(socket, "provider probe").await?; + let token = BearerToken::new(random_bearer(32)?) + .map_err(|_| anyhow::anyhow!("generated provider bearer is invalid"))?; + let hello: HelloResult = request( + &mut control, + 1, + ProviderRequest::Hello(HelloParams { + token: token.clone(), + client: ClientDescriptor { + name: "cmux-tui-iroh-probe".into(), + version: env!("CARGO_PKG_VERSION").into(), + supported_versions: vec![1], + }, + }), + ) + .await?; + ensure!(hello.negotiated_version == Version, "provider did not negotiate v1"); + let snapshot: SnapshotResult = + request(&mut control, 2, ProviderRequest::Snapshot(SnapshotParams::default())).await?; + let machine = match machine_id { + Some(machine_id) => snapshot + .machines + .iter() + .find(|machine| machine.id.as_str() == machine_id) + .context("requested machine is absent from provider snapshot")?, + None => snapshot + .machines + .iter() + .find(|machine| machine.connectable) + .context("provider snapshot has no connectable Linux machine")?, + }; + let selected_machine = machine.id.clone(); + + let mut first = open(&mut control, &token, socket, 3, selected_machine.clone()).await?; + let identify = + raw_request(&mut first.stream, json!({"id":"identify-1","cmd":"identify"})).await?; + ensure!(identify["ok"] == true, "protocol identify failed"); + ensure!(identify["data"]["protocol"] == 10, "remote protocol is not v10"); + let mut workspaces = + raw_request(&mut first.stream, json!({"id":"list-1","cmd":"list-workspaces"})).await?; + ensure!(workspaces["ok"] == true, "initial workspace listing failed"); + if !workspace_present(&workspaces, marker_key) { + let revision = workspaces["data"]["workspace_revision"] + .as_u64() + .context("workspace revision is missing")?; + let created = raw_request( + &mut first.stream, + json!({ + "id":"create-marker", + "cmd":"create-workspace", + "name":"iroh-stage1-marker", + "key":marker_key, + "expected_revision":revision + }), + ) + .await?; + ensure!(created["ok"] == true, "marker workspace creation failed"); + workspaces = raw_request( + &mut first.stream, + json!({"id":"list-after-create","cmd":"list-workspaces"}), + ) + .await?; + ensure!(workspace_present(&workspaces, marker_key), "marker workspace was not persisted"); + } + drop(first.stream); + close(&mut control, 4, first.connection_id).await?; + + let mut second = open(&mut control, &token, socket, 5, selected_machine.clone()).await?; + let identify = + raw_request(&mut second.stream, json!({"id":"identify-2","cmd":"identify"})).await?; + ensure!(identify["ok"] == true, "reattached protocol identify failed"); + ensure!(identify["data"]["protocol"] == 10, "reattached protocol is not v10"); + let workspaces = + raw_request(&mut second.stream, json!({"id":"list-2","cmd":"list-workspaces"})).await?; + ensure!(workspaces["ok"] == true, "reattached workspace listing failed"); + ensure!(workspace_present(&workspaces, marker_key), "marker workspace did not survive detach"); + drop(second.stream); + close(&mut control, 6, second.connection_id).await?; + println!( + "probe protocol=10 machine={} resolution=broker detach_reattach=ok marker={} provider={}", + selected_machine, marker_key, hello.provider_id, + ); + Ok(()) +} + +struct Opened { + connection_id: OpaqueId, + stream: Wire, +} + +async fn open( + control: &mut Wire, + token: &BearerToken, + socket: &Path, + request_id: u64, + machine_id: OpaqueId, +) -> Result { + let opened: OpenMachineResult = request( + control, + request_id, + ProviderRequest::OpenMachine(OpenMachineParams { + machine_id, + workspace_mirror_authority: false, + }), + ) + .await?; + let TransportDescriptor::ProviderStream { ticket, expires_at: _ } = opened.transport; + let mut stream = Wire::connect(socket, "provider transport").await?; + stream + .write( + &TransportHandshake { + protocol: Protocol, + version: Version, + role: TransportRole::Transport, + token: token.clone(), + ticket, + }, + TRANSPORT_FRAME_BYTES, + ) + .await?; + let result: TransportHandshakeResult = stream.read(TRANSPORT_FRAME_BYTES).await?; + ensure!(result.accepted, "provider transport was rejected"); + Ok(Opened { connection_id: opened.connection_id, stream }) +} + +async fn close(control: &mut Wire, request_id: u64, connection_id: OpaqueId) -> Result<()> { + let _: CloseMachineResult = request( + control, + request_id, + ProviderRequest::CloseMachine(CloseMachineParams { connection_id }), + ) + .await?; + Ok(()) +} + +async fn request( + wire: &mut Wire, + id: u64, + request: ProviderRequest, +) -> Result { + let id = OpaqueId::new(format!("probe-{id}")) + .map_err(|_| anyhow::anyhow!("probe request ID is invalid"))?; + wire.write(&RequestEnvelope::new(id.clone(), request), CONTROL_FRAME_BYTES).await?; + let response: ResponseEnvelope = wire.read(CONTROL_FRAME_BYTES).await?; + ensure!(response.id == id, "provider response ID changed"); + match response.response { + ProviderResponse::Success(value) => Ok(value), + ProviderResponse::Failure(error) => { + bail!("provider request failed: {}", error.code.as_str()) + } + } +} + +/// Sends one raw v10 request and returns the response correlated by `id`, +/// skipping interleaved event lines and stale responses to earlier requests. +async fn raw_request(wire: &mut Wire, request: Value) -> Result { + let id = request["id"].as_str().context("raw protocol request needs an id")?.to_string(); + wire.write(&request, CONTROL_FRAME_BYTES).await?; + for _ in 0..MAX_SKIPPED_LINES { + let response: Value = wire.read(CONTROL_FRAME_BYTES).await?; + if response.get("event").is_some() { + continue; + } + if response["id"].as_str() == Some(id.as_str()) { + return Ok(response); + } + } + bail!("no protocol response matched request id {id:?}") +} + +fn workspace_present(response: &Value, marker_key: &str) -> bool { + response["data"]["workspaces"] + .as_array() + .is_some_and(|workspaces| workspaces.iter().any(|workspace| workspace["key"] == marker_key)) +} + +fn random_bearer(bytes: usize) -> Result { + let mut value = vec![0_u8; bytes]; + getrandom::fill(&mut value).context("cannot generate probe bearer")?; + Ok(URL_SAFE_NO_PAD.encode(value)) +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/provider.rs b/cmux-tui/crates/cmux-tui-iroh/src/provider.rs new file mode 100644 index 000000000000..1436a8d23c3e --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/provider.rs @@ -0,0 +1,642 @@ +use std::collections::HashMap; +use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use cmux_remote::secure_directory::{DirectoryAccess, ensure_secure_directory}; +use cmux_tui_machine_protocol::{ + BearerToken, CloseMachineResult, HelloResult, MachineDescriptor, MachineStatus, OpaqueId, + OpenMachineResult, Protocol, ProviderCapabilities, ProviderError, ProviderErrorCode, + ProviderRequest, RequestEnvelope, ResponseEnvelope, ScopeDescriptor, ScopeKind, SnapshotResult, + TransportDescriptor, TransportHandshake, TransportHandshakeResult, TransportRole, Version, + WorkspaceCreatePolicy, +}; +use serde::Serialize; +use subtle::ConstantTimeEq as _; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::Mutex; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::broker::{Binding, Platform, unix_time}; +use crate::grant::{verify_grant_pair, verify_pair_grant}; +use crate::transport::{ + EndpointRuntime, bridge_unix_and_iroh, is_stream_closed, read_json_line, send_admission, + write_bounded_json_line, +}; + +const CONTROL_FRAME_BYTES: usize = 1024 * 1024; +const TRANSPORT_HANDSHAKE_BYTES: usize = 64 * 1024; +const TICKET_LIFETIME: Duration = Duration::from_secs(30); +const MAX_PROVIDER_CONNECTIONS: usize = 64; +const MAX_PROVIDER_TICKETS: usize = 128; +const PROVIDER_ID: &str = "cmux-iroh-account"; +const SCOPE_ID: &str = "cmux-account"; + +pub async fn serve( + runtime: Arc, + socket_path: PathBuf, + shutdown: CancellationToken, +) -> Result<()> { + let listener = bind_owner_socket(&socket_path)?; + let state = Arc::new(ProviderState::new(Arc::clone(&runtime))); + let mut connections = JoinSet::new(); + let refresh_shutdown = shutdown.child_token(); + let refresh_runtime = Arc::clone(&runtime); + let mut refresh = + tokio::spawn( + async move { refresh_runtime.refresh_until_cancelled(refresh_shutdown).await }, + ); + let mut refresh_finished = false; + let mut serve_result = Ok(()); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + result = &mut refresh => { + refresh_finished = true; + serve_result = match result { + Ok(result) => result, + Err(error) => Err(anyhow::anyhow!("relay refresh task failed: {error}")), + }; + break; + } + completed = connections.join_next(), if !connections.is_empty() => { + match completed { + Some(Ok(Err(error))) => { + eprintln!("cmux-tui-iroh: provider request denied or closed: {error:#}"); + } + Some(Err(error)) => { + eprintln!("cmux-tui-iroh: provider connection task failed: {error}"); + } + _ => {} + } + } + accepted = listener.accept() => { + // Break instead of returning so the cleanup below still runs + // (cancel connections, remove the socket file, close iroh). + let stream = match accepted { + Ok((stream, _)) => stream, + Err(error) => { + serve_result = + Err(anyhow::Error::new(error) + .context("cannot accept provider connection")); + break; + } + }; + if connections.len() >= MAX_PROVIDER_CONNECTIONS { + drop(stream); + continue; + } + let state = Arc::clone(&state); + connections.spawn(async move { handle_connection(state, stream).await }); + } + } + } + shutdown.cancel(); + state.invalidate_all().await; + connections.shutdown().await; + if !refresh_finished { + let _ = refresh.await; + } + drop(listener); + if socket_path.exists() { + let _ = std::fs::remove_file(&socket_path); + } + runtime.close().await; + serve_result +} + +struct ProviderState { + runtime: Arc, + mutable: Mutex, +} + +struct ProviderMutable { + generation: Option, + tickets: HashMap, + connections: HashMap, +} + +struct Ticket { + generation: String, + connection_id: Uuid, + expires_at: i64, + remote: Binding, + grant: String, +} + +impl ProviderState { + fn new(runtime: Arc) -> Self { + Self { + runtime, + mutable: Mutex::new(ProviderMutable { + generation: None, + tickets: HashMap::new(), + connections: HashMap::new(), + }), + } + } + + async fn begin_generation(&self, token: &str) { + let mut state = self.mutable.lock().await; + for cancellation in state.connections.values() { + cancellation.cancel(); + } + state.connections.clear(); + state.tickets.clear(); + state.generation = Some(token.to_string()); + } + + async fn end_generation(&self, token: &str) { + let mut state = self.mutable.lock().await; + if state.generation.as_deref().is_some_and(|current| secret_eq(current, token)) { + for cancellation in state.connections.values() { + cancellation.cancel(); + } + state.connections.clear(); + state.tickets.clear(); + state.generation = None; + } + } + + async fn generation_is_current(&self, token: &str) -> bool { + self.mutable + .lock() + .await + .generation + .as_deref() + .is_some_and(|current| secret_eq(current, token)) + } + + async fn insert_ticket(&self, value: String, ticket: Ticket) -> Result<()> { + let mut state = self.mutable.lock().await; + ensure!( + state + .generation + .as_deref() + .is_some_and(|current| secret_eq(current, &ticket.generation)), + "provider generation was replaced" + ); + state.tickets.retain(|_, ticket| ticket.expires_at > now_lossy()); + ensure!(state.tickets.len() < MAX_PROVIDER_TICKETS, "provider ticket capacity exhausted"); + state.tickets.insert(value, ticket); + Ok(()) + } + + async fn consume_ticket(&self, generation: &str, value: &str) -> Result { + let mut state = self.mutable.lock().await; + ensure!( + state.generation.as_deref().is_some_and(|current| secret_eq(current, generation)), + "provider generation is not current" + ); + let key = state + .tickets + .keys() + .find(|candidate| secret_eq(candidate, value)) + .cloned() + .context("transport ticket is invalid or already used")?; + let ticket = state.tickets.remove(&key).expect("ticket key came from map"); + ensure!(ticket.expires_at > unix_time()? as i64, "transport ticket expired"); + ensure!(secret_eq(&ticket.generation, generation), "transport ticket generation changed"); + Ok(ticket) + } + + async fn register_connection(&self, id: Uuid, cancellation: CancellationToken) { + self.mutable.lock().await.connections.insert(id, cancellation); + } + + async fn remove_connection(&self, id: Uuid) { + self.mutable.lock().await.connections.remove(&id); + } + + async fn close_connection(&self, id: Uuid) { + let mut state = self.mutable.lock().await; + state.tickets.retain(|_, ticket| ticket.connection_id != id); + if let Some(cancellation) = state.connections.remove(&id) { + cancellation.cancel(); + } + } + + async fn invalidate_all(&self) { + let mut state = self.mutable.lock().await; + for cancellation in state.connections.values() { + cancellation.cancel(); + } + state.connections.clear(); + state.tickets.clear(); + state.generation = None; + } +} + +type LocalReader = tokio::io::BufReader; +type LocalWriter = tokio::net::unix::OwnedWriteHalf; + +async fn handle_connection(state: Arc, stream: UnixStream) -> Result<()> { + // One buffered reader per connection for its whole lifetime; bytes after + // any newline stay available to later reads and to the transport bridge. + let (read_half, write_half) = stream.into_split(); + let mut reader = tokio::io::BufReader::new(read_half); + let first: serde_json::Value = read_json_line(&mut reader, CONTROL_FRAME_BYTES).await?; + if first.get("role").is_some() { + let handshake: TransportHandshake = + serde_json::from_value(first).context("transport handshake is invalid")?; + handle_transport(state, reader, write_half, handshake).await + } else { + let hello: RequestEnvelope = + serde_json::from_value(first).context("provider hello is invalid")?; + handle_control(state, reader, write_half, hello).await + } +} + +async fn handle_control( + state: Arc, + mut reader: LocalReader, + mut writer: LocalWriter, + hello: RequestEnvelope, +) -> Result<()> { + let ProviderRequest::Hello(params) = hello.request else { + bail!("first provider request must be hello"); + }; + ensure!(params.client.supported_versions.contains(&1), "client does not support provider v1"); + let generation = params.token.expose().to_string(); + state.begin_generation(&generation).await; + write_response( + &mut writer, + ResponseEnvelope::success( + hello.id, + HelloResult { + provider_id: opaque(PROVIDER_ID)?, + provider_name: "cmux iroh account".into(), + negotiated_version: Version, + }, + ), + ) + .await?; + + let result = control_loop(&state, &generation, &mut reader, &mut writer).await; + state.end_generation(&generation).await; + result +} + +async fn control_loop( + state: &Arc, + generation: &str, + reader: &mut LocalReader, + stream: &mut LocalWriter, +) -> Result<()> { + loop { + let request: RequestEnvelope = match read_json_line(reader, CONTROL_FRAME_BYTES).await { + Ok(request) => request, + Err(error) if is_closed_stream_error(&error) => return Ok(()), + Err(error) => return Err(error), + }; + ensure!(state.generation_is_current(generation).await, "provider generation was replaced"); + let id = request.id; + match request.request { + ProviderRequest::Snapshot(_) => match snapshot(&state.runtime).await { + Ok(snapshot) => { + write_response(stream, ResponseEnvelope::success(id, snapshot)).await?; + } + Err(_) => { + write_response( + stream, + ResponseEnvelope::::failure( + id, + provider_error( + ProviderErrorCode::Unavailable, + "broker discovery failed", + true, + ), + ), + ) + .await?; + } + }, + ProviderRequest::OpenMachine(params) => { + match open_machine(state, generation, params.machine_id.as_str()).await { + Ok(opened) => { + write_response(stream, ResponseEnvelope::success(id, opened)).await?; + } + Err(_) => { + write_response( + stream, + ResponseEnvelope::::failure( + id, + provider_error( + ProviderErrorCode::Unavailable, + "machine connection could not be opened", + true, + ), + ), + ) + .await?; + } + } + } + ProviderRequest::CloseMachine(params) => { + let parsed = Uuid::parse_str(params.connection_id.as_str()); + match parsed { + Ok(connection_id) => { + state.close_connection(connection_id).await; + // Never fabricate a revision: the close itself is + // idempotent, so a retry after this retryable failure + // reads the authoritative revision. + match state.runtime.fresh_discovery().await { + Ok(snapshot) => { + write_response( + stream, + ResponseEnvelope::success( + id, + CloseMachineResult { revision: snapshot.revision }, + ), + ) + .await?; + } + Err(_) => { + write_response( + stream, + ResponseEnvelope::::failure( + id, + provider_error( + ProviderErrorCode::Unavailable, + "broker discovery failed after close", + true, + ), + ), + ) + .await?; + } + } + } + Err(_) => { + write_response( + stream, + ResponseEnvelope::::failure( + id, + provider_error( + ProviderErrorCode::InvalidInput, + "connection ID is invalid", + false, + ), + ), + ) + .await?; + } + } + } + ProviderRequest::Hello(_) => { + write_response( + stream, + ResponseEnvelope::::failure( + id, + provider_error( + ProviderErrorCode::Conflict, + "hello already completed", + false, + ), + ), + ) + .await?; + } + _ => { + write_response( + stream, + ResponseEnvelope::::failure( + id, + provider_error( + ProviderErrorCode::InvalidInput, + "provider method is unsupported", + false, + ), + ), + ) + .await?; + } + } + } +} + +async fn snapshot(runtime: &EndpointRuntime) -> Result { + let snapshot = runtime.fresh_discovery().await?; + let machines = snapshot + .bindings + .into_iter() + .filter(|binding| { + binding.platform == Platform::Linux + && binding.pairing_enabled + && binding.device_id != runtime.binding.device_id + }) + .map(machine_descriptor) + .collect::>>()?; + Ok(SnapshotResult { + revision: snapshot.revision, + scopes: vec![ScopeDescriptor { + id: opaque(SCOPE_ID)?, + display_name: "cmux account".into(), + kind: ScopeKind::Personal, + can_admin: false, + }], + selected_scope_id: opaque(SCOPE_ID)?, + machines, + selected_machine_id: None, + capabilities: ProviderCapabilities::default(), + actions: Vec::new(), + notice: None, + }) +} + +async fn open_machine( + state: &Arc, + generation: &str, + machine_id: &str, +) -> Result { + let target_id = Uuid::parse_str(machine_id).context("machine ID is invalid")?; + let snapshot = state.runtime.fresh_discovery().await?; + let initiator = snapshot + .bindings + .iter() + .find(|binding| binding.binding_id == state.runtime.binding.binding_id) + .context("initiator binding is unavailable")?; + let acceptor = snapshot + .bindings + .iter() + .find(|binding| binding.binding_id == target_id) + .context("machine binding is unavailable")?; + ensure!(acceptor.platform == Platform::Linux, "machine is not a TUI server"); + ensure!(acceptor.pairing_enabled, "machine pairing is disabled"); + ensure!(acceptor.device_id != initiator.device_id, "machine uses the initiator device"); + let grant = state + .runtime + .broker + .issue_pair_grant(&state.runtime.credential, initiator.binding_id, acceptor.binding_id) + .await?; + let now = unix_time()? as i64; + let claims = verify_pair_grant(&grant.grant, &snapshot.grant_verification_keys, now)?; + verify_grant_pair(&claims, initiator, acceptor)?; + + let ticket_value = random_bearer(32)?; + let expires_at = now + TICKET_LIFETIME.as_secs() as i64; + let connection_id = Uuid::new_v4(); + state + .insert_ticket( + ticket_value.clone(), + Ticket { + generation: generation.to_string(), + connection_id, + expires_at, + remote: acceptor.clone(), + grant: grant.grant, + }, + ) + .await?; + Ok(OpenMachineResult { + connection_id: opaque(&connection_id.to_string())?, + transport: TransportDescriptor::ProviderStream { + ticket: BearerToken::new(ticket_value) + .map_err(|_| anyhow::anyhow!("generated ticket is invalid"))?, + expires_at: OffsetDateTime::from_unix_timestamp(expires_at)?.format(&Rfc3339)?, + }, + workspace_mirror_authority: None, + }) +} + +async fn handle_transport( + state: Arc, + local_reader: LocalReader, + local_writer: LocalWriter, + handshake: TransportHandshake, +) -> Result<()> { + ensure!(handshake.protocol == Protocol, "transport protocol is invalid"); + ensure!(handshake.version == Version, "transport version is invalid"); + ensure!(handshake.role == TransportRole::Transport, "transport role is invalid"); + let generation = handshake.token.expose().to_string(); + let ticket = state.consume_ticket(&generation, handshake.ticket.expose()).await?; + // Register the cancellation token before dialing so a concurrent + // CloseMachine can abort the setup instead of missing the map entry + // while the dial and admission are still in flight. + let connection_id = ticket.connection_id; + let cancellation = CancellationToken::new(); + state.register_connection(connection_id, cancellation.clone()).await; + let result = run_transport(&state, ticket, local_reader, local_writer, cancellation).await; + state.remove_connection(connection_id).await; + result +} + +async fn run_transport( + state: &Arc, + ticket: Ticket, + local_reader: LocalReader, + mut local_writer: LocalWriter, + cancellation: CancellationToken, +) -> Result<()> { + let connection = tokio::select! { + _ = cancellation.cancelled() => bail!("machine connection was closed during dial"), + connection = state.runtime.dial(&ticket.remote.endpoint_id) => connection?, + }; + let (mut sender, receiver) = connection.open_bi().await.context("cannot open iroh stream")?; + let mut receiver = tokio::io::BufReader::new(receiver); + send_admission(&mut sender, &mut receiver, &ticket.grant).await?; + if cancellation.is_cancelled() { + connection.close(0_u8.into(), b"provider transport closed"); + bail!("machine connection was closed during setup"); + } + write_bounded_json_line( + &mut local_writer, + &TransportHandshakeResult { accepted: true }, + TRANSPORT_HANDSHAKE_BYTES, + ) + .await?; + + eprintln!( + "cmux-tui-iroh: connected machine={} peer={} path=relay address_source=endpoint_id+verified_catalog", + ticket.remote.binding_id, + connection.remote_id().fmt_short(), + ); + let result = + bridge_unix_and_iroh(local_reader, local_writer, sender, receiver, cancellation).await; + connection.close(0_u8.into(), b"provider transport closed"); + result +} + +fn machine_descriptor(binding: Binding) -> Result { + let endpoint_prefix = &binding.endpoint_id[..10]; + Ok(MachineDescriptor { + id: opaque(&binding.binding_id.to_string())?, + display_name: binding.display_name.unwrap_or_else(|| format!("cmux-tui {endpoint_prefix}")), + subtitle: format!("Endpoint {endpoint_prefix}"), + status: MachineStatus::Running, + connectable: binding.pairing_enabled, + workspace_create: WorkspaceCreatePolicy::Session, + }) +} + +async fn write_response( + stream: &mut LocalWriter, + response: ResponseEnvelope, +) -> Result<()> { + write_bounded_json_line(stream, &response, CONTROL_FRAME_BYTES).await +} + +fn provider_error(code: ProviderErrorCode, message: &str, retryable: bool) -> ProviderError { + ProviderError { code, message: message.to_string(), retryable } +} + +fn opaque(value: &str) -> Result { + OpaqueId::new(value).map_err(|_| anyhow::anyhow!("provider identifier is invalid")) +} + +fn random_bearer(bytes: usize) -> Result { + let mut value = vec![0_u8; bytes]; + getrandom::fill(&mut value).context("cannot generate provider ticket")?; + Ok(URL_SAFE_NO_PAD.encode(value)) +} + +fn secret_eq(left: &str, right: &str) -> bool { + left.len() == right.len() && bool::from(left.as_bytes().ct_eq(right.as_bytes())) +} + +fn now_lossy() -> i64 { + unix_time().ok().and_then(|value| i64::try_from(value).ok()).unwrap_or(i64::MAX) +} + +fn is_closed_stream_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionReset + ) + }) + }) || is_stream_closed(error) +} + +fn bind_owner_socket(path: &Path) -> Result { + let parent = path.parent().context("provider socket has no parent")?; + ensure_secure_directory(parent, DirectoryAccess::ManagedOwnerOnly) + .with_context(|| format!("cannot secure provider socket directory {}", parent.display()))?; + if path.exists() { + if std::os::unix::net::UnixStream::connect(path).is_ok() { + bail!("provider socket {} is already active", path.display()); + } + let metadata = std::fs::symlink_metadata(path)?; + ensure!(metadata.file_type().is_socket(), "stale provider path is not a socket"); + ensure!( + metadata.uid() == unsafe { libc::geteuid() }, + "stale provider socket has wrong owner" + ); + std::fs::remove_file(path)?; + } + let listener = UnixListener::bind(path) + .with_context(|| format!("cannot bind provider socket {}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(listener) +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/server.rs b/cmux-tui/crates/cmux-tui-iroh/src/server.rs new file mode 100644 index 000000000000..d6a77ded86bd --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/server.rs @@ -0,0 +1,229 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use cmux_remote::provider::{IrohAdmission, IrohListenerLimits}; +use tokio::net::UnixStream; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +use crate::CMUX_TUI_ALPN; +use crate::broker::unix_time; +use crate::grant::{verify_server_admission, verify_server_preflight}; +use crate::transport::{ + DISCOVERY_MAX_AGE, EndpointRuntime, acknowledge_admission, bridge_unix_and_iroh, + receive_admission, +}; + +pub async fn serve( + runtime: Arc, + session_socket: PathBuf, + shutdown: CancellationToken, +) -> Result<()> { + ensure!( + runtime.binding.platform == crate::broker::Platform::Linux, + "server binding is not Linux" + ); + ensure!(runtime.binding.pairing_enabled, "server binding does not permit pairing"); + let limits = IrohListenerLimits::default() + .validate() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let admission = Arc::new(IrohAdmission::new(limits)); + let mut connections = JoinSet::new(); + let refresh_shutdown = shutdown.child_token(); + let refresh_runtime = Arc::clone(&runtime); + let mut refresh = + tokio::spawn( + async move { refresh_runtime.refresh_until_cancelled(refresh_shutdown).await }, + ); + let mut refresh_finished = false; + let mut serve_result = Ok(()); + + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + result = &mut refresh => { + refresh_finished = true; + serve_result = match result { + Ok(result) => result, + Err(error) => Err(anyhow::anyhow!("relay refresh task failed: {error}")), + }; + break; + } + completed = connections.join_next(), if !connections.is_empty() => { + match completed { + Some(Ok(Err(error))) => { + eprintln!("cmux-tui-iroh: connection denied or closed: {error:#}"); + } + Some(Err(error)) => eprintln!("cmux-tui-iroh: connection task failed: {error}"), + _ => {} + } + } + incoming = runtime.endpoint.accept() => { + let Some(incoming) = incoming else { break }; + let Some(connection_reservation) = admission.try_reserve_connection() else { + incoming.refuse(); + continue; + }; + let runtime = Arc::clone(&runtime); + let session_socket = session_socket.clone(); + let admission = Arc::clone(&admission); + let shutdown = shutdown.child_token(); + connections.spawn(async move { + let limits = admission.limits(); + let connection = tokio::time::timeout( + limits.connection_handshake_timeout, + incoming, + ) + .await + .context("iroh TLS handshake timed out")? + .context("iroh TLS handshake failed")?; + let connection_permit = tokio::time::timeout( + limits.first_stream_timeout, + admission.acquire_connection(connection_reservation), + ) + .await + .context("iroh connection admission timed out")?; + let result = serve_connection( + runtime, + session_socket, + admission, + connection.clone(), + shutdown, + ) + .await; + drop(connection_permit); + if result.is_err() { + connection.close(7_u8.into(), b"cmux TUI admission failed"); + } else { + connection.close(0_u8.into(), b"cmux TUI stream closed"); + } + result + }); + } + } + } + + shutdown.cancel(); + connections.shutdown().await; + if !refresh_finished { + let _ = refresh.await; + } + runtime.close().await; + serve_result +} + +async fn serve_connection( + runtime: Arc, + session_socket: PathBuf, + admission: Arc, + connection: iroh::endpoint::Connection, + shutdown: CancellationToken, +) -> Result<()> { + ensure!(connection.alpn() == CMUX_TUI_ALPN, "unexpected iroh ALPN"); + let tls_initiator = connection.remote_id().to_string(); + let limits = admission.limits(); + let (mut sender, receiver) = + tokio::time::timeout(limits.first_stream_timeout, connection.accept_bi()) + .await + .context("first iroh stream timed out")? + .context("first iroh stream failed")?; + // One buffered reader for the connection's lifetime: bytes the peer sends + // after the admission line stay buffered and are bridged below. + let mut receiver = tokio::io::BufReader::new(receiver); + let stream_reservation = + admission.try_reserve_pending_stream().context("pre-auth stream capacity exhausted")?; + let stream_permit = tokio::time::timeout( + limits.pre_auth_timeout, + admission.acquire_pending_stream(stream_reservation), + ) + .await + .context("pre-auth stream admission timed out")?; + let request = receive_admission(&mut receiver).await?; + let preflight_now = unix_time()? as i64; + let grant_keys = runtime.grant_verification_keys().await; + let preflight = verify_server_preflight( + &request.grant, + &tls_initiator, + &runtime.binding, + &grant_keys, + preflight_now, + )?; + ensure!(preflight.expires_at() > preflight_now, "pair grant expired"); + + let lease = runtime.fresh_discovery_with_fleet().await?; + let now = unix_time()? as i64; + let claims = verify_server_admission( + &request.grant, + &tls_initiator, + &runtime.binding, + &lease.snapshot, + &lease.relay_urls, + now, + )?; + ensure!(claims.expires_at() > now, "pair grant expired"); + + let local = UnixStream::connect(&session_socket).await.with_context(|| { + format!("cannot connect admitted stream to {}", session_socket.display()) + })?; + acknowledge_admission(&mut sender).await?; + eprintln!( + "cmux-tui-iroh: admitted peer={} binding={} path=relay", + connection.remote_id().fmt_short(), + claims.initiator.binding_id, + ); + + let admitted = CancellationToken::new(); + let revalidation_cancel = admitted.clone(); + let revalidation_runtime = Arc::clone(&runtime); + let revalidation_grant = request.grant; + let revalidation_tls = tls_initiator; + let revalidation = tokio::spawn(async move { + let revalidation_deadline = tokio::time::sleep_until(lease.fetched_at + DISCOVERY_MAX_AGE); + let expiry_deadline = tokio::time::sleep(Duration::from_secs( + u64::try_from(claims.expires_at().saturating_sub(now)).unwrap_or(0), + )); + tokio::pin!(revalidation_deadline); + tokio::pin!(expiry_deadline); + loop { + tokio::select! { + _ = revalidation_cancel.cancelled() => return Ok::<(), anyhow::Error>(()), + _ = &mut expiry_deadline => bail!("pair grant expired"), + _ = &mut revalidation_deadline => { + let now = unix_time()? as i64; + ensure!(now < claims.expires_at(), "pair grant expired"); + let lease = revalidation_runtime.fresh_discovery_with_fleet().await?; + verify_server_admission( + &revalidation_grant, + &revalidation_tls, + &revalidation_runtime.binding, + &lease.snapshot, + &lease.relay_urls, + now, + )?; + revalidation_deadline + .as_mut() + .reset(lease.fetched_at + DISCOVERY_MAX_AGE); + } + } + } + }); + + let bridge_cancel = admitted.clone(); + let (local_reader, local_writer) = local.into_split(); + let result = tokio::select! { + result = bridge_unix_and_iroh(local_reader, local_writer, sender, receiver, bridge_cancel) => result, + _ = shutdown.cancelled() => Ok(()), + result = revalidation => { + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(error.context("admitted stream revalidation failed")), + Err(error) => Err(anyhow::anyhow!("revalidation task failed: {error}")), + } + } + }; + admitted.cancel(); + drop(stream_permit); + result +} diff --git a/cmux-tui/crates/cmux-tui-iroh/src/transport.rs b/cmux-tui/crates/cmux-tui-iroh/src/transport.rs new file mode 100644 index 000000000000..d9ff13d4d9e2 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-iroh/src/transport.rs @@ -0,0 +1,677 @@ +use std::collections::HashSet; +use std::path::Path; +use std::str::FromStr as _; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use cmux_remote::provider::{ + IrohPathMode, IrohProviderConfig, bind_iroh_endpoint, connect_iroh_endpoint, +}; +use iroh::{ + Endpoint, EndpointAddr, EndpointId, RelayConfig, RelayMap, RelayMode, RelayUrl, TransportAddr, + Watcher as _, +}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::sync::{Mutex, RwLock}; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +use crate::CMUX_TUI_ALPN; +use crate::broker::{ + Binding, BrokerClient, DiscoverySnapshot, GrantVerificationKeySet, Platform, + RelayAccessResponse, same_relay_fleet, unix_time, +}; +use crate::identity::{BrokerCredential, IdentityStore}; +use crate::policy::{RelayEnvironment, RelayPolicyVerifier, VerifiedRelayPolicy}; + +pub const ADMISSION_FRAME_BYTES: usize = 16 * 1024; +pub const ADMISSION_TIMEOUT: Duration = Duration::from_secs(5); +const ENDPOINT_ONLINE_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_TRANSPORT_FRAME_BYTES: usize = 64 * 1024 * 1024; +const RELAY_REFRESH_RETRY_MAX: Duration = Duration::from_secs(30); +pub const DISCOVERY_MAX_AGE: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone)] +struct RelayGeneration { + runtime_generation: u64, + policy: VerifiedRelayPolicy, + refresh_after: i64, + expires_at: i64, +} + +pub struct DiscoveryLease { + pub snapshot: DiscoverySnapshot, + pub relay_urls: Vec, + pub fetched_at: Instant, +} + +pub struct EndpointRuntime { + pub identity: Arc, + pub broker: BrokerClient, + pub credential: Arc, + pub binding: Binding, + pub endpoint: Endpoint, + verifier: RelayPolicyVerifier, + relay: RwLock, + grant_keys: RwLock, + discovery_guard: Mutex<()>, +} + +impl std::fmt::Debug for EndpointRuntime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EndpointRuntime") + .field("endpoint", &self.endpoint.id().fmt_short().to_string()) + .field("binding_id", &self.binding.binding_id) + .field("platform", &self.binding.platform) + .finish_non_exhaustive() + } +} + +impl EndpointRuntime { + pub async fn start(config: EndpointRuntimeConfig<'_>) -> Result { + let identity = Arc::new(IdentityStore::open(config.state_root, config.identity_name)?); + let credential = Arc::new(identity.load_credential()?); + let broker = BrokerClient::new(config.broker_url.clone())?; + let verifier = RelayPolicyVerifier::new(config.relay_environment, identity.directory())?; + let endpoint_id = identity.endpoint_id().to_string(); + + let bootstrap = broker.relay_access(&credential, &endpoint_id).await?; + ensure!(bootstrap.endpoint_id == endpoint_id, "relay bootstrap changed EndpointID"); + let now = unix_time()? as i64; + let bootstrap_policy = verifier.verify(&bootstrap.policy, now)?; + ensure!( + !bootstrap_policy.relays.is_empty(), + "broker returned an empty verified relay policy" + ); + + let binding = broker + .register_endpoint( + &credential, + identity.secret_key(), + identity.metadata(), + config.platform, + config.display_name, + config.pairing_enabled, + ) + .await?; + let access = broker.relay_access(&credential, &endpoint_id).await?; + ensure!(access.endpoint_id == endpoint_id, "relay credential changed EndpointID"); + let now = unix_time()? as i64; + let policy = verifier.verify(&access.policy, now)?; + ensure!( + policy.sequence >= bootstrap_policy.sequence, + "bound relay policy rolled back bootstrap policy" + ); + let validated = validate_relay_access(&policy, &access, now)?; + let endpoint_config = IrohProviderConfig { + secret_key: Some(identity.secret_key().clone()), + relay_mode: validated.relay_mode(), + path_mode: IrohPathMode::RelayOnly, + discovery_n0: false, + alpn: CMUX_TUI_ALPN.to_vec(), + maximum_frame_bytes: MAX_TRANSPORT_FRAME_BYTES, + }; + let endpoint = bind_iroh_endpoint(&endpoint_config) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + tokio::time::timeout(ENDPOINT_ONLINE_TIMEOUT, endpoint.online()) + .await + .context("iroh endpoint did not reach a verified relay")?; + + let snapshot = broker.discover(&credential).await?; + validate_discovery(&snapshot, &binding, &policy.relay_urls())?; + verifier.record(&policy)?; + + let grant_keys = snapshot.grant_verification_keys; + Ok(Self { + identity, + broker, + credential, + binding, + endpoint, + verifier, + relay: RwLock::new(RelayGeneration { + runtime_generation: 1, + policy, + refresh_after: validated.refresh_after, + expires_at: validated.expires_at, + }), + grant_keys: RwLock::new(grant_keys), + discovery_guard: Mutex::new(()), + }) + } + + pub fn endpoint_id(&self) -> EndpointId { + self.endpoint.id() + } + + pub async fn relay_urls(&self) -> Vec { + self.relay.read().await.policy.relay_urls() + } + + pub async fn relay_count(&self) -> usize { + self.relay.read().await.policy.relays.len() + } + + pub async fn relay_sequence(&self) -> u64 { + self.relay.read().await.policy.sequence + } + + pub async fn fresh_discovery(&self) -> Result { + self.fresh_discovery_with_fleet().await.map(|lease| lease.snapshot) + } + + pub async fn fresh_discovery_with_fleet(&self) -> Result { + let generation = self.active_relay_generation().await?; + let relay_urls = generation.policy.relay_urls(); + let _discovery_guard = self.discovery_guard.lock().await; + let snapshot = self.broker.discover(&self.credential).await?; + validate_discovery(&snapshot, &self.binding, &relay_urls)?; + let fetched_at = Instant::now(); + *self.grant_keys.write().await = snapshot.grant_verification_keys.clone(); + Ok(DiscoveryLease { snapshot, relay_urls, fetched_at }) + } + + pub async fn grant_verification_keys(&self) -> GrantVerificationKeySet { + self.grant_keys.read().await.clone() + } + + pub async fn dial(&self, remote_endpoint: &str) -> Result { + let generation = self.active_relay_generation().await?; + let endpoint_id = + EndpointId::from_str(remote_endpoint).context("remote EndpointID is invalid")?; + let addresses = generation + .policy + .relays + .iter() + .map(|relay| { + relay + .url + .parse::() + .map(TransportAddr::Relay) + .context("verified relay URL is not accepted by iroh") + }) + .collect::>>()?; + let address = EndpointAddr::from_parts(endpoint_id, addresses); + connect_iroh_endpoint(&self.endpoint, &address, CMUX_TUI_ALPN) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + pub async fn refresh_until_cancelled(&self, shutdown: CancellationToken) -> Result<()> { + loop { + let current = self.active_relay_generation().await?; + let now = unix_time()? as i64; + let wait = duration_until(current.refresh_after, now); + tokio::select! { + _ = shutdown.cancelled() => return Ok(()), + _ = tokio::time::sleep(wait) => {} + } + + let mut retry = Duration::from_secs(1); + loop { + match self.refresh_once().await { + Ok(generation) => { + eprintln!( + "cmux-tui-iroh: relay credentials refreshed sequence={} generation={}", + generation.policy.sequence, generation.runtime_generation, + ); + break; + } + Err(_) => { + let current = self.relay.read().await.clone(); + let now = unix_time()? as i64; + ensure!( + now < current.expires_at, + "relay refresh failed before the installed credential expired" + ); + let wait = retry.min(duration_until(current.expires_at, now)); + eprintln!("cmux-tui-iroh: relay refresh failed; retrying"); + tokio::select! { + _ = shutdown.cancelled() => return Ok(()), + _ = tokio::time::sleep(wait) => {} + } + retry = retry.saturating_mul(2).min(RELAY_REFRESH_RETRY_MAX); + } + } + } + } + } + + async fn refresh_once(&self) -> Result { + let endpoint_id = self.endpoint_id().to_string(); + let access = self.broker.relay_access(&self.credential, &endpoint_id).await?; + ensure!(access.endpoint_id == endpoint_id, "relay refresh changed EndpointID"); + let now = unix_time()? as i64; + let policy = self.verifier.verify(&access.policy, now)?; + let validated = validate_relay_access(&policy, &access, now)?; + let current = self.relay.read().await.clone(); + ensure!( + policy.sequence >= current.policy.sequence, + "relay refresh rolled back the live policy" + ); + + let _discovery_guard = self.discovery_guard.lock().await; + let snapshot = self.broker.discover(&self.credential).await?; + validate_discovery(&snapshot, &self.binding, &policy.relay_urls())?; + ensure!(!self.endpoint.is_closed(), "iroh endpoint closed during relay refresh"); + + let mut replaced = Vec::with_capacity(validated.configs.len()); + for (url, config) in &validated.configs { + let previous = self.endpoint.insert_relay(url.clone(), Arc::clone(config)).await; + replaced.push((url.clone(), previous)); + } + let install_result = async { + wait_for_verified_relay(&self.endpoint, &validated.urls()).await?; + self.verifier.record(&policy)?; + Result::<(), anyhow::Error>::Ok(()) + } + .await; + if let Err(error) = install_result { + rollback_relays(&self.endpoint, replaced).await; + return Err(error); + } + + let next = RelayGeneration { + runtime_generation: current.runtime_generation.saturating_add(1), + policy, + refresh_after: validated.refresh_after, + expires_at: validated.expires_at, + }; + *self.relay.write().await = next.clone(); + *self.grant_keys.write().await = snapshot.grant_verification_keys.clone(); + let next_urls = + next.policy.relays.iter().map(|relay| relay.url.as_str()).collect::>(); + for relay in ¤t.policy.relays { + if !next_urls.contains(relay.url.as_str()) { + let url = + relay.url.parse::().context("installed relay URL is invalid")?; + self.endpoint.remove_relay(&url).await; + } + } + ensure!(!self.endpoint.is_closed(), "iroh endpoint closed during relay refresh"); + Ok(next) + } + + async fn active_relay_generation(&self) -> Result { + let generation = self.relay.read().await.clone(); + let now = unix_time()? as i64; + ensure!(now < generation.expires_at, "installed relay authorization expired"); + Ok(generation) + } + + pub async fn close(&self) { + self.endpoint.close().await; + } +} + +pub struct EndpointRuntimeConfig<'a> { + pub state_root: &'a Path, + pub identity_name: &'a str, + pub broker_url: url::Url, + pub relay_environment: RelayEnvironment, + pub platform: Platform, + pub display_name: Option<&'a str>, + pub pairing_enabled: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdmissionRequest { + pub version: u32, + pub grant: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdmissionResponse { + pub accepted: bool, +} + +pub async fn send_admission( + sender: &mut iroh::endpoint::SendStream, + receiver: &mut R, + grant: &str, +) -> Result<()> +where + R: tokio::io::AsyncBufRead + Unpin, +{ + ensure!(grant.len() <= ADMISSION_FRAME_BYTES / 2, "pair grant is too large"); + write_json_line(sender, &AdmissionRequest { version: 1, grant: grant.to_string() }).await?; + let response: AdmissionResponse = + tokio::time::timeout(ADMISSION_TIMEOUT, read_json_line(receiver, ADMISSION_FRAME_BYTES)) + .await + .context("server admission acknowledgement timed out")??; + ensure!(response.accepted, "server rejected transport admission"); + Ok(()) +} + +pub async fn receive_admission(receiver: &mut R) -> Result +where + R: tokio::io::AsyncBufRead + Unpin, +{ + let request: AdmissionRequest = + tokio::time::timeout(ADMISSION_TIMEOUT, read_json_line(receiver, ADMISSION_FRAME_BYTES)) + .await + .context("transport admission frame timed out")??; + ensure!(request.version == 1, "unsupported transport admission version"); + ensure!(!request.grant.is_empty(), "transport admission grant is empty"); + Ok(request) +} + +pub async fn acknowledge_admission(sender: &mut iroh::endpoint::SendStream) -> Result<()> { + write_json_line(sender, &AdmissionResponse { accepted: true }).await +} + +pub async fn bridge_unix_and_iroh( + mut local_reader: LR, + mut local_writer: LW, + mut sender: iroh::endpoint::SendStream, + mut receiver: RR, + cancel: CancellationToken, +) -> Result<()> +where + LR: tokio::io::AsyncRead + Unpin, + LW: tokio::io::AsyncWrite + Unpin, + RR: tokio::io::AsyncRead + Unpin, +{ + let upstream = async { + tokio::io::copy(&mut local_reader, &mut sender).await?; + sender.finish()?; + Result::<(), anyhow::Error>::Ok(()) + }; + let downstream = async { + tokio::io::copy(&mut receiver, &mut local_writer).await?; + local_writer.shutdown().await?; + Result::<(), anyhow::Error>::Ok(()) + }; + tokio::select! { + result = async { tokio::try_join!(upstream, downstream).map(|_| ()) } => result, + _ = cancel.cancelled() => Ok(()), + } +} + +/// Typed end-of-stream signal for framed JSON reads, so callers can +/// distinguish a peer that closed the stream from a malformed frame +/// without matching on error-message text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StreamClosed; + +impl std::fmt::Display for StreamClosed { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("stream closed before JSON line") + } +} + +impl std::error::Error for StreamClosed {} + +pub fn is_stream_closed(error: &anyhow::Error) -> bool { + error.chain().any(|cause| cause.downcast_ref::().is_some()) +} + +/// Callers must keep one buffered reader alive for the connection's whole +/// lifetime: bytes after the newline stay in the reader's buffer. +pub async fn read_json_line(reader: &mut R, maximum_bytes: usize) -> Result +where + R: tokio::io::AsyncBufRead + Unpin, + T: serde::de::DeserializeOwned, +{ + let mut bytes = Vec::new(); + let mut limited = reader.take(maximum_bytes as u64); + let count = limited.read_until(b'\n', &mut bytes).await?; + if count == 0 { + return Err(anyhow::Error::new(StreamClosed)); + } + if bytes.last() == Some(&b'\n') { + bytes.pop(); + } else if count == maximum_bytes { + anyhow::bail!("JSON line is too large"); + } else { + return Err(anyhow::Error::new(StreamClosed)); + } + ensure!(!bytes.is_empty(), "JSON line is empty"); + serde_json::from_slice(&bytes).context("JSON line is invalid") +} + +pub async fn write_json_line(writer: &mut W, value: &T) -> Result<()> +where + W: tokio::io::AsyncWrite + Unpin, + T: Serialize, +{ + write_bounded_json_line(writer, value, ADMISSION_FRAME_BYTES).await +} + +pub async fn write_bounded_json_line( + writer: &mut W, + value: &T, + maximum_bytes: usize, +) -> Result<()> +where + W: tokio::io::AsyncWrite + Unpin, + T: Serialize, +{ + let mut bytes = serde_json::to_vec(value).context("cannot encode JSON line")?; + ensure!(bytes.len() < maximum_bytes, "JSON line is too large"); + bytes.push(b'\n'); + writer.write_all(&bytes).await?; + writer.flush().await?; + Ok(()) +} + +struct ValidatedRelayAccess { + configs: Vec<(RelayUrl, Arc)>, + refresh_after: i64, + expires_at: i64, +} + +impl ValidatedRelayAccess { + fn relay_mode(&self) -> RelayMode { + RelayMode::Custom(RelayMap::from_iter( + self.configs.iter().map(|(_, config)| Arc::clone(config)), + )) + } + + fn urls(&self) -> HashSet { + self.configs.iter().map(|(url, _)| url.clone()).collect() + } +} + +fn validate_relay_access( + policy: &VerifiedRelayPolicy, + response: &RelayAccessResponse, + now: i64, +) -> Result { + ensure!( + response.relay_credentials.len() == policy.relays.len(), + "relay credential set does not cover the signed policy" + ); + let expected = policy.relays.iter().map(|relay| relay.url.as_str()).collect::>(); + let mut observed = HashSet::new(); + let mut configs = Vec::with_capacity(response.relay_credentials.len()); + let mut refresh_after = i64::MAX; + let mut expires_at = i64::MAX; + for credential in &response.relay_credentials { + ensure!( + expected.contains(credential.relay_url.as_str()), + "credential covers an unknown relay" + ); + ensure!(observed.insert(credential.relay_url.as_str()), "duplicate relay credential"); + ensure!( + !credential.token.is_empty() + && credential.token.len() <= 8 * 1024 + && !credential.token.bytes().any(|byte| byte.is_ascii_control()), + "relay credential token is invalid" + ); + ensure!( + (30..=24 * 60 * 60).contains(&credential.ttl_seconds), + "relay credential TTL is invalid" + ); + ensure!(credential.refresh_after > now, "relay credential refresh time is stale"); + ensure!( + credential.expires_at > credential.refresh_after, + "relay credential expiry is invalid" + ); + ensure!( + credential.refresh_after >= credential.expires_at - credential.ttl_seconds, + "relay credential lifetime is inconsistent" + ); + ensure!( + credential.expires_at <= now + credential.ttl_seconds + 30, + "relay credential expiry exceeds its TTL" + ); + let url = + credential.relay_url.parse::().context("verified relay URL is invalid")?; + let config = + Arc::new(RelayConfig::from(url.clone()).with_auth_token(credential.token.clone())); + configs.push((url, config)); + refresh_after = refresh_after.min(credential.refresh_after); + expires_at = expires_at.min(credential.expires_at); + } + ensure!(observed.len() == expected.len(), "relay credential fleet is incomplete"); + expires_at = expires_at.min(policy.expires_at); + ensure!(expires_at > now, "relay authorization is already expired"); + refresh_after = refresh_after.min(policy.expires_at.saturating_sub(30)); + ensure!(refresh_after > now, "relay authorization refresh window is too short"); + Ok(ValidatedRelayAccess { configs, refresh_after, expires_at }) +} + +fn validate_discovery( + snapshot: &DiscoverySnapshot, + binding: &Binding, + relay_urls: &[String], +) -> Result<()> { + ensure!( + same_relay_fleet(&snapshot.relay_fleet, relay_urls), + "broker discovery fleet differs from installed signed policy" + ); + let local = snapshot + .bindings + .iter() + .filter(|candidate| candidate.binding_id == binding.binding_id) + .collect::>(); + ensure!(local.len() == 1, "local binding is missing or ambiguous"); + ensure!(local[0].same_identity(binding), "local binding changed after registration"); + Ok(()) +} + +async fn wait_for_verified_relay(endpoint: &Endpoint, expected: &HashSet) -> Result<()> { + let wait = async { + let mut status = endpoint.home_relay_status(); + let mut current = status.get(); + loop { + if current.iter().any(|relay| relay.is_connected() && expected.contains(relay.url())) { + return Ok::<(), anyhow::Error>(()); + } + current = status.updated().await.context("relay status watcher closed")?; + } + }; + tokio::time::timeout(ENDPOINT_ONLINE_TIMEOUT, wait) + .await + .context("refreshed relay fleet did not become reachable")??; + Ok(()) +} + +async fn rollback_relays(endpoint: &Endpoint, replaced: Vec<(RelayUrl, Option>)>) { + for (url, previous) in replaced.into_iter().rev() { + match previous { + Some(previous) => { + endpoint.insert_relay(url, previous).await; + } + None => { + endpoint.remove_relay(&url).await; + } + } + } +} + +fn duration_until(timestamp: i64, now: i64) -> Duration { + Duration::from_secs(u64::try_from(timestamp.saturating_sub(now)).unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use tokio::io::duplex; + use uuid::Uuid; + + use super::*; + + #[tokio::test] + async fn bounded_json_line_preserves_following_protocol_bytes() { + let (mut writer, reader) = duplex(1024); + tokio::spawn(async move { + writer.write_all(b"{\"accepted\":true}\n{\"id\":1}\n").await.unwrap(); + }); + let mut reader = tokio::io::BufReader::new(reader); + let response: AdmissionResponse = read_json_line(&mut reader, 128).await.unwrap(); + assert!(response.accepted); + let mut rest = [0; 9]; + reader.read_exact(&mut rest).await.unwrap(); + assert_eq!(&rest, b"{\"id\":1}\n"); + } + + #[tokio::test] + async fn bounded_json_line_rejects_oversize() { + let (mut writer, reader) = duplex(1024); + tokio::spawn(async move { + writer.write_all(b"123456789\n").await.unwrap(); + }); + let mut reader = tokio::io::BufReader::new(reader); + let error = read_json_line::<_, serde_json::Value>(&mut reader, 8).await.unwrap_err(); + assert!(!is_stream_closed(&error)); + assert!(error.to_string().contains("too large")); + } + + #[tokio::test] + async fn closed_stream_is_a_typed_signal() { + let (writer, reader) = duplex(1024); + drop(writer); + let mut reader = tokio::io::BufReader::new(reader); + let error = read_json_line::<_, serde_json::Value>(&mut reader, 128).await.unwrap_err(); + assert!(is_stream_closed(&error)); + } + + #[test] + fn relay_credentials_cover_the_verified_fleet_exactly() { + let policy = VerifiedRelayPolicy { + jti: Uuid::new_v4(), + sequence: 1, + issued_at: 1_000, + not_before: 1_000, + expires_at: 1_300, + relays: vec![crate::policy::VerifiedRelay { + id: "test".into(), + provider: "cmux".into(), + region: "local".into(), + url: "https://relay.example.com/".into(), + }], + compact: "signed-policy".into(), + }; + let credential = crate::broker::RelayCredential { + relay_url: "https://relay.example.com/".into(), + token: "relay-token".into(), + expires_at: 1_300, + refresh_after: 1_240, + ttl_seconds: 300, + }; + let response = RelayAccessResponse { + endpoint_id: "aa".repeat(32), + relay_credentials: vec![credential.clone()], + policy: "signed-policy".into(), + preference: json!({}), + preference_revision: 1, + }; + assert!(validate_relay_access(&policy, &response, 1_000).is_ok()); + + let mut extra = response.clone(); + extra.relay_credentials.push(credential); + assert!(validate_relay_access(&policy, &extra, 1_000).is_err()); + + let mut impossible_ttl = response; + impossible_ttl.relay_credentials[0].expires_at = 2_000; + assert!(validate_relay_access(&policy, &impossible_ttl, 1_000).is_err()); + } +} diff --git a/docs/evidence/iroh-tui-stage1-sol/transcript.txt b/docs/evidence/iroh-tui-stage1-sol/transcript.txt new file mode 100644 index 000000000000..28abd5a215fd --- /dev/null +++ b/docs/evidence/iroh-tui-stage1-sol/transcript.txt @@ -0,0 +1,20 @@ +iroh TUI Stage 1 acceptance +broker=http://127.0.0.1:4581 relay_environment=staging +source=46a733ddd29a64446e08a9b836a040e6104930ae-dirty +build_started=2026-08-04T17:43:15Z +enrolled endpoint=87393775ed device=24118508-bb1d-4da7-9651-0a88018f4e28 tag=tui-f1a54e84-6f73-42bd-9597-10f5a6b30862 +container_port_bindings={} +container_published_ports=none +container_network_mode=default +first_server ready endpoint=21bc3904d2 identity=0e263a3f80365053 binding=92f78e98-5ec9-48fc-b98a-e74e1a86a621 relays=7 inbound_ports=0 ip_transports=0 +provider ready endpoint=87393775ed identity=6006762ac9e04938 binding=89a483c6-264c-49e7-89f0-312a0a744901 relays=7 ip_transports=0 socket=/var/folders/xw/j2s0lpvj16b4y5_5hsfcphb00000gn/T//cmux-iroh-stage1-sol.ykqjaq/provider.sock +probe protocol=10 machine=92f78e98-5ec9-48fc-b98a-e74e1a86a621 resolution=broker detach_reattach=ok marker=1f9c9c70-a083-4890-b3b3-336eb1df626b provider=cmux-iroh-account +detach_reattach_before_restart=ok +second_server ready endpoint=21bc3904d2 identity=0e263a3f80365053 binding=92f78e98-5ec9-48fc-b98a-e74e1a86a621 relays=7 inbound_ports=0 ip_transports=0 +container_endpoint_device_tag_and_binding_stable=ok +probe protocol=10 machine=92f78e98-5ec9-48fc-b98a-e74e1a86a621 resolution=broker detach_reattach=ok marker=1f9c9c70-a083-4890-b3b3-336eb1df626b provider=cmux-iroh-account +reattach_after_restart=ok +cmux-tui-iroh: connected machine=92f78e98-5ec9-48fc-b98a-e74e1a86a621 peer=21bc3904d2 path=relay address_source=endpoint_id+verified_catalog +cmux-tui-iroh: connected machine=92f78e98-5ec9-48fc-b98a-e74e1a86a621 peer=21bc3904d2 path=relay address_source=endpoint_id+verified_catalog +cmux-tui-iroh: connected machine=92f78e98-5ec9-48fc-b98a-e74e1a86a621 peer=21bc3904d2 path=relay address_source=endpoint_id+verified_catalog +acceptance=pass completed=2026-08-04T17:54:07Z diff --git a/docs/iroh-tui-transport-stage1-sol.md b/docs/iroh-tui-transport-stage1-sol.md new file mode 100644 index 000000000000..c9013bb231bf --- /dev/null +++ b/docs/iroh-tui-transport-stage1-sol.md @@ -0,0 +1,191 @@ +# Iroh transport for cmux-tui, Stage 1 + +Status: implementation design for the independent `feat-iroh-tui-transport-sol` attempt. + +This stage makes one existing cmux-tui session socket reachable through iroh by EndpointID. It keeps protocol v10 JSON-lines unchanged after a transport admission exchange. It intentionally uses managed relays only. Direct UDP, hole punching, LAN discovery, private candidate exchange, and offline admission remain outside Stage 1. + +The broker contract is the contract in [manaflow-ai/cmux#9515](https://github.com/manaflow-ai/cmux/pull/9515): Linux endpoint registration, TUI pair grants with ALPN `cmux/tui/1` and scope `cmux.tui.attach`, and one-use headless enrollment tokens. This transport consumes that contract and does not add competing web routes or persistence. + +## Stage 1 boundary + +The transport consists of a small Rust sidecar and library in the cmux-tui workspace: + +- `cmux-tui-iroh server` owns one persistent Linux iroh endpoint, registers it, admits one broker-authorized stream at a time, and proxies admitted bytes to an existing local cmux-tui Unix socket. +- `cmux-tui-iroh provider` exposes a machine-provider v1 Unix socket on the frontend machine. It registers its own endpoint, resolves Linux account devices through the broker, issues one-use provider tickets, obtains a pair grant, dials the selected EndpointID, and proxies the admitted stream to the frontend. +- `cmux-tui-iroh enroll` exchanges a one-use provisioning token for a persisted credential pair without placing the provisioning token on an iroh stream or in durable state. + +The sidecar is a transport adapter. The session owner stays the existing cmux-tui server, and every byte after admission is its existing protocol v10 JSON-lines stream. + +Stage 1 configures `iroh::endpoint::presets::Minimal`, a custom relay map built only from a verified broker policy, and `clear_ip_transports()`. No n0 preset, n0 discovery, public n0 DNS, direct address, LAN discovery, or environment-supplied relay URL is reachable in this mode. Relay-only operation also means stock iroh 1.0.3 has no private candidate to disclose before pair-grant admission. Direct path migration will require the accepted pre-admission candidate barrier before it can be enabled. + +## End-to-end flow + +### First enrollment and registration + +1. `enroll` opens or creates the selected cmux-tui iroh state directory with owner-only access. +2. It creates `endpoint.key` through `cmux_remote::provider::load_or_create_iroh_secret` and creates metadata containing a client-minted `deviceId`, `appInstanceId`, provisioning tag, and identity generation 1. +3. It reads the one-use provisioning token from an owner-only file or inherited standard input, posts exactly `{ "token": ... }` to `POST /api/devices/iroh/enroll`, zeroizes the token buffer, and stores the returned access and refresh tokens in an owner-only credential file. +4. Server or provider startup calls `POST /api/relay/token` for its EndpointID. The unbound bootstrap response supplies the signed relay policy. The client verifies the compact Ed25519 JWS against the selected built-in trust root and the complete policy schema before accepting any relay URL. +5. Startup serializes one canonical registration payload, hashes those exact bytes, obtains a challenge from `POST /api/devices/iroh/challenge`, signs the specified registration transcript with the endpoint key, and posts the signed payload to `POST /api/devices/iroh/register`. +6. Startup calls `POST /api/relay/token` again. It requires one endpoint-bound credential for every relay in the verified policy and no extra credential. Only then does it bind the iroh endpoint. + +The registration payload uses platform `linux` for a server and the local frontend platform for a provider. The server advertises capability `cmux.tui.attach`, enables pairing, and publishes no path hints in Stage 1. The provider does not enable pairing. Empty path hints are intentional because the remote address is reconstructed from the authenticated EndpointID plus the locally verified relay catalog. + +The broker serializes registration by `(userId, deviceId, tag)`. Reusing the persisted endpoint key, device ID, app instance ID, tag, and generation therefore updates the same active binding in place. A container restart never mints a new identity when its state volume remains mounted. + +### Provider discovery and open + +1. A frontend connects to the owner-only provider Unix socket. Its first control frame is machine-provider `hello` with a fresh generation bearer. +2. `snapshot` fetches every broker page, requires one coherent revision and relay fleet, and returns active pairable Linux bindings as machine descriptors in one personal scope. +3. `open_machine` refreshes discovery, locates the frontend's exact initiator binding and selected Linux acceptor binding, obtains a TUI pair grant, and creates a random in-memory provider ticket valid for 30 seconds. +4. The result is `TransportDescriptor::ProviderStream { ticket, expires_at }` with a provider connection ID. +5. The frontend opens another provider socket and sends the required `TransportHandshake` with role `transport`, its generation bearer, and the ticket. The provider compares the bearer in constant time and atomically consumes the ticket. +6. The provider builds an `EndpointAddr` containing only the acceptor EndpointID and every relay URL in the verified catalog, dials ALPN `cmux/tui/1`, and verifies `connection.remote_id()` equals the requested acceptor EndpointID. +7. The provider sends one bounded transport admission frame containing the pair grant. It waits for the server admission acknowledgement, returns `TransportHandshakeResult { accepted: true }` locally, then copies bytes in both directions without interpreting protocol v10. + +Tickets are generation-bound, machine-bound, one use, and memory-only. Expiry, control-generation replacement, `close_machine`, local stream closure, or provider exit closes the corresponding upstream connection. + +### Server admission + +1. The listener accepts TLS only under ALPN `cmux/tui/1`. The authenticated initiator EndpointID comes from the completed iroh connection. +2. Before opening the cmux-tui Unix socket or forwarding an application byte, it reads one admission frame with a 16 KiB limit and a five-second timeout. +3. Before broker traffic, it verifies the compact JWS with the last authenticated discovery key set, including type `cmux-pair-grant+jwt`, `alg=EdDSA`, TUI ALPN, TUI scope, canonical UUIDs, valid times, and the broker lifetime bound. +4. That local preflight requires the grant initiator EndpointID to equal the TLS initiator EndpointID and the acceptor tuple to equal the persisted local binding. Invalid or cross-endpoint traffic therefore cannot induce authenticated HTTP. +5. It then obtains a current authenticated discovery snapshot. A mutex serializes discovery with relay-fleet replacement, but no prior snapshot can authorize a new connection. +6. It re-verifies the signature with that snapshot's key set and requires every initiator and acceptor field to match exactly one corresponding discovery binding. The acceptor must remain pairable, advertise `cmux.tui.attach`, use route contract 1, and the discovery relay fleet must equal the installed signed relay fleet. +7. Only after all checks pass does it connect to the local session socket, acknowledge admission, and start the byte bridge. + +The server revalidates the exact bindings, pairing flag, route contract, and relay fleet at most 30 seconds after the snapshot fetch, including while idle. The first deadline is inherited from the admission snapshot instead of starting a second 30-second window. Stage 1 fails closed on every authentication, HTTP, timeout, decode, contract, fleet, missing-row, or ambiguous-row failure. An independent deadline closes exactly at grant expiry. This is stricter than the accepted policy that permits an already admitted connection to survive a pure broker connectivity failure. + +Admission rejection is sticky for that connection. Retrying requires a new TLS connection and a fresh transport admission frame. + +## Persistent state + +For a state root `` and identity name ``, the sidecar uses `/iroh-tui//`: + +| File | Contents | Mode | +| --- | --- | --- | +| `endpoint.key` | Raw 32-byte Ed25519 seed used by iroh and registration signatures | `0600` | +| `identity.json` | Schema version, device ID, app instance ID, tag, generation | `0600` | +| `credential.json` | Access token and refresh token returned by enrollment | `0600` | +| `relay-policy.json` | Last verified policy sequence, JTI, expiry, and compact JWS | `0600` | +| `state.lock` | Exclusive process lock for this endpoint identity | `0600` | + +The directory is opened component by component with `O_NOFOLLOW` through cmux-remote's secure-directory code and ends at mode `0700`. Secret files are read from one descriptor with `O_NOFOLLOW`, owner checks, regular-file checks, byte limits, and no group or other permissions. JSON replacement uses an owner-only temporary file, file sync, atomic rename, and parent sync. Secrets are redacted from `Debug`, errors, logs, scripts, and evidence. + +Losing `endpoint.key` means creating a new device identity and binding. Stage 1 never accepts a replacement key for an existing generation and does not implement endpoint rotation proof. + +## Relay policy and credentials + +The relay-policy verifier implements the accepted v1 schema: + +- compact JWS with exactly three segments, `alg=EdDSA`, type `cmux-relay-policy-v1+jwt`, and a pinned key ID; +- claims `version`, `jti`, `sequence`, `iat`, `nbf`, `exp`, `aud`, `relay_protocol`, and `relays`, with unknown fields rejected; +- audience `cmux-iroh-relay-policy`, relay protocol `iroh-relay-v1`, canonical UUID JTI, positive monotonic sequence, a maximum seven-day lifetime, and a 30-second clock skew; +- one to sixteen unique relays, safe identifiers, and canonical root `https://` URLs without user info, ports, paths, queries, or fragments; +- no sequence rollback relative to the cached accepted policy; +- one URL-bound managed credential for every policy relay, with sane refresh and expiry fields. + +The production and staging trust roots are compiled from the existing Xcode relay-policy pin configuration. Runtime selects one named environment and cannot add keys or relay URLs through environment variables or flags. Tests can inject keys only through Rust test constructors. + +Every endpoint is built with `RelayMode::Custom` from that exact verified set. `RelayMode::Default`, `presets::N0`, public n0 DNS, custom relay flags, and unverified registry hints are absent from the Stage 1 code path. + +The relay credential coordinator refreshes before `refresh_after`. It validates the complete replacement policy, credential set, and discovery fleet, inserts or replaces those relay configurations on the live Minimal relay-only endpoint, waits for a verified fleet relay to be connected, commits the new runtime generation, then removes retired relays. The EndpointID and application streams do not change. Failed installation rolls relay configurations back, retries with bounded backoff, and stops accepting new streams before the last installed authorization expires. + +## Relationship to cmux-remote + +`crates/cmux-remote` already owns hardened process and iroh transport primitives. Stage 1 reuses and slightly generalizes those primitives instead of making a second security implementation: + +- endpoint keys use `provider::load_or_create_iroh_secret`; +- state directories use `secure_directory::ensure_secure_directory` and its symlink-resistant traversal; +- credential and metadata reads use `secret_file::read_owner_only`; +- atomic owner-only JSON persistence is extracted from cmux-remote identity storage as a public state-file helper, then used by both identity systems; +- Minimal endpoint construction and authenticated remote-ID checks are extracted from the current iroh provider into public helpers that require an explicit relay mode and path mode; +- the listener uses the same bounded connection, overflow, pending-stream, per-connection stream, and pre-auth admission machinery as `IrohListener`. + +The cmux-remote Noise identity and `dev.cmux.remote/1` protocol are not placed on this stream. They authorize a different remote-daemon protocol and local enrollment database. TUI admission is instead the broker-signed same-account pair grant bound to iroh TLS identities, and the payload after admission must remain cmux-tui protocol v10. The existing cmux-remote daemon remains available for its authenticated lane protocol; this Stage 1 sidecar only shares its hardened lower-level machinery. + +## Architecture constraint map + +| Accepted architecture constraint | Stage 1 implementation choice | +| --- | --- | +| EndpointID is the cryptographic endpoint identity | Persist one Ed25519 iroh secret and compare every dialed or accepted TLS peer with the expected EndpointID. Canonical wire form is lowercase 64-character hex. | +| Reachability hints are never account or grant authority | Bindings select a candidate machine only. Pair-grant claims plus fresh same-account discovery authorize access. Registry path hints are ignored for Stage 1 dialing. | +| One endpoint per transport process | Server and provider each bind one endpoint per process and reject a second process through the identity lock. | +| Minimal preset, verified relays, no n0 defaults | Use `presets::Minimal`, `RelayMode::Custom`, pinned policy verification, and `clear_ip_transports()`. No default discovery or relay mode exists in this path. | +| Publish only safe reachability | Registration publishes no path hints or direct ports. The broker therefore publishes EndpointID only. | +| Globally useful bootstrap | Every peer constructs the remote address from EndpointID plus the complete verified managed relay catalog. No inbound port or local candidate is needed. | +| Private candidates wait behind admission | Stage 1 has no IP transport and therefore no private candidate. Direct and hole-punched paths remain disabled until the fork barrier is available. | +| Grant binds both devices, endpoints, generations, ALPN, scope, time, and JTI | Verify every claim exactly, including TLS initiator and persisted local acceptor identity, under `cmux/tui/1` and `cmux.tui.attach`. | +| Online same-account validation is fail closed | Fetch complete authenticated discovery during admission and every 30 seconds. Missing, duplicate, mismatched, stale, malformed, or unreachable broker state closes the stream. | +| Offline acceptance is tightly limited | Stage 1 has no offline grant or cached-discovery admission path. | +| Grant refresh and expiry are bounded | Provider obtains a grant for each `open_machine`; server closes at its signed expiry. | +| Tokens never cross iroh | Enrollment, Stack credentials, relay credentials, and provider bearer/ticket stay on HTTPS or owner-only local sockets. Only the signed pair grant crosses the admission stream. | +| First application stream is admission-only | The first bounded frame is the pair grant. The local session socket is unopened until admission succeeds. | +| Invalid peers cannot amplify broker traffic | Verify the signed grant, TLS initiator, and exact local acceptor against the last authenticated key set before discovery. Each surviving TLS connection gets one bounded authenticated discovery request under the fixed admission limits. | +| Fixed unauthenticated resource limits | Reuse cmux-remote's bounded semaphores, five-second admission timeout, one admitted protocol stream per connection, and bounded frame sizes. | +| Registration proves endpoint-key possession | Use the exact challenge hash and `cmux/iroh/device-registration/v1` transcript, signed by the persisted endpoint key. | +| Registration slots and rotation semantics | Persist client-minted device ID and tag. Reboot updates the same `(userId, deviceId, tag)` slot. Key loss creates a new identity; rotation is not guessed. | +| Secure platform storage | Use cmux-remote owner-only, `O_NOFOLLOW`, atomic persistence under the cmux-tui state root. | +| Signed relay policy and safe rollout | Verify pins, schema, time, monotonic sequence, exact fleet, and exact credentials before live add-before-remove replacement. EndpointID and application streams stay unchanged. | +| Endpoint-bound relay credential | Fetch credentials only after registration and require the broker-returned EndpointID to match the local endpoint. | +| Custom relays need saved broker metadata | Stage 1 does not support custom relays. | +| No public development relay in production | There is no default or development relay fallback. Startup fails when signed managed policy or credentials are unavailable. | +| Stream credit and backpressure barrier | Before admission, only one bounded admission frame is read. After admission, Tokio copy backpressure and QUIC flow control govern the unchanged byte stream. | +| Redacted diagnostics | Full EndpointIDs, grants, enrollment tokens, session tokens, and relay credentials never enter normal logs. Endpoint labels use a short prefix only. | +| Exact public direct-hint classification | Stage 1 publishes and consumes no direct hints. | +| Platform lifecycle and background behavior | Server is a foreground or supervised headless process. Mobile background lifecycle is outside this Linux/Mac Stage 1. | +| Rollout gates | Feature is opt-in through a separate binary and state directory. Existing remote and local transports are unchanged. | + +## Limits and timeouts + +| Resource | Limit | +| --- | --- | +| Simultaneous TLS connections | 64 admitted plus 8 bounded overflow waiters | +| Pending streams globally | 64 admitted plus 8 bounded overflow waiters | +| Pending streams per connection | 8, while Stage 1 accepts only the first protocol stream | +| TLS or first-stream wait | 10 seconds and 15 seconds, inherited from cmux-remote | +| Admission frame | 16 KiB, one JSON line, 5 seconds | +| Broker request | 10 seconds, response body 1 MiB maximum | +| Machine-provider control frame | 1 MiB | +| Machine-provider transport handshake | 64 KiB, then streaming bytes without buffering the session | +| Provider ticket | 30 seconds, one use | +| Admission snapshot age | Fetched for the current connection; revalidated within 30 seconds | +| Grant lifetime | Broker maximum seven days, connection closes at signed expiry | + +## Verification plan + +Unit and integration coverage will prove: + +- first boot persists one endpoint key, device ID, app instance ID, and tag; a second boot keeps every value; +- insecure directory, symlink, wrong owner, permissive mode, truncated secret, and oversized state files fail closed; +- registration signs the exact challenge transcript and a repeated registration preserves the broker slot; +- relay policies reject an unknown key, wrong type or audience, non-root URL, duplicate fleet member, expiry, excessive lifetime, and sequence rollback; +- relay credentials must cover the policy fleet exactly; +- grants reject a wrong TLS initiator, wrong local acceptor, mobile ALPN or scope, stale generation, expired time, missing binding, disabled pairing, fleet mismatch, or broker failure; +- no local session byte is read or written before admission success; +- machine-provider hello, snapshot, open, transport handshake, ticket replay, ticket expiry, close, and control-generation replacement follow v1; +- a protocol v10 request and asynchronous event cross the admitted stream byte-for-byte; +- detach closes only the transport, reattach creates a new grant and stream, and the headless session remains alive. + +The Docker acceptance script will: + +1. build only `cmux-tui` and `cmux-tui-iroh` in one reusable target directory; +2. start a Linux container with no published port and a named identity/state volume; +3. exchange a one-use server provisioning token, launch a headless cmux-tui session, and launch the iroh server sidecar; +4. enroll and start the Mac provider, resolve the container from broker discovery, and attach through its EndpointID and verified relays; +5. create durable session output, detach, reattach, and confirm the same session state; +6. restart the container, confirm the same EndpointID, device ID, tag, and binding ID, then attach again; +7. record timestamps, redacted identity prefixes, broker binding IDs, transport path `relay`, protocol checks, and Docker port mappings in an evidence transcript, and refuse to publish evidence from a dirty source tree; +8. remove the exact demo container, volume, image, and temporary host Rust target while retaining the source evidence. It does not globally prune shared Docker caches owned by concurrent work. + +Acceptance is complete only when the evidence shows zero published container ports, a catalog-relay path, EndpointID-only remote construction, successful detach and reattach, stable identity across restart, and no credential or full EndpointID disclosure. + +## Deferred work + +- Direct UDP and NAT hole punching wait for the accepted pre-admission candidate and stream-credit barrier in the cmux iroh fork. +- Offline same-account admission is not implemented. +- Endpoint-key rotation and recovery are not implemented. +- Mobile background lifecycle and constrained-path behavior are not part of this Linux/Mac stage. +- Custom account relays are not implemented. +- Stage 1 uses stricter fail-closed revalidation during broker outages. A later stage may preserve an already admitted connection for classified connectivity failures while keeping policy denial sticky. diff --git a/scripts/iroh-tui-stage1-demo.sh b/scripts/iroh-tui-stage1-demo.sh new file mode 100755 index 000000000000..b31fe0549f5b --- /dev/null +++ b/scripts/iroh-tui-stage1-demo.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +: "${CMUX_BROKER_URL:?Set CMUX_BROKER_URL to the broker origin}" +: "${CMUX_BROKER_ACCESS_TOKEN:?Set CMUX_BROKER_ACCESS_TOKEN for enrollment-token minting}" +: "${CMUX_BROKER_REFRESH_TOKEN:?Set CMUX_BROKER_REFRESH_TOKEN for enrollment-token minting}" + +CMUX_RELAY_ENVIRONMENT="${CMUX_RELAY_ENVIRONMENT:-production}" +CMUX_CONTAINER_BROKER_URL="${CMUX_CONTAINER_BROKER_URL:-$CMUX_BROKER_URL}" +CMUX_CONTAINER_BROKER_FORWARD="${CMUX_CONTAINER_BROKER_FORWARD:-}" +EVIDENCE_DIR="${EVIDENCE_DIR:-$REPO_ROOT/docs/evidence/iroh-tui-stage1-sol}" +demo_root="$(mktemp -d "${TMPDIR:-/tmp}/cmux-iroh-stage1-sol.XXXXXX")" +build_cache_root="${CMUX_STAGE1_BUILD_CACHE_ROOT:-$demo_root/build-cache}" +image="cmux-iroh-stage1-sol:$(date +%s)-$$" +builder_image="cmux-iroh-stage1-sol-builder:$(date +%s)-$$" +container="cmux-iroh-stage1-sol-$$" +volume="cmux-iroh-stage1-sol-state-$$" +zig_cache_volume="${CMUX_STAGE1_ZIG_CACHE_VOLUME:-cmux-iroh-stage1-sol-zig-cache-$$}" +zig_pkg_volume="${CMUX_STAGE1_ZIG_PKG_VOLUME:-cmux-iroh-stage1-sol-zig-pkg-$$}" +remove_zig_cache_volume=1 +remove_zig_pkg_volume=1 +if [ -n "${CMUX_STAGE1_ZIG_CACHE_VOLUME:-}" ]; then + remove_zig_cache_volume=0 +fi +if [ -n "${CMUX_STAGE1_ZIG_PKG_VOLUME:-}" ]; then + remove_zig_pkg_volume=0 +fi +case "$zig_cache_volume" in + "" | *[!a-zA-Z0-9_.-]*) + echo "invalid Docker volume name" >&2 + exit 1 + ;; +esac +case "$zig_pkg_volume" in + "" | *[!a-zA-Z0-9_.-]*) + echo "invalid Docker volume name" >&2 + exit 1 + ;; +esac +provider_socket="$demo_root/provider.sock" +provider_log="$demo_root/provider.log" +server_token_file="$demo_root/server-provisioning-token" +transcript="$demo_root/transcript.txt" +host_target="$build_cache_root/host-target" +host_binary="$host_target/debug/cmux-tui-iroh" +linux_target="$build_cache_root/linux-target" +linux_cargo_home="$build_cache_root/linux-cargo-home" +linux_resolv_conf="$demo_root/linux-resolv.conf" +runtime_context="$demo_root/runtime-context" +build_commit="$(git -C "$REPO_ROOT" rev-parse HEAD)" +if [ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=normal)" ]; then + build_commit="${build_commit}-dirty" + # Tracked evidence must prove that the reviewed commit produced the + # recorded result; a dirty tree cannot, so publishing is refused. + if [ "${CMUX_STAGE1_ALLOW_DIRTY:-0}" = "1" ]; then + EVIDENCE_DIR="$demo_root/evidence" + echo "source tree is dirty; writing non-publishable evidence to $EVIDENCE_DIR" >&2 + else + echo "source tree is dirty; commit the changes, or set CMUX_STAGE1_ALLOW_DIRTY=1 for a non-publishing run" >&2 + exit 1 + fi +fi + +provider_pid="" +cleanup() { + if [ -n "$provider_pid" ]; then + kill "$provider_pid" 2>/dev/null || true + wait "$provider_pid" 2>/dev/null || true + fi + docker rm -f "$container" >/dev/null 2>&1 || true + docker volume rm "$volume" >/dev/null 2>&1 || true + if [ "$remove_zig_cache_volume" -eq 1 ]; then + docker volume rm "$zig_cache_volume" >/dev/null 2>&1 || true + fi + if [ "$remove_zig_pkg_volume" -eq 1 ]; then + docker volume rm "$zig_pkg_volume" >/dev/null 2>&1 || true + fi + docker image rm "$image" >/dev/null 2>&1 || true + docker image rm "$builder_image" >/dev/null 2>&1 || true + rm -rf "$demo_root" +} +trap cleanup EXIT + +record() { + printf '%s\n' "$*" | tee -a "$transcript" +} + +mint_enrollment_token() { + printf 'header = "Authorization: Bearer %s"\nheader = "X-Stack-Refresh-Token: %s"\n' \ + "$CMUX_BROKER_ACCESS_TOKEN" "$CMUX_BROKER_REFRESH_TOKEN" \ + | curl --config - --fail --silent --show-error \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{}' \ + "$CMUX_BROKER_URL/api/devices/iroh/enrollment-tokens" \ + | jq --exit-status --raw-output '.token' +} + +wait_for_file() { + local path="$1" + local _ + for _ in $(seq 1 240); do + if [ -S "$path" ] || [ -f "$path" ]; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +wait_for_container_ready_count() { + local expected="$1" + local _ count + for _ in $(seq 1 240); do + count="$(docker logs "$container" 2>&1 | grep -c 'server ready' || true)" + if [ "$count" -ge "$expected" ]; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +mkdir -p \ + "$EVIDENCE_DIR" \ + "$linux_target" \ + "$linux_cargo_home" \ + "$runtime_context/bin" \ + "$runtime_context/lib" +record "iroh TUI Stage 1 acceptance" +record "broker=$CMUX_BROKER_URL relay_environment=$CMUX_RELAY_ENVIRONMENT" +record "source=$build_commit" +record "build_started=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +docker build \ + --progress plain \ + --file "$REPO_ROOT/scripts/iroh-tui-stage1/Dockerfile" \ + --tag "$builder_image" \ + "$REPO_ROOT" + +docker run --rm "$builder_image" \ + awk '/^nameserver[[:space:]]/ { print "nameserver " $2; exit }' /etc/resolv.conf \ + > "$linux_resolv_conf" +if ! grep -Eq '^nameserver [0-9a-fA-F:.]+$' "$linux_resolv_conf"; then + echo "failed to derive a clean Docker resolver configuration" >&2 + exit 1 +fi + +docker volume create "$zig_cache_volume" >/dev/null +docker volume create "$zig_pkg_volume" >/dev/null +docker run --rm \ + --env "CARGO_HOME=/build/cargo-home" \ + --env "CARGO_PROFILE_RELEASE_LTO=false" \ + --env "CARGO_TARGET_DIR=/build/target" \ + --env "CMUX_TUI_BUILD_COMMIT=$build_commit" \ + --env "ZIG_GLOBAL_CACHE_DIR=/build/zig-cache/global" \ + --env "ZIG_LOCAL_CACHE_DIR=/build/zig-cache/local" \ + --mount "type=bind,source=$REPO_ROOT,target=/source,readonly" \ + --mount "type=bind,source=$linux_cargo_home,target=/build/cargo-home" \ + --mount "type=bind,source=$linux_resolv_conf,target=/etc/resolv.conf,readonly" \ + --mount "type=bind,source=$linux_target,target=/build/target" \ + --mount "type=volume,source=$zig_cache_volume,target=/build/zig-cache" \ + --mount "type=volume,source=$zig_pkg_volume,target=/source/ghostty/zig-pkg" \ + --workdir /source \ + "$builder_image" \ + cargo build \ + --manifest-path /source/cmux-tui/Cargo.toml \ + --locked \ + --release \ + -p cmux-tui \ + -p cmux-tui-iroh + +cp "$linux_target/release/cmux-tui" "$runtime_context/bin/" +cp "$linux_target/release/cmux-tui-iroh" "$runtime_context/bin/" +find "$linux_target/release/build" -path '*/out/ghostty-vt/lib/libghostty-vt.so*' \ + -exec cp {} "$runtime_context/lib/" \; +cp "$REPO_ROOT/scripts/iroh-tui-stage1/container-entrypoint.sh" \ + "$runtime_context/container-entrypoint.sh" + +docker build \ + --progress plain \ + --file "$REPO_ROOT/scripts/iroh-tui-stage1/Dockerfile.runtime" \ + --build-arg "CMUX_TUI_BUILD_COMMIT=$build_commit" \ + --tag "$image" \ + "$runtime_context" + +cargo build \ + --manifest-path "$REPO_ROOT/cmux-tui/Cargo.toml" \ + --target-dir "$host_target" \ + --locked \ + -p cmux-tui \ + -p cmux-tui-iroh + +server_token="$(mint_enrollment_token)" +(umask 022; printf '%s\n' "$server_token" > "$server_token_file") +unset server_token +provider_token="$(mint_enrollment_token)" +unset CMUX_BROKER_ACCESS_TOKEN CMUX_BROKER_REFRESH_TOKEN +printf '%s\n' "$provider_token" \ + | "$host_binary" enroll \ + --state-root "$demo_root/provider-state" \ + --identity provider \ + --broker "$CMUX_BROKER_URL" \ + --token-stdin \ + | tee -a "$transcript" +unset provider_token + +docker volume create "$volume" >/dev/null +docker_args=( + --detach + --name "$container" + --env "CMUX_BROKER_URL=$CMUX_CONTAINER_BROKER_URL" + --env "CMUX_PROVISIONING_TOKEN_FILE=/run/cmux/provisioning-token" + --env "CMUX_RELAY_ENVIRONMENT=$CMUX_RELAY_ENVIRONMENT" + --mount "type=bind,source=$server_token_file,target=/run/cmux/provisioning-token,readonly" + --mount "type=volume,source=$volume,target=/state" +) +if [ -n "$CMUX_CONTAINER_BROKER_FORWARD" ]; then + docker_args+=( + --add-host host.docker.internal:host-gateway + --env "CMUX_BROKER_FORWARD=$CMUX_CONTAINER_BROKER_FORWARD" + ) +fi +if ! docker run "${docker_args[@]}" "$image" >/dev/null; then + docker logs "$container" >&2 || true + exit 1 +fi + +if ! wait_for_container_ready_count 1; then + docker logs "$container" >&2 || true + exit 1 +fi +chmod 0600 "$server_token_file" +: > "$server_token_file" +first_ready="$(docker logs "$container" 2>&1 | grep 'server ready' | head -n 1)" +server_binding="${first_ready#* binding=}" +server_binding="${server_binding%% *}" +if [[ ! "$server_binding" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]]; then + record "FAIL server binding is invalid" + exit 1 +fi +port_bindings="$(docker inspect --format '{{json .HostConfig.PortBindings}}' "$container")" +published_ports="$(docker port "$container")" +record "container_port_bindings=$port_bindings" +if [ -n "$published_ports" ]; then + record "FAIL published_ports=$published_ports" + exit 1 +fi +record "container_published_ports=none" +record "container_network_mode=$(docker inspect --format '{{.HostConfig.NetworkMode}}' "$container")" +record "first_$first_ready" +identity_before="$(docker exec "$container" cmux-tui-iroh status \ + --state-root /state/transport --identity server)" +record "identity_before_restart $identity_before" + +"$host_binary" provider \ + --state-root "$demo_root/provider-state" \ + --identity provider \ + --broker "$CMUX_BROKER_URL" \ + --relay-environment "$CMUX_RELAY_ENVIRONMENT" \ + --display-name mac-stage1 \ + --socket "$provider_socket" >"$provider_log" 2>&1 & +provider_pid=$! +if ! wait_for_file "$provider_socket"; then + cat "$provider_log" >&2 + exit 1 +fi +grep 'provider ready' "$provider_log" | tee -a "$transcript" + +if ! "$host_binary" probe \ + --socket "$provider_socket" \ + --machine-id "$server_binding" | tee -a "$transcript"; then + cat "$provider_log" >&2 + docker logs "$container" >&2 || true + exit 1 +fi +record "detach_reattach_before_restart=ok" + +docker restart "$container" >/dev/null +if ! wait_for_container_ready_count 2; then + docker logs "$container" >&2 || true + exit 1 +fi +second_ready="$(docker logs "$container" 2>&1 | grep 'server ready' | tail -n 1)" +record "second_$second_ready" +if [ "$first_ready" != "$second_ready" ]; then + record "FAIL server identity or binding changed across restart" + exit 1 +fi +# `status` exposes device, app-instance, tag, and identity-generation, which +# the ready line does not; compare the full identity tuple across the restart. +identity_after="$(docker exec "$container" cmux-tui-iroh status \ + --state-root /state/transport --identity server)" +record "identity_after_restart $identity_after" +if [ "$identity_before" != "$identity_after" ]; then + record "FAIL device, tag, or identity generation changed across restart" + exit 1 +fi +record "container_endpoint_device_tag_and_binding_stable=ok" + +if ! "$host_binary" probe \ + --socket "$provider_socket" \ + --machine-id "$server_binding" | tee -a "$transcript"; then + cat "$provider_log" >&2 + docker logs "$container" >&2 || true + exit 1 +fi +record "reattach_after_restart=ok" +grep -E 'connected machine=.*path=relay' "$provider_log" | tail -n 3 | tee -a "$transcript" +record "acceptance=pass completed=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +cp "$transcript" "$EVIDENCE_DIR/transcript.txt" +record "evidence=$EVIDENCE_DIR/transcript.txt" diff --git a/scripts/iroh-tui-stage1/Dockerfile b/scripts/iroh-tui-stage1/Dockerfile new file mode 100644 index 000000000000..da8d48adf189 --- /dev/null +++ b/scripts/iroh-tui-stage1/Dockerfile @@ -0,0 +1,16 @@ +FROM rust:1.91-bookworm AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential clang cmake curl libssl-dev minisign pkg-config python3 xz-utils \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /toolchain +COPY scripts/install-zig-ci.sh scripts/ghostty-zig-version.sh ./scripts/ +COPY ghostty/build.zig.zon ./ghostty/build.zig.zon +RUN ZIG_FORCE_LOCAL_INSTALL=1 ZIG_INSTALL_ROOT=/opt/zig ./scripts/install-zig-ci.sh \ + && zig_version="$(bash ./scripts/ghostty-zig-version.sh)" \ + && ln -s "/opt/zig/zig-$(uname -m)-linux-${zig_version}/zig" /usr/local/bin/zig \ + && /usr/local/bin/zig version +ENV ZIG=/usr/local/bin/zig +WORKDIR /source diff --git a/scripts/iroh-tui-stage1/Dockerfile.dockerignore b/scripts/iroh-tui-stage1/Dockerfile.dockerignore new file mode 100644 index 000000000000..671ea64fba01 --- /dev/null +++ b/scripts/iroh-tui-stage1/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +** +!ghostty/ +!ghostty/build.zig.zon +!scripts/ +!scripts/ghostty-zig-version.sh +!scripts/install-zig-ci.sh diff --git a/scripts/iroh-tui-stage1/Dockerfile.runtime b/scripts/iroh-tui-stage1/Dockerfile.runtime new file mode 100644 index 000000000000..39a9e8728484 --- /dev/null +++ b/scripts/iroh-tui-stage1/Dockerfile.runtime @@ -0,0 +1,22 @@ +FROM debian:bookworm-slim + +ARG CMUX_TUI_BUILD_COMMIT=iroh-stage1-sol +LABEL dev.cmux.purpose="iroh-tui-stage1-sol" \ + dev.cmux.build-commit="${CMUX_TUI_BUILD_COMMIT}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates socat \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 1000 --shell /bin/sh cmux \ + && mkdir -p /state /run/cmux \ + && chown -R cmux:cmux /state /run/cmux + +COPY bin/ /usr/local/bin/ +COPY lib/ /usr/local/lib/ +COPY container-entrypoint.sh /usr/local/bin/container-entrypoint.sh +RUN chmod 0755 /usr/local/bin/container-entrypoint.sh \ + && ldconfig + +USER cmux +VOLUME ["/state"] +ENTRYPOINT ["/usr/local/bin/container-entrypoint.sh"] diff --git a/scripts/iroh-tui-stage1/container-entrypoint.sh b/scripts/iroh-tui-stage1/container-entrypoint.sh new file mode 100755 index 000000000000..e0b4558bca40 --- /dev/null +++ b/scripts/iroh-tui-stage1/container-entrypoint.sh @@ -0,0 +1,113 @@ +#!/bin/sh +set -eu + +: "${CMUX_BROKER_URL:?CMUX_BROKER_URL is required}" +CMUX_RELAY_ENVIRONMENT="${CMUX_RELAY_ENVIRONMENT:-production}" +CMUX_SESSION_SOCKET="/run/cmux/stage1.sock" +CMUX_SESSION_STATE="/state/session" +CMUX_IROH_STATE="/state/transport" +CMUX_PROVISIONING_TOKEN_FILE="${CMUX_PROVISIONING_TOKEN_FILE:-}" + +if [ -n "${CMUX_BROKER_FORWARD:-}" ]; then + case "$CMUX_BROKER_FORWARD" in + *[!A-Za-z0-9.:-]*) + echo "cmux-tui-iroh: invalid development broker forward" >&2 + exit 1 + ;; + esac + socat \ + TCP4-LISTEN:43100,bind=127.0.0.1,fork,reuseaddr \ + "TCP:$CMUX_BROKER_FORWARD" & +fi + +if [ ! -f "$CMUX_IROH_STATE/iroh-tui/server/credential.json" ]; then + if [ -n "$CMUX_PROVISIONING_TOKEN_FILE" ]; then + if [ ! -r "$CMUX_PROVISIONING_TOKEN_FILE" ]; then + echo "cmux-tui-iroh: provisioning token file is not readable" >&2 + exit 1 + fi + IFS= read -r provisioning_token < "$CMUX_PROVISIONING_TOKEN_FILE" + else + IFS= read -r provisioning_token + fi + if [ -z "$provisioning_token" ]; then + echo "cmux-tui-iroh: initial provisioning token is required" >&2 + exit 1 + fi + printf '%s\n' "$provisioning_token" \ + | cmux-tui-iroh enroll \ + --state-root "$CMUX_IROH_STATE" \ + --identity server \ + --broker "$CMUX_BROKER_URL" \ + --token-stdin + unset provisioning_token +fi + +cmux-tui \ + --headless \ + --session iroh-stage1 \ + --socket "$CMUX_SESSION_SOCKET" \ + --state "$CMUX_SESSION_STATE" & +session_pid=$! + +server_pid="" +watcher_pid="" + +# The server runs as a supervised child (never exec) so these traps stay +# alive to stop and reap both processes on container shutdown. +# shellcheck disable=SC2329 # invoked via trap +cleanup() { + if [ -n "$watcher_pid" ]; then + kill "$watcher_pid" 2>/dev/null || true + fi + if [ -n "$server_pid" ]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + kill "$session_pid" 2>/dev/null || true + wait "$session_pid" 2>/dev/null || true +} +trap 'cleanup; exit 143' TERM +trap 'cleanup; exit 130' INT +trap cleanup EXIT + +# Readiness: wait for the session socket, fail immediately if the session +# process exits first, and bound the wait with a configurable deadline. +ready_timeout="${CMUX_SESSION_READY_TIMEOUT_SECONDS:-10}" +attempt=0 +attempts=$((ready_timeout * 20)) +while [ ! -S "$CMUX_SESSION_SOCKET" ]; do + if ! kill -0 "$session_pid" 2>/dev/null; then + echo "cmux-tui-iroh: session process exited before its socket became ready" >&2 + exit 1 + fi + attempt=$((attempt + 1)) + if [ "$attempt" -ge "$attempts" ]; then + echo "cmux-tui-iroh: session socket did not become ready within ${ready_timeout}s" >&2 + exit 1 + fi + sleep 0.05 +done + +cmux-tui-iroh server \ + --state-root "$CMUX_IROH_STATE" \ + --identity server \ + --broker "$CMUX_BROKER_URL" \ + --relay-environment "$CMUX_RELAY_ENVIRONMENT" \ + --display-name docker-stage1 \ + --session-socket "$CMUX_SESSION_SOCKET" & +server_pid=$! + +# Stop the server if the session dies so the container exits instead of +# serving a dead session socket. +( + while kill -0 "$session_pid" 2>/dev/null && kill -0 "$server_pid" 2>/dev/null; do + sleep 1 + done + kill "$server_pid" 2>/dev/null || true +) & +watcher_pid=$! + +server_status=0 +wait "$server_pid" || server_status=$? +exit "$server_status"