s2s: resolve nick collisions by timestamp (force-rename loser to its UUID, SAVE) instead of killing; services always keep their nick
This commit is contained in:
parent
767ecb3471
commit
d4a7043d10
3 changed files with 108 additions and 10 deletions
114
src/link.rs
114
src/link.rs
|
|
@ -167,6 +167,7 @@ impl Server {
|
||||||
"NICK" if registered => self.link_nick_recv(uid, msg),
|
"NICK" if registered => self.link_nick_recv(uid, msg),
|
||||||
"QUIT" if registered => self.link_quit_recv(uid, msg),
|
"QUIT" if registered => self.link_quit_recv(uid, msg),
|
||||||
"KILL" if registered => self.link_kill_recv(uid, msg),
|
"KILL" if registered => self.link_kill_recv(uid, msg),
|
||||||
|
"SAVE" if registered => self.link_save_recv(uid, msg),
|
||||||
"ADDLINE" if registered => self.link_addline_recv(uid, msg),
|
"ADDLINE" if registered => self.link_addline_recv(uid, msg),
|
||||||
"DELLINE" if registered => self.link_delline_recv(uid, msg),
|
"DELLINE" if registered => self.link_delline_recv(uid, msg),
|
||||||
"PRIVMSG" if registered => self.link_message_recv(uid, msg, false),
|
"PRIVMSG" if registered => self.link_message_recv(uid, msg, false),
|
||||||
|
|
@ -945,7 +946,7 @@ impl Server {
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let nick = msg.params[2].clone();
|
let mut nick = msg.params[2].clone();
|
||||||
let host = msg.params[4].clone(); // displayed host
|
let host = msg.params[4].clone(); // displayed host
|
||||||
let ident = msg.params[6].clone(); // displayed ident
|
let ident = msg.params[6].clone(); // displayed ident
|
||||||
let ip = msg.params[7].clone();
|
let ip = msg.params[7].clone();
|
||||||
|
|
@ -955,12 +956,14 @@ impl Server {
|
||||||
.map(|m| m.trim_start_matches('+').to_string())
|
.map(|m| m.trim_start_matches('+').to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let realname = msg.params.last().cloned().unwrap_or_default();
|
let realname = msg.params.last().cloned().unwrap_or_default();
|
||||||
// nick collision: a local holder is killed (both sides do this, so both
|
// nick collision with a local user: resolve by timestamp, force-renaming the
|
||||||
// vanish deterministically); an existing remote holder simply wins.
|
// loser to its UUID rather than killing anyone.
|
||||||
if let Some(luid) = self.find_nick(&nick) {
|
if let Some(luid) = self.find_nick(&nick) {
|
||||||
self.send(luid, "ERROR :Closing link: Nick collision".to_string());
|
let nickts: u64 = msg.params[1].parse().unwrap_or_else(|_| now());
|
||||||
self.remove_user(luid, "Nick collision");
|
let rsvc = self.server_is_service(&sid);
|
||||||
return;
|
if self.resolve_collision(via, luid, nickts, &ident, &ip, &uuid, rsvc) {
|
||||||
|
nick = uuid.clone(); // the incoming user lost: introduce it under its UUID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if self.remote_nick.contains_key(&nick.to_ascii_lowercase()) {
|
if self.remote_nick.contains_key(&nick.to_ascii_lowercase()) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -992,6 +995,64 @@ impl Server {
|
||||||
self.remote_users.get(uuid).map(|ru| ru.via) == Some(via)
|
self.remote_users.get(uuid).map(|ru| ru.via) == Some(via)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a nick collision between local user `luid` and an incoming remote
|
||||||
|
/// user by timestamp: same user@ip → the OLDER changes; else the NEWER changes;
|
||||||
|
/// equal TS → both. The loser is force-renamed to its UUID — locally right here
|
||||||
|
/// (with the normal NICK propagation), remotely via a SAVE back to the source.
|
||||||
|
/// Returns true if the REMOTE user must take its UUID.
|
||||||
|
fn resolve_collision(
|
||||||
|
&mut self,
|
||||||
|
via: Uid,
|
||||||
|
luid: Uid,
|
||||||
|
remote_ts: u64,
|
||||||
|
remote_user: &str,
|
||||||
|
remote_ip: &str,
|
||||||
|
remote_uuid: &str,
|
||||||
|
remote_is_service: bool,
|
||||||
|
) -> bool {
|
||||||
|
let (local_ts, local_user, local_ip, local_uuid) = match self.users.get(&luid) {
|
||||||
|
Some(u) => (
|
||||||
|
u.nick_ts,
|
||||||
|
u.ident.clone(),
|
||||||
|
u.addr.ip().to_string(),
|
||||||
|
u.uuid.clone(),
|
||||||
|
),
|
||||||
|
None => return true,
|
||||||
|
};
|
||||||
|
let same = local_user == remote_user && local_ip == remote_ip;
|
||||||
|
// a network service always keeps its nick; the local user is the one to yield
|
||||||
|
let (change_local, change_remote) = if remote_is_service {
|
||||||
|
(true, false)
|
||||||
|
} else {
|
||||||
|
collision_decision(local_ts, remote_ts, same)
|
||||||
|
};
|
||||||
|
if change_local {
|
||||||
|
self.set_nick(luid, &local_uuid);
|
||||||
|
}
|
||||||
|
if change_remote {
|
||||||
|
self.link_out(via, format!(":{} SAVE {} {}", self.sid, remote_uuid, remote_ts));
|
||||||
|
}
|
||||||
|
change_remote
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:<src> SAVE <uuid> <ts>` — force our local user to its UUID if the ts still
|
||||||
|
/// matches (it lost a collision elsewhere), or forward toward a remote target.
|
||||||
|
fn link_save_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
let (Some(target), Some(ts)) = (msg.params.first().cloned(), msg.params.get(1).cloned())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ts: u64 = ts.parse().unwrap_or(0);
|
||||||
|
if let Some(&luid) = self.uuid_local.get(&target) {
|
||||||
|
if self.users.get(&luid).map(|u| u.nick_ts) == Some(ts) {
|
||||||
|
let uuid = self.users[&luid].uuid.clone();
|
||||||
|
self.set_nick(luid, &uuid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.forward_to_target(&target, msg, via);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn link_nick_recv(&mut self, via: Uid, msg: &Message) {
|
fn link_nick_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
// :<uuid> NICK <newnick> [<ts>]
|
// :<uuid> NICK <newnick> [<ts>]
|
||||||
let Some(uuid) = msg.source.clone() else {
|
let Some(uuid) = msg.source.clone() else {
|
||||||
|
|
@ -1003,11 +1064,19 @@ impl Server {
|
||||||
if !self.sourced_via(&uuid, via) {
|
if !self.sourced_via(&uuid, via) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// collision with a local user: kill the local holder (same policy as an
|
// collision with a local user: resolve by timestamp (force-rename the loser
|
||||||
// incoming UID clash) so the network converges to one owner for the nick
|
// to its UUID) rather than killing.
|
||||||
if let Some(luid) = self.find_nick(&newnick) {
|
if let Some(luid) = self.find_nick(&newnick) {
|
||||||
self.send(luid, "ERROR :Closing link: Nick collision".to_string());
|
let remote_ts = msg.params.get(1).and_then(|t| t.parse().ok()).unwrap_or_else(now);
|
||||||
self.remove_user(luid, "Nick collision");
|
let (ruser, rip) = self
|
||||||
|
.remote_users
|
||||||
|
.get(&uuid)
|
||||||
|
.map(|r| (r.ident.clone(), r.ip.clone()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let rsvc = self.uuid_is_service(&uuid);
|
||||||
|
if self.resolve_collision(via, luid, remote_ts, &ruser, &rip, &uuid, rsvc) {
|
||||||
|
return; // remote lost: it keeps its old nick; a SAVE will move it to UUID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let old = match self.remote_users.get_mut(&uuid) {
|
let old = match self.remote_users.get_mut(&uuid) {
|
||||||
Some(ru) => {
|
Some(ru) => {
|
||||||
|
|
@ -2044,6 +2113,18 @@ impl Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nick-collision outcome `(change_local, change_remote)` by timestamp: same
|
||||||
|
/// user@ip → the older nick changes; different → the newer changes; equal → both.
|
||||||
|
fn collision_decision(local_ts: u64, remote_ts: u64, same_person: bool) -> (bool, bool) {
|
||||||
|
if remote_ts == local_ts {
|
||||||
|
(true, true)
|
||||||
|
} else if (same_person && remote_ts < local_ts) || (!same_person && remote_ts > local_ts) {
|
||||||
|
(false, true)
|
||||||
|
} else {
|
||||||
|
(true, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Split a bursted member token `ov,0AAAAAAAB` (with an optional `:membid`
|
/// Split a bursted member token `ov,0AAAAAAAB` (with an optional `:membid`
|
||||||
/// suffix) into its status mode letters and the bare uuid.
|
/// suffix) into its status mode letters and the bare uuid.
|
||||||
fn split_member(tok: &str) -> (String, String) {
|
fn split_member(tok: &str) -> (String, String) {
|
||||||
|
|
@ -2056,6 +2137,19 @@ fn split_member(tok: &str) -> (String, String) {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nick_collision_timestamp_rules() {
|
||||||
|
// equal TS → both change
|
||||||
|
assert_eq!(collision_decision(100, 100, true), (true, true));
|
||||||
|
assert_eq!(collision_decision(100, 100, false), (true, true));
|
||||||
|
// same user@ip (reconnect): the OLDER nick changes
|
||||||
|
assert_eq!(collision_decision(200, 100, true), (false, true)); // remote older → remote
|
||||||
|
assert_eq!(collision_decision(100, 200, true), (true, false)); // local older → local
|
||||||
|
// different user@ip: the NEWER nick changes
|
||||||
|
assert_eq!(collision_decision(100, 200, false), (false, true)); // remote newer → remote
|
||||||
|
assert_eq!(collision_decision(200, 100, false), (true, false)); // local newer → local
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sid_validation() {
|
fn sid_validation() {
|
||||||
assert!(valid_sid("0AA"));
|
assert!(valid_sid("0AA"));
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,7 @@ impl Server {
|
||||||
certfp,
|
certfp,
|
||||||
account: None,
|
account: None,
|
||||||
signon: now(),
|
signon: now(),
|
||||||
|
nick_ts: now(),
|
||||||
addr,
|
addr,
|
||||||
port: local_port,
|
port: local_port,
|
||||||
registered: false,
|
registered: false,
|
||||||
|
|
@ -1192,6 +1193,7 @@ mod tests {
|
||||||
certfp: None,
|
certfp: None,
|
||||||
account: None,
|
account: None,
|
||||||
signon: 0,
|
signon: 0,
|
||||||
|
nick_ts: 0,
|
||||||
addr: "127.0.0.1:1".parse().unwrap(),
|
addr: "127.0.0.1:1".parse().unwrap(),
|
||||||
port: 6667,
|
port: 6667,
|
||||||
registered: true,
|
registered: true,
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,7 @@ pub struct User {
|
||||||
pub certfp: Option<String>, // TLS client-cert fingerprint (SASL EXTERNAL / CertFP)
|
pub certfp: Option<String>, // TLS client-cert fingerprint (SASL EXTERNAL / CertFP)
|
||||||
pub account: Option<String>, // logged-in account name (set by services)
|
pub account: Option<String>, // logged-in account name (set by services)
|
||||||
pub signon: u64, // unix secs at registration (WHOIS 317)
|
pub signon: u64, // unix secs at registration (WHOIS 317)
|
||||||
|
pub nick_ts: u64, // unix secs the current nick was taken (nick-collision arbitration)
|
||||||
pub addr: SocketAddr,
|
pub addr: SocketAddr,
|
||||||
pub port: u16, // listener port the client connected to (connectclass port=, ident)
|
pub port: u16, // listener port the client connected to (connectclass port=, ident)
|
||||||
pub registered: bool,
|
pub registered: bool,
|
||||||
|
|
@ -450,6 +451,7 @@ impl Server {
|
||||||
self.nick_index.insert(newnick.to_ascii_lowercase(), uid);
|
self.nick_index.insert(newnick.to_ascii_lowercase(), uid);
|
||||||
if let Some(u) = self.users.get_mut(&uid) {
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
u.nick = newnick.to_string();
|
u.nick = newnick.to_string();
|
||||||
|
u.nick_ts = crate::server::now();
|
||||||
}
|
}
|
||||||
if registered {
|
if registered {
|
||||||
let line = format!(":{prefix} NICK :{newnick}");
|
let line = format!(":{prefix} NICK :{newnick}");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue