Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 321 additions & 23 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -444,6 +446,23 @@ mostro-cli admsenddm -p <user-pubkey> -m "hi, I'm the solver assigned to your di
mostro-cli sendadmindmattach -p <user-pubkey> -o <order-id> -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.
Expand Down
39 changes: 39 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String>,
},
/// 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
Expand Down Expand Up @@ -417,6 +434,13 @@ fn check_fiat_range(s: &str) -> Result<(i64, Option<i64>)> {
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 {
Expand Down Expand Up @@ -566,6 +590,18 @@ fn is_admin_command(command: &Option<Commands>) -> 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<Result<()>> {
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
Expand Down Expand Up @@ -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 {} => {
Expand Down
209 changes: 209 additions & 0 deletions src/cli/maintenance.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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"));
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ pub mod error;
pub mod lightning;
pub mod nip33;
pub mod parser;
pub mod rpc;
pub mod util;
Loading
Loading