dnsbl on connect (m_dnsbl-style, ipv4+ipv6, own module), cache + parallelize resolver, group connect notices before cap
This commit is contained in:
parent
296d87726a
commit
e736188378
9 changed files with 440 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
22
src/ircd.rs
22
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<String>,
|
||||
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 {
|
||||
|
|
|
|||
110
src/modules/dnsbl.rs
Normal file
110
src/modules/dnsbl.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
213
src/resolver.rs
213
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<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 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<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 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<String> {
|
|||
(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<String> {
|
||||
/// 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<Vec<u8>> {
|
||||
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<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).
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
}
|
||||
|
||||
|
|
@ -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 * :*** <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));
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// 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<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 {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
17
src/users.rs
17
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<String>, // 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<String>, // SASL mechanism chosen, mid-handshake
|
||||
pub channels: HashSet<String>, // lowercased channel keys
|
||||
pub watch: Vec<String>, // WATCH list — lowercased nicks
|
||||
pub monitor: Vec<String>, // MONITOR list — lowercased nicks
|
||||
pub silence: Vec<String>, // SILENCE masks — nick!user@host globs
|
||||
pub accept: Vec<String>, // ACCEPT list — lowercased nicks (callerid +g)
|
||||
pub watch: Vec<String>, // WATCH list — lowercased nicks
|
||||
pub monitor: Vec<String>, // MONITOR list — lowercased nicks
|
||||
pub silence: Vec<String>, // SILENCE masks — nick!user@host globs
|
||||
pub accept: Vec<String>, // ACCEPT list — lowercased nicks (callerid +g)
|
||||
pub quitting: Option<String>, // set by QUIT; drained by the core
|
||||
pub flags: UserFlags,
|
||||
pub last_active: u64, // unix secs of the last line we received
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue