diff --git a/api/src/lib.rs b/api/src/lib.rs index 92c9ced..168a637 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -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; 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) -> bool; fn account_note(&self, account: &str) -> Option; diff --git a/chanserv/src/lib.rs b/chanserv/src/lib.rs index 1d6384f..5850f54 100644 --- a/chanserv/src/lib.rs +++ b/chanserv/src/lib.rs @@ -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."); diff --git a/operserv/src/defcon.rs b/operserv/src/defcon.rs new file mode 100644 index 0000000..96d61c0 --- /dev/null +++ b/operserv/src/defcon.rs @@ -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::() { + 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", + } +} diff --git a/operserv/src/lib.rs b/operserv/src/lib.rs index 0eb588c..aabebbb 100644 --- a/operserv/src/lib.rs +++ b/operserv/src/lib.rs @@ -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 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 | 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 (announce to everyone), \x02KILL\x02 [reason] (disconnect a user), \x02KICK\x02 <#chan> [reason], \x02MODE\x02 <#chan> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 , \x02SVSJOIN\x02 <#chan> [key], \x02INFO\x02 ADD|DEL (staff notes), \x02NEWS\x02 ADD|DEL|LIST (announcements), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log), \x02DEFCON\x02 [1-5] (network defence level)."); } diff --git a/src/engine/db.rs b/src/engine/db.rs index 6e2d113..32ecf5f 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -982,6 +982,9 @@ pub struct Db { // so they aren't federated (that would collide SIDs across nodes). jupes: Vec, 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) -> bool { Db::set_account_note(self, account, note) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 2496712..ed4d7c2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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 { - 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> { + 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 ("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 vec![notice(format!("\x02{nick}\x02 is already registered. If it's yours, use \x02IDENTIFY \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]