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,
|
||||
&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();
|
||||
for name in peers {
|
||||
s.numeric(uid, RPL_MAP, &format!("`- {name}"));
|
||||
|
|
|
|||
|
|
@ -76,9 +76,12 @@ impl Command for Links {
|
|||
RPL_LINKS,
|
||||
&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
|
||||
.servers
|
||||
.values()
|
||||
.filter(|sv| !(hide_svc && sv.is_service))
|
||||
.map(|sv| (sv.name.clone(), sv.desc.clone()))
|
||||
.collect();
|
||||
rows.sort();
|
||||
|
|
@ -123,6 +126,15 @@ impl Command for Whois {
|
|||
RPL_WHOISSERVER,
|
||||
&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 {
|
||||
s.numeric(
|
||||
uid,
|
||||
|
|
|
|||
145
src/link.rs
145
src/link.rs
|
|
@ -45,6 +45,13 @@ pub struct RemoteServer {
|
|||
pub name: String,
|
||||
pub desc: String,
|
||||
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`.
|
||||
|
|
@ -170,17 +177,21 @@ impl Server {
|
|||
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
||||
"IJOIN" if registered => self.link_ijoin_recv(uid, msg),
|
||||
// services (SVS*) enforcement + account login, driven by a linked
|
||||
// services pseudoserver (forwarded on if the target is on another server)
|
||||
"SVSNICK" if registered => self.link_svsnick(uid, msg),
|
||||
"SVSJOIN" if registered => self.link_svsjoin(uid, msg),
|
||||
"SVSPART" if registered => self.link_svspart(uid, msg),
|
||||
"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),
|
||||
// services pseudoserver (forwarded on if the target is on another server).
|
||||
// Like InspIRCd's m_services, these are honoured ONLY from a source on a
|
||||
// U-lined services server — an ordinary peer's SVS* is ignored.
|
||||
"SVSNICK" if registered && self.source_is_service(msg) => self.link_svsnick(uid, msg),
|
||||
"SVSJOIN" if registered && self.source_is_service(msg) => self.link_svsjoin(uid, msg),
|
||||
"SVSPART" if registered && self.source_is_service(msg) => self.link_svspart(uid, msg),
|
||||
"SVSMODE" if registered && self.source_is_service(msg) => self.link_svsmode(uid, msg),
|
||||
"SVSLOGIN" if registered && self.source_is_service(msg) => self.link_svslogin(uid, msg),
|
||||
"SVSLOGOUT" if registered && self.source_is_service(msg) => {
|
||||
self.link_svslogout(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),
|
||||
"METADATA" if registered => self.link_metadata(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.
|
||||
/// 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) {
|
||||
if msg.params.len() < 4 {
|
||||
self.reject_link(uid, "Not enough SERVER parameters");
|
||||
|
|
@ -234,6 +291,7 @@ impl Server {
|
|||
l.sid = Some(sid.clone());
|
||||
l.name = Some(name.clone());
|
||||
}
|
||||
let (is_service, silent_service) = self.uline_match(&name);
|
||||
self.servers.insert(
|
||||
sid.clone(),
|
||||
RemoteServer {
|
||||
|
|
@ -241,8 +299,13 @@ impl Server {
|
|||
name: name.clone(),
|
||||
desc: desc.clone(),
|
||||
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 !already_sent {
|
||||
self.link_out(
|
||||
|
|
@ -961,13 +1024,19 @@ impl Server {
|
|||
if !self.channels.contains_key(&key) {
|
||||
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();
|
||||
for m in members {
|
||||
if self.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||
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
|
||||
for l in self.channel_link_targets(&key, Some(via)) {
|
||||
|
|
@ -979,7 +1048,10 @@ impl Server {
|
|||
.get(&dst)
|
||||
.map(|u| u.nick.clone())
|
||||
.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 {
|
||||
// a remote target reached via another link (multi-hop) — forward onward
|
||||
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.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.webirc = fresh.webirc;
|
||||
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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue