akick: match the server and fingerprint extbans from the SERVER name and ssl_cert metadata the s2s stream carries
This commit is contained in:
parent
f7c38cedad
commit
74a983467c
4 changed files with 98 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -63,14 +63,27 @@ impl Protocol for InspIrcd {
|
|||
// downstream link — track it for the server tree. The unsourced auth
|
||||
// handshake ( SERVER <name> <pass> <sid> … ) is our uplink registering.
|
||||
"SERVER" => match source {
|
||||
// Downstream link: :<parent> SERVER <name> <sid> [hops] :<desc>.
|
||||
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 <name> <password> <sid> :<desc>.
|
||||
None => {
|
||||
let name = tokens.next().unwrap_or("").to_string();
|
||||
let sid = tokens.nth(1).unwrap_or("").to_string(); // skip <password>
|
||||
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 {
|
|||
}
|
||||
}
|
||||
// :<src> METADATA <target> <key> :<value> — 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 != "*" =>
|
||||
{
|
||||
(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 "<flags> <fp,fp> <dn> <issuer>", or "<flags> <error>"
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<String, String>,
|
||||
// SID -> server name, for the `server` extban. Rebuilt each link.
|
||||
server_names: HashMap<String, String>,
|
||||
// Shared, namespaced stat counters any service contributes to (persisted via
|
||||
// StatsSet). Read by StatServ and the gRPC Stats API.
|
||||
stats: BTreeMap<String, u64>,
|
||||
|
|
@ -85,6 +87,7 @@ pub struct User {
|
|||
pub realhost: String, // the real host, for host bans against it
|
||||
pub ip: String,
|
||||
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<String>) {
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue