Add a JSON-RPC stats endpoint for websites
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.
This commit is contained in:
parent
04f927d3db
commit
07eb9939d7
6 changed files with 219 additions and 0 deletions
50
Cargo.lock
generated
50
Cargo.lock
generated
|
|
@ -110,6 +110,8 @@ dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"itoa",
|
"itoa",
|
||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -118,10 +120,15 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -142,6 +149,7 @@ dependencies = [
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -283,6 +291,7 @@ version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"fedserv-api",
|
"fedserv-api",
|
||||||
"fedserv-botserv",
|
"fedserv-botserv",
|
||||||
|
|
@ -382,6 +391,15 @@ version = "1.0.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
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]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -1035,6 +1053,12 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -1078,6 +1102,17 @@ dependencies = [
|
||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "0.6.9"
|
||||||
|
|
@ -1087,6 +1122,18 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
|
|
@ -1378,8 +1425,10 @@ dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1400,6 +1449,7 @@ version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
tokio-rustls = "0.26.4"
|
tokio-rustls = "0.26.4"
|
||||||
rustls-pemfile = "2.2.0"
|
rustls-pemfile = "2.2.0"
|
||||||
tonic = { version = "0.12", features = ["tls"] }
|
tonic = { version = "0.12", features = ["tls"] }
|
||||||
|
axum = "0.7"
|
||||||
prost = "0.13"
|
prost = "0.13"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ pub struct Config {
|
||||||
// directory. Absent = the RPC server does not start.
|
// directory. Absent = the RPC server does not start.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub grpc: Option<Grpc>,
|
pub grpc: Option<Grpc>,
|
||||||
|
// 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<JsonRpc>,
|
||||||
// Which service modules to start. Absent = the built-in NickServ + ChanServ.
|
// Which service modules to start. Absent = the built-in NickServ + ChanServ.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modules: Modules,
|
pub modules: Modules,
|
||||||
|
|
@ -90,6 +94,14 @@ pub struct ServerTls {
|
||||||
pub key: String, // private key (PEM)
|
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 <token>`).
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Email {
|
pub struct Email {
|
||||||
// Sender address stamped on outgoing mail.
|
// Sender address stamped on outgoing mail.
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,11 @@ impl Engine {
|
||||||
self.network.bump(key);
|
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
|
// The shared counters plus live gauges derived from the store, for the gRPC
|
||||||
// Stats API. Any service's counters ride in here alongside these.
|
// Stats API. Any service's counters ride in here alongside these.
|
||||||
pub fn stats_snapshot(&self) -> std::collections::BTreeMap<String, u64> {
|
pub fn stats_snapshot(&self) -> std::collections::BTreeMap<String, u64> {
|
||||||
|
|
|
||||||
145
src/jsonrpc.rs
Normal file
145
src/jsonrpc.rs
Normal file
|
|
@ -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<Mutex<Engine>>;
|
||||||
|
|
||||||
|
#[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<AppState>, headers: HeaderMap, body: Json<Value>) -> (StatusCode, Json<Value>) {
|
||||||
|
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 -> { "<key>": <count>, … } (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<Value> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ mod config;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod gossip;
|
mod gossip;
|
||||||
mod grpc;
|
mod grpc;
|
||||||
|
mod jsonrpc;
|
||||||
mod link;
|
mod link;
|
||||||
mod proto;
|
mod proto;
|
||||||
|
|
||||||
|
|
@ -110,6 +111,11 @@ async fn main() -> Result<()> {
|
||||||
tokio::spawn(grpc::run(engine.clone(), grpc_cfg, gossip_tx));
|
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.
|
// Periodically fold log churn into a snapshot when it grows past the accounts.
|
||||||
{
|
{
|
||||||
let engine = engine.clone();
|
let engine = engine.clone();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue