OperServ: add FORBID (nick/channel/email registration bans)
All checks were successful
CI / check (push) Successful in 3m56s

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.
This commit is contained in:
Jean Chevronnet 2026-07-15 19:14:07 +00:00
parent ac50af92fc
commit be4860c9a8
No known key found for this signature in database
12 changed files with 220 additions and 1 deletions

View file

@ -625,6 +625,16 @@ pub struct AkillView {
pub expires: Option<u64>,
}
// 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<u64>) -> Result<bool, RegError>;
fn akill_del(&mut self, kind: &str, mask: &str) -> Result<bool, RegError>;
fn akills(&self) -> Vec<AkillView>;
// Registration bans (OperServ FORBID).
fn forbid_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError>;
fn forbid_del(&mut self, kind: &str, mask: &str) -> Result<bool, RegError>;
fn forbids(&self) -> Vec<ForbidView>;
fn is_forbidden(&self, kind: &str, name: &str) -> Option<String>;
// Services ignores (node-local, oper-only).
fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>);
fn ignore_del(&mut self, mask: &str) -> bool;

View file

@ -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

View file

@ -0,0 +1,62 @@
use echo_api::{human_time, Priv, Sender, ServiceCtx, Store};
// FORBID ADD <NICK|CHAN|EMAIL> <mask> <reason> | DEL <NICK|CHAN|EMAIL> <mask> | 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 <NICK|CHAN|EMAIL> <mask> <reason>");
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 <NICK|CHAN|EMAIL> <mask>");
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 <NICK|CHAN|EMAIL> <mask> <reason> | DEL <NICK|CHAN|EMAIL> <mask> | 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,
}
}

View file

@ -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] <user@host> <reason> | AKILL DEL <mask|number> | 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 <NICK|CHAN|EMAIL> <mask> <reason> | FORBID DEL <NICK|CHAN|EMAIL> <mask> | 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] <nick> <reason> | SQLINE DEL <mask|number> | SQLINE LIST [pattern]\x02\nBans matching nicknames." },
HelpEntry { cmd: "SNLINE", summary: "ban a realname pattern", detail: "Syntax: \x02SNLINE ADD [+expiry] <realname> <reason> | SNLINE DEL <mask|number> | SNLINE LIST [pattern]\x02\nBans matching real names." },
HelpEntry { cmd: "SHUN", summary: "silence a host", detail: "Syntax: \x02SHUN ADD [+expiry] <mask> <reason> | SHUN DEL <mask|number> | SHUN LIST [pattern]\x02\nSilences a matching host: it stays connected but can't act." },

View file

@ -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<String, Account>, 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;

View file

@ -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<u64>,
}
// 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<Akill>,
pub forbids: Vec<Forbid>,
pub news: Vec<News>,
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 });
}

View file

@ -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<bool, RegError> {
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<bool, RegError> {
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<ForbidView> {
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<String> {
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() });

View file

@ -186,6 +186,18 @@ impl Store for Db {
fn akills(&self) -> Vec<AkillView> {
Db::akills(self)
}
fn forbid_add(&mut self, kind: &str, mask: &str, setter: &str, reason: &str) -> Result<bool, RegError> {
Db::forbid_add(self, kind, mask, setter, reason)
}
fn forbid_del(&mut self, kind: &str, mask: &str) -> Result<bool, RegError> {
Db::forbid_del(self, kind, mask)
}
fn forbids(&self) -> Vec<ForbidView> {
Db::forbids(self)
}
fn is_forbidden(&self, kind: &str, name: &str) -> Option<String> {
Db::is_forbidden(self, kind, name)
}
fn ignore_add(&mut self, mask: &str, reason: &str, expires: Option<u64>) {
Db::ignore_add(self, mask, reason, expires)
}

View file

@ -1119,6 +1119,8 @@ fn audit_summary(event: &db::Event) -> Option<String> {
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<NetAct
let (status, code, message) = match outcome {
RegOutcome::Ok => ("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<NetAct
NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: nick.clone() },
notice(format!("Your nick \x02{nick}\x02 is now registered and you're logged in. Welcome!")),
],
RegOutcome::Forbidden => 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 <password>\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())],

View file

@ -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));
}

View file

@ -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() {

View file

@ -168,6 +168,8 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::ChannelNoExpire { .. }
| Event::AkillAdded { .. }
| Event::AkillRemoved { .. }
| Event::ForbidAdded { .. }
| Event::ForbidRemoved { .. }
| Event::AccountExpiryWarned { .. }
| Event::ChannelExpiryWarned { .. }
| Event::AccountOperNoteSet { .. }