Add ChanServ with channel registration

REGISTER/INFO/DROP with founder auth, stored as ChannelRegistered and
ChannelDropped events so channel registrations replicate across nodes
and compact like accounts. Registration requires an identified account;
only the founder can drop.
This commit is contained in:
Jean Chevronnet 2026-07-12 08:19:58 +00:00
parent 64c6facd56
commit 89593ebeb4
No known key found for this signature in database
5 changed files with 227 additions and 25 deletions

View file

@ -38,6 +38,16 @@ pub enum Event {
AccountRegistered(Account),
CertAdded { account: String, fp: String },
CertRemoved { account: String, fp: String },
ChannelRegistered { name: String, founder: String, ts: u64 },
ChannelDropped { name: String },
}
// A registered channel and who owns it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelInfo {
pub name: String,
pub founder: String,
pub ts: u64,
}
// A durable record: the event plus the metadata a gossip layer needs to address
@ -163,21 +173,22 @@ impl EventLog {
self.entries.len()
}
// Rewrite the log to a minimal snapshot: one AccountRegistered per account,
// authored under our origin at fresh sequence numbers. Peers re-converge
// because AccountRegistered overwrites and cert replay is idempotent. The
// version vector resets to our origin; peers re-advertise theirs on the next
// sync. Written to a temp file and renamed, so a crash leaves the old log.
fn compact(&mut self, accounts: &HashMap<String, Account>) -> std::io::Result<()> {
// Rewrite the log to a minimal snapshot: one event per live account and
// channel, authored under our origin at fresh sequence numbers. Peers
// re-converge because the register events overwrite and cert replay is
// idempotent. The version vector resets to our origin; peers re-advertise
// theirs on the next sync. Written to a temp file and renamed, so a crash
// leaves the old log.
fn compact(&mut self, events: Vec<Event>) -> std::io::Result<()> {
let mut seq = self.next_seq();
let mut snapshot = Vec::with_capacity(accounts.len());
for account in accounts.values() {
let mut snapshot = Vec::with_capacity(events.len());
for event in events {
self.lamport += 1;
snapshot.push(LogEntry {
origin: self.origin.clone(),
seq,
lamport: self.lamport,
event: Event::AccountRegistered(account.clone()),
event,
});
seq += 1;
}
@ -213,6 +224,13 @@ pub enum CertError {
Internal, // persistence failed
}
#[derive(Debug)]
pub enum ChanError {
Exists, // channel already registered
NoChannel, // channel is not registered
Internal, // persistence failed
}
// A fingerprint is hex (hash digest), optionally colon-separated. Bound the
// length so a junk value can't bloat an account.
fn valid_fp(fp: &str) -> bool {
@ -231,6 +249,7 @@ pub struct Credentials {
pub struct Db {
accounts: HashMap<String, Account>, // keyed by casefolded name
channels: HashMap<String, ChannelInfo>, // keyed by casefolded name
log: EventLog,
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
pub(crate) scram_iterations: u32,
@ -248,34 +267,40 @@ impl Db {
pub fn open(path: impl Into<PathBuf>, origin: impl Into<String>) -> Self {
let (log, events) = EventLog::open(path.into(), origin.into());
let mut accounts = HashMap::new();
let mut channels = HashMap::new();
for event in events {
apply(&mut accounts, event);
apply(&mut accounts, &mut channels, event);
}
tracing::info!(accounts = accounts.len(), "account store loaded");
Self { accounts, log, scram_iterations: scram::DEFAULT_ITERATIONS }
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
Self { accounts, channels, log, scram_iterations: scram::DEFAULT_ITERATIONS }
}
/// Fold an entry authored by another node into the store — the services-side
/// of the gossip seam. Idempotent (re-delivered entries are dropped).
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
if let Some(event) = self.log.ingest(entry)? {
apply(&mut self.accounts, event);
apply(&mut self.accounts, &mut self.channels, event);
}
Ok(())
}
/// Rewrite the log to one entry per account, reclaiming churn.
/// Rewrite the log to one entry per account and channel, reclaiming churn.
pub fn compact(&mut self) -> std::io::Result<()> {
let before = self.log.len();
self.log.compact(&self.accounts)?;
let mut snapshot: Vec<Event> = self.accounts.values().cloned().map(Event::AccountRegistered).collect();
snapshot.extend(self.channels.values().map(|c| Event::ChannelRegistered {
name: c.name.clone(),
founder: c.founder.clone(),
ts: c.ts,
}));
self.log.compact(snapshot)?;
tracing::info!(before, after = self.log.len(), "compacted event log");
Ok(())
}
/// Whether the log has grown enough past the live account count to be worth
/// compacting.
/// Whether the log has grown enough past the live state to be worth compacting.
pub fn should_compact(&self) -> bool {
self.log.len() > self.accounts.len() * 3 + 64
self.log.len() > (self.accounts.len() + self.channels.len()) * 3 + 64
}
/// Wire the log to a broadcast channel; each new entry is pushed to peers.
@ -396,11 +421,41 @@ impl Db {
self.accounts.get_mut(&k).unwrap().certfps.retain(|c| *c != fp);
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);
if self.channels.contains_key(&k) {
return Err(ChanError::Exists);
}
let ts = now();
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 });
Ok(())
}
/// The registration for `name`, if any.
pub fn channel(&self, name: &str) -> Option<&ChannelInfo> {
self.channels.get(&key(name))
}
/// Unregister `name`.
pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
let k = key(name);
if !self.channels.contains_key(&k) {
return Err(ChanError::NoChannel);
}
self.log.append(Event::ChannelDropped { name: name.to_string() }).map_err(|_| ChanError::Internal)?;
self.channels.remove(&k);
Ok(())
}
}
// Fold one event into the account map. Shared by log replay (`open`) and gossip
// Fold one event into the store. Shared by log replay (`open`) and gossip
// ingest, so both routes reconstruct identical state.
fn apply(accounts: &mut HashMap<String, Account>, event: Event) {
fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String, ChannelInfo>, event: Event) {
match event {
Event::AccountRegistered(a) => {
accounts.insert(key(&a.name), a);
@ -417,6 +472,12 @@ fn apply(accounts: &mut HashMap<String, Account>, event: Event) {
a.certfps.retain(|c| *c != fp);
}
}
Event::ChannelRegistered { name, founder, ts } => {
channels.insert(key(&name), ChannelInfo { name, founder, ts });
}
Event::ChannelDropped { name } => {
channels.remove(&key(&name));
}
}
}
@ -547,4 +608,21 @@ mod tests {
assert!(b.exists("alice"), "B converges from a compacted node");
assert_eq!(b.certfps("alice"), &[fp][..], "certs arrive folded into the snapshot");
}
// Channels register (case-insensitively unique), persist, and drop.
#[test]
fn channels_register_drop_and_persist() {
let p = tmp("chan");
let mut db = Db::open(&p, "local");
db.register_channel("#Chat", "alice").unwrap();
assert!(matches!(db.register_channel("#chat", "bob"), Err(ChanError::Exists)), "dup is case-insensitive");
assert_eq!(db.channel("#CHAT").map(|c| c.founder.as_str()), Some("alice"));
drop(db);
let mut db = Db::open(&p, "local");
assert_eq!(db.channel("#chat").map(|c| c.founder.as_str()), Some("alice"), "survives reopen");
db.drop_channel("#chat").unwrap();
assert!(db.channel("#chat").is_none());
assert!(matches!(db.drop_channel("#chat"), Err(ChanError::NoChannel)));
}
}

View file

@ -896,4 +896,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:?}");
}
// ChanServ: registration needs identification, INFO shows the founder, and
// only the founder can drop.
#[test]
fn chanserv_register_info_drop() {
use crate::services::chanserv::ChanServ;
let path = std::env::temp_dir().join("fedserv-chanserv.jsonl");
let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path, "42S");
db.scram_iterations = 4096;
db.register("alice", "sesame", None).unwrap();
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
let cs = ChanServ { uid: "42SAAAAAB".into() };
let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db);
let to_cs = |e: &mut Engine, from: &str, text: &str| {
e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".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: "alice".into() });
// Not identified yet: refused.
assert!(notice(&to_cs(&mut e, "000AAAAAB", "REGISTER #room"), "must be identified"));
// Identify, then register.
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
assert!(notice(&to_cs(&mut e, "000AAAAAB", "REGISTER #room"), "now registered"));
assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #room"), "alice"));
// A different, unidentified user cannot drop it.
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() });
assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder"));
// The founder can.
assert!(notice(&to_cs(&mut e, "000AAAAAB", "DROP #room"), "dropped"));
}
}