From 75ba9b35b4dc977ffd558173b3c877597430b7b7 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 02:09:03 +0000 Subject: [PATCH] nickserv: AJOIN auto-join list Adds the account auto-join list (AJOIN ADD/DEL/LIST): NickServ SVSJOINs you into your listed channels each time you identify. The list is a typed Vec on the account, folded through the event log like the cert and akick lists (persists and replays; no separate store), reached by modules through the Store trait. New ForceJoin action serialises to InspIRCd SVSJOIN. Shown in INFO. Operates on the caller's own account; the oper form for other users waits on a privilege system. --- api/src/lib.rs | 23 +++++++++ inspircd/src/lib.rs | 9 ++++ nickserv/src/ajoin.rs | 63 ++++++++++++++++++++++++ nickserv/src/identify.rs | 4 ++ nickserv/src/info.rs | 4 ++ nickserv/src/lib.rs | 5 +- src/engine/db.rs | 103 +++++++++++++++++++++++++++++++++++++-- src/engine/mod.rs | 2 +- src/grpc.rs | 3 ++ 9 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 nickserv/src/ajoin.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index fd18f65..241b22b 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -59,6 +59,9 @@ pub enum NetAction { // Force a user's nick (SVSNICK), e.g. renaming to a guest nick on logout. // The protocol stamps the new nick's timestamp. ForceNick { uid: String, nick: String }, + // Force a user into a channel (SVSJOIN), e.g. applying an account's auto-join + // list on identify. `key` is empty for keyless channels. + ForceJoin { uid: String, channel: String, key: String }, // Set channel modes from services, e.g. +r on a registered channel. `from` is // the pseudoclient uid to source it from (empty = the services server). The // protocol stamps a timestamp the ircd will accept. @@ -189,6 +192,15 @@ impl ServiceCtx { }); } + // Force a user into a channel (SVSJOIN), e.g. an account's auto-join list. + pub fn force_join(&mut self, uid: &str, channel: &str, key: &str) { + self.actions.push(NetAction::ForceJoin { + uid: uid.to_string(), + channel: channel.to_string(), + key: key.to_string(), + }); + } + // Set channel modes, sourced from pseudoclient `from` (e.g. ChanServ). pub fn channel_mode(&mut self, from: &str, channel: &str, modes: &str) { self.actions.push(NetAction::ChannelMode { @@ -259,6 +271,13 @@ pub struct ChanAkickView { pub reason: String, } +// One auto-join entry: a channel joined on identify, with an optional key. +#[derive(Debug, Clone)] +pub struct AjoinView { + pub channel: String, + pub key: String, +} + // A registered channel and its ops lists. #[derive(Debug, Clone)] pub struct ChannelView { @@ -374,6 +393,10 @@ pub trait Store { fn drop_account(&mut self, account: &str) -> Result; fn certfp_add(&mut self, account: &str, fp: &str) -> Result<(), CertError>; fn certfp_del(&mut self, account: &str, fp: &str) -> Result; + // Per-account auto-join list (AJOIN): channels the user is SVSJOINed to on identify. + fn ajoin_list(&self, account: &str) -> Vec; + fn ajoin_add(&mut self, account: &str, channel: &str, key: &str) -> Result; + fn ajoin_del(&mut self, account: &str, channel: &str) -> Result; fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>; fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>; fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>; diff --git a/inspircd/src/lib.rs b/inspircd/src/lib.rs index aefaa85..726078f 100644 --- a/inspircd/src/lib.rs +++ b/inspircd/src/lib.rs @@ -254,6 +254,15 @@ impl Protocol for InspIrcd { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); vec![self.from_us(format!("SVSNICK {} {} {}", uid, nick, now))] } + // SVSJOIN [key]: force a user into a channel, sourced from + // the services server. Used to apply an account's auto-join list. + NetAction::ForceJoin { uid, channel, key } => { + if key.is_empty() { + vec![self.from_us(format!("SVSJOIN {} {}", uid, channel))] + } else { + vec![self.from_us(format!("SVSJOIN {} {} {}", uid, channel, key))] + } + } // FMODE . The ircd drops an FMODE whose TS is newer // than the channel's, so we send TS 1 to guarantee it applies. Sourced // from the given pseudoclient (e.g. ChanServ) so users see who set it, diff --git a/nickserv/src/ajoin.rs b/nickserv/src/ajoin.rs new file mode 100644 index 0000000..65cd233 --- /dev/null +++ b/nickserv/src/ajoin.rs @@ -0,0 +1,63 @@ +use fedserv_api::Store; +use fedserv_api::{Sender, ServiceCtx}; + +// A sane cap so a runaway list can't bloat an account or flood a user on identify. +const MAX_AJOIN: usize = 25; + +// AJOIN [ADD <#channel> [key] | DEL <#channel> | LIST]: manage your auto-join +// list — the channels NickServ joins you to each time you identify. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You must identify to NickServ to use \x02AJOIN\x02."); + return; + }; + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => { + let Some(&channel) = args.get(2) else { + ctx.notice(me, from.uid, "Syntax: AJOIN ADD <#channel> [key]"); + return; + }; + if !channel.starts_with('#') { + ctx.notice(me, from.uid, "That doesn't look like a channel name."); + return; + } + let key = args.get(3).copied().unwrap_or(""); + if db.ajoin_list(account).len() >= MAX_AJOIN { + ctx.notice(me, from.uid, format!("Your auto-join list is full (max {MAX_AJOIN}).")); + return; + } + match db.ajoin_add(account, channel, key) { + Ok(true) => ctx.notice(me, from.uid, format!("Added \x02{channel}\x02 to your auto-join list.")), + Ok(false) => ctx.notice(me, from.uid, format!("Updated the key for \x02{channel}\x02 on your auto-join list.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + Some("DEL") => { + let Some(&channel) = args.get(2) else { + ctx.notice(me, from.uid, "Syntax: AJOIN DEL <#channel>"); + return; + }; + match db.ajoin_del(account, channel) { + Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{channel}\x02 from your auto-join list.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{channel}\x02 isn't on your auto-join list.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + None | Some("LIST") => { + let list = db.ajoin_list(account); + if list.is_empty() { + ctx.notice(me, from.uid, "Your auto-join list is empty. Add channels with \x02AJOIN ADD <#channel>\x02."); + return; + } + ctx.notice(me, from.uid, format!("Your auto-join list ({}):", list.len())); + for e in &list { + if e.key.is_empty() { + ctx.notice(me, from.uid, format!(" \x02{}\x02", e.channel)); + } else { + ctx.notice(me, from.uid, format!(" \x02{}\x02 (key: {})", e.channel, e.key)); + } + } + } + Some(other) => ctx.notice(me, from.uid, format!("Unknown AJOIN command \x02{other}\x02. Use \x02ADD\x02, \x02DEL\x02 or \x02LIST\x02.")), + } +} diff --git a/nickserv/src/identify.rs b/nickserv/src/identify.rs index dc00ec9..bf9ae71 100644 --- a/nickserv/src/identify.rs +++ b/nickserv/src/identify.rs @@ -27,6 +27,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: let account = account.to_string(); ctx.login(from.uid, &account); ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account)); + // Apply the account's auto-join list (AJOIN). + for entry in db.ajoin_list(&account) { + ctx.force_join(from.uid, &entry.channel, &entry.key); + } } None => ctx.notice(me, from.uid, "Invalid password. Please try again."), } diff --git a/nickserv/src/info.rs b/nickserv/src/info.rs index 94334b9..6d49cd8 100644 --- a/nickserv/src/info.rs +++ b/nickserv/src/info.rs @@ -17,5 +17,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")), None => ctx.notice(me, from.uid, " Email : (none set)"), } + let ajoin = db.ajoin_list(&acct.name); + if !ajoin.is_empty() { + ctx.notice(me, from.uid, format!(" Auto-join : {} channel(s) — see \x02AJOIN LIST\x02", ajoin.len())); + } } } diff --git a/nickserv/src/lib.rs b/nickserv/src/lib.rs index 89207d5..1946a61 100644 --- a/nickserv/src/lib.rs +++ b/nickserv/src/lib.rs @@ -30,6 +30,8 @@ mod ghost; mod resetpass; #[path = "confirm.rs"] mod confirm; +#[path = "ajoin.rs"] +mod ajoin; pub struct NickServ { pub uid: String, @@ -70,7 +72,8 @@ impl Service for NickServ { Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db), Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db), Some("CONFIRM") => confirm::handle(me, from, args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 [email], \x02IDENTIFY\x02 [account] , \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 , \x02CONFIRM\x02 , \x02DROP\x02 , \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST [fingerprint]."), + Some("AJOIN") => ajoin::handle(me, from, args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 [email], \x02IDENTIFY\x02 [account] , \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 [password], \x02SET\x02 PASSWORD|EMAIL, \x02AJOIN\x02 ADD|DEL|LIST, \x02RESETPASS\x02 , \x02CONFIRM\x02 , \x02DROP\x02 , \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST [fingerprint]."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/src/engine/db.rs b/src/engine/db.rs index b34dd14..f01abeb 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -15,7 +15,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, ChanAccessView, ChanAkickView, ChanError, ChannelView, CertError, CodeKind, RegError, Store, + AccountView, AjoinView, ChanAccessView, ChanAkickView, ChanError, ChannelView, CertError, CodeKind, RegError, Store, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -46,6 +46,9 @@ pub struct Account { // as verified. #[serde(default = "verified_default")] pub verified: bool, + // Channels this account is auto-joined to on identify (AJOIN). + #[serde(default)] + pub ajoin: Vec, } fn verified_default() -> bool { @@ -65,6 +68,8 @@ pub enum Event { AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String }, AccountDropped { account: String }, AccountVerified { account: String }, + AjoinAdded { account: String, channel: String, key: String }, + AjoinRemoved { account: String, channel: String }, NickGrouped { nick: String, account: String }, NickUngrouped { nick: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, @@ -99,6 +104,8 @@ impl Event { | Event::AccountPasswordSet { .. } | Event::AccountDropped { .. } | Event::AccountVerified { .. } + | Event::AjoinAdded { .. } + | Event::AjoinRemoved { .. } | Event::NickGrouped { .. } | Event::NickUngrouped { .. } => Scope::Global, Event::ChannelRegistered { .. } @@ -129,6 +136,15 @@ pub struct ChanAkick { pub reason: String, } +// An auto-join entry: a channel this account is joined to on identify, with an +// optional key for keyed channels. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AjoinEntry { + pub channel: String, + #[serde(default)] + pub key: String, +} + // A registered channel and who owns it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChannelInfo { @@ -634,6 +650,7 @@ impl Db { scram512: Some(creds.scram512), certfps: Vec::new(), verified, + ajoin: Vec::new(), }; self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -830,6 +847,40 @@ impl Db { Ok(true) } + /// The account's auto-join list. + pub fn ajoin_list(&self, account: &str) -> &[AjoinEntry] { + self.accounts.get(&key(account)).map_or(&[], |a| a.ajoin.as_slice()) + } + + /// Add a channel to the account's auto-join list (updating the key if it was + /// already listed). Returns whether it was newly added. + pub fn ajoin_add(&mut self, account: &str, channel: &str, join_key: &str) -> Result { + let k = key(account); + let Some(a) = self.accounts.get(&k) else { return Err(RegError::Internal) }; + let existed = a.ajoin.iter().any(|e| e.channel.eq_ignore_ascii_case(channel)); + self.log + .append(Event::AjoinAdded { account: account.to_string(), channel: channel.to_string(), key: join_key.to_string() }) + .map_err(|_| RegError::Internal)?; + let a = self.accounts.get_mut(&k).unwrap(); + a.ajoin.retain(|e| !e.channel.eq_ignore_ascii_case(channel)); + a.ajoin.push(AjoinEntry { channel: channel.to_string(), key: join_key.to_string() }); + Ok(!existed) + } + + /// Remove a channel from the account's auto-join list. Returns whether it was present. + pub fn ajoin_del(&mut self, account: &str, channel: &str) -> Result { + let k = key(account); + let Some(a) = self.accounts.get(&k) else { return Err(RegError::Internal) }; + if !a.ajoin.iter().any(|e| e.channel.eq_ignore_ascii_case(channel)) { + return Ok(false); + } + self.log + .append(Event::AjoinRemoved { account: account.to_string(), channel: channel.to_string() }) + .map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().ajoin.retain(|e| !e.channel.eq_ignore_ascii_case(channel)); + Ok(true) + } + /// Register `name` to `founder` (an account name). pub fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { let k = key(name); @@ -1058,6 +1109,18 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(a) = accounts.get_mut(&key(&account)) { + // Idempotent (safe to replay over a snapshot): last write wins per channel. + a.ajoin.retain(|e| !e.channel.eq_ignore_ascii_case(&channel)); + a.ajoin.push(AjoinEntry { channel, key: join_key }); + } + } + Event::AjoinRemoved { account, channel } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.ajoin.retain(|e| !e.channel.eq_ignore_ascii_case(&channel)); + } + } Event::NickGrouped { nick, account } => { grouped.insert(key(&nick), account); } @@ -1244,6 +1307,18 @@ impl Store for Db { fn certfp_del(&mut self, account: &str, fp: &str) -> Result { Db::certfp_del(self, account, fp) } + fn ajoin_list(&self, account: &str) -> Vec { + Db::ajoin_list(self, account) + .iter() + .map(|e| AjoinView { channel: e.channel.clone(), key: e.key.clone() }) + .collect() + } + fn ajoin_add(&mut self, account: &str, channel: &str, key: &str) -> Result { + Db::ajoin_add(self, account, channel, key) + } + fn ajoin_del(&mut self, account: &str, channel: &str) -> Result { + Db::ajoin_del(self, account, channel) + } fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { Db::register_channel(self, name, founder) } @@ -1324,7 +1399,7 @@ mod tests { fn account_conflict_resolves_deterministically() { let alice = |hash: &str, ts: u64, home: &str| Account { name: "alice".into(), password_hash: hash.into(), email: None, - ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new()); @@ -1381,6 +1456,28 @@ mod tests { assert!(db.channel("#c").unwrap().akick.is_empty()); } + // The auto-join list adds, updates a key in place, removes case-insensitively, + // and replays from the event log after a reopen. + #[test] + fn ajoin_add_del_and_persist() { + let p = tmp("ajoin"); + { + let mut db = Db::open(&p, "N1"); + db.scram_iterations = 4096; + db.register("alice", "pw", None).unwrap(); + assert!(db.ajoin_add("alice", "#chat", "").unwrap(), "newly added"); + assert!(!db.ajoin_add("alice", "#chat", "sekret").unwrap(), "re-add updates the key, not newly added"); + db.ajoin_add("alice", "#dev", "").unwrap(); + assert_eq!(db.ajoin_list("alice").len(), 2); + assert_eq!(db.ajoin_list("alice")[0].key, "sekret", "key updated in place"); + assert!(db.ajoin_del("alice", "#CHAT").unwrap(), "case-insensitive remove"); + assert!(!db.ajoin_del("alice", "#chat").unwrap(), "already gone"); + } + let db = Db::open(&p, "N1"); + assert_eq!(db.ajoin_list("alice").len(), 1, "list replays from the log"); + assert_eq!(db.ajoin_list("alice")[0].channel, "#dev"); + } + // Locally-authored events get an incrementing per-origin seq and a ticking // Lamport clock. #[test] @@ -1429,7 +1526,7 @@ mod tests { db.register("alice", "pw", None).unwrap(); let bob = Account { name: "bob".into(), password_hash: "x".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(bob) }; db.ingest(entry).unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 06c3a59..6684931 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1242,7 +1242,7 @@ mod tests { // 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![], verified: true, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner)); e.gossip_ingest(entry).unwrap(); diff --git a/src/grpc.rs b/src/grpc.rs index ae3a86c..1822d0b 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -122,6 +122,8 @@ fn to_wire(entry: &LogEntry) -> Option { Event::CertAdded { .. } | Event::CertRemoved { .. } | Event::AccountPasswordSet { .. } + | Event::AjoinAdded { .. } + | Event::AjoinRemoved { .. } | Event::ChannelMlock { .. } | Event::ChannelAccessAdd { .. } | Event::ChannelAccessDel { .. } @@ -351,6 +353,7 @@ mod tests { scram512: None, certfps: vec!["deadbeef".into()], verified: true, + ajoin: vec![], }; let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(acct)); let wire = to_wire(®istered).expect("account registration replicates");