From f7c38cedad9be5a10fda28bbb26d16e6425f416c Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 21:06:14 +0000 Subject: [PATCH] chanserv: full AKICK extban registry, [extban] enabled gate, passive extbans pushed as channel bans for the ircd to enforce --- api/src/lib.rs | 169 +++++++++++++++++++++++--------- modules/chanserv/src/akick.rs | 48 ++++++--- modules/chanserv/src/enforce.rs | 6 ++ src/config.rs | 12 +++ src/engine/db/account.rs | 13 +++ src/engine/db/mod.rs | 4 +- src/engine/db/store.rs | 3 + src/engine/db/tests.rs | 21 +++- src/engine/state.rs | 10 ++ src/main.rs | 3 + 10 files changed, 224 insertions(+), 65 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 4a897a7..372e15e 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1022,70 +1022,119 @@ pub struct BanTarget<'a> { pub ip: &'a str, pub gecos: &'a str, // real name pub account: Option<&'a str>, + pub server: &'a str, // the user's server name (for the server extban) + pub fingerprint: Option<&'a str>, // TLS client-cert fingerprint (for the fingerprint extban) + pub channels: Vec, // channels the user is in (for the channel extban) } -/// How echo interprets an AKICK mask. A plain `nick!user@host` glob is `Host`; -/// the rest are the InspIRCd matching-extbans echo has the s2s data to match (by -/// name `account:`… or by letter `R:`…). `Realmask` is the `nick!user@host+realname` -/// form (letter `a`). `Other` is any extban echo has no per-user data for (country, -/// class, …) — rejected at AKICK ADD. +/// How echo can match a user against an extban — or `Passive` when the ircd +/// advertises the extban but the s2s protocol gives echo no per-user data for it +/// (country, class, …). Passive extbans are still known and settable; the ircd +/// enforces the resulting `+b`, echo just can't proactively kick for them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtKind { + Account, // R — the user's services account + Unauthed, // U — not logged in, and the hostmask matches + Realname, // r — the real name (gecos) + Realmask, // a — + + Server, // s — the user's server name + Fingerprint, // z — the TLS client-cert fingerprint + Channel, // j — the user is in a matching channel + Passive, +} + +/// One InspIRCd matching-extban echo knows about, keyed by its `name` (stable) and +/// `letter` (network-configured). The full set matches this ircd's `EXTBAN=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExtBan { + pub name: &'static str, + pub letter: char, + pub kind: ExtKind, +} + +/// Every InspIRCd matching-extban, name + letter + how echo matches it. The +/// admin's `[extban] enabled` list picks which of these AKICK accepts. +pub const EXTBANS: &[ExtBan] = &[ + ExtBan { name: "account", letter: 'R', kind: ExtKind::Account }, + ExtBan { name: "unauthed", letter: 'U', kind: ExtKind::Unauthed }, + ExtBan { name: "realname", letter: 'r', kind: ExtKind::Realname }, + ExtBan { name: "realmask", letter: 'a', kind: ExtKind::Realmask }, + ExtBan { name: "server", letter: 's', kind: ExtKind::Server }, + ExtBan { name: "fingerprint", letter: 'z', kind: ExtKind::Fingerprint }, + ExtBan { name: "channel", letter: 'j', kind: ExtKind::Channel }, + // Recognized but data-less over s2s — settable, ircd-enforced (see ExtKind). + ExtBan { name: "country", letter: 'G', kind: ExtKind::Passive }, + ExtBan { name: "asn", letter: 'b', kind: ExtKind::Passive }, + ExtBan { name: "class", letter: 'n', kind: ExtKind::Passive }, + ExtBan { name: "gateway", letter: 'w', kind: ExtKind::Passive }, + ExtBan { name: "bot", letter: 'B', kind: ExtKind::Passive }, + ExtBan { name: "oper", letter: 'o', kind: ExtKind::Passive }, + ExtBan { name: "opertype", letter: 'O', kind: ExtKind::Passive }, + ExtBan { name: "securitygroup", letter: 'g', kind: ExtKind::Passive }, + ExtBan { name: "score", letter: 'y', kind: ExtKind::Passive }, + ExtBan { name: "team", letter: 't', kind: ExtKind::Passive }, + ExtBan { name: "redirect", letter: 'd', kind: ExtKind::Passive }, +]; + +impl ExtBan { + /// Find an extban by its name (case-insensitive) or single letter (case- + /// sensitive: R=account, r=realname, a=realmask, s=server, …). + pub fn lookup(token: &str) -> Option<&'static ExtBan> { + let mut chars = token.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => EXTBANS.iter().find(|e| e.letter == c), + _ => EXTBANS.iter().find(|e| e.name.eq_ignore_ascii_case(token)), + } + } + /// Whether echo can match a live user against this extban (has the s2s data). + pub fn matchable(&self) -> bool { + !matches!(self.kind, ExtKind::Passive) + } +} + +/// 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). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AkickMask<'a> { Host(&'a str), - Account(&'a str), - Unauthed(&'a str), - Realname(&'a str), - Realmask(&'a str, &'a str), // (hostmask, realname-glob) - Other(&'a str), + Ext(&'static ExtBan, &'a str), + Unknown(&'a str), } impl<'a> AkickMask<'a> { - /// Parse a stored/entered AKICK mask. Extbans are `[name|letter]:value` whose - /// name holds no `!`/`@` (so an IPv6 host in a normal mask isn't mistaken for - /// one). Letters are case-sensitive (R = account, r = realname, a = realmask). + /// Parse a mask. An extban is `[name|letter]:value` whose name holds no `!`/`@` + /// (so an IPv6 host in a normal mask isn't mistaken for one). pub fn parse(mask: &'a str) -> AkickMask<'a> { let Some((name, value)) = mask.split_once(':').filter(|(n, _)| !n.is_empty() && !n.contains(['!', '@'])) else { return AkickMask::Host(mask); }; - let letter = if name.len() == 1 { - name.chars().next().unwrap() - } else { - match name.to_ascii_lowercase().as_str() { - "account" => 'R', - "unauthed" => 'U', - "realname" => 'r', - "realmask" => 'a', - _ => return AkickMask::Other(name), - } - }; - match letter { - 'R' => AkickMask::Account(value), - 'U' => AkickMask::Unauthed(value), - 'r' => AkickMask::Realname(value), - // realmask value is `+` (m_realnameban). - 'a' => match value.split_once('+') { - Some((hm, real)) => AkickMask::Realmask(hm, real), - None => AkickMask::Other(name), - }, - _ => AkickMask::Other(name), + match ExtBan::lookup(name) { + Some(eb) => AkickMask::Ext(eb, value), + None => AkickMask::Unknown(name), } } } /// Whether an AKICK `mask` matches a live user `t` — a plain hostmask (globbed /// against the displayed-host, real-host and IP forms, as InspIRCd's CheckBan -/// does) or one of the matching-extbans echo has the data for. An `Other` extban -/// (no per-user data) never matches. +/// does) or a matching-extban echo has the data for. Passive/unknown never match. pub fn akick_matches(mask: &str, t: &BanTarget) -> bool { let hm = |host: &str| format!("{}!{}@{}", t.nick, t.ident, host); let host_hit = |g: &str| glob_match(g, &hm(t.host)) || glob_match(g, &hm(t.realhost)) || glob_match(g, &hm(t.ip)); match AkickMask::parse(mask) { AkickMask::Host(g) => host_hit(g), - AkickMask::Account(g) => t.account.is_some_and(|a| glob_match(g, a)), - AkickMask::Unauthed(g) => t.account.is_none() && host_hit(g), - AkickMask::Realname(g) => glob_match(g, t.gecos), - AkickMask::Realmask(hmask, real) => host_hit(hmask) && glob_match(real, t.gecos), - AkickMask::Other(_) => false, + AkickMask::Ext(eb, v) => match eb.kind { + ExtKind::Account => t.account.is_some_and(|a| glob_match(v, a)), + ExtKind::Unauthed => t.account.is_none() && host_hit(v), + ExtKind::Realname => glob_match(v, t.gecos), + ExtKind::Realmask => v.split_once('+').is_some_and(|(h, r)| host_hit(h) && glob_match(r, t.gecos)), + ExtKind::Server => glob_match(v, t.server), + ExtKind::Fingerprint => t.fingerprint.is_some_and(|f| glob_match(v, f)), + ExtKind::Channel => t.channels.iter().any(|c| glob_match(v, c)), + ExtKind::Passive => false, // ircd-enforced; echo has no data to match + }, + AkickMask::Unknown(_) => false, } } @@ -1480,6 +1529,12 @@ pub enum CodeKind { // service `&mut dyn Store`; the concrete implementation keeps its log, gossip // and credentials to itself. pub trait Store { + // Whether an extban of this name may be used in AKICK (the `[extban] enabled` + // config). The default is permissive — every extban echo knows; the live Db + // narrows it to the configured set. + fn extban_enabled(&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. @@ -1916,13 +1971,27 @@ mod tests { // split, and an extban echo has no data for. assert_eq!(AkickMask::parse("*!*@host"), AkickMask::Host("*!*@host")); assert_eq!(AkickMask::parse("*!*@2001:db8::1"), AkickMask::Host("*!*@2001:db8::1"), "ipv6 host isn't an extban"); - assert_eq!(AkickMask::parse("account:bad"), AkickMask::Account("bad")); - assert_eq!(AkickMask::parse("R:bad"), AkickMask::Account("bad"), "letter form"); - assert_eq!(AkickMask::parse("realname:*spam*"), AkickMask::Realname("*spam*")); - assert_eq!(AkickMask::parse("realmask:*!*@h+*bot*"), AkickMask::Realmask("*!*@h", "*bot*")); - assert!(matches!(AkickMask::parse("country:US"), AkickMask::Other("country"))); + assert!(matches!(AkickMask::parse("account:bad"), AkickMask::Ext(eb, "bad") if eb.name == "account")); + assert!(matches!(AkickMask::parse("R:bad"), AkickMask::Ext(eb, "bad") if eb.name == "account"), "letter form"); + assert!(matches!(AkickMask::parse("realname:*spam*"), AkickMask::Ext(eb, "*spam*") if eb.name == "realname")); + assert!(matches!(AkickMask::parse("realmask:*!*@h+*bot*"), AkickMask::Ext(eb, "*!*@h+*bot*") if eb.name == "realmask")); + // A passive extban (echo has no per-user data for it) still parses as a + // known extban — it just never matches. + assert!(matches!(AkickMask::parse("country:US"), AkickMask::Ext(eb, "US") if eb.name == "country")); + assert!(matches!(AkickMask::parse("nonsense:x"), AkickMask::Unknown("nonsense"))); - let t = BanTarget { nick: "n", ident: "u", host: "h.com", realhost: "real.h", ip: "1.2.3.4", gecos: "a spammer", account: Some("bad") }; + let t = BanTarget { + nick: "n", + ident: "u", + host: "h.com", + realhost: "real.h", + ip: "1.2.3.4", + gecos: "a spammer", + account: Some("bad"), + server: "irc.test", + fingerprint: Some("deadbeef"), + channels: vec!["#spam".to_string()], + }; assert!(akick_matches("*!*@h.com", &t), "displayed host"); assert!(akick_matches("*!*@real.h", &t), "real host too"); assert!(akick_matches("*!*@1.2.3.4", &t), "ip too"); @@ -1932,7 +2001,11 @@ mod tests { assert!(akick_matches("realmask:*!*@h.com+*spammer*", &t), "host + realname"); assert!(!akick_matches("realmask:*!*@nope+*spammer*", &t), "host part must also match"); assert!(!akick_matches("unauthed:*!*@h.com", &t), "logged-in user isn't unauthed"); - assert!(!akick_matches("country:US", &t), "no data -> never matches"); + assert!(akick_matches("server:irc.test", &t), "server name"); + assert!(akick_matches("fingerprint:deadbeef", &t), "tls fingerprint"); + assert!(akick_matches("channel:#spam", &t), "shares a banned channel"); + assert!(!akick_matches("channel:#clean", &t)); + assert!(!akick_matches("country:US", &t), "passive extban never matches"); let anon = BanTarget { account: None, ..t }; assert!(akick_matches("unauthed:*!*@h.com", &anon), "not logged in + host matches"); diff --git a/modules/chanserv/src/akick.rs b/modules/chanserv/src/akick.rs index f2ff114..b942454 100644 --- a/modules/chanserv/src/akick.rs +++ b/modules/chanserv/src/akick.rs @@ -1,10 +1,12 @@ use echo_api::Store; use echo_api::{Sender, ServiceCtx}; -// AKICK <#channel> ADD [reason] | DEL | LIST -// Masks are nick!user@host globs or an extban echo can match: account:, -// realname:, realmask:+, or unauthed:. Matching users -// are banned and kicked on join. +// AKICK <#channel> ADD [reason] | DEL | LIST | CLEAR +// A mask is a nick!user@host glob or any extban the ircd advertises (named or by +// letter). Extbans echo can evaluate itself (account, unauthed, realname, realmask, +// server, fingerprint, channel) get matched and kicked on join / ENFORCE; the rest +// are pushed to the channel as a ban for the ircd to enforce. The accepted set is +// narrowed by the `[extban] enabled` config. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { let Some(&chan) = args.get(1) else { ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD [reason] | DEL | LIST"); @@ -26,18 +28,33 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD [reason]"); return; }; - // Reject extbans echo has no per-user data to match (country, class, …), - // so we never store an akick that can't be enforced. - if let echo_api::AkickMask::Other(name) = echo_api::AkickMask::parse(mask) { - ctx.notice(me, from.uid, format!("Can't auto-kick by the \x02{name}\x02 extban. Use a host mask or \x02account:\x02 / \x02realname:\x02 / \x02realmask:\x02 / \x02unauthed:\x02.")); - return; + // Reject something that's neither a host mask nor a known extban, and + // any extban the network's `[extban] enabled` config has turned off. + match echo_api::AkickMask::parse(mask) { + echo_api::AkickMask::Unknown(name) => { + 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_enabled(eb.name) => { + ctx.notice(me, from.uid, format!("The \x02{}\x02 extban isn't enabled on this network.", eb.name)); + return; + } + _ => {} } if !super::require_op(me, from, chan, ctx, db) { return; } let reason = if args.len() > 4 { args[4..].join(" ") } else { "Auto-kicked".to_string() }; match db.akick_add(chan, mask, &reason) { - Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{mask}\x02 to \x02{chan}\x02's auto-kick list.")), + Ok(()) => { + // Extbans echo can't evaluate itself (country, asn, …) are pushed + // to the channel as a ban so the ircd enforces them; the ones echo + // matches are applied on join / ENFORCE and need no standing mode. + if matches!(echo_api::AkickMask::parse(mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + ctx.channel_mode(me, chan, &format!("+b {mask}")); + } + ctx.notice(me, from.uid, format!("Added \x02{mask}\x02 to \x02{chan}\x02's auto-kick list.")); + } Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } @@ -50,7 +67,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } match db.akick_del(chan, mask) { - Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{mask}\x02 from \x02{chan}\x02's auto-kick list.")), + Ok(true) => { + // Lift the standing ban we set for an ircd-enforced extban. + if matches!(echo_api::AkickMask::parse(mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + ctx.channel_mode(me, chan, &format!("-b {mask}")); + } + ctx.notice(me, from.uid, format!("Removed \x02{mask}\x02 from \x02{chan}\x02's auto-kick list.")); + } Ok(false) => ctx.notice(me, from.uid, format!("\x02{mask}\x02 isn't on \x02{chan}\x02's auto-kick list.")), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } @@ -63,6 +86,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: let masks: Vec = db.channel(chan).map_or_else(Vec::new, |info| info.akick.iter().map(|k| k.mask.clone()).collect()); for mask in &masks { let _ = db.akick_del(chan, mask); + if matches!(echo_api::AkickMask::parse(mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + ctx.channel_mode(me, chan, &format!("-b {mask}")); + } } ctx.notice(me, from.uid, format!("Cleared \x02{}\x02 entr{} from \x02{chan}\x02's auto-kick list.", masks.len(), if masks.len() == 1 { "y" } else { "ies" })); } diff --git a/modules/chanserv/src/enforce.rs b/modules/chanserv/src/enforce.rs index 1d00fae..97ff800 100644 --- a/modules/chanserv/src/enforce.rs +++ b/modules/chanserv/src/enforce.rs @@ -16,6 +16,12 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: return; }; ctx.channel_mode(me, chan, &info.lock_modes()); + // Re-assert the bans for extbans the ircd enforces (echo can't match them itself). + for k in &info.akick { + if matches!(echo_api::AkickMask::parse(&k.mask), echo_api::AkickMask::Ext(eb, _) if !eb.matchable()) { + ctx.channel_mode(me, chan, &format!("+b {}", k.mask)); + } + } let members: Vec = net.channel_members(chan); for uid in members { match net.account_of(&uid).and_then(|a| info.join_mode(a)) { diff --git a/src/config.rs b/src/config.rs index e1fe456..17f1ad0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,6 +45,18 @@ pub struct Config { // credentials (`kc_…` over SASL) are not honoured. #[serde(default)] pub keycard: Option, + // Which InspIRCd matching-extbans AKICK may use. Absent = every extban echo + // knows (full compatibility). List `enabled` to restrict it — e.g. drop the + // ones your ircd doesn't provide. + #[serde(default)] + pub extban: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Extban { + // Enabled extban names ("account", "realname", "country", …). Empty = all. + #[serde(default)] + pub enabled: Vec, } // Redeeming a website login keycard: a member already authenticated on the diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 90d5b49..23a5baa 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -163,6 +163,19 @@ impl Db { self.email_enabled = on; } + /// Restrict which extbans AKICK accepts to the configured set (`[extban] + /// enabled`). An empty list leaves the default (all extbans echo knows). + pub fn set_extban_enabled(&mut self, names: Vec) { + if !names.is_empty() { + self.extban_enabled = Some(names.into_iter().map(|n| n.to_ascii_lowercase()).collect()); + } + } + + /// Whether `name` is an enabled extban (all, unless narrowed by config). + pub fn extban_allowed(&self, name: &str) -> bool { + self.extban_enabled.as_ref().is_none_or(|set| set.contains(&name.to_ascii_lowercase())) + } + /// 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 40a6880..bea519a 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -963,6 +963,8 @@ pub struct Db { channels: HashMap, // keyed by casefolded name grouped: HashMap, // casefolded alias nick -> canonical account name log: EventLog, + // Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows. + extban_enabled: 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. @@ -1063,7 +1065,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(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false } + 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 } } /// 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 e0d8483..50cdc3c 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -54,6 +54,9 @@ impl Store for Db { fn channels_owned_by(&self, account: &str) -> Vec { Db::channels_owned_by(self, account) } + fn extban_enabled(&self, name: &str) -> bool { + Db::extban_allowed(self, name) + } fn email_enabled(&self) -> bool { Db::email_enabled(self) } diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index ac6eda1..4686df4 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -78,14 +78,25 @@ #[test] fn akick_add_del_and_match() { - let mut db = Db::open(&tmp("akick"), "N1"); + let mut db = Db::open(tmp("akick"), "N1"); db.register_channel("#c", "founder").unwrap(); db.akick_add("#c", "*!*@bad.host", "spam").unwrap(); // Also an account extban (the evasion-proof kind) and a realname extban. db.akick_add("#c", "account:baddie", "gone").unwrap(); db.akick_add("#c", "realname:*viagra*", "spam").unwrap(); let info = db.channel("#c").unwrap(); - let t = |nick, ident, host, gecos, account| echo_api::BanTarget { nick, ident, host, realhost: host, ip: "0.0.0.0", gecos, account }; + let t = |nick, ident, host, gecos, account| echo_api::BanTarget { + nick, + ident, + host, + realhost: host, + ip: "0.0.0.0", + gecos, + account, + server: "irc.test", + fingerprint: None, + channels: Vec::new(), + }; assert!(info.akick_match(&t("evil", "~e", "bad.host", "", None)).is_some(), "host mask"); assert!(info.akick_match(&t("good", "~g", "ok.host", "", None)).is_none()); assert!(info.akick_match(&t("x", "y", "ok.host", "", Some("baddie"))).is_some(), "account extban"); @@ -141,7 +152,7 @@ #[test] fn access_rank_orders_all_tiers() { use echo_api::Rank; - let mut db = Db::open(&tmp("rank"), "N1"); + let mut db = Db::open(tmp("rank"), "N1"); db.register_channel("#c", "boss").unwrap(); db.access_add("#c", "sop1", "sop").unwrap(); db.access_add("#c", "op1", "op").unwrap(); @@ -277,7 +288,7 @@ // be ground down online even though it is short. #[test] fn code_burns_after_too_many_wrong_guesses() { - let mut db = Db::open(&tmp("code"), "N1"); + let mut db = Db::open(tmp("code"), "N1"); db.scram_iterations = 4096; db.register("alice", "password", None).unwrap(); let code = db.issue_code("alice", CodeKind::Reset); @@ -290,7 +301,7 @@ // Password auth backs off after a few failures and the throttle clears on success. #[test] fn auth_throttle_locks_then_clears() { - let mut db = Db::open(&tmp("throttle"), "N1"); + let mut db = Db::open(tmp("throttle"), "N1"); for _ in 0..=AUTH_FREE_TRIES { db.note_auth("mallory", false); } diff --git a/src/engine/state.rs b/src/engine/state.rs index 6bc2407..7dba3bb 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -227,9 +227,19 @@ impl Network { ip: &u.ip, gecos: &u.gecos, account: self.accounts.get(uid).map(String::as_str), + // TODO(extban): capture the user's server + TLS fingerprint from the s2s + // stream to match the `server`/`fingerprint` extbans. + server: "", + fingerprint: None, + channels: self.channels_of(uid), }) } + /// The channels `uid` is currently in (for the `channel` extban). + pub fn channels_of(&self, uid: &str) -> Vec { + self.channels.iter().filter(|(_, c)| c.members.contains(uid)).map(|(k, _)| k.clone()).collect() + } + // Every known user whose uid carries `sid` as its prefix — i.e. those behind // a server, used to forget them all when it splits (SQUIT). pub fn uids_on_server(&self, sid: &str) -> Vec { diff --git a/src/main.rs b/src/main.rs index 64fcc19..c228393 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,6 +177,9 @@ async fn main() -> Result<()> { db.set_outbound(gossip_tx.clone()); db.set_email_enabled(cfg.email.is_some()); db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external)); + if let Some(extban) = &cfg.extban { + db.set_extban_enabled(extban.enabled.clone()); + } if let Some(email) = &cfg.email { db.set_email_brand(&email.brand); db.set_email_accent(&email.accent);