diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs index 1a72202..6c8fea1 100644 --- a/src/coremods/core_mode.rs +++ b/src/coremods/core_mode.rs @@ -155,6 +155,66 @@ pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { CmdResult::Ok } +/// Apply channel modes with **server** authority (no acting user) — for the RPC +/// `channel.set_mode`. Same per-letter dispatch as [`apply_mode`] under `mode_sudo` +/// (so every rank gate passes), but the resulting `MODE` line is sourced from the +/// server, not a user. The handlers only ever touch the actor uid through +/// `s.rank()` (maxed by sudo) or `s.users.get()` (safe on the `0` sentinel), so no +/// live actor is needed. Returns whether anything actually changed. +pub fn svs_set_chan_modes(s: &mut Server, target: &str, modestring: &str, args: &[String]) -> bool { + let key = target.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + return false; + } + s.mode_sudo = true; + let mut argi = 0usize; + let mut sign = '+'; + let mut applied = String::new(); + let mut last = ' '; + let mut echoed: Vec = Vec::new(); + for c in modestring.chars() { + if c == '+' || c == '-' { + sign = c; + continue; + } + let adding = sign == '+'; + let Some(handler) = chan_mode(c) else { + continue; + }; + let param = if handler.wants_param(adding) { + let p = args.get(argi).cloned(); + if p.is_some() { + argi += 1; + } + p + } else { + None + }; + if let Applied::Yes(echo) = handler.apply(s, target, &key, 0, adding, param.as_deref()) { + emit(&mut applied, &mut last, sign, c); + if let Some(p) = echo { + echoed.push(p); + } + } + } + s.mode_sudo = false; + if applied.is_empty() { + return false; + } + let pstr = if echoed.is_empty() { + String::new() + } else { + format!(" {}", echoed.join(" ")) + }; + s.to_channel( + &key, + &format!(":{} MODE {target} {applied}{pstr}", s.name), + None, + ); + s.propagate(&format!(":{} MODE {target} {applied}{pstr}", s.sid), None); // links + true +} + /// User modes: dispatched to the [`crate::mode`] `UserMode` handler objects. fn apply_user_modes(s: &mut Server, uid: Uid, target: &str, params: &[String]) -> CmdResult { let me = s diff --git a/src/modules/rpc/channel.rs b/src/modules/rpc/channel.rs index 966c508..e185760 100644 --- a/src/modules/rpc/channel.rs +++ b/src/modules/rpc/channel.rs @@ -148,6 +148,24 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + let name = json_channel(params)?; + let key = name.to_ascii_lowercase(); + let modes = super::json::get_str(params, "modes") + .ok_or_else(|| RpcError::invalid_params("missing 'modes'"))?; + if !s.channels.contains_key(&key) { + return Err(RpcError::not_found("no such channel")); + } + // `parameters` (array) or `param` (single) — the mode arguments + let mut args = super::json::get_str_array(params, "parameters"); + if args.is_empty() { + if let Some(p) = super::json::get_str(params, "param") { + args.push(p); + } + } + let changed = crate::coremods::core_mode::svs_set_chan_modes(s, &name, &modes, &args); + Ok(obj(&[("result", changed.to_string())])) + } other => Err(RpcError::method_not_found(&format!("channel.{other}"))), } } diff --git a/src/modules/rpc/json.rs b/src/modules/rpc/json.rs index 09c644f..dfcf091 100644 --- a/src/modules/rpc/json.rs +++ b/src/modules/rpc/json.rs @@ -112,6 +112,36 @@ pub fn get_num(obj: &str, key: &str) -> Option { raw.trim_matches('"').parse().ok() } +/// The string elements of a top-level array field, e.g. `"parameters":["a","b"]` +/// → `["a","b"]`. Empty vec if the key is absent or not an array of strings. +pub fn get_str_array(obj: &str, key: &str) -> Vec { + let Some(raw) = get_raw(obj, key) else { + return Vec::new(); + }; + let inner = raw.trim(); + if !inner.starts_with('[') { + return Vec::new(); + } + let b = inner.as_bytes(); + let mut out = Vec::new(); + let mut i = 1; + while i < b.len() { + match b[i] { + b'"' => { + if let Some(end) = scan_string(b, i) { + out.push(unescape(&inner[i + 1..end - 1])); + i = end; + } else { + break; + } + } + b']' => break, + _ => i += 1, + } + } + out +} + /// 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('"') { diff --git a/src/modules/rpc/log.rs b/src/modules/rpc/log.rs new file mode 100644 index 0000000..912bf71 --- /dev/null +++ b/src/modules/rpc/log.rs @@ -0,0 +1,46 @@ +//! rpc log provider — `log.tail` and `log.events`. InspIRCd's `m_rpc_log` / +//! `m_jsonrpclog`. echoIRCd has no log *file* (it logs to journald), so both read +//! the in-memory server-log ring that `Server::snotice` feeds (`Server.log`). + +use super::json::{self, obj, qstr}; +use super::RpcError; +use crate::server::Server; + +pub fn handle(s: &mut Server, action: &str, params: &str) -> Result { + let log = s.log.borrow(); + match action { + "tail" => { + let n = json::get_num::(params, "lines") + .unwrap_or(200) + .clamp(1, 1000); + let start = log.ring.len().saturating_sub(n); + let lines: Vec = log.ring.iter().skip(start).map(|e| qstr(&e.msg)).collect(); + Ok(obj(&[("lines", format!("[{}]", lines.join(",")))])) + } + "events" => { + let since = json::get_num::(params, "since").unwrap_or(0); + let limit = json::get_num::(params, "limit") + .unwrap_or(200) + .clamp(1, 1000); + let events: Vec = log + .ring + .iter() + .filter(|e| e.id > since) + .take(limit) + .map(|e| { + obj(&[ + ("id", e.id.to_string()), + ("timestamp", e.ts.to_string()), + ("msg", qstr(&e.msg)), + ]) + }) + .collect(); + let last_id = log.ring.back().map(|e| e.id).unwrap_or(since); + Ok(obj(&[ + ("events", format!("[{}]", events.join(","))), + ("last_id", last_id.to_string()), + ])) + } + other => Err(RpcError::method_not_found(&format!("log.{other}"))), + } +} diff --git a/src/modules/rpc/mod.rs b/src/modules/rpc/mod.rs index 74eec87..9a215fc 100644 --- a/src/modules/rpc/mod.rs +++ b/src/modules/rpc/mod.rs @@ -19,6 +19,7 @@ pub mod channel; pub mod core; pub mod httpd; pub mod json; +pub mod log; pub mod message; pub mod server; pub mod spamfilter; @@ -83,8 +84,10 @@ pub const ALL_METHODS: &[&str] = &[ "channel.get", "channel.kick", "channel.set_topic", + "channel.set_mode", "server.list", "server.rehash", + "server.connect", "server.disconnect", "module.list", "oper.list", @@ -97,6 +100,8 @@ pub const ALL_METHODS: &[&str] = &[ "spamfilter.list", "spamfilter.add", "spamfilter.del", + "log.tail", + "log.events", ]; /// Run a parsed JSON-RPC request on the core thread. `params` is the raw JSON of @@ -115,6 +120,7 @@ pub fn dispatch(s: &mut Server, method: &str, params: &str, id: &str) -> String Some(("message", action)) => message::handle(s, action, params), Some(("whowas", action)) => whowas::handle(s, action, params), Some(("spamfilter", action)) => spamfilter::handle(s, action, params), + Some(("log", action)) => log::handle(s, action, params), _ => Err(RpcError::method_not_found(method)), }, }; diff --git a/src/modules/rpc/server.rs b/src/modules/rpc/server.rs index 03f713e..950be99 100644 --- a/src/modules/rpc/server.rs +++ b/src/modules/rpc/server.rs @@ -40,6 +40,28 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result Err(RpcError::internal("config file could not be read")), }, + "connect" => { + let name = json_name(params)?; + let Some(b) = s + .link_blocks + .iter() + .find(|b| b.name.eq_ignore_ascii_case(&name)) + .cloned() + else { + return Err(RpcError::not_found(&format!("no link block named {name}"))); + }; + if s.servers + .values() + .any(|sv| sv.name.eq_ignore_ascii_case(&b.name)) + { + return Err(RpcError::invalid_params("server is already linked")); + } + let addr = format!("{}:{}", b.ip, b.port); + let (tx, counter) = (s.event_tx.clone(), s.conn_counter.clone()); + std::thread::spawn(move || crate::socketengine::connect_link(&addr, tx, counter)); + s.snotice(&format!("RPC initiated a link to {}", b.name)); + Ok(obj(&[("result", "true".into())])) + } "disconnect" => { let name = json_name(params)?; let via = s diff --git a/src/server.rs b/src/server.rs index aa15bcc..08bb2f3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -95,6 +95,21 @@ pub struct WhowasEntry { pub ts: u64, } +/// One captured server log line (fed by `snotice`), for the RPC `log.tail` / +/// `log.events` methods — echoIRCd's in-memory answer to InspIRCd's log file. +pub struct LogLine { + pub id: u64, + pub ts: u64, + pub msg: String, +} + +/// The rolling server-log ring plus its monotonic sequence counter. +#[derive(Default)] +pub struct LogState { + pub seq: u64, + pub ring: VecDeque, +} + pub struct Server { pub name: String, pub network: String, @@ -141,6 +156,8 @@ pub struct Server { // the command's `label` (single tag, BATCH, or ACK). RefCell because the // output primitives are `&self`. pub label_capture: RefCell)>>, + /// Rolling in-memory server log (fed by `snotice`), read by the RPC log methods. + pub log: RefCell, pub event_tx: Sender, // self-inject events (DNS results) pub conn_counter: Arc, // mints connection uids (for CONNECT dials) /// Module-owned server state, keyed by type — the InspIRCd `ExtensionItem` @@ -189,6 +206,7 @@ impl Server { webirc: cfg.webirc, raw_config: cfg.raw, label_capture: RefCell::new(None), + log: RefCell::new(LogState::default()), event_tx, conn_counter, ext: Extensible::default(), @@ -581,6 +599,20 @@ impl Server { /// Send a server notice to every operator who has snomask (+s) on. pub fn snotice(&self, msg: &str) { + // record it in the rolling server log (for RPC log.tail / log.events) + { + let mut lg = self.log.borrow_mut(); + lg.seq += 1; + let id = lg.seq; + lg.ring.push_back(LogLine { + id, + ts: now(), + msg: msg.to_string(), + }); + while lg.ring.len() > 1000 { + lg.ring.pop_front(); + } + } let opers: Vec = self .users .iter()