57 files changed
--- 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| {
1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 future::Future,4 io::Write,5 ops::Deref,6 path::PathBuf,7 pin::Pin,8 str::FromStr,9 sync::{Arc, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use camino::{Utf8Path, Utf8PathBuf};14use chrono::{DateTime, Utc};15use fleet_shared::SecretData;16use nix_eval::{Store, Value, eval_store, nix_go, nix_go_json, util::assert_warn};17use remowt_client::{AgentBundle, Remowt};18use remowt_endpoints::fs::FsClient;19use remowt_link_shared::Address;20use remowt_ui_prompt::auto::AutoPrompter;21use remowt_ui_prompt::bifrost::PromptEndpoints;22use remowt_ui_prompt::{PrependSourcePrompter, Source};23use tabled::Tabled;24use tempfile::NamedTempFile;25use time::UtcDateTime;26use tokio::task::spawn_blocking;27use tracing::warn;2829use crate::fleetdata::{30 FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,31};3233pub struct FleetConfigInternals {34 pub prefer_identities: BTreeSet<SecretOwner>,35 pub now: DateTime<Utc>,3637 38 pub directory: PathBuf,39 40 pub local_system: String,41 pub data: Arc<FleetData>,42 43 pub config_field: Value,44 45 pub flake_outputs: Value,46 47 pub localhost: String,4849 50 pub default_pkgs: Value,51 52 pub nixpkgs: Value,5354 pub local_host: OnceLock<Arc<ConfigHost>>,55}565758#[derive(Clone)]59pub struct Config(pub Arc<FleetConfigInternals>);6061impl Deref for Config {62 type Target = FleetConfigInternals;6364 fn deref(&self) -> &Self::Target {65 &self.066 }67}6869#[derive(Clone, PartialEq, Copy, Debug)]70pub enum DeployKind {71 72 UpgradeToFleet,73 74 Fleet,75 76 77 NixosInstall,78 79 80 81 NixosLustrate,82}8384impl FromStr for DeployKind {85 type Err = anyhow::Error;86 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {87 match s {88 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),89 "fleet" => Ok(Self::Fleet),90 "nixos-install" => Ok(Self::NixosInstall),91 "nixos-lustrate" => Ok(Self::NixosLustrate),92 v => bail!(93 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""94 ),95 }96 }97}98pub struct ConfigHost {99 config: Config,100 pub name: String,101 groups: OnceLock<Vec<String>>,102103 104 deploy_kind: OnceLock<DeployKind>,105 session_destination: OnceLock<String>,106 legacy_ssh_store: OnceLock<bool>,107108 pub host_config: Option<Value>,109 pub nixos_config: OnceLock<Value>,110 pub nixos_unchecked_config: OnceLock<Value>,111 pub pkgs_override: Option<Value>,112113 114 pub local: bool,115 pub remowt: OnceLock<Remowt>,116 nix_store: OnceLock<Arc<Store>>,117 nix_plugin: tokio::sync::OnceCell<()>,118}119120const NIX_PLUGIN_ID: u16 = 2;121122fn agents_dir() -> Result<PathBuf> {123 std::env::var_os("REMOWT_AGENTS_DIR")124 .map(PathBuf::from)125 .or_else(|| option_env!("REMOWT_AGENTS_DIR").map(PathBuf::from))126 .ok_or_else(|| {127 anyhow!("no remowt-agents bundle; set REMOWT_AGENTS_DIR to a remowt-agents output")128 })129}130131fn agent_bundle() -> Result<AgentBundle> {132 AgentBundle::from_dir(agents_dir()?)133}134135#[derive(Debug, Clone, Copy)]136pub enum GenerationStorage {137 Deployer,138 Machine,139 Pusher,140}141impl GenerationStorage {142 fn prefix(&self) -> &'static str {143 match self {144 GenerationStorage::Deployer => "deployer.",145 GenerationStorage::Machine => "",146 GenerationStorage::Pusher => "pusher.",147 }148 }149}150151#[derive(Tabled, Debug)]152pub struct Generation {153 #[tabled(rename = "ID", format("{}", self.rollback_id()))]154 pub id: u32,155 #[tabled(rename = "Current")]156 pub current: bool,157 #[tabled(rename = "Created at")]158 pub datetime: UtcDateTime,159 #[tabled(format = "{:?}")]160 pub store_path: Utf8PathBuf,161 #[tabled(skip)]162 pub location: GenerationStorage,163}164impl Generation {165 pub fn rollback_id(&self) -> String {166 format!("{}{}", self.location.prefix(), self.id)167 }168}169170impl ConfigHost {171 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {172 let plugin_id = self.ensure_nix_plugin().await?;173 let nix = self174 .remowt()175 .await?176 .plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);177 let raw = nix178 .list_generations(profile.to_owned())179 .await180 .map_err(|e| anyhow!("{e:?}"))?181 .map_err(|e| anyhow!("{e}"))?;182 raw.into_iter()183 .map(|g| {184 let id: u32 =185 g.id.try_into()186 .with_context(|| format!("generation id {} doesn't fit in u32", g.id))?;187 let datetime = UtcDateTime::from_unix_timestamp(g.creation_time_unix)188 .with_context(|| {189 format!("invalid generation timestamp {}", g.creation_time_unix)190 })?;191 Ok(Generation {192 id,193 current: g.current,194 datetime,195 store_path: g.store_path,196 location: GenerationStorage::Machine,197 })198 })199 .collect()200 }201202 pub fn set_session_destination(&self, dest: String) {203 self.session_destination204 .set(dest)205 .expect("session destination is already set")206 }207 pub fn set_deploy_kind(&self, kind: DeployKind) {208 self.deploy_kind209 .set(kind)210 .expect("deploy kind is already set");211 }212 pub fn set_legacy_ssh_store(&self, legacy: bool) {213 self.legacy_ssh_store214 .set(legacy)215 .expect("legacy ssh store is already set")216 }217 pub async fn deploy_kind(&self) -> Result<DeployKind> {218 if let Some(kind) = self.deploy_kind.get() {219 return Ok(*kind);220 }221 let remowt = self.remowt().await?;222 let fs = remowt.endpoints::<FsClient<_>>();223 let is_fleet_managed = match fs.file_exists(Utf8PathBuf::from("/etc/FLEET_HOST")).await {224 Ok(v) => v,225 Err(e) => {226 bail!("failed to query remote system kind: {e}");227 }228 };229 if !is_fleet_managed {230 bail!(231 "{}",232 indoc::indoc! {"233 host is not marked as managed by fleet234 if you're not trying to lustrate/install system from scratch,235 you should either236 1. manually create /etc/FLEET_HOST file on the target host,237 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet238 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos239 for installation use ?deploy_kind=nixos_install / ?deploy_kind=nixos_lustrate 240 "}241 );242 }243 244 let _ = self.deploy_kind.set(DeployKind::Fleet);245 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))246 }247 async fn connection(&self) -> Result<Remowt> {248 if let Some(conn) = self.remowt.get() {249 return Ok(conn.clone());250 }251 let bundle = agent_bundle()?;252 let conn = if self.local {253 Remowt::connect_local(&bundle, "remowt-fleet".to_owned())254 .await255 .context("starting local remowt agent")?256 } else {257 let dest = self258 .session_destination259 .get()260 .cloned()261 .unwrap_or_else(|| self.name.clone());262 Remowt::connect(&dest, &bundle, "remowt-fleet".to_owned())263 .await264 .map_err(|e| anyhow!("remowt error while connecting to {}: {e:#?}", self.name))?265 };266 PromptEndpoints(PrependSourcePrompter {267 prompter: AutoPrompter::new().await,268 source: if self.local {269 vec![]270 } else {271 vec![Source(std::borrow::Cow::Owned(format!(272 "ssh host: {}",273 self.name274 )))]275 },276 description: "".to_owned(),277 })278 .register_endpoints(&mut conn.rpc());279 let _ = self.remowt.set(conn);280 Ok(self.remowt.get().expect("just set").clone())281 }282283 284 pub async fn remowt(&self) -> Result<Remowt> {285 self.connection().await286 }287288 pub fn ensure_nix_plugin(&self) -> Pin<Box<dyn Future<Output = Result<u16>> + Send + '_>> {289 Box::pin(async {290 self.nix_plugin291 .get_or_try_init(|| async {292 let pkgs = self.pkgs()?;293 let name = "remowt-plugin-fleet";294 let plugin = nix_go!(pkgs[{ name }]);295 let built = plugin296 .build("out")297 .context("failed to build the fleet nix plugin")?;298 let copied = self299 .remote_derivation(&built)300 .await301 .context("failed to copy the fleet nix plugin to the host store")?;302 let bin = copied.join("bin/remowt-plugin-fleet");303 self.remowt()304 .await?305 .run0_load_plugin_path(NIX_PLUGIN_ID, bin.as_str())306 .await307 .context("failed to load the fleet nix plugin")?;308 self.remowt()309 .await?310 .rpc()311 .wait_for_connection_to(Address::Plugin(NIX_PLUGIN_ID))312 .await313 .map_err(|_| anyhow!("failed to wait for plugin"))?;314 anyhow::Ok(())315 })316 .await?;317 Ok(NIX_PLUGIN_ID)318 })319 }320321 async fn nix_store(&self) -> Result<Arc<Store>> {322 if let Some(store) = self.nix_store.get() {323 return Ok(store.clone());324 }325 let conn = self.connection().await?;326 let socket = match self.deploy_kind().await? {327 DeployKind::NixosInstall => {328 remowt_fleet::nix_store_socket(conn, "/mnt?require-sigs=false").await?329 }330 _ => remowt_fleet::nix_store_socket(conn, "auto").await?,331 };332 let uri = format!("unix://{}", socket.display());333 let store = Arc::new(Store::open(&uri)?);334 let _ = self.nix_store.set(store);335 Ok(self.nix_store.get().expect("just set").clone())336 }337338 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {339 ensure!(data.encrypted, "secret is not encrypted");340 let remowt = self.remowt().await?;341 let mut cmd = remowt.cmd("fleet-install-secrets");342 cmd.arg("decrypt").eqarg("--secret", data.to_string());343 let encoded = cmd344 .sudo()345 .run_string()346 .await347 .context("failed to call remote host for decrypt")?;348 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;349 ensure!(!data.encrypted, "secret came out encrypted");350 Ok(data.data)351 }352 pub async fn reencrypt_distribution(353 &self,354 data: &FleetSecretDistribution,355 targets: BTreeSet<SecretOwner>,356 now: DateTime<Utc>,357 ) -> Result<FleetSecretDistribution> {358 let mut parts = BTreeMap::new();359 for (part_name, part) in &data.secret.parts {360 parts.insert(361 part_name.clone(),362 if part.raw.encrypted {363 FleetSecretPart {364 raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,365 }366 } else {367 part.clone()368 },369 );370 }371 let secret = FleetSecretData {372 created_at: data.secret.created_at,373 expires_at: data.secret.expires_at,374 generation_data: data.secret.generation_data.clone(),375 parts,376 };377 Ok(FleetSecretDistribution::new(targets, secret, now))378 }379 pub async fn reencrypt(380 &self,381 data: SecretData,382 targets: BTreeSet<SecretOwner>,383 ) -> Result<SecretData> {384 let remowt = self.remowt().await?;385 ensure!(data.encrypted, "secret is not encrypted");386 let mut cmd = remowt.cmd("fleet-install-secrets");387 cmd.arg("reencrypt").eqarg("--secret", data.to_string());388 for target in targets {389 let key = self.config.key(&target).await?;390 cmd.eqarg("--targets", key);391 }392 let encoded = cmd393 .sudo()394 .run_string()395 .await396 .context("failed to call remote host for decrypt")?;397 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;398 ensure!(data.encrypted, "secret came out not encrypted");399 Ok(data)400 }401 402 pub async fn remote_derivation(&self, path: impl AsRef<Utf8Path>) -> Result<Utf8PathBuf> {403 let path = path.as_ref().to_owned();404 if self.local {405 406 return Ok(path);407 }408 let sign: Pin<Box<dyn Future<Output = Result<()>> + Send>> = {409 let path = path.clone();410 Box::pin(async move {411 let local = self.config.local_host();412 let plugin_id = local.ensure_nix_plugin().await?;413 let nix = local414 .remowt()415 .await?416 .plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);417 nix.sign_closure(path, Utf8PathBuf::from("/etc/nix/private-key"))418 .await419 .map_err(|e| anyhow!("{e:?}"))?420 .map_err(|e| anyhow!("{e}"))?;421 Ok(())422 })423 };424 if let Err(e) = sign.await {425 warn!("failed to sign store paths: {e}");426 }427 let store = self.nix_store().await?;428 let eval_store = eval_store();429 {430 let path = path.clone();431 spawn_blocking(move || eval_store.copy_to(&store, path.as_ref()))432 .await433 .expect("copy_to panicked")434 .context("copying closure to remote store")?;435 }436 Ok(path)437 }438}439440struct HostSecretDefinition(Value);441442impl ConfigHost {443 444 445 pub fn tags(&self) -> Result<Vec<String>> {446 if let Some(v) = self.groups.get() {447 return Ok(v.clone());448 }449 let Some(host_config) = &self.host_config else {450 return Ok(vec![]);451 };452 let tags: Vec<String> = nix_go_json!(host_config.tags);453454 let _ = self.groups.set(tags.clone());455456 Ok(tags)457 }458 pub fn nixos_config(&self) -> Result<Value> {459 if let Some(v) = self.nixos_config.get() {460 return Ok(v.clone());461 }462 let Some(host_config) = &self.host_config else {463 bail!("local host has no nixos_config");464 };465 let nixos_config = nix_go!(host_config.nixos.config);466 assert_warn("nixos config evaluation", &nixos_config)?;467468 let _ = self.nixos_config.set(nixos_config.clone());469470 Ok(nixos_config)471 }472 pub fn nixos_unchecked_config(&self) -> Result<Value> {473 if let Some(v) = self.nixos_unchecked_config.get() {474 return Ok(v.clone());475 }476 let Some(host_config) = &self.host_config else {477 bail!("local host has no nixos_config");478 };479 let nixos_config = nix_go!(host_config.nixos_unchecked.config);480481 let _ = self.nixos_unchecked_config.set(nixos_config.clone());482483 Ok(nixos_config)484 }485486 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {487 let nixos = self.nixos_unchecked_config()?;488 let secrets = nix_go!(nixos.secrets);489 secrets.list_fields()490 }491492 493 pub fn pkgs(&self) -> Result<Value> {494 if let Some(value) = &self.pkgs_override {495 return Ok(value.clone());496 }497 let Some(host_config) = &self.host_config else {498 bail!("local host has no host_config");499 };500 501 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))502 }503}504505#[derive(Clone)]506pub struct SharedSecretDefinition(Value);507impl SharedSecretDefinition {508 pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {509 let secret = &self.0;510 Ok(nix_go_json!(secret.expectedOwners))511 }512 pub fn allow_different(&self) -> Result<bool> {513 let secret = &self.0;514 Ok(nix_go_json!(secret.allowDifferent))515 }516 pub fn regenerate_on_owner_added(&self) -> Result<bool> {517 let secret = &self.0;518 Ok(nix_go_json!(secret.regenerateOnOwnerAdded))519 }520 pub fn regenerate_on_owner_removed(&self) -> Result<bool> {521 let secret = &self.0;522 Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))523 }524 pub fn generator(&self) -> Result<Value> {525 let secret = &self.0;526 Ok(nix_go!(secret.generator))527 }528}529530impl Config {531 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {532 let config = &self.config_field;533 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);534 Ok(tagged)535 }536 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {537 let mut out = BTreeSet::new();538 for owner in owners {539 if let Some(tag) = owner.strip_prefix('@') {540 let hosts = self.tagged_hostnames(tag)?;541 out.extend(hosts);542 } else {543 out.insert(owner);544 }545 }546 Ok(out)547 }548 pub fn local_host(&self) -> Arc<ConfigHost> {549 self.local_host550 .get_or_init(|| {551 Arc::new(ConfigHost {552 config: self.clone(),553 name: "<virtual localhost>".to_owned(),554 host_config: None,555 nixos_config: OnceLock::new(),556 nixos_unchecked_config: OnceLock::new(),557 groups: {558 let cell = OnceLock::new();559 let _ = cell.set(vec![]);560 cell561 },562 pkgs_override: Some(self.default_pkgs.clone()),563564 local: true,565 remowt: OnceLock::new(),566 nix_store: OnceLock::new(),567 nix_plugin: tokio::sync::OnceCell::new(),568 deploy_kind: OnceLock::new(),569 session_destination: OnceLock::new(),570 legacy_ssh_store: OnceLock::new(),571 })572 })573 .clone()574 }575576 pub fn preferred_hosts(577 &self,578 filter: impl Fn(&str) -> bool,579 ) -> Result<impl Iterator<Item = Result<ConfigHost>>> {580 let prefer = self581 .prefer_identities582 .iter()583 .filter_map(|v| v.as_host())584 .collect::<HashSet<_>>();585 let config = &self.config_field;586 let mut names = nix_go!(config.hosts).list_fields()?;587 names.retain(|s| filter(s));588 names.sort_by_key(|h| prefer.contains(h.as_str()));589590 Ok(names.into_iter().map(|h| self.host(&h)))591 }592593 pub fn host(&self, name: &str) -> Result<ConfigHost> {594 let config = &self.config_field;595 let host_config = nix_go!(config.hosts[{ name }]);596597 Ok(ConfigHost {598 config: self.clone(),599 name: name.to_owned(),600 host_config: Some(host_config),601 nixos_config: OnceLock::new(),602 nixos_unchecked_config: OnceLock::new(),603 groups: OnceLock::new(),604 pkgs_override: None,605606 607 local: self.localhost == name,608 remowt: OnceLock::new(),609 nix_store: OnceLock::new(),610 nix_plugin: tokio::sync::OnceCell::new(),611 deploy_kind: OnceLock::new(),612 session_destination: OnceLock::new(),613 legacy_ssh_store: OnceLock::new(),614 })615 }616 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {617 let config = &self.config_field;618 let names = nix_go!(config.hosts).list_fields()?;619 let mut out = vec![];620 for name in names {621 out.push(self.host(&name)?);622 }623 Ok(out)624 }625 626 pub fn system_config(&self, host: &str) -> Result<Value> {627 let fleet_field = &self.config_field;628 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))629 }630631 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {632 let config = &self.config_field;633 let shared_secrets = nix_go!(config.secrets);634 if !shared_secrets.has_field(secret)? {635 return Ok(None);636 }637 Ok(Some(SharedSecretDefinition(nix_go!(638 shared_secrets[secret]639 ))))640 }641642 pub fn save(&self) -> Result<()> {643 let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;644 let data = nixlike::serialize(&*self.data)?;645 tempfile.write_all(646 format!(647 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"648 )649 .as_bytes(),650 )?;651 let mut fleet_data_path = self.directory.clone();652 fleet_data_path.push("fleet.nix");653 tempfile.persist(fleet_data_path)?;654 Ok(())655 }656}
--- 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 @@
+<?xml version="1.0"?>
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" "https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+ <policy user="root">
+ <allow own = "lach.polkit.helper1"/>
+ <allow send_interface="lach.PolkitInputHandler"/>
+ </policy>
+ <policy context="default">
+ <allow send_destination="lach.polkit.helper1"/>
+ <deny send_interface="lach.PolkitInputHandler"/>
+ </policy>
+</busconfig>
--- /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<HashSet<&str>> = 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>(P);
+impl<P: BlockingPrompter> Conversation<P> {
+ fn prompt_inner(&self, echo: bool, prompt: &CStr) -> Result<CString, ErrorCode> {
+ 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<P: BlockingPrompter> ConversationHandler for Conversation<P> {
+ fn prompt_echo_on(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {
+ self.prompt_inner(true, prompt)
+ }
+
+ fn prompt_echo_off(&mut self, prompt: &CStr) -> Result<CString, ErrorCode> {
+ 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<bool, ErrorCode> {
+ 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<HashMap<String, OwnedValue>>;
+}
+
+#[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::<HashMap<_, _>>();
+ 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<blocking::Connection> = 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<P>(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<PrivateBus> {
+ 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<BifConfig>,
+}
+
+#[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<u16> {
+ 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<u16> {
+ 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<BifConfig>,
+) -> 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<u16>;
+ async fn forward_udp(&self, addr: &str) -> fdo::Result<u16>;
+}
+
+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<P: Prompter + 'static> {
+ connection: Connection,
+ path: String,
+ _marker: PhantomData<P>,
+}
+impl<P: Prompter + 'static> TemporaryPrompterInterface<P> {
+ 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<P: Prompter + Send + Sync + 'static> Drop for TemporaryPrompterInterface<P> {
+ 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::<DbusPrompterInterface<P>, String>(path)
+ .await;
+ });
+ }
+}
+
+#[derive(Clone)]
+pub struct DbusHelper {
+ connection: Connection,
+ helper: PolkitHelperProxy<'static>,
+}
+impl DbusHelper {
+ pub async fn new(connection: Connection) -> zbus::Result<Self> {
+ let helper = PolkitHelperProxy::new(&connection).await?;
+ Ok(Self { connection, helper })
+ }
+}
+impl Helper for DbusHelper {
+ async fn help_me<P: Prompter + Send + Sync + 'static>(
+ &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<P: Prompter + Send + Sync + 'static>(
+ &self,
+ cookie: &str,
+ prompt: P,
+ identity: Identity,
+ ) -> impl Future<Output = anyhow::Result<()>> + 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<R, W, P>(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<F> {
+ pub fallback: F,
+}
+
+impl<F: Helper + Sync> Helper for SocketHelper<F> {
+ async fn help_me<P: Prompter + Send + Sync + 'static>(
+ &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<P: Prompter + 'static>(
+ &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<Mutex<HashMap<String, AbortHandle>>>,
+ 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<H, P> {
+ tasks: Arc<Mutex<HashMap<String, AbortHandle>>>,
+ helper: H,
+ prompter: P,
+}
+impl<H, P> Agent<H, P> {
+ fn new(helper: H, prompter: P) -> Self {
+ Agent {
+ tasks: Arc::new(Mutex::new(HashMap::new())),
+ helper,
+ prompter,
+ }
+ }
+}
+
+#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
+impl<H, P> Agent<H, P>
+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<String, String>,
+ cookie: String,
+ identities: Vec<Identity>,
+ ) -> 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\n<b>Action id:</b> {action_id}",);
+ if let Some(subject) = details.remove("polkit.caller-pid") {
+ let _ = write!(description, "\n<b>Caller:</b> ");
+ if let Ok(pid) = subject.parse::<u32>() {
+ let _ = write!(description, "{}", PidDisplay(pid));
+ } else {
+ let _ = write!(description, "{}", emphasize("invalid pid"));
+ }
+ }
+ if let Some(subject) = details.remove("polkit.subject-pid") {
+ let _ = write!(description, "\n<b>Subject:</b> ");
+ if let Ok(pid) = subject.parse::<u32>() {
+ 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<String> =
+ 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,
+ "\n<b>Identity:</b> {}",
+ 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<PathBuf>,
+ /// 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<PathBuf>,
+ privileged: bool,
+ local: bool,
+) -> anyhow::Result<()> {
+ let address = if privileged {
+ Address::AgentPrivileged
+ } else {
+ Address::Agent
+ };
+ let mut rpc = Rpc::<BifConfig>::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<H, P>(conn: &Connection, agent: Agent<H, P>) -> 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<Subject> {
+ 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<PathBuf> {
+ 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<RawFd>,
+ original_flags: i32,
+}
+
+impl AsyncStdin {
+ fn new() -> io::Result<Self> {
+ 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<io::Result<()>> {
+ 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<Self> {
+ 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<str>) -> String {
+ format!("<span style=\"italic\"><{}></span>", escape(s),)
+}
+fn command(s: impl AsRef<str>) -> String {
+ format!("<u><tt>{}</tt></u>", s.as_ref())
+}
+fn escape(s: impl AsRef<str>) -> 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<String, OwnedValue>,
+}
+
+impl Identity {
+ pub fn uid(&self) -> Option<Uid> {
+ 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<String, String>,
+ 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<Handle<SshHandler>>,
+ 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<u16, Error> {
+ 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<BifConfig> = 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<u16, Error> {
+ 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<BifConfig> = 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<Mutex<HashMap<SocketAddr, u64>>> =
+ Arc::new(Mutex::new(HashMap::new()));
+ let source_for_sub: Arc<Mutex<HashMap<u64, SocketAddr>>> =
+ 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<Channel<Msg>>),
+ Local(UnixListener, Utf8PathBuf),
+ Iroh(oneshot::Receiver<RemowtStream>),
+}
+
+impl RemowtListener {
+ pub async fn accept(self) -> Result<RemowtStream> {
+ 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<Msg>),
+ Local(UnixStream),
+ Iroh(IrohBiStream),
+}
+
+impl AsyncRead for RemowtStream {
+ fn poll_read(
+ self: Pin<&mut Self>,
+ cx: &mut Context<'_>,
+ buf: &mut ReadBuf<'_>,
+ ) -> Poll<io::Result<()>> {
+ 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<io::Result<usize>> {
+ 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<io::Result<()>> {
+ 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<io::Result<()>> {
+ 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<Mutex<HashMap<Utf8PathBuf, oneshot::Sender<Channel<Msg>>>>>;
+
+fn sh_quote(s: impl AsRef<str>) -> String {
+ format!("'{}'", s.as_ref().replace('\'', "'\\''"))
+}
+
+const ESCALATORS: [(&str, &[&str]); 2] = [("run0", &["--background=", "--pipe"]), ("sudo", &[])];
+
+pub struct AgentBundle {
+ dir: PathBuf,
+ hashes: HashMap<String, String>,
+}
+
+impl AgentBundle {
+ pub fn from_dir(dir: impl Into<PathBuf>) -> Result<Self> {
+ 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<PathBuf> {
+ 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<SshHandler>, cmd: &str) -> Result<(Option<u32>, Vec<u8>)> {
+ 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<SshHandler>, cmd: &str) -> Result<String> {
+ 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<SshHandler>, bundle: &AgentBundle) -> Result<Utf8PathBuf> {
+ 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<SshHandler>,
+ dir: &Utf8Path,
+ path: &Utf8Path,
+ bytes: Vec<u8>,
+) -> 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<bool, Self::Error> {
+ Ok(check_known_hosts(&self.host, self.port, server_public_key)?)
+ }
+ async fn server_channel_open_forwarded_streamlocal(
+ &mut self,
+ channel: Channel<Msg>,
+ 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<Handle<SshHandler>>,
+ subs: Subs,
+ runtime_dir: Utf8PathBuf,
+ agent_path: Utf8PathBuf,
+ },
+ Local {
+ agent_path: PathBuf,
+ runtime_dir: Utf8PathBuf,
+ },
+}
+
+struct RemowtInner {
+ transport: Transport,
+ rpc: Rpc<BifConfig>,
+ elevated: tokio::sync::OnceCell<()>,
+ #[allow(dead_code)]
+ children: Mutex<Vec<tokio::process::Child>>,
+ _runtime_tmp: Option<TempDir>,
+ user: String,
+ iroh: IrohState,
+}
+
+#[derive(Default)]
+struct IrohState {
+ conn: tokio::sync::OnceCell<iroh::endpoint::Connection>,
+ #[allow(dead_code)]
+ endpoint: Mutex<Option<iroh::Endpoint>>,
+ subs: Arc<Mutex<HashMap<u64, oneshot::Sender<RemowtStream>>>>,
+ next_token: AtomicU64,
+ router: Mutex<Option<Arc<DatagramRouter>>>,
+}
+
+#[derive(Clone)]
+pub struct Remowt(Arc<RemowtInner>);
+
+pub type RemowtRemote = Remote<BifConfig>;
+
+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<Self> {
+ 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::<BifConfig>::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<Self> {
+ 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::<BifConfig>::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<Arc<Handle<SshHandler>>> {
+ match &self.0.transport {
+ Transport::Ssh { sess, .. } => Some(sess.clone()),
+ Transport::Local { .. } => None,
+ }
+ }
+
+ pub fn rpc(&self) -> Rpc<BifConfig> {
+ self.0.rpc.clone()
+ }
+
+ pub async fn load_plugin(&self, id: u16, name: &str) -> Result<()> {
+ let client: PluginEndpointsClient<BifConfig> = 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<BifConfig> =
+ 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<R: RemoteEndpoints<BifConfig>>(&self, id: u16) -> R {
+ R::wrap(self.0.rpc.remote(Address::Plugin(id)))
+ }
+
+ pub fn endpoints<R: RemoteEndpoints<BifConfig>>(&self) -> R {
+ R::wrap(self.0.rpc.remote(Address::Agent))
+ }
+ pub async fn run0_endpoints<R: RemoteEndpoints<BifConfig>>(&self) -> Result<R> {
+ 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<String> = 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<RemowtListener> {
+ 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<BifConfig> =
+ 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<Arc<DatagramRouter>> {
+ 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<TempDir>)> {
+ 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<SshHandler>) -> Result<Utf8PathBuf> {
+ 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<BifConfig>,
+}
+impl RemowtShell {
+ pub fn resizer(&self) -> RemowtShellResizer {
+ RemowtShellResizer {
+ remote: self.remote.clone(),
+ id: self.id,
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct RemowtShellResizer {
+ remote: Remote<BifConfig>,
+ 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<RemowtShell> {
+ let (forwarded, tunnel) = self.bind_fast_tunnel("shell", escalate).await?;
+ let client: PtyClient<BifConfig> = 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<Option<u32>>,
+}
+
+impl SshExecChild {
+ /// Manage channel returned by russh exec().
+ pub(crate) fn from_exec(ch: Channel<Msg>) -> 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<u32> {
+ 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<String>,
+ pub env: Vec<(String, String)>,
+ pub env_clear: bool,
+ pub cwd: Option<Utf8PathBuf>,
+ pub escalated: bool,
+ pub stdin: StdioMode,
+ pub stdout: StdioMode,
+ pub stderr: StderrMode,
+}
+
+pub struct RemowtChild {
+ pub stdin: Option<RemowtStream>,
+ pub stdout: Option<RemowtStream>,
+ pub stderr: Option<RemowtStream>,
+ id: ProcId,
+ client: SubprocessClient<BifConfig>,
+}
+
+impl RemowtChild {
+ pub async fn wait(self) -> Result<Option<i32>> {
+ 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, "<child stdout>".to_owned(), false).await;
+ }
+ };
+ let drain_err = async move {
+ if let Some(s) = stderr {
+ let _ = drain_to_tracing(s, "<child stderr>".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<RemowtChild> {
+ 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<BifConfig> = if escalated {
+ // Boxed to break the async-fn type cycle
+ Box::pin(self.run0_endpoints::<SubprocessClient<BifConfig>>()).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<str>) -> 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<Option<RemowtStream>> {
+ match b {
+ Some((l, _)) => Ok(Some(l.accept().await?)),
+ None => Ok(None),
+ }
+}
+
+fn handle_stdin(mode: StdioMode, s: Option<RemowtStream>, program: &str) -> Option<RemowtStream> {
+ 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<RemowtStream>, program: &str) -> Option<RemowtStream> {
+ 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<RemowtStream>,
+ program: &str,
+) -> Option<RemowtStream> {
+ 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<String>,
+ env: Vec<(String, String)>,
+ remowt: Remowt,
+ escalated: bool,
+}
+
+impl RemowtCommand {
+ pub fn arg(&mut self, arg: impl AsRef<str>) -> &mut Self {
+ self.args.push(arg.as_ref().to_owned());
+ self
+ }
+ pub fn args<V: AsRef<str>>(&mut self, args: impl IntoIterator<Item = V>) -> &mut Self {
+ for arg in args {
+ self.args.push(arg.as_ref().to_owned());
+ }
+ self
+ }
+ pub fn eqarg(&mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> &mut Self {
+ self.args
+ .push(format!("{}={}", key.as_ref(), value.as_ref()));
+ self
+ }
+ pub fn comparg(&mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> &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<str>, value: impl AsRef<str>) -> &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<String> {
+ let bytes = run_inner(self, true).await?.expect("want_stdout");
+ Ok(String::from_utf8(bytes)?)
+ }
+ pub async fn run_value<T: DeserializeOwned>(self) -> Result<T> {
+ let s = self.run_string().await?;
+ Ok(serde_json::from_str(&s)?)
+ }
+}
+
+async fn run_inner(cmd: RemowtCommand, want_stdout: bool) -> Result<Option<Vec<u8>>> {
+ 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<TunnelDialer>,
+ next_session: Arc<AtomicU64>,
+}
+
+impl Forward {
+ pub fn new(dialer: Arc<TunnelDialer>) -> 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<u64, Error> {
+ 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<Mutex<HashMap<u64, Arc<UdpSocket>>>> =
+ 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<Vec<TempDir>>,
+}
+
+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<Vec<u8>, 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<Vec<Utf8PathBuf>, 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<Utf8PathBuf, Error> {
+ 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<C: Config> FsClient<C> {
+ pub async fn read_file_text(&self, path: impl Into<Utf8PathBuf>) -> Result<String, Error> {
+ 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<T: FromStr>(
+ &self,
+ path: impl Into<Utf8PathBuf>,
+ ) -> Result<Result<T, T::Err>, Error> {
+ let text = self.read_file_text(path).await?;
+ Ok(T::from_str(&text))
+ }
+ pub async fn mktemp_dir(&self) -> Result<Utf8PathBuf, Error> {
+ self.mktemp_dir_raw().await.map_err(|_| Error::Unknown)?
+ }
+ pub async fn read_dir(&self, path: impl Into<Utf8PathBuf>) -> Result<Vec<Utf8PathBuf>, 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<TunnelDialer>,
+ endpoint: Arc<Mutex<Option<Endpoint>>>,
+}
+
+impl IrohTunnel {
+ pub fn new(dialer: Arc<TunnelDialer>) -> 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<EndpointId, Error> {
+ 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<TunnelDialer>,
+}
+
+impl NixDaemon {
+ pub fn new(dialer: Arc<TunnelDialer>) -> 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<io::Error> for Error {
+ fn from(e: io::Error) -> Self {
+ Error::Io(e.to_string())
+ }
+}
+
+#[derive(Clone)]
+pub struct Pty {
+ shells: Arc<Mutex<HashMap<ShellId, OwnedFd>>>,
+ next_id: Arc<AtomicU64>,
+ dialer: Arc<TunnelDialer>,
+}
+
+impl Pty {
+ pub fn new(dialer: Arc<TunnelDialer>) -> 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<ShellId, Error> {
+ 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<OwnedFd>,
+}
+
+impl AsyncPty {
+ fn new(fd: OwnedFd) -> io::Result<Self> {
+ 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<io::Result<()>> {
+ 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<io::Result<usize>> {
+ 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<io::Result<()>> {
+ Poll::Ready(Ok(()))
+ }
+
+ fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+ 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<String>,
+ pub env: Vec<(String, String)>,
+ pub env_clear: bool,
+ pub cwd: Option<Utf8PathBuf>,
+ 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<io::Error> for Error {
+ fn from(e: io::Error) -> Self {
+ Error::Io(e.to_string())
+ }
+}
+
+struct ChildState {
+ pid: u32,
+ exit_rx: watch::Receiver<Option<Option<i32>>>,
+}
+
+#[derive(Clone)]
+pub struct Subprocess {
+ children: Arc<Mutex<HashMap<ProcId, ChildState>>>,
+ next_id: Arc<AtomicU64>,
+ dialer: Arc<TunnelDialer>,
+}
+
+impl Subprocess {
+ pub fn new(dialer: Arc<TunnelDialer>) -> 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<ProcId, Error> {
+ 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<Option<i32>, 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<R>(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::<Vec<u8>>(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<OwnedObjectPath>;
+ fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
+}
+
+async fn manager() -> Result<ManagerProxy<'static>, 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<Output = Result<(), Error>> + Send;
+ fn expose_tcp(&self, addr: String) -> impl Future<Output = Result<u16, Error>> + Send;
+ fn expose_udp(&self, addr: String) -> impl Future<Output = Result<u16, Error>> + Send;
+}
+
+pub struct EditorEndpoints<E>(pub E);
+
+#[endpoints(ns = 8)]
+impl<E: EditorBackend + 'static> EditorEndpoints<E> {
+ #[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<u16, Error> {
+ self.0.expose_tcp(addr).await
+ }
+
+ #[endpoints(id = 3)]
+ async fn expose_udp(&self, addr: String) -> Result<u16, Error> {
+ self.0.expose_udp(addr).await
+ }
+}
+
+pub fn serve_editor<E, C>(rpc: &mut Rpc<C>, 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<S>(
+ secret: SecretKey,
+ stream: S,
+ remote: EndpointId,
+ accept: bool,
+) -> Result<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)
+ .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<Vec<CustomAddr>>,
+ endpoint: Mutex<Option<SshEndpointParts>>,
+ sender: Arc<SshSender>,
+}
+
+struct SshEndpointParts {
+ reader: Pin<Box<dyn FramedDatagrams>>,
+}
+
+trait FramedDatagrams: Stream<Item = io::Result<BytesMut>> + Send + Sync {}
+impl<T: Stream<Item = io::Result<BytesMut>> + 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<S>(stream: S, local: EndpointId, remote: EndpointId) -> Self
+ where
+ S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
+ {
+ let (read, write): (ReadHalf<S>, WriteHalf<S>) = tokio::io::split(stream);
+ let reader = FramedRead::new(read, LengthDelimitedCodec::new());
+
+ let (tx, rx) = mpsc::unbounded_channel::<Bytes>();
+ 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<W: AsyncWrite + Unpin>(mut rx: mpsc::UnboundedReceiver<Bytes>, 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<Box<dyn CustomEndpoint>> {
+ 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<Vec<CustomAddr>>,
+ sender: Arc<SshSender>,
+ reader: Pin<Box<dyn FramedDatagrams>>,
+}
+
+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<Vec<CustomAddr>> {
+ self.addrs.watch()
+ }
+
+ fn create_sender(&self) -> Arc<dyn CustomSender> {
+ 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<io::Result<usize>> {
+ 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<Bytes>,
+}
+
+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<io::Result<()>> {
+ 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<io::Result<()>> {
+ 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<io::Result<usize>> {
+ AsyncWrite::poll_write(Pin::new(&mut self.get_mut().send), cx, buf)
+ }
+ fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+ AsyncWrite::poll_flush(Pin::new(&mut self.get_mut().send), cx)
+ }
+ fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+ 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<io::Result<()>> {
+ 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<io::Result<usize>> {
+ 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<io::Result<()>> {
+ 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<io::Result<()>> {
+ 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<Option<Connection>>,
+ router: Arc<Mutex<Option<Arc<DatagramRouter>>>>,
+}
+
+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<Arc<DatagramRouter>> {
+ self.router.lock().expect("lock").clone()
+ }
+
+ pub async fn connect_tunnel(&self, addr: &TunnelAddr) -> io::Result<TunnelStream> {
+ 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<Mutex<HashMap<u64, mpsc::UnboundedSender<(u64, Bytes)>>>>;
+
+pub struct DatagramRouter {
+ conn: Connection,
+ routes: DatagramRoutes,
+}
+
+impl DatagramRouter {
+ pub fn spawn(conn: Connection) -> Arc<Self> {
+ 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<S>(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<ListenerForYourRequestHasBeenDeadError> for Error {
+ fn from(_value: ListenerForYourRequestHasBeenDeadError) -> Self {
+ Self::ListenerDead
+ }
+}
+impl From<serde_json::Error> for Error {
+ fn from(_value: serde_json::Error) -> Self {
+ Self::ListenerDead
+ }
+}
+impl From<Error> for ResponseError {
+ fn from(val: Error) -> Self {
+ ResponseError(val.to_string())
+ }
+}
+impl From<ResponseError> 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<u8>;
+
+ fn decode_headers(
+ data_with_headers: Bytes,
+ ) -> Result<(OpaquePacketWrapper<Self::Address>, Self::EncodedData), Self::Error> {
+ let (header, data): (OpaquePacketWrapper<Self::Address>, Vec<u8>) =
+ serde_json::from_slice(&data_with_headers)?;
+ Ok((header, data))
+ }
+
+ fn decode_data<T: DeserializeOwned>(data: Self::EncodedData) -> Result<T, Self::Error> {
+ let v: T = serde_json::from_slice(&data)?;
+ Ok(v)
+ }
+
+ fn encode_data<T: Serialize>(headers: OpaquePacketWrapper<Self::Address>, 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<Output = Result<(), Error>> + Send;
+
+ fn load_plugin_path(
+ &self,
+ id: u16,
+ path: String,
+ ) -> impl Future<Output = Result<(), Error>> + Send;
+}
+
+pub struct PluginEndpoints<H>(pub H);
+
+#[endpoints(ns = 9)]
+impl<H: PluginHost + 'static> PluginEndpoints<H> {
+ #[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<R, W>(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<W: AsyncWrite + Unpin>(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<BifConfig>) {
+ 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<BifConfig>,
+ children: Mutex<Vec<Child>>,
+}
+
+impl Host {
+ fn spawn(&self, id: u16, path: impl AsRef<OsStr>) -> 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<u16> {
+ 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<Address> {
+ 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<F>(register: F) -> Result<()>
+where
+ F: FnOnce(&mut Rpc<BifConfig>),
+{
+ 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::<BifConfig>::new(Address::Plugin(index));
+ rpc.add_direct(host, from_stdio(), Rtt(0));
+ register(&mut rpc);
+ let _rpc = rpc;
+ pending::<Result<()>>().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<DbusPrompterProxy<'static>>,
+ 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<DbusPrompterProxy<'static>> {
+ 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<u32> {
+ 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<String> {
+ 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<P>(pub P);
+
+#[endpoints(ns = 2)]
+impl<P> PromptEndpoints<P>
+where
+ P: Prompter + Send + Sync + 'static,
+{
+ #[endpoints(id = 1, cancel)]
+ async fn prompt_enum(
+ &self,
+ prompt: String,
+ description: String,
+ variants: Vec<String>,
+ source: Vec<Source>,
+ ) -> Result<u32, Error> {
+ 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<Source>,
+ ) -> Result<String, Error> {
+ self.0
+ .prompt_text(echo, &prompt, &description, &source)
+ .await
+ }
+
+ #[endpoints(id = 3, cancel)]
+ async fn display_text(
+ &self,
+ error: bool,
+ description: String,
+ source: Vec<Source>,
+ ) -> Result<(), Error> {
+ self.0.display_text(error, &description, &source).await
+ }
+}
+
+impl<C: Config> Prompter for PromptEndpointsClient<C>
+where
+ Error: ToString,
+{
+ async fn prompt_enum(
+ &self,
+ prompt: &str,
+ description: &str,
+ variants: &[&str],
+ source: &[Source],
+ ) -> crate::Result<u32> {
+ 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<String> {
+ 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<P, C>(rpc: &mut Rpc<C>, prompt: P)
+where
+ P: Prompter + Send + Sync + 'static,
+ C: Config,
+ C::Error: From<Error>,
+{
+ 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<P>(pub P);
+
+#[interface(name = "lach.PolkitInputHandler")]
+impl<P: Prompter + Send + Sync + 'static> DbusPrompterInterface<P> {
+ async fn prompt_enum(
+ &self,
+ prompt: &str,
+ description: &str,
+ variants: Vec<String>,
+ source: Vec<Source>,
+ ) -> fdo::Result<u32> {
+ 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<Source>,
+ ) -> fdo::Result<String> {
+ Ok(self
+ .0
+ .prompt_text(echo, prompt, description, &source)
+ .await?)
+ }
+ async fn display_text(
+ &self,
+ error: bool,
+ description: &str,
+ source: Vec<Source>,
+ ) -> 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<u32>;
+ async fn prompt_text(
+ &self,
+ echo: bool,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> fdo::Result<String>;
+ 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<u32> {
+ Ok(self
+ .prompt_enum(prompt, description, variants, source)
+ .await?)
+ }
+
+ async fn prompt_text(
+ &self,
+ echo: bool,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> Result<String> {
+ 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<u32> {
+ Ok(self.prompt_enum(prompt, description, variants, source)?)
+ }
+
+ fn prompt_text(
+ &self,
+ echo: bool,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> Result<String> {
+ 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<fdo::Error> for Error {
+ fn from(value: fdo::Error) -> Self {
+ if matches!(value, fdo::Error::NoReply(_)) {
+ return Self::Cancel;
+ }
+ Self::InputError(format!("{value}"))
+ }
+}
+impl From<Error> 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<T, E = Error> = result::Result<T, E>;
+
+#[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, "<u>{}</u>", self.0)
+ }
+}
+
+pub trait Prompter: Send + Sync {
+ fn prompt_radio(
+ &self,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> impl Future<Output = Result<bool>> + 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<Output = Result<u32>> + Send;
+ fn prompt_text(
+ &self,
+ echo: bool,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> impl Future<Output = Result<String>> + Send;
+ fn display_text(
+ &self,
+ error: bool,
+ description: &str,
+ source: &[Source],
+ ) -> impl Future<Output = Result<()>> + Send;
+}
+pub trait BlockingPrompter {
+ fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool> {
+ 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<u32>;
+ fn prompt_text(
+ &self,
+ echo: bool,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> Result<String>;
+ fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>;
+}
+impl<P> Prompter for &P
+where
+ P: Prompter,
+{
+ fn prompt_radio(
+ &self,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> impl Future<Output = Result<bool>> + Send {
+ (*self).prompt_radio(prompt, description, source)
+ }
+
+ fn prompt_enum(
+ &self,
+ prompt: &str,
+ description: &str,
+ variants: &[&str],
+ source: &[Source],
+ ) -> impl Future<Output = Result<u32>> + Send {
+ (*self).prompt_enum(prompt, description, variants, source)
+ }
+
+ fn prompt_text(
+ &self,
+ echo: bool,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> impl Future<Output = Result<String>> + Send {
+ (*self).prompt_text(echo, prompt, description, source)
+ }
+
+ fn display_text(
+ &self,
+ error: bool,
+ description: &str,
+ source: &[Source],
+ ) -> impl Future<Output = Result<()>> + Send {
+ (*self).display_text(error, description, source)
+ }
+}
+
+pub struct PrependSourcePrompter<P> {
+ pub prompter: P,
+ pub source: Vec<Source>,
+ pub description: String,
+}
+impl<P> PrependSourcePrompter<P> {
+ fn source(&self, input: &[Source]) -> Vec<Source> {
+ 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<P> Prompter for PrependSourcePrompter<P>
+where
+ P: Prompter + Sync,
+{
+ async fn prompt_radio(
+ &self,
+ prompt: &str,
+ description: &str,
+ source: &[Source],
+ ) -> Result<bool> {
+ 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<u32> {
+ 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<String> {
+ 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<u32> {
+ trace!("rofi radio");
+ let mut cmd = rofi_command();
+ let mesg = if source.is_empty() {
+ description.to_owned()
+ } else {
+ let mut out = format!("{description}\n\n<b>Requested on ",);
+ for (i, s) in source.iter().enumerate() {
+ if i != 0 {
+ out.push_str(" -> ");
+ }
+ out.push_str(&s.to_string());
+ }
+ out.push_str("</b>");
+ 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<String> {
+ trace!("rofi text");
+ let mut cmd = rofi_command();
+ let mesg = if source.is_empty() {
+ description.to_owned()
+ } else {
+ let mut out = format!("{description}\n\n<b>Requested on ",);
+ for (i, s) in source.iter().enumerate() {
+ if i != 0 {
+ out.push_str(" -> ");
+ }
+ out.push_str(&s.to_string());
+ }
+ out.push_str("</b>");
+ 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\n<b>Coming from ",);
+ for s in source.iter() {
+ out.push_str(&s.to_string());
+ }
+ out.push_str("</b>");
+ out
+ };
+ if error {
+ mesg.insert_str(0, "<span color=\"red\">");
+ mesg.push_str("</span>");
+ }
+ 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");
+ }
+}