diff --git a/Cargo.lock b/Cargo.lock index f1ee2e3..a0e921d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -97,6 +147,45 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[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.20", + "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 2.0.119", + "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 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -131,7 +220,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http", "log", "url", @@ -166,12 +255,33 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -282,6 +392,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.5.4" @@ -297,6 +447,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.8" @@ -479,6 +635,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -505,6 +675,37 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der" version = "0.8.1" @@ -516,6 +717,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" @@ -558,6 +773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common 0.2.2", ] @@ -567,7 +783,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -651,6 +867,29 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -687,18 +926,21 @@ checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flexaccess-iroh" -version = "0.0.3" +version = "0.0.6" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", + "env_logger", "flexaccess-keys", "futures", + "getrandom 0.4.3", "iroh", "iroh-mdns-address-lookup", + "iroh-relay", "log", - "n0-watcher", "rustls", "tokio", + "url", ] [[package]] @@ -706,7 +948,7 @@ name = "flexaccess-keys" version = "0.0.2" source = "git+https://github.com/flexaccessdev/flexaccess-keys?tag=v0.0.2#e3d700482d44f53f89b5f6210c8c24c2a4b8ea0a" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.4.3", ] @@ -939,6 +1181,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1130,7 +1378,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1308,7 +1556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1462,12 +1710,20 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" dependencies = [ + "http-body-util", + "hyper", + "hyper-util", "iroh-metrics-derive", "itoa", "n0-error", "portable-atomic", + "reqwest", + "rustls", + "rustls-platform-verifier", "ryu", "serde", + "tokio", + "tokio-util", "tracing", ] @@ -1492,6 +1748,8 @@ dependencies = [ "blake3", "bytes", "cfg_aliases", + "clap", + "dashmap", "data-encoding", "derive_more", "getrandom 0.4.3", @@ -1512,28 +1770,81 @@ dependencies = [ "pin-project", "postcard", "rand", + "rcgen", + "reloadable-state", "reqwest", "rustls", + "rustls-cert-file-reader", + "rustls-cert-reloadable-resolver", "rustls-pki-types", "serde", "serde_bytes", + "serde_json", + "sha1", + "simdutf8", "strum", + "time", "tokio", "tokio-rustls", + "tokio-rustls-acme", "tokio-util", "tokio-websockets", + "toml", "tracing", + "tracing-subscriber", "url", "webpki-roots", "ws_stream_wasm", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "jni" version = "0.21.1" @@ -1671,7 +1982,7 @@ version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1701,6 +2012,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.3" @@ -1860,7 +2177,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "log", "netlink-packet-core 0.8.2", @@ -1872,7 +2189,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "log", "netlink-packet-core 0.9.0", @@ -1956,6 +2273,16 @@ dependencies = [ "wmi", ] +[[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.2.0" @@ -1997,6 +2324,7 @@ dependencies = [ "rustc-hash", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "slab", "sorted-index-buffer", "thiserror 2.0.20", @@ -2027,12 +2355,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2079,7 +2426,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "dispatch2", "libc", @@ -2092,7 +2439,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -2112,7 +2459,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -2125,7 +2472,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -2146,7 +2493,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "libc", "objc2", @@ -2154,6 +2501,15 @@ dependencies = [ "objc2-security", ] +[[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" @@ -2164,6 +2520,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2221,6 +2583,26 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -2288,7 +2670,7 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", "quick-xml", "serde", @@ -2316,13 +2698,22 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca97242f016e090a25330613bc2382aab65eb22f9149dbe84d8f8e711d49a530" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "derive_more", "hyper-util", @@ -2463,13 +2854,39 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem 4.0.0", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -2489,13 +2906,30 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[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.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -2512,6 +2946,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -2561,6 +2997,15 @@ 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 = "rustls" version = "0.23.43" @@ -2576,6 +3021,40 @@ dependencies = [ "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.20", + "tokio", +] + +[[package]] +name = "rustls-cert-read" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-cert-reloadable-resolver" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8" +dependencies = [ + "futures-util", + "reloadable-state", + "rustls", + "rustls-cert-read", + "thiserror 2.0.20", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -2684,7 +3163,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2759,6 +3238,28 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serdect" version = "0.4.3" @@ -2769,6 +3270,17 @@ dependencies = [ "serde", ] +[[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.1", + "digest", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -2842,7 +3354,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6f884fa9a8d48101774bfbd3aeb81e968dd22cffd19a372da69f183db22c1a" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -2912,6 +3424,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" @@ -3002,7 +3520,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3166,6 +3684,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 0.22.1", + "chrono", + "futures", + "log", + "num-bigint", + "pem 3.0.6", + "proc-macro2", + "rcgen", + "reqwest", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-rustls", + "webpki-roots", + "x509-parser", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -3199,7 +3745,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", @@ -3216,6 +3762,21 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3246,6 +3807,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -3267,7 +3834,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -3418,6 +3985,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.0" @@ -3927,6 +4500,24 @@ dependencies = [ "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.20", + "time", +] + [[package]] name = "xml-rs" version = "0.8.29" @@ -3942,6 +4533,16 @@ dependencies = [ "xml-rs", ] +[[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.3" @@ -4058,3 +4659,9 @@ dependencies = [ "quote", "syn 3.0.4", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 4f812e0..6f6176e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "flexaccess-iroh" -version = "0.0.3" +version = "0.0.6" edition = "2024" -description = "Shared iroh transport layer for FlexAccess applications: relay configuration and probing, endpoint building and rebuilding, the home-relay watchdog, and the endpoint-bound public-key auth transcript" +description = "Shared iroh transport layer for FlexAccess applications: relay configuration and probing, the self-hosted address lookup custom relays require, endpoint building and rebuilding, and the endpoint-bound public-key auth transcript" repository = "https://github.com/flexaccessdev/flexaccess-iroh" [features] @@ -20,6 +20,8 @@ base64 = "0.22" # consumers see exactly the version this crate signs and verifies with. flexaccess-keys = { git = "https://github.com/flexaccessdev/flexaccess-keys", tag = "v0.0.2", default-features = false } futures = "0.3" +# Random bytes for a fresh lookup secret. +getrandom = "0.4" # A range, deliberately: every consumer resolves one `iroh` 1.1.x for its own # workspace and this crate compiles against it. A consumer on a fork of iroh # redirects this dependency too with `[patch.crates-io]` (see README.md). @@ -27,6 +29,7 @@ iroh = ">=1.1.0, <1.2.0" log = "0.4" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } +url = "2" # iroh's mDNS lookup sends raw UDP multicast, which iOS rejects without the # multicast entitlement, so it is never even a dependency there. @@ -34,7 +37,8 @@ tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } iroh-mdns-address-lookup = { version = "0.5", optional = true } [dev-dependencies] -# Test double for iroh's `Watcher`-based status APIs (the relay watchdog tests -# drive a plain `Watchable`); the same crate iroh itself re-exports `Watcher` from. -n0-watcher = "1" -tokio = { version = "1", features = ["full", "test-util"] } +env_logger = "0.11" +# An in-process relay for the lookup integration test, plain HTTP on +# localhost (the same server `iroh-relay --dev` runs). +iroh-relay = { version = ">=1.1.0, <1.2.0", features = ["server"] } +tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md index bcad002..ba8256e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Shared iroh transport layer for FlexAccess applications, as a Rust crate. The programs built on iroh in this org — [tunnel-rs], [ezvpn], [flextunnel] — share one transport foundation. Its design is documented once in [iroh-common-architecture]; this crate is that design as code, so a fix to the -relay watchdog or the relay probe lands here once instead of being ported by +relay probe or endpoint construction lands here once instead of being ported by hand into every repo. [tunnel-rs]: https://github.com/flexaccessdev/tunnel-rs @@ -17,9 +17,9 @@ hand into every repo. | Module | Contents | |---|---| -| `relay` | `RelayConfig` (default vs custom relays, which also decides whether n0 internet discovery is on), the shared relay auth token, the strict per-relay startup probe | -| `endpoint` | the common endpoint builder, `create_endpoint` (strict first creation) vs `rebuild_endpoint` (tolerant mid-run replacement), `RebuildableEndpoint` | -| `relay_watchdog` | the server-side home-relay watchdog: nudge with `network_change()`, then ask for a rebuild | +| `relay` | `RelayConfig` (default vs custom relays, which also decides where address lookup happens), the shared relay auth token, the strict per-relay startup probe | +| `lookup` | the self-hosted address lookup service that custom relays **require**: the `lks1-` secret format (lowercase z-base-32 with a CRC-32, so a typo fails at config load) and the `lookup_url` / `lookup_secret` pair the crate turns into `//pkarr` | +| `endpoint` | the common endpoint builder, `create_endpoint` (strict first creation, including the foreground first publish to the lookup service) vs `rebuild_endpoint` (tolerant mid-run replacement), `RebuildableEndpoint` | | `auth` | the endpoint-bound public-key auth transcript over the [flexaccess-keys] format; each application passes its own domain-separation context | Deliberately **not** in it: ALPNs, handshake wire formats, QUIC transport @@ -31,13 +31,39 @@ takes the resulting `iroh::SecretKey` / `flexaccess_keys` values. [flexaccess-keys]: https://github.com/flexaccessdev/flexaccess-keys +## Custom relays need a lookup service + +Custom relays turn n0's address lookup off, so without a replacement a +server that moves to another relay is unreachable to every client that only +knows the old one. `RelayConfig::resolve` therefore **rejects** custom relay +URLs without a `lookup_url` and `lookup_secret`: one self-hosted +`iroh-dns-server` behind a reverse proxy that only serves +`//…`. Servers publish their relay URL there at startup (in the +foreground, so a wrong secret or a dead service stops the program with the +reason) and iroh keeps republishing; clients resolve peers from it. The +deployment recipe is in +[self-hosting.md](https://github.com/flexaccessdev/iroh-common-architecture/blob/main/self-hosting.md), +the design in +[relays-and-address-lookup.md](https://github.com/flexaccessdev/iroh-common-architecture/blob/main/relays-and-address-lookup.md#custom-relays). + +## Server relay recovery + +Servers rely on iroh 1.1.x for relay reconnects and re-homing, and on the +lookup service to tell clients where they went; they keep the same endpoint +during relay outages. The former server watchdog has been removed. Its +history, the failure it covered, and the conditions for bringing it back are +in +[home-relay-watchdog.md](https://github.com/flexaccessdev/iroh-common-architecture/blob/main/home-relay-watchdog.md). + +The client-side `RebuildableEndpoint` remains available for reconnect escalation. + ## Depending on it ```toml [dependencies] -flexaccess-iroh = { git = "https://github.com/flexaccessdev/flexaccess-iroh", tag = "v0.0.3" } +flexaccess-iroh = { git = "https://github.com/flexaccessdev/flexaccess-iroh", tag = "v0.0.6" } # or, with mDNS local-network discovery on every endpoint (compiled out on iOS): -flexaccess-iroh = { git = "...", tag = "v0.0.3", features = ["mdns"] } +flexaccess-iroh = { git = "...", tag = "v0.0.6", features = ["mdns"] } ``` The `flexaccess_keys` crate is re-exported so a consumer signs and verifies diff --git a/src/endpoint.rs b/src/endpoint.rs index 5692d5d..09cbc42 100644 --- a/src/endpoint.rs +++ b/src/endpoint.rs @@ -10,18 +10,27 @@ //! [`endpoint_builder`], then hand it to [`create_endpoint`] (first creation: //! strict) or [`rebuild_endpoint`] (mid-run replacement: tolerant). +use crate::lookup::LookupConfig; use crate::relay::{RELAY_CONNECT_TIMEOUT, RelayConfig, probe_custom_relays}; use anyhow::{Context, Result}; use futures::future::BoxFuture; use iroh::{ - Endpoint, EndpointId, - address_lookup::{DnsAddressLookup, PkarrPublisher}, + Endpoint, EndpointId, TransportAddr, + address_lookup::{ + DEFAULT_PKARR_TTL, DnsAddressLookup, EndpointData, EndpointInfo, PkarrPublisher, + PkarrRelayClient, PkarrResolver, + }, endpoint::{Builder as EndpointBuilder, QuicTransportConfig, presets}, }; use log::info; use std::sync::Arc; use std::time::Duration; +/// How long the first publish of a server's address record may take. It is a +/// single HTTP PUT to the lookup service; a Cloudflare-tunnelled service +/// answers in well under a second. +pub const LOOKUP_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10); + /// What an application decides about every endpoint it builds. #[derive(Debug, Clone)] pub struct EndpointOptions { @@ -30,40 +39,44 @@ pub struct EndpointOptions { /// product-specific by design — a VPN's datagram path and a proxy's /// stream path want different settings. pub transport_config: QuicTransportConfig, - /// Whether to publish this endpoint's address to n0's pkarr DNS when on the - /// default relays (a no-op with custom relays, where internet discovery is - /// off). A server with a persistent identity publishes so clients can - /// resolve it by id; a client that only dials out should not advertise - /// itself. + /// Whether to publish this endpoint's address record: to n0's pkarr + /// service on the default relays, to the configured lookup service with + /// custom relays. A server with a persistent identity publishes so clients + /// can resolve it by id; a client that only dials out should not + /// advertise itself. Only the relay URL is ever published, never IP + /// addresses. pub publish_address: bool, /// Reach peers **only** through the configured relays: the direct IP - /// transports are dropped and no address lookup of any kind (n0 internet - /// discovery, mDNS) is added, so nothing can ever produce a direct path. - /// A testing and reference mode for a self-hosted relay deployment; only - /// meaningful with custom relays (the default relays are rate-limited). + /// transports are dropped and no local-network discovery (mDNS) is added, + /// so nothing can ever produce a direct path. The address lookup service + /// stays, since it carries relay URLs only. A testing and reference mode + /// for a self-hosted relay deployment; only meaningful with custom relays + /// (the default relays are rate-limited). pub relay_only: bool, } /// Create a base endpoint builder with the common configuration. /// -/// iroh *internet* discovery (n0 pkarr publishing + DNS-based lookup of -/// `_iroh..dns.iroh.link`, see -/// ) follows the relay mode: +/// Internet address lookup (pkarr publishing of the home relay and +/// resolution of a peer's, see +/// ) follows the relay +/// mode: /// -/// - [`RelayConfig::Default`]: the n0 lookup stack is enabled — DNS resolution -/// is always on, and pkarr publishing is added only when -/// [`EndpointOptions::publish_address`] is set. -/// - [`RelayConfig::Custom`]: n0 internet discovery is disabled — nothing is -/// published to or resolved from n0's public infrastructure. Dialers reach -/// peers through relay hints attached to the peer's `EndpointAddr`: iroh -/// sends QUIC Initials to every configured relay, so the handshake succeeds -/// via whichever relay the peer is homed on. +/// - [`RelayConfig::Default`]: the n0 lookup stack — DNS resolution of +/// `_iroh..dns.iroh.link` is always on, and pkarr publishing +/// to n0 is added only when [`EndpointOptions::publish_address`] is set. +/// - [`RelayConfig::Custom`]: the configured self-hosted lookup service — +/// pkarr resolution over HTTP is always on, and pkarr publishing is added +/// only when `publish_address` is set. Nothing is published to or resolved +/// from n0's infrastructure. Dialers may still attach relay hints to the +/// peer's `EndpointAddr`; the lookup record is what reaches them once the +/// peer has moved to a relay the hints do not name. /// /// With the `mdns` feature, mDNS local-network discovery is added independent /// of the relay mode (except on iOS, where it is compiled out). /// -/// [`EndpointOptions::relay_only`] overrides all of that: the IP transports -/// are cleared and no address lookup at all is added. +/// [`EndpointOptions::relay_only`] then clears the IP transports and skips +/// mDNS; the internet lookup stays. pub fn endpoint_builder(relay_config: &RelayConfig, options: EndpointOptions) -> EndpointBuilder { // iroh 1.x requires the crypto provider to be set explicitly on the // builder when starting from the `Empty` preset — the `tls-ring` feature @@ -73,18 +86,29 @@ pub fn endpoint_builder(relay_config: &RelayConfig, options: EndpointOptions) -> .transport_config(options.transport_config) .crypto_provider(Arc::new(rustls::crypto::ring::default_provider())); - if options.relay_only { - info!("Relay-only mode: no direct paths and no address lookup"); - return builder.clear_ip_transports(); + match relay_config.lookup() { + Some(lookup) => { + let pkarr_url = lookup.pkarr_url(); + if options.publish_address { + builder = builder.address_lookup(PkarrPublisher::builder(pkarr_url.clone())); + } + builder = builder.address_lookup(PkarrResolver::builder(pkarr_url)); + info!( + "Address lookup via {} (custom relays; nothing goes to n0)", + lookup.display_host() + ); + } + None => { + if options.publish_address { + builder = builder.address_lookup(PkarrPublisher::n0_dns()); + } + builder = builder.address_lookup(DnsAddressLookup::n0_dns()); + } } - if relay_config.is_custom() { - info!("Internet discovery disabled (custom relays configured)"); - } else { - if options.publish_address { - builder = builder.address_lookup(PkarrPublisher::n0_dns()); - } - builder = builder.address_lookup(DnsAddressLookup::n0_dns()); + if options.relay_only { + info!("Relay-only mode: no direct paths and no local-network discovery"); + return builder.clear_ip_transports(); } #[cfg(all(feature = "mdns", not(target_os = "ios")))] { @@ -94,6 +118,60 @@ pub fn endpoint_builder(relay_config: &RelayConfig, options: EndpointOptions) -> builder } +/// Publish the endpoint's address record to the lookup service now, in the +/// foreground, and fail if the service rejects it. +/// +/// iroh's own publisher does the same in the background and keeps +/// republishing for the life of the endpoint, but it only logs failures and +/// retries forever. A server that cannot publish is unreachable to every +/// client that does not already know its relay, so the first publish is done +/// here where a wrong `lookup_secret` (a `404` from the reverse proxy), a +/// wrong host, or a service that is down stops the program with the reason. +/// The record carries the relay URLs only, never IP addresses, exactly like +/// the background publisher's. +/// +/// Requires the endpoint to be online (it has a home relay to publish). +pub async fn publish_address_record(endpoint: &Endpoint, lookup: &LookupConfig) -> Result<()> { + let addr = endpoint.addr(); + let relays: Vec = addr + .relay_urls() + .map(|url| TransportAddr::Relay(url.clone())) + .collect(); + if relays.is_empty() { + anyhow::bail!("Endpoint has no home relay to publish (is it online?)"); + } + let relay_list: Vec = addr.relay_urls().map(ToString::to_string).collect(); + let info = EndpointInfo::from_parts(endpoint.id(), EndpointData::new(relays)); + let packet = info + .to_pkarr_signed_packet(endpoint.secret_key(), DEFAULT_PKARR_TTL) + .map_err(|e| anyhow::anyhow!("{e:#}")) + .context("Failed to sign the address record")?; + let dns_resolver = endpoint + .dns_resolver() + .map_err(|e| anyhow::anyhow!("{e:#}")) + .context("Endpoint has no DNS resolver")? + .clone(); + let client = PkarrRelayClient::new(lookup.pkarr_url(), endpoint.tls_config().clone(), dns_resolver); + let host = lookup.display_host(); + match tokio::time::timeout(LOOKUP_PUBLISH_TIMEOUT, client.publish(&packet)).await { + Ok(Ok(())) => { + info!( + "Published address record to the lookup service at {host} (relay: {})", + relay_list.join(", ") + ); + Ok(()) + } + Ok(Err(e)) => anyhow::bail!( + "Failed to publish the address record to the lookup service at {host}: {e:#}. \ + Check lookup_url and lookup_secret, and that the service is up (a wrong secret is a 404)" + ), + Err(_) => anyhow::bail!( + "Publishing the address record to the lookup service at {host} timed out after {}s", + LOOKUP_PUBLISH_TIMEOUT.as_secs() + ), + } +} + /// Wait for a freshly bound endpoint to come online (relay/discovery ready), /// bounded by [`RELAY_CONNECT_TIMEOUT`]. Does not close the endpoint on /// failure; the caller decides (creation closes and fails, a rebuild carries @@ -113,11 +191,19 @@ pub async fn wait_online(endpoint: &Endpoint) -> Result<()> { } /// First creation of an endpoint: log the relay setup, probe every custom -/// relay (fail if any is unreachable — configuration validation), bind, and -/// require the endpoint to come online. On failure after binding the endpoint -/// is closed before the error propagates (dropping a bound endpoint without -/// `close()` is fatal under `panic = "abort"`). -pub async fn create_endpoint(relay_config: &RelayConfig, builder: EndpointBuilder) -> Result { +/// relay (fail if any is unreachable — configuration validation), bind, +/// require the endpoint to come online, and — for an endpoint that publishes +/// its address (`publishes_address`, the same value the builder was given as +/// [`EndpointOptions::publish_address`]) on custom relays — publish its +/// record to the lookup service in the foreground, failing if the service +/// rejects it (see [`publish_address_record`]). On failure after binding the +/// endpoint is closed before the error propagates (dropping a bound endpoint +/// without `close()` is fatal under `panic = "abort"`). +pub async fn create_endpoint( + relay_config: &RelayConfig, + builder: EndpointBuilder, + publishes_address: bool, +) -> Result { relay_config.log_status(); probe_custom_relays(relay_config).await?; let endpoint = builder.bind().await.context("Failed to create iroh endpoint")?; @@ -125,6 +211,12 @@ pub async fn create_endpoint(relay_config: &RelayConfig, builder: EndpointBuilde endpoint.close().await; return Err(e); } + if publishes_address && let Some(lookup) = relay_config.lookup() + && let Err(e) = publish_address_record(&endpoint, lookup).await + { + endpoint.close().await; + return Err(e); + } Ok(endpoint) } @@ -136,8 +228,9 @@ pub async fn create_endpoint(relay_config: &RelayConfig, builder: EndpointBuilde /// would block recovery through the one relay that still answers. /// - **The online wait is tolerated failing.** A fresh endpoint is no worse /// than the wedged one it replaces — LAN peers can still find it over mDNS — -/// and whatever tripped the rebuild (the relay watchdog, a client's -/// reconnect escalation) trips again if the relays stay unreachable. +/// and the client's reconnect escalation retries if the relays stay unreachable. +/// - **No foreground publish.** A rebuilt endpoint that publishes leaves it +/// to iroh's background publisher, which retries until the service answers. pub async fn rebuild_endpoint(builder: EndpointBuilder) -> Result { let endpoint = builder.bind().await.context("Failed to create iroh endpoint")?; if let Err(e) = wait_online(&endpoint).await { @@ -147,8 +240,7 @@ pub async fn rebuild_endpoint(builder: EndpointBuilder) -> Result { } /// Recipe producing a fresh, fully bound endpoint — how a -/// [`RebuildableEndpoint`] replaces itself mid-session, or how a server -/// replaces a wedged endpoint when the relay watchdog gives up on it. +/// [`RebuildableEndpoint`] replaces itself mid-session. pub type EndpointFactory = Arc BoxFuture<'static, Result> + Send + Sync>; /// Bound wait on the old endpoint's graceful close during a rebuild. The close diff --git a/src/lib.rs b/src/lib.rs index b07f96d..aa09818 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,13 +7,15 @@ //! across repos: //! //! - [`relay`]: the default-vs-custom [`relay::RelayConfig`] (which also decides -//! whether n0 internet discovery is on), the shared relay auth token, and the +//! where address lookup happens), the shared relay auth token, and the //! strict per-relay startup probe. +//! - [`lookup`]: the self-hosted address lookup service that custom relays +//! require — the `lks1-` secret format and the `lookup_url` / +//! `lookup_secret` pair. //! - [`endpoint`]: the common endpoint builder, the bind-and-come-online policy -//! for first creation versus a mid-run rebuild, and a +//! for first creation versus a mid-run rebuild (including the foreground +//! first publish to the lookup service), and a //! [`endpoint::RebuildableEndpoint`] handle. -//! - [`relay_watchdog`]: the server-side home-relay watchdog that nudges, then -//! asks for a rebuild, when iroh silently loses its home relay. //! - [`auth`]: the endpoint-bound public-key authentication transcript over the //! shared [`flexaccess_keys`] format; each application supplies its own //! domain-separation context. @@ -31,7 +33,7 @@ pub mod auth; pub mod endpoint; +pub mod lookup; pub mod relay; -pub mod relay_watchdog; pub use flexaccess_keys; diff --git a/src/lookup.rs b/src/lookup.rs new file mode 100644 index 0000000..b95ac03 --- /dev/null +++ b/src/lookup.rs @@ -0,0 +1,364 @@ +//! The self-hosted address lookup service that custom relays require, and the +//! secret that guards it. +//! +//! With custom relays nothing publishes to n0's infrastructure, so a peer +//! that changes its home relay would be unreachable to everyone who only +//! knows the old one. A self-hosted [pkarr] service (an `iroh-dns-server`) +//! is the publish path that puts the standard iroh failover back: servers +//! publish their relay URL there and clients resolve it. It is served behind +//! a capability URL — the whole service sits under `//` and a reverse +//! proxy 404s everything else — so a [`LookupSecret`] is the credential and +//! the crate composes `//pkarr` itself. +//! +//! The design and the deployment recipe are in +//! +//! (`relays-and-address-lookup.md` and `self-hosting.md`). +//! +//! [pkarr]: https://pkarr.org + +use anyhow::{Context, Result, bail}; +use std::fmt; +use std::str::FromStr; +use url::Url; + +/// Prefix of every lookup secret. The `1` is the format version. +pub const LOOKUP_SECRET_PREFIX: &str = "lks1-"; + +/// Random bytes in a secret. +const SECRET_BYTES: usize = 20; +/// CRC-32 appended to the random bytes so a typo fails at config load. +const CHECKSUM_BYTES: usize = 4; +/// z-base-32 characters encoding `SECRET_BYTES + CHECKSUM_BYTES` bytes. +const ENCODED_LEN: usize = 39; + +/// The z-base-32 alphabet: lowercase only, no padding, chosen for a value +/// that lives in a URL path and gets typed by hand. +const Z_BASE_32: &[u8; 32] = b"ybndrfg8ejkmcpqxot1uwisza345h769"; + +/// A validated lookup secret: `lks1-` followed by 39 z-base-32 characters +/// encoding 20 random bytes and their CRC-32. +/// +/// Both sides of a connection carry the same secret. Parsing checks the +/// prefix, the alphabet (lowercase only — the token is never case-folded, +/// so a shouted copy is rejected rather than silently accepted) and the +/// checksum, so a mistyped secret fails when the configuration is loaded +/// instead of as a `404` from the lookup service at runtime. +#[derive(Clone, PartialEq, Eq)] +pub struct LookupSecret(String); + +impl LookupSecret { + /// A fresh random secret. + pub fn generate() -> Self { + let mut bytes = [0u8; SECRET_BYTES]; + getrandom::fill(&mut bytes).expect("operating system randomness"); + Self::from_bytes(&bytes) + } + + fn from_bytes(bytes: &[u8; SECRET_BYTES]) -> Self { + let mut payload = [0u8; SECRET_BYTES + CHECKSUM_BYTES]; + payload[..SECRET_BYTES].copy_from_slice(bytes); + payload[SECRET_BYTES..].copy_from_slice(&crc32(bytes).to_be_bytes()); + let encoded = z_base_32_encode(&payload); + debug_assert_eq!(encoded.len(), ENCODED_LEN); + Self(format!("{LOOKUP_SECRET_PREFIX}{encoded}")) + } + + /// Validate a configured secret. Surrounding whitespace is ignored; + /// nothing else is normalized. + pub fn parse(secret: &str) -> Result { + let secret = secret.trim(); + let Some(encoded) = secret.strip_prefix(LOOKUP_SECRET_PREFIX) else { + bail!( + "lookup_secret must start with `{LOOKUP_SECRET_PREFIX}` (generate one with the program's generate-lookup-secret command)" + ); + }; + if encoded.len() != ENCODED_LEN { + bail!( + "lookup_secret must be `{LOOKUP_SECRET_PREFIX}` followed by {ENCODED_LEN} characters, got {}", + encoded.len() + ); + } + let payload = z_base_32_decode(encoded).context( + "lookup_secret has an invalid character (only lowercase z-base-32 letters and digits are allowed)", + )?; + let (bytes, checksum) = payload.split_at(SECRET_BYTES); + if checksum != crc32(bytes).to_be_bytes() { + bail!("lookup_secret checksum does not match: the secret was mistyped or truncated"); + } + Ok(Self(secret.to_string())) + } + + /// The full token, prefix included. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for LookupSecret { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl fmt::Display for LookupSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Redacted: the secret is a credential and never belongs in logs. +impl fmt::Debug for LookupSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("LookupSecret()") + } +} + +/// Where the lookup service is and how to get in: the pair every program +/// configures as `lookup_url` and `lookup_secret`. +#[derive(Clone, PartialEq, Eq)] +pub struct LookupConfig { + url: Url, + secret: LookupSecret, +} + +impl LookupConfig { + /// Validate the configured pair. + /// + /// `url` is the scheme and host of the service only, e.g. + /// `https://lookup.example.com`: a path, query, fragment, or credentials + /// are rejected because the crate owns the layout beneath the host (see + /// [`Self::pkarr_url`]). + pub fn new(url: &str, secret: &str) -> Result { + let url = url.trim(); + let parsed = Url::parse(url).with_context(|| format!("Invalid lookup_url: {url}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + bail!("lookup_url must use http or https, got `{}`", parsed.scheme()); + } + if parsed.host_str().is_none() { + bail!("lookup_url has no host: {url}"); + } + if !matches!(parsed.path(), "" | "/") { + bail!( + "lookup_url must not have a path (got `{}`): give only the scheme and host, the `//pkarr` layout is added automatically", + parsed.path() + ); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + bail!("lookup_url must not have a query or fragment: {url}"); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + bail!("lookup_url must not carry credentials; the lookup_secret is the credential"); + } + Ok(Self { + url: parsed, + secret: LookupSecret::parse(secret)?, + }) + } + + /// The configured base URL (scheme and host). + pub fn url(&self) -> &Url { + &self.url + } + + /// The secret. + pub fn secret(&self) -> &LookupSecret { + &self.secret + } + + /// The pkarr endpoint iroh publishes to and resolves from: + /// `//pkarr`. iroh appends `/`. + pub fn pkarr_url(&self) -> Url { + let mut url = self.url.clone(); + url.path_segments_mut() + .expect("http(s) URLs have path segments") + .pop_if_empty() + .push(self.secret.as_str()) + .push("pkarr"); + url + } + + /// The service as it may be named in logs: scheme and host, no secret. + pub fn display_host(&self) -> String { + match self.url.port() { + Some(port) => format!( + "{}://{}:{port}", + self.url.scheme(), + self.url.host_str().unwrap_or_default() + ), + None => format!( + "{}://{}", + self.url.scheme(), + self.url.host_str().unwrap_or_default() + ), + } + } +} + +/// Redacted secret; the host is shown. +impl fmt::Debug for LookupConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LookupConfig") + .field("url", &self.url.as_str()) + .field("secret", &self.secret) + .finish() + } +} + +/// CRC-32 (IEEE 802.3, as in zip and PNG) of `data`. +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc ^= u32::from(byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); + } + } + !crc +} + +/// z-base-32, unpadded, most significant bit first. +fn z_base_32_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8); + let mut buffer: u32 = 0; + let mut bits = 0u32; + for &byte in bytes { + buffer = (buffer << 8) | u32::from(byte); + bits += 8; + while bits >= 5 { + bits -= 5; + out.push(Z_BASE_32[((buffer >> bits) & 0x1F) as usize] as char); + } + } + if bits > 0 { + out.push(Z_BASE_32[((buffer << (5 - bits)) & 0x1F) as usize] as char); + } + out +} + +/// Inverse of [`z_base_32_encode`]. Strict: unknown characters (uppercase +/// included) and non-zero padding bits are errors. +fn z_base_32_decode(text: &str) -> Result> { + let mut out = Vec::with_capacity(text.len() * 5 / 8); + let mut buffer: u32 = 0; + let mut bits = 0u32; + for ch in text.bytes() { + let value = Z_BASE_32 + .iter() + .position(|&c| c == ch) + .with_context(|| format!("invalid z-base-32 character `{}`", ch as char))?; + buffer = (buffer << 5) | value as u32; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push(((buffer >> bits) & 0xFF) as u8); + } + } + if bits >= 5 || (buffer & ((1 << bits) - 1)) != 0 { + bail!("invalid z-base-32 padding"); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc32_matches_the_ieee_check_value() { + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + assert_eq!(crc32(b""), 0); + } + + #[test] + fn z_base_32_round_trips_and_rejects_bad_input() { + for len in 0..12 { + let bytes: Vec = (0..len).map(|i| (i * 37 + 11) as u8).collect(); + let encoded = z_base_32_encode(&bytes); + assert!(encoded.bytes().all(|c| Z_BASE_32.contains(&c))); + assert_eq!(z_base_32_decode(&encoded).unwrap(), bytes); + } + // Known vector from the z-base-32 spec: 0xf0bf c7 -> "6n9hq". + assert_eq!(z_base_32_encode(&[0xf0, 0xbf, 0xc7]), "6n9hq"); + assert!(z_base_32_decode("6N9HQ").is_err(), "uppercase is not folded"); + assert!(z_base_32_decode("l").is_err(), "l is not in the alphabet"); + } + + #[test] + fn generated_secret_has_the_documented_shape_and_parses() { + let secret = LookupSecret::generate(); + let text = secret.to_string(); + assert!(text.starts_with(LOOKUP_SECRET_PREFIX)); + assert_eq!(text.len(), LOOKUP_SECRET_PREFIX.len() + ENCODED_LEN); + assert_eq!(text.to_lowercase(), text, "secret must be lowercase"); + assert_eq!(LookupSecret::parse(&text).unwrap(), secret); + assert_eq!(format!(" {text}\n").parse::().unwrap(), secret); + assert_ne!(LookupSecret::generate(), secret); + } + + #[test] + fn a_typo_fails_the_checksum() { + let text = LookupSecret::generate().to_string(); + let mut chars: Vec = text.chars().collect(); + let i = LOOKUP_SECRET_PREFIX.len() + 3; + chars[i] = if chars[i] == 'y' { 'b' } else { 'y' }; + let typo: String = chars.into_iter().collect(); + let err = LookupSecret::parse(&typo).expect_err("typo must be rejected"); + assert!(err.to_string().contains("checksum"), "unexpected error: {err}"); + + let truncated = &text[..text.len() - 1]; + let err = LookupSecret::parse(truncated).expect_err("truncation must be rejected"); + assert!(err.to_string().contains("39 characters"), "unexpected error: {err}"); + + let err = LookupSecret::parse(&text.to_uppercase()).expect_err("uppercase must be rejected"); + assert!(err.to_string().contains("must start with"), "unexpected error: {err}"); + + let shouted = format!("{LOOKUP_SECRET_PREFIX}{}", text[LOOKUP_SECRET_PREFIX.len()..].to_uppercase()); + let err = LookupSecret::parse(&shouted).expect_err("uppercase body must be rejected"); + assert!(err.to_string().contains("invalid character"), "unexpected error: {err}"); + + let err = LookupSecret::parse("secret").expect_err("wrong prefix must be rejected"); + assert!(err.to_string().contains("must start with"), "unexpected error: {err}"); + } + + #[test] + fn secret_debug_is_redacted() { + let secret = LookupSecret::generate(); + let dbg = format!("{secret:?}"); + assert!(!dbg.contains(&secret.to_string()[10..]), "secret leaked: {dbg}"); + assert!(dbg.contains("")); + } + + #[test] + fn lookup_url_is_scheme_and_host_only() { + let secret = LookupSecret::generate().to_string(); + let cfg = LookupConfig::new("https://lookup.example.com", &secret).unwrap(); + assert_eq!( + cfg.pkarr_url().as_str(), + format!("https://lookup.example.com/{secret}/pkarr") + ); + assert_eq!(cfg.display_host(), "https://lookup.example.com"); + + let cfg = LookupConfig::new(" http://127.0.0.1:8053/ ", &secret).unwrap(); + assert_eq!(cfg.pkarr_url().as_str(), format!("http://127.0.0.1:8053/{secret}/pkarr")); + assert_eq!(cfg.display_host(), "http://127.0.0.1:8053"); + + for bad in [ + "https://lookup.example.com/pkarr", + "https://lookup.example.com/?x=1", + "https://lookup.example.com/#frag", + "https://user:pw@lookup.example.com", + "ftp://lookup.example.com", + "lookup.example.com", + ] { + assert!(LookupConfig::new(bad, &secret).is_err(), "accepted {bad}"); + } + assert!(LookupConfig::new("https://lookup.example.com", "lks1-nope").is_err()); + + let dbg = format!("{cfg:?}"); + assert!(dbg.contains("127.0.0.1:8053") && dbg.contains("")); + assert!(!dbg.contains(&secret[10..]), "secret leaked: {dbg}"); + } +} diff --git a/src/relay.rs b/src/relay.rs index d04d640..7440291 100644 --- a/src/relay.rs +++ b/src/relay.rs @@ -1,10 +1,11 @@ -//! Relay configuration, the shared relay auth token, and the per-relay startup -//! probe. +//! Relay configuration, the shared relay auth token, the address lookup +//! service custom relays require, and the per-relay startup probe. //! //! The design is documented in //! (see //! `relays-and-address-lookup.md`); this module is its implementation. +use crate::lookup::LookupConfig; use anyhow::{Context, Result}; use futures::future::join_all; use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl, endpoint::presets}; @@ -17,16 +18,35 @@ use std::time::Duration; /// online before that is treated as a relay connectivity failure. pub const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Relay configuration, resolved once from the raw config strings. +/// The raw relay settings a program collects from its config file, command +/// line, and environment, before [`RelayConfig::resolve`] validates them. +/// +/// Blank strings are treated as unset, so a program can pass through empty +/// config values without normalizing them first. +#[derive(Debug, Clone, Default)] +pub struct RelaySettings { + /// Custom relay URLs; empty selects the default relays. + pub relay_urls: Vec, + /// Shared bearer token for the custom relays. + pub relay_auth_token: Option, + /// Scheme and host of the self-hosted address lookup service. + pub lookup_url: Option, + /// The lookup service's secret (`lks1-...`). + pub lookup_secret: Option, +} + +/// Relay configuration, resolved once from the raw settings. /// /// This is the single source of the default-vs-custom distinction. It selects -/// both which relay map iroh uses **and** whether iroh *internet* discovery is -/// enabled: [`Default`](Self::Default) uses the n0 relays with the n0 lookup -/// stack (pkarr publishing + DNS resolution of the peer's home relay — see +/// both which relay map iroh uses **and** where address lookup happens: +/// [`Default`](Self::Default) uses the n0 relays with the n0 lookup stack +/// (pkarr publishing + DNS resolution of the peer's home relay — see /// ), while -/// [`Custom`](Self::Custom) uses the configured relays with n0 internet -/// discovery disabled (dialers use relay hints instead). mDNS local-network -/// discovery is independent of this choice (see the `mdns` feature). +/// [`Custom`](Self::Custom) uses the configured relays with a self-hosted +/// lookup service in place of n0's, which is why that service is mandatory: +/// without a publish path a peer that moves to another relay is unreachable, +/// and the standard iroh failover cannot work. mDNS local-network discovery +/// is independent of this choice (see the `mdns` feature). #[derive(Clone, PartialEq, Eq, Default)] pub enum RelayConfig { /// iroh's default relay map, with n0 address lookup. @@ -42,24 +62,34 @@ pub enum RelayConfig { /// `auth_token`, when set, is sent to every custom relay as an /// `Authorization: Bearer ` header on the WebSocket upgrade (see /// [`Self::relay_mode`]). It is only ever carried by custom relays — the - /// default relays never receive a token (see [`Self::from_urls_with_token`]). + /// default relays never receive a token (see [`Self::resolve`]). + /// + /// `lookup` is the self-hosted address lookup service: servers publish + /// their relay URL to it and clients resolve peers from it. Custom { urls: Vec, auth_token: Option, + lookup: LookupConfig, }, } /// Manual `Debug` so the relay auth token is never written to logs or error /// messages: `Custom.auth_token` is shown only as a redacted marker (present -/// vs. absent), while `urls` keep their normal `Debug` formatting. +/// vs. absent), while `urls` keep their normal `Debug` formatting (and the +/// lookup config redacts its own secret). impl fmt::Debug for RelayConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Default => f.write_str("Default"), - Self::Custom { urls, auth_token } => f + Self::Custom { + urls, + auth_token, + lookup, + } => f .debug_struct("Custom") .field("urls", urls) .field("auth_token", &auth_token.as_ref().map(|_| RedactedToken)) + .field("lookup", lookup) .finish(), } } @@ -74,39 +104,60 @@ impl fmt::Debug for RedactedToken { } } -impl RelayConfig { - /// Parse raw config strings with no relay auth token. - /// - /// Thin wrapper over [`Self::from_urls_with_token`]; see there for behavior. - pub fn from_urls(urls: &[String]) -> Result { - Self::from_urls_with_token(urls, None) - } +/// Blank or whitespace-only settings count as unset. +fn non_blank(value: Option) -> Option { + value.and_then(|value| { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + }) +} - /// Parse raw config strings and attach an optional shared relay auth token. - /// - /// Empty input selects the default relays. Parsing fails on the first - /// malformed URL, so config typos surface at resolve time instead of at each - /// use site. +/// Where the mandatory lookup service is explained. +const LOOKUP_DOCS: &str = + "https://github.com/flexaccessdev/iroh-common-architecture/blob/main/relays-and-address-lookup.md#custom-relays"; + +impl RelayConfig { + /// Validate the raw settings into a relay configuration. /// - /// The token is normalized (blank/whitespace-only becomes `None`) and is - /// **strictly gated to custom relays**: a non-empty token with no custom - /// relay URLs is a hard error, since the default iroh relays never take a - /// token. This surfaces the misconfiguration before the endpoint starts. - pub fn from_urls_with_token(urls: &[String], auth_token: Option) -> Result { - let auth_token = auth_token.and_then(|token| { - let token = token.trim(); - (!token.is_empty()).then(|| token.to_string()) - }); - if urls.is_empty() { + /// No relay URLs selects the default relays; then the auth token and the + /// lookup pair must be unset, since the default relays take no token and + /// use n0's lookup. Custom relay URLs are parsed (failing on the first + /// malformed one, so config typos surface at resolve time instead of at + /// each use site) and **require** both `lookup_url` and `lookup_secret`; + /// the secret's checksum is verified here. Every misconfiguration is a + /// hard error before any endpoint starts. + pub fn resolve(settings: RelaySettings) -> Result { + let auth_token = non_blank(settings.relay_auth_token); + let lookup_url = non_blank(settings.lookup_url); + let lookup_secret = non_blank(settings.lookup_secret); + if settings.relay_urls.is_empty() { if auth_token.is_some() { anyhow::bail!( "relay_auth_token requires custom relay_urls; it is not used with the default iroh relays" ); } + if lookup_url.is_some() || lookup_secret.is_some() { + anyhow::bail!( + "lookup_url and lookup_secret require custom relay_urls; the default iroh relays use n0's address lookup" + ); + } return Ok(Self::Default); } - let mut parsed: Vec = Vec::with_capacity(urls.len()); - for url in urls { + let lookup = match (lookup_url, lookup_secret) { + (Some(url), Some(secret)) => LookupConfig::new(&url, &secret)?, + (url, secret) => { + let missing = match (url.is_some(), secret.is_some()) { + (false, false) => "lookup_url and lookup_secret", + (false, true) => "lookup_url", + _ => "lookup_secret", + }; + anyhow::bail!( + "custom relay_urls require a self-hosted address lookup service: {missing} not set (see {LOOKUP_DOCS})" + ); + } + }; + let mut parsed: Vec = Vec::with_capacity(settings.relay_urls.len()); + for url in &settings.relay_urls { let url = url .parse::() .with_context(|| format!("Invalid relay URL: {url}"))?; @@ -117,6 +168,7 @@ impl RelayConfig { Ok(Self::Custom { urls: parsed, auth_token, + lookup, }) } @@ -136,6 +188,14 @@ impl RelayConfig { } } + /// The self-hosted lookup service (custom relays only). + pub fn lookup(&self) -> Option<&LookupConfig> { + match self { + Self::Default => None, + Self::Custom { lookup, .. } => Some(lookup), + } + } + pub fn is_custom(&self) -> bool { matches!(self, Self::Custom { .. }) } @@ -148,7 +208,9 @@ impl RelayConfig { pub fn relay_mode(&self) -> RelayMode { match self { Self::Default => RelayMode::Default, - Self::Custom { urls, auth_token } => { + Self::Custom { + urls, auth_token, .. + } => { let map = RelayMap::from_iter(urls.iter().cloned()); let map = match auth_token { Some(token) => map.with_auth_token(token.clone()), @@ -159,18 +221,27 @@ impl RelayConfig { } } - /// Log which relays are in use (silent for the default relays). Only ever - /// reports *whether* an auth token is set — never the token itself. + /// Log which relays and lookup service are in use (silent for the default + /// relays). Only ever reports *whether* an auth token is set — never the + /// token — and names the lookup service by host, never its secret. pub fn log_status(&self) { - let auth = if self.relay_auth_token().is_some() { + let Self::Custom { + urls, + auth_token, + lookup, + } = self + else { + return; + }; + let auth = if auth_token.is_some() { " (authenticated)" } else { "" }; - match self.custom_urls().len() { - 0 => {} - 1 => info!("Using custom relay server{auth}"), - n => info!("Using {n} custom relay servers with failover{auth}"), + let host = lookup.display_host(); + match urls.len() { + 1 => info!("Using custom relay server{auth}; address lookup via {host}"), + n => info!("Using {n} custom relay servers with failover{auth}; address lookup via {host}"), } } } @@ -236,7 +307,10 @@ async fn probe_relay(relay_url: &RelayUrl, auth_token: Option<&str>) -> Result<( /// rebuild's recovery through the one relay that still answers, so /// [`crate::endpoint::rebuild_endpoint`] deliberately skips it. pub async fn probe_custom_relays(relay_config: &RelayConfig) -> Result<()> { - let RelayConfig::Custom { urls, auth_token } = relay_config else { + let RelayConfig::Custom { + urls, auth_token, .. + } = relay_config + else { return Ok(()); }; let token = auth_token.as_deref(); @@ -264,39 +338,99 @@ pub async fn probe_custom_relays(relay_config: &RelayConfig) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::lookup::LookupSecret; const RELAY: &str = "https://relay.example.com./"; + const LOOKUP: &str = "https://lookup.example.com"; + + fn custom(urls: &[&str]) -> RelaySettings { + RelaySettings { + relay_urls: urls.iter().map(ToString::to_string).collect(), + relay_auth_token: None, + lookup_url: Some(LOOKUP.to_string()), + lookup_secret: Some(LookupSecret::generate().to_string()), + } + } #[test] - fn empty_urls_no_token_is_default() { - let cfg = RelayConfig::from_urls_with_token(&[], None).unwrap(); + fn empty_settings_are_default() { + let cfg = RelayConfig::resolve(RelaySettings::default()).unwrap(); assert_eq!(cfg, RelayConfig::Default); assert!(!cfg.is_custom()); assert_eq!(cfg.relay_auth_token(), None); + assert!(cfg.lookup().is_none()); + assert!(matches!(cfg.relay_mode(), RelayMode::Default)); } #[test] - fn blank_token_without_urls_is_default() { - // A whitespace-only token normalizes to None, so it is not an error. - let cfg = RelayConfig::from_urls_with_token(&[], Some(" ".to_string())).unwrap(); + fn blank_values_without_urls_are_default() { + // Whitespace-only values normalize to unset, so they are not errors. + let cfg = RelayConfig::resolve(RelaySettings { + relay_urls: vec![], + relay_auth_token: Some(" ".to_string()), + lookup_url: Some(" ".to_string()), + lookup_secret: Some("".to_string()), + }) + .unwrap(); assert_eq!(cfg, RelayConfig::Default); } #[test] fn token_without_custom_urls_is_error() { - let err = RelayConfig::from_urls_with_token(&[], Some("secret".to_string())) - .expect_err("token without custom relays must be rejected"); + let err = RelayConfig::resolve(RelaySettings { + relay_auth_token: Some("secret".to_string()), + ..RelaySettings::default() + }) + .expect_err("token without custom relays must be rejected"); + assert!( + err.to_string().contains("relay_auth_token requires custom relay_urls"), + "unexpected error: {err}" + ); + } + + #[test] + fn lookup_without_custom_urls_is_error() { + let err = RelayConfig::resolve(RelaySettings { + lookup_url: Some(LOOKUP.to_string()), + ..RelaySettings::default() + }) + .expect_err("lookup without custom relays must be rejected"); + assert!( + err.to_string().contains("require custom relay_urls"), + "unexpected error: {err}" + ); + } + + #[test] + fn custom_urls_require_the_lookup_pair() { + let mut settings = custom(&[RELAY]); + settings.lookup_secret = None; + let err = RelayConfig::resolve(settings).expect_err("missing secret must be rejected"); + assert!(err.to_string().contains("lookup_secret not set"), "unexpected error: {err}"); + + let mut settings = custom(&[RELAY]); + settings.lookup_url = None; + let err = RelayConfig::resolve(settings).expect_err("missing url must be rejected"); + assert!(err.to_string().contains("lookup_url not set"), "unexpected error: {err}"); + + let mut settings = custom(&[RELAY]); + settings.lookup_url = None; + settings.lookup_secret = None; + let err = RelayConfig::resolve(settings).expect_err("missing pair must be rejected"); assert!( - err.to_string() - .contains("relay_auth_token requires custom relay_urls"), + err.to_string().contains("lookup_url and lookup_secret not set"), "unexpected error: {err}" ); + + let mut settings = custom(&[RELAY]); + settings.lookup_secret = Some("lks1-typo".to_string()); + let err = RelayConfig::resolve(settings).expect_err("bad secret must be rejected"); + assert!(err.to_string().contains("lookup_secret"), "unexpected error: {err}"); } #[test] - fn malformed_custom_url_is_rejected_without_token() { - // Custom relays are always parse-validated, independent of any token. - let err = RelayConfig::from_urls_with_token(&["not a url".to_string()], None) + fn malformed_custom_url_is_rejected() { + let err = RelayConfig::resolve(custom(&["not a url"])) .expect_err("malformed relay URL must be rejected"); assert!( err.to_string().contains("Invalid relay URL"), @@ -305,11 +439,17 @@ mod tests { } #[test] - fn custom_urls_without_token() { - let cfg = RelayConfig::from_urls_with_token(&[RELAY.to_string()], None).unwrap(); + fn custom_urls_with_lookup() { + let settings = custom(&[RELAY]); + let secret = settings.lookup_secret.clone().unwrap(); + let cfg = RelayConfig::resolve(settings).unwrap(); assert!(cfg.is_custom()); assert_eq!(cfg.custom_urls().len(), 1); assert_eq!(cfg.relay_auth_token(), None); + assert_eq!( + cfg.lookup().unwrap().pkarr_url().as_str(), + format!("{LOOKUP}/{secret}/pkarr") + ); assert!(matches!(cfg.relay_mode(), RelayMode::Custom(_))); } @@ -317,59 +457,48 @@ mod tests { fn custom_urls_keep_configured_order_while_deduping() { // A relay-only dialer walks custom_urls() in order, so the configured // order is the failover order and must survive dedup unsorted. - let cfg = RelayConfig::from_urls(&[ - "https://b.example.com./".to_string(), - "https://a.example.com./".to_string(), - "https://b.example.com./".to_string(), - ]) + let cfg = RelayConfig::resolve(custom(&[ + "https://b.example.com./", + "https://a.example.com./", + "https://b.example.com./", + ])) .unwrap(); let urls: Vec = cfg.custom_urls().iter().map(ToString::to_string).collect(); assert_eq!(urls, ["https://b.example.com./", "https://a.example.com./"]); } #[test] - fn custom_urls_with_token_trimmed() { - let cfg = - RelayConfig::from_urls_with_token(&[RELAY.to_string()], Some(" secret\n".to_string())) - .unwrap(); - assert!(cfg.is_custom()); + fn token_is_trimmed() { + let mut settings = custom(&[RELAY]); + settings.relay_auth_token = Some(" secret\n".to_string()); + let cfg = RelayConfig::resolve(settings).unwrap(); assert_eq!(cfg.relay_auth_token(), Some("secret")); - assert!(matches!(cfg.relay_mode(), RelayMode::Custom(_))); - } - #[test] - fn token_is_trimmed_to_none_with_custom_urls() { // A blank token alongside custom relays is simply no token, not an error. - let cfg = RelayConfig::from_urls_with_token(&[RELAY.to_string()], Some(" ".to_string())) - .unwrap(); - assert!(cfg.is_custom()); + let mut settings = custom(&[RELAY]); + settings.relay_auth_token = Some(" ".to_string()); + let cfg = RelayConfig::resolve(settings).unwrap(); assert_eq!(cfg.relay_auth_token(), None); } #[test] - fn debug_output_redacts_auth_token() { - let cfg = - RelayConfig::from_urls_with_token(&[RELAY.to_string()], Some("secret".to_string())) - .unwrap(); + fn debug_output_redacts_secrets() { + let mut settings = custom(&[RELAY]); + settings.relay_auth_token = Some("hunter2".to_string()); + let lookup_secret = settings.lookup_secret.clone().unwrap(); + let cfg = RelayConfig::resolve(settings).unwrap(); let dbg = format!("{cfg:?}"); - assert!( - !dbg.contains("secret"), - "token leaked in Debug output: {dbg}" - ); + assert!(!dbg.contains("hunter2"), "token leaked in Debug output: {dbg}"); + assert!(!dbg.contains(&lookup_secret[10..]), "lookup secret leaked: {dbg}"); assert!(dbg.contains(""), "unexpected Debug output: {dbg}"); assert!(dbg.contains(RELAY), "urls missing from Debug output: {dbg}"); + assert!(dbg.contains(LOOKUP), "lookup host missing from Debug output: {dbg}"); - let no_token = RelayConfig::from_urls(&[RELAY.to_string()]).unwrap(); + let no_token = RelayConfig::resolve(custom(&[RELAY])).unwrap(); assert!(format!("{no_token:?}").contains("auth_token: None")); assert_eq!(format!("{:?}", RelayConfig::Default), "Default"); } - #[test] - fn from_urls_carries_no_token() { - let cfg = RelayConfig::from_urls(&[RELAY.to_string()]).unwrap(); - assert_eq!(cfg.relay_auth_token(), None); - } - #[tokio::test] async fn default_relays_are_not_probed() { // Must return immediately without touching the network. diff --git a/src/relay_watchdog.rs b/src/relay_watchdog.rs deleted file mode 100644 index 20da4e5..0000000 --- a/src/relay_watchdog.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! Server-side home-relay watchdog. -//! -//! A server configured with custom relays is reachable to off-LAN clients -//! *only* through its home relay: with n0 discovery off, clients dial with -//! relay hints, and a relay forwards QUIC Initials only to endpoints currently -//! registered on it. iroh keeps that registration alive on its own, but it has -//! been observed (v1.0.3, relays behind Cloudflare tunnels that reset idle -//! WebSockets roughly hourly) to silently lose its home relay for good after -//! one such reset: no dial retries, no warnings, no registration on any relay — -//! the server just stops being dialable until the process is restarted, while -//! LAN clients that find it over mDNS keep working and mask the outage. -//! -//! [`watch_home_relay`] observes [`Endpoint::home_relay_status`] and reacts in -//! two steps, mirroring the client's reconnect escalation: -//! -//! 1. after [`RELAY_OUTAGE_NUDGE`] without a connected home relay it calls -//! [`Endpoint::network_change`], which forces a fresh net report and relay -//! re-selection (enough when only the bookkeeping went stale); -//! 2. after the caller's rebuild deadline ([`RELAY_OUTAGE_REBUILD`] by -//! default) it resolves, telling the caller to replace the endpoint — the -//! in-process equivalent of the restart that is known to fix it. The caller -//! (the server's serve loop) closes the wedged endpoint, binds a fresh one -//! with the same identity (see [`crate::endpoint::rebuild_endpoint`]), and -//! serves on again. -//! -//! The resolution also says whether a home relay was connected at *any* point -//! of the watch ([`RelayOutage::relay_seen`]). A rebuilt endpoint that never -//! registers is a sign the relay itself is unreachable, not that iroh's -//! bookkeeping went stale; rebuilding it again drops every LAN client for -//! nothing, so the caller backs off between such rebuilds by passing a longer -//! deadline. -//! -//! Only the *home* relay matters: non-home relays are connected on demand and -//! dropped after a minute idle, which is normal and not an outage. - -use iroh::endpoint::RelayStatus; -use iroh::{Endpoint, Watcher}; -use std::future::Future; -use std::time::Duration; -use tokio::time::Instant; - -/// How long the endpoint may go without a connected home relay before the -/// watchdog nudges it with `network_change()`. Long enough to ride out a -/// routine relay reconnect (iroh's own reconnect backoff caps at 16s) plus the -/// ~25s cadence of its periodic net report. -pub const RELAY_OUTAGE_NUDGE: Duration = Duration::from_secs(60); - -/// Default for how long from the start of the outage before the watchdog -/// gives up on the endpoint and asks for a rebuild. Leaves the nudge two -/// minutes to take effect (a net report through slow relays can take tens of -/// seconds). -pub const RELAY_OUTAGE_REBUILD: Duration = Duration::from_secs(180); - -/// A tripped watchdog: the endpoint should be replaced. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RelayOutage { - /// How long the endpoint has had no connected home relay. - pub duration: Duration, - /// Whether a home relay was connected at any point during the watch. - /// `false` means this endpoint never registered at all. - pub relay_seen: bool, -} - -/// Watch `endpoint`'s home-relay status and resolve once it has had no -/// connected home relay for `rebuild_after` (at least -/// [`RELAY_OUTAGE_NUDGE`]; [`RELAY_OUTAGE_REBUILD`] is the usual value), -/// having nudged it with `network_change()` at [`RELAY_OUTAGE_NUDGE`]. Never -/// resolves while the home relay stays connected; a reconnect at any point -/// resets the clock. Pending forever once the endpoint is gone. -pub async fn watch_home_relay(endpoint: &Endpoint, rebuild_after: Duration) -> RelayOutage { - watch_outage( - endpoint.home_relay_status(), - |statuses| describe_statuses(statuses), - || endpoint.network_change(), - rebuild_after, - ) - .await -} - -/// Describe a home-relay status vector for the watchdog: `Ok(())` when some -/// home relay is connected, otherwise `Err(reason)` naming what is wrong. -fn describe_statuses(statuses: &[RelayStatus]) -> Result<(), String> { - if statuses.iter().any(RelayStatus::is_connected) { - return Ok(()); - } - if statuses.is_empty() { - return Err("no home relay selected".into()); - } - let parts: Vec = statuses - .iter() - .map(|s| match s.last_error() { - Some(e) => format!("{} disconnected ({e:#})", s.url()), - None => format!("{} not connected", s.url()), - }) - .collect(); - Err(parts.join("; ")) -} - -/// The watchdog proper, generic over the status source so it can be driven by -/// a plain watchable in tests. `describe` classifies a status value -/// (`Ok` = connected); `nudge` is the first-stage remedy; `rebuild_after` is -/// the outage duration at which the watchdog trips. -async fn watch_outage( - mut watcher: W, - describe: D, - mut nudge: N, - rebuild_after: Duration, -) -> RelayOutage -where - W: Watcher, - D: Fn(&W::Value) -> Result<(), String>, - N: FnMut() -> Fut, - Fut: Future, -{ - let rebuild_after = rebuild_after.max(RELAY_OUTAGE_NUDGE); - let mut outage_since: Option = None; - let mut nudged = false; - let mut relay_seen = false; - let mut value = watcher.get(); - loop { - match describe(&value) { - Ok(()) => { - relay_seen = true; - if let Some(since) = outage_since.take() { - log::info!( - "Home relay connection restored after {:.0}s", - since.elapsed().as_secs_f64() - ); - } - nudged = false; - } - Err(reason) => { - if outage_since.is_none() { - outage_since = Some(Instant::now()); - log::warn!( - "No connected home relay ({reason}); off-LAN clients cannot reach this \ - server until it reconnects" - ); - } - } - } - - let Some(since) = outage_since else { - // Healthy: nothing to time, just wait for the next status change. - value = match watcher.updated().await { - Ok(value) => value, - Err(_disconnected) => std::future::pending().await, - }; - continue; - }; - - let deadline = since - + if nudged { - rebuild_after - } else { - RELAY_OUTAGE_NUDGE - }; - tokio::select! { - _ = tokio::time::sleep_until(deadline) => { - if nudged { - return RelayOutage { - duration: since.elapsed(), - relay_seen, - }; - } - nudged = true; - log::warn!( - "Still no connected home relay after {:.0}s; nudging the endpoint to \ - re-check its network and relays", - since.elapsed().as_secs_f64() - ); - nudge().await; - // The nudge may have already reconnected the relay; re-read - // rather than wait for a change notification we may have - // missed while it ran. - value = watcher.get(); - } - updated = watcher.updated() => { - value = match updated { - Ok(value) => value, - Err(_disconnected) => std::future::pending().await, - }; - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use n0_watcher::Watchable; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// Test double for the home-relay status: `true` = a home relay is - /// connected. - fn describe(connected: &bool) -> Result<(), String> { - if *connected { - Ok(()) - } else { - Err("down".into()) - } - } - - /// Run the watchdog on `status` with the default rebuild deadline, - /// counting nudges. Returns the watchdog future's resolution wrapped in a - /// bounded wait so a test never hangs. - async fn run_for( - status: &Watchable, - nudges: Arc, - bound: Duration, - ) -> Option { - run_with_deadline(status, nudges, bound, RELAY_OUTAGE_REBUILD).await - } - - async fn run_with_deadline( - status: &Watchable, - nudges: Arc, - bound: Duration, - rebuild_after: Duration, - ) -> Option { - let watchdog = watch_outage( - status.watch(), - describe, - || { - let nudges = nudges.clone(); - async move { - nudges.fetch_add(1, Ordering::SeqCst); - } - }, - rebuild_after, - ); - tokio::time::timeout(bound, watchdog).await.ok() - } - - #[tokio::test(start_paused = true)] - async fn healthy_relay_never_trips() { - let status = Watchable::new(true); - let nudges = Arc::new(AtomicUsize::new(0)); - let tripped = run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 3).await; - assert!( - tripped.is_none(), - "healthy relay must never request a rebuild" - ); - assert_eq!(nudges.load(Ordering::SeqCst), 0); - } - - #[tokio::test(start_paused = true)] - async fn sustained_outage_nudges_then_requests_rebuild() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let outage = run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 2) - .await - .expect("a sustained outage must request a rebuild"); - assert_eq!( - nudges.load(Ordering::SeqCst), - 1, - "exactly one nudge before the rebuild" - ); - assert!(outage.duration >= RELAY_OUTAGE_REBUILD); - assert!(outage.duration < RELAY_OUTAGE_REBUILD + Duration::from_secs(1)); - assert!( - !outage.relay_seen, - "a relay that was never connected must be reported as never seen" - ); - } - - #[tokio::test(start_paused = true)] - async fn a_longer_rebuild_deadline_delays_the_trip_but_not_the_nudge() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let rebuild_after = RELAY_OUTAGE_REBUILD * 4; - let nudge_count = nudges.clone(); - let (outage, ()) = tokio::join!( - run_with_deadline(&status, nudges.clone(), rebuild_after * 2, rebuild_after), - async move { - // The nudge still comes at the fixed first-stage deadline. - tokio::time::sleep(RELAY_OUTAGE_NUDGE + Duration::from_secs(1)).await; - assert_eq!(nudge_count.load(Ordering::SeqCst), 1); - } - ); - let outage = outage.expect("a sustained outage must request a rebuild"); - assert!(outage.duration >= rebuild_after); - assert!(outage.duration < rebuild_after + Duration::from_secs(1)); - assert_eq!(nudges.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn recovery_before_the_nudge_resets_the_clock() { - let status = Watchable::new(true); - let nudges = Arc::new(AtomicUsize::new(0)); - let flipper = { - let status = status.clone(); - async move { - // Drop out for half the nudge window, then recover; the - // watchdog must neither nudge nor trip. - tokio::time::sleep(Duration::from_secs(5)).await; - status.set(false).ok(); - tokio::time::sleep(RELAY_OUTAGE_NUDGE / 2).await; - status.set(true).ok(); - } - }; - let (tripped, ()) = tokio::join!( - run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 2), - flipper - ); - assert!(tripped.is_none()); - assert_eq!(nudges.load(Ordering::SeqCst), 0); - } - - #[tokio::test(start_paused = true)] - async fn recovery_after_the_nudge_avoids_the_rebuild() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let flipper = { - let status = status.clone(); - async move { - // Recover between the nudge and the rebuild deadline. - tokio::time::sleep(RELAY_OUTAGE_NUDGE + Duration::from_secs(10)).await; - status.set(true).ok(); - } - }; - let (tripped, ()) = tokio::join!( - run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 2), - flipper - ); - assert!( - tripped.is_none(), - "a relay that came back must not be rebuilt" - ); - assert_eq!(nudges.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn a_second_outage_starts_a_fresh_clock() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let flipper = { - let status = status.clone(); - async move { - // First outage: nudged, then recovers. Second outage: must - // get its own nudge and only trip a full window later. - tokio::time::sleep(RELAY_OUTAGE_NUDGE + Duration::from_secs(10)).await; - status.set(true).ok(); - tokio::time::sleep(Duration::from_secs(10)).await; - status.set(false).ok(); - } - }; - let start = Instant::now(); - let (tripped, ()) = tokio::join!( - run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 3), - flipper - ); - let outage = tripped.expect("second outage must eventually trip"); - assert_eq!(nudges.load(Ordering::SeqCst), 2); - assert!(outage.duration >= RELAY_OUTAGE_REBUILD); - assert!(outage.duration < RELAY_OUTAGE_REBUILD + Duration::from_secs(1)); - assert!( - outage.relay_seen, - "the relay was connected between the outages, so it was seen" - ); - // Second outage began at nudge + 20s; the trip comes a full window after that. - let total = start.elapsed(); - assert!(total >= RELAY_OUTAGE_NUDGE + Duration::from_secs(20) + RELAY_OUTAGE_REBUILD); - } -} diff --git a/tests/lookup.rs b/tests/lookup.rs new file mode 100644 index 0000000..e715508 --- /dev/null +++ b/tests/lookup.rs @@ -0,0 +1,283 @@ +//! The mandatory lookup service end to end, in process: a plain-HTTP relay +//! (the same server `iroh-relay --dev` runs), a pkarr store behind a +//! secret-prefix gate that behaves like the documented Caddy `handle_path` +//! block, a server that must publish through it, and a client that resolves +//! the server through it with **no relay hints** — the standard iroh dial. + +use anyhow::{Context, Result}; +use flexaccess_iroh::endpoint::{EndpointOptions, create_endpoint, endpoint_builder}; +use flexaccess_iroh::lookup::LookupSecret; +use flexaccess_iroh::relay::{RelayConfig, RelaySettings}; +use iroh::endpoint::QuicTransportConfig; +use iroh::{EndpointAddr, SecretKey}; +use iroh_relay::server::{RelayConfig as RelayServerConfig, Server, ServerConfig}; +use std::collections::HashMap; +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +const ALPN: &[u8] = b"flexaccess-iroh/lookup-test"; + +/// A pkarr store gated by a secret path prefix, the way the production +/// reverse proxy gates `iroh-dns-server`: `PUT/GET //pkarr/` +/// are served, everything else is a 404 with no hint of why. +struct GatedPkarr { + url: String, + records: Arc>>>, + rejected: Arc>, + task: JoinHandle<()>, +} + +impl GatedPkarr { + async fn spawn(secret: &LookupSecret) -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let url = format!("http://{}", listener.local_addr()?); + let records: Arc>>> = Arc::default(); + let rejected: Arc> = Arc::default(); + let prefix = format!("/{secret}/pkarr/"); + let task = tokio::spawn({ + let records = records.clone(); + let rejected = rejected.clone(); + async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let records = records.clone(); + let rejected = rejected.clone(); + let prefix = prefix.clone(); + tokio::spawn(async move { + let _ = serve_one(stream, &prefix, &records, &rejected).await; + }); + } + } + }); + Ok(Self { + url, + records, + rejected, + task, + }) + } + + fn record(&self, endpoint_id: &iroh::EndpointId) -> Option> { + self.records.lock().unwrap().get(&endpoint_id.to_z32()).cloned() + } + + fn rejected(&self) -> u32 { + *self.rejected.lock().unwrap() + } +} + +impl Drop for GatedPkarr { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// One HTTP/1.1 request, then close. +async fn serve_one( + stream: tokio::net::TcpStream, + prefix: &str, + records: &Mutex>>, + rejected: &Mutex, +) -> Result<()> { + let mut reader = BufReader::new(stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let path = parts.next().unwrap_or_default().to_string(); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim()) + { + content_length = value.parse().context("content-length")?; + } + } + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).await?; + + let (status, payload): (&str, Vec) = match path.strip_prefix(prefix) { + Some(key) if method == "PUT" => { + records.lock().unwrap().insert(key.to_string(), body); + ("204 No Content", Vec::new()) + } + Some(key) if method == "GET" => match records.lock().unwrap().get(key) { + Some(record) => ("200 OK", record.clone()), + None => ("404 Not Found", Vec::new()), + }, + _ => { + *rejected.lock().unwrap() += 1; + ("404 Not Found", Vec::new()) + } + }; + let mut stream = reader.into_inner(); + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ) + .as_bytes(), + ) + .await?; + stream.write_all(&payload).await?; + stream.shutdown().await?; + Ok(()) +} + +async fn spawn_relay() -> Result<(Server, String)> { + let bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into(); + let mut config = ServerConfig::default(); + config.relay = Some(RelayServerConfig::new(bind)); + let server = Server::spawn(config).await?; + let url = format!("http://{}", server.http_addr().context("relay http addr")?); + Ok((server, url)) +} + +fn settings(relay_url: &str, lookup_url: &str, secret: &LookupSecret) -> RelaySettings { + RelaySettings { + relay_urls: vec![relay_url.to_string()], + relay_auth_token: None, + lookup_url: Some(lookup_url.to_string()), + lookup_secret: Some(secret.to_string()), + } +} + +fn options(publish_address: bool) -> EndpointOptions { + EndpointOptions { + transport_config: QuicTransportConfig::builder().build(), + publish_address, + relay_only: true, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn server_publishes_through_the_gate_and_a_client_dials_by_id_alone() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + let (_relay, relay_url) = spawn_relay().await?; + let secret = LookupSecret::generate(); + let lookup = GatedPkarr::spawn(&secret).await?; + let relay_config = RelayConfig::resolve(settings(&relay_url, &lookup.url, &secret))?; + + let server_key = SecretKey::generate(); + let server_id = server_key.public(); + let server = create_endpoint( + &relay_config, + endpoint_builder(&relay_config, options(true)) + .alpns(vec![ALPN.to_vec()]) + .secret_key(server_key), + true, + ) + .await?; + let record = lookup + .record(&server_id) + .context("server record missing from the lookup store after creation")?; + assert!(!record.is_empty()); + assert_eq!(lookup.rejected(), 0, "the right secret never hits the gate"); + + let accept = tokio::spawn({ + let server = server.clone(); + async move { + let incoming = server.accept().await.context("server closed")?; + let conn = incoming.await?; + let (mut send, mut recv) = conn.accept_bi().await?; + let buf = recv.read_to_end(64).await?; + send.write_all(&buf).await?; + send.finish()?; + conn.closed().await; + anyhow::Ok(buf) + } + }); + + let client = create_endpoint( + &relay_config, + endpoint_builder(&relay_config, options(false)), + false, + ) + .await?; + // No relay hint at all: the client must learn the server's relay from the + // lookup service, exactly as it would after the server moved relays. + let conn = tokio::time::timeout( + Duration::from_secs(15), + client.connect(EndpointAddr::new(server_id), ALPN), + ) + .await + .context("dial by id alone timed out: the lookup record was not resolved")??; + let (mut send, mut recv) = conn.open_bi().await?; + send.write_all(b"via lookup").await?; + send.finish()?; + let echoed = recv.read_to_end(64).await?; + assert_eq!(echoed, b"via lookup"); + conn.close(0u32.into(), b"done"); + assert_eq!(accept.await??, b"via lookup"); + assert!(lookup.rejected() == 0); + + client.close().await; + server.close().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_server_with_the_wrong_secret_does_not_start() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + let (_relay, relay_url) = spawn_relay().await?; + let lookup = GatedPkarr::spawn(&LookupSecret::generate()).await?; + // Well-formed (valid checksum), just not the service's secret: only the + // gate can tell, and it answers 404. + let wrong = LookupSecret::generate(); + let relay_config = RelayConfig::resolve(settings(&relay_url, &lookup.url, &wrong))?; + + let err = create_endpoint( + &relay_config, + endpoint_builder(&relay_config, options(true)) + .alpns(vec![ALPN.to_vec()]) + .secret_key(SecretKey::generate()), + true, + ) + .await + .err() + .context("a rejected publish must fail endpoint creation")?; + let msg = format!("{err:#}"); + assert!( + msg.contains("Failed to publish the address record") && msg.contains("404"), + "unexpected error: {msg}" + ); + assert!(lookup.rejected() >= 1, "the gate saw the rejected publish"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_client_never_publishes() -> Result<()> { + let _ = env_logger::builder().is_test(true).try_init(); + let (_relay, relay_url) = spawn_relay().await?; + let secret = LookupSecret::generate(); + let lookup = GatedPkarr::spawn(&secret).await?; + let relay_config = RelayConfig::resolve(settings(&relay_url, &lookup.url, &secret))?; + let client = create_endpoint( + &relay_config, + endpoint_builder(&relay_config, options(false)), + false, + ) + .await?; + tokio::time::sleep(Duration::from_millis(500)).await; + assert!( + lookup.record(&client.id()).is_none(), + "an endpoint that only dials out must not publish itself" + ); + client.close().await; + Ok(()) +}