Add EventLog with origin/seq metadata for replication

This commit is contained in:
Jean Chevronnet 2026-07-12 06:08:27 +00:00
parent 65729a62b8
commit c066f1072f
No known key found for this signature in database
3 changed files with 90 additions and 30 deletions

View file

@ -39,6 +39,57 @@ pub enum Event {
CertRemoved { account: String, fp: String }, CertRemoved { account: String, fp: String },
} }
// A durable record: the event plus the metadata a gossip layer needs to address
// and order it — the origin node and its per-node sequence. Lines written before
// these existed default them in.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LogEntry {
#[serde(default)]
origin: String,
#[serde(default)]
seq: u64,
#[serde(flatten)]
event: Event,
}
// Append-only log, the sole persistent source of truth: `open` replays it, `append`
// stamps and writes one entry. Owning origin + next_seq is the seam a future gossip
// layer ships entries over, without the services knowing.
pub struct EventLog {
path: PathBuf,
origin: String,
next_seq: u64,
}
impl EventLog {
fn open(path: PathBuf, origin: String) -> (Self, Vec<Event>) {
let mut events = Vec::new();
let mut next_seq = 0;
if let Ok(data) = std::fs::read_to_string(&path) {
for line in data.lines().filter(|l| !l.trim().is_empty()) {
match serde_json::from_str::<LogEntry>(line) {
Ok(entry) => {
if entry.origin == origin {
next_seq = next_seq.max(entry.seq + 1);
}
events.push(entry.event);
}
Err(e) => tracing::warn!(%e, "skipping malformed event log line"),
}
}
}
(Self { path, origin, next_seq }, events)
}
fn append(&mut self, event: Event) -> std::io::Result<()> {
let entry = LogEntry { origin: self.origin.clone(), seq: self.next_seq, event };
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
writeln!(f, "{}", serde_json::to_string(&entry).unwrap_or_default())?;
self.next_seq += 1;
Ok(())
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum RegError { pub enum RegError {
Exists, Exists,
@ -71,7 +122,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
path: PathBuf, 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,
} }
@ -85,31 +136,28 @@ fn now() -> u64 {
} }
impl Db { impl Db {
pub fn open(path: impl Into<PathBuf>) -> Self { pub fn open(path: impl Into<PathBuf>, origin: impl Into<String>) -> Self {
let path = path.into(); let (log, events) = EventLog::open(path.into(), origin.into());
let mut accounts = HashMap::new(); let mut accounts = HashMap::new();
if let Ok(data) = std::fs::read_to_string(&path) { for event in events {
for line in data.lines().filter(|l| !l.trim().is_empty()) { match event {
match serde_json::from_str::<Event>(line) { Event::AccountRegistered(a) => {
Ok(Event::AccountRegistered(a)) => {
accounts.insert(key(&a.name), a); accounts.insert(key(&a.name), a);
} }
Ok(Event::CertAdded { account, fp }) => { Event::CertAdded { account, fp } => {
if let Some(a) = accounts.get_mut(&key(&account)) { if let Some(a) = accounts.get_mut(&key(&account)) {
a.certfps.push(fp); a.certfps.push(fp);
} }
} }
Ok(Event::CertRemoved { account, fp }) => { Event::CertRemoved { account, fp } => {
if let Some(a) = accounts.get_mut(&key(&account)) { if let Some(a) = accounts.get_mut(&key(&account)) {
a.certfps.retain(|c| *c != fp); a.certfps.retain(|c| *c != fp);
} }
} }
Err(e) => tracing::warn!(%e, "skipping malformed event log line"),
} }
} }
} tracing::info!(accounts = accounts.len(), "account store loaded");
tracing::info!(accounts = accounts.len(), ?path, "account store loaded"); Self { accounts, log, scram_iterations: scram::DEFAULT_ITERATIONS }
Self { accounts, path, scram_iterations: scram::DEFAULT_ITERATIONS }
} }
pub fn exists(&self, name: &str) -> bool { pub fn exists(&self, name: &str) -> bool {
@ -141,7 +189,7 @@ impl Db {
scram512: Some(creds.scram512), scram512: Some(creds.scram512),
certfps: Vec::new(), certfps: Vec::new(),
}; };
self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?;
self.accounts.insert(key(name), account); self.accounts.insert(key(name), account);
Ok(()) Ok(())
} }
@ -197,7 +245,7 @@ impl Db {
if !self.accounts.contains_key(&k) { if !self.accounts.contains_key(&k) {
return Err(CertError::NoAccount); return Err(CertError::NoAccount);
} }
self.append(&Event::CertAdded { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?; self.log.append(Event::CertAdded { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?;
self.accounts.get_mut(&k).unwrap().certfps.push(fp); self.accounts.get_mut(&k).unwrap().certfps.push(fp);
Ok(()) Ok(())
} }
@ -211,15 +259,10 @@ impl Db {
Some(a) if !a.certfps.iter().any(|c| *c == fp) => return Ok(false), Some(a) if !a.certfps.iter().any(|c| *c == fp) => return Ok(false),
Some(_) => {} Some(_) => {}
} }
self.append(&Event::CertRemoved { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?; self.log.append(Event::CertRemoved { account: account.to_string(), fp: fp.clone() }).map_err(|_| CertError::Internal)?;
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)
} }
fn append(&self, event: &Event) -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default())
}
} }
fn hash_password(password: &str) -> Option<String> { fn hash_password(password: &str) -> Option<String> {

View file

@ -491,7 +491,7 @@ mod tests {
fn engine_with(name: &str, account: &str, password: &str) -> Engine { fn engine_with(name: &str, account: &str, password: &str) -> Engine {
let path = std::env::temp_dir().join(format!("fedserv-sasl-{name}.jsonl")); let path = std::env::temp_dir().join(format!("fedserv-sasl-{name}.jsonl"));
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let mut db = Db::open(&path); let mut db = Db::open(&path, "test");
db.scram_iterations = 4096; // keep the debug-build verifier cheap in tests db.scram_iterations = 4096; // keep the debug-build verifier cheap in tests
assert!(db.register(account, password, None).is_ok()); assert!(db.register(account, password, None).is_ok());
Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".to_string(), guest_nick: "Guest".to_string(), guest_seq: 12345 })], db) Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".to_string(), guest_nick: "Guest".to_string(), guest_seq: 12345 })], db)
@ -833,6 +833,23 @@ mod tests {
assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("already identified"))), "{second:?}"); assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("already identified"))), "{second:?}");
} }
// The event log is the source of truth: state survives a reopen, rebuilt by
// folding the log (with its origin/seq metadata) rather than a snapshot.
#[test]
fn account_store_survives_reopen() {
let path = std::env::temp_dir().join("fedserv-reopen.jsonl");
let _ = std::fs::remove_file(&path);
{
let mut db = Db::open(&path, "n1");
db.scram_iterations = 4096;
db.register("foo", "sesame", None).unwrap();
db.certfp_add("foo", "aabbccddeeff00112233445566778899").unwrap();
}
let db = Db::open(&path, "n1");
assert_eq!(db.certfps("foo").len(), 1, "cert replayed from log");
assert!(db.authenticate("foo", "sesame").is_some(), "account replayed from log");
}
// Regression: after a nick change (e.g. a guest rename, then back to your // Regression: after a nick change (e.g. a guest rename, then back to your
// real nick), IDENTIFY must authenticate the CURRENT nick, not the one held // real nick), IDENTIFY must authenticate the CURRENT nick, not the one held
// at burst time — otherwise you log into the wrong account. // at burst time — otherwise you log into the wrong account.

View file

@ -37,7 +37,7 @@ async fn main() -> Result<()> {
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,
})]; })];
let mut db = engine::db::Db::open("fedserv.db.jsonl"); 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;
let engine = Engine::new(services, db); let engine = Engine::new(services, db);