rpc: add server/stats/ban/message/whowas/spamfilter providers; fix REHASH to reload raw_config

This commit is contained in:
Jean Chevronnet 2026-08-09 16:07:53 +00:00
parent ee445396eb
commit 89f508aca9
10 changed files with 408 additions and 11 deletions

83
src/modules/rpc/ban.rs Normal file
View file

@ -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<XKind> {
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<String, RpcError> {
match action {
"list" => {
let want = json::get_str(params, "type").and_then(|t| kind_of(&t));
let items: Vec<String> = 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::<u64>(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}"))),
}
}

View file

@ -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<String, RpcError> {
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<crate::Uid> = 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}"))),
}
}

View file

@ -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<String, RpcError> = 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)),
},
};

62
src/modules/rpc/server.rs Normal file
View file

@ -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<String, RpcError> {
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<String, RpcError> {
super::json::get_str(params, "name")
.or_else(|| super::json::get_str(params, "server"))
.ok_or_else(|| RpcError::invalid_params("missing 'name'"))
}

View file

@ -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<String, RpcError> {
match action {
"list" => {
let items: Vec<String> = s
.ext
.get::<Filters>()
.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::<Vec<_>>()
})
.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::<u64>(params, "duration").unwrap_or(0);
let set = s.ext.get_or_insert_with::<Filters>(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>(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}"))),
}
}

61
src/modules/rpc/stats.rs Normal file
View file

@ -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<String, RpcError> {
match method {
"module.list" => {
let mods: Vec<String> = 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<String> = 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<String> = s
.conf_all("securitygroup")
.iter()
.filter_map(|line| {
let mut it = line.split_whitespace();
let name = it.next()?;
let criteria = it.collect::<Vec<_>>().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)),
}
}

35
src/modules/rpc/whowas.rs Normal file
View file

@ -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<String, RpcError> {
match action {
"get" => {
let nick = json::get_str(params, "nick")
.ok_or_else(|| RpcError::invalid_params("missing 'nick'"))?;
let entries: Vec<String> = 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}"))),
}
}