OperServ: DEFCON network defence levels

DEFCON [1-5] reads or sets a network-wide defence posture, each level
adding a restriction the engine and services enforce:
- 4: channel registrations frozen (ChanServ REGISTER)
- 3: all registrations frozen (the pre_register_check choke-point, covering
     NickServ and the IRCv3 relay, plus channels)
- 2: sessions additionally capped to one per host
- 1: full lockdown — new connections are turned away on connect

Setting a level announces it to the whole network. Node-local level on the
db so services can read the derived freezes; admin-only.
This commit is contained in:
Jean Chevronnet 2026-07-14 02:35:09 +00:00
parent 85d01b3ebf
commit 4c899d80b0
No known key found for this signature in database
6 changed files with 165 additions and 4 deletions

View file

@ -982,6 +982,9 @@ pub struct Db {
// so they aren't federated (that would collide SIDs across nodes).
jupes: Vec<Jupe>,
jupe_seq: u32,
// Network defence level (OperServ DEFCON), 5 = normal down to 1 = lockdown.
// Node-local; the derived restrictions are read by the engine and services.
defcon: u8,
}
// A juped server: the held name, the fake server id we allocated for it, and why.
@ -1045,7 +1048,7 @@ impl Db {
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event);
}
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0 }
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5 }
}
/// Fold an entry authored by another node into the store — the services-side
@ -2050,6 +2053,26 @@ impl Db {
.collect()
}
/// The network defence level (5 = normal, 1 = full lockdown).
pub fn defcon(&self) -> u8 {
self.defcon
}
/// Set the defence level, clamped to 1..=5.
pub fn set_defcon(&mut self, level: u8) {
self.defcon = level.clamp(1, 5);
}
/// Whether new nick/account registrations are frozen (defcon 3 or lower).
pub fn registrations_frozen(&self) -> bool {
self.defcon <= 3
}
/// Whether new channel registrations are frozen (defcon 4 or lower).
pub fn channel_regs_frozen(&self) -> bool {
self.defcon <= 4
}
/// Jupe a server name: allocate a fake sid, store it, return the sid to
/// introduce (or the existing sid if the name is already juped).
pub fn jupe_add(&mut self, name: &str, reason: &str) -> String {
@ -3324,6 +3347,18 @@ impl Store for Db {
fn jupes(&self) -> Vec<(String, String, String)> {
Db::jupes(self)
}
fn defcon(&self) -> u8 {
Db::defcon(self)
}
fn set_defcon(&mut self, level: u8) {
Db::set_defcon(self, level)
}
fn registrations_frozen(&self) -> bool {
Db::registrations_frozen(self)
}
fn channel_regs_frozen(&self) -> bool {
Db::channel_regs_frozen(self)
}
fn set_account_note(&mut self, account: &str, note: Option<String>) -> bool {
Db::set_account_note(self, account, note)
}

View file

@ -286,9 +286,10 @@ impl Engine {
}
// The effective session limit for `ip`: a matching exception's allowance, else
// the default. None means unlimited (limiting off, or an exception of 0).
// the default. None means unlimited (limiting off, or an exception of 0). At
// DEFCON 2 or lower the base tightens to a single session regardless of config.
fn session_limit_for(&self, ip: &str) -> Option<u32> {
let default = self.session_limit?;
let default = if self.db.defcon() <= 2 { 1 } else { self.session_limit? };
let limit = self.db.session_exception_for(ip).unwrap_or(default);
(limit > 0).then_some(limit)
}
@ -740,6 +741,14 @@ impl Engine {
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
NetEvent::UserConnect { uid, nick, host, ip } => {
self.network.user_connect(uid.clone(), nick, host, ip.clone());
// DEFCON 1 is a full lockdown: no new connections are accepted.
if self.db.defcon() == 1 && !self.sid.is_empty() {
return vec![NetAction::KillUser {
from: self.sid.clone(),
uid,
reason: "The network is in lockdown (DEFCON 1). Please try again later.".to_string(),
}];
}
// Enforce the session limit: kill the connection that puts an IP
// over its allowance (default limit, raised/lowered by exceptions).
if let Some(limit) = self.session_limit_for(&ip) {
@ -1138,6 +1147,9 @@ impl Engine {
// outright, and rate-limit the rest so a REGISTER flood can't pin CPU. Returns
// the rejection response if refused, or None to proceed (spending a token).
pub fn pre_register_check(&mut self, account: &str, reply: &RegReply) -> Option<Vec<NetAction>> {
if self.db.registrations_frozen() {
return Some(reg_reply(reply, RegOutcome::Frozen, account));
}
if self.db.exists(account) {
return Some(reg_reply(reply, RegOutcome::Exists, account));
}
@ -1766,6 +1778,7 @@ enum RegOutcome {
Ok,
Exists,
RateLimited,
Frozen,
Internal,
}
@ -1778,6 +1791,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec<NetAct
RegOutcome::Ok => ("success", "*", "Account registered."),
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."),
RegOutcome::Internal => ("error", "TEMPORARILY_UNAVAILABLE", "Registration is unavailable, try again later."),
};
vec![NetAction::AccountResponse {
@ -1799,6 +1813,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec<NetAct
],
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())],
RegOutcome::Internal => vec![notice("Sorry, that didn't work. Please try again in a moment.".to_string())],
}
}
@ -4298,6 +4313,65 @@ mod tests {
assert!(!e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { .. })), "gone after DEL");
}
// OperServ DEFCON: each level tightens the network — announce on change, freeze
// channel then all registrations, cap sessions, and lock out new connections.
#[test]
fn operserv_defcon_levels() {
use fedserv_chanserv::ChanServ;
use fedserv_operserv::OperServ;
let path = std::env::temp_dir().join("fedserv-defcon.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
db.register("staff", "password1", None).unwrap();
let mut e = Engine::new(
vec![
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
Box::new(ChanServ { uid: "42SAAAAAB".into() }),
Box::new(OperServ { uid: "42SAAAAAH".into() }),
],
db,
);
e.set_sid("42S".into());
let mut opers = std::collections::HashMap::new();
opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin));
e.set_opers(opers);
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() });
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
let killed = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::KillUser { .. }));
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "1.1.1.1".into() });
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
e.handle(NetEvent::Join { uid: "000AAAAAS".into(), channel: "#room".into(), op: true });
// Setting a level announces it network-wide.
let out = os(&mut e, "000AAAAAS", "DEFCON 4");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { to, text, .. } if to == "$*" && text.contains("DEFCON 4"))), "announced: {out:?}");
// DEFCON 4 freezes channel registrations but not account ones.
assert!(has(&cs(&mut e, "000AAAAAS", "REGISTER #room"), "frozen"), "chan reg frozen at 4");
let reply = crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAX".into(), nick: "newbie".into() };
assert!(e.pre_register_check("newbie", &reply).is_none(), "account reg still ok at 4");
// DEFCON 3 freezes account registrations too.
os(&mut e, "000AAAAAS", "DEFCON 3");
assert!(e.pre_register_check("newbie", &reply).is_some_and(|r| r.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("frozen")))), "account reg frozen at 3");
// DEFCON 2 caps everyone to one session per host.
os(&mut e, "000AAAAAS", "DEFCON 2");
assert!(!killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA1".into(), nick: "a".into(), host: "h".into(), ip: "2.2.2.2".into() })), "first from an IP ok");
assert!(killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA2".into(), nick: "b".into(), host: "h".into(), ip: "2.2.2.2".into() })), "second from the same IP capped");
// DEFCON 1 turns away every new connection.
os(&mut e, "000AAAAAS", "DEFCON 1");
assert!(killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA3".into(), nick: "c".into(), host: "h".into(), ip: "3.3.3.3".into() })), "lockdown rejects new connections");
// Back to 5: normal again.
os(&mut e, "000AAAAAS", "DEFCON 5");
assert!(!killed(&e.handle(NetEvent::UserConnect { uid: "000AAAAA4".into(), nick: "d".into(), host: "h".into(), ip: "4.4.4.4".into() })), "connections fine at 5");
assert!(has(&cs(&mut e, "000AAAAAS", "REGISTER #room"), "now registered"), "chan reg works at 5");
}
// OperServ session limiting: the connection that puts an IP over its limit is
// killed; SESSION inspects counts; EXCEPTION raises the allowance per IP-mask.
#[test]