diff --git a/src/ircd.rs b/src/ircd.rs index 798f7c3..7e9e54e 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -52,6 +52,15 @@ pub enum Event { status: u16, body: String, }, + /// An inbound JSON-RPC request from the HTTP control interface (see + /// `crate::modules::rpc`). Handled inline on the core thread; the reply JSON is + /// sent back to the waiting httpd thread over `reply`. + RpcRequest { + method: String, + params: String, + id: String, + reply: std::sync::mpsc::Sender, + }, /// Background timer tick — drives ping/idle timeouts. Tick, } @@ -156,6 +165,16 @@ impl Ircd { ); } } + Event::RpcRequest { + method, + params, + id, + reply, + } => { + let resp = + crate::modules::rpc::dispatch(&mut self.server, &method, ¶ms, &id); + let _ = reply.send(resp); + } Event::Tick => self.on_tick(), } self.drain_hooks(); diff --git a/src/main.rs b/src/main.rs index bbd7fe0..b577189 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,6 +101,9 @@ fn main() { } } + // optional JSON-RPC-over-HTTP control interface (see crate::modules::rpc) + echoircd::modules::rpc::maybe_start(&cfg, tx.clone()); + // dial any autoconnect uplinks (after a short delay so the peer can boot) for block in cfg.links.iter().filter(|b| b.autoconnect) { let addr = format!("{}:{}", block.ip, block.port); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 756ef50..c119e86 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -32,6 +32,7 @@ pub mod recaptcha; pub mod reputation; pub mod restrictcommands; pub mod restrictmsg; +pub mod rpc; pub mod securelist; pub mod securitygroups; pub mod serverban; diff --git a/src/modules/rpc/core.rs b/src/modules/rpc/core.rs new file mode 100644 index 0000000..61135cb --- /dev/null +++ b/src/modules/rpc/core.rs @@ -0,0 +1,51 @@ +//! rpc core provider — introspection: `rpc.methods` (list the interface), +//! `rpc.info` (identity + methods), and `server.info` / `stats.get` (identity + +//! network counts). InspIRCd's `m_rpc_core` + the legacy `stats.get`. + +use super::json::{obj, qstr}; +use super::{RpcError, ALL_METHODS}; +use crate::server::{now, Server, VERSION}; + +/// `{"methods":[...]}` — every method name the interface exposes. +fn methods_json() -> String { + let list: Vec = ALL_METHODS.iter().map(|m| qstr(m)).collect(); + format!("[{}]", list.join(",")) +} + +/// `rpc.methods` and `rpc.info`. +pub fn rpc_info(s: &Server, method: &str) -> Result { + if method == "rpc.methods" { + return Ok(obj(&[("methods", methods_json())])); + } + // rpc.info: identity + the method list + let mut fields = identity_fields(s); + fields.push(("methods", methods_json())); + Ok(obj(&fields)) +} + +/// `server.info` / `stats.get` — identity plus live network counts. +pub fn server_info(s: &Server) -> Result { + Ok(obj(&identity_fields(s))) +} + +/// The shared identity + counts fields. +fn identity_fields(s: &Server) -> Vec<(&'static str, String)> { + let opers = s.users.values().filter(|u| u.flags.oper).count(); + let users_local = s.users.len(); + let users_total = users_local + s.remote_users.len(); + let counts = obj(&[ + ("users", users_total.to_string()), + ("users_local", users_local.to_string()), + ("opers", opers.to_string()), + ("channels", s.channels.len().to_string()), + ]); + vec![ + ("name", qstr(&s.name)), + ("id", qstr(&s.sid)), + ("description", qstr(&s.server_desc)), + ("version", qstr(&format!("echoircd-{VERSION}"))), + ("boot_time", s.created.to_string()), + ("current_time", now().to_string()), + ("counts", counts), + ] +} diff --git a/src/modules/rpc/httpd.rs b/src/modules/rpc/httpd.rs new file mode 100644 index 0000000..74dbf32 --- /dev/null +++ b/src/modules/rpc/httpd.rs @@ -0,0 +1,209 @@ +//! The RPC HTTP server: a blocking listener thread that accepts a connection, +//! reads one HTTP request, authenticates it, and forwards the JSON-RPC body to the +//! core as `Event::RpcRequest` — then writes back whatever the core replies. Low +//! volume (admin tooling), so thread-per-connection is fine. Native `TcpStream` +//! only; no `unsafe`, no new crate. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{channel, Sender}; +use std::time::Duration; + +use crate::ircd::Event; + +const MAX_REQUEST: usize = 256 * 1024; +const IO_TIMEOUT: Duration = Duration::from_secs(20); +const CORE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Accept loop. Never returns while the listener is alive. +pub fn serve(listener: TcpListener, tx: Sender, user: String, token: String) { + for conn in listener.incoming() { + let Ok(stream) = conn else { continue }; + let (tx, user, token) = (tx.clone(), user.clone(), token.clone()); + std::thread::spawn(move || { + let _ = handle(stream, &tx, &user, &token); + }); + } +} + +/// Constant-time-ish equality (length-guarded so OpenSSL's memcmp is safe). +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && openssl::memcmp::eq(a, b) +} + +/// Verify the `Authorization` header carries the right token, via HTTP Basic +/// (`base64(user:token)`) or Bearer (`token`). +fn auth_ok(header: Option<&str>, user: &str, token: &str) -> bool { + let Some(h) = header else { return false }; + if let Some(b64) = h + .strip_prefix("Basic ") + .or_else(|| h.strip_prefix("basic ")) + { + let Ok(raw) = openssl::base64::decode_block(b64.trim()) else { + return false; + }; + let Ok(text) = String::from_utf8(raw) else { + return false; + }; + let Some((u, t)) = text.split_once(':') else { + return false; + }; + return ct_eq(u.as_bytes(), user.as_bytes()) && ct_eq(t.as_bytes(), token.as_bytes()); + } + if let Some(bearer) = h + .strip_prefix("Bearer ") + .or_else(|| h.strip_prefix("bearer ")) + { + return ct_eq(bearer.trim().as_bytes(), token.as_bytes()); + } + false +} + +fn handle( + mut stream: TcpStream, + tx: &Sender, + user: &str, + token: &str, +) -> std::io::Result<()> { + stream.set_read_timeout(Some(IO_TIMEOUT))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + + // read until we have the full header block, then the declared body + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + let (mut head_end, mut content_len) = (None, 0usize); + loop { + if head_end.is_none() { + if let Some(pos) = find_headers_end(&buf) { + head_end = Some(pos); + content_len = content_length(&buf[..pos]); + } + } + if let Some(he) = head_end { + if buf.len() >= he + content_len { + break; + } + } + if buf.len() > MAX_REQUEST { + return respond(&mut stream, 413, "Payload Too Large", "{}"); + } + let n = stream.read(&mut chunk)?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + + let Some(he) = head_end else { + return respond(&mut stream, 400, "Bad Request", "{}"); + }; + let header_text = String::from_utf8_lossy(&buf[..he]).into_owned(); + let body = + String::from_utf8_lossy(&buf[he + 4..(he + 4 + content_len).min(buf.len())]).into_owned(); + + // request line: only POST is accepted + let first = header_text.lines().next().unwrap_or(""); + if !first + .split_whitespace() + .next() + .is_some_and(|m| m.eq_ignore_ascii_case("POST")) + { + return respond(&mut stream, 405, "Method Not Allowed", "{}"); + } + + // authenticate + let authz = header_line(&header_text, "authorization"); + if !auth_ok(authz.as_deref(), user, token) { + return respond_with( + &mut stream, + 401, + "Unauthorized", + "{\"error\":\"authentication required\"}", + Some("WWW-Authenticate: Basic realm=\"echoircd-rpc\""), + ); + } + + // parse the JSON-RPC request + let method = super::json::get_str(&body, "method"); + let id = super::json::get_raw(&body, "id").unwrap_or_else(|| "null".to_string()); + let params = super::json::get_raw(&body, "params").unwrap_or_else(|| "{}".to_string()); + let Some(method) = method else { + let env = super::envelope( + "", + &id, + Err(super::RpcError { + code: -32600, + message: "Invalid Request: no method".into(), + }), + ); + return respond(&mut stream, 200, "OK", &env); + }; + + // hand off to the core and wait for its reply + let (rtx, rrx) = channel::(); + if tx + .send(Event::RpcRequest { + method: method.clone(), + params, + id: id.clone(), + reply: rtx, + }) + .is_err() + { + return respond(&mut stream, 503, "Service Unavailable", "{}"); + } + match rrx.recv_timeout(CORE_TIMEOUT) { + Ok(resp) => respond(&mut stream, 200, "OK", &resp), + Err(_) => { + let env = super::envelope( + &method, + &id, + Err(super::RpcError::internal("core did not respond in time")), + ); + respond(&mut stream, 504, "Gateway Timeout", &env) + } + } +} + +/// Index of the `\r\n\r\n` that ends the header block. +fn find_headers_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +/// The `Content-Length` from a header block (0 if absent/invalid). +fn content_length(head: &[u8]) -> usize { + let text = String::from_utf8_lossy(head); + header_line(&text, "content-length") + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0) +} + +/// The value of a header, matched case-insensitively. +fn header_line(head: &str, name: &str) -> Option { + head.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + k.trim() + .eq_ignore_ascii_case(name) + .then(|| v.trim().to_string()) + }) +} + +fn respond(stream: &mut TcpStream, code: u16, text: &str, body: &str) -> std::io::Result<()> { + respond_with(stream, code, text, body, None) +} + +fn respond_with( + stream: &mut TcpStream, + code: u16, + text: &str, + body: &str, + extra: Option<&str>, +) -> std::io::Result<()> { + let extra = extra.map(|e| format!("{e}\r\n")).unwrap_or_default(); + let resp = format!( + "HTTP/1.1 {code} {text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(resp.as_bytes())?; + stream.flush() +} diff --git a/src/modules/rpc/json.rs b/src/modules/rpc/json.rs new file mode 100644 index 0000000..09c644f --- /dev/null +++ b/src/modules/rpc/json.rs @@ -0,0 +1,215 @@ +//! Minimal JSON for the RPC subsystem — no serde (openssl+mio-only crate policy). +//! Two jobs: pull a named field out of a flat-ish request object (`get_*`), and +//! escape strings when *building* result JSON with `format!`. The scanners respect +//! nesting and string escapes, so `get_raw` only ever matches a **top-level** key +//! (a `"nick"` buried inside a nested value or another string won't false-match). + +/// Given `b[i] == b'"'`, return the index just past the closing quote. +fn scan_string(b: &[u8], mut i: usize) -> Option { + debug_assert!(b[i] == b'"'); + i += 1; + while i < b.len() { + match b[i] { + b'\\' => i += 2, // skip the escaped char + b'"' => return Some(i + 1), + _ => i += 1, + } + } + None +} + +/// Given `i` at the first byte of a JSON value, return the index just past it. +fn scan_value(b: &[u8], i: usize) -> Option { + match b.get(i)? { + b'"' => scan_string(b, i), + b'{' | b'[' => { + let (open, close) = if b[i] == b'{' { + (b'{', b'}') + } else { + (b'[', b']') + }; + let mut depth = 0usize; + let mut j = i; + while j < b.len() { + match b[j] { + b'"' => j = scan_string(b, j)?, + c if c == open => { + depth += 1; + j += 1; + } + c if c == close => { + depth -= 1; + j += 1; + if depth == 0 { + return Some(j); + } + } + _ => j += 1, + } + } + None + } + // scalar: number / true / false / null — up to the next delimiter + _ => { + let mut j = i; + while j < b.len() && !matches!(b[j], b',' | b'}' | b']' | b' ' | b'\t' | b'\n' | b'\r') + { + j += 1; + } + (j > i).then_some(j) + } + } +} + +/// The raw text of top-level object key `key` in `obj` (value verbatim, incl. any +/// quotes/braces), or `None` if the key isn't a top-level member. +pub fn get_raw(obj: &str, key: &str) -> Option { + let b = obj.as_bytes(); + let mut i = obj.find('{')? + 1; + loop { + // skip whitespace / commas to the next key string + while i < b.len() && matches!(b[i], b' ' | b'\t' | b'\n' | b'\r' | b',') { + i += 1; + } + if i >= b.len() || b[i] == b'}' { + return None; + } + if b[i] != b'"' { + return None; // malformed + } + let key_end = scan_string(b, i)?; + let this_key = &obj[i + 1..key_end - 1]; + // skip ws + ':' + let mut c = key_end; + while c < b.len() && matches!(b[c], b' ' | b'\t' | b'\n' | b'\r') { + c += 1; + } + if b.get(c) != Some(&b':') { + return None; + } + c += 1; + while c < b.len() && matches!(b[c], b' ' | b'\t' | b'\n' | b'\r') { + c += 1; + } + let val_end = scan_value(b, c)?; + if this_key == key { + return Some(obj[c..val_end].to_string()); + } + i = val_end; + } +} + +/// A string field, JSON-unescaped. `None` if absent or not a string. +pub fn get_str(obj: &str, key: &str) -> Option { + let raw = get_raw(obj, key)?; + let inner = raw.strip_prefix('"')?.strip_suffix('"')?; + Some(unescape(inner)) +} + +/// A numeric field parsed as `T`. Accepts a bare number or a quoted number. +pub fn get_num(obj: &str, key: &str) -> Option { + let raw = get_raw(obj, key)?; + raw.trim_matches('"').parse().ok() +} + +/// A boolean field (`true`/`false`, or the strings `"true"`/`"false"`). +pub fn get_bool(obj: &str, key: &str) -> Option { + match get_raw(obj, key)?.trim_matches('"') { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +/// Unescape a JSON string body (the bytes between the quotes). +fn unescape(s: &str) -> String { + if !s.contains('\\') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '\\' { + out.push(c); + continue; + } + match it.next() { + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('/') => out.push('/'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('t') => out.push('\t'), + Some('b') => out.push('\u{08}'), + Some('f') => out.push('\u{0C}'), + Some('u') => { + let hex: String = it.by_ref().take(4).collect(); + if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + out.push(ch); + } + } + Some(other) => out.push(other), + None => break, + } + } + out +} + +/// Escape a string for embedding in JSON output (between quotes). +pub fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +/// A JSON object literal from `(key, raw-json-value)` pairs. Values are inserted +/// verbatim (already valid JSON) — use [`esc`] + quotes for strings. +pub fn obj(fields: &[(&str, String)]) -> String { + let body: Vec = fields.iter().map(|(k, v)| format!("\"{k}\":{v}")).collect(); + format!("{{{}}}", body.join(",")) +} + +/// A quoted, escaped JSON string value from a Rust string. +pub fn qstr(s: &str) -> String { + format!("\"{}\"", esc(s)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn top_level_keys_only() { + let o = r#"{"nick":"bob","nested":{"nick":"decoy"},"n":42,"ok":true}"#; + assert_eq!(get_str(o, "nick").as_deref(), Some("bob")); + assert_eq!(get_num::(o, "n"), Some(42)); + assert_eq!(get_bool(o, "ok"), Some(true)); + assert_eq!(get_raw(o, "nested").as_deref(), Some(r#"{"nick":"decoy"}"#)); + assert_eq!(get_str(o, "missing"), None); + } + + #[test] + fn escapes_roundtrip() { + let o = r#"{"reason":"a \"quoted\" line\nnext"}"#; + assert_eq!( + get_str(o, "reason").as_deref(), + Some("a \"quoted\" line\nnext") + ); + assert_eq!(esc("a\"b\\c"), "a\\\"b\\\\c"); + } + + #[test] + fn quoted_number_accepted() { + assert_eq!(get_num::(r#"{"d":"3600"}"#, "d"), Some(3600)); + } +} diff --git a/src/modules/rpc/mod.rs b/src/modules/rpc/mod.rs new file mode 100644 index 0000000..c819299 --- /dev/null +++ b/src/modules/rpc/mod.rs @@ -0,0 +1,119 @@ +//! rpc — a JSON-RPC 2.0 control interface over a small native HTTP server, the +//! echoIRCd analogue of InspIRCd's `m_httpd` + `m_jsonrpc` + `m_rpc_*`. Admin tools +//! call it to introspect and drive the ircd (list/kill users, manage bans, rehash…). +//! +//! Layering (each provider is its own file, per [[echoircd-module-per-file]]): +//! * [`httpd`] — the listener thread: accept, parse HTTP, authenticate, and hand +//! the JSON-RPC body to the core as `Event::RpcRequest`. +//! * [`json`] — native JSON scan/build (no serde). +//! * `core` / `user` / `channel` / `server` / `stats` / `ban` / `message` / +//! `whowas` / `spamfilter` / `log` — the method providers, called on the core +//! thread with `&mut Server`. +//! +//! Security: **off** unless `rpc = yes` AND `rpc_token` is set; binds `rpc_bind` +//! (default `127.0.0.1:8080`); every request must carry the token (HTTP Basic or +//! Bearer), checked constant-time on the listener thread before anything dispatches. + +pub mod core; +pub mod httpd; +pub mod json; + +use std::sync::mpsc::Sender; + +use crate::config::Config; +use crate::ircd::Event; +use crate::server::Server; + +/// A JSON-RPC error (standard codes plus app-specific negatives). +pub struct RpcError { + pub code: i64, + pub message: String, +} + +impl RpcError { + pub fn method_not_found(m: &str) -> RpcError { + RpcError { + code: -32601, + message: format!("Method not found: {m}"), + } + } + pub fn invalid_params(msg: &str) -> RpcError { + RpcError { + code: -32602, + message: msg.to_string(), + } + } + pub fn not_found(msg: &str) -> RpcError { + RpcError { + code: -1000, + message: msg.to_string(), + } + } + pub fn internal(msg: &str) -> RpcError { + RpcError { + code: -32603, + message: msg.to_string(), + } + } +} + +/// Every method name the interface exposes (drives `rpc.methods`). Keep in sync +/// with the `dispatch` routes as providers are added. +pub const ALL_METHODS: &[&str] = &["rpc.methods", "rpc.info", "server.info", "stats.get"]; + +/// Run a parsed JSON-RPC request on the core thread. `params` is the raw JSON of +/// the `params` member (`{}` if none); `id` is the raw JSON of the request id +/// (echoed verbatim). Returns the full JSON-RPC response envelope. +pub fn dispatch(s: &mut Server, method: &str, _params: &str, id: &str) -> String { + // `_params` is consumed once the param-taking providers (user/channel/…) land. + let result: Result = match method { + "rpc.methods" | "rpc.info" => core::rpc_info(s, method), + "server.info" | "stats.get" => core::server_info(s), + other => Err(RpcError::method_not_found(other)), + }; + envelope(method, id, result) +} + +/// Wrap a provider result (or error) in the JSON-RPC 2.0 response envelope, echoing +/// the method and id (InspIRCd includes the method in its responses too). +pub fn envelope(method: &str, id: &str, result: Result) -> String { + let id = if id.trim().is_empty() { "null" } else { id }; + match result { + Ok(res) => format!( + "{{\"jsonrpc\":\"2.0\",\"method\":{},\"result\":{res},\"id\":{id}}}", + json::qstr(method) + ), + Err(e) => format!( + "{{\"jsonrpc\":\"2.0\",\"method\":{},\"error\":{{\"code\":{},\"message\":{}}},\"id\":{id}}}", + json::qstr(method), + e.code, + json::qstr(&e.message) + ), + } +} + +/// Start the RPC HTTP listener if configured. Called from `main` with a clone of +/// the core's event sender. No-op (with a stderr note) when disabled or misconfigured. +pub fn maybe_start(cfg: &Config, tx: Sender) { + let get = |k: &str| cfg.raw.get(k).and_then(|v| v.last()).map(|s| s.as_str()); + let on = get("rpc").map(crate::config::yesish).unwrap_or(false); + if !on { + return; + } + let token = match get("rpc_token") { + Some(t) if !t.is_empty() => t.to_string(), + _ => { + eprintln!("echoircd: rpc enabled but no rpc_token set — RPC stays OFF"); + return; + } + }; + let bind = get("rpc_bind").unwrap_or("127.0.0.1:8080").to_string(); + let user = get("rpc_user").unwrap_or("admin").to_string(); + match std::net::TcpListener::bind(&bind) { + Ok(listener) => { + eprintln!("echoircd JSON-RPC on {bind} (token auth)"); + std::thread::spawn(move || httpd::serve(listener, tx, user, token)); + } + Err(e) => eprintln!("echoircd: cannot bind rpc {bind}: {e}"), + } +}