diff --git a/Cargo.lock b/Cargo.lock index f44b6fcd4..38e2487fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,33 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -118,12 +145,57 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -147,6 +219,17 @@ dependencies = [ "syn", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atoi" version = "2.0.0" @@ -259,7 +342,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -308,6 +391,12 @@ dependencies = [ "fastrand", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base58ck" version = "0.1.0" @@ -347,6 +436,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin" version = "0.32.9" @@ -410,6 +508,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -651,6 +763,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -748,6 +869,41 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -820,6 +976,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.16.0" @@ -901,6 +1063,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -910,14 +1081,67 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] @@ -933,13 +1157,24 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn", ] @@ -964,6 +1199,26 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -1006,6 +1261,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1016,6 +1285,66 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" @@ -1079,32 +1408,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "either" -version = "1.15.0" +name = "ed25519" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "serde", + "serdect", + "signature 3.0.0", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "ed25519-dalek" +version = "3.0.0-pre.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "subtle", + "zeroize", +] [[package]] -name = "errno" -version = "0.3.14" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ - "libc", - "windows-sys 0.61.2", + "serde", ] [[package]] -name = "etcetera" +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" @@ -1157,6 +1534,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1181,7 +1564,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1232,6 +1615,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1276,6 +1672,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1375,11 +1784,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1484,6 +1905,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1527,6 +1953,83 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni", + "rand 0.10.1", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -1803,6 +2306,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -1917,11 +2426,168 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh-base" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2160a45265eba3bd290ce698f584c9b088bee47e518e9ec4460d5e5888ef660e" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "digest 0.11.3", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "sha2 0.11.0", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8b6d2946350d398c9d2d795bb99b04f22e8414c8a8ad9c5c3c0c5b7899af9a4" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "rand 0.10.1", + "reqwest 0.13.3", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "reqwest 0.13.3", + "rustls", + "rustls-platform-verifier", + "ryu", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c8e0c97f1dc787107f388433c349397c565572fe6406d600ff7bb7b7fe3b30" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f490405e42dd2ecf16be18a3587d2665401e94a498094f12322eaa6d5ebb2b" +dependencies = [ + "ahash", + "blake3", + "bytes", + "cfg_aliases", + "clap", + "dashmap", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "rcgen", + "reloadable-state", + "reqwest 0.13.3", + "rustls", + "rustls-cert-file-reader", + "rustls-cert-reloadable-resolver", + "rustls-pki-types", + "serde", + "serde_bytes", + "serde_json", + "sha1 0.11.0", + "simdutf8", + "strum", + "time", + "tokio", + "tokio-rustls", + "tokio-rustls-acme", + "tokio-util", + "tokio-websockets", + "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-subscriber", + "url", + "vergen-gitcl", + "webpki-roots 1.0.7", + "ws_stream_wasm", +] [[package]] name = "is_terminal_polyfill" @@ -2021,7 +2687,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2115,6 +2781,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2244,6 +2919,12 @@ dependencies = [ "rxml", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2306,6 +2987,54 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "n0-error" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "negentropy" version = "0.3.1" @@ -2330,6 +3059,79 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22739e0831e40f5ab7d6ac5317ed80bfe5fb3f44be57d23fa2eea8bff83fb303" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cee32450cf726b223ac4154003c93cb52fbde159ab1240990e88945bf3ae35e" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78633d1fe1bde91d12bcabb230ac9edb890857414c6d44f3212e0d309525b5ff" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.3", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "nostr" version = "0.36.0" @@ -2464,6 +3266,37 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2483,11 +3316,24 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2573,6 +3419,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2588,6 +3444,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.11.3" @@ -2630,6 +3496,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2693,11 +3579,50 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "potential_utf" @@ -2723,6 +3648,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2733,6 +3669,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2946,6 +3891,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -2973,6 +3927,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redis" version = "0.27.6" @@ -3054,6 +4022,23 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reloadable-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0" + +[[package]] +name = "reloadable-state" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8" +dependencies = [ + "arc-swap", + "reloadable-core", + "tokio", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -3132,6 +4117,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -3176,7 +4167,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "serde_json", @@ -3197,7 +4188,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "spki", "subtle", "zeroize", @@ -3263,33 +4254,76 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-cert-file-reader" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff" +dependencies = [ + "rustls-cert-read", + "rustls-pki-types", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "rustls-cert-read" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "rustls-pki-types", ] [[package]] -name = "rustls" -version = "0.23.40" +name = "rustls-cert-reloadable-resolver" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8" dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "futures-util", + "reloadable-state", + "rustls", + "rustls-cert-read", + "thiserror 2.0.18", ] [[package]] @@ -3320,7 +4354,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3509,7 +4543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3531,6 +4565,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -3541,6 +4581,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3631,6 +4681,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3642,6 +4702,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -3705,6 +4776,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3733,6 +4810,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3780,6 +4866,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "spin" version = "0.9.8" @@ -3789,6 +4892,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -3833,7 +4942,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "tokio-util", - "toml", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "url", @@ -4156,6 +5265,7 @@ dependencies = [ "hex", "hmac 0.13.0", "infer", + "iroh-relay", "metrics", "metrics-exporter-prometheus", "moka", @@ -4387,7 +5497,7 @@ dependencies = [ "rand 0.8.6", "rsa", "serde", - "sha1", + "sha1 0.10.6", "sha2 0.10.9", "smallvec", "sqlx-core", @@ -4492,6 +5602,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4543,6 +5674,27 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -4619,7 +5771,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4713,6 +5867,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls-acme" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "log", + "num-bigint", + "pem", + "proc-macro2", + "rcgen", + "reqwest 0.13.3", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "webpki-roots 1.0.7", + "x509-parser", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4722,6 +5904,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -4754,6 +5937,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -4763,12 +5969,27 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -4778,6 +5999,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -4945,7 +6187,7 @@ dependencies = [ "rand 0.9.4", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", ] @@ -4988,6 +6230,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5065,6 +6313,43 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5285,6 +6570,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5461,6 +6752,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5751,6 +7053,9 @@ name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -5852,6 +7157,53 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 54f885e30..1e6554898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ resolver = "2" [workspace.package] version = "0.1.0" edition = "2021" -rust-version = "1.88.0" +rust-version = "1.91.0" license = "Apache-2.0" repository = "https://github.com/sprout-rs/sprout" diff --git a/README.md b/README.md index bc89abffe..e1c290fa1 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Agents are colleagues, not haunted cron jobs. ## Quick start -You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`). +You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.91+, Node 24+, pnpm 10+, `just`). **Once:** ```bash diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 30ff51ba7..e9ec34f2d 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -23,6 +23,8 @@ pub mod error; pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). pub mod nip98; +/// Canonical URL builder for NIP-98 `u`-tag signing/verification. +pub mod nip98_url; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -32,6 +34,7 @@ pub use access::{check_read_access, check_write_access, require_scope, ChannelAc pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; +pub use nip98_url::{nip98_canonical_url, nip98_canonicalize}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; diff --git a/crates/sprout-auth/src/nip98_url.rs b/crates/sprout-auth/src/nip98_url.rs new file mode 100644 index 000000000..dd0d48c3b --- /dev/null +++ b/crates/sprout-auth/src/nip98_url.rs @@ -0,0 +1,234 @@ +//! Canonical URL builder for NIP-98 `u`-tag signing and verification. +//! +//! Both the **signer** (e.g. desktop iroh-relay bearer-token producer) and the +//! **verifier** (e.g. the iroh-relay `AccessConfig::Restricted` callback) must +//! compute the same canonical URL string. Any drift between the two sides +//! produces a `URL mismatch` rejection on every single connection, which is +//! the canonical NIP-98 deploy bug. +//! +//! This module centralises the canonicalisation rules so they cannot drift: +//! +//! 1. Scheme/host are lowercased by [`url::Url`]. +//! 2. `localhost` and `::1` collapse to `127.0.0.1` (so dev signers that bind +//! `[::]` and verifiers that see `127.0.0.1` agree). +//! 3. Query and fragment are stripped (NIP-98 signs the URL "path identity", +//! not transient query parameters). +//! 4. Trailing slashes on the path are collapsed to a single canonical form. +//! 5. Path-prefix joins are suffix-aware: `base=https://h/iroh` joined with +//! `path=/relay` yields `https://h/iroh/relay`, NOT `https://h/relay` +//! (which is what [`url::Url::join`] would produce). +//! +//! The single canonical-string format is consumed by both +//! [`crate::verify_nip98_event`] and external signers; the round-trip test +//! pins them together. + +use url::Url; + +/// Build the canonical NIP-98 `u`-tag value for a request, joining a base URL +/// with a (potentially absolute) path while preserving any base path prefix. +/// +/// Returns `None` if `base` is not a parseable URL. +/// +/// # Examples +/// +/// Plain join: +/// +/// ``` +/// use sprout_auth::nip98_canonical_url; +/// assert_eq!( +/// nip98_canonical_url("https://relay.example.com", "/iroh/relay").as_deref(), +/// Some("https://relay.example.com/iroh/relay"), +/// ); +/// ``` +/// +/// Path-prefix preservation (the typical reverse-proxy case): +/// +/// ``` +/// use sprout_auth::nip98_canonical_url; +/// assert_eq!( +/// nip98_canonical_url("https://relay.example.com/iroh", "/relay").as_deref(), +/// Some("https://relay.example.com/iroh/relay"), +/// ); +/// ``` +pub fn nip98_canonical_url(base: &str, path: &str) -> Option { + let mut parsed = Url::parse(base).ok()?; + + // localhost collapse — `Url::host_str()` returns the canonicalised host + // string, which for IPv6 omits brackets (`"::1"`) but for v4-mapped or + // alternate IPv6 spellings may yield different forms. We compare against + // the parsed `Host` enum where available to catch all loopback shapes. + let is_loopback = match parsed.host() { + Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv6(addr)) => addr.is_loopback(), + Some(url::Host::Ipv4(addr)) => addr.is_loopback(), + None => false, + }; + if is_loopback { + parsed.set_host(Some("127.0.0.1")).ok()?; + } + + // Suffix-join: append `path` to the base's path, keeping the prefix. + let base_path = parsed.path().trim_end_matches('/').to_string(); + let suffix = path.trim_start_matches('/'); + let joined = if suffix.is_empty() { + base_path + } else if base_path.is_empty() { + format!("/{suffix}") + } else { + format!("{base_path}/{suffix}") + }; + let collapsed = joined.trim_end_matches('/').to_string(); + let final_path = if collapsed.is_empty() { + "/".to_string() + } else { + collapsed + }; + parsed.set_path(&final_path); + + // Strip query + fragment — NIP-98 signs path identity, not transient args. + parsed.set_query(None); + parsed.set_fragment(None); + + Some(parsed.to_string()) +} + +/// Build a canonical NIP-98 `u`-tag value from a fully-qualified URL. +/// +/// Useful on the verifier side, where the caller has already reconstructed +/// the full request URL (e.g. from `X-Forwarded-Proto` + `Host` + path) and +/// only needs canonicalisation. +pub fn nip98_canonicalize(url: &str) -> Option { + nip98_canonical_url(url, "") +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + #[test] + fn plain_join_no_base_path() { + assert_eq!( + nip98_canonical_url("https://relay.example.com", "/iroh/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn suffix_join_preserves_base_path_prefix() { + // The classic Plan v4 deploy bug: signer reads `iroh_relay_url` from + // NIP-11 as `https://host/iroh` and joins path `/relay`. `Url::join` + // would discard the `/iroh` prefix; the canonical helper must keep it. + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh", "/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn trailing_slash_on_base_collapsed() { + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh/", "/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn trailing_slash_on_path_collapsed() { + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh", "/relay/").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn localhost_collapses_to_loopback() { + assert_eq!( + nip98_canonical_url("http://localhost:3000", "/iroh/relay").as_deref(), + Some("http://127.0.0.1:3000/iroh/relay"), + ); + } + + #[test] + fn ipv6_loopback_collapses_to_loopback() { + assert_eq!( + nip98_canonical_url("http://[::1]:3000", "/iroh/relay").as_deref(), + Some("http://127.0.0.1:3000/iroh/relay"), + ); + } + + #[test] + fn explicit_port_is_preserved() { + assert_eq!( + nip98_canonical_url("https://relay.example.com:8443", "/iroh/relay").as_deref(), + Some("https://relay.example.com:8443/iroh/relay"), + ); + } + + #[test] + fn query_and_fragment_stripped() { + assert_eq!( + nip98_canonical_url("https://relay.example.com/iroh?foo=bar#x", "/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn scheme_and_host_lowercased() { + assert_eq!( + nip98_canonical_url("HTTPS://Relay.Example.COM", "/iroh/relay").as_deref(), + Some("https://relay.example.com/iroh/relay"), + ); + } + + #[test] + fn empty_path_yields_root() { + assert_eq!( + nip98_canonical_url("https://relay.example.com", "").as_deref(), + Some("https://relay.example.com/"), + ); + } + + #[test] + fn invalid_base_returns_none() { + assert!(nip98_canonical_url("not a url", "/iroh/relay").is_none()); + } + + #[test] + fn canonicalize_full_url_round_trip() { + let canonical = nip98_canonicalize("https://relay.example.com:8443/iroh/relay?x=1#y") + .expect("canonicalize must succeed"); + assert_eq!(canonical, "https://relay.example.com:8443/iroh/relay"); + } + + /// **Critical round-trip test:** signs an event using the canonical helper, + /// then verifies it through [`crate::verify_nip98_event`]. If they drift, + /// every connection in production deny-loops. + #[test] + fn round_trip_with_verify_nip98_event() { + let keys = Keys::generate(); + let canonical = nip98_canonical_url("https://relay.example.com/iroh", "/relay").unwrap(); + + let event = EventBuilder::new( + Kind::HttpAuth, + "", + vec![ + Tag::parse(&["u", &canonical]).unwrap(), + Tag::parse(&["method", "GET"]).unwrap(), + ], + ) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .unwrap(); + let json = serde_json::to_string(&event).unwrap(); + + // Verifier reconstructs the exact same canonical URL — different inputs, + // same string. + let verifier_url = + nip98_canonical_url("https://relay.example.com/iroh/", "/relay/").unwrap(); + + let result = crate::verify_nip98_event(&json, &verifier_url, "GET", None); + assert!(result.is_ok(), "round-trip verify failed: {:?}", result); + assert_eq!(result.unwrap(), keys.public_key()); + } +} diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 3ac6bcec1..1c8db284b 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -111,6 +111,16 @@ pub const KIND_NIP29_GROUP_ROLES: u32 = 39003; /// Workflow definition (parameterized replaceable, d=workflow_uuid). pub const KIND_WORKFLOW_DEF: u32 = 30620; +/// Mesh-LLM compute-offer discovery announcement (parameterized replaceable, +/// `d` = mesh node identifier). +/// +/// Published by Sprout members willing to share their local LLM/compute with +/// the rest of the relay. The event content carries the offer envelope +/// (model id, max VRAM/RAM, iroh endpoint id), and is addressable so a member +/// can replace their own offer atomically. Stored globally (`channel_id = NULL`) +/// — relay membership, not channel scope, is the audience. +pub const KIND_MESH_LLM_DISCOVERY: u32 = 31990; + /// Lower bound of the NIP-33 parameterized replaceable range (30000–39999). pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000; /// Upper bound of the NIP-33 parameterized replaceable range (30000–39999). @@ -393,6 +403,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_WORKFLOW_DEF, + KIND_MESH_LLM_DISCOVERY, KIND_LONG_FORM, KIND_USER_STATUS, KIND_READ_STATE, @@ -511,6 +522,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 { // Compile-time: new kinds are in the expected ranges. const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_DISCOVERY)); // 31990 ∈ 30000–39999 // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index a0395a060..cecc3a61a 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod filter; pub mod git_perms; /// Sprout kind number registry — custom event type constants. pub mod kind; +/// Mesh-LLM compute-offer envelope (kind:31990 event content). +pub mod mesh_llm; /// Network utilities — SSRF-safe IP classification. pub mod network; /// Agent observer frame helpers. diff --git a/crates/sprout-core/src/mesh_llm.rs b/crates/sprout-core/src/mesh_llm.rs new file mode 100644 index 000000000..b4293bccc --- /dev/null +++ b/crates/sprout-core/src/mesh_llm.rs @@ -0,0 +1,424 @@ +//! Mesh-LLM compute offer envelope (kind:31990 event content). +//! +//! Published by Sprout members willing to share their local LLM/compute with +//! the rest of the relay. Consumers (other Sprout members) subscribe to +//! kind:31990 events scoped to relay membership and pick an offer that +//! matches their request. +//! +//! # Schema +//! +//! The event content is a JSON-serialised [`MeshLlmOffer`]. The event itself +//! is a NIP-33 parameterized-replaceable event addressed by +//! `(pubkey, kind:31990, d_tag)` where `d_tag` is the [`MeshLlmOffer::d_tag`]. +//! This means a member can replace their own offer atomically (e.g. when the +//! VRAM cap changes or a model is loaded/unloaded) without leaking dangling +//! stale offers. +//! +//! # Trust model +//! +//! The signing pubkey of the kind:31990 event is the Nostr identity of the +//! offering member; the event flows through the existing NIP-43 fan-out, so +//! only relay members ever see it. The iroh [`endpoint_id`](MeshLlmOffer::endpoint_id) +//! is a separate ed25519 keypair under the same member's control — the +//! Nostr signature on the kind:31990 event is what binds those two +//! identities together. +//! +//! When a consumer connects to the offered iroh endpoint, the consumer's own +//! NIP-98 bearer (signed with its Nostr key, NOT its iroh key) is what the +//! receiving relay uses to gate admission. So the chain of trust is: +//! +//! - The 31990 event proves "Nostr pubkey N offers compute via iroh endpoint E". +//! - The NIP-98 bearer on the iroh connection proves "Nostr pubkey N' is the +//! connecting party". +//! - Sprout's [`check_relay_membership`] confirms N' is a relay member. +//! +//! There is no need to also bind N' ↔ iroh-client-endpoint cryptographically: +//! once the membership decision allows the connection, the QUIC stream itself +//! is end-to-end-encrypted between the two iroh endpoints. The offering side +//! sees only `(member-pubkey N', iroh-endpoint E')`, both authenticated. + +use serde::{Deserialize, Serialize}; + +/// The full content of a kind:31990 event. +/// +/// Serialized to JSON and placed in the event's `content` field. The event's +/// `d` tag should equal [`MeshLlmOffer::d_tag`] so the event is a stable +/// addressable replacement target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MeshLlmOffer { + /// Schema version. Bumped on breaking changes. Current: `1`. + pub v: u32, + + /// Stable identifier for *this offering node* under the publisher's + /// pubkey. A member may publish multiple offers (e.g. one per host they + /// own, or one per GPU); each gets a distinct `d_tag`. + /// + /// MUST be ≤64 chars, ASCII alphanumeric + `-` + `_`. The same value + /// must be used as the kind:31990 event's `d` tag so replaces are + /// atomic. + pub d_tag: String, + + /// Iroh endpoint id (ed25519 public key, base32 z-base form as iroh + /// renders it) of the offering node's iroh endpoint. Consumers dial + /// this through an iroh `EndpointAddr` constructed from + /// `(endpoint_id, iroh_relay_url)`. + pub endpoint_id: String, + + /// Iroh relay URL through which the offering endpoint is reachable. + /// + /// This is the *Sprout-hosted* iroh-relay URL — copied verbatim from + /// the publisher's view of NIP-11 `iroh_relay_url`. The field is + /// preserved for future cross-relay bridging, but **v1 consumers MUST + /// ignore offers whose `iroh_relay_url` doesn't match the current + /// relay's NIP-11 `iroh_relay_url`** (see [`Self::matches_local_relay`]). + /// This keeps "one relay = one mesh boundary" as an enforced invariant + /// until cross-relay membership is explicitly designed. + pub iroh_relay_url: String, + + /// Unix-seconds timestamp at which this offer becomes stale. Consumers + /// MUST ignore offers where `expires_at <= now` (publishers SHOULD + /// republish a fresh offer well before this deadline to act as a + /// heartbeat). Because crashed publishers cannot send the NIP-33 + /// delete-by-replace tombstone, this TTL is the only thing that + /// removes their offers from the consumer view. + pub expires_at: u64, + + /// Resource caps the offering side promises to honour for any single + /// consumer at a time. The publisher should re-publish (replacing the + /// previous event) whenever these change materially. **These are + /// claims/UI hints, not authority** — the provider runtime must + /// enforce its own caps locally; the consumer cannot rely on the + /// publisher to honour them at admission time. + pub caps: ResourceCaps, + + /// Models this node is willing to serve. Empty list = "negotiate at + /// connect time"; non-empty = the consumer should pick one of these. + #[serde(default)] + pub models: Vec, + + /// Free-form opaque metadata field, reserved for future extensions + /// (e.g. region, accelerator type, presence-style state). + /// + /// Stored as `serde_json::Value` so additions don't require a schema + /// bump. `deny_unknown_fields` above keeps the *top-level* schema + /// strict; freeform extension lives here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +/// Resource caps the offering side commits to for a single consumer. +/// +/// Caps are *per-consumer* upper bounds — the offering side may host +/// multiple concurrent consumers, each subject to these caps. The +/// `max_concurrency` field expresses how many concurrent consumers the node +/// will accept across all consumers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceCaps { + /// Max VRAM (megabytes) the offering side will commit to a single + /// request. `None` = no cap advertised (consumer decides whether to + /// proceed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_vram_mb: Option, + + /// Max system RAM (megabytes) the offering side will commit to a + /// single request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_ram_mb: Option, + + /// Max number of concurrent consumers the offering node will accept + /// across all currently-running requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, +} + +/// A single model the offering node is prepared to serve. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ModelOffer { + /// Model identifier. Convention: HuggingFace-style `org/name[:tag]`, + /// or `local:` for ad-hoc local files. Free-form string; + /// the consumer side is responsible for matching this against its own + /// requested model. + pub id: String, + + /// Optional human-readable label for UI surfaces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + + /// Approximate context window this model serves (tokens). Used for + /// UI hints; not enforced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_tokens: Option, +} + +impl MeshLlmOffer { + /// Maximum length of a `d_tag` string. Mirrors NIP-33's general rule + /// that `d` tags should be short and stable. + pub const MAX_D_TAG_LEN: usize = 64; + + /// Validate that a `d_tag` is well-formed: ≤64 chars, ASCII + /// alphanumeric / `-` / `_`. + pub fn is_valid_d_tag(d_tag: &str) -> bool { + !d_tag.is_empty() + && d_tag.len() <= Self::MAX_D_TAG_LEN + && d_tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + } + + /// Returns true if every required field is well-formed for publishing. + /// + /// This is a *publisher-side* sanity check; consumers should be + /// permissive in what they accept as long as serde-deserialization + /// succeeds (modulo the [`Self::is_expired`] and + /// [`Self::matches_local_relay`] filters below). + pub fn is_publishable(&self) -> bool { + self.v == 1 + && Self::is_valid_d_tag(&self.d_tag) + && !self.endpoint_id.is_empty() + && !self.iroh_relay_url.is_empty() + && self.expires_at > 0 + } + + /// Consumer-side TTL check. Returns `true` when `expires_at <= now`, + /// in which case the offer must be ignored (crashed publishers can't + /// send a delete-by-replace tombstone; the TTL is the only reaper). + /// + /// `now` is unix-seconds — callers in this crate pass `Timestamp::now()` + /// or a test clock to avoid pulling `std::time::SystemTime` into the + /// trust path. + pub fn is_expired(&self, now: u64) -> bool { + self.expires_at <= now + } + + /// Consumer-side same-relay filter for v1 discovery. Returns `true` + /// when the offer's advertised `iroh_relay_url` matches `current_relay` + /// after canonicalisation (lower-case scheme/host, trailing slash on + /// path collapsed, query/fragment dropped). + /// + /// **v1 consumers MUST ignore offers where this returns `false`.** The + /// invariant is "one relay = one mesh boundary"; cross-relay bridging + /// is reserved for a future explicit design. + pub fn matches_local_relay(&self, current_relay: &str) -> bool { + canonical_relay_url(&self.iroh_relay_url) == canonical_relay_url(current_relay) + } +} + +/// Lightweight URL canonicaliser used only for the same-relay filter. Not +/// to be confused with [`sprout_auth::nip98_canonical_url`], which has a +/// different job (computing the `u`-tag value); this one just strips +/// query/fragment, lower-cases scheme/host, and collapses one trailing +/// slash so users who paste `https://r.example.com/iroh/` see it match +/// `https://r.example.com/iroh`. +fn canonical_relay_url(raw: &str) -> String { + let trimmed = raw.trim(); + // Split off query + fragment. Compose the splits in two steps so the + // second split operates on the result of the first, not on `trimmed`. + let no_query = match trimmed.split_once('?') { + Some((h, _)) => h, + None => trimmed, + }; + let no_frag = match no_query.split_once('#') { + Some((h, _)) => h, + None => no_query, + }; + let head = if let Some(stripped) = no_frag.strip_suffix('/') { + // Only strip *one* trailing slash — don't collapse repeated + // slashes (those are real path components). + stripped + } else { + no_frag + }; + + // Lower-case the scheme+authority portion; leave the path case-sensitive. + if let Some(idx) = head.find("://") { + let (scheme, rest) = head.split_at(idx); + // rest starts with "://"; authority ends at next '/'. + let after_proto = &rest[3..]; + let (authority, path) = match after_proto.find('/') { + Some(p) => after_proto.split_at(p), + None => (after_proto, ""), + }; + format!( + "{}://{}{}", + scheme.to_ascii_lowercase(), + authority.to_ascii_lowercase(), + path, + ) + } else { + head.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> MeshLlmOffer { + MeshLlmOffer { + v: 1, + d_tag: "node-1".to_string(), + endpoint_id: "1234abcd".to_string(), + iroh_relay_url: "https://relay.example.com/iroh".to_string(), + expires_at: 2_000_000_000, // far-future fixture + caps: ResourceCaps { + max_vram_mb: Some(24_000), + max_ram_mb: Some(64_000), + max_concurrency: Some(2), + }, + models: vec![ModelOffer { + id: "meta-llama/Llama-3-8B".to_string(), + label: Some("Llama 3 8B".to_string()), + context_tokens: Some(8192), + }], + extra: None, + } + } + + #[test] + fn round_trip_via_json() { + let offer = sample(); + let s = serde_json::to_string(&offer).expect("serialise"); + let back: MeshLlmOffer = serde_json::from_str(&s).expect("deserialise"); + assert_eq!(offer, back); + } + + #[test] + fn optional_caps_default_to_none() { + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r/", + "expires_at": 2000000000, + "caps": {} + }"#; + let offer: MeshLlmOffer = serde_json::from_str(s).expect("deserialise minimal"); + assert!(offer.caps.max_vram_mb.is_none()); + assert!(offer.caps.max_ram_mb.is_none()); + assert!(offer.caps.max_concurrency.is_none()); + assert!(offer.models.is_empty()); + } + + #[test] + fn unknown_top_level_field_rejected() { + // deny_unknown_fields catches schema drift. + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "expires_at": 2000000000, + "caps": {}, + "wat": "lol" + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn unknown_caps_field_rejected() { + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "expires_at": 2000000000, + "caps": { "wat": 7 } + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn expires_at_required() { + // expires_at has no serde default; missing it is a hard error + // (consumers depend on TTL for correctness). + let s = r#"{ + "v": 1, + "d_tag": "x", + "endpoint_id": "abc", + "iroh_relay_url": "https://r", + "caps": {} + }"#; + assert!(serde_json::from_str::(s).is_err()); + } + + #[test] + fn is_expired_filter() { + let mut offer = sample(); + offer.expires_at = 1_000; + assert!(offer.is_expired(2_000), "now > expires_at must expire"); + assert!(offer.is_expired(1_000), "now == expires_at must expire"); + assert!(!offer.is_expired(999), "now < expires_at must not expire"); + } + + #[test] + fn matches_local_relay_canonicalises() { + let mut offer = sample(); + offer.iroh_relay_url = "https://relay.example.com/iroh".to_string(); + // exact match + assert!(offer.matches_local_relay("https://relay.example.com/iroh")); + // trailing slash on one side + assert!(offer.matches_local_relay("https://relay.example.com/iroh/")); + // upper-case host + assert!(offer.matches_local_relay("HTTPS://Relay.Example.COM/iroh")); + // query/fragment stripped + assert!(offer.matches_local_relay("https://relay.example.com/iroh?x=1#y")); + // different host -> reject + assert!(!offer.matches_local_relay("https://other.example.com/iroh")); + // different path -> reject (different mesh boundary) + assert!(!offer.matches_local_relay("https://relay.example.com/other")); + } + + #[test] + fn is_publishable_rejects_zero_expires_at() { + let mut offer = sample(); + offer.expires_at = 0; + assert!(!offer.is_publishable()); + } + + #[test] + fn extra_freeform_passes_through() { + let offer = MeshLlmOffer { + extra: Some(serde_json::json!({"region": "us-east", "gpu": "H100"})), + ..sample() + }; + let s = serde_json::to_string(&offer).unwrap(); + let back: MeshLlmOffer = serde_json::from_str(&s).unwrap(); + assert_eq!(offer, back); + } + + #[test] + fn d_tag_validation() { + assert!(MeshLlmOffer::is_valid_d_tag("node-1")); + assert!(MeshLlmOffer::is_valid_d_tag("a")); + assert!(MeshLlmOffer::is_valid_d_tag(&"a".repeat(64))); + assert!(!MeshLlmOffer::is_valid_d_tag("")); + assert!(!MeshLlmOffer::is_valid_d_tag(&"a".repeat(65))); + assert!(!MeshLlmOffer::is_valid_d_tag("node 1")); + assert!(!MeshLlmOffer::is_valid_d_tag("node/1")); + assert!(!MeshLlmOffer::is_valid_d_tag("nodé")); + } + + #[test] + fn is_publishable_rejects_bad_d_tag() { + let mut offer = sample(); + offer.d_tag = "bad tag with spaces".to_string(); + assert!(!offer.is_publishable()); + } + + #[test] + fn is_publishable_rejects_wrong_version() { + let mut offer = sample(); + offer.v = 2; + assert!(!offer.is_publishable()); + } + + #[test] + fn is_publishable_rejects_empty_endpoint() { + let mut offer = sample(); + offer.endpoint_id = String::new(); + assert!(!offer.is_publishable()); + } +} diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index 997e5de47..7f9550236 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -58,9 +58,14 @@ url = { workspace = true } moka = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } +iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] } [features] dev = ["sprout-auth/dev"] +# Enables APIs that only exist on a locally-patched fork of iroh-relay +# (notably the per-client max lifetime hook used by Step 3). Off by default +# so the published rc.0 crate compiles unmodified. +patched-iroh-relay = [] [dev-dependencies] sprout-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index bd799b19a..2e6b44969 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -38,51 +38,65 @@ pub mod relay_members { use crate::state::AppState; - /// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback. + /// Outcome of the transport-neutral membership check. /// - /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but - /// its NIP-OA owner *is* — access is granted via delegation. + /// Distinguishes the four meaningful states without forcing the caller to + /// produce an HTTP response — used by both the HTTP wrapper + /// [`enforce_relay_membership`] and by non-HTTP gates (e.g. the iroh-relay + /// `AccessConfig::Restricted` callback). + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum MembershipDecision { + /// The relay does not enforce membership (`require_relay_membership = false`). + OpenRelay, + /// The caller's pubkey is in `relay_members` directly. + Member, + /// The caller is an agent and its NIP-OA owner is in `relay_members`. + /// The owner pubkey is included so callers can audit or backfill. + ViaOwner(nostr::PublicKey), + /// The caller is not a relay member and no valid NIP-OA delegation applies. + Denied, + } + + /// Transport-neutral relay membership check. /// - /// On open relays (`require_relay_membership = false`), returns `Ok(None)` - /// immediately — no membership check is performed. Callers that need NIP-OA - /// owner extraction on open relays should call [`extract_nip_oa_owner`] directly. + /// Returns a [`MembershipDecision`] without producing an HTTP response. + /// HTTP callers should prefer [`enforce_relay_membership`], which maps + /// `Denied → 403 JSON`. Non-HTTP callers (e.g. iroh-relay access checks) + /// inspect the decision directly. /// - /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when - /// no NIP-OA tag is present/applicable (open relay without auth tag). - pub async fn enforce_relay_membership( + /// `Err` is reserved for **infrastructure failures** (database errors, + /// invalid pubkey bytes) — never for the authorization decision itself. + pub async fn check_relay_membership( state: &AppState, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, - ) -> Result, (StatusCode, Json)> { + ) -> Result { if !state.config.require_relay_membership { - return Ok(None); + return Ok(MembershipDecision::OpenRelay); } let pubkey_hex = hex::encode(pubkey_bytes); - let is_member = state.db.is_relay_member(&pubkey_hex).await.map_err(|e| { - tracing::error!("relay membership check failed: {e}"); - super::internal_error(&format!("relay membership check failed: {e}")) - })?; + let is_member = state + .db + .is_relay_member(&pubkey_hex) + .await + .map_err(|e| format!("relay membership check failed: {e}"))?; if is_member { - return Ok(None); + return Ok(MembershipDecision::Member); } - // NIP-OA fallback: check if agent's owner is a relay member. if state.config.allow_nip_oa_auth { if let Some(tag_json) = auth_tag_header { - let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).map_err(|e| { - super::internal_error(&format!("invalid agent pubkey for NIP-OA check: {e}")) - })?; + let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes) + .map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?; match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { Ok(owner_pubkey) => { let owner_hex = owner_pubkey.to_hex(); let owner_is_member = state.db.is_relay_member(&owner_hex).await.map_err(|e| { - super::internal_error(&format!( - "relay membership check (owner) failed: {e}" - )) + format!("relay membership check (owner) failed: {e}") })?; if owner_is_member { @@ -91,7 +105,7 @@ pub mod relay_members { owner = %owner_hex, "NIP-OA membership granted via owner" ); - return Ok(Some(owner_pubkey)); + return Ok(MembershipDecision::ViaOwner(owner_pubkey)); } } Err(e) => { @@ -101,13 +115,43 @@ pub mod relay_members { } } - Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "relay_membership_required", - "message": "You must be a relay member to access this relay" - })), - )) + Ok(MembershipDecision::Denied) + } + + /// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback. + /// + /// Thin HTTP-layer wrapper around [`check_relay_membership`] that converts + /// `Denied → 403 JSON` and infra errors → 500 envelope. + /// + /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but + /// its NIP-OA owner *is* — access is granted via delegation. + /// + /// On open relays (`require_relay_membership = false`), returns `Ok(None)` + /// immediately — no membership check is performed. Callers that need NIP-OA + /// owner extraction on open relays should call [`extract_nip_oa_owner`] directly. + /// + /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when + /// no NIP-OA tag is present/applicable (open relay without auth tag). + pub async fn enforce_relay_membership( + state: &AppState, + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + ) -> Result, (StatusCode, Json)> { + match check_relay_membership(state, pubkey_bytes, auth_tag_header).await { + Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), + Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), + Ok(MembershipDecision::Denied) => Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "relay_membership_required", + "message": "You must be a relay member to access this relay" + })), + )), + Err(e) => { + tracing::error!("relay membership check errored: {e}"); + Err(super::internal_error(&e)) + } + } } /// Extract NIP-OA owner from an auth tag without membership enforcement. diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 23b2cf4a7..a2cedb07b 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -118,6 +118,31 @@ pub struct Config { /// When set, the relay serves the SPA from this directory for browser requests. /// When unset, no static file serving happens (relay behaves as before). pub web_dir: Option, + + // ── Mesh-LLM iroh-relay advertisement ──────────────────────────────────── + /// Optional publicly-reachable URL of this relay's embedded iroh-relay + /// endpoint, advertised in the NIP-11 document as `iroh_relay_url`. + /// + /// Read by mesh-llm clients (desktop sidecar) so they can connect their + /// iroh endpoints to Sprout's own relay — keeping all mesh-LLM QUIC + /// traffic on the same trust boundary as the Sprout relay membership, + /// with **no out-of-band configuration** required from the user. + /// + /// Absent → NIP-11 omits the field (older clients unaffected). Set via + /// `SPROUT_IROH_RELAY_PUBLIC_URL`. Example: + /// `https://relay.example.com/iroh` (a path prefix is supported and + /// preserved by the NIP-98 canonicaliser). + pub iroh_relay_public_url: Option, + + /// Optional local socket address the embedded iroh-relay binds to. + /// + /// `iroh_relay::server::Server::spawn` owns its own listener, so this is + /// independent of [`Self::bind_addr`] (the Sprout HTTP/WS port). When + /// unset, [`crate::iroh_relay::spawn`] is *not* started by `fn main` — + /// even if `iroh_relay_public_url` is configured, since advertising a + /// URL without a listener would be a deploy footgun. Set via + /// `SPROUT_IROH_RELAY_BIND_ADDR`, e.g. `0.0.0.0:3478`. + pub iroh_relay_bind_addr: Option, } impl Config { @@ -319,6 +344,27 @@ impl Config { let secret: [u8; 32] = rand::random(); hex::encode(secret) }); + // Mesh-LLM iroh-relay advertisement (optional) + let iroh_relay_public_url = std::env::var("SPROUT_IROH_RELAY_PUBLIC_URL") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + // Mesh-LLM iroh-relay local bind address (optional; independent of + // the Sprout HTTP listener since Server::spawn owns its own socket). + let iroh_relay_bind_addr = match std::env::var("SPROUT_IROH_RELAY_BIND_ADDR") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + Some(s) => Some(s.parse::().map_err(|e| { + ConfigError::InvalidValue(format!( + "SPROUT_IROH_RELAY_BIND_ADDR={s:?} is not a valid socket address: {e}" + )) + })?), + None => None, + }; + // Web UI static file serving let web_dir = std::env::var("SPROUT_WEB_DIR") .ok() @@ -378,6 +424,8 @@ impl Config { git_max_concurrent_ops, git_hook_hmac_secret, web_dir, + iroh_relay_public_url, + iroh_relay_bind_addr, }) } } diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 47235e9b7..60360bb53 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -21,9 +21,9 @@ use sprout_core::kind::{ KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, - KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, - KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_DISCOVERY, KIND_NIP29_CREATE_GROUP, + KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, + KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, @@ -215,6 +215,11 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_WORKFLOW_DEF | KIND_WORKFLOW_TRIGGER => Ok(Scope::MessagesWrite), + // Mesh-LLM compute-offer discovery — relay members fan out their compute + // availability to other relay members. Same write scope as regular + // messages; the audience boundary is relay membership (enforced by + // NIP-43), not channel scope. + KIND_MESH_LLM_DISCOVERY => Ok(Scope::MessagesWrite), KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY => Ok(Scope::MessagesWrite), _ => Err("restricted: unknown event kind"), } @@ -322,6 +327,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE | KIND_NIP43_LEAVE_REQUEST + // Mesh-LLM compute-offer discovery is addressed by (pubkey, kind, d_tag) + // and consumed by every relay member; a stray `h` tag must not + // accidentally channel-scope it. + | KIND_MESH_LLM_DISCOVERY ) } @@ -1795,6 +1804,28 @@ mod tests { assert!(is_global_only_kind(KIND_USER_STATUS)); } + #[test] + fn mesh_llm_discovery_requires_messages_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_MESH_LLM_DISCOVERY, &dummy).unwrap(), + Scope::MessagesWrite, + "kind:31990 must require MessagesWrite — relay membership is enforced by the existing NIP-43 gate" + ); + } + + #[test] + fn mesh_llm_discovery_is_global_only() { + // kind:31990 is addressed by (pubkey, kind, d_tag); a stray `h` tag + // must not channel-scope it. + assert!(is_global_only_kind(KIND_MESH_LLM_DISCOVERY)); + } + + #[test] + fn mesh_llm_discovery_does_not_require_h_tag() { + assert!(!requires_h_channel_scope(KIND_MESH_LLM_DISCOVERY)); + } + #[test] fn user_status_does_not_require_h_tag() { assert!(!requires_h_channel_scope(KIND_USER_STATUS)); diff --git a/crates/sprout-relay/src/iroh_relay.rs b/crates/sprout-relay/src/iroh_relay.rs new file mode 100644 index 000000000..50297d2ed --- /dev/null +++ b/crates/sprout-relay/src/iroh_relay.rs @@ -0,0 +1,494 @@ +//! Embedded iroh-relay server, gated by Sprout relay membership. +//! +//! This is the **Step 3** half of the mesh-LLM plan (v6.1). The desktop +//! sidecar connects its iroh endpoint to `iroh_relay_url` advertised in the +//! Sprout NIP-11 document; this module hosts that relay endpoint inside the +//! Sprout process and gates every connection with the same NIP-98 + relay- +//! membership check we already use for HTTP entry points. +//! +//! The result: mesh-LLM QUIC traffic never leaves the relay's trust boundary +//! and **n0's public relays are never in the path**. No subscriptions, no +//! signups, no out-of-band config — relay members get pooled compute "for +//! free" once they install Sprout. +//! +//! # Access flow +//! +//! 1. The iroh client opens a WebSocket to `https:///iroh/relay` +//! carrying `Authorization: Bearer ` and its +//! proven `EndpointId` (proved by iroh-relay's handshake before we run). +//! 2. iroh-relay calls our [`AccessConfig::Restricted`] callback with the +//! [`ClientRequest`]. +//! 3. We verify the NIP-98 event against the canonical relay URL using +//! [`sprout_auth::nip98_canonical_url`] + [`sprout_auth::verify_nip98_event`]. +//! Any failure → `Access::Deny`. This proves the connecting pubkey. +//! 4. We run [`crate::api::relay_members::check_relay_membership`] against +//! that pubkey. Anything other than `OpenRelay`/`Member`/`ViaOwner` → +//! `Access::Deny`. +//! 5. We log the bound (NIP-98 pubkey, EndpointId, decision) for audit. +//! +//! No state is cached: every connection re-runs steps 3 + 4. The cost is one +//! Schnorr verify and 1-2 DB reads per connection, which is negligible +//! versus the QUIC + model traffic that follows. +//! +//! # Patched-fork hooks (forward-looking) +//! +//! Upstream iroh-relay rc.0 does not expose a per-client maximum-lifetime +//! hook. The mesh-LLM plan (v6.1, upstream PR C) will add one so we can +//! force re-auth every N minutes. The `patched-iroh-relay` Cargo feature +//! and the `TODO(patched-iroh-relay)` marker in [`spawn`] are reserved +//! insertion points for that wiring — **the hook is not implemented yet**. +//! Unpatched rc.0 stays compile-clean either way. + +use std::net::SocketAddr; +use std::sync::Arc; + +use iroh_relay::server::{ + Access, AccessConfig, ClientRequest, RelayConfig, Server, ServerConfig, SpawnError, +}; +use tracing::{debug, info, warn}; + +use crate::api::relay_members::{check_relay_membership, MembershipDecision}; +use crate::state::AppState; + +/// Path component appended to `iroh_relay_public_url` for the access check. +/// +/// Iroh's WebSocket upgrade always hits `/relay`; if the relay is reverse- +/// proxied under a path prefix (e.g. `https://host/iroh`), the full canonical +/// URL is `https://host/iroh/relay`. The NIP-98 signer and verifier both +/// compute this via [`sprout_auth::nip98_canonical_url`]. +pub const IROH_RELAY_PATH: &str = "/relay"; + +/// HTTP method bound into NIP-98 events for iroh-relay connection auth. +const NIP98_METHOD: &str = "GET"; + +/// Maximum size of a bearer token (raw, pre-base64-decode) we'll even try to +/// process. A well-formed NIP-98 event JSON is well under a kilobyte; the +/// base64 expansion of that is ~1.4 KiB. We allow up to 64 KiB so generous +/// signers don't trip over `payload` tag hashes etc., but reject anything +/// larger before allocating decode buffers — admission requests must not be +/// able to coerce the relay into multi-megabyte allocations. +const MAX_BEARER_LEN: usize = 64 * 1024; + +/// Handle returned by [`spawn`]. +/// +/// Dropping the handle aborts the iroh-relay supervisor task (via +/// `AbortOnDropHandle` inside `Server`). For graceful drain, prefer +/// [`IrohRelayHandle::shutdown`] which waits for in-flight connections to +/// finish before returning. +pub struct IrohRelayHandle { + /// The bound HTTP address (resolved if the caller passed port 0). + pub http_addr: Option, + /// The bound HTTPS address, if TLS was configured. + pub https_addr: Option, + server: Server, +} + +impl IrohRelayHandle { + /// Request graceful shutdown of the embedded iroh-relay. + /// + /// Returns once all relay tasks have stopped. Used by the Sprout main + /// shutdown loop so SIGTERM stops admitting new mesh-LLM connections + /// alongside the HTTP listener, instead of yanking the socket out from + /// under in-flight QUIC sessions. + pub async fn shutdown(self) -> Result<(), iroh_relay::server::SupervisorError> { + self.server.shutdown().await + } +} + +/// Spawn an embedded iroh-relay bound to `bind_addr`, gated by Sprout's +/// NIP-98 + relay-membership check. +/// +/// Returns `Ok(None)` if `state.config.iroh_relay_public_url` is not set: +/// without a stable public URL the NIP-98 `u`-tag can't be canonicalised, so +/// hosting an iroh-relay endpoint would just produce an undebuggable storm +/// of `URL mismatch` denials. We surface that as "not enabled" instead. +pub async fn spawn( + state: Arc, + bind_addr: SocketAddr, +) -> Result, SpawnError> { + let Some(public_url) = state.config.iroh_relay_public_url.clone() else { + info!("SPROUT_IROH_RELAY_PUBLIC_URL not set — embedded iroh-relay disabled"); + return Ok(None); + }; + + let canonical_url = match sprout_auth::nip98_canonical_url(&public_url, IROH_RELAY_PATH) { + Some(u) => u, + None => { + warn!( + public_url = %public_url, + "SPROUT_IROH_RELAY_PUBLIC_URL is not a parseable URL — iroh-relay disabled", + ); + return Ok(None); + } + }; + + info!( + bind_addr = %bind_addr, + canonical_url = %canonical_url, + "spawning embedded iroh-relay", + ); + + let access = build_access_config(state.clone(), canonical_url); + + let mut relay = RelayConfig::new(bind_addr); + relay.access = access; + + // TODO(patched-iroh-relay): once upstream PR C lands, set the per-client + // maximum-lifetime hook here (gated on `#[cfg(feature = "patched-iroh-relay")]`) + // so we force re-auth every N minutes. Until then the connection lifetime + // is whatever iroh-relay's defaults are. + + let mut cfg = ServerConfig::default(); + cfg.relay = Some(relay); + + let server = Server::spawn(cfg).await?; + Ok(Some(IrohRelayHandle { + http_addr: server.http_addr(), + https_addr: server.https_addr(), + server, + })) +} + +/// Build the [`AccessConfig::Restricted`] callback that gates every +/// iroh-relay connection on (NIP-98 ∧ relay-membership). +fn build_access_config(state: Arc, canonical_url: String) -> AccessConfig { + AccessConfig::Restricted(Box::new(move |request: &ClientRequest| { + let state = state.clone(); + let canonical_url = canonical_url.clone(); + let endpoint_id = request.endpoint_id(); + let auth_token = request.auth_token(); + Box::pin(async move { + match decide(&state, &canonical_url, auth_token.as_deref()).await { + Decision::Allow { pubkey, owner } => { + debug!( + endpoint = %endpoint_id, + pubkey = %pubkey, + via_owner = ?owner, + "iroh-relay admission allowed", + ); + Access::Allow + } + Decision::Deny(reason) => { + debug!( + endpoint = %endpoint_id, + reason = %reason, + "iroh-relay admission denied", + ); + Access::Deny + } + } + }) + })) +} + +/// Internal decision type for the access callback — kept separate so it's +/// straightforward to unit-test [`decide`] without spinning up a full server. +#[derive(Debug)] +enum Decision { + /// Connection should be admitted. + Allow { + /// The NIP-98-proven pubkey of the connecting client. + pubkey: nostr::PublicKey, + /// `Some(owner)` if admission was via NIP-OA delegation. + owner: Option, + }, + /// Connection should be rejected, with a debug-only reason string. + Deny(String), +} + +/// Pure-logic admission decision, decoupled from iroh-relay's types so it +/// can be unit-tested with a real [`AppState`] and a synthetic bearer token. +async fn decide(state: &AppState, canonical_url: &str, auth_token: Option<&str>) -> Decision { + // Step 1+2+3 — extract and verify the NIP-98 bearer to recover the + // Nostr pubkey. This sub-function is unit-testable in isolation. + let pubkey = match verify_bearer(canonical_url, auth_token) { + Ok(pk) => pk, + Err(reason) => return Decision::Deny(reason), + }; + + // Step 4 — now and only now, run the membership check. We pass the NIP-98 + // pubkey bytes, not the iroh `EndpointId` — the latter is just an + // anonymous network identifier; membership is on Nostr identity. + match check_relay_membership(state, &pubkey.to_bytes(), None).await { + Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Decision::Allow { + pubkey, + owner: None, + }, + Ok(MembershipDecision::ViaOwner(owner)) => Decision::Allow { + pubkey, + owner: Some(owner), + }, + Ok(MembershipDecision::Denied) => Decision::Deny(format!("not a relay member: {}", pubkey)), + Err(e) => { + // Infrastructure failure. Fail closed. + warn!("iroh-relay membership check infra error: {e}"); + Decision::Deny(format!("membership check infra error: {e}")) + } + } +} + +/// Decode + verify the bearer token, returning the proven Nostr pubkey. +/// +/// Fail-closed on: +/// - missing/empty token, +/// - non-base64, +/// - non-UTF-8 JSON, +/// - any NIP-98 verification failure (wrong kind, bad signature, stale +/// timestamp, URL mismatch, method mismatch, payload mismatch). +/// +/// The returned `String` is a debug-only deny reason; do not forward to +/// clients (some failures distinguish "what" from "why" in ways we don't +/// want to leak). +fn verify_bearer( + canonical_url: &str, + auth_token: Option<&str>, +) -> Result { + let token = match auth_token { + Some(t) if !t.is_empty() => t, + _ => return Err("missing or empty bearer token".to_string()), + }; + + // Pre-decode length cap (Mari's review note). NIP-98 events are tiny; an + // attacker shouldn't be able to coerce the relay into allocating a + // multi-megabyte decode buffer before any signature check runs. + if token.len() > MAX_BEARER_LEN { + return Err(format!( + "bearer token exceeds {MAX_BEARER_LEN}-byte limit ({} bytes)", + token.len() + )); + } + + let json = + decode_bearer(token).ok_or_else(|| "bearer token is not valid base64".to_string())?; + + sprout_auth::verify_nip98_event(&json, canonical_url, NIP98_METHOD, None) + .map_err(|e| format!("NIP-98 verification failed: {e}")) +} + +/// Decode a NIP-98 bearer token. NIP-98 specifies base64 over the JSON event; +/// some signers use URL-safe encoding and/or omit padding, so we accept both. +fn decode_bearer(token: &str) -> Option { + use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; + use base64::Engine; + + let trimmed = token.trim(); + for engine in [&STANDARD, &URL_SAFE, &STANDARD_NO_PAD, &URL_SAFE_NO_PAD] { + if let Ok(bytes) = engine.decode(trimmed) { + if let Ok(s) = String::from_utf8(bytes) { + return Some(s); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use sprout_auth::nip98_canonical_url; + + /// Build a signed NIP-98 event JSON for the given canonical URL. + fn signed_event_json(keys: &Keys, canonical_url: &str, method: &str) -> String { + let event = EventBuilder::new( + Kind::HttpAuth, + "", + vec![ + Tag::parse(&["u", canonical_url]).unwrap(), + Tag::parse(&["method", method]).unwrap(), + ], + ) + .custom_created_at(Timestamp::now()) + .sign_with_keys(keys) + .unwrap(); + serde_json::to_string(&event).unwrap() + } + + fn bearer(json: &str) -> String { + STANDARD.encode(json) + } + + fn canonical() -> String { + nip98_canonical_url("https://relay.example.com/iroh", IROH_RELAY_PATH).unwrap() + } + + // ── Bearer verification ────────────────────────────────────────────── + + #[test] + fn verify_bearer_accepts_valid_nip98() { + let keys = Keys::generate(); + let url = canonical(); + let json = signed_event_json(&keys, &url, NIP98_METHOD); + let token = bearer(&json); + + let result = verify_bearer(&url, Some(&token)); + assert!(result.is_ok(), "expected accept, got {result:?}"); + assert_eq!(result.unwrap(), keys.public_key()); + } + + #[test] + fn verify_bearer_rejects_missing_token() { + let url = canonical(); + let result = verify_bearer(&url, None); + assert!(matches!(result, Err(ref e) if e.contains("missing"))); + } + + #[test] + fn verify_bearer_rejects_empty_token() { + let url = canonical(); + let result = verify_bearer(&url, Some("")); + assert!(matches!(result, Err(ref e) if e.contains("missing"))); + } + + #[test] + fn verify_bearer_rejects_non_base64() { + let url = canonical(); + let result = verify_bearer(&url, Some("not!!!base64!!!")); + assert!(matches!(result, Err(ref e) if e.contains("base64"))); + } + + #[test] + fn verify_bearer_rejects_oversized_token() { + // 64 KiB + 1 byte. Must be rejected by the length cap *before* any + // decode allocation happens, so an attacker can't coerce a giant + // base64 buffer. + let url = canonical(); + let huge = "A".repeat(MAX_BEARER_LEN + 1); + let result = verify_bearer(&url, Some(&huge)); + assert!( + matches!(result, Err(ref e) if e.contains("exceeds")), + "expected length-cap denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_internal_whitespace() { + // base64 0.22's `general_purpose` engines reject internal whitespace + // (no MIME mode). A valid token with a space spliced into the middle + // must therefore fail decode, not be silently accepted as if the + // whitespace were ignored. + let keys = Keys::generate(); + let url = canonical(); + let json = signed_event_json(&keys, &url, NIP98_METHOD); + let mut token = bearer(&json); + // Splice a space into the middle of an otherwise valid token. + let mid = token.len() / 2; + token.insert(mid, ' '); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("base64")), + "expected base64 denial on internal whitespace, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_wrong_method() { + // NIP-98 event signed for POST but the iroh-relay handshake is GET. + // The bearer must not be accepted with the wrong method. + let keys = Keys::generate(); + let url = canonical(); + let json = signed_event_json(&keys, &url, "POST"); + let token = bearer(&json); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("method")), + "expected method-mismatch denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_wrong_url() { + // Event signed for a DIFFERENT relay URL must not authorize access + // to *this* relay. This is the property that breaks if the canonical + // helper drifts between signer and verifier. + let keys = Keys::generate(); + let other_url = + nip98_canonical_url("https://other-relay.example.com/iroh", IROH_RELAY_PATH).unwrap(); + let json = signed_event_json(&keys, &other_url, NIP98_METHOD); + let token = bearer(&json); + + let result = verify_bearer(&canonical(), Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("URL")), + "expected URL-mismatch denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_wrong_kind() { + let keys = Keys::generate(); + let url = canonical(); + // Build a kind:1 (text note) event instead of kind:27235 — should fail. + let event = EventBuilder::new( + Kind::TextNote, + "", + vec![ + Tag::parse(&["u", &url]).unwrap(), + Tag::parse(&["method", NIP98_METHOD]).unwrap(), + ], + ) + .sign_with_keys(&keys) + .unwrap(); + let token = bearer(&serde_json::to_string(&event).unwrap()); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("kind")), + "expected kind-mismatch denial, got {result:?}", + ); + } + + #[test] + fn verify_bearer_rejects_stale_timestamp() { + let keys = Keys::generate(); + let url = canonical(); + let event = EventBuilder::new( + Kind::HttpAuth, + "", + vec![ + Tag::parse(&["u", &url]).unwrap(), + Tag::parse(&["method", NIP98_METHOD]).unwrap(), + ], + ) + // Two hours in the past — well outside the ±60s NIP-98 tolerance. + .custom_created_at(Timestamp::from(Timestamp::now().as_u64() - 7200)) + .sign_with_keys(&keys) + .unwrap(); + let token = bearer(&serde_json::to_string(&event).unwrap()); + + let result = verify_bearer(&url, Some(&token)); + assert!( + matches!(result, Err(ref e) if e.contains("timestamp")), + "expected timestamp denial, got {result:?}", + ); + } + + // ── Bearer decoding ────────────────────────────────────────────────── + + #[test] + fn decode_bearer_accepts_standard() { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + let payload = r#"{"hello":"world"}"#; + let token = STANDARD.encode(payload); + assert_eq!(decode_bearer(&token).as_deref(), Some(payload)); + } + + #[test] + fn decode_bearer_accepts_url_safe_no_pad() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + let payload = r#"{"hello":"world"}"#; + let token = URL_SAFE_NO_PAD.encode(payload); + assert_eq!(decode_bearer(&token).as_deref(), Some(payload)); + } + + #[test] + fn decode_bearer_rejects_garbage() { + assert!(decode_bearer("not base64 at all !!!").is_none()); + } +} diff --git a/crates/sprout-relay/src/lib.rs b/crates/sprout-relay/src/lib.rs index 3c0096431..7fa1fbc59 100644 --- a/crates/sprout-relay/src/lib.rs +++ b/crates/sprout-relay/src/lib.rs @@ -14,6 +14,8 @@ pub mod connection; pub mod error; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; +/// Embedded iroh-relay endpoint, gated by Sprout relay membership. +pub mod iroh_relay; /// Prometheus metrics: recorder, upkeep, HTTP middleware. pub mod metrics; /// NIP-11 relay information document. diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index 5ee724b4f..a52850318 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -11,6 +11,7 @@ use sprout_pubsub::PubSubManager; use sprout_search::{SearchConfig, SearchService}; use sprout_relay::config::Config; +use sprout_relay::iroh_relay; use sprout_relay::metrics as relay_metrics; use sprout_relay::router::{build_health_router, build_router}; use sprout_relay::state::AppState; @@ -482,6 +483,65 @@ async fn serve( .map_err(|e| anyhow::anyhow!("Failed to bind {}: {e}", config.bind_addr))?; info!(addr = %config.bind_addr, "sprout-relay TCP listening"); + // ── Embedded iroh-relay (mesh-LLM, optional) ───────────────────────────── + // Started only when both `SPROUT_IROH_RELAY_PUBLIC_URL` and + // `SPROUT_IROH_RELAY_BIND_ADDR` are configured. Advertising a URL without + // a listener (or vice-versa) is a deploy footgun, so we log loudly and + // refuse to start the iroh-relay if exactly one is set. + let iroh_relay_task = match ( + config.iroh_relay_public_url.as_deref(), + config.iroh_relay_bind_addr, + ) { + (Some(_), Some(bind_addr)) => { + match iroh_relay::spawn(Arc::clone(&state), bind_addr).await { + Ok(Some(handle)) => { + info!( + bind_addr = %bind_addr, + http_addr = ?handle.http_addr, + "embedded iroh-relay started", + ); + let mut iroh_rx = shutdown_tx.subscribe(); + Some(tokio::spawn(async move { + // Wait for the workspace shutdown signal, then drain + // the iroh-relay gracefully so in-flight QUIC sessions + // get a chance to finish. + iroh_rx.changed().await.ok(); + info!("draining embedded iroh-relay"); + if let Err(e) = handle.shutdown().await { + tracing::error!("embedded iroh-relay shutdown error: {e}"); + } + })) + } + Ok(None) => { + // `spawn` returned None — likely an unparseable + // `iroh_relay_public_url`. Already logged inside `spawn`. + None + } + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to spawn embedded iroh-relay on {bind_addr}: {e}" + )); + } + } + } + (Some(_), None) => { + tracing::warn!( + "SPROUT_IROH_RELAY_PUBLIC_URL set but SPROUT_IROH_RELAY_BIND_ADDR is not — \ + NIP-11 will advertise a URL that nothing serves. Mesh-LLM will not work.", + ); + None + } + (None, Some(_)) => { + tracing::warn!( + "SPROUT_IROH_RELAY_BIND_ADDR set but SPROUT_IROH_RELAY_PUBLIC_URL is not — \ + iroh-relay refused to start because clients cannot construct the canonical \ + NIP-98 `u` tag without a public URL.", + ); + None + } + (None, None) => None, + }; + // ── App listener (UDS, optional) ───────────────────────────────────────── #[cfg(unix)] if let Some(ref uds_path) = config.uds_path { @@ -524,6 +584,10 @@ async fn serve( .map_err(|e| anyhow::anyhow!("TCP server error: {e}"))?; uds_handle.abort(); + if let Some(task) = iroh_relay_task { + // Already triggered by shutdown_tx; just wait for the drain task. + let _ = task.await; + } return Ok(()); } @@ -544,6 +608,10 @@ async fn serve( .await .map_err(|e| anyhow::anyhow!("Server error: {e}"))?; + if let Some(task) = iroh_relay_task { + let _ = task.await; + } + Ok(()) } diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index 7cbff06d2..684cbc2ae 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -41,6 +41,15 @@ pub struct RelayInfo { /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, + /// Publicly-reachable URL of this relay's embedded iroh-relay endpoint, + /// used by mesh-LLM clients to wire their QUIC compute traffic through + /// the same trust boundary as Sprout relay membership. + /// + /// Absent unless the relay is configured to host an iroh-relay + /// (`SPROUT_IROH_RELAY_PUBLIC_URL`). Older clients that don't recognise + /// this field see no behaviour change. + #[serde(skip_serializing_if = "Option::is_none")] + pub iroh_relay_url: Option, } /// Protocol and resource limits advertised in the NIP-11 document. @@ -102,7 +111,11 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. - pub fn build(relay_self: Option<&str>, advertise_nip43: bool) -> Self { + pub fn build( + relay_self: Option<&str>, + advertise_nip43: bool, + iroh_relay_url: Option<&str>, + ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), "advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`" @@ -123,6 +136,7 @@ impl RelayInfo { version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation()), relay_self: relay_self.map(|s| s.to_string()), + iroh_relay_url: iroh_relay_url.map(|s| s.to_string()), } } } @@ -131,11 +145,15 @@ impl RelayInfo { pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, ) -> axum::response::Json { - let (relay_self, advertise_nip43) = nip11_facts(&state); - axum::response::Json(RelayInfo::build(relay_self.as_deref(), advertise_nip43)) + let (relay_self, advertise_nip43, iroh_relay_url) = nip11_facts(&state); + axum::response::Json(RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + iroh_relay_url.as_deref(), + )) } -/// Derives the two NIP-11 facts that depend on runtime config: +/// Derives the NIP-11 facts that depend on runtime config: /// /// - `relay_self`: the NIP-11 `self` pubkey, set whenever the relay has a /// stable signing key. Consumed by NIP-29 (group metadata verification) @@ -144,14 +162,19 @@ pub async fn relay_info_handler( /// - `advertise_nip43`: whether to list NIP-43 in `supported_nips`. True /// only when membership is actually enforced AND we have a stable key /// (NIP-43 events must be verifiable against `self`). +/// - `iroh_relay_url`: the publicly-reachable iroh-relay URL (see +/// [`crate::config::Config::iroh_relay_public_url`]). /// /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. -pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, bool) { +pub(crate) fn nip11_facts( + state: &crate::state::AppState, +) -> (Option, bool, Option) { let has_stable_key = state.config.relay_private_key.is_some(); let relay_self = has_stable_key.then(|| state.relay_keypair.public_key().to_hex()); let advertise_nip43 = has_stable_key && state.config.require_relay_membership; - (relay_self, advertise_nip43) + let iroh_relay_url = state.config.iroh_relay_public_url.clone(); + (relay_self, advertise_nip43, iroh_relay_url) } #[cfg(test)] @@ -214,7 +237,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, false); + let info = RelayInfo::build(None, false, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -227,7 +250,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), false); + let info = RelayInfo::build(Some(pk), false, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -236,7 +259,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), true); + let info = RelayInfo::build(Some(pk), true, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -247,6 +270,33 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, true); + let _ = RelayInfo::build(None, true, None); + } + + /// `iroh_relay_url` is omitted from the NIP-11 doc when unset, so older + /// clients that don't recognise the field see no extra payload. + #[test] + fn build_omits_iroh_relay_url_when_unset() { + let info = RelayInfo::build(None, false, None); + assert!(info.iroh_relay_url.is_none()); + let json = serde_json::to_value(&info).unwrap(); + assert!( + json.get("iroh_relay_url").is_none(), + "iroh_relay_url must be skipped when None: {json}" + ); + } + + /// When configured, the iroh-relay URL is advertised verbatim. + #[test] + fn build_advertises_iroh_relay_url_when_set() { + let url = "https://relay.example.com/iroh"; + let info = RelayInfo::build(None, false, Some(url)); + assert_eq!(info.iroh_relay_url.as_deref(), Some(url)); + let json = serde_json::to_value(&info).unwrap(); + assert_eq!( + json.get("iroh_relay_url").and_then(|v| v.as_str()), + Some(url), + "iroh_relay_url must be serialised when Some: {json}" + ); } } diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index f72516bf3..55921ab51 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -153,10 +153,14 @@ async fn nip11_or_ws_handler( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let (relay_self, advertise_nip43) = nip11_facts(&state); + let (relay_self, advertise_nip43, iroh_relay_url) = nip11_facts(&state); if accept.contains("application/nostr+json") { - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + iroh_relay_url.as_deref(), + ); return Json(info).into_response(); } @@ -175,7 +179,11 @@ async fn nip11_or_ws_handler( } } // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + iroh_relay_url.as_deref(), + ); Json(info).into_response() } } diff --git a/crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs b/crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs new file mode 100644 index 000000000..6cfa3458d --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_mesh_llm_discovery.rs @@ -0,0 +1,325 @@ +//! End-to-end tests for kind:31990 mesh-LLM compute-offer discovery. +//! +//! These tests require a running relay instance. By default they are marked +//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not +//! available. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! cargo test --test e2e_mesh_llm_discovery -- --ignored +//! ``` +//! +//! Override the relay URL with the `RELAY_URL` environment variable: +//! +//! ```text +//! RELAY_URL=ws://relay.example.com cargo test --test e2e_mesh_llm_discovery -- --ignored +//! ``` + +use std::time::Duration; + +use nostr::{EventBuilder, Filter, Keys, Kind, Tag, Timestamp}; +use sprout_core::kind::KIND_MESH_LLM_DISCOVERY; +use sprout_core::mesh_llm::{MeshLlmOffer, ModelOffer, ResourceCaps}; +use sprout_test_client::SproutTestClient; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-mesh-{name}-{}", uuid::Uuid::new_v4()) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn sample_offer(d_tag: &str, expires_at: u64) -> MeshLlmOffer { + MeshLlmOffer { + v: 1, + d_tag: d_tag.to_string(), + endpoint_id: "endpoint-id-test".to_string(), + iroh_relay_url: "https://relay.example.com/iroh".to_string(), + expires_at, + caps: ResourceCaps { + max_vram_mb: Some(8192), + max_ram_mb: Some(16_000), + max_concurrency: Some(1), + }, + models: vec![ModelOffer { + id: "test/model-1".to_string(), + label: Some("Test Model".to_string()), + context_tokens: Some(4096), + }], + extra: None, + } +} + +fn build_offer_event(keys: &Keys, offer: &MeshLlmOffer) -> nostr::Event { + let content = serde_json::to_string(offer).expect("serialise offer"); + let tags = vec![Tag::parse(&["d", &offer.d_tag]).expect("d tag")]; + EventBuilder::new(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), content, tags) + .sign_with_keys(keys) + .expect("sign event") +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// kind:31990 events with a far-future `expires_at` are accepted by the +/// relay and retrievable by a subsequent REQ scoped to that kind + author. +#[tokio::test] +#[ignore] +async fn test_offer_publish_then_retrieve() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("offer-{}", uuid::Uuid::new_v4().simple()); + let offer = sample_offer(&d_tag, now_secs() + 600); + let event = build_offer_event(&keys, &offer); + let event_id = event.id; + + let ok = client.send_event(event).await.expect("send event"); + assert!( + ok.accepted, + "relay should accept kind:31990 (well-formed offer): {}", + ok.message, + ); + + // Pull it back via REQ scoped to this author. + let sid = sub_id("retrieve"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == event_id), + "should find the published offer in query results", + ); + + // Deserialize and sanity-check the content survives the round trip. + let stored = events.iter().find(|e| e.id == event_id).unwrap(); + let parsed: MeshLlmOffer = + serde_json::from_str(&stored.content).expect("offer round-trips through relay"); + assert_eq!(parsed.d_tag, d_tag); + assert_eq!(parsed.expires_at, offer.expires_at); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 replace semantics: publishing a second event under the same +/// (pubkey, d_tag) replaces the first. The REQ that follows should +/// return only the latest version. +#[tokio::test] +#[ignore] +async fn test_offer_replace_by_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("replace-{}", uuid::Uuid::new_v4().simple()); + + // Publish v1 — short TTL. + let offer_v1 = sample_offer(&d_tag, now_secs() + 60); + let event_v1 = build_offer_event(&keys, &offer_v1); + let v1_id = event_v1.id; + let ok = client.send_event(event_v1).await.expect("send v1"); + assert!(ok.accepted, "v1 accepted: {}", ok.message); + + // Wait a beat so created_at differs (NIP-33 tie-breaker). + tokio::time::sleep(Duration::from_secs(1)).await; + + // Publish v2 — longer TTL, same d_tag → replaces v1. + let mut offer_v2 = sample_offer(&d_tag, now_secs() + 600); + offer_v2.endpoint_id = "endpoint-id-v2".to_string(); + let event_v2 = build_offer_event(&keys, &offer_v2); + let v2_id = event_v2.id; + let ok = client.send_event(event_v2).await.expect("send v2"); + assert!(ok.accepted, "v2 accepted: {}", ok.message); + + // Query — should see only the replacement. + let sid = sub_id("replace"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + let matching: Vec<_> = events + .iter() + .filter(|e| { + e.tags.iter().any(|t| { + t.as_slice().first().map(|s| s.as_str()) == Some("d") + && t.as_slice().get(1).map(|s| s.as_str()) == Some(d_tag.as_str()) + }) + }) + .collect(); + + assert!( + matching.iter().any(|e| e.id == v2_id), + "v2 must be present after replace", + ); + assert!( + !matching.iter().any(|e| e.id == v1_id), + "v1 must be replaced by v2 (NIP-33)", + ); +} + +/// Empty-content kind:31990 with the same (pubkey, d_tag) is the +/// delete-by-replace tombstone the desktop publisher emits when the user +/// toggles compute-sharing off. Consumers see the empty content and drop +/// the offer from their cache. +#[tokio::test] +#[ignore] +async fn test_offer_delete_by_empty_replace() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("delete-{}", uuid::Uuid::new_v4().simple()); + + // Publish a real offer. + let offer = sample_offer(&d_tag, now_secs() + 600); + let event = build_offer_event(&keys, &offer); + let real_id = event.id; + let ok = client.send_event(event).await.expect("send real"); + assert!(ok.accepted, "real offer accepted: {}", ok.message); + + tokio::time::sleep(Duration::from_secs(1)).await; + + // Publish the empty-content replacement (the tombstone). + let tombstone = EventBuilder::new( + Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), + "", + vec![Tag::parse(&["d", &d_tag]).unwrap()], + ) + .sign_with_keys(&keys) + .expect("sign tombstone"); + let tombstone_id = tombstone.id; + let ok = client.send_event(tombstone).await.expect("send tombstone"); + assert!(ok.accepted, "tombstone accepted: {}", ok.message); + + // Query — only the tombstone should remain at this address. + let sid = sub_id("delete"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + let matching: Vec<_> = events + .iter() + .filter(|e| { + e.tags.iter().any(|t| { + t.as_slice().first().map(|s| s.as_str()) == Some("d") + && t.as_slice().get(1).map(|s| s.as_str()) == Some(d_tag.as_str()) + }) + }) + .collect(); + + assert!( + matching + .iter() + .any(|e| e.id == tombstone_id && e.content.is_empty()), + "tombstone (empty content) must be the visible event at this address", + ); + assert!( + !matching.iter().any(|e| e.id == real_id), + "real offer must be replaced by tombstone", + ); + + // Sanity: a consumer would treat the empty content as 'offer withdrawn'. + // We don't enforce this at the relay; it's a consumer-side convention + // pinned by the useMeshLlmOffers hook in desktop and by + // MeshLlmOffer::is_publishable / is_expired in core. +} + +/// kind:31990 is global (`is_global_only_kind`): a stray `h` tag must not +/// channel-scope the event. The relay should accept it; a query without +/// `#h` should find it. +#[tokio::test] +#[ignore] +async fn test_offer_stray_h_tag_is_ignored() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("stray-h-{}", uuid::Uuid::new_v4().simple()); + let offer = sample_offer(&d_tag, now_secs() + 600); + let content = serde_json::to_string(&offer).unwrap(); + let fake_channel = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new( + Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), + content, + vec![ + Tag::parse(&["d", &d_tag]).unwrap(), + Tag::parse(&["h", &fake_channel]).unwrap(), + ], + ) + .custom_created_at(Timestamp::now()) + .sign_with_keys(&keys) + .expect("sign"); + let event_id = event.id; + + let ok = client.send_event(event).await.expect("send"); + assert!( + ok.accepted, + "kind:31990 with stray h-tag should still be accepted (h-tag ignored): {}", + ok.message, + ); + + // Query globally (no #h filter) — must find the offer. + let sid = sub_id("stray-h"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == event_id), + "stray-h-tag offer must be retrievable via global query", + ); + + client.disconnect().await.expect("disconnect"); +} diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 54c924c11..a8beadf92 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -43,7 +43,7 @@ const overrides = new Map([ ["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav - ["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ["src/shared/api/relayClientSession.ts", 960], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + subscribeToMeshLlmOffers (kind:31990 discovery) ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b87b7273a..168b999e1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -403,6 +403,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base58ck" version = "0.1.0" @@ -854,6 +860,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "combine" version = "4.6.7" @@ -911,6 +923,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1154,6 +1175,44 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.2", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -1200,6 +1259,26 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "data-encoding-macro" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] + [[package]] name = "dbus" version = "0.9.11" @@ -1244,7 +1323,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1266,10 +1345,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1292,6 +1373,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1445,6 +1527,31 @@ dependencies = [ "libm", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.8" @@ -1570,6 +1677,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1934,11 +2047,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2201,6 +2316,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2559,6 +2683,28 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh-base" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2160a45265eba3bd290ce698f584c9b088bee47e518e9ec4460d5e5888ef660e" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more 2.1.1", + "digest 0.11.2", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "sha2 0.11.0", + "url", + "zeroize", + "zeroize_derive", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2995,6 +3141,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "n0-error" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3649,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -5024,6 +5191,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5136,6 +5313,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5224,6 +5407,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sprout" version = "0.1.0" @@ -5239,6 +5433,7 @@ dependencies = [ "futures-util", "hex", "infer", + "iroh-base", "libc", "neteq", "nostr 0.36.0", @@ -5253,6 +5448,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "sherpa-onnx", + "sprout-auth", "sprout-core", "sprout-persona", "sprout-sdk", @@ -5270,6 +5466,7 @@ dependencies = [ "tauri-plugin-websocket", "tauri-plugin-window-state", "tempfile", + "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", "tokio-util", @@ -5280,17 +5477,37 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "sprout-auth" +version = "0.1.0" +dependencies = [ + "hex", + "nostr 0.36.0", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "sprout-core", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "sprout-core" version = "0.1.0" dependencies = [ "chrono", "hex", + "hmac 0.13.0", "nostr 0.36.0", "percent-encoding", "rand 0.10.1", "serde", "serde_json", + "sha2 0.11.0", "subtle", "thiserror 2.0.18", "url", @@ -6301,6 +6518,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -8085,7 +8303,7 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.14.0", "lzma-rs", "memchr", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d880acff2..4e052432d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -52,9 +52,11 @@ nostr-compat = { package = "nostr", version = "0.36" } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } url = "2" +sprout-auth = { path = "../../crates/sprout-auth" } sprout-core = { path = "../../crates/sprout-core" } sprout-persona = { path = "../../crates/sprout-persona" } sprout-sdk = { path = "../../crates/sprout-sdk" } +iroh-base = { version = "=1.0.0-rc.0", features = ["key"] } base64 = "0.22" sha2 = "0.11" tar = "0.4" @@ -73,5 +75,6 @@ earshot = "1.0" rubato = "2.0" audioadapter-buffers = "3.0" tempfile = "3" +thiserror = "2" [dev-dependencies] diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs new file mode 100644 index 000000000..014dcabf9 --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -0,0 +1,156 @@ +//! Tauri commands for the mesh-LLM frontend surface. +//! +//! All commands deal with *the local user's own* mesh-LLM state: +//! - the persisted iroh endpoint id, +//! - the persisted compute-sharing preferences (the avatar-menu sliders), +//! - explicit toggle/save calls invoked when the user changes the prefs, +//! - publishing / deleting the user's kind:31990 compute-offer event. +//! +//! Discovering *other* members' offers happens through the relay +//! WebSocket pipeline already exposed by `relayClientSession.ts`. + +use nostr::{EventBuilder, Kind, Tag}; +use serde::Serialize; +use sprout_core::kind::KIND_MESH_LLM_DISCOVERY; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; +use crate::mesh_llm; +use crate::relay::submit_event; + +/// Result type for mesh-LLM commands: errors are surfaced as user-facing +/// strings by the frontend. +type CmdResult = Result; + +/// Stable identifier of the local iroh endpoint, in iroh's canonical +/// Display form. Returned to the frontend so the user can see *which* +/// machine identity they're publishing under (useful when one user has +/// multiple devices each running Sprout). +#[derive(Debug, Clone, Serialize)] +pub struct MeshEndpointInfo { + /// Iroh endpoint id (= public key) as displayed by `iroh-base`. + pub endpoint_id: String, +} + +/// Returns the local mesh-LLM iroh endpoint id, creating + persisting the +/// keypair on first call. +#[tauri::command] +pub fn mesh_get_endpoint_id(app: AppHandle) -> CmdResult { + let key = mesh_llm::load_or_create_endpoint_key(&app).map_err(|e| e.to_string())?; + Ok(MeshEndpointInfo { + endpoint_id: key.public().to_string(), + }) +} + +/// Returns the persisted compute-sharing preferences for the avatar menu. +#[tauri::command] +pub fn mesh_get_sharing_prefs(app: AppHandle) -> CmdResult { + mesh_llm::offer::load_prefs(&app).map_err(|e| e.to_string()) +} + +/// Replaces the persisted compute-sharing preferences. The caller is +/// responsible for republishing or deleting the kind:31990 offer to reflect +/// the change — this command only touches local state. +#[tauri::command] +pub fn mesh_set_sharing_prefs( + app: AppHandle, + prefs: mesh_llm::ComputeSharingPrefs, +) -> CmdResult<()> { + mesh_llm::offer::save_prefs(&app, &prefs).map_err(|e| e.to_string()) +} + +/// Probe the connected relay's NIP-11 for an `iroh_relay_url`. +/// +/// Returns: +/// - `Ok(Some(url))` if the relay advertises one, +/// - `Ok(None)` if it doesn't, or if the relay is unreachable / malformed. +/// - `Err(_)` only for caller-side errors (e.g. bad WS URL shape). +#[tauri::command] +pub async fn mesh_relay_iroh_url( + _state: State<'_, AppState>, + relay_ws_url: String, +) -> CmdResult> { + mesh_llm::fetch_iroh_relay_url(&relay_ws_url) + .await + .map_err(|e| e.to_string()) +} + +// ── Publisher ────────────────────────────────────────────────────────────── + +/// Result of `mesh_publish_offer` — surface enough state so the frontend +/// can show the user *which* offer just went on the wire. +#[derive(Debug, Clone, Serialize)] +pub struct PublishOfferResult { + /// `event_id` returned by the relay on accept. + pub event_id: String, + /// `true` if compute-sharing is currently enabled. When false, the + /// command publishes an *empty-content* kind:31990 event at the same + /// `(pubkey, d_tag)` address, which under NIP-33 is the canonical way + /// to indicate "this offer is no longer active". Consumers that observe + /// the empty content drop the offer from their cache. + pub published_offer: bool, +} + +/// Publish (or revoke) the user's kind:31990 compute-offer event. +/// +/// Reads the current prefs from disk and the local iroh endpoint id. If +/// `enabled = true`, builds a kind:31990 with the offer envelope content +/// and the matching `d` tag; signs and POSTs via the existing +/// [`submit_event`] pipeline (NIP-98-authenticated to the configured relay). +/// If `enabled = false`, publishes the *same address* with empty content +/// to tell consumers the offer has been retired. +/// +/// `iroh_relay_url` should be the relay's NIP-11 `iroh_relay_url` (fetched +/// via [`mesh_relay_iroh_url`] at session start). The offer envelope +/// carries it so consumers know where to dial. +#[tauri::command] +pub async fn mesh_publish_offer( + app: AppHandle, + state: State<'_, AppState>, + iroh_relay_url: String, +) -> CmdResult { + // Load prefs + endpoint key. These are sync; complete before any await. + let prefs = mesh_llm::offer::load_prefs(&app).map_err(|e| e.to_string())?; + let endpoint_key = + mesh_llm::load_or_create_endpoint_key(&app).map_err(|e| e.to_string())?; + let endpoint_id_str = endpoint_key.public().to_string(); + + let d_tag = prefs.d_tag.clone(); + let d_tag_tag = Tag::parse(["d", &d_tag]).map_err(|e| format!("d tag: {e}"))?; + + let (content, published_offer) = if prefs.enabled { + // expires_at = now + OFFER_TTL_SECS. The frontend hook re-invokes + // mesh_publish_offer on a heartbeat well before the deadline; if + // the publisher crashes before the next heartbeat, consumers reap + // the offer once `now > expires_at` (see MeshLlmOffer::is_expired). + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("system clock: {e}"))? + .as_secs(); + let expires_at = now + mesh_llm::offer::OFFER_TTL_SECS; + let offer = prefs + .build_offer(&endpoint_id_str, &iroh_relay_url, expires_at) + .ok_or_else(|| { + "build_offer returned None despite enabled=true (logic bug)".to_string() + })?; + if !offer.is_publishable() { + return Err("offer envelope failed publishable check".to_string()); + } + let json = serde_json::to_string(&offer).map_err(|e| format!("serialise: {e}"))?; + (json, true) + } else { + // NIP-33 "delete by replace": same (pubkey, kind, d) address, empty + // content. Consumers must treat an empty content as 'offer + // withdrawn'. + (String::new(), false) + }; + + let builder = EventBuilder::new(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), content) + .tags(vec![d_tag_tag]); + + let res = submit_event(builder, &state).await?; + Ok(PublishOfferResult { + event_id: res.event_id, + published_offer, + }) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 2bc5062f5..1d5fc6be2 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod export_util; mod identity; mod media; mod media_download; +mod mesh_llm; mod messages; pub mod pairing; mod personas; @@ -32,6 +33,7 @@ pub use dms::*; pub use identity::*; pub use media::*; pub use media_download::*; +pub use mesh_llm::*; pub use messages::*; pub use pairing::*; pub use personas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0443243a0..8e98f90f4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod events; mod huddle; mod managed_agents; mod media_proxy; +mod mesh_llm; mod migration; mod models; pub mod nostr_convert; @@ -454,6 +455,11 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + mesh_get_endpoint_id, + mesh_get_sharing_prefs, + mesh_set_sharing_prefs, + mesh_relay_iroh_url, + mesh_publish_offer, discover_acp_providers, discover_managed_agent_prereqs, sign_event, diff --git a/desktop/src-tauri/src/mesh_llm/endpoint.rs b/desktop/src-tauri/src/mesh_llm/endpoint.rs new file mode 100644 index 000000000..df5a34b0d --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/endpoint.rs @@ -0,0 +1,145 @@ +//! Iroh endpoint keypair: persisted per Sprout install. +//! +//! The user's Nostr identity (`identity.key`) is separate from the iroh +//! endpoint identity. The Nostr key signs kind:31990 offers and the NIP-98 +//! admission bearer; the iroh key proves possession of the iroh `EndpointId` +//! during QUIC handshake. The kind:31990 event's Nostr signature binds the +//! two identities together — anyone who trusts the Nostr pubkey can trust +//! the advertised endpoint id, because nobody else could have signed that +//! offer. +//! +//! We deliberately do **not** derive the iroh key from the Nostr key: +//! +//! - It would couple key rotation: rotating the Nostr key would silently +//! change the iroh endpoint id, breaking active offers. +//! - It would force a particular HKDF over the Nostr seckey, picking a new +//! custody convention nobody else implements. +//! - The iroh key is generated once, never leaves the desktop, and is +//! already inside the same Tauri sandbox as `identity.key`. Two files, +//! one trust boundary. + +use std::path::{Path, PathBuf}; + +use iroh_base::SecretKey; +use tauri::{AppHandle, Manager}; + +const KEY_FILENAME: &str = "mesh_iroh.key"; + +/// Errors loading or creating the iroh endpoint keypair. +#[derive(Debug, thiserror::Error)] +pub enum EndpointKeyError { + /// Couldn't determine the Tauri app data dir. + #[error("app data dir: {0}")] + AppDataDir(String), + /// Filesystem I/O failure. + #[error("filesystem: {0}")] + Io(String), + /// On-disk key file exists but is malformed. + #[error("malformed key file: {0}")] + MalformedKeyFile(String), +} + +/// Resolve the iroh endpoint key file path under the Tauri app data dir. +fn key_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| EndpointKeyError::AppDataDir(e.to_string()))?; + std::fs::create_dir_all(&data_dir).map_err(|e| EndpointKeyError::Io(e.to_string()))?; + Ok(data_dir.join(KEY_FILENAME)) +} + +/// Load the persisted iroh endpoint keypair, generating + saving one on +/// first run. Mirrors the pattern in [`crate::app_state::resolve_persisted_identity`]. +/// +/// File format: 32 raw secret-key bytes encoded as lower-case hex on a single +/// line. Matches what `iroh_base::SecretKey`'s `FromStr` accepts. +pub fn load_or_create_endpoint_key(app: &AppHandle) -> Result { + let path = key_path(app)?; + + if path.exists() { + match load_key_file(&path) { + Ok(k) => return Ok(k), + Err(e) => { + // Quarantine corrupt files so we never overwrite a usable + // backup — same pattern as `identity.key`. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let bad = path.with_extension(format!("bad.{ts}")); + let _ = std::fs::rename(&path, &bad); + eprintln!( + "sprout-desktop: corrupt mesh_iroh.key ({e}), quarantined to {}", + bad.display(), + ); + } + } + } + + let key = SecretKey::generate(); + save_key_file(&path, &key)?; + eprintln!( + "sprout-desktop: generated and saved mesh iroh endpoint pubkey {}", + key.public(), + ); + Ok(key) +} + +fn load_key_file(path: &Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| EndpointKeyError::Io(e.to_string()))?; + let trimmed = content.trim(); + trimmed + .parse::() + .map_err(|e| EndpointKeyError::MalformedKeyFile(e.to_string())) +} + +fn save_key_file(path: &Path, key: &SecretKey) -> Result<(), EndpointKeyError> { + let bytes = key.to_bytes(); + let hex = hex::encode(bytes); + // Atomic write: write to a temp file in the same dir, fsync, rename. + let dir = path + .parent() + .ok_or_else(|| EndpointKeyError::Io("key path has no parent".to_string()))?; + let tmp = tempfile::NamedTempFile::new_in(dir) + .map_err(|e| EndpointKeyError::Io(format!("temp file: {e}")))?; + std::fs::write(tmp.path(), hex.as_bytes()) + .map_err(|e| EndpointKeyError::Io(format!("write temp: {e}")))?; + tmp.persist(path) + .map_err(|e| EndpointKeyError::Io(format!("rename temp: {e}")))?; + // No fsync of the directory here — matches the existing identity.key path. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// Round-trip a generated key through the save/load functions. + #[test] + fn round_trip_save_load() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mesh_iroh.key"); + let original = SecretKey::generate(); + save_key_file(&path, &original).expect("save"); + let loaded = load_key_file(&path).expect("load"); + assert_eq!(original.to_bytes(), loaded.to_bytes()); + } + + /// A truncated/corrupted file is rejected with `MalformedKeyFile`. + #[test] + fn corrupt_file_returns_malformed() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mesh_iroh.key"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"not a valid hex key").unwrap(); + drop(f); + let err = load_key_file(&path).expect_err("should fail"); + match err { + EndpointKeyError::MalformedKeyFile(_) => {} + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs new file mode 100644 index 000000000..40ed11dc6 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -0,0 +1,30 @@ +//! Mesh-LLM client: discover, dial, and publish kind:31990 offers. +//! +//! This module is the desktop-side counterpart to `sprout-relay`'s embedded +//! iroh-relay (see `crates/sprout-relay/src/iroh_relay.rs`). The relay gates +//! admission with NIP-98 + relay membership; this module signs that bearer +//! token, dials offers advertised under kind:31990, and publishes our own +//! offer when the user enables compute-sharing. +//! +//! ## Submodules +//! +//! - [`endpoint`]: long-lived iroh endpoint keypair persisted at +//! `{app_data_dir}/mesh_iroh.key`. +//! - [`nip98`]: build the NIP-98 bearer event signed with the user's Nostr +//! key for a given canonical relay URL. +//! - [`offer`]: load/save the user's mesh-LLM offer preferences +//! (VRAM/RAM/concurrency caps, models). + +pub mod endpoint; +pub mod nip11; +pub mod nip98; +pub mod offer; + +// Wildcard re-exports are deliberately avoided so that adding an +// unused-by-design helper to a submodule (e.g. a publisher that hasn't been +// wired yet) doesn't trip dead-code lints at the top level. Callers reach +// into the submodules directly until a public API surface stabilises. + +pub use endpoint::load_or_create_endpoint_key; +pub use nip11::fetch_iroh_relay_url; +pub use offer::ComputeSharingPrefs; diff --git a/desktop/src-tauri/src/mesh_llm/nip11.rs b/desktop/src-tauri/src/mesh_llm/nip11.rs new file mode 100644 index 000000000..feaa6835e --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/nip11.rs @@ -0,0 +1,108 @@ +//! NIP-11 fetch helper specialised for mesh-LLM bootstrapping. +//! +//! At session start the desktop queries the connected Sprout relay's +//! NIP-11 document and reads the `iroh_relay_url` field +//! ([`sprout_relay::nip11::RelayInfo::iroh_relay_url`]). When set, this is +//! the URL the desktop signs NIP-98 bearer tokens against and dials iroh +//! connections through. +//! +//! Unreachable relays / malformed responses / missing field all return +//! `Ok(None)` — the desktop falls back to "mesh-LLM disabled for this +//! relay", same UX as a relay that simply doesn't host iroh. + +use std::time::Duration; + +const NIP11_TIMEOUT: Duration = Duration::from_secs(5); + +/// Errors that bubble up only for *infrastructural* failures the caller +/// should surface (e.g. a malformed user-provided URL). Network and +/// "field-not-present" cases collapse to `Ok(None)` because mesh-LLM is +/// optional. +#[derive(Debug, thiserror::Error)] +pub enum Nip11Error { + /// The provided relay URL doesn't start with `ws://` or `wss://`. + #[error("not a ws:// or wss:// URL: {0}")] + NotWebsocketUrl(String), + /// The `reqwest` client failed to construct. + #[error("http client init: {0}")] + ClientBuild(String), +} + +/// Convert a Nostr `ws(s)://` relay URL to its `http(s)://` NIP-11 base. +fn to_http_base(relay_url: &str) -> Result { + if let Some(rest) = relay_url.strip_prefix("wss://") { + Ok(format!("https://{rest}")) + } else if let Some(rest) = relay_url.strip_prefix("ws://") { + Ok(format!("http://{rest}")) + } else { + Err(Nip11Error::NotWebsocketUrl(relay_url.to_string())) + } +} + +/// Fetch the relay's NIP-11 document and extract `iroh_relay_url`. +/// +/// Returns: +/// - `Ok(Some(url))` — the relay advertises an iroh-relay endpoint. +/// - `Ok(None)` — relay is reachable but does not advertise `iroh_relay_url`, +/// OR the relay is unreachable / returned a malformed doc. Mesh-LLM is +/// silently disabled in both cases (same UX). +/// - `Err(_)` — the *caller* gave us something un-fixable (e.g. a non-ws +/// URL). Should be surfaced as a developer error, not a runtime fallback. +pub async fn fetch_iroh_relay_url(relay_url: &str) -> Result, Nip11Error> { + let http_url = to_http_base(relay_url)?; + + let client = reqwest::Client::builder() + .timeout(NIP11_TIMEOUT) + .build() + .map_err(|e| Nip11Error::ClientBuild(e.to_string()))?; + + let resp = match client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + { + Ok(r) => r, + Err(_) => return Ok(None), // unreachable — graceful no-mesh + }; + + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(_) => return Ok(None), // malformed — graceful no-mesh + }; + + Ok(json + .get("iroh_relay_url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_base_strips_ws_prefix() { + assert_eq!( + to_http_base("wss://relay.example.com/iroh").unwrap(), + "https://relay.example.com/iroh", + ); + assert_eq!( + to_http_base("ws://localhost:3000").unwrap(), + "http://localhost:3000", + ); + } + + #[test] + fn http_base_rejects_non_ws() { + assert!(matches!( + to_http_base("https://relay.example.com"), + Err(Nip11Error::NotWebsocketUrl(_)) + )); + assert!(matches!( + to_http_base("relay.example.com"), + Err(Nip11Error::NotWebsocketUrl(_)) + )); + } +} diff --git a/desktop/src-tauri/src/mesh_llm/nip98.rs b/desktop/src-tauri/src/mesh_llm/nip98.rs new file mode 100644 index 000000000..104913eb7 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/nip98.rs @@ -0,0 +1,110 @@ +//! NIP-98 bearer token builder for iroh-relay admission. +//! +//! Signs a kind:27235 event over the canonical iroh-relay URL using the +//! user's Nostr identity, then base64-encodes the event JSON. The receiving +//! relay's `sprout_relay::iroh_relay` access callback decodes + verifies +//! this exact bearer string. +//! +//! Both sides use the same `sprout_auth::nip98_canonical_url` helper, so +//! path-prefix / trailing-slash / localhost-vs-127.0.0.1 drift cannot +//! create undebuggable per-connection denials. +//! +//! Reserved for the deferred iroh dial path (upstream mesh-llm PR A); the +//! verifier side in `sprout_relay::iroh_relay` is already wired and tested. +//! Once the dial lands these become call-sites, not dead code. +#![allow(dead_code)] + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + +const IROH_RELAY_PATH: &str = "/relay"; +const NIP98_METHOD: &str = "GET"; + +/// Errors produced while building a NIP-98 bearer. +#[derive(Debug, thiserror::Error)] +pub enum Nip98BearerError { + /// `iroh_relay_url` from NIP-11 wasn't a parseable URL. + #[error("invalid iroh relay URL: {0}")] + InvalidUrl(String), + /// `nostr` library failed to construct/sign the event. + #[error("event signing failed: {0}")] + Sign(String), + /// Tag construction failed (should never happen for static "u"/"method"). + #[error("tag construction failed: {0}")] + Tag(String), +} + +/// Build the `Authorization: Bearer ` value for an iroh-relay +/// admission request. +/// +/// `iroh_relay_public_url` is the value taken verbatim from the target +/// relay's NIP-11 `iroh_relay_url` field. We canonicalise it the same way +/// the relay does before signing the `u` tag. +pub fn build_nip98_bearer( + keys: &Keys, + iroh_relay_public_url: &str, +) -> Result { + let canonical = sprout_auth::nip98_canonical_url(iroh_relay_public_url, IROH_RELAY_PATH) + .ok_or_else(|| Nip98BearerError::InvalidUrl(iroh_relay_public_url.to_string()))?; + + let tags = vec![ + Tag::parse(["u", &canonical]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?, + Tag::parse(["method", NIP98_METHOD]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?, + ]; + + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| Nip98BearerError::Sign(e.to_string()))?; + + let json = event.as_json(); + Ok(STANDARD.encode(json)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signs_for_canonical_url() { + let keys = Keys::generate(); + let token = + build_nip98_bearer(&keys, "https://relay.example.com/iroh").expect("build bearer"); + // Round-trip through base64 -> JSON to confirm the event has the + // canonical URL in its `u` tag. + let bytes = STANDARD.decode(&token).expect("base64 decode"); + let json = String::from_utf8(bytes).expect("utf8"); + assert!( + json.contains("\"u\""), + "bearer event should carry `u` tag: {json}", + ); + assert!( + json.contains("https://relay.example.com/iroh/relay"), + "bearer event should canonicalise the URL: {json}", + ); + assert!( + json.contains("\"method\""), + "bearer event should carry `method` tag", + ); + } + + #[test] + fn rejects_unparseable_url() { + let keys = Keys::generate(); + let err = build_nip98_bearer(&keys, "definitely not a url").expect_err("should fail"); + match err { + Nip98BearerError::InvalidUrl(_) => {} + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn different_keys_produce_different_bearers() { + let a = Keys::generate(); + let b = Keys::generate(); + let ta = build_nip98_bearer(&a, "https://relay.example.com/iroh").unwrap(); + let tb = build_nip98_bearer(&b, "https://relay.example.com/iroh").unwrap(); + assert_ne!(ta, tb); + } +} diff --git a/desktop/src-tauri/src/mesh_llm/offer.rs b/desktop/src-tauri/src/mesh_llm/offer.rs new file mode 100644 index 000000000..03563cfa1 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/offer.rs @@ -0,0 +1,208 @@ +//! Persisted compute-sharing preferences (the avatar-menu sliders). +//! +//! When the user turns on compute sharing and dials the VRAM/RAM/concurrency +//! sliders in the bottom-left avatar menu, those preferences live in +//! `{app_data_dir}/mesh_offer.json`. The publisher reads this file when it +//! builds a kind:31990 event; the settings UI reads + writes it via Tauri +//! commands. +//! +//! Keeping the prefs as a plain JSON file (rather than baking them into the +//! kind:31990 event directly) lets the user toggle sharing without +//! republishing on every restart and makes the file inspectable for support. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use sprout_core::mesh_llm::{MeshLlmOffer, ModelOffer, ResourceCaps}; +use tauri::{AppHandle, Manager}; + +const OFFER_FILENAME: &str = "mesh_offer.json"; +const DEFAULT_D_TAG: &str = "default"; + +/// Errors loading or saving offer preferences. +#[derive(Debug, thiserror::Error)] +pub enum OfferPrefsError { + /// Couldn't determine the Tauri app data dir. + #[error("app data dir: {0}")] + AppDataDir(String), + /// Filesystem I/O failure. + #[error("filesystem: {0}")] + Io(String), + /// JSON parse / serialize failure. + #[error("json: {0}")] + Json(String), +} + +/// Persisted compute-sharing preferences. +/// +/// `enabled = false` is the default; the publisher must skip publishing in +/// that case and **must delete any previously-published offer** (NIP-09 or +/// kind:31990 with empty content per NIP-33's replace-with-empty convention). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComputeSharingPrefs { + /// Whether the user has opted in to sharing compute. Default: `false`. + pub enabled: bool, + + /// Caps the user wants to advertise. `None` on a field = "no cap"; the + /// publisher passes `Some(0)` through unchanged because the schema + /// allows it (consumers should treat 0 as "explicit zero"). + pub caps: ResourceCaps, + + /// Models the user wants to advertise. May be empty. + pub models: Vec, + + /// Persistent `d_tag` for the user's offer. Generated once on first + /// enable and re-used so replaces target the same address. + pub d_tag: String, +} + +impl Default for ComputeSharingPrefs { + fn default() -> Self { + Self { + enabled: false, + caps: ResourceCaps { + max_vram_mb: None, + max_ram_mb: None, + max_concurrency: Some(1), + }, + models: vec![], + d_tag: DEFAULT_D_TAG.to_string(), + } + } +} + +impl ComputeSharingPrefs { + /// Builds the kind:31990 offer envelope to publish. Returns `None` if + /// sharing is disabled; the publisher should then *delete* any prior + /// offer rather than calling this. + /// + /// `expires_at` is the unix-seconds deadline after which consumers will + /// drop this offer. Callers pass `now + OFFER_TTL_SECS` so an + /// unannounced publisher crash naturally reaps the stale offer. + pub fn build_offer( + &self, + endpoint_id: &str, + iroh_relay_url: &str, + expires_at: u64, + ) -> Option { + if !self.enabled { + return None; + } + Some(MeshLlmOffer { + v: 1, + d_tag: self.d_tag.clone(), + endpoint_id: endpoint_id.to_string(), + iroh_relay_url: iroh_relay_url.to_string(), + expires_at, + caps: self.caps.clone(), + models: self.models.clone(), + extra: None, + }) + } +} + +/// Default offer TTL: 15 minutes. Publishers republish on each prefs +/// change *and* should heartbeat at ~`OFFER_TTL_SECS/3` so a one-missed +/// heartbeat still leaves the offer visible. +pub const OFFER_TTL_SECS: u64 = 15 * 60; + +fn prefs_path(app: &AppHandle) -> Result { + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| OfferPrefsError::AppDataDir(e.to_string()))?; + std::fs::create_dir_all(&data_dir).map_err(|e| OfferPrefsError::Io(e.to_string()))?; + Ok(data_dir.join(OFFER_FILENAME)) +} + +/// Load persisted prefs; returns [`ComputeSharingPrefs::default`] if the file +/// is absent. On parse errors, returns the error verbatim — callers should +/// surface it in the settings UI rather than silently resetting. +pub fn load_prefs(app: &AppHandle) -> Result { + let path = prefs_path(app)?; + if !path.exists() { + return Ok(ComputeSharingPrefs::default()); + } + let content = std::fs::read_to_string(&path).map_err(|e| OfferPrefsError::Io(e.to_string()))?; + serde_json::from_str(&content).map_err(|e| OfferPrefsError::Json(e.to_string())) +} + +/// Atomically replace the on-disk prefs file. +pub fn save_prefs(app: &AppHandle, prefs: &ComputeSharingPrefs) -> Result<(), OfferPrefsError> { + let path = prefs_path(app)?; + let dir = path + .parent() + .ok_or_else(|| OfferPrefsError::Io("prefs path has no parent".to_string()))?; + let json = serde_json::to_string_pretty(prefs).map_err(|e| OfferPrefsError::Json(e.to_string()))?; + let tmp = tempfile::NamedTempFile::new_in(dir) + .map_err(|e| OfferPrefsError::Io(format!("temp file: {e}")))?; + std::fs::write(tmp.path(), json.as_bytes()) + .map_err(|e| OfferPrefsError::Io(format!("write temp: {e}")))?; + tmp.persist(&path) + .map_err(|e| OfferPrefsError::Io(format!("rename temp: {e}")))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_disabled() { + let prefs = ComputeSharingPrefs::default(); + assert!(!prefs.enabled); + assert_eq!(prefs.caps.max_concurrency, Some(1)); + assert_eq!(prefs.d_tag, DEFAULT_D_TAG); + } + + #[test] + fn build_offer_returns_none_when_disabled() { + let prefs = ComputeSharingPrefs::default(); + assert!( + prefs + .build_offer("endpoint", "https://relay/iroh", 2_000_000_000) + .is_none() + ); + } + + #[test] + fn build_offer_returns_envelope_when_enabled() { + let prefs = ComputeSharingPrefs { + enabled: true, + ..Default::default() + }; + let offer = prefs + .build_offer( + "endpoint-id-hex", + "https://relay.example.com/iroh", + 2_000_000_000, + ) + .expect("offer"); + assert_eq!(offer.endpoint_id, "endpoint-id-hex"); + assert_eq!(offer.iroh_relay_url, "https://relay.example.com/iroh"); + assert_eq!(offer.expires_at, 2_000_000_000); + assert!(offer.is_publishable()); + } + + /// Round-trip prefs through serde so the on-disk format stays stable. + #[test] + fn round_trip_via_json() { + let prefs = ComputeSharingPrefs { + enabled: true, + caps: ResourceCaps { + max_vram_mb: Some(8192), + max_ram_mb: Some(16_000), + max_concurrency: Some(3), + }, + models: vec![ModelOffer { + id: "qwen/Qwen2.5-7B-Instruct".to_string(), + label: Some("Qwen 2.5 7B".to_string()), + context_tokens: Some(32_768), + }], + d_tag: "node-laptop".to_string(), + }; + let s = serde_json::to_string(&prefs).unwrap(); + let back: ComputeSharingPrefs = serde_json::from_str(&s).unwrap(); + assert_eq!(prefs, back); + } +} diff --git a/desktop/src/features/settings/hooks/useMeshLlmOffers.ts b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts new file mode 100644 index 000000000..6eba832e2 --- /dev/null +++ b/desktop/src/features/settings/hooks/useMeshLlmOffers.ts @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; + +/** + * Mesh-LLM offer envelope as carried in the `content` field of a kind:31990 + * event. Keep in sync with the Rust `sprout_core::mesh_llm::MeshLlmOffer`. + */ +export interface MeshLlmOffer { + v: number; + d_tag: string; + endpoint_id: string; + iroh_relay_url: string; + /** Unix-seconds deadline; consumers ignore offers where `expires_at <= now`. */ + expires_at: number; + caps: { + max_vram_mb?: number | null; + max_ram_mb?: number | null; + max_concurrency?: number | null; + }; + models: Array<{ + id: string; + label?: string | null; + context_tokens?: number | null; + }>; + extra?: unknown; +} + +/** + * A kind:31990 offer paired with the *Nostr* pubkey that signed it (so the + * UI can show 'Alice is offering Llama 3 8B') and the event's `created_at` + * (for sorting and freshness display). + */ +export interface ResolvedOffer { + offer: MeshLlmOffer; + pubkey: string; + createdAt: number; + d_tag: string; +} + +function extractDTag(event: RelayEvent): string | null { + for (const tag of event.tags) { + if (tag.length >= 2 && tag[0] === "d") return tag[1]; + } + return null; +} + +/** + * Canonicalise a relay URL the same way `sprout_core::mesh_llm`'s same-relay + * filter does, so the JS side can't accept an offer the Rust schema check + * would reject. (See `MeshLlmOffer::matches_local_relay` in core.) + */ +function canonicalRelayUrl(raw: string): string { + const trimmed = raw.trim(); + const noQuery = trimmed.split("?")[0] ?? trimmed; + const noFrag = noQuery.split("#")[0] ?? noQuery; + const noTrail = noFrag.endsWith("/") ? noFrag.slice(0, -1) : noFrag; + const protoIdx = noTrail.indexOf("://"); + if (protoIdx === -1) return noTrail; + const scheme = noTrail.slice(0, protoIdx).toLowerCase(); + const rest = noTrail.slice(protoIdx + 3); + const slash = rest.indexOf("/"); + if (slash === -1) { + return `${scheme}://${rest.toLowerCase()}`; + } + const authority = rest.slice(0, slash).toLowerCase(); + const path = rest.slice(slash); + return `${scheme}://${authority}${path}`; +} + +/** + * How often (in ms) the hook recomputes the rendered list so freshly + * expired offers drop without waiting for a new event to arrive. Tied to a + * setInterval rather than per-offer setTimeouts so the cost is O(1) no + * matter how many offers are in the cache. + */ +const EXPIRY_TICK_MS = 30_000; + +/** + * Subscribe to live mesh-LLM offers from the connected relay. + * + * Returns the de-duplicated set of *currently-active* offers (keyed by + * `(pubkey, d_tag)` per NIP-33), filtered to: + * - the current relay's iroh-relay URL (v1 invariant: one relay = one mesh + * boundary). Offers advertising a different `iroh_relay_url` are dropped. + * - non-expired offers (`expires_at > now`). Crashed publishers can't send + * the NIP-33 delete-by-replace tombstone; the TTL is the reaper. + * + * An event with empty `content` is treated as 'offer withdrawn' and removes + * the corresponding entry — this is the NIP-33 delete-by-replace idiom the + * Rust publisher emits when the user toggles compute-sharing off. + */ +export function useMeshLlmOffers(): { + offers: ResolvedOffer[]; + error: string | null; +} { + const [offers, setOffers] = useState>(new Map()); + const [error, setError] = useState(null); + const [localRelay, setLocalRelay] = useState(null); + const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); + + // Discover the current relay's iroh_relay_url once so we can filter + // offers against it. If the relay doesn't advertise one, the same-relay + // filter rejects everything and the panel correctly shows the empty + // state. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const ws = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl: ws, + }); + if (!cancelled) setLocalRelay(irohUrl ?? null); + } catch (e) { + if (!cancelled) setError(String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Periodic re-tick so expired offers fall out of the rendered list even + // when no new events arrive. + useEffect(() => { + const id = setInterval(() => { + setNowSec(Math.floor(Date.now() / 1000)); + }, EXPIRY_TICK_MS); + return () => clearInterval(id); + }, []); + + useEffect(() => { + let cancelled = false; + let unsub: (() => Promise) | null = null; + + function onEvent(event: RelayEvent) { + if (cancelled) return; + const dTag = extractDTag(event); + if (!dTag) return; + const key = `${event.pubkey}:${dTag}`; + + // Empty content = NIP-33 delete-by-replace. + if (event.content.trim() === "") { + setOffers((prev) => { + if (!prev.has(key)) return prev; + const next = new Map(prev); + next.delete(key); + return next; + }); + return; + } + + let parsed: MeshLlmOffer; + try { + parsed = JSON.parse(event.content) as MeshLlmOffer; + } catch { + // Skip malformed offers silently; one bad publisher must not + // poison the list. + return; + } + // Reject obviously-bad schema versions before storing. + if (parsed.v !== 1) return; + + setOffers((prev) => { + const existing = prev.get(key); + if (existing && existing.createdAt >= event.created_at) { + // We already have a fresher version under the same address. + return prev; + } + const next = new Map(prev); + next.set(key, { + offer: parsed, + pubkey: event.pubkey, + createdAt: event.created_at, + d_tag: dTag, + }); + return next; + }); + } + + (async () => { + try { + const u = await relayClient.subscribeToMeshLlmOffers(onEvent); + if (cancelled) { + void u(); + } else { + unsub = u; + } + } catch (e) { + if (!cancelled) setError(String(e)); + } + })(); + + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, []); + + // Filter on every render so newly-expired offers drop without a refresh + // event, and so the same-relay filter applies as soon as the NIP-11 + // probe completes. + const localCanonical = + localRelay != null ? canonicalRelayUrl(localRelay) : null; + const list = Array.from(offers.values()) + .filter((entry) => entry.offer.expires_at > nowSec) + .filter((entry) => { + if (localCanonical == null) return false; + return canonicalRelayUrl(entry.offer.iroh_relay_url) === localCanonical; + }) + .sort((a, b) => b.createdAt - a.createdAt); + + return { offers: list, error }; +} diff --git a/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts new file mode 100644 index 000000000..81e670f7a --- /dev/null +++ b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +/** + * Heartbeat interval (ms) for the mesh-LLM offer publisher. + * + * The Rust side stamps `expires_at = now + OFFER_TTL_SECS` (15 min) on + * each publish. We re-publish at ~1/3 of that so even a single missed + * heartbeat leaves the offer visible to consumers; only a crash that + * misses two consecutive heartbeats actually drops us out of the UI. + * + * Kept here rather than computed from a Rust constant because the value + * is fundamentally a *frontend timer* (Tauri-side has no concept of "the + * UI mounted"); we just need it to stay strictly less than the + * Rust-side TTL so the invariant holds. + */ +const HEARTBEAT_MS = 5 * 60 * 1000; + +/** + * While `enabled` is true, periodically re-invoke `mesh_publish_offer` so + * the kind:31990 event's `expires_at` stays fresh. Republishes are NIP-33 + * replaces under the same `(pubkey, d_tag)` address — consumers do not see + * a flicker; they just see the deadline advance. + * + * Errors are logged to the console and dropped on the floor — the user + * is not in front of the settings panel waiting for them, and the next + * heartbeat (or an explicit prefs change) will surface a fresh error if + * the relay is durably down. + */ +export function useMeshOfferHeartbeat(params: { + enabled: boolean; + irohRelayUrl: string | null; +}): void { + const { enabled, irohRelayUrl } = params; + + useEffect(() => { + if (!enabled || irohRelayUrl == null || irohRelayUrl === "") { + return; + } + let cancelled = false; + const tick = async () => { + if (cancelled) return; + try { + await invoke("mesh_publish_offer", { irohRelayUrl }); + } catch (e) { + // Heartbeat failure is non-fatal — log and let the next tick try. + console.warn("mesh-llm heartbeat failed:", e); + } + }; + const id = setInterval(tick, HEARTBEAT_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [enabled, irohRelayUrl]); +} diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx new file mode 100644 index 000000000..66594886e --- /dev/null +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -0,0 +1,326 @@ +import * as React from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Cpu, Users } from "lucide-react"; + +import { useMeshLlmOffers } from "@/features/settings/hooks/useMeshLlmOffers"; +import { useMeshOfferHeartbeat } from "@/features/settings/hooks/useMeshOfferHeartbeat"; +import { Switch } from "@/shared/ui/switch"; +import { Input } from "@/shared/ui/input"; + +// --------------------------------------------------------------------------- +// Types matching the Rust mesh_llm::ComputeSharingPrefs / ResourceCaps shape. +// --------------------------------------------------------------------------- + +interface ResourceCaps { + max_vram_mb: number | null; + max_ram_mb: number | null; + max_concurrency: number | null; +} + +interface ModelOffer { + id: string; + label?: string | null; + context_tokens?: number | null; +} + +interface ComputeSharingPrefs { + enabled: boolean; + caps: ResourceCaps; + models: ModelOffer[]; + d_tag: string; +} + +interface MeshEndpointInfo { + endpoint_id: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Parse a string into Some(n) when it represents a positive integer, or +/// None to clear the cap. Empty string also clears. +function parseCap(raw: string): number | null { + if (raw.trim() === "") return null; + const n = Number.parseInt(raw, 10); + if (Number.isFinite(n) && n >= 0) return n; + return null; +} + +function formatCap(value: number | null): string { + return value == null ? "" : String(value); +} + +// --------------------------------------------------------------------------- +// Card +// --------------------------------------------------------------------------- + +export function MeshComputeSettingsCard() { + const [prefs, setPrefs] = React.useState(null); + const [endpoint, setEndpoint] = React.useState(null); + const [irohRelayUrl, setIrohRelayUrl] = React.useState(null); + const [error, setError] = React.useState(null); + const [saving, setSaving] = React.useState(false); + const { offers, error: offersError } = useMeshLlmOffers(); + + // While the card is mounted AND the user has compute-sharing enabled, + // re-publish the offer every ~5 min so its `expires_at` doesn't lapse + // (see useMeshOfferHeartbeat for the rationale). The heartbeat hook + // no-ops when `enabled` is false or `irohRelayUrl` is missing. + useMeshOfferHeartbeat({ + enabled: prefs?.enabled === true, + irohRelayUrl, + }); + + // Load the persisted prefs + the iroh endpoint identity + the relay's + // iroh_relay_url on mount. The latter is what the heartbeat republishes + // against, so we have to know it before the heartbeat fires its first + // tick. + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const [p, e] = await Promise.all([ + invoke("mesh_get_sharing_prefs"), + invoke("mesh_get_endpoint_id"), + ]); + if (cancelled) return; + setPrefs(p); + setEndpoint(e); + // Probe the relay's iroh_relay_url so the heartbeat hook has + // something to publish against. Failures here are non-fatal — + // the user can still see/edit prefs; we just won't heartbeat. + try { + const relayWsUrl = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl, + }); + if (!cancelled) setIrohRelayUrl(irohUrl ?? null); + } catch (probeErr) { + console.warn("mesh-llm iroh url probe failed:", probeErr); + } + } catch (e) { + if (!cancelled) setError(String(e)); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const persist = React.useCallback(async (next: ComputeSharingPrefs) => { + setSaving(true); + setError(null); + try { + // Save first so a failed publish leaves the prefs in a sane state. + await invoke("mesh_set_sharing_prefs", { prefs: next }); + setPrefs(next); + + // Probe the connected relay for its iroh_relay_url. If it doesn't + // advertise mesh-LLM at all, the offer can't be published — but the + // local prefs are still saved (the user might re-connect to a + // mesh-capable relay later). + const relayWsUrl = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl, + }); + setIrohRelayUrl(irohUrl ?? null); + if (irohUrl) { + await invoke("mesh_publish_offer", { irohRelayUrl: irohUrl }); + } else if (next.enabled) { + setError( + "Saved locally, but this relay does not advertise iroh_relay_url — your offer will not be visible to other members until the relay is configured for mesh-LLM.", + ); + } + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }, []); + + if (!prefs) { + return ( +
+
+

+ Share compute +

+

+ {error ?? "Loading mesh-LLM preferences…"} +

+
+
+ ); + } + + const updateCap = (field: keyof ResourceCaps, raw: string) => { + persist({ + ...prefs, + caps: { ...prefs.caps, [field]: parseCap(raw) }, + }); + }; + + return ( +
+
+

Share compute

+

+ When enabled, other members of this relay can run agents on this + machine using the limits you set below. Your relay membership is the + only gate — there is no signup or external account. +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ {/* ── Master toggle ────────────────────────────────────────── */} +
+
+ +

+ Publishes a kind:31990 compute-offer event when on; deletes it + when off. +

+
+ + persist({ ...prefs, enabled: checked }) + } + /> +
+ + {/* ── Caps ─────────────────────────────────────────────────── */} +
+ + + Limits per request + + +
+
+ + updateCap("max_vram_mb", e.target.value)} + placeholder="No limit" + value={formatCap(prefs.caps.max_vram_mb)} + /> +
+
+ + updateCap("max_ram_mb", e.target.value)} + placeholder="No limit" + value={formatCap(prefs.caps.max_ram_mb)} + /> +
+
+ + updateCap("max_concurrency", e.target.value)} + placeholder="1" + value={formatCap(prefs.caps.max_concurrency)} + /> +
+
+
+ + {/* ── Offers visible from other members ──────────────────── */} +
+

