From b76fbfdb79400ecd2005d8f9bf357a4ce89da5a6 Mon Sep 17 00:00:00 2001 From: reverse Date: Mon, 10 Aug 2026 21:00:23 +0000 Subject: [PATCH] services: SVSHOLD nick reservation, SVSTOPIC, SVSOPER, SVSCMODE over s2s (+ stats S) --- src/coremods/core_extra.rs | 3 +- src/coremods/core_user.rs | 4 +- src/link.rs | 138 +++++++++++++++++++++++++++++++++++++ src/xline.rs | 27 +++++++- 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/src/coremods/core_extra.rs b/src/coremods/core_extra.rs index acfbdc3..49cf26a 100644 --- a/src/coremods/core_extra.rs +++ b/src/coremods/core_extra.rs @@ -243,13 +243,14 @@ impl Command for Stats { s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper")); } } - 'k' | 'g' | 'z' | 'e' | 'q' | 's' => { + 'k' | 'g' | 'z' | 'e' | 'q' | 's' | 'S' => { let kind = match letter { 'k' => XKind::Kline, 'g' => XKind::Gline, 'z' => XKind::Zline, 'e' => XKind::Eline, 'q' => XKind::Qline, + 'S' => XKind::Svshold, _ => XKind::Shun, }; let rows: Vec = s diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 3377dae..46ea1ce 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -422,9 +422,9 @@ impl Command for Nick { ); return CmdResult::Fail; } - // Q-line: a reserved nick is refused (opers and services bypass) + // Q-line / SVSHOLD: a reserved nick is refused (opers and services bypass) if !s.is_oper(uid) { - if let Some(reason) = s.matched_qline(newnick) { + if let Some(reason) = s.nick_reserved(newnick) { s.numeric( uid, ERR_ERRONEUSNICKNAME, diff --git a/src/link.rs b/src/link.rs index 8a06758..2ad2457 100644 --- a/src/link.rs +++ b/src/link.rs @@ -167,6 +167,10 @@ impl Server { "SVSMODE" if registered => self.link_svsmode(uid, msg), "SVSLOGIN" if registered => self.link_svslogin(uid, msg), "SVSLOGOUT" if registered => self.link_svslogout(uid, msg), + "SVSHOLD" if registered => self.link_svshold(uid, msg), + "SVSTOPIC" if registered => self.link_svstopic(uid, msg), + "SVSOPER" if registered => self.link_svsoper(uid, msg), + "SVSCMODE" if registered => self.link_svscmode(uid, msg), "ENCAP" if registered => self.link_encap(uid, msg), "METADATA" if registered => self.link_metadata(uid, msg), "SASL" if registered => self.link_sasl(uid, msg), @@ -523,6 +527,140 @@ impl Server { } } + /// A display name for whoever sourced a services command: the source uuid's + /// nick if we know it, else the raw source, else "services". + fn link_setter(&self, msg: &Message) -> String { + let Some(src) = &msg.source else { + return "services".to_string(); + }; + if let Some(ru) = self.remote_users.get(src) { + return ru.nick.clone(); + } + if let Some(&uid) = self.uuid_local.get(src) { + if let Some(u) = self.users.get(&uid) { + return u.nick.clone(); + } + } + src.clone() + } + + /// `:src SVSHOLD [ :]` — services reserve a nick (added + /// as an SVSHOLD x-line, so NICK to it is refused) or, with just the nick, + /// release it. Broadcast across the network. + fn link_svshold(&mut self, from: Uid, msg: &Message) { + let Some(nick) = msg.params.first().cloned() else { + return; + }; + if msg.params.len() == 1 { + self.remove_xline(crate::xline::XKind::Svshold, &nick); + } else if msg.params.len() >= 3 { + let Some(dur) = crate::xline::parse_duration(&msg.params[1]) else { + return; + }; + let setter = self.link_setter(msg); + self.add_xline( + crate::xline::XKind::Svshold, + &nick, + dur, + &setter, + &msg.params[2], + ); + } else { + return; + } + self.propagate(&msg.to_wire(), Some(from)); // spanning-tree broadcast + } + + /// `:src SVSTOPIC [ :]` — services set (4-param) + /// or clear (1-param) a channel's topic, overriding +t and op checks. + fn link_svstopic(&mut self, from: Uid, msg: &Message) { + let Some(chan) = msg.params.first().cloned() else { + return; + }; + let key = chan.to_ascii_lowercase(); + if !self.channels.contains_key(&key) { + return; + } + let (text, setter, ts) = if msg.params.len() >= 4 { + let ts = msg.params[1].parse::().unwrap_or_else(|_| now()); + (msg.params[3].clone(), msg.params[2].clone(), ts) + } else { + (String::new(), String::new(), 0) // clear + }; + if let Some(ch) = self.channels.get_mut(&key) { + ch.topic = if text.is_empty() { + None + } else { + Some(Topic { + text: text.clone(), + setter, + ts, + }) + }; + } + let src = self.link_setter(msg); + self.to_channel(&key, &format!(":{src} TOPIC {chan} :{text}"), None); + self.propagate(&msg.to_wire(), Some(from)); + } + + /// `:src SVSOPER ` — services grant IRC-operator status to a + /// local user (echo's opers are flat, so the type is accepted but not stored). + fn link_svsoper(&mut self, from: Uid, msg: &Message) { + if msg.params.len() < 2 { + return; + } + match self.link_local_target(&msg.params[0]) { + Some(tuid) => { + if !self.is_oper(tuid) { + self.oper_up(tuid); + } + } + None => { + self.forward_to_target(&msg.params[0], msg, from); + } + } + } + + /// `:src SVSCMODE ` — services clear the target user's + /// matching entries from the named channel list modes (e.g. `b` to unban them, + /// `be` bans + exceptions). + fn link_svscmode(&mut self, from: Uid, msg: &Message) { + if msg.params.len() < 3 { + return; + } + let Some(tuid) = self.link_local_target(&msg.params[0]) else { + self.forward_to_target(&msg.params[0], msg, from); + return; + }; + let key = msg.params[1].to_ascii_lowercase(); + let mut removals: Vec<(char, String)> = Vec::new(); + if let Some(ch) = self.channels.get(&key) { + for mc in msg.params[2].chars() { + let list = match mc { + 'b' => &ch.bans, + 'e' => &ch.excepts, + 'I' => &ch.invex, + _ => continue, + }; + for ban in list { + if self.ban_list_hit(tuid, std::slice::from_ref(ban)) { + removals.push((mc, ban.mask.clone())); + } + } + } + } else { + return; + } + for (mc, mask) in removals { + crate::coremods::core_mode::svs_set_chan_modes( + self, + &msg.params[1], + &format!("-{mc}"), + std::slice::from_ref(&mask), + ); + } + } + /// `:src ENCAP [params...]` — a command encapsulated /// for specific server(s); services wrap SVS*/SASL this way. If the mask /// matches us we unwrap and dispatch the subcommand; a `*` mask is also flooded diff --git a/src/xline.rs b/src/xline.rs index c76348c..c28b5b3 100644 --- a/src/xline.rs +++ b/src/xline.rs @@ -13,9 +13,10 @@ pub enum XKind { Gline, // user@host, "global" (locally the same until services span it) Zline, // an IP address Eline, // user@host / ip EXEMPT from K/G/Z-lines - Shun, // user@host allowed to connect but whose commands are dropped - Qline, // a reserved/forbidden nick glob - Cban, // a forbidden channel-name glob + Shun, // user@host allowed to connect but whose commands are dropped + Qline, // a reserved/forbidden nick glob + Cban, // a forbidden channel-name glob + Svshold, // a services-reserved nick glob (like Qline, but services-owned) } impl XKind { @@ -28,6 +29,7 @@ impl XKind { XKind::Shun => "SHUN", XKind::Qline => "Q", XKind::Cban => "CBAN", + XKind::Svshold => "SVSHOLD", } } @@ -41,6 +43,7 @@ impl XKind { "SHUN" => XKind::Shun, "Q" => XKind::Qline, "CBAN" => XKind::Cban, + "SVSHOLD" => XKind::Svshold, _ => return None, }) } @@ -125,6 +128,24 @@ impl Server { .map(|x| x.reason.clone()) } + /// The reason nick `nick` is held by services (SVSHOLD), if any. + pub fn matched_svshold(&self, nick: &str) -> Option { + let n = now(); + self.xlines + .iter() + .find(|x| { + x.kind == XKind::Svshold + && (x.expires == 0 || x.expires > n) + && glob_match(&x.mask, nick) + }) + .map(|x| x.reason.clone()) + } + + /// The reason nick `nick` may not be used — a Q-line or a services SVSHOLD. + pub fn nick_reserved(&self, nick: &str) -> Option { + self.matched_qline(nick).or_else(|| self.matched_svshold(nick)) + } + /// The reason channel `chan` is CBAN'd (forbidden), if any. Case-insensitive. pub fn matched_cban(&self, chan: &str) -> Option { let n = now();