protocol: verify echo ircd module dependencies from CAPAB MODULES at link, warning per-feature if one is missing rather than failing silently
All checks were successful
CI / check (push) Successful in 3m49s
All checks were successful
CI / check (push) Successful in 3m49s
This commit is contained in:
parent
0ca06247f8
commit
2f70b0c133
4 changed files with 97 additions and 0 deletions
|
|
@ -72,6 +72,11 @@ pub enum NetEvent {
|
||||||
// The ircd's `CASEMAPPING` from its `CAPAB CAPABILITIES` burst. echo folds
|
// The ircd's `CASEMAPPING` from its `CAPAB CAPABILITIES` burst. echo folds
|
||||||
// identifiers as ascii, so it verifies this matches and warns otherwise.
|
// identifiers as ascii, so it verifies this matches and warns otherwise.
|
||||||
Casemapping { name: String },
|
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<String> },
|
||||||
|
// `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
|
// 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
|
// behind any server in its subtree — is gone, since a split is signalled once
|
||||||
// rather than as a QUIT per user.
|
// rather than as a QUIT per user.
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,17 @@ impl Protocol for InspIrcd {
|
||||||
None => vec![],
|
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![],
|
||||||
},
|
},
|
||||||
_ => vec![NetEvent::Unknown { line: line.to_string() }],
|
_ => vec![NetEvent::Unknown { line: line.to_string() }],
|
||||||
|
|
@ -574,6 +585,14 @@ fn parse_extban_cap(token: &str) -> Option<echo_api::ExtbanCap> {
|
||||||
(!name.is_empty()).then(|| echo_api::ExtbanCap { name: name.to_string(), letter, acting })
|
(!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:
|
// Parse one `CAPAB CHANMODES` token into a ChanModeCap:
|
||||||
// list:ban=b param:key=k param-set:limit=l simple:moderated=m
|
// list:ban=b param:key=k param-set:limit=l simple:moderated=m
|
||||||
// prefix:<rank>:op=@o (value is <symbol><letter>)
|
// prefix:<rank>:op=@o (value is <symbol><letter>)
|
||||||
|
|
@ -663,6 +682,19 @@ mod tests {
|
||||||
assert!(p.parse("CAPAB CHANMODES :ban=b").is_empty(), "other CAPAB subcommands ignored");
|
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).
|
// CAPAB CAPABILITIES surfaces CASEMAPPING (echo verifies it's ascii).
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_capab_casemapping() {
|
fn parses_capab_casemapping() {
|
||||||
|
|
|
||||||
|
|
@ -1116,6 +1116,14 @@ impl Engine {
|
||||||
self.db.set_live_chanmodes(modes);
|
self.db.set_live_chanmodes(modes);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
NetEvent::ModulesAvailable { modules } => {
|
||||||
|
self.network.learn_modules(modules);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
NetEvent::CapabEnd => {
|
||||||
|
verify_ircd_dependencies(&self.network);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
NetEvent::Casemapping { name } => {
|
NetEvent::Casemapping { name } => {
|
||||||
// echo folds identifiers as ascii. Verify the ircd agrees, rather than
|
// echo folds identifiers as ascii. Verify the ircd agrees, rather than
|
||||||
// assuming it silently — a mismatch would desync account/channel identity.
|
// 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
|
// 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
|
// feed, or None for events that are private (memos), cosmetic self-service
|
||||||
// (greets, auto-join, personal settings), or otherwise not worth surfacing.
|
// (greets, auto-join, personal settings), or otherwise not worth surfacing.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ pub struct Network {
|
||||||
servers: HashMap<String, String>,
|
servers: HashMap<String, String>,
|
||||||
// SID -> server name, for the `server` extban. Rebuilt each link.
|
// SID -> server name, for the `server` extban. Rebuilt each link.
|
||||||
server_names: HashMap<String, String>,
|
server_names: HashMap<String, String>,
|
||||||
|
// 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<String>,
|
||||||
// Shared, namespaced stat counters any service contributes to (persisted via
|
// Shared, namespaced stat counters any service contributes to (persisted via
|
||||||
// StatsSet). Read by StatServ and the gRPC Stats API.
|
// StatsSet). Read by StatServ and the gRPC Stats API.
|
||||||
stats: BTreeMap<String, u64>,
|
stats: BTreeMap<String, u64>,
|
||||||
|
|
@ -161,6 +164,21 @@ impl Network {
|
||||||
self.server_names.insert(sid.to_string(), name);
|
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<String>) {
|
||||||
|
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
|
// Mirror the engine's declarative config operators, so services can enumerate
|
||||||
// staff. Called whenever the config oper set changes.
|
// staff. Called whenever the config oper set changes.
|
||||||
pub fn set_config_opers(&mut self, accounts: Vec<String>) {
|
pub fn set_config_opers(&mut self, accounts: Vec<String>) {
|
||||||
|
|
@ -292,6 +310,7 @@ impl Network {
|
||||||
pub fn clear_servers(&mut self) {
|
pub fn clear_servers(&mut self) {
|
||||||
self.servers.clear();
|
self.servers.clear();
|
||||||
self.server_names.clear();
|
self.server_names.clear();
|
||||||
|
self.ircd_modules.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_nick_change(&mut self, uid: &str, nick: String) {
|
pub fn user_nick_change(&mut self, uid: &str, nick: String) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue