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
7e0449e2dd
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.
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/ircd.rs
22
src/ircd.rs
|
|
@ -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
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/`.
|
//! 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;
|
||||||
|
|
||||||
|
|
|
||||||
213
src/resolver.rs
213
src/resolver.rs
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue