Add SIGNKICK LEVEL mode: sign only kicks by users without op access
All checks were successful
CI / check (push) Successful in 5m19s

This commit is contained in:
Jean Chevronnet 2026-07-20 22:00:34 +00:00
parent ae90388fbf
commit 140b19ee04
No known key found for this signature in database
9 changed files with 72 additions and 8 deletions

View file

@ -1846,6 +1846,7 @@ pub struct ChannelView {
pub email: String, pub email: String,
// ChanServ SET options. // ChanServ SET options.
pub signkick: bool, pub signkick: bool,
pub signkick_level: bool,
pub private: bool, pub private: bool,
pub peace: bool, pub peace: bool,
pub secureops: bool, pub secureops: bool,
@ -1869,6 +1870,7 @@ pub struct ChannelView {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChanSetting { pub enum ChanSetting {
SignKick, SignKick,
SignKickLevel,
Private, Private,
Peace, Peace,
SecureOps, SecureOps,

View file

@ -20,9 +20,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
return; return;
} }
let mut reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() }; let mut reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() };
// SIGNKICK: attribute the kick to whoever asked for it. // SIGNKICK: attribute the kick to the requester. LEVEL mode signs only kicks
if db.channel(chan).is_some_and(|c| c.signkick) { // by users without op-level access, leaving trusted ops' kicks unsigned.
reason = format!("{reason} (requested by {})", from.nick); 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); ctx.kick(me, chan, target, &reason);
} }

View file

@ -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)); ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason));
} }
let mut opts = Vec::new(); 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.private { opts.push("PRIVATE"); }
if info.peace { opts.push("PEACE"); } if info.peace { opts.push("PEACE"); }
if info.secureops { opts.push("SECUREOPS"); } if info.secureops { opts.push("SECUREOPS"); }

View file

@ -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."), 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("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("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()), 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("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("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()), Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()),
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | SUCCESSOR <account>|OFF | DESC <text> | 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 <account> | SUCCESSOR <account>|OFF | DESC <text> | 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. // The SET keyword for a channel option, used in its messages.
fn label(setting: ChanSetting) -> &'static str { fn label(setting: ChanSetting) -> &'static str {
match setting { match setting {
ChanSetting::SignKick => "SIGNKICK", ChanSetting::SignKick | ChanSetting::SignKickLevel => "SIGNKICK",
ChanSetting::Private => "PRIVATE", ChanSetting::Private => "PRIVATE",
ChanSetting::Peace => "PEACE", ChanSetting::Peace => "PEACE",
ChanSetting::SecureOps => "SECUREOPS", ChanSetting::SecureOps => "SECUREOPS",
@ -120,6 +120,25 @@ fn label(setting: ChanSetting) -> &'static str {
} }
// Flip one on/off channel option, reporting the new state. // 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>) { 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 name = label(setting);
let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() { let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() {

View file

@ -271,6 +271,7 @@ impl Db {
let mut settings = c.settings; let mut settings = c.settings;
match setting { match setting {
ChanSetting::SignKick => settings.signkick = on, ChanSetting::SignKick => settings.signkick = on,
ChanSetting::SignKickLevel => settings.signkick_level = on,
ChanSetting::Private => settings.private = on, ChanSetting::Private => settings.private = on,
ChanSetting::Peace => settings.peace = on, ChanSetting::Peace => settings.peace = on,
ChanSetting::SecureOps => settings.secureops = on, ChanSetting::SecureOps => settings.secureops = on,

View file

@ -419,6 +419,10 @@ pub struct ChanSettings {
// Append "(requested by <nick>)" to ChanServ KICK reasons. // Append "(requested by <nick>)" to ChanServ KICK reasons.
#[serde(default)] #[serde(default)]
pub signkick: bool, 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. // Hide the channel from ChanServ LIST.
#[serde(default)] #[serde(default)]
pub private: bool, pub private: bool,

View file

@ -650,6 +650,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
url: c.url.clone(), url: c.url.clone(),
email: c.email.clone(), email: c.email.clone(),
signkick: c.settings.signkick, signkick: c.settings.signkick,
signkick_level: c.settings.signkick_level,
private: c.settings.private, private: c.settings.private,
peace: c.settings.peace, peace: c.settings.peace,
secureops: c.settings.secureops, secureops: c.settings.secureops,

View file

@ -5312,6 +5312,38 @@
assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-v"))), "founder keeps voice: {out:?}"); 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. // ChanServ SET: description and founder transfer, founder-gated.
#[test] #[test]
fn chanserv_set() { fn chanserv_set() {

View file

@ -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 any = |names: &[&str]| names.iter().any(|n| flag(ci, n));
let settings = ChanSettings { let settings = ChanSettings {
signkick: any(&["SIGNKICK"]), signkick: any(&["SIGNKICK", "SIGNKICK_LEVEL"]),
signkick_level: any(&["SIGNKICK_LEVEL"]),
private: any(&["PRIVATE", "CS_PRIVATE"]), private: any(&["PRIVATE", "CS_PRIVATE"]),
peace: any(&["PEACE"]), peace: any(&["PEACE"]),
secureops: any(&["SECUREOPS", "CS_SECUREOPS"]), secureops: any(&["SECUREOPS", "CS_SECUREOPS"]),