relaymsg: RELAYMSG + draft/relaymsg cap for bridge-style spoofed-nick channel messages

This commit is contained in:
Jean Chevronnet 2026-08-10 09:06:30 +00:00
parent 71d6e7441b
commit 0d5717c585
5 changed files with 112 additions and 0 deletions

View file

@ -120,6 +120,12 @@ amu_target = both
# --- chanlog (m_chanlog): mirror the oper server-notice stream into a channel so # --- 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): # staff can watch it in a normal window. Set the channel (create/keep it opped):
# chanlog = #snotices # chanlog = #snotices
# --- relaymsg (m_relaymsg / draft/relaymsg): a member whose client negotiated the
# capability can /RELAYMSG <#chan> <nick> <text> 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), # --- helpmode (m_helpmode): no config — adds oper-settable user mode +h (helpop),
# which shows "is available for help" in the user's WHOIS. # which shows "is available for help" in the user's WHOIS.
# --- globops (m_globops): no config — adds the oper command /GLOBOPS <message>, # --- globops (m_globops): no config — adds the oper command /GLOBOPS <message>,

View file

@ -47,6 +47,7 @@ pub mod profilelink;
pub mod randquote; pub mod randquote;
pub mod realnameban; pub mod realnameban;
pub mod recaptcha; pub mod recaptcha;
pub mod relaymsg;
pub mod reputation; pub mod reputation;
pub mod restrictchans; pub mod restrictchans;
pub mod restrictcommands; pub mod restrictcommands;
@ -131,5 +132,6 @@ pub fn module_commands() -> Vec<Box<dyn Command>> {
.chain(dccallow::commands()) .chain(dccallow::commands())
.chain(geoip::commands()) .chain(geoip::commands())
.chain(globops::commands()) .chain(globops::commands())
.chain(relaymsg::commands())
.collect() .collect()
} }

99
src/modules/relaymsg.rs Normal file
View file

@ -0,0 +1,99 @@
//! relaymsg — `RELAYMSG <channel> <nick> <text>` (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=<sender>` 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<Box<dyn Command>> {
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) = (&params[0], &params[1], &params[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<Uid> = 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
}
}

View file

@ -119,6 +119,7 @@ pub const RPL_LINKS: u16 = 364;
pub const RPL_ENDOFLINKS: u16 = 365; pub const RPL_ENDOFLINKS: u16 = 365;
pub const ERR_NOSUCHNICK: u16 = 401; 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_NOSUCHCHANNEL: u16 = 403;
pub const ERR_CANNOTSENDTOCHAN: u16 = 404; pub const ERR_CANNOTSENDTOCHAN: u16 = 404;
pub const ERR_NORECIPIENT: u16 = 411; pub const ERR_NORECIPIENT: u16 = 411;

View file

@ -121,6 +121,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[
"draft/json-log", "draft/json-log",
"draft/extended-isupport", "draft/extended-isupport",
"reverse.im/filehost", "reverse.im/filehost",
"draft/relaymsg",
"cap-notify", "cap-notify",
]; ];
@ -155,6 +156,7 @@ pub struct Caps {
pub json_log: bool, // draft/json-log — structured JSON tag on server notices 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 ext_isupport: bool, // draft/extended-isupport — ISUPPORT command + batched 005
pub filehost: bool, // reverse.im/filehost — knows the file-host extension 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, pub cap_notify: bool,
} }
@ -222,6 +224,7 @@ impl Caps {
"draft/json-log" => self.json_log, "draft/json-log" => self.json_log,
"draft/extended-isupport" => self.ext_isupport, "draft/extended-isupport" => self.ext_isupport,
"reverse.im/filehost" => self.filehost, "reverse.im/filehost" => self.filehost,
"draft/relaymsg" => self.relaymsg,
"cap-notify" => self.cap_notify, "cap-notify" => self.cap_notify,
_ => false, _ => false,
} }
@ -256,6 +259,7 @@ impl Caps {
"draft/json-log" => &mut self.json_log, "draft/json-log" => &mut self.json_log,
"draft/extended-isupport" => &mut self.ext_isupport, "draft/extended-isupport" => &mut self.ext_isupport,
"reverse.im/filehost" => &mut self.filehost, "reverse.im/filehost" => &mut self.filehost,
"draft/relaymsg" => &mut self.relaymsg,
"cap-notify" => &mut self.cap_notify, "cap-notify" => &mut self.cap_notify,
_ => return false, _ => return false,
}; };