Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ dialoguer = "0.11"
dirs = "6.0.0"
dotenvy = "0.15.7"
clearscreen = "4.0.1"
tonic = "0.14.2"
tonic = { version = "0.14.2", features = ["tls-ring"] }
prost = "0.14.1"
tonic-prost = "0.14.1"
cdk = { version = "0.17.2", default-features = false, features = ["wallet"] }
Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,13 +655,25 @@ bitcoin_price_api_url = "https://api.yadio.io"
enabled = false # Set to true to enable gRPC admin interface
listen_address = "127.0.0.1"
port = 50051
allow_remote = false # Required to bind a non-loopback address
```

When enabled, exposes gRPC interface on `127.0.0.1:50051` for:
- Admin order cancellation
- Dispute settlement
- Solver management

Enabling it **requires** a bearer token in the `MOSTRO_RPC_TOKEN` environment
variable (or `~/.mostro/.env`); the daemon refuses to start without one, and
refuses a non-loopback bind unless `allow_remote = true`. Reaching this port is
equivalent to holding the operator key — see the security notes in
[docs/RPC.md](docs/RPC.md).

```bash
# ~/.mostro/.env
MOSTRO_RPC_TOKEN=<output of: openssl rand -base64 32>
```

---

#### Database
Expand Down Expand Up @@ -773,17 +785,20 @@ For client documentation, see the respective client repositories.

### Admin Operations (RPC Interface)

If RPC is enabled, use admin tools for dispute resolution:
If RPC is enabled, use admin tools for dispute resolution. Every call must carry
the bearer token; `-plaintext` is only safe over loopback:

```bash
AUTH="authorization: Bearer $MOSTRO_RPC_TOKEN"

# Cancel an order (admin override)
grpcurl -plaintext -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/CancelOrder
grpcurl -plaintext -H "$AUTH" -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/CancelOrder

# Settle disputed order
grpcurl -plaintext -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/SettleOrder
grpcurl -plaintext -H "$AUTH" -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/SettleOrder

# Add dispute solver
grpcurl -plaintext -d '{"solver_pubkey": "npub1..."}' localhost:50051 mostro.admin.v1.AdminService/AddSolver
grpcurl -plaintext -H "$AUTH" -d '{"solver_pubkey": "npub1..."}' localhost:50051 mostro.admin.v1.AdminService/AddSolver
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

---
Expand Down
5 changes: 3 additions & 2 deletions docs/ADMIN_RPC_AND_DISPUTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ Admin capabilities and dispute resolution paths.

## RPC Server
- Source: `src/rpc/server.rs`
- Enable: `settings.toml` → `[rpc] enabled = true`
- Enable: `settings.toml` → `[rpc] enabled = true`, plus a `MOSTRO_RPC_TOKEN` bearer token in the environment (startup is fatal without it).
- Binds `listen_address:port`; injects Keys, `Arc<Pool<Sqlite>>`, `Arc<Mutex<LndConnector>>`.
- Auth: `src/rpc/auth.rs` intercepts every method and rejects any request without the token. This is the *only* caller authorization on the RPC path — the handlers run as the daemon identity, which every downstream check treats as fully privileged.
- Uses `tonic`; see `docs/RPC.md` and `proto/admin.proto`.

## Dispute Lifecycle
Expand Down Expand Up @@ -41,7 +42,7 @@ sequenceDiagram
```

## Audit and Safety
- Require admin authentication/authorization at message level.
- Nostr admin messages are authorized at message level; the gRPC path is authorized at the transport instead (bearer token), because its synthesized events always carry the daemon identity.
- Enforce solver permission levels in the daemon: `read` solvers can assist but cannot execute `admin-settle` or `admin-cancel`.
- Record solver, timestamps, and decisions in DB for traceability.
- Avoid leaking sensitive data in logs; scrub invoices and keys.
83 changes: 72 additions & 11 deletions docs/RPC.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The RPC interface provides a direct communication method for admin operations, c

## Configuration

Add the following section to your `settings.toml` (keys are required; fields have Rust Default implementations but must be present):
Add the following section to your `settings.toml` (`enabled`, `listen_address` and `port` are required keys; fields have Rust Default implementations but must be present):

```toml
[rpc]
Expand All @@ -22,8 +22,25 @@ enabled = true
listen_address = "127.0.0.1"
# RPC server port (required key; default=50051)
port = 50051
# Optional: acknowledge a non-loopback bind (default=false)
allow_remote = false
# Optional: serve TLS. Both paths are required together.
# tls_cert_path = "/etc/mostro/rpc-cert.pem"
# tls_key_path = "/etc/mostro/rpc-key.pem"
```

The bearer token is **not** configured here. It is read from the `MOSTRO_RPC_TOKEN`
environment variable, which the daemon also picks up from `<settings_dir>/.env`:

```bash
# ~/.mostro/.env
MOSTRO_RPC_TOKEN=<output of: openssl rand -base64 32>
```

`settings.toml` is the file operators paste into bug reports, so it never holds
the credential. The daemon **refuses to start** when `enabled = true` and the
variable is unset or shorter than 32 characters.

## Available Admin Operations

The RPC interface supports the following admin operations:
Expand Down Expand Up @@ -124,45 +141,89 @@ service AdminService {
}
```

