From b693c5e5df19539470ea235439d2a4ef8e7a2748 Mon Sep 17 00:00:00 2001 From: reverse Date: Fri, 21 Aug 2026 14:14:17 +0000 Subject: [PATCH] dnsbl: per-zone name/action/duration/reason with %ip%; XLINE notice shows duration + absolute expiry --- docs/anti-abuse.md | 4 +- echoircd.conf.example | 7 +- src/config.rs | 6 +- src/modules/dnsbl.rs | 193 ++++++++++++++++++++++++++++++++++++------ src/server.rs | 91 +++++++++++++++++++- src/xline.rs | 45 ++++++++-- 6 files changed, 309 insertions(+), 37 deletions(-) diff --git a/docs/anti-abuse.md b/docs/anti-abuse.md index 9b44703..e1ea9c5 100644 --- a/docs/anti-abuse.md +++ b/docs/anti-abuse.md @@ -61,7 +61,9 @@ Rejected before any per-connection state is allocated — the cheapest point: - **Reputation** — every address accrues a score over time; the `y:` extban bans by it (`+b y:<100`). - **DNSBL** — check connecting IPs against DNS blocklists (`mark` / `kill` / - `kline` / `gline` / `zline`). + `kline` / `gline` / `zline`). Each `dnsbl` zone may carry its own + `name` / `action` / `duration` / `reason` (the reason supports `%ip%`), or fall + back to the global defaults. - **X-lines** — persistent `K` / `G` / `Z` / `Q` / `CBAN` / `RLINE` bans (see [operators](operators.md)). diff --git a/echoircd.conf.example b/echoircd.conf.example index d360c86..125e5d6 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -109,12 +109,17 @@ use_resolved_host = on # for multiple zones. On a listing, `dnsbl_action` decides what happens: # mark = just show the "*** ... LISTED" notice, let them in (default, safe) # kill = disconnect them (no persistent ban) -# kline / gline / zline = add a 1-day ban and disconnect +# kline / gline / zline = add a ban (dnsbl_duration) and disconnect # (leave commented to disable DNSBL entirely) # dnsbl = dnsbl.dronebl.org # dnsbl = rbl.efnetrbl.org # dnsbl_action = mark # dnsbl_reason = Your host is listed in a DNS blocklist +# +# Per-blocklist form: attributes on one line override the globals above (unset +# ones fall back to them). name= is the label in the DNSBL notice; reason= is the +# ban reason and may contain %ip% (the client address). Values may be "quoted". +# dnsbl = 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." # antimixedutf8 — block spam that mixes look-alike scripts within words. # action = block | kill | gline | kline | zline ; target = both | channel | private diff --git a/src/config.rs b/src/config.rs index bd231ed..78b585a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, // DNS blocklist zones to check on connect + pub dnsbl_zones: Vec, // 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(), diff --git a/src/modules/dnsbl.rs b/src/modules/dnsbl.rs index fa91622..e9a9feb 100644 --- a/src/modules/dnsbl.rs +++ b/src/modules/dnsbl.rs @@ -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, // per-zone action override (mark/kill/kline/gline/zline) + pub duration: Option, // per-zone ban duration override (seconds) + pub reason: Option, // 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 { + 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 = 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); } diff --git a/src/server.rs b/src/server.rs index fbb4fa8..255ad29 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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 { @@ -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, // DNS blocklist zones checked on connect + pub dnsbl_zones: Vec, // 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 = 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::>().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(); diff --git a/src/xline.rs b/src/xline.rs index 555fb0d..a65dd3d 100644 --- a/src/xline.rs +++ b/src/xline.rs @@ -83,6 +83,23 @@ pub fn parse_duration(s: &str) -> Option { 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"); + } }