Answer CTCP VERSION/PING/TIME/CLIENTINFO on service pseudo-clients and split over-long NOTICE/PRIVMSG lines under 512 bytes
All checks were successful
CI / check (push) Successful in 5m32s

This commit is contained in:
Jean Chevronnet 2026-07-20 14:29:02 +00:00
parent 3723617367
commit 58082248ec
No known key found for this signature in database
4 changed files with 138 additions and 5 deletions

View file

@ -420,10 +420,10 @@ impl Protocol for InspIrcd {
uid = uid, ts = self.ts, nick = nick, host = host, ident = ident, modes = self.service_modes, gecos = gecos uid = uid, ts = self.ts, nick = nick, host = host, ident = ident, modes = self.service_modes, gecos = gecos
))], ))],
NetAction::Privmsg { from, to, text } => { NetAction::Privmsg { from, to, text } => {
vec![format!(":{} PRIVMSG {} :{}", from, to, text)] split_body(text).into_iter().map(|chunk| format!(":{} PRIVMSG {} :{}", from, to, chunk)).collect()
} }
NetAction::Notice { from, to, text } => { NetAction::Notice { from, to, text } => {
vec![format!(":{} NOTICE {} :{}", from, to, text)] split_body(text).into_iter().map(|chunk| format!(":{} NOTICE {} :{}", from, to, chunk)).collect()
} }
// Standard reply: encapsulate for the target's server to re-emit locally // Standard reply: encapsulate for the target's server to re-emit locally
// (FAIL/WARN/NOTE aren't s2s-routable). ENCAP * so only the server the // (FAIL/WARN/NOTE aren't s2s-routable). ENCAP * so only the server the
@ -694,6 +694,49 @@ fn reason(rest: &str) -> String {
} }
} }
// Message-body bytes kept per PRIVMSG/NOTICE line. The whole IRC line is 512
// bytes incl. CR-LF; leaving ~110 for the ircd-expanded `:nick!user@host CMD
// target :` prefix keeps even a max-length bot source under the limit, so the
// ircd never truncates a reply mid-word.
const MAX_MSG_TEXT: usize = 400;
// Split an over-long message body into `MAX_MSG_TEXT`-byte chunks, breaking on a
// char boundary (and preferably a space) so no NOTICE/PRIVMSG line is truncated.
// A CTCP body (contains 0x01) is never split — a split would strand its delimiter.
fn split_body(text: &str) -> Vec<&str> {
if text.len() <= MAX_MSG_TEXT || text.contains('\x01') {
return vec![text];
}
let mut out = Vec::new();
let mut rest = text;
while rest.len() > MAX_MSG_TEXT {
let mut cut = MAX_MSG_TEXT;
while cut > 0 && !rest.is_char_boundary(cut) {
cut -= 1;
}
// Prefer to break at the last space in the back half of the window.
if let Some(sp) = rest[..cut].rfind(' ') {
if sp >= MAX_MSG_TEXT / 2 {
cut = sp;
}
}
if cut == 0 {
// A single word longer than the window: hard-split at a char boundary.
cut = rest.len().min(MAX_MSG_TEXT);
while !rest.is_char_boundary(cut) {
cut -= 1;
}
}
let (head, tail) = rest.split_at(cut);
out.push(head);
rest = tail.trim_start_matches(' ');
}
if !rest.is_empty() {
out.push(rest);
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -958,6 +1001,27 @@ mod tests {
assert_eq!(lines, vec![":42S SVSPART 0IRAAAAAB #chan".to_string()]); assert_eq!(lines, vec![":42S SVSPART 0IRAAAAAB #chan".to_string()]);
} }
#[test]
fn long_notice_splits_under_the_line_limit() {
let text = "word ".repeat(200).trim().to_string(); // 200 words, ~999 bytes
let lines = proto().serialize(&NetAction::Notice { from: "42SAAAAAA".into(), to: "0IRAAAAAB".into(), text });
assert!(lines.len() >= 3, "split into multiple lines, got {}", lines.len());
let mut words = 0;
for l in &lines {
assert!(l.len() <= 512, "each line under 512 bytes: {}", l.len());
let body = l.split_once(" :").expect("has a trailing param").1;
words += body.split_whitespace().count();
}
assert_eq!(words, 200, "every word preserved across the split");
}
#[test]
fn ctcp_body_is_never_split() {
let text = format!("\x01{}\x01", "A".repeat(700));
let lines = proto().serialize(&NetAction::Notice { from: "42SAAAAAA".into(), to: "0IRAAAAAB".into(), text });
assert_eq!(lines.len(), 1, "a CTCP body stays on one line (a split would strand its \\x01)");
}
// Free-form text with embedded line breaks must not smuggle a second line // Free-form text with embedded line breaks must not smuggle a second line
// onto the link: the serializer strips CR/LF/NUL from every outbound line. // onto the link: the serializer strips CR/LF/NUL from every outbound line.
#[test] #[test]

View file

@ -414,8 +414,11 @@ pub struct Server {
#[serde(default)] #[serde(default)]
pub service_host: String, pub service_host: String,
// User modes the service pseudo-clients (services + bots) are introduced with. // User modes the service pseudo-clients (services + bots) are introduced with.
// Default "iHkBT": invisible, hideoper, servprotect (unkillable — needs the // Default "iHkB": invisible, hideoper, servprotect (unkillable — needs the
// services server U-lined), bot, block-CTCP. Set per the ircd's loaded modules. // services server U-lined), bot. NOT +T (block-CTCP): echo answers CTCP
// VERSION/PING/TIME/CLIENTINFO itself (and ignores the rest), so a blanket
// ircd block would only stop those introspection replies. Set per the ircd's
// loaded modules; add "T" back to have the ircd drop all CTCP instead.
#[serde(default = "default_service_modes")] #[serde(default = "default_service_modes")]
pub service_modes: String, pub service_modes: String,
// Oper type the service pseudo-clients are flagged with, so WHOIS shows // Oper type the service pseudo-clients are flagged with, so WHOIS shows
@ -438,7 +441,7 @@ fn default_services_channel() -> String {
} }
fn default_service_modes() -> String { fn default_service_modes() -> String {
"iHkBT".to_string() // invisible, hideoper, servprotect, bot, block-CTCP (matches the doc above) "iHkB".to_string() // invisible, hideoper, servprotect, bot — CTCP handled by echo, not blocked at the ircd
} }
fn default_service_oper_type() -> String { fn default_service_oper_type() -> String {

View file

@ -1,6 +1,29 @@
use super::*; use super::*;
impl Engine { impl Engine {
// Answer a CTCP query addressed to a service pseudo-client. Replies to the
// standard introspection queries via a CTCP NOTICE (the CTCP convention);
// ACTION and anything unrecognised get no reply at all — a services daemon must
// not treat `\x01VERSION\x01` as a bad command.
fn ctcp_reply(&self, svc_uid: &str, to: &str, text: &str) -> Option<NetAction> {
let body = text.trim_matches('\x01');
let mut parts = body.splitn(2, ' ');
let cmd = parts.next().unwrap_or("").to_ascii_uppercase();
let arg = parts.next().unwrap_or("");
let response = match cmd.as_str() {
"VERSION" => format!("VERSION echo services {}", env!("CARGO_PKG_VERSION")),
"PING" => format!("PING {arg}"),
"TIME" => format!("TIME {}", echo_api::human_time(self.now_secs())),
"CLIENTINFO" => "CLIENTINFO ACTION CLIENTINFO PING TIME VERSION".to_string(),
_ => return None, // ACTION and unknown CTCPs: stay silent
};
Some(NetAction::Notice {
from: svc_uid.to_string(),
to: to.to_string(),
text: format!("\x01{response}\x01"),
})
}
// Route a PRIVMSG addressed to a service (by uid or nick) into that service, // Route a PRIVMSG addressed to a service (by uid or nick) into that service,
// handing it the sender's resolved nick, login state, and the account store. // handing it the sender's resolved nick, login state, and the account store.
pub(crate) fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> { pub(crate) fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
@ -71,6 +94,21 @@ impl Engine {
} }
} }
} }
// A CTCP query to a service (a PRIVMSG wrapped in \x01) gets a proper
// CTCP NOTICE reply for VERSION/PING/TIME/CLIENTINFO, and is otherwise
// ignored — never bounced back as an "I don't know the command" notice.
if text.starts_with('\x01') {
let svc_uid = self.services.iter()
.find(|s| to.eq_ignore_ascii_case(s.uid()) || to.eq_ignore_ascii_case(s.nick()))
.map(|s| s.uid().to_string());
let mut out = Vec::new();
if let Some(uid) = svc_uid {
if let Some(reply) = self.ctcp_reply(&uid, from, text) {
out.push(reply);
}
}
return out;
}
let mut matched: Option<String> = None; let mut matched: Option<String> = None;
{ {
let Self { services, network, db, .. } = self; let Self { services, network, db, .. } = self;

View file

@ -4017,6 +4017,34 @@
assert!(os(&mut e, "000AAAAAS", "AKILL LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("No matching"))), "list now empty"); assert!(os(&mut e, "000AAAAAS", "AKILL LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("No matching"))), "list now empty");
} }
#[test]
fn ctcp_query_to_a_service_replies_or_stays_silent() {
use echo_nickserv::NickServ;
let path = std::env::temp_dir().join("echo-ctcp.jsonl");
let _ = std::fs::remove_file(&path);
let db = Db::open(&path, "42S");
let mut e = Engine::new(
vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })],
db,
);
e.set_sid("42S".into());
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() });
// VERSION → a CTCP NOTICE from the service, never an "unknown command".
let ver = ns(&mut e, "\x01VERSION\x01");
assert!(ver.iter().any(|a| matches!(a, NetAction::Notice { from, text, .. } if from == "42SAAAAAA" && text.starts_with("\x01VERSION echo") && text.ends_with('\x01'))), "VERSION reply: {ver:?}");
assert!(!ver.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("don't know"))), "no unknown-command bounce");
// PING echoes the token back verbatim.
assert!(ns(&mut e, "\x01PING tok123\x01").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text == "\x01PING tok123\x01")), "PING echo");
// ACTION and unrecognised CTCPs get no reply at all.
assert!(ns(&mut e, "\x01ACTION waves\x01").iter().all(|a| !matches!(a, NetAction::Notice { .. })), "ACTION silent");
assert!(ns(&mut e, "\x01FLOOBLE\x01").iter().all(|a| !matches!(a, NetAction::Notice { .. })), "unknown CTCP silent");
// A normal (non-CTCP) unknown command is still answered — the intercept
// didn't swallow ordinary routing.
assert!(ns(&mut e, "FLOOBLE").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("don't know"))), "normal unknown command still answered");
}
// An account approaching expiry with an email on file is warned once by // An account approaching expiry with an email on file is warned once by
// email, isn't dropped while still in the window, and isn't warned twice. // email, isn't dropped while still in the window, and isn't warned twice.
#[test] #[test]