services: add server-level uline/is_service flag and gate the service WHOIS line, echo/services tag, MAP/LINKS hide and SVS* on it
This commit is contained in:
parent
64d3bfc003
commit
d012623dff
4 changed files with 165 additions and 15 deletions
|
|
@ -304,7 +304,14 @@ impl Command for Map {
|
||||||
RPL_MAP,
|
RPL_MAP,
|
||||||
&format!("{} ({} users)", s.name, s.users.len()),
|
&format!("{} ({} users)", s.name, s.users.len()),
|
||||||
);
|
);
|
||||||
let mut peers: Vec<String> = s.servers.values().map(|sv| sv.name.clone()).collect();
|
// hideservices: services (U-lined) servers are hidden from non-opers.
|
||||||
|
let hide_svc = s.conf_bool("hideservices", false) && !s.is_oper(uid);
|
||||||
|
let mut peers: Vec<String> = s
|
||||||
|
.servers
|
||||||
|
.values()
|
||||||
|
.filter(|sv| !(hide_svc && sv.is_service))
|
||||||
|
.map(|sv| sv.name.clone())
|
||||||
|
.collect();
|
||||||
peers.sort();
|
peers.sort();
|
||||||
for name in peers {
|
for name in peers {
|
||||||
s.numeric(uid, RPL_MAP, &format!("`- {name}"));
|
s.numeric(uid, RPL_MAP, &format!("`- {name}"));
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,12 @@ impl Command for Links {
|
||||||
RPL_LINKS,
|
RPL_LINKS,
|
||||||
&format!("{} {} :0 {}", s.name, s.name, s.server_desc),
|
&format!("{} {} :0 {}", s.name, s.name, s.server_desc),
|
||||||
);
|
);
|
||||||
|
// hideservices: services (U-lined) servers are hidden from non-opers.
|
||||||
|
let hide_svc = s.conf_bool("hideservices", false) && !s.is_oper(uid);
|
||||||
let mut rows: Vec<(String, String)> = s
|
let mut rows: Vec<(String, String)> = s
|
||||||
.servers
|
.servers
|
||||||
.values()
|
.values()
|
||||||
|
.filter(|sv| !(hide_svc && sv.is_service))
|
||||||
.map(|sv| (sv.name.clone(), sv.desc.clone()))
|
.map(|sv| (sv.name.clone(), sv.desc.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
rows.sort();
|
rows.sort();
|
||||||
|
|
@ -123,6 +126,15 @@ impl Command for Whois {
|
||||||
RPL_WHOISSERVER,
|
RPL_WHOISSERVER,
|
||||||
&format!("{} {srv} :remote user", ru.nick),
|
&format!("{} {srv} :remote user", ru.nick),
|
||||||
);
|
);
|
||||||
|
// 313: a user on a U-lined services server is "a network service"
|
||||||
|
// (InspIRCd reworks the oper line the same way for services).
|
||||||
|
if s.server_is_service(&ru.sid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISOPERATOR,
|
||||||
|
&format!("{} :is a network service", ru.nick),
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Some(a) = &ru.account {
|
if let Some(a) = &ru.account {
|
||||||
s.numeric(
|
s.numeric(
|
||||||
uid,
|
uid,
|
||||||
|
|
|
||||||
145
src/link.rs
145
src/link.rs
|
|
@ -45,6 +45,13 @@ pub struct RemoteServer {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub desc: String,
|
pub desc: String,
|
||||||
pub via: Uid, // the local link uid it is reachable through
|
pub via: Uid, // the local link uid it is reachable through
|
||||||
|
/// A services (U-lined) server: its name matches a `uline` config entry or the
|
||||||
|
/// configured `sasl_server`. Derived locally from OUR config at link/rehash time,
|
||||||
|
/// exactly like InspIRCd's `<services server=...>` — nothing on the wire declares
|
||||||
|
/// it. Every user on it is a network service.
|
||||||
|
pub is_service: bool,
|
||||||
|
/// `uline ... silent`: suppress this server's users' connect/quit server-notices.
|
||||||
|
pub silent_service: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A user living on another server, reached via a link — not a local `User`.
|
/// A user living on another server, reached via a link — not a local `User`.
|
||||||
|
|
@ -170,17 +177,21 @@ impl Server {
|
||||||
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
||||||
"IJOIN" if registered => self.link_ijoin_recv(uid, msg),
|
"IJOIN" if registered => self.link_ijoin_recv(uid, msg),
|
||||||
// services (SVS*) enforcement + account login, driven by a linked
|
// services (SVS*) enforcement + account login, driven by a linked
|
||||||
// services pseudoserver (forwarded on if the target is on another server)
|
// services pseudoserver (forwarded on if the target is on another server).
|
||||||
"SVSNICK" if registered => self.link_svsnick(uid, msg),
|
// Like InspIRCd's m_services, these are honoured ONLY from a source on a
|
||||||
"SVSJOIN" if registered => self.link_svsjoin(uid, msg),
|
// U-lined services server — an ordinary peer's SVS* is ignored.
|
||||||
"SVSPART" if registered => self.link_svspart(uid, msg),
|
"SVSNICK" if registered && self.source_is_service(msg) => self.link_svsnick(uid, msg),
|
||||||
"SVSMODE" if registered => self.link_svsmode(uid, msg),
|
"SVSJOIN" if registered && self.source_is_service(msg) => self.link_svsjoin(uid, msg),
|
||||||
"SVSLOGIN" if registered => self.link_svslogin(uid, msg),
|
"SVSPART" if registered && self.source_is_service(msg) => self.link_svspart(uid, msg),
|
||||||
"SVSLOGOUT" if registered => self.link_svslogout(uid, msg),
|
"SVSMODE" if registered && self.source_is_service(msg) => self.link_svsmode(uid, msg),
|
||||||
"SVSHOLD" if registered => self.link_svshold(uid, msg),
|
"SVSLOGIN" if registered && self.source_is_service(msg) => self.link_svslogin(uid, msg),
|
||||||
"SVSTOPIC" if registered => self.link_svstopic(uid, msg),
|
"SVSLOGOUT" if registered && self.source_is_service(msg) => {
|
||||||
"SVSOPER" if registered => self.link_svsoper(uid, msg),
|
self.link_svslogout(uid, msg)
|
||||||
"SVSCMODE" if registered => self.link_svscmode(uid, msg),
|
}
|
||||||
|
"SVSHOLD" if registered && self.source_is_service(msg) => self.link_svshold(uid, msg),
|
||||||
|
"SVSTOPIC" if registered && self.source_is_service(msg) => self.link_svstopic(uid, msg),
|
||||||
|
"SVSOPER" if registered && self.source_is_service(msg) => self.link_svsoper(uid, msg),
|
||||||
|
"SVSCMODE" if registered && self.source_is_service(msg) => self.link_svscmode(uid, msg),
|
||||||
"ENCAP" if registered => self.link_encap(uid, msg),
|
"ENCAP" if registered => self.link_encap(uid, msg),
|
||||||
"METADATA" if registered => self.link_metadata(uid, msg),
|
"METADATA" if registered => self.link_metadata(uid, msg),
|
||||||
"SASL" if registered => self.link_sasl(uid, msg),
|
"SASL" if registered => self.link_sasl(uid, msg),
|
||||||
|
|
@ -204,6 +215,52 @@ impl Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the `SERVER <name> <password> <sid> :<desc>` handshake line.
|
/// Handle the `SERVER <name> <password> <sid> :<desc>` handshake line.
|
||||||
|
/// Whether a server NAME is a services (U-lined) server, and whether it is
|
||||||
|
/// "silent". A name matches if it is the configured `sasl_server` (a SASL
|
||||||
|
/// provider is a service) or appears as a `uline = <name> [silent]` entry.
|
||||||
|
/// Case-insensitive, matching InspIRCd's `<services server=...>` by name.
|
||||||
|
pub fn uline_match(&self, name: &str) -> (bool, bool) {
|
||||||
|
if !self.sasl_server.is_empty() && name.eq_ignore_ascii_case(&self.sasl_server) {
|
||||||
|
return (true, false);
|
||||||
|
}
|
||||||
|
for line in self.conf_all("uline") {
|
||||||
|
let mut it = line.split_whitespace();
|
||||||
|
if it.next().is_some_and(|n| n.eq_ignore_ascii_case(name)) {
|
||||||
|
return (true, it.any(|t| t.eq_ignore_ascii_case("silent")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the server with this SID is a services (U-lined) server.
|
||||||
|
pub fn server_is_service(&self, sid: &str) -> bool {
|
||||||
|
self.servers.get(sid).is_some_and(|s| s.is_service)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a message's source (a SID or a UUID whose first 3 chars are the SID)
|
||||||
|
/// originates on a services server — the authority gate for SVS* commands.
|
||||||
|
pub fn source_is_service(&self, msg: &Message) -> bool {
|
||||||
|
let Some(src) = msg.source.as_deref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.server_is_service(src.get(..3).unwrap_or(src))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the remote user with this UUID lives on a services server. Local
|
||||||
|
/// users are never services (they are on us, and a server is not its own uline).
|
||||||
|
pub fn uuid_is_service(&self, uuid: &str) -> bool {
|
||||||
|
self.remote_users
|
||||||
|
.get(uuid)
|
||||||
|
.is_some_and(|ru| self.server_is_service(&ru.sid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the user reachable by this nick is a network service.
|
||||||
|
pub fn nick_is_service(&self, nick: &str) -> bool {
|
||||||
|
self.remote_nick
|
||||||
|
.get(&nick.to_ascii_lowercase())
|
||||||
|
.is_some_and(|uuid| self.uuid_is_service(uuid))
|
||||||
|
}
|
||||||
|
|
||||||
fn link_server(&mut self, uid: Uid, msg: &Message) {
|
fn link_server(&mut self, uid: Uid, msg: &Message) {
|
||||||
if msg.params.len() < 4 {
|
if msg.params.len() < 4 {
|
||||||
self.reject_link(uid, "Not enough SERVER parameters");
|
self.reject_link(uid, "Not enough SERVER parameters");
|
||||||
|
|
@ -234,6 +291,7 @@ impl Server {
|
||||||
l.sid = Some(sid.clone());
|
l.sid = Some(sid.clone());
|
||||||
l.name = Some(name.clone());
|
l.name = Some(name.clone());
|
||||||
}
|
}
|
||||||
|
let (is_service, silent_service) = self.uline_match(&name);
|
||||||
self.servers.insert(
|
self.servers.insert(
|
||||||
sid.clone(),
|
sid.clone(),
|
||||||
RemoteServer {
|
RemoteServer {
|
||||||
|
|
@ -241,8 +299,13 @@ impl Server {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
desc: desc.clone(),
|
desc: desc.clone(),
|
||||||
via: uid,
|
via: uid,
|
||||||
|
is_service,
|
||||||
|
silent_service,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if is_service {
|
||||||
|
eprintln!("[link] {name} ({sid}) is a services (U-lined) server");
|
||||||
|
}
|
||||||
// if we accepted (inbound) we still owe them our SERVER line
|
// if we accepted (inbound) we still owe them our SERVER line
|
||||||
if !already_sent {
|
if !already_sent {
|
||||||
self.link_out(
|
self.link_out(
|
||||||
|
|
@ -961,13 +1024,19 @@ impl Server {
|
||||||
if !self.channels.contains_key(&key) {
|
if !self.channels.contains_key(&key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let line = format!(":{prefix} {cmd} {target} :{text}");
|
let base = format!(":{prefix} {cmd} {target} :{text}");
|
||||||
|
// reverse.im/service: tag messages from a network service so capable
|
||||||
|
// clients can badge them. Per-recipient (needs the message-tags cap).
|
||||||
|
let is_service = self.uuid_is_service(&src);
|
||||||
|
let tagged = format!("@reverse.im/service {base}");
|
||||||
let members: Vec<Uid> = self.channels[&key].members.keys().copied().collect();
|
let members: Vec<Uid> = self.channels[&key].members.keys().copied().collect();
|
||||||
for m in members {
|
for m in members {
|
||||||
if self.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
if self.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
self.send(m, line.clone());
|
let want_tag = is_service
|
||||||
|
&& self.users.get(&m).map(|u| u.caps.message_tags).unwrap_or(false);
|
||||||
|
self.send(m, if want_tag { tagged.clone() } else { base.clone() });
|
||||||
}
|
}
|
||||||
// forward to the other links that have members in this channel
|
// forward to the other links that have members in this channel
|
||||||
for l in self.channel_link_targets(&key, Some(via)) {
|
for l in self.channel_link_targets(&key, Some(via)) {
|
||||||
|
|
@ -979,7 +1048,10 @@ impl Server {
|
||||||
.get(&dst)
|
.get(&dst)
|
||||||
.map(|u| u.nick.clone())
|
.map(|u| u.nick.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.send(dst, format!(":{prefix} {cmd} {nick} :{text}"));
|
let want_tag = self.uuid_is_service(&src)
|
||||||
|
&& self.users.get(&dst).map(|u| u.caps.message_tags).unwrap_or(false);
|
||||||
|
let tag = if want_tag { "@reverse.im/service " } else { "" };
|
||||||
|
self.send(dst, format!("{tag}:{prefix} {cmd} {nick} :{text}"));
|
||||||
} else {
|
} else {
|
||||||
// a remote target reached via another link (multi-hop) — forward onward
|
// a remote target reached via another link (multi-hop) — forward onward
|
||||||
self.forward_to_target(&target, msg, via);
|
self.forward_to_target(&target, msg, via);
|
||||||
|
|
@ -1852,4 +1924,49 @@ mod tests {
|
||||||
assert!(m.admin, "bot should hold +a (&) from the IJOIN status token");
|
assert!(m.admin, "bot should hold +a (&) from the IJOIN status token");
|
||||||
assert!(m.op, "bot should hold +o (@) from the IJOIN status token");
|
assert!(m.op, "bot should hold +o (@) from the IJOIN status token");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A server is a service iff its NAME matches the sasl_server or a `uline` config
|
||||||
|
// entry (case-insensitive); `silent` is honoured. Mirrors InspIRCd IsService.
|
||||||
|
#[test]
|
||||||
|
fn uline_recognises_services_server() {
|
||||||
|
use crate::config::Config;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
let (tx, _rx) = mpsc::channel();
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
cfg.sasl_server = "services.example.net".to_string();
|
||||||
|
cfg.raw
|
||||||
|
.entry("uline".to_string())
|
||||||
|
.or_default()
|
||||||
|
.push("other.example.net silent".to_string());
|
||||||
|
let s = Server::new(cfg, tx, Arc::new(AtomicU64::new(1)));
|
||||||
|
assert_eq!(s.uline_match("services.example.net"), (true, false)); // sasl_server ⇒ implicit uline
|
||||||
|
assert_eq!(s.uline_match("OTHER.example.net"), (true, true)); // explicit, silent, case-insensitive
|
||||||
|
assert_eq!(s.uline_match("hub.example.net"), (false, false)); // an ordinary peer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVS* authority: only a source on a U-lined services server counts.
|
||||||
|
#[test]
|
||||||
|
fn svs_source_must_be_a_service() {
|
||||||
|
use crate::config::Config;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
let (tx, _rx) = mpsc::channel();
|
||||||
|
let mut s = Server::new(Config::default(), tx, Arc::new(AtomicU64::new(1)));
|
||||||
|
let mk = |sid: &str, is_service: bool| RemoteServer {
|
||||||
|
sid: sid.to_string(),
|
||||||
|
name: format!("{sid}.example.net"),
|
||||||
|
desc: String::new(),
|
||||||
|
via: 1,
|
||||||
|
is_service,
|
||||||
|
silent_service: false,
|
||||||
|
};
|
||||||
|
s.servers.insert("42S".into(), mk("42S", true));
|
||||||
|
s.servers.insert("10H".into(), mk("10H", false));
|
||||||
|
// from a service pseudo-client (uuid → sid 42S) and from the service SID itself
|
||||||
|
assert!(s.source_is_service(&crate::message::parse(":42SB00000 SVSMODE 0AAAAAAAB +r").unwrap()));
|
||||||
|
assert!(s.source_is_service(&crate::message::parse(":42S SVSJOIN 0AAAAAAAB #c").unwrap()));
|
||||||
|
// from an ordinary peer: rejected
|
||||||
|
assert!(!s.source_is_service(&crate::message::parse(":10HAAAAAA SVSNICK 0AAAAAAAB g").unwrap()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,20 @@ impl Server {
|
||||||
self.sasl_server = fresh.sasl_server;
|
self.sasl_server = fresh.sasl_server;
|
||||||
self.webirc = fresh.webirc;
|
self.webirc = fresh.webirc;
|
||||||
self.raw_config = fresh.raw;
|
self.raw_config = fresh.raw;
|
||||||
|
// Re-evaluate which linked servers are services against the fresh
|
||||||
|
// `uline`/`sasl_server` config (like InspIRCd recomputing IsService on rehash).
|
||||||
|
let names: Vec<(String, String)> = self
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.map(|(sid, srv)| (sid.clone(), srv.name.clone()))
|
||||||
|
.collect();
|
||||||
|
for (sid, name) in names {
|
||||||
|
let (is_service, silent_service) = self.uline_match(&name);
|
||||||
|
if let Some(srv) = self.servers.get_mut(&sid) {
|
||||||
|
srv.is_service = is_service;
|
||||||
|
srv.silent_service = silent_service;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remember an identity for WHOWAS (capped ring, newest first).
|
/// Remember an identity for WHOWAS (capped ring, newest first).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue