dnsbl on connect (m_dnsbl-style, ipv4+ipv6, own module), cache + parallelize resolver, group connect notices before cap

This commit is contained in:
Jean Chevronnet 2026-08-08 02:26:02 +00:00
parent 296d87726a
commit e736188378
9 changed files with 440 additions and 47 deletions

View file

@ -39,6 +39,17 @@ resolve_hosts = on
# and reports "Found your hostname". Only matters when resolve_hosts = on. # and reports "Found your hostname". Only matters when resolve_hosts = on.
use_resolved_host = 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. # 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
antimixedutf8 = off antimixedutf8 = off

View file

@ -71,6 +71,9 @@ 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_action: String, // mark | kline | gline | zline (on a hit)
pub dnsbl_reason: String, // ban reason for a DNSBL hit
} }
impl Default for Config { impl Default for Config {
@ -94,6 +97,9 @@ impl Default for Config {
amu: AntiMixedCfg::default(), amu: AntiMixedCfg::default(),
resolve_hosts: true, resolve_hosts: true,
use_resolved_host: 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" "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(),
_ => {} _ => {}
} }
} }

View file

@ -63,6 +63,9 @@ impl Command for Rehash {
s.amu = fresh.amu; s.amu = fresh.amu;
s.resolve_hosts = fresh.resolve_hosts; s.resolve_hosts = fresh.resolve_hosts;
s.use_resolved_host = fresh.use_resolved_host; 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.announce("Server configuration reloaded.");
s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing")); s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing"));
} }

View file

