From 0d5717c5858a059623f483917cca0e226baf416a Mon Sep 17 00:00:00 2001 From: reverse Date: Mon, 10 Aug 2026 09:06:30 +0000 Subject: [PATCH] relaymsg: RELAYMSG + draft/relaymsg cap for bridge-style spoofed-nick channel messages --- echoircd.conf.example | 6 +++ src/modules/mod.rs | 2 + src/modules/relaymsg.rs | 99 +++++++++++++++++++++++++++++++++++++++++ src/numeric.rs | 1 + src/users.rs | 4 ++ 5 files changed, 112 insertions(+) create mode 100644 src/modules/relaymsg.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index b2d439e..64567f5 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -120,6 +120,12 @@ amu_target = both # --- chanlog (m_chanlog): mirror the oper server-notice stream into a channel so # staff can watch it in a normal window. Set the channel (create/keep it opped): # chanlog = #snotices +# --- relaymsg (m_relaymsg / draft/relaymsg): a member whose client negotiated the +# capability can /RELAYMSG <#chan> to speak under a spoofed relay +# nick (for bridges). The nick must contain a separator and not collide. +# relaymsg_separators = / +# relaymsg_ident = relay +# relaymsg_host = relay.example.com # default: the server name # --- helpmode (m_helpmode): no config — adds oper-settable user mode +h (helpop), # which shows "is available for help" in the user's WHOIS. # --- globops (m_globops): no config — adds the oper command /GLOBOPS , diff --git a/src/modules/mod.rs b/src/modules/mod.rs index f79a913..d0a065d 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -47,6 +47,7 @@ pub mod profilelink; pub mod randquote; pub mod realnameban; pub mod recaptcha; +pub mod relaymsg; pub mod reputation; pub mod restrictchans; pub mod restrictcommands; @@ -131,5 +132,6 @@ pub fn module_commands() -> Vec> { .chain(dccallow::commands()) .chain(geoip::commands()) .chain(globops::commands()) + .chain(relaymsg::commands()) .collect() } diff --git a/src/modules/relaymsg.rs b/src/modules/relaymsg.rs new file mode 100644 index 0000000..3f4d6ad --- /dev/null +++ b/src/modules/relaymsg.rs @@ -0,0 +1,99 @@ +//! relaymsg — `RELAYMSG ` (IRCv3 `draft/relaymsg`): a member +//! whose client negotiated the capability sends a channel message under a spoofed +//! "relay" nick (e.g. `discord/alice`), for stateless bridges. The message is +//! tagged `@draft/relaymsg=` so clients can attribute it. The spoofed nick +//! must contain a configured separator and must not collide with a real nick. +//! +//! Config: `relaymsg_separators` (default `/`), `relaymsg_ident` (default `relay`), +//! `relaymsg_host` (default = server name). Reference: InspIRCd's `m_relaymsg`. +//! (Local delivery; cross-server ENCAP relay is not propagated.) Original native Rust. + +use crate::command::{CmdResult, Command}; +use crate::numeric::{ERR_BADRELAYNICK, ERR_CANNOTSENDTOCHAN, ERR_NOPRIVILEGES, ERR_NOSUCHCHANNEL}; +use crate::server::Server; +use crate::Uid; + +/// Characters never allowed in a spoofed relay nick (core IRC syntax). +const FORBIDDEN: &str = "!+%@&#$:'\"?*,."; + +pub fn commands() -> Vec> { + vec![Box::new(RelayMsg)] +} + +struct RelayMsg; +impl Command for RelayMsg { + fn name(&self) -> &'static str { + "RELAYMSG" + } + fn min_params(&self) -> usize { + 3 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let (chan, nick, text) = (¶ms[0], ¶ms[1], ¶ms[2]); + let bad = |s: &mut Server, msg: &str| { + s.numeric(uid, ERR_BADRELAYNICK, &format!("{nick} :{msg}")); + CmdResult::Fail + }; + + if !s.users.get(&uid).map(|u| u.caps.relaymsg).unwrap_or(false) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":You must enable the draft/relaymsg capability to use RELAYMSG", + ); + return CmdResult::Fail; + } + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + if !s.is_member(uid, &key) { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{chan} :You must be in the channel to use RELAYMSG"), + ); + return CmdResult::Fail; + } + if s.find_nick(nick).is_some() { + return bad(s, "RELAYMSG spoofed nick is already in use"); + } + if nick.chars().any(|c| FORBIDDEN.contains(c)) { + return bad(s, "Invalid characters in spoofed nick"); + } + let seps = s + .conf("relaymsg_separators") + .filter(|v| !v.is_empty()) + .unwrap_or("/") + .to_string(); + if !nick.chars().any(|c| seps.contains(c)) { + return bad( + s, + &format!("Spoofed nick must include one of these separators: {seps}"), + ); + } + + // build the fake source and relay it to every member (sender included, so + // their own client sees the @draft/relaymsg echo) + let ident = s.conf("relaymsg_ident").filter(|v| !v.is_empty()).unwrap_or("relay").to_string(); + let host = s + .conf("relaymsg_host") + .filter(|v| !v.is_empty()) + .unwrap_or(s.name.as_str()) + .to_string(); + let sender = s.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default(); + let body = format!(":{nick}!{ident}@{host} PRIVMSG {chan} :{text}"); + let ctags = format!("draft/relaymsg={sender}"); + let msgid = s.next_msgid(); + let members: Vec = s + .channels + .get(&key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default(); + for m in members { + s.send_tagged(m, uid, &ctags, &msgid, &body); + } + CmdResult::Ok + } +} diff --git a/src/numeric.rs b/src/numeric.rs index 0f494e5..35e9174 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -119,6 +119,7 @@ pub const RPL_LINKS: u16 = 364; pub const RPL_ENDOFLINKS: u16 = 365; pub const ERR_NOSUCHNICK: u16 = 401; +pub const ERR_BADRELAYNICK: u16 = 573; // RELAYMSG: bad/taken spoofed nick pub const ERR_NOSUCHCHANNEL: u16 = 403; pub const ERR_CANNOTSENDTOCHAN: u16 = 404; pub const ERR_NORECIPIENT: u16 = 411; diff --git a/src/users.rs b/src/users.rs index b4ced01..fbe5b16 100644 --- a/src/users.rs +++ b/src/users.rs @@ -121,6 +121,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "draft/json-log", "draft/extended-isupport", "reverse.im/filehost", + "draft/relaymsg", "cap-notify", ]; @@ -155,6 +156,7 @@ pub struct Caps { pub json_log: bool, // draft/json-log — structured JSON tag on server notices pub ext_isupport: bool, // draft/extended-isupport — ISUPPORT command + batched 005 pub filehost: bool, // reverse.im/filehost — knows the file-host extension + pub relaymsg: bool, // draft/relaymsg — may use RELAYMSG (bridge relaying) pub cap_notify: bool, } @@ -222,6 +224,7 @@ impl Caps { "draft/json-log" => self.json_log, "draft/extended-isupport" => self.ext_isupport, "reverse.im/filehost" => self.filehost, + "draft/relaymsg" => self.relaymsg, "cap-notify" => self.cap_notify, _ => false, } @@ -256,6 +259,7 @@ impl Caps { "draft/json-log" => &mut self.json_log, "draft/extended-isupport" => &mut self.ext_isupport, "reverse.im/filehost" => &mut self.filehost, + "draft/relaymsg" => &mut self.relaymsg, "cap-notify" => &mut self.cap_notify, _ => return false, };