From 7e0449e2ddb98c7c7905365ec5e91ce5b288026c Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 02:26:02 +0000 Subject: [PATCH] dnsbl on connect (m_dnsbl-style, ipv4+ipv6, own module), cache + parallelize resolver, group connect notices before cap --- echoircd.conf.example | 11 ++ src/config.rs | 13 +++ src/coremods/core_rehash.rs | 3 + src/ircd.rs | 22 +++- src/modules/dnsbl.rs | 110 +++++++++++++++++++ src/modules/mod.rs | 5 +- src/resolver.rs | 213 ++++++++++++++++++++++++++++++++---- src/server.rs | 93 ++++++++++++++-- src/users.rs | 17 +-- 9 files changed, 440 insertions(+), 47 deletions(-) create mode 100644 src/modules/dnsbl.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index 071a62f..dcbd29e 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -39,6 +39,17 @@ resolve_hosts = on # and reports "Found your hostname". Only matters when resolve_hosts = on. use_resolved_host = on +# DNS blocklist (DNSBL) checks on connect, like InspIRCd's m_dnsbl. Repeat `dnsbl` +# 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 +# (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 + # antimixedutf8 — block spam that mixes look-alike scripts within words. # action = block | kill | gline | kline | zline ; target = both | channel | private antimixedutf8 = off diff --git a/src/config.rs b/src/config.rs index 9f00519..101deb0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,6 +71,9 @@ 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_action: String, // mark | kline | gline | zline (on a hit) + pub dnsbl_reason: String, // ban reason for a DNSBL hit } impl Default for Config { @@ -94,6 +97,9 @@ impl Default for Config { amu: AntiMixedCfg::default(), resolve_hosts: true, use_resolved_host: true, + dnsbl_zones: Vec::new(), + dnsbl_action: "mark".to_string(), + dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(), } } } @@ -219,6 +225,13 @@ impl Config { "off" | "false" | "no" | "0" ) } + "dnsbl" | "dnsbl_zone" => { + if !v.is_empty() { + c.dnsbl_zones.push(v.to_string()); + } + } + "dnsbl_action" => c.dnsbl_action = v.to_ascii_lowercase(), + "dnsbl_reason" => c.dnsbl_reason = v.to_string(), _ => {} } } diff --git a/src/coremods/core_rehash.rs b/src/coremods/core_rehash.rs index d1a5ff7..d9bcb32 100644 --- a/src/coremods/core_rehash.rs +++ b/src/coremods/core_rehash.rs @@ -63,6 +63,9 @@ impl Command for Rehash { s.amu = fresh.amu; s.resolve_hosts = fresh.resolve_hosts; s.use_resolved_host = fresh.use_resolved_host; + s.dnsbl_zones = fresh.dnsbl_zones; + s.dnsbl_action = fresh.dnsbl_action; + s.dnsbl_reason = fresh.dnsbl_reason; s.announce("Server configuration reloaded."); s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing")); } diff --git a/src/ircd.rs b/src/ircd.rs index 0706b77..e7740ef 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -34,10 +34,12 @@ pub enum Event { Disconnect { uid: Uid, }, - /// A client's reverse-DNS lookup finished (`None` = no confirmed hostname). + /// A client's connect-time DNS work finished: the reverse-DNS hostname + /// (`None` = none confirmed) and the DNSBL outcome. ResolvedHost { uid: Uid, host: Option, + dnsbl: crate::modules::dnsbl::Outcome, }, /// Background timer tick — drives ping/idle timeouts. Tick, @@ -93,8 +95,16 @@ impl Ircd { self.quit_user(uid, "Connection closed"); } } - Event::ResolvedHost { uid, host } => { - self.server.on_resolved(uid, host); + Event::ResolvedHost { uid, host, dnsbl } => { + self.server.on_resolved(uid, host, dnsbl); + // now that the notice block has printed, replay the handshake + // lines we held while resolving + for line in self.server.take_deferred(uid) { + if !self.server.users.contains_key(&uid) { + break; // a replayed QUIT/ban already dropped them + } + self.on_line(uid, &line); + } self.try_register(uid); // DNS may have been the last thing we waited on } Event::Tick => self.on_tick(), @@ -122,6 +132,12 @@ impl Ircd { .map(|u| u.registered) .unwrap_or(false); + // Hold the handshake while the connect-time DNS/DNSBL lookups run, so the + // "*** ..." notices print as one block; replayed in Event::ResolvedHost. + if !registered && self.server.defer_if_resolving(uid, line) { + return; + } + // module pre-command gate for m in &mut self.modules { if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny { diff --git a/src/modules/dnsbl.rs b/src/modules/dnsbl.rs new file mode 100644 index 0000000..75d1dc6 --- /dev/null +++ b/src/modules/dnsbl.rs @@ -0,0 +1,110 @@ +//! DNSBL — DNS blocklist checks on connect, InspIRCd `m_dnsbl` style. On connect +//! the resolver thread reverses the client's IP under each configured blocklist +//! zone and A-looks it up (see [`crate::resolver`]); a listing triggers the +//! configured action. Works for IPv4 **and** IPv6 (v4 reversed octets or v6 +//! reversed nibbles under the zone) — a v4-only blocklist simply NXDOMAINs a v6 +//! query, which reads as "not listed". +//! +//! Actions (`dnsbl_action`): `mark` just shows the notice and lets them in +//! (default, safe), `kill` disconnects, `kline`/`gline`/`zline` add a 1-day ban +//! and disconnect. This isn't a hook `Module` — it's driven from the connection +//! lifecycle (`Server::add_conn` → `on_resolved`) — but it lives here as its own +//! self-contained unit. + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use crate::resolver; +use crate::server::Server; +use crate::xline::XKind; +use crate::Uid; + +/// Ban length applied by the `*line` actions on a hit. +const DNSBL_BAN: u64 = 86_400; // 1 day + +/// Outcome of a DNSBL check for one connecting client. +pub enum Outcome { + /// No blocklists configured — the check didn't run. + Skipped, + /// Checked against every zone; the address is not listed. + Clean, + /// Listed: `zone` returned `reply` (`127.0.0.x`, last octet = reason code). + Hit { zone: String, reply: Ipv4Addr }, +} + +/// Check `ip` against every blocklist `zone`; the first listing wins. Runs off the +/// core thread (called from the resolver worker), so it may block on DNS. +pub fn check(ip: IpAddr, zones: &[String], timeout: Duration) -> Outcome { + if zones.is_empty() { + return Outcome::Skipped; + } + for zone in zones { + let z = zone.trim().trim_end_matches('.'); + let qname = format!("{}.{z}", resolver::reverse_labels(ip)); + if let Some(reply) = resolver::a_lookup(&qname, timeout) { + return Outcome::Hit { + zone: zone.clone(), + reply, + }; + } + } + Outcome::Clean +} + +/// Emit the DNSBL notices for `outcome` and, on a hit, take the configured action. +/// Called from `Server::on_resolved` on the core thread. +pub fn report(s: &mut Server, uid: Uid, outcome: Outcome) { + match outcome { + Outcome::Skipped => {} + Outcome::Clean => { + s.notice_star(uid, "Checking for DNSBL"); + s.notice_star(uid, "Checking for DNSBL done, no hit."); + } + 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); + } + } +} + +/// 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) { + 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(&format!( + "DNSBL: {mask} is listed on {zone} ({reply}); action={action}" + )); + let reason = format!("{} (listed on {zone})", s.dnsbl_reason); + let ipstr = ip.to_string(); + match action.as_str() { + "kline" => s.add_xline( + XKind::Kline, + &format!("*@{ipstr}"), + DNSBL_BAN, + "dnsbl", + &reason, + ), + "gline" => s.add_xline( + XKind::Gline, + &format!("*@{ipstr}"), + DNSBL_BAN, + "dnsbl", + &reason, + ), + "zline" => s.add_xline(XKind::Zline, &ipstr, DNSBL_BAN, "dnsbl", &reason), + "kill" | "reject" => {} + _ => return, // "mark" or unknown: notify only, don't disconnect + } + // 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/modules/mod.rs b/src/modules/mod.rs index da044e4..89a01b5 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,8 +1,11 @@ //! Optional, pluggable modules — echoIRCd's answer to InspIRCd's `src/modules/`. -//! Each hooks lifecycle events via the [`crate::module::Module`] trait. +//! Most hook lifecycle events via the [`crate::module::Module`] trait; [`dnsbl`] +//! is the exception — it's driven straight from the connection lifecycle rather +//! than the hook bus, but lives here as its own self-contained unit. pub mod antimixedutf8; pub mod cloak; +pub mod dnsbl; pub mod flood; pub mod snoop; diff --git a/src/resolver.rs b/src/resolver.rs index afa520f..470ac55 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -1,14 +1,21 @@ -//! Reverse-DNS host resolution — echoIRCd's answer to InspIRCd's async resolver, -//! done from scratch with std UDP (no DNS crate, no `unsafe`). Given a client IP -//! it looks up the PTR record and **forward-confirms** it (the name must resolve -//! back to the same IP, so a client can't fake a hostname — same anti-spoofing -//! InspIRCd does). Best-effort: any failure returns `None` and the caller keeps -//! the IP. It runs off the core thread, so it never blocks the daemon, and it's -//! bounded in time (the UDP read timeout) and in concurrency (`try_acquire`). +//! DNS lookups — echoIRCd's answer to InspIRCd's async resolver + `m_dnsbl`, done +//! from scratch with std UDP (no DNS crate, no `unsafe`). Two things: +//! +//! * **reverse-DNS**: PTR-resolve a client IP and **forward-confirm** it (the name +//! must resolve back to the same IP, so a client can't fake a hostname — the +//! anti-spoofing InspIRCd does); +//! * **DNSBL**: reverse the client's v4 octets under a blocklist zone and A-lookup +//! it (`m_dnsbl` style), reporting the listing reply. +//! +//! Best-effort: any failure returns "not found / clean" and the caller keeps the +//! IP. Runs off the core thread (never blocks the daemon), bounded in time (the UDP +//! read timeout) and in concurrency (`try_acquire`). -use std::net::{IpAddr, ToSocketAddrs, UdpSocket}; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, ToSocketAddrs, UdpSocket}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; /// How long to wait for the DNS server before giving up. pub const DNS_TIMEOUT: Duration = Duration::from_millis(2500); @@ -17,6 +24,40 @@ pub const DNS_TIMEOUT: Duration = Duration::from_millis(2500); const MAX_ACTIVE: usize = 512; static ACTIVE: AtomicUsize = AtomicUsize::new(0); +// --- caches ------------------------------------------------------------------- +// Memoize lookups so reconnects and clients sharing an IP (NAT/CGNAT, bouncers) +// skip the network entirely. These Mutexes are internal to the resolver worker +// threads — the single-threaded core never touches them, so the "no locks in the +// core" rule still holds. Entries store their expiry `Instant`; a bounded map +// (purge-expired, then clear on a pathological unique-IP flood) caps memory. +const CACHE_CAP: usize = 65_536; +const NS_TTL: Duration = Duration::from_secs(30); // re-read resolv.conf at most this often +const RDNS_TTL_HIT: Duration = Duration::from_secs(600); // resolved hostname +const RDNS_TTL_MISS: Duration = Duration::from_secs(60); // no/unconfirmed PTR +const A_TTL_HIT: Duration = Duration::from_secs(300); // A record present (e.g. DNSBL listing) +const A_TTL_MISS: Duration = Duration::from_secs(120); // NXDOMAIN / no A (e.g. not listed) + +/// A lazily-initialised, expiry-tagged lookup cache keyed by `K` holding `V`. +type Cache = OnceLock>>; + +static NS_CACHE: Mutex> = Mutex::new(None); +static RDNS_CACHE: Cache> = OnceLock::new(); +static A_CACHE: Cache> = OnceLock::new(); + +/// Keep a cache map bounded: once it hits the cap, drop expired entries, and if +/// it's *still* full (a flood of distinct fresh IPs), clear it — degrading to +/// no-cache rather than growing without bound. +fn evict_if_full(map: &mut HashMap) { + if map.len() >= CACHE_CAP { + let now = Instant::now(); + map.retain(|_, (_, exp)| *exp > now); + if map.len() >= CACHE_CAP { + map.clear(); + } + } +} + +const QTYPE_A: u16 = 1; const QTYPE_PTR: u16 = 12; const QCLASS_IN: u16 = 1; @@ -37,8 +78,32 @@ pub fn release() { } /// Reverse-resolve `ip` and forward-confirm. `Some(host)` only if a PTR exists -/// and that host resolves back to `ip`. +/// and that host resolves back to `ip`. Cached by IP so reconnects and clients +/// behind the same NAT resolve instantly. pub fn reverse_confirmed(ip: IpAddr, timeout: Duration) -> Option { + let cache = RDNS_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(g) = cache.lock() { + if let Some((val, exp)) = g.get(&ip) { + if Instant::now() < *exp { + return val.clone(); + } + } + } + let val = resolve_reverse(ip, timeout); + let ttl = if val.is_some() { + RDNS_TTL_HIT + } else { + RDNS_TTL_MISS + }; + if let Ok(mut g) = cache.lock() { + evict_if_full(&mut g); + g.insert(ip, (val.clone(), Instant::now() + ttl)); + } + val +} + +/// The actual reverse lookup + forward-confirm (uncached; see `reverse_confirmed`). +fn resolve_reverse(ip: IpAddr, timeout: Duration) -> Option { let ns = nameserver(); let ptr = ptr_lookup(&ns, &reverse_name(ip), timeout)?; // forward-confirm: the resolved name must map back to this IP @@ -49,26 +114,54 @@ pub fn reverse_confirmed(ip: IpAddr, timeout: Duration) -> Option { (ok && !ptr.is_empty()).then_some(ptr) } -/// The `in-addr.arpa` / `ip6.arpa` reverse name for `ip`. -fn reverse_name(ip: IpAddr) -> String { +/// The reversed digit/nibble labels for `ip`, without any suffix — `1.2.3.4` → +/// `4.3.2.1`, `2001:db8::1` → the 32 reversed hex nibbles. PTR appends +/// `.in-addr.arpa` / `.ip6.arpa`; DNSBL appends the blocklist zone. +pub fn reverse_labels(ip: IpAddr) -> String { match ip { IpAddr::V4(a) => { let o = a.octets(); - format!("{}.{}.{}.{}.in-addr.arpa", o[3], o[2], o[1], o[0]) + format!("{}.{}.{}.{}", o[3], o[2], o[1], o[0]) } IpAddr::V6(a) => { - let mut s = String::with_capacity(72); + let mut s = String::with_capacity(64); for octet in a.octets().iter().rev() { s.push_str(&format!("{:x}.{:x}.", octet & 0xf, octet >> 4)); } - s.push_str("ip6.arpa"); + s.pop(); // drop the trailing '.' s } } } -/// First `nameserver` in /etc/resolv.conf, else a sensible fallback. +/// The `in-addr.arpa` / `ip6.arpa` reverse name for `ip` (for PTR lookups). +fn reverse_name(ip: IpAddr) -> String { + let suffix = if ip.is_ipv4() { + "in-addr.arpa" + } else { + "ip6.arpa" + }; + format!("{}.{suffix}", reverse_labels(ip)) +} + +/// First `nameserver` in /etc/resolv.conf, else a sensible fallback — cached for +/// `NS_TTL` so we don't stat+read the file on every single DNS query. fn nameserver() -> String { + if let Ok(mut g) = NS_CACHE.lock() { + if let Some((ns, at)) = g.as_ref() { + if at.elapsed() < NS_TTL { + return ns.clone(); + } + } + let ns = read_nameserver(); + *g = Some((ns.clone(), Instant::now())); + return ns; + } + read_nameserver() +} + +/// Read the first `nameserver` from /etc/resolv.conf (uncached; see `nameserver`). +fn read_nameserver() -> String { if let Ok(text) = std::fs::read_to_string("/etc/resolv.conf") { for line in text.lines() { let line = line.trim(); @@ -83,27 +176,101 @@ fn nameserver() -> String { "1.1.1.1:53".to_string() } -/// Send a PTR query for `qname` to `ns` and return the first PTR answer name. -fn ptr_lookup(ns: &str, qname: &str, timeout: Duration) -> Option { +/// Build a `qtype` query for `qname`, send it to `ns`, and return the raw reply +/// (with the transaction id validated). +fn send_query(ns: &str, qname: &str, qtype: u16, id: u16, timeout: Duration) -> Option> { let sock = UdpSocket::bind("0.0.0.0:0") .or_else(|_| UdpSocket::bind("[::]:0")) .ok()?; sock.set_read_timeout(Some(timeout)).ok()?; - - let id: u16 = 0x4543; // fixed query id ("EC"); we match it on the reply let mut query = Vec::with_capacity(qname.len() + 18); query.extend_from_slice(&id.to_be_bytes()); query.extend_from_slice(&[0x01, 0x00]); // flags: RD=1 query.extend_from_slice(&[0, 1]); // QDCOUNT=1 query.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // AN/NS/AR = 0 encode_name(&mut query, qname); - query.extend_from_slice(&QTYPE_PTR.to_be_bytes()); + query.extend_from_slice(&qtype.to_be_bytes()); query.extend_from_slice(&QCLASS_IN.to_be_bytes()); - sock.send_to(&query, ns).ok()?; let mut buf = [0u8; 1500]; let n = sock.recv(&mut buf).ok()?; - parse_ptr_reply(&buf[..n], id) + if n < 12 || u16::from_be_bytes([buf[0], buf[1]]) != id { + return None; + } + Some(buf[..n].to_vec()) +} + +/// Send a PTR query for `qname` to `ns` and return the first PTR answer name. +fn ptr_lookup(ns: &str, qname: &str, timeout: Duration) -> Option { + let reply = send_query(ns, qname, QTYPE_PTR, 0x4543, timeout)?; + parse_ptr_reply(&reply, 0x4543) +} + +/// Resolve `qname`'s first A record. Generic — the DNSBL module builds a +/// `.` name and calls this to test a listing. Cached by qname +/// so repeat DNSBL checks for the same IP+zone don't re-hit the network. +pub fn a_lookup(qname: &str, timeout: Duration) -> Option { + let cache = A_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(g) = cache.lock() { + if let Some((val, exp)) = g.get(qname) { + if Instant::now() < *exp { + return *val; + } + } + } + let val = send_query(&nameserver(), qname, QTYPE_A, 0x4544, timeout) + .and_then(|reply| parse_a_reply(&reply, 0x4544)); + let ttl = if val.is_some() { A_TTL_HIT } else { A_TTL_MISS }; + if let Ok(mut g) = cache.lock() { + evict_if_full(&mut g); + g.insert(qname.to_string(), (val, Instant::now() + ttl)); + } + val +} + +/// Parse a DNS reply for the first A record (4-byte address). Bounds-checked. +fn parse_a_reply(msg: &[u8], want_id: u16) -> Option { + if msg.len() < 12 || u16::from_be_bytes([msg[0], msg[1]]) != want_id { + return None; + } + if msg[3] & 0x0f != 0 { + return None; // rcode != NOERROR (e.g. NXDOMAIN = not listed) + } + let qd = u16::from_be_bytes([msg[4], msg[5]]); + let an = u16::from_be_bytes([msg[6], msg[7]]); + if an == 0 { + return None; + } + let mut pos = 12; + for _ in 0..qd { + pos = skip_name(msg, pos)?; + pos = pos.checked_add(4)?; + if pos > msg.len() { + return None; + } + } + for _ in 0..an { + pos = skip_name(msg, pos)?; + if pos + 10 > msg.len() { + return None; + } + let rtype = u16::from_be_bytes([msg[pos], msg[pos + 1]]); + let rdlen = u16::from_be_bytes([msg[pos + 8], msg[pos + 9]]) as usize; + let rdata = pos + 10; + if rdata + rdlen > msg.len() { + return None; + } + if rtype == QTYPE_A && rdlen == 4 { + return Some(Ipv4Addr::new( + msg[rdata], + msg[rdata + 1], + msg[rdata + 2], + msg[rdata + 3], + )); + } + pos = rdata + rdlen; + } + None } /// Encode a dotted name into wire format (length-prefixed labels + root 0). diff --git a/src/server.rs b/src/server.rs index 6eeb322..dc0c84f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -17,6 +17,7 @@ use crate::extensible::Extensible; use crate::ircd::Event; use crate::link::{Link, RemoteServer, RemoteUser}; use crate::module::Hook; +use crate::modules::dnsbl; use crate::resolver; use crate::socketengine::OutSink; use crate::users::{Caps, User, UserFlags}; @@ -99,6 +100,9 @@ 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_action: String, // mark | kline | gline | zline + pub dnsbl_reason: String, // ban reason on a DNSBL hit pub event_tx: Sender, // self-inject events (DNS results) } @@ -135,6 +139,9 @@ impl Server { amu: cfg.amu, resolve_hosts: cfg.resolve_hosts, use_resolved_host: cfg.use_resolved_host, + dnsbl_zones: cfg.dnsbl_zones, + dnsbl_action: cfg.dnsbl_action, + dnsbl_reason: cfg.dnsbl_reason, event_tx, } } @@ -194,6 +201,7 @@ impl Server { addr, registered: false, dns_pending: false, + deferred: Vec::new(), cap: false, cap_302: false, caps: Caps::default(), @@ -218,19 +226,38 @@ impl Server { // is real (see `resolver`) — its result arrives later as an Event. self.notice_star(uid, "Checking Ident"); self.notice_star(uid, "No Ident response"); - self.notice_star(uid, "Looking up your hostname..."); - if self.resolve_hosts && resolver::try_acquire() { + let do_rdns = self.resolve_hosts; + let zones = self.dnsbl_zones.clone(); // DNSBL runs if any zones are configured + if do_rdns { + self.notice_star(uid, "Looking up your hostname..."); + } + if (do_rdns || !zones.is_empty()) && resolver::try_acquire() { if let Some(u) = self.users.get_mut(&uid) { - u.dns_pending = true; // hold registration until the lookup returns + u.dns_pending = true; // hold registration until the lookups return } let tx = self.event_tx.clone(); thread::spawn(move || { - let host = resolver::reverse_confirmed(ip, resolver::DNS_TIMEOUT); + // rDNS and DNSBL are independent (DNSBL only needs the IP), so run + // them concurrently — the client waits on max(rdns, dnsbl), not the + // sum. Only spin up the extra thread when both are actually needed. + let (host, dnsbl) = if do_rdns && !zones.is_empty() { + let job = + thread::spawn(move || dnsbl::check(ip, &zones, resolver::DNS_TIMEOUT)); + let host = resolver::reverse_confirmed(ip, resolver::DNS_TIMEOUT); + (host, job.join().unwrap_or(dnsbl::Outcome::Skipped)) + } else if do_rdns { + ( + resolver::reverse_confirmed(ip, resolver::DNS_TIMEOUT), + dnsbl::Outcome::Skipped, + ) + } else { + (None, dnsbl::check(ip, &zones, resolver::DNS_TIMEOUT)) + }; resolver::release(); - let _ = tx.send(Event::ResolvedHost { uid, host }); + let _ = tx.send(Event::ResolvedHost { uid, host, dnsbl }); }); - } else { - // resolution off (or too many in flight): keep the IP as the host + } else if do_rdns { + // wanted rDNS but couldn't start (too many in flight): keep the IP self.notice_star( uid, "Couldn't look up your hostname; using your IP address instead", @@ -239,20 +266,47 @@ impl Server { } /// A pre-registration `:server NOTICE * :*** ` line. - fn notice_star(&self, uid: Uid, msg: &str) { + pub(crate) fn notice_star(&self, uid: Uid, msg: &str) { self.send(uid, format!(":{} NOTICE * :*** {msg}", self.name)); } + /// While a client's connect-time DNS/DNSBL lookups are still running, hold its + /// handshake lines instead of processing them, so the "*** ..." notices print + /// as one contiguous block rather than interleaving with the CAP/NICK replies. + /// Returns true if `line` was buffered. Bounded — past the cap we let lines + /// through (degrading to interleaved output rather than dropping input). + pub fn defer_if_resolving(&mut self, uid: Uid, line: &str) -> bool { + const MAX_DEFERRED: usize = 32; + match self.users.get_mut(&uid) { + Some(u) if u.dns_pending && u.deferred.len() < MAX_DEFERRED => { + u.deferred.push(line.to_string()); + true + } + _ => false, + } + } + + /// Take and clear the handshake lines held while `uid`'s lookups ran, to replay + /// once the notice block has printed. + pub fn take_deferred(&mut self, uid: Uid) -> Vec { + self.users + .get_mut(&uid) + .map(|u| std::mem::take(&mut u.deferred)) + .unwrap_or_default() + } + /// A client's reverse-DNS lookup finished. Set the resolved host (so WHOIS, /// bans and cloaking use the hostname, not the IP), tell the client, and clear /// the flag that was holding their registration. - pub fn on_resolved(&mut self, uid: Uid, host: Option) { + pub fn on_resolved(&mut self, uid: Uid, host: Option, outcome: dnsbl::Outcome) { + // hostname result (only announced if we actually attempted the lookup) match &host { Some(h) => self.notice_star(uid, &format!("Found your hostname ({h})")), - None => self.notice_star( + None if self.resolve_hosts => self.notice_star( uid, "Couldn't look up your hostname; using your IP address instead", ), + None => {} } let apply = self.use_resolved_host; if let Some(u) = self.users.get_mut(&uid) { @@ -263,6 +317,12 @@ impl Server { u.host = h; } } + } + // DNSBL notices + action (InspIRCd m_dnsbl style) — see `modules::dnsbl`. + // May close the connection if the zone is listed and the action bans. + dnsbl::report(self, uid, outcome); + // release the registration hold (no-op if a DNSBL ban already removed them) + if let Some(u) = self.users.get_mut(&uid) { u.dns_pending = false; } } @@ -561,6 +621,7 @@ mod tests { addr: "127.0.0.1:1".parse().unwrap(), registered: true, dns_pending: false, + deferred: Vec::new(), cap: false, cap_302: false, caps: Caps::default(), @@ -592,13 +653,21 @@ mod tests { fn resolved_host_applied_only_when_configured() { let mut s = srv(); // use_resolved_host = true (default) let _a = add_user(&mut s, 1, "ann"); // host starts "localhost" - s.on_resolved(1, Some("host.example.net".to_string())); + s.on_resolved( + 1, + Some("host.example.net".to_string()), + dnsbl::Outcome::Skipped, + ); assert_eq!(s.users[&1].host, "host.example.net"); assert!(!s.users[&1].dns_pending); s.use_resolved_host = false; // resolve + report, but keep the IP in the mask let _b = add_user(&mut s, 2, "bob"); - s.on_resolved(2, Some("host.example.net".to_string())); + s.on_resolved( + 2, + Some("host.example.net".to_string()), + dnsbl::Outcome::Skipped, + ); assert_eq!(s.users[&2].host, "localhost"); assert!(!s.users[&2].dns_pending); // registration still un-held either way } diff --git a/src/users.rs b/src/users.rs index 0ca5ab3..2a8c441 100644 --- a/src/users.rs +++ b/src/users.rs @@ -205,16 +205,17 @@ pub struct User { pub signon: u64, // unix secs at registration (WHOIS 317) pub addr: SocketAddr, pub registered: bool, - pub dns_pending: bool, // holding registration for a reverse-DNS lookup - pub cap: bool, // CAP negotiation in progress (holds registration) - pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware) - pub caps: Caps, // enabled IRCv3 capabilities + pub dns_pending: bool, // holding registration for a reverse-DNS lookup + pub deferred: Vec, // handshake lines held while dns_pending (replayed after) + pub cap: bool, // CAP negotiation in progress (holds registration) + pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware) + pub caps: Caps, // enabled IRCv3 capabilities pub sasl_mech: Option, // SASL mechanism chosen, mid-handshake pub channels: HashSet, // lowercased channel keys - pub watch: Vec, // WATCH list — lowercased nicks - pub monitor: Vec, // MONITOR list — lowercased nicks - pub silence: Vec, // SILENCE masks — nick!user@host globs - pub accept: Vec, // ACCEPT list — lowercased nicks (callerid +g) + pub watch: Vec, // WATCH list — lowercased nicks + pub monitor: Vec, // MONITOR list — lowercased nicks + pub silence: Vec, // SILENCE masks — nick!user@host globs + pub accept: Vec, // ACCEPT list — lowercased nicks (callerid +g) pub quitting: Option, // set by QUIT; drained by the core pub flags: UserFlags, pub last_active: u64, // unix secs of the last line we received