diff --git a/api/src/lib.rs b/api/src/lib.rs index 372e15e..0b0cd40 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -57,6 +57,12 @@ pub enum NetEvent { // A downstream server was introduced (a sourced SERVER). `parent` is the SID // that introduced it, so we can track the tree and cascade a hub's SQUIT. ServerLink { sid: String, parent: String }, + // A server's SID-to-name mapping (from any SERVER line, uplink or downstream), + // so the `server` extban can match a user by the server they're on. + ServerInfo { sid: String, name: String }, + // A user's TLS client-certificate fingerprint (ircd `ssl_cert` metadata), for + // the `fingerprint` extban. Empty `fp` means the user has no cert. + UserCert { uid: String, fp: String }, // A server split away (SQUIT). `server` is its SID; every user behind it — and // behind any server in its subtree — is gone, since a split is signalled once // rather than as a QUIT per user. @@ -1129,7 +1135,7 @@ pub fn akick_matches(mask: &str, t: &BanTarget) -> bool { ExtKind::Unauthed => t.account.is_none() && host_hit(v), ExtKind::Realname => glob_match(v, t.gecos), ExtKind::Realmask => v.split_once('+').is_some_and(|(h, r)| host_hit(h) && glob_match(r, t.gecos)), - ExtKind::Server => glob_match(v, t.server), + ExtKind::Server => !t.server.is_empty() && glob_match(v, t.server), ExtKind::Fingerprint => t.fingerprint.is_some_and(|f| glob_match(v, f)), ExtKind::Channel => t.channels.iter().any(|c| glob_match(v, c)), ExtKind::Passive => false, // ircd-enforced; echo has no data to match diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index a00326c..910d669 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -63,14 +63,27 @@ impl Protocol for InspIrcd { // downstream link — track it for the server tree. The unsourced auth // handshake ( SERVER … ) is our uplink registering. "SERVER" => match source { + // Downstream link: : SERVER [hops] :. Some(parent) => { - let _name = tokens.next(); + let name = tokens.next().unwrap_or("").to_string(); match tokens.next() { - Some(sid) if !sid.is_empty() => vec![NetEvent::ServerLink { sid: sid.to_string(), parent }], + Some(sid) if !sid.is_empty() => vec![ + NetEvent::ServerLink { sid: sid.to_string(), parent }, + NetEvent::ServerInfo { sid: sid.to_string(), name }, + ], _ => vec![], } } - None => vec![NetEvent::Registered], + // Uplink auth handshake: SERVER :. + None => { + let name = tokens.next().unwrap_or("").to_string(); + let sid = tokens.nth(1).unwrap_or("").to_string(); // skip + let mut out = vec![NetEvent::Registered]; + if !sid.is_empty() { + out.push(NetEvent::ServerInfo { sid, name }); + } + out + } }, "ENDBURST" => vec![NetEvent::EndBurst], "PING" => { @@ -223,17 +236,26 @@ impl Protocol for InspIrcd { } } // : METADATA : — the ircd sharing user - // state. We care about `accountname` on a uid (e.g. replayed on our - // netburst) to restore who is logged in; an empty value is a logout. + // state. `accountname` (e.g. replayed on our netburst) restores who is + // logged in (empty = logout); `ssl_cert` carries the TLS fingerprint for + // the `fingerprint` extban. "METADATA" => { let a: Vec<&str> = tokens.collect(); match (source.as_deref(), a.first(), a.get(1)) { - (Some(src), Some(target), Some(key)) - if !src.starts_with(self.sid.as_str()) - && key.eq_ignore_ascii_case("accountname") - && *target != "*" => - { - vec![NetEvent::AccountLogin { uid: target.to_string(), account: trailing(rest) }] + (Some(src), Some(target), Some(key)) if !src.starts_with(self.sid.as_str()) && *target != "*" => { + if key.eq_ignore_ascii_case("accountname") { + vec![NetEvent::AccountLogin { uid: target.to_string(), account: trailing(rest) }] + } else if key.eq_ignore_ascii_case("ssl_cert") { + // Value is " ", or " " + // when the flags contain 'E'. Take the first fingerprint. + let value = trailing(rest); + let mut parts = value.split_whitespace(); + let flags = parts.next().unwrap_or(""); + let fp = if flags.contains('E') { "" } else { parts.next().unwrap_or("").split(',').next().unwrap_or("") }; + vec![NetEvent::UserCert { uid: target.to_string(), fp: fp.to_string() }] + } else { + vec![] + } } _ => vec![], } @@ -539,7 +561,30 @@ mod tests { assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB accountname :").as_slice(), [NetEvent::AccountLogin { uid, account }] if uid == "0IRAAAAAB" && account.is_empty()), "empty value = logout"); assert!(p.parse(":42S METADATA 0IRAAAAAB accountname :bob").is_empty(), "our own echo is ignored"); - assert!(p.parse(":0IR METADATA 0IRAAAAAB ssl_cert :deadbeef").is_empty(), "non-account keys ignored"); + assert!(p.parse(":0IR METADATA 0IRAAAAAB swhois :hi").is_empty(), "unhandled keys ignored"); + } + + // `ssl_cert` metadata surfaces the TLS fingerprint (2nd field) for the extban; + // a cert error (flags contain 'E') has no fingerprint. + #[test] + fn parses_ssl_cert_metadata() { + let mut p = proto(); + assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB ssl_cert :VTrSe aabbccddeeff /CN=user /CN=CA").as_slice(), + [NetEvent::UserCert { uid, fp }] if uid == "0IRAAAAAB" && fp == "aabbccddeeff"), "fingerprint captured"); + assert!(matches!(p.parse(":0IR METADATA 0IRAAAAAB ssl_cert :vTrSE unable to get certificate").as_slice(), + [NetEvent::UserCert { fp, .. }] if fp.is_empty()), "cert error = no fingerprint"); + assert!(p.parse(":42S METADATA 0IRAAAAAB ssl_cert :VTrSe aabbccdd").is_empty(), "our own echo is ignored"); + } + + // Both SERVER forms surface a ServerInfo (SID -> name) for the `server` extban: + // the uplink handshake (name password sid) and a sourced downstream link (name sid). + #[test] + fn parses_server_names() { + let mut p = proto(); + assert!(p.parse("SERVER hub.example.net linkpass 0IR :A hub").iter() + .any(|e| matches!(e, NetEvent::ServerInfo { sid, name } if sid == "0IR" && name == "hub.example.net")), "uplink name"); + assert!(p.parse(":0IR SERVER leaf.example.net 0XY :A leaf").iter() + .any(|e| matches!(e, NetEvent::ServerInfo { sid, name } if sid == "0XY" && name == "leaf.example.net")), "downstream name"); } // A KILL surfaces as a UserKilled for the target uid. @@ -554,9 +599,10 @@ mod tests { #[test] fn parses_server_link() { let mut p = proto(); - assert!(matches!(p.parse("SERVER hub.example password 0IR :a hub").as_slice(), [NetEvent::Registered]), "uplink handshake"); + assert!(matches!(p.parse("SERVER hub.example password 0IR :a hub").as_slice(), + [NetEvent::Registered, NetEvent::ServerInfo { .. }]), "uplink handshake"); assert!(matches!(p.parse(":0IR SERVER leaf.example 0XY :a leaf").as_slice(), - [NetEvent::ServerLink { sid, parent }] if sid == "0XY" && parent == "0IR"), "downstream link"); + [NetEvent::ServerLink { sid, parent }, NetEvent::ServerInfo { .. }] if sid == "0XY" && parent == "0IR"), "downstream link"); } // A SQUIT surfaces as a ServerSplit carrying the departed server's SID. diff --git a/src/engine/mod.rs b/src/engine/mod.rs index eb88217..5ec7a2e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1090,6 +1090,14 @@ impl Engine { self.network.set_user_attrs(&uid, ident, realhost, gecos); Vec::new() } + NetEvent::UserCert { uid, fp } => { + self.network.set_user_cert(&uid, fp); + Vec::new() + } + NetEvent::ServerInfo { sid, name } => { + self.network.set_server_name(&sid, name); + Vec::new() + } NetEvent::UserConnect { uid, nick, host, ip } => { let arriving_nick = nick.clone(); self.network.user_connect(uid.clone(), nick, host, ip.clone()); diff --git a/src/engine/state.rs b/src/engine/state.rs index 7dba3bb..9d55210 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -23,6 +23,8 @@ pub struct Network { // Downstream server tree: SID -> the SID that introduced it. Lets a hub's // SQUIT cascade to every server (and user) behind it. Rebuilt each link. servers: HashMap, + // SID -> server name, for the `server` extban. Rebuilt each link. + server_names: HashMap, // Shared, namespaced stat counters any service contributes to (persisted via // StatsSet). Read by StatServ and the gRPC Stats API. stats: BTreeMap, @@ -84,7 +86,8 @@ pub struct User { pub host: String, // the displayed host pub realhost: String, // the real host, for host bans against it pub ip: String, - pub gecos: String, // real name, for realname / realmask extbans + pub gecos: String, // real name, for realname / realmask extbans + pub fingerprint: String, // TLS cert fingerprint (ssl_cert metadata); empty = none } // A channel's live membership, ops, and current key (+k), tracked from the burst. @@ -132,7 +135,7 @@ impl Network { if !ip.is_empty() { *self.sessions.entry(ip.clone()).or_insert(0) += 1; } - self.users.insert(uid.clone(), User { uid, nick, ident: String::new(), host, realhost: String::new(), ip, gecos: String::new() }); + self.users.insert(uid.clone(), User { uid, nick, ident: String::new(), host, realhost: String::new(), ip, gecos: String::new(), fingerprint: String::new() }); } // Fill in the rest of a user's identity (ident, real host, real name), which @@ -145,6 +148,19 @@ impl Network { } } + // Record a user's TLS fingerprint (ssl_cert metadata), for the `fingerprint` extban. + pub fn set_user_cert(&mut self, uid: &str, fp: String) { + if let Some(u) = self.users.get_mut(uid) { + u.fingerprint = fp; + } + } + + // Record a server's name for its SID, so the `server` extban can resolve which + // server a user is on (their uid's SID prefix). + pub fn set_server_name(&mut self, sid: &str, name: String) { + self.server_names.insert(sid.to_string(), name); + } + // Mirror the engine's declarative config operators, so services can enumerate // staff. Called whenever the config oper set changes. pub fn set_config_opers(&mut self, accounts: Vec) { @@ -227,10 +243,9 @@ impl Network { ip: &u.ip, gecos: &u.gecos, account: self.accounts.get(uid).map(String::as_str), - // TODO(extban): capture the user's server + TLS fingerprint from the s2s - // stream to match the `server`/`fingerprint` extbans. - server: "", - fingerprint: None, + // A uid's first 3 chars are its server's SID; resolve that to the name. + server: uid.get(..3).and_then(|sid| self.server_names.get(sid)).map_or("", String::as_str), + fingerprint: (!u.fingerprint.is_empty()).then_some(u.fingerprint.as_str()), channels: self.channels_of(uid), }) } @@ -268,6 +283,7 @@ impl Network { } for s in &subtree { self.servers.remove(s); + self.server_names.remove(s); } subtree } @@ -275,6 +291,7 @@ impl Network { // Drop the whole server tree (on a fresh link, before the burst rebuilds it). pub fn clear_servers(&mut self) { self.servers.clear(); + self.server_names.clear(); } pub fn user_nick_change(&mut self, uid: &str, nick: String) {