xline: broadcast the XLINE notice on remove and expire too, so it covers every x-line's whole lifecycle

This commit is contained in:
Jean Chevronnet 2026-08-21 15:03:36 +00:00
parent 6301b64509
commit 3ad67c6b52
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
5 changed files with 69 additions and 30 deletions

View file

@ -726,20 +726,16 @@ fn do_xline(s: &mut Server, uid: Uid, params: &[String], kind: XKind) -> CmdResu
.map(|u| u.nick.clone())
.unwrap_or_default();
if params.len() < 2 {
let word = if s.remove_xline(kind, &mask) {
// A successful removal is announced by remove_xline (snomask +x), same as the
// add; only tell the oper directly when there was nothing to remove.
if s.remove_xline(kind, &mask, &nick) {
s.propagate_delline(kind.tag(), &mask);
"removed"
} else {
"not found"
};
s.send(
uid,
format!(
":{} NOTICE {nick} :{}-line {word}: {mask}",
s.name,
kind.tag()
),
format!(":{} NOTICE {nick} :{}-line not found: {mask}", s.name, kind.tag()),
);
}
return CmdResult::Ok;
}
// <mask> <duration> :<reason>; tolerate the durationless form by taking an
@ -879,16 +875,14 @@ impl Command for Rline {
.map(|u| u.nick.clone())
.unwrap_or_default();
if params.len() < 2 {
let word = if s.remove_xline(XKind::Rline, &pattern) {
if s.remove_xline(XKind::Rline, &pattern, &nick) {
s.propagate_delline("R", &pattern);
"removed"
} else {
"not found"
};
s.send(
uid,
format!(":{} NOTICE {nick} :R-line {word}: {pattern}", s.name),
format!(":{} NOTICE {nick} :R-line not found: {pattern}", s.name),
);
}
return CmdResult::Ok;
}
if let Err(e) = crate::regex::Regex::new(&pattern) {

View file

@ -659,7 +659,8 @@ impl Server {
return;
};
if msg.params.len() == 1 {
self.remove_xline(crate::xline::XKind::Svshold, &nick);
let setter = self.link_setter(msg);
self.remove_xline(crate::xline::XKind::Svshold, &nick, &setter);
} else if msg.params.len() >= 3 {
let Some(dur) = crate::xline::parse_duration(&msg.params[1]) else {
return;
@ -1448,7 +1449,8 @@ impl Server {
let Some(kind) = crate::xline::XKind::from_tag(&msg.params[0]) else {
return;
};
if self.remove_xline(kind, &msg.params[1]) {
let remover = self.link_setter(msg);
if self.remove_xline(kind, &msg.params[1], &remover) {
self.propagate(&msg.to_wire(), Some(via));
}
}

View file

@ -78,7 +78,8 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
let mask = json::get_str(params, "mask")
.or_else(|| json::get_str(params, "name"))
.ok_or_else(|| RpcError::invalid_params("missing 'mask'"))?;
let removed = s.remove_xline(kind, &mask);
let setter = json::get_str(params, "setter").unwrap_or_else(|| "RPC".into());
let removed = s.remove_xline(kind, &mask, &setter);
if removed {
Ok(obj(&[("result", "true".into())]))
} else {

View file

@ -1618,6 +1618,36 @@ mod tests {
assert_eq!(super::long_date(86400 * 31), "Sun 01 Feb 1970 00:00:00");
}
#[test]
fn xline_add_remove_expire_all_notify() {
let mut s = srv();
s.name = "irc.test".to_string();
s.conf_path = std::env::temp_dir().join("echo-xline-notify-test").display().to_string();
let orx = add_user(&mut s, 1, "op");
if let Some(u) = s.users.get_mut(&1) {
u.flags.oper = true;
u.flags.snomask = true;
u.flags.snomask_cats = "x".to_string();
}
// permanent G-line, then removed
s.add_xline(crate::xline::XKind::Gline, "*@bad.example", 0, "op", "spam");
assert!(s.remove_xline(crate::xline::XKind::Gline, "*@bad.example", "op"));
assert!(!s.remove_xline(crate::xline::XKind::Gline, "*@bad.example", "op")); // gone: no re-announce
// a timed Z-line whose expiry is forced into the past, then purged
s.add_xline(crate::xline::XKind::Zline, "192.0.2.5", 3600, "op", "temp");
for x in s.xlines.iter_mut() {
if x.mask == "192.0.2.5" {
x.expires = 1;
}
}
s.purge_xlines();
let joined: String =
std::iter::from_fn(|| orx.try_recv().ok()).collect::<Vec<_>>().join("\n");
assert!(joined.contains("XLINE: op added a permanent G-line on *@bad.example: spam"), "add: {joined}");
assert!(joined.contains("XLINE: op removed a G-line on *@bad.example"), "remove: {joined}");
assert!(joined.contains("XLINE: Z-line on 192.0.2.5 expired"), "expire: {joined}");
}
#[test]
fn dnsbl_hit_emits_expected_snotices() {
use std::net::Ipv4Addr;

View file

@ -294,8 +294,9 @@ impl Server {
self.enforce_xlines();
}
/// Remove an x-line by kind + mask; returns whether one was found.
pub fn remove_xline(&mut self, kind: XKind, mask: &str) -> bool {
/// Remove an x-line by kind + mask; announces it (snomask +x, like the add) and
/// returns whether one was found. `remover` is who took it off.
pub fn remove_xline(&mut self, kind: XKind, mask: &str, remover: &str) -> bool {
let before = self.xlines.len();
// case-insensitive: nick/host/channel masks match case-insensitively when
// enforced, so removal must too (e.g. remove `CBAN #foo` for a `#Foo` ban).
@ -303,6 +304,7 @@ impl Server {
.retain(|x| !(x.kind == kind && x.mask.eq_ignore_ascii_case(mask)));
let removed = self.xlines.len() < before;
if removed {
self.snotice_c('x', &format!("XLINE: {remover} removed a {}-line on {mask}", kind.tag()));
self.save_xlines();
}
removed
@ -335,14 +337,24 @@ impl Server {
}
}
/// Drop expired x-lines (called on the background tick).
/// Drop expired x-lines (called on the background tick), announcing each one
/// (snomask +x) so the XLINE notices cover a ban's whole life: add → expire.
pub fn purge_xlines(&mut self) {
let n = now();
let before = self.xlines.len();
self.xlines.retain(|x| x.expires == 0 || x.expires > n);
if self.xlines.len() != before {
self.save_xlines(); // an expiry changed the set — persist it
let expired: Vec<(XKind, String)> = self
.xlines
.iter()
.filter(|x| x.expires != 0 && x.expires <= n)
.map(|x| (x.kind, x.mask.clone()))
.collect();
if expired.is_empty() {
return;
}
self.xlines.retain(|x| x.expires == 0 || x.expires > n);
for (kind, mask) in &expired {
self.snotice_c('x', &format!("XLINE: {}-line on {mask} expired", kind.tag()));
}
self.save_xlines(); // an expiry changed the set — persist it
}
/// Path of the on-disk x-line db (beside the config file).