diff --git a/api/src/lib.rs b/api/src/lib.rs index d27c618..f798b63 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -72,6 +72,11 @@ pub enum NetEvent { // The ircd's `CASEMAPPING` from its `CAPAB CAPABILITIES` burst. echo folds // identifiers as ascii, so it verifies this matches and warns otherwise. Casemapping { name: String }, + // Module names the ircd advertises (`CAPAB MODULES` / `MODSUPPORT`), normalized + // (no `m_`/`.so`/`=data`). Accumulated to verify echo's dependencies. + ModulesAvailable { modules: Vec }, + // `CAPAB END` — the module lists are complete; verify dependencies now. + CapabEnd, // 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. diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 7c28dcc..1e82fe4 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -337,6 +337,17 @@ impl Protocol for InspIrcd { None => vec![], } } + // CAPAB MODULES/MODSUPPORT :mod mod=data … — the ircd's loaded modules, + // which echo verifies its dependencies against. + Some(s) if s.eq_ignore_ascii_case("MODULES") || s.eq_ignore_ascii_case("MODSUPPORT") => { + let modules: Vec<_> = trailing(rest).split_whitespace().map(module_name).collect(); + if modules.is_empty() { + vec![] + } else { + vec![NetEvent::ModulesAvailable { modules }] + } + } + Some(s) if s.eq_ignore_ascii_case("END") => vec![NetEvent::CapabEnd], _ => vec![], }, _ => vec![NetEvent::Unknown { line: line.to_string() }], @@ -574,6 +585,14 @@ fn parse_extban_cap(token: &str) -> Option { (!name.is_empty()).then(|| echo_api::ExtbanCap { name: name.to_string(), letter, acting }) } +// Normalize a `CAPAB MODULES` token (`m_foo.so=data` / `foo=data` / `foo`) to the +// bare module name, matching the ircd's own naming. +fn module_name(token: &str) -> String { + let name = token.split_once('=').map_or(token, |(n, _)| n); + let name = name.strip_prefix("m_").unwrap_or(name); + name.strip_suffix(".so").unwrap_or(name).to_string() +} + // 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 ) @@ -663,6 +682,19 @@ mod tests { assert!(p.parse("CAPAB CHANMODES :ban=b").is_empty(), "other CAPAB subcommands ignored"); } + // CAPAB MODULES/MODSUPPORT surface normalized module names; CAPAB END triggers + // the dependency check. Names are stripped of m_/.so/=data. + #[test] + fn parses_capab_modules_and_end() { + let mut p = proto(); + let ev = p.parse("CAPAB MODULES :m_account.so=1.0 cban chghost=2 ircv3_ctctags"); + let [NetEvent::ModulesAvailable { modules }] = ev.as_slice() else { panic!("{ev:?}") }; + assert_eq!(modules, &["account", "cban", "chghost", "ircv3_ctctags"]); + assert!(matches!(p.parse("CAPAB MODSUPPORT :rline services").as_slice(), + [NetEvent::ModulesAvailable { modules }] if modules == &["rline", "services"])); + assert!(matches!(p.parse("CAPAB END").as_slice(), [NetEvent::CapabEnd])); + } + // CAPAB CAPABILITIES surfaces CASEMAPPING (echo verifies it's ascii). #[test] fn parses_capab_casemapping() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 2620011..97b855e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1116,6 +1116,14 @@ impl Engine { self.db.set_live_chanmodes(modes); Vec::new() } + NetEvent::ModulesAvailable { modules } => { + self.network.learn_modules(modules); + Vec::new() + } + NetEvent::CapabEnd => { + verify_ircd_dependencies(&self.network); + Vec::new() + } NetEvent::Casemapping { name } => { // echo folds identifiers as ascii. Verify the ircd agrees, rather than // assuming it silently — a mismatch would desync account/channel identity. @@ -1619,6 +1627,39 @@ fn event_category(event: &db::Event) -> &'static str { } } +// echo's ircd module dependencies: (module, critical?, the feature it enables). +const IRCD_DEPS: &[(&str, bool, &str)] = &[ + ("account", true, "account login tracking"), + ("services", true, "services-server privileges (SVS*, u-line)"), + ("rline", false, "regex realname bans (SNLINE)"), + ("chghost", false, "vhosts (HostServ)"), + ("chgident", false, "vidents (HostServ)"), + ("cban", false, "channel-name bans (CBAN)"), + ("ircv3_ctctags", false, "tag messages (GameServ)"), +]; + +// At CAPAB END, check the modules the ircd advertised against echo's dependencies, +// so a missing one is a clear diagnostic at link rather than a silent malfunction. +fn verify_ircd_dependencies(net: &Network) { + if net.module_count() == 0 { + return; // the ircd didn't advertise its modules — nothing to verify against + } + let mut missing = 0; + for &(module, critical, feature) in IRCD_DEPS { + if !net.has_module(module) { + missing += 1; + if critical { + tracing::error!(module, "REQUIRED ircd module not loaded — {feature} will not work"); + } else { + tracing::warn!(module, "ircd module not loaded — {feature} is unavailable"); + } + } + } + if missing == 0 { + tracing::info!(count = net.module_count(), "verified ircd module dependencies"); + } +} + // A one-line, human-readable summary of a logged event for the staff audit // feed, or None for events that are private (memos), cosmetic self-service // (greets, auto-join, personal settings), or otherwise not worth surfacing. diff --git a/src/engine/state.rs b/src/engine/state.rs index 9d55210..28b46af 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -25,6 +25,9 @@ pub struct Network { servers: HashMap, // SID -> server name, for the `server` extban. Rebuilt each link. server_names: HashMap, + // Module names the ircd advertised in its CAPAB burst. Rebuilt each link, used + // to verify echo's dependencies at CAPAB END. + ircd_modules: std::collections::HashSet, // Shared, namespaced stat counters any service contributes to (persisted via // StatsSet). Read by StatServ and the gRPC Stats API. stats: BTreeMap, @@ -161,6 +164,21 @@ impl Network { self.server_names.insert(sid.to_string(), name); } + // Accumulate ircd module names from a CAPAB MODULES/MODSUPPORT line. + pub fn learn_modules(&mut self, names: Vec) { + self.ircd_modules.extend(names); + } + + // Whether the ircd advertised module `name`. Empty set (never received) reads + // as unknown → callers should skip the dependency check. + pub fn has_module(&self, name: &str) -> bool { + self.ircd_modules.contains(name) + } + + pub fn module_count(&self) -> usize { + self.ircd_modules.len() + } + // Mirror the engine's declarative config operators, so services can enumerate // staff. Called whenever the config oper set changes. pub fn set_config_opers(&mut self, accounts: Vec) { @@ -292,6 +310,7 @@ impl Network { pub fn clear_servers(&mut self) { self.servers.clear(); self.server_names.clear(); + self.ircd_modules.clear(); } pub fn user_nick_change(&mut self, uid: &str, nick: String) {