diff --git a/Cargo.lock b/Cargo.lock index 2c82c16..462e9e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "async-utility" version = "0.3.1" @@ -173,6 +184,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base58ck" version = "0.1.0" @@ -448,7 +502,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -489,6 +543,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -635,7 +699,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -749,6 +813,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -831,7 +901,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -937,6 +1007,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1062,6 +1151,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.3.0" @@ -1078,9 +1173,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1106,6 +1203,19 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1328,6 +1438,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1478,6 +1597,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -1494,6 +1619,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1536,6 +1667,7 @@ dependencies = [ "mostro-core", "nostr-sdk", "pretty_env_logger", + "prost", "rand_core 0.10.0", "reqwest", "rstest", @@ -1545,6 +1677,8 @@ dependencies = [ "sqlx", "tokio", "tokio-test", + "tonic", + "tonic-prost", "uuid", ] @@ -1710,6 +1844,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1781,6 +1921,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1872,7 +2032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1893,6 +2053,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2189,7 +2372,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.117", "unicode-ident", ] @@ -2236,6 +2419,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2287,6 +2482,15 @@ dependencies = [ "sdd", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2332,6 +2536,29 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2365,7 +2592,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2416,7 +2643,7 @@ checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2576,7 +2803,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2599,7 +2826,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -2747,6 +2974,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2764,7 +3002,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2802,7 +3040,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2813,7 +3051,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2866,7 +3104,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2929,6 +3167,20 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -2959,6 +3211,48 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -2967,11 +3261,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3024,7 +3322,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3287,7 +3585,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3434,7 +3732,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3445,7 +3743,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3733,7 +4031,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3749,7 +4047,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3816,7 +4114,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3837,7 +4135,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3857,7 +4155,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3897,7 +4195,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9b3086d..96ddd25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,11 @@ rand_core = "0.10.0" bitcoin = "0.32.8" bitcoin_hashes = { version = "0.20.0", default-features = false } base64 = "0.22" +# Admin gRPC (mostrod `[rpc]`): hand-written prost messages + tonic client, +# no protoc / build.rs needed to `cargo install`. +tonic = { version = "0.14.2", features = ["tls-ring", "tls-native-roots"] } +tonic-prost = "0.14.1" +prost = "0.14.1" [package.metadata.release] # (Default: true) Set to false to prevent automatically running `cargo publish`. diff --git a/README.md b/README.md index 9d2edbd..057b340 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ The mnemonic-based user and the admin key are completely independent. You can ru | `SECRET` | `-s, --secret` | Use secret/anonymous mode for the inner event tuple (advanced, hides trade index from gift-wrap inner). | | `TRANSPORT` | `-t, --transport` | Wire transport: `gift-wrap` (protocol v1) or `nip44` (protocol v2). Leave unset to auto-detect from the instance's info event. | | `ADMIN_NSEC` | — | Admin/solver private key in `nsec1...` or hex format. Only read when an `adm*` command is invoked. | +| `MOSTRO_RPC_URL` | `http://127.0.0.1:50051` | `mostrod` admin gRPC endpoint (`[rpc]` in the daemon's settings). Only used by `admsetmaintenance` / `admmaintenancestatus`. | +| `MOSTRO_RPC_TOKEN` | — | Bearer token for the admin gRPC, required when the daemon sets `[rpc].auth_token`. Only used by the two commands above. Sent in cleartext only to a loopback URL (direct or through an SSH tunnel); any other `http://` host is refused, use `https://` via a TLS proxy instead. | | `RUST_LOG` | `-v, --verbose` | **Not actually configurable.** The logger is initialised only when `-v` is passed, and `-v` overwrites `RUST_LOG` with `info` first. So `RUST_LOG` alone produces no output, and `RUST_LOG=debug -v` still logs at `info`. `-v` is the only available level. | ### Choosing a Mostro instance @@ -444,6 +446,23 @@ mostro-cli admsenddm -p -m "hi, I'm the solver assigned to your di mostro-cli sendadmindmattach -p -o -f /path/to/evidence.pdf ``` +### Operator commands: maintenance mode (Lightning node migration) + +These two commands talk to the daemon's admin gRPC directly instead of Nostr, so they need `MOSTRO_RPC_URL` (and `MOSTRO_RPC_TOKEN` if the daemon requires it) but **not** `ADMIN_NSEC`, relays or a mnemonic. They must run on the daemon's host or through a tunnel to it: `mostrod` only accepts `SetMaintenanceMode` from loopback peers. + +```bash +# Close the book: new orders and takes are rejected, open trades keep working +mostro-cli admsetmaintenance --enabled true --reason "LN node migration" + +# Watch the drain; switch the Lightning node only once drained = true +mostro-cli admmaintenancestatus + +# Reopen the book +mostro-cli admsetmaintenance --enabled false +``` + +The full procedure (drain, stop, edit `[lightning]`, start, reopen) is in the daemon's `docs/LIGHTNING_OPS.md`, section "Migrating to a Different Lightning Node". + ### Tips for solvers - Always read both sides' DMs (`getadmindm` plus the order's chat history) before deciding. diff --git a/src/cli.rs b/src/cli.rs index f309e0f..1c6b573 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,6 +8,7 @@ pub mod get_dm_user; pub mod last_trade_index; pub mod list_disputes; pub mod list_orders; +pub mod maintenance; pub mod new_order; pub mod orders_info; pub mod rate_user; @@ -30,6 +31,7 @@ use crate::cli::last_trade_index::{ }; use crate::cli::list_disputes::execute_list_disputes; use crate::cli::list_orders::execute_list_orders; +use crate::cli::maintenance::{execute_maintenance_status, execute_set_maintenance}; use crate::cli::new_order::execute_new_order; use crate::cli::orders_info::execute_orders_info; use crate::cli::rate_user::execute_rate_user; @@ -303,6 +305,21 @@ pub enum Commands { }, /// Requests open disputes from Mostro pubkey ListDisputes {}, + /// Enable/disable the daemon's maintenance (drain) mode over the admin + /// gRPC (only operator; needs MOSTRO_RPC_URL / MOSTRO_RPC_TOKEN, not + /// ADMIN_NSEC). While ON, new orders and takes are rejected; open trades + /// keep working so escrow can drain before a Lightning node migration. + AdmSetMaintenance { + /// true to enter maintenance mode, false to leave it + #[arg(short, long, action = clap::ArgAction::Set)] + enabled: bool, + /// Free-text reason stored with the flag (never published) + #[arg(short, long)] + reason: Option, + }, + /// Show the maintenance flag and what is still bound to the daemon's + /// Lightning node; poll until `drained = true` before switching nodes + AdmMaintenanceStatus {}, /// Add a new dispute's solver (only admin) AdmAddSolver { /// npubkey @@ -417,6 +434,13 @@ fn check_fiat_range(s: &str) -> Result<(i64, Option)> { pub async fn run() -> Result<()> { let cli = Cli::parse(); + // Daemon-local gRPC commands: no relays, keys or database involved. + if let Some(cmd) = &cli.command { + if let Some(result) = cmd.run_rpc().await { + return result; + } + } + let ctx = init_context(&cli).await?; if let Some(cmd) = &cli.command { @@ -566,6 +590,18 @@ fn is_admin_command(command: &Option) -> bool { } impl Commands { + /// Run a command that talks to `mostrod`'s admin gRPC directly. `None` + /// when the command is a Nostr one and needs a [`Context`]. + pub async fn run_rpc(&self) -> Option> { + match self { + Commands::AdmSetMaintenance { enabled, reason } => { + Some(execute_set_maintenance(*enabled, reason.clone()).await) + } + Commands::AdmMaintenanceStatus {} => Some(execute_maintenance_status().await), + _ => None, + } + } + pub async fn run(&self, ctx: &Context) -> Result<()> { match self { // Simple order message commands @@ -692,6 +728,9 @@ impl Commands { slash_buyer, } => execute_admin_cancel_dispute(order_id, *slash_seller, *slash_buyer, ctx).await, Commands::AdmTakeDispute { dispute_id } => execute_take_dispute(dispute_id, ctx).await, + Commands::AdmSetMaintenance { .. } | Commands::AdmMaintenanceStatus {} => { + unreachable!("handled by run_rpc before a Context is built") + } // Simple commands Commands::Restore {} => { diff --git a/src/cli/maintenance.rs b/src/cli/maintenance.rs new file mode 100644 index 0000000..63354cd --- /dev/null +++ b/src/cli/maintenance.rs @@ -0,0 +1,209 @@ +//! `admsetmaintenance` / `admmaintenancestatus`: drive `mostrod`'s +//! maintenance (drain) mode over the admin gRPC. These talk to the daemon +//! directly, not over Nostr, so they need neither relays nor `ADMIN_NSEC`. + +use crate::parser::common::{ + create_emoji_field_row, create_field_value_header, create_standard_table, +}; +use crate::rpc::{AdminRpcClient, GetMaintenanceStatusResponse, RpcConfig, RPC_URL_ENV}; +use anyhow::{anyhow, Result}; + +pub async fn execute_set_maintenance(enabled: bool, reason: Option) -> Result<()> { + let config = RpcConfig::from_env(); + println!("👑 Admin Set Maintenance Mode"); + println!("═══════════════════════════════════════"); + let mut table = create_standard_table(); + table.set_header(create_field_value_header()); + table.add_row(create_emoji_field_row("🔌 ", RPC_URL_ENV, &config.url)); + table.add_row(create_emoji_field_row( + "🛠️ ", + "Enabled", + if enabled { "true" } else { "false" }, + )); + if let Some(r) = &reason { + table.add_row(create_emoji_field_row("📝 ", "Reason", r)); + } + println!("{table}"); + + let mut client = AdminRpcClient::connect(&config).await?; + let resp = client.set_maintenance_mode(enabled, reason).await?; + if !resp.success { + return Err(anyhow!( + "daemon refused the change: {}", + resp.error_message.unwrap_or_else(|| "unknown error".into()) + )); + } + if enabled { + println!("✅ Maintenance mode is ON: new orders and takes are rejected; open trades keep working."); + println!("💡 Poll `mostro-cli admmaintenancestatus` until it reports drained = true."); + } else { + println!("✅ Maintenance mode is OFF: the order book is open again."); + } + Ok(()) +} + +pub async fn execute_maintenance_status() -> Result<()> { + let config = RpcConfig::from_env(); + let mut client = AdminRpcClient::connect(&config).await?; + let status = client.get_maintenance_status().await?; + print!("{}", render_status(&status)); + Ok(()) +} + +/// Pure rendering of the status, so it is testable without a daemon. +pub fn render_status(s: &GetMaintenanceStatusResponse) -> String { + let mut out = String::new(); + out.push_str("👑 Mostro Maintenance Status\n"); + out.push_str("═══════════════════════════════════════\n"); + let mut table = create_standard_table(); + table.set_header(create_field_value_header()); + table.add_row(create_emoji_field_row( + "🛠️ ", + "Maintenance mode", + if s.enabled { "ON" } else { "OFF" }, + )); + if let Some(r) = &s.reason { + table.add_row(create_emoji_field_row("📝 ", "Reason", r)); + } + if let Some(since) = s.since { + let when = chrono::DateTime::from_timestamp(since, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_else(|| since.to_string()); + table.add_row(create_emoji_field_row("⏱️ ", "Since", &when)); + } + let c = s.counters.clone().unwrap_or_default(); + table.add_row(create_emoji_field_row( + "🔒 ", + "Escrowed orders", + &c.escrowed_orders.to_string(), + )); + table.add_row(create_emoji_field_row( + "✈️ ", + "In-flight payouts", + &c.inflight_payouts.to_string(), + )); + table.add_row(create_emoji_field_row( + "💸 ", + "In-flight dev fees", + &c.inflight_dev_fees.to_string(), + )); + table.add_row(create_emoji_field_row( + "🪢 ", + "Open bonds", + &c.open_bonds.to_string(), + )); + table.add_row(create_emoji_field_row( + "🪢 ", + "Pending bond payouts", + &c.pending_bond_payouts.to_string(), + )); + table.add_row(create_emoji_field_row( + "📋 ", + "Pending orders (no escrow)", + &c.pending_orders.to_string(), + )); + table.add_row(create_emoji_field_row( + if s.drained { "✅ " } else { "⏳ " }, + "Drained", + if s.drained { "true" } else { "false" }, + )); + table.add_row(create_emoji_field_row( + "⚡ ", + "LN node pubkey", + &s.ln_node_pubkey, + )); + if let Some(stored) = &s.stored_ln_node_pubkey { + table.add_row(create_emoji_field_row( + "💾 ", + "Stored LN node pubkey", + stored, + )); + } + out.push_str(&format!("{table}\n")); + out.push_str(verdict(s)); + out.push('\n'); + out +} + +/// The operator-facing verdict. "Safe to switch" needs BOTH conditions: with +/// the book open a drained daemon can take on new node-bound escrow the +/// moment after the operator reads this line. +fn verdict(s: &GetMaintenanceStatusResponse) -> &'static str { + match (s.enabled, s.drained) { + (true, true) => { + "✅ Maintenance mode is ON and nothing is bound to the Lightning node: safe to stop mostrod and switch [lightning]." + } + (true, false) => { + "⏳ Escrow is still bound to the Lightning node; keep it online and poll again." + } + (false, true) => { + "⚠️ Nothing is bound right now, but the book is OPEN: new escrow can arrive at any moment. Run `admsetmaintenance --enabled true` first, then poll again." + } + (false, false) => { + "⚠️ Escrow is bound to the Lightning node and the book is OPEN. Run `admsetmaintenance --enabled true` to stop new escrow, then poll until drained." + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpc::DrainCounters; + + fn status(drained: bool) -> GetMaintenanceStatusResponse { + GetMaintenanceStatusResponse { + enabled: true, + reason: Some("ln migration".into()), + since: Some(1_700_000_000), + counters: Some(DrainCounters { + escrowed_orders: if drained { 0 } else { 2 }, + ..Default::default() + }), + drained, + ln_node_pubkey: "02aa".into(), + stored_ln_node_pubkey: Some("02bb".into()), + } + } + + #[test] + fn render_reports_drained_verdict_and_fields() { + let out = render_status(&status(false)); + assert!(out.contains("ON")); + assert!(out.contains("ln migration")); + assert!(out.contains("2023-11-14T22:13:20+00:00")); + assert!(out.contains("02aa") && out.contains("02bb")); + assert!(out.contains("keep it online")); + + let out = render_status(&status(true)); + assert!(out.contains("safe to stop mostrod")); + } + + /// "Safe to switch" must never be printed while the book is open. + #[test] + fn verdict_requires_maintenance_on_and_drained() { + let mut s = status(true); + assert!(verdict(&s).contains("safe to stop")); + s.enabled = false; + let v = verdict(&s); + assert!(!v.contains("safe to stop") && v.contains("OPEN") && v.contains("--enabled true")); + s.drained = false; + let v = verdict(&s); + assert!(!v.contains("safe to stop") && v.contains("--enabled true")); + s.enabled = true; + assert!(verdict(&s).contains("keep it online")); + let out = render_status(&GetMaintenanceStatusResponse::default()); + assert!( + !out.contains("safe to stop"), + "default (OFF, drained) is not safe" + ); + } + + #[test] + fn render_tolerates_missing_optionals() { + let out = render_status(&GetMaintenanceStatusResponse::default()); + assert!(out.contains("OFF")); + assert!(!out.contains("Reason")); + assert!(!out.contains("Since")); + assert!(!out.contains("Stored LN")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 14986a5..2cd2e76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ pub mod error; pub mod lightning; pub mod nip33; pub mod parser; +pub mod rpc; pub mod util; diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..c9d40a7 --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,410 @@ +//! Minimal client for `mostrod`'s admin gRPC (`proto/admin.proto` in the +//! daemon repo, service `mostro.admin.v1.AdminService`). +//! +//! Only the maintenance-mode calls are wired: everything else the admin +//! needs already travels over Nostr. The messages are hand-written `prost` +//! structs mirroring the proto (field numbers matter, names do not), so +//! building the CLI needs neither `protoc` nor a `build.rs`. +//! +//! Endpoint and credentials come from the environment: +//! - `MOSTRO_RPC_URL` (default `http://127.0.0.1:50051`) +//! - `MOSTRO_RPC_TOKEN` (optional; sent as `authorization: Bearer `, +//! required when the daemon sets `[rpc].auth_token`) + +use anyhow::{anyhow, Context as _, Result}; +use std::time::Duration; +use tonic::client::Grpc; +use tonic::codegen::http::uri::PathAndQuery; +use tonic::metadata::MetadataValue; +use tonic::transport::{Channel, Endpoint}; +use tonic::Request; + +pub const RPC_URL_ENV: &str = "MOSTRO_RPC_URL"; +pub const RPC_TOKEN_ENV: &str = "MOSTRO_RPC_TOKEN"; +pub const DEFAULT_RPC_URL: &str = "http://127.0.0.1:50051"; + +const SERVICE: &str = "mostro.admin.v1.AdminService"; +/// A black-holed `MOSTRO_RPC_URL` must fail fast, not hang until the OS gives up. +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// A server that accepts the connection but never answers. +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, PartialEq, prost::Message)] +pub struct SetMaintenanceModeRequest { + #[prost(bool, tag = "1")] + pub enabled: bool, + #[prost(string, optional, tag = "2")] + pub reason: Option, + #[prost(string, optional, tag = "3")] + pub request_id: Option, +} + +#[derive(Clone, PartialEq, prost::Message)] +pub struct SetMaintenanceModeResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, optional, tag = "2")] + pub error_message: Option, +} + +#[derive(Clone, PartialEq, prost::Message)] +pub struct GetMaintenanceStatusRequest { + #[prost(string, optional, tag = "1")] + pub request_id: Option, +} + +/// What is still bound to the daemon's connected Lightning node. +#[derive(Clone, PartialEq, prost::Message)] +pub struct DrainCounters { + #[prost(uint32, tag = "1")] + pub escrowed_orders: u32, + #[prost(uint32, tag = "2")] + pub inflight_payouts: u32, + #[prost(uint32, tag = "3")] + pub inflight_dev_fees: u32, + #[prost(uint32, tag = "4")] + pub open_bonds: u32, + #[prost(uint32, tag = "5")] + pub pending_bond_payouts: u32, + #[prost(uint32, tag = "6")] + pub pending_orders: u32, +} + +#[derive(Clone, PartialEq, prost::Message)] +pub struct GetMaintenanceStatusResponse { + #[prost(bool, tag = "1")] + pub enabled: bool, + #[prost(string, optional, tag = "2")] + pub reason: Option, + #[prost(int64, optional, tag = "3")] + pub since: Option, + #[prost(message, optional, tag = "4")] + pub counters: Option, + #[prost(bool, tag = "5")] + pub drained: bool, + #[prost(string, tag = "6")] + pub ln_node_pubkey: String, + #[prost(string, optional, tag = "7")] + pub stored_ln_node_pubkey: Option, +} + +/// Connection settings, resolved from the environment by [`RpcConfig::from_env`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RpcConfig { + pub url: String, + pub token: Option, +} + +impl RpcConfig { + pub fn from_env() -> Self { + Self::from_values( + std::env::var(RPC_URL_ENV).ok(), + std::env::var(RPC_TOKEN_ENV).ok(), + ) + } + + /// Pure resolution rule: blank values count as unset, the URL falls back + /// to [`DEFAULT_RPC_URL`], the token is trimmed. + pub fn from_values(url: Option, token: Option) -> Self { + let clean = |v: Option| v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + Self { + url: clean(url).unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()), + token: clean(token), + } + } +} + +/// Refuse to send a bearer token in cleartext anywhere but to the local +/// machine. `mostrod` itself only serves plaintext gRPC and only accepts +/// `SetMaintenanceMode` from loopback peers, so the supported shapes are a +/// loopback URL (directly or through an SSH tunnel) or `https://` via a +/// TLS-terminating proxy in front of the daemon. +pub fn check_token_transport(url: &str, has_token: bool) -> Result<()> { + if !has_token { + return Ok(()); + } + let uri: tonic::codegen::http::Uri = url + .parse() + .map_err(|e| anyhow!("invalid {RPC_URL_ENV}: {url}: {e}"))?; + let scheme = uri.scheme_str().unwrap_or("http"); + if scheme == "https" { + return Ok(()); + } + let host = uri.host().unwrap_or(""); + let is_loopback = host == "localhost" + || host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false); + if is_loopback { + Ok(()) + } else { + Err(anyhow!( + "refusing to send {RPC_TOKEN_ENV} in cleartext to {url}: use a loopback URL \ + (e.g. an SSH tunnel to the daemon host) or an https:// endpoint" + )) + } +} + +/// `authorization` header value for a configured token, or `None`. +pub fn bearer_header(token: Option<&str>) -> Result>> { + token + .map(|t| { + format!("Bearer {t}") + .parse() + .map_err(|_| anyhow!("{RPC_TOKEN_ENV} contains characters not allowed in a header")) + }) + .transpose() +} + +pub struct AdminRpcClient { + inner: Grpc, + auth: Option>, + url: String, +} + +impl AdminRpcClient { + pub async fn connect(config: &RpcConfig) -> Result { + Self::connect_with_timeouts(config, CONNECT_TIMEOUT, REQUEST_TIMEOUT).await + } + + pub async fn connect_with_timeouts( + config: &RpcConfig, + connect_timeout: Duration, + request_timeout: Duration, + ) -> Result { + check_token_transport(&config.url, config.token.is_some())?; + let endpoint = Endpoint::from_shared(config.url.clone()) + .with_context(|| format!("invalid {RPC_URL_ENV}: {}", config.url))? + .connect_timeout(connect_timeout) + .timeout(request_timeout); + let endpoint = if config.url.starts_with("https://") { + endpoint + .tls_config(tonic::transport::ClientTlsConfig::new().with_native_roots()) + .context("cannot configure TLS for the admin RPC")? + } else { + endpoint + }; + let channel = endpoint.connect().await.with_context(|| { + format!( + "cannot reach mostrod admin RPC at {} (connect timeout {}s)", + config.url, + connect_timeout.as_secs() + ) + })?; + Ok(Self { + inner: Grpc::new(channel), + auth: bearer_header(config.token.as_deref())?, + url: config.url.clone(), + }) + } + + async fn unary(&mut self, method: &'static str, body: Req) -> Result + where + Req: prost::Message + 'static, + Resp: prost::Message + Default + 'static, + { + self.inner + .ready() + .await + .with_context(|| format!("admin RPC at {} is not ready", self.url))?; + let mut request = Request::new(body); + if let Some(auth) = &self.auth { + request.metadata_mut().insert("authorization", auth.clone()); + } + let path = PathAndQuery::try_from(format!("/{SERVICE}/{method}")) + .map_err(|e| anyhow!("bad gRPC path for {method}: {e}"))?; + let codec = tonic_prost::ProstCodec::::default(); + let response = self + .inner + .unary(request, path, codec) + .await + .map_err(|status| describe_status(method, &status))?; + Ok(response.into_inner()) + } + + pub async fn set_maintenance_mode( + &mut self, + enabled: bool, + reason: Option, + ) -> Result { + self.unary( + "SetMaintenanceMode", + SetMaintenanceModeRequest { + enabled, + reason, + request_id: None, + }, + ) + .await + } + + pub async fn get_maintenance_status(&mut self) -> Result { + self.unary( + "GetMaintenanceStatus", + GetMaintenanceStatusRequest { request_id: None }, + ) + .await + } +} + +/// Turn a gRPC status into an operator-readable error, with a hint for the +/// two refusals the daemon documents. +pub fn describe_status(method: &str, status: &tonic::Status) -> anyhow::Error { + let hint = match status.code() { + tonic::Code::PermissionDenied => { + " (hint: SetMaintenanceMode is loopback-only; if the daemon sets [rpc].auth_token, \ + export MOSTRO_RPC_TOKEN)" + } + tonic::Code::Unimplemented => { + " (hint: the daemon predates maintenance mode; upgrade mostrod)" + } + _ => "", + }; + anyhow!( + "{method} failed: {} {}{hint}", + status.code(), + status.message() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use prost::Message; + + #[test] + fn config_defaults_and_trims() { + assert_eq!( + RpcConfig::from_values(None, None), + RpcConfig { + url: DEFAULT_RPC_URL.into(), + token: None + } + ); + assert_eq!( + RpcConfig::from_values(Some(" ".into()), Some("".into())), + RpcConfig { + url: DEFAULT_RPC_URL.into(), + token: None + }, + "blank values are unset" + ); + assert_eq!( + RpcConfig::from_values( + Some(" http://10.0.0.5:50051 ".into()), + Some(" s3cret\n".into()) + ), + RpcConfig { + url: "http://10.0.0.5:50051".into(), + token: Some("s3cret".into()) + } + ); + } + + #[test] + fn bearer_header_formats_and_validates() { + assert!(bearer_header(None).unwrap().is_none()); + let h = bearer_header(Some("s3cret")).unwrap().unwrap(); + assert_eq!(h.to_str().unwrap(), "Bearer s3cret"); + assert!(bearer_header(Some("bad\nvalue")).is_err()); + } + + /// Field numbers are the wire contract with `proto/admin.proto`; pin the + /// encoding so a renumbering here cannot silently talk past the daemon. + #[test] + fn set_request_encodes_with_proto_field_numbers() { + let bytes = SetMaintenanceModeRequest { + enabled: true, + reason: Some("x".into()), + request_id: None, + } + .encode_to_vec(); + // field 1 varint = 0x08 0x01; field 2 len-delimited = 0x12 0x01 'x' + assert_eq!(bytes, vec![0x08, 0x01, 0x12, 0x01, b'x']); + } + + #[test] + fn status_response_round_trips_with_nested_counters() { + let resp = GetMaintenanceStatusResponse { + enabled: true, + reason: Some("ln migration".into()), + since: Some(1_700_000_000), + counters: Some(DrainCounters { + escrowed_orders: 2, + inflight_payouts: 1, + inflight_dev_fees: 0, + open_bonds: 3, + pending_bond_payouts: 0, + pending_orders: 7, + }), + drained: false, + ln_node_pubkey: "02aa".into(), + stored_ln_node_pubkey: Some("02bb".into()), + }; + let decoded = + GetMaintenanceStatusResponse::decode(resp.encode_to_vec().as_slice()).unwrap(); + assert_eq!(decoded, resp); + // Tag 4 (counters) must be a length-delimited nested message. + assert!(resp.encode_to_vec().contains(&0x22)); + } + + #[test] + fn token_transport_rule() { + // No token: anything goes. + assert!(check_token_transport("http://10.0.0.5:50051", false).is_ok()); + // Token over cleartext to loopback (direct or tunnelled) is fine. + for url in [ + "http://127.0.0.1:50051", + "http://localhost:50051", + "http://[::1]:50051", + "http://127.5.5.5:50051", + ] { + assert!(check_token_transport(url, true).is_ok(), "{url}"); + } + // Token over TLS to anywhere is fine. + assert!(check_token_transport("https://mostro.example:443", true).is_ok()); + // Token over cleartext to a remote host is refused. + for url in ["http://10.0.0.5:50051", "http://mostro.example:50051"] { + let err = check_token_transport(url, true).unwrap_err(); + assert!(err.to_string().contains("cleartext"), "{url}: {err}"); + } + } + + /// A black-holed address must fail within the connect timeout, not hang. + #[tokio::test] + async fn connect_honours_the_connect_timeout() { + let config = RpcConfig::from_values(Some("http://10.255.255.1:50051".into()), None); + let started = std::time::Instant::now(); + let result = AdminRpcClient::connect_with_timeouts( + &config, + Duration::from_millis(300), + Duration::from_secs(1), + ) + .await; + assert!( + started.elapsed() < Duration::from_secs(5), + "took {:?}", + started.elapsed() + ); + match result { + Err(e) => assert!(e.to_string().contains("connect timeout"), "{e}"), + Ok(_) => panic!("a black-holed address must not connect"), + } + } + + #[test] + fn describe_status_adds_hints() { + let denied = describe_status( + "SetMaintenanceMode", + &tonic::Status::permission_denied("nope"), + ); + assert!(denied.to_string().contains("MOSTRO_RPC_TOKEN")); + let old = describe_status("GetMaintenanceStatus", &tonic::Status::unimplemented("")); + assert!(old.to_string().contains("upgrade mostrod")); + let other = describe_status("X", &tonic::Status::internal("boom")); + assert!(other.to_string().contains("boom") && !other.to_string().contains("hint")); + } +} diff --git a/tests/cli_functions.rs b/tests/cli_functions.rs index d846c21..1faaec4 100644 --- a/tests/cli_functions.rs +++ b/tests/cli_functions.rs @@ -302,3 +302,74 @@ fn test_last_trade_index_message() { assert!(inner.id.is_none()); assert!(inner.payload.is_none()); } + +// ── admin maintenance-mode commands (gRPC, no Nostr context) ────────────── +mod maintenance_commands { + use clap::Parser; + use mostro_client::cli::{Cli, Commands}; + + #[test] + fn admsetmaintenance_parses_explicit_bool_and_reason() { + let cli = Cli::try_parse_from([ + "mostro-cli", + "admsetmaintenance", + "--enabled", + "true", + "--reason", + "LN node migration", + ]) + .unwrap(); + match cli.command { + Some(Commands::AdmSetMaintenance { enabled, reason }) => { + assert!(enabled); + assert_eq!(reason.as_deref(), Some("LN node migration")); + } + _ => panic!("unexpected command"), + } + + let cli = Cli::try_parse_from(["mostro-cli", "admsetmaintenance", "-e", "false"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::AdmSetMaintenance { + enabled: false, + reason: None + }) + )); + } + + #[test] + fn admsetmaintenance_requires_the_enabled_value() { + assert!(Cli::try_parse_from(["mostro-cli", "admsetmaintenance"]).is_err()); + assert!(Cli::try_parse_from(["mostro-cli", "admsetmaintenance", "--enabled"]).is_err()); + assert!( + Cli::try_parse_from(["mostro-cli", "admsetmaintenance", "--enabled", "yes"]).is_err() + ); + } + + #[test] + fn admmaintenancestatus_parses_without_arguments() { + let cli = Cli::try_parse_from(["mostro-cli", "admmaintenancestatus"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::AdmMaintenanceStatus {}) + )); + } + + /// The gRPC commands must be dispatched before any Nostr context (relays, + /// mnemonic, ADMIN_NSEC) is built; Nostr commands must not be. + #[tokio::test] + async fn rpc_commands_are_routed_before_the_nostr_context() { + let cli = Cli::try_parse_from(["mostro-cli", "listdisputes"]).unwrap(); + assert!(cli.command.unwrap().run_rpc().await.is_none()); + + // Point at a closed port so the attempt fails fast instead of hanging. + std::env::set_var("MOSTRO_RPC_URL", "http://127.0.0.1:1"); + let cli = Cli::try_parse_from(["mostro-cli", "admmaintenancestatus"]).unwrap(); + let result = cli.command.unwrap().run_rpc().await; + let err = result.expect("rpc command must be handled").unwrap_err(); + assert!( + err.to_string().contains("cannot reach mostrod admin RPC"), + "{err}" + ); + } +}