extban: learn the ircd live set from CAPAB EXTBANS and validate AKICK/MODE against what it actually offers
This commit is contained in:
parent
da94142bad
commit
c990eb1e1e
8 changed files with 95 additions and 1 deletions
|
|
@ -63,6 +63,9 @@ pub enum NetEvent {
|
||||||
// A user's TLS client-certificate fingerprint (ircd `ssl_cert` metadata), for
|
// A user's TLS client-certificate fingerprint (ircd `ssl_cert` metadata), for
|
||||||
// the `fingerprint` extban. Empty `fp` means the user has no cert.
|
// the `fingerprint` extban. Empty `fp` means the user has no cert.
|
||||||
UserCert { uid: String, fp: String },
|
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
|
// 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
|
// behind any server in its subtree — is gone, since a split is signalled once
|
||||||
// rather than as a QUIT per user.
|
// 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
|
/// 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
|
/// recognized extban with its value (`Ext`), or something that is neither
|
||||||
/// (`Unknown` — rejected at AKICK ADD).
|
/// (`Unknown` — rejected at AKICK ADD).
|
||||||
|
|
@ -1541,6 +1555,11 @@ pub trait Store {
|
||||||
fn extban_enabled(&self, _name: &str) -> bool {
|
fn extban_enabled(&self, _name: &str) -> bool {
|
||||||
true
|
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 exists(&self, name: &str) -> bool;
|
||||||
fn account(&self, name: &str) -> Option<AccountView>;
|
fn account(&self, name: &str) -> Option<AccountView>;
|
||||||
// Canonical account name for a nick (following a grouping), if registered.
|
// Canonical account name for a nick (following a grouping), if registered.
|
||||||
|
|
|
||||||
|
|
@ -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."));
|
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't a host mask or a known extban."));
|
||||||
return;
|
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) => {
|
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));
|
ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
for mask in ban_masks(&args[2..]) {
|
for mask in ban_masks(&args[2..]) {
|
||||||
let core = mask.strip_prefix('!').unwrap_or(mask); // extbans may be inverted
|
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 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) {
|
if !db.extban_enabled(eb.name) {
|
||||||
ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name));
|
ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,20 @@ impl Protocol for InspIrcd {
|
||||||
_ => vec![NetEvent::Unknown { line: line.to_string() }],
|
_ => 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() }],
|
_ => 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.
|
// 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 {
|
fn trailing(rest: &str) -> String {
|
||||||
match rest.find(" :") {
|
match rest.find(" :") {
|
||||||
Some(i) => rest[i + 2..].to_string(),
|
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");
|
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:
|
// 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).
|
// the uplink handshake (name password sid) and a sourced downstream link (name sid).
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,17 @@ impl Db {
|
||||||
self.extban_enabled.as_ref().is_none_or(|set| set.contains(&name.to_ascii_lowercase()))
|
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.
|
/// Display name used in email templates.
|
||||||
pub fn email_brand(&self) -> &str {
|
pub fn email_brand(&self) -> &str {
|
||||||
&self.email_brand
|
&self.email_brand
|
||||||
|
|
|
||||||
|
|
@ -965,6 +965,9 @@ pub struct Db {
|
||||||
log: EventLog,
|
log: EventLog,
|
||||||
// Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows.
|
// Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows.
|
||||||
extban_enabled: Option<std::collections::HashSet<String>>,
|
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.
|
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
||||||
pub(crate) scram_iterations: u32,
|
pub(crate) scram_iterations: u32,
|
||||||
// Whether outbound email is configured, so email features can gate themselves.
|
// 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);
|
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, 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
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ impl Store for Db {
|
||||||
fn extban_enabled(&self, name: &str) -> bool {
|
fn extban_enabled(&self, name: &str) -> bool {
|
||||||
Db::extban_allowed(self, name)
|
Db::extban_allowed(self, name)
|
||||||
}
|
}
|
||||||
|
fn extban_offered(&self, name: &str) -> bool {
|
||||||
|
Db::extban_offered(self, name)
|
||||||
|
}
|
||||||
fn email_enabled(&self) -> bool {
|
fn email_enabled(&self) -> bool {
|
||||||
Db::email_enabled(self)
|
Db::email_enabled(self)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1098,6 +1098,12 @@ impl Engine {
|
||||||
self.network.set_server_name(&sid, name);
|
self.network.set_server_name(&sid, name);
|
||||||
Vec::new()
|
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 } => {
|
NetEvent::UserConnect { uid, nick, host, ip } => {
|
||||||
let arriving_nick = nick.clone();
|
let arriving_nick = nick.clone();
|
||||||
self.network.user_connect(uid.clone(), nick, host, ip.clone());
|
self.network.user_connect(uid.clone(), nick, host, ip.clone());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue