From bbbe2c6cb8613925a1a02992b03ad1164d8c49c7 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 04:01:30 +0000 Subject: [PATCH] nickserv: account SUSPEND / UNSUSPEND (oper-gated) Freezes an account (data kept, login blocked) gated on the typed Priv::Suspend. A typed Suspension{by,reason,ts,expires} on the account, folded through the event log (persists, replays, federates) -- no stringly Extensible/Checker like Anope. Expiry is evaluated lazily at check-time, so there is no expiry timer. Blocks IDENTIFY and all SASL mechanisms (via one sasl_login gate); logs out any live sessions on suspend; the login refusal names who/when/why and when it lifts. Shown in INFO to owner/auspex. Data + full-flow tests. --- api/src/lib.rs | 15 +++++ nickserv/src/identify.rs | 23 +++++++- nickserv/src/info.rs | 3 + nickserv/src/lib.rs | 6 +- nickserv/src/suspend.rs | 67 ++++++++++++++++++++++ src/engine/db.rs | 117 ++++++++++++++++++++++++++++++++++++++- src/engine/mod.rs | 48 ++++++++++++++-- src/grpc.rs | 3 + 8 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 nickserv/src/suspend.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index 4f710a6..6d69226 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -308,6 +308,16 @@ pub struct ChanAkickView { pub reason: String, } +// A services suspension on an account: who, why, when, and an optional expiry +// (absolute unix seconds; None = until manually lifted). +#[derive(Debug, Clone)] +pub struct SuspensionView { + pub by: String, + pub reason: String, + pub ts: u64, + pub expires: Option, +} + // One auto-join entry: a channel joined on identify, with an optional key. #[derive(Debug, Clone)] pub struct AjoinView { @@ -470,6 +480,11 @@ pub trait Store { 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; + // Suspension (oper-only, gated on Priv::Suspend at the command layer). + fn suspend_account(&mut self, account: &str, by: &str, reason: &str, expires: Option) -> Result<(), RegError>; + fn unsuspend_account(&mut self, account: &str) -> Result; + fn is_suspended(&self, account: &str) -> bool; + fn suspension(&self, account: &str) -> Option; 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/nickserv/src/identify.rs b/nickserv/src/identify.rs index 41f59ff..77e092e 100644 --- a/nickserv/src/identify.rs +++ b/nickserv/src/identify.rs @@ -1,4 +1,4 @@ -use fedserv_api::Store; +use fedserv_api::{human_time, Store}; use fedserv_api::{Sender, ServiceCtx}; // IDENTIFY [account] : log in. The account defaults to the current @@ -17,6 +17,27 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, format!("\x02{account_name}\x02 isn't registered.")); return; } + // A suspended account can't be logged into (checked before the password so it + // doesn't reveal whether the password was right). Tell them who, when and why, + // and when it lifts, so they know what happened and who to reach. + if let Some(acc) = db.resolve_account(account_name).map(str::to_string) { + if db.is_suspended(&acc) { + if let Some(s) = db.suspension(&acc) { + let mut msg = format!("\x02{acc}\x02 is suspended, so you can't log in to it right now. It was suspended by \x02{}\x02 on {}", s.by, human_time(s.ts)); + if s.reason.trim().is_empty() { + msg.push('.'); + } else { + msg.push_str(&format!(" — reason: {}", s.reason)); + } + if let Some(exp) = s.expires { + msg.push_str(&format!(" The suspension is due to lift on {}.", human_time(exp))); + } + msg.push_str(" If you think this is a mistake, please contact the network staff."); + ctx.notice(me, from.uid, msg); + } + return; + } + } // Refuse while throttled, so a password can't be brute-forced. if let Some(secs) = db.auth_lockout(account_name) { ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again.")); diff --git a/nickserv/src/info.rs b/nickserv/src/info.rs index 61d217b..8af40e7 100644 --- a/nickserv/src/info.rs +++ b/nickserv/src/info.rs @@ -14,6 +14,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts))); let is_owner = from.account == Some(acct.name.as_str()); if is_owner || from.privs.has(Priv::Auspex) { + if let Some(s) = db.suspension(&acct.name) { + ctx.notice(me, from.uid, format!(" Suspended : by \x02{}\x02 — {}", s.by, s.reason)); + } match &acct.email { Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")), Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")), diff --git a/nickserv/src/lib.rs b/nickserv/src/lib.rs index 1afcadb..6015454 100644 --- a/nickserv/src/lib.rs +++ b/nickserv/src/lib.rs @@ -32,6 +32,8 @@ mod resetpass; mod confirm; #[path = "ajoin.rs"] mod ajoin; +#[path = "suspend.rs"] +mod suspend; #[path = "password.rs"] mod password; @@ -75,7 +77,9 @@ impl Service for NickServ { Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db), Some("CONFIRM") => confirm::handle(me, from, args, ctx, db), 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("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(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/suspend.rs b/nickserv/src/suspend.rs new file mode 100644 index 0000000..21ddc88 --- /dev/null +++ b/nickserv/src/suspend.rs @@ -0,0 +1,67 @@ +use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// SUSPEND [+expiry] [reason] / UNSUSPEND : freeze or unfreeze +// an account. Its data is kept but it can't be logged into. Oper-only +// (Priv::Suspend). `suspending` selects SUSPEND vs UNSUSPEND. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, suspending: bool) { + if !from.privs.has(Priv::Suspend) { + ctx.notice(me, from.uid, "Access denied — that command is for services operators."); + return; + } + let Some(&target) = args.get(1) else { + let syntax = if suspending { "Syntax: SUSPEND [+expiry] [reason]" } else { "Syntax: UNSUSPEND " }; + ctx.notice(me, from.uid, syntax); + 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; + }; + + if !suspending { + match db.unsuspend_account(&account) { + Ok(true) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is no longer suspended.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't suspended.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + return; + } + + // Optional leading +expiry (e.g. +30d), then a free-text reason. + let mut rest = &args[2..]; + let expires = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration).map(|secs| now_unix() + secs); + if expires.is_some() { + rest = &rest[1..]; + } + let reason = if rest.is_empty() { "No reason given.".to_string() } else { rest.join(" ") }; + let by = from.account.unwrap_or(from.nick); + match db.suspend_account(&account, by, &reason, expires) { + Ok(()) => { + // Log out every session currently identified to the account. + for uid in net.uids_logged_into(&account) { + ctx.logout(&uid); + } + let expiry = if expires.is_some() { " (with expiry)" } else { "" }; + ctx.notice(me, from.uid, format!("\x02{account}\x02 is now suspended{expiry}.")); + } + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +// Parse a duration like "30d", "12h", "45m", "90s", or a bare number of seconds. +fn parse_duration(s: &str) -> Option { + let (num, mult) = match s.chars().last()? { + 'd' | 'D' => (&s[..s.len() - 1], 86_400), + 'h' | 'H' => (&s[..s.len() - 1], 3_600), + 'm' | 'M' => (&s[..s.len() - 1], 60), + 's' | 'S' => (&s[..s.len() - 1], 1), + c if c.is_ascii_digit() => (s, 1), + _ => return None, + }; + num.parse::().ok().map(|n| n * mult) +} + +fn now_unix() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} diff --git a/src/engine/db.rs b/src/engine/db.rs index 0b9414d..30b2677 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, AjoinView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store, + AccountView, AjoinView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -49,6 +49,9 @@ pub struct Account { // Channels this account is auto-joined to on identify (AJOIN). #[serde(default)] pub ajoin: Vec, + // Services suspension, if any (login blocked while set and unexpired). + #[serde(default)] + pub suspension: Option, } fn verified_default() -> bool { @@ -70,6 +73,8 @@ pub enum Event { AccountVerified { account: String }, AjoinAdded { account: String, channel: String, key: String }, AjoinRemoved { account: String, channel: String }, + AccountSuspended { account: String, by: String, reason: String, ts: u64, expires: Option }, + AccountUnsuspended { account: String }, NickGrouped { nick: String, account: String }, NickUngrouped { nick: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, @@ -108,6 +113,8 @@ impl Event { | Event::AccountVerified { .. } | Event::AjoinAdded { .. } | Event::AjoinRemoved { .. } + | Event::AccountSuspended { .. } + | Event::AccountUnsuspended { .. } | Event::NickGrouped { .. } | Event::NickUngrouped { .. } => Scope::Global, Event::ChannelRegistered { .. } @@ -140,6 +147,17 @@ pub struct ChanAkick { pub reason: String, } +// A services suspension on an account: who set it, why, when, and an optional +// absolute-unix-seconds expiry (None = until manually lifted). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Suspension { + pub by: String, + pub reason: String, + pub ts: u64, + #[serde(default)] + pub expires: Option, +} + // 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)] @@ -714,6 +732,7 @@ impl Db { certfps: Vec::new(), verified, ajoin: Vec::new(), + suspension: None, }; self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -981,6 +1000,49 @@ impl Db { Ok(true) } + /// Suspend an account. `expires` is an absolute unix time (None = permanent). + pub fn suspend_account(&mut self, account: &str, by: &str, reason: &str, expires: Option) -> Result<(), RegError> { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(RegError::Internal); + } + let ts = now(); + self.log + .append(Event::AccountSuspended { account: account.to_string(), by: by.to_string(), reason: reason.to_string(), ts, expires }) + .map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().suspension = Some(Suspension { by: by.to_string(), reason: reason.to_string(), ts, expires }); + Ok(()) + } + + /// Lift a suspension. Returns whether one was set. + pub fn unsuspend_account(&mut self, account: &str) -> Result { + let k = key(account); + match self.accounts.get(&k) { + None => return Err(RegError::Internal), + Some(a) if a.suspension.is_none() => return Ok(false), + Some(_) => {} + } + self.log.append(Event::AccountUnsuspended { account: account.to_string() }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().suspension = None; + Ok(true) + } + + /// Whether an account has a set, unexpired suspension (evaluated lazily — no timer). + pub fn is_suspended(&self, account: &str) -> bool { + self.accounts + .get(&key(account)) + .and_then(|a| a.suspension.as_ref()) + .is_some_and(|s| s.expires.is_none_or(|e| e > now())) + } + + /// The account's suspension record, if any (shown in INFO even once expired). + pub fn suspension(&self, account: &str) -> Option { + self.accounts + .get(&key(account)) + .and_then(|a| a.suspension.as_ref()) + .map(|s| SuspensionView { by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires }) + } + /// Register `name` to `founder` (an account name). pub fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { let k = key(name); @@ -1254,6 +1316,16 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.suspension = Some(Suspension { by, reason, ts, expires }); + } + } + Event::AccountUnsuspended { account } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.suspension = None; + } + } Event::NickGrouped { nick, account } => { grouped.insert(key(&nick), account); } @@ -1473,6 +1545,18 @@ impl Store for Db { fn ajoin_del(&mut self, account: &str, channel: &str) -> Result { Db::ajoin_del(self, account, channel) } + fn suspend_account(&mut self, account: &str, by: &str, reason: &str, expires: Option) -> Result<(), RegError> { + Db::suspend_account(self, account, by, reason, expires) + } + fn unsuspend_account(&mut self, account: &str) -> Result { + Db::unsuspend_account(self, account) + } + fn is_suspended(&self, account: &str) -> bool { + Db::is_suspended(self, account) + } + fn suspension(&self, account: &str) -> Option { + Db::suspension(self, account) + } fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> { Db::register_channel(self, name, founder) } @@ -1566,7 +1650,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![], + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new()); @@ -1678,6 +1762,33 @@ mod tests { assert_eq!(cv.access_rank(None), 0); } + // Suspension sets/lifts, expires lazily, and replays from the log. + #[test] + fn suspend_lifts_expires_and_persists() { + let p = tmp("suspend"); + { + let mut db = Db::open(&p, "N1"); + db.scram_iterations = 4096; + db.register("alice", "password1", None).unwrap(); + assert!(!db.is_suspended("alice")); + db.suspend_account("alice", "oper", "spamming", None).unwrap(); + assert!(db.is_suspended("alice")); + assert_eq!(db.suspension("alice").unwrap().reason, "spamming"); + assert!(db.unsuspend_account("alice").unwrap()); + assert!(!db.is_suspended("alice")); + assert!(!db.unsuspend_account("alice").unwrap(), "already lifted"); + // A past expiry is not active, but the record still shows in INFO. + db.suspend_account("alice", "oper", "temp", Some(1)).unwrap(); + assert!(!db.is_suspended("alice"), "expired suspension is inactive"); + assert!(db.suspension("alice").is_some()); + } + { + let mut db = Db::open(&p, "N1"); + db.suspend_account("alice", "oper", "again", None).unwrap(); + } + assert!(Db::open(&p, "N1").is_suspended("alice"), "suspension replays from the log"); + } + // A wrong code is tolerated a few times, then the code is burned so it can't // be ground down online even though it is short. #[test] @@ -1752,7 +1863,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![], + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, }; 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 0348d53..2f652f2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -127,6 +127,14 @@ impl Engine { self.opers.get(&account.to_ascii_lowercase()).copied().unwrap_or_default() } + // Finish a SASL login, unless the account is suspended. + fn sasl_login(&self, agent: &str, client: &str, account: String) -> Vec { + if self.db.is_suspended(&account) { + return sasl_fail(agent, client); + } + sasl_success(agent, client, account) + } + 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 @@ -612,7 +620,7 @@ impl Engine { return Vec::new(); // more chunks still to come } match login_plain(&response, &self.db) { - Some(account) => sasl_success(&agent, &client, account), + Some(account) => self.sasl_login(&agent, &client, account), None => mk("D", vec!["F".to_string()]), } } @@ -677,7 +685,7 @@ impl Engine { } } // Client acknowledged our server-final ("+"); apply the login. - ScramStep::Ack { account } => sasl_success(agent, client, account), + ScramStep::Ack { account } => self.sasl_login(agent, client, account), } } @@ -696,7 +704,7 @@ impl Engine { match fingerprints.iter().find_map(|fp| self.db.certfp_owner(fp)) { Some(account) if authzid.is_empty() || authzid.eq_ignore_ascii_case(account) => { let account = account.to_string(); - sasl_success(agent, client, account) + self.sasl_login(agent, client, account) } _ => sasl_fail(agent, client), } @@ -1295,7 +1303,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![], + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner)); e.gossip_ingest(entry).unwrap(); @@ -1545,6 +1553,38 @@ mod tests { assert!(out.is_empty(), "no enforcement when SECUREOPS is off: {out:?}"); } + // SUSPEND is oper-gated, blocks the victim's login, and UNSUSPEND restores it. + #[test] + fn suspend_blocks_login_and_needs_oper() { + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-suspendcmd.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("victim", "password1", None).unwrap(); + db.register("staff", "password1", None).unwrap(); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + let mut opers = std::collections::HashMap::new(); + opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Suspend)); + e.set_opers(opers); + let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() }); + let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "victim".into(), host: "h".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "staff".into(), host: "h".into() }); + + // A non-oper cannot suspend. + assert!(notice(&to_ns(&mut e, "000AAAAAB", "SUSPEND victim"), "Access denied")); + // The oper identifies and suspends the victim. + to_ns(&mut e, "000AAAAAC", "IDENTIFY password1"); + assert!(notice(&to_ns(&mut e, "000AAAAAC", "SUSPEND victim being a nuisance"), "now suspended")); + // The victim can no longer identify. + assert!(notice(&to_ns(&mut e, "000AAAAAB", "IDENTIFY password1"), "suspended")); + // UNSUSPEND lets them back in. + assert!(notice(&to_ns(&mut e, "000AAAAAC", "UNSUSPEND victim"), "no longer suspended")); + assert!(notice(&to_ns(&mut e, "000AAAAAB", "IDENTIFY password1"), "now identified")); + } + // An auspex oper sees another account's hidden INFO (email); a non-oper does not. #[test] fn auspex_oper_sees_hidden_info() { diff --git a/src/grpc.rs b/src/grpc.rs index 7102ac9..323aa88 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -124,6 +124,8 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::AccountPasswordSet { .. } | Event::AjoinAdded { .. } | Event::AjoinRemoved { .. } + | Event::AccountSuspended { .. } + | Event::AccountUnsuspended { .. } | Event::ChannelMlock { .. } | Event::ChannelAccessAdd { .. } | Event::ChannelAccessDel { .. } @@ -356,6 +358,7 @@ mod tests { certfps: vec!["deadbeef".into()], verified: true, ajoin: vec![], + suspension: None, }; let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(acct)); let wire = to_wire(®istered).expect("account registration replicates");