Files
smirk-backend-core/src/lib.rs
T
jwinterm a80fa46daf feat(config): configurable restart_apply_mode + graceful shutdown (Stage 3b)
- console section: restart_apply_mode (manual | auto), an operator setting (runtime-safe,
  DB-overridable via /admin/config), env RESTART_APPLY_MODE default manual.
- Graceful shutdown: main serve now awaits SIGTERM/Ctrl-C OR an in-app shutdown signal
  (AppState.shutdown Notify) via with_graceful_shutdown, draining in-flight requests.
- PUT /admin/config in `auto` mode self-restarts shortly after responding to apply a
  restart-required change (systemd Restart=always brings it back with the new config);
  `manual` just flags restart_pending. The console warns before an auto save.

Stage 3 complete: operator config is readable + editable through the real validated,
audited, precedence-based layer; runtime-safe edits apply live, infra edits restart.
cargo all-targets clean, lib tests pass.
2026-07-18 14:08:33 -04:00

216 lines
9.5 KiB
Rust

//! smirk-backend-core
//!
//! Open, self-hostable backend for the Smirk non-custodial multi-chain wallet:
//! authentication, per-chain wallet access (Bitcoin, Litecoin, Monero, Wownero,
//! Grin), the Grin slatepack relay, and Nostr-based identity.
//!
//! The HTTP contract is generated from the handlers (`utoipa`) into
//! `openapi.json`, which is the single source of truth for the API and the
//! wallet's generated TypeScript client.
pub mod api;
pub mod config;
pub mod config_overlay;
pub mod core;
pub mod error;
pub mod infra;
pub mod models;
pub mod tips;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::DefaultBodyLimit;
use axum::http::{header, HeaderValue, Method};
use axum::{routing::get, Router};
use tokio::sync::RwLock;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::timeout::TimeoutLayer;
use tower_http::trace::TraceLayer;
use crate::config::Config;
use crate::core::admin_session::AdminSessionManager;
use crate::core::session::{SessionManager, WebChallenge};
use crate::infra::chains::ChainClients;
use crate::infra::db::Database;
use crate::infra::prices::PriceSnapshot;
/// Global request-body cap (backstop; handlers validate tighter per field).
const MAX_BODY_BYTES: usize = 1024 * 1024;
/// Global request deadline (backstop above the chain clients' own ~30s timeouts,
/// so their errors surface first for chain calls; bounds any slow handler).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
// Per-IP rate-limit tiers (one token replenished per period; `burst` = bucket).
// STRICT throttles the unauthenticated, abuse-prone surface (auth + website);
// NORMAL covers the authenticated wallet/identity API. Sane defaults; operator-
// tunable config can follow.
const STRICT_PERIOD_MS: u64 = 500; // ~2 req/s sustained
const STRICT_BURST: u32 = 20;
const NORMAL_PERIOD_MS: u64 = 100; // ~10 req/s sustained
const NORMAL_BURST: u32 = 60;
/// Shared application state injected into handlers via `State<Arc<AppState>>`.
#[derive(Clone)]
pub struct AppState {
/// Effective config = env-derived base + validated DB overlay. Hot-swappable:
/// a runtime-safe operator edit stores a new `Arc<Config>` here (lock-free).
/// Read it via [`AppState::cfg`] (`state.cfg().field`), never a stale copy.
pub config: Arc<arc_swap::ArcSwap<Config>>,
/// Immutable env-only config (no overlay). Used to recompute the effective
/// config on a settings change and to report a field's source (default/env/db).
pub config_base: Arc<Config>,
pub db: Database,
pub sessions: SessionManager,
/// Per-chain data-source clients (present only for enabled chains).
pub chains: ChainClients,
/// Payment processor backing the optional pay-to-register gate; `Some` only
/// when `REGISTRATION_REQUIRE_PAYMENT` is on.
pub payment: Option<Arc<dyn crate::infra::payment::PaymentProvider>>,
/// Optional first-party Nostr relay (messaging plane); `Some` only when
/// `RELAY_ENABLED` is on. Advertised via `/capabilities` + NIP-05 hints.
pub relay: Option<Arc<dyn crate::infra::relay::RelayProvider>>,
/// In-memory website-auth challenges, keyed by nonce. Single-node store;
/// a shared/stateless variant is the load-balanced-fleet path.
pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>,
/// Latest fetched fiat prices, refreshed by a background task when the price
/// feed is enabled. Stays empty (and `/prices` 404s) otherwise.
pub prices: Arc<RwLock<PriceSnapshot>>,
/// Admin token minter/verifier. `Some` only when the admin surface is
/// enabled; the admin plane and guard refuse all requests otherwise.
pub admin_sessions: Option<AdminSessionManager>,
/// Signalled to trigger a graceful shutdown from INSIDE the app — e.g. an operator
/// config change in `auto` restart mode. `main` awaits this alongside SIGTERM;
/// systemd `Restart=always` brings the instance back with the new config applied.
pub shutdown: Arc<tokio::sync::Notify>,
}
impl AppState {
/// Load the current effective [`Config`] (lock-free, wait-free). The returned
/// guard derefs to `&Config`, so `state.cfg().field` reads the live value and
/// `&state.cfg()` passes to a `fn(&Config)` helper. Every config read goes
/// through here so a runtime-safe operator edit is seen on the next request.
#[inline]
pub fn cfg(&self) -> arc_swap::Guard<std::sync::Arc<Config>> {
self.config.load()
}
}
/// Assemble the full application router with every route mounted and state
/// applied. Shared by `main` and the integration-test harness so both exercise
/// the exact same wiring: `/health` and the NIP-05 directory at the root, the
/// authenticated wallet/identity API nested under `/api/v1`.
pub fn build_router(state: Arc<AppState>) -> Router {
let cors = cors_layer(&state.cfg());
// Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves
// with `into_make_service_with_connect_info`; the test harness injects it).
// Tiered: stricter on the unauthenticated auth/website surface (closes the
// website-challenge growth vector), looser on the wallet/identity API.
let mut strict_cfg = GovernorConfigBuilder::default();
strict_cfg
.per_millisecond(STRICT_PERIOD_MS)
.burst_size(STRICT_BURST);
let strict = GovernorLayer {
config: Arc::new(strict_cfg.finish().expect("valid strict governor config")),
};
let mut normal_cfg = GovernorConfigBuilder::default();
normal_cfg
.per_millisecond(NORMAL_PERIOD_MS)
.burst_size(NORMAL_BURST);
let normal = GovernorLayer {
config: Arc::new(normal_cfg.finish().expect("valid normal governor config")),
};
let api_v1 = api::auth::routes()
.merge(api::website::routes())
.layer(strict)
.merge(
api::users::routes()
.merge(api::wallet::routes())
.merge(api::capabilities::routes())
.merge(api::prices::routes())
.merge(api::landing::routes())
.merge(api::erasure::routes())
.merge(api::premium::routes())
.merge(api::tips::routes())
.layer(normal),
);
// The NIP-05 directory (+ health / landing) is PUBLIC, read-only, unauth'd
// data that MUST be cross-origin fetchable for federation to work — any Nostr
// client on any domain resolves `user@this-instance` by fetching the
// well-known. So it ALWAYS serves `Access-Control-Allow-Origin: *`, regardless
// of the operator's API CORS restriction (`CORS_ALLOWED_ORIGINS`). We apply the
// two policies to two route groups rather than one outer layer, so a restrictive
// API policy can't silently break NIP-05 resolution.
let public_cors = CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(Any);
let public = Router::new()
.route("/health", get(api::health::health))
.route("/", get(api::landing::root))
.merge(api::nip05::routes())
.layer(public_cors);
Router::new()
.merge(public)
.nest("/api/v1", api_v1.layer(cors))
.with_state(state)
// Layers apply outside-in (last added = outermost). Body cap + timeout
// sit closest to the handlers; compression wraps them; tracing is
// outermost so it observes every request (incl. rejected ones). CORS is
// applied PER GROUP above (public → `*`, API → operator policy).
// Per-IP rate limiting is layered separately (it needs ConnectInfo).
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.layer(TimeoutLayer::new(REQUEST_TIMEOUT))
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
}
/// Assemble the admin-plane router. Served on a SEPARATE loopback socket (see
/// `main`), never merged into the public router: confidentiality is by socket,
/// not middleware ordering. Deliberately carries NO CORS layer (a browser's
/// cross-origin call to the loopback port gets no `Access-Control-Allow-Origin`
/// and is blocked); per-route `admin_guard` is the load-bearing control.
/// Subsystem 2 adds Host-allowlist + anti-clickjacking hardening on top.
pub fn admin_router(state: Arc<AppState>) -> Router {
Router::new()
.merge(api::admin::routes())
// Host allowlist (anti DNS-rebinding) + anti-clickjacking headers.
.layer(axum::middleware::from_fn_with_state(
state.clone(),
api::admin::admin_plane_guard,
))
.with_state(state)
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.layer(TimeoutLayer::new(REQUEST_TIMEOUT))
.layer(TraceLayer::new_for_http())
}
/// Build the CORS layer. With no configured origins, allow any origin — safe
/// because authentication is a Bearer token (no cookies), so a cross-origin
/// request carries no ambient credentials. Credentials are never allowed.
fn cors_layer(config: &Config) -> CorsLayer {
let methods = [Method::GET, Method::POST, Method::OPTIONS];
let headers = [header::AUTHORIZATION, header::CONTENT_TYPE];
let base = CorsLayer::new()
.allow_methods(methods)
.allow_headers(headers);
if config.cors_allowed_origins.is_empty() {
base.allow_origin(Any)
} else {
let origins: Vec<HeaderValue> = config
.cors_allowed_origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
base.allow_origin(origins)
}
}