Skip to content
Open
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
2 changes: 2 additions & 0 deletions Cargo.lock

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

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ 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"] }
secp256k1 = { version = "0.30", features = ["serde"] }
secrecy = { version = "0.10", features = ["serde"] }
subtle = "2.6"
zeroize = "1.8"

[dev-dependencies]
Expand Down
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,15 +653,27 @@ bitcoin_price_api_url = "https://api.yadio.io"
```toml
[rpc]
enabled = false # Set to true to enable gRPC admin interface
listen_address = "127.0.0.1"
listen_address = "127.0.0.1" # IP literal, IPv6 bracketed; "localhost" is not resolved
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,19 +785,26 @@ 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.
```

The shell expands `$AUTH` into `grpcurl`'s arguments, so any local user can read
the token with `ps`. On a host you do not have to yourself, use the Rust client
in [docs/RPC.md](docs/RPC.md) instead, which keeps the token in the environment.

---

### Querying Audit Events
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.
103 changes: 92 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,32 @@ 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"
```

`listen_address` must be an IP literal, with IPv6 bracketed: `127.0.0.1`,
`[::1]` or `0.0.0.0`. Hostnames are never resolved, so `localhost` and
unbracketed `::1` are refused at startup rather than accepted and then failed on
at bind time: `fn validate_rpc_settings` in `src/config/util.rs` and `fn bind`
for `RpcServer` in `src/rpc/server.rs` both resolve the address through
`fn listen_socket_addr` in `src/rpc/server.rs`.

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 +148,102 @@ service AdminService {
}
```

## Authentication

Every method, `GetVersion` included, requires an `authorization: Bearer <token>`
header carrying the value of `MOSTRO_RPC_TOKEN`. The scheme is matched
case-insensitively per RFC 7235; the token itself must match exactly. A missing,
malformed or incorrect token is answered with `UNAUTHENTICATED` before the
handler runs, and the token comparison itself is constant-time
(`fn credentials_match` in `src/rpc/auth.rs`), so it does not leak how many
bytes matched. Total request latency is not claimed to be constant: header
parsing, logging and transport all contribute to it.

The token must be printable ASCII with no spaces, since it is sent verbatim as
an HTTP header. The daemon rejects anything else at startup.

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

> **Shared hosts:** the shell expands `$MOSTRO_RPC_TOKEN` into `grpcurl`'s
> arguments, where any local user can read it with `ps`. On a host with other
> users, drive the API from the Rust client below instead, which keeps the token
> in the process environment.

## 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: `fn ensure_dispute_finalize_permission`
in `src/db.rs` waives its solver-category check for that key, and
`fn admin_add_solver_action` in `src/app/admin_add_solver.rs` accepts it
outright. The handlers apply no caller authorization of their own, so the bearer
token (`fn call` for `BearerAuth` in `src/rpc/auth.rs`) 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 by **`fn call`** for **`BearerAuth`** in **`src/rpc/auth.rs`**: a **`MOSTRO_RPC_TOKEN`** bearer token, compared in constant time (**`fn credentials_match`**, same file) on every method. The interceptor is deliberately **not** rate-limited — the token's entropy, not a counter, is what defeats guessing. Because a rejected request never reaches the limiter, its *log line* is throttled instead (**`fn should_warn`**, same file): the first rejection from a peer within 60s is logged at `warn!` and the rest at `debug!`, so an unauthenticated flood cannot fill the disk. |

## Testing

Expand Down
9 changes: 6 additions & 3 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")
- `listen_address` (String): Bind address, as an IP literal with IPv6 bracketed - `127.0.0.1`, `[::1]`, `0.0.0.0`. Hostnames such as `localhost` are not resolved (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` are required in `settings.toml`. `RpcSettings` has a Rust `Default` implementation, but neither those fields nor `rpc` on `Settings` carry `#[serde(default)]`, so an omitted key makes `toml::from_str` (`fn init_configuration_file` in `src/config/util.rs`) fail with a missing-field error rather than fall back, and an empty value is preserved as-is only for the string-typed `listen_address`; an empty value for `enabled` or `port` fails deserialization against their `bool` and `u16` types. 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 `listen_address` is not an address the server can bind, 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
21 changes: 19 additions & 2 deletions settings.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,31 @@ 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
# RPC server listen address. Must be an IP literal, with IPv6 bracketed:
# "127.0.0.1", "[::1]" or "0.0.0.0". Hostnames such as "localhost" are not
# resolved and the daemon refuses to start on one.
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
Loading