diff --git a/api/src/lib.rs b/api/src/lib.rs index 5c09c2a..4a897a7 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -21,6 +21,10 @@ pub enum NetEvent { Idle { requester: String, target: String }, Privmsg { from: String, to: String, text: String }, UserConnect { uid: String, nick: String, host: String, ip: String }, + // The rest of a user's UID that UserConnect omits — ident, real host, and + // real name (gecos). The ircd sends them in the same UID line; extban + // matching (AKICK) needs them. Emitted right after UserConnect. + UserAttrs { uid: String, ident: String, realhost: String, gecos: String }, NickChange { uid: String, nick: String }, // The ircd tells us a user's logged-in account (METADATA accountname), e.g. // replayed on a services netburst so we can restore who was identified. An @@ -1008,6 +1012,83 @@ pub struct ChanAkickView { pub reason: String, } +/// A live user's identity, matched against an AKICK mask (plain host or extban). +/// Built by the engine from what the ircd told us in the UID + metadata. +pub struct BanTarget<'a> { + pub nick: &'a str, + pub ident: &'a str, + pub host: &'a str, // displayed host + pub realhost: &'a str, // real host + pub ip: &'a str, + pub gecos: &'a str, // real name + pub account: Option<&'a str>, +} + +/// 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. +#[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), +} + +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). + 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), + } + } +} + +/// 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. +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, + } +} + // A memo left for an account (MemoServ). #[derive(Debug, Clone)] pub struct MemoView { @@ -1345,9 +1426,9 @@ impl ChannelView { s } - // The first auto-kick entry whose mask matches the given hostmask. - pub fn akick_match(&self, hostmask: &str) -> Option<&ChanAkickView> { - self.akick.iter().find(|k| glob_match(&k.mask, hostmask)) + // The first auto-kick entry matching a live user (host mask or extban). + pub fn akick_match(&self, target: &BanTarget) -> Option<&ChanAkickView> { + self.akick.iter().find(|k| akick_matches(&k.mask, target)) } } @@ -1643,6 +1724,8 @@ pub trait NetView { fn nick_of(&self, uid: &str) -> Option<&str>; fn host_of(&self, uid: &str) -> Option<&str>; fn account_of(&self, uid: &str) -> Option<&str>; + // A user's identity for extban / AKICK matching, if known. + fn ban_target(&self, uid: &str) -> Option>; fn uids_logged_into(&self, account: &str) -> Vec; fn is_op(&self, channel: &str, uid: &str) -> bool; fn channel_members(&self, channel: &str) -> Vec; @@ -1827,6 +1910,35 @@ pub fn human_time(ts: u64) -> String { mod tests { use super::*; + #[test] + fn akick_extbans_parse_and_match() { + // Parse: hostmask fallback (incl IPv6), named + letter extbans, realmask + // 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"))); + + let t = BanTarget { nick: "n", ident: "u", host: "h.com", realhost: "real.h", ip: "1.2.3.4", gecos: "a spammer", account: Some("bad") }; + 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"); + assert!(akick_matches("account:bad", &t)); + assert!(!akick_matches("account:other", &t)); + assert!(akick_matches("realname:*spammer*", &t), "gecos"); + 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"); + + let anon = BanTarget { account: None, ..t }; + assert!(akick_matches("unauthed:*!*@h.com", &anon), "not logged in + host matches"); + assert!(!akick_matches("account:bad", &anon), "no account -> account extban can't match"); + } + #[test] fn flags_bitset_parses_applies_and_matches_presets() { // ACCESS_FLAGS is exactly the flag letters in order — one source of truth. diff --git a/modules/chanserv/src/akick.rs b/modules/chanserv/src/akick.rs index ea18342..f2ff114 100644 --- a/modules/chanserv/src/akick.rs +++ b/modules/chanserv/src/akick.rs @@ -2,7 +2,9 @@ use echo_api::Store; use echo_api::{Sender, ServiceCtx}; // AKICK <#channel> ADD [reason] | DEL | LIST -// Masks are nick!user@host globs; matching users are banned and kicked on join. +// 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. 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"); @@ -24,6 +26,12 @@ 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; + } if !super::require_op(me, from, chan, ctx, db) { return; } diff --git a/modules/chanserv/src/enforce.rs b/modules/chanserv/src/enforce.rs index 57413a4..1d00fae 100644 --- a/modules/chanserv/src/enforce.rs +++ b/modules/chanserv/src/enforce.rs @@ -21,9 +21,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: match net.account_of(&uid).and_then(|a| info.join_mode(a)) { Some(m) => ctx.channel_mode(me, chan, &status_mode(m, &uid)), None => { - let nick = net.nick_of(&uid).unwrap_or("*"); - let host = net.host_of(&uid).unwrap_or("*"); - if let Some(k) = info.akick_match(&format!("{nick}!*@{host}")) { + if let Some(k) = net.ban_target(&uid).and_then(|t| info.akick_match(&t)) { ctx.channel_mode(me, chan, &format!("+b {}", k.mask)); let reason = if k.reason.is_empty() { "You are banned from this channel." } else { &k.reason }; ctx.kick(me, chan, &uid, reason); diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 90a355c..a00326c 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -106,12 +106,20 @@ impl Protocol for InspIrcd { // … — take the displayed host for ban masks and the ip // (index 7) for session limiting. "UID" => { + // insp4: uuid nickts nick realhost disphost realuser dispuser ip signonts +modes[ params] :gecos let a: Vec<&str> = tokens.collect(); match (a.first(), a.get(2)) { (Some(uid), Some(nick)) => { + let realhost = a.get(3).unwrap_or(&"").to_string(); let host = a.get(4).unwrap_or(&"").to_string(); + let ident = a.get(6).unwrap_or(&"").to_string(); let ip = a.get(7).unwrap_or(&"").to_string(); - vec![NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string(), host, ip }] + // gecos is the trailing param (has spaces) — take it whole. + let gecos = rest.split_once(" :").map_or("", |(_, g)| g).to_string(); + vec![ + NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string(), host, ip }, + NetEvent::UserAttrs { uid: uid.to_string(), ident, realhost, gecos }, + ] } _ => vec![], } @@ -558,12 +566,17 @@ mod tests { assert!(matches!(ev.as_slice(), [NetEvent::ServerSplit { server }] if server == "0IR"), "{ev:?}"); } - // A UID burst introduces the user under their current nick. + // A UID burst introduces the user and, alongside, the identity fields the + // UID carries that UserConnect omits (ident, real host, gecos). #[test] fn parses_uid_burst() { - let ev = proto().parse(":0IR UID 0IRAAAAAB 1783833000 alice host host user user 0.0.0.0 1783833000 +i :real"); + let ev = proto().parse(":0IR UID 0IRAAAAAB 1783833000 alice real.host disp.host ruser duser 1.2.3.4 1783833000 +i :Real Name"); assert!( - matches!(ev.as_slice(), [NetEvent::UserConnect { uid, nick, .. }] if uid == "0IRAAAAAB" && nick == "alice"), + matches!(ev.as_slice(), [ + NetEvent::UserConnect { uid, nick, host, ip }, + NetEvent::UserAttrs { ident, realhost, gecos, .. }, + ] if uid == "0IRAAAAAB" && nick == "alice" && host == "disp.host" && ip == "1.2.3.4" + && ident == "duser" && realhost == "real.host" && gecos == "Real Name"), "{ev:?}" ); } diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 53de277..40a6880 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -625,9 +625,9 @@ impl ChannelInfo { .is_some_and(|a| echo_api::level_caps(&a.level).op) } - /// The matching auto-kick entry for `hostmask` (nick!user@host), if any. - pub fn akick_match(&self, hostmask: &str) -> Option<&ChanAkick> { - self.akick.iter().find(|k| glob_match(&k.mask, hostmask)) + /// The auto-kick entry matching a live user (plain host mask or extban), if any. + pub fn akick_match(&self, target: &echo_api::BanTarget) -> Option<&ChanAkick> { + self.akick.iter().find(|k| echo_api::akick_matches(&k.mask, target)) } /// The mode string services keep applied: +r plus the lock. diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index e247ccf..ac6eda1 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -81,12 +81,18 @@ 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(); - assert!(info.akick_match("evil!~e@bad.host").is_some()); - assert!(info.akick_match("good!~g@ok.host").is_none()); + let t = |nick, ident, host, gecos, account| echo_api::BanTarget { nick, ident, host, realhost: host, ip: "0.0.0.0", gecos, account }; + 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"); + assert!(info.akick_match(&t("x", "y", "ok.host", "cheap VIAGRA now", None)).is_some(), "realname extban"); + assert!(info.akick_match(&t("x", "y", "ok.host", "hello", None)).is_none()); assert!(db.akick_del("#c", "*!*@bad.host").unwrap()); assert!(!db.akick_del("#c", "*!*@bad.host").unwrap()); - assert!(db.channel("#c").unwrap().akick.is_empty()); } // The auto-join list adds, updates a key in place, removes case-insensitively, diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 9dd181d..e8aadcc 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1078,6 +1078,10 @@ impl Engine { Vec::new() } } + NetEvent::UserAttrs { uid, ident, realhost, gecos } => { + self.network.set_user_attrs(&uid, ident, realhost, gecos); + Vec::new() + } NetEvent::UserConnect { uid, nick, host, ip } => { let arriving_nick = nick.clone(); self.network.user_connect(uid.clone(), nick, host, ip.clone()); @@ -1218,13 +1222,17 @@ impl Engine { } // No access: an auto-kick match is banned and kicked, else greeted. None => { - let nick = self.network.nick_of(&uid).unwrap_or("*").to_string(); - let host = self.network.host_of(&uid).unwrap_or("*").to_string(); - let hostmask = format!("{nick}!*@{host}"); - let akick = self.db.channel(&channel).and_then(|c| c.akick_match(&hostmask)).map(|k| { - let reason = if k.reason.is_empty() { "You are banned from this channel.".to_string() } else { k.reason.clone() }; - (k.mask.clone(), reason) - }); + // Match the akick list against the user's full identity — + // host and the extbans (account/realname/…) the ircd gave us. + let akick = { + let target = self.network.ban_target(&uid); + target.as_ref().and_then(|t| { + self.db.channel(&channel).and_then(|c| c.akick_match(t)).map(|k| { + let reason = if k.reason.is_empty() { "You are banned from this channel.".to_string() } else { k.reason.clone() }; + (k.mask.clone(), reason) + }) + }) + }; match akick { Some((mask, reason)) => { self.network.channel_part(&channel, &uid); diff --git a/src/engine/state.rs b/src/engine/state.rs index 3cbdb7e..6bc2407 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -80,8 +80,11 @@ fn incident_code(seq: u64) -> String { pub struct User { pub uid: String, pub nick: String, - pub host: String, + pub ident: String, // user@ — from the UID (was discarded); empty until UserAttrs + pub host: String, // the displayed host + pub realhost: String, // the real host, for host bans against it pub ip: String, + pub gecos: String, // real name, for realname / realmask extbans } // A channel's live membership, ops, and current key (+k), tracked from the burst. @@ -129,7 +132,17 @@ impl Network { if !ip.is_empty() { *self.sessions.entry(ip.clone()).or_insert(0) += 1; } - self.users.insert(uid.clone(), User { uid, nick, host, ip }); + self.users.insert(uid.clone(), User { uid, nick, ident: String::new(), host, realhost: String::new(), ip, gecos: String::new() }); + } + + // Fill in the rest of a user's identity (ident, real host, real name), which + // the UID carries but UserConnect doesn't — needed for extban matching. + pub fn set_user_attrs(&mut self, uid: &str, ident: String, realhost: String, gecos: String) { + if let Some(u) = self.users.get_mut(uid) { + u.ident = ident; + u.realhost = realhost; + u.gecos = gecos; + } } // Mirror the engine's declarative config operators, so services can enumerate @@ -202,6 +215,21 @@ impl Network { self.users.get(uid).map(|u| u.host.as_str()) } + // A user's identity for extban / ban matching (nick, ident, hosts, ip, gecos, + // account). `None` if we don't know the uid. + pub fn ban_target<'a>(&'a self, uid: &str) -> Option> { + let u = self.users.get(uid)?; + Some(echo_api::BanTarget { + nick: &u.nick, + ident: &u.ident, + host: &u.host, + realhost: &u.realhost, + ip: &u.ip, + gecos: &u.gecos, + account: self.accounts.get(uid).map(String::as_str), + }) + } + // 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 { @@ -483,6 +511,9 @@ impl NetView for Network { fn nick_of(&self, uid: &str) -> Option<&str> { Network::nick_of(self, uid) } + fn ban_target(&self, uid: &str) -> Option> { + Network::ban_target(self, uid) + } fn host_of(&self, uid: &str) -> Option<&str> { Network::host_of(self, uid) }