+ + Compute offered by other members +

+ {offersError ? ( +

{offersError}

+ ) : null} + {offers.length === 0 ? ( +

+ Nobody else on this relay is currently sharing compute. +

+ ) : ( +
    + {offers.map((entry) => ( +
  • +
    + + {entry.pubkey.slice(0, 16)}…{entry.pubkey.slice(-4)} + + {" · "} + {entry.d_tag} +
    +
    + {entry.offer.models.length === 0 + ? "No models advertised" + : entry.offer.models + .map((m) => m.label ?? m.id) + .join(", ")} +
    +
    + {entry.offer.caps.max_vram_mb != null + ? `${entry.offer.caps.max_vram_mb} MB VRAM · ` + : ""} + {entry.offer.caps.max_concurrency != null + ? `${entry.offer.caps.max_concurrency} concurrent` + : ""} +
    +
  • + ))} +
+ )} +
+ + {/* ── Identity ────────────────────────────────────────────── */} + {endpoint ? ( +
+ + This device's iroh endpoint: + {" "} + {endpoint.endpoint_id} +
+ ) : null} +
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 661926de5..786352000 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -3,6 +3,7 @@ import { BellRing, Bot, Check, + Cpu, Download, Keyboard, LayoutTemplate, @@ -31,6 +32,7 @@ import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; +import { MeshComputeSettingsCard } from "./MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; @@ -41,6 +43,7 @@ export type SettingsSection = | "profile" | "notifications" | "agents" + | "compute" | "channel-templates" | "appearance" | "shortcuts" @@ -87,6 +90,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Agents", icon: Bot, }, + { + value: "compute", + label: "Share compute", + icon: Cpu, + }, { value: "channel-templates", label: "Templates", @@ -282,6 +290,8 @@ export function renderSettingsSection( ); case "agents": return ; + case "compute": + return ; case "channel-templates": return ; case "appearance": diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 2d846d473..db51a8164 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -9,6 +9,7 @@ import type { PresenceStatus, RelayEvent } from "@/shared/api/types"; import { CHANNEL_EVENT_KINDS, HOME_MENTION_EVENT_KINDS, + KIND_MESH_LLM_DISCOVERY, KIND_STREAM_MESSAGE, KIND_TYPING_INDICATOR, KIND_USER_STATUS, @@ -311,6 +312,24 @@ export class RelayClient { ); } + /** + * Subscribe to kind:31990 mesh-LLM compute-offer events. + * + * Pulls the most recent 200 offers (one per (pubkey, d_tag) address under + * NIP-33) so the UI sees the steady-state set of who is currently + * offering, then streams live updates. Consumers should treat an event + * with empty `content` as 'offer withdrawn' (NIP-33 delete-by-replace). + * + * Membership is enforced relay-side via the existing NIP-43 fan-out gate + * — this subscription will only deliver events authored by relay members. + */ + async subscribeToMeshLlmOffers(onEvent: (event: RelayEvent) => void) { + return this.subscribe( + { kinds: [KIND_MESH_LLM_DISCOVERY], limit: 200 }, + onEvent, + ); + } + async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) { return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); } diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 73028596f..8e6a21a92 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -20,6 +20,8 @@ export const KIND_READ_STATE = 30078; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_REPO_ANNOUNCEMENT = 30617; +/** Mesh-LLM compute-offer discovery. Parameterized replaceable (NIP-33). */ +export const KIND_MESH_LLM_DISCOVERY = 31990; // Human-visible "new content" message kinds. Used as the unread trigger set // (sidebar badges, catch-up queries) and as the Home-feed mention query.