extban: learn the ircd live set from CAPAB EXTBANS and validate AKICK/MODE against what it actually offers

This commit is contained in:
Jean Chevronnet 2026-07-17 21:47:02 +00:00
parent da94142bad
commit c990eb1e1e
No known key found for this signature in database
8 changed files with 95 additions and 1 deletions

View file

@ -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<ExtbanCap> },
// 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<char>,
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<AccountView>;
// Canonical account name for a nick (following a grouping), if registered.

View file

@ -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;

View file

@ -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;

View file

@ -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 — "<matching|acting>:<name>[=<letter>]" — into
// an ExtbanCap. Anything not shaped like that is skipped.
fn parse_extban_cap(token: &str) -> Option<echo_api::ExtbanCap> {
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]

View file

@ -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<echo_api::ExtbanCap>) {
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

View file

@ -965,6 +965,9 @@ pub struct Db {
log: EventLog,
// Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows.
extban_enabled: Option<std::collections::HashSet<String>>,
// 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<Vec<echo_api::ExtbanCap>>,
// 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

View file

@ -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)
}

View file

@ -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::<Vec<_>>().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());