diff --git a/changelog.d/rest-electrum.md b/changelog.d/rest-electrum.md new file mode 100644 index 000000000..93dd7f627 --- /dev/null +++ b/changelog.d/rest-electrum.md @@ -0,0 +1,11 @@ +Security + +- `/rest/` on the RPC listener is off unless `--rest` or `rest=` is set. + It uses its own queue, and the body is read before that permit is taken. +- A silent-payment subscribe scans at most the recent 256-block window, + including when the client passes a start height. The scan stops when + the client hangs up. +- RPC waits are capped at two minutes, the listener accepts at most 256 + connections, and a long-poll does not hold a work-queue slot. +- API logs strip `xprv` / `tprv` material and silent-payment scan secrets. +- Findings write-ups: 063, 064, 065, 066. diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index 6c61bdc57..49f014f03 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -17,7 +17,7 @@ use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::sync::{broadcast, Notify, Semaphore}; use tokio::task::JoinHandle; @@ -570,6 +570,7 @@ where let params_v = req.get("params").cloned().unwrap_or(json!([])); if method == "blockchain.silentpayments.subscribe" { serve_sp_subscribe( + &mut reader, &mut writer, &query, ¶ms, @@ -738,7 +739,8 @@ where /// Tweaks stream: JSON-RPC result = first height, then one notify per /// following height, then `{"message":"done"}`. Honor `count` through tip. /// Answer `server.ping` while computing. -async fn serve_sp_subscribe( +async fn serve_sp_subscribe( + reader: &mut R, writer: &mut W, query: &Arc, chain: &Arc, @@ -748,6 +750,7 @@ async fn serve_sp_subscribe( conn: &mut ElectrumConn, ) -> Result<(), std::io::Error> where + R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin, { let tip = query.tip_height().map(|h| h.0); @@ -782,8 +785,13 @@ where write_line(writer, &rpc_result(&id, &result, None)).await?; conn.sp_scan_busy = true; let last = tip.unwrap_or(sub.start); - let hits = scan_sp_off_connection(query, chain, &sub, last).await; + let scanned = scan_sp_off_connection(reader, query, chain, &sub, last).await; + let hits = scanned.hits; conn.sp_scan_busy = false; + // The client left before any chunk. A history line would write into a closed socket. + if scanned.chunks == 0 && sub.start <= last { + return Ok(()); + } let note = json!({ "jsonrpc": "2.0", "method": "blockchain.silentpayments.subscribe", @@ -901,16 +909,41 @@ fn sp_scan_ranges(start: u32, last: u32) -> Vec<(u32, u32)> { ranges } -async fn scan_sp_off_connection( +struct SpScan { + hits: Vec, + /// Chunks scanned before the client hung up. + chunks: usize, +} + +/// One poll. `fill_buf` does not consume: an empty ready buffer is EOF, +/// and pending means the client is still connected. A zero timeout can +/// poll twice, so this does not use one. +fn peer_hung_up(reader: &mut R) -> bool { + let waker = std::task::Waker::noop(); + let mut cx = std::task::Context::from_waker(waker); + let mut fut = std::pin::pin!(reader.fill_buf()); + match std::future::Future::poll(fut.as_mut(), &mut cx) { + std::task::Poll::Ready(Ok(buf)) => buf.is_empty(), + std::task::Poll::Ready(Err(_)) => true, + std::task::Poll::Pending => false, + } +} + +async fn scan_sp_off_connection( + reader: &mut R, query: &Arc, chain: &Arc, sub: &crate::silent_scan::SpSub, last: u32, -) -> Vec { +) -> SpScan { use crate::silent_scan::SP_SCAN_PERMITS; static PERMITS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(SP_SCAN_PERMITS); let mut hits = Vec::new(); + let mut chunks = 0usize; for (from, end) in sp_scan_ranges(sub.start, last) { + if peer_hung_up(reader) { + break; + } let Ok(permit) = PERMITS.acquire().await else { break; }; @@ -925,8 +958,9 @@ async fn scan_sp_off_connection( .unwrap_or_default(); drop(permit); hits.extend(chunk); + chunks += 1; } - hits + SpScan { hits, chunks } } #[allow(clippy::too_many_arguments)] // matches handle_client call-site diff --git a/crates/rbitcoin-electrum/src/server_tests.rs b/crates/rbitcoin-electrum/src/server_tests.rs index b92f95213..59bed26cc 100644 --- a/crates/rbitcoin-electrum/src/server_tests.rs +++ b/crates/rbitcoin-electrum/src/server_tests.rs @@ -1498,4 +1498,62 @@ fn sp_scan_ranges_cover_one_height_and_the_next_chunk() { assert_eq!(super::sp_scan_ranges(0, 1), vec![(0, 1)]); } +#[tokio::test(flavor = "current_thread")] +async fn sp_scan_stops_when_the_client_hangs_up() { + use std::pin::Pin; + use std::task::{Context, Poll}; + use tokio::io::{AsyncBufRead, AsyncRead, ReadBuf}; + + struct HangUp { + polls: std::cell::Cell, + } + impl AsyncRead for HangUp { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Pending + } + } + impl AsyncBufRead for HangUp { + fn poll_fill_buf( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + let n = self.polls.get(); + self.polls.set(n + 1); + if n == 0 { + Poll::Pending + } else { + Poll::Ready(Ok(&[])) + } + } + fn consume(self: Pin<&mut Self>, _amt: usize) {} + } + + let (_dir, q) = tmp_store(); + let q = std::sync::Arc::new(q); + let chain = std::sync::Arc::new(ChainParams::regtest()); + let scan = "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c"; + let spend = "025cc9856d6f8375350e123978daac200c260cb5b5ae83106cab90484dcd8fcf36"; + let sub = crate::silent_scan::parse_sub( + &json!([scan, spend, 0]), + bitcoin::Network::Regtest, + Some(crate::silent_scan::SP_SCAN_CHUNK), + ) + .unwrap(); + let last = crate::silent_scan::SP_SCAN_CHUNK; + let ranges = super::sp_scan_ranges(sub.start, last); + assert_eq!(ranges.len(), 2, "the fixture spans two chunks"); + let mut reader = HangUp { + polls: std::cell::Cell::new(0), + }; + let scanned = super::scan_sp_off_connection(&mut reader, &q, &chain, &sub, last).await; + assert_eq!( + scanned.chunks, 1, + "a hang-up before the next chunk stops the scan" + ); +} + include!("electrum_sh_journey.rs"); diff --git a/crates/rbitcoin-electrum/src/silent_scan.rs b/crates/rbitcoin-electrum/src/silent_scan.rs index d1379bc36..46ba394be 100644 --- a/crates/rbitcoin-electrum/src/silent_scan.rs +++ b/crates/rbitcoin-electrum/src/silent_scan.rs @@ -66,11 +66,10 @@ pub fn parse_sub(params: &Value, network: Network, tip: Option) -> Result start.max(tip_h.saturating_sub(SP_HISTORY_WINDOW)), + None => start, }; let address = encode_sp_address(network, &scan, &spend); Ok(SpSub { @@ -198,6 +197,9 @@ mod tests { assert_eq!(null_start.start, 0); let bounded = parse_sub(&json!([scan, spend, 0]), Network::Regtest, Some(1_000)).unwrap(); assert_eq!(bounded.start, 1_000 - SP_HISTORY_WINDOW); + let wide = parse_sub(&json!([scan, spend, 1]), Network::Regtest, Some(10_000)).unwrap(); + assert_eq!(wide.start, 10_000 - SP_HISTORY_WINDOW); + assert!(10_000 - wide.start <= SP_HISTORY_WINDOW); let bad_spend = match parse_sub(&json!([scan, "02"]), Network::Regtest, Some(0)) { Err(e) => e, Ok(_) => panic!("spend"), diff --git a/crates/rbitcoin-log/src/api_log.rs b/crates/rbitcoin-log/src/api_log.rs index d3577b9ca..93b9cda29 100644 --- a/crates/rbitcoin-log/src/api_log.rs +++ b/crates/rbitcoin-log/src/api_log.rs @@ -54,8 +54,8 @@ fn compact_params(params: &str) -> String { /// Record one Electrum / Esplora / RPC call. /// -/// `params` should already be compact (see [`compact_params`]). `err` is -/// `None` on success. +/// Extended private keys and silent-payment scan secrets are stripped here, +/// then the params blob is compacted. `err` is `None` on success. pub fn api_call( surface: &str, peer: &str, @@ -64,7 +64,7 @@ pub fn api_call( wall_ms: u64, err: Option<&str>, ) { - let params = compact_params(params); + let params = compact_params(&redact_secrets(method, params)); match err { None => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} ok"), Some(e) => trace!("api: {surface} peer={peer} {method} {params} wall_ms={wall_ms} err={e}"), @@ -91,6 +91,75 @@ pub fn api_call( let _ = file.flush(); } +fn redact_secrets(method: &str, params: &str) -> String { + let out = redact_ext_privkeys(params); + if method.contains("silentpayment") { + redact_quoted_hex64(&out) + } else { + out + } +} + +fn is_base58(b: u8) -> bool { + matches!( + b, + b'1'..=b'9' | b'A'..=b'H' | b'J'..=b'N' | b'P'..=b'Z' | b'a'..=b'k' | b'm'..=b'z' + ) +} + +fn is_hex(b: u8) -> bool { + b.is_ascii_hexdigit() +} + +/// `xprv` / `tprv` / `yprv` / `zprv` plus the following base58 key material. +fn redact_ext_privkeys(s: &str) -> String { + let b = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < b.len() { + if i + 4 <= b.len() { + let tag = &b[i..i + 4]; + if matches!(tag, b"xprv" | b"tprv" | b"yprv" | b"zprv") { + let mut j = i + 4; + while j < b.len() && is_base58(b[j]) { + j += 1; + } + if j > i + 4 { + out.push_str(""); + i = j; + continue; + } + } + } + let ch = s[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +/// A quoted 64-hex string is a silent-payment scan secret on that method. +fn redact_quoted_hex64(s: &str) -> String { + let b = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'"' + && i + 65 < b.len() + && b[i + 65] == b'"' + && b[i + 1..i + 65].iter().copied().all(is_hex) + { + out.push_str("\"\""); + i += 66; + continue; + } + let ch = s[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { @@ -189,4 +258,54 @@ mod tests { fn json_escape_quotes() { assert_eq!(json_escape("a\"b\\c"), "a\\\"b\\\\c"); } + + #[test] + fn api_call_redacts_scan_secrets_and_ext_privkeys() { + let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let scan = "ab".repeat(32); + let xprv = format!("xprv{}", "1".repeat(40)); + let tprv = format!("tprv{}", "A".repeat(20)); + let path = std::env::temp_dir().join(format!( + "rbitcoin-api-redact-{}-{}.jsonl", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_file(&path); + init_api_log(&path).unwrap(); + crate::capture_logs(true); + api_call( + "electrum", + "127.0.0.1:1", + "blockchain.silentpayments.unsubscribe", + &format!("[\"{scan}\",\"02ff\",1]"), + 4, + None, + ); + api_call( + "rpc", + "-", + "scantxoutset", + &format!("[\"start\",[\"{xprv}\",\"{tprv}\"]]"), + 5, + None, + ); + let logs = crate::take_logs(); + crate::capture_logs(false); + close_api_log(); + let body = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let trace = logs + .iter() + .map(|(_, msg)| msg.clone()) + .collect::>() + .join("\n"); + let all = format!("{body}\n{trace}"); + assert!(!all.contains(&scan), "{all}"); + assert!(!all.contains(&xprv), "{all}"); + assert!(!all.contains(&tprv), "{all}"); + assert!(all.contains(""), "{all}"); + } } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 9f54760fb..04bc55247 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -304,7 +304,7 @@ fn operator_usage() -> String { [--i2p-sam [HOST:PORT]] [--i2p-accept-incoming] \\\n\ [--electrum-listen ADDR] [--esplora-listen ADDR] [--esplora-onion[=0|1]] [--health-listen [ADDR]] [--metrics] \\\n\ [--sh-index] [--block-filter-index] [--prune-seqsigwit] [--prune-seqsigwit-ram-threshold-bytes N] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--electrum-max-subs N] [--esplora-block-template] \\\n\ - [--rpc] [--rpc-listen [ADDR]] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\ + [--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\ [--milestone HEIGHT] \\\n\ [--max-outbound N] [--max-inbound N] \\\n\ [--mempool-size-mb N] [--mempool-expiry HOURS] \\\n\ @@ -357,7 +357,7 @@ Silent payments: --sp-tweaks (default off) writes/serves the thin BIP-352 tweak Health: --health-listen [ADDR] serves GET /healthz from the first second of startup\n\ and GET /readyz (default 127.0.0.1:9332). Unauthenticated; keep it on loopback or a\n\ probe-only network. --metrics adds Prometheus GET /metrics there (needs --health-listen).\n\ -RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser.\n\ +RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ Cold files: --datadir-cold PATH puts Class A seqsigwit.body/idx under PATH/store (HDD).\n\ Default (flag omitted): hot and cold files both live under --datadir.\n\ Conf: --conf FILE (snake_case key=value; CLI kebab overrides conf). See OPERATOR.md and docs/rpc.md.\n\ @@ -413,6 +413,7 @@ fn is_bool_key(key: &str) -> bool { | "i2p_accept_incoming" | "inhibit_suspend" | "metrics" + | "rest" | "trusted" | "always_relay" | "relay" @@ -625,6 +626,7 @@ mod tests { "--esplora-onion", "--rpc", "--rpc-listen", + "--rest", "--rpc-socket", "--rpc-token-file", "--rpc-cookie-file", diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index a6c8d91a3..75ca0c54e 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -252,6 +252,8 @@ pub struct RpcOpts { /// Opt-in Core-format `username:password` cookie accepted as TCP HTTP Basic. pub cookie_file: Option, pub work_queue: Option, + /// Core REST on the RPC listener. Off unless `--rest` / `rest=`. + pub rest: bool, } impl Default for RpcOpts { @@ -264,6 +266,7 @@ impl Default for RpcOpts { token_file: None, cookie_file: None, work_queue: Some(rbitcoin_rpc::DEFAULT_RPC_WORK_QUEUE), + rest: false, } } } @@ -617,6 +620,9 @@ impl NodeConfig { if self.metrics && self.listen.health.is_none() { return Err(NodeError::Config("--metrics needs --health-listen".into())); } + if self.rpc.rest && self.rpc.listen.is_none() && !self.rpc.socket { + return Err(NodeError::Config("--rest needs --rpc or --rpc-listen".into())); + } self.validate_only_net()?; self.validate_hidden_inbound()?; self.validate_rpc_cookie() @@ -1325,6 +1331,10 @@ impl NodeConfig { self.metrics = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf metrics: {e}")))?; } + "rest" => { + self.rpc.rest = parse_conf_bool(val) + .map_err(|e| NodeError::Config(format!("conf rest: {e}")))?; + } "inhibit_suspend" => { self.inhibit_suspend = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf inhibit_suspend: {e}")))?; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 7348742fc..073014b95 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -876,6 +876,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { token_path: Some(config.rpc_token_path()), cookie_path: config.rpc_cookie_path(), work_queue: config.rpc.work_queue, + rest: config.rpc.rest, subversion: Some( rbitcoin_primitives::rbitcoin_subversion( env!("CARGO_PKG_VERSION"), diff --git a/crates/rbitcoin-rpc/src/methods/chain.rs b/crates/rbitcoin-rpc/src/methods/chain.rs index 1c8f58e76..15457f6b7 100644 --- a/crates/rbitcoin-rpc/src/methods/chain.rs +++ b/crates/rbitcoin-rpc/src/methods/chain.rs @@ -1111,7 +1111,8 @@ pub(crate) fn getchaintips(ctx: &RpcContext, params: &RpcParams) -> Result Result { - Ok(params.opt_u64(idx, name)?.unwrap_or(30_000)) + let ms = params.opt_u64(idx, name)?.unwrap_or(30_000); + Ok(ms.min(super::RPC_WAIT_TIMEOUT_MS)) } pub(crate) fn tip_hash_height(ctx: &RpcContext) -> Result<(String, u32), Value> { @@ -1312,3 +1313,18 @@ mod core_double_tests { assert_eq!(format_core_double(132757073449487.52), "132757073449487.5"); } } + +#[cfg(test)] +mod wait_tests { + use super::*; + + #[test] + fn wait_timeout_ms_caps_at_two_minutes() { + let huge = RpcParams::positional(vec![json!(500_000)]); + assert_eq!(wait_timeout_ms(&huge, 0, "timeout").unwrap(), 120_000); + let absent = RpcParams::positional(vec![]); + assert_eq!(wait_timeout_ms(&absent, 0, "timeout").unwrap(), 30_000); + let short = RpcParams::positional(vec![json!(50)]); + assert_eq!(wait_timeout_ms(&short, 0, "timeout").unwrap(), 50); + } +} diff --git a/crates/rbitcoin-rpc/src/methods/mod.rs b/crates/rbitcoin-rpc/src/methods/mod.rs index f5d3dd532..d931ff30d 100644 --- a/crates/rbitcoin-rpc/src/methods/mod.rs +++ b/crates/rbitcoin-rpc/src/methods/mod.rs @@ -15,6 +15,9 @@ use std::time::Instant; pub use chain::difficulty_rpc_f64; pub(crate) use chain::{tip_hash_height, wait_timeout_ms}; + +/// Cap for `waitfor*` and `getblocktemplate` long-poll. Not a knob. +pub(crate) const RPC_WAIT_TIMEOUT_MS: u64 = 120_000; pub(crate) use mine::gbt_longpoll_id; pub use mine::{gbt_template, submit_received_block}; pub(crate) use rest::{dispatch_rest, RestReply}; diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index 416c0f1d9..d1cae03c9 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -11,11 +11,15 @@ use axum::middleware::{from_fn_with_state, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; +use tower::limit::ConcurrencyLimitLayer; +use tower_http::timeout::TimeoutLayer; /// Axum's default request-body cap, named so auth and 413 share one limit. pub const RPC_MAX_HTTP_BODY: usize = 2 * 1024 * 1024; /// Core `-rpcworkqueue`. Omitted or `0` is this finite queue. pub const DEFAULT_RPC_WORK_QUEUE: usize = 16; +/// Accept cap, copied from the Electrum public listener. Not a knob. +const RPC_MAX_CONNECTIONS: usize = 256; use rbitcoin_log::info; use rbitcoin_net::{BlockingRegion, MempoolHub}; use rbitcoin_primitives::Network; @@ -55,6 +59,8 @@ pub struct RpcConfig { pub subversion: Option, /// HTTP occupancy cap. `None` and `0` are [`DEFAULT_RPC_WORK_QUEUE`]. pub work_queue: Option, + /// `GET`/`POST /rest/` on this listener. Off unless `--rest` / `rest=`. + pub rest: bool, /// `--alert-notify` (`%s` = warning text). pub alert_notify: Option, } @@ -91,6 +97,9 @@ struct AppState { auth: RpcAuth, cookie: Option, work_queue: Arc, + /// Separate from [`Self::work_queue`]. REST must not take an RPC slot. + rest_queue: Arc, + rest: bool, require_auth: bool, } @@ -156,6 +165,7 @@ pub async fn run_rpc( let n = work_queue_permits(config.work_queue); let work_queue = Arc::new(tokio::sync::Semaphore::new(n)); + let rest_queue = Arc::new(tokio::sync::Semaphore::new(DEFAULT_RPC_WORK_QUEUE)); let shutdown = Arc::new(AtomicBool::new(false)); let mut tasks = Vec::new(); let mut local_addr = None; @@ -173,6 +183,8 @@ pub async fn run_rpc( auth: auth.clone(), cookie: cookie.clone(), work_queue: work_queue.clone(), + rest_queue: rest_queue.clone(), + rest: config.rest, require_auth: true, }; let app = rpc_app(state); @@ -207,6 +219,8 @@ pub async fn run_rpc( auth: auth.clone(), cookie, work_queue, + rest_queue, + rest: config.rest, require_auth: false, }; let app = rpc_app(state); @@ -261,11 +275,26 @@ fn rpc_app(state: AppState) -> Router { .route("/rest/{*path}", get(rest_entry).post(rest_entry)) .layer(DefaultBodyLimit::max(RPC_MAX_HTTP_BODY)) .layer(from_fn_with_state(state.clone(), reject_unauthorized)) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + std::time::Duration::from_millis(crate::methods::RPC_WAIT_TIMEOUT_MS), + )) + .layer(ConcurrencyLimitLayer::new(RPC_MAX_CONNECTIONS)) .with_state(state) } async fn rest_entry(State(state): State, req: axum::extract::Request) -> Response { - let _permit = match state.work_queue.try_acquire() { + if !state.rest { + return StatusCode::NOT_FOUND.into_response(); + } + // Body first, then the REST queue. A slow client must not hold an RPC slot, + // and REST does not use the RPC work queue. + let path = req.uri().path().to_string(); + let query = req.uri().query().unwrap_or("").to_string(); + let body = axum::body::to_bytes(req.into_body(), RPC_MAX_HTTP_BODY) + .await + .unwrap_or_default(); + let _permit = match state.rest_queue.try_acquire() { Ok(p) => p, Err(_) => { return ( @@ -275,11 +304,6 @@ async fn rest_entry(State(state): State, req: axum::extract::Request) .into_response(); } }; - let path = req.uri().path().to_string(); - let query = req.uri().query().unwrap_or("").to_string(); - let body = axum::body::to_bytes(req.into_body(), RPC_MAX_HTTP_BODY) - .await - .unwrap_or_default(); let ctx = Arc::clone(&state.ctx); let reply = tokio::task::spawn_blocking(move || { let _g = BlockingRegion::enter(); @@ -358,7 +382,12 @@ async fn satisfy_http_wait( rbitcoin_log::info!("ThreadRPCServer method=getblocktemplate"); let _active = crate::methods::ActiveCall::enter(&ctx.active, method); let ctx = Arc::clone(ctx); + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_millis(crate::methods::RPC_WAIT_TIMEOUT_MS); loop { + if ctx.stop.load(Ordering::SeqCst) || tokio::time::Instant::now() >= deadline { + return true; + } let ready = { let ctx = Arc::clone(&ctx); let want = want.clone(); @@ -368,11 +397,14 @@ async fn satisfy_http_wait( .await .unwrap_or(true) }; - if ready { + if ready || tokio::time::Instant::now() >= deadline { return true; } + let slice = deadline + .saturating_duration_since(tokio::time::Instant::now()) + .min(std::time::Duration::from_millis(50)); tokio::select! { - _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {} + _ = tokio::time::sleep(slice) => {} _ = recv_tip(&mut tips) => {} } } @@ -460,6 +492,15 @@ async fn recv_tip(tips: &mut Option, body: Bytes) -> Response { + let parsed: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => { + return parse_error_response(); + } + }; + let ctx = Arc::clone(&state.ctx); + // The long-poll must not sit on the only work-queue slot. + let waited = satisfy_http_wait(&ctx, &parsed).await; let _permit = match state.work_queue.try_acquire() { Ok(p) => p, Err(_) => { @@ -470,14 +511,6 @@ async fn rpc_post(State(state): State, body: Bytes) -> Response { .into_response(); } }; - let parsed: serde_json::Value = match serde_json::from_slice(&body) { - Ok(v) => v, - Err(_) => { - return parse_error_response(); - } - }; - let ctx = Arc::clone(&state.ctx); - let waited = satisfy_http_wait(&ctx, &parsed).await; let joined = tokio::task::spawn_blocking(move || { let _g = BlockingRegion::enter(); if waited { @@ -803,6 +836,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: true, alert_notify: None, }; @@ -888,6 +922,39 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[tokio::test] + async fn rest_is_404_without_the_flag() { + let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-rest-off").expect("temp dir"); + let q = Arc::new(Query::open_or_create_tiny(dir.join("store")).unwrap()); + let cfg = RpcConfig { + listen: Some("127.0.0.1:0".parse().unwrap()), + socket_path: None, + socket_shared: false, + datadir: dir.path().to_path_buf(), + network: Network::Regtest, + token_path: None, + cookie_path: None, + subversion: None, + work_queue: None, + rest: false, + alert_notify: None, + }; + let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut rest = tokio::net::TcpStream::connect(tcp_addr(&handle)) + .await + .unwrap(); + let get = b"GET /rest/chaininfo.json HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"; + rest.write_all(get).await.unwrap(); + let mut buf = Vec::new(); + rest.read_to_end(&mut buf).await.unwrap(); + let text = String::from_utf8_lossy(&buf); + assert!(text.contains("404"), "REST is off unless --rest: {text}"); + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + async fn post_raw( addr: SocketAddr, auth: &RpcAuth, @@ -937,6 +1004,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; @@ -1018,6 +1086,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); @@ -1058,6 +1127,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); @@ -1124,6 +1194,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) @@ -1262,6 +1333,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) @@ -1309,6 +1381,68 @@ mod tests { }); } + #[tokio::test] + async fn long_poll_does_not_hold_the_work_queue() { + let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-wait-queue").expect("dir"); + let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); + let hub = rbitcoin_net::ChainHub::new( + q, + rbitcoin_consensus::ChainParams::regtest(), + rbitcoin_consensus::Milestone::NONE, + ); + hub.ensure_genesis().unwrap(); + let query = Arc::clone(&hub.query); + let cfg = RpcConfig { + listen: Some("127.0.0.1:0".parse().unwrap()), + socket_path: None, + socket_shared: false, + datadir: dir.path().to_path_buf(), + network: Network::Regtest, + token_path: None, + cookie_path: None, + subversion: None, + work_queue: Some(1), + rest: false, + alert_notify: None, + }; + let handle = run_rpc(cfg, query, None, None, None, Some(Arc::new(hub)), None) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + let addr = tcp_addr(&handle); + let auth = handle.auth.clone(); + let waiter = tokio::spawn(async move { + let body = serde_json::json!({ + "jsonrpc": "1.0", + "id": "w", + "method": "waitfornewblock", + "params": [5_000] + }) + .to_string(); + let _ = post_raw(addr, &auth, body.as_bytes()).await; + }); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + let (st, body) = tokio::time::timeout( + std::time::Duration::from_secs(2), + post_raw( + tcp_addr(&handle), + &handle.auth, + br#"{"jsonrpc":"1.0","id":1,"method":"getblockcount"}"#, + ), + ) + .await + .expect("getblockcount while a long-poll is in flight"); + assert_eq!(st, 200, "{body:?}"); + assert_eq!(body.expect("json")["result"], 0); + assert!( + !waiter.is_finished(), + "waitfornewblock returned before the queue probe" + ); + waiter.abort(); + handle.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn rpc_work_queue_exceeded() { let dir = rbitcoin_store::testutil::TempDir::labeled("rpc-wq").expect("temp dir"); @@ -1325,6 +1459,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: Some(1), + rest: false, alert_notify: None, }; @@ -1407,6 +1542,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None) @@ -1577,6 +1713,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc( @@ -1617,6 +1754,7 @@ mod tests { cookie_path: Some(cookie_path.clone()), subversion: None, work_queue: None, + rest: false, alert_notify: None, }; // The cookie is TCP-only: a socket-only listener must not silently ignore it. @@ -1765,6 +1903,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let err = run_rpc(cfg, q, None, None, None, None, None) @@ -1792,6 +1931,7 @@ mod tests { cookie_path: None, subversion: None, work_queue: None, + rest: false, alert_notify: None, }; let handle = run_rpc(cfg, q, None, None, None, None, None).await.unwrap(); diff --git a/docs/external_findings/052-livera-review-index.md b/docs/external_findings/052-livera-review-index.md index ad6ed73e2..d7a8567a1 100644 --- a/docs/external_findings/052-livera-review-index.md +++ b/docs/external_findings/052-livera-review-index.md @@ -11,13 +11,13 @@ steps. | H1 | high | Decoy and invalid-type packets skip the rate window | fixed | `decoy_packet_is_handed_to_the_rate_hook` ([055](./055-decoy-rate.md)) | | H2 | high | Inbound eviction drops the longest-connected peer | fixed | `eviction_drops_the_newest_in_the_largest_netgroup` ([060](./060-evict-newest-netgroup.md)) | | H2-ban | high | Misbehavior disconnect is not remembered | fixed | `misbehavior_disconnect_refuses_the_same_address` ([061](./061-misbehavior-remembered.md)) | -| H3 | high | REST always on and shares the RPC work queue | open | — | -| H4 | medium | Silent-payment unsubscribe logs the scan secret | open | — | +| H3 | high | REST always on and shares the RPC work queue | fixed | `rest_is_404_without_the_flag` ([063](./063-rest-own-queue.md)) | +| H4 | medium | Silent-payment unsubscribe logs the scan secret | fixed | `api_call_redacts_scan_secrets_and_ext_privkeys` ([064](./064-api-log-redaction.md)) | | H5 | high | IBD reader credits unsolicited data as progress | fixed | `unsolicited_block_does_not_refresh_progress` ([062](./062-ibd-requested-progress.md)) | | N1 | medium | Inv getdata does not charge the send budget | fixed | `inv_getdata_charges_send_budget` ([056](./056-inv-getdata-budget.md)) | | M1 | medium | Block getdata can queue past the send budget | fixed | `getdata_stops_when_send_budget_is_already_over` ([057](./057-block-getdata-budget.md)) | -| M2 | medium | Silent-payment scan span is unbounded when start is set | open | — | -| M3 | medium | RPC listener has no accept timeout; long-poll holds a permit | open | — | +| M2 | medium | Silent-payment scan span is unbounded when start is set | fixed | `parse_sub_labels_start_and_networks`, `sp_scan_stops_when_the_client_hangs_up` ([065](./065-sp-scan-window.md)) | +| M3 | medium | RPC listener has no accept timeout; long-poll holds a permit | fixed | `long_poll_does_not_hold_the_work_queue`, `wait_timeout_ms_caps_at_two_minutes` ([066](./066-rpc-wait-cap.md)) | | M4 | medium | fuse8 segment length need not be a power of two | open | — | | M5 | medium | Class A bulk read ignores the published end | open | — | | M6 | medium | Testnet milestone is height-only | open | — | diff --git a/docs/external_findings/063-rest-own-queue.md b/docs/external_findings/063-rest-own-queue.md new file mode 100644 index 000000000..5dbf4b842 --- /dev/null +++ b/docs/external_findings/063-rest-own-queue.md @@ -0,0 +1,9 @@ +# 063 — REST is off unless --rest is set + +**Severity:** high +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (H3) + +REST on the RPC listener was always on, shared the RPC work queue, and took a permit before the body was read. REST is off unless `--rest` or `rest=` is set, uses its own queue of the same depth, and reads the body before taking a permit. A full REST queue is HTTP 503. NixOS `services.rbitcoin.rpc.rest` defaults to false and passes `--rest` only when RPC and that option are on. + +**Regression:** `rbitcoin-rpc` `rest_is_404_without_the_flag`. diff --git a/docs/external_findings/064-api-log-redaction.md b/docs/external_findings/064-api-log-redaction.md new file mode 100644 index 000000000..e200d2147 --- /dev/null +++ b/docs/external_findings/064-api-log-redaction.md @@ -0,0 +1,9 @@ +# 064 — API logs redact scan secrets and extended keys + +**Severity:** medium +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (H4) + +Silent-payment unsubscribe and generic RPC `api_call` wrote the scan secret and extended private keys to the API log. Those values are redacted before the line is written. The method name stays. + +**Regression:** `rbitcoin-rpc` `api_call_redacts_scan_secrets_and_ext_privkeys`. diff --git a/docs/external_findings/065-sp-scan-window.md b/docs/external_findings/065-sp-scan-window.md new file mode 100644 index 000000000..34937c5fa --- /dev/null +++ b/docs/external_findings/065-sp-scan-window.md @@ -0,0 +1,9 @@ +# 065 — Silent-payment scan stays inside 256 blocks + +**Severity:** medium +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (M2) + +A silent-payment scan whose start height was not zero walked from there to the tip and kept running after the client disconnected. Every scan is at most the existing 256-height window at the tip, including a nonzero start, and it stops when the connection ends. The scan uses the tweak index. It does not require `--sh-index`. + +**Regression:** `rbitcoin-electrum` `parse_sub_labels_start_and_networks`, `sp_scan_stops_when_the_client_hangs_up`. diff --git a/docs/external_findings/066-rpc-wait-cap.md b/docs/external_findings/066-rpc-wait-cap.md new file mode 100644 index 000000000..3efef8adf --- /dev/null +++ b/docs/external_findings/066-rpc-wait-cap.md @@ -0,0 +1,9 @@ +# 066 — RPC accept times out and long-polls release the permit + +**Severity:** medium +**Status:** fixed +**Found by:** Stephan Livera, 2026-10-02 (M3) + +The RPC listener had no accept timeout, and a long-poll held a work-queue permit for the whole wait. Accepts time out at two minutes, the listener caps connections, and the long-poll takes a permit only after the wait. + +**Regression:** `rbitcoin-rpc` `long_poll_does_not_hold_the_work_queue`, `wait_timeout_ms_caps_at_two_minutes`. diff --git a/docs/external_findings/README.md b/docs/external_findings/README.md index 8b1d6609d..30a77349f 100644 --- a/docs/external_findings/README.md +++ b/docs/external_findings/README.md @@ -67,6 +67,10 @@ rbitcoin reference, or redteam static analysis). Numbered reports live beside th | [060](./060-evict-newest-netgroup.md) | high | Evict the newest inbound in the largest netgroup | fixed | `eviction_drops_the_newest_in_the_largest_netgroup` | | [061](./061-misbehavior-remembered.md) | high | Misbehavior disconnect is remembered in memory | fixed | `misbehavior_disconnect_refuses_the_same_address` | | [062](./062-ibd-requested-progress.md) | high | Only a requested block moves the IBD stall clock | fixed | `unsolicited_block_does_not_refresh_progress` | +| [063](./063-rest-own-queue.md) | high | REST is off unless --rest is set | fixed | `rest_is_404_without_the_flag` | +| [064](./064-api-log-redaction.md) | medium | API logs redact scan secrets and extended keys | fixed | `api_call_redacts_scan_secrets_and_ext_privkeys` | +| [065](./065-sp-scan-window.md) | medium | Silent-payment scan stays inside 256 blocks | fixed | `parse_sub_labels_start_and_networks` | +| [066](./066-rpc-wait-cap.md) | medium | RPC accept times out and long-polls release the permit | fixed | `long_poll_does_not_hold_the_work_queue` | **012–021:** fuzzamoto differential report (`rbitcoin-report.tar.gz`, baseline `8f3990f`). Report-local 001–010 are **renumbered** here. Identity/BIP30 diff --git a/docs/lightning.md b/docs/lightning.md index 5bc2613b2..09bb5d22b 100644 --- a/docs/lightning.md +++ b/docs/lightning.md @@ -15,7 +15,7 @@ Fee math: [`mempool-fee-estimation.md`](./mempool-fee-estimation.md). | **CLN** stock `bcli` | `bitcoin-cli` → Core RPC | Methods exist on unix `{datadir}/rpc.sock`. Wrapper: [`scripts/lightning/bitcoin-cli`](../scripts/lightning/bitcoin-cli) (`-datadir=` → `--datadir`). Cookie/TCP `rpcauth` is not the product listen. | | **ldk-node Esplora** | `--esplora-listen` REST | Tip, `/tx/*` (raw/status/outspend/merkleblock-proof), `/fee-estimates`, `POST /tx` work **without** `--sh-index`. Address/scripthash: 503 `scripthash index disabled`. | | **ldk-node Electrum** | `--electrum-listen` TCP | Headers, `transaction.get` / broadcast, `estimatefee` work **without** `--sh-index`. `blockchain.scripthash.*`: JSON-RPC `scripthash index disabled`. TLS is reverse-proxy only (**Q-63**). | -| **ldk-node bitcoind REST** | `--rpc-listen` `GET /rest/block/` | Block bytes, headers, and hash-by-height. TCP `/rest/` is unauthenticated. The BDK wallet still needs Esplora or Electrum with `--sh-index`. | +| **ldk-node bitcoind REST** | `--rpc-listen --rest` `GET /rest/block/` | Block bytes, headers, and hash-by-height. TCP `/rest/` is unauthenticated and off unless `--rest` is set. The BDK wallet still needs Esplora or Electrum with `--sh-index`. | | **LND** | bitcoind + ZMQ or BIP157 | ZMQ stays out. Optional `--block-filter-index` serves BIP158 basic for sealed heights and advertises `NODE_COMPACT_FILTERS` once filters first reach the tip. | `--sh-index` is **not** required to start Electrum/Esplora or for channel @@ -67,8 +67,8 @@ Wrapper [`scripts/lightning/bitcoin-cli`](../scripts/lightning/bitcoin-cli) talk ## LDK / ldk-node ldk-node chain sources: Esplora, Electrum, bitcoind RPC/REST. Esplora and -Electrum are above. Bitcoind REST is `GET /rest/…` on `--rpc-listen` (same -port as JSON-RPC). The BDK wallet on that REST source still needs an +Electrum are above. Bitcoind REST is `GET /rest/…` on `--rpc-listen` when +`--rest` is also set (same port as JSON-RPC). The BDK wallet on that REST source still needs an address index, so point BDK at Esplora or Electrum with `--sh-index`. ### Esplora (`EsploraSyncClient` + BDK) diff --git a/docs/operator/interfaces.md b/docs/operator/interfaces.md index a6a71e5ef..f9b8e5865 100644 --- a/docs/operator/interfaces.md +++ b/docs/operator/interfaces.md @@ -267,7 +267,7 @@ client in rbitcoin's group can connect without traversing the `0700` datadir. (mainnet 8332, testnet 18332, signet 38332, regtest 18443). TCP auth is `Authorization: Bearer` from `{datadir}/rpc.token` (0600), plus optional Core cookie HTTP Basic from `--rpc-cookie-file PATH`. The same -listeners serve Core REST: `GET /rest/chaininfo.json`, block, headers, tx, +listeners serve Core REST when `--rest` is set: `GET /rest/chaininfo.json`, block, headers, tx, mempool, `getutxos`, `deploymentinfo`, and `blockfilter/basic` for heights `--block-filter-index` has sealed. TCP `/rest/` is unauthenticated (Core). `{datadir}/rpc.sock` stays mode 0600 with no HTTP header. See diff --git a/docs/operator/operations.md b/docs/operator/operations.md index 84afb1d1d..99ed1c234 100644 --- a/docs/operator/operations.md +++ b/docs/operator/operations.md @@ -106,6 +106,7 @@ Clean smoke: | `--esplora-block-template` | `esplora_block_template=` | **off** — `GET /block-template` is 404; on = GBT JSON (same as RPC template mode) | | `--rpc` | `rpc=` | **off** — unix JSON-RPC `{datadir}/rpc.sock` (mode 0600) | | `--rpc-listen [ADDR]` | `rpc_listen=` | disabled — implies `--rpc`; omit ADDR → `127.0.0.1` and Core-matching RPC port | +| `--rest` | `rest=` | **off** — unauthenticated `/rest/` on the RPC listener. Own queue. Needs `--rpc` or `--rpc-listen` | | `--rpc-token-file PATH` | `rpc_token_file=` | `{datadir}/rpc.token` (CSPRNG hex; TCP Bearer) | | `--rpc-cookie-file PATH` | `rpc_cookie_file=` | disabled — existing Core `username:password` cookie (no trailing newline) enables TCP HTTP Basic alongside Bearer | | `--rpc-work-queue N` | `rpc_work_queue=` | **16** in-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (array batches still run). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | diff --git a/docs/rpc.md b/docs/rpc.md index 569bd956e..1c4c88b34 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -57,11 +57,12 @@ later Core RPC is present, including descriptor-wallet calls from 0.21 | `--sh-index` | **off** | Class B scripthash (Electrum/Esplora only; RPC by height/hash/txid does not need it) | | `--block-filter-index` | **off** | BIP158 basic. IBD seals them when the flag is on from the start. A later enable still materializes after catch-up. `NODE_COMPACT_FILTERS` is advertised once filters first reach the tip (`getnetworkinfo` lists `COMPACT_FILTERS`), then for the life of the process. `getblockfilter` and `/rest/blockfilter/` serve heights the watermark already covers. Independent of `--sh-index` | | `--rpc-work-queue N` | **16** | In-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (a JSON-RPC array is still one slot). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | +| `--rest` / conf `rest=` | **off** | Unauthenticated `/rest/` on the RPC listener. Own queue, same depth as `--rpc-work-queue`'s default. The body is read before that permit is taken. Without the flag those paths are 404. | TLS is external (reverse proxy). Unix socket needs no HTTP header. TCP is Bearer-authenticated (`{datadir}/rpc.token`) and, only when `--rpc-cookie-file` is configured, also accepts Core cookie HTTP Basic. `GET /rest/…` is on the same -binds. TCP `/rest/` skips Bearer, matching Core. Routes: `chaininfo.json`, +binds only when `--rest` is set. TCP `/rest/` skips Bearer, matching Core. Routes: `chaininfo.json`, `blockhashbyheight/.`, `headers//.*`, `block/.*`, `block/notxdetails/.*`, `tx/.*` (chain and mempool), `mempool/info.json`, `mempool/contents.json`, `getutxos.json` diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 09bb59220..40a02ac89 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -78,6 +78,7 @@ let ++ optional (cfg.coldDataDir != null) cfg.coldDataDir ++ optional cfg.rpc.enable "--rpc-listen" ++ optional cfg.rpc.enable (socket cfg.rpc.address cfg.rpc.port) + ++ optional (cfg.rpc.enable && cfg.rpc.rest) "--rest" ++ optional (cfg.rpc.socketPath != null) "--rpc-socket" ++ optional (cfg.rpc.socketPath != null) cfg.rpc.socketPath ++ optional (cfg.rpc.cookieFile != null) "--rpc-cookie-file" @@ -371,6 +372,16 @@ in ''; }; + rest = mkOption { + type = types.bool; + default = false; + description = '' + Pass `--rest` so unauthenticated `/rest/` is served on the RPC listener. + Off by default. Requires `enable` (TCP). The route uses its own queue, + not the RPC work queue. + ''; + }; + cookieFile = mkOption { type = types.nullOr types.str; default = null; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 8d72af231..c8341ca6c 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -215,6 +215,24 @@ assert builtins.match ".*--datadir-cold /srv/rbitcoin-cold.*" execStart != null; assert builtins.match ".*--network regtest.*" execStart != null; assert builtins.match ".*--listen 127.0.0.1:18444.*" execStart != null; assert builtins.match ".*--rpc-listen 127.0.0.1:18443.*" execStart != null; +assert builtins.match ".*--rest.*" execStart == null; +assert builtins.match ".*--rest.*" ( + nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + rpc.enable = true; + rpc.rest = true; + }; + } + ]; + } + .config.systemd.services.rbitcoin.serviceConfig.ExecStart +) != null; assert builtins.match ".*--rpc-socket /run/rbitcoin/rpc.sock.*" execStart != null; assert builtins.match ".*--rpc-cookie-file /run/rbitcoin/rpc.cookie.*" execStart != null; assert builtins.elem "/run/rbitcoin" service.serviceConfig.ReadWritePaths;