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:
parent
64c6facd56
commit
89593ebeb4
5 changed files with 227 additions and 25 deletions
118
src/engine/db.rs
118
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<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(())
|
||||
}
|
||||
|
||||
// Fold one event into the account map. Shared by log replay (`open`) and gossip
|
||||
/// 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 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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/main.rs
10
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<Box<dyn engine::service::Service>> = vec![Box::new(NickServ {
|
||||
let services: Vec<Box<dyn engine::service::Service>> = 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::<engine::db::LogEntry>(1024);
|
||||
let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid);
|
||||
db.scram_iterations = cfg.server.scram_iterations;
|
||||
|
|
|
|||
79
src/services/chanserv.rs
Normal file
79
src/services/chanserv.rs
Normal file
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
pub mod chanserv;
|
||||
pub mod nickserv;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue