From 99df7baf103d11a2a0756a49ece134cf770cc951 Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 09:28:56 +0000 Subject: [PATCH] engine: apply gossiped network bans to the ircd immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local AKILL/SQLINE/etc. pushes an ADDLINE to the ircd at once, but a ban arriving by gossip was only held for the next netburst — so a user banned on one network could keep hopping to peers until their next relink. ingest now signals BanAdded/BanLifted (all xline kinds), and gossip_ingest pushes the ADDLINE/DELLINE. Renamed AccountChange to IngestEffect to match its broader role. --- src/engine/db/mod.rs | 35 ++++++++++++++++++++++++++++------- src/engine/mod.rs | 16 +++++++++++++--- src/engine/tests.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index b3a8904..985f47f 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -883,14 +883,20 @@ impl EventLog { } } -// What an ingested entry did to a locally-known account, so the engine can log -// out sessions that were relying on it. -pub enum AccountChange { +// A side-effect an ingested (gossiped) entry needs the engine to carry out on +// the local ircd — the peer only changed shared state, so anything the ircd must +// be told (log a session out, add/lift a network ban) is signalled back here. +pub enum IngestEffect { TakenOver(String), Dropped(String), // A gossiped suspension that is now in force: local sessions must be logged // out, but the account (and its channels) stay put. Suspended(String), + // A gossiped network ban (AKILL/SQLINE/…) that must be pushed to the local + // ircd now, not just held for the next netburst. `duration` is the remaining + // seconds (0 = permanent). + BanAdded { kind: String, mask: String, setter: String, duration: u64, reason: String }, + BanLifted { kind: String, mask: String }, } // A fingerprint is hex (hash digest), optionally colon-separated. Bound the @@ -1022,7 +1028,7 @@ impl Db { /// of the gossip seam. Idempotent (re-delivered entries are dropped). Returns /// the account name if this ingest changed its owner (a registration conflict /// resolved against the local claim), so the caller can log out stale sessions. - pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result> { + pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result> { // Snapshot the incoming account's local owner before applying, to detect a // takeover (home changed) or a remote drop (it disappears). let watched: Option<(String, Option)> = match &entry.event { @@ -1036,22 +1042,37 @@ impl Db { Event::AccountSuspended { account, .. } => Some(account.clone()), _ => None, }); + // A freshly applied network ban must be pushed to the local ircd now. + let ban = applied.as_ref().and_then(|e| match e { + Event::AkillAdded { kind, mask, setter, reason, expires, .. } => Some(IngestEffect::BanAdded { + kind: kind.clone(), + mask: mask.clone(), + setter: setter.clone(), + duration: (*expires).map(|x| x.saturating_sub(now())).unwrap_or(0), + reason: reason.clone(), + }), + Event::AkillRemoved { kind, mask } => Some(IngestEffect::BanLifted { kind: kind.clone(), mask: mask.clone() }), + _ => None, + }); if let Some(event) = applied { apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.net, event); } if let Some((name, prev_home)) = watched { match (prev_home, self.account(&name).map(|c| c.home.clone())) { - (Some(prev), Some(cur)) if cur != prev => return Ok(Some(AccountChange::TakenOver(name))), - (Some(_), None) => return Ok(Some(AccountChange::Dropped(name))), + (Some(prev), Some(cur)) if cur != prev => return Ok(Some(IngestEffect::TakenOver(name))), + (Some(_), None) => return Ok(Some(IngestEffect::Dropped(name))), _ => {} } } if let Some(account) = suspended { // Only if the suspension is actually in force (not an already-expired one). if self.is_suspended(&account) { - return Ok(Some(AccountChange::Suspended(account))); + return Ok(Some(IngestEffect::Suspended(account))); } } + if let Some(effect) = ban { + return Ok(Some(effect)); + } Ok(None) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4ef77df..49a5eae 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -461,20 +461,30 @@ impl Engine { // If ingesting a peer's entry removed an account a local session or channel // relied on (lost a conflict, or dropped elsewhere), clean up after it. let gone = match self.db.ingest(entry)? { - Some(db::AccountChange::TakenOver(a)) => { + Some(db::IngestEffect::TakenOver(a)) => { self.handle_account_gone(&a, "collided with another network and no longer belongs to you"); true } - Some(db::AccountChange::Dropped(a)) => { + Some(db::IngestEffect::Dropped(a)) => { self.handle_account_gone(&a, "was dropped"); true } - Some(db::AccountChange::Suspended(a)) => { + Some(db::IngestEffect::Suspended(a)) => { // A gossiped suspension keeps the account and its channels, but its // live sessions here must end (a local SUSPEND logs them out too). self.logout_account_sessions(&a, "was suspended"); false } + Some(db::IngestEffect::BanAdded { kind, mask, setter, duration, reason }) => { + // Push the gossiped network ban to our ircd now, rather than only + // holding it for the next netburst. + self.emit_irc(NetAction::AddLine { kind, mask, setter, duration, reason }); + false + } + Some(db::IngestEffect::BanLifted { kind, mask }) => { + self.emit_irc(NetAction::DelLine { kind, mask }); + false + } None => false, }; // handle_account_gone may have released a channel that had a bot assigned. diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 057c553..b09310a 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -469,6 +469,37 @@ assert!(told, "the user is told they were suspended"); } + // A network ban that arrives by gossip is pushed to the local ircd right + // away, not just held for the next netburst — otherwise a banned user could + // hop to a peer network until its next relink. + #[test] + fn gossiped_network_ban_is_pushed_to_the_ircd() { + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-gossipban.jsonl"); + let _ = std::fs::remove_file(&path); + let db = Db::open(&path, "test"); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + e.set_sid("42S".into()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + e.set_irc_out(tx); + + // A peer sets a permanent G-line; it must be applied to our ircd now. + e.gossip_ingest(LogEntry::for_test("peer", 0, 1, db::Event::AkillAdded { + kind: "G".into(), mask: "*@evil.example".into(), setter: "peer-oper".into(), reason: "spam".into(), ts: 0, expires: None, + })).unwrap(); + let added = std::iter::from_fn(|| rx.try_recv().ok()) + .any(|a| matches!(a, NetAction::AddLine { kind, mask, duration, .. } if kind == "G" && mask == "*@evil.example" && duration == 0)); + assert!(added, "gossiped ban pushed to the ircd immediately"); + + // A peer lifts it; the ircd is told to drop the line. + e.gossip_ingest(LogEntry::for_test("peer", 1, 2, db::Event::AkillRemoved { + kind: "G".into(), mask: "*@evil.example".into(), + })).unwrap(); + let lifted = std::iter::from_fn(|| rx.try_recv().ok()) + .any(|a| matches!(a, NetAction::DelLine { kind, mask } if kind == "G" && mask == "*@evil.example")); + assert!(lifted, "gossiped ban lift removes the line from the ircd"); + } + // Losing a registration conflict logs out the local session on that name and // notifies them over the services-initiated outbound path. #[test]