diff --git a/api/src/lib.rs b/api/src/lib.rs index 8fca025..c952f47 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1577,6 +1577,11 @@ pub trait Store { fn extban_lookup(&self, _token: &str) -> Option { None } + // The ircd's live extban set (its CAPAB EXTBANS burst), for HELP to list what + // this network actually offers. Empty until we link. + fn extbans(&self) -> Vec { + Vec::new() + } fn exists(&self, name: &str) -> bool; fn account(&self, name: &str) -> Option; // Canonical account name for a nick (following a grouping), if registered. diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index 9b99410..2392665 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -61,7 +61,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "OWNER/PROTECT/HALFOP", summary: "give or take higher status", detail: "Syntax: \x02OWNER|PROTECT|HALFOP <#channel> [nick]\x02 (and \x02DEOWNER|DEPROTECT|DEHALFOP\x02)\nSets +q/+a/+h. OWNER is founder-only; PROTECT and HALFOP need op access." }, HelpEntry { cmd: "KICK", summary: "kick from the channel", detail: "Syntax: \x02KICK <#channel> [reason]\x02\nKicks a user from the channel." }, HelpEntry { cmd: "BAN/UNBAN", summary: "ban or unban a user", detail: "Syntax: \x02BAN <#channel> [reason]\x02, \x02UNBAN <#channel> [nick]\x02\nBans or unbans a user by host." }, - HelpEntry { cmd: "AKICK", summary: "manage the auto-kick list", detail: "Syntax: \x02AKICK <#channel> ADD [reason] | DEL | LIST | CLEAR\x02\nManages the auto-kick list; matches are banned and kicked on join. CLEAR empties it." }, + HelpEntry { cmd: "AKICK", summary: "manage the auto-kick list", detail: "Syntax: \x02AKICK <#channel> ADD [reason] | DEL | LIST | CLEAR\x02\nManages the auto-kick list; matches are banned and kicked on join. CLEAR empties it.\nA mask is a nick!user@host glob or an extban — type \x02/msg HelpServ HELP EXTBANS\x02 for the list." }, HelpEntry { cmd: "ENFORCE", summary: "re-apply lock and access", detail: "Syntax: \x02ENFORCE <#channel>\x02\nRe-applies the mode-lock, access, and akick list to everyone present. \x02SYNC\x02 is an alias." }, HelpEntry { cmd: "SYNC", summary: "re-apply access to members", detail: "Syntax: \x02SYNC <#channel>\x02\nRe-applies everyone's access status modes. An alias for \x02ENFORCE\x02." }, HelpEntry { cmd: "TOPIC", summary: "set the topic", detail: "Syntax: \x02TOPIC <#channel> \x02\nSets the channel topic." }, @@ -70,7 +70,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "GETKEY", summary: "show the channel key", detail: "Syntax: \x02GETKEY <#channel>\x02\nShows the channel key (+k), if one is set." }, HelpEntry { cmd: "SEEN", summary: "when a nick was last seen", detail: "Syntax: \x02SEEN \x02\nShows when a nick was last seen." }, HelpEntry { cmd: "CLONE", summary: "copy channel settings", detail: "Syntax: \x02CLONE \x02\nCopies one channel's settings to another you own." }, - HelpEntry { cmd: "MODE", summary: "set channel modes", detail: "Syntax: \x02MODE <#channel> \x02\nSets modes on the channel, e.g. MODE #chan +nt." }, + HelpEntry { cmd: "MODE", summary: "set channel modes", detail: "Syntax: \x02MODE <#channel> \x02\nSets modes on the channel, e.g. MODE #chan +nt. For extban ban types, see \x02/msg HelpServ HELP EXTBANS\x02." }, HelpEntry { cmd: "MLOCK", summary: "lock channel modes", detail: "Syntax: \x02MLOCK <#channel> [modes]\x02\nLocks modes set or unset, e.g. MLOCK #chan +nt-s." }, HelpEntry { cmd: "DROP", summary: "delete the registration", detail: "Syntax: \x02DROP <#channel>\x02\nDeletes the channel registration." }, HelpEntry { cmd: "SUSPEND/UNSUSPEND", summary: "suspend a channel (operator)", detail: "Syntax: \x02SUSPEND <#channel> [+expiry] [reason]\x02, \x02UNSUSPEND <#channel>\x02\nSuspends or unsuspends a channel. Operators only." }, diff --git a/modules/helpserv/src/extbans.rs b/modules/helpserv/src/extbans.rs new file mode 100644 index 0000000..a51bb45 --- /dev/null +++ b/modules/helpserv/src/extbans.rs @@ -0,0 +1,82 @@ +use echo_api::{Sender, ServiceCtx, Store}; + +// A friendly one-liner (with an example) for each extban, keyed by its ircd name. +// The letter and whether it's offered come live from the ircd's CAPAB set, so this +// only supplies the human description; names the ircd offers but we have no blurb +// for are still listed, just without the sentence. +const DESC: &[(&str, &str)] = &[ + // Matching extbans — they match *who* a user is. + ("account", "a logged-in account. e.g. +b account:baddie"), + ("unauthed", "users NOT logged in whose mask matches. e.g. +b unauthed:*!*@*.example"), + ("realname", "the real name (gecos). e.g. +b realname:*viagra*"), + ("realmask", "host and real name together, joined by +. e.g. +b realmask:*!*@*.tor+*bot*"), + ("server", "everyone on a server. e.g. +b server:leaf2.example.net"), + ("fingerprint", "a TLS client-certificate fingerprint. e.g. +b fingerprint:"), + ("channel", "users also in another channel. e.g. +b channel:#trolls"), + ("banlist", "users banned in another channel. e.g. +b banlist:#other"), + ("country", "users connecting from a country (GeoIP). e.g. +b country:RU"), + ("asn", "users on a network operator, by AS number. e.g. +b asn:12345"), + ("class", "users in a connect class. e.g. +b class:webchat"), + ("gateway", "users behind a web gateway. e.g. +b gateway:kiwiirc"), + ("bot", "users flagged as bots (+B). e.g. +b bot:*!*@*"), + ("oper", "IRC operators. e.g. +b oper:*"), + ("opertype", "a specific oper type. e.g. +b opertype:NetAdmin"), + ("securitygroup", "members of a security group. e.g. +b securitygroup:trusted"), + ("score", "users over a connection-score threshold. e.g. +b score:10"), + ("team", "members of an account team. e.g. +b team:staff"), + ("redirect", "ban and send them to another channel. e.g. +b redirect:#overflow:*!*@lots"), + // Acting extbans — they restrict *what* a user can do. + ("mute", "can stay and read but not speak. e.g. +b mute:*!*@spam.host"), + ("nonick", "cannot change nick in the channel. e.g. +b nonick:*!*@*"), + ("noctcp", "cannot send CTCPs. e.g. +b noctcp:*!*@*"), + ("nonotice", "cannot send channel notices. e.g. +b nonotice:*!*@*"), + ("nokick", "cannot use KICK. e.g. +b nokick:*!*@*"), + ("blockcolor", "colour/formatting in their messages is blocked. e.g. +b blockcolor:*!*@*"), + ("stripcolor", "colour is stripped from their messages. e.g. +b stripcolor:*!*@*"), + ("blockinvite", "cannot invite others. e.g. +b blockinvite:*!*@*"), + ("opmoderated", "their messages reach only ops. e.g. +b opmoderated:*!*@*"), +]; + +// HELP EXTBANS: explain extended bans and list the ones this network actually +// offers, each with an example, split into matching (who) and acting (what). +pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) { + let say = |ctx: &mut ServiceCtx, s: String| ctx.notice(me, from.uid, s); + + say(ctx, "Extended bans (\x02extbans\x02) match users by more than nick!user@host.".to_string()); + say(ctx, "Use one where a ban mask goes: \x02/mode #chan +b \x02 (also +e/+I), or in \x02ChanServ AKICK\x02 (matching types only).".to_string()); + say(ctx, "Write them by name or letter — \x02account:bad\x02 is the same as \x02R:bad\x02 — and prefix \x02!\x02 to invert (match everyone except).".to_string()); + + let mut offered = db.extbans(); + offered.sort_by(|a, b| a.name.cmp(&b.name)); + let (acting, matching): (Vec<_>, Vec<_>) = offered.into_iter().partition(|e| e.acting); + + let line = |ctx: &mut ServiceCtx, e: &echo_api::ExtbanCap| { + let letter = e.letter.map_or_else(|| "-".to_string(), |c| c.to_string()); + match DESC.iter().find(|(n, _)| n.eq_ignore_ascii_case(&e.name)) { + Some((_, desc)) => say(ctx, format!(" \x02{}\x02 (\x02{}\x02) — {}", e.name, letter, desc)), + None => say(ctx, format!(" \x02{}\x02 (\x02{}\x02)", e.name, letter)), + } + }; + + if matching.is_empty() && acting.is_empty() { + // We haven't learned the ircd's set yet — show the reference from DESC. + say(ctx, "\x02Extban types\x02 (your network's exact set is confirmed on link):".to_string()); + for (name, desc) in DESC { + say(ctx, format!(" \x02{name}\x02 — {desc}")); + } + return; + } + if !matching.is_empty() { + say(ctx, "\x02Matching\x02 — match who a user is (work in AKICK and bans):".to_string()); + for e in &matching { + line(ctx, e); + } + } + if !acting.is_empty() { + say(ctx, "\x02Acting\x02 — restrict what a user can do (bans only):".to_string()); + for e in &acting { + line(ctx, e); + } + } + say(ctx, "ChanServ accepts only the extbans this network offers and hasn't disabled.".to_string()); +} diff --git a/modules/helpserv/src/lib.rs b/modules/helpserv/src/lib.rs index 571c686..f61b020 100644 --- a/modules/helpserv/src/lib.rs +++ b/modules/helpserv/src/lib.rs @@ -23,6 +23,8 @@ mod take; mod next; #[path = "close.rs"] mod close; +#[path = "extbans.rs"] +mod extbans; const BLURB: &str = "HelpServ is the help desk and the network's help index. Open a ticket with \x02REQUEST\x02; operators work the queue."; @@ -65,16 +67,22 @@ impl Service for HelpServ { Some("TAKE") | Some("ASSIGN") => take::handle(me, from, args.get(1).copied(), ctx, db), Some("NEXT") => next::handle(me, from, ctx, db), Some("CLOSE") | Some("RESOLVE") => close::handle(me, from, args.get(1).copied(), ctx, db), + // The extended-ban reference, listing this network's live extban set. + Some("EXTBANS") | Some("EXTBAN") => extbans::handle(me, from, ctx, db), // HELP [service [command]]: bare HELP is HelpServ's own; HELP // [command] fronts any other service's help through the shared renderer. - Some("HELP") | None => match args.get(1).and_then(|&s| net.service_help(s)) { - Some((blurb, topics)) => echo_api::help(me, from, ctx, blurb, topics, args.get(2).copied()), - None => { - echo_api::help(me, from, ctx, BLURB, TOPICS, args.get(1).copied()); - if args.get(1).is_none() { - ctx.notice(me, from.uid, format!("For a service's own commands, type \x02HELP \x02. Services: {}.", net.help_services().join(", "))); + Some("HELP") | None => match args.get(1).copied() { + Some(t) if t.eq_ignore_ascii_case("extbans") || t.eq_ignore_ascii_case("extban") => extbans::handle(me, from, ctx, db), + other => match other.and_then(|s| net.service_help(s)) { + Some((blurb, topics)) => echo_api::help(me, from, ctx, blurb, topics, args.get(2).copied()), + None => { + echo_api::help(me, from, ctx, BLURB, TOPICS, other); + if other.is_none() { + ctx.notice(me, from.uid, format!("For a service's own commands, type \x02HELP \x02. Services: {}.", net.help_services().join(", "))); + ctx.notice(me, from.uid, "For the extended ban types you can use, type \x02HELP EXTBANS\x02."); + } } - } + }, }, // Not a HelpServ command — maybe the name of a service to get help for. Some(_) => match net.service_help(args[0]) { diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 8853f3f..9eb4548 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -187,6 +187,11 @@ impl Db { self.live_extbans.as_ref().is_none_or(|set| set.iter().any(|e| e.name.eq_ignore_ascii_case(name))) } + /// The ircd's live extban set (empty until we link and get its CAPAB burst). + pub fn extbans(&self) -> Vec { + self.live_extbans.clone().unwrap_or_default() + } + /// Resolve an extban token (name, case-insensitive; or single letter, case- /// sensitive) against the ircd's live set. For extbans echo's static table lacks. pub fn extban_lookup(&self, token: &str) -> Option { diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 7e8d852..c848976 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -63,6 +63,9 @@ impl Store for Db { fn extban_lookup(&self, token: &str) -> Option { Db::extban_lookup(self, token) } + fn extbans(&self) -> Vec { + Db::extbans(self) + } fn email_enabled(&self) -> bool { Db::email_enabled(self) }