From ccbbb91fda48aaac12fc28ed41b4eeb8b8e6a108 Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 00:00:18 +0000 Subject: [PATCH] OperServ: add SVSPART SVSPART <#channel> [reason] forces a user out of a channel (admin-only), completing the SVS command family alongside SVSNICK and SVSJOIN. New ForcePart action rendered as the ircd SVSPART command. --- api/src/lib.rs | 10 +++++++++ modules/operserv/src/lib.rs | 2 ++ modules/operserv/src/svs.rs | 23 ++++++++++++++++++++ modules/protocol/inspircd/src/lib.rs | 16 ++++++++++++++ src/engine/tests.rs | 32 ++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/api/src/lib.rs b/api/src/lib.rs index 68bf79d..e80b073 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -71,6 +71,7 @@ pub enum NetAction { // Force a user into a channel (SVSJOIN), e.g. applying an account's auto-join // list on identify. `key` is empty for keyless channels. ForceJoin { uid: String, channel: String, key: String }, + ForcePart { uid: String, channel: String, reason: String }, // Remove one of our pseudo-clients (e.g. a deleted bot) from the network. QuitUser { uid: String, reason: String }, // A services pseudo-client (a bot) joins / parts a channel. @@ -349,6 +350,15 @@ impl ServiceCtx { }); } + // Force a user out of a channel (SVSPART). + pub fn force_part(&mut self, uid: &str, channel: &str, reason: &str) { + self.actions.push(NetAction::ForcePart { + uid: uid.to_string(), + channel: channel.to_string(), + reason: reason.to_string(), + }); + } + // Set channel modes, sourced from pseudoclient `from` (e.g. ChanServ). pub fn channel_mode(&mut self, from: &str, channel: &str, modes: &str) { self.actions.push(NetAction::ChannelMode { diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index 4053195..f7f674f 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -79,6 +79,7 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("STATS") => stats::handle(me, from, db, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("SVSNICK") => svs::nick(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("SVSJOIN") => svs::join(me, from, args, ctx, net), + Some(cmd) if cmd.eq_ignore_ascii_case("SVSPART") => svs::part(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("INFO") => info::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("OPER") => oper::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SESSION") => session::handle_session(me, from, args, ctx, net), @@ -112,6 +113,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "STATS", summary: "enforcement overview", detail: "Syntax: \x02STATS\x02\nShows an overview of the enforcement lists." }, HelpEntry { cmd: "SVSNICK", summary: "force a nick change", detail: "Syntax: \x02SVSNICK \x02\nForces a user to change nick." }, HelpEntry { cmd: "SVSJOIN", summary: "force a channel join", detail: "Syntax: \x02SVSJOIN <#channel> [key]\x02\nForces a user to join a channel." }, + HelpEntry { cmd: "SVSPART", summary: "force a channel part", detail: "Syntax: \x02SVSPART <#channel> [reason]\x02\nForces a user out of a channel." }, HelpEntry { cmd: "INFO", summary: "staff notes on a target", detail: "Syntax: \x02INFO | INFO ADD | INFO DEL \x02\nReads or sets staff notes on an account or channel. (Bulletins are on InfoServ.)" }, HelpEntry { cmd: "OPER", summary: "runtime operators", detail: "Syntax: \x02OPER ADD [+duration] | OPER DEL | OPER LIST\x02\nGrants or revokes runtime operator privileges: auspex, suspend, admin." }, HelpEntry { cmd: "SESSION", summary: "inspect per-IP sessions", detail: "Syntax: \x02SESSION LIST | SESSION VIEW \x02\nInspects per-IP session counts." }, diff --git a/modules/operserv/src/svs.rs b/modules/operserv/src/svs.rs index 30f4d89..4b46ee9 100644 --- a/modules/operserv/src/svs.rs +++ b/modules/operserv/src/svs.rs @@ -43,3 +43,26 @@ pub fn join(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: & ctx.force_join(&uid, chan, args.get(3).copied().unwrap_or("")); ctx.notice(me, from.uid, format!("\x02{target}\x02 has been joined to \x02{chan}\x02.")); } + +// SVSPART <#channel> [reason]: force a user out of a channel. Admin-only. +pub fn part(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — SVSPART needs the \x02admin\x02 privilege."); + return; + } + let (Some(&target), Some(&chan)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: SVSPART <#channel> [reason]"); + return; + }; + if !chan.starts_with('#') && !chan.starts_with('&') { + ctx.notice(me, from.uid, "SVSPART targets a channel."); + return; + } + let Some(uid) = net.uid_by_nick(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("There's no \x02{target}\x02 online.")); + return; + }; + let reason = if args.len() > 3 { args[3..].join(" ") } else { "Removed by services".to_string() }; + ctx.force_part(&uid, chan, &reason); + ctx.notice(me, from.uid, format!("\x02{target}\x02 has been removed from \x02{chan}\x02.")); +} diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index de36e89..dc55dbb 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -290,6 +290,14 @@ impl Protocol for InspIrcd { } // SVSJOIN [key]: force a user into a channel, sourced from // the services server. Used to apply an account's auto-join list. + // SVSPART [:reason]: force a user out of a channel. + NetAction::ForcePart { uid, channel, reason } => { + if reason.is_empty() { + vec![self.sourced(format!("SVSPART {} {}", uid, channel))] + } else { + vec![self.sourced(format!("SVSPART {} {} :{}", uid, channel, reason))] + } + } NetAction::ForceJoin { uid, channel, key } => { if key.is_empty() { vec![self.sourced(format!("SVSJOIN {} {}", uid, channel))] @@ -483,6 +491,14 @@ mod tests { assert_eq!(lines, vec![":42S ENCAP 0IR CHGIDENT 0IRAAAAAB cool".to_string()]); } + #[test] + fn serializes_svspart() { + let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: "bye".into() }); + assert_eq!(lines, vec![":42S SVSPART 0IRAAAAAB #chan :bye".to_string()]); + let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: String::new() }); + assert_eq!(lines, vec![":42S SVSPART 0IRAAAAAB #chan".to_string()]); + } + // Free-form text with embedded line breaks must not smuggle a second line // onto the link: the serializer strips CR/LF/NUL from every outbound line. #[test] diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 89f2af1..c2c81a2 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -976,6 +976,38 @@ assert!(notice(&e.handle(NetEvent::Privmsg { from: "000AAAAAD".into(), to: "42SAAAAAH".into(), text: "FORBID ADD NICK x y".into() }), "Access denied"), "non-oper refused"); } + // OperServ SVSPART (admin) forces a user out of a channel. + #[test] + fn operserv_svspart() { + use echo_nickserv::NickServ; + use echo_operserv::OperServ; + let path = std::env::temp_dir().join("echo-svspart.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("boss", "pw", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(OperServ { uid: "42SAAAAAH".into() }), + ], + db, + ); + let mut opers = std::collections::HashMap::new(); + opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin)); + e.set_opers(opers); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY pw".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "victim".into(), host: "h".into(), ip: "0.0.0.0".into() }); + + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: "SVSPART victim #trouble go away".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ForcePart { uid, channel, .. } if uid == "000AAAAAC" && channel == "#trouble")), "victim parted: {out:?}"); + // A non-oper can't. + e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() }); + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAD".into(), to: "42SAAAAAH".into(), text: "SVSPART victim #trouble".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}"); + } + // NickServ GETEMAIL (oper) finds accounts by their email glob. #[test] fn nickserv_getemail() {