From d012623dff5372b797b8fa8736fa270a1355ea96 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 16 Aug 2026 13:43:17 +0000 Subject: [PATCH] services: add server-level uline/is_service flag and gate the service WHOIS line, echo/services tag, MAP/LINKS hide and SVS* on it --- src/coremods/core_extra.rs | 9 ++- src/coremods/core_info.rs | 12 +++ src/link.rs | 145 +++++++++++++++++++++++++++++++++---- src/server.rs | 14 ++++ 4 files changed, 165 insertions(+), 15 deletions(-) diff --git a/src/coremods/core_extra.rs b/src/coremods/core_extra.rs index 9dd359b..63508a0 100644 --- a/src/coremods/core_extra.rs +++ b/src/coremods/core_extra.rs @@ -304,7 +304,14 @@ impl Command for Map { RPL_MAP, &format!("{} ({} users)", s.name, s.users.len()), ); - let mut peers: Vec = 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 = 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}")); diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 6306fc4..9061f55 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -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, diff --git a/src/link.rs b/src/link.rs index 7319c37..30c407c 100644 --- a/src/link.rs +++ b/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 `` — 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 :` 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 = [silent]` entry. + /// Case-insensitive, matching InspIRCd's `` 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 = 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())); + } } diff --git a/src/server.rs b/src/server.rs index 812905f..2c608ce 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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).