chanserv: full AKICK extban registry, [extban] enabled gate, passive extbans pushed as channel bans for the ircd to enforce
This commit is contained in:
parent
9a197fca3d
commit
f7c38cedad
10 changed files with 224 additions and 65 deletions
169
api/src/lib.rs
169
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<String>, // 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 — <hostmask>+<realname>
|
||||
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 `<hostmask>+<realname>` (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<AccountView>;
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
use echo_api::Store;
|
||||
use echo_api::{Sender, ServiceCtx};
|
||||
|
||||
// AKICK <#channel> ADD <mask> [reason] | DEL <mask> | LIST
|
||||
// Masks are nick!user@host globs or an extban echo can match: account:<glob>,
|
||||
// realname:<glob>, realmask:<host>+<realname>, or unauthed:<host>. Matching users
|
||||
// are banned and kicked on join.
|
||||
// AKICK <#channel> ADD <mask> [reason] | DEL <mask> | 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 <mask> [reason] | DEL <mask> | 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 <mask> [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<String> = 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" }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = net.channel_members(chan);
|
||||
for uid in members {
|
||||
match net.account_of(&uid).and_then(|a| info.join_mode(a)) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,18 @@ pub struct Config {
|
|||
// credentials (`kc_…` over SASL) are not honoured.
|
||||
#[serde(default)]
|
||||
pub keycard: Option<Keycard>,
|
||||
// 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<Extban>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Extban {
|
||||
// Enabled extban names ("account", "realname", "country", …). Empty = all.
|
||||
#[serde(default)]
|
||||
pub enabled: Vec<String>,
|
||||
}
|
||||
|
||||
// Redeeming a website login keycard: a member already authenticated on the
|
||||
|
|
|
|||
|
|
@ -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<String>) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -963,6 +963,8 @@ pub struct Db {
|
|||
channels: HashMap<String, ChannelInfo>, // keyed by casefolded name
|
||||
grouped: HashMap<String, String>, // casefolded alias nick -> canonical account name
|
||||
log: EventLog,
|
||||
// Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows.
|
||||
extban_enabled: Option<std::collections::HashSet<String>>,
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ impl Store for Db {
|
|||
fn channels_owned_by(&self, account: &str) -> Vec<String> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue