--- a/Cargo.lock +++ b/Cargo.lock @@ -20,9 +20,9 @@ [[package]] name = "aead" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef60ac202874e574ce7a7158cc8bca7313dd344322482e4fadee288bf4a306b8" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", "inout 0.2.2", @@ -71,7 +71,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8" dependencies = [ - "aead 0.6.0", + "aead 0.6.1", "aes 0.9.1", "cipher 0.5.2", "ctr 0.10.1", @@ -145,6 +145,12 @@ ] [[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -231,6 +237,12 @@ ] [[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -329,7 +341,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -364,7 +376,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -377,18 +389,74 @@ ] [[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + +[[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] name = "base16ct" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -475,7 +543,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -492,11 +560,31 @@ [[package]] name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.118", +] + +[[package]] +name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -507,11 +595,17 @@ "regex", "rustc-hash 2.1.2", "shlex 1.3.0", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" @@ -526,6 +620,20 @@ ] [[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -563,6 +671,15 @@ ] [[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] name = "blocking" version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -625,9 +742,9 @@ [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "camino" @@ -663,10 +780,18 @@ checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex 2.0.1", ] [[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -815,7 +940,7 @@ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -825,12 +950,30 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] name = "codespan-reporting" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -848,6 +991,16 @@ 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 = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -893,6 +1046,21 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] name = "cookie-factory" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -902,7 +1070,27 @@ ] [[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" @@ -951,6 +1139,30 @@ ] [[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -958,13 +1170,13 @@ [[package]] name = "crypto-bigint" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a0d26b245348befa0c121944541476763dcc46ede886c88f9d12e1697d27c3" +checksum = "97bb4a855e3b10f84c4e7e895a7de01db7f9a7b7eb7f73ed9773fd52ac686451" dependencies = [ "cpubits", "ctutils", - "getrandom 0.4.2", + "getrandom 0.4.3", "hybrid-array", "num-traits", "rand_core 0.10.1", @@ -990,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "hybrid-array", "rand_core 0.10.1", ] @@ -1048,7 +1260,6 @@ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -1066,7 +1277,9 @@ "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -1079,7 +1292,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1093,7 +1306,7 @@ "cxxbridge-cmd", "cxxbridge-flags", "cxxbridge-macro", - "foldhash 0.2.0", + "foldhash", "link-cplusplus", ] @@ -1109,7 +1322,7 @@ "proc-macro2", "quote", "scratch", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1123,7 +1336,7 @@ "indexmap", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1141,16 +1354,71 @@ "indexmap", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] + +[[package]] name = "delegate" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1158,7 +1426,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1200,6 +1468,60 @@ ] [[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", + "unicode-xid", +] + +[[package]] name = "des" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1209,6 +1531,12 @@ ] [[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1233,6 +1561,18 @@ ] [[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] name = "displaydoc" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1240,10 +1580,27 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", ] [[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] name = "ecdsa" version = "0.17.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1256,16 +1613,6 @@ "signature 3.0.0", "spki 0.8.0", "zeroize", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", ] [[package]] @@ -1275,31 +1622,18 @@ checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8 0.11.0", + "serdect", "signature 3.0.0", ] [[package]] name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - -[[package]] -name = "ed25519-dalek" version = "3.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" dependencies = [ "curve25519-dalek 5.0.0-rc.0", - "ed25519 3.0.0", + "ed25519", "rand_core 0.10.1", "serde", "sha2 0.11.0", @@ -1338,6 +1672,18 @@ ] [[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1350,6 +1696,28 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enum-repr" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad30c9c0fa1aaf1ae5010dab11f1117b15d35faf62cda4bbbc53b9987950f18" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] name = "enum_dispatch" version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1358,7 +1726,7 @@ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1379,7 +1747,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1505,7 +1873,7 @@ "futures", "human-repr", "indicatif", - "itertools 0.14.0", + "itertools 0.15.0", "nix-eval", "opentelemetry", "opentelemetry-appender-tracing", @@ -1514,7 +1882,7 @@ "remowt-fleet", "serde", "serde_json", - "shlex 1.3.0", + "shlex 2.0.1", "tabled", "tempfile", "tokio", @@ -1526,7 +1894,7 @@ [[package]] name = "fleet-base" -version = "0.1.0" +version = "0.1.9" dependencies = [ "age", "anyhow", @@ -1537,7 +1905,7 @@ "futures", "hostname", "indoc", - "itertools 0.14.0", + "itertools 0.15.0", "nix-eval", "nixlike", "nom 8.0.0", @@ -1558,16 +1926,16 @@ [[package]] name = "fleet-generator-helper" -version = "0.1.0" +version = "0.1.9" dependencies = [ "age", "anyhow", "base64 0.22.1", "clap", - "ed25519-dalek 2.2.0", + "ed25519-dalek", "fleet-shared", "hex", - "hmac 0.12.1", + "hmac 0.13.0", "pbkdf2 0.12.2", "rand 0.10.1", "sha2 0.10.9", @@ -1576,7 +1944,7 @@ [[package]] name = "fleet-install-secrets" -version = "0.1.0" +version = "0.1.9" dependencies = [ "age", "anyhow", @@ -1592,7 +1960,7 @@ [[package]] name = "fleet-shared" -version = "0.1.0" +version = "0.1.9" dependencies = [ "base64 0.22.1", "serde", @@ -1651,12 +2019,6 @@ [[package]] name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" @@ -1671,6 +2033,12 @@ ] [[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] name = "futures" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1686,6 +2054,19 @@ ] [[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1739,7 +2120,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1772,6 +2153,21 @@ ] [[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1821,17 +2217,15 @@ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -1874,6 +2268,18 @@ ] [[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "group" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1886,9 +2292,9 @@ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1905,18 +2311,14 @@ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash 0.1.5", + "allocator-api2", + "equivalent", + "foldhash", ] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1943,6 +2345,83 @@ checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" [[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 0.22.4", + "rand 0.10.1", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2038,6 +2517,12 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] name = "human-repr" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2069,6 +2554,7 @@ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2086,7 +2572,6 @@ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -2177,7 +2662,7 @@ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", "unic-langid", ] @@ -2191,7 +2676,7 @@ "i18n-config", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2301,10 +2786,16 @@ ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" [[package]] name = "idna" @@ -2328,15 +2819,33 @@ ] [[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + +[[package]] name = "indexmap" version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -2420,12 +2929,195 @@ checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" [[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "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 = "iroh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c95e4459d9bb828a77084277abd308aa2b58a096652b079bddfd6ef2361f53" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.1", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754f7e0c1f67938e1d671007264ffef158f14a9f795a7cc219ea68ea09a9d4c9" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.1", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c12e48fef252fd04f8e6b6a8802b377baf72548d62ae4838816624cd0e06b79" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2433,6 +3125,15 @@ [[package]] name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" @@ -2450,12 +3151,105 @@ ] [[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[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 0.4.1", + "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.118", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[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.118", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] name = "js-sys" version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2492,14 +3286,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "lazycell" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" @@ -2529,7 +3323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "plain", "redox_syscall 0.8.1", @@ -2584,12 +3378,40 @@ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown", +] + +[[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] name = "matchers" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2672,12 +3494,212 @@ ] [[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 = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d31e7286c21ceaf0ddb1d881964011214555ea0b317cc2eb1a1d68d861386fc" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8487d26d691cd98d5c17b2adb4b1fd4b31cccc820da1eac827d483295d7bb94a" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.31.0", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2685,15 +3707,15 @@ [[package]] name = "nix-eval" -version = "0.1.0" +version = "0.1.9" dependencies = [ "anyhow", - "bindgen", + "bindgen 0.72.1", "camino", "cxx", "cxx-build", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "nixlike", "pkg-config", "serde", @@ -2707,16 +3729,16 @@ [[package]] name = "nix-plugin-fleet" -version = "0.1.0" +version = "0.1.9" dependencies = [ "fleet-base", ] [[package]] name = "nixlike" -version = "0.1.0" +version = "0.1.9" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "linked-hash-map", "peg", "serde", @@ -2743,6 +3765,68 @@ ] [[package]] +name = "noq" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f0c73794bfde94db01379c46990b9a773993fca2b61a66184ce148b7c7a187" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775be06b8d66c2c64db60140bf54dee8410f67b73c81cc1e1e32f11dfdaae501" +dependencies = [ + "aes-gcm 0.10.3", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2814,10 +3898,135 @@ ] [[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[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" @@ -2839,9 +4048,9 @@ [[package]] name = "opentelemetry" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" dependencies = [ "futures-core", "futures-sink", @@ -2853,9 +4062,9 @@ [[package]] name = "opentelemetry-appender-tracing" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2" +checksum = "2c0080f0dc1d7c786f467cd85a4e395fcab11ee852004f39a29a18ab7c25d837" dependencies = [ "opentelemetry", "tracing", @@ -2865,7 +4074,7 @@ [[package]] name = "opentelemetry-exporter-env" -version = "0.1.0" +version = "0.1.9" dependencies = [ "clap", "opentelemetry-otlp", @@ -2874,9 +4083,9 @@ [[package]] name = "opentelemetry-http" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", @@ -2887,9 +4096,9 @@ [[package]] name = "opentelemetry-otlp" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ "http", "opentelemetry", @@ -2902,14 +4111,14 @@ "thiserror 2.0.18", "tokio", "tonic", - "tracing", + "tonic-types", ] [[package]] name = "opentelemetry-proto" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "base64 0.22.1", "const-hex", @@ -2917,22 +4126,22 @@ "opentelemetry_sdk", "prost", "serde", - "serde_json", "tonic", "tonic-prost", ] [[package]] name = "opentelemetry_sdk" -version = "0.31.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" dependencies = [ "futures-channel", "futures-executor", "futures-util", "opentelemetry", "percent-encoding", + "portable-atomic", "rand 0.9.4", "thiserror 2.0.18", ] @@ -3009,10 +4218,44 @@ ] [[package]] +name = "pam-client" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bd776116a7ada5ebbe31f54cdc5b1030ed7265686cf7c8a21c057a2f8dab9a" +dependencies = [ + "bitflags 1.3.2", + "enum-repr", + "libc", + "pam-sys", + "rpassword", + "rustversion", +] + +[[package]] +name = "pam-sys" +version = "1.0.0-alpha5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" +dependencies = [ + "bindgen 0.69.5", + "libc", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] name = "papergrid" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +checksum = "d0984e668274d34691bc2b262ef0d115de5fa9973bcdee7ae32213f93099153e" dependencies = [ "bytecount", "fnv", @@ -3126,6 +4369,16 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] name = "phc" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3152,7 +4405,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3244,6 +4497,36 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polkit-backend" +version = "0.1.9" +dependencies = [ + "anyhow", + "clap", + "nix", + "pam-client", + "remowt-polkit-shared", + "remowt-ui-prompt", + "tokio", + "tracing", + "tracing-subscriber", + "zbus", + "zbus_polkit", +] + +[[package]] name = "polling" version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3307,6 +4590,62 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc716c56a0a50f7e4e25f41446419599d47c6197cc5c9858174220e97c272e6" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "potential_utf" @@ -3333,20 +4672,31 @@ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "primefield" -version = "0.14.0-rc.11" +version = "0.14.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d7e42f46a29abc16fb621a3466ee453358ebaae48a9e515f287e0af052ed8f" +checksum = "8675564771a62f69a0af716b03e89b917b963c7b173b5855575e84fd4f605ca0" dependencies = [ "crypto-bigint", "crypto-common 0.2.2", @@ -3393,7 +4743,7 @@ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3411,7 +4761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bitflags", + "bitflags 2.13.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -3440,10 +4790,28 @@ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] name = "quinn" version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3469,6 +4837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -3547,7 +4916,7 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20 0.10.0", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3596,6 +4965,15 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3610,7 +4988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -3619,7 +4997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -3652,16 +5030,40 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] +name = "remowt-agent" +version = "0.1.9" +dependencies = [ + "anyhow", + "bifrostlink", + "bifrostlink-ports", + "clap", + "futures", + "nix", + "remowt-endpoints", + "remowt-link-shared", + "remowt-plugin", + "remowt-polkit-shared", + "remowt-ui-prompt", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", + "zbus", + "zbus_polkit", +] + +[[package]] name = "remowt-client" version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af0ab07d961f14fa5c4933899a5253341e2044dec71db6e341f80b16beff2c20" dependencies = [ "anyhow", "bifrostlink", "bytes", "camino", "futures", + "iroh", "remowt-endpoints", "remowt-link-shared", "russh", @@ -3678,12 +5080,12 @@ [[package]] name = "remowt-endpoints" version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a841eec11d4cc0593188062634f7ee34e87a2052a70c0fa40fbecb4aa8692c0" dependencies = [ "bifrostlink", "camino", + "iroh", "nix", + "remowt-link-shared", "serde", "tempfile", "thiserror 2.0.18", @@ -3694,7 +5096,7 @@ [[package]] name = "remowt-fleet" -version = "0.1.0" +version = "0.1.9" dependencies = [ "anyhow", "bifrostlink", @@ -3712,24 +5114,28 @@ [[package]] name = "remowt-link-shared" version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5036c5e2d5f47bb7ecb25542268f865c298c2b747d74269ff6b2b387574071a" dependencies = [ + "anyhow", "bifrostlink", "bytes", + "camino", + "futures", + "iroh", + "iroh-base", + "n0-watcher", + "noq-udp", "remowt-ui-prompt", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", ] [[package]] name = "remowt-plugin" version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca599e67dc675d8dd59abfe8f08679f7f3c6fd7aa84507ab426839985a142ab" dependencies = [ "anyhow", "bifrostlink", @@ -3742,7 +5148,7 @@ [[package]] name = "remowt-plugin-fleet" -version = "0.1.0" +version = "0.1.9" dependencies = [ "anyhow", "remowt-fleet", @@ -3750,10 +5156,32 @@ ] [[package]] +name = "remowt-polkit-shared" +version = "0.1.9" +dependencies = [ + "nix", + "serde", + "zbus", +] + +[[package]] +name = "remowt-ssh" +version = "0.1.9" +dependencies = [ + "anyhow", + "clap", + "nix", + "remowt-client", + "remowt-link-shared", + "remowt-ui-prompt", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] name = "remowt-ui-prompt" version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2610933566f9b057dc47145b7bfc5876c4139eb766fc2fe9f2ce60baf1856c8c" dependencies = [ "anyhow", "bifrostlink", @@ -3767,9 +5195,9 @@ [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -3788,24 +5216,29 @@ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", + "rustls-platform-verifier", "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] [[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.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3830,6 +5263,18 @@ ] [[package]] +name = "rpassword" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf099a1888612545b683d2661a1940089f6c2e5a8e38979b2159da876bfd956" +dependencies = [ + "libc", + "serde", + "serde_json", + "winapi", +] + +[[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3875,7 +5320,7 @@ checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0" dependencies = [ "aes 0.9.1", - "bitflags", + "bitflags 2.13.0", "block-padding 0.4.2", "byteorder", "bytes", @@ -3889,13 +5334,13 @@ "der 0.8.0", "digest 0.11.3", "ecdsa", - "ed25519-dalek 3.0.0-rc.0", + "ed25519-dalek", "elliptic-curve", "enum_dispatch", "flate2", "futures", "generic-array 1.4.3", - "getrandom 0.4.2", + "getrandom 0.4.3", "ghash 0.6.0", "hex-literal", "hmac 0.13.0", @@ -3998,7 +5443,7 @@ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.117", + "syn 2.0.118", "walkdir", ] @@ -4039,7 +5484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4052,7 +5497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -4065,6 +5510,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4096,11 +5543,39 @@ ] [[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4156,6 +5631,12 @@ ] [[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4219,8 +5700,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.13.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4237,6 +5718,16 @@ ] [[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] name = "self_cell" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4258,6 +5749,12 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4268,6 +5765,16 @@ ] [[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4284,7 +5791,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4308,19 +5815,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "syn 2.0.118", ] [[package]] @@ -4345,6 +5840,12 @@ ] [[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4434,6 +5935,31 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +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 = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4456,12 +5982,35 @@ ] [[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] name = "spki" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4487,7 +6036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36" dependencies = [ - "aead 0.6.0", + "aead 0.6.1", "aes 0.9.1", "aes-gcm 0.11.0-rc.4", "cbc 0.2.1", @@ -4525,7 +6074,7 @@ "argon2", "bcrypt-pbkdf 0.11.0", "ctutils", - "ed25519-dalek 3.0.0-rc.0", + "ed25519-dalek", "hex", "hmac 0.13.0", "p256", @@ -4549,12 +6098,39 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4573,9 +6149,9 @@ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4599,14 +6175,35 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "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 = "tabled" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +checksum = "b5dc662e6da844ad6e428ad16b57967c9d33c82e16bb1c258326c0c078605dff" dependencies = [ "papergrid", "tabled_derive", @@ -4623,17 +6220,23 @@ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -4677,7 +6280,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4686,7 +6289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "944ad38adcbb71eaa682c56bceeb079e4ca82b4b3edc2a0fde5cb297b77dac8d" dependencies = [ - "syn 2.0.117", + "syn 2.0.118", "test-log-core", ] @@ -4725,7 +6328,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4736,7 +6339,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4755,7 +6358,10 @@ checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", + "js-sys", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4817,6 +6423,7 @@ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4828,7 +6435,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4850,6 +6457,7 @@ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -4861,11 +6469,35 @@ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] [[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] name = "toml" version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4943,6 +6575,17 @@ ] [[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] name = "tower" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4967,7 +6610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -4997,6 +6640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5010,7 +6654,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5048,9 +6692,9 @@ [[package]] name = "tracing-opentelemetry" -version = "0.32.1" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" dependencies = [ "js-sys", "opentelemetry", @@ -5150,6 +6794,12 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5209,6 +6859,7 @@ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -5229,7 +6880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -5242,6 +6893,43 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5265,7 +6953,7 @@ checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags", + "bitflags 2.13.0", "cursor-icon", "log", "memchr", @@ -5302,16 +6990,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -5362,7 +7041,7 @@ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -5376,57 +7055,54 @@ ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "wasm-streams" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ - "leb128fmt", - "wasmparser", + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "web-sys" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "web-sys" -version = "0.3.102" +name = "webpki-root-certs" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ - "js-sys", - "wasm-bindgen", + "rustls-pki-types", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "webpki-roots" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ - "js-sys", - "wasm-bindgen", + "rustls-pki-types", ] [[package]] @@ -5453,6 +7129,28 @@ ] [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +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" @@ -5462,6 +7160,12 @@ ] [[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5514,7 +7218,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5525,7 +7229,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5545,6 +7249,17 @@ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5564,6 +7279,15 @@ [[package]] name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" @@ -5600,6 +7324,21 @@ [[package]] name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" @@ -5642,6 +7381,12 @@ [[package]] name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" @@ -5654,6 +7399,12 @@ [[package]] name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" @@ -5666,6 +7417,12 @@ [[package]] name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" @@ -5690,6 +7447,12 @@ [[package]] name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" @@ -5702,6 +7465,12 @@ [[package]] name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" @@ -5714,6 +7483,12 @@ [[package]] name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" @@ -5726,6 +7501,12 @@ [[package]] name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" @@ -5747,103 +7528,49 @@ [[package]] name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "wmi" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", ] [[package]] -name = "wit-component" -version = "0.244.0" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "wit-parser" -version = "0.244.0" +name = "ws_stream_wasm" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" dependencies = [ - "anyhow", - "id-arena", - "indexmap", + "async_io_stream", + "futures", + "js-sys", "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wsl" @@ -5864,6 +7591,21 @@ ] [[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] name = "yoke" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5882,7 +7624,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -5911,6 +7653,7 @@ "rustix 1.1.4", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -5930,7 +7673,7 @@ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zbus_names", "zvariant", "zvariant_utils", @@ -5948,6 +7691,19 @@ ] [[package]] +name = "zbus_polkit" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad23d5c4d198c7e2641b33e6e0d1f866f117408ba66fe80bbe52e289eeb77c52" +dependencies = [ + "enumflags2", + "serde", + "serde_repr", + "static_assertions", + "zbus", +] + +[[package]] name = "zerocopy" version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5964,7 +7720,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5984,7 +7740,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -6005,7 +7761,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6039,7 +7795,7 @@ dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6071,7 +7827,7 @@ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zvariant_utils", ] @@ -6084,6 +7840,6 @@ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.118", "winnow", ] --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [workspace] -members = ["crates/*", "cmds/*"] -resolver = "2" -package.version = "0.1.0" +members = ["crates/*", "cmds/*", "remowt/crates/*", "remowt/cmds/*"] +resolver = "3" +package.version = "0.1.9" package.edition = "2024" -package.rust-version = "1.89.0" +package.rust-version = "1.95.0" +package.license = "MIT" [workspace.dependencies] fleet-base = { path = "./crates/fleet-base" } @@ -13,42 +14,56 @@ opentelemetry-exporter-env = { path = "./crates/opentelemetry-exporter-env" } remowt-fleet = { path = "./crates/remowt-fleet" } -remowt-client = "0.1.3" -remowt-endpoints = "0.1.3" -remowt-link-shared = "0.1.3" -remowt-plugin = "0.1.3" -remowt-ui-prompt = "0.1.3" +remowt-client = { version = "0.1.9", path = "remowt/crates/remowt-client" } +remowt-polkit-shared = { version = "0.1.9", path = "remowt/crates/polkit-shared" } +remowt-link-shared = { version = "0.1.9", path = "remowt/crates/remowt-link-shared" } +remowt-plugin = { version = "0.1.9", path = "remowt/crates/remowt-plugin" } +remowt-ui-prompt = { version = "0.1.9", path = "remowt/crates/remowt-ui-prompt" } +remowt-endpoints = { version = "0.1.9", path = "remowt/crates/remowt-endpoints" } -bifrostlink = "0.2.3" +bifrostlink = "0.2.0" +bifrostlink-macros = "0.2.0" +bifrostlink-ports = "0.2.0" -uuid = { version = "1", features = ["v4"] } +iroh = { version = "1.0.0", features = ["unstable-custom-transports"] } +iroh-base = "1.0.0" +noq-udp = { version = "1.0.0", default-features = false } +n0-watcher = "1.0.0" +uuid = { version = "1", features = ["v4"] } +russh = { version = "0.61.2", default-features = false, features = [ + "ring", + "flate2", + "rsa", +] } +russh-config = "0.58.0" age = { version = "0.11", features = ["plugin", "ssh"] } anyhow = "1.0" base64 = "0.22.1" bindgen = "0.72.0" +bytes = "1.11.0" camino = "1.2.2" chrono = { version = "0.4.41", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] } clap_complete = "4.5" cxx = "1.0.168" cxx-build = "1.0.168" -ed25519-dalek = "2.1" +ed25519-dalek = "3.0.0-rc.0" futures = "0.3.31" hex = "0.4.3" -hmac = "0.12" +hmac = "0.13.0" hostname = "0.4.1" human-repr = "1.1" indicatif = "0.18" indoc = "2.0.6" -itertools = "0.14.0" +itertools = "0.15.0" linked-hash-map = "0.5.6" nix = { version = "0.31.2", features = ["fs", "user"] } nom = "8.0.0" -opentelemetry = "0.31.0" -opentelemetry-appender-tracing = "0.31.1" -opentelemetry-otlp = { version = "0.31.0", features = ["grpc-tonic", "gzip-tonic", "http-json", "reqwest-rustls"] } -opentelemetry_sdk = "0.31.0" +opentelemetry = "0.32.0" +opentelemetry-appender-tracing = "0.32.0" +opentelemetry-otlp = { version = "0.32.0", features = ["grpc-tonic", "gzip-tonic", "http-json", "reqwest-rustls"] } +opentelemetry_sdk = "0.32.0" pbkdf2 = "0.12" peg = "0.8.5" pkg-config = "0.3.30" @@ -56,8 +71,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" -shlex = "1.3" -tabled = "0.20.0" +shlex = "2.0.1" +tabled = "0.21.0" tempfile = "3.20" test-log = { version = "0.2.19", features = ["trace"] } thiserror = "2.0.12" @@ -65,12 +80,16 @@ tokio = { version = "1.45.1", features = ["fs", "macros", "rt", "rt-multi-thread", "sync", "time"] } tracing = "0.1" tracing-indicatif = "0.3.13" -tracing-opentelemetry = "0.32.1" +tracing-opentelemetry = "0.33.0" # For fixed coloring needs to be updated to https://github.com/tokio-rs/tracing/pull/3484 tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] } unicode_categories = "0.1.1" vte = { version = "0.15.0", features = ["ansi"] } x25519-dalek = { version = "2.0.1", features = ["getrandom"] } +zbus = "5.16.0" +zbus_polkit = "5.0.0" +tokio-util = "0.7.11" +pam-client = "0.5.0" [profile.dev] panic = "abort" --- a/cmds/remowt-plugin-fleet/src/main.rs +++ b/cmds/remowt-plugin-fleet/src/main.rs @@ -1,4 +1,4 @@ -use remowt_fleet::{Nix, init_libraries}; +use remowt_fleet::{init_libraries, Nix}; fn main() -> anyhow::Result<()> { remowt_plugin::run(|rpc| { --- a/crates/fleet-base/src/host.rs +++ b/crates/fleet-base/src/host.rs @@ -425,9 +425,9 @@ warn!("failed to sign store paths: {e}"); } let store = self.nix_store().await?; + let eval_store = eval_store(); { let path = path.clone(); - let eval_store = eval_store(); spawn_blocking(move || eval_store.copy_to(&store, path.as_ref())) .await .expect("copy_to panicked") --- a/crates/nix-eval/src/lib.rs +++ b/crates/nix-eval/src/lib.rs @@ -565,7 +565,7 @@ #[instrument(skip(self, dst))] pub fn copy_to(&self, dst: &Store, path: &Utf8Path) -> Result<()> { let sp = self - .parse_path(&path) + .parse_path(path) .context("failed to parse store path")?; let rc = with_default_context(|c, _| unsafe { store_copy_closure(c, self.as_ptr(), dst.0, sp.as_ptr()) --- /dev/null +++ b/remowt/README.adoc @@ -0,0 +1,3 @@ += Remowt + +A framework for executing code on remote machines. --- /dev/null +++ b/remowt/cmds/polkit-dbus-helper/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "polkit-backend" +description = "polkit/PAM D-Bus authentication backend for remowt" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +anyhow.workspace = true +clap = { workspace = true, features = ["derive"] } +nix.workspace = true +pam-client.workspace = true +remowt-polkit-shared.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +tracing.workspace = true +tracing-subscriber.workspace = true +remowt-ui-prompt.workspace = true +zbus = { workspace = true, features = ["tokio"] } +zbus_polkit = { workspace = true, features = ["tokio"] } --- /dev/null +++ b/remowt/cmds/polkit-dbus-helper/README.adoc @@ -0,0 +1,8 @@ +== What is it? + +Usually, there is a `polkit-agent-helper-1` suid binary installed on the system with polkit package. + +This, however, an alternative to that approach, a system daemon listening for dbus requests, which works +without using suid binaries. + +In future it will provide some additional features. --- /dev/null +++ b/remowt/cmds/polkit-dbus-helper/etc/systemd/system/remowt-polkit-helper.service @@ -0,0 +1,12 @@ +[Unit] +Description=Remowt polkit helper service + +[Service] +Type=dbus +BusName=lach.polkit.helper1 +ExecStart=@libexecdir@/polkit-backend +# TODO: Hardening + +[Install] +WantedBy=multi-user.target +Alias=dbus-lach.polkit.helper1.service --- /dev/null +++ b/remowt/cmds/polkit-dbus-helper/share/dbus-1/system-services/lach.polkit.helper1.conf @@ -0,0 +1,5 @@ +[D-BUS Service] +Name=lach.polkit.helper1 +Exec=/bin/false +User=root +SystemdService=dbus-lach.polkit.helper1.service --- /dev/null +++ b/remowt/cmds/polkit-dbus-helper/share/dbus-1/system.d/lach.polkit.helper1.conf @@ -0,0 +1,12 @@ + + + + + + + + + + + + --- /dev/null +++ b/remowt/cmds/polkit-dbus-helper/src/main.rs @@ -0,0 +1,236 @@ +use std::collections::{HashMap, HashSet}; +use std::ffi::{CStr, CString}; +use std::future::pending; +use std::sync::LazyLock; + +use anyhow::Context as _; +use clap::Parser; +use nix::unistd::{setuid, Uid, User}; +use pam_client::{Context, ConversationHandler, ErrorCode, Flag}; +use remowt_polkit_shared::BackendRequest; +use remowt_ui_prompt::dbus::DbusPrompterProxyBlocking; +use remowt_ui_prompt::BlockingPrompter; +use tokio::task::{block_in_place, spawn_blocking}; +use tracing::trace; +use zbus::fdo; +use zbus::message::Header; +use zbus::zvariant::OwnedValue; +use zbus::{blocking, interface, proxy, Connection}; + +struct Helper { + connection: Connection, + blocking_connection: blocking::Connection, +} + +static ALLOWED_ENVIRONMENT: LazyLock> = LazyLock::new(|| { + [ + // pam ssh agent auth + "SSH_AUTH_SOCK", + // ssh itself provides this when running PAM + "SSH_AUTH_INFO_0", + // contains user which ran sudo + "SUDO_USER", + ] + .into_iter() + .collect() +}); + +struct Conversation

(P); +impl Conversation

{ + fn prompt_inner(&self, echo: bool, prompt: &CStr) -> Result { + trace!("do prompt"); + let out = self + .0 + .prompt_text(echo, &prompt.to_string_lossy(), "PAM prompt request", &[]) + .map_err(|e| { + trace!("prompt error: {e}"); + ErrorCode::CONV_ERR + })?; + CString::new(out).map_err(|_| ErrorCode::CONV_AGAIN) + } + fn text_inner(&self, error: bool, msg: &CStr) { + trace!("do text"); + let msg = msg.to_string_lossy(); + let _ = self.0.display_text(error, &msg, &[]); + } +} +impl ConversationHandler for Conversation

{ + fn prompt_echo_on(&mut self, prompt: &CStr) -> Result { + self.prompt_inner(true, prompt) + } + + fn prompt_echo_off(&mut self, prompt: &CStr) -> Result { + self.prompt_inner(false, prompt) + } + + fn text_info(&mut self, msg: &CStr) { + self.text_inner(false, msg) + } + + fn error_msg(&mut self, msg: &CStr) { + self.text_inner(true, msg) + } + + fn radio_prompt(&mut self, prompt: &CStr) -> Result { + let prompt = prompt.to_string_lossy(); + let result = self + .0 + .prompt_radio(&prompt, "PAM prompt request", &[]) + .map_err(|_| ErrorCode::CONV_ERR)?; + Ok(result) + } +} + +#[proxy( + default_service = "org.freedesktop.DBus", + default_path = "/org/freedesktop/DBus" +)] +trait DBus { + fn get_connection_credentials(&self, body: &str) -> zbus::Result>; +} + +#[interface(name = "lach.PolkitHelper")] +impl Helper { + async fn init_conversation( + &self, + request: BackendRequest, + #[zbus(header)] hdr: Header<'_>, + ) -> fdo::Result<()> { + let Some(sender) = hdr.sender().map(|v| v.to_owned()) else { + trace!("missing sender"); + return Err(fdo::Error::AuthFailed("missing sender".to_owned())); + }; + + let dbus = DBusProxy::new(&self.connection).await?; + + // TOCTOU: sender might be already disconnected, and there might be another + // user with different user id here, but does it matters? + let reply = dbus.get_connection_credentials(&sender).await?; + let connection_uid: u32 = (&reply["UnixUserID"]).try_into().unwrap(); + + let identity = request.identity.clone(); + let blocking_connection = self.blocking_connection.clone(); + let thread_result: fdo::Result<()> = block_in_place(move || { + trace!("find user"); + let Some(identity_uid) = identity.uid() else { + return Err(fdo::Error::AuthFailed("can't process identity".to_owned())); + }; + let user = User::from_uid(identity_uid) + .map_err(|_| fdo::Error::AuthFailed("error querying user".to_owned()))? + .ok_or_else(|| fdo::Error::AuthFailed("uid not found".to_owned()))?; + + let responder = DbusPrompterProxyBlocking::new( + &blocking_connection, + sender, + request.prompter_path, + )?; + let conversation = Conversation(responder); + trace!("run context for {}", &user.name); + let mut ctx = Context::new( + // TODO: Should another scope be used? + "login", + Some(&user.name), + conversation, + ) + .map_err(|_| fdo::Error::Failed("pam context init failed".to_owned()))?; + + trace!("fill env"); + for (k, v) in request.environment { + if k.contains('=') || !ALLOWED_ENVIRONMENT.contains(k.as_str()) { + continue; + } + let _ = ctx.putenv(format!("{k}={v}")); + } + + trace!("authenticate"); + ctx.authenticate(Flag::NONE) + .map_err(|_| fdo::Error::AuthFailed("pam authentication failed".to_owned()))?; + + trace!("acct mgmt"); + ctx.acct_mgmt(Flag::NONE) + .map_err(|_| fdo::Error::AuthFailed("pam acct mgmt failed".to_owned()))?; + + Ok(()) + }); + + thread_result?; + + trace!("respond"); + let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&self.connection).await?; + + let identity_details = request + .identity + .details + .iter() + .map(|(k, v)| (k.as_str(), (**v).try_clone().expect("success"))) + .collect::>(); + proxy + .authentication_agent_response2( + connection_uid, + &request.cookie, + &zbus_polkit::policykit1::Identity { + identity_kind: &request.identity.kind, + identity_details: &identity_details, + }, + ) + .await?; + Ok(()) + } +} + +const OBJ_PATH: &str = "/lach/PolkitHelper"; + +#[derive(Parser)] +struct Opts { + /// Not recommended: start as a session connection, then use escalation + /// to respond to polkit requests. + #[arg(long)] + session: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + let opts = Opts::parse(); + let connection = if opts.session { + Connection::session().await + } else { + Connection::system().await + } + .context("failed to open connection")?; + + let session = opts.session; + let blocking_connection: anyhow::Result = spawn_blocking(move || { + Ok(if session { + blocking::Connection::session()? + } else { + blocking::Connection::system()? + }) + }) + .await?; + let blocking_connection = blocking_connection.context("failed to open blocking connection")?; + + if opts.session { + setuid(Uid::from_raw(0)) + .context("polkit-backend needs to be suid if run in session mode")?; + } + + connection + .object_server() + .at( + OBJ_PATH, + Helper { + connection: connection.clone(), + blocking_connection, + }, + ) + .await + .context("failed listen path")?; + + connection + .request_name("lach.polkit.helper1") + .await + .context("failed to request name")?; + + pending().await +} --- /dev/null +++ b/remowt/cmds/remowt-agent/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "remowt-agent" +description = "remowt on-host agent serving fs/pty/systemd endpoints over bifrostlink" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +anyhow.workspace = true +bifrostlink.workspace = true +bifrostlink-ports.workspace = true +clap = { workspace = true, features = ["derive"] } +futures.workspace = true +nix.workspace = true +remowt-polkit-shared.workspace = true +remowt-link-shared.workspace = true +remowt-plugin.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = [ + "rt", + "fs", + "macros", + "net", + "io-util", + "time", + "process", +] } +tokio-util = { workspace = true, features = ["codec"] } +tracing.workspace = true +tracing-subscriber.workspace = true +remowt-ui-prompt.workspace = true +uuid = { workspace = true, features = ["v4"] } +zbus = { workspace = true, features = ["tokio"] } +zbus_polkit = { workspace = true, features = ["tokio"] } +remowt-endpoints.workspace = true --- /dev/null +++ b/remowt/cmds/remowt-agent/src/askpass.rs @@ -0,0 +1,51 @@ +use std::borrow::Cow; +use std::io::Write as _; + +use anyhow::Context as _; +use remowt_ui_prompt::dbus::{DbusPrompterInterface, DbusPrompterProxy, BUS_NAME, PROMPTER_PATH}; +use remowt_ui_prompt::{Prompter, Source}; +use tracing::debug; +use zbus::Connection; + +pub async fn serve

(conn: &Connection, prompter: P) -> anyhow::Result<()> +where + P: Prompter + 'static, +{ + conn.object_server() + .at(PROMPTER_PATH, DbusPrompterInterface(prompter)) + .await?; + match conn.request_name(BUS_NAME).await { + Ok(()) => {} + Err(zbus::Error::NameTaken) => { + debug!("{BUS_NAME} already owned, chaining to upstream"); + } + Err(e) => return Err(e.into()), + } + Ok(()) +} + +pub async fn ask(prompt: &str, description: String) -> anyhow::Result<()> { + let conn = Connection::session() + .await + .context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?; + let proxy = DbusPrompterProxy::builder(&conn) + .destination(BUS_NAME)? + .path(PROMPTER_PATH)? + .build() + .await?; + + let password = proxy + .prompt_text( + false, + prompt, + &description, + &[Source(Cow::Borrowed("remowt-askpass"))], + ) + .await?; + + let mut out = std::io::stdout().lock(); + out.write_all(password.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + Ok(()) +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/bus.rs @@ -0,0 +1,40 @@ +use std::process::Stdio; + +use anyhow::Context as _; +use futures::StreamExt as _; +use tokio::process::{Child, Command}; +use tokio_util::codec::{FramedRead, LinesCodec}; +use zbus::Connection; + +pub struct PrivateBus { + pub address: String, + pub conn: Connection, + _child: Child, +} + +pub async fn spawn() -> anyhow::Result { + let mut child = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address"]) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("spawning dbus-daemon for the private bus")?; + + let stdout = child.stdout.take().expect("piped"); + let address = FramedRead::new(stdout, LinesCodec::new()) + .next() + .await + .context("dbus-daemon exited before printing its address")? + .context("reading dbus-daemon address")?; + + let conn = zbus::connection::Builder::address(address.as_str())? + .build() + .await + .context("connecting to the private bus")?; + + Ok(PrivateBus { + address, + conn, + _child: child, + }) +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/editor.rs @@ -0,0 +1,158 @@ +use std::env::{current_dir, temp_dir}; +use std::path::Path; +use std::time::Duration; +use std::{fs, io}; + +use anyhow::{bail, Context as _}; +use nix::libc; +use remowt_link_shared::editor::EditorEndpointsClient; +use tokio::process::Command; +use zbus::{fdo, interface, proxy, Connection}; + +use remowt_link_shared::BifConfig; + +const BUS_NAME: &str = "lach.RemowtEditor"; +const SERVICE_PATH: &str = "/lach/Editor"; + +pub struct EditorService { + editor: EditorEndpointsClient, +} + +#[interface(name = "lach.RemowtEditor")] +impl EditorService { + /// Attach the User's GUI to the nvim server at `socket_path` (on the remote), + /// blocking until the user is done. + async fn edit(&self, socket_path: String) -> fdo::Result<()> { + self.editor + .open_editor(socket_path) + .await + .map_err(|e| fdo::Error::Failed(format!("requesting editor on the User: {e}")))? + .map_err(|e| fdo::Error::Failed(format!("editor failed: {e}")))?; + Ok(()) + } + + async fn forward_tcp(&self, addr: String) -> fdo::Result { + let local = self + .editor + .expose_tcp(addr) + .await + .map_err(|e| fdo::Error::Failed(format!("requesting tcp forward on the User: {e}")))? + .map_err(|e| fdo::Error::Failed(format!("tcp forward failed: {e}")))?; + Ok(local) + } + + async fn forward_udp(&self, addr: String) -> fdo::Result { + let local = self + .editor + .expose_udp(addr) + .await + .map_err(|e| fdo::Error::Failed(format!("requesting udp forward on the User: {e}")))? + .map_err(|e| fdo::Error::Failed(format!("udp forward failed: {e}")))?; + Ok(local) + } +} + +pub async fn serve( + conn: &Connection, + editor: EditorEndpointsClient, +) -> anyhow::Result<()> { + conn.object_server() + .at(SERVICE_PATH, EditorService { editor }) + .await?; + conn.request_name(BUS_NAME).await?; + Ok(()) +} + +#[proxy(interface = "lach.RemowtEditor")] +trait RemowtEditor { + async fn edit(&self, socket_path: &str) -> fdo::Result<()>; + async fn forward_tcp(&self, addr: &str) -> fdo::Result; + async fn forward_udp(&self, addr: &str) -> fdo::Result; +} + +pub async fn forward(udp: bool, addr: String) -> anyhow::Result<()> { + let conn = Connection::session() + .await + .context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?; + let proxy = RemowtEditorProxy::builder(&conn) + .destination(BUS_NAME)? + .path(SERVICE_PATH)? + .build() + .await?; + let local = if udp { + proxy.forward_udp(&addr).await? + } else { + proxy.forward_tcp(&addr).await? + }; + println!("{local}"); + Ok(()) +} + +pub async fn edit(path: String) -> anyhow::Result<()> { + let path = Path::new(&path); + let abs = if path.is_absolute() { + path.to_path_buf() + } else { + current_dir()?.join(path) + }; + + let sock = temp_dir().join(format!("remowt-nvim-{}.sock", uuid::Uuid::new_v4())); + let sock_str = sock + .to_str() + .context("temp socket path is not utf-8")? + .to_owned(); + + let mut child = Command::new("nvim"); + child + .arg("--headless") + .arg("--listen") + .arg(&sock) + .arg("--") + .arg(&abs) + .kill_on_drop(true); + // SAFETY: only an async-signal-safe `prctl` call. + unsafe { + child.pre_exec(|| { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = child.spawn().context("spawning nvim")?; + + wait_for_socket(&sock) + .await + .context("nvim did not start its server")?; + + let conn = Connection::session() + .await + .context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?; + let proxy = RemowtEditorProxy::builder(&conn) + .destination(BUS_NAME)? + .path(SERVICE_PATH)? + .build() + .await?; + let result = proxy.edit(&sock_str).await; + + if tokio::time::timeout(Duration::from_secs(2), child.wait()) + .await + .is_err() + { + let _ = child.kill().await; + } + let _ = fs::remove_file(&sock); + + result?; + Ok(()) +} + +async fn wait_for_socket(path: &Path) -> anyhow::Result<()> { + for _ in 0..200 { + if tokio::fs::try_exists(path).await.unwrap_or(false) { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + bail!("timed out waiting for {}", path.display()) +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/helper/dbus.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; +use std::marker::PhantomData; + +use remowt_polkit_shared::{BackendRequest, Identity}; +use remowt_ui_prompt::dbus::DbusPrompterInterface; +use remowt_ui_prompt::Prompter; +use zbus::Connection; + +use crate::PolkitHelperProxy; + +use super::Helper; + +struct TemporaryPrompterInterface { + connection: Connection, + path: String, + _marker: PhantomData

, +} +impl TemporaryPrompterInterface

{ + async fn new(connection: Connection, prompter: P) -> Self { + let path = format!( + "/remowt/prompters/{}", + uuid::Uuid::new_v4().to_string().replace("-", "_") + ); + let _ = connection + .object_server() + .at(path.clone(), DbusPrompterInterface(prompter)) + .await; + Self { + connection, + path, + _marker: PhantomData, + } + } +} +impl Drop for TemporaryPrompterInterface

{ + fn drop(&mut self) { + // Removal is async because of async RwLock used inside... + // We should not care about its reuse + let connection = self.connection.clone(); + let path = std::mem::take(&mut self.path); + tokio::spawn(async move { + let _ = connection + .object_server() + .remove::, String>(path) + .await; + }); + } +} + +#[derive(Clone)] +pub struct DbusHelper { + connection: Connection, + helper: PolkitHelperProxy<'static>, +} +impl DbusHelper { + pub async fn new(connection: Connection) -> zbus::Result { + let helper = PolkitHelperProxy::new(&connection).await?; + Ok(Self { connection, helper }) + } +} +impl Helper for DbusHelper { + async fn help_me( + &self, + cookie: &str, + prompter: P, + identity: Identity, + ) -> anyhow::Result<()> { + let prompter = TemporaryPrompterInterface::new(self.connection.clone(), prompter).await; + self.helper + .init_conversation( + BackendRequest { + cookie: cookie.to_owned(), + environment: HashMap::new(), + prompter_path: prompter.path.clone(), + identity, + }, // cookie.to_owned(), HashMap::new(), prompter.path.clone() + ) + .await?; + Ok(()) + } +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/helper/mod.rs @@ -0,0 +1,21 @@ +use futures::Future; +use remowt_polkit_shared::Identity; +use remowt_ui_prompt::Prompter; + +mod dbus; +mod protocol; +mod socket; +mod suid; + +pub use dbus::DbusHelper; +pub use socket::SocketHelper; +pub use suid::SuidHelper; + +pub trait Helper { + fn help_me( + &self, + cookie: &str, + prompt: P, + identity: Identity, + ) -> impl Future> + Send; +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/helper/protocol.rs @@ -0,0 +1,50 @@ +use std::pin::pin; + +use anyhow::bail; +use futures::stream::Peekable; +use futures::StreamExt as _; +use remowt_ui_prompt::Prompter; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; +use tokio::select; +use tokio_util::codec::{FramedRead, LinesCodec}; + +pub async fn run_conversation(reader: R, mut writer: W, prompt: P) -> anyhow::Result<()> +where + R: AsyncRead, + W: AsyncWrite + Unpin, + P: Prompter, +{ + let mut lines = pin!(FramedRead::new(reader, LinesCodec::new()).peekable()); + + while let Some(line) = lines.next().await { + let line = line?; + let res = if let Some(prompt_text) = line.strip_prefix("PAM_PROMPT_ECHO_OFF ") { + prompt.prompt_text(false, prompt_text, "", &[]).await? + } else if let Some(prompt_text) = line.strip_prefix("PAM_PROMPT_ECHO_ON ") { + prompt.prompt_text(true, prompt_text, "", &[]).await? + } else if let Some(msg_text) = line.strip_prefix("PAM_ERROR_MSG ") { + prompt.display_text(true, msg_text, &[]).await?; + String::new() + } else if let Some(msg_text) = line.strip_prefix("PAM_TEXT_INFO ") { + select! { + _ = Peekable::peek(lines.as_mut()) => {}, + r = prompt.display_text(false, msg_text, &[]) => {r?} + } + String::new() + } else if line == "SUCCESS" { + return Ok(()); + } else if line == "FAILURE" { + bail!("helper reported failure") + } else { + bail!("unknown agent request: {line}") + }; + + if res.contains('\n') { + bail!("response should not include newline") + } + + writer.write_all(res.as_bytes()).await?; + writer.write_all(b"\n").await?; + } + bail!("agent finished unexpectedly") +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/helper/socket.rs @@ -0,0 +1,53 @@ +use anyhow::{anyhow, bail}; +use nix::unistd::User; +use remowt_polkit_shared::Identity; +use remowt_ui_prompt::Prompter; +use tokio::io::AsyncWriteExt as _; +use tokio::net::UnixStream; +use tracing::debug; + +use super::protocol::run_conversation; +use super::Helper; + +/// Polkit 127 introduced an alternative backend similar to `lach.PolkitHelper` +const SOCKET_PATH: &str = "/run/polkit/agent-helper.socket"; + +#[derive(Clone)] +pub struct SocketHelper { + pub fallback: F, +} + +impl Helper for SocketHelper { + async fn help_me( + &self, + cookie: &str, + prompt: P, + identity: Identity, + ) -> anyhow::Result<()> { + let Some(uid) = identity.uid() else { + bail!("can't process identity"); + }; + + let stream = match UnixStream::connect(SOCKET_PATH).await { + Ok(stream) => stream, + Err(e) => { + debug!("agent-helper.socket unavailable ({e}), using fallback helper"); + return self.fallback.help_me(cookie, prompt, identity).await; + } + }; + + let user = User::from_uid(uid) + .map_err(|e| anyhow!("error querying user: {e}"))? + .ok_or_else(|| anyhow!("user not found"))?; + + assert!(!cookie.contains('\n')); + let (reader, mut writer) = stream.into_split(); + + writer.write_all(user.name.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.write_all(cookie.as_bytes()).await?; + writer.write_all(b"\n").await?; + + run_conversation(reader, writer, prompt).await + } +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/helper/suid.rs @@ -0,0 +1,46 @@ +use std::process::Stdio; + +use anyhow::{anyhow, bail}; +use nix::unistd::User; +use remowt_polkit_shared::Identity; +use remowt_ui_prompt::Prompter; +use tokio::io::AsyncWriteExt as _; +use tokio::process::Command; + +use super::protocol::run_conversation; +use super::Helper; + +#[derive(Clone)] +pub struct SuidHelper; +impl Helper for SuidHelper { + async fn help_me( + &self, + cookie: &str, + prompt: P, + identity: Identity, + ) -> anyhow::Result<()> { + let Some(uid) = identity.uid() else { + bail!("can't process identity"); + }; + let user = User::from_uid(uid) + .map_err(|e| anyhow!("error querying user: {e}"))? + .ok_or_else(|| anyhow!("user not found"))?; + + let mut cmd = Command::new("polkit-agent-helper-1"); + cmd.arg(user.name); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.kill_on_drop(true); + let mut child = cmd.spawn()?; + let mut stdin = child.stdin.take().expect("piped"); + let stdout = child.stdout.take().expect("piped"); + + assert!(!cookie.contains('\n')); + stdin.write_all(cookie.as_bytes()).await?; + stdin.write_all(b"\n").await?; + + let res = run_conversation(stdout, stdin, prompt).await; + drop(child); + res + } +} --- /dev/null +++ b/remowt/cmds/remowt-agent/src/main.rs @@ -0,0 +1,437 @@ +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap}; +use std::fs::Permissions; +use std::future::pending; +use std::io; +use std::os::unix::fs::PermissionsExt as _; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +use bifrostlink::declarative::RemoteEndpoints; +use bifrostlink::Rpc; +use bifrostlink_ports::stdio::from_stdio; +use bifrostlink_ports::unix_socket::from_socket; +use clap::Parser; +use remowt_endpoints::{ + forward::Forward, fs::Fs, iroh_tunnel::IrohTunnel, nix_daemon::NixDaemon, pty::Pty, + subprocess::Subprocess, systemd::Systemd, +}; +use remowt_link_shared::iroh_tunnel::TunnelDialer; +use remowt_link_shared::{editor::EditorEndpointsClient, Address, BifConfig}; +use remowt_polkit_shared::{emphasize, BackendRequest, Identity, PidDisplay}; +use remowt_ui_prompt::bifrost::PromptEndpointsClient; +use remowt_ui_prompt::rofi::RofiPrompter; +use remowt_ui_prompt::{PrependSourcePrompter, Prompter, Source}; +use tokio::fs; +use tokio::net::UnixStream; +use tokio::runtime::Builder; +use tokio::task::AbortHandle; +use tracing::{debug, trace}; +use zbus::fdo; +use zbus::zvariant::{OwnedValue, Str}; +use zbus::{interface, proxy, Connection}; +use zbus_polkit::policykit1::Subject; + +use self::helper::{Helper, SocketHelper, SuidHelper}; + +pub mod askpass; +pub mod bus; +pub mod editor; +pub mod helper; + +struct CancelTaskOnDrop { + tasks: Arc>>, + handle: String, +} +impl Drop for CancelTaskOnDrop { + fn drop(&mut self) { + debug!("cancel on drop"); + if let Some(task) = self + .tasks + .lock() + .expect("not poisoned") + .remove(&self.handle) + { + task.abort(); + } + } +} + +struct Agent { + tasks: Arc>>, + helper: H, + prompter: P, +} +impl Agent { + fn new(helper: H, prompter: P) -> Self { + Agent { + tasks: Arc::new(Mutex::new(HashMap::new())), + helper, + prompter, + } + } +} + +#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] +impl Agent +where + H: Helper + Clone + Send + Sync + 'static, + P: Prompter + Clone + Send + Sync + 'static, +{ + /// BeginAuthentication method + #[allow(clippy::too_many_arguments)] + async fn begin_authentication( + &self, + action_id: String, + message: String, + _icon_name: String, + mut details: BTreeMap, + cookie: String, + identities: Vec, + ) -> zbus::fdo::Result<()> { + use std::fmt::Write; + debug!("begin auth"); + let _cancel_guard = Arc::new(OnceLock::new()); + let task = { + let helper = self.helper.clone(); + let prompter = self.prompter.clone(); + let cookie = cookie.clone(); + let _cancel_guard = _cancel_guard.clone(); + tokio::task::spawn(async move { + let _cancel_guard = _cancel_guard.clone(); + trace!("conversation task"); + let mut description = format!("{message}\n\nAction id: {action_id}",); + if let Some(subject) = details.remove("polkit.caller-pid") { + let _ = write!(description, "\nCaller: "); + if let Ok(pid) = subject.parse::() { + let _ = write!(description, "{}", PidDisplay(pid)); + } else { + let _ = write!(description, "{}", emphasize("invalid pid")); + } + } + if let Some(subject) = details.remove("polkit.subject-pid") { + let _ = write!(description, "\nSubject: "); + if let Ok(pid) = subject.parse::() { + let _ = write!(description, "{}", PidDisplay(pid)); + } else { + let _ = write!(description, "{}", emphasize("invalid pid")); + } + } + let mut prompter = PrependSourcePrompter { + source: vec![Source(Cow::Borrowed("polkit agent"))], + description: description.clone(), + prompter, + }; + + let identity_displays: Vec = + identities.iter().map(|v| v.to_string()).collect(); + let identity_displays: Vec<&str> = + identity_displays.iter().map(|v| v.as_str()).collect(); + debug!("choose identity"); + let choosen_identity = match identity_displays.len() { + 0 => { + return Err(fdo::Error::AuthFailed( + "no identity to authenticate as".to_owned(), + )) + } + 1 => 0, + _ => { + prompter + .prompt_enum( + "Identity", + "Select identity to use for polkit authorization", + &identity_displays, + &[], + ) + .await? + } + }; + debug!("identity chosen"); + + let _ = write!( + description, + "\nIdentity: {}", + identities[choosen_identity as usize] + ); + prompter.description = description; + + prompter.source.push(Source(Cow::Borrowed("polkit daemon"))); + + helper + .help_me( + &cookie, + prompter, + identities[choosen_identity as usize].clone(), + ) + .await + .map_err(|e| fdo::Error::Failed(e.to_string()))?; + // let connection = Connection::system().await?; + // let helper = PolkitHelperProxy::new(&connection).await?; + + Ok(()) + }) + }; + self.tasks + .lock() + .unwrap() + .insert(cookie.clone(), task.abort_handle()); + debug!("abort handle stored"); + let _ = _cancel_guard.set(CancelTaskOnDrop { + tasks: self.tasks.clone(), + handle: cookie.clone(), + }); + + let _ = task.await; + + Ok(()) + } + + /// CancelAuthentication method + async fn cancel_authentication(&self, cookie: &str) -> zbus::fdo::Result<()> { + debug!("auth cancelled"); + if let Some(abort) = self.tasks.lock().unwrap().remove(cookie) { + debug!("abort handle found"); + abort.abort(); + } + // debug!("Authentication cancled ! {cookie}"); + Ok(()) + } +} + +const OBJ_PATH: &str = "/org/freedesktop/PolicyKit1/AuthenticationAgent"; + +#[proxy( + interface = "lach.PolkitHelper", + default_service = "lach.polkit.helper1", + default_path = "/lach/PolkitHelper" +)] +trait PolkitHelper { + fn init_conversation(&self, request: BackendRequest) -> zbus::Result<()>; +} + +#[derive(Parser)] +enum Opts { + AskPass { + prompt: String, + description: String, + }, + Editor { + /// Argument to nvim + path: String, + }, + /// Expose a remote TCP/UDP address on the local (User) machine, printing the local port. + Forward { + /// Protocol: `tcp` (ssh or iroh) or `udp` (iroh-only). + proto: ForwardProto, + /// Remote socket address to expose locally, e.g. `127.0.0.1:8080`. + addr: String, + }, + RealAgent { + #[arg(long)] + path: Option, + /// Expect own address to be AgentPrivileged, skip installing polkit agent + #[arg(long)] + privileged: bool, + #[arg(long)] + local: bool, + }, + LocalAgent, +} + +#[derive(Clone, Copy, clap::ValueEnum)] +enum ForwardProto { + Tcp, + Udp, +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_writer(io::stderr) + .without_time() + .init(); + let opts = Opts::parse(); + + let runtime = Builder::new_current_thread().enable_all().build()?; + + match opts { + Opts::AskPass { + prompt, + description, + } => runtime.block_on(askpass::ask(&prompt, description)), + Opts::LocalAgent => runtime.block_on(main_real()), + Opts::Editor { path } => runtime.block_on(editor::edit(path)), + Opts::Forward { proto, addr } => { + runtime.block_on(editor::forward(matches!(proto, ForwardProto::Udp), addr)) + } + Opts::RealAgent { + path, + privileged, + local, + } => runtime.block_on(main_real_agent(path, privileged, local)), + } +} +async fn main_real() -> anyhow::Result<()> { + let system_conn = Connection::system().await?; + let helper = SocketHelper { + fallback: SuidHelper, + }; + register_auth_agent(&system_conn, Agent::new(helper, RofiPrompter)).await?; + + let session_conn = Connection::session().await?; + askpass::serve(&session_conn, RofiPrompter).await?; + + let _keep_alive = (system_conn, session_conn); + pending().await +} +async fn main_real_agent( + path: Option, + privileged: bool, + local: bool, +) -> anyhow::Result<()> { + let address = if privileged { + Address::AgentPrivileged + } else { + Address::Agent + }; + let mut rpc = Rpc::::new(address); + + let dialer = Arc::new(TunnelDialer::new()); + Fs::new().register_endpoints(&mut rpc); + Systemd.register_endpoints(&mut rpc); + Pty::new(dialer.clone()).register_endpoints(&mut rpc); + Subprocess::new(dialer.clone()).register_endpoints(&mut rpc); + NixDaemon::new(dialer.clone()).register_endpoints(&mut rpc); + IrohTunnel::new(dialer.clone()).register_endpoints(&mut rpc); + Forward::new(dialer.clone()).register_endpoints(&mut rpc); + + remowt_plugin::host::serve(&mut rpc); + + let user_prompter = PromptEndpointsClient::wrap(rpc.remote(Address::User)); + let editor_client = EditorEndpointsClient::wrap(rpc.remote(Address::User)); + + let bus = bus::spawn().await?; + askpass::serve(&bus.conn, user_prompter.clone()).await?; + editor::serve(&bus.conn, editor_client).await?; + + let helpers = tempfile::Builder::new().prefix("remowt-path.").tempdir()?; + let exe = std::env::current_exe()?; + let askpass_helper = helpers.path().join("remowt-askpass"); + let editor_helper = helpers.path().join("remowt-editor"); + let forward_helper = helpers.path().join("remowt-forward"); + { + let script = format!( + "#!/bin/sh\nexec {} ask-pass \"password\" \"$1\"\n", + sh_quote(&exe.to_string_lossy()) + ); + fs::write(&askpass_helper, script).await?; + fs::set_permissions(&askpass_helper, Permissions::from_mode(0o755)).await?; + } + { + let script = format!( + "#!/bin/sh\nexec {} editor \"$1\"\n", + sh_quote(&exe.to_string_lossy()) + ); + fs::write(&editor_helper, script).await?; + fs::set_permissions(&editor_helper, Permissions::from_mode(0o755)).await?; + } + { + let script = format!( + "#!/bin/sh\nexec {} forward \"$@\"\n", + sh_quote(&exe.to_string_lossy()) + ); + fs::write(&forward_helper, script).await?; + fs::set_permissions(&forward_helper, Permissions::from_mode(0o755)).await?; + } + + // Safety: Hoping tokio own threads won't read any of those... + unsafe { + prepend_path(helpers.path()); + std::env::set_var("SUDO_ASKPASS", &askpass_helper); + std::env::set_var("SSH_ASKPASS", &askpass_helper); + std::env::set_var("SSH_ASKPASS_REQUIRE", "force"); + std::env::set_var("EDITOR", &editor_helper); + std::env::set_var("VISUAL", &editor_helper); + std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &bus.address); + } + + let port = match path { + Some(path) => from_socket(UnixStream::connect(path).await?), + None => from_stdio(), + }; + rpc.add_direct(Address::User, port, bifrostlink::Rtt(0)); + + let polkit_conn = if !privileged && !local { + // The unprivileged agent doubles as a polkit authentication agent so + // `run0` (e.g. our own elevation) routes its prompt to the User over + // bifrost instead of failing on a tty-less session. + let conn = Connection::system().await?; + let helper = SocketHelper { + fallback: SuidHelper, + }; + register_auth_agent(&conn, Agent::new(helper, user_prompter)).await?; + Some(conn) + } else { + None + }; + + let _keep_alive = (bus, helpers, polkit_conn); + pending().await +} + +async fn register_auth_agent(conn: &Connection, agent: Agent) -> anyhow::Result<()> +where + H: Helper + Clone + Send + Sync + 'static, + P: Prompter + Clone + Send + Sync + 'static, +{ + let proxy = zbus_polkit::policykit1::AuthorityProxy::new(conn).await?; + conn.object_server().at(OBJ_PATH, agent).await?; + + let subject = auth_agent_subject()?; + proxy + .register_authentication_agent(&subject, "C", OBJ_PATH) + .await?; + debug!(kind = subject.subject_kind, "registered polkit agent"); + Ok(()) +} + +fn auth_agent_subject() -> anyhow::Result { + let mut details = HashMap::new(); + if let Ok(session_id) = std::env::var("XDG_SESSION_ID") { + let val: OwnedValue = Str::from(session_id).into(); + details.insert("session-id".to_string(), val); + return Ok(Subject { + subject_kind: "unix-session".to_string(), + subject_details: details, + }); + } + + details.insert("pid".to_string(), OwnedValue::from(std::process::id())); + Ok(Subject { + subject_kind: "unix-process".to_string(), + subject_details: details, + }) +} + +fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Prepend `dir` to the process `PATH`. +/// +/// # SAFETY +/// +/// Same as `set_var` +unsafe fn prepend_path(dir: &std::path::Path) { + let value = match std::env::var_os("PATH") { + Some(existing) => { + let mut v = dir.as_os_str().to_owned(); + v.push(":"); + v.push(existing); + v + } + None => dir.as_os_str().to_owned(), + }; + unsafe { + std::env::set_var("PATH", value); + } +} --- /dev/null +++ b/remowt/cmds/remowt-ssh/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "remowt-ssh" +description = "SSH transport client for connecting to a remowt agent" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +clap = { workspace = true, features = ["derive"] } +tracing-subscriber.workspace = true +remowt-link-shared.workspace = true +remowt-client.workspace = true +tokio = { workspace = true, features = [ + "macros", + "fs", + "net", + "io-util", + "rt", + "signal", +] } +nix = { workspace = true, features = ["term"] } +anyhow.workspace = true +tracing.workspace = true +remowt-ui-prompt.workspace = true --- /dev/null +++ b/remowt/cmds/remowt-ssh/src/main.rs @@ -0,0 +1,230 @@ +use std::borrow::Cow; +use std::env::VarError; +use std::io; +use std::os::fd::{AsRawFd, RawFd}; +use std::path::PathBuf; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use anyhow::anyhow; +use clap::Parser; +use nix::libc; +use nix::sys::termios::{self, SetArg, Termios}; +use remowt_client::editor::SshEditor; +use remowt_client::{AgentBundle, Remowt}; +use remowt_link_shared::editor::serve_editor; +use remowt_ui_prompt::auto::AutoPrompter; +use remowt_ui_prompt::bifrost::serve_prompts; +use remowt_ui_prompt::{PrependSourcePrompter, Source}; +use tokio::io::unix::AsyncFd; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::signal::unix::{signal, SignalKind}; +use tracing::debug; + +#[derive(Parser)] +enum Opts { + /// Connect to remote host with remowt agent. + Ssh { + host: String, + #[arg(long)] + escalate: bool, + }, + /// Connect to local host for testing the connectivity. + Local { + #[arg(long)] + escalate: bool, + }, +} + +fn agents_dir() -> anyhow::Result { + std::env::var_os("REMOWT_AGENTS_DIR") + .map(PathBuf::from) + .or_else(|| option_env!("REMOWT_AGENTS_DIR").map(PathBuf::from)) + .ok_or_else(|| anyhow!("no remowt-agents bundle")) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .without_time() + .init(); + let opts = Opts::parse(); + + let bundle = AgentBundle::from_dir(agents_dir()?)?; + let (conn, escalate) = match &opts { + Opts::Ssh { host, escalate } => ( + Remowt::connect(host, &bundle, "remowt-ssh".to_owned()).await?, + *escalate, + ), + Opts::Local { escalate } => ( + Remowt::connect_local(&bundle, "remowt-ssh".to_owned()).await?, + *escalate, + ), + }; + let mut rpc = conn.rpc(); + + serve_prompts( + &mut rpc, + PrependSourcePrompter { + prompter: AutoPrompter::new().await, + source: match opts { + Opts::Ssh { host, .. } => vec![Source(Cow::Owned(format!("ssh host: {}", host)))], + Opts::Local { .. } => vec![], + }, + description: "".to_owned(), + }, + ); + if let Some(sess) = conn.ssh() { + serve_editor( + &mut rpc, + SshEditor { + sess, + conn: conn.clone(), + }, + ); + } + + debug!("entering shell"); + run_shell(&conn, escalate).await?; + debug!("shell ended"); + + Ok(()) +} + +async fn run_shell(conn: &Remowt, escalate: bool) -> anyhow::Result<()> { + let term = match std::env::var("TERM") { + Ok(v) => v, + Err(VarError::NotPresent) => "xterm-256color".to_owned(), + Err(e) => return Err(e.into()), + }; + let (cols, rows) = term_size().unwrap_or((80, 24)); + + let shell = conn.open_shell(&term, cols, rows, escalate).await?; + let resizer = shell.resizer(); + let stream = shell.stream; + + let _raw = RawMode::enable(); + + if let Ok(mut winch) = signal(SignalKind::window_change()) { + tokio::spawn(async move { + while winch.recv().await.is_some() { + if let Some((cols, rows)) = term_size() { + let _ = resizer.resize(cols, rows).await; + } + } + }); + } + + let (mut from_remote, mut to_remote) = tokio::io::split(stream); + let mut stdin = AsyncStdin::new()?; + let mut stdout = tokio::io::stdout(); + + tokio::select! { + r = tokio::io::copy(&mut from_remote, &mut stdout) => { r?; } + _ = tokio::io::copy(&mut stdin, &mut to_remote) => {} + } + + Ok(()) +} + +struct AsyncStdin { + fd: AsyncFd, + original_flags: i32, +} + +impl AsyncStdin { + fn new() -> io::Result { + let raw = libc::STDIN_FILENO; + // SAFETY: F_GETFL/F_SETFL round-trip on a valid fd. + let original_flags = unsafe { libc::fcntl(raw, libc::F_GETFL) }; + if original_flags < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(raw, libc::F_SETFL, original_flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(Self { + fd: AsyncFd::new(raw)?, + original_flags, + }) + } +} + +impl Drop for AsyncStdin { + fn drop(&mut self) { + // SAFETY: restoring the flags we saved on a valid fd. + unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, self.original_flags) }; + } +} + +impl AsyncRead for AsyncStdin { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + loop { + let mut guard = match this.fd.poll_read_ready(cx) { + Poll::Ready(Ok(g)) => g, + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + }; + let unfilled = buf.initialize_unfilled(); + let res = guard.try_io(|inner| { + let fd = *inner.get_ref(); + // SAFETY: writing into `unfilled`'s own backing storage. + let n = unsafe { libc::read(fd, unfilled.as_mut_ptr().cast(), unfilled.len()) }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } + }); + match res { + Ok(Ok(n)) => { + buf.advance(n); + return Poll::Ready(Ok(())); + } + Ok(Err(e)) => return Poll::Ready(Err(e)), + Err(_would_block) => continue, + } + } + } +} + +fn term_size() -> Option<(u16, u16)> { + let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::ioctl(libc::STDIN_FILENO, libc::TIOCGWINSZ, &mut ws) }; + if rc != 0 || ws.ws_col == 0 { + None + } else { + Some((ws.ws_col, ws.ws_row)) + } +} + +struct RawMode { + original: Termios, +} + +impl RawMode { + fn enable() -> Option { + let stdin = std::io::stdin(); + // SAFETY: trivial libc call on a borrowed fd. + if unsafe { libc::isatty(stdin.as_raw_fd()) } != 1 { + return None; + } + let original = termios::tcgetattr(&stdin).ok()?; + let mut raw = original.clone(); + termios::cfmakeraw(&mut raw); + termios::tcsetattr(&stdin, SetArg::TCSANOW, &raw).ok()?; + Some(Self { original }) + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + let _ = termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &self.original); + } +} --- /dev/null +++ b/remowt/crates/polkit-shared/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "remowt-polkit-shared" +description = "Shared polkit/PAM types for remowt" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +nix = { workspace = true, features = ["user"] } +serde = { workspace = true, features = ["derive"] } +zbus.workspace = true --- /dev/null +++ b/remowt/crates/polkit-shared/src/lib.rs @@ -0,0 +1,113 @@ +use std::collections::HashMap; +use std::{fmt, fs}; + +use nix::unistd::{Uid, User}; +use serde::{Deserialize, Serialize}; +use zbus::zvariant::{OwnedValue, Type, Value}; + +pub fn emphasize(s: impl AsRef) -> String { + format!("<{}>", escape(s),) +} +fn command(s: impl AsRef) -> String { + format!("{}", s.as_ref()) +} +fn escape(s: impl AsRef) -> String { + s.as_ref() + .replace("&", """) + .replace("<", "<") + .replace(">", ">") +} + +pub struct PidDisplay(pub u32); +impl fmt::Display for PidDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0 == 1 { + emphasize("init").fmt(f) + } else if let Ok(proc) = fs::read_to_string(format!("/proc/{}/cmdline", self.0)) { + write!( + f, + "command: {}", + command( + proc.replace("\0", " ") + .strip_suffix(" ") + .expect("cmdline should end with NUL") + ) + ) + } else if let Ok(proc) = fs::read_to_string(format!("/proc/{}/comm", self.0)) { + write!(f, "process: {}", command(proc.replace("\0", " "))) + } else { + emphasize("unknown process").fmt(f) + } + } +} + +#[derive(Serialize, Deserialize, Type, PartialEq, Debug)] +pub struct Identity { + pub kind: String, + pub details: HashMap, +} + +impl Identity { + pub fn uid(&self) -> Option { + if self.kind != "unix-user" { + return None; + } + let uid = self.details.get("uid")?; + let Value::U32(uid) = &**uid else { + return None; + }; + Some(Uid::from_raw(*uid)) + } +} + +impl fmt::Display for Identity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.kind.as_str() { + "unix-user" => match self.details.get("uid").map(|v| &**v) { + Some(Value::U32(uid)) => match User::from_uid(Uid::from_raw(*uid)) { + Ok(Some(u)) => write!( + f, + "user: {} {} {}", + u.name, + u.uid, + if u.gecos.is_empty() { + "".to_owned() + } else { + format!(": {}", escape(u.gecos.to_string_lossy())) + } + ), + Ok(None) => emphasize("not found").fmt(f), + Err(e) => { + let user = format!("could not get user: {e}"); + emphasize(&user).fmt(f)?; + Ok(()) + } + }, + + _ => emphasize("unknown uid").fmt(f), + }, + _ => emphasize(format!("identity of unknown kind: {}", self.kind)).fmt(f), + } + } +} + +impl Clone for Identity { + fn clone(&self) -> Self { + Self { + kind: self.kind.clone(), + details: self + .details + .iter() + .map(|(k, v)| (k.clone(), v.try_clone().expect("no fds are expected"))) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, Type, PartialEq, Debug)] +pub struct BackendRequest { + pub cookie: String, + pub environment: HashMap, + pub prompter_path: String, + pub identity: Identity, +} --- /dev/null +++ b/remowt/crates/remowt-client/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "remowt-client" +description = "russh-based client connection to a remowt agent" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +anyhow.workspace = true +bifrostlink.workspace = true +bytes.workspace = true +camino.workspace = true +serde.workspace = true +serde_json.workspace = true +remowt-link-shared.workspace = true +russh.workspace = true +russh-config.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = [ + "net", + "io-util", + "rt", + "sync", + "macros", + "process", +] } +tracing.workspace = true +uuid = { workspace = true, features = ["v4"] } +remowt-endpoints.workspace = true +tokio-util = { workspace = true, features = ["codec"] } +futures.workspace = true +iroh.workspace = true --- /dev/null +++ b/remowt/crates/remowt-client/src/editor.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use remowt_endpoints::forward::ForwardClient; +use remowt_link_shared::editor::{EditorBackend, Error}; +use remowt_link_shared::BifConfig; +use russh::client::Handle; +use tokio::net::{TcpListener, UdpSocket, UnixListener}; +use tracing::error; + +use crate::{Remowt, SshHandler}; + +pub struct SshEditor { + pub sess: Arc>, + pub conn: Remowt, +} +impl EditorBackend for SshEditor { + async fn open_editor(&self, socket_path: String) -> Result<(), Error> { + let local = std::env::temp_dir().join(format!("remowt-nvim-{}.sock", uuid::Uuid::new_v4())); + let _ = std::fs::remove_file(&local); + let listener = UnixListener::bind(&local).map_err(|e| Error::Failed(e.to_string()))?; + + let sess = self.sess.clone(); + let forward = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let sess = sess.clone(); + let remote = socket_path.clone(); + tokio::spawn(async move { + match sess.channel_open_direct_streamlocal(remote).await { + Ok(ch) => { + let mut remote = ch.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut remote).await; + } + Err(e) => error!("opening direct-streamlocal to nvim failed: {e}"), + } + }); + } + }); + + let status = tokio::process::Command::new("neovide") + .arg("--no-fork") + .arg("--server") + .arg(&local) + .status() + .await + .map_err(|e| Error::Failed(format!("spawning neovide: {e}"))); + + forward.abort(); + let _ = std::fs::remove_file(&local); + + match status? { + s if s.success() => Ok(()), + s => Err(Error::Failed(format!("neovide exited with {s}"))), + } + } + + async fn expose_tcp(&self, addr: String) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|e| Error::Failed(e.to_string()))?; + let local = listener + .local_addr() + .map_err(|e| Error::Failed(e.to_string()))? + .port(); + + let conn = self.conn.clone(); + tokio::spawn(async move { + loop { + let Ok((mut tcp, _)) = listener.accept().await else { + break; + }; + let conn = conn.clone(); + let addr = addr.clone(); + tokio::spawn(async move { + let (forwarded, tunnel) = match conn.bind_fast_tunnel("forward", false).await { + Ok(v) => v, + Err(e) => { + error!("forward: bind tunnel failed: {e}"); + return; + } + }; + let fclient: ForwardClient = conn.endpoints(); + match fclient.connect_tcp(tunnel, addr).await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + error!("forward: agent connect_tcp failed: {e}"); + return; + } + Err(e) => { + error!("forward: connect_tcp rpc failed: {e}"); + return; + } + } + match forwarded.accept().await { + Ok(mut stream) => { + let _ = tokio::io::copy_bidirectional(&mut tcp, &mut stream).await; + } + Err(e) => error!("forward: accept tunnel failed: {e}"), + } + }); + } + }); + + Ok(local) + } + + async fn expose_udp(&self, addr: String) -> Result { + let router = self.conn.datagram_router().ok_or_else(|| { + Error::Failed( + "udp forward requires the iroh fast path, which is not established".into(), + ) + })?; + + let fclient: ForwardClient = self.conn.endpoints(); + let session = fclient + .open_udp(addr) + .await + .map_err(|e| Error::Failed(format!("open_udp rpc: {e}")))? + .map_err(|e| Error::Failed(format!("agent open_udp: {e}")))?; + + let sock = Arc::new( + UdpSocket::bind(("127.0.0.1", 0)) + .await + .map_err(|e| Error::Failed(e.to_string()))?, + ); + let local = sock + .local_addr() + .map_err(|e| Error::Failed(e.to_string()))? + .port(); + + let sub_for_source: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + let source_for_sub: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + let next_sub = Arc::new(AtomicU64::new(0)); + let mut rx = router.register(session); + + let up_sock = sock.clone(); + let up_router = router.clone(); + let down_source_for_sub = source_for_sub.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 65535]; + loop { + let (n, src) = match up_sock.recv_from(&mut buf).await { + Ok(v) => v, + Err(_) => break, + }; + let sub = { + let mut by_src = sub_for_source.lock().expect("lock"); + if let Some(&sub) = by_src.get(&src) { + sub + } else { + let sub = next_sub.fetch_add(1, Ordering::Relaxed); + by_src.insert(src, sub); + source_for_sub.lock().expect("lock").insert(sub, src); + sub + } + }; + if up_router.send(session, sub, &buf[..n]).is_err() { + break; + } + } + up_router.unregister(session); + }); + + let down_sock = sock.clone(); + tokio::spawn(async move { + while let Some((sub, payload)) = rx.recv().await { + let dst = down_source_for_sub.lock().expect("lock").get(&sub).copied(); + if let Some(dst) = dst { + let _ = down_sock.send_to(&payload, dst).await; + } + } + }); + + Ok(local) + } +} --- /dev/null +++ b/remowt/crates/remowt-client/src/forwarded.rs @@ -0,0 +1,89 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use anyhow::{anyhow, Result}; +use camino::Utf8PathBuf; +use remowt_link_shared::iroh_tunnel::IrohBiStream; +use russh::client::Msg; +use russh::{Channel, ChannelStream}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::oneshot; + +pub enum RemowtListener { + Ssh(oneshot::Receiver>), + Local(UnixListener, Utf8PathBuf), + Iroh(oneshot::Receiver), +} + +impl RemowtListener { + pub async fn accept(self) -> Result { + match self { + RemowtListener::Ssh(rx) => { + let ch = rx + .await + .map_err(|_| anyhow!("agent never connected the forwarded socket"))?; + Ok(RemowtStream::Ssh(ch.into_stream())) + } + RemowtListener::Local(listener, path) => { + let (stream, _) = listener.accept().await?; + let _ = std::fs::remove_file(&path); + Ok(RemowtStream::Local(stream)) + } + RemowtListener::Iroh(rx) => rx + .await + .map_err(|_| anyhow!("agent never opened the iroh tunnel")), + } + } +} + +pub enum RemowtStream { + Ssh(ChannelStream), + Local(UnixStream), + Iroh(IrohBiStream), +} + +impl AsyncRead for RemowtStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + RemowtStream::Ssh(s) => Pin::new(s).poll_read(cx, buf), + RemowtStream::Local(s) => Pin::new(s).poll_read(cx, buf), + RemowtStream::Iroh(s) => Pin::new(s).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for RemowtStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + RemowtStream::Ssh(s) => Pin::new(s).poll_write(cx, buf), + RemowtStream::Local(s) => Pin::new(s).poll_write(cx, buf), + RemowtStream::Iroh(s) => Pin::new(s).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + RemowtStream::Ssh(s) => Pin::new(s).poll_flush(cx), + RemowtStream::Local(s) => Pin::new(s).poll_flush(cx), + RemowtStream::Iroh(s) => Pin::new(s).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + RemowtStream::Ssh(s) => Pin::new(s).poll_shutdown(cx), + RemowtStream::Local(s) => Pin::new(s).poll_shutdown(cx), + RemowtStream::Iroh(s) => Pin::new(s).poll_shutdown(cx), + } + } +} --- /dev/null +++ b/remowt/crates/remowt-client/src/lib.rs @@ -0,0 +1,714 @@ +use std::collections::HashMap; +use std::env; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, bail, ensure, Context as _, Result}; +use bifrostlink::declarative::RemoteEndpoints; +use bifrostlink::{Remote, Rpc, Rtt}; +use camino::{Utf8Path, Utf8PathBuf}; +use remowt_link_shared::iroh_tunnel::{DatagramRouter, IrohBiStream, TunnelAddr}; +use remowt_link_shared::plugin::PluginEndpointsClient; +use remowt_link_shared::port::child_port; +use remowt_link_shared::{Address, BifConfig}; +use russh::client::{connect, Config, Handle, Handler, Msg, Session}; +use russh::keys::agent::client::AgentClient; +use russh::keys::agent::AgentIdentity; +use russh::keys::check_known_hosts; +use russh::keys::ssh_key::PublicKey; +use russh::Channel; +use tempfile::TempDir; +use tokio::io::AsyncRead; +use tokio::net::UnixListener; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio::{ + fs, + io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}, +}; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +pub mod editor; +mod forwarded; +mod shell; +mod ssh_exec; +mod subprocess; + +use self::ssh_exec::SshExecChild; +pub use self::subprocess::{RemowtChild, SpawnOptions, StderrMode, StdioMode}; +pub use forwarded::{RemowtListener, RemowtStream}; +pub use shell::{RemowtShell, RemowtShellResizer}; + +type Subs = Arc>>>>; + +fn sh_quote(s: impl AsRef) -> String { + format!("'{}'", s.as_ref().replace('\'', "'\\''")) +} + +const ESCALATORS: [(&str, &[&str]); 2] = [("run0", &["--background=", "--pipe"]), ("sudo", &[])]; + +pub struct AgentBundle { + dir: PathBuf, + hashes: HashMap, +} + +impl AgentBundle { + pub fn from_dir(dir: impl Into) -> Result { + let dir = dir.into(); + let hashes_path = dir.join("hashes"); + let raw = std::fs::read_to_string(&hashes_path) + .with_context(|| format!("reading agent hashes at {}", hashes_path.display()))?; + let mut hashes = HashMap::new(); + for line in raw.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let (arch, hash) = line + .split_once(char::is_whitespace) + .ok_or_else(|| anyhow!("malformed hashes line: {line:?}"))?; + hashes.insert(arch.to_owned(), hash.trim().to_owned()); + } + ensure!( + !hashes.is_empty(), + "agent bundle {} has no hashes", + dir.display() + ); + Ok(Self { dir, hashes }) + } + + fn binary(&self, arch: &str) -> PathBuf { + self.dir.join(format!("remowt-agent-{arch}")) + } + + fn local_binary(&self) -> Result { + let arch = env::consts::ARCH; + let path = self.binary(arch); + ensure!( + path.is_file(), + "no local remowt-agent build for arch {arch} in bundle {}", + self.dir.display() + ); + Ok(path) + } +} + +async fn run(sess: &Handle, cmd: &str) -> Result<(Option, Vec)> { + let ch = sess.channel_open_session().await?; + ch.exec(true, cmd).await?; + + let mut child = SshExecChild::from_exec(ch); + drop(child.stdin); + drain_to_tracing(child.stderr, cmd.to_owned(), true); + + let mut out = Vec::new(); + child.stdout.read_to_end(&mut out).await?; + let code = child.exit.await.ok().flatten(); + Ok((code, out)) +} + +async fn run_string_ok(sess: &Handle, cmd: &str) -> Result { + let (code, mut out) = run(sess, cmd).await?; + ensure!( + code == Some(0), + "remote command failed (exit {code:?}): {cmd}" + ); + if !out.is_empty() { + ensure!( + out.ends_with(b"\n"), + "remote command was not newline-terminated: {cmd}: {out:?}" + ); + out.pop(); + } + String::from_utf8(out).context("expected utf8 output for command") +} + +async fn deploy_agent(sess: &Handle, bundle: &AgentBundle) -> Result { + debug!("uname -a"); + let arch = run_string_ok(sess, "uname -m").await?; + let hash = bundle + .hashes + .get(&arch) + .ok_or_else(|| anyhow!("no remowt-agent build for remote arch {arch:?}"))?; + + debug!("get dir"); + let cache = run_string_ok(sess, "echo \"$XDG_CACHE_HOME\"").await?; + let dir = if cache.is_empty() { + let home = run_string_ok(sess, "echo \"$HOME\"").await?; + ensure!( + !home.is_empty(), + "remote $HOME and $XDG_CACHE_HOME both empty" + ); + Utf8PathBuf::from(home).join(".cache/remowt") + } else { + Utf8PathBuf::from(cache).join("remowt") + }; + let path = dir.join(hash); + + debug!("presence"); + let (present, _) = run(sess, &format!("test -x {}", sh_quote(&path))).await?; + if present != Some(0) { + let bin = bundle.binary(&arch); + debug!("read"); + let bytes = fs::read(&bin) + .await + .with_context(|| format!("reading agent binary {}", bin.display()))?; + debug!("upload"); + upload_agent(sess, &dir, &path, bytes).await?; + } + Ok(path) +} + +async fn upload_agent( + sess: &Handle, + dir: &Utf8Path, + path: &Utf8Path, + bytes: Vec, +) -> Result<()> { + debug!("mkdirp"); + run_string_ok(sess, &format!("mkdir -p {}", sh_quote(dir))).await?; + + let tmp = dir.join(format!("tmp.{}", Uuid::new_v4())); + let ch = sess.channel_open_session().await?; + debug!("cat"); + ch.exec(true, format!("cat > {}", sh_quote(&tmp))).await?; + + let mut child = SshExecChild::from_exec(ch); + child + .stdin + .write_all(&bytes) + .await + .context("sending agent binary")?; + child + .stdin + .shutdown() + .await + .context("sending agent binary")?; + let code = child.wait().await; + ensure!(code == Some(0), "agent upload failed (exit {code:?})"); + + debug!("chmod"); + run_string_ok(sess, &format!("chmod 0755 {}", sh_quote(&tmp))).await?; + run_string_ok( + sess, + &format!("mv -f {} {}", sh_quote(&tmp), sh_quote(path)), + ) + .await?; + Ok(()) +} + +pub struct SshHandler { + host: String, + port: u16, + subs: Subs, +} +impl Handler for SshHandler { + type Error = russh::Error; + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { + Ok(check_known_hosts(&self.host, self.port, server_public_key)?) + } + async fn server_channel_open_forwarded_streamlocal( + &mut self, + channel: Channel, + socket_path: &str, + _session: &mut Session, + ) -> Result<(), Self::Error> { + let Some(ch) = self + .subs + .lock() + .expect("lock") + .remove(&Utf8PathBuf::from(socket_path)) + else { + return Err(russh::Error::WrongChannel); + }; + let _ = ch.send(channel); + Ok(()) + } +} + +enum Transport { + Ssh { + sess: Arc>, + subs: Subs, + runtime_dir: Utf8PathBuf, + agent_path: Utf8PathBuf, + }, + Local { + agent_path: PathBuf, + runtime_dir: Utf8PathBuf, + }, +} + +struct RemowtInner { + transport: Transport, + rpc: Rpc, + elevated: tokio::sync::OnceCell<()>, + #[allow(dead_code)] + children: Mutex>, + _runtime_tmp: Option, + user: String, + iroh: IrohState, +} + +#[derive(Default)] +struct IrohState { + conn: tokio::sync::OnceCell, + #[allow(dead_code)] + endpoint: Mutex>, + subs: Arc>>>, + next_token: AtomicU64, + router: Mutex>>, +} + +#[derive(Clone)] +pub struct Remowt(Arc); + +pub type RemowtRemote = Remote; + +impl Remowt { + /// Connect to the remote host over ssh, detect the architecture and deploy the required + /// agent binary. + pub async fn connect(host: &str, bundle: &AgentBundle, remowt_user: String) -> Result { + let conf = russh_config::parse_home(host)?; + let port = conf.host_config.port.or(conf.port).unwrap_or(22); + let hostname = conf + .host_config + .hostname + .clone() + .unwrap_or_else(|| conf.host_name.clone()); + let user = conf + .user + .clone() + .unwrap_or_else(|| env::var("USER").unwrap_or_else(|_| "root".to_owned())); + + let subs: Subs = Arc::new(Mutex::new(HashMap::new())); + let mut sess = connect( + Arc::new(Config::default()), + (hostname.clone(), port), + SshHandler { + host: hostname, + port, + subs: subs.clone(), + }, + ) + .await?; + + let mut agent = AgentClient::connect_env().await?; + let rsa_hash = sess.best_supported_rsa_hash().await?.flatten(); + let mut authenticated = false; + for ident in agent.request_identities().await? { + let AgentIdentity::PublicKey { key, .. } = ident else { + continue; + }; + if sess + .authenticate_publickey_with(user.clone(), key, rsa_hash, &mut agent) + .await? + .success() + { + authenticated = true; + break; + } + } + ensure!(authenticated, "ssh authentication failed"); + + let sess = Arc::new(sess); + + debug!("deploying agent"); + let agent_path = deploy_agent(&sess, bundle).await?; + + debug!("runtime dir"); + let runtime_dir = remote_runtime_dir(&sess).await?; + + let rpc = Rpc::::new(Address::User); + + let cmd_chan = sess.channel_open_session().await?; + debug!("starting agent"); + cmd_chan + .exec(true, format!("{} real-agent", sh_quote(&agent_path))) + .await?; + + let child = SshExecChild::from_exec(cmd_chan); + drain_to_tracing(child.stderr, "agent".to_owned(), true); + rpc.add_direct( + Address::Agent, + child_port(child.stdout, child.stdin), + Rtt(0), + ); + + let remowt = Self(Arc::new(RemowtInner { + transport: Transport::Ssh { + sess, + subs, + runtime_dir, + agent_path, + }, + rpc, + elevated: tokio::sync::OnceCell::new(), + children: Mutex::new(Vec::new()), + _runtime_tmp: None, + user: remowt_user, + iroh: IrohState::default(), + })); + remowt.setup_iroh().await; + Ok(remowt) + } + + /// "Connect" to the local machine's agent, by starting the agent binary locally. + pub async fn connect_local(bundle: &AgentBundle, user: String) -> Result { + let agent_path = bundle.local_binary()?; + let mut child = tokio::process::Command::new(&agent_path) + .arg("real-agent") + .arg("--local") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("spawning agent binary {}", agent_path.display()))?; + let stdin = child.stdin.take().expect("stdin piped"); + let stdout = child.stdout.take().expect("stdout piped"); + + let rpc = Rpc::::new(Address::User); + rpc.add_direct(Address::Agent, child_port(stdout, stdin), Rtt(0)); + + let (runtime_dir, runtime_tmp) = local_runtime_dir()?; + + Ok(Self(Arc::new(RemowtInner { + transport: Transport::Local { + agent_path, + runtime_dir, + }, + rpc, + elevated: tokio::sync::OnceCell::new(), + children: Mutex::new(vec![child]), + _runtime_tmp: runtime_tmp, + user, + iroh: IrohState::default(), + }))) + } + + /// Get the handle to the underlying russh session handle. + pub fn ssh(&self) -> Option>> { + match &self.0.transport { + Transport::Ssh { sess, .. } => Some(sess.clone()), + Transport::Local { .. } => None, + } + } + + pub fn rpc(&self) -> Rpc { + self.0.rpc.clone() + } + + pub async fn load_plugin(&self, id: u16, name: &str) -> Result<()> { + let client: PluginEndpointsClient = self.endpoints(); + client + .load_plugin(id, name.to_owned()) + .await? + .map_err(|e| anyhow!("agent failed to load plugin: {e}")) + } + pub async fn run0_load_plugin_path(&self, id: u16, path: &str) -> Result<()> { + self.ensure_escalated().await?; + let client: PluginEndpointsClient = + PluginEndpointsClient::wrap(self.0.rpc.remote(Address::AgentPrivileged)); + client + .load_plugin_path(id, path.to_owned()) + .await? + .map_err(|e| anyhow!("privileged agent failed to load plugin: {e}")) + } + pub fn plugin_endpoints>(&self, id: u16) -> R { + R::wrap(self.0.rpc.remote(Address::Plugin(id))) + } + + pub fn endpoints>(&self) -> R { + R::wrap(self.0.rpc.remote(Address::Agent)) + } + pub async fn run0_endpoints>(&self) -> Result { + self.ensure_escalated().await?; + Ok(R::wrap(self.0.rpc.remote(Address::AgentPrivileged))) + } + + async fn ensure_escalated(&self) -> Result<()> { + self.0 + .elevated + .get_or_try_init(|| async { + let (agent_path, local) = match &self.0.transport { + Transport::Ssh { agent_path, .. } => (agent_path.as_str().to_owned(), false), + Transport::Local { agent_path, .. } => ( + agent_path + .to_str() + .ok_or_else(|| anyhow!("local agent path is not utf-8"))? + .to_owned(), + true, + ), + }; + + let (tool, flags) = self.detect_escalation().await?; + let mut args: Vec = Vec::new(); + args.push("-w".to_owned()); + args.push(tool.to_owned()); + args.extend(flags.iter().copied().map(str::to_owned)); + if tool == "run0" { + args.push(format!( + "--unit={}-{}.service", + self.0.user, + Uuid::new_v4().simple() + )); + } + args.push(agent_path); + args.push("real-agent".to_owned()); + args.push("--privileged".to_owned()); + if local { + args.push("--local".to_owned()); + } + + let child = self + .spawn(SpawnOptions { + program: "setsid".to_owned(), + args, + stdin: StdioMode::Pipe, + stdout: StdioMode::Pipe, + stderr: StderrMode::Inherit, + ..Default::default() + }) + .await + .context("spawning privileged agent")?; + + let stdin = child + .stdin + .ok_or_else(|| anyhow!("privileged agent stdin missing"))?; + let stdout = child + .stdout + .ok_or_else(|| anyhow!("privileged agent stdout missing"))?; + + let port = child_port(stdout, stdin); + self.0 + .rpc + .add_direct(Address::AgentPrivileged, port, Rtt(0)); + anyhow::Ok(()) + }) + .await?; + Ok(()) + } + + async fn detect_escalation(&self) -> Result<(&'static str, &'static [&'static str])> { + for (tool, flags) in ESCALATORS { + let probe = self + .spawn(SpawnOptions { + program: (*tool).to_owned(), + args: vec!["--version".to_owned()], + stdout: StdioMode::Null, + stderr: StderrMode::Null, + ..Default::default() + }) + .await; + if let Ok(child) = probe { + let _ = child.wait().await; + return Ok((tool, flags)); + } + } + bail!("no escalation tool found") + } + + /// XDG_RUNTIME_DIR on the remote machine. + pub fn runtime_dir(&self) -> Utf8PathBuf { + match &self.0.transport { + Transport::Ssh { runtime_dir, .. } => runtime_dir.clone(), + Transport::Local { runtime_dir, .. } => runtime_dir.clone(), + } + } + + /// Bind unix listener socket on the remote machine with auto-generated path, returning the path. + pub async fn bind_runtime_unix(&self, hint: &str) -> Result<(RemowtListener, Utf8PathBuf)> { + let sock = self + .runtime_dir() + .join(format!("remowt-{hint}-{}.sock", Uuid::new_v4())); + let listener = self.bind_unix(&sock).await?; + Ok((listener, sock)) + } + + /// Bind unix listener socket on the remote machine on the specified path. + pub async fn bind_unix(&self, path: &Utf8Path) -> Result { + match &self.0.transport { + Transport::Ssh { sess, subs, .. } => { + let (tx, rx) = oneshot::channel(); + subs.lock().expect("lock").insert(path.to_owned(), tx); + sess.streamlocal_forward(path.to_owned()).await?; + Ok(RemowtListener::Ssh(rx)) + } + Transport::Local { .. } => { + let _ = std::fs::remove_file(path); + Ok(RemowtListener::Local( + UnixListener::bind(path)?, + path.to_owned(), + )) + } + } + } + + /// Bind a data tunnel, preferring the iroh fast path when it is up. Escalated tunnels + /// (the privileged agent, a separate process with no iroh connection of its own) and the + /// local transport always use the ssh/unix path. + pub async fn bind_fast_tunnel( + &self, + hint: &str, + escalated: bool, + ) -> Result<(RemowtListener, TunnelAddr)> { + if !escalated && self.0.iroh.conn.get().is_some() { + let token = self + .0 + .iroh + .next_token + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.0.iroh.subs.lock().expect("lock").insert(token, tx); + return Ok((RemowtListener::Iroh(rx), TunnelAddr::Iroh { token })); + } + let (listener, path) = self.bind_runtime_unix(hint).await?; + Ok((listener, TunnelAddr::Unix(path))) + } + + async fn setup_iroh(&self) { + if std::env::var_os("REMOWT_NO_IROH").is_some() { + debug!("REMOWT_NO_IROH set, skipping iroh fast tunnel"); + return; + } + if !matches!(self.0.transport, Transport::Ssh { .. }) { + return; + } + if let Err(e) = self.try_setup_iroh().await { + warn!("iroh fast tunnel unavailable, using ssh: {e}"); + } + } + + async fn try_setup_iroh(&self) -> Result<()> { + use remowt_endpoints::iroh_tunnel::IrohTunnelClient; + use remowt_link_shared::iroh_tunnel::{build_endpoint, ssh_custom_addr, REMOWT_ALPN}; + + let (listener, sock) = self.bind_runtime_unix("iroh-xport").await?; + let secret = iroh::SecretKey::generate(); + let client_id = secret.public(); + + let client: IrohTunnelClient = + IrohTunnelClient::wrap(self.0.rpc.remote(Address::Agent)); + let (accepted, agent_id) = tokio::join!(listener.accept(), client.setup(client_id, sock)); + let stream = accepted?; + let agent_id = agent_id?.map_err(|e| anyhow!("agent iroh setup failed: {e}"))?; + + let ep = build_endpoint(secret, stream, agent_id, false).await?; + let addr = iroh::EndpointAddr::from_parts( + agent_id, + [iroh::TransportAddr::Custom(ssh_custom_addr(agent_id))], + ); + let conn = ep.connect(addr, REMOWT_ALPN).await?; + ensure!(conn.remote_id() == agent_id, "iroh peer identity mismatch"); + info!("iroh fast tunnel established"); + + let subs = self.0.iroh.subs.clone(); + let accept_conn = conn.clone(); + tokio::spawn(async move { + loop { + match accept_conn.accept_bi().await { + Ok((send, mut recv)) => { + let subs = subs.clone(); + tokio::spawn(async move { + let mut buf = [0u8; 8]; + if tokio::io::AsyncReadExt::read_exact(&mut recv, &mut buf) + .await + .is_err() + { + return; + } + let token = u64::from_be_bytes(buf); + let tx = subs.lock().expect("lock").remove(&token); + if let Some(tx) = tx { + let _ = tx.send(RemowtStream::Iroh(IrohBiStream::new(send, recv))); + } + }); + } + Err(e) => { + debug!("iroh accept loop ended: {e}"); + break; + } + } + } + }); + + let log_conn = conn.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + for p in log_conn.paths().iter() { + if p.is_selected() { + info!(rtt = ?p.rtt(), "iroh selected path: {}", p.remote_addr()); + } + } + }); + + *self.0.iroh.router.lock().expect("lock") = Some(DatagramRouter::spawn(conn.clone())); + *self.0.iroh.endpoint.lock().expect("lock") = Some(ep); + let _ = self.0.iroh.conn.set(conn); + Ok(()) + } + + pub fn datagram_router(&self) -> Option> { + self.0.iroh.router.lock().expect("lock").clone() + } +} + +pub(crate) fn drain_to_tracing( + stream: impl AsyncRead + Unpin + 'static + Send, + context: String, + stderr: bool, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut reader = BufReader::new(stream); + let mut buf = Vec::with_capacity(4096); + loop { + buf.clear(); + match reader.read_until(b'\n', &mut buf).await { + Ok(0) => break, + Ok(_) => { + let line = String::from_utf8_lossy(buf.strip_suffix(b"\n").unwrap_or(&buf)); + if stderr { + warn!(context = %context, "{line}"); + } else { + info!(context = %context, "{line}"); + } + } + Err(e) => { + warn!(context = %context, "child stdio read failed: {e}"); + break; + } + } + } + }) +} + +fn local_runtime_dir() -> Result<(Utf8PathBuf, Option)> { + if let Ok(dir) = env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return Ok((Utf8PathBuf::from(dir), None)); + } + } + let tmp = tempfile::Builder::new() + .prefix("remowt.") + .rand_bytes(12) + .tempdir()?; + let dir = Utf8PathBuf::from_path_buf(tmp.path().to_owned()) + .map_err(|p| anyhow!("temp dir {} is not utf-8", p.display()))?; + Ok((dir, Some(tmp))) +} + +async fn remote_runtime_dir(sess: &Handle) -> Result { + let dir = run_string_ok(sess, "echo \"$XDG_RUNTIME_DIR\"").await?; + let dir = dir.trim(); + if dir.is_empty() { + let tmp = run_string_ok(sess, "mktemp -d remowt.XXXXXXXXXXXX --tmpdir").await?; + Ok(Utf8PathBuf::from(tmp)) + } else { + Ok(Utf8PathBuf::from(dir)) + } +} --- /dev/null +++ b/remowt/crates/remowt-client/src/shell.rs @@ -0,0 +1,65 @@ +use anyhow::{anyhow, Result}; +use bifrostlink::declarative::RemoteEndpoints as _; +use bifrostlink::Remote; +use remowt_endpoints::pty::{PtyClient, ShellId}; +use remowt_link_shared::{Address, BifConfig}; + +use crate::forwarded::RemowtStream; +use crate::Remowt; + +pub struct RemowtShell { + pub id: ShellId, + pub stream: RemowtStream, + remote: Remote, +} +impl RemowtShell { + pub fn resizer(&self) -> RemowtShellResizer { + RemowtShellResizer { + remote: self.remote.clone(), + id: self.id, + } + } +} + +#[derive(Clone)] +pub struct RemowtShellResizer { + remote: Remote, + id: ShellId, +} + +impl RemowtShellResizer { + pub async fn resize(&self, cols: u16, rows: u16) -> Result<()> { + PtyClient::wrap(self.remote.clone()) + .resize(self.id, cols, rows) + .await? + .map_err(|e| anyhow!("failed to resize remote shell: {e}")) + } +} + +impl Remowt { + pub async fn open_shell( + &self, + term: &str, + cols: u16, + rows: u16, + escalate: bool, + ) -> Result { + let (forwarded, tunnel) = self.bind_fast_tunnel("shell", escalate).await?; + let client: PtyClient = if escalate { + self.run0_endpoints().await? + } else { + self.endpoints() + }; + let id = client + .open_shell(tunnel, term.to_owned(), cols, rows) + .await? + .map_err(|e| anyhow!("agent failed to open shell: {e}"))?; + let stream = forwarded.accept().await?; + + Ok(RemowtShell { + id, + stream, + remote: self.0.rpc.remote(Address::Agent), + }) + } +} --- /dev/null +++ b/remowt/crates/remowt-client/src/ssh_exec.rs @@ -0,0 +1,82 @@ +use bytes::Bytes; +use russh::client::Msg; +use russh::{Channel, ChannelMsg}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _, DuplexStream}; +use tokio::sync::oneshot; + +const BUF: usize = 64 * 1024; + +pub(crate) struct SshExecChild { + pub stdin: DuplexStream, + pub stdout: DuplexStream, + pub stderr: DuplexStream, + pub exit: oneshot::Receiver>, +} + +impl SshExecChild { + /// Manage channel returned by russh exec(). + pub(crate) fn from_exec(ch: Channel) -> Self { + let (stdin, mut stdin_r) = tokio::io::duplex(BUF); + let (mut out_w, stdout) = tokio::io::duplex(BUF); + let (mut err_w, stderr) = tokio::io::duplex(BUF); + let (exit_tx, exit) = oneshot::channel(); + + tokio::spawn(async move { + let (mut read, write) = ch.split(); + + let stdin_pump = tokio::spawn(async move { + let mut buf = vec![0u8; BUF]; + loop { + match stdin_r.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if write + .data_bytes(Bytes::copy_from_slice(&buf[..n])) + .await + .is_err() + { + return; + } + } + } + } + let _ = write.eof().await; + }); + + let mut code = None; + while let Some(msg) = read.wait().await { + match msg { + ChannelMsg::Data { data } => { + if out_w.write_all(&data).await.is_err() { + break; + } + } + ChannelMsg::ExtendedData { data, .. } => { + if err_w.write_all(&data).await.is_err() { + break; + } + } + ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status), + _ => {} + } + } + + stdin_pump.abort(); + let _ = out_w.shutdown().await; + let _ = err_w.shutdown().await; + let _ = exit_tx.send(code); + }); + + SshExecChild { + stdin, + stdout, + stderr, + exit, + } + } + + /// Wait for the process to finish, returning its exit status. + pub(crate) async fn wait(self) -> Option { + self.exit.await.ok().flatten() + } +} --- /dev/null +++ b/remowt/crates/remowt-client/src/subprocess.rs @@ -0,0 +1,439 @@ +use std::pin::pin; + +use anyhow::{anyhow, bail, Result}; +use camino::Utf8PathBuf; +use futures::StreamExt as _; +use remowt_endpoints::subprocess::{ProcId, SpawnSpec, StderrSpec, StdioSpec, SubprocessClient}; +use remowt_link_shared::iroh_tunnel::TunnelAddr; +use remowt_link_shared::BifConfig; +use serde::de::DeserializeOwned; +use tokio::io::AsyncWriteExt as _; +use tokio::select; +use tokio_util::codec::{BytesCodec, FramedRead, LinesCodec}; +use tracing::{debug, info, warn}; + +use crate::forwarded::{RemowtListener, RemowtStream}; +use crate::{drain_to_tracing, Remowt}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StdioMode { + #[default] + Null, + Pipe, + Inherit, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StderrMode { + #[default] + Null, + Pipe, + Inherit, + MergeWithStdout, +} + +#[derive(Default)] +pub struct SpawnOptions { + pub program: String, + pub args: Vec, + pub env: Vec<(String, String)>, + pub env_clear: bool, + pub cwd: Option, + pub escalated: bool, + pub stdin: StdioMode, + pub stdout: StdioMode, + pub stderr: StderrMode, +} + +pub struct RemowtChild { + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, + id: ProcId, + client: SubprocessClient, +} + +impl RemowtChild { + pub async fn wait(self) -> Result> { + let RemowtChild { + stdin, + stdout, + stderr, + id, + client, + } = self; + drop(stdin); + let drain_out = async move { + if let Some(s) = stdout { + let _ = drain_to_tracing(s, "".to_owned(), false).await; + } + }; + let drain_err = async move { + if let Some(s) = stderr { + let _ = drain_to_tracing(s, "".to_owned(), true).await; + } + }; + let wait = async move { + client + .wait(id) + .await? + .map_err(|e| anyhow!("agent wait failed: {e}")) + }; + let (code, _, _) = tokio::join!(wait, drain_out, drain_err); + code + } + + pub async fn kill(&self, signal: i32) -> Result<()> { + self.client + .kill(self.id, signal) + .await? + .map_err(|e| anyhow!("agent kill failed: {e}")) + } +} + +fn needs_socket(m: StdioMode) -> bool { + matches!(m, StdioMode::Pipe | StdioMode::Inherit) +} + +fn stderr_needs_socket(m: StderrMode) -> bool { + matches!(m, StderrMode::Pipe | StderrMode::Inherit) +} + +impl Remowt { + pub async fn spawn(&self, opts: SpawnOptions) -> Result { + let SpawnOptions { + program, + args, + env, + env_clear, + cwd, + escalated, + stdin, + stdout, + stderr, + } = opts; + + if matches!(stderr, StderrMode::MergeWithStdout) && !needs_socket(stdout) { + bail!("stderr=MergeWithStdout requires stdout=Pipe or Inherit"); + } + + let stdin_bound = if needs_socket(stdin) { + Some(self.bind_fast_tunnel("proc-stdin", escalated).await?) + } else { + None + }; + let stdout_bound = if needs_socket(stdout) { + Some(self.bind_fast_tunnel("proc-stdout", escalated).await?) + } else { + None + }; + let stderr_bound = if stderr_needs_socket(stderr) { + Some(self.bind_fast_tunnel("proc-stderr", escalated).await?) + } else { + None + }; + + let stdin_spec = match &stdin_bound { + Some((_, t)) => StdioSpec::Tunnel(t.clone()), + None => StdioSpec::Null, + }; + let stdout_spec = match &stdout_bound { + Some((_, t)) => StdioSpec::Tunnel(t.clone()), + None => StdioSpec::Null, + }; + let stderr_spec = match (&stderr, &stderr_bound) { + (StderrMode::Pipe | StderrMode::Inherit, Some((_, t))) => StderrSpec::Tunnel(t.clone()), + (StderrMode::MergeWithStdout, _) => StderrSpec::MergeWithStdout, + _ => StderrSpec::Null, + }; + + let client: SubprocessClient = if escalated { + // Boxed to break the async-fn type cycle + Box::pin(self.run0_endpoints::>()).await? + } else { + self.endpoints() + }; + + let spec = SpawnSpec { + program: program.clone(), + args, + env, + env_clear, + cwd, + stdin: stdin_spec, + stdout: stdout_spec, + stderr: stderr_spec, + }; + let id = client + .spawn(spec) + .await? + .map_err(|e| anyhow!("agent spawn failed: {e}"))?; + + let (stdin_res, stdout_res, stderr_res) = tokio::join!( + accept(stdin_bound), + accept(stdout_bound), + accept(stderr_bound), + ); + + let stdin_stream = handle_stdin(stdin, stdin_res?, &program); + let stdout_stream = handle_output(stdout, stdout_res?, &program); + let stderr_stream = handle_output_err(stderr, stderr_res?, &program); + + Ok(RemowtChild { + stdin: stdin_stream, + stdout: stdout_stream, + stderr: stderr_stream, + id, + client, + }) + } + + pub fn cmd(&self, program: impl AsRef) -> RemowtCommand { + let program = program.as_ref().to_owned(); + RemowtCommand { + program, + args: vec![], + env: vec![], + remowt: self.clone(), + escalated: false, + } + } +} + +async fn accept(b: Option<(RemowtListener, TunnelAddr)>) -> Result> { + match b { + Some((l, _)) => Ok(Some(l.accept().await?)), + None => Ok(None), + } +} + +fn handle_stdin(mode: StdioMode, s: Option, program: &str) -> Option { + match mode { + StdioMode::Pipe => s, + StdioMode::Inherit => { + if let Some(s) = s { + let program = program.to_owned(); + tokio::spawn(async move { + let mut stdin = tokio::io::stdin(); + let mut s = s; + if let Err(e) = tokio::io::copy(&mut stdin, &mut s).await { + warn!(program, "stdin forward ended: {e}"); + } + let _ = s.shutdown().await; + }); + } + None + } + StdioMode::Null => None, + } +} + +fn handle_output(mode: StdioMode, s: Option, program: &str) -> Option { + match mode { + StdioMode::Pipe => s, + StdioMode::Inherit => { + if let Some(s) = s { + let program = program.to_owned(); + tokio::spawn(drain_to_tracing(s, program, false)); + } + None + } + StdioMode::Null => None, + } +} + +fn handle_output_err( + mode: StderrMode, + s: Option, + program: &str, +) -> Option { + match mode { + StderrMode::Pipe => s, + StderrMode::Inherit => { + if let Some(s) = s { + let program = program.to_owned(); + tokio::spawn(drain_to_tracing(s, program, true)); + } + None + } + StderrMode::MergeWithStdout | StderrMode::Null => None, + } +} + +fn escape_bash(input: &str, out: &mut String) { + const TO_ESCAPE: &str = "$ !\"#&'()*,;<>?[\\]^`{|}"; + if input.chars().all(|c| !TO_ESCAPE.contains(c)) { + out.push_str(input); + return; + } + out.push('\''); + for (i, v) in input.split('\'').enumerate() { + if i != 0 { + out.push_str("'\"'\"'"); + } + out.push_str(v); + } + out.push('\''); +} + +#[derive(Clone)] +pub struct RemowtCommand { + program: String, + args: Vec, + env: Vec<(String, String)>, + remowt: Remowt, + escalated: bool, +} + +impl RemowtCommand { + pub fn arg(&mut self, arg: impl AsRef) -> &mut Self { + self.args.push(arg.as_ref().to_owned()); + self + } + pub fn args>(&mut self, args: impl IntoIterator) -> &mut Self { + for arg in args { + self.args.push(arg.as_ref().to_owned()); + } + self + } + pub fn eqarg(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + self.args + .push(format!("{}={}", key.as_ref(), value.as_ref())); + self + } + pub fn comparg(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + self.args.push(key.as_ref().to_owned()); + self.args.push(value.as_ref().to_owned()); + self + } + pub fn env(&mut self, name: impl AsRef, value: impl AsRef) -> &mut Self { + self.env + .push((name.as_ref().to_owned(), value.as_ref().to_owned())); + self + } + + pub fn sudo(mut self) -> Self { + self.escalated = true; + self + } + + /// Only for display. + fn shell_line(&self) -> String { + let mut out = String::new(); + if self.escalated { + out.push_str("run0 "); + } + if !self.env.is_empty() { + out.push_str("env"); + for (k, v) in &self.env { + out.push(' '); + assert!(!k.contains('=')); + escape_bash(k, &mut out); + out.push('='); + escape_bash(v, &mut out); + } + out.push(' '); + } + escape_bash(&self.program, &mut out); + for arg in &self.args { + out.push(' '); + escape_bash(arg, &mut out); + } + out + } + + fn into_spawn_options(self) -> (Remowt, SpawnOptions, String) { + let line = self.shell_line(); + let opts = SpawnOptions { + program: self.program, + args: self.args, + env: self.env, + env_clear: false, + cwd: None, + escalated: self.escalated, + stdin: StdioMode::Null, + stdout: StdioMode::Pipe, + stderr: StderrMode::Pipe, + }; + (self.remowt, opts, line) + } + + pub async fn run(self) -> Result<()> { + run_inner(self, false).await.map(|_| ()) + } + pub async fn run_string(self) -> Result { + let bytes = run_inner(self, true).await?.expect("want_stdout"); + Ok(String::from_utf8(bytes)?) + } + pub async fn run_value(self) -> Result { + let s = self.run_string().await?; + Ok(serde_json::from_str(&s)?) + } +} + +async fn run_inner(cmd: RemowtCommand, want_stdout: bool) -> Result>> { + let (remowt, opts, line) = cmd.into_spawn_options(); + debug!("running command {line:?} over remowt"); + let program = opts.program.clone(); + let mut child = remowt.spawn(opts).await?; + let stderr = child.stderr.take().expect("stderr=Pipe"); + let stdout = child.stdout.take().expect("stdout=Pipe"); + + let mut err = FramedRead::new(stderr, LinesCodec::new()); + let (mut out_bytes, mut out_lines) = if want_stdout { + (Some(FramedRead::new(stdout, BytesCodec::new())), None) + } else { + (None, Some(FramedRead::new(stdout, LinesCodec::new()))) + }; + + let mut buf = if want_stdout { Some(Vec::new()) } else { None }; + + let mut wait = pin!(child.wait()); + let exit = loop { + select! { + biased; + + Some(e) = err.next() => { + let e = e?; + warn!(program = %program, "{e}"); + } + Some(o) = async { out_bytes.as_mut()?.next().await }, if want_stdout => { + buf.as_mut().expect("want_stdout").extend_from_slice(&o?); + } + Some(o) = async { out_lines.as_mut()?.next().await }, if !want_stdout => { + let o = o?; + info!(program = %program, "{o}"); + } + res = &mut wait => { + break res?; + } + } + }; + + while let Some(e) = err.next().await { + if let Ok(line) = e { + warn!(program = %program, "{line}"); + } + } + if want_stdout { + if let Some(out_bytes) = out_bytes.as_mut() { + while let Some(o) = out_bytes.next().await { + if let Ok(chunk) = o { + buf.as_mut().expect("want_stdout").extend_from_slice(&chunk); + } + } + } + } else if let Some(out_lines) = out_lines.as_mut() { + while let Some(o) = out_lines.next().await { + if let Ok(line) = o { + info!(program = %program, "{line}"); + } + } + } + + match exit { + Some(0) => Ok(buf), + Some(c) => bail!("command '{line}' failed with status {c}"), + None => Err(anyhow!("command '{line}' ended without an exit status")), + } +} --- /dev/null +++ b/remowt/crates/remowt-endpoints/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "remowt-endpoints" +description = "Nix daemon proxy" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +bifrostlink.workspace = true +camino.workspace = true +serde = { workspace = true } +tempfile.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = [ + "net", + "io-util", + "rt", + "process", + "io-std", +] } +tracing.workspace = true +nix = { workspace = true, features = ["process", "signal", "term"] } +zbus.workspace = true +remowt-link-shared.workspace = true +iroh.workspace = true --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/forward.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer}; +use serde::{Deserialize, Serialize}; +use std::result::Result; +use tokio::net::{TcpStream, UdpSocket}; +use tracing::warn; + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("tunnel unavailable: {0}")] + Tunnel(String), + #[error("connect to {0} failed: {1}")] + Connect(String, String), + #[error("invalid target address {0:?}")] + BadAddr(String), + #[error("udp forward requires the iroh fast path, which is not established")] + NoIroh, +} + +#[derive(Clone)] +pub struct Forward { + dialer: Arc, + next_session: Arc, +} + +impl Forward { + pub fn new(dialer: Arc) -> Self { + Self { + dialer, + next_session: Arc::new(AtomicU64::new(0)), + } + } +} + +#[endpoints(ns = 12)] +impl Forward { + #[endpoints(id = 1)] + async fn connect_tcp(&self, tunnel: TunnelAddr, addr: String) -> Result<(), Error> { + let stream = self + .dialer + .connect_tunnel(&tunnel) + .await + .map_err(|e| Error::Tunnel(e.to_string()))?; + let tcp = TcpStream::connect(&addr) + .await + .map_err(|e| Error::Connect(addr, e.to_string()))?; + tokio::spawn(async move { + let mut stream = stream; + let mut tcp = tcp; + let _ = tokio::io::copy_bidirectional(&mut stream, &mut tcp).await; + }); + Ok(()) + } + + #[endpoints(id = 2)] + async fn open_udp(&self, addr: String) -> Result { + let target: SocketAddr = addr.parse().map_err(|_| Error::BadAddr(addr.clone()))?; + let router = self.dialer.router().ok_or(Error::NoIroh)?; + let session = self.next_session.fetch_add(1, Ordering::Relaxed); + let mut rx = router.register(session); + + let sockets: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + tokio::spawn(async move { + while let Some((sub, payload)) = rx.recv().await { + let existing = sockets.lock().expect("lock").get(&sub).cloned(); + let sock = match existing { + Some(s) => s, + None => { + let sock = match UdpSocket::bind(unspecified_for(&target)).await { + Ok(s) => s, + Err(e) => { + warn!("udp forward: bind failed: {e}"); + continue; + } + }; + if let Err(e) = sock.connect(target).await { + warn!("udp forward: connect {target} failed: {e}"); + continue; + } + let sock = Arc::new(sock); + sockets.lock().expect("lock").insert(sub, sock.clone()); + // Reply reader: datagrams from the target go back on (session, sub). + let router = router.clone(); + let reply_sock = sock.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 65535]; + while let Ok(n) = reply_sock.recv(&mut buf).await { + if router.send(session, sub, &buf[..n]).is_err() { + break; + } + } + }); + sock + } + }; + let _ = sock.send(&payload).await; + } + router.unregister(session); + }); + + Ok(session) + } +} + +fn unspecified_for(target: &SocketAddr) -> SocketAddr { + match target { + SocketAddr::V4(_) => SocketAddr::from(([0, 0, 0, 0], 0)), + SocketAddr::V6(_) => SocketAddr::from(([0u16; 8], 0)), + } +} --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/fs.rs @@ -0,0 +1,105 @@ +use std::io::ErrorKind; +use std::str::FromStr; +use std::sync::Mutex; + +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use camino::Utf8PathBuf; +use serde::{Deserialize, Serialize}; +use tempfile::TempDir; + +#[derive(Default)] +pub struct Fs { + tempdirs: Mutex>, +} + +impl Fs { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("file not found")] + NotFound, + #[error("file name/contents is not utf8")] + InvalidUtf8, + #[error("unknown fs error")] + Unknown, +} + +#[endpoints(ns = 1)] +impl Fs { + #[endpoints(id = 1)] + async fn read_file_tiny(&self, path: Utf8PathBuf) -> Result, Error> { + match tokio::fs::read(path).await { + Ok(v) => Ok(v), + Err(e) if e.kind() == ErrorKind::NotFound => Err(Error::NotFound), + _ => Err(Error::Unknown), + } + } + #[endpoints(id = 2)] + async fn file_exists(&self, path: Utf8PathBuf) -> bool { + tokio::fs::try_exists(path).await.unwrap_or(false) + } + #[endpoints(id = 3)] + async fn read_dir_raw(&self, path: Utf8PathBuf) -> Result, Error> { + let mut dir = match tokio::fs::read_dir(path).await { + Ok(dir) => dir, + Err(e) if e.kind() == ErrorKind::NotFound => return Err(Error::NotFound), + Err(_) => return Err(Error::Unknown), + }; + let mut out = Vec::new(); + while let Ok(Some(entry)) = dir.next_entry().await { + let name = Utf8PathBuf::try_from(entry.file_name()).map_err(|_| Error::InvalidUtf8)?; + out.push(name); + } + Ok(out) + } + #[endpoints(id = 4)] + async fn mktemp_dir_raw(&self) -> Result { + let dir = tempfile::Builder::new() + .prefix("remowt.") + .tempdir() + .map_err(|_| Error::Unknown)?; + let mut tempdirs = self.tempdirs.lock().expect("not poisoned"); + let path = Utf8PathBuf::try_from(dir.path().to_owned()).map_err(|_| Error::InvalidUtf8); + tempdirs.push(dir); + path + } + #[endpoints(id = 5)] + async fn rm_file(&self, path: Utf8PathBuf) -> Result<(), Error> { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(()), + Err(_) => Err(Error::Unknown), + } + } +} + +impl FsClient { + pub async fn read_file_text(&self, path: impl Into) -> Result { + let v = self + .read_file_tiny(path.into()) + .await + .map_err(|_| Error::Unknown)?; + let v = v?; + String::from_utf8(v).map_err(|_| Error::InvalidUtf8) + } + pub async fn read_file_value( + &self, + path: impl Into, + ) -> Result, Error> { + let text = self.read_file_text(path).await?; + Ok(T::from_str(&text)) + } + pub async fn mktemp_dir(&self) -> Result { + self.mktemp_dir_raw().await.map_err(|_| Error::Unknown)? + } + pub async fn read_dir(&self, path: impl Into) -> Result, Error> { + self.read_dir_raw(path.into()) + .await + .map_err(|_| Error::Unknown)? + } +} --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/iroh_tunnel.rs @@ -0,0 +1,85 @@ +use std::sync::{Arc, Mutex}; + +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use camino::Utf8PathBuf; +use iroh::{Endpoint, EndpointId, SecretKey}; +use remowt_link_shared::iroh_tunnel::{build_endpoint, TunnelDialer}; +use serde::{Deserialize, Serialize}; +use std::result::Result; +use tokio::net::UnixStream; +use tracing::{debug, warn}; + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("iroh transport pipe unavailable: {0}")] + Pipe(String), + #[error("iroh endpoint failed: {0}")] + Iroh(String), +} + +#[derive(Clone)] +pub struct IrohTunnel { + dialer: Arc, + endpoint: Arc>>, +} + +impl IrohTunnel { + pub fn new(dialer: Arc) -> Self { + Self { + dialer, + endpoint: Arc::new(Mutex::new(None)), + } + } +} + +#[endpoints(ns = 11)] +impl IrohTunnel { + #[endpoints(id = 1)] + async fn setup( + &self, + client_id: EndpointId, + xport_socket: Utf8PathBuf, + ) -> Result { + let stream = UnixStream::connect(&xport_socket) + .await + .map_err(|e| Error::Pipe(e.to_string()))?; + + let secret = SecretKey::generate(); + let agent_id = secret.public(); + let ep = build_endpoint(secret, stream, client_id, true) + .await + .map_err(|e| Error::Iroh(e.to_string()))?; + + let dialer = self.dialer.clone(); + let accept_ep = ep.clone(); + tokio::spawn(async move { + while let Some(incoming) = accept_ep.accept().await { + let dialer = dialer.clone(); + match incoming.accept() { + Ok(accepting) => { + tokio::spawn(async move { + match accepting.await { + Ok(conn) => { + if conn.remote_id() != client_id { + warn!("iroh: rejecting connection from unexpected peer"); + conn.close(0u32.into(), b"unexpected peer"); + return; + } + debug!("iroh tunnel connection accepted"); + dialer.set_conn(conn); + } + Err(e) => warn!("iroh accept failed: {e}"), + } + }); + } + Err(e) => warn!("iroh incoming rejected: {e}"), + } + } + }); + + *self.endpoint.lock().expect("not poisoned") = Some(ep); + + Ok(agent_id) + } +} --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/lib.rs @@ -0,0 +1,7 @@ +pub mod forward; +pub mod fs; +pub mod iroh_tunnel; +pub mod nix_daemon; +pub mod pty; +pub mod subprocess; +pub mod systemd; --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/nix_daemon.rs @@ -0,0 +1,59 @@ +use std::process::Stdio; +use std::sync::Arc; + +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer}; +use serde::{Deserialize, Serialize}; +use std::result::Result; +use tokio::process::Command; + +#[derive(Clone)] +pub struct NixDaemon { + dialer: Arc, +} + +impl NixDaemon { + pub fn new(dialer: Arc) -> Self { + Self { dialer } + } +} + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("nix daemon unavailable: {0}")] + DaemonUnavailable(String), + #[error("tunnel socket unavailable: {0}")] + Tunnel(String), +} + +#[endpoints(ns = 4)] +impl NixDaemon { + #[endpoints(id = 2)] + async fn serve_store(&self, store: String, tunnel: TunnelAddr) -> Result<(), Error> { + let mut child = Command::new("nix-daemon") + .arg("--stdio") + .arg("--store") + .arg(&store) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|e| Error::DaemonUnavailable(e.to_string()))?; + let tunnel = self + .dialer + .connect_tunnel(&tunnel) + .await + .map_err(|e| Error::Tunnel(e.to_string()))?; + let mut stdin = child.stdin.take().expect("piped"); + let mut stdout = child.stdout.take().expect("piped"); + tokio::spawn(async move { + let (mut tr, mut tw) = tokio::io::split(tunnel); + let _ = tokio::join!( + tokio::io::copy(&mut tr, &mut stdin), + tokio::io::copy(&mut stdout, &mut tw), + ); + let _ = child.wait().await; + }); + Ok(()) + } +} --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/pty.rs @@ -0,0 +1,260 @@ +use std::collections::HashMap; +use std::io; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use nix::libc; +use nix::pty::{openpty, OpenptyResult, Winsize}; +use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer}; +use serde::{Deserialize, Serialize}; +use tokio::io::unix::AsyncFd; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tracing::{debug, info, warn}; + +pub type ShellId = u64; + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("openpty failed: {0}")] + Open(String), + #[error("failed to spawn shell: {0}")] + Spawn(String), + #[error("failed to connect to forwarded socket: {0}")] + Connect(String), + #[error("no shell with that id")] + NoSuchShell, + #[error("resize failed: {0}")] + Resize(String), + #[error("io error: {0}")] + Io(String), +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e.to_string()) + } +} + +#[derive(Clone)] +pub struct Pty { + shells: Arc>>, + next_id: Arc, + dialer: Arc, +} + +impl Pty { + pub fn new(dialer: Arc) -> Self { + Self { + shells: Default::default(), + next_id: Default::default(), + dialer, + } + } +} + +#[endpoints(ns = 7)] +impl Pty { + #[endpoints(id = 1)] + async fn open_shell( + &self, + tunnel: TunnelAddr, + term: String, + cols: u16, + rows: u16, + ) -> Result { + let ws = Winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let OpenptyResult { master, slave } = + openpty(Some(&ws), None).map_err(|e| Error::Open(e.to_string()))?; + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned()); + + let slave_in = slave.try_clone()?; + let slave_out = slave.try_clone()?; + let slave_err = slave; + + let mut cmd = tokio::process::Command::new(&shell); + cmd.env("TERM", &term); + if let Ok(home) = std::env::var("HOME") { + cmd.current_dir(home); + } + cmd.stdin(Stdio::from(slave_in)); + cmd.stdout(Stdio::from(slave_out)); + cmd.stderr(Stdio::from(slave_err)); + // SAFETY: only async-signal-safe calls (setsid, ioctl) before exec. + unsafe { + cmd.pre_exec(|| { + nix::unistd::setsid().map_err(|e| io::Error::from_raw_os_error(e as i32))?; + if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + + let mut child = cmd.spawn().map_err(|e| Error::Spawn(e.to_string()))?; + + let resize_fd = master.try_clone()?; + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + self.shells + .lock() + .expect("not poisoned") + .insert(id, resize_fd); + + let sock = match self.dialer.connect_tunnel(&tunnel).await { + Ok(s) => s, + Err(e) => { + self.shells.lock().expect("not poisoned").remove(&id); + let _ = child.kill().await; + return Err(Error::Connect(e.to_string())); + } + }; + let pty = AsyncPty::new(master)?; + + debug!(id, shell, "shell opened"); + let shells = self.shells.clone(); + tokio::spawn(async move { + let mut pty = pty; + let mut sock = sock; + if let Err(e) = tokio::io::copy_bidirectional(&mut pty, &mut sock).await { + warn!(id, "shell pump ended: {e}"); + } + let _ = child.kill().await; + shells.lock().expect("not poisoned").remove(&id); + info!(id, "shell closed"); + }); + + Ok(id) + } + + #[endpoints(id = 2)] + async fn resize(&self, id: ShellId, cols: u16, rows: u16) -> Result<(), Error> { + let ws = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let shells = self.shells.lock().expect("not poisoned"); + let fd = shells.get(&id).ok_or(Error::NoSuchShell)?; + // SAFETY: `fd` is a live PTY master + let rc = unsafe { libc::ioctl(fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) }; + if rc < 0 { + return Err(Error::Resize(io::Error::last_os_error().to_string())); + } + Ok(()) + } +} + +struct AsyncPty { + fd: AsyncFd, +} + +impl AsyncPty { + fn new(fd: OwnedFd) -> io::Result { + let raw = fd.as_raw_fd(); + // SAFETY: standard F_GETFL/F_SETFL round-trip on a valid fd. + unsafe { + let flags = libc::fcntl(raw, libc::F_GETFL); + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(Self { + fd: AsyncFd::new(fd)?, + }) + } +} + +impl AsyncRead for AsyncPty { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + loop { + let mut guard = match this.fd.poll_read_ready(cx) { + Poll::Ready(Ok(g)) => g, + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + }; + let unfilled = buf.initialize_unfilled(); + let res = guard.try_io(|inner| { + let fd = inner.get_ref().as_raw_fd(); + // SAFETY: writing into `unfilled`'s own backing storage. + let n = unsafe { libc::read(fd, unfilled.as_mut_ptr().cast(), unfilled.len()) }; + if n < 0 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EIO) { + Ok(0) + } else { + Err(err) + } + } else { + Ok(n as usize) + } + }); + match res { + Ok(Ok(n)) => { + buf.advance(n); + return Poll::Ready(Ok(())); + } + Ok(Err(e)) => return Poll::Ready(Err(e)), + Err(_would_block) => continue, + } + } + } +} + +impl AsyncWrite for AsyncPty { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + loop { + let mut guard = match this.fd.poll_write_ready(cx) { + Poll::Ready(Ok(g)) => g, + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + }; + let res = guard.try_io(|inner| { + let fd = inner.get_ref().as_raw_fd(); + // SAFETY: reading from `buf` for `buf.len()` bytes. + let n = unsafe { libc::write(fd, buf.as_ptr().cast(), buf.len()) }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } + }); + match res { + Ok(r) => return Poll::Ready(r), + Err(_would_block) => continue, + } + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/subprocess.rs @@ -0,0 +1,293 @@ +use std::collections::HashMap; +use std::io; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use camino::Utf8PathBuf; +use nix::sys::signal::{self, Signal}; +use nix::unistd::Pid; +use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer, TunnelStream}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::process::{ChildStderr, ChildStdout, Command}; +use tokio::sync::{mpsc, watch}; +use tracing::{debug, warn}; + +pub type ProcId = u64; + +#[derive(Serialize, Deserialize, Debug)] +pub enum StdioSpec { + Null, + Tunnel(TunnelAddr), +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum StderrSpec { + Null, + Tunnel(TunnelAddr), + MergeWithStdout, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SpawnSpec { + pub program: String, + pub args: Vec, + pub env: Vec<(String, String)>, + pub env_clear: bool, + pub cwd: Option, + pub stdin: StdioSpec, + pub stdout: StdioSpec, + pub stderr: StderrSpec, +} + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("spawn failed: {0}")] + Spawn(String), + #[error("connect to forwarded socket failed: {0}")] + Connect(String), + #[error("no process with that id")] + NoSuchProcess, + #[error("MergeWithStdout requires stdout=Socket")] + BadMerge, + #[error("invalid signal: {0}")] + BadSignal(i32), + #[error("kill failed: {0}")] + Kill(String), + #[error("io error: {0}")] + Io(String), +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e.to_string()) + } +} + +struct ChildState { + pid: u32, + exit_rx: watch::Receiver>>, +} + +#[derive(Clone)] +pub struct Subprocess { + children: Arc>>, + next_id: Arc, + dialer: Arc, +} + +impl Subprocess { + pub fn new(dialer: Arc) -> Self { + Self { + children: Default::default(), + next_id: Default::default(), + dialer, + } + } +} + +#[endpoints(ns = 10)] +impl Subprocess { + #[endpoints(id = 1)] + async fn spawn(&self, spec: SpawnSpec) -> Result { + let SpawnSpec { + program, + args, + env, + env_clear, + cwd, + stdin, + stdout, + stderr, + } = spec; + + if matches!(stderr, StderrSpec::MergeWithStdout) && !matches!(stdout, StdioSpec::Tunnel(_)) + { + return Err(Error::BadMerge); + } + + let mut cmd = Command::new(&program); + cmd.args(&args); + if env_clear { + cmd.env_clear(); + } + for (k, v) in &env { + cmd.env(k, v); + } + if let Some(cwd) = &cwd { + cmd.current_dir(cwd); + } + cmd.stdin(match &stdin { + StdioSpec::Tunnel(_) => Stdio::piped(), + StdioSpec::Null => Stdio::null(), + }); + cmd.stdout(match &stdout { + StdioSpec::Tunnel(_) => Stdio::piped(), + StdioSpec::Null => Stdio::null(), + }); + cmd.stderr(match &stderr { + StderrSpec::Tunnel(_) | StderrSpec::MergeWithStdout => Stdio::piped(), + StderrSpec::Null => Stdio::null(), + }); + cmd.kill_on_drop(false); + + let mut child = cmd.spawn().map_err(|e| Error::Spawn(e.to_string()))?; + let pid = child + .id() + .ok_or_else(|| Error::Spawn("child exited before pid available".to_owned()))?; + + if let StdioSpec::Tunnel(addr) = &stdin { + let sock = self + .dialer + .connect_tunnel(addr) + .await + .map_err(|e| Error::Connect(e.to_string()))?; + let mut stdin_w = child.stdin.take().expect("piped"); + tokio::spawn(async move { + let (mut sr, _) = tokio::io::split(sock); + let _ = tokio::io::copy(&mut sr, &mut stdin_w).await; + let _ = stdin_w.shutdown().await; + }); + } + + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + + match (&stdout, &stderr, stdout_handle, stderr_handle) { + (StdioSpec::Tunnel(out_addr), StderrSpec::MergeWithStdout, Some(out), Some(err)) => { + let sock = self + .dialer + .connect_tunnel(out_addr) + .await + .map_err(|e| Error::Connect(e.to_string()))?; + tokio::spawn(merge_to_sock(out, err, sock)); + } + (StdioSpec::Tunnel(out_addr), _, Some(out), err_opt) => { + let sock = self + .dialer + .connect_tunnel(out_addr) + .await + .map_err(|e| Error::Connect(e.to_string()))?; + tokio::spawn(pump_to_sock(out, sock)); + if let (StderrSpec::Tunnel(err_addr), Some(err)) = (&stderr, err_opt) { + let err_sock = self + .dialer + .connect_tunnel(err_addr) + .await + .map_err(|e| Error::Connect(e.to_string()))?; + tokio::spawn(pump_to_sock(err, err_sock)); + } + } + (StdioSpec::Null, StderrSpec::Tunnel(err_addr), _, Some(err)) => { + let sock = self + .dialer + .connect_tunnel(err_addr) + .await + .map_err(|e| Error::Connect(e.to_string()))?; + tokio::spawn(pump_to_sock(err, sock)); + } + _ => {} + } + + let (exit_tx, exit_rx) = watch::channel(None); + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + self.children + .lock() + .expect("not poisoned") + .insert(id, ChildState { pid, exit_rx }); + + debug!(id, pid, program, "subprocess spawned"); + tokio::spawn(async move { + let result = child.wait().await; + let code = match result { + Ok(status) => status.code(), + Err(e) => { + warn!(id, "child.wait failed: {e}"); + None + } + }; + let _ = exit_tx.send(Some(code)); + }); + + Ok(id) + } + + #[endpoints(id = 2)] + async fn wait(&self, id: ProcId) -> Result, Error> { + let mut rx = { + let map = self.children.lock().expect("not poisoned"); + let entry = map.get(&id).ok_or(Error::NoSuchProcess)?; + entry.exit_rx.clone() + }; + rx.wait_for(|v| v.is_some()) + .await + .map_err(|_| Error::Io("exit channel closed".to_owned()))?; + let code = rx.borrow().flatten(); + self.children.lock().expect("not poisoned").remove(&id); + Ok(code) + } + + #[endpoints(id = 3)] + async fn kill(&self, id: ProcId, signal: i32) -> Result<(), Error> { + let pid = { + let map = self.children.lock().expect("not poisoned"); + let entry = map.get(&id).ok_or(Error::NoSuchProcess)?; + entry.pid + }; + let sig = Signal::try_from(signal).map_err(|_| Error::BadSignal(signal))?; + signal::kill(Pid::from_raw(pid as i32), sig).map_err(|e| Error::Kill(e.to_string()))?; + Ok(()) + } +} + +async fn pump_to_sock(mut from: R, sock: TunnelStream) +where + R: tokio::io::AsyncRead + Unpin, +{ + let (_, mut sw) = tokio::io::split(sock); + let _ = tokio::io::copy(&mut from, &mut sw).await; + let _ = sw.shutdown().await; +} + +async fn merge_to_sock(mut stdout: ChildStdout, mut stderr: ChildStderr, sock: TunnelStream) { + let (_, mut sw) = tokio::io::split(sock); + let (tx, mut rx) = mpsc::channel::>(64); + let tx_out = tx.clone(); + let out_pump = tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + loop { + match stdout.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx_out.send(buf[..n].to_vec()).await.is_err() { + break; + } + } + } + } + }); + let err_pump = tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + loop { + match stderr.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.send(buf[..n].to_vec()).await.is_err() { + break; + } + } + } + } + }); + while let Some(chunk) = rx.recv().await { + if sw.write_all(&chunk).await.is_err() { + break; + } + } + let _ = out_pump.await; + let _ = err_pump.await; + let _ = sw.shutdown().await; +} --- /dev/null +++ b/remowt/crates/remowt-endpoints/src/systemd.rs @@ -0,0 +1,54 @@ +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use serde::{Deserialize, Serialize}; +use zbus::proxy; +use zbus::zvariant::OwnedObjectPath; + +pub struct Systemd; + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("systemd request failed: {0}")] + Failed(String), +} + +#[proxy( + interface = "org.freedesktop.systemd1.Manager", + default_service = "org.freedesktop.systemd1", + default_path = "/org/freedesktop/systemd1" +)] +trait Manager { + fn start_unit(&self, name: &str, mode: &str) -> zbus::Result; + fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result; +} + +async fn manager() -> Result, Error> { + let conn = zbus::Connection::system() + .await + .map_err(|e| Error::Failed(e.to_string()))?; + ManagerProxy::new(&conn) + .await + .map_err(|e| Error::Failed(e.to_string())) +} + +#[endpoints(ns = 5)] +impl Systemd { + #[endpoints(id = 1)] + async fn start(&self, unit: String) -> Result<(), Error> { + manager() + .await? + .start_unit(&unit, "replace") + .await + .map_err(|e| Error::Failed(e.to_string()))?; + Ok(()) + } + #[endpoints(id = 2)] + async fn stop(&self, unit: String) -> Result<(), Error> { + manager() + .await? + .stop_unit(&unit, "replace") + .await + .map_err(|e| Error::Failed(e.to_string()))?; + Ok(()) + } +} --- /dev/null +++ b/remowt/crates/remowt-link-shared/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "remowt-link-shared" +description = "Shared bifrostlink endpoint wiring for remowt" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +anyhow.workspace = true +bifrostlink.workspace = true +bytes.workspace = true +camino = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "sync", "rt"] } +tokio-util = { workspace = true, features = ["codec"] } +tracing.workspace = true +remowt-ui-prompt.workspace = true + +iroh.workspace = true +iroh-base.workspace = true +noq-udp.workspace = true +n0-watcher.workspace = true +futures.workspace = true --- /dev/null +++ b/remowt/crates/remowt-link-shared/src/editor.rs @@ -0,0 +1,45 @@ +use std::future::Future; + +use bifrostlink::declarative::endpoints; +use bifrostlink::{Config, Rpc}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("editor failed: {0}")] + Failed(String), +} + +pub trait EditorBackend: Send + Sync { + fn open_editor(&self, socket_path: String) -> impl Future> + Send; + fn expose_tcp(&self, addr: String) -> impl Future> + Send; + fn expose_udp(&self, addr: String) -> impl Future> + Send; +} + +pub struct EditorEndpoints(pub E); + +#[endpoints(ns = 8)] +impl EditorEndpoints { + #[endpoints(id = 1)] + async fn open_editor(&self, socket_path: String) -> Result<(), Error> { + self.0.open_editor(socket_path).await + } + + #[endpoints(id = 2)] + async fn expose_tcp(&self, addr: String) -> Result { + self.0.expose_tcp(addr).await + } + + #[endpoints(id = 3)] + async fn expose_udp(&self, addr: String) -> Result { + self.0.expose_udp(addr).await + } +} + +pub fn serve_editor(rpc: &mut Rpc, editor: E) +where + E: EditorBackend + Send + Sync + 'static, + C: Config, +{ + EditorEndpoints(editor).register_endpoints(rpc); +} --- /dev/null +++ b/remowt/crates/remowt-link-shared/src/iroh_tunnel.rs @@ -0,0 +1,536 @@ +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; +use std::{fmt, io}; + +use anyhow::Result; +use bytes::{Bytes, BytesMut}; +use camino::Utf8PathBuf; +use futures::{SinkExt as _, Stream}; +use iroh::endpoint::transports::{ + CustomEndpoint, CustomSender, CustomTransport, RecvInfo, Transmit, +}; +use iroh::endpoint::{presets, Connection, RecvStream, SendStream}; +use iroh::{Endpoint, EndpointId, SecretKey}; +use iroh_base::CustomAddr; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf, ReadHalf, WriteHalf}; +use tokio::net::UnixStream; +use tokio::sync::{mpsc, watch}; +use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; +use tracing::{debug, warn}; + +// "rmwtssh" +pub const REMOWT_SSH_TRANSPORT_ID: u64 = 0x_72_6d_77_74_73_73_68; + +pub const REMOWT_ALPN: &[u8] = b"remowt/tunnel/0"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum TunnelAddr { + Unix(Utf8PathBuf), + Iroh { token: u64 }, +} + +pub fn ssh_custom_addr(id: EndpointId) -> CustomAddr { + CustomAddr::from((REMOWT_SSH_TRANSPORT_ID, &id.as_bytes()[..])) +} + +pub async fn build_endpoint( + secret: SecretKey, + stream: S, + remote: EndpointId, + accept: bool, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, +{ + let local = secret.public(); + let transport = Arc::new(SshTransport::new(stream, local, remote)); + let mut builder = Endpoint::builder(presets::N0) + .secret_key(secret) + .add_custom_transport(transport); + if accept { + builder = builder.alpns(vec![REMOWT_ALPN.to_vec()]); + } + Ok(builder.bind().await?) +} + +struct SshTransport { + local: CustomAddr, + remote: CustomAddr, + addrs: n0_watcher::Watchable>, + endpoint: Mutex>, + sender: Arc, +} + +struct SshEndpointParts { + reader: Pin>, +} + +trait FramedDatagrams: Stream> + Send + Sync {} +impl> + Send + Sync> FramedDatagrams for T {} + +impl fmt::Debug for SshTransport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SshTransport").finish_non_exhaustive() + } +} + +impl SshTransport { + fn new(stream: S, local: EndpointId, remote: EndpointId) -> Self + where + S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, + { + let (read, write): (ReadHalf, WriteHalf) = tokio::io::split(stream); + let reader = FramedRead::new(read, LengthDelimitedCodec::new()); + + let (tx, rx) = mpsc::unbounded_channel::(); + tokio::spawn(writer_task(rx, write)); + + let local = ssh_custom_addr(local); + Self { + addrs: n0_watcher::Watchable::new(vec![local.clone()]), + local, + remote: ssh_custom_addr(remote), + endpoint: Mutex::new(Some(SshEndpointParts { + reader: Box::pin(reader), + })), + sender: Arc::new(SshSender { tx }), + } + } +} + +async fn writer_task(mut rx: mpsc::UnboundedReceiver, write: W) { + let mut framed = FramedWrite::new(write, LengthDelimitedCodec::new()); + while let Some(datagram) = rx.recv().await { + if let Err(e) = framed.send(datagram).await { + debug!("ssh transport writer ended: {e}"); + break; + } + } +} + +impl CustomTransport for SshTransport { + fn bind(&self) -> io::Result> { + let parts = self + .endpoint + .lock() + .expect("not poisoned") + .take() + .ok_or_else(|| io::Error::other("ssh transport already bound"))?; + Ok(Box::new(SshEndpoint { + local: self.local.clone(), + remote: self.remote.clone(), + addrs: self.addrs.clone(), + sender: self.sender.clone(), + reader: parts.reader, + })) + } +} + +struct SshEndpoint { + local: CustomAddr, + remote: CustomAddr, + addrs: n0_watcher::Watchable>, + sender: Arc, + reader: Pin>, +} + +impl fmt::Debug for SshEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SshEndpoint").finish_non_exhaustive() + } +} + +impl CustomEndpoint for SshEndpoint { + fn watch_local_addrs(&self) -> n0_watcher::Direct> { + self.addrs.watch() + } + + fn create_sender(&self) -> Arc { + self.sender.clone() + } + + fn poll_recv( + &mut self, + cx: &mut Context, + bufs: &mut [io::IoSliceMut<'_>], + metas: &mut [noq_udp::RecvMeta], + recv_infos: &mut [RecvInfo], + ) -> Poll> { + assert_eq!(bufs.len(), metas.len()); + assert_eq!(bufs.len(), recv_infos.len()); + if bufs.is_empty() { + return Poll::Ready(Ok(0)); + } + let mut count = 0; + while count < bufs.len() { + match self.reader.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(frame))) => { + let buf = &mut bufs[count]; + if buf.len() < frame.len() { + warn!("ssh transport datagram {} > buf {}", frame.len(), buf.len()); + if count > 0 { + break; + } + return Poll::Ready(Err(io::Error::other("datagram exceeds recv buffer"))); + } + buf[..frame.len()].copy_from_slice(&frame); + metas[count].len = frame.len(); + metas[count].stride = frame.len(); + recv_infos[count] = + RecvInfo::new(self.remote.clone(), Some(self.local.clone())); + count += 1; + } + Poll::Ready(Some(Err(e))) => { + if count > 0 { + break; + } + return Poll::Ready(Err(e)); + } + Poll::Ready(None) => { + if count > 0 { + break; + } + return Poll::Ready(Err(io::Error::other("ssh transport closed"))); + } + Poll::Pending => { + if count > 0 { + break; + } + return Poll::Pending; + } + } + } + Poll::Ready(Ok(count)) + } +} + +#[derive(Debug)] +struct SshSender { + tx: mpsc::UnboundedSender, +} + +impl CustomSender for SshSender { + fn is_valid_send_addr(&self, addr: &CustomAddr) -> bool { + addr.id() == REMOWT_SSH_TRANSPORT_ID + } + + fn poll_send( + &self, + _cx: &mut Context, + _dst: &CustomAddr, + _src: Option<&CustomAddr>, + transmit: &Transmit<'_>, + ) -> Poll> { + let segment = transmit.segment_size.unwrap_or(transmit.contents.len()); + if segment == 0 || transmit.contents.is_empty() { + return Poll::Ready(Ok(())); + } + for chunk in transmit.contents.chunks(segment) { + if self.tx.send(Bytes::copy_from_slice(chunk)).is_err() { + return Poll::Ready(Err(io::Error::other("ssh transport writer gone"))); + } + } + Poll::Ready(Ok(())) + } +} + +pub struct IrohBiStream { + send: SendStream, + recv: RecvStream, +} + +impl IrohBiStream { + pub fn new(send: SendStream, recv: RecvStream) -> Self { + Self { send, recv } + } +} + +impl AsyncRead for IrohBiStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + AsyncRead::poll_read(Pin::new(&mut self.get_mut().recv), cx, buf) + } +} + +impl AsyncWrite for IrohBiStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + AsyncWrite::poll_write(Pin::new(&mut self.get_mut().send), cx, buf) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + AsyncWrite::poll_flush(Pin::new(&mut self.get_mut().send), cx) + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + AsyncWrite::poll_shutdown(Pin::new(&mut self.get_mut().send), cx) + } +} + +pub enum TunnelStream { + Unix(UnixStream), + Iroh(IrohBiStream), +} + +impl AsyncRead for TunnelStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + TunnelStream::Unix(s) => Pin::new(s).poll_read(cx, buf), + TunnelStream::Iroh(s) => Pin::new(s).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for TunnelStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + TunnelStream::Unix(s) => Pin::new(s).poll_write(cx, buf), + TunnelStream::Iroh(s) => Pin::new(s).poll_write(cx, buf), + } + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + TunnelStream::Unix(s) => Pin::new(s).poll_flush(cx), + TunnelStream::Iroh(s) => Pin::new(s).poll_flush(cx), + } + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + TunnelStream::Unix(s) => Pin::new(s).poll_shutdown(cx), + TunnelStream::Iroh(s) => Pin::new(s).poll_shutdown(cx), + } + } +} + +#[derive(Clone)] +pub struct TunnelDialer { + conn: watch::Sender>, + router: Arc>>>, +} + +impl Default for TunnelDialer { + fn default() -> Self { + Self::new() + } +} + +impl TunnelDialer { + pub fn new() -> Self { + let (conn, _) = watch::channel(None); + Self { + conn, + router: Arc::new(Mutex::new(None)), + } + } + + pub fn set_conn(&self, conn: Connection) { + *self.router.lock().expect("lock") = Some(DatagramRouter::spawn(conn.clone())); + self.conn.send_replace(Some(conn)); + } + + pub fn router(&self) -> Option> { + self.router.lock().expect("lock").clone() + } + + pub async fn connect_tunnel(&self, addr: &TunnelAddr) -> io::Result { + match addr { + TunnelAddr::Unix(path) => Ok(TunnelStream::Unix(UnixStream::connect(path).await?)), + TunnelAddr::Iroh { token } => { + let mut rx = self.conn.subscribe(); + let conn = + tokio::time::timeout(Duration::from_secs(5), rx.wait_for(|c| c.is_some())) + .await + .map_err(|_| io::Error::other("timed out waiting for iroh connection"))? + .map_err(|_| io::Error::other("iroh connection channel closed"))? + .clone() + .expect("is_some"); + let (mut send, recv) = conn.open_bi().await.map_err(io::Error::other)?; + send.write_all(&token.to_be_bytes()) + .await + .map_err(io::Error::other)?; + Ok(TunnelStream::Iroh(IrohBiStream::new(send, recv))) + } + } + } +} + +const DGRAM_HEADER: usize = 16; + +type DatagramRoutes = Arc>>>; + +pub struct DatagramRouter { + conn: Connection, + routes: DatagramRoutes, +} + +impl DatagramRouter { + pub fn spawn(conn: Connection) -> Arc { + let routes: DatagramRoutes = Arc::new(Mutex::new(HashMap::new())); + let read_routes = routes.clone(); + let read_conn = conn.clone(); + tokio::spawn(async move { + loop { + match read_conn.read_datagram().await { + Ok(dg) if dg.len() >= DGRAM_HEADER => { + let session = u64::from_be_bytes(dg[0..8].try_into().expect("8 bytes")); + let sub = u64::from_be_bytes(dg[8..16].try_into().expect("8 bytes")); + let payload = dg.slice(DGRAM_HEADER..); + let tx = read_routes.lock().expect("lock").get(&session).cloned(); + if let Some(tx) = tx { + let _ = tx.send((sub, payload)); + } + } + Ok(_) => {} + Err(e) => { + debug!("datagram read loop ended: {e}"); + break; + } + } + } + }); + Arc::new(Self { conn, routes }) + } + + pub fn register(&self, session: u64) -> mpsc::UnboundedReceiver<(u64, Bytes)> { + let (tx, rx) = mpsc::unbounded_channel(); + self.routes.lock().expect("lock").insert(session, tx); + rx + } + + pub fn unregister(&self, session: u64) { + self.routes.lock().expect("lock").remove(&session); + } + + pub fn send(&self, session: u64, sub: u64, payload: &[u8]) -> io::Result<()> { + let mut buf = BytesMut::with_capacity(DGRAM_HEADER + payload.len()); + buf.extend_from_slice(&session.to_be_bytes()); + buf.extend_from_slice(&sub.to_be_bytes()); + buf.extend_from_slice(payload); + self.conn + .send_datagram(buf.freeze()) + .map_err(io::Error::other) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iroh::endpoint::presets; + use iroh::{EndpointAddr, RelayMode, TransportAddr}; + + async fn endpoint(secret: SecretKey, stream: S, remote: EndpointId, accept: bool) -> Endpoint + where + S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, + { + let local = secret.public(); + let transport = Arc::new(SshTransport::new(stream, local, remote)); + let mut builder = Endpoint::builder(presets::N0) + .secret_key(secret) + .relay_mode(RelayMode::Disabled) + .clear_ip_transports() + .add_custom_transport(transport); + if accept { + builder = builder.alpns(vec![REMOWT_ALPN.to_vec()]); + } + builder.bind().await.expect("bind") + } + + #[tokio::test] + async fn echo_over_ssh_transport() { + let (client_pipe, agent_pipe) = tokio::io::duplex(256 * 1024); + let client_secret = SecretKey::generate(); + let agent_secret = SecretKey::generate(); + let client_id = client_secret.public(); + let agent_id = agent_secret.public(); + + let client = endpoint(client_secret, client_pipe, agent_id, false).await; + let agent = endpoint(agent_secret, agent_pipe, client_id, true).await; + + let server = tokio::spawn(async move { + let incoming = agent.accept().await.expect("incoming"); + let conn = incoming + .accept() + .expect("accept") + .await + .expect("connecting"); + assert_eq!(conn.remote_id(), client_id); + let (mut send, mut recv) = conn.accept_bi().await.expect("accept_bi"); + let mut buf = [0u8; 5]; + recv.read_exact(&mut buf).await.expect("read"); + send.write_all(&buf).await.expect("write"); + send.finish().expect("finish"); + conn.closed().await; + }); + + let addr = + EndpointAddr::from_parts(agent_id, [TransportAddr::Custom(ssh_custom_addr(agent_id))]); + let conn = client.connect(addr, REMOWT_ALPN).await.expect("connect"); + assert_eq!(conn.remote_id(), agent_id); + let (mut send, mut recv) = conn.open_bi().await.expect("open_bi"); + send.write_all(b"hello").await.expect("write"); + send.finish().expect("finish"); + let mut buf = [0u8; 5]; + recv.read_exact(&mut buf).await.expect("read"); + assert_eq!(&buf, b"hello"); + + conn.close(0u32.into(), b"done"); + let _ = server.await; + } + + #[tokio::test] + async fn dgram_round_trip() { + let (client_pipe, agent_pipe) = tokio::io::duplex(256 * 1024); + let client_secret = SecretKey::generate(); + let agent_secret = SecretKey::generate(); + let client_id = client_secret.public(); + let agent_id = agent_secret.public(); + + let client = endpoint(client_secret, client_pipe, agent_id, false).await; + let agent = endpoint(agent_secret, agent_pipe, client_id, true).await; + + let server = tokio::spawn(async move { + let incoming = agent.accept().await.expect("incoming"); + let conn = incoming + .accept() + .expect("accept") + .await + .expect("connecting"); + let router = DatagramRouter::spawn(conn); + let mut rx = router.register(7); + if let Some((sub, payload)) = rx.recv().await { + router.send(7, sub, &payload).expect("send reply"); + } + tokio::time::sleep(Duration::from_millis(200)).await; + }); + + let addr = + EndpointAddr::from_parts(agent_id, [TransportAddr::Custom(ssh_custom_addr(agent_id))]); + let conn = client.connect(addr, REMOWT_ALPN).await.expect("connect"); + let router = DatagramRouter::spawn(conn); + let mut rx = router.register(7); + router.send(7, 42, b"ping").expect("send"); + let (sub, payload) = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("datagram timed out") + .expect("router closed"); + assert_eq!(sub, 42); + assert_eq!(&payload[..], b"ping"); + let _ = server.await; + } +} --- /dev/null +++ b/remowt/crates/remowt-link-shared/src/lib.rs @@ -0,0 +1,89 @@ +use bifrostlink::error::{ErrorT, ListenerForYourRequestHasBeenDeadError, ResponseError}; +use bifrostlink::notification; +use bifrostlink::packet::OpaquePacketWrapper; +use bifrostlink::AddressT; +use bytes::Bytes; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +pub mod editor; +pub mod iroh_tunnel; +pub mod port; + +#[derive(Clone, Serialize, Hash, Eq, Debug, PartialEq, Deserialize)] +pub enum Address { + User, + Agent, + AgentPrivileged, + Plugin(u16), +} +impl AddressT for Address {} + +pub mod plugin; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("listener is dead")] + ListenerDead, + #[error("response: {0}")] + Response(String), + + #[error(transparent)] + Ui(#[from] remowt_ui_prompt::Error), +} + +impl From for Error { + fn from(_value: ListenerForYourRequestHasBeenDeadError) -> Self { + Self::ListenerDead + } +} +impl From for Error { + fn from(_value: serde_json::Error) -> Self { + Self::ListenerDead + } +} +impl From for ResponseError { + fn from(val: Error) -> Self { + ResponseError(val.to_string()) + } +} +impl From for Error { + fn from(value: ResponseError) -> Self { + Self::Response(value.0) + } +} +impl ErrorT for Error {} + +#[derive(Serialize, Deserialize, Debug)] +pub struct TestNotification { + pub value: u32, +} +notification!((0x0100) TestNotification); + +pub struct BifConfig; +impl bifrostlink::Config for BifConfig { + type Address = Address; + + type Error = Error; + + type EncodedData = Vec; + + fn decode_headers( + data_with_headers: Bytes, + ) -> Result<(OpaquePacketWrapper, Self::EncodedData), Self::Error> { + let (header, data): (OpaquePacketWrapper, Vec) = + serde_json::from_slice(&data_with_headers)?; + Ok((header, data)) + } + + fn decode_data(data: Self::EncodedData) -> Result { + let v: T = serde_json::from_slice(&data)?; + Ok(v) + } + + fn encode_data(headers: OpaquePacketWrapper, data: T) -> Bytes { + let data = serde_json::to_vec(&data).expect("serialization shouldn't fail"); + let o = serde_json::to_vec(&(headers, data)).expect("serialization shouldn't fail"); + o.into() + } +} --- /dev/null +++ b/remowt/crates/remowt-link-shared/src/plugin.rs @@ -0,0 +1,39 @@ +use std::future::Future; + +use bifrostlink::declarative::endpoints; +use bifrostlink::Config; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, thiserror::Error)] +pub enum Error { + #[error("plugin name must be a bare file name")] + BadName, + #[error("spawning plugin failed: {0}")] + Spawn(String), + #[error("agent is shutting down")] + Gone, +} + +pub trait PluginHost: Send + Sync { + fn load_plugin(&self, id: u16, name: String) -> impl Future> + Send; + + fn load_plugin_path( + &self, + id: u16, + path: String, + ) -> impl Future> + Send; +} + +pub struct PluginEndpoints(pub H); + +#[endpoints(ns = 9)] +impl PluginEndpoints { + #[endpoints(id = 1)] + async fn load_plugin(&self, id: u16, name: String) -> Result<(), Error> { + self.0.load_plugin(id, name).await + } + #[endpoints(id = 2)] + async fn load_plugin_path(&self, id: u16, path: String) -> Result<(), Error> { + self.0.load_plugin_path(id, path).await + } +} --- /dev/null +++ b/remowt/crates/remowt-link-shared/src/port.rs @@ -0,0 +1,82 @@ +use std::io; + +use bifrostlink::Port; +use bytes::{Bytes, BytesMut}; +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; +use tokio::select; + +pub fn child_port(mut reader: R, mut writer: W) -> Port +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + Port::new(|mut rx, tx| async move { + let read_task = async move { + loop { + let len = match reader.read_u32().await { + Ok(len) => len, + Err(e) => { + log_read_end(&e); + break; + } + }; + let mut buf = BytesMut::zeroed(len as usize); + if let Err(e) = reader.read_exact(&mut buf).await { + log_read_end(&e); + break; + } + if tx.send(buf.freeze()).is_err() { + break; + } + } + }; + let write_task = async move { + while let Some(msg) = rx.recv().await { + if let Err(e) = write_frame(&mut writer, msg).await { + log_write_end(&e); + break; + } + } + }; + select! { + _ = read_task => {}, + _ = write_task => {}, + } + }) +} + +fn log_read_end(e: &io::Error) { + if matches!( + e.kind(), + io::ErrorKind::UnexpectedEof + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + ) { + tracing::debug!("child read ended: {e}"); + } else { + tracing::error!("child read failed: {e}"); + } +} + +fn log_write_end(e: &io::Error) { + if matches!( + e.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + ) { + tracing::debug!("child write ended: {e}"); + } else { + tracing::error!("child write failed: {e}"); + } +} + +async fn write_frame(writer: &mut W, msg: Bytes) -> io::Result<()> { + let len = u32::try_from(msg.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "message larger than 4GB"))?; + writer.write_u32(len).await?; + writer.write_all(&msg).await?; + writer.flush().await?; + Ok(()) +} --- /dev/null +++ b/remowt/crates/remowt-plugin/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "remowt-plugin" +description = "Plugin host and protocol for remowt agents" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +anyhow.workspace = true +bifrostlink.workspace = true +bifrostlink-ports.workspace = true +remowt-link-shared.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = [ + "rt", + "net", + "io-std", + "io-util", + "macros", + "time", + "process", +] } +tracing-subscriber.workspace = true --- /dev/null +++ b/remowt/crates/remowt-plugin/src/host.rs @@ -0,0 +1,70 @@ +use std::ffi::OsStr; +use std::process::Stdio; +use std::sync::Mutex; + +use bifrostlink::{Rpc, Rtt, WeakRpc}; +use tokio::process::{Child, Command}; + +use remowt_link_shared::plugin::{Error, PluginEndpoints, PluginHost}; +use remowt_link_shared::port::child_port; +use remowt_link_shared::{Address, BifConfig}; + +pub fn serve(rpc: &mut Rpc) { + let host = Host { + me: rpc.me(), + rpc: rpc.clone().downgrade(), + children: Mutex::new(Vec::new()), + }; + PluginEndpoints(host).register_endpoints(rpc); +} + +struct Host { + me: Address, + rpc: WeakRpc, + children: Mutex>, +} + +impl Host { + fn spawn(&self, id: u16, path: impl AsRef) -> Result<(), Error> { + let rpc = self.rpc.clone().upgrade().ok_or(Error::Gone)?; + + let mut child = Command::new(path) + .arg(id.to_string()) + .arg(serde_json::to_string(&self.me).expect("address serializes")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| Error::Spawn(e.to_string()))?; + let stdin = child.stdin.take().expect("stdin piped"); + let stdout = child.stdout.take().expect("stdout piped"); + + let addr = Address::Plugin(id); + rpc.add_direct(addr, child_port(stdout, stdin), Rtt(0)); + self.children.lock().expect("not poisoned").push(child); + Ok(()) + } +} + +impl PluginHost for Host { + async fn load_plugin(&self, id: u16, name: String) -> Result<(), Error> { + // TODO: Right now loads plugin next to the binary... + // But with our CA addressed schema, the plugins should be located in content-addressed subdir... + // Maybe it should just be scrapped in favor of load_plugin_path. + if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\0']) { + return Err(Error::BadName); + } + let exe = std::env::current_exe().map_err(|e| Error::Spawn(e.to_string()))?; + let dir = exe + .parent() + .ok_or_else(|| Error::Spawn("primary agent has no parent directory".to_owned()))?; + self.spawn(id, dir.join(&name)) + } + + async fn load_plugin_path(&self, id: u16, path: String) -> Result<(), Error> { + if path.is_empty() || path.contains('\0') { + return Err(Error::BadName); + } + self.spawn(id, path) + } +} --- /dev/null +++ b/remowt/crates/remowt-plugin/src/lib.rs @@ -0,0 +1,46 @@ +use std::future::pending; + +use anyhow::Result; +use bifrostlink::{Rpc, Rtt}; +use bifrostlink_ports::stdio::from_stdio; +use tokio::runtime::Builder; + +pub mod host; + +pub use bifrostlink; +pub use remowt_link_shared::{self, Address, BifConfig}; + +pub fn plugin_index() -> Result { + let arg = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("missing plugin index argument"))?; + arg.parse() + .map_err(|e| anyhow::anyhow!("invalid plugin index {arg:?}: {e}")) +} + +pub fn host_address() -> Result

{ + let arg = std::env::args() + .nth(2) + .ok_or_else(|| anyhow::anyhow!("missing host address argument"))?; + serde_json::from_str(&arg).map_err(|e| anyhow::anyhow!("invalid host address {arg:?}: {e}")) +} + +pub fn run(register: F) -> Result<()> +where + F: FnOnce(&mut Rpc), +{ + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .init(); + + let index = plugin_index()?; + let host = host_address()?; + let runtime = Builder::new_current_thread().enable_all().build()?; + runtime.block_on(async move { + let mut rpc = Rpc::::new(Address::Plugin(index)); + rpc.add_direct(host, from_stdio(), Rtt(0)); + register(&mut rpc); + let _rpc = rpc; + pending::>().await + }) +} --- /dev/null +++ b/remowt/crates/remowt-ui-prompt/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "remowt-ui-prompt" +description = "Interactive UI prompt endpoint for remowt (D-Bus)" +version.workspace = true +edition = "2021" +license.workspace = true + +[dependencies] +anyhow.workspace = true +bifrostlink.workspace = true +bifrostlink-macros.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["io-util", "macros", "process", "rt"] } +tracing.workspace = true +zbus = { workspace = true, optional = true } + +[features] +default = ["dbus"] +dbus = ["dep:zbus"] --- /dev/null +++ b/remowt/crates/remowt-ui-prompt/src/auto.rs @@ -0,0 +1,83 @@ +use anyhow::bail; +use tracing::debug; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; + +use crate::dbus::{DbusPrompterProxy, BUS_NAME, PROMPTER_PATH}; +use crate::rofi::RofiPrompter; +use crate::{Prompter, Result, Source}; + +pub struct AutoPrompter { + dbus: Option>, + fallback: RofiPrompter, +} + +impl AutoPrompter { + pub async fn new() -> Self { + let dbus = match Self::try_dbus().await { + Ok(p) => Some(p), + Err(e) => { + debug!("dbus prompter unavailable, falling back to rofi: {e}"); + None + } + }; + Self { + dbus, + fallback: RofiPrompter, + } + } + + async fn try_dbus() -> anyhow::Result> { + let conn = zbus::Connection::session().await?; + let dbus = DBusProxy::new(&conn).await?; + let name = BusName::try_from(BUS_NAME)?; + if !dbus.name_has_owner(name).await? { + bail!("{BUS_NAME} not registered on session bus"); + } + let proxy = DbusPrompterProxy::builder(&conn) + .destination(BUS_NAME)? + .path(PROMPTER_PATH)? + .build() + .await?; + Ok(proxy) + } +} + +impl Prompter for AutoPrompter { + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> Result { + if let Some(dbus) = &self.dbus { + return Prompter::prompt_enum(dbus, prompt, description, variants, source).await; + } + self.fallback + .prompt_enum(prompt, description, variants, source) + .await + } + + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + if let Some(dbus) = &self.dbus { + return Prompter::prompt_text(dbus, echo, prompt, description, source).await; + } + self.fallback + .prompt_text(echo, prompt, description, source) + .await + } + + async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> { + if let Some(dbus) = &self.dbus { + return Prompter::display_text(dbus, error, description, source).await; + } + self.fallback.display_text(error, description, source).await + } +} --- /dev/null +++ b/remowt/crates/remowt-ui-prompt/src/bifrost.rs @@ -0,0 +1,109 @@ +use bifrostlink::{Config, Rpc}; +use bifrostlink_macros::endpoints; +use serde::{Deserialize, Serialize}; + +use crate::{Error, Prompter, Source}; + +pub struct PromptEndpoints

(pub P); + +#[endpoints(ns = 2)] +impl

PromptEndpoints

+where + P: Prompter + Send + Sync + 'static, +{ + #[endpoints(id = 1, cancel)] + async fn prompt_enum( + &self, + prompt: String, + description: String, + variants: Vec, + source: Vec, + ) -> Result { + let variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect(); + self.0 + .prompt_enum(&prompt, &description, &variants, &source) + .await + } + + #[endpoints(id = 2, cancel)] + async fn prompt_text( + &self, + echo: bool, + prompt: String, + description: String, + source: Vec, + ) -> Result { + self.0 + .prompt_text(echo, &prompt, &description, &source) + .await + } + + #[endpoints(id = 3, cancel)] + async fn display_text( + &self, + error: bool, + description: String, + source: Vec, + ) -> Result<(), Error> { + self.0.display_text(error, &description, &source).await + } +} + +impl Prompter for PromptEndpointsClient +where + Error: ToString, +{ + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> crate::Result { + self.prompt_enum( + prompt.to_owned(), + description.to_owned(), + variants.iter().map(|v| (*v).to_owned()).collect(), + source.to_vec(), + ) + .await + .map_err(|e| Error::Remote(e.to_string()))? + } + + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> crate::Result { + self.prompt_text( + echo, + prompt.to_owned(), + description.to_owned(), + source.to_vec(), + ) + .await + .map_err(|e| Error::Remote(e.to_string()))? + } + + async fn display_text( + &self, + error: bool, + description: &str, + source: &[Source], + ) -> crate::Result<()> { + self.display_text(error, description.to_owned(), source.to_vec()) + .await + .map_err(|e| Error::Remote(e.to_string()))? + } +} + +pub fn serve_prompts(rpc: &mut Rpc, prompt: P) +where + P: Prompter + Send + Sync + 'static, + C: Config, + C::Error: From, +{ + PromptEndpoints(prompt).register_endpoints(rpc); +} --- /dev/null +++ b/remowt/crates/remowt-ui-prompt/src/dbus.rs @@ -0,0 +1,143 @@ +use zbus::interface; +use zbus::{fdo, proxy}; + +use crate::Source; +use crate::{BlockingPrompter, Result}; +use crate::{Error, Prompter}; + +pub const BUS_NAME: &str = "lach.RemowtAskpass"; +pub const PROMPTER_PATH: &str = "/lach/Askpass"; + +pub struct DbusPrompterInterface

(pub P); + +#[interface(name = "lach.PolkitInputHandler")] +impl DbusPrompterInterface

{ + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: Vec, + source: Vec, + ) -> fdo::Result { + let variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect(); + Ok(self + .0 + .prompt_enum(prompt, description, &variants, &source) + .await?) + } + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: Vec, + ) -> fdo::Result { + Ok(self + .0 + .prompt_text(echo, prompt, description, &source) + .await?) + } + async fn display_text( + &self, + error: bool, + description: &str, + source: Vec, + ) -> fdo::Result<()> { + Ok(self.0.display_text(error, description, &source).await?) + } +} + +#[proxy(interface = "lach.PolkitInputHandler")] +pub trait DbusPrompter { + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> fdo::Result; + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> fdo::Result; + async fn display_text( + &self, + error: bool, + description: &str, + source: &[Source], + ) -> fdo::Result<()>; +} + +impl Prompter for DbusPrompterProxy<'_> { + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> Result { + Ok(self + .prompt_enum(prompt, description, variants, source) + .await?) + } + + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + Ok(self.prompt_text(echo, prompt, description, source).await?) + } + + async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> { + Ok(self.display_text(error, description, source).await?) + } +} +impl BlockingPrompter for DbusPrompterProxyBlocking<'_> { + fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> Result { + Ok(self.prompt_enum(prompt, description, variants, source)?) + } + + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + Ok(self.prompt_text(echo, prompt, description, source)?) + } + + fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> { + Ok(self.display_text(error, description, source)?) + } +} + +impl From for Error { + fn from(value: fdo::Error) -> Self { + if matches!(value, fdo::Error::NoReply(_)) { + return Self::Cancel; + } + Self::InputError(format!("{value}")) + } +} +impl From for fdo::Error { + fn from(value: Error) -> Self { + match value { + Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()), + Error::Remote(e) => fdo::Error::NoReply(format!("remote error occured: {e}")), + Error::InputError(e) => fdo::Error::Failed(e), + } + } +} --- /dev/null +++ b/remowt/crates/remowt-ui-prompt/src/lib.rs @@ -0,0 +1,202 @@ +use core::fmt; +use std::borrow::Cow; +use std::future::Future; +use std::result; + +pub mod auto; +pub mod bifrost; +pub mod dbus; +pub mod rofi; + +#[derive(thiserror::Error, Debug, serde::Serialize, serde::Deserialize)] +pub enum Error { + #[error("user has cancelled input")] + Cancel, + #[error("input error: {0}")] + InputError(String), + #[error("unknown remote error: {0}")] + Remote(String), +} + +pub type Result = result::Result; + +#[cfg_attr(feature = "dbus", derive(zbus::zvariant::Type))] +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct Source(pub Cow<'static, str>); +impl fmt::Display for Source { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +pub trait Prompter: Send + Sync { + fn prompt_radio( + &self, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + Send { + let fut = self.prompt_enum(prompt, description, &["No", "Yes"], source); + async { fut.await.map(|v| v == 1) } + } + fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> impl Future> + Send; + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + Send; + fn display_text( + &self, + error: bool, + description: &str, + source: &[Source], + ) -> impl Future> + Send; +} +pub trait BlockingPrompter { + fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result { + self.prompt_enum(prompt, description, &["No", "Yes"], source) + .map(|v| v == 1) + } + fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> Result; + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result; + fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>; +} +impl

Prompter for &P +where + P: Prompter, +{ + fn prompt_radio( + &self, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + Send { + (*self).prompt_radio(prompt, description, source) + } + + fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> impl Future> + Send { + (*self).prompt_enum(prompt, description, variants, source) + } + + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + Send { + (*self).prompt_text(echo, prompt, description, source) + } + + fn display_text( + &self, + error: bool, + description: &str, + source: &[Source], + ) -> impl Future> + Send { + (*self).display_text(error, description, source) + } +} + +pub struct PrependSourcePrompter

{ + pub prompter: P, + pub source: Vec, + pub description: String, +} +impl

PrependSourcePrompter

{ + fn source(&self, input: &[Source]) -> Vec { + let mut out = self.source.clone(); + out.extend(input.iter().cloned()); + out + } + fn description(&self, input: &str) -> String { + if self.description.is_empty() { + input.to_owned() + } else if input.is_empty() { + self.description.to_owned() + } else { + format!("{input}\n\n{}", self.description) + } + } +} +impl

Prompter for PrependSourcePrompter

+where + P: Prompter + Sync, +{ + async fn prompt_radio( + &self, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + self.prompter + .prompt_radio(prompt, &self.description(description), &self.source(source)) + .await + } + + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> Result { + self.prompter + .prompt_enum( + prompt, + &self.description(description), + variants, + &self.source(source), + ) + .await + } + + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + self.prompter + .prompt_text( + echo, + prompt, + &self.description(description), + &self.source(source), + ) + .await + } + + async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> { + self.prompter + .display_text(error, &self.description(description), &self.source(source)) + .await + } +} --- /dev/null +++ b/remowt/crates/remowt-ui-prompt/src/rofi.rs @@ -0,0 +1,226 @@ +use std::process::Stdio; + +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tracing::trace; + +use crate::{Error, Prompter, Result, Source}; + +#[derive(Clone)] +pub struct RofiPrompter; + +fn fixup_prompt(prompt: &str) -> &str { + // Rofi always appends such suffix + prompt.strip_suffix(": ").unwrap_or(prompt) +} + +fn rofi_command() -> Command { + Command::new(option_env!("ROFI").unwrap_or("rofi")) +} + +impl Prompter for RofiPrompter { + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> Result { + trace!("rofi radio"); + let mut cmd = rofi_command(); + let mesg = if source.is_empty() { + description.to_owned() + } else { + let mut out = format!("{description}\n\nRequested on ",); + for (i, s) in source.iter().enumerate() { + if i != 0 { + out.push_str(" -> "); + } + out.push_str(&s.to_string()); + } + out.push_str(""); + out + }; + cmd.args([ + "-dmenu", + "-mesg", + &mesg, + "-sync", + "-no-custom", + "-p", + fixup_prompt(prompt), + "-format", + "i", + "-markup-rows", + ]); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.kill_on_drop(true); + let mut child = cmd + .spawn() + .map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?; + + let mut stdin = child.stdin.take().expect("stdin is piped"); + for var in variants { + stdin + .write_all(var.replace('\n', " ").as_bytes()) + .await + .map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?; + stdin + .write_all(b"\n") + .await + .map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?; + } + // write_all already flushes, just to be sure. + let _ = stdin.flush().await; + drop(stdin); + + let out = child + .wait_with_output() + .await + .map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?; + match out.status.code() { + Some(0) => {} + Some(1) => return Err(Error::Cancel), + other => { + return Err(Error::InputError(format!( + "rofi exited with status {other:?}" + ))); + } + } + let stdout = out + .stdout + .strip_suffix(b"\n") + .unwrap_or(&out.stdout) + .to_owned(); + + let id: u32 = String::from_utf8(stdout) + .map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))? + .parse() + .map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?; + if id as usize >= variants.len() { + return Err(Error::InputError("invalid rofi response".to_owned())); + } + + Ok(id) + } + + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + trace!("rofi text"); + let mut cmd = rofi_command(); + let mesg = if source.is_empty() { + description.to_owned() + } else { + let mut out = format!("{description}\n\nRequested on ",); + for (i, s) in source.iter().enumerate() { + if i != 0 { + out.push_str(" -> "); + } + out.push_str(&s.to_string()); + } + out.push_str(""); + out + }; + cmd.args(["-dmenu", "-mesg", &mesg, "-p", fixup_prompt(prompt)]); + if !echo { + cmd.arg("-password"); + } + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.kill_on_drop(true); + let child = cmd + .spawn() + .map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?; + + let out = child + .wait_with_output() + .await + .map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?; + match out.status.code() { + Some(0) => {} + Some(1) => return Err(Error::Cancel), + other => { + return Err(Error::InputError(format!( + "rofi exited with status {other:?}" + ))); + } + } + let stdout = out + .stdout + .strip_suffix(b"\n") + .unwrap_or(&out.stdout) + .to_owned(); + + Ok(String::from_utf8_lossy(&stdout).to_string()) + } + + async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> { + trace!("rofi display"); + let mut cmd = rofi_command(); + let mut mesg = if source.is_empty() { + description.to_owned() + } else { + let mut out = format!("{description}\n\nComing from ",); + for s in source.iter() { + out.push_str(&s.to_string()); + } + out.push_str(""); + out + }; + if error { + mesg.insert_str(0, ""); + mesg.push_str(""); + } + cmd.args(["-e", &mesg, "-markup"]); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.kill_on_drop(true); + let mut child = cmd + .spawn() + .map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?; + + child + .wait() + .await + .map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use crate::rofi::RofiPrompter; + use crate::{PrependSourcePrompter, Prompter as _, Source}; + + // #[tokio::test] + #[tokio::test] + #[ignore = "interactive"] + async fn test() { + let prompter = PrependSourcePrompter { + prompter: RofiPrompter, + description: "test".to_owned(), + source: vec![Source(Cow::Borrowed("ssh"))], + }; + prompter + .prompt_radio("Enable", "Polkit needs access", &[]) + .await + .expect("rofi"); + prompter + .prompt_text(false, "Password", "Polkit needs access", &[]) + .await + .expect("rofi"); + prompter + .display_text(true, "Polkit needs access", &[]) + .await + .expect("rofi"); + } +}