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:
parent
85d01b3ebf
commit
4c899d80b0
6 changed files with 165 additions and 4 deletions
|
|
@ -771,6 +771,11 @@ pub trait Store {
|
||||||
fn jupe_add(&mut self, name: &str, reason: &str) -> String;
|
fn jupe_add(&mut self, name: &str, reason: &str) -> String;
|
||||||
fn jupe_del(&mut self, name: &str) -> Option<String>;
|
fn jupe_del(&mut self, name: &str) -> Option<String>;
|
||||||
fn jupes(&self) -> Vec<(String, String, 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.
|
// 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 set_account_note(&mut self, account: &str, note: Option<String>) -> bool;
|
||||||
fn account_note(&self, account: &str) -> Option<String>;
|
fn account_note(&self, account: &str) -> Option<String>;
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ impl Service for ChanServ {
|
||||||
ctx.notice(me, from.uid, "Channel names start with \x02#\x02.");
|
ctx.notice(me, from.uid, "Channel names start with \x02#\x02.");
|
||||||
return;
|
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.
|
// The founder is the account the sender is identified to.
|
||||||
let Some(account) = from.account else {
|
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.");
|
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
40
operserv/src/defcon.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,8 @@ mod jupe;
|
||||||
mod chankill;
|
mod chankill;
|
||||||
#[path = "logsearch.rs"]
|
#[path = "logsearch.rs"]
|
||||||
mod logsearch;
|
mod logsearch;
|
||||||
|
#[path = "defcon.rs"]
|
||||||
|
mod defcon;
|
||||||
|
|
||||||
pub struct OperServ {
|
pub struct OperServ {
|
||||||
pub uid: String,
|
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("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("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("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),
|
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
|
||||||
None => 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.")),
|
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) {
|
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).");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -982,6 +982,9 @@ pub struct Db {
|
||||||
// so they aren't federated (that would collide SIDs across nodes).
|
// so they aren't federated (that would collide SIDs across nodes).
|
||||||
jupes: Vec<Jupe>,
|
jupes: Vec<Jupe>,
|
||||||
jupe_seq: u32,
|
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.
|
// 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);
|
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");
|
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
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
|
|
@ -2050,6 +2053,26 @@ impl Db {
|
||||||
.collect()
|
.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
|
/// 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).
|
/// introduce (or the existing sid if the name is already juped).
|
||||||
pub fn jupe_add(&mut self, name: &str, reason: &str) -> String {
|
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)> {
|
fn jupes(&self) -> Vec<(String, String, String)> {
|
||||||
Db::jupes(self)
|
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 {
|
fn set_account_note(&mut self, account: &str, note: Option<String>) -> bool {
|
||||||
Db::set_account_note(self, account, note)
|
Db::set_account_note(self, account, note)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -286,9 +286,10 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The effective session limit for `ip`: a matching exception's allowance, else
|
// 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> {
|
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);
|
let limit = self.db.session_exception_for(ip).unwrap_or(default);
|
||||||
(limit > 0).then_some(limit)
|
(limit > 0).then_some(limit)
|
||||||
}
|
}
|
||||||
|
|
@ -740,6 +741,14 @@ impl Engine {
|
||||||
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
||||||
NetEvent::UserConnect { uid, nick, host, ip } => {
|
NetEvent::UserConnect { uid, nick, host, ip } => {
|
||||||
self.network.user_connect(uid.clone(), nick, host, ip.clone());
|
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
|
// Enforce the session limit: kill the connection that puts an IP
|
||||||
// over its allowance (default limit, raised/lowered by exceptions).
|
// over its allowance (default limit, raised/lowered by exceptions).
|
||||||
if let Some(limit) = self.session_limit_for(&ip) {
|
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
|
// 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).
|
// 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>> {
|
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) {
|
if self.db.exists(account) {
|
||||||
return Some(reg_reply(reply, RegOutcome::Exists, account));
|
return Some(reg_reply(reply, RegOutcome::Exists, account));
|
||||||
}
|
}
|
||||||
|
|
@ -1766,6 +1778,7 @@ enum RegOutcome {
|
||||||
Ok,
|
Ok,
|
||||||
Exists,
|
Exists,
|
||||||
RateLimited,
|
RateLimited,
|
||||||
|
Frozen,
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1778,6 +1791,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec<NetAct
|
||||||
RegOutcome::Ok => ("success", "*", "Account registered."),
|
RegOutcome::Ok => ("success", "*", "Account registered."),
|
||||||
RegOutcome::Exists => ("error", "ACCOUNT_EXISTS", "That account name is already 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::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."),
|
RegOutcome::Internal => ("error", "TEMPORARILY_UNAVAILABLE", "Registration is unavailable, try again later."),
|
||||||
};
|
};
|
||||||
vec![NetAction::AccountResponse {
|
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::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::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())],
|
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");
|
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
|
// OperServ session limiting: the connection that puts an IP over its limit is
|
||||||
// killed; SESSION inspects counts; EXCEPTION raises the allowance per IP-mask.
|
// killed; SESSION inspects counts; EXCEPTION raises the allowance per IP-mask.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue