From ff4856a12ec4ed9be9f11f3357614e23ae446879 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 20 Jul 2026 23:10:01 +0000 Subject: [PATCH] Add OperServ PROTECT to turn nick protection on/off per account or for all (bulk-enable after a migration) --- modules/operserv/src/lib.rs | 4 ++++ modules/operserv/src/protect.rs | 37 +++++++++++++++++++++++++++++++++ src/engine/tests.rs | 26 +++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 modules/operserv/src/protect.rs diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index fa2fb0f..67cf95d 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -11,6 +11,8 @@ mod spamfilter; mod redact; #[path = "forbid.rs"] mod forbid; +#[path = "protect.rs"] +mod protect; mod notify; #[path = "global.rs"] mod global; @@ -82,6 +84,7 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("SPAMFILTER") => spamfilter::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("REDACT") => redact::handle(me, from, args, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::handle(me, from, args, ctx, db), + Some(cmd) if cmd.eq_ignore_ascii_case("PROTECT") => protect::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("NOTIFY") => notify::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net), @@ -118,6 +121,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "SPAMFILTER", summary: "content spam filter (ircd m_filter)", detail: "Syntax: \x02SPAMFILTER ADD [+expiry] | SPAMFILTER DEL | SPAMFILTER LIST [pattern]\x02\nMatches message content (a regular expression, e.g. \x02.*free.*bitcoin.*\x02) and acts on it. Actions: gline, zline, block, silent, kill, shun, warn, none. echo persists filters and re-applies them to the ircd at each link. Admin only." }, HelpEntry { cmd: "REDACT", summary: "delete a message by its msgid", detail: "Syntax: \x02REDACT <#channel|nick> [reason]\x02\nDeletes a specific message (by its IRCv3 \x02msgid\x02, which your client shows) from everyone who received it. draft/message-redaction." }, HelpEntry { cmd: "FORBID", summary: "ban a nick/chan/email from registration", detail: "Syntax: \x02FORBID ADD | FORBID DEL | FORBID LIST\x02\nStops a nick, channel, or email pattern from being registered." }, + HelpEntry { cmd: "PROTECT", summary: "turn nick protection on/off for accounts", detail: "Syntax: \x02PROTECT ALL | [ON|OFF]\x02\nToggles the identify-or-be-renamed enforcement. \x02ALL\x02 enables it on every account that has it off (e.g. after a migration). Admin only." }, HelpEntry { cmd: "NOTIFY", summary: "watch masks and log their events", detail: "Syntax: \x02NOTIFY ADD + | NOTIFY DEL | NOTIFY LIST [pattern] | NOTIFY VIEW [pattern] | NOTIFY CLEAR\x02\nWatches a \x02nick!user@host\x02 pattern, bare nick glob, or \x02#channel\x02; matching users have their flagged events announced to the staff feed. Flags: \x02c\x02 connect, \x02d\x02 disconnect, \x02o\x02 oper-up, \x02j\x02 join, \x02p\x02 part, \x02k\x02 kick, \x02m\x02 channel mode, \x02t\x02 topic, \x02n\x02 nick change, \x02u\x02 user mode, \x02s\x02 services command, \x02S\x02 services SET (\x02*\x02 for all). Expiry like \x02+30d\x02, or \x02+0\x02 for permanent. Admin only." }, HelpEntry { cmd: "SQLINE", summary: "ban a nick pattern", detail: "Syntax: \x02SQLINE ADD [+expiry] | SQLINE DEL | SQLINE LIST [pattern]\x02\nBans matching nicknames." }, HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] | SNLINE DEL | SNLINE LIST [pattern]\x02\nBans matching real names." }, diff --git a/modules/operserv/src/protect.rs b/modules/operserv/src/protect.rs new file mode 100644 index 0000000..a37185f --- /dev/null +++ b/modules/operserv/src/protect.rs @@ -0,0 +1,37 @@ +use echo_api::{Priv, Sender, ServiceCtx, Store}; + +// PROTECT ALL | [ON|OFF]: turn NickServ nick-protection (the +// identify-or-be-renamed enforcement) on or off for accounts. ALL enables it on +// every account that currently has it off — useful after a migration that +// carried protection over as off. Admin-only. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — PROTECT needs the \x02admin\x02 privilege."); + return; + } + match args.get(1) { + Some(t) if t.eq_ignore_ascii_case("ALL") => { + let mut n = 0; + for a in db.accounts_matching("*") { + if !db.account_wants_protect(&a.name) && db.set_account_kill(&a.name, true).is_ok() { + n += 1; + } + } + ctx.notice(me, from.uid, format!("Enabled nick protection on \x02{n}\x02 account(s) that had it off.")); + ctx.alert("OPER", format!("enabled nick protection on {n} accounts (PROTECT ALL)")); + } + Some(&account) => { + if !db.exists(account) { + ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't registered.")); + return; + } + let on = !matches!(args.get(2).map(|s| s.to_ascii_uppercase()).as_deref(), Some("OFF")); + if db.set_account_kill(account, on).is_ok() { + ctx.notice(me, from.uid, format!("Nick protection for \x02{account}\x02 is now \x02{}\x02.", if on { "on" } else { "off" })); + } else { + ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."); + } + } + None => ctx.notice(me, from.uid, "Syntax: PROTECT ALL | [ON|OFF]"), + } +} diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 27afc22..2223b73 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5403,6 +5403,32 @@ assert!(out.iter().any(|a| matches!(a, NetAction::DelLine { kind, mask } if kind == "Q" && mask == "badname")), "qline removed: {out:?}"); } + // OperServ PROTECT ALL enables nick protection on accounts that had it off + // (e.g. carried over from a migration). + #[test] + fn operserv_protect_all_enables_protection() { + use echo_operserv::OperServ; + let path = std::env::temp_dir().join("echo-protectall.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("bob", "pw", None).unwrap(); + db.set_account_kill("bob", false).unwrap(); // bob has protection OFF + assert!(!db.account_wants_protect("bob")); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let os = OperServ { uid: "42SAAAAAH".into() }; + let mut e = Engine::new(vec![Box::new(ns), Box::new(os)], db); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin)); + e.set_opers(opers); + 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::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: "PROTECT ALL".into() }); + assert!(e.db.account_wants_protect("bob"), "bob is now protected after PROTECT ALL"); + } + // ChanServ SET: description and founder transfer, founder-gated. #[test] fn chanserv_set() {