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

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