diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 3ffdcc8..4c68895 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -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 ))], 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 } => { - 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 // (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)] mod tests { use super::*; @@ -958,6 +1001,27 @@ mod tests { 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 // onto the link: the serializer strips CR/LF/NUL from every outbound line. #[test] diff --git a/src/config.rs b/src/config.rs index a93cf3a..9db3ed9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -414,8 +414,11 @@ pub struct Server { #[serde(default)] pub service_host: String, // User modes the service pseudo-clients (services + bots) are introduced with. - // Default "iHkBT": invisible, hideoper, servprotect (unkillable — needs the - // services server U-lined), bot, block-CTCP. Set per the ircd's loaded modules. + // Default "iHkB": invisible, hideoper, servprotect (unkillable — needs the + // 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")] pub service_modes: String, // 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 { - "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 { diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index c714bc3..2434eee 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -1,6 +1,29 @@ use super::*; 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 { + 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, // 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 { @@ -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 = None; { let Self { services, network, db, .. } = self; diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 8c6d730..6b05122 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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"); } + #[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 // email, isn't dropped while still in the window, and isn't warned twice. #[test]