nickserv: log out the loser of a registration conflict
When a peer's registration wins a name a local session was logged into, that session authenticated against a credential that no longer exists. The node now clears its accountname (the ircd emits RPL_LOGGEDOUT) and tells the user why. A new outbound channel lets the engine push these services-initiated actions to the uplink from the gossip path, drained by the link loop alongside incoming traffic.
This commit is contained in:
parent
f46662db90
commit
aab64baf86
7 changed files with 164 additions and 33 deletions
|
|
@ -21,6 +21,9 @@ impl Service for NickServ {
|
||||||
fn gecos(&self) -> &str {
|
fn gecos(&self) -> &str {
|
||||||
"Nickname Services"
|
"Nickname Services"
|
||||||
}
|
}
|
||||||
|
fn manages_accounts(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &Network, db: &mut Db) {
|
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &Network, db: &mut Db) {
|
||||||
let me = self.uid.as_str();
|
let me = self.uid.as_str();
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,13 @@ pub struct LogEntry {
|
||||||
event: Event,
|
event: Event,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl LogEntry {
|
||||||
|
pub(crate) fn for_test(origin: &str, seq: u64, lamport: u64, event: Event) -> Self {
|
||||||
|
LogEntry { origin: origin.to_string(), seq, lamport, event }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Append-only log, the sole persistent source of truth: `open` replays it,
|
// Append-only log, the sole persistent source of truth: `open` replays it,
|
||||||
// `append` stamps and writes a locally-authored entry, and `ingest` folds in an
|
// `append` stamps and writes a locally-authored entry, and `ingest` folds in an
|
||||||
// entry authored by another node. The version vector (highest seq applied per
|
// entry authored by another node. The version vector (highest seq applied per
|
||||||
|
|
@ -439,12 +446,25 @@ impl Db {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold an entry authored by another node into the store — the services-side
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
/// of the gossip seam. Idempotent (re-delivered entries are dropped).
|
/// of the gossip seam. Idempotent (re-delivered entries are dropped). Returns
|
||||||
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
|
/// the account name if this ingest changed its owner (a registration conflict
|
||||||
|
/// resolved against the local claim), so the caller can log out stale sessions.
|
||||||
|
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<Option<String>> {
|
||||||
|
// Record the current owner of an incoming account before applying, to see
|
||||||
|
// if this ingest transfers ownership away from us.
|
||||||
|
let contested = match &entry.event {
|
||||||
|
Event::AccountRegistered(a) => self.accounts.get(&key(&a.name)).map(|cur| (a.name.clone(), cur.home.clone())),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
if let Some(event) = self.log.ingest(entry)? {
|
if let Some(event) = self.log.ingest(entry)? {
|
||||||
apply(&mut self.accounts, &mut self.channels, event);
|
apply(&mut self.accounts, &mut self.channels, event);
|
||||||
}
|
}
|
||||||
Ok(())
|
if let Some((name, prev_home)) = contested {
|
||||||
|
if self.accounts.get(&key(&name)).is_some_and(|cur| cur.home != prev_home) {
|
||||||
|
return Ok(Some(name)); // ownership moved to another node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the log to one entry per account and channel, reclaiming churn.
|
/// Rewrite the log to one entry per account and channel, reclaiming churn.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use std::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::proto::{NetAction, NetEvent, RegReply};
|
use crate::proto::{NetAction, NetEvent, RegReply};
|
||||||
use db::{Db, LogEntry, RegError};
|
use db::{Db, LogEntry, RegError};
|
||||||
|
|
@ -74,11 +75,14 @@ pub struct Engine {
|
||||||
sasl_sessions: HashMap<String, TimedSession>, // client uid -> in-progress exchange
|
sasl_sessions: HashMap<String, TimedSession>, // client uid -> in-progress exchange
|
||||||
reg_limiter: RegLimiter,
|
reg_limiter: RegLimiter,
|
||||||
chan_service: Option<String>, // uid to source channel modes from (ChanServ)
|
chan_service: Option<String>, // uid to source channel modes from (ChanServ)
|
||||||
|
nick_service: Option<String>, // uid of the account service (NickServ), for its notices
|
||||||
|
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
pub fn new(services: Vec<Box<dyn Service>>, db: Db) -> Self {
|
pub fn new(services: Vec<Box<dyn Service>>, db: Db) -> Self {
|
||||||
let chan_service = services.iter().find(|s| s.manages_channels()).map(|s| s.uid().to_string());
|
let chan_service = services.iter().find(|s| s.manages_channels()).map(|s| s.uid().to_string());
|
||||||
|
let nick_service = services.iter().find(|s| s.manages_accounts()).map(|s| s.uid().to_string());
|
||||||
Self {
|
Self {
|
||||||
services,
|
services,
|
||||||
network: Network::default(),
|
network: Network::default(),
|
||||||
|
|
@ -86,6 +90,21 @@ impl Engine {
|
||||||
sasl_sessions: HashMap::new(),
|
sasl_sessions: HashMap::new(),
|
||||||
reg_limiter: RegLimiter::new(),
|
reg_limiter: RegLimiter::new(),
|
||||||
chan_service,
|
chan_service,
|
||||||
|
nick_service,
|
||||||
|
irc_out: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the channel the link layer drains, so the engine can push actions to the
|
||||||
|
// uplink that no ircd event asked for (here: a forced logout after losing a
|
||||||
|
// registration conflict).
|
||||||
|
pub fn set_irc_out(&mut self, tx: mpsc::UnboundedSender<NetAction>) {
|
||||||
|
self.irc_out = Some(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_irc(&self, action: NetAction) {
|
||||||
|
if let Some(tx) = &self.irc_out {
|
||||||
|
let _ = tx.send(action); // unbounded: never blocks; only fails if the link is down
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +123,29 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
|
pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
|
||||||
self.db.ingest(entry)
|
// If ingesting a peer's registration took an account name away from a
|
||||||
|
// local session (it lost the conflict), log that session out.
|
||||||
|
if let Some(account) = self.db.ingest(entry)? {
|
||||||
|
self.logout_lost_sessions(&account);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A registration conflict resolved against this node: the name `account` is now
|
||||||
|
// owned by another node. Any local session logged into it authenticated against
|
||||||
|
// the old owner's credential, which no longer exists — force them out and say why.
|
||||||
|
fn logout_lost_sessions(&mut self, account: &str) {
|
||||||
|
for uid in self.network.uids_logged_into(account) {
|
||||||
|
self.network.clear_account(&uid);
|
||||||
|
self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() });
|
||||||
|
if let Some(ns) = &self.nick_service {
|
||||||
|
self.emit_irc(NetAction::Notice {
|
||||||
|
from: ns.clone(),
|
||||||
|
to: uid,
|
||||||
|
text: format!("Your registration of \x02{account}\x02 collided with another network and it now belongs elsewhere. You have been logged out; please register a different name."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact the log if it has grown past the live account count. Returns
|
// Compact the log if it has grown past the live account count. Returns
|
||||||
|
|
@ -988,6 +1029,42 @@ mod tests {
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "realnick")), "must log into the current nick's account: {out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "realnick")), "must log into the current nick's account: {out:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Losing a registration conflict logs out the local session on that name and
|
||||||
|
// notifies them over the services-initiated outbound path.
|
||||||
|
#[test]
|
||||||
|
fn lost_conflict_logs_out_local_session() {
|
||||||
|
let mut e = engine_with("lostconf", "alice", "sesame");
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
e.set_irc_out(tx);
|
||||||
|
// A local user logs into alice.
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||||
|
assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "logged in before the conflict");
|
||||||
|
|
||||||
|
// An earlier claim from another node wins and takes the name over.
|
||||||
|
let winner = db::Account {
|
||||||
|
name: "alice".into(), password_hash: "OTHER".into(), email: None,
|
||||||
|
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![],
|
||||||
|
};
|
||||||
|
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner));
|
||||||
|
e.gossip_ingest(entry).unwrap();
|
||||||
|
|
||||||
|
// The local session is logged out...
|
||||||
|
assert!(e.network.account_of("000AAAAAB").is_none(), "session cleared after losing the name");
|
||||||
|
// ...and the outbound path got a logout (empty accountname) plus a notice.
|
||||||
|
let mut logout = false;
|
||||||
|
let mut notice = false;
|
||||||
|
while let Ok(a) = rx.try_recv() {
|
||||||
|
match a {
|
||||||
|
NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty() => logout = true,
|
||||||
|
NetAction::Notice { to, text, .. } if to == "000AAAAAB" && text.contains("collided") => notice = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(logout, "a logout must be pushed to the uplink");
|
||||||
|
assert!(notice, "the user must be told why");
|
||||||
|
}
|
||||||
|
|
||||||
// IDENTIFY accepts the two-arg account+password form: log into a named
|
// IDENTIFY accepts the two-arg account+password form: log into a named
|
||||||
// account even when the current nick differs; a wrong password is refused.
|
// account even when the current nick differs; a wrong password is refused.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ pub trait Service: Send {
|
||||||
fn manages_channels(&self) -> bool {
|
fn manages_channels(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
// Whether this is the account service (NickServ), so the engine can source
|
||||||
|
// account-related notices from it.
|
||||||
|
fn manages_accounts(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db);
|
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,15 @@ impl Network {
|
||||||
self.accounts.get(uid).map(String::as_str)
|
self.accounts.get(uid).map(String::as_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uids of every live session currently identified to `account`.
|
||||||
|
pub fn uids_logged_into(&self, account: &str) -> Vec<String> {
|
||||||
|
self.accounts
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, a)| a.eq_ignore_ascii_case(account))
|
||||||
|
.map(|(uid, _)| uid.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_account(&mut self, uid: &str, account: &str) {
|
pub fn set_account(&mut self, uid: &str, account: &str) {
|
||||||
self.accounts.insert(uid.to_string(), account.to_string());
|
self.accounts.insert(uid.to_string(), account.to_string());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
69
src/link.rs
69
src/link.rs
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
use crate::engine::db::Db;
|
use crate::engine::db::Db;
|
||||||
use crate::engine::Engine;
|
use crate::engine::Engine;
|
||||||
|
|
@ -12,7 +12,7 @@ use crate::proto::{NetAction, Protocol};
|
||||||
// One uplink session: connect, handshake + burst, then translate lines forever.
|
// One uplink session: connect, handshake + burst, then translate lines forever.
|
||||||
// The engine is shared with the gossip layer, so it is locked per operation and
|
// The engine is shared with the gossip layer, so it is locked per operation and
|
||||||
// never held across the registration key-stretching await.
|
// never held across the registration key-stretching await.
|
||||||
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str) -> Result<()> {
|
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>) -> Result<()> {
|
||||||
let stream = TcpStream::connect(addr).await?;
|
let stream = TcpStream::connect(addr).await?;
|
||||||
let (read, mut write) = stream.into_split();
|
let (read, mut write) = stream.into_split();
|
||||||
let mut lines = BufReader::new(read).lines();
|
let mut lines = BufReader::new(read).lines();
|
||||||
|
|
@ -27,35 +27,48 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some(line) = lines.next_line().await? {
|
loop {
|
||||||
tracing::debug!(dir = "<<", %line);
|
tokio::select! {
|
||||||
for event in proto.parse(&line) {
|
// A line from the uplink: translate it and answer.
|
||||||
let actions = engine.lock().await.handle(event);
|
line = lines.next_line() => {
|
||||||
for action in actions {
|
let Some(line) = line? else { break };
|
||||||
let outs = match action {
|
tracing::debug!(dir = "<<", %line);
|
||||||
// Registration: derive the password off the reactor so the
|
for event in proto.parse(&line) {
|
||||||
// ~1s of key stretching can't stall the link, after a cheap
|
let actions = engine.lock().await.handle(event);
|
||||||
// gate that rejects taken names and rate-limits floods.
|
for action in actions {
|
||||||
NetAction::DeferRegister { account, password, email, reply } => {
|
let outs = match action {
|
||||||
let pre = engine.lock().await.pre_register_check(&account, &reply);
|
// Registration: derive the password off the reactor so the
|
||||||
match pre {
|
// ~1s of key stretching can't stall the link, after a cheap
|
||||||
Some(rejection) => rejection,
|
// gate that rejects taken names and rate-limits floods.
|
||||||
None => {
|
NetAction::DeferRegister { account, password, email, reply } => {
|
||||||
let iterations = engine.lock().await.scram_iterations();
|
let pre = engine.lock().await.pre_register_check(&account, &reply);
|
||||||
let creds = tokio::task::spawn_blocking(move || {
|
match pre {
|
||||||
Db::derive_credentials(&password, iterations)
|
Some(rejection) => rejection,
|
||||||
})
|
None => {
|
||||||
.await?;
|
let iterations = engine.lock().await.scram_iterations();
|
||||||
engine.lock().await.complete_register(&account, creds, email, reply)
|
let creds = tokio::task::spawn_blocking(move || {
|
||||||
|
Db::derive_credentials(&password, iterations)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
engine.lock().await.complete_register(&account, creds, email, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action => vec![action],
|
||||||
|
};
|
||||||
|
for act in outs {
|
||||||
|
for out in proto.serialize(&act) {
|
||||||
|
send(&mut write, &out).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
action => vec![action],
|
}
|
||||||
};
|
}
|
||||||
for act in outs {
|
// A services-initiated action (e.g. a forced logout after a lost
|
||||||
for out in proto.serialize(&act) {
|
// registration conflict), pushed by the engine from the gossip path.
|
||||||
send(&mut write, &out).await?;
|
Some(action) = irc_rx.recv() => {
|
||||||
}
|
for out in proto.serialize(&action) {
|
||||||
|
send(&mut write, &out).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ async fn main() -> Result<()> {
|
||||||
db.set_outbound(gossip_tx.clone());
|
db.set_outbound(gossip_tx.clone());
|
||||||
let engine = Arc::new(Mutex::new(Engine::new(services, db)));
|
let engine = Arc::new(Mutex::new(Engine::new(services, db)));
|
||||||
|
|
||||||
|
// Channel for services-initiated actions to reach the uplink (drained by the link loop).
|
||||||
|
let (irc_tx, irc_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
engine.lock().await.set_irc_out(irc_tx);
|
||||||
|
|
||||||
if let Some(gossip) = cfg.gossip.clone() {
|
if let Some(gossip) = cfg.gossip.clone() {
|
||||||
tracing::info!(peers = cfg.peer.len(), "starting gossip");
|
tracing::info!(peers = cfg.peer.len(), "starting gossip");
|
||||||
tokio::spawn(gossip::run(engine.clone(), gossip, cfg.peer.clone(), cfg.server.sid.clone(), gossip_tx));
|
tokio::spawn(gossip::run(engine.clone(), gossip, cfg.peer.clone(), cfg.server.sid.clone(), gossip_tx));
|
||||||
|
|
@ -77,5 +81,5 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
|
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
|
||||||
tracing::info!(server = %cfg.server.name, %addr, "linking to uplink");
|
tracing::info!(server = %cfg.server.name, %addr, "linking to uplink");
|
||||||
link::run(proto, engine, &addr).await
|
link::run(proto, engine, &addr, irc_rx).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue