diff --git a/api/src/lib.rs b/api/src/lib.rs index 3d376a8..52fba51 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -15,6 +15,10 @@ pub enum NetEvent { Registered, EndBurst, Ping { token: String, from: Option }, + // A remote server asks for one of our pseudo-clients' idle/signon time — the + // idle half of a routed WHOIS (`WHOIS x x` / mIRC's double-click). Must be + // answered or that WHOIS produces no output at all. + Idle { requester: String, target: String }, Privmsg { from: String, to: String, text: String }, UserConnect { uid: String, nick: String, host: String, ip: String }, NickChange { uid: String, nick: String }, @@ -66,6 +70,9 @@ pub enum NetAction { Burst, EndBurst, Pong { token: String, from: Option }, + // Answer a remote IDLE request for one of our pseudo-clients so a routed + // WHOIS completes: `: IDLE `. + IdleReply { target: String, requester: String, signon: u64, idle: u64 }, IntroduceUser { uid: String, nick: String, ident: String, host: String, gecos: String }, Privmsg { from: String, to: String, text: String }, Notice { from: String, to: String, text: String }, diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 2348edb..c332f4e 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -78,6 +78,17 @@ impl Protocol for InspIrcd { .to_string(); vec![NetEvent::Ping { token, from: source }] } + "IDLE" => { + // `: IDLE ` — the idle half of a routed WHOIS. + // Multi-param IDLE is a reply (never sent to us); ignore it. + let target = tokens.next().unwrap_or("").to_string(); + match source { + Some(requester) if !target.is_empty() && tokens.next().is_none() => { + vec![NetEvent::Idle { requester, target }] + } + _ => vec![], + } + } "PRIVMSG" => { let to = tokens.next().unwrap_or("").to_string(); vec![NetEvent::Privmsg { @@ -279,6 +290,11 @@ impl Protocol for InspIrcd { let dest = from.clone().unwrap_or_else(|| self.sid.clone()); vec![self.sourced(format!("PONG {} {}", dest, token))] } + // Reply to a routed-WHOIS idle request, sourced from the target client: + // `: IDLE ` (m_spanningtree/idle.cpp). + NetAction::IdleReply { target, requester, signon, idle } => { + vec![format!(":{target} IDLE {requester} {signon} {idle}")] + } // insp4 UID: uuid nickts nick realhost disphost realuser dispuser ip signonts +modes :gecos // (both a real AND a displayed user field — see m_spanningtree/uid.cpp Builder). NetAction::IntroduceUser { uid, nick, ident, host, gecos } => vec![self.sourced(format!( diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b9e7298..3d323e2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -107,6 +107,7 @@ pub struct Engine { enforce_seq: u32, // counter appended to guest_nick pending_enforce: Vec, // registered nicks awaiting identify-or-rename bot_uids: HashMap, // casefolded bot nick -> live uid (per connection) + services_signon: u64, // burst time our pseudo-clients signed on, for WHOIS IDLE replies bot_idents: HashMap, // casefolded bot nick -> hash of user/host/gecos (to spot BOT CHANGE) next_bot_index: u32, bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined @@ -240,6 +241,7 @@ impl Engine { enforce_seq: 0, pending_enforce: Vec::new(), bot_uids: HashMap::new(), + services_signon: 0, bot_idents: HashMap::new(), next_bot_index: 0, bot_channels: std::collections::HashSet::new(), @@ -902,7 +904,15 @@ impl Engine { } // Sent right after the SERVER line: burst, introduce every service, endburst. + // Whether `uid` is one of our own pseudo-clients — a service or a bot — so we + // answer a WHOIS IDLE request for it (and only it). + fn is_own_client(&self, uid: &str) -> bool { + self.services.iter().any(|s| s.uid() == uid) || self.bot_uids.values().any(|v| v == uid) + } + 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(); // Fresh link: forget bot uids minted on any previous connection. self.bot_uids.clear(); self.bot_idents.clear(); @@ -955,6 +965,16 @@ impl Engine { let mut out = self.sweep_unbans(); let evout = match event { NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], + NetEvent::Idle { requester, target } => { + // A routed WHOIS (2-param / mIRC double-click) of one of our + // pseudo-clients asks us for its idle/signon; answer or that WHOIS + // shows nothing at all. Services are always active (idle 0). + if self.is_own_client(&target) { + vec![NetAction::IdleReply { target, requester, signon: self.services_signon, idle: 0 }] + } else { + Vec::new() + } + } NetEvent::UserConnect { uid, nick, host, ip } => { let arriving_nick = nick.clone(); self.network.user_connect(uid.clone(), nick, host, ip.clone()); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 9270bd6..533a8c2 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -43,6 +43,22 @@ assert!(is_success(&out), "{out:?}"); } + // A routed WHOIS (mIRC double-click) asks the service's server for its idle + // time; we must answer for our own pseudo-clients, and only them. + #[test] + fn idle_request_is_answered_for_our_clients_only() { + let mut e = engine_with("idle", "foo", "sesame"); + e.set_sid("42S".into()); + let ours = e.handle(NetEvent::Idle { requester: "000AAAAAB".into(), target: "42SAAAAAA".into() }); + assert!( + ours.iter().any(|a| matches!(a, NetAction::IdleReply { target, requester, idle, .. } + if target == "42SAAAAAA" && requester == "000AAAAAB" && *idle == 0)), + "expected an IDLE reply for NickServ, got {ours:?}" + ); + let foreign = e.handle(NetEvent::Idle { requester: "000AAAAAB".into(), target: "000AAAAAZ".into() }); + assert!(!foreign.iter().any(|a| matches!(a, NetAction::IdleReply { .. })), "{foreign:?}"); + } + // DebugServ streams auth outcomes into the log channel, and stays silent when // it isn't running. #[test]