services: SVSHOLD nick reservation, SVSTOPIC, SVSOPER, SVSCMODE over s2s (+ stats S)

This commit is contained in:
Jean Chevronnet 2026-08-10 21:00:23 +00:00
parent f371ed0a18
commit b76fbfdb79
4 changed files with 166 additions and 6 deletions

View file

@ -243,13 +243,14 @@ impl Command for Stats {
s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper"));
}
}
'k' | 'g' | 'z' | 'e' | 'q' | 's' => {
'k' | 'g' | 'z' | 'e' | 'q' | 's' | 'S' => {
let kind = match letter {
'k' => XKind::Kline,
'g' => XKind::Gline,
'z' => XKind::Zline,
'e' => XKind::Eline,
'q' => XKind::Qline,
'S' => XKind::Svshold,
_ => XKind::Shun,
};
let rows: Vec<String> = s

View file

@ -422,9 +422,9 @@ impl Command for Nick {
);
return CmdResult::Fail;
}
// Q-line: a reserved nick is refused (opers and services bypass)
// Q-line / SVSHOLD: a reserved nick is refused (opers and services bypass)
if !s.is_oper(uid) {
if let Some(reason) = s.matched_qline(newnick) {
if let Some(reason) = s.nick_reserved(newnick) {
s.numeric(
uid,
ERR_ERRONEUSNICKNAME,

View file

@ -167,6 +167,10 @@ impl Server {
"SVSMODE" if registered => self.link_svsmode(uid, msg),
"SVSLOGIN" if registered => self.link_svslogin(uid, msg),
"SVSLOGOUT" if registered => self.link_svslogout(uid, msg),
"SVSHOLD" if registered => self.link_svshold(uid, msg),
"SVSTOPIC" if registered => self.link_svstopic(uid, msg),
"SVSOPER" if registered => self.link_svsoper(uid, msg),
"SVSCMODE" if registered => self.link_svscmode(uid, msg),
"ENCAP" if registered => self.link_encap(uid, msg),
"METADATA" if registered => self.link_metadata(uid, msg),
"SASL" if registered => self.link_sasl(uid, msg),
@ -523,6 +527,140 @@ impl Server {
}
}
/// A display name for whoever sourced a services command: the source uuid's
/// nick if we know it, else the raw source, else "services".
fn link_setter(&self, msg: &Message) -> String {
let Some(src) = &msg.source else {
return "services".to_string();
};
if let Some(ru) = self.remote_users.get(src) {
return ru.nick.clone();
}
if let Some(&uid) = self.uuid_local.get(src) {
if let Some(u) = self.users.get(&uid) {
return u.nick.clone();
}
}
src.clone()
}
/// `:src SVSHOLD <nick> [<duration> :<reason>]` — services reserve a nick (added
/// as an SVSHOLD x-line, so NICK to it is refused) or, with just the nick,
/// release it. Broadcast across the network.
fn link_svshold(&mut self, from: Uid, msg: &Message) {
let Some(nick) = msg.params.first().cloned() else {
return;
};
if msg.params.len() == 1 {
self.remove_xline(crate::xline::XKind::Svshold, &nick);
} else if msg.params.len() >= 3 {
let Some(dur) = crate::xline::parse_duration(&msg.params[1]) else {
return;
};
let setter = self.link_setter(msg);
self.add_xline(
crate::xline::XKind::Svshold,
&nick,
dur,
&setter,
&msg.params[2],
);
} else {
return;
}
self.propagate(&msg.to_wire(), Some(from)); // spanning-tree broadcast
}
/// `:src SVSTOPIC <chan> [<topicts> <setter> :<topic>]` — services set (4-param)
/// or clear (1-param) a channel's topic, overriding +t and op checks.
fn link_svstopic(&mut self, from: Uid, msg: &Message) {
let Some(chan) = msg.params.first().cloned() else {
return;
};
let key = chan.to_ascii_lowercase();
if !self.channels.contains_key(&key) {
return;
}
let (text, setter, ts) = if msg.params.len() >= 4 {
let ts = msg.params[1].parse::<u64>().unwrap_or_else(|_| now());
(msg.params[3].clone(), msg.params[2].clone(), ts)
} else {
(String::new(), String::new(), 0) // clear
};
if let Some(ch) = self.channels.get_mut(&key) {
ch.topic = if text.is_empty() {
None
} else {
Some(Topic {
text: text.clone(),
setter,
ts,
})
};
}
let src = self.link_setter(msg);
self.to_channel(&key, &format!(":{src} TOPIC {chan} :{text}"), None);
self.propagate(&msg.to_wire(), Some(from));
}
/// `:src SVSOPER <target> <opertype>` — services grant IRC-operator status to a
/// local user (echo's opers are flat, so the type is accepted but not stored).
fn link_svsoper(&mut self, from: Uid, msg: &Message) {
if msg.params.len() < 2 {
return;
}
match self.link_local_target(&msg.params[0]) {
Some(tuid) => {
if !self.is_oper(tuid) {
self.oper_up(tuid);
}
}
None => {
self.forward_to_target(&msg.params[0], msg, from);
}
}
}
/// `:src SVSCMODE <target> <chan> <listmodes>` — services clear the target user's
/// matching entries from the named channel list modes (e.g. `b` to unban them,
/// `be` bans + exceptions).
fn link_svscmode(&mut self, from: Uid, msg: &Message) {
if msg.params.len() < 3 {
return;
}
let Some(tuid) = self.link_local_target(&msg.params[0]) else {
self.forward_to_target(&msg.params[0], msg, from);
return;
};
let key = msg.params[1].to_ascii_lowercase();
let mut removals: Vec<(char, String)> = Vec::new();
if let Some(ch) = self.channels.get(&key) {
for mc in msg.params[2].chars() {
let list = match mc {
'b' => &ch.bans,
'e' => &ch.excepts,
'I' => &ch.invex,
_ => continue,
};
for ban in list {
if self.ban_list_hit(tuid, std::slice::from_ref(ban)) {
removals.push((mc, ban.mask.clone()));
}
}
}
} else {
return;
}
for (mc, mask) in removals {
crate::coremods::core_mode::svs_set_chan_modes(
self,
&msg.params[1],
&format!("-{mc}"),
std::slice::from_ref(&mask),
);
}
}
/// `:src ENCAP <servermask> <subcommand> [params...]` — a command encapsulated
/// for specific server(s); services wrap SVS*/SASL this way. If the mask
/// matches us we unwrap and dispatch the subcommand; a `*` mask is also flooded

View file

@ -16,6 +16,7 @@ pub enum XKind {
Shun, // user@host allowed to connect but whose commands are dropped
Qline, // a reserved/forbidden nick glob
Cban, // a forbidden channel-name glob
Svshold, // a services-reserved nick glob (like Qline, but services-owned)
}
impl XKind {
@ -28,6 +29,7 @@ impl XKind {
XKind::Shun => "SHUN",
XKind::Qline => "Q",
XKind::Cban => "CBAN",
XKind::Svshold => "SVSHOLD",
}
}
@ -41,6 +43,7 @@ impl XKind {
"SHUN" => XKind::Shun,
"Q" => XKind::Qline,
"CBAN" => XKind::Cban,
"SVSHOLD" => XKind::Svshold,
_ => return None,
})
}
@ -125,6 +128,24 @@ impl Server {
.map(|x| x.reason.clone())
}
/// The reason nick `nick` is held by services (SVSHOLD), if any.
pub fn matched_svshold(&self, nick: &str) -> Option<String> {
let n = now();
self.xlines
.iter()
.find(|x| {
x.kind == XKind::Svshold
&& (x.expires == 0 || x.expires > n)
&& glob_match(&x.mask, nick)
})
.map(|x| x.reason.clone())
}
/// The reason nick `nick` may not be used — a Q-line or a services SVSHOLD.
pub fn nick_reserved(&self, nick: &str) -> Option<String> {
self.matched_qline(nick).or_else(|| self.matched_svshold(nick))
}
/// The reason channel `chan` is CBAN'd (forbidden), if any. Case-insensitive.
pub fn matched_cban(&self, chan: &str) -> Option<String> {
let n = now();