From e6f05a3ede727af8ff37152da51ce24d196adbe6 Mon Sep 17 00:00:00 2001 From: Jean Date: Sat, 18 Jul 2026 01:15:23 +0000 Subject: [PATCH] protocol: learn channel-mode arity + prefix modes (incl custom Y/ojoin) from CAPAB CHANMODES; services MODE uses the authoritative set --- api/src/lib.rs | 49 ++++++++++++++++++++- modules/chanserv/src/mode.rs | 22 +++++----- modules/operserv/src/lib.rs | 2 +- modules/operserv/src/mode.rs | 12 +++--- modules/protocol/inspircd/src/lib.rs | 64 +++++++++++++++++++++++++--- src/engine/db/account.rs | 23 ++++++++++ src/engine/db/mod.rs | 5 ++- src/engine/db/store.rs | 6 +++ src/engine/mod.rs | 6 +++ 9 files changed, 163 insertions(+), 26 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 56ed8ad..dcf47a6 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -66,6 +66,9 @@ pub enum NetEvent { // 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 }, + // The ircd's live channel-mode set from its `CAPAB CHANMODES` burst — mode + // letters, param arity, and prefix (status) modes incl custom ones like `Y`. + ChanModeRegistry { modes: Vec }, // 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 // rather than as a QUIT per user. @@ -231,7 +234,7 @@ pub trait Protocol: Send { // The canonical arity for the standard chanmodes, shared by the protocol module // (parsing bursts) and services (building a MODE change): list and prefix modes // and the key take a parameter both ways; the rest that take one do so only when -// set. A single source of truth so the two never drift. +// set. Used as the fallback before the ircd's live CAPAB CHANMODES set is learned. pub fn chanmode_takes_param(m: char, adding: bool) -> bool { match m { 'b' | 'e' | 'I' | 'q' | 'a' | 'o' | 'h' | 'v' | 'k' => true, @@ -241,9 +244,42 @@ pub fn chanmode_takes_param(m: char, adding: bool) -> bool { } // The channel prefix (status) modes, whose parameter is a nick/uid rather than a -// mask or value. +// mask or value. Fallback before the ircd's live prefix set is learned. pub const STATUS_MODES: &str = "qaohv"; +/// How a channel mode consumes its parameter, from the ircd's `CAPAB CHANMODES`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChanModeKind { + List, // param on set + unset (b/e/I) + ParamAlways, // param on set + unset (k) + ParamSet, // param on set only (l) + Simple, // no param (n/t/s/...) + Prefix { symbol: char, rank: u32 }, // status mode: +v/@o/!Y, param is a nick +} + +/// One channel mode the linked ircd advertises: its letter and how it takes a +/// parameter. Learned from `CAPAB CHANMODES` so echo's mode handling matches the +/// ircd exactly (custom param modes, and prefix modes like ojoin's `Y`/`!`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChanModeCap { + pub letter: char, + pub kind: ChanModeKind, +} + +impl ChanModeCap { + /// Whether this mode consumes a parameter in the given direction. + pub fn takes_param(&self, adding: bool) -> bool { + match self.kind { + ChanModeKind::Simple => false, + ChanModeKind::ParamSet => adding, + _ => true, // list, param-always, prefix: a param both ways + } + } + pub fn is_prefix(&self) -> bool { + matches!(self.kind, ChanModeKind::Prefix { .. }) + } +} + // --------------------------------------------------------------------------- // Service vocabulary // --------------------------------------------------------------------------- @@ -1705,6 +1741,15 @@ pub trait Store { fn extbans(&self) -> Vec { Vec::new() } + // Whether channel mode `m` takes a parameter (direction-aware), from the ircd's + // advertised CHANMODES if known. Default is the static arity. + fn chanmode_takes_param(&self, m: char, adding: bool) -> bool { + chanmode_takes_param(m, adding) + } + // The channel prefix (status) mode letters the ircd advertises. Default static. + fn status_modes(&self) -> String { + STATUS_MODES.to_string() + } 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/mode.rs b/modules/chanserv/src/mode.rs index 0cffda8..f2a3fec 100644 --- a/modules/chanserv/src/mode.rs +++ b/modules/chanserv/src/mode.rs @@ -25,7 +25,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } // Reject an extban the network has turned off, before relaying anything. - for mask in ban_masks(&args[2..]) { + for mask in ban_masks(&args[2..], |m, adding| db.chanmode_takes_param(m, adding)) { 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 !db.extban_offered(eb.name) { @@ -44,9 +44,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } // The parameters attached to +b/-b/+e/-e/+I/-I in a ` [params...]` change. -// Every param-taking mode is accounted for (via the shared arity helper) so ban -// masks pair to the right mode in IRC order. -fn ban_masks<'a>(tokens: &'a [&'a str]) -> Vec<&'a str> { +// `takes_param` (the ircd's advertised arity) accounts for every param-taking mode +// so ban masks pair to the right mode in IRC order. +fn ban_masks<'a>(tokens: &'a [&'a str], takes_param: impl Fn(char, bool) -> bool) -> Vec<&'a str> { let modes = tokens.first().copied().unwrap_or(""); let mut params = tokens.iter().skip(1); let mut adding = true; @@ -55,7 +55,7 @@ fn ban_masks<'a>(tokens: &'a [&'a str]) -> Vec<&'a str> { match ch { '+' => adding = true, '-' => adding = false, - m if echo_api::chanmode_takes_param(m, adding) => { + m if takes_param(m, adding) => { if let Some(&p) = params.next() { if matches!(m, 'b' | 'e' | 'I') { out.push(p); @@ -75,14 +75,14 @@ mod tests { #[test] fn ban_masks_pairs_params_in_mode_order() { // A lone ban, and a ban after a prefix mode: the mask must pair to +b. - assert_eq!(ban_masks(&["+b", "account:x"]), ["account:x"]); - assert_eq!(ban_masks(&["+ob", "nick", "account:x"]), ["account:x"]); + assert_eq!(ban_masks(&["+b", "account:x"], echo_api::chanmode_takes_param), ["account:x"]); + assert_eq!(ban_masks(&["+ob", "nick", "account:x"], echo_api::chanmode_takes_param), ["account:x"]); // The key's value is not a ban mask even when it looks like an extban. - assert!(ban_masks(&["+k", "account:secret"]).is_empty()); + assert!(ban_masks(&["+k", "account:secret"], echo_api::chanmode_takes_param).is_empty()); // Exceptions and invex count; plain flag modes carry nothing. - assert_eq!(ban_masks(&["+eI", "R:a", "r:b"]), ["R:a", "r:b"]); - assert!(ban_masks(&["+nt"]).is_empty()); + assert_eq!(ban_masks(&["+eI", "R:a", "r:b"], echo_api::chanmode_takes_param), ["R:a", "r:b"]); + assert!(ban_masks(&["+nt"], echo_api::chanmode_takes_param).is_empty()); // Removal still carries a mask. - assert_eq!(ban_masks(&["-b", "*!*@h"]), ["*!*@h"]); + assert_eq!(ban_masks(&["-b", "*!*@h"], echo_api::chanmode_takes_param), ["*!*@h"]); } } diff --git a/modules/operserv/src/lib.rs b/modules/operserv/src/lib.rs index a66528f..9e64258 100644 --- a/modules/operserv/src/lib.rs +++ b/modules/operserv/src/lib.rs @@ -82,7 +82,7 @@ impl Service for OperServ { Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net), Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net), - Some(cmd) if cmd.eq_ignore_ascii_case("MODE") => mode::handle(me, from, args, ctx, net), + Some(cmd) if cmd.eq_ignore_ascii_case("MODE") => mode::handle(me, from, args, ctx, net, db), Some(cmd) if cmd.eq_ignore_ascii_case("IGNORE") => ignore::handle(me, from, args, ctx, db), Some(cmd) if cmd.eq_ignore_ascii_case("STATS") => stats::handle(me, from, db, ctx), Some(cmd) if cmd.eq_ignore_ascii_case("SVSNICK") => svs::nick(me, from, args, ctx, net), diff --git a/modules/operserv/src/mode.rs b/modules/operserv/src/mode.rs index df2c273..f533457 100644 --- a/modules/operserv/src/mode.rs +++ b/modules/operserv/src/mode.rs @@ -1,9 +1,9 @@ -use echo_api::{chanmode_takes_param, NetView, Priv, Sender, ServiceCtx, STATUS_MODES}; +use echo_api::{NetView, Priv, Sender, ServiceCtx, Store}; // MODE <#channel> [params]: set channel modes as a services override // (forced, so it applies regardless of the current TS). Admin-only. Status-mode // targets may be given as nicks — they're resolved to uids for the ircd. -pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) { +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) { if !from.privs.has(Priv::Admin) { ctx.notice(me, from.uid, "Access denied — MODE needs the \x02admin\x02 privilege."); return; @@ -19,17 +19,19 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: let params = &args[3..]; // Walk the change alongside its params: each param-taking mode consumes one, - // and a status mode's param is a nick we translate to its uid. + // and a status mode's param is a nick we translate to its uid. Both arity and + // the prefix set come from the ircd's advertised CHANMODES (incl custom modes). + let status_modes = db.status_modes(); let (mut adding, mut pi) = (true, 0); let mut out_params: Vec = Vec::new(); for m in modes.chars() { match m { '+' => adding = true, '-' => adding = false, - _ if chanmode_takes_param(m, adding) => { + _ if db.chanmode_takes_param(m, adding) => { if let Some(&p) = params.get(pi) { pi += 1; - if STATUS_MODES.contains(m) { + if status_modes.contains(m) { out_params.push(net.uid_by_nick(p).map(str::to_string).unwrap_or_else(|| p.to_string())); } else { out_params.push(p.to_string()); diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index b910a39..fe02bb1 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -312,19 +312,26 @@ impl Protocol for InspIrcd { } } // 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")) { + // extban / channel-mode sets. Other CAPAB subcommands are noise. + "CAPAB" => match tokens.next() { + Some(s) if 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![] } - } + Some(s) if s.eq_ignore_ascii_case("CHANMODES") => { + let modes: Vec<_> = trailing(rest).split_whitespace().filter_map(parse_chanmode_cap).collect(); + if modes.is_empty() { + vec![] + } else { + vec![NetEvent::ChanModeRegistry { modes }] + } + } + _ => vec![], + }, _ => vec![NetEvent::Unknown { line: line.to_string() }], } } @@ -555,6 +562,30 @@ fn parse_extban_cap(token: &str) -> Option { (!name.is_empty()).then(|| echo_api::ExtbanCap { name: name.to_string(), letter, acting }) } +// Parse one `CAPAB CHANMODES` token into a ChanModeCap: +// list:ban=b param:key=k param-set:limit=l simple:moderated=m +// prefix::op=@o (value is ) +fn parse_chanmode_cap(token: &str) -> Option { + use echo_api::ChanModeKind::*; + let (ty, spec) = token.split_once(':')?; + if ty == "prefix" { + let (rank, rest) = spec.split_once(':')?; + let (_name, val) = rest.split_once('=')?; + let (symbol, letter) = (val.chars().next()?, val.chars().nth(1)?); + return Some(echo_api::ChanModeCap { letter, kind: Prefix { symbol, rank: rank.parse().ok()? } }); + } + let (_name, val) = spec.split_once('=')?; + let letter = val.chars().next()?; + let kind = match ty { + "list" => List, + "param" => ParamAlways, + "param-set" => ParamSet, + "simple" => Simple, + _ => return None, + }; + Some(echo_api::ChanModeCap { letter, kind }) +} + fn trailing(rest: &str) -> String { match rest.find(" :") { Some(i) => rest[i + 2..].to_string(), @@ -620,6 +651,27 @@ mod tests { assert!(p.parse("CAPAB CHANMODES :ban=b").is_empty(), "other CAPAB subcommands ignored"); } + // CAPAB CHANMODES surfaces mode arity + prefix modes, including a custom prefix + // like ojoin's Y (symbol !, rank 9000000) that the hardcoded set doesn't know. + #[test] + fn parses_capab_chanmodes() { + use echo_api::ChanModeKind::*; + let mut p = proto(); + let ev = p.parse("CAPAB CHANMODES :list:ban=b param-set:limit=l param:key=k prefix:30000:op=@o prefix:9000000:official-join=!Y simple:moderated=m"); + let [NetEvent::ChanModeRegistry { modes }] = ev.as_slice() else { panic!("{ev:?}") }; + let find = |c: char| modes.iter().find(|m| m.letter == c).copied(); + assert_eq!(find('b').unwrap().kind, List); + assert_eq!(find('l').unwrap().kind, ParamSet); + assert_eq!(find('k').unwrap().kind, ParamAlways); + assert_eq!(find('m').unwrap().kind, Simple); + assert_eq!(find('o').unwrap().kind, Prefix { symbol: '@', rank: 30000 }); + assert_eq!(find('Y').unwrap().kind, Prefix { symbol: '!', rank: 9000000 }, "custom ojoin prefix"); + // Arity: limit takes a param only when set; the key both ways. + assert!(find('l').unwrap().takes_param(true) && !find('l').unwrap().takes_param(false)); + assert!(find('k').unwrap().takes_param(true) && find('k').unwrap().takes_param(false)); + assert!(find('Y').unwrap().is_prefix()); + } + // 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). #[test] diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 9eb4548..b7248c6 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -192,6 +192,29 @@ impl Db { self.live_extbans.clone().unwrap_or_default() } + /// Record the ircd's live channel-mode set (its CAPAB CHANMODES burst). + pub fn set_live_chanmodes(&mut self, modes: Vec) { + self.live_chanmodes = Some(modes); + } + + /// Whether channel mode `m` takes a parameter in the given direction — from + /// the ircd's advertised set if known, else the static fallback. + pub fn chanmode_takes_param(&self, m: char, adding: bool) -> bool { + match self.live_chanmodes.as_ref().and_then(|v| v.iter().find(|c| c.letter == m)) { + Some(cap) => cap.takes_param(adding), + None => echo_api::chanmode_takes_param(m, adding), + } + } + + /// The channel prefix (status) mode letters the ircd advertises (incl custom + /// ones like ojoin's `Y`), or the static fallback until we've linked. + pub fn status_modes(&self) -> String { + match &self.live_chanmodes { + Some(v) => v.iter().filter(|c| c.is_prefix()).map(|c| c.letter).collect(), + None => echo_api::STATUS_MODES.to_string(), + } + } + /// 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/mod.rs b/src/engine/db/mod.rs index ca7b761..8b67c9b 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -987,6 +987,9 @@ pub struct Db { // 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>, + // The ircd's live channel-mode set from its CAPAB CHANMODES burst (param arity + // + prefix modes, incl custom ones like ojoin's Y). None until we link. + live_chanmodes: 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. @@ -1087,7 +1090,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, 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 } + Self { accounts, channels, grouped, log, extban_enabled: None, live_extbans: None, live_chanmodes: 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 3489499..d834d11 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -66,6 +66,12 @@ impl Store for Db { fn extbans(&self) -> Vec { Db::extbans(self) } + fn chanmode_takes_param(&self, m: char, adding: bool) -> bool { + Db::chanmode_takes_param(self, m, adding) + } + fn status_modes(&self) -> String { + Db::status_modes(self) + } fn email_enabled(&self) -> bool { Db::email_enabled(self) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 1876e38..b0a4b2a 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1110,6 +1110,12 @@ impl Engine { self.db.set_live_extbans(entries); Vec::new() } + NetEvent::ChanModeRegistry { modes } => { + let prefixes = modes.iter().filter(|c| c.is_prefix()).map(|c| c.letter).collect::(); + tracing::info!(count = modes.len(), prefixes = %prefixes, "learned ircd channel-mode set"); + self.db.set_live_chanmodes(modes); + Vec::new() + } NetEvent::UserConnect { uid, nick, host, ip } => { let arriving_nick = nick.clone(); self.network.user_connect(uid.clone(), nick, host, ip.clone());