From be4860c9a85ccb399d34b8b041ce8f35dce4097e Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 15 Jul 2026 19:14:07 +0000 Subject: [PATCH] OperServ: add FORBID (nick/channel/email registration bans) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FORBID ADD/DEL/LIST bans a NICK, CHAN, or EMAIL glob from being registered — a network-wide policy that gossips like an AKILL and every node enforces. Nick registration is blocked in pre_register_check (both NickServ REGISTER and the account-registration relay), and channel registration in ChanServ REGISTER. New Forbid store + events, mirroring the akill subsystem. --- api/src/lib.rs | 15 ++++++++ modules/chanserv/src/lib.rs | 4 +++ modules/operserv/src/forbid.rs | 62 ++++++++++++++++++++++++++++++++++ modules/operserv/src/lib.rs | 4 +++ src/engine/db/event.rs | 13 +++++++ src/engine/db/mod.rs | 18 +++++++++- src/engine/db/network.rs | 40 ++++++++++++++++++++++ src/engine/db/store.rs | 12 +++++++ src/engine/mod.rs | 6 ++++ src/engine/register.rs | 3 ++ src/engine/tests.rs | 42 +++++++++++++++++++++++ src/grpc.rs | 2 ++ 12 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 modules/operserv/src/forbid.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index 7cf4d9a..6251e49 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -625,6 +625,16 @@ pub struct AkillView { pub expires: Option, } +// A registration ban held by OperServ FORBID (kind = NICK/CHAN/EMAIL). +#[derive(Debug, Clone)] +pub struct ForbidView { + pub kind: String, + pub mask: String, + pub setter: String, + pub reason: String, + pub ts: u64, +} + // A services ignore held by OperServ. #[derive(Debug, Clone)] pub struct IgnoreView { @@ -917,6 +927,11 @@ pub trait Store { fn akill_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str, expires: Option) -> Result; fn akill_del(&mut self, kind: &str, mask: &str) -> Result; fn akills(&self) -> Vec; + // Registration bans (OperServ FORBID). + fn forbid_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str) -> Result; + fn forbid_del(&mut self, kind: &str, mask: &str) -> Result; + fn forbids(&self) -> Vec; + fn is_forbidden(&self, kind: &str, name: &str) -> Option; // Services ignores (node-local, oper-only). fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option); fn ignore_del(&mut self, mask: &str) -> bool; diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index c2d0599..8fb8a51 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -124,6 +124,10 @@ impl Service for ChanServ { ctx.notice(me, from.uid, format!("You must be a channel operator (\x02@\x02) in \x02{chan}\x02 to register it.")); return; } + if let Some(reason) = db.is_forbidden("CHAN", chan) { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 can't be registered: {reason}")); + return; + } match db.register_channel(chan, account) { Ok(()) => { ctx.channel_mode(me, chan, "+r"); // mark the channel registered diff --git a/modules/operserv/src/forbid.rs b/modules/operserv/src/forbid.rs new file mode 100644 index 0000000..fa8efd2 --- /dev/null +++ b/modules/operserv/src/forbid.rs @@ -0,0 +1,62 @@ +use echo_api::{human_time, Priv, Sender, ServiceCtx, Store}; + +// FORBID ADD | DEL | LIST +// Bans a nick, channel, or email pattern from being registered. 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 — FORBID needs the \x02admin\x02 privilege."); + return; + } + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => { + let (Some(kind), Some(&mask)) = (args.get(2).and_then(|k| norm_kind(k)), args.get(3)) else { + ctx.notice(me, from.uid, "Syntax: FORBID ADD "); + return; + }; + if args.len() < 5 { + ctx.notice(me, from.uid, "Please give a reason."); + return; + } + let reason = args[4..].join(" "); + let setter = from.account.unwrap_or(from.nick); + match db.forbid_add(kind, mask, setter, &reason) { + Ok(true) => ctx.notice(me, from.uid, format!("Forbade {} \x02{mask}\x02.", kind.to_lowercase())), + Ok(false) => ctx.notice(me, from.uid, format!("Updated the forbid on {} \x02{mask}\x02.", kind.to_lowercase())), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some("DEL") | Some("REMOVE") => { + let (Some(kind), Some(&mask)) = (args.get(2).and_then(|k| norm_kind(k)), args.get(3)) else { + ctx.notice(me, from.uid, "Syntax: FORBID DEL "); + return; + }; + match db.forbid_del(kind, mask) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed the forbid on {} \x02{mask}\x02.", kind.to_lowercase())), + Ok(false) => ctx.notice(me, from.uid, format!("No forbid on {} \x02{mask}\x02.", kind.to_lowercase())), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some("LIST") | Some("VIEW") => { + let forbids = db.forbids(); + if forbids.is_empty() { + ctx.notice(me, from.uid, "The forbid list is empty."); + return; + } + ctx.notice(me, from.uid, "Registration bans:"); + for f in &forbids { + ctx.notice(me, from.uid, format!(" [{}] \x02{}\x02 by {} ({}) — {}", f.kind, f.mask, f.setter, human_time(f.ts), f.reason)); + } + } + _ => ctx.notice(me, from.uid, "Syntax: FORBID ADD | DEL | LIST"), + } +} + +// Canonicalise the kind argument to NICK / CHAN / EMAIL. +fn norm_kind(k: &str) -> Option<&'static str> { + match k.to_ascii_uppercase().as_str() { + "NICK" | "NICKNAME" | "ACCOUNT" => Some("NICK"), + "CHAN" | "CHANNEL" => Some("CHAN"), + "EMAIL" | "MAIL" => Some("EMAIL"), + _ => None, + } +} diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index 4480e4a..4053195 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -7,6 +7,8 @@ use echo_api::{HelpEntry, NetView, Sender, Service, ServiceCtx, Store}; #[path = "xline.rs"] mod xline; +#[path = "forbid.rs"] +mod forbid; #[path = "global.rs"] mod global; #[path = "kill.rs"] @@ -68,6 +70,7 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("CBAN") => xline::CBAN.handle(me, from, args, ctx, db), + Some(cmd) if cmd.eq_ignore_ascii_case("FORBID") => forbid::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), Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net), @@ -95,6 +98,7 @@ const BLURB: &str = "OperServ holds the network operator tools. Every command is const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "AKILL", summary: "network-ban a user@host", detail: "Syntax: \x02AKILL ADD [+expiry] | AKILL DEL | AKILL LIST [pattern]\x02\nA network-wide user@host ban." }, + 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: "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." }, HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] | SHUN DEL | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." }, diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index c856ea2..da1b389 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -118,6 +118,10 @@ pub enum Event { kind: String, mask: String, }, + // Registration bans (OperServ FORBID). Global network policy. `kind` is + // "NICK", "CHAN", or "EMAIL". + ForbidAdded { kind: String, mask: String, setter: String, reason: String, ts: u64 }, + ForbidRemoved { kind: String, mask: String }, } // Whether an event replicates across the federation. Account identity is Global @@ -160,6 +164,8 @@ impl Event { | Event::AccountOperNoteSet { .. } | Event::AkillAdded { .. } | Event::AkillRemoved { .. } + | Event::ForbidAdded { .. } + | Event::ForbidRemoved { .. } | Event::NewsAdded { .. } | Event::NewsDeleted { .. } | Event::ReportFiled { .. } @@ -495,6 +501,13 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash Event::AkillRemoved { kind, mask } => { net.akills.retain(|a| !(a.kind == kind && a.mask.eq_ignore_ascii_case(&mask))); } + Event::ForbidAdded { kind, mask, setter, reason, ts } => { + net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask))); + net.forbids.push(Forbid { kind, mask, setter, reason, ts }); + } + Event::ForbidRemoved { kind, mask } => { + net.forbids.retain(|f| !(f.kind == kind && f.mask.eq_ignore_ascii_case(&mask))); + } Event::AccountExpiryWarned { account } => { if let Some(a) = accounts.get_mut(&key(&account)) { a.expiry_warned = true; diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 4e5bb55..fe7be94 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -40,7 +40,7 @@ pub(crate) use event::{apply, Scope}; // echo-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use echo_api::{ - AccountView, AjoinView, AkillView, BotView, Caps, GroupView, HelpView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, + AccountView, AjoinView, AkillView, BotView, Caps, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -166,6 +166,18 @@ pub struct Akill { pub expires: Option, } +// A registration ban: an account name, channel, or email pattern that may not be +// registered. `kind` is "NICK", "CHAN", or "EMAIL". Network-wide policy like an +// AKILL, so it gossips and every node enforces it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Forbid { + pub kind: String, + pub mask: String, + pub setter: String, + pub reason: String, + pub ts: u64, +} + // The default ban kind for records written before bans carried one: a G-line. fn gline_kind() -> String { "G".to_string() @@ -249,6 +261,7 @@ pub struct Report { #[derive(Debug, Clone, Default)] pub struct NetData { pub akills: Vec, + pub forbids: Vec, pub news: Vec, pub news_seq: u64, // Abuse reports (ReportServ), oldest first. @@ -1039,6 +1052,9 @@ impl Db { } // Compaction is a good moment to forget akills that have already expired. let now = now(); + for f in &self.net.forbids { + snapshot.push(Event::ForbidAdded { kind: f.kind.clone(), mask: f.mask.clone(), setter: f.setter.clone(), reason: f.reason.clone(), ts: f.ts }); + } for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) { snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires }); } diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 1115b81..01a676a 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -35,6 +35,46 @@ impl Db { .collect() } + /// Add a registration ban of `kind` ("NICK"/"CHAN"/"EMAIL") for `mask`. + /// Returns whether it was new. + pub fn forbid_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str) -> Result { + let same = |f: &Forbid| f.kind == kind && f.mask.eq_ignore_ascii_case(mask); + let fresh = !self.net.forbids.iter().any(same); + self.log + .append(Event::ForbidAdded { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now() }) + .map_err(|_| RegError::Internal)?; + self.net.forbids.retain(|f| !same(f)); + self.net.forbids.push(Forbid { kind: kind.to_string(), mask: mask.to_string(), setter: setter.to_string(), reason: reason.to_string(), ts: now() }); + Ok(fresh) + } + + /// Remove a registration ban of `kind` for `mask`. Returns whether one existed. + pub fn forbid_del(&mut self, kind: &str, mask: &str) -> Result { + let same = |f: &Forbid| f.kind == kind && f.mask.eq_ignore_ascii_case(mask); + if !self.net.forbids.iter().any(same) { + return Ok(false); + } + self.log.append(Event::ForbidRemoved { kind: kind.to_string(), mask: mask.to_string() }).map_err(|_| RegError::Internal)?; + self.net.forbids.retain(|f| !same(f)); + Ok(true) + } + + /// All registration bans, oldest first. + pub fn forbids(&self) -> Vec { + self.net.forbids + .iter() + .map(|f| ForbidView { kind: f.kind.clone(), mask: f.mask.clone(), setter: f.setter.clone(), reason: f.reason.clone(), ts: f.ts }) + .collect() + } + + /// The reason `name` is forbidden for `kind` registration (glob), if it is. + pub fn is_forbidden(&self, kind: &str, name: &str) -> Option { + self.net.forbids + .iter() + .find(|f| f.kind == kind && glob_match(&f.mask, name)) + .map(|f| f.reason.clone()) + } + /// Add (or replace) a session-limit exception for an IP-mask. pub fn session_except_add(&mut self, mask: &str, limit: u32, reason: &str) { let _ = self.log.append(Event::SessionExceptionAdded { mask: mask.to_string(), limit, reason: reason.to_string() }); diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index b341d15..d793805 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -186,6 +186,18 @@ impl Store for Db { fn akills(&self) -> Vec { Db::akills(self) } + fn forbid_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str) -> Result { + Db::forbid_add(self, kind, mask, setter, reason) + } + fn forbid_del(&mut self, kind: &str, mask: &str) -> Result { + Db::forbid_del(self, kind, mask) + } + fn forbids(&self) -> Vec { + Db::forbids(self) + } + fn is_forbidden(&self, kind: &str, name: &str) -> Option { + Db::is_forbidden(self, kind, name) + } fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option) { Db::ignore_add(self, mask, reason, expires) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 18fe30e..89ac0eb 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1119,6 +1119,8 @@ fn audit_summary(event: &db::Event) -> Option { format!("set a {} on \x02{mask}\x02{temp} ({reason})", ban_kind_label(kind)) } AkillRemoved { kind, mask } => format!("lifted the {} on \x02{mask}\x02", ban_kind_label(kind)), + ForbidAdded { kind, mask, reason, .. } => format!("forbade {} \x02{mask}\x02 ({reason})", kind.to_ascii_lowercase()), + ForbidRemoved { kind, mask } => format!("un-forbade {} \x02{mask}\x02", kind.to_ascii_lowercase()), AccountOperNoteSet { account, note } => match note { Some(_) => format!("set a staff note on \x02{account}\x02"), None => format!("cleared the staff note on \x02{account}\x02"), @@ -1241,6 +1243,8 @@ enum RegOutcome { Ok, /// Registered, but an emailed code must be confirmed before it's verified. VerifyRequired, + /// The name is on the OperServ FORBID list. + Forbidden, Exists, RateLimited, Frozen, @@ -1256,6 +1260,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec ("success", "*", "Account registered."), RegOutcome::VerifyRequired => ("verification_required", "VERIFICATION_REQUIRED", "Registered — check your email for a code, then VERIFY."), + RegOutcome::Forbidden => ("error", "BAD_ACCOUNT_NAME", "That account name is forbidden by network policy."), RegOutcome::Exists => ("error", "ACCOUNT_EXISTS", "That account name is already registered."), RegOutcome::RateLimited => ("error", "TEMPORARILY_UNAVAILABLE", "Too many registrations, please wait a moment."), RegOutcome::Frozen => ("error", "TEMPORARILY_UNAVAILABLE", "Registrations are temporarily frozen by network staff."), @@ -1282,6 +1287,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec vec![notice("That nickname is forbidden and can't be registered.".to_string())], RegOutcome::Exists => vec![notice(format!("\x02{nick}\x02 is already registered. If it's yours, use \x02IDENTIFY \x02."))], RegOutcome::RateLimited => vec![notice("Registrations are busy right now. Please try again in a moment.".to_string())], RegOutcome::Frozen => vec![notice("Registrations are temporarily frozen by network staff. Please try again later.".to_string())], diff --git a/src/engine/register.rs b/src/engine/register.rs index b9f8397..c2fb2de 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -206,6 +206,9 @@ impl Engine { if self.db.exists(account) { return Some(reg_reply(reply, RegOutcome::Exists, account)); } + if self.db.is_forbidden("NICK", account).is_some() { + return Some(reg_reply(reply, RegOutcome::Forbidden, account)); + } if !self.reg_limiter.allow() { return Some(reg_reply(reply, RegOutcome::RateLimited, account)); } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 3fc3d57..7c111d6 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -934,6 +934,48 @@ assert!(e.db.account("alice").is_none(), "the dropped account is gone"); } + // OperServ FORBID bans a nick/channel pattern from registration. + #[test] + fn operserv_forbid_blocks_registration() { + use echo_nickserv::NickServ; + use echo_operserv::OperServ; + let path = std::env::temp_dir().join("echo-osforbid.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() }); + let os = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAH".into(), text: t.into() }); + let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + assert!(notice(&os(&mut e, "FORBID ADD NICK evil* squatting"), "Forbade"), "nick forbid added"); + assert!(notice(&os(&mut e, "FORBID ADD CHAN #warez piracy"), "Forbade"), "chan forbid added"); + + let reply = crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAC".into(), nick: "evilbob".into() }; + assert!(e.pre_register_check("evilbob", &reply).is_some(), "forbidden nick refused"); + assert!(e.pre_register_check("cleanname", &reply).is_none(), "clean nick allowed"); + assert!(e.db.is_forbidden("CHAN", "#warez").is_some(), "channel is forbidden"); + + assert!(notice(&os(&mut e, "FORBID LIST"), "evil*"), "list shows the nick forbid"); + assert!(notice(&os(&mut e, "FORBID DEL NICK evil*"), "Removed"), "nick forbid removed"); + assert!(e.pre_register_check("evilbob", &reply).is_none(), "no longer forbidden"); + + // A non-oper cannot FORBID. + e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() }); + assert!(notice(&e.handle(NetEvent::Privmsg { from: "000AAAAAD".into(), to: "42SAAAAAH".into(), text: "FORBID ADD NICK x y".into() }), "Access denied"), "non-oper refused"); + } + // BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered. #[test] fn botserv_bot_add_list_is_oper_gated() { diff --git a/src/grpc.rs b/src/grpc.rs index 192c921..f7fed03 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -168,6 +168,8 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelNoExpire { .. } | Event::AkillAdded { .. } | Event::AkillRemoved { .. } + | Event::ForbidAdded { .. } + | Event::ForbidRemoved { .. } | Event::AccountExpiryWarned { .. } | Event::ChannelExpiryWarned { .. } | Event::AccountOperNoteSet { .. }