whois: answer IDLE for pseudo-clients

This commit is contained in:
Jean Chevronnet 2026-07-16 23:22:35 +00:00
parent a60a6df19d
commit eaf3111917
4 changed files with 59 additions and 0 deletions

View file

@ -15,6 +15,10 @@ pub enum NetEvent {
Registered, Registered,
EndBurst, EndBurst,
Ping { token: String, from: Option<String> }, Ping { token: String, from: Option<String> },
// 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 }, Privmsg { from: String, to: String, text: String },
UserConnect { uid: String, nick: String, host: String, ip: String }, UserConnect { uid: String, nick: String, host: String, ip: String },
NickChange { uid: String, nick: String }, NickChange { uid: String, nick: String },
@ -66,6 +70,9 @@ pub enum NetAction {
Burst, Burst,
EndBurst, EndBurst,
Pong { token: String, from: Option<String> }, Pong { token: String, from: Option<String> },
// Answer a remote IDLE request for one of our pseudo-clients so a routed
// WHOIS completes: `:<target> IDLE <requester> <signon> <idle>`.
IdleReply { target: String, requester: String, signon: u64, idle: u64 },
IntroduceUser { uid: String, nick: String, ident: String, host: String, gecos: String }, IntroduceUser { uid: String, nick: String, ident: String, host: String, gecos: String },
Privmsg { from: String, to: String, text: String }, Privmsg { from: String, to: String, text: String },
Notice { from: String, to: String, text: String }, Notice { from: String, to: String, text: String },

View file

@ -78,6 +78,17 @@ impl Protocol for InspIrcd {
.to_string(); .to_string();
vec![NetEvent::Ping { token, from: source }] vec![NetEvent::Ping { token, from: source }]
} }
"IDLE" => {
// `:<requester> IDLE <target>` — 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" => { "PRIVMSG" => {
let to = tokens.next().unwrap_or("").to_string(); let to = tokens.next().unwrap_or("").to_string();
vec![NetEvent::Privmsg { vec![NetEvent::Privmsg {
@ -279,6 +290,11 @@ impl Protocol for InspIrcd {
let dest = from.clone().unwrap_or_else(|| self.sid.clone()); let dest = from.clone().unwrap_or_else(|| self.sid.clone());
vec![self.sourced(format!("PONG {} {}", dest, token))] vec![self.sourced(format!("PONG {} {}", dest, token))]
} }
// Reply to a routed-WHOIS idle request, sourced from the target client:
// `:<target> IDLE <requester> <signon> <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 // 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). // (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!( NetAction::IntroduceUser { uid, nick, ident, host, gecos } => vec![self.sourced(format!(

View file

@ -107,6 +107,7 @@ pub struct Engine {
enforce_seq: u32, // counter appended to guest_nick enforce_seq: u32, // counter appended to guest_nick
pending_enforce: Vec<PendingEnforce>, // registered nicks awaiting identify-or-rename pending_enforce: Vec<PendingEnforce>, // registered nicks awaiting identify-or-rename
bot_uids: HashMap<String, String>, // casefolded bot nick -> live uid (per connection) bot_uids: HashMap<String, String>, // casefolded bot nick -> live uid (per connection)
services_signon: u64, // burst time our pseudo-clients signed on, for WHOIS IDLE replies
bot_idents: HashMap<String, u64>, // casefolded bot nick -> hash of user/host/gecos (to spot BOT CHANGE) bot_idents: HashMap<String, u64>, // casefolded bot nick -> hash of user/host/gecos (to spot BOT CHANGE)
next_bot_index: u32, next_bot_index: u32,
bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined
@ -240,6 +241,7 @@ impl Engine {
enforce_seq: 0, enforce_seq: 0,
pending_enforce: Vec::new(), pending_enforce: Vec::new(),
bot_uids: HashMap::new(), bot_uids: HashMap::new(),
services_signon: 0,
bot_idents: HashMap::new(), bot_idents: HashMap::new(),
next_bot_index: 0, next_bot_index: 0,
bot_channels: std::collections::HashSet::new(), bot_channels: std::collections::HashSet::new(),
@ -902,7 +904,15 @@ impl Engine {
} }
// Sent right after the SERVER line: burst, introduce every service, endburst. // 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<NetAction> { pub fn startup_actions(&mut self) -> Vec<NetAction> {
// 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. // Fresh link: forget bot uids minted on any previous connection.
self.bot_uids.clear(); self.bot_uids.clear();
self.bot_idents.clear(); self.bot_idents.clear();
@ -955,6 +965,16 @@ impl Engine {
let mut out = self.sweep_unbans(); let mut out = self.sweep_unbans();
let evout = match event { let evout = match event {
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], 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 } => { NetEvent::UserConnect { uid, nick, host, ip } => {
let arriving_nick = nick.clone(); let arriving_nick = nick.clone();
self.network.user_connect(uid.clone(), nick, host, ip.clone()); self.network.user_connect(uid.clone(), nick, host, ip.clone());

View file

@ -43,6 +43,22 @@
assert!(is_success(&out), "{out:?}"); 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 // DebugServ streams auth outcomes into the log channel, and stays silent when
// it isn't running. // it isn't running.
#[test] #[test]