## Authentication

Every method, `GetVersion` included, requires an `authorization: Bearer <token>`
header carrying the value of `MOSTRO_RPC_TOKEN`. A missing, malformed or
incorrect token is answered with `UNAUTHENTICATED` before the handler runs, and
the token is compared in constant time so a caller learns nothing from how long
the rejection took.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

```bash
grpcurl -plaintext \
-H "authorization: Bearer $MOSTRO_RPC_TOKEN" \
-d '{"order_id": "550e8400-e29b-41d4-a716-446655440000"}' \
localhost:50051 mostro.admin.v1.AdminService/CancelOrder
```

## Client Implementation Example

Here's an example of how to create a gRPC client for the Mostro admin RPC:

```rust
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::Request;
use mostro::rpc::admin::{admin_service_client::AdminServiceClient, CancelOrderRequest};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let channel = Channel::from_static("http://127.0.0.1:50051")
.connect()
.await?;

let mut client = AdminServiceClient::new(channel);


let token: MetadataValue<_> =
format!("Bearer {}", std::env::var("MOSTRO_RPC_TOKEN")?).parse()?;

let mut client = AdminServiceClient::with_interceptor(channel, move |mut req: Request<()>| {
req.metadata_mut().insert("authorization", token.clone());
Ok(req)
});

let request = tonic::Request::new(CancelOrderRequest {
order_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
request_id: Some("12345".to_string()),
});

let response = client.cancel_order(request).await?;

if response.get_ref().success {
println!("Order cancelled successfully");
} else {
println!("Failed to cancel order: {:?}", response.get_ref().error_message);
}

Ok(())
}
```

## Security Considerations

- The RPC server listens on localhost by default for security
- Consider implementing authentication/authorization for production use
- The RPC interface provides the same admin capabilities as Nostr-based commands
- Only enable the RPC server in trusted environments
Treat reaching this port as equivalent to holding the Mostro operator key.

Every RPC is executed under the daemon's own Nostr identity, and the daemon
identity is fully privileged downstream: `ensure_dispute_finalize_permission`
waives its solver-category check for that key, and `admin_add_solver_action`
accepts it outright. The handlers apply no caller authorization of their own, so
the bearer token is the only thing between the network and a settled dispute.

- **Never expose this port beyond loopback without TLS.** The daemon refuses to
start on a non-loopback `listen_address` unless `allow_remote = true`, and
warns when such a bind runs without TLS. Over plaintext, anyone on the path
reads the bearer token and replays it.
- **The token is a credential, not a setting.** Keep it in `MOSTRO_RPC_TOKEN`
(environment or `<settings_dir>/.env`, which the wizard writes with
owner-only permissions), rotate it by restarting with a new value, and never
commit it to `settings.toml`.
- **Container and appliance images publish ports easily.** Wrappers such as
Start9 or Umbrel map container ports to the host or LAN. Verify the mapping
before enabling the RPC; binding `0.0.0.0` inside a container whose port is
published hands the admin API to every device on the network.
- **A compromised token is a compromised node.** An attacker who holds it can
settle disputed orders to their own invoice or grant themselves permanent
solver rights over Nostr, which survives a token rotation.
- The RPC interface provides the same admin capabilities as Nostr-based
commands, without the Nostr-side key requirement.

## Debugging

