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

@ -771,6 +771,11 @@ pub trait Store {
fn jupe_add(&mut self, name: &str, reason: &str) -> String;
fn jupe_del(&mut self, name: &str) -> Option<String>;
fn jupes(&self) -> Vec<(String, String, String)>;
// Network defence level (OperServ DEFCON) and its derived registration freezes.
fn defcon(&self) -> u8;
fn set_defcon(&mut self, level: u8);
fn registrations_frozen(&self) -> bool;
fn channel_regs_frozen(&self) -> bool;
// Staff notes on accounts/channels (oper-only), shown in INFO to operators.
fn set_account_note(&mut self, account: &str, note: Option<String>) -> bool;
fn account_note(&self, account: &str) -> Option<String>;

View file

@ -72,6 +72,10 @@ impl Service for ChanServ {
ctx.notice(me, from.uid, "Channel names start with \x02#\x02.");
return;
}
if db.channel_regs_frozen() {
ctx.notice(me, from.uid, "Channel registrations are temporarily frozen by network staff. Please try again later.");
return;
}
// The founder is the account the sender is identified to.
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in to register a channel. Identify to NickServ first.");

40
operserv/src/defcon.rs Normal file
View file

@ -0,0 +1,40 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// DEFCON [1-5]: read or set the network defence level. 5 is normal; each lower
// level adds a restriction — 4 freezes channel registrations, 3 freezes all
// registrations, 2 also caps everyone to one session, 1 is a full lockdown that
// turns away new connections. Changing it announces the level to the network.
// 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 — DEFCON needs the \x02admin\x02 privilege.");
return;
}
match args.get(1) {
None => ctx.notice(me, from.uid, format!("The network is at \x02DEFCON {}\x02{}.", db.defcon(), describe(db.defcon()))),
Some(&arg) => match arg.parse::<u8>() {
Ok(level) if (1..=5).contains(&level) => {
db.set_defcon(level);
ctx.notice(me, from.uid, format!("DEFCON set to \x02{level}\x02{}.", describe(level)));
// Let everyone know the posture changed.
let msg = if level == 5 {
"The network is back to normal operations (DEFCON 5).".to_string()
} else {
format!("Network defence level is now \x02DEFCON {level}\x02: {}.", describe(level))
};
ctx.global(me, msg);
}
_ => ctx.notice(me, from.uid, "Syntax: DEFCON [1-5] (5 = normal, 1 = lockdown)"),
},
}
}
fn describe(level: u8) -> &'static str {
match level {
5 => "normal operations",
4 => "channel registrations frozen",
3 => "all registrations frozen",
2 => "registrations frozen and sessions capped to one per host",
_ => "full lockdown, no new connections",
}
}

View file

@ -35,6 +35,8 @@ mod jupe;
mod chankill;
#[path = "logsearch.rs"]
mod logsearch;
#[path = "defcon.rs"]
mod defcon;
pub struct OperServ {
pub uid: String,
@ -80,6 +82,7 @@ impl Service for OperServ {
Some(cmd) if cmd.eq_ignore_ascii_case("JUPE") => jupe::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("CHANKILL") => chankill::handle(me, from, args, ctx, net, db),
Some(cmd) if cmd.eq_ignore_ascii_case("LOGSEARCH") => logsearch::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("DEFCON") => defcon::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
@ -88,5 +91,5 @@ impl Service for OperServ {
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CBAN\x02 ADD|DEL|LIST (ban a channel name), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log).");
ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CBAN\x02 ADD|DEL|LIST (ban a channel name), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes), \x02NEWS\x02 ADD|DEL|LIST <LOGON|OPER> (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log), \x02DEFCON\x02 [1-5] (network defence level).");
}

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]