From c990eb1e1e0a3d76ca0ef1da837dbbedecc638ec Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 21:47:02 +0000 Subject: [PATCH] extban: learn the ircd live set from CAPAB EXTBANS and validate AKICK/MODE against what it actually offers --- api/src/lib.rs | 19 ++++++++++++ modules/chanserv/src/akick.rs | 4 +++ modules/chanserv/src/mode.rs | 4 +++ modules/protocol/inspircd/src/lib.rs | 44 ++++++++++++++++++++++++++++ src/engine/db/account.rs | 11 +++++++ src/engine/db/mod.rs | 5 +++- src/engine/db/store.rs | 3 ++ src/engine/mod.rs | 6 ++++ 8 files changed, 95 insertions(+), 1 deletion(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 0b0cd40..54baabb 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -63,6 +63,9 @@ pub enum NetEvent { // A user's TLS client-certificate fingerprint (ircd `ssl_cert` metadata), for // the `fingerprint` extban. Empty `fp` means the user has no cert. UserCert { uid: String, fp: String }, + // The ircd's live extban set from its `CAPAB EXTBANS` burst — the authoritative + // list AKICK/MODE validate against, replacing echo's static guess. + ExtbanRegistry { entries: Vec }, // A server split away (SQUIT). `server` is its SID; every user behind it — and // behind any server in its subtree — is gone, since a split is signalled once // rather than as a QUIT per user. @@ -1098,6 +1101,17 @@ impl ExtBan { } } +/// One extban the linked ircd actually advertises in its `CAPAB EXTBANS` burst: +/// its `name`, optional `letter`, and whether it is an acting extban (mute, …) +/// rather than a matching one. This is the authoritative live set — echo uses it +/// to accept only the extbans this ircd really offers, instead of a static guess. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtbanCap { + pub name: String, + pub letter: Option, + pub acting: bool, +} + /// How echo interprets an AKICK mask: a plain `nick!user@host` glob (`Host`), a /// recognized extban with its value (`Ext`), or something that is neither /// (`Unknown` — rejected at AKICK ADD). @@ -1541,6 +1555,11 @@ pub trait Store { fn extban_enabled(&self, _name: &str) -> bool { true } + // Whether the linked ircd actually offers this extban (its CAPAB EXTBANS set). + // Permissive by default; the live Db answers from what the ircd advertised. + fn extban_offered(&self, _name: &str) -> bool { + true + } fn exists(&self, name: &str) -> bool; fn account(&self, name: &str) -> Option; // Canonical account name for a nick (following a grouping), if registered. diff --git a/modules/chanserv/src/akick.rs b/modules/chanserv/src/akick.rs index b942454..417ddec 100644 --- a/modules/chanserv/src/akick.rs +++ b/modules/chanserv/src/akick.rs @@ -35,6 +35,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't a host mask or a known extban.")); return; } + echo_api::AkickMask::Ext(eb, _) if !db.extban_offered(eb.name) => { + ctx.notice(me, from.uid, format!("This network's ircd doesn't offer the \x02{}\x02 extban.", eb.name)); + return; + } echo_api::AkickMask::Ext(eb, _) if !db.extban_enabled(eb.name) => { ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name)); return; diff --git a/modules/chanserv/src/mode.rs b/modules/chanserv/src/mode.rs index 8b8f718..70107f3 100644 --- a/modules/chanserv/src/mode.rs +++ b/modules/chanserv/src/mode.rs @@ -28,6 +28,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: for mask in ban_masks(&args[2..]) { let core = mask.strip_prefix('!').unwrap_or(mask); // extbans may be inverted if let echo_api::AkickMask::Ext(eb, _) = echo_api::AkickMask::parse(core) { + if !db.extban_offered(eb.name) { + ctx.notice(me, from.uid, format!("This network's ircd doesn't offer the \x02{}\x02 extban.", eb.name)); + return; + } if !db.extban_enabled(eb.name) { ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name)); return; diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 910d669..b910a39 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -311,6 +311,20 @@ impl Protocol for InspIrcd { _ => vec![NetEvent::Unknown { line: line.to_string() }], } } + // CAPAB EXTBANS :matching:account=R acting:mute=m … — the ircd's live + // extban set. Other CAPAB subcommands (MODULES, CHANMODES, …) are noise. + "CAPAB" => { + if tokens.next().is_some_and(|s| s.eq_ignore_ascii_case("EXTBANS")) { + let entries: Vec<_> = trailing(rest).split_whitespace().filter_map(parse_extban_cap).collect(); + if entries.is_empty() { + vec![] + } else { + vec![NetEvent::ExtbanRegistry { entries }] + } + } else { + vec![] + } + } _ => vec![NetEvent::Unknown { line: line.to_string() }], } } @@ -525,6 +539,22 @@ fn scan_status(modes: &str, params: &[&str], want: char) -> Vec<(bool, String)> } // The IRC "trailing" argument: everything after the first " :", else the last word. +// Parse one `CAPAB EXTBANS` token — ":[=]" — into +// an ExtbanCap. Anything not shaped like that is skipped. +fn parse_extban_cap(token: &str) -> Option { + let (ty, spec) = token.split_once(':')?; + let acting = match ty { + "acting" => true, + "matching" => false, + _ => return None, + }; + let (name, letter) = match spec.split_once('=') { + Some((n, l)) => (n, l.chars().next()), + None => (spec, None), + }; + (!name.is_empty()).then(|| echo_api::ExtbanCap { name: name.to_string(), letter, acting }) +} + fn trailing(rest: &str) -> String { match rest.find(" :") { Some(i) => rest[i + 2..].to_string(), @@ -576,6 +606,20 @@ mod tests { assert!(p.parse(":42S METADATA 0IRAAAAAB ssl_cert :VTrSe aabbccdd").is_empty(), "our own echo is ignored"); } + // CAPAB EXTBANS surfaces the ircd's live extban set: name, optional letter, + // and matching-vs-acting. Malformed tokens are skipped; other CAPAB lines don't. + #[test] + fn parses_capab_extbans() { + let mut p = proto(); + let ev = p.parse("CAPAB EXTBANS :matching:account=R acting:mute=m matching:noletter garbage"); + let [NetEvent::ExtbanRegistry { entries }] = ev.as_slice() else { panic!("{ev:?}") }; + assert_eq!(entries.len(), 3, "garbage token skipped"); + assert_eq!(entries[0], echo_api::ExtbanCap { name: "account".into(), letter: Some('R'), acting: false }); + assert_eq!(entries[1], echo_api::ExtbanCap { name: "mute".into(), letter: Some('m'), acting: true }); + assert_eq!(entries[2], echo_api::ExtbanCap { name: "noletter".into(), letter: None, acting: false }); + assert!(p.parse("CAPAB CHANMODES :ban=b").is_empty(), "other CAPAB subcommands ignored"); + } + // Both SERVER forms surface a ServerInfo (SID -> name) for the `server` extban: // the uplink handshake (name password sid) and a sourced downstream link (name sid). #[test] diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 23a5baa..14a788a 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -176,6 +176,17 @@ impl Db { self.extban_enabled.as_ref().is_none_or(|set| set.contains(&name.to_ascii_lowercase())) } + /// Record the ircd's live extban set (its CAPAB EXTBANS burst). + pub fn set_live_extbans(&mut self, entries: Vec) { + self.live_extbans = Some(entries); + } + + /// Whether this ircd actually offers `name`. True until the ircd tells us its + /// set (we don't second-guess before the burst). + pub fn extban_offered(&self, name: &str) -> bool { + self.live_extbans.as_ref().is_none_or(|set| set.iter().any(|e| e.name.eq_ignore_ascii_case(name))) + } + /// Display name used in email templates. pub fn email_brand(&self) -> &str { &self.email_brand diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index bea519a..3cf865d 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -965,6 +965,9 @@ pub struct Db { log: EventLog, // Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows. extban_enabled: Option>, + // The ircd's live extban set from its CAPAB EXTBANS burst. None until we link; + // once known, it's authoritative for what AKICK/MODE may set. + live_extbans: Option>, // PBKDF2 cost baked into new SCRAM verifiers; lowered by tests. pub(crate) scram_iterations: u32, // Whether outbound email is configured, so email features can gate themselves. @@ -1065,7 +1068,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, extban_enabled: None, 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(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false } + Self { accounts, channels, grouped, log, extban_enabled: None, live_extbans: None, 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(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false } } /// Fold an entry authored by another node into the store — the services-side diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 50cdc3c..39d9faf 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -57,6 +57,9 @@ impl Store for Db { fn extban_enabled(&self, name: &str) -> bool { Db::extban_allowed(self, name) } + fn extban_offered(&self, name: &str) -> bool { + Db::extban_offered(self, name) + } fn email_enabled(&self) -> bool { Db::email_enabled(self) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 5ec7a2e..b7e2d6c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1098,6 +1098,12 @@ impl Engine { self.network.set_server_name(&sid, name); Vec::new() } + NetEvent::ExtbanRegistry { entries } => { + let names = entries.iter().map(|e| e.name.as_str()).collect::>().join(" "); + tracing::info!(count = entries.len(), extbans = %names, "learned ircd extban set"); + self.db.set_live_extbans(entries); + Vec::new() + } NetEvent::UserConnect { uid, nick, host, ip } => { let arriving_nick = nick.clone(); self.network.user_connect(uid.clone(), nick, host, ip.clone());