dnsbl: per-zone name/action/duration/reason with %ip%; XLINE notice shows duration + absolute expiry

This commit is contained in:
Jean Chevronnet 2026-08-21 14:14:17 +00:00
parent 1824af5d90
commit b693c5e5df
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
6 changed files with 309 additions and 37 deletions

View file

@ -113,7 +113,7 @@ pub struct Config {
pub amu: AntiMixedCfg, // antimixedutf8 module config
pub resolve_hosts: bool, // reverse-DNS clients on connect (default on)
pub use_resolved_host: bool, // put the resolved hostname in the hostmask (default on)
pub dnsbl_zones: Vec<String>, // DNS blocklist zones to check on connect
pub dnsbl_zones: Vec<crate::modules::dnsbl::DnsblZone>, // DNS blocklists to check on connect
pub dnsbl_action: String, // mark | kline | gline | zline (on a hit)
pub dnsbl_reason: String, // ban reason for a DNSBL hit
pub sasl_server: String, // linked services server that handles SASL ("" = none)
@ -301,8 +301,8 @@ impl Config {
)
}
"dnsbl" | "dnsbl_zone" => {
if !v.is_empty() {
c.dnsbl_zones.push(v.to_string());
if let Some(z) = crate::modules::dnsbl::parse_zone(v) {
c.dnsbl_zones.push(z);
}
}
"dnsbl_action" => c.dnsbl_action = v.to_ascii_lowercase(),

View file

@ -17,6 +17,89 @@ use crate::server::Server;
use crate::xline::XKind;
use crate::Uid;
/// One configured DNS blocklist. The resolver worker only needs `domain` (the zone
/// it reverses the client IP under); the rest shape what happens on a hit and are
/// looked up on the core thread. Unset per-zone fields fall back to the global
/// `dnsbl_action` / `dnsbl_reason` / `dnsbl_duration`.
#[derive(Clone, Debug)]
pub struct DnsblZone {
pub domain: String, // DNS zone queried (e.g. torexit.dan.me.uk)
pub name: String, // friendly label shown in the hit notice
pub action: Option<String>, // per-zone action override (mark/kill/kline/gline/zline)
pub duration: Option<u64>, // per-zone ban duration override (seconds)
pub reason: Option<String>, // per-zone ban reason (supports %ip%)
}
/// Parse one `dnsbl = …` config value. Two forms:
/// * bare zone — `dnsbl = torexit.dan.me.uk` (uses the global action/reason)
/// * attributes — `dnsbl = domain=torexit.dan.me.uk name="Tor exit node"
/// action=zline duration=1w reason="… %ip% …"` (values may be "quoted")
pub fn parse_zone(value: &str) -> Option<DnsblZone> {
let value = value.trim();
let first = value.split_whitespace().next().unwrap_or("");
if first.is_empty() {
return None;
}
if !first.contains('=') {
let domain = first.trim_end_matches('.').to_string();
return Some(DnsblZone { name: domain.clone(), domain, action: None, duration: None, reason: None });
}
let attrs = parse_kv(value);
let get = |k: &str| attrs.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone());
let domain = get("domain")?.trim_end_matches('.').to_string();
if domain.is_empty() {
return None;
}
Some(DnsblZone {
action: get("action").map(|a| a.to_ascii_lowercase()),
duration: get("duration").and_then(|d| crate::xline::parse_duration(&d)),
reason: get("reason"),
name: get("name").unwrap_or_else(|| domain.clone()),
domain,
})
}
/// Tokenise `key=value` attributes, honouring `"double quotes"` so a value may
/// contain spaces (a reason string, a URL).
fn parse_kv(s: &str) -> Vec<(String, String)> {
let b: Vec<char> = s.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
while i < b.len() && b[i].is_whitespace() {
i += 1;
}
let ks = i;
while i < b.len() && b[i] != '=' && !b[i].is_whitespace() {
i += 1;
}
let key: String = b[ks..i].iter().collect();
if i < b.len() && b[i] == '=' {
i += 1;
let val = if i < b.len() && b[i] == '"' {
i += 1;
let vs = i;
while i < b.len() && b[i] != '"' {
i += 1;
}
let v: String = b[vs..i].iter().collect();
i += 1; // skip the closing quote (or the end)
v
} else {
let vs = i;
while i < b.len() && !b[i].is_whitespace() {
i += 1;
}
b[vs..i].iter().collect()
};
if !key.is_empty() {
out.push((key.to_ascii_lowercase(), val));
}
}
}
out
}
/// Ban length applied by the `*line` actions on a hit.
const DNSBL_BAN: u64 = 86_400; // default ban length (1 day) if `dnsbl_duration` unset
@ -64,40 +147,100 @@ pub fn report(s: &mut Server, uid: Uid, outcome: Outcome) {
s.notice_star(uid, "Checking for DNSBL");
s.notice_star(uid, "Checking for DNSBL done, no hit.");
}
Outcome::Hit { zone, reply } => {
Outcome::Hit { zone, reply: _ } => {
s.notice_star(uid, "Checking for DNSBL");
s.notice_star(
uid,
&format!("Checking for DNSBL done — LISTED on {zone} ({reply})."),
);
act(s, uid, &zone, reply);
s.notice_star(uid, "Checking for DNSBL done.");
act(s, uid, &zone);
}
}
}
/// Act on a hit per `dnsbl_action`: `mark` just informs; the `*line` actions add a
/// temporary ban and close; `kill` closes without a persistent ban.
fn act(s: &mut Server, uid: Uid, zone: &str, reply: Ipv4Addr) {
/// Act on a hit against blocklist `domain` per its (or the global) action: `mark`
/// just informs; the `*line` actions add a ban and close; `kill` closes without a
/// persistent ban. Emits the XLINE notice (via `add_xline`) then the DNSBL one.
fn act(s: &mut Server, uid: Uid, domain: &str) {
let (mask, ip) = match s.users.get(&uid) {
Some(u) => (u.prefix(), u.addr.ip()),
None => return,
};
let action = s.dnsbl_action.clone();
s.snotice_c('d', &format!(
"DNSBL: {mask} is listed on {zone} ({reply}); action={action}"
));
let reason = format!("{} (listed on {zone})", s.dnsbl_reason);
let ipstr = ip.to_string();
let dur = s.conf_num("dnsbl_duration", DNSBL_BAN);
match action.as_str() {
"kline" => s.add_xline(XKind::Kline, &format!("*@{ipstr}"), dur, "dnsbl", &reason),
"gline" => s.add_xline(XKind::Gline, &format!("*@{ipstr}"), dur, "dnsbl", &reason),
"zline" => s.add_xline(XKind::Zline, &ipstr, dur, "dnsbl", &reason),
"kill" | "reject" => {}
_ => return, // "mark" or unknown: notify only, don't disconnect
// Resolve this hit's per-zone settings, each falling back to the global default.
let zone = s
.dnsbl_zones
.iter()
.find(|z| z.domain.eq_ignore_ascii_case(domain))
.cloned();
let name = zone.as_ref().map(|z| z.name.clone()).unwrap_or_else(|| domain.to_string());
let action = zone
.as_ref()
.and_then(|z| z.action.clone())
.unwrap_or_else(|| s.dnsbl_action.clone());
let dur = zone
.as_ref()
.and_then(|z| z.duration)
.unwrap_or_else(|| s.conf_num("dnsbl_duration", DNSBL_BAN));
let reason_tmpl = zone
.as_ref()
.and_then(|z| z.reason.clone())
.unwrap_or_else(|| s.dnsbl_reason.clone());
// Reason templating: %ip% → the client IP, %dnsbl% → the zone domain.
let reason = reason_tmpl.replace("%ip%", &ipstr).replace("%dnsbl%", domain);
let setter = format!("dnsbl@{}", s.name);
// Apply the action first so the XLINE notice precedes the DNSBL one, matching
// how an operator watching both snomasks sees a blocklist ban land.
let closes = match action.as_str() {
"kline" => {
s.add_xline(XKind::Kline, &format!("*@{ipstr}"), dur, &setter, &reason);
true
}
"gline" => {
s.add_xline(XKind::Gline, &format!("*@{ipstr}"), dur, &setter, &reason);
true
}
"zline" => {
s.add_xline(XKind::Zline, &ipstr, dur, &setter, &reason);
true
}
"kill" | "reject" => true,
_ => false, // "mark" or unknown: notify only, let them in
};
s.snotice_c('d', &format!(
"DNSBL: Connecting user {mask} ({ipstr}) detected as being on the '{domain}' DNSBL: {name}"
));
if closes {
// pre-registration users aren't caught by add_xline's enforce sweep, so close
// this connection explicitly (the ERROR flushes before the socket).
s.send(uid, format!("ERROR :Closing link: ({reason})"));
s.remove_user(uid, &reason);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_zone_bare_domain() {
let z = parse_zone("torexit.dan.me.uk.").unwrap();
assert_eq!(z.domain, "torexit.dan.me.uk"); // trailing dot trimmed
assert_eq!(z.name, "torexit.dan.me.uk"); // name defaults to the domain
assert!(z.action.is_none() && z.duration.is_none() && z.reason.is_none());
assert!(parse_zone(" ").is_none());
}
#[test]
fn parse_zone_with_quoted_attrs() {
let z = parse_zone(
"domain=torexit.dan.me.uk name=\"Tor exit node\" action=ZLINE duration=1w \
reason=\"Not allowed. See https://x/%ip% here\"",
)
.unwrap();
assert_eq!(z.domain, "torexit.dan.me.uk");
assert_eq!(z.name, "Tor exit node");
assert_eq!(z.action.as_deref(), Some("zline")); // lower-cased
assert_eq!(z.duration, Some(604800)); // 1w
assert_eq!(z.reason.as_deref(), Some("Not allowed. See https://x/%ip% here"));
// attrs missing a domain are rejected
assert!(parse_zone("name=\"no domain\" action=zline").is_none());
}
// pre-registration users aren't caught by add_xline's enforce sweep, so close
// this connection explicitly (the ERROR flushes before the socket).
s.send(uid, format!("ERROR :Closing link: ({reason})"));
s.remove_user(uid, &reason);
}

View file

@ -61,6 +61,34 @@ pub fn iso_time(secs: u64) -> String {
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
}
/// A unix time as `Fri 28 Aug 2026 13:45:29` (UTC) — the long form used in the
/// XLINE server notice for a ban's absolute expiry. Shares the civil-date
/// arithmetic with [`iso_time`], plus the weekday (1970-01-01 was a Thursday).
pub fn long_date(secs: u64) -> String {
const WD: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MO: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let days = (secs / 86400) as i64;
let (h, mi, s) = ((secs % 86400) / 3600, (secs % 3600) / 60, secs % 60);
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
let wd = ((days % 7 + 4) % 7 + 7) % 7; // 0 = Sunday
format!(
"{} {d:02} {} {y:04} {h:02}:{mi:02}:{s:02}",
WD[wd as usize],
MO[(m - 1) as usize]
)
}
/// Parse an IRCv3 `server-time` value (`2026-08-08T19:52:42.000Z`) back to unix
/// seconds — the inverse of [`iso_time`], for CHATHISTORY `timestamp=` selectors.
pub fn parse_iso(s: &str) -> Option<u64> {
@ -153,7 +181,7 @@ pub struct Server {
pub amu: crate::config::AntiMixedCfg, // antimixedutf8 module config
pub resolve_hosts: bool, // reverse-DNS clients on connect
pub use_resolved_host: bool, // apply the resolved name to the hostmask
pub dnsbl_zones: Vec<String>, // DNS blocklist zones checked on connect
pub dnsbl_zones: Vec<crate::modules::dnsbl::DnsblZone>, // DNS blocklists checked on connect
pub dnsbl_action: String, // mark | kline | gline | zline
pub dnsbl_reason: String, // ban reason on a DNSBL hit
pub sasl_server: String, // services server that handles SASL
@ -439,7 +467,9 @@ impl Server {
crate::modules::ident::dispatch(self, uid);
// a connection class may opt out of reverse-DNS (resolvehostnames=no)
let do_rdns = self.resolve_hosts && crate::modules::connclass::resolve_hostnames(self, uid);
let zones = self.dnsbl_zones.clone(); // DNSBL runs if any zones are configured
// The resolver worker only needs the zone domains to reverse the IP under;
// per-zone action/reason are looked up back on the core thread on a hit.
let zones: Vec<String> = self.dnsbl_zones.iter().map(|z| z.domain.clone()).collect();
if do_rdns {
self.notice_star(uid, "Looking up your hostname...");
}
@ -1584,6 +1614,63 @@ mod tests {
assert!(!s.users[&2].dns_pending); // registration still un-held either way
}
#[test]
fn long_date_formats_weekday_and_month() {
// 1970-01-01 was a Thursday; step across a day and a month boundary.
assert_eq!(super::long_date(0), "Thu 01 Jan 1970 00:00:00");
assert_eq!(super::long_date(86400), "Fri 02 Jan 1970 00:00:00");
assert_eq!(super::long_date(86400 * 31), "Sun 01 Feb 1970 00:00:00");
}
#[test]
fn dnsbl_hit_emits_expected_snotices() {
use std::net::Ipv4Addr;
let mut s = srv();
s.name = "irc.test".to_string();
s.conf_path = std::env::temp_dir().join("echo-dnsbl-test").display().to_string();
// an operator watching the xline (x) and dnsbl (d) snomasks
let orx = add_user(&mut s, 1, "watcher");
if let Some(u) = s.users.get_mut(&1) {
u.flags.oper = true;
u.flags.snomask = true;
u.flags.snomask_cats = "xd".to_string();
}
// the connecting user tripping the blocklist — a distinct IP so the ban
// doesn't also match the watcher (add_user gives everyone 127.0.0.1).
let _brx = add_user(&mut s, 2, "badguy");
if let Some(u) = s.users.get_mut(&2) {
u.addr = "[2a06:1700:0:12::1]:6667".parse().unwrap();
}
s.dnsbl_zones = vec![crate::modules::dnsbl::parse_zone(
"domain=torexit.dan.me.uk name=\"Tor exit node\" action=zline duration=1w \
reason=\"Tor exit nodes are not allowed on this network. \
See https://metrics.torproject.org/rs.html#search/%ip% for more information.\"",
)
.unwrap()];
crate::modules::dnsbl::report(
&mut s,
2,
crate::modules::dnsbl::Outcome::Hit {
zone: "torexit.dan.me.uk".to_string(),
reply: Ipv4Addr::new(127, 0, 0, 2),
},
);
let joined: String =
std::iter::from_fn(|| orx.try_recv().ok()).collect::<Vec<_>>().join("\n");
assert!(
joined.contains(
"XLINE: dnsbl@irc.test added a timed Z-line on 2a06:1700:0:12::1, expires in 1 week (on "
),
"xline notice: {joined}"
);
assert!(
joined.contains("detected as being on the 'torexit.dan.me.uk' DNSBL: Tor exit node"),
"dnsbl notice: {joined}"
);
assert!(joined.contains("search/2a06:1700:0:12::1 for more information."), "%ip% substituted: {joined}");
assert!(!joined.contains("%ip%"), "no literal %ip% left: {joined}");
}
#[test]
fn join_broadcasts_and_tracks_membership() {
let mut s = srv();

View file

@ -83,6 +83,23 @@ pub fn parse_duration(s: &str) -> Option<u64> {
Some(n.saturating_mul(mul))
}
/// Render a duration (seconds) as a human phrase — `1 week`, `1 day 2 hours`,
/// `30 minutes` — largest non-zero units first. Used in the XLINE server notice.
pub fn human_duration(mut secs: u64) -> String {
if secs == 0 {
return "0 seconds".to_string();
}
let mut parts = Vec::new();
for (size, label) in [(604800, "week"), (86400, "day"), (3600, "hour"), (60, "minute"), (1, "second")] {
let n = secs / size;
if n > 0 {
parts.push(format!("{n} {label}{}", if n == 1 { "" } else { "s" }));
secs -= n * size;
}
}
parts.join(" ")
}
impl Server {
/// Whether an active x-line of `kind` matches this `user@host` / `ip`.
fn xmatch(&self, kind: XKind, uh: &str, ip: &str) -> bool {
@ -254,17 +271,25 @@ impl Server {
) {
let n = now();
self.xlines.retain(|x| !(x.kind == kind && x.mask == mask));
let expires = if duration == 0 { 0 } else { n.saturating_add(duration) };
self.xlines.push(XLine {
kind,
mask: mask.to_string(),
reason: reason.to_string(),
setter: setter.to_string(),
expires: if duration == 0 { 0 } else { n.saturating_add(duration) },
expires,
});
self.snotice_c('x', &format!(
"{setter} added a {}-line on {mask}: {reason}",
kind.tag()
));
let detail = if duration == 0 {
format!("permanent {}-line on {mask}", kind.tag())
} else {
format!(
"timed {}-line on {mask}, expires in {} (on {})",
kind.tag(),
human_duration(duration),
crate::server::long_date(expires)
)
};
self.snotice_c('x', &format!("XLINE: {setter} added a {detail}: {reason}"));
self.save_xlines();
self.enforce_xlines();
}
@ -384,4 +409,14 @@ mod tests {
let _ = parse_duration(&s);
}
}
#[test]
fn human_duration_reads_naturally() {
assert_eq!(human_duration(604800), "1 week");
assert_eq!(human_duration(86400), "1 day");
assert_eq!(human_duration(2 * 604800), "2 weeks");
assert_eq!(human_duration(90061), "1 day 1 hour 1 minute 1 second");
assert_eq!(human_duration(3600), "1 hour");
assert_eq!(human_duration(0), "0 seconds");
}
}