OperServ: add FORBID (nick/channel/email registration bans)
All checks were successful
CI / check (push) Successful in 3m56s
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:
parent
ac50af92fc
commit
be4860c9a8
12 changed files with 220 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())],
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue