diff --git a/src/engine/mod.rs b/src/engine/mod.rs index aabc09a..03e960b 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1202,6 +1202,23 @@ impl Engine { self.services.iter().any(|s| s.uid() == uid) || self.bot_uids.values().any(|v| v == uid) } + // Restore a user's host after they deoper: their services vhost if one is + // assigned, otherwise the connect-time cloak the oper vhost had replaced (the + // ircd doesn't put it back itself). An `ident@host` vhost also sets the ident. + fn restore_host_after_deoper(&self, uid: &str) -> Vec { + let vhost = self.network.account_of(uid).and_then(|a| self.db.active_vhost(a)); + let Some(host) = vhost.or_else(|| self.network.host_of(uid).map(str::to_string)) else { + return Vec::new(); + }; + match host.split_once('@') { + Some((ident, h)) => vec![ + NetAction::SetIdent { uid: uid.to_string(), ident: ident.to_string() }, + NetAction::SetHost { uid: uid.to_string(), host: h.to_string() }, + ], + None => vec![NetAction::SetHost { uid: uid.to_string(), host }], + } + } + pub fn startup_actions(&mut self) -> Vec { // Fresh link: our pseudo-clients (re)sign on now — remember it for IDLE. self.services_signon = self.now_secs(); @@ -1618,7 +1635,13 @@ impl Engine { out } NetEvent::UserMode { uid, modes } => { - self.notify_line('u', &uid, None, &format!("set user mode {modes}")).into_iter().collect() + let mut out: Vec = self.notify_line('u', &uid, None, &format!("set user mode {modes}")).into_iter().collect(); + // On deoper the ircd leaves the oper vhost applied; put back the user's + // services vhost, or the connect-time cloak if they have none (#462). + if !self.is_own_client(&uid) && mode_removed(&modes, 'o') { + out.extend(self.restore_host_after_deoper(&uid)); + } + out } NetEvent::OperUp { uid, oper_type } => { let what = if oper_type.is_empty() { "opered up".to_string() } else { format!("opered up as \x02{oper_type}\x02") }; @@ -2100,6 +2123,20 @@ fn audit_summary(event: &db::Event) -> Option { // Rewrite the source uid of an action from `old` to `new`, where the variant // carries a `from`. Used to make fantasy output appear to come from the bot. +// True if an IRC mode string (e.g. "+i-o") removes `letter`. +fn mode_removed(modes: &str, letter: char) -> bool { + let mut adding = true; + for c in modes.chars() { + match c { + '+' => adding = true, + '-' => adding = false, + l if l == letter && !adding => return true, + _ => {} + } + } + false +} + fn resource_action(a: &mut NetAction, old: &str, new: &str) { let from = match a.inner_mut() { NetAction::Notice { from, .. } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 020dc33..2185246 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5293,6 +5293,19 @@ } // Logging out reverses a services vhost, restoring the connect-time host (the + // When an oper deopers, the ircd leaves the oper vhost in place; echo puts the + // connect-time cloak back (or the services vhost, if one is assigned). + #[test] + fn deoper_restores_the_connect_cloak() { + let mut e = engine_with("deop", "alice", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "bob".into(), host: "cloak.example".into(), ip: "0.0.0.0".into() }); + let out = e.handle(NetEvent::UserMode { uid: "000AAAAAB".into(), modes: "-o".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAB" && host == "cloak.example")), "deoper restores cloak: {out:?}"); + // Opering up (+o) is not a restore. + let out = e.handle(NetEvent::UserMode { uid: "000AAAAAB".into(), modes: "+o".into() }); + assert!(!out.iter().any(|a| matches!(a, NetAction::SetHost { .. })), "operup does not restore: {out:?}"); + } + // cloak we were given at UserConnect), not the real host. #[test] fn logout_restores_the_connect_host_over_a_vhost() {