Expand Down
4 changes: 2 additions & 2 deletions docs/RPC_RATE_LIMITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ How the original issue’s ideas map to the codebase today:
| Per-IP rate limiting | **`check_rate_limit`** runs before the handler body. |
| Exponential backoff / lockout | Implemented inside **`RateLimiter`**; **not** triggered by **`ValidateDbPassword`** (no **`record_failure`**). |
| Audit logging | **tracing** in service + limiter. |
| Localhost-only | Default RPC bind **`127.0.0.1`** (see `settings.toml` / `docs/RPC.md`). |
| Strong auth | Out of scope for this stub; would need API keys or similar. |
| Localhost-only | Default RPC bind **`127.0.0.1`**, and a non-loopback bind now requires an explicit **`allow_remote = true`** (see `settings.toml` / `docs/RPC.md`). |
| Strong auth | Implemented in **`src/rpc/auth.rs`**: a **`MOSTRO_RPC_TOKEN`** bearer token, checked in constant time by a tonic interceptor on every method. The interceptor is deliberately **not** rate-limited — the token's entropy, not a counter, is what defeats guessing. |

## Testing

Expand Down
7 changes: 5 additions & 2 deletions docs/STARTUP_AND_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,14 @@ Configuration is loaded from `~/.mostro/settings.toml` (template: `settings.tpl.
- `picture` (Option\<String\>): URL to avatar image, recommended square max 128x128px (default: None)
- `website` (Option\<String\>): Operator website URL (default: None)

**RPC** (`src/config/types.rs:55-74`):
**RPC** (`RpcSettings` in `src/config/types.rs`):
- `enabled` (bool): Enable RPC server (Rust Default: false)
- `listen_address` (String): Bind address (Rust Default: "127.0.0.1")
- `port` (u16): Listen port (Rust Default: 50051)
- Note: These fields have a Rust Default implementation, but `settings.toml` must still include these keys. If a key is present but empty or omitted by tooling, the daemon falls back to the Rust Default value.
- `allow_remote` (bool): Acknowledge a non-loopback bind (Rust Default: false)
- `tls_cert_path` / `tls_key_path` (Option\<String\>): PEM material for TLS; required together (Rust Default: None)
- Note: `enabled`, `listen_address` and `port` have a Rust Default implementation, but `settings.toml` must still include these keys. If a key is present but empty or omitted by tooling, the daemon falls back to the Rust Default value. The remaining fields are optional.
- The bearer token lives in the `MOSTRO_RPC_TOKEN` environment variable, never in `settings.toml`. `validate_rpc_settings` (`src/config/util.rs`) makes startup fatal when `enabled = true` and the token is missing or under 32 characters, when a non-loopback address is bound without `allow_remote = true`, or when only one half of the TLS pair is configured. See `docs/RPC.md`.

## Global Variables

Expand Down
17 changes: 16 additions & 1 deletion settings.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,29 @@ fee_audit_days = 365
dm_days = 30

[rpc]
# Enable RPC server for direct admin communication
# Enable RPC server for direct admin communication.
#
# The admin RPC settles disputes, cancels orders and grants solver rights, and
# every call runs with the daemon's own privileges. Enabling it REQUIRES the
# MOSTRO_RPC_TOKEN environment variable (set it in the environment or in
# <settings_dir>/.env, never here); the daemon refuses to start otherwise.
# Generate one with: openssl rand -base64 32
enabled = false
# RPC server listen address
listen_address = "127.0.0.1"
# RPC server port
port = 50051
# Duration in seconds after which inactive rate-limiter entries are evicted
# rate_limiter_stale_duration = 3600
# Acknowledge binding to a non-loopback address. The daemon refuses to start on
# a routable address unless this is true, so the admin API is never published to
# a LAN by accident.
# allow_remote = false
# Serve the admin RPC over TLS. Both paths are required together; without them
# the bearer token crosses the network in cleartext, so configure them (or a
# TLS-terminating reverse proxy) whenever allow_remote is true.
# tls_cert_path = "/etc/mostro/rpc-cert.pem"
# tls_key_path = "/etc/mostro/rpc-key.pem"

# Multi-source price providers (see docs/PRICE_PROVIDERS.md).
# Absent section ≡ legacy single-source behaviour synthesised from
Expand Down
11 changes: 11 additions & 0 deletions src/config/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,14 @@ pub const ENV_FILENAME: &str = ".env";
/// Environment variable name used to override the Nostr private key from the
/// process environment. Shared between the wizard and the loader.
pub const NSEC_ENV_VAR: &str = "MOSTRO_NSEC_PRIVKEY";

/// Environment variable holding the shared bearer token that authenticates
/// admin gRPC callers. Deliberately env-only (like `NSEC_ENV_VAR`): the token
/// never lives in `settings.toml`, only in the process environment or
/// `<settings_dir>/.env`.
pub const RPC_TOKEN_ENV_VAR: &str = "MOSTRO_RPC_TOKEN";

/// Minimum accepted length for `MOSTRO_RPC_TOKEN`. 32 characters is the
/// shortest base64 encoding of 24 random bytes, well past the point where
/// online guessing against a single daemon is meaningful.
pub const MIN_RPC_TOKEN_LEN: usize = 32;
32 changes: 24 additions & 8 deletions src/config/secret.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Helpers for loading and parsing the Mostro Nostr private key with
//! zeroization of transient buffers.

use crate::config::constants::NSEC_ENV_VAR;
use crate::config::constants::{NSEC_ENV_VAR, RPC_TOKEN_ENV_VAR};
use crate::config::types::NostrSettings;
use mostro_core::error::MostroError::{self, *};
use mostro_core::error::ServiceError;
Expand All @@ -18,20 +18,36 @@ where
serializer.serialize_str(secret.expose_secret())
}

/// Read `MOSTRO_NSEC_PRIVKEY` from the process environment, trim whitespace,
/// and wrap in a [`SecretString`]. Returns `None` when unset or blank.
pub fn read_nsec_env_var() -> Option<SecretString> {
let mut nsec_from_env = std::env::var(NSEC_ENV_VAR).ok()?;
let trimmed = nsec_from_env.trim();
/// Read `name` from the process environment, trim whitespace, and wrap in a
/// [`SecretString`], zeroizing the transient buffer. Returns `None` when the
/// variable is unset or blank.
fn read_secret_env_var(name: &str) -> Option<SecretString> {
let mut value_from_env = std::env::var(name).ok()?;
let trimmed = value_from_env.trim();
if trimmed.is_empty() {
nsec_from_env.zeroize();
value_from_env.zeroize();
return None;
}
let secret = SecretString::from(trimmed.to_owned());
nsec_from_env.zeroize();
value_from_env.zeroize();
Some(secret)
}

/// Read `MOSTRO_NSEC_PRIVKEY` from the process environment, trim whitespace,
/// and wrap in a [`SecretString`]. Returns `None` when unset or blank.
pub fn read_nsec_env_var() -> Option<SecretString> {
read_secret_env_var(NSEC_ENV_VAR)
}

/// Read `MOSTRO_RPC_TOKEN` from the process environment, trim whitespace, and
/// wrap in a [`SecretString`]. Returns `None` when unset or blank.
///
/// The admin RPC bearer token is env-only by design: `settings.toml` is the
/// file operators paste into issues when asking for help.
pub fn read_rpc_token_env_var() -> Option<SecretString> {
read_secret_env_var(RPC_TOKEN_ENV_VAR)
}

/// Parse a bech32 nsec into [`Keys`], exposing the secret only in this scope.
pub fn parse_mostro_keys(secret: &SecretString) -> Result<Keys, MostroError> {
let nsec = secret.expose_secret();
Expand Down
28 changes: 28 additions & 0 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,18 @@ pub struct RpcSettings {
/// Duration in seconds after which inactive rate-limiter entries are evicted
#[serde(default = "default_rate_limiter_stale_duration")]
pub rate_limiter_stale_duration: u64,
/// Acknowledge binding the admin RPC to a non-loopback address. The daemon
/// refuses to start on a routable address unless this is set, so an
/// operator cannot publish the admin surface to a LAN by accident.
#[serde(default)]
pub allow_remote: bool,
/// Path to the PEM-encoded TLS certificate chain served by the admin RPC.
/// Must be set together with `tls_key_path`; absent means plaintext.
#[serde(default)]
pub tls_cert_path: Option<String>,
/// Path to the PEM-encoded TLS private key matching `tls_cert_path`.
#[serde(default)]
pub tls_key_path: Option<String>,
}

fn default_rate_limiter_stale_duration() -> u64 {
Expand All @@ -481,6 +493,22 @@ impl Default for RpcSettings {
listen_address: "127.0.0.1".to_string(),
port: 50051,
rate_limiter_stale_duration: default_rate_limiter_stale_duration(),
allow_remote: false,
tls_cert_path: None,
tls_key_path: None,
}
}
}

impl RpcSettings {
/// TLS certificate/key pair when both are configured.
///
/// `validate_rpc_settings` rejects a half-configured pair at startup, so a
/// `None` here means plaintext was chosen, never that one path was lost.
pub fn tls_paths(&self) -> Option<(&str, &str)> {
match (self.tls_cert_path.as_deref(), self.tls_key_path.as_deref()) {
(Some(cert), Some(key)) => Some((cert, key)),
_ => None,
}
}
}
Expand Down
Loading