From ee445396eb8186f2bf9a66d51ad03791744e4c65 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 16:00:28 +0000 Subject: [PATCH] rpc: add user + channel providers (list/get + kill/kick/set_topic/set_nick/set_mode/set_vhost/set_oper) --- src/modules/rpc/channel.rs | 159 +++++++++++++++++++++++++++++++++++++ src/modules/rpc/mod.rs | 29 ++++++- src/modules/rpc/user.rs | 130 ++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 src/modules/rpc/channel.rs create mode 100644 src/modules/rpc/user.rs diff --git a/src/modules/rpc/channel.rs b/src/modules/rpc/channel.rs new file mode 100644 index 0000000..966c508 --- /dev/null +++ b/src/modules/rpc/channel.rs @@ -0,0 +1,159 @@ +//! rpc channel provider — `channel.list`, `channel.get`, and the mutators +//! `channel.kick`, `channel.set_topic`. InspIRCd's `m_rpc_channel`. (`channel.set_mode` +//! lands with the shared server-side mode applier in a later pass.) + +use super::json::{obj, qstr}; +use super::RpcError; +use crate::channels::Topic; +use crate::server::{now, Server}; + +/// All prefix chars a member holds, highest first (e.g. `"@+"`). +fn prefixes(m: &crate::channels::Member) -> String { + let mut p = String::new(); + for (has, ch) in [ + (m.owner, '~'), + (m.admin, '&'), + (m.op, '@'), + (m.halfop, '%'), + (m.voice, '+'), + ] { + if has { + p.push(ch); + } + } + p +} + +/// Compact channel object for `channel.list`. +fn brief(s: &Server, key: &str) -> String { + let ch = &s.channels[key]; + obj(&[ + ("name", qstr(&ch.name)), + ("usercount", ch.members.len().to_string()), + ("modes", qstr(&ch.modes.render(true))), + ]) +} + +/// Full channel object for `channel.get`. +fn full(s: &Server, key: &str) -> String { + let ch = &s.channels[key]; + let members: Vec = ch + .members + .iter() + .map(|(&uid, m)| { + let (nick, uuid) = s + .users + .get(&uid) + .map(|u| (u.nick.clone(), u.uuid.clone())) + .unwrap_or_default(); + obj(&[ + ("nick", qstr(&nick)), + ("uuid", qstr(&uuid)), + ("prefixes", qstr(&prefixes(m))), + ]) + }) + .collect(); + let bans: Vec = ch + .bans + .iter() + .map(|b| { + obj(&[ + ("mask", qstr(&b.mask)), + ("setter", qstr(&b.setter)), + ("time", b.ts.to_string()), + ]) + }) + .collect(); + let mut fields = vec![ + ("name", qstr(&ch.name)), + ("created", ch.created.to_string()), + ("usercount", ch.members.len().to_string()), + ("modes", qstr(&ch.modes.render(true))), + ("members", format!("[{}]", members.join(","))), + ("bans", format!("[{}]", bans.join(","))), + ]; + if let Some(t) = &ch.topic { + let topic = obj(&[ + ("text", qstr(&t.text)), + ("setter", qstr(&t.setter)), + ("time", t.ts.to_string()), + ]); + fields.push(("topic", topic)); + } + obj(&fields) +} + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + match action { + "list" => { + let keys: Vec = s.channels.keys().cloned().collect(); + let arr: Vec = keys.iter().map(|k| brief(s, k)).collect(); + Ok(obj(&[("channels", format!("[{}]", arr.join(",")))])) + } + "get" => { + let name = json_channel(params)?; + let key = name.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + return Err(RpcError::not_found("no such channel")); + } + Ok(full(s, &key)) + } + "kick" => { + let name = json_channel(params)?; + let key = name.to_ascii_lowercase(); + let victim = super::json::get_str(params, "nick") + .ok_or_else(|| RpcError::invalid_params("missing 'nick'"))?; + let reason = + super::json::get_str(params, "reason").unwrap_or_else(|| "Kicked via RPC".into()); + if !s.channels.contains_key(&key) { + return Err(RpcError::not_found("no such channel")); + } + let tuid = s + .find_nick(&victim) + .filter(|t| s.channels[&key].members.contains_key(t)) + .ok_or_else(|| RpcError::not_found("user not on channel"))?; + s.to_channel( + &key, + &format!( + ":{} KICK {} {victim} :{reason}", + s.name, s.channels[&key].name + ), + None, + ); + if let Some(ch) = s.channels.get_mut(&key) { + ch.members.remove(&tuid); + } + if let Some(u) = s.users.get_mut(&tuid) { + u.channels.remove(&key); + } + s.channels.retain(|_, c| c.keep_alive()); + Ok(obj(&[("result", "true".into())])) + } + "set_topic" => { + let name = json_channel(params)?; + let key = name.to_ascii_lowercase(); + let text = super::json::get_str(params, "topic") + .ok_or_else(|| RpcError::invalid_params("missing 'topic'"))?; + if !s.channels.contains_key(&key) { + return Err(RpcError::not_found("no such channel")); + } + let display = s.channels[&key].name.clone(); + if let Some(ch) = s.channels.get_mut(&key) { + ch.topic = Some(Topic { + text: text.clone(), + setter: s.name.clone(), + ts: now(), + }); + } + s.to_channel(&key, &format!(":{} TOPIC {display} :{text}", s.name), None); + Ok(obj(&[("result", "true".into())])) + } + other => Err(RpcError::method_not_found(&format!("channel.{other}"))), + } +} + +/// The required `channel` param. +fn json_channel(params: &str) -> Result { + super::json::get_str(params, "channel") + .ok_or_else(|| RpcError::invalid_params("missing 'channel'")) +} diff --git a/src/modules/rpc/mod.rs b/src/modules/rpc/mod.rs index c819299..c751173 100644 --- a/src/modules/rpc/mod.rs +++ b/src/modules/rpc/mod.rs @@ -14,9 +14,11 @@ //! (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 channel; pub mod core; pub mod httpd; pub mod json; +pub mod user; use std::sync::mpsc::Sender; @@ -59,17 +61,36 @@ impl RpcError { /// 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"]; +pub const ALL_METHODS: &[&str] = &[ + "rpc.methods", + "rpc.info", + "server.info", + "stats.get", + "user.list", + "user.get", + "user.kill", + "user.set_mode", + "user.set_vhost", + "user.set_nick", + "user.set_oper", + "channel.list", + "channel.get", + "channel.kick", + "channel.set_topic", +]; /// 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. +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), - other => Err(RpcError::method_not_found(other)), + _ => match method.split_once('.') { + Some(("user", action)) => user::handle(s, action, params), + Some(("channel", action)) => channel::handle(s, action, params), + _ => Err(RpcError::method_not_found(method)), + }, }; envelope(method, id, result) } diff --git a/src/modules/rpc/user.rs b/src/modules/rpc/user.rs new file mode 100644 index 0000000..3ab7d23 --- /dev/null +++ b/src/modules/rpc/user.rs @@ -0,0 +1,130 @@ +//! rpc user provider — `user.list`, `user.get`, and the mutators `user.kill`, +//! `user.set_mode`, `user.set_vhost`, `user.set_nick`, `user.set_oper`. InspIRCd's +//! `m_rpc_user`. Mutators route through the same `Server` primitives the commands +//! use, so behaviour and side-effects (QUIT/CHGHOST/MODE broadcasts) stay identical. + +use super::json::{self, obj, qstr}; +use super::RpcError; +use crate::coremods::core_mode::svs_set_user_modes; +use crate::server::Server; +use crate::Uid; + +/// Resolve the target uid from a `nick` or `uuid` param. +fn resolve(s: &Server, params: &str) -> Option { + if let Some(nick) = json::get_str(params, "nick") { + if let Some(uid) = s.find_nick(&nick) { + return Some(uid); + } + } + if let Some(uuid) = json::get_str(params, "uuid") { + return s + .users + .iter() + .find(|(_, u)| u.uuid == uuid) + .map(|(&id, _)| id); + } + None +} + +/// User modes as a bare letter string (no leading `+`). +fn modes_str(s: &Server, uid: Uid) -> String { + s.users + .get(&uid) + .map(|u| u.flags.umodes().trim_start_matches('+').to_string()) + .unwrap_or_default() +} + +/// A compact user object (used in `user.list`). +fn brief(s: &Server, uid: Uid) -> String { + let u = &s.users[&uid]; + obj(&[ + ("nick", qstr(&u.nick)), + ("uuid", qstr(&u.uuid)), + ("ident", qstr(&u.ident)), + ("host", qstr(u.host_display())), + ]) +} + +/// The full user object (used in `user.get`). +fn full(s: &Server, uid: Uid) -> String { + let u = &s.users[&uid]; + let channels: Vec = u.channels.iter().map(|c| qstr(c)).collect(); + let mut fields = vec![ + ("nick", qstr(&u.nick)), + ("uuid", qstr(&u.uuid)), + ("ident", qstr(&u.ident)), + ("realname", qstr(&u.realname)), + ("host", qstr(&u.host)), + ("displayhost", qstr(u.host_display())), + ("ip", qstr(&u.addr.ip().to_string())), + ("mask", qstr(&u.prefix())), + ("server", qstr(&s.name)), + ("signon", u.signon.to_string()), + ("modes", qstr(&modes_str(s, uid))), + ("channels", format!("[{}]", channels.join(","))), + ("oper", u.flags.oper.to_string()), + ("secure", u.secure.to_string()), + ]; + if let Some(acct) = &u.account { + fields.push(("account", qstr(acct))); + } + if let Some(away) = &u.flags.away { + fields.push(("away", qstr(away))); + } + if let Some(fp) = &u.certfp { + fields.push(("fingerprint", qstr(fp))); + } + obj(&fields) +} + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + match action { + "list" => { + let users: Vec = s.users.keys().copied().collect(); + let arr: Vec = users.iter().map(|&id| brief(s, id)).collect(); + Ok(obj(&[("users", format!("[{}]", arr.join(",")))])) + } + "get" => { + let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?; + Ok(full(s, uid)) + } + "kill" => { + let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?; + let reason = json::get_str(params, "reason").unwrap_or_else(|| "Killed via RPC".into()); + s.remove_user(uid, &reason); + Ok(obj(&[("result", "true".into())])) + } + "set_mode" => { + let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?; + let modes = json::get_str(params, "modes") + .ok_or_else(|| RpcError::invalid_params("missing 'modes'"))?; + svs_set_user_modes(s, uid, &modes); + Ok(obj(&[("result", "true".into())])) + } + "set_vhost" => { + let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?; + let host = json::get_str(params, "vhost") + .or_else(|| json::get_str(params, "host")) + .ok_or_else(|| RpcError::invalid_params("missing 'vhost'"))?; + s.change_host_ident(uid, None, Some(&host)); + Ok(obj(&[("result", "true".into())])) + } + "set_nick" => { + let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?; + let newnick = json::get_str(params, "newnick") + .ok_or_else(|| RpcError::invalid_params("missing 'newnick'"))?; + s.set_nick(uid, &newnick); + Ok(obj(&[("result", "true".into())])) + } + "set_oper" => { + let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?; + let oper = json::get_str(params, "oper").or_else(|| json::get_str(params, "type")); + match oper { + Some(name) if !name.is_empty() => s.oper_up(uid), + _ => svs_set_user_modes(s, uid, "-o"), // de-oper + } + Ok(obj(&[("result", "true".into())])) + } + other => Err(RpcError::method_not_found(&format!("user.{other}"))), + } +}