From 140b19ee0401080cb4f9a08d47e4995e1e56df30 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 20 Jul 2026 22:00:34 +0000 Subject: [PATCH] Add SIGNKICK LEVEL mode: sign only kicks by users without op access --- api/src/lib.rs | 2 ++ modules/chanserv/src/kick.rs | 10 +++++++--- modules/chanserv/src/lib.rs | 2 +- modules/chanserv/src/set.rs | 25 ++++++++++++++++++++++--- src/engine/db/channel.rs | 1 + src/engine/db/mod.rs | 4 ++++ src/engine/db/store.rs | 1 + src/engine/tests.rs | 32 ++++++++++++++++++++++++++++++++ src/migrate.rs | 3 ++- 9 files changed, 72 insertions(+), 8 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 1f6c50b..48b6937 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1846,6 +1846,7 @@ pub struct ChannelView { pub email: String, // ChanServ SET options. pub signkick: bool, + pub signkick_level: bool, pub private: bool, pub peace: bool, pub secureops: bool, @@ -1869,6 +1870,7 @@ pub struct ChannelView { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChanSetting { SignKick, + SignKickLevel, Private, Peace, SecureOps, diff --git a/modules/chanserv/src/kick.rs b/modules/chanserv/src/kick.rs index d782b7f..9d43244 100644 --- a/modules/chanserv/src/kick.rs +++ b/modules/chanserv/src/kick.rs @@ -20,9 +20,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: return; } let mut reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() }; - // SIGNKICK: attribute the kick to whoever asked for it. - if db.channel(chan).is_some_and(|c| c.signkick) { - reason = format!("{reason} (requested by {})", from.nick); + // SIGNKICK: attribute the kick to the requester. LEVEL mode signs only kicks + // by users without op-level access, leaving trusted ops' kicks unsigned. + if let Some(c) = db.channel(chan) { + let kicker_op = from.account.is_some_and(|a| c.is_op(a)); + if c.signkick && (!c.signkick_level || !kicker_op) { + reason = format!("{reason} (requested by {})", from.nick); + } } ctx.kick(me, chan, target, &reason); } diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index aee069b..b3a5827 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -197,7 +197,7 @@ impl Service for ChanServ { ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason)); } let mut opts = Vec::new(); - if info.signkick { opts.push("SIGNKICK"); } + if info.signkick { opts.push(if info.signkick_level { "SIGNKICK (level)" } else { "SIGNKICK" }); } if info.private { opts.push("PRIVATE"); } if info.peace { opts.push("PEACE"); } if info.secureops { opts.push("SECUREOPS"); } diff --git a/modules/chanserv/src/set.rs b/modules/chanserv/src/set.rs index c514cba..6278b71 100644 --- a/modules/chanserv/src/set.rs +++ b/modules/chanserv/src/set.rs @@ -89,7 +89,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } - Some("SIGNKICK") => toggle(me, from, ctx, db, chan, ChanSetting::SignKick, args.get(3).copied()), + Some("SIGNKICK") => signkick_set(me, from, ctx, db, chan, args.get(3).copied()), Some("PRIVATE") => toggle(me, from, ctx, db, chan, ChanSetting::Private, args.get(3).copied()), Some("PEACE") => toggle(me, from, ctx, db, chan, ChanSetting::Peace, args.get(3).copied()), Some("SECUREOPS") => toggle(me, from, ctx, db, chan, ChanSetting::SecureOps, args.get(3).copied()), @@ -98,14 +98,14 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: Some("AUTOOP") => toggle(me, from, ctx, db, chan, ChanSetting::AutoOp, args.get(3).copied()), Some("KEEPTOPIC") => toggle(me, from, ctx, db, chan, ChanSetting::KeepTopic, args.get(3).copied()), Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()), - _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | SUCCESSOR |OFF | DESC | URL [address] | EMAIL [address] | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | SECUREVOICES {ON|OFF} | RESTRICTED {ON|OFF} | AUTOOP {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), + _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | SUCCESSOR |OFF | DESC | URL [address] | EMAIL [address] | SIGNKICK {ON|OFF|LEVEL} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | SECUREVOICES {ON|OFF} | RESTRICTED {ON|OFF} | AUTOOP {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), } } // The SET keyword for a channel option, used in its messages. fn label(setting: ChanSetting) -> &'static str { match setting { - ChanSetting::SignKick => "SIGNKICK", + ChanSetting::SignKick | ChanSetting::SignKickLevel => "SIGNKICK", ChanSetting::Private => "PRIVATE", ChanSetting::Peace => "PEACE", ChanSetting::SecureOps => "SECUREOPS", @@ -120,6 +120,25 @@ fn label(setting: ChanSetting) -> &'static str { } // Flip one on/off channel option, reporting the new state. +// SIGNKICK is three-state: ON (sign every CS KICK), LEVEL (sign only kicks by +// users without op access), OFF. Stored as the signkick + signkick_level pair. +fn signkick_set(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, chan: &str, arg: Option<&str>) { + let (on, level, state) = match arg.map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ON") => (true, false, "on"), + Some("LEVEL") => (true, true, "level"), + Some("OFF") => (false, false, "off"), + _ => { + ctx.notice(me, from.uid, "Syntax: SET <#channel> SIGNKICK {ON|OFF|LEVEL}"); + return; + } + }; + let _ = db.set_channel_setting(chan, ChanSetting::SignKick, on); + match db.set_channel_setting(chan, ChanSetting::SignKickLevel, level) { + Ok(()) => ctx.notice(me, from.uid, t!(ctx, "\x02{name}\x02 for \x02{chan}\x02 is now \x02{state}\x02.", name = "SIGNKICK", chan = chan, state = state)), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + fn toggle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, chan: &str, setting: ChanSetting, arg: Option<&str>) { let name = label(setting); let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() { diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index ee29ca6..f898793 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -271,6 +271,7 @@ impl Db { let mut settings = c.settings; match setting { ChanSetting::SignKick => settings.signkick = on, + ChanSetting::SignKickLevel => settings.signkick_level = on, ChanSetting::Private => settings.private = on, ChanSetting::Peace => settings.peace = on, ChanSetting::SecureOps => settings.secureops = on, diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 8f2e0c7..c5794b1 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -419,6 +419,10 @@ pub struct ChanSettings { // Append "(requested by )" to ChanServ KICK reasons. #[serde(default)] pub signkick: bool, + // With signkick on: sign only kicks by users WITHOUT op-level access (trusted + // ops' kicks stay unsigned). Ignored when signkick is off. + #[serde(default)] + pub signkick_level: bool, // Hide the channel from ChanServ LIST. #[serde(default)] pub private: bool, diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index fd89f55..36e9a9d 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -650,6 +650,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView { url: c.url.clone(), email: c.email.clone(), signkick: c.settings.signkick, + signkick_level: c.settings.signkick_level, private: c.settings.private, peace: c.settings.peace, secureops: c.settings.secureops, diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 7b73d94..4f79cc0 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5312,6 +5312,38 @@ assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-v"))), "founder keeps voice: {out:?}"); } + // SIGNKICK ON signs every CS KICK; LEVEL exempts kickers with op access. + #[test] + fn signkick_level_exempts_ops() { + use echo_chanserv::ChanServ; + let path = std::env::temp_dir().join("echo-signkick-level.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register_channel("#c", "alice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let cs = ChanServ { uid: "42SAAAAAB".into() }; + let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db); + let to_cs = |e: &mut Engine, from: &str, text: &str| { + e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() }) + }; + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAE".into(), nick: "eve".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Join { uid: "000AAAAAE".into(), channel: "#c".into(), op: false }); + + to_cs(&mut e, "000AAAAAB", "SET #c SIGNKICK ON"); + let out = to_cs(&mut e, "000AAAAAB", "KICK #c eve"); + assert!(out.iter().any(|a| matches!(a, NetAction::Kick { reason, .. } if reason.contains("requested by alice"))), "signed on ON: {out:?}"); + + e.handle(NetEvent::Join { uid: "000AAAAAE".into(), channel: "#c".into(), op: false }); + to_cs(&mut e, "000AAAAAB", "SET #c SIGNKICK LEVEL"); + let out = to_cs(&mut e, "000AAAAAB", "KICK #c eve"); + assert!(out.iter().any(|a| matches!(a, NetAction::Kick { .. })), "kick still happens: {out:?}"); + assert!(!out.iter().any(|a| matches!(a, NetAction::Kick { reason, .. } if reason.contains("requested by"))), "op founder exempt on LEVEL: {out:?}"); + } + // ChanServ SET: description and founder transfer, founder-gated. #[test] fn chanserv_set() { diff --git a/src/migrate.rs b/src/migrate.rs index 8d7aa17..8dc6ab9 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -269,7 +269,8 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re } let any = |names: &[&str]| names.iter().any(|n| flag(ci, n)); let settings = ChanSettings { - signkick: any(&["SIGNKICK"]), + signkick: any(&["SIGNKICK", "SIGNKICK_LEVEL"]), + signkick_level: any(&["SIGNKICK_LEVEL"]), private: any(&["PRIVATE", "CS_PRIVATE"]), peace: any(&["PEACE"]), secureops: any(&["SECUREOPS", "CS_SECUREOPS"]),