diff --git a/echoircd.conf.example b/echoircd.conf.example index 3819a1c..342b1b9 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -31,6 +31,10 @@ oper = admin CHANGE_THIS_PASSWORD # Changing it re-cloaks everyone. cloak_key = CHANGE_THIS_TO_A_LONG_RANDOM_HEX_STRING +# reverse-DNS clients on connect (the "*** Looking up your hostname..." notices). +# on (default) shows resolved hostnames; off keeps bare IPs. +resolve_hosts = on + # antimixedutf8 — block spam that mixes look-alike scripts within words. # action = block | kill | gline | kline | zline ; target = both | channel | private antimixedutf8 = off diff --git a/src/config.rs b/src/config.rs index 8a8ecbd..e1e864c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -69,6 +69,7 @@ pub struct Config { pub conf_path: String, // where this was loaded from (for REHASH) pub censor: Vec<(String, String)>, // +G bad words: (find, replace); empty replace = block pub amu: AntiMixedCfg, // antimixedutf8 module config + pub resolve_hosts: bool, // reverse-DNS clients on connect (default on) } impl Default for Config { @@ -90,6 +91,7 @@ impl Default for Config { conf_path: "echoircd.conf".to_string(), censor: Vec::new(), amu: AntiMixedCfg::default(), + resolve_hosts: true, } } } @@ -185,6 +187,12 @@ impl Config { c.amu.check_channel = t == "both" || t == "channel"; c.amu.check_private = t == "both" || t == "private"; } + "resolve_hosts" | "resolvehosts" | "dns" => { + c.resolve_hosts = !matches!( + v.to_ascii_lowercase().as_str(), + "off" | "false" | "no" | "0" + ) + } _ => {} } } diff --git a/src/ircd.rs b/src/ircd.rs index 5f97f8a..0706b77 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::net::{SocketAddr, TcpStream}; -use std::sync::mpsc::Receiver; +use std::sync::mpsc::{Receiver, Sender}; use crate::command::Command; use crate::config::Config; @@ -34,6 +34,11 @@ pub enum Event { Disconnect { uid: Uid, }, + /// A client's reverse-DNS lookup finished (`None` = no confirmed hostname). + ResolvedHost { + uid: Uid, + host: Option, + }, /// Background timer tick — drives ping/idle timeouts. Tick, } @@ -45,9 +50,9 @@ pub struct Ircd { } impl Ircd { - pub fn new(cfg: Config) -> Ircd { + pub fn new(cfg: Config, event_tx: Sender) -> Ircd { Ircd { - server: Server::new(cfg), + server: Server::new(cfg, event_tx), commands: command_table(), modules: crate::modules::default_modules(), } @@ -88,6 +93,10 @@ impl Ircd { self.quit_user(uid, "Connection closed"); } } + Event::ResolvedHost { uid, host } => { + self.server.on_resolved(uid, host); + self.try_register(uid); // DNS may have been the last thing we waited on + } Event::Tick => self.on_tick(), } self.drain_hooks(); @@ -162,15 +171,27 @@ impl Ircd { } // …or completed the registration handshake if !registered { - let ready = self - .server - .users - .get(&uid) - .map(|u| !u.registered && !u.nick.is_empty() && !u.ident.is_empty() && !u.cap) - .unwrap_or(false); - if ready { - self.complete_registration(uid); - } + self.try_register(uid); + } + } + + /// Finish registration if NICK, USER, CAP and the reverse-DNS lookup are all + /// done. Called after each command and when a DNS result arrives. + fn try_register(&mut self, uid: Uid) { + let ready = self + .server + .users + .get(&uid) + .map(|u| { + !u.registered + && !u.nick.is_empty() + && !u.ident.is_empty() + && !u.cap + && !u.dns_pending + }) + .unwrap_or(false); + if ready { + self.complete_registration(uid); } } diff --git a/src/lib.rs b/src/lib.rs index 7df8a39..03fa9dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod mode; pub mod module; pub mod modules; pub mod numeric; +pub mod resolver; pub mod server; pub mod socketengine; pub mod tls; diff --git a/src/main.rs b/src/main.rs index 27d5409..9a189e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,8 @@ fn main() { let (tx, rx) = mpsc::channel(); let core_cfg = cfg.clone(); - let core = thread::spawn(move || Ircd::new(core_cfg).run(rx)); + let core_tx = tx.clone(); // the core self-injects events (DNS results) + let core = thread::spawn(move || Ircd::new(core_cfg, core_tx).run(rx)); // background timer: drives ping/idle timeouts let tick_tx = tx.clone(); diff --git a/src/resolver.rs b/src/resolver.rs new file mode 100644 index 0000000..afa520f --- /dev/null +++ b/src/resolver.rs @@ -0,0 +1,272 @@ +//! 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`). + +use std::net::{IpAddr, ToSocketAddrs, UdpSocket}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +/// How long to wait for the DNS server before giving up. +pub const DNS_TIMEOUT: Duration = Duration::from_millis(2500); +/// Cap on concurrent in-flight lookups (one short-lived thread each), so a +/// connection flood can't spawn unbounded resolver threads. +const MAX_ACTIVE: usize = 512; +static ACTIVE: AtomicUsize = AtomicUsize::new(0); + +const QTYPE_PTR: u16 = 12; +const QCLASS_IN: u16 = 1; + +/// Reserve a lookup slot; `false` if too many are already in flight. +pub fn try_acquire() -> bool { + // bump then check, so this is a simple bounded gate + if ACTIVE.fetch_add(1, Ordering::Relaxed) < MAX_ACTIVE { + true + } else { + ACTIVE.fetch_sub(1, Ordering::Relaxed); + false + } +} + +/// Release a slot taken by [`try_acquire`] (call once the lookup is done). +pub fn release() { + ACTIVE.fetch_sub(1, Ordering::Relaxed); +} + +/// Reverse-resolve `ip` and forward-confirm. `Some(host)` only if a PTR exists +/// and that host resolves back to `ip`. +pub fn reverse_confirmed(ip: IpAddr, timeout: Duration) -> Option { + let ns = nameserver(); + let ptr = ptr_lookup(&ns, &reverse_name(ip), timeout)?; + // forward-confirm: the resolved name must map back to this IP + let ok = (ptr.as_str(), 0u16) + .to_socket_addrs() + .ok()? + .any(|sa| sa.ip() == ip); + (ok && !ptr.is_empty()).then_some(ptr) +} + +/// The `in-addr.arpa` / `ip6.arpa` reverse name for `ip`. +fn reverse_name(ip: IpAddr) -> String { + match ip { + IpAddr::V4(a) => { + let o = a.octets(); + format!("{}.{}.{}.{}.in-addr.arpa", o[3], o[2], o[1], o[0]) + } + IpAddr::V6(a) => { + let mut s = String::with_capacity(72); + for octet in a.octets().iter().rev() { + s.push_str(&format!("{:x}.{:x}.", octet & 0xf, octet >> 4)); + } + s.push_str("ip6.arpa"); + s + } + } +} + +/// First `nameserver` in /etc/resolv.conf, else a sensible fallback. +fn nameserver() -> String { + if let Ok(text) = std::fs::read_to_string("/etc/resolv.conf") { + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("nameserver ") { + let ns = rest.trim(); + if !ns.is_empty() { + return format!("{ns}:53"); + } + } + } + } + "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 { + 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(&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) +} + +/// Encode a dotted name into wire format (length-prefixed labels + root 0). +fn encode_name(out: &mut Vec, name: &str) { + for label in name.split('.').filter(|l| !l.is_empty()) { + let bytes = label.as_bytes(); + let len = bytes.len().min(63); + out.push(len as u8); + out.extend_from_slice(&bytes[..len]); + } + out.push(0); +} + +/// Parse a DNS reply for the first PTR answer's name. Fully bounds-checked — the +/// packet is untrusted, so nothing here may panic or loop forever. +fn parse_ptr_reply(msg: &[u8], want_id: u16) -> Option { + if msg.len() < 12 { + return None; + } + if u16::from_be_bytes([msg[0], msg[1]]) != want_id { + return None; + } + if msg[3] & 0x0f != 0 { + return None; // rcode != NOERROR + } + 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; + // skip the questions + for _ in 0..qd { + pos = skip_name(msg, pos)?; + pos = pos.checked_add(4)?; // qtype + qclass + if pos > msg.len() { + return None; + } + } + // walk the answer records + for _ in 0..an { + pos = skip_name(msg, pos)?; // name + 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_PTR { + return read_name(msg, rdata, 0).map(|(name, _)| name); + } + pos = rdata + rdlen; + } + None +} + +/// Advance past a name (labels + optional compression pointer), returning the +/// offset just after it (a pointer ends the name in-place). +fn skip_name(msg: &[u8], mut pos: usize) -> Option { + loop { + let len = *msg.get(pos)?; + if len & 0xc0 == 0xc0 { + return Some(pos + 2); // 2-byte compression pointer ends the name + } + if len == 0 { + return Some(pos + 1); + } + pos = pos.checked_add(1 + len as usize)?; + if pos > msg.len() { + return None; + } + } +} + +/// Decode a (possibly compressed) name at `pos`. `jumps` guards against pointer +/// loops. Returns the dotted name and the offset after the name in the record. +fn read_name(msg: &[u8], mut pos: usize, jumps: u32) -> Option<(String, usize)> { + if jumps > 32 { + return None; // too many compression jumps — malformed/hostile + } + let mut out = String::new(); + let mut after: Option = None; + loop { + let len = *msg.get(pos)?; + if len & 0xc0 == 0xc0 { + let ptr = ((len as usize & 0x3f) << 8) | *msg.get(pos + 1)? as usize; + let end = after.unwrap_or(pos + 2); + let (rest, _) = read_name(msg, ptr, jumps + 1)?; + if !rest.is_empty() { + if !out.is_empty() { + out.push('.'); + } + out.push_str(&rest); + } + return Some((out, end)); + } + if len == 0 { + return Some((out, after.unwrap_or(pos + 1))); + } + let start = pos + 1; + let stop = start.checked_add(len as usize)?; + let label = msg.get(start..stop)?; + if !out.is_empty() { + out.push('.'); + } + out.push_str(&String::from_utf8_lossy(label)); + pos = stop; + after.get_or_insert(pos); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn reverse_names() { + assert_eq!( + reverse_name(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))), + "4.3.2.1.in-addr.arpa" + ); + let v6 = reverse_name(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); + assert!(v6.ends_with("ip6.arpa") && v6.starts_with("1.0.0.0.")); + } + + // End-to-end round trip against the real resolver — proves query build → send + // → recv → parse → forward-confirm works. Needs network, so it's #[ignore]d; + // run with `cargo test -- --ignored`. + #[test] + #[ignore = "needs network"] + fn live_reverse_lookup_of_public_ips() { + let one = reverse_confirmed(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), DNS_TIMEOUT); + assert_eq!(one.as_deref(), Some("one.one.one.one"), "got {one:?}"); + let g = reverse_confirmed(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), DNS_TIMEOUT); + assert_eq!(g.as_deref(), Some("dns.google"), "got {g:?}"); + } + + #[test] + fn parses_a_ptr_reply() { + // hand-built reply: id EC, 1 question, 1 PTR answer "host.example" using + // a compression pointer back to "example" in the question. + let mut m = Vec::new(); + m.extend_from_slice(&0x4543u16.to_be_bytes()); // id + m.extend_from_slice(&[0x81, 0x80]); // flags: response, RD, RA, NOERROR + m.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 0]); // qd=1 an=1 + // question: 1.0.0.127.in-addr.arpa PTR IN (we just need a name to skip) + let qstart = m.len(); + super::encode_name(&mut m, "example"); + m.extend_from_slice(&QTYPE_PTR.to_be_bytes()); + m.extend_from_slice(&QCLASS_IN.to_be_bytes()); + // answer: name = pointer to question name, PTR, rdata = "host" + ptr(qname) + m.extend_from_slice(&[0xc0, qstart as u8]); + m.extend_from_slice(&QTYPE_PTR.to_be_bytes()); + m.extend_from_slice(&QCLASS_IN.to_be_bytes()); + m.extend_from_slice(&[0, 0, 0, 60]); // ttl + let rd = vec![4, b'h', b'o', b's', b't', 0xc0, qstart as u8]; + m.extend_from_slice(&(rd.len() as u16).to_be_bytes()); + m.extend_from_slice(&rd); + assert_eq!(parse_ptr_reply(&m, 0x4543).as_deref(), Some("host.example")); + assert_eq!(parse_ptr_reply(&m, 0x9999), None); // wrong id + } +} diff --git a/src/server.rs b/src/server.rs index 7fa53c6..3aeeb22 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,13 +7,17 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::net::{SocketAddr, TcpStream}; +use std::sync::mpsc::Sender; +use std::thread; use std::time::{SystemTime, UNIX_EPOCH}; use crate::channels::Channel; use crate::config::{Config, LinkBlock}; use crate::extensible::Extensible; +use crate::ircd::Event; use crate::link::{Link, RemoteServer, RemoteUser}; use crate::module::Hook; +use crate::resolver; use crate::socketengine::OutSink; use crate::users::{Caps, User, UserFlags}; use crate::xline::XLine; @@ -93,10 +97,12 @@ pub struct Server { pub in_redirect: bool, // +L: guards against redirect loops pub censor: Vec<(String, String)>, // +G bad words: (find, replace) pub amu: crate::config::AntiMixedCfg, // antimixedutf8 module config + pub resolve_hosts: bool, // reverse-DNS clients on connect + pub event_tx: Sender, // self-inject events (DNS results) } impl Server { - pub fn new(cfg: Config) -> Server { + pub fn new(cfg: Config, event_tx: Sender) -> Server { Server { name: cfg.servername, network: cfg.network, @@ -126,6 +132,8 @@ impl Server { in_redirect: false, censor: cfg.censor, amu: cfg.amu, + resolve_hosts: cfg.resolve_hosts, + event_tx, } } @@ -166,6 +174,7 @@ impl Server { ) { let uuid = self.next_uuid(); self.uuid_local.insert(uuid.clone(), uid); + let ip = addr.ip(); self.users.insert( uid, User { @@ -182,6 +191,7 @@ impl Server { signon: now(), addr, registered: false, + dns_pending: false, cap: false, cap_302: false, caps: Caps::default(), @@ -200,6 +210,54 @@ impl Server { sock, }, ); + + // Pre-registration connection notices, InspIRCd / solanum style. Ident-113 + // is archaic and firewalled, so those two are cosmetic; the hostname lookup + // 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() { + if let Some(u) = self.users.get_mut(&uid) { + u.dns_pending = true; // hold registration until the lookup returns + } + let tx = self.event_tx.clone(); + thread::spawn(move || { + let host = resolver::reverse_confirmed(ip, resolver::DNS_TIMEOUT); + resolver::release(); + let _ = tx.send(Event::ResolvedHost { uid, host }); + }); + } else { + // resolution off (or too many in flight): keep the IP as the host + self.notice_star( + uid, + "Couldn't look up your hostname; using your IP address instead", + ); + } + } + + /// A pre-registration `:server NOTICE * :*** ` line. + fn notice_star(&self, uid: Uid, msg: &str) { + self.send(uid, format!(":{} NOTICE * :*** {msg}", self.name)); + } + + /// 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) { + match &host { + Some(h) => self.notice_star(uid, &format!("Found your hostname ({h})")), + None => self.notice_star( + uid, + "Couldn't look up your hostname; using your IP address instead", + ), + } + if let Some(u) = self.users.get_mut(&uid) { + if let Some(h) = host { + u.host = h; + } + u.dns_pending = false; + } } /// Mark a user as quitting; the core turns this into a full quit after the @@ -484,6 +542,7 @@ mod tests { signon: 0, addr: "127.0.0.1:1".parse().unwrap(), registered: true, + dns_pending: false, cap: false, cap_302: false, caps: Caps::default(), @@ -507,7 +566,8 @@ mod tests { } fn srv() -> Server { - Server::new(Config::default()) + let (tx, _rx) = mpsc::channel(); + Server::new(Config::default(), tx) } #[test] diff --git a/src/users.rs b/src/users.rs index ec763d6..a40331c 100644 --- a/src/users.rs +++ b/src/users.rs @@ -197,7 +197,7 @@ pub struct User { pub nick: String, // "" until NICK pub ident: String, // "" until USER pub realname: String, - pub host: String, // real host (ip string; no rDNS) + pub host: String, // displayed host: reverse-DNS name if resolved, else IP pub cloak: String, // masked host shown under +x ("" until computed) pub vhost: Option, // displayed-host override (CHGHOST/SETHOST vhost) pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo) @@ -205,16 +205,17 @@ pub struct User { pub signon: u64, // unix secs at registration (WHOIS 317) pub addr: SocketAddr, pub registered: bool, - 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 cap: bool, // CAP negotiation in progress (holds registration) + pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware) + pub caps: Caps, // enabled IRCv3 capabilities pub sasl_mech: Option, // SASL mechanism chosen, mid-handshake pub channels: HashSet, // lowercased channel keys - pub watch: Vec, // WATCH list — lowercased nicks - pub monitor: Vec, // MONITOR list — lowercased nicks - pub silence: Vec, // SILENCE masks — nick!user@host globs - pub accept: Vec, // ACCEPT list — lowercased nicks (callerid +g) - pub quitting: Option, // set by QUIT; drained by the core + pub watch: Vec, // WATCH list — lowercased nicks + pub monitor: Vec, // MONITOR list — lowercased nicks + pub silence: Vec, // SILENCE masks — nick!user@host globs + pub accept: Vec, // ACCEPT list — lowercased nicks (callerid +g) + pub quitting: Option, // set by QUIT; drained by the core pub flags: UserFlags, pub last_active: u64, // unix secs of the last line we received pub ping_sent: bool, // a server PING is outstanding