rpc: JSON-RPC-over-HTTP control interface — native httpd + json + dispatch, core provider
This commit is contained in:
parent
9dce3bde92
commit
4b1f096ed8
7 changed files with 617 additions and 0 deletions
19
src/ircd.rs
19
src/ircd.rs
|
|
@ -52,6 +52,15 @@ pub enum Event {
|
||||||
status: u16,
|
status: u16,
|
||||||
body: String,
|
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<String>,
|
||||||
|
},
|
||||||
/// Background timer tick — drives ping/idle timeouts.
|
/// Background timer tick — drives ping/idle timeouts.
|
||||||
Tick,
|
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(),
|
Event::Tick => self.on_tick(),
|
||||||
}
|
}
|
||||||
self.drain_hooks();
|
self.drain_hooks();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
// dial any autoconnect uplinks (after a short delay so the peer can boot)
|
||||||
for block in cfg.links.iter().filter(|b| b.autoconnect) {
|
for block in cfg.links.iter().filter(|b| b.autoconnect) {
|
||||||
let addr = format!("{}:{}", block.ip, block.port);
|
let addr = format!("{}:{}", block.ip, block.port);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ pub mod recaptcha;
|
||||||
pub mod reputation;
|
pub mod reputation;
|
||||||
pub mod restrictcommands;
|
pub mod restrictcommands;
|
||||||
pub mod restrictmsg;
|
pub mod restrictmsg;
|
||||||
|
pub mod rpc;
|
||||||
pub mod securelist;
|
pub mod securelist;
|
||||||
pub mod securitygroups;
|
pub mod securitygroups;
|
||||||
pub mod serverban;
|
pub mod serverban;
|
||||||
|
|
|
||||||
51
src/modules/rpc/core.rs
Normal file
51
src/modules/rpc/core.rs
Normal file
|
|
@ -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<String> = 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<String, RpcError> {
|
||||||
|
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<String, RpcError> {
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
}
|
||||||
209
src/modules/rpc/httpd.rs
Normal file
209
src/modules/rpc/httpd.rs
Normal file
|
|
@ -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<Event>, 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<Event>,
|
||||||
|
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::<String>();
|
||||||
|
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<usize> {
|
||||||
|
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<String> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
215
src/modules/rpc/json.rs
Normal file
215
src/modules/rpc/json.rs
Normal file
|
|
@ -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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<T: std::str::FromStr>(obj: &str, key: &str) -> Option<T> {
|
||||||
|
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<bool> {
|
||||||
|
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<String> = 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::<i64>(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::<u64>(r#"{"d":"3600"}"#, "d"), Some(3600));
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/modules/rpc/mod.rs
Normal file
119
src/modules/rpc/mod.rs
Normal file
|
|
@ -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<String, RpcError> = 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, RpcError>) -> 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<Event>) {
|
||||||
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue