diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10f5959d..3e63b2ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,7 +95,7 @@ jobs: for b in $BINS; do cp "target/${{ matrix.target }}/release/$b" "$stage/" done - cp LICENSE README.md "$stage/" 2>/dev/null || true + cp LICENSE README.md THIRD_PARTY_NOTICES.md "$stage/" 2>/dev/null || true tar -czf "$stage.tar.gz" "$stage" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$stage.tar.gz" > "$stage.tar.gz.sha256" diff --git a/Cargo.lock b/Cargo.lock index 3343f1e3..c58e3bab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -15,6 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -80,7 +81,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -91,14 +92,23 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "arrayvec" @@ -106,6 +116,57 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "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.117", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -152,19 +213,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bb8" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" +dependencies = [ + "futures-util", + "parking_lot", + "portable-atomic", + "tokio", +] + [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", ] [[package]] @@ -173,6 +264,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[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" @@ -220,9 +320,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -261,6 +361,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chromiumoxide" version = "0.7.0" @@ -268,7 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8380ce7721cc895fe8a184c49d615fe755b0c9a3d7986355cee847439fff907f" dependencies = [ "async-tungstenite", - "base64", + "base64 0.22.1", "cfg-if", "chromiumoxide_cdp", "chromiumoxide_types", @@ -387,6 +498,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -401,6 +522,21 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "construct-adapter-antigravity" version = "0.14.3" @@ -490,7 +626,7 @@ version = "0.14.3" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "construct-client", @@ -517,7 +653,7 @@ version = "0.14.3" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "clap", @@ -572,7 +708,7 @@ name = "construct-daemon" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "clap", "construct-adapter-common", @@ -596,6 +732,7 @@ dependencies = [ "tracing-subscriber", "uuid", "which", + "wstunnel", ] [[package]] @@ -603,7 +740,7 @@ name = "construct-e2e" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chromiumoxide", "construct-client", "construct-protocol", @@ -621,7 +758,7 @@ name = "construct-mcp" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "construct-client", "construct-protocol", "futures", @@ -642,7 +779,7 @@ name = "construct-protocol" version = "0.14.3" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "futures", "portable-pty", @@ -666,6 +803,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -681,6 +838,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -690,6 +856,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.28.1" @@ -734,6 +930,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -754,6 +962,33 @@ dependencies = [ "phf", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -800,6 +1035,31 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "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" @@ -829,6 +1089,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -847,7 +1108,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -882,12 +1145,71 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -901,7 +1223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -913,6 +1235,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -946,12 +1289,47 @@ dependencies = [ "regex", ] +[[package]] +name = "fast-socks5" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9545787d8304a71e1bf1b711705070a4c400cce9b332c4a11800627b7c9a2067" +dependencies = [ + "anyhow", + "async-trait", + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "tokio-stream", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fastwebsockets" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "305d3ba574508e27190906d11707dad683e0494e6b85eae9b044cb2734a5e422" +dependencies = [ + "base64 0.21.7", + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "pin-project", + "rand 0.8.6", + "sha1", + "simdutf8", + "thiserror 1.0.69", + "tokio", + "utf-8", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -962,10 +1340,26 @@ dependencies = [ ] [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ "libc", "thiserror 1.0.69", @@ -1027,6 +1421,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -1129,6 +1532,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1167,10 +1571,41 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1233,6 +1668,101 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni", + "rand 0.10.2", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1281,19 +1811,27 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1323,7 +1861,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", @@ -1334,7 +1872,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -1515,6 +2053,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.12" @@ -1528,11 +2086,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.5", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -1555,6 +2129,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -1567,6 +2190,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "zeroize", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1578,6 +2223,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lab" version = "0.11.0" @@ -1589,6 +2254,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1602,6 +2270,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1657,9 +2331,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -1744,6 +2418,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1754,6 +2445,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nix" version = "0.28.0" @@ -1779,6 +2476,19 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -1789,13 +2499,66 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", ] [[package]] @@ -1815,6 +2578,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1822,6 +2604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1833,11 +2616,24 @@ dependencies = [ "libc", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -1845,6 +2641,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1854,6 +2656,36 @@ dependencies = [ "num-traits", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1877,6 +2709,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1978,12 +2819,53 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2045,6 +2927,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppp" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7a2049cd2570bd67bf0228e86bf850f8ceb5190a345c471d03a909da6049e0" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2054,6 +2945,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2064,6 +2966,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2098,7 +3009,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -2135,7 +3046,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] @@ -2182,6 +3093,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2220,6 +3142,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -2305,6 +3233,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2316,9 +3257,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -2339,9 +3280,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2349,7 +3290,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -2384,6 +3325,22 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2398,6 +3355,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -2427,6 +3404,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 = "rustix" version = "0.38.44" @@ -2450,7 +3436,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2459,6 +3445,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2467,6 +3454,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2500,12 +3508,67 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2555,6 +3618,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_regex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2576,6 +3649,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" version = "0.2.37" @@ -2594,7 +3680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2605,7 +3691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2671,12 +3757,38 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -2697,12 +3809,38 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", ] [[package]] @@ -2792,6 +3930,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -2799,10 +3964,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2833,7 +3998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.11.1", "fancy-regex", "filedescriptor", @@ -2924,12 +4089,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2938,6 +4105,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2975,11 +4152,21 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-fd" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cedf0b897610a4baff98bf6116c060c5cfe7574d4339c50e9d23fe09377641d" +dependencies = [ + "libc", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -3001,6 +4188,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -3122,6 +4320,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3279,6 +4478,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3297,6 +4502,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3317,9 +4528,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "atomic", "getrandom 0.4.2", @@ -3374,6 +4585,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3631,6 +4852,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -3647,6 +4874,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3694,6 +4930,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4077,6 +5324,86 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wstunnel" +version = "10.6.1" +source = "git+https://github.com/erebe/wstunnel.git?rev=5cac9a52338a21e67254cea5d749f9cc170193be#5cac9a52338a21e67254cea5d749f9cc170193be" +dependencies = [ + "ahash", + "anyhow", + "arc-swap", + "async-channel", + "base64 0.22.1", + "bb8", + "bytes", + "clap", + "crossterm 0.29.0", + "derive_more", + "fast-socks5", + "fastwebsockets", + "futures-util", + "hickory-resolver", + "http-body-util", + "httparse", + "hyper", + "hyper-util", + "ipnet", + "jsonwebtoken", + "log", + "nix 0.31.3", + "notify", + "parking_lot", + "pin-project", + "ppp", + "rcgen", + "regex", + "rustls-native-certs", + "rustls-pemfile", + "scopeguard", + "serde", + "serde_regex", + "serde_yaml", + "socket2 0.6.5", + "tokio", + "tokio-fd", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "uuid", + "x509-parser", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.2" @@ -4146,6 +5473,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index ff3891ea..03038649 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ license = "MIT" authors = ["construct contributors"] repository = "https://github.com/construct-worlds/construct" description = "Cross-harness agent fleet TUI and daemon" -rust-version = "1.75" +rust-version = "1.85" [workspace.dependencies] tokio = { version = "1", features = ["full"] } @@ -59,6 +59,7 @@ eventsource-stream = "0.2" tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } qrcode = { version = "0.14", default-features = false } httparse = "1" +wstunnel = { git = "https://github.com/erebe/wstunnel.git", rev = "5cac9a52338a21e67254cea5d749f9cc170193be", default-features = false, features = ["clap", "ring"] } chromiumoxide = { version = "0.7", default-features = false, features = ["tokio-runtime"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diffy = "0.4" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..9dfcfbe0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,37 @@ +# Third-party notices + +## wstunnel + +Construct links the `wstunnel` Rust library from +. + +BSD 3-Clause License + +Copyright (c) 2016-2024, Erèbe - Romain Gerard + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index d2ef4c59..2787e42b 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -1682,6 +1682,7 @@ pub struct App { pub remote_control_task: Option< tokio::task::JoinHandle<( construct_protocol::TunnelProvider, + Option, Result, )>, >, @@ -2747,6 +2748,7 @@ impl ProgramSmartClipGroup { #[derive(Debug, Clone)] pub enum RemoteControlPopup { Choose(RemoteControlChoose), + Name(RemoteControlName), Starting(RemoteControlOk), Ok { ok: RemoteControlOk, @@ -2759,6 +2761,15 @@ pub enum RemoteControlPopup { }, } +#[derive(Debug, Clone)] +pub struct RemoteControlName { + pub choose: RemoteControlChoose, + pub name: String, + /// The suggestion behaves like selected text: the first typed + /// character replaces it, while Enter accepts it unchanged. + pub pristine: bool, +} + /// The two buttons on the tunnel-ready view. `Back` is the default /// focus so a reflexive Enter is non-destructive — it returns to the /// chooser without killing the tunnel. @@ -2828,12 +2839,14 @@ pub struct RemoteControlOk { pub hint: Option, /// Which provider is publishing `url`, if any. pub provider: construct_protocol::TunnelProvider, - /// Loopback URL. Always shown, because it is the one address that - /// cannot fail, and it is what someone at this machine types. + /// Loopback URL. Shown for direct access because it is the one address + /// that cannot fail, and it is what someone at this machine types. pub local_url: String, /// LAN URL, when this machine has a private address. `None` on a /// machine with no local network to be reached from. pub lan_url: Option, + /// Browser login link while tunnel.zarvis.ai waits for OAuth. + pub auth_url: Option, } impl From for RemoteControlOk { @@ -2847,6 +2860,7 @@ impl From for RemoteControlOk { provider: r.provider, local_url: r.local_url, lan_url: r.lan_url, + auth_url: r.auth_url, } } } @@ -10193,16 +10207,13 @@ impl App { ).await } "construct" | "zarvis" => { - let subdomain = (!rest.is_empty()).then(|| rest.to_string()); - if subdomain.is_none() { - self.set_status( - "usage: /remote-control construct ".to_string(), - ); + if rest.is_empty() { + self.open_remote_control_popup(None).await; } else { self.start_remote_control_provider( TunnelProvider::Construct, None, - subdomain, + Some(rest.to_string()), ).await; } } @@ -10392,14 +10403,32 @@ impl App { let client = self.client.clone(); self.remote_control_task = Some(tokio::spawn(async move { + let remembered_name = subdomain.clone(); let result = client .remote_start_named_with_wait(provider, password, subdomain, true) .await; - (provider, result) + (provider, remembered_name, result) })); } async fn poll_remote_control_task(&mut self) { + let needs_auth_url = matches!( + self.remote_control_popup.as_ref(), + Some(RemoteControlPopup::Starting(ok)) if ok.auth_url.is_none() + ); + if needs_auth_url { + if let Ok(snapshot) = self + .client + .remote_start(construct_protocol::TunnelProvider::None, None) + .await + { + if let Some(RemoteControlPopup::Starting(ok)) = + self.remote_control_popup.as_mut() + { + ok.auth_url = snapshot.auth_url; + } + } + } let Some(task) = self.remote_control_task.as_mut() else { return; }; @@ -10408,7 +10437,14 @@ impl App { }; self.remote_control_task = None; match joined { - Ok((provider, Ok(r))) => { + Ok((provider, subdomain, Ok(r))) => { + if provider == construct_protocol::TunnelProvider::Construct { + if let Some(name) = subdomain.as_deref() { + if let Err(error) = persist_remembered_tunnel_name(name) { + tracing::warn!(%error, "could not remember tunnel name"); + } + } + } let mut ok = RemoteControlOk::from(r); ok.provider = provider; self.remote_control_popup = Some(RemoteControlPopup::Ok { @@ -10416,7 +10452,7 @@ impl App { focus: ReadyButton::Back, }); } - Ok((provider, Err(e))) => { + Ok((provider, _, Err(e))) => { self.remote_control_popup = Some(RemoteControlPopup::Err { provider, message: e.to_string(), @@ -10464,11 +10500,60 @@ impl App { ); return true; } - self.start_remote_control_provider( - opt.provider, - None, - std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok(), - ).await; + if opt.provider == construct_protocol::TunnelProvider::Construct { + self.remote_control_popup = Some(RemoteControlPopup::Name( + RemoteControlName { + choose: choose.clone(), + name: remembered_tunnel_name() + .unwrap_or_else(generated_tunnel_name), + pristine: true, + }, + )); + } else { + self.start_remote_control_provider(opt.provider, None, None).await; + } + } + _ => {} + }, + RemoteControlPopup::Name(name) => match key.code { + KeyCode::Esc => { + self.remote_control_popup = + Some(RemoteControlPopup::Choose(name.choose.clone())); + } + KeyCode::Enter => { + let tunnel_name = name.name.clone(); + if !valid_tunnel_name(&tunnel_name) { + self.set_status( + "use 1–32 lowercase letters, numbers, or hyphens; no leading/trailing hyphen" + .into(), + ); + } else { + self.start_remote_control_provider( + construct_protocol::TunnelProvider::Construct, + None, + Some(tunnel_name), + ) + .await; + } + } + KeyCode::Backspace => { + if name.pristine { + name.name.clear(); + name.pristine = false; + } else { + name.name.pop(); + } + } + KeyCode::Char(c) + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' => + { + if name.pristine { + name.name.clear(); + name.pristine = false; + } + if name.name.len() < 32 { + name.name.push(c); + } } _ => {} }, @@ -10497,11 +10582,18 @@ impl App { }, _ => {} }, - RemoteControlPopup::Starting(_) => { - if matches!(key.code, KeyCode::Esc) { - self.close_remote_control_popup(); + RemoteControlPopup::Starting(starting) => match key.code { + KeyCode::Esc => self.close_remote_control_popup(), + KeyCode::Char('o') => { + if let Some(url) = starting.auth_url.as_deref() { + match open_url(url) { + Ok(()) => self.set_status("opened tunnel login in browser".into()), + Err(error) => self.set_status(format!("could not open login: {error}")), + } + } } - } + _ => {} + }, } true } @@ -10514,9 +10606,12 @@ impl App { if let Some(task) = self.remote_control_task.take() { task.abort(); } - if let Err(e) = self.client.remote_stop_with(true).await { - self.set_status(format!("stop tunnel failed: {e}")); - return; + match self.client.remote_stop_with(true).await { + Ok(result) => logout_after_construct_stop(result.provider, self), + Err(e) => { + self.set_status(format!("stop tunnel failed: {e}")); + return; + } } self.show_remote_chooser(None).await; } @@ -10547,7 +10642,11 @@ impl App { match self.client.remote_stop().await { Ok(r) if r.was_running => { self.remote_control_popup = None; - self.set_status("remote stopped; QR + URL invalidated".into()); + if r.provider == construct_protocol::TunnelProvider::Construct { + logout_after_construct_stop(r.provider, self); + } else { + self.set_status("remote stopped; QR + URL invalidated".into()); + } } Ok(_) => { self.set_status("remote wasn't running".into()); @@ -10557,6 +10656,92 @@ impl App { } } +fn logout_after_construct_stop(provider: construct_protocol::TunnelProvider, app: &mut App) { + let Some(url) = tunnel_logout_url(provider) else { + return; + }; + match open_url(url) { + Ok(()) => app.set_status("tunnel stopped; opened tunnel.zarvis.ai logout".into()), + Err(error) => app.set_status(format!( + "tunnel stopped, but could not open tunnel.zarvis.ai logout: {error}" + )), + } +} + +fn tunnel_logout_url(provider: construct_protocol::TunnelProvider) -> Option<&'static str> { + (provider == construct_protocol::TunnelProvider::Construct) + .then_some("https://tunnel.zarvis.ai/logout") +} + +fn generated_tunnel_name() -> String { + const ADJECTIVES: &[&str] = &[ + "bright", "calm", "clever", "gentle", "lucky", "quiet", "swift", "warm", + ]; + const NOUNS: &[&str] = &[ + "badger", "comet", "falcon", "maple", "otter", "panda", "river", "willow", + ]; + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos() as usize) + .unwrap_or(0) + ^ std::process::id() as usize; + format!( + "{}-{}-{:02}", + ADJECTIVES[seed % ADJECTIVES.len()], + NOUNS[(seed / ADJECTIVES.len()) % NOUNS.len()], + (seed / (ADJECTIVES.len() * NOUNS.len())) % 100 + ) +} + +fn remembered_tunnel_name_path() -> std::path::PathBuf { + construct_protocol::paths::Paths::discover() + .data_dir + .join("tunnel") + .join("last-name") +} + +fn remembered_tunnel_name() -> Option { + remembered_tunnel_name_at(&remembered_tunnel_name_path()) +} + +fn remembered_tunnel_name_at(path: &std::path::Path) -> Option { + let value = std::fs::read_to_string(path).ok()?; + let value = value.trim(); + valid_tunnel_name(value).then(|| value.to_string()) +} + +fn persist_remembered_tunnel_name(value: &str) -> std::io::Result<()> { + persist_remembered_tunnel_name_at(&remembered_tunnel_name_path(), value) +} + +fn persist_remembered_tunnel_name_at( + path: &std::path::Path, + value: &str, +) -> std::io::Result<()> { + if !valid_tunnel_name(value) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid tunnel name", + )); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("tmp"); + std::fs::write(&temporary, format!("{value}\n"))?; + std::fs::rename(temporary, path) +} + +fn valid_tunnel_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 32 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + /// Best-effort one-line summary of a tool call's args JSON for the /// PTY tool-block header. Prefers a single salient field /// (`command` for shell, `path` for read_file, `query` for search- @@ -11758,6 +11943,59 @@ mod tests { use super::*; use ratatui::layout::Rect; + #[test] + fn generated_tunnel_name_is_human_friendly_and_dns_safe() { + let name = generated_tunnel_name(); + let parts: Vec<&str> = name.split('-').collect(); + assert_eq!(parts.len(), 3, "expected adjective-noun-number: {name}"); + assert_eq!(parts[2].len(), 2, "expected two-digit suffix: {name}"); + assert!(valid_tunnel_name(&name)); + } + + #[test] + fn tunnel_name_rejects_invalid_dns_labels() { + assert!(valid_tunnel_name("quiet-otter-42")); + assert!(!valid_tunnel_name("")); + assert!(!valid_tunnel_name("-demo")); + assert!(!valid_tunnel_name("demo-")); + assert!(!valid_tunnel_name("Demo")); + assert!(!valid_tunnel_name(&"a".repeat(33))); + } + + #[test] + fn successful_tunnel_name_can_be_remembered() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tunnel").join("last-name"); + + assert_eq!(remembered_tunnel_name_at(&path), None); + persist_remembered_tunnel_name_at(&path, "quiet-otter-42").unwrap(); + assert_eq!( + remembered_tunnel_name_at(&path).as_deref(), + Some("quiet-otter-42") + ); + assert!(persist_remembered_tunnel_name_at(&path, "Not Valid").is_err()); + assert_eq!( + remembered_tunnel_name_at(&path).as_deref(), + Some("quiet-otter-42") + ); + } + + #[test] + fn only_first_party_stop_has_a_browser_logout() { + assert_eq!( + tunnel_logout_url(construct_protocol::TunnelProvider::Construct), + Some("https://tunnel.zarvis.ai/logout") + ); + assert_eq!( + tunnel_logout_url(construct_protocol::TunnelProvider::Cloudflare), + None + ); + assert_eq!( + tunnel_logout_url(construct_protocol::TunnelProvider::None), + None + ); + } + /// Spec 0065 agent presence: the local receipt clock must renew only when /// the daemon's `updated_at_ms` genuinely advances, never on a rebase /// that leaves it unchanged (GAP D — a rebase must not re-trigger the diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index e3de1e1d..f3c42202 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15381,8 +15381,13 @@ fn render_remote_control_popup(f: &mut Frame, app: &mut App) { let total = f.area(); let (title, title_color, body_lines, body_w, body_h) = match popup { - crate::app::RemoteControlPopup::Choose(c) => render_remote_choose(app, c, total.width), - crate::app::RemoteControlPopup::Starting(p) => render_remote_starting(app, p, total.width), + crate::app::RemoteControlPopup::Choose(c) => { + render_remote_choose(app, c, total.width, total.height) + } + crate::app::RemoteControlPopup::Name(n) => render_remote_name(app, n, total.width), + crate::app::RemoteControlPopup::Starting(p) => { + render_remote_starting(app, p, total.width) + } crate::app::RemoteControlPopup::Ok { ok, focus } => { render_remote_ok(app, ok, *focus, total.width) } @@ -15453,38 +15458,46 @@ fn remote_kv<'a>(app: &App, label: &str, value: &str, accent: bool) -> (Line<'a> } /// Every address and credential the user might need — no QR; that gets -/// composed alongside these by [`compose_qr_and_info`]. +/// composed alongside these by [`compose_qr_and_info`]. Active tunnels show +/// only their public URL so the QR screen stays focused on the shareable link. /// /// The accented row is always the one the QR actually encodes, so /// "which of these am I looking at?" never needs asking: with a tunnel /// up it's the tunnel URL, otherwise it's the LAN address (or loopback /// on a machine with no LAN). -fn remote_info_lines<'a>(app: &App, p: &crate::app::RemoteControlOk) -> Vec> { - let mut lines: Vec = Vec::new(); - +fn remote_address_rows<'a>( + p: &'a crate::app::RemoteControlOk, +) -> Vec<(&'static str, &'a str, bool)> { if p.tunnel_ready { - lines.push(remote_kv(app, "tunnel:", &p.url, true).0); + return vec![("tunnel:", &p.url, true)]; } + + let mut rows = Vec::new(); if let Some(lan) = p.lan_url.as_deref() { - lines.push(remote_kv(app, "lan:", lan, !p.tunnel_ready).0); + rows.push(("lan:", lan, true)); } - // Loopback is always shown: it is the one address that cannot - // fail, and it's what someone sitting at this machine types. - lines.push( - remote_kv( - app, - "local:", - &p.local_url, - !p.tunnel_ready && p.lan_url.is_none(), - ) - .0, - ); + rows.push(("local:", p.local_url.as_str(), p.lan_url.is_none())); + rows +} - // The browser's Basic-auth prompt asks for a username too, and the - // daemon pins it to "remote" — so this row isn't decorative; - // anything else 401s. - lines.push(remote_kv(app, "user:", "remote", true).0); - lines.push(remote_kv(app, "password:", &p.password, true).0); +fn remote_info_lines<'a>( + app: &App, + p: &crate::app::RemoteControlOk, + include_credentials: bool, +) -> Vec> { + let mut lines: Vec = remote_address_rows(p) + .into_iter() + .map(|(label, value, accent)| remote_kv(app, label, value, accent).0) + .collect(); + + if include_credentials { + // Direct LAN and Cloudflare visitors see the browser's Basic-auth + // prompt. The daemon pins its username to "remote", so neither row + // is decorative. tunnel.zarvis.ai instead authenticates socially and + // supplies these upstream credentials inside its gateway. + lines.push(remote_kv(app, "user:", "remote", true).0); + lines.push(remote_kv(app, "password:", &p.password, true).0); + } lines } @@ -15613,12 +15626,25 @@ fn compose_qr_and_info<'a>( /// The dialog's resting state: reachable on the LAN, published nowhere, /// with a row of buttons offering to change that. +fn remote_provider_available_description( + provider: construct_protocol::TunnelProvider, +) -> &'static str { + match provider { + construct_protocol::TunnelProvider::Cloudflare => "No signup, URL rotates each run", + construct_protocol::TunnelProvider::Construct => { + "Stable URL. Google or GitHub OAuth sign-in" + } + construct_protocol::TunnelProvider::None => "", + } +} + fn render_remote_choose<'a>( app: &App, c: &crate::app::RemoteControlChoose, area_w: u16, + area_h: u16, ) -> RemotePopupBody<'a> { - let mut info = remote_info_lines(app, &c.base); + let mut info = remote_info_lines(app, &c.base, true); if let Some(hint) = c.base.hint.as_deref() { info.push(Line::raw("")); @@ -15674,15 +15700,7 @@ fn render_remote_choose<'a>( app.theme.success, ), Some(opt) if opt.available => ( - match opt.provider { - construct_protocol::TunnelProvider::Cloudflare => { - "Anyone with the URL. Rotates each run.".to_string() - } - construct_protocol::TunnelProvider::Construct => { - "Stable name. Visitors sign in with GitHub or Google.".to_string() - } - construct_protocol::TunnelProvider::None => String::new(), - }, + remote_provider_available_description(opt.provider).to_string(), app.theme.dim, ), Some(opt) => ( @@ -15709,9 +15727,55 @@ fn render_remote_choose<'a>( Style::default().fg(app.theme.dim), ))); - let (lines, width, height) = compose_qr_and_info(&c.base.qr, info, area_w); + // On a short terminal the local-network QR would push the provider row + // below the clipped modal. Keep local details textual there; the final + // public tunnel URL still gets its QR in the ready view. + let qr = if area_h >= 30 { &c.base.qr } else { "" }; + let (lines, width, height) = compose_qr_and_info(qr, info, area_w); + ( + " /remote-connect — local network — Esc to close ".to_string(), + app.theme.info, + lines, + width, + height, + ) +} + +fn render_remote_name<'a>( + app: &App, + state: &crate::app::RemoteControlName, + area_w: u16, +) -> RemotePopupBody<'a> { + let mut info = remote_info_lines(app, &state.choose.base, true); + info.push(Line::raw("")); + info.push(Line::from(Span::styled( + "Choose your tunnel name:", + Style::default().fg(app.theme.text), + ))); + info.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + format!(" {} ", state.name), + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::REVERSED | Modifier::BOLD), + ), + Span::styled( + "-.tunnel.zarvis.ai", + Style::default().fg(app.theme.dim), + ), + ])); + info.push(Line::raw("")); + let guidance = if state.pristine { + "Type to replace the suggestion · Enter continue · Esc back" + } else { + "Lowercase letters, numbers, hyphens · Enter continue · Esc back" + }; + info.extend(wrapped_lines(guidance, Style::default().fg(app.theme.dim))); + + let (lines, width, height) = compose_qr_and_info("", info, area_w); ( - " /remote-control — local network — Esc to close ".to_string(), + " /remote-connect — tunnel.zarvis.ai — name your tunnel ".to_string(), app.theme.info, lines, width, @@ -15727,7 +15791,7 @@ fn render_remote_starting<'a>( area_w: u16, ) -> RemotePopupBody<'a> { let label = p.provider.label(); - let mut info = remote_info_lines(app, p); + let mut info = remote_info_lines(app, p, true); info.push(Line::raw("")); info.push(Line::from(Span::styled( format!("Starting {label} tunnel…"), @@ -15735,14 +15799,33 @@ fn render_remote_starting<'a>( .fg(app.theme.warning) .add_modifier(Modifier::BOLD), ))); - info.push(Line::from(Span::styled( - "The QR updates when it publishes a URL.", - Style::default().fg(app.theme.dim), - ))); + if let Some(auth_url) = p.auth_url.as_deref() { + info.push(Line::from(Span::styled( + "Finish signing in in your browser:", + Style::default().fg(app.theme.text), + ))); + info.extend(wrapped_lines(auth_url, Style::default().fg(app.theme.accent))); + info.push(Line::from(Span::styled( + "o open login again · Esc close", + Style::default().fg(app.theme.dim), + ))); + } else { + info.push(Line::from(Span::styled( + "Opening browser login… The QR updates when the tunnel is ready.", + Style::default().fg(app.theme.dim), + ))); + } - let (lines, width, height) = compose_qr_and_info(&p.qr, info, area_w); + // OAuth is the action the user must take now. A local-network QR is + // unrelated and can hide the login link in compact terminals. + let qr = if p.provider == construct_protocol::TunnelProvider::Construct { + "" + } else { + &p.qr + }; + let (lines, width, height) = compose_qr_and_info(qr, info, area_w); ( - format!(" /remote-control — starting {label}… — Esc to close "), + format!(" /remote-connect — starting {label}… — Esc to close "), app.theme.warning, lines, width, @@ -15760,7 +15843,8 @@ fn render_remote_ok<'a>( area_w: u16, ) -> RemotePopupBody<'a> { use crate::app::ReadyButton; - let mut info = remote_info_lines(app, p); + let include_credentials = ready_screen_shows_basic_credentials(p.provider); + let mut info = remote_info_lines(app, p, include_credentials); if let Some(hint) = p.hint.as_deref() { info.push(Line::raw("")); @@ -15799,12 +15883,18 @@ fn render_remote_ok<'a>( ))); let label = p.provider.label(); - let title = format!(" /remote-control — {label} ready (public URL) — Esc to close "); + let title = format!(" /remote-connect — {label} ready (public URL) — Esc to close "); let (lines, width, height) = compose_qr_and_info(&p.qr, info, area_w); (title, app.theme.success, lines, width, height) } +fn ready_screen_shows_basic_credentials( + provider: construct_protocol::TunnelProvider, +) -> bool { + provider != construct_protocol::TunnelProvider::Construct +} + /// A provider failed to start. Paint the daemon's diagnostic — which is /// written as the next thing the user should do — instead of a URL that /// would not work. @@ -15844,7 +15934,7 @@ fn render_remote_err<'a>( .max(keys.chars().count() as u16); let height = 4 + message.lines().count() as u16; ( - format!(" /remote-control — {label} failed — Esc to close "), + format!(" /remote-connect — {label} failed — Esc to close "), app.theme.danger, lines, width, @@ -15856,6 +15946,20 @@ fn render_remote_err<'a>( mod remote_popup_tests { use super::*; + fn remote_ok(tunnel_ready: bool) -> crate::app::RemoteControlOk { + crate::app::RemoteControlOk { + url: "https://quiet-river.tunnel.zarvis.ai".into(), + qr: String::new(), + tunnel_ready, + password: "secret".into(), + hint: None, + provider: construct_protocol::TunnelProvider::Construct, + local_url: "http://127.0.0.1:9800".into(), + lan_url: Some("http://192.168.1.2:9800".into()), + auth_url: None, + } + } + /// A QR of a typical URL, at the size the daemon renders it. fn qr() -> String { (0..19) @@ -15939,6 +16043,51 @@ mod remote_popup_tests { let joined = wrap_plain(text, 8).join(" "); assert_eq!(joined, text); } + + #[test] + fn zarvis_ready_screen_hides_gateway_internal_basic_credentials() { + use construct_protocol::TunnelProvider; + + assert!(!ready_screen_shows_basic_credentials( + TunnelProvider::Construct + )); + assert!(ready_screen_shows_basic_credentials( + TunnelProvider::Cloudflare + )); + assert!(ready_screen_shows_basic_credentials(TunnelProvider::None)); + } + + #[test] + fn tunnel_ready_screen_only_lists_the_public_tunnel_address() { + let remote = remote_ok(true); + let rows = remote_address_rows(&remote); + let labels: Vec<_> = rows.iter().map(|(label, _, _)| *label).collect(); + + assert_eq!(labels, vec!["tunnel:"]); + } + + #[test] + fn direct_access_screen_keeps_lan_and_local_addresses() { + let remote = remote_ok(false); + let rows = remote_address_rows(&remote); + let labels: Vec<_> = rows.iter().map(|(label, _, _)| *label).collect(); + + assert_eq!(labels, vec!["lan:", "local:"]); + } + + #[test] + fn provider_descriptions_match_remote_connect_copy() { + use construct_protocol::TunnelProvider; + + assert_eq!( + remote_provider_available_description(TunnelProvider::Cloudflare), + "No signup, URL rotates each run" + ); + assert_eq!( + remote_provider_available_description(TunnelProvider::Construct), + "Stable URL. Google or GitHub OAuth sign-in" + ); + } } #[cfg(test)] diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 8e9ba4a4..a6113dbe 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -36,6 +36,7 @@ tokio-tungstenite.workspace = true qrcode.workspace = true httparse.workspace = true reqwest.workspace = true +wstunnel.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index eb996ae4..4cfbd5c2 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -242,7 +242,7 @@ pub async fn run(socket_override: Option) -> Result<()> { let params = construct_protocol::RemoteStartParams { provider: boot_tunnel_provider(), password: None, - subdomain: std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok(), + subdomain: None, wait_for_tunnel: true, }; if let Err(e) = mgr.start_remote(Some(port), params).await { @@ -280,7 +280,7 @@ pub async fn run(socket_override: Option) -> Result<()> { let params = construct_protocol::RemoteStartParams { provider, password: None, - subdomain: std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok(), + subdomain: None, wait_for_tunnel: true, }; // port_hint=None — the supervisor reads the snapshot diff --git a/crates/daemon/src/remote.rs b/crates/daemon/src/remote.rs index f4de88e6..a4eb56af 100644 --- a/crates/daemon/src/remote.rs +++ b/crates/daemon/src/remote.rs @@ -48,6 +48,14 @@ pub struct RemoteState { /// override via `/remote-control ` to set their own. password: Arc, tunnel_url: Arc>>, + /// Ephemeral browser authorization URL for an interactive tunnel + /// start. Unlike the tunnel URL, this is deliberately never included + /// in RemoteSnapshot or written to disk. + auth_url: Arc>>, + /// Most recent provider failure while establishing a public tunnel. + /// Ephemeral like `auth_url`: used to fail the waiting IPC request + /// promptly instead of leaving the TUI on its starting screen. + tunnel_error: Arc>>, /// Active remote WS connection count. Bumped on accept, /// decremented when the connection task drops. Read by the /// `remote/state` broadcast path on every change so local @@ -182,6 +190,8 @@ impl RemoteState { token: Arc::new(token), password: Arc::new(password), tunnel_url: Arc::new(RwLock::new(None)), + auth_url: Arc::new(RwLock::new(None)), + tunnel_error: Arc::new(RwLock::new(None)), clients: Arc::new(AtomicUsize::new(0)), tunnel_pid: Arc::new(AtomicU32::new(0)), tunnel_provider: Arc::new(AtomicU8::new(TunnelProvider::None.as_u8())), @@ -202,6 +212,8 @@ impl RemoteState { token: Arc::new(snap.token.clone()), password: Arc::new(snap.password.clone()), tunnel_url: Arc::new(RwLock::new(snap.tunnel_url.clone())), + auth_url: Arc::new(RwLock::new(None)), + tunnel_error: Arc::new(RwLock::new(None)), clients: Arc::new(AtomicUsize::new(0)), tunnel_pid: Arc::new(AtomicU32::new(snap.tunnel_pid)), tunnel_provider: Arc::new(AtomicU8::new(snap.tunnel_provider.as_u8())), @@ -396,6 +408,22 @@ impl RemoteState { pub async fn tunnel_url(&self) -> Option { self.tunnel_url.read().await.clone() } + + pub async fn set_auth_url(&self, url: Option) { + *self.auth_url.write().await = url; + } + + pub async fn auth_url(&self) -> Option { + self.auth_url.read().await.clone() + } + + pub async fn set_tunnel_error(&self, error: Option) { + *self.tunnel_error.write().await = error; + } + + pub async fn tunnel_error(&self) -> Option { + self.tunnel_error.read().await.clone() + } } fn unix_now() -> u64 { @@ -555,4 +583,21 @@ mod tests { s.set_tunnel_url(None).await; assert_eq!(s.tunnel_url().await, None); } + + #[tokio::test] + async fn browser_auth_url_is_ephemeral_and_not_snapshotted() { + let s = RemoteState::new(); + s.set_auth_url(Some("https://tunnel.zarvis.ai/auth/device/example".into())) + .await; + s.set_tunnel_error(Some("registration rejected".into())) + .await; + assert_eq!( + s.auth_url().await.as_deref(), + Some("https://tunnel.zarvis.ai/auth/device/example") + ); + let snapshot = s.snapshot().await; + let restored = RemoteState::from_snapshot(&snapshot); + assert_eq!(restored.auth_url().await, None); + assert_eq!(restored.tunnel_error().await, None); + } } diff --git a/crates/daemon/src/remote_supervisor.rs b/crates/daemon/src/remote_supervisor.rs index f7e3c5df..ba242dbe 100644 --- a/crates/daemon/src/remote_supervisor.rs +++ b/crates/daemon/src/remote_supervisor.rs @@ -92,6 +92,15 @@ fn load_restore_snapshot(path: &std::path::Path) -> Option { /// resume at all. pub fn restorable_provider(path: &std::path::Path) -> Option { let snap = load_restore_snapshot(path)?; + // The first-party client runs inside the daemon and deliberately keeps + // every registration capability only in memory. An exec-style daemon + // restart therefore cannot adopt or silently recreate that tunnel: doing + // so would require persisting a credential or opening OAuth unexpectedly. + // Switch remote control off and let the user reconnect explicitly. + if snap.tunnel_provider == TunnelProvider::Construct { + let _ = std::fs::remove_file(path); + return None; + } // A snapshot written by a daemon from before providers existed // records a live tunnel PID but no provider, and taking its // `None` at face value would leave that cloudflared orphaned with @@ -247,6 +256,7 @@ async fn handle_start( // `tunnel_task`, so a subsequent start spawns fresh. if provider != TunnelProvider::None && tunnel_task.is_none() { if std::env::var("CONSTRUCT_REMOTE_NO_TUNNEL").is_err() { + state.set_tunnel_error(None).await; state.set_tunnel_provider(provider).await; // `adopt_pid` is non-zero only after a `/construct // restart`: the snapshot captured a still-running tunnel @@ -378,6 +388,8 @@ async fn handle_stop_tunnel( // looks LAN-only, and a subsequent start re-spawns a fresh // tunnel rather than short-circuiting on the dead URL. state.set_tunnel_url(None).await; + state.set_auth_url(None).await; + state.set_tunnel_error(None).await; state.set_tunnel_pid(0).await; state.set_tunnel_provider(TunnelProvider::None).await; } @@ -521,6 +533,19 @@ mod tests { } } + #[test] + fn in_process_construct_tunnel_requires_explicit_reconnect_after_restart() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("remote.json"); + let mut snap = snapshot(0, now_secs()); + snap.tunnel_provider = TunnelProvider::Construct; + snap.tunnel_url = Some("https://swift-willow-4827.tunnel.zarvis.ai/".into()); + snap.write(&path).unwrap(); + + assert_eq!(restorable_provider(&path), None); + assert!(!path.exists(), "non-adoptable snapshot must be removed"); + } + /// A PID that is guaranteed dead: spawn a trivial process, reap it, /// then reuse its (now-freed) PID. fn dead_pid() -> u32 { diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 86d3397b..7e181b0d 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -1859,6 +1859,11 @@ impl SessionManager { tunnel_only: bool, ) -> anyhow::Result { use anyhow::Context as _; + let provider = self + .remote_slot() + .ok() + .and_then(|slot| slot.as_ref().map(|handle| handle.state.tunnel_provider())) + .unwrap_or_default(); let (tx, rx) = tokio::sync::oneshot::channel(); self.remote_starter .send(crate::remote_supervisor::SupervisorMsg::Stop( @@ -1873,6 +1878,7 @@ impl SessionManager { .context("remote supervisor dropped reply channel")??; Ok(construct_protocol::RemoteStopResult { was_running: outcome.was_running, + provider, }) } @@ -1948,15 +1954,22 @@ impl SessionManager { local_url, lan_url, active_provider, + auth_url: state.auth_url().await, }); } - // Provider mode: poll the shared tunnel-url slot. 15s covers a - // cold cloudflared start (1–3s) plus slack for a slow network. + // Provider mode: poll the shared tunnel-url slot. Browser OAuth is + // intentionally allowed much longer than a subprocess-only provider: + // the user may need to switch windows or complete MFA. // We poll instead of wiring a notifier because the call shape is // request/reply over IPC — the caller is already blocked on this // future. - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + let wait_seconds = if provider == TunnelProvider::Construct { + 10 * 60 + } else { + 15 + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(wait_seconds); loop { if let Some(u) = state.tunnel_url().await { let qr = crate::remote::render_qr_dense1x2(&u).unwrap_or_default(); @@ -1970,8 +1983,12 @@ impl SessionManager { local_url, lan_url, active_provider: provider, + auth_url: None, }); } + if let Some(error) = state.tunnel_error().await { + anyhow::bail!("{} tunnel failed: {error}", provider.label()); + } if tokio::time::Instant::now() >= deadline { break; } @@ -1992,7 +2009,7 @@ impl SessionManager { match crate::tunnel::preflight(provider).await { Err(detail) => anyhow::bail!("{detail}"), Ok(()) => anyhow::bail!( - "{label} started but published no URL within 15s. Check the daemon log \ + "{label} started but published no URL within {wait_seconds}s. Check the daemon log \ (RUST_LOG=info,construct=debug) for its output." ), } diff --git a/crates/daemon/src/tunnel/construct.rs b/crates/daemon/src/tunnel/construct.rs index 4057a181..97b4970b 100644 --- a/crates/daemon/src/tunnel/construct.rs +++ b/crates/daemon/src/tunnel/construct.rs @@ -10,9 +10,10 @@ use std::process::Stdio; use std::time::Duration; use anyhow::{anyhow, Context, Result}; +use clap::Parser; use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; +use wstunnel::executor::JoinSetTokioExecutor; use crate::remote::RemoteState; @@ -20,7 +21,8 @@ const DEFAULT_API_URL: &str = "https://tunnel.zarvis.ai/api/v1/tunnels"; #[derive(Serialize)] struct RegisterRequest<'a> { - subdomain: &'a str, + construct_instance_id: &'a str, + tunnel_name: &'a str, upstream_username: &'static str, upstream_password: &'a str, } @@ -35,55 +37,77 @@ struct Registration { expires_in_seconds: u64, } +#[derive(Deserialize)] +struct AuthRequest { + verification_url: String, + poll_url: String, + poll_token: String, + expires_in_seconds: u64, + interval_seconds: u64, +} + +#[derive(Deserialize)] +struct AuthPoll { + #[serde(default)] + owner_token: Option, +} + pub fn preflight() -> Result<(), String> { - let binary = binary(); - if which::which(&binary).is_err() { - return Err(format!( - "{binary} is not on PATH — install wstunnel or set CONSTRUCT_WSTUNNEL_BIN" - )); - } - if std::env::var("CONSTRUCT_TUNNEL_OWNER_TOKEN").is_err() { - return Err( - "sign in at tunnel.zarvis.ai, then set CONSTRUCT_TUNNEL_OWNER_TOKEN".to_string(), - ); - } Ok(()) } +#[derive(Parser)] +struct InProcessClient { + #[command(flatten)] + client: wstunnel::config::Client, +} + pub async fn run_once( remote: &RemoteState, local_port: u16, - requested_subdomain: Option<&str>, + requested_tunnel_name: Option<&str>, + construct_instance_id: &str, + cached_owner_token: &mut Option, ) -> Result<()> { - let subdomain = requested_subdomain - .map(str::to_owned) - .or_else(|| std::env::var("CONSTRUCT_TUNNEL_SUBDOMAIN").ok()) - .ok_or_else(|| { - anyhow!( - "a subdomain is required; use `/remote-control construct ` or set \ - CONSTRUCT_TUNNEL_SUBDOMAIN" - ) - })?; - validate_subdomain(&subdomain)?; - - let owner_token = std::env::var("CONSTRUCT_TUNNEL_OWNER_TOKEN") - .context("CONSTRUCT_TUNNEL_OWNER_TOKEN is not set")?; + let tunnel_name = requested_tunnel_name + .filter(|name| valid_tunnel_name(name)) + .ok_or_else(|| anyhow!("choose a tunnel name using 1–32 lowercase letters, numbers, or hyphens"))?; let api_url = std::env::var("CONSTRUCT_TUNNEL_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()); let http = reqwest::Client::new(); - let registration = http + let owner_token = match cached_owner_token.as_ref() { + Some(token) => token.clone(), + None => { + let token = authorize(&http, remote, &api_url).await?; + *cached_owner_token = Some(token.clone()); + token + } + }; + let response = http .post(&api_url) .bearer_auth(&owner_token) .json(&RegisterRequest { - subdomain: &subdomain, + construct_instance_id, + tunnel_name, upstream_username: "remote", upstream_password: remote.password(), }) .send() .await - .context("contact Construct tunnel service")? - .error_for_status() - .context("Construct tunnel registration rejected")? + .context("contact Construct tunnel service")?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + *cached_owner_token = None; + } + let status = response.status(); + if !status.is_success() { + let detail = response.text().await.unwrap_or_default(); + let detail = detail.trim(); + if detail.is_empty() { + anyhow::bail!("Construct tunnel registration rejected ({status})"); + } + anyhow::bail!("Construct tunnel registration rejected ({status}): {detail}"); + } + let registration = response .json::() .await .context("decode Construct tunnel registration")?; @@ -93,47 +117,24 @@ pub async fn run_once( registration.remote_port ); let auth_header = format!("Authorization: Bearer {}", registration.tunnel_token); - let mut child = Command::new(binary()) - .args([ - "client", - "--log-lvl", - "WARN", - "--remote-to-local", - &reverse, - "--http-headers", - &auth_header, - ®istration.relay_url, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .process_group(0) - .spawn() - .context("spawn wstunnel client")?; - - let pid = child.id().unwrap_or(0); - if pid != 0 { - remote.set_tunnel_pid(pid).await; - } - let stderr = child - .stderr - .take() - .ok_or_else(|| anyhow!("wstunnel stderr not captured"))?; - let drain = tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::debug!(target: "wstunnel", "{line}"); - } - }); + let client = InProcessClient::try_parse_from([ + "construct-wstunnel", + "--remote-to-local", + &reverse, + "--http-headers", + &auth_header, + ®istration.relay_url, + ]) + .context("configure in-process wstunnel client")? + .client; + let executor = JoinSetTokioExecutor::default(); + let tunnel = wstunnel::run_client(client, executor); let public_url = normalize_public_url(®istration.public_url)?; let ready_url = registration.ready_url; let tunnel_token = registration.tunnel_token; - let refresh_after = Duration::from_secs( - registration - .expires_in_seconds - .saturating_sub(60) - .max(1), - ); + let refresh_after = + Duration::from_secs(registration.expires_in_seconds.saturating_sub(60).max(1)); let readiness = async { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { @@ -157,46 +158,123 @@ pub async fn run_once( } }; - tokio::pin!(readiness); + tokio::pin!(readiness, tunnel); tokio::select! { ready = &mut readiness => ready?, - status = child.wait() => { - drain.abort(); - let status = status.context("wait for wstunnel client")?; - return Err(anyhow!("wstunnel exited before the tunnel was ready: {status}")); + result = &mut tunnel => { + result.context("run in-process wstunnel client")?; + return Err(anyhow!("wstunnel exited before the tunnel was ready")); } } - let status = tokio::select! { - status = child.wait() => status.context("wait for wstunnel client")?, - _ = tokio::time::sleep(refresh_after) => { - child.start_kill().context("stop wstunnel for capability refresh")?; - child.wait().await.context("wait for refreshing wstunnel client")? + tokio::select! { + result = &mut tunnel => { + result.context("run in-process wstunnel client")?; + Err(anyhow!("wstunnel exited")) } - }; - drain.abort(); - if !status.success() { - return Err(anyhow!("wstunnel exited: {status}")); + _ = tokio::time::sleep(refresh_after) => Ok(()), } - Ok(()) } -fn binary() -> String { - std::env::var("CONSTRUCT_WSTUNNEL_BIN").unwrap_or_else(|_| "wstunnel".to_string()) -} +async fn authorize( + http: &reqwest::Client, + remote: &RemoteState, + tunnel_api_url: &str, +) -> Result { + let auth_api_url = auth_api_url(tunnel_api_url)?; + let request = http + .post(auth_api_url) + .send() + .await + .context("start tunnel.zarvis.ai login")? + .error_for_status() + .context("tunnel.zarvis.ai rejected login request")? + .json::() + .await + .context("decode tunnel.zarvis.ai login request")?; -fn validate_subdomain(value: &str) -> Result<()> { - let valid = (1..=63).contains(&value.len()) - && !value.starts_with('-') - && !value.ends_with('-') - && value - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); - if !valid { - anyhow::bail!( - "invalid tunnel subdomain `{value}`; use 1–63 lowercase letters, digits, or hyphens" - ); + let verification_url = validate_https_url(&request.verification_url)?; + remote.set_auth_url(Some(verification_url.clone())).await; + tracing::info!(url = %verification_url, "authorize tunnel.zarvis.ai in a browser"); + if let Err(error) = open_browser(&verification_url) { + tracing::info!(%error, url = %verification_url, "could not open login browser; showing URL in remote-connect dialog"); } + + let interval = Duration::from_secs(request.interval_seconds.clamp(1, 10)); + let deadline = tokio::time::Instant::now() + + Duration::from_secs(request.expires_in_seconds.clamp(1, 10 * 60)); + let result = async { + loop { + let response = match http + .get(&request.poll_url) + .bearer_auth(&request.poll_token) + .send() + .await + { + Ok(response) => response, + Err(error) if tokio::time::Instant::now() < deadline => { + tracing::debug!(%error, "login poll failed; retrying"); + tokio::time::sleep(interval).await; + continue; + } + Err(error) => break Err(error).context("poll tunnel.zarvis.ai login"), + }; + if response.status() == reqwest::StatusCode::ACCEPTED { + if tokio::time::Instant::now() >= deadline { + break Err(anyhow!("tunnel.zarvis.ai login expired; start again")); + } + tokio::time::sleep(interval).await; + continue; + } + let poll = response + .error_for_status() + .context("tunnel.zarvis.ai login failed")? + .json::() + .await + .context("decode tunnel.zarvis.ai login result")?; + match poll.owner_token { + Some(token) if !token.is_empty() => break Ok(token), + _ => break Err(anyhow!("tunnel.zarvis.ai login omitted authorization")), + } + } + } + .await; + remote.set_auth_url(None).await; + result +} + +fn auth_api_url(tunnel_api_url: &str) -> Result { + let mut url = + reqwest::Url::parse(tunnel_api_url).context("invalid Construct tunnel API URL")?; + let path = url.path().trim_end_matches('/'); + let prefix = path + .strip_suffix("/tunnels") + .ok_or_else(|| anyhow!("Construct tunnel API URL must end in /tunnels"))?; + url.set_path(&format!("{prefix}/auth/requests")); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut command = Command::new("open"); + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "start", ""]); + command + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = Command::new("xdg-open"); + + command + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("open browser for {url}"))?; Ok(()) } @@ -208,26 +286,56 @@ fn normalize_public_url(value: &str) -> Result { Ok(format!("{}/", value.trim_end_matches('/'))) } +fn validate_https_url(value: &str) -> Result { + let url = reqwest::Url::parse(value).context("service returned an invalid HTTPS URL")?; + if url.scheme() != "https" || url.host_str().is_none() { + anyhow::bail!("service returned a non-HTTPS URL"); + } + Ok(value.to_string()) +} + +fn valid_tunnel_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 32 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + #[cfg(test)] mod tests { use super::*; #[test] - fn subdomain_validation_is_dns_label_safe() { - for valid in ["demo", "demo-2", "a"] { - assert!(validate_subdomain(valid).is_ok(), "{valid}"); - } - for invalid in ["", "Demo", "-demo", "demo-", "two.labels", "has space"] { - assert!(validate_subdomain(invalid).is_err(), "{invalid}"); - } + fn public_url_must_be_https() { + assert_eq!( + normalize_public_url("https://swift-willow-4827.tunnel.zarvis.ai").unwrap(), + "https://swift-willow-4827.tunnel.zarvis.ai/" + ); + assert!(normalize_public_url("http://demo.example").is_err()); } #[test] - fn public_url_must_be_https() { + fn auth_endpoint_is_derived_from_tunnel_endpoint() { assert_eq!( - normalize_public_url("https://demo.user.tunnel.zarvis.ai").unwrap(), - "https://demo.user.tunnel.zarvis.ai/" + auth_api_url("https://tunnel.zarvis.ai/api/v1/tunnels") + .unwrap() + .as_str(), + "https://tunnel.zarvis.ai/api/v1/auth/requests" ); - assert!(normalize_public_url("http://demo.example").is_err()); + assert!(auth_api_url("https://tunnel.zarvis.ai/wrong").is_err()); + } + + #[test] + fn tunnel_name_is_a_short_lowercase_dns_label() { + assert!(valid_tunnel_name("quiet-otter-42")); + assert!(valid_tunnel_name("demo")); + assert!(!valid_tunnel_name("")); + assert!(!valid_tunnel_name("-demo")); + assert!(!valid_tunnel_name("demo-")); + assert!(!valid_tunnel_name("Demo")); + assert!(!valid_tunnel_name(&"a".repeat(33))); } } diff --git a/crates/daemon/src/tunnel/mod.rs b/crates/daemon/src/tunnel/mod.rs index 363f7d44..cad9f529 100644 --- a/crates/daemon/src/tunnel/mod.rs +++ b/crates/daemon/src/tunnel/mod.rs @@ -10,26 +10,21 @@ //! provider is a backend module plus a `TunnelProvider` variant, and //! nothing upstream of here has to change. //! -//! Every backend presents the same shape to the supervisor, which is -//! what keeps the rest of the daemon provider-agnostic: +//! Every backend presents the same long-running-future shape to the +//! supervisor, which is what keeps the rest of the daemon provider-agnostic. +//! External providers may use a child process; the first-party provider links +//! `wstunnel` and runs it inside that future: //! -//! 1. Spawn a child process in its own process group, so it survives -//! the daemon's `exec()` on `/construct restart` and the new daemon -//! can adopt it by PID. -//! 2. Record that PID on [`RemoteState`] so stop + restart-adoption -//! can find it. -//! 3. Publish a browser URL on [`RemoteState`] once — and only once — +//! 1. Publish a browser URL on [`RemoteState`] once — and only once — //! the tunnel is actually serving. -//! 4. Die when SIGTERM'd, releasing whatever it registered. -//! -//! Step 4 is why the backend runs its child in the *foreground*: the -//! tunnel then lives exactly as long as a process we hold a PID for, -//! and a crashed daemon can't leave a mapping behind with nothing to -//! withdraw it. +//! 2. End when its supervisor future is cancelled, releasing whatever +//! it registered. Child-process providers additionally record a PID so +//! they can survive and be adopted across an exec-style daemon restart. pub mod cloudflare; pub mod construct; +use std::io::Write; use std::time::Duration; use construct_protocol::{RemoteProviderInfo, TunnelProvider}; @@ -37,10 +32,7 @@ use construct_protocol::{RemoteProviderInfo, TunnelProvider}; use crate::remote::{process_alive, RemoteState}; /// Providers the dialog offers, in the order it offers them. -pub const PROVIDERS: [TunnelProvider; 2] = [ - TunnelProvider::Cloudflare, - TunnelProvider::Construct, -]; +pub const PROVIDERS: [TunnelProvider; 2] = [TunnelProvider::Construct, TunnelProvider::Cloudflare]; /// Probe every provider. Read-only — nothing is spawned, so the /// dialog can call this on every open without side effects. @@ -116,18 +108,41 @@ pub async fn run( "adopted tunnel exited; spawning fresh" ); remote.set_tunnel_url(None).await; + remote.set_auth_url(None).await; remote.set_tunnel_pid(0).await; } let mut backoff_secs: u64 = 1; + let construct_instance_id = match stable_construct_instance_id() { + Ok(id) => id, + Err(error) => { + remote + .set_tunnel_error(Some(format!("load Construct installation id: {error}"))) + .await; + return; + } + }; + // Keep a successful OAuth grant in memory across transport and service + // retries. A failure after login must not repeatedly open browser windows. + let mut construct_owner_token = None; loop { - match run_once(provider, &remote, local_port, subdomain.as_deref()).await { + match run_once( + provider, + &remote, + local_port, + subdomain.as_deref(), + &construct_instance_id, + &mut construct_owner_token, + ) + .await + { Ok(()) => { tracing::warn!(provider = label, "tunnel exited cleanly; respawning"); backoff_secs = 1; } Err(e) => { tracing::warn!(provider = label, error = %e, "tunnel run failed; backing off"); + remote.set_tunnel_error(Some(e.to_string())).await; } } remote.set_tunnel_url(None).await; @@ -153,10 +168,88 @@ async fn run_once( remote: &RemoteState, local_port: u16, subdomain: Option<&str>, + construct_instance_id: &str, + construct_owner_token: &mut Option, ) -> anyhow::Result<()> { match provider { TunnelProvider::None => Ok(()), TunnelProvider::Cloudflare => cloudflare::run_once(remote, local_port).await, - TunnelProvider::Construct => construct::run_once(remote, local_port, subdomain).await, + TunnelProvider::Construct => { + construct::run_once( + remote, + local_port, + subdomain, + construct_instance_id, + construct_owner_token, + ) + .await + } + } +} + +fn stable_construct_instance_id() -> anyhow::Result { + let dir = construct_protocol::paths::Paths::discover() + .data_dir + .join("tunnel"); + stable_construct_instance_id_in(&dir) +} + +fn stable_construct_instance_id_in(dir: &std::path::Path) -> anyhow::Result { + std::fs::create_dir_all(&dir)?; + let path = dir.join("installation-id"); + if let Ok(value) = std::fs::read_to_string(&path) { + let value = value.trim(); + if valid_instance_id(value) { + return Ok(value.to_string()); + } + anyhow::bail!("{} contains an invalid id", path.display()); + } + + let id = uuid::Uuid::new_v4().simple().to_string(); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + writeln!(file, "{id}")?; + file.sync_all()?; + Ok(id) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let value = std::fs::read_to_string(&path)?; + let value = value.trim(); + if valid_instance_id(value) { + Ok(value.to_string()) + } else { + anyhow::bail!("{} contains an invalid id", path.display()) + } + } + Err(error) => Err(error.into()), + } +} + +fn valid_instance_id(value: &str) -> bool { + value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn construct_instance_id_is_persisted_and_reused() { + let temp = tempfile::tempdir().unwrap(); + let first = stable_construct_instance_id_in(temp.path()).unwrap(); + let second = stable_construct_instance_id_in(temp.path()).unwrap(); + + assert_eq!(first, second); + assert!(valid_instance_id(&first)); + assert_eq!( + std::fs::read_to_string(temp.path().join("installation-id")) + .unwrap() + .trim(), + first + ); } } diff --git a/crates/e2e/tests/tui_smoke.rs b/crates/e2e/tests/tui_smoke.rs index b3ad39b9..f60eba07 100644 --- a/crates/e2e/tests/tui_smoke.rs +++ b/crates/e2e/tests/tui_smoke.rs @@ -84,7 +84,7 @@ async fn tui_remote_control_popup_via_palette() { // depend on the popup's internal alignment (the labels are // padded with spaces so a `user: remote` literal would // mismatch). The popup title is itself a useful needle. - tui.wait_for("/remote-control", Duration::from_secs(15)) + tui.wait_for("/remote-connect", Duration::from_secs(15)) .await .expect("popup title never appeared"); tui.wait_for("user:", Duration::from_secs(5)) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index f6e18801..d8bc9ac8 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2614,7 +2614,7 @@ impl TunnelProvider { match self { TunnelProvider::None => "local network", TunnelProvider::Cloudflare => "Cloudflare", - TunnelProvider::Construct => "Construct", + TunnelProvider::Construct => "tunnel.zarvis.ai", } } } @@ -2729,6 +2729,11 @@ pub struct RemoteStartResult { /// exposed. #[serde(default)] pub active_provider: TunnelProvider, + /// Short-lived browser login URL while an interactive provider is + /// waiting for authorization. It is a one-time request identifier, + /// not an owner credential. Never persisted by the daemon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_url: Option, } /// Params for the `remote.providers` IPC method. No options — the @@ -2786,6 +2791,10 @@ pub struct RemoteStopParams { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RemoteStopResult { pub was_running: bool, + /// Provider that was active immediately before the stop. Clients use + /// this to perform provider-specific logout without guessing from UI state. + #[serde(default)] + pub provider: TunnelProvider, } /// Params for `daemon.restart`. `exe: None` re-execs the daemon's own diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b1b6cc75..ffc39286 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -46,7 +46,7 @@ never publish a mislabelled binary. The tarballs use `construct-` names (which `install.sh` and `construct upgrade` expect). - Each tarball contains the single `construct` binary plus `README.md` and `LICENSE`. All adapter and MCP functionality is built into `construct` (`construct __adapter `, `construct __mcp`). + Each tarball contains the single `construct` binary plus `README.md`, `LICENSE`, and `THIRD_PARTY_NOTICES.md`. All adapter and MCP functionality is built into `construct` (`construct __adapter `, `construct __mcp`). 4. Review the release notes. The workflow passes `generate_release_notes: true` to the release step, so GitHub fills the release body automatically from the diff --git a/docs/remote-control.md b/docs/remote-control.md index bf5ad64a..0dc4de48 100644 --- a/docs/remote-control.md +++ b/docs/remote-control.md @@ -14,17 +14,15 @@ from the buttons in the dialog. | Command / setting | Purpose | |---|---| +| `/remote-connect` | Guided public-tunnel flow: select `tunnel.zarvis.ai` and authorize in the browser. | | `/remote-control` | Open the dialog: bind the listener, show the LAN address + QR, and offer a tunnel. No tunnel is started until you pick one. | | `/remote-control ` | Same, with a user-chosen Basic-auth password. | | `/remote-control cloudflare` | Skip the dialog and start a Cloudflare tunnel directly. | -| `/remote-control construct ` | Start an authenticated first-party tunnel at `..tunnel.zarvis.ai`. `/remote-connect` is an alias. | +| `/remote-control construct` | Start the first-party flow directly; the service assigns the name. | | `/remote-control stop` | Stop the listener + tunnel entirely and rotate credentials for the next start. | | `/remote-control debug` | Alias for `/remote-control` — kept because the plain dialog is now the local-only resting state. | | `CONSTRUCT_REMOTE_WS_PORT=` | Start the remote WebSocket listener on daemon boot for scripted/headless use. | | `CONSTRUCT_REMOTE_PROVIDER=` | Tunnel provider for the boot-time listener above. Defaults to `cloudflare`. | -| `CONSTRUCT_TUNNEL_SUBDOMAIN=` | Static label for a boot-time Construct tunnel. | -| `CONSTRUCT_TUNNEL_OWNER_TOKEN=` | Short-lived owner token obtained after signing in at `tunnel.zarvis.ai`. | -| `CONSTRUCT_WSTUNNEL_BIN=` | Optional `wstunnel` executable override. | | `CONSTRUCT_WEBUI_PORT=` | Override the always-on localhost web UI port. Defaults to `5746`. | ## The tunnel @@ -43,12 +41,21 @@ Once the tunnel's QR is up, the ready view offers two buttons: - **stop** — stop the tunnel and drop the public URL, but keep the LAN listener and its password. A phone connected over the LAN keeps working. -The **Construct** provider uses the separately installed `wstunnel` client. It -registers the selected name with the first-party service, then opens a reverse -tunnel restricted to that registration. The service publishes a stable -`..tunnel.zarvis.ai` URL only after the reverse endpoint answers. -Visitors sign in with GitHub or Google; initially, the social identity must -derive to the same user-id as the tunnel owner. +The **Construct** provider links the `wstunnel` Rust library directly; there is +no separate executable to install or configure. Construct opens a short-lived +`tunnel.zarvis.ai` browser login (and shows +the link in the dialog). After GitHub or Google OAuth succeeds, the running +daemon receives authorization directly, and the service assigns a unique, +human-friendly random name before opening a reverse tunnel restricted to that +registration. No owner token is shown, +copied, placed in an environment variable, or written to a configuration file. + +The service publishes a `.tunnel.zarvis.ai` URL only +after the reverse endpoint answers. The ready view displays that URL and its QR +code without showing the gateway's internal upstream Basic credentials. +Visitors sign in with GitHub or Google. The service maps the active name to its +owner and reverse endpoint in memory; the signed-in provider and immutable +provider subject must match the tunnel owner. To turn remote control off completely — listener included — use `/remote-control stop`. @@ -70,3 +77,7 @@ loopback while the remote-control listener does not. Tunnel + listener state is persisted under the runtime directory so a daemon restart preserves the active URL, password, and provider when possible; a restart never silently rotates the URL or switches how the machine is exposed. +Cloudflare's child process can be adopted across a daemon restart. A +`tunnel.zarvis.ai` connection runs in-process and keeps its authorization only +in memory, so it stops on restart and requires an explicit `/remote-connect` +and browser authorization afterward. diff --git a/specs/0095-first-party-named-tunnels.md b/specs/0095-first-party-named-tunnels.md index 49c899f9..79f67e7f 100644 --- a/specs/0095-first-party-named-tunnels.md +++ b/specs/0095-first-party-named-tunnels.md @@ -1,41 +1,50 @@ # 0095-first-party-named-tunnels Status: accepted -Date: 2026-07-14 +Date: 2026-07-15 Area: architecture -Scope: Construct's first-party tunnel names, identity, authorization, and runtime state +Scope: Construct's first-party tunnel names, identity, authorization, and persistence ## Decision -Construct's first-party tunnel provider uses a user-selected DNS label and a deterministic, provider-scoped user identifier to form `