From 07eb9939d7de984ef1c58636287eb75b7681bddf Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 20:21:41 +0000 Subject: [PATCH] Add a JSON-RPC stats endpoint for websites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain HTTP + JSON-RPC 2.0 (axum, already in the tree) so a site backend can pull stats without gRPC stubs. Methods: stats.get (counters + gauges) and stats.channel { channel } (lines + top talkers). Optional [jsonrpc] config, off unless set. Security: bearer-token authenticated with a constant-time compare, refuses to start with an empty token, read-only (only stats.* methods — no way to mutate state), method-allowlisted, a 64 KiB body limit, and meant to bind to localhost beside the backend. --- Cargo.lock | 50 ++++++++++++++++ Cargo.toml | 1 + src/config.rs | 12 ++++ src/engine/mod.rs | 5 ++ src/jsonrpc.rs | 145 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 ++ 6 files changed, 219 insertions(+) create mode 100644 src/jsonrpc.rs diff --git a/Cargo.lock b/Cargo.lock index c0bec6d..1585cba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,8 @@ dependencies = [ "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -118,10 +120,15 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower 0.5.3", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -142,6 +149,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -283,6 +291,7 @@ version = "0.0.1" dependencies = [ "anyhow", "argon2", + "axum", "base64", "fedserv-api", "fedserv-botserv", @@ -382,6 +391,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1035,6 +1053,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.228" @@ -1078,6 +1102,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1087,6 +1122,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1378,8 +1425,10 @@ dependencies = [ "futures-util", "pin-project-lite", "sync_wrapper", + "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1400,6 +1449,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index c7afb0f..f9b883f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-rustls = "0.26.4" rustls-pemfile = "2.2.0" tonic = { version = "0.12", features = ["tls"] } +axum = "0.7" prost = "0.13" tokio-stream = "0.1" diff --git a/src/config.rs b/src/config.rs index cd95b69..6f71065 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,10 @@ pub struct Config { // directory. Absent = the RPC server does not start. #[serde(default)] pub grpc: Option, + // JSON-RPC stats endpoint (plain HTTP+JSON) for a website's stats pages. + // Absent = it does not start. Bind to localhost; a token is required. + #[serde(default)] + pub jsonrpc: Option, // Which service modules to start. Absent = the built-in NickServ + ChanServ. #[serde(default)] pub modules: Modules, @@ -90,6 +94,14 @@ pub struct ServerTls { pub key: String, // private key (PEM) } +#[derive(Debug, Deserialize, Clone)] +pub struct JsonRpc { + // Address to accept HTTP on, e.g. "127.0.0.1:5601". Keep it on localhost. + pub bind: String, + // Bearer token every request must present (`authorization: Bearer `). + pub token: String, +} + #[derive(Debug, Deserialize, Clone)] pub struct Email { // Sender address stamped on outgoing mail. diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c7edb6d..4d0b3a3 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -201,6 +201,11 @@ impl Engine { self.network.bump(key); } + // Per-channel activity (lines + top talkers) for the stats APIs. + pub fn channel_activity(&self, channel: &str) -> Option<(u64, Vec<(String, u64)>)> { + self.network.channel_activity(channel) + } + // The shared counters plus live gauges derived from the store, for the gRPC // Stats API. Any service's counters ride in here alongside these. pub fn stats_snapshot(&self) -> std::collections::BTreeMap { diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs new file mode 100644 index 0000000..adbf3f6 --- /dev/null +++ b/src/jsonrpc.rs @@ -0,0 +1,145 @@ +//! A small JSON-RPC 2.0 endpoint (plain HTTP + JSON) that exposes read-only +//! statistics for a website. Deliberately minimal and safe: +//! - bearer-token authenticated (constant-time compare), token required; +//! - read-only — only `stats.*` methods, no way to mutate any state; +//! - method-allowlisted (unknown methods get a JSON-RPC error, not a panic); +//! - a tight request-body limit; +//! - meant to bind to localhost, alongside the website backend. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use subtle::ConstantTimeEq; +use tokio::sync::Mutex; + +use crate::config::JsonRpc as JsonRpcCfg; +use crate::engine::Engine; + +type Shared = Arc>; + +#[derive(Clone)] +struct AppState { + engine: Shared, + token: String, +} + +// Start the endpoint, if configured. Absent [jsonrpc] in config.toml = no-op. +pub async fn run(engine: Shared, cfg: JsonRpcCfg) { + let addr: SocketAddr = match cfg.bind.parse() { + Ok(a) => a, + Err(e) => return tracing::error!(%e, bind = %cfg.bind, "bad jsonrpc bind address"), + }; + if cfg.token.is_empty() { + return tracing::error!("jsonrpc token is empty; refusing to start an unauthenticated stats endpoint"); + } + let state = AppState { engine, token: cfg.token }; + let app = Router::new() + .route("/", post(handle)) + .layer(DefaultBodyLimit::max(64 * 1024)) + .with_state(state); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => return tracing::error!(%e, %addr, "jsonrpc bind failed"), + }; + tracing::info!(%addr, "jsonrpc stats API listening"); + if let Err(e) = axum::serve(listener, app).await { + tracing::error!(%e, "jsonrpc server exited"); + } +} + +async fn handle(State(state): State, headers: HeaderMap, body: Json) -> (StatusCode, Json) { + if !authorized(&headers, &state.token) { + return (StatusCode::UNAUTHORIZED, Json(error(&Value::Null, -32001, "unauthorized"))); + } + let req = body.0; + let id = req.get("id").cloned().unwrap_or(Value::Null); + let Some(method) = req.get("method").and_then(Value::as_str) else { + return (StatusCode::OK, Json(error(&id, -32600, "invalid request: no method"))); + }; + let params = req.get("params").cloned().unwrap_or(Value::Null); + + let result = match method { + // stats.get -> { "": , … } (counters + live gauges). + "stats.get" => Ok(json!(state.engine.lock().await.stats_snapshot())), + // stats.channel { channel } -> { channel, lines, top: [{nick, lines}] }. + "stats.channel" => match params.get("channel").and_then(Value::as_str) { + Some(chan) => { + let (lines, top) = state.engine.lock().await.channel_activity(chan).unwrap_or((0, Vec::new())); + let top: Vec = top.into_iter().map(|(nick, n)| json!({ "nick": nick, "lines": n })).collect(); + Ok(json!({ "channel": chan, "lines": lines, "top": top })) + } + None => Err((-32602, "params.channel is required")), + }, + _ => Err((-32601, "method not found")), + }; + + match result { + Ok(value) => (StatusCode::OK, Json(json!({ "jsonrpc": "2.0", "result": value, "id": id }))), + Err((code, message)) => (StatusCode::OK, Json(error(&id, code, message))), + } +} + +// Constant-time bearer check (length compared first — token length isn't secret). +fn authorized(headers: &HeaderMap, token: &str) -> bool { + let Some(header) = headers.get("authorization").and_then(|v| v.to_str().ok()) else { + return false; + }; + let Some(presented) = header.strip_prefix("Bearer ") else { + return false; + }; + let (a, b) = (presented.as_bytes(), token.as_bytes()); + a.len() == b.len() && a.ct_eq(b).into() +} + +fn error(id: &Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "error": { "code": code, "message": message }, "id": id }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::db::Db; + + fn engine_with_stat(tag: &str) -> Shared { + let path = std::env::temp_dir().join(format!("fedserv-jsonrpc-{tag}.jsonl")); + let _ = std::fs::remove_file(&path); + let mut e = Engine::new(vec![], Db::open(&path, "42S")); + e.bump("botserv.messages"); + Arc::new(Mutex::new(e)) + } + + fn bearer(token: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert("authorization", format!("Bearer {token}").parse().unwrap()); + h + } + + #[tokio::test] + async fn requires_token_then_returns_counters() { + let state = AppState { engine: engine_with_stat("auth"), token: "secret".into() }; + // No/wrong token -> 401, no data. + let (status, _) = handle(State(state.clone()), HeaderMap::new(), Json(json!({"method": "stats.get", "id": 1}))).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _) = handle(State(state.clone()), bearer("wrong"), Json(json!({"method": "stats.get", "id": 1}))).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + // Correct token -> the counters and gauges. + let (status, Json(resp)) = handle(State(state), bearer("secret"), Json(json!({"jsonrpc": "2.0", "method": "stats.get", "id": 7}))).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(resp["result"]["botserv.messages"], 1); + assert!(resp["result"]["accounts.total"].is_number(), "gauge present: {resp}"); + assert_eq!(resp["id"], 7); + } + + #[tokio::test] + async fn unknown_method_is_a_jsonrpc_error() { + let state = AppState { engine: engine_with_stat("badmethod"), token: "secret".into() }; + let (status, Json(resp)) = handle(State(state), bearer("secret"), Json(json!({"method": "drop.everything", "id": 2}))).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(resp["error"]["code"], -32601); + } +} diff --git a/src/main.rs b/src/main.rs index 00ead70..6bceb81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod config; mod engine; mod gossip; mod grpc; +mod jsonrpc; mod link; mod proto; @@ -110,6 +111,11 @@ async fn main() -> Result<()> { tokio::spawn(grpc::run(engine.clone(), grpc_cfg, gossip_tx)); } + // JSON-RPC stats endpoint (plain HTTP), for a website's stats pages. + if let Some(jsonrpc_cfg) = cfg.jsonrpc.clone() { + tokio::spawn(jsonrpc::run(engine.clone(), jsonrpc_cfg)); + } + // Periodically fold log churn into a snapshot when it grows past the accounts. { let engine = engine.clone();