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),
|
AccountRegistered(Account),
|
||||||
CertAdded { account: String, fp: String },
|
CertAdded { account: String, fp: String },
|
||||||
CertRemoved { 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
|
// A durable record: the event plus the metadata a gossip layer needs to address
|
||||||
|
|
@ -163,21 +173,22 @@ impl EventLog {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewrite the log to a minimal snapshot: one AccountRegistered per account,
|
// Rewrite the log to a minimal snapshot: one event per live account and
|
||||||
// authored under our origin at fresh sequence numbers. Peers re-converge
|
// channel, authored under our origin at fresh sequence numbers. Peers
|
||||||
// because AccountRegistered overwrites and cert replay is idempotent. The
|
// re-converge because the register events overwrite and cert replay is
|
||||||
// version vector resets to our origin; peers re-advertise theirs on the next
|
// idempotent. The version vector resets to our origin; peers re-advertise
|
||||||
// sync. Written to a temp file and renamed, so a crash leaves the old log.
|
// theirs on the next sync. Written to a temp file and renamed, so a crash
|
||||||
fn compact(&mut self, accounts: &HashMap<String, Account>) -> std::io::Result<()> {
|
// leaves the old log.
|
||||||
|
fn compact(&mut self, events: Vec<Event>) -> std::io::Result<()> {
|
||||||
let mut seq = self.next_seq();
|
let mut seq = self.next_seq();
|
||||||
let mut snapshot = Vec::with_capacity(accounts.len());
|
let mut snapshot = Vec::with_capacity(events.len());
|
||||||
for account in accounts.values() {
|
for event in events {
|
||||||
self.lamport += 1;
|
self.lamport += 1;
|
||||||
snapshot.push(LogEntry {
|
snapshot.push(LogEntry {
|
||||||
origin: self.origin.clone(),
|
origin: self.origin.clone(),
|
||||||
seq,
|
seq,
|
||||||
lamport: self.lamport,
|
lamport: self.lamport,
|
||||||
event: Event::AccountRegistered(account.clone()),
|
event,
|
||||||
});
|
});
|
||||||
seq += 1;
|
seq += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -213,6 +224,13 @@ pub enum CertError {
|
||||||
Internal, // persistence failed
|
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
|
// A fingerprint is hex (hash digest), optionally colon-separated. Bound the
|
||||||
// length so a junk value can't bloat an account.
|
// length so a junk value can't bloat an account.
|
||||||
fn valid_fp(fp: &str) -> bool {
|
fn valid_fp(fp: &str) -> bool {
|
||||||
|
|
@ -231,6 +249,7 @@ pub struct Credentials {
|
||||||
|
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
accounts: HashMap<String, Account>, // keyed by casefolded name
|
accounts: HashMap<String, Account>, // keyed by casefolded name
|
||||||
|
channels: HashMap<String, ChannelInfo>, // keyed by casefolded name
|
||||||
log: EventLog,
|
log: EventLog,
|
||||||
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
||||||
pub(crate) scram_iterations: u32,
|
pub(crate) scram_iterations: u32,
|
||||||
|
|
@ -248,34 +267,40 @@ impl Db {
|
||||||
pub fn open(path: impl Into<PathBuf>, origin: impl Into<String>) -> Self {
|
pub fn open(path: impl Into<PathBuf>, origin: impl Into<String>) -> Self {
|
||||||
let (log, events) = EventLog::open(path.into(), origin.into());
|
let (log, events) = EventLog::open(path.into(), origin.into());
|
||||||
let mut accounts = HashMap::new();
|
let mut accounts = HashMap::new();
|
||||||
|
let mut channels = HashMap::new();
|
||||||
for event in events {
|
for event in events {
|
||||||
apply(&mut accounts, event);
|
apply(&mut accounts, &mut channels, event);
|
||||||
}
|
}
|
||||||
tracing::info!(accounts = accounts.len(), "account store loaded");
|
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
||||||
Self { accounts, log, scram_iterations: scram::DEFAULT_ITERATIONS }
|
Self { accounts, channels, log, scram_iterations: scram::DEFAULT_ITERATIONS }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold an entry authored by another node into the store — the services-side
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
/// of the gossip seam. Idempotent (re-delivered entries are dropped).
|
/// of the gossip seam. Idempotent (re-delivered entries are dropped).
|
||||||
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
|
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
|
||||||
if let Some(event) = self.log.ingest(entry)? {
|
if let Some(event) = self.log.ingest(entry)? {
|
||||||
apply(&mut self.accounts, event);
|
apply(&mut self.accounts, &mut self.channels, event);
|
||||||
}
|
}
|
||||||
Ok(())
|
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<()> {
|
pub fn compact(&mut self) -> std::io::Result<()> {
|
||||||
let before = self.log.len();
|
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");
|
tracing::info!(before, after = self.log.len(), "compacted event log");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the log has grown enough past the live account count to be worth
|
/// Whether the log has grown enough past the live state to be worth compacting.
|
||||||
/// compacting.
|
|
||||||
pub fn should_compact(&self) -> bool {
|
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.
|
/// 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);
|
self.accounts.get_mut(&k).unwrap().certfps.retain(|c| *c != fp);
|
||||||
Ok(true)
|
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.
|
// 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 {
|
match event {
|
||||||
Event::AccountRegistered(a) => {
|
Event::AccountRegistered(a) => {
|
||||||
accounts.insert(key(&a.name), 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);
|
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!(b.exists("alice"), "B converges from a compacted node");
|
||||||
assert_eq!(b.certfps("alice"), &[fp][..], "certs arrive folded into the snapshot");
|
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:?}");
|
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 engine::Engine;
|
||||||
use proto::inspircd::InspIrcd;
|
use proto::inspircd::InspIrcd;
|
||||||
|
use services::chanserv::ChanServ;
|
||||||
use services::nickserv::NickServ;
|
use services::nickserv::NickServ;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -35,11 +36,16 @@ async fn main() -> Result<()> {
|
||||||
ts,
|
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),
|
uid: format!("{}AAAAAA", cfg.server.sid),
|
||||||
guest_nick: cfg.server.guest_nick.clone(),
|
guest_nick: cfg.server.guest_nick.clone(),
|
||||||
guest_seq: (ts % 100_000) as u32,
|
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 (gossip_tx, _) = tokio::sync::broadcast::channel::<engine::db::LogEntry>(1024);
|
||||||
let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid);
|
let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid);
|
||||||
db.scram_iterations = cfg.server.scram_iterations;
|
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;
|
pub mod nickserv;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue