diff --git a/api/src/lib.rs b/api/src/lib.rs index f798b63..fb475c7 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -77,6 +77,10 @@ pub enum NetEvent { ModulesAvailable { modules: Vec }, // `CAPAB END` — the module lists are complete; verify dependencies now. CapabEnd, + // The ircd's `servprotect` user mode (from CAPAB USERMODES) and whether echo's + // configured service_modes request it — a services pseudoclient without it can + // be killed/hijacked by an operator. + ServProtect { letter: char, requested: bool }, // 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 1e82fe4..80f7278 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -347,6 +347,14 @@ impl Protocol for InspIrcd { vec![NetEvent::ModulesAvailable { modules }] } } + // CAPAB USERMODES: we only need servprotect (+k) — verify our own + // pseudoclients request it, so they can't be killed by operators. + Some(s) if s.eq_ignore_ascii_case("USERMODES") => { + match trailing(rest).split_whitespace().find_map(servprotect_letter) { + Some(letter) => vec![NetEvent::ServProtect { letter, requested: self.service_modes.contains(letter) }], + None => vec![], + } + } Some(s) if s.eq_ignore_ascii_case("END") => vec![NetEvent::CapabEnd], _ => vec![], }, @@ -585,6 +593,18 @@ fn parse_extban_cap(token: &str) -> Option { (!name.is_empty()).then(|| echo_api::ExtbanCap { name: name.to_string(), letter, acting }) } +// The mode letter of the `servprotect` user mode in a CAPAB USERMODES token +// (`simple:servprotect=k`), or None for any other mode. +fn servprotect_letter(token: &str) -> Option { + let (_ty, spec) = token.split_once(':')?; + let (name, val) = spec.split_once('=')?; + if name.eq_ignore_ascii_case("servprotect") { + val.chars().next() + } else { + None + } +} + // 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 { @@ -682,6 +702,18 @@ mod tests { assert!(p.parse("CAPAB CHANMODES :ban=b").is_empty(), "other CAPAB subcommands ignored"); } + // CAPAB USERMODES surfaces servprotect and whether our service_modes request it + // (proto()'s service_modes includes k). Only servprotect is extracted. + #[test] + fn parses_capab_usermodes_servprotect() { + let mut p = proto(); + assert!(matches!(p.parse("CAPAB USERMODES :simple:invisible=i simple:servprotect=k param:snomask=s").as_slice(), + [NetEvent::ServProtect { letter: 'k', requested: true }])); + assert!(matches!(p.parse("CAPAB USERMODES :simple:servprotect=Z").as_slice(), + [NetEvent::ServProtect { letter: 'Z', requested: false }], "char not in service_modes -> not requested")); + assert!(p.parse("CAPAB USERMODES :simple:invisible=i simple:wallops=w").is_empty(), "no servprotect -> nothing"); + } + // CAPAB MODULES/MODSUPPORT surface normalized module names; CAPAB END triggers // the dependency check. Names are stripped of m_/.so/=data. #[test] diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 97b855e..4ef4f53 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1124,6 +1124,14 @@ impl Engine { verify_ircd_dependencies(&self.network); Vec::new() } + NetEvent::ServProtect { letter, requested } => { + if requested { + tracing::info!(mode = %letter, "services are servprotected (+{letter})"); + } else { + tracing::warn!(mode = %letter, "service_modes does not request servprotect (+{letter}) — echo's pseudoclients can be killed or hijacked by operators; add it to [server] service_modes"); + } + 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.