Rebrand the daemon and workspace: the binary is now `echo`, every crate is `echo-*` (`echo-api` and the module crates), Rust imports use `echo_*`, the gRPC proto is `proto/echo.proto` with package `echo.v1`, and the log filter, gossip peer-name default, and on-disk store default (`echo.db.jsonl`) follow. Docs updated throughout. No behaviour change; crate directories and the config schema are untouched.
211 lines
9.3 KiB
Rust
211 lines
9.3 KiB
Rust
//! 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, HeaderValue, 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,
|
|
origins: Arc<Vec<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 tls = cfg.tls.clone();
|
|
let state = AppState { engine, token: cfg.token, origins: Arc::new(cfg.origins) };
|
|
let app = Router::new()
|
|
.route("/", post(handle).options(preflight))
|
|
.layer(DefaultBodyLimit::max(64 * 1024))
|
|
.with_state(state);
|
|
|
|
match tls {
|
|
// TLS terminated here → HTTP/2 (ALPN) plus HTTP/1.1, encrypted to echo.
|
|
Some(t) => {
|
|
// The dependency tree carries more than one rustls crypto provider, so
|
|
// pin one before building any TLS config (ignored if already set).
|
|
let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
|
|
let config = match axum_server::tls_rustls::RustlsConfig::from_pem_file(&t.cert, &t.key).await {
|
|
Ok(c) => c,
|
|
Err(e) => return tracing::error!(%e, "jsonrpc TLS cert/key unreadable"),
|
|
};
|
|
tracing::info!(%addr, "jsonrpc stats API listening (TLS, HTTP/2)");
|
|
if let Err(e) = axum_server::bind_rustls(addr, config).serve(app.into_make_service()).await {
|
|
tracing::error!(%e, "jsonrpc server exited");
|
|
}
|
|
}
|
|
// Plain HTTP, for when a reverse proxy terminates TLS/HTTP-2/HTTP-3.
|
|
None => {
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Preflight (OPTIONS): tell the browser this origin may POST with an
|
|
// Authorization header. Only origins on the allowlist are answered.
|
|
async fn preflight(State(state): State<AppState>, headers: HeaderMap) -> (StatusCode, HeaderMap) {
|
|
let mut out = cors_headers(&headers, &state.origins);
|
|
out.insert("access-control-allow-methods", HeaderValue::from_static("POST, OPTIONS"));
|
|
out.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type"));
|
|
out.insert("access-control-max-age", HeaderValue::from_static("86400"));
|
|
(StatusCode::NO_CONTENT, out)
|
|
}
|
|
|
|
async fn handle(State(state): State<AppState>, headers: HeaderMap, body: Json<Value>) -> (StatusCode, HeaderMap, Json<Value>) {
|
|
let cors = cors_headers(&headers, &state.origins);
|
|
if !authorized(&headers, &state.token) {
|
|
return (StatusCode::UNAUTHORIZED, cors, 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, cors, 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, cors, Json(json!({ "jsonrpc": "2.0", "result": value, "id": id }))),
|
|
Err((code, message)) => (StatusCode::OK, cors, Json(error(&id, code, message))),
|
|
}
|
|
}
|
|
|
|
// CORS headers for a request: echo the Origin only if it's on the allowlist
|
|
// (never `*`), and always Vary on Origin so caches don't cross origins.
|
|
fn cors_headers(headers: &HeaderMap, origins: &[String]) -> HeaderMap {
|
|
let mut out = HeaderMap::new();
|
|
out.insert("vary", HeaderValue::from_static("Origin"));
|
|
let origin = headers.get("origin").and_then(|v| v.to_str().ok());
|
|
if let Some(o) = origin {
|
|
if origins.iter().any(|a| a == o) {
|
|
if let Ok(v) = HeaderValue::from_str(o) {
|
|
out.insert("access-control-allow-origin", v);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// 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!("echo-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 state(tag: &str, origins: Vec<String>) -> AppState {
|
|
AppState { engine: engine_with_stat(tag), token: "secret".into(), origins: Arc::new(origins) }
|
|
}
|
|
|
|
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 st = state("auth", vec![]);
|
|
// No/wrong token -> 401, no data.
|
|
let (status, _, _) = handle(State(st.clone()), HeaderMap::new(), Json(json!({"method": "stats.get", "id": 1}))).await;
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
let (status, _, _) = handle(State(st.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(st), 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 (status, _, Json(resp)) = handle(State(state("badmethod", vec![])), bearer("secret"), Json(json!({"method": "drop.everything", "id": 2}))).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(resp["error"]["code"], -32601);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cors_echoes_only_allowlisted_origins() {
|
|
let st = state("cors", vec!["https://tchatou.fr".into()]);
|
|
let mut allowed = bearer("secret");
|
|
allowed.insert("origin", "https://tchatou.fr".parse().unwrap());
|
|
let (_, headers, _) = handle(State(st.clone()), allowed, Json(json!({"method": "stats.get", "id": 1}))).await;
|
|
assert_eq!(headers.get("access-control-allow-origin").unwrap(), "https://tchatou.fr");
|
|
// A site not on the list gets no allow-origin header (browser blocks it).
|
|
let mut evil = bearer("secret");
|
|
evil.insert("origin", "https://evil.example".parse().unwrap());
|
|
let (_, headers, _) = handle(State(st), evil, Json(json!({"method": "stats.get", "id": 1}))).await;
|
|
assert!(headers.get("access-control-allow-origin").is_none(), "unlisted origin not echoed");
|
|
}
|
|
}
|