@ -34,10 +34,12 @@ pub enum Event {
Disconnect { Disconnect {
uid: Uid, 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 { ResolvedHost {
uid: Uid, uid: Uid,
host: Option<String>, host: Option<String>,
dnsbl: crate::modules::dnsbl::Outcome,
}, },
/// Background timer tick — drives ping/idle timeouts. /// Background timer tick — drives ping/idle timeouts.
Tick, Tick,
@ -93,8 +95,16 @@ impl Ircd {
self.quit_user(uid, "Connection closed"); self.quit_user(uid, "Connection closed");
} }
} }
Event::ResolvedHost { uid, host } => { Event::ResolvedHost { uid, host, dnsbl } => {
self.server.on_resolved(uid, host); 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 self.try_register(uid); // DNS may have been the last thing we waited on
} }
Event::Tick => self.on_tick(), Event::Tick => self.on_tick(),
@ -122,6 +132,12 @@ impl Ircd {
.map(|u| u.registered) .map(|u| u.registered)
.unwrap_or(false); .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 // module pre-command gate
for m in &mut self.modules { for m in &mut self.modules {
if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny { if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny {

110
src/modules/dnsbl.rs Normal file
View file

@ -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);
}

View file

@ -1,8 +1,11 @@
//! Optional, pluggable modules — echoIRCd's answer to InspIRCd's `src/modules/`. //! 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 antimixedutf8;
pub mod cloak; pub mod cloak;
pub mod dnsbl;
pub mod flood; pub mod flood;
pub mod snoop; pub mod snoop;

View file

@ -1,14 +1,21 @@
//! Reverse-DNS host resolution — echoIRCd's answer to InspIRCd's async resolver, //! DNS lookups — echoIRCd's answer to InspIRCd's async resolver + `m_dnsbl`, done
//! done from scratch with std UDP (no DNS crate, no `unsafe`). Given a client IP //! from scratch with std UDP (no DNS crate, no `unsafe`). Two things:
//! 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 //! * **reverse-DNS**: PTR-resolve a client IP and **forward-confirm** it (the name
//! InspIRCd does). Best-effort: any failure returns `None` and the caller keeps //! must resolve back to the same IP, so a client can't fake a hostname — the
//! the IP. It runs off the core thread, so it never blocks the daemon, and it's //! anti-spoofing InspIRCd does);
//! bounded in time (the UDP read timeout) and in concurrency (`try_acquire`). //! * **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::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. /// How long to wait for the DNS server before giving up.
pub const DNS_TIMEOUT: Duration = Duration::from_millis(2500); 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; const MAX_ACTIVE: usize = 512;
static ACTIVE: AtomicUsize = AtomicUsize::new(0); 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<K, V> = OnceLock<Mutex<HashMap<K, (V, Instant)>>>;
static NS_CACHE: Mutex<Option<(String, Instant)>> = Mutex::new(None);
static RDNS_CACHE: Cache<IpAddr, Option<String>> = OnceLock::new();
static A_CACHE: Cache<String, Option<Ipv4Addr>> = 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<K: Eq + std::hash::Hash, V>(map: &mut HashMap<K, (V, Instant)>) {
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 QTYPE_PTR: u16 = 12;
const QCLASS_IN: u16 = 1; 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 /// 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<String> { pub fn reverse_confirmed(ip: IpAddr, timeout: Duration) -> Option<String> {
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<String> {
let ns = nameserver(); let ns = nameserver();
let ptr = ptr_lookup(&ns, &reverse_name(ip), timeout)?; let ptr = ptr_lookup(&ns, &reverse_name(ip), timeout)?;
// forward-confirm: the resolved name must map back to this IP // forward-confirm: the resolved name must map back to this IP
@ -49,26 +114,54 @@ pub fn reverse_confirmed(ip: IpAddr, timeout: Duration) -> Option<String> {
(ok && !ptr.is_empty()).then_some(ptr) (ok && !ptr.is_empty()).then_some(ptr)
} }
/// The `in-addr.arpa` / `ip6.arpa` reverse name for `ip`. /// The reversed digit/nibble labels for `ip`, without any suffix — `1.2.3.4` →
fn reverse_name(ip: IpAddr) -> String { /// `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 { match ip {
IpAddr::V4(a) => { IpAddr::V4(a) => {
let o = a.octets(); 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) => { IpAddr::V6(a) => {
let mut s = String::with_capacity(72); let mut s = String::with_capacity(64);
for octet in a.octets().iter().rev() { for octet in a.octets().iter().rev() {
s.push_str(&format!("{:x}.{:x}.", octet & 0xf, octet >> 4)); s.push_str(&format!("{:x}.{:x}.", octet & 0xf, octet >> 4));
} }
s.push_str("ip6.arpa"); s.pop(); // drop the trailing '.'
s 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 { 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") { if let Ok(text) = std::fs::read_to_string("/etc/resolv.conf") {
for line in text.lines() { for line in text.lines() {
let line = line.trim(); let line = line.trim();
@ -83,27 +176,101 @@ fn nameserver() -> String {
"1.1.1.1:53".to_string() "1.1.1.1:53".to_string()
} }
/// Send a PTR query for `qname` to `ns` and return the first PTR answer name. /// Build a `qtype` query for `qname`, send it to `ns`, and return the raw reply
fn ptr_lookup(ns: &str, qname: &str, timeout: Duration) -> Option<String> { /// (with the transaction id validated).
fn send_query(ns: &str, qname: &str, qtype: u16, id: u16, timeout: Duration) -> Option<Vec<u8>> {
let sock = UdpSocket::bind("0.0.0.0:0") let sock = UdpSocket::bind("0.0.0.0:0")
.or_else(|_| UdpSocket::bind("[::]:0")) .or_else(|_| UdpSocket::bind("[::]:0"))
.ok()?; .ok()?;
sock.set_read_timeout(Some(timeout)).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); let mut query = Vec::with_capacity(qname.len() + 18);
query.extend_from_slice(&id.to_be_bytes()); query.extend_from_slice(&id.to_be_bytes());
query.extend_from_slice(&[0x01, 0x00]); // flags: RD=1 query.extend_from_slice(&[0x01, 0x00]); // flags: RD=1
query.extend_from_slice(&[0, 1]); // QDCOUNT=1 query.extend_from_slice(&[0, 1]); // QDCOUNT=1
query.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // AN/NS/AR = 0 query.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // AN/NS/AR = 0
encode_name(&mut query, qname); 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()); query.extend_from_slice(&QCLASS_IN.to_be_bytes());
sock.send_to(&query, ns).ok()?; sock.send_to(&query, ns).ok()?;
let mut buf = [0u8; 1500]; let mut buf = [0u8; 1500];
let n = sock.recv(&mut buf).ok()?; 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<String> {
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
/// `<reversed-ip>.<zone>` 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<Ipv4Addr> {
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<Ipv4Addr> {
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). /// Encode a dotted name into wire format (length-prefixed labels + root 0).

View file

@ -17,6 +17,7 @@ use crate::extensible::Extensible;
use crate::ircd::Event; use crate::ircd::Event;
use crate::link::{Link, RemoteServer, RemoteUser}; use crate::link::{Link, RemoteServer, RemoteUser};
use crate::module::Hook; use crate::module::Hook;
use crate::modules::dnsbl;
use crate::resolver; use crate::resolver;
use crate::socketengine::OutSink; use crate::socketengine::OutSink;
use crate::users::{Caps, User, UserFlags}; use crate::users::{Caps, User, UserFlags};
@ -99,6 +100,9 @@ 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_action: String, // mark | kline | gline | zline
pub dnsbl_reason: String, // ban reason on a DNSBL hit
pub event_tx: Sender<Event>, // self-inject events (DNS results) pub event_tx: Sender<Event>, // self-inject events (DNS results)
} }
@ -135,6 +139,9 @@ impl Server {
amu: cfg.amu, amu: cfg.amu,
resolve_hosts: cfg.resolve_hosts, resolve_hosts: cfg.resolve_hosts,
use_resolved_host: cfg.use_resolved_host, use_resolved_host: cfg.use_resolved_host,
dnsbl_zones: cfg.dnsbl_zones,
dnsbl_action: cfg.dnsbl_action,
dnsbl_reason: cfg.dnsbl_reason,
event_tx, event_tx,
} }
} }
@ -194,6 +201,7 @@ impl Server {
addr, addr,
registered: false, registered: false,
dns_pending: false, dns_pending: false,
deferred: Vec::new(),
cap: false, cap: false,
cap_302: false, cap_302: false,
caps: Caps::default(), caps: Caps::default(),
@ -218,19 +226,38 @@ impl Server {
// is real (see `resolver`) — its result arrives later as an Event. // is real (see `resolver`) — its result arrives later as an Event.
self.notice_star(uid, "Checking Ident"); self.notice_star(uid, "Checking Ident");
self.notice_star(uid, "No Ident response"); self.notice_star(uid, "No Ident response");
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..."); self.notice_star(uid, "Looking up your hostname...");
if self.resolve_hosts && resolver::try_acquire() { }
if (do_rdns || !zones.is_empty()) && resolver::try_acquire() {
if let Some(u) = self.users.get_mut(&uid) { 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(); let tx = self.event_tx.clone();
thread::spawn(move || { thread::spawn(move || {
// 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); let host = resolver::reverse_confirmed(ip, resolver::DNS_TIMEOUT);
resolver::release(); (host, job.join().unwrap_or(dnsbl::Outcome::Skipped))
let _ = tx.send(Event::ResolvedHost { uid, host }); } else if do_rdns {
}); (
resolver::reverse_confirmed(ip, resolver::DNS_TIMEOUT),
dnsbl::Outcome::Skipped,
)
} else { } else {
// resolution off (or too many in flight): keep the IP as the host (None, dnsbl::check(ip, &zones, resolver::DNS_TIMEOUT))
};
resolver::release();
let _ = tx.send(Event::ResolvedHost { uid, host, dnsbl });
});
} else if do_rdns {
// wanted rDNS but couldn't start (too many in flight): keep the IP
self.notice_star( self.notice_star(
uid, uid,
"Couldn't look up your hostname; using your IP address instead", "Couldn't look up your hostname; using your IP address instead",
@ -239,20 +266,47 @@ impl Server {
} }
/// A pre-registration `:server NOTICE * :*** <msg>` line. /// A pre-registration `:server NOTICE * :*** <msg>` 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)); 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<String> {
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, /// 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 /// bans and cloaking use the hostname, not the IP), tell the client, and clear
/// the flag that was holding their registration. /// the flag that was holding their registration.
pub fn on_resolved(&mut self, uid: Uid, host: Option<String>) { pub fn on_resolved(&mut self, uid: Uid, host: Option<String>, outcome: dnsbl::Outcome) {
// hostname result (only announced if we actually attempted the lookup)
match &host { match &host {
Some(h) => self.notice_star(uid, &format!("Found your hostname ({h})")), Some(h) => self.notice_star(uid, &format!("Found your hostname ({h})")),
None => self.notice_star( None if self.resolve_hosts => self.notice_star(
uid, uid,
"Couldn't look up your hostname; using your IP address instead", "Couldn't look up your hostname; using your IP address instead",
), ),
None => {}
} }
let apply = self.use_resolved_host; let apply = self.use_resolved_host;
if let Some(u) = self.users.get_mut(&uid) { if let Some(u) = self.users.get_mut(&uid) {
@ -263,6 +317,12 @@ impl Server {
u.host = h; 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; u.dns_pending = false;
} }
} }
@ -561,6 +621,7 @@ mod tests {
addr: "127.0.0.1:1".parse().unwrap(), addr: "127.0.0.1:1".parse().unwrap(),
registered: true, registered: true,
dns_pending: false, dns_pending: false,
deferred: Vec::new(),
cap: false, cap: false,
cap_302: false, cap_302: false,
caps: Caps::default(), caps: Caps::default(),
@ -592,13 +653,21 @@ mod tests {
fn resolved_host_applied_only_when_configured() { fn resolved_host_applied_only_when_configured() {
let mut s = srv(); // use_resolved_host = true (default) let mut s = srv(); // use_resolved_host = true (default)
let _a = add_user(&mut s, 1, "ann"); // host starts "localhost" 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_eq!(s.users[&1].host, "host.example.net");
assert!(!s.users[&1].dns_pending); assert!(!s.users[&1].dns_pending);
s.use_resolved_host = false; // resolve + report, but keep the IP in the mask s.use_resolved_host = false; // resolve + report, but keep the IP in the mask
let _b = add_user(&mut s, 2, "bob"); 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_eq!(s.users[&2].host, "localhost");
assert!(!s.users[&2].dns_pending); // registration still un-held either way assert!(!s.users[&2].dns_pending); // registration still un-held either way
} }

View file

@ -206,6 +206,7 @@ pub struct User {
pub addr: SocketAddr, pub addr: SocketAddr,
pub registered: bool, pub registered: bool,
pub dns_pending: bool, // holding registration for a reverse-DNS lookup pub dns_pending: bool, // holding registration for a reverse-DNS lookup
pub deferred: Vec<String>, // handshake lines held while dns_pending (replayed after)
pub cap: bool, // CAP negotiation in progress (holds registration) pub cap: bool, // CAP negotiation in progress (holds registration)
pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware) pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware)
pub caps: Caps, // enabled IRCv3 capabilities pub caps: Caps, // enabled IRCv3 capabilities