dnsbl: per-zone name/action/duration/reason with %ip%; XLINE notice shows duration + absolute expiry
This commit is contained in:
parent
1824af5d90
commit
b693c5e5df
6 changed files with 309 additions and 37 deletions
|
|
@ -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:<score>`
|
- **Reputation** — every address accrues a score over time; the `y:<score>`
|
||||||
extban bans by it (`+b y:<100`).
|
extban bans by it (`+b y:<100`).
|
||||||
- **DNSBL** — check connecting IPs against DNS blocklists (`mark` / `kill` /
|
- **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
|
- **X-lines** — persistent `K` / `G` / `Z` / `Q` / `CBAN` / `RLINE` bans (see
|
||||||
[operators](operators.md)).
|
[operators](operators.md)).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,17 @@ use_resolved_host = on
|
||||||
# for multiple zones. On a listing, `dnsbl_action` decides what happens:
|
# for multiple zones. On a listing, `dnsbl_action` decides what happens:
|
||||||
# mark = just show the "*** ... LISTED" notice, let them in (default, safe)
|
# mark = just show the "*** ... LISTED" notice, let them in (default, safe)
|
||||||
# kill = disconnect them (no persistent ban)
|
# 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)
|
# (leave commented to disable DNSBL entirely)
|
||||||
# dnsbl = dnsbl.dronebl.org
|
# dnsbl = dnsbl.dronebl.org
|
||||||
# dnsbl = rbl.efnetrbl.org
|
# dnsbl = rbl.efnetrbl.org
|
||||||
# dnsbl_action = mark
|
# dnsbl_action = mark
|
||||||
# dnsbl_reason = Your host is listed in a DNS blocklist
|
# 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.
|
# antimixedutf8 — block spam that mixes look-alike scripts within words.
|
||||||
# action = block | kill | gline | kline | zline ; target = both | channel | private
|
# action = block | kill | gline | kline | zline ; target = both | channel | private
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ pub struct Config {
|
||||||
pub amu: AntiMixedCfg, // antimixedutf8 module config
|
pub amu: AntiMixedCfg, // antimixedutf8 module config
|
||||||
pub resolve_hosts: bool, // reverse-DNS clients on connect (default on)
|
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 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_action: String, // mark | kline | gline | zline (on a hit)
|
||||||
pub dnsbl_reason: String, // ban reason for a DNSBL hit
|
pub dnsbl_reason: String, // ban reason for a DNSBL hit
|
||||||
pub sasl_server: String, // linked services server that handles SASL ("" = none)
|
pub sasl_server: String, // linked services server that handles SASL ("" = none)
|
||||||
|
|
@ -301,8 +301,8 @@ impl Config {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"dnsbl" | "dnsbl_zone" => {
|
"dnsbl" | "dnsbl_zone" => {
|
||||||
if !v.is_empty() {
|
if let Some(z) = crate::modules::dnsbl::parse_zone(v) {
|
||||||
c.dnsbl_zones.push(v.to_string());
|
c.dnsbl_zones.push(z);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"dnsbl_action" => c.dnsbl_action = v.to_ascii_lowercase(),
|
"dnsbl_action" => c.dnsbl_action = v.to_ascii_lowercase(),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,89 @@ use crate::server::Server;
|
||||||
use crate::xline::XKind;
|
use crate::xline::XKind;
|
||||||
use crate::Uid;
|
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.
|
/// 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
|
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");
|
||||||
s.notice_star(uid, "Checking for DNSBL done, no hit.");
|
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, "Checking for DNSBL");
|
||||||
s.notice_star(
|
s.notice_star(uid, "Checking for DNSBL done.");
|
||||||
uid,
|
act(s, uid, &zone);
|
||||||
&format!("Checking for DNSBL done — LISTED on {zone} ({reply})."),
|
|
||||||
);
|
|
||||||
act(s, uid, &zone, reply);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Act on a hit per `dnsbl_action`: `mark` just informs; the `*line` actions add a
|
/// Act on a hit against blocklist `domain` per its (or the global) action: `mark`
|
||||||
/// temporary ban and close; `kill` closes without a persistent ban.
|
/// just informs; the `*line` actions add a ban and close; `kill` closes without a
|
||||||
fn act(s: &mut Server, uid: Uid, zone: &str, reply: Ipv4Addr) {
|
/// 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) {
|
let (mask, ip) = match s.users.get(&uid) {
|
||||||
Some(u) => (u.prefix(), u.addr.ip()),
|
Some(u) => (u.prefix(), u.addr.ip()),
|
||||||
None => return,
|
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 ipstr = ip.to_string();
|
||||||
let dur = s.conf_num("dnsbl_duration", DNSBL_BAN);
|
// Resolve this hit's per-zone settings, each falling back to the global default.
|
||||||
match action.as_str() {
|
let zone = s
|
||||||
"kline" => s.add_xline(XKind::Kline, &format!("*@{ipstr}"), dur, "dnsbl", &reason),
|
.dnsbl_zones
|
||||||
"gline" => s.add_xline(XKind::Gline, &format!("*@{ipstr}"), dur, "dnsbl", &reason),
|
.iter()
|
||||||
"zline" => s.add_xline(XKind::Zline, &ipstr, dur, "dnsbl", &reason),
|
.find(|z| z.domain.eq_ignore_ascii_case(domain))
|
||||||
"kill" | "reject" => {}
|
.cloned();
|
||||||
_ => return, // "mark" or unknown: notify only, don't disconnect
|
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
|
// pre-registration users aren't caught by add_xline's enforce sweep, so close
|
||||||
// this connection explicitly (the ERROR flushes before the socket).
|
// this connection explicitly (the ERROR flushes before the socket).
|
||||||
s.send(uid, format!("ERROR :Closing link: ({reason})"));
|
s.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||||
s.remove_user(uid, &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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
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
|
/// 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.
|
/// seconds — the inverse of [`iso_time`], for CHATHISTORY `timestamp=` selectors.
|
||||||
pub fn parse_iso(s: &str) -> Option<u64> {
|
pub fn parse_iso(s: &str) -> Option<u64> {
|
||||||
|
|
@ -153,7 +181,7 @@ pub struct Server {
|
||||||
pub amu: crate::config::AntiMixedCfg, // antimixedutf8 module config
|
pub amu: crate::config::AntiMixedCfg, // antimixedutf8 module config
|
||||||
pub resolve_hosts: bool, // reverse-DNS clients on connect
|
pub resolve_hosts: bool, // reverse-DNS clients on connect
|
||||||
pub use_resolved_host: bool, // apply the resolved name to the hostmask
|
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_action: String, // mark | kline | gline | zline
|
||||||
pub dnsbl_reason: String, // ban reason on a DNSBL hit
|
pub dnsbl_reason: String, // ban reason on a DNSBL hit
|
||||||
pub sasl_server: String, // services server that handles SASL
|
pub sasl_server: String, // services server that handles SASL
|
||||||
|
|
@ -439,7 +467,9 @@ impl Server {
|
||||||
crate::modules::ident::dispatch(self, uid);
|
crate::modules::ident::dispatch(self, uid);
|
||||||
// a connection class may opt out of reverse-DNS (resolvehostnames=no)
|
// 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 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 {
|
if do_rdns {
|
||||||
self.notice_star(uid, "Looking up your hostname...");
|
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
|
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]
|
#[test]
|
||||||
fn join_broadcasts_and_tracks_membership() {
|
fn join_broadcasts_and_tracks_membership() {
|
||||||
let mut s = srv();
|
let mut s = srv();
|
||||||
|
|
|
||||||
45
src/xline.rs
45
src/xline.rs
|
|
@ -83,6 +83,23 @@ pub fn parse_duration(s: &str) -> Option<u64> {
|
||||||
Some(n.saturating_mul(mul))
|
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 {
|
impl Server {
|
||||||
/// Whether an active x-line of `kind` matches this `user@host` / `ip`.
|
/// Whether an active x-line of `kind` matches this `user@host` / `ip`.
|
||||||
fn xmatch(&self, kind: XKind, uh: &str, ip: &str) -> bool {
|
fn xmatch(&self, kind: XKind, uh: &str, ip: &str) -> bool {
|
||||||
|
|
@ -254,17 +271,25 @@ impl Server {
|
||||||
) {
|
) {
|
||||||
let n = now();
|
let n = now();
|
||||||
self.xlines.retain(|x| !(x.kind == kind && x.mask == mask));
|
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 {
|
self.xlines.push(XLine {
|
||||||
kind,
|
kind,
|
||||||
mask: mask.to_string(),
|
mask: mask.to_string(),
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
setter: setter.to_string(),
|
setter: setter.to_string(),
|
||||||
expires: if duration == 0 { 0 } else { n.saturating_add(duration) },
|
expires,
|
||||||
});
|
});
|
||||||
self.snotice_c('x', &format!(
|
let detail = if duration == 0 {
|
||||||
"{setter} added a {}-line on {mask}: {reason}",
|
format!("permanent {}-line on {mask}", kind.tag())
|
||||||
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.save_xlines();
|
||||||
self.enforce_xlines();
|
self.enforce_xlines();
|
||||||
}
|
}
|
||||||
|
|
@ -384,4 +409,14 @@ mod tests {
|
||||||
let _ = parse_duration(&s);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue