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]