From 89f508aca91986c1fa1b09a62da31a0f1d71c4b1 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 16:07:53 +0000 Subject: [PATCH] rpc: add server/stats/ban/message/whowas/spamfilter providers; fix REHASH to reload raw_config --- src/coremods/core_rehash.rs | 12 +---- src/modules/mod.rs | 8 ++++ src/modules/rpc/ban.rs | 83 +++++++++++++++++++++++++++++++++++ src/modules/rpc/message.rs | 48 ++++++++++++++++++++ src/modules/rpc/mod.rs | 26 +++++++++++ src/modules/rpc/server.rs | 62 ++++++++++++++++++++++++++ src/modules/rpc/spamfilter.rs | 66 ++++++++++++++++++++++++++++ src/modules/rpc/stats.rs | 61 +++++++++++++++++++++++++ src/modules/rpc/whowas.rs | 35 +++++++++++++++ src/server.rs | 18 ++++++++ 10 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 src/modules/rpc/ban.rs create mode 100644 src/modules/rpc/message.rs create mode 100644 src/modules/rpc/server.rs create mode 100644 src/modules/rpc/spamfilter.rs create mode 100644 src/modules/rpc/stats.rs create mode 100644 src/modules/rpc/whowas.rs diff --git a/src/coremods/core_rehash.rs b/src/coremods/core_rehash.rs index 51f1777..55f1462 100644 --- a/src/coremods/core_rehash.rs +++ b/src/coremods/core_rehash.rs @@ -56,17 +56,7 @@ impl Command for Rehash { "admin {who} has changed the configuration of the server." )); s.announce(&format!("{who} is rehashing the server config file.")); - s.motd = fresh.motd; - s.opers = fresh.opers; - s.cloak_key = fresh.cloak_key; - s.censor = fresh.censor; - s.amu = fresh.amu; - s.resolve_hosts = fresh.resolve_hosts; - s.use_resolved_host = fresh.use_resolved_host; - s.dnsbl_zones = fresh.dnsbl_zones; - s.dnsbl_action = fresh.dnsbl_action; - s.dnsbl_reason = fresh.dnsbl_reason; - s.sasl_server = fresh.sasl_server; + s.apply_config(fresh); s.announce("Server configuration reloaded."); s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing")); } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index c119e86..db06f1f 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -67,6 +67,14 @@ pub fn default_modules() -> Vec> { ] } +/// The names of the modules loaded at boot (drives the `module.list` RPC). +pub fn module_names() -> Vec { + default_modules() + .iter() + .map(|m| m.name().to_string()) + .collect() +} + /// Commands contributed by modules (chained into the core command table), so a /// module that adds a command keeps it in its own file, InspIRCd-style. pub fn module_commands() -> Vec> { diff --git a/src/modules/rpc/ban.rs b/src/modules/rpc/ban.rs new file mode 100644 index 0000000..a33fdee --- /dev/null +++ b/src/modules/rpc/ban.rs @@ -0,0 +1,83 @@ +//! rpc ban provider — `xline.list`, `xline.add`, `xline.del`. InspIRCd's +//! `m_rpc_ban`. Covers every echoIRCd x-line kind (K/G/Z/E/SHUN/Q/CBAN) through the +//! same `add_xline`/`remove_xline` primitives the oper commands use. + +use super::json::{self, obj, qstr}; +use super::RpcError; +use crate::server::Server; +use crate::xline::{parse_duration, XKind}; + +/// Map a request `type` (letter or unreal-ish name) to an `XKind`. +fn kind_of(t: &str) -> Option { + let up = t.to_ascii_uppercase(); + XKind::from_tag(&up).or_else(|| { + Some(match up.as_str() { + "KLINE" => XKind::Kline, + "GLINE" => XKind::Gline, + "ZLINE" => XKind::Zline, + "ELINE" => XKind::Eline, + "QLINE" => XKind::Qline, + _ => return None, + }) + }) +} + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + match action { + "list" => { + let want = json::get_str(params, "type").and_then(|t| kind_of(&t)); + let items: Vec = s + .xlines + .iter() + .filter(|x| want.is_none_or(|k| x.kind == k)) + .map(|x| { + let expires_at = if x.expires == 0 { + "null".to_string() + } else { + x.expires.to_string() + }; + obj(&[ + ("type", qstr(x.kind.tag())), + ("mask", qstr(&x.mask)), + ("reason", qstr(&x.reason)), + ("setter", qstr(&x.setter)), + ("expires_at", expires_at), + ]) + }) + .collect(); + Ok(obj(&[("xlines", format!("[{}]", items.join(",")))])) + } + "add" => { + let kind = json::get_str(params, "type") + .and_then(|t| kind_of(&t)) + .ok_or_else(|| RpcError::invalid_params("missing/unknown 'type'"))?; + let mask = json::get_str(params, "mask") + .or_else(|| json::get_str(params, "name")) + .ok_or_else(|| RpcError::invalid_params("missing 'mask'"))?; + let duration = json::get_num::(params, "duration") + .or_else(|| { + json::get_str(params, "duration_string").and_then(|d| parse_duration(&d)) + }) + .unwrap_or(0); + let reason = json::get_str(params, "reason").unwrap_or_else(|| "Set via RPC".into()); + let setter = json::get_str(params, "setter").unwrap_or_else(|| "RPC".into()); + s.add_xline(kind, &mask, duration, &setter, &reason); + Ok(obj(&[("result", "true".into())])) + } + "del" => { + let kind = json::get_str(params, "type") + .and_then(|t| kind_of(&t)) + .ok_or_else(|| RpcError::invalid_params("missing/unknown 'type'"))?; + let mask = json::get_str(params, "mask") + .or_else(|| json::get_str(params, "name")) + .ok_or_else(|| RpcError::invalid_params("missing 'mask'"))?; + let removed = s.remove_xline(kind, &mask); + if removed { + Ok(obj(&[("result", "true".into())])) + } else { + Err(RpcError::not_found("no matching x-line")) + } + } + other => Err(RpcError::method_not_found(&format!("xline.{other}"))), + } +} diff --git a/src/modules/rpc/message.rs b/src/modules/rpc/message.rs new file mode 100644 index 0000000..17a8494 --- /dev/null +++ b/src/modules/rpc/message.rs @@ -0,0 +1,48 @@ +//! rpc message provider — `message.send_notice`. InspIRCd's `m_rpc_message`. +//! Sends a server NOTICE to a channel (`#…`), a single user (nick), or every +//! local user (`*` / `$*`). + +use super::json::{self, obj}; +use super::RpcError; +use crate::server::Server; + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + match action { + "send_notice" => { + let target = json::get_str(params, "target") + .ok_or_else(|| RpcError::invalid_params("missing 'target'"))?; + let text = json::get_str(params, "message") + .ok_or_else(|| RpcError::invalid_params("missing 'message'"))?; + let src = s.name.clone(); + if target == "*" || target == "$*" { + let uids: Vec = s.users.keys().copied().collect(); + for uid in uids { + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.send(uid, format!(":{src} NOTICE {nick} :{text}")); + } + } else if target.starts_with('#') { + let key = target.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + return Err(RpcError::not_found("no such channel")); + } + s.to_channel(&key, &format!(":{src} NOTICE {target} :{text}"), None); + } else { + let uid = s + .find_nick(&target) + .ok_or_else(|| RpcError::not_found("no such nick"))?; + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.send(uid, format!(":{src} NOTICE {nick} :{text}")); + } + Ok(obj(&[("result", "true".into())])) + } + other => Err(RpcError::method_not_found(&format!("message.{other}"))), + } +} diff --git a/src/modules/rpc/mod.rs b/src/modules/rpc/mod.rs index c751173..74eec87 100644 --- a/src/modules/rpc/mod.rs +++ b/src/modules/rpc/mod.rs @@ -14,11 +14,17 @@ //! (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 ban; pub mod channel; pub mod core; pub mod httpd; pub mod json; +pub mod message; +pub mod server; +pub mod spamfilter; +pub mod stats; pub mod user; +pub mod whowas; use std::sync::mpsc::Sender; @@ -77,6 +83,20 @@ pub const ALL_METHODS: &[&str] = &[ "channel.get", "channel.kick", "channel.set_topic", + "server.list", + "server.rehash", + "server.disconnect", + "module.list", + "oper.list", + "security_group.list", + "xline.list", + "xline.add", + "xline.del", + "message.send_notice", + "whowas.get", + "spamfilter.list", + "spamfilter.add", + "spamfilter.del", ]; /// Run a parsed JSON-RPC request on the core thread. `params` is the raw JSON of @@ -86,9 +106,15 @@ pub fn dispatch(s: &mut Server, method: &str, params: &str, id: &str) -> String let result: Result = match method { "rpc.methods" | "rpc.info" => core::rpc_info(s, method), "server.info" | "stats.get" => core::server_info(s), + "module.list" | "oper.list" | "security_group.list" => stats::handle(s, method, params), _ => match method.split_once('.') { Some(("user", action)) => user::handle(s, action, params), Some(("channel", action)) => channel::handle(s, action, params), + Some(("server", action)) => server::handle(s, action, params), + Some(("xline", action)) => ban::handle(s, action, params), + Some(("message", action)) => message::handle(s, action, params), + Some(("whowas", action)) => whowas::handle(s, action, params), + Some(("spamfilter", action)) => spamfilter::handle(s, action, params), _ => Err(RpcError::method_not_found(method)), }, }; diff --git a/src/modules/rpc/server.rs b/src/modules/rpc/server.rs new file mode 100644 index 0000000..03f713e --- /dev/null +++ b/src/modules/rpc/server.rs @@ -0,0 +1,62 @@ +//! rpc server provider — `server.list`, `server.rehash`, `server.disconnect`. +//! InspIRCd's `m_rpc_server`. (`server.connect` needs the socketengine's dialer, +//! which isn't reachable from the core thread — use the `CONNECT` command instead.) + +use super::json::{obj, qstr}; +use super::RpcError; +use crate::config::Config; +use crate::server::Server; + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + match action { + "list" => { + let mut servers = vec![obj(&[ + ("name", qstr(&s.name)), + ("description", qstr(&s.server_desc)), + ("uplink", qstr("")), + ("usercount", s.users.len().to_string()), + ])]; + for rs in s.servers.values() { + let uplink = s + .servers + .values() + .find(|o| o.sid == rs.sid) + .map(|_| s.name.clone()) + .unwrap_or_default(); + servers.push(obj(&[ + ("name", qstr(&rs.name)), + ("description", qstr(&rs.desc)), + ("uplink", qstr(&uplink)), + ("usercount", "0".to_string()), + ])); + } + Ok(obj(&[("servers", format!("[{}]", servers.join(",")))])) + } + "rehash" => match Config::try_load(&s.conf_path) { + Some(fresh) => { + s.apply_config(fresh); + s.announce("Server configuration reloaded via RPC."); + Ok(obj(&[("result", "true".into())])) + } + None => Err(RpcError::internal("config file could not be read")), + }, + "disconnect" => { + let name = json_name(params)?; + let via = s + .servers + .values() + .find(|rs| rs.name.eq_ignore_ascii_case(&name)) + .map(|rs| rs.via) + .ok_or_else(|| RpcError::not_found("no such linked server"))?; + s.close_link(via, "Disconnected via RPC"); + Ok(obj(&[("result", "true".into())])) + } + other => Err(RpcError::method_not_found(&format!("server.{other}"))), + } +} + +fn json_name(params: &str) -> Result { + super::json::get_str(params, "name") + .or_else(|| super::json::get_str(params, "server")) + .ok_or_else(|| RpcError::invalid_params("missing 'name'")) +} diff --git a/src/modules/rpc/spamfilter.rs b/src/modules/rpc/spamfilter.rs new file mode 100644 index 0000000..7fc96e1 --- /dev/null +++ b/src/modules/rpc/spamfilter.rs @@ -0,0 +1,66 @@ +//! rpc spamfilter provider — `spamfilter.list`, `spamfilter.add`, `spamfilter.del`. +//! InspIRCd's `m_rpc_spamfilter`. Operates on the same [`crate::modules::filter`] +//! rule set (stored in `Server.ext`) that the `FILTER` command and the enforcement +//! hook use, so a rule added here takes effect immediately. + +use super::json::{self, obj, qstr}; +use super::RpcError; +use crate::modules::filter::{Filters, SpamFilter}; +use crate::server::Server; + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + match action { + "list" => { + let items: Vec = s + .ext + .get::() + .map(|f| { + f.0.iter() + .map(|r| { + obj(&[ + ("pattern", qstr(&r.pattern)), + ("reason", qstr(&r.reason)), + ("action", qstr(&r.action)), + ("duration", r.duration.to_string()), + ]) + }) + .collect::>() + }) + .unwrap_or_default(); + Ok(obj(&[("filters", format!("[{}]", items.join(",")))])) + } + "add" => { + let pattern = json::get_str(params, "pattern") + .or_else(|| json::get_str(params, "name")) + .ok_or_else(|| RpcError::invalid_params("missing 'pattern'"))?; + let action = json::get_str(params, "action").unwrap_or_else(|| "block".into()); + let reason = json::get_str(params, "reason").unwrap_or_else(|| "Set via RPC".into()); + let duration = json::get_num::(params, "duration").unwrap_or(0); + let set = s.ext.get_or_insert_with::(Filters::default); + if set.0.iter().any(|f| f.pattern == pattern) { + return Err(RpcError::not_found("filter already exists")); + } + set.0.push(SpamFilter { + pattern, + action, + duration, + reason, + }); + Ok(obj(&[("result", "true".into())])) + } + "del" => { + let pattern = json::get_str(params, "pattern") + .or_else(|| json::get_str(params, "name")) + .ok_or_else(|| RpcError::invalid_params("missing 'pattern'"))?; + let set = s.ext.get_or_insert_with::(Filters::default); + let before = set.0.len(); + set.0.retain(|f| f.pattern != pattern); + if set.0.len() < before { + Ok(obj(&[("result", "true".into())])) + } else { + Err(RpcError::not_found("no matching filter")) + } + } + other => Err(RpcError::method_not_found(&format!("spamfilter.{other}"))), + } +} diff --git a/src/modules/rpc/stats.rs b/src/modules/rpc/stats.rs new file mode 100644 index 0000000..a1f3a34 --- /dev/null +++ b/src/modules/rpc/stats.rs @@ -0,0 +1,61 @@ +//! rpc stats provider — read-only introspection: `module.list`, `oper.list`, +//! `security_group.list`. InspIRCd's `m_rpc_stats`. + +use super::json::{obj, qstr}; +use super::RpcError; +use crate::server::Server; + +pub fn handle(s: &mut Server, method: &str, _params: &str) -> Result { + match method { + "module.list" => { + let mods: Vec = crate::modules::module_names() + .iter() + .map(|n| { + obj(&[ + ("name", qstr(n)), + ("description", qstr("")), + ("version", qstr("")), + ]) + }) + .collect(); + Ok(obj(&[("modules", format!("[{}]", mods.join(",")))])) + } + "oper.list" => { + // configured oper blocks — names only, never the passwords + let opers: Vec = s + .opers + .iter() + .map(|(name, _pass)| { + obj(&[ + ("name", qstr(name)), + ("type", qstr("")), + ("online", "0".into()), + ]) + }) + .collect(); + Ok(obj(&[("opers", format!("[{}]", opers.join(",")))])) + } + "security_group.list" => { + let groups: Vec = s + .conf_all("securitygroup") + .iter() + .filter_map(|line| { + let mut it = line.split_whitespace(); + let name = it.next()?; + let criteria = it.collect::>().join(" "); + let public = criteria.split_whitespace().any(|t| t == "public"); + Some(obj(&[ + ("name", qstr(name)), + ("criteria", qstr(&criteria)), + ("public", public.to_string()), + ])) + }) + .collect(); + Ok(obj(&[( + "security_groups", + format!("[{}]", groups.join(",")), + )])) + } + other => Err(RpcError::method_not_found(other)), + } +} diff --git a/src/modules/rpc/whowas.rs b/src/modules/rpc/whowas.rs new file mode 100644 index 0000000..2afaecd --- /dev/null +++ b/src/modules/rpc/whowas.rs @@ -0,0 +1,35 @@ +//! rpc whowas provider — `whowas.get`. InspIRCd's `m_rpc_whowas`. Returns the +//! recent-nick-history entries the ircd keeps for `WHOWAS`. + +use super::json::{self, obj, qstr}; +use super::RpcError; +use crate::server::Server; + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + match action { + "get" => { + let nick = json::get_str(params, "nick") + .ok_or_else(|| RpcError::invalid_params("missing 'nick'"))?; + let entries: Vec = s + .whowas + .iter() + .filter(|e| e.nick.eq_ignore_ascii_case(&nick)) + .map(|e| { + let mut fields = vec![ + ("nick", qstr(&e.nick)), + ("ident", qstr(&e.ident)), + ("host", qstr(&e.host)), + ("realname", qstr(&e.realname)), + ("signon", e.ts.to_string()), + ]; + if let Some(acct) = &e.account { + fields.push(("account", qstr(acct))); + } + obj(&fields) + }) + .collect(); + Ok(obj(&[("entries", format!("[{}]", entries.join(",")))])) + } + other => Err(RpcError::method_not_found(&format!("whowas.{other}"))), + } +} diff --git a/src/server.rs b/src/server.rs index 9071e1b..aa15bcc 100644 --- a/src/server.rs +++ b/src/server.rs @@ -224,6 +224,24 @@ impl Server { .unwrap_or(default) } + /// Apply a freshly-loaded config to the running server — the shared body of + /// REHASH and the `server.rehash` RPC. Reloads the typed fields **and** + /// `raw_config`, so modules reading via `conf*` see the new values too. + pub fn apply_config(&mut self, fresh: crate::config::Config) { + self.motd = fresh.motd; + self.opers = fresh.opers; + self.cloak_key = fresh.cloak_key; + self.censor = fresh.censor; + self.amu = fresh.amu; + self.resolve_hosts = fresh.resolve_hosts; + self.use_resolved_host = fresh.use_resolved_host; + self.dnsbl_zones = fresh.dnsbl_zones; + self.dnsbl_action = fresh.dnsbl_action; + self.dnsbl_reason = fresh.dnsbl_reason; + self.sasl_server = fresh.sasl_server; + self.raw_config = fresh.raw; + } + /// Remember an identity for WHOWAS (capped ring, newest first). pub fn push_whowas( &mut self,