diff --git a/src/engine/db.rs b/src/engine/db.rs index 3455c49..d403eae 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -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) -> 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) -> 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, // keyed by casefolded name + channels: HashMap, // 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, origin: impl Into) -> 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 = 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, event: Event) { +fn apply(accounts: &mut HashMap, channels: &mut HashMap, event: Event) { match event { Event::AccountRegistered(a) => { accounts.insert(key(&a.name), a); @@ -417,6 +472,12 @@ fn apply(accounts: &mut HashMap, 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))); + } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4704f54..41a637c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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")); + } } diff --git a/src/main.rs b/src/main.rs index be43d46..7ef8091 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use tokio::sync::Mutex; use engine::Engine; use proto::inspircd::InspIrcd; +use services::chanserv::ChanServ; use services::nickserv::NickServ; #[tokio::main] @@ -35,11 +36,16 @@ async fn main() -> Result<()> { ts, )); - let services: Vec> = vec![Box::new(NickServ { - uid: format!("{}AAAAAA", cfg.server.sid), - guest_nick: cfg.server.guest_nick.clone(), - guest_seq: (ts % 100_000) as u32, - })]; + let services: Vec> = vec![ + Box::new(NickServ { + uid: format!("{}AAAAAA", cfg.server.sid), + guest_nick: cfg.server.guest_nick.clone(), + guest_seq: (ts % 100_000) as u32, + }), + Box::new(ChanServ { + uid: format!("{}AAAAAB", cfg.server.sid), + }), + ]; let (gossip_tx, _) = tokio::sync::broadcast::channel::(1024); let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid); db.scram_iterations = cfg.server.scram_iterations; diff --git a/src/services/chanserv.rs b/src/services/chanserv.rs new file mode 100644 index 0000000..365db3e --- /dev/null +++ b/src/services/chanserv.rs @@ -0,0 +1,79 @@ +use crate::engine::db::{ChanError, Db}; +use crate::engine::service::{Sender, Service, ServiceCtx}; + +pub struct ChanServ { + pub uid: String, +} + +impl Service for ChanServ { + fn nick(&self) -> &str { + "ChanServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Channel Services" + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let me = self.uid.as_str(); + match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { + Some("REGISTER") => { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: REGISTER <#channel>"); + return; + }; + if !chan.starts_with('#') { + ctx.notice(me, from.uid, "Channel names start with #."); + return; + } + // The founder is the account the sender is identified to. + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You must be identified to register a channel."); + return; + }; + match db.register_channel(chan, account) { + Ok(()) => ctx.notice(me, from.uid, format!("Channel \x02{chan}\x02 is now registered to \x02{account}\x02.")), + Err(ChanError::Exists) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 is already registered.")), + Err(_) => ctx.notice(me, from.uid, "Registration failed, please try again later."), + } + } + Some("INFO") => { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: INFO <#channel>"); + return; + }; + match db.channel(chan) { + Some(info) => ctx.notice(me, from.uid, format!("\x02{}\x02 is registered to \x02{}\x02 (since {}).", info.name, info.founder, info.ts)), + None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 is not registered.")), + } + } + Some("DROP") => { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: DROP <#channel>"); + return; + }; + // Read the founder, then drop, without holding the borrow across the mutation. + let founder = match db.channel(chan) { + Some(info) => info.founder.clone(), + None => { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 is not registered.")); + return; + } + }; + if from.account != Some(founder.as_str()) { + ctx.notice(me, from.uid, "Only the channel founder can drop it."); + return; + } + match db.drop_channel(chan) { + Ok(()) => ctx.notice(me, from.uid, format!("Channel \x02{chan}\x02 has been dropped.")), + Err(_) => ctx.notice(me, from.uid, "Drop failed, please try again later."), + } + } + Some("HELP") => ctx.notice(me, from.uid, "Commands: REGISTER <#channel>, INFO <#channel>, DROP <#channel>."), + Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {other}. Try HELP.")), + None => {} + } + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index c72e5a2..1459fce 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1 +1,2 @@ +pub mod chanserv; pub mod nickserv;