diff --git a/api/src/lib.rs b/api/src/lib.rs index 530f0cc..39f0f9c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -629,6 +629,9 @@ pub trait Store { fn unsuspend_account(&mut self, account: &str) -> Result; fn is_suspended(&self, account: &str) -> bool; fn suspension(&self, account: &str) -> Option; + // Inactivity-expiry pins (oper-only, gated on Priv::Admin at the command layer). + fn set_account_noexpire(&mut self, account: &str, on: bool) -> Result; + fn set_channel_noexpire(&mut self, channel: &str, on: bool) -> 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/chanserv/src/lib.rs b/chanserv/src/lib.rs index 3abe569..eaf74b6 100644 --- a/chanserv/src/lib.rs +++ b/chanserv/src/lib.rs @@ -40,6 +40,7 @@ mod clone; mod xop; #[path = "suspend.rs"] mod suspend; +mod noexpire; pub struct ChanServ { pub uid: String, @@ -207,6 +208,7 @@ impl Service for ChanServ { Some("STATUS") => status::handle(me, from, args, ctx, net, db), Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true), Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false), + Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, db), Some("LIST") => list::handle(me, from, args, ctx, db), Some("SET") => set::handle(me, from, args, ctx, db), Some("ENTRYMSG") => entrymsg::handle(me, from, args, ctx, db), @@ -217,7 +219,7 @@ impl Service for ChanServ { Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db), Some("SOP") => xop::handle(me, from, "SOP", "op", args, ctx, db), Some("VOP") => xop::handle(me, from, "VOP", "voice", args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02."), + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <#channel> {ON|OFF}."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/chanserv/src/noexpire.rs b/chanserv/src/noexpire.rs new file mode 100644 index 0000000..178f84e --- /dev/null +++ b/chanserv/src/noexpire.rs @@ -0,0 +1,32 @@ +use fedserv_api::{Priv, Sender, ServiceCtx, Store}; + +// NOEXPIRE <#channel> {ON|OFF}: pin a channel so inactivity-expiry never drops +// it (or lift the pin). Oper-only (Priv::Admin). +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — that command is for services operators."); + return; + } + let (Some(&chan), Some(on)) = (args.get(1), args.get(2).and_then(|s| parse_toggle(s))) else { + ctx.notice(me, from.uid, "Syntax: NOEXPIRE <#channel> {ON|OFF}"); + return; + }; + if db.channel(chan).is_none() { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + } + match db.set_channel_noexpire(chan, on) { + Ok(true) if on => ctx.notice(me, from.uid, format!("\x02{chan}\x02 will no longer expire.")), + Ok(true) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 can expire from inactivity again.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 was already set that way.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn parse_toggle(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "ON" | "TRUE" | "YES" => Some(true), + "OFF" | "FALSE" | "NO" => Some(false), + _ => None, + } +} diff --git a/config.example.toml b/config.example.toml index 346df0e..35c598a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -55,3 +55,12 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # surfaced. Omit the section to disable the feed. # [log] # channel = "#services" + +# Inactivity-expiry: accounts not identified to, and channels not joined, for +# longer than the threshold are dropped on a periodic pass (opers, live +# sessions, occupied channels, and NOEXPIRE-pinned records are spared). A zero +# or omitted field leaves that kind never expiring. Omit the section to disable +# expiry entirely. +# [expire] +# accounts_days = 90 +# channels_days = 30 diff --git a/nickserv/src/lib.rs b/nickserv/src/lib.rs index 6015454..48b5664 100644 --- a/nickserv/src/lib.rs +++ b/nickserv/src/lib.rs @@ -34,6 +34,7 @@ mod confirm; mod ajoin; #[path = "suspend.rs"] mod suspend; +mod noexpire; #[path = "password.rs"] mod password; @@ -79,7 +80,8 @@ impl Service for NickServ { Some("AJOIN") => ajoin::handle(me, from, args, ctx, db), Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true), Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false), - 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]. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02."), + Some("NOEXPIRE") => noexpire::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]. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 {ON|OFF}."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/nickserv/src/noexpire.rs b/nickserv/src/noexpire.rs new file mode 100644 index 0000000..7a6ca08 --- /dev/null +++ b/nickserv/src/noexpire.rs @@ -0,0 +1,33 @@ +use fedserv_api::{Priv, Sender, ServiceCtx, Store}; + +// NOEXPIRE {ON|OFF}: pin an account so inactivity-expiry never drops +// it (or lift the pin). Oper-only (Priv::Admin) — protecting a record from +// expiry is a staff decision, not self-service. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !from.privs.has(Priv::Admin) { + ctx.notice(me, from.uid, "Access denied — that command is for services operators."); + return; + } + let (Some(&target), Some(on)) = (args.get(1), args.get(2).and_then(|s| parse_toggle(s))) else { + ctx.notice(me, from.uid, "Syntax: NOEXPIRE {ON|OFF}"); + return; + }; + let Some(account) = db.resolve_account(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); + return; + }; + match db.set_account_noexpire(&account, on) { + Ok(true) if on => ctx.notice(me, from.uid, format!("\x02{account}\x02 will no longer expire.")), + Ok(true) => ctx.notice(me, from.uid, format!("\x02{account}\x02 can expire from inactivity again.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 was already set that way.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn parse_toggle(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "ON" | "TRUE" | "YES" => Some(true), + "OFF" | "FALSE" | "NO" => Some(false), + _ => None, + } +} diff --git a/src/config.rs b/src/config.rs index 9f6d785..a92ac74 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,30 @@ pub struct Config { // Staff audit feed. Absent = no audit log is emitted. #[serde(default)] pub log: Option, + // Inactivity-expiry. Absent = accounts and channels never expire. + #[serde(default)] + pub expire: Option, +} + +// Inactivity-expiry thresholds, in days. A zero (or omitted) field leaves that +// kind never expiring, so an operator can expire only accounts, only channels, +// or both. +#[derive(Debug, Deserialize, Clone)] +pub struct Expire { + #[serde(default)] + pub accounts_days: u64, + #[serde(default)] + pub channels_days: u64, +} + +impl Expire { + // The thresholds in seconds, or None where that kind is disabled (zero days). + pub fn account_ttl(&self) -> Option { + (self.accounts_days > 0).then(|| self.accounts_days * 86_400) + } + pub fn channel_ttl(&self) -> Option { + (self.channels_days > 0).then(|| self.channels_days * 86_400) + } } // The staff audit feed: notable service actions are announced to this channel diff --git a/src/engine/db.rs b/src/engine/db.rs index 5674690..7281528 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -79,6 +79,13 @@ pub struct Account { // A vhost the user has requested, pending operator approval. #[serde(default)] pub vhost_request: Option, + // Unix time this account was last active (identified). 0 = never stamped + // since expiry tracking existed, in which case `ts` (registration) stands in. + #[serde(default)] + pub last_seen: u64, + // Pinned by an operator so inactivity-expiry never drops it. + #[serde(default)] + pub noexpire: bool, } // A requested vhost awaiting approval. @@ -158,6 +165,13 @@ pub enum Event { VhostForbidAdded { pattern: String }, VhostForbidRemoved { pattern: String }, VhostTemplateSet { template: Option }, + // Inactivity-expiry bookkeeping. Seen/Used stamp last activity (coalesced, so + // at most one per account/channel per day); NoExpire pins a record so the + // sweep never expires it. + AccountSeen { account: String, ts: u64 }, + ChannelUsed { channel: String, ts: u64 }, + AccountNoExpire { account: String, on: bool }, + ChannelNoExpire { channel: String, on: bool }, } // Whether an event replicates across the federation. Account identity is Global @@ -193,7 +207,9 @@ impl Event { | Event::MemoRead { .. } | Event::MemoDeleted { .. } | Event::NickGrouped { .. } - | Event::NickUngrouped { .. } => Scope::Global, + | Event::NickUngrouped { .. } + | Event::AccountSeen { .. } + | Event::AccountNoExpire { .. } => Scope::Global, Event::ChannelRegistered { .. } | Event::ChannelDropped { .. } | Event::ChannelMlock { .. } @@ -219,7 +235,9 @@ impl Event { | Event::VhostOfferRemoved { .. } | Event::VhostForbidAdded { .. } | Event::VhostForbidRemoved { .. } - | Event::VhostTemplateSet { .. } => Scope::Local, + | Event::VhostTemplateSet { .. } + | Event::ChannelUsed { .. } + | Event::ChannelNoExpire { .. } => Scope::Local, } } } @@ -358,6 +376,13 @@ pub struct ChannelInfo { pub triggers: Vec, #[serde(skip)] pub triggers_rev: u64, + // Unix time this channel was last used (a member joined). 0 = never stamped + // since expiry tracking existed, in which case `ts` (registration) stands in. + #[serde(default)] + pub last_used: u64, + // Pinned by an operator so inactivity-expiry never drops it. + #[serde(default)] + pub noexpire: bool, } // A bot auto-response: when a channel line matches `pattern`, the assigned bot @@ -1059,6 +1084,8 @@ impl Db { greet: String::new(), vhost: None, vhost_request: None, + last_seen: now(), + noexpire: false, }; self.log.append(Event::AccountRegistered(Box::new(account.clone()))).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -1539,6 +1566,84 @@ impl Db { .is_some_and(|s| s.expires.is_none_or(|e| e > now())) } + /// Stamp an account as active at `now`, coalesced: a fresh stamp is logged + /// only once its last one is a day old, so routine logins don't flood the + /// log. No-op for an account that doesn't exist. + pub fn mark_account_seen(&mut self, account: &str, now: u64) { + const COALESCE: u64 = 86_400; + let k = key(account); + let stale = self.accounts.get(&k).is_some_and(|a| now.saturating_sub(a.last_seen.max(a.ts)) >= COALESCE); + if stale { + let _ = self.log.append(Event::AccountSeen { account: account.to_string(), ts: now }); + if let Some(a) = self.accounts.get_mut(&k) { + a.last_seen = a.last_seen.max(now); + } + } + } + + /// Stamp a channel as used at `now`, coalesced like [`mark_account_seen`]. + pub fn mark_channel_used(&mut self, channel: &str, now: u64) { + const COALESCE: u64 = 86_400; + let k = key(channel); + let stale = self.channels.get(&k).is_some_and(|c| now.saturating_sub(c.last_used.max(c.ts)) >= COALESCE); + if stale { + let _ = self.log.append(Event::ChannelUsed { channel: channel.to_string(), ts: now }); + if let Some(c) = self.channels.get_mut(&k) { + c.last_used = c.last_used.max(now); + } + } + } + + /// Pin or unpin an account against inactivity-expiry. Returns whether the + /// flag actually changed. + pub fn set_account_noexpire(&mut self, account: &str, on: bool) -> Result { + let k = key(account); + match self.accounts.get(&k) { + None => Err(RegError::Internal), + Some(a) if a.noexpire == on => Ok(false), + Some(_) => { + self.log.append(Event::AccountNoExpire { account: account.to_string(), on }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().noexpire = on; + Ok(true) + } + } + } + + /// Pin or unpin a channel against inactivity-expiry. + pub fn set_channel_noexpire(&mut self, channel: &str, on: bool) -> Result { + let k = key(channel); + match self.channels.get(&k) { + None => Err(ChanError::NoChannel), + Some(c) if c.noexpire == on => Ok(false), + Some(_) => { + self.log.append(Event::ChannelNoExpire { channel: channel.to_string(), on }).map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().noexpire = on; + Ok(true) + } + } + } + + /// Accounts inactive longer than `ttl` seconds as of `now` and eligible to be + /// expired: not pinned (NOEXPIRE) and not currently suspended. Callers apply + /// their own further guards (skip opers and live sessions) before dropping. + pub fn expired_accounts(&self, now: u64, ttl: u64) -> Vec { + self.accounts + .values() + .filter(|a| !a.noexpire && a.suspension.is_none() && now.saturating_sub(a.last_seen.max(a.ts)) > ttl) + .map(|a| a.name.clone()) + .collect() + } + + /// Channels unused longer than `ttl` seconds as of `now` and eligible to be + /// expired: not pinned and not suspended. + pub fn expired_channels(&self, now: u64, ttl: u64) -> Vec { + self.channels + .values() + .filter(|c| !c.noexpire && c.suspension.is_none() && now.saturating_sub(c.last_used.max(c.ts)) > ttl) + .map(|c| c.name.clone()) + .collect() + } + /// The account's suspension record, if any (shown in INFO even once expired). pub fn suspension(&self, account: &str) -> Option { self.accounts @@ -1557,7 +1662,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0 }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false }); Ok(()) } @@ -2295,7 +2400,7 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0 }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -2415,6 +2520,26 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.last_seen = a.last_seen.max(ts); // monotonic: never move activity backwards + } + } + Event::ChannelUsed { channel, ts } => { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.last_used = c.last_used.max(ts); + } + } + Event::AccountNoExpire { account, on } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.noexpire = on; + } + } + Event::ChannelNoExpire { channel, on } => { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.noexpire = on; + } + } } } @@ -2641,6 +2766,12 @@ impl Store for Db { fn suspension(&self, account: &str) -> Option { Db::suspension(self, account) } + fn set_account_noexpire(&mut self, account: &str, on: bool) -> Result { + Db::set_account_noexpire(self, account, on) + } + fn set_channel_noexpire(&mut self, channel: &str, on: bool) -> Result { + Db::set_channel_noexpire(self, channel, on) + } fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { Db::register_channel(self, name, founder) } @@ -2841,7 +2972,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, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr, mut bo, mut hc) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default()); @@ -3117,7 +3248,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, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(Box::new(bob)) }; db.ingest(entry).unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4de56de..bff8450 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -124,6 +124,9 @@ pub struct Engine { // Staff audit channel: notable service actions are announced here. None = // no audit feed. log_channel: Option, + // Inactivity-expiry thresholds in seconds. None = that kind never expires. + account_ttl: Option, + channel_ttl: Option, } struct VoteState { @@ -196,6 +199,8 @@ impl Engine { pending_unbans: Vec::new(), votes: HashMap::new(), log_channel: None, + account_ttl: None, + channel_ttl: None, } } @@ -254,6 +259,12 @@ impl Engine { self.log_channel = channel; } + // Inactivity-expiry thresholds (seconds); None leaves that kind never expiring. + pub fn set_expiry(&mut self, account_ttl: Option, channel_ttl: Option) { + self.account_ttl = account_ttl; + self.channel_ttl = channel_ttl; + } + // Bring the network's bot pseudo-clients in line with the registry: introduce // any new bots, quit any that were deleted. Run at burst and after commands. fn reconcile_bots(&mut self) -> Vec { @@ -525,6 +536,52 @@ impl Engine { Ok(false) } + // Drop accounts and channels left inactive past the configured thresholds. + // Lazy: computed from stored last-activity stamps on this periodic pass, with + // no per-record timer. Opers, accounts with a live session, and occupied + // channels are spared; each expiry is announced to the audit channel. Emits + // cleanup directly over the outbound path (like a takeover cleanup). + pub fn expire_sweep(&mut self) { + let now = self.now_secs(); + if let Some(ttl) = self.account_ttl { + for account in self.db.expired_accounts(now, ttl) { + // Never expire an operator's account or one still in use. + if self.opers.contains_key(&account.to_ascii_lowercase()) || !self.network.uids_logged_into(&account).is_empty() { + continue; + } + if self.db.drop_account(&account).unwrap_or(false) { + self.handle_account_gone(&account, "expired after a long period of inactivity"); + self.audit(format!("Account \x02{account}\x02 expired after inactivity.")); + } + } + } + if let Some(ttl) = self.channel_ttl { + let cs = self.chan_service.clone(); + for channel in self.db.expired_channels(now, ttl) { + // A channel people are currently sitting in is still in use. + if self.network.channel_members(&channel).next().is_some() { + continue; + } + if self.db.drop_channel(&channel).is_ok() { + if let Some(cs) = &cs { + self.emit_irc(NetAction::ChannelMode { from: cs.clone(), channel: channel.clone(), modes: "-r".to_string() }); + } + self.audit(format!("Channel \x02{channel}\x02 expired after inactivity.")); + } + } + } + } + + // Announce a line to the staff audit channel, if one is configured, sourced + // from the services server. Used for actions that aren't tied to a command. + fn audit(&self, text: String) { + if let Some(channel) = &self.log_channel { + if !self.sid.is_empty() { + self.emit_irc(NetAction::Notice { from: self.sid.clone(), to: channel.clone(), text }); + } + } + } + #[cfg(test)] pub(crate) fn test_register(&mut self, name: &str) { self.db.register(name, "pw", None).unwrap(); @@ -648,6 +705,10 @@ impl Engine { // members their status mode, and send the entry message. NetEvent::Join { uid, channel, op } => { self.network.channel_join(&channel, &uid, op); + // A join to a registered channel is activity: keep it from expiring. + if self.db.channel(&channel).is_some() { + self.db.mark_channel_used(&channel, self.now_secs()); + } // A suspended channel is frozen: no auto-op, akick, or entry message. if self.db.is_channel_suspended(&channel) { return Vec::new(); @@ -794,6 +855,8 @@ impl Engine { self.network.clear_account(target); } else { self.network.set_account(target, value); + // A login is activity: keep the account from expiring. + self.db.mark_account_seen(value, self.now_secs()); } } } @@ -1448,12 +1511,20 @@ fn audit_summary(event: &db::Event) -> Option { Some(t) => format!("set the vhost template to \x02{t}\x02"), None => "cleared the vhost template".to_string(), }, + AccountNoExpire { account, on } => { + let verb = if *on { "pinned" } else { "unpinned" }; + format!("{verb} account \x02{account}\x02 against expiry") + } + ChannelNoExpire { channel, on } => { + let verb = if *on { "pinned" } else { "unpinned" }; + format!("{verb} channel \x02{channel}\x02 against expiry") + } // Private, self-service, or cosmetic — not surfaced. AjoinAdded { .. } | AjoinRemoved { .. } | AccountGreetSet { .. } | VhostRequested { .. } | VhostRequestCleared { .. } | MemoSent { .. } | MemoRead { .. } | MemoDeleted { .. } | ChannelMlock { .. } | ChannelDescSet { .. } | ChannelEntryMsgSet { .. } | ChannelSettingsSet { .. } | ChannelKickerSet { .. } | ChannelBadwordsSet { .. } | ChannelTriggersSet { .. } - | ChannelTopicSet { .. } => return None, + | ChannelTopicSet { .. } | AccountSeen { .. } | ChannelUsed { .. } => return None, }; Some(s) } @@ -2013,7 +2084,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, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner))); e.gossip_ingest(entry).unwrap(); @@ -3604,6 +3675,88 @@ mod tests { assert!(!out.iter().any(|a| matches!(a, NetAction::Notice { to, .. } if to == "#services")), "no feed when unset: {out:?}"); } + // Inactivity-expiry drops stale accounts and channels, but spares opers, live + // sessions, occupied channels, and anything pinned with NOEXPIRE. Each expiry + // is announced to the audit channel. + #[test] + fn expiry_sweep_respects_pins_sessions_and_opers() { + use fedserv_chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-expiry.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + for a in ["victim", "staff", "active", "pinned"] { + db.register(a, "password1", None).unwrap(); + } + db.register_channel("#dead", "staff").unwrap(); // founder is an oper, so only the channel expires + db.register_channel("#pinned", "staff").unwrap(); + db.register_channel("#live", "staff").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".into() }), + ], + db, + ); + e.set_sid("42S".into()); + e.set_log_channel(Some("#services".into())); + let mut opers = std::collections::HashMap::new(); + opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + e.set_opers(opers); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + e.set_irc_out(tx); + + // staff (an oper) pins one account and one channel against expiry. + e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "NOEXPIRE pinned ON".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAB".into(), text: "NOEXPIRE #pinned ON".into() }); + // active keeps a live session; #live keeps a member sitting in it. + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "active".into(), host: "h".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#live".into(), op: false }); + + // Jump far past the threshold and sweep. + e.now_override = Some(10_000_000_000); + e.set_expiry(Some(100), Some(100)); + e.expire_sweep(); + + // Only the plain, unused, unpinned, session-less records are gone. + assert!(!e.db.exists("victim"), "inactive account expired"); + assert!(e.db.channel("#dead").is_none(), "unused channel expired"); + assert!(e.db.exists("staff"), "oper account spared"); + assert!(e.db.exists("active"), "logged-in account spared"); + assert!(e.db.exists("pinned"), "NOEXPIRE account spared"); + assert!(e.db.channel("#pinned").is_some(), "NOEXPIRE channel spared"); + assert!(e.db.channel("#live").is_some(), "occupied channel spared"); + + // The expiries were announced and the dropped channel had +r cleared. + let (mut acct_note, mut chan_note, mut unreg) = (false, false, false); + while let Ok(a) = rx.try_recv() { + match a { + NetAction::Notice { to, text, .. } if to == "#services" && text.contains("victim") && text.contains("expired") => acct_note = true, + NetAction::Notice { to, text, .. } if to == "#services" && text.contains("#dead") && text.contains("expired") => chan_note = true, + NetAction::ChannelMode { channel, modes, .. } if channel == "#dead" && modes == "-r" => unreg = true, + _ => {} + } + } + assert!(acct_note, "account expiry announced to audit channel"); + assert!(chan_note, "channel expiry announced to audit channel"); + assert!(unreg, "expired channel had +r cleared"); + } + + // NOEXPIRE is oper-only. + #[test] + fn noexpire_command_is_oper_gated() { + let mut e = engine_with("noexp", "alice", "sesame"); + e.db.register("mallory", "password1", None).unwrap(); + 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() }); + // alice is not an oper: refused, and the flag is untouched. + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "NOEXPIRE mallory ON".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "non-oper refused: {out:?}"); + } + // ChanServ moderation: an op can op/kick/ban users; a non-op is refused. #[test] fn chanserv_moderation() { diff --git a/src/grpc.rs b/src/grpc.rs index 82fe790..e676013 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -160,7 +160,11 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::VhostOfferRemoved { .. } | Event::VhostForbidAdded { .. } | Event::VhostForbidRemoved { .. } - | Event::VhostTemplateSet { .. } => return None, + | Event::VhostTemplateSet { .. } + | Event::AccountSeen { .. } + | Event::ChannelUsed { .. } + | Event::AccountNoExpire { .. } + | Event::ChannelNoExpire { .. } => return None, }; Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) }) } @@ -417,6 +421,8 @@ mod tests { greet: String::new(), vhost: None, vhost_request: None, + last_seen: 111, + noexpire: false, }; let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(Box::new(acct))); let wire = to_wire(®istered).expect("account registration replicates"); diff --git a/src/main.rs b/src/main.rs index 4ffc4e3..293e56f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,6 +106,9 @@ async fn main() -> Result<()> { engine.lock().await.set_opers(cfg.opers()); engine.lock().await.set_sid(cfg.server.sid.clone()); engine.lock().await.set_log_channel(cfg.log.as_ref().map(|l| l.channel.clone())); + if let Some(expire) = &cfg.expire { + engine.lock().await.set_expiry(expire.account_ttl(), expire.channel_ttl()); + } if let Some(gossip) = cfg.gossip.clone() { tracing::info!(peers = cfg.peer.len(), "starting gossip"); @@ -130,8 +133,10 @@ async fn main() -> Result<()> { tokio::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_secs(1800)).await; - if let Err(e) = engine.lock().await.maybe_compact() { - tracing::warn!(%e, "compaction failed"); + let mut e = engine.lock().await; + e.expire_sweep(); + if let Err(err) = e.maybe_compact() { + tracing::warn!(%err, "compaction failed"); } } });