diff --git a/echoircd.conf.example b/echoircd.conf.example index 5ddb7f4..11ef55e 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -85,13 +85,46 @@ amu_target = both # connflood = 5 10 # --- connectclass: per-class connection policy. Each line matches connecting -# clients by IP glob (+ optional TLS); first match wins, else global limits. -# Keys: allow=, deny=yes (reject), ssl=yes (TLS only), password= -# (client must PASS it), localmax= (max connections per IP in this class), -# maxchans=, pingfreq=, timeout= (registration), modes=<+modes>. -# connectclass = trusted allow=10.0.0.* maxchans=200 pingfreq=120 -# connectclass = vpn allow=* localmax=2 maxchans=20 modes=+ix -# connectclass = banned allow=1.2.3.* deny=yes +# clients by IP/host mask (glob OR CIDR) and optional TLS/port; the first match +# wins, else the global limits apply. Masks are tested against the IP at connect +# and re-tested against the resolved host at registration. Keys: +# allow= IP/host globs or CIDR (e.g. 10.0.0.0/8); any match hits +# deny=yes reject clients matching this class +# parent= inherit this class's other settings (not allow/deny) +# requiressl=yes|trusted require TLS; "trusted" also requires a client cert +# password= client must send it via PASS; may be hashed +# hash= names the hash of a hashed password (md5/sha256/…) +# port= only clients that connected to these listener ports +# localmax= max connections per IP in this class (local server) +# globalmax= max connections per IP across the whole network +# limit= max total local users in this class +# maxchans= max channels a member may join +# pingfreq= ping frequency; timeout= registration timeout +# modes=<+modes> usermodes set on connect +# recvq= receive-queue cap; hardsendq= send-queue cap +# softsendq= send-queue level above which reads pause (backpressure) +# fakelag=no disconnect flooders instead of rate-limiting them +# penaltythreshold= flood message cap; commandrate= flood window +# useident=yes do an ident (RFC1413) lookup for this class +# requireident=yes refuse clients whose ident can't be confirmed +# resolvehostnames=no skip reverse-DNS for this class +# maxconnwarn=yes snotice opers when a limit refuses a client +# (recvq/hardsendq/softsendq apply to plaintext clients; TLS clients and links +# use the global max_line/max_sendq below.) +# connectclass = trusted allow=10.0.0.0/8 maxchans=200 pingfreq=120 fakelag=no +# connectclass = secure allow=* requiressl=yes password=sha256: hash=sha256 +# connectclass = vpn allow=* parent=trusted localmax=2 maxchans=20 modes=+ix +# connectclass = banned allow=1.2.3.0/24 deny=yes +# connectclass_required = yes # refuse clients that match no allow class (default no) + +# --- global connection limits (per-class recvq/hardsendq/softsendq override these) --- +# max_line = 16384 # max bytes in one line / receive queue (default 16 KiB) +# max_sendq = 1048576 # max queued output before a slow client is dropped (1 MiB) + +# --- ident (RFC1413): off by default; a connection class can also enable it --- +# useident = yes # look up every client's ident (adds connect latency) +# requireident = yes # refuse clients whose ident can't be confirmed +# ident_timeout = 5 # seconds to wait for the ident reply # --- security groups: securitygroup = [criteria...] # criteria: public tls insecure account unregistered oper exclude-oper diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 9b4221d..6bf9e55 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -853,7 +853,8 @@ impl Command for Connect { } let addr = format!("{}:{}", b.ip, b.port); let (tx, counter) = (s.event_tx.clone(), s.conn_counter.clone()); - std::thread::spawn(move || crate::socketengine::connect_link(&addr, tx, counter)); + let max_line = s.conf_num("max_line", crate::socketengine::DEFAULT_MAX_LINE); + std::thread::spawn(move || crate::socketengine::connect_link(&addr, tx, counter, max_line)); let by = oper_nick(s, uid); s.snotice(&format!( "{by} used CONNECT to {} ({}:{})", diff --git a/src/ircd.rs b/src/ircd.rs index df5a3f4..81b890b 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -25,6 +25,7 @@ pub enum Event { sock: Option, secure: bool, certfp: Option, // TLS client-cert fingerprint (clients only) + local_port: u16, // the listener port the client connected to link: bool, // a server-to-server connection, not a client outbound: bool, // (link) we dialed them websocket: bool, // arrived over the WebSocket transport @@ -43,6 +44,12 @@ pub enum Event { host: Option, dnsbl: crate::modules::dnsbl::Outcome, }, + /// A client's ident (RFC 1413) lookup finished: the confirmed username, or + /// `None` if the host gave no valid response (see `crate::modules::ident`). + Ident { + uid: Uid, + ident: Option, + }, /// A module's async HTTP request finished. `tag` is `":"` /// so the core can route the reply back to the module that issued it (e.g. /// account registration, captcha verification). `status` is 0 on transport @@ -114,6 +121,7 @@ impl Ircd { sock, secure, certfp, + local_port, link, outbound, websocket, @@ -121,7 +129,8 @@ impl Ircd { if link { self.server.add_link(uid, addr, out, sock, outbound); } else { - self.server.add_conn(uid, addr, out, sock, secure, certfp); + self.server + .add_conn(uid, addr, out, sock, secure, certfp, local_port); if websocket { if let Some(u) = self.server.users.get_mut(&uid) { u.flags.via_websocket = true; @@ -157,6 +166,10 @@ impl Ircd { } self.try_register(uid); // DNS may have been the last thing we waited on } + Event::Ident { uid, ident } => { + crate::modules::ident::on_result(&mut self.server, uid, ident); + self.try_register(uid); // ident may have been the last hold + } Event::HttpResult { uid, tag, @@ -384,6 +397,7 @@ impl Ircd { && !u.ident.is_empty() && !u.cap && !u.dns_pending + && !u.ident_pending && u.waitpong.is_none() }) .unwrap_or(false); @@ -414,6 +428,13 @@ impl Ircd { self.server.remove_user(uid, &reason); return; } + // ident: apply a confirmed username (dropping `~`) and enforce requireident + if let Some(reason) = crate::modules::ident::finalize(&mut self.server, uid) { + self.server + .send(uid, format!("ERROR :Closing link: ({reason})")); + self.server.remove_user(uid, &reason); + return; + } // connectclass: verify the class password and apply its on-connect modes if let Some(reason) = crate::modules::connclass::on_register(&mut self.server, uid) { self.server diff --git a/src/link.rs b/src/link.rs index 7b09b3a..8a06758 100644 --- a/src/link.rs +++ b/src/link.rs @@ -55,6 +55,7 @@ pub struct RemoteUser { pub host: String, pub realname: String, pub account: Option, + pub ip: String, // client IP (for network-wide clone limits); "" if a peer omitted it pub sid: String, // origin server id pub via: Uid, // local link uid it is reached through } @@ -313,13 +314,14 @@ impl Server { fn uid_line(&self, u: &User) -> String { let acct = u.account.clone().unwrap_or_else(|| "*".to_string()); format!( - ":{} UID {} {} {} {} {} :{}", + ":{} UID {} {} {} {} {} {} :{}", self.sid, u.uuid, u.nick, u.ident, u.host_display(), acct, + u.addr.ip(), u.realname ) } @@ -674,10 +676,22 @@ impl Server { // --- inbound S2S records -------------------------------------------------- fn link_uid_recv(&mut self, via: Uid, msg: &Message) { - // : UID : + // : UID : + // The field is newer; tolerate the older 6-param form (no IP). if msg.params.len() < 6 { return; } + let has_ip = msg.params.len() >= 7; + let ip = if has_ip { + msg.params[5].clone() + } else { + String::new() + }; + let realname = if has_ip { + msg.params[6].clone() + } else { + msg.params[5].clone() + }; let sid = msg.source.clone().unwrap_or_default(); let uuid = msg.params[0].clone(); let nick = msg.params[1].clone(); @@ -705,21 +719,36 @@ impl Server { nick, ident: msg.params[2].clone(), host: msg.params[3].clone(), - realname: msg.params[5].clone(), + realname, account, + ip: ip.clone(), sid: sid.clone(), via, }, ); - let line = format!( - ":{sid} UID {} {} {} {} {} :{}", - msg.params[0], - msg.params[1], - msg.params[2], - msg.params[3], - msg.params[4], - msg.params[5] - ); + // re-propagate to our other peers, carrying the IP when we have one + let line = if has_ip { + format!( + ":{sid} UID {} {} {} {} {} {} :{}", + msg.params[0], + msg.params[1], + msg.params[2], + msg.params[3], + msg.params[4], + ip, + msg.params[6] + ) + } else { + format!( + ":{sid} UID {} {} {} {} {} :{}", + msg.params[0], + msg.params[1], + msg.params[2], + msg.params[3], + msg.params[4], + msg.params[5] + ) + }; self.propagate(&line, Some(via)); } diff --git a/src/main.rs b/src/main.rs index d80ddee..013468b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,17 @@ fn main() { cfg.servername ); + // global queue limits (per-class overrides layer on top of these in the reactor) + let raw_num = |k: &str, d: usize| { + cfg.raw + .get(k) + .and_then(|v| v.first()) + .and_then(|s| s.parse().ok()) + .unwrap_or(d) + }; + let max_line = raw_num("max_line", socketengine::DEFAULT_MAX_LINE); + let max_sendq = raw_num("max_sendq", socketengine::DEFAULT_MAX_SENDQ); + // one uid counter shared by every listener (and by CONNECT) so ids stay unique let counter = Arc::new(AtomicU64::new(1)); @@ -79,6 +90,7 @@ fn main() { Some(backend), tls_counter, false, + max_line, ) }); } @@ -95,7 +107,9 @@ fn main() { eprintln!("echoircd S2S link listener on {bind_srv} (sid {})", cfg.sid); let s_tx = tx.clone(); let s_counter = counter.clone(); - thread::spawn(move || socketengine::accept_loop(sl, s_tx, None, s_counter, true)); + thread::spawn(move || { + socketengine::accept_loop(sl, s_tx, None, s_counter, true, max_line) + }); } Err(e) => eprintln!("echoircd: cannot bind server port {bind_srv}: {e}"), } @@ -114,11 +128,13 @@ fn main() { let u_counter = counter.clone(); thread::spawn(move || { thread::sleep(std::time::Duration::from_secs(2)); - socketengine::connect_link(&addr, u_tx, u_counter); + socketengine::connect_link(&addr, u_tx, u_counter, max_line); }); } // client plaintext connections: one mio reactor thread drives them all - thread::spawn(move || socketengine::run_reactor(client_listener, tx, counter)); + thread::spawn(move || { + socketengine::run_reactor(client_listener, tx, counter, max_line, max_sendq) + }); let _ = core.join(); } diff --git a/src/modules/connclass.rs b/src/modules/connclass.rs index 30b0b3b..ce2e0ef 100644 --- a/src/modules/connclass.rs +++ b/src/modules/connclass.rs @@ -1,123 +1,390 @@ //! connclass — connection classes. Each `connectclass` config line matches -//! connecting clients by IP glob (and optionally TLS), then applies per-class -//! policy: reject (deny), a per-IP connection cap, a password, usermodes on -//! connect, and overrides for max channels / ping frequency / registration -//! timeout. Config, one line per class (first token = name, rest key=value): +//! connecting clients by IP/host mask (CIDR or glob) and optional TLS/port, then +//! applies per-class policy: reject (deny), per-IP and per-class connection caps, a +//! password, on-connect usermodes, queue/flood limits, and overrides for max +//! channels / ping frequency / registration timeout. One line per class — the first +//! token is the name, the rest are `key=value`: //! //! ```text -//! connectclass = allow= [deny=yes] [ssl=yes] [password=] -//! [localmax=] [maxchans=] [pingfreq=] [timeout=] [modes=<+modes>] +//! connectclass = allow= [parent=] [deny=yes] +//! [requiressl=yes|trusted] [password=] [hash=] [port=] +//! [localmax=] [globalmax=] [limit=] [maxchans=] [pingfreq=] +//! [timeout=] [modes=<+modes>] [recvq=] [hardsendq=] +//! [softsendq=] [fakelag=yes|no] [penaltythreshold=] [commandrate=] +//! [useident=yes] [requireident=yes] [resolvehostnames=no] [maxconnwarn=yes] //! ``` //! -//! The first class whose `allow` glob (and `ssl` if given) matches a client is -//! assigned at connect. With no class, the global limits apply. Matching is against -//! the IP (the host isn't resolved yet at connect). +//! The first class whose masks (and TLS/port conditions) match a client is assigned. +//! Masks are tested against the IP at connect and re-tested against the resolved +//! host at registration, so host masks work once rDNS returns. With no class the +//! global limits apply; set `connectclass_required = yes` to refuse clients that +//! match no allow class. + +use std::net::IpAddr; use crate::channels::glob_match; use crate::server::Server; use crate::Uid; -#[derive(Default)] +#[derive(Default, Clone)] pub struct ConnClass { pub name: String, - pub allow: String, - pub deny: bool, - pub ssl: bool, - pub password: Option, - pub localmax: Option, + pub allow: Vec, // IP/host masks (glob or CIDR); any match = match + pub deny: bool, // deny class: matching clients are refused + pub ssl: bool, // require TLS + pub ssl_trusted: bool, // require a TLS client certificate (requiressl=trusted) + pub password: Option, // PASS credential (plain or hashed; verify auto-detects) + pub ports: Vec, // restrict to these listener ports (empty = any) + pub localmax: Option, // max local connections per IP in this class + pub globalmax: Option, // max network-wide connections per IP + pub limit: Option, // max total local users in this class pub maxchans: Option, pub pingfreq: Option, - pub timeout: Option, - pub modes: Option, + pub timeout: Option, // registration timeout + pub modes: Option, // usermodes set on connect + pub recvq: Option, // per-conn receive-queue byte cap + pub hardsendq: Option, // send-queue byte cap → disconnect + pub softsendq: Option, // send-queue byte cap → pause reading (backpressure) + pub penaltythreshold: Option, // flood message cap override (see modules::flood) + pub commandrate: Option, // flood window override, seconds + pub fakelag: bool, // apply flood limiting (default); false = kill on flood + pub useident: bool, // do an ident (RFC1413) lookup for this class + pub requireident: bool, // refuse if the ident lookup fails + pub resolvehostnames: bool, // resolve rDNS for this class (default yes) + pub maxconnwarn: bool, // snotice opers when a limit refuses a client } -fn parse(line: &str) -> Option { - let mut it = line.split_whitespace(); +/// Split a `key=value` value on commas into non-empty pieces. +fn list(v: &str) -> impl Iterator { + v.split(',').map(str::trim).filter(|s| !s.is_empty()) +} + +/// Apply one `key=value` token to `c`. +fn apply(c: &mut ConnClass, k: &str, v: &str) { + match k { + "allow" => c.allow.extend(list(v).map(str::to_string)), + "deny" => c.deny = v.eq_ignore_ascii_case("yes"), + "ssl" | "requiressl" => { + c.ssl = !v.eq_ignore_ascii_case("no") && !v.is_empty(); + c.ssl_trusted = v.eq_ignore_ascii_case("trusted"); + } + "password" | "pass" => c.password = Some(v.to_string()), + "hash" => { + // name the algorithm of a hashed password: fold it into the stored + // credential (`:`) that verify() auto-detects, unless the + // password value already carries its own prefix. + if let Some(pw) = c.password.take() { + c.password = Some(if pw.contains(':') { + pw + } else { + format!("{v}:{pw}") + }); + } + } + "port" => c.ports.extend(list(v).filter_map(|p| p.parse::().ok())), + "localmax" => c.localmax = v.parse().ok(), + "globalmax" => c.globalmax = v.parse().ok(), + "limit" => c.limit = v.parse().ok(), + "maxchans" => c.maxchans = v.parse().ok(), + "pingfreq" => c.pingfreq = v.parse().ok(), + "timeout" => c.timeout = v.parse().ok(), + "modes" => c.modes = Some(v.to_string()), + "recvq" => c.recvq = v.parse().ok(), + "hardsendq" => c.hardsendq = v.parse().ok(), + "softsendq" => c.softsendq = v.parse().ok(), + "penaltythreshold" => c.penaltythreshold = v.parse().ok(), + "commandrate" => c.commandrate = v.parse().ok(), + "fakelag" => c.fakelag = !v.eq_ignore_ascii_case("no"), + "useident" => c.useident = v.eq_ignore_ascii_case("yes"), + "requireident" => c.requireident = v.eq_ignore_ascii_case("yes"), + "resolvehostnames" => c.resolvehostnames = !v.eq_ignore_ascii_case("no"), + "maxconnwarn" => c.maxconnwarn = v.eq_ignore_ascii_case("yes"), + _ => {} + } +} + +/// The raw `connectclass` line whose first token is `name`. +fn raw_line(s: &Server, name: &str) -> Option { + s.conf_all("connectclass") + .iter() + .find(|l| l.split_whitespace().next() == Some(name)) + .map(|l| l.to_string()) +} + +/// The effective token list for `name` with `parent=` inheritance applied: a +/// parent's tokens come first (so the child overrides), minus the block-defining +/// `allow`/`deny`/`parent` keys, which stay class-local. Bounded against cycles. +fn tokens_for(s: &Server, name: &str, depth: u8) -> Option> { + let line = raw_line(s, name)?; + let own: Vec = line.split_whitespace().skip(1).map(str::to_string).collect(); + let parent = own + .iter() + .find_map(|t| t.strip_prefix("parent=")) + .map(str::to_string); + let mut merged = Vec::new(); + if let Some(p) = parent { + if depth < 8 { + if let Some(pt) = tokens_for(s, &p, depth + 1) { + merged.extend(pt.into_iter().filter(|t| { + !t.starts_with("allow=") + && !t.starts_with("deny=") + && !t.starts_with("parent=") + })); + } + } + } + merged.extend(own); + Some(merged) +} + +/// Build a resolved class (parent inheritance applied) from its config line. +fn build(s: &Server, name: &str) -> Option { + let toks = tokens_for(s, name, 0)?; let mut c = ConnClass { - name: it.next()?.to_string(), - allow: "*".to_string(), + name: name.to_string(), + fakelag: true, + resolvehostnames: true, ..Default::default() }; - for tok in it { - let Some((k, v)) = tok.split_once('=') else { - continue; - }; - match k { - "allow" => c.allow = v.to_string(), - "deny" => c.deny = v.eq_ignore_ascii_case("yes"), - "ssl" | "requiressl" => c.ssl = v.eq_ignore_ascii_case("yes"), - "password" | "pass" => c.password = Some(v.to_string()), - "localmax" => c.localmax = v.parse().ok(), - "maxchans" => c.maxchans = v.parse().ok(), - "pingfreq" => c.pingfreq = v.parse().ok(), - "timeout" => c.timeout = v.parse().ok(), - "modes" => c.modes = Some(v.to_string()), - _ => {} + for tok in toks { + if let Some((k, v)) = tok.split_once('=') { + apply(&mut c, k, v); } } + if c.allow.is_empty() { + c.allow.push("*".to_string()); // an unqualified class matches everyone + } Some(c) } +/// Every configured class, resolved. pub fn all(s: &Server) -> Vec { s.conf_all("connectclass") .iter() - .filter_map(|l| parse(l)) + .filter_map(|l| l.split_whitespace().next()) + .filter_map(|name| build(s, name)) .collect() } +/// A single resolved class by name. pub fn named(s: &Server, name: &str) -> Option { - all(s).into_iter().find(|c| c.name == name) + build(s, name) +} + +// --- mask matching ----------------------------------------------------------- + +/// Whether the first `bits` bits of `a` and `b` are equal. +fn prefix_eq(a: &[u8], b: &[u8], bits: u8) -> bool { + let full = (bits / 8) as usize; + if a[..full] != b[..full] { + return false; + } + let rem = bits % 8; + if rem == 0 { + return true; + } + let mask = 0xffu8 << (8 - rem); + (a[full] & mask) == (b[full] & mask) +} + +/// Whether `target` falls inside the CIDR `base`/`bits` (same family required). +fn cidr_contains(base: IpAddr, bits: u8, target: IpAddr) -> bool { + match (base, target) { + (IpAddr::V4(b), IpAddr::V4(t)) => prefix_eq(&b.octets(), &t.octets(), bits.min(32)), + (IpAddr::V6(b), IpAddr::V6(t)) => prefix_eq(&b.octets(), &t.octets(), bits.min(128)), + _ => false, + } +} + +/// Match one mask against a client's IP and (once known) resolved host. A mask with +/// a `/` is a CIDR range tested against the IP; otherwise it's a glob tested against +/// both the IP text and the host. +fn mask_match(mask: &str, ip: &str, host: &str) -> bool { + if let Some((net, bits)) = mask.split_once('/') { + if let (Ok(base), Ok(bits), Ok(target)) = + (net.parse::(), bits.parse::(), ip.parse::()) + { + return cidr_contains(base, bits, target); + } + return false; + } + glob_match(mask, ip) || (!host.is_empty() && glob_match(mask, host)) +} + +// --- class selection --------------------------------------------------------- + +enum Pick { + Class(ConnClass), + Deny(String), + None, +} + +/// Choose the first suitable class for a client. Suitability = a matching mask plus +/// any TLS/port/limit conditions; an unsuitable class is skipped, a matching deny +/// class rejects. `host` is empty at connect (pre-rDNS) and the resolved name later. +fn pick( + s: &Server, + uid: Uid, + ip: &str, + host: &str, + secure: bool, + has_cert: bool, + port: u16, +) -> Pick { + for c in all(s) { + if !c.allow.iter().any(|m| mask_match(m, ip, host)) { + continue; + } + if c.ssl && !secure { + continue; + } + if c.ssl_trusted && !has_cert { + continue; + } + if !c.ports.is_empty() && !c.ports.contains(&port) { + continue; + } + if c.deny { + return Pick::Deny(c.name); + } + if let Some(max) = c.limit { + if class_count(s, &c.name, uid) >= max { + if c.maxconnwarn { + s.snotice(&format!("connect class {} is full ({max})", c.name)); + } + continue; // full — try the next class + } + } + return Pick::Class(c); + } + Pick::None +} + +/// Local users currently in class `name` (excluding `uid`). +fn class_count(s: &Server, name: &str, uid: Uid) -> usize { + s.users + .iter() + .filter(|(&k, u)| k != uid && u.class.as_deref() == Some(name)) + .count() +} + +/// Local connections from `ip` in class `name` (excluding `uid`). +fn local_clones(s: &Server, ip: &str, name: &str, uid: Uid) -> usize { + s.users + .iter() + .filter(|(&k, u)| { + k != uid && u.addr.ip().to_string() == ip && u.class.as_deref() == Some(name) + }) + .count() +} + +/// Connections from `ip` across the whole network (local + remote), excluding `uid`. +fn global_clones(s: &Server, ip: &str, uid: Uid) -> usize { + let local = s + .users + .iter() + .filter(|(&k, u)| k != uid && u.addr.ip().to_string() == ip) + .count(); + let remote = s.remote_users.values().filter(|ru| ru.ip == ip).count(); + local + remote } /// Assign the connecting client to the first matching class. Returns `Some(reason)` -/// if the connection must be rejected (a deny class or a per-IP cap); otherwise sets -/// the class name on the user and returns `None`. Called from `add_conn`. +/// if the connection must be rejected (a deny class or a per-IP/per-class cap); +/// otherwise sets the class on the user and returns `None`. Called from `add_conn`. pub fn assign(s: &mut Server, uid: Uid) -> Option { - let (ip, secure) = { + let (ip, secure, has_cert, port) = { let u = s.users.get(&uid)?; - (u.addr.ip().to_string(), u.secure) + ( + u.addr.ip().to_string(), + u.secure, + u.certfp.is_some(), + u.port, + ) + }; + let class = match pick(s, uid, &ip, "", secure, has_cert, port) { + Pick::Deny(name) => { + return Some(format!("Connection class {name} denies your address")); + } + Pick::None => { + if !all(s).iter().any(|c| !c.deny) || !s.conf_bool("connectclass_required", false) { + return None; // no allow classes, or strict mode off: allow, no class + } + return Some("You are not allowed to connect to this server".to_string()); + } + Pick::Class(c) => c, + }; + let warn = |s: &Server, why: &str| { + if class.maxconnwarn { + s.snotice(&format!("connect class {} refused {ip}: {why}", class.name)); + } }; - let class = all(s) - .into_iter() - .find(|c| glob_match(&c.allow, &ip) && (!c.ssl || secure))?; - if class.deny { - return Some(format!("Connection class {} denies your address", class.name)); - } if let Some(max) = class.localmax { - let n = s - .users - .values() - .filter(|u| { - u.addr.ip().to_string() == ip && u.class.as_deref() == Some(class.name.as_str()) - }) - .count(); - if n >= max { + if local_clones(s, &ip, &class.name, uid) >= max { + warn(s, "local clone limit"); return Some("Too many connections from your address".to_string()); } } + if let Some(max) = class.globalmax { + if global_clones(s, &ip, uid) >= max { + warn(s, "global clone limit"); + return Some("Too many global connections from your address".to_string()); + } + } if let Some(u) = s.users.get_mut(&uid) { u.class = Some(class.name); } None } -/// At registration: verify the class password (if any) and apply the class's -/// on-connect usermodes. Returns `Some(reason)` to reject. +/// At registration: re-pick the class now the host is resolved (host masks), verify +/// the class password, enforce a required client cert, and apply on-connect modes. +/// Returns `Some(reason)` to reject. pub fn on_register(s: &mut Server, uid: Uid) -> Option { + let (ip, host, secure, has_cert, port, sent) = { + let u = s.users.get(&uid)?; + ( + u.addr.ip().to_string(), + u.host.clone(), + u.secure, + u.certfp.is_some(), + u.port, + u.pass.clone(), + ) + }; + match pick(s, uid, &ip, &host, secure, has_cert, port) { + Pick::Deny(name) => { + return Some(format!("Connection class {name} denies your address")); + } + Pick::Class(c) => { + if let Some(u) = s.users.get_mut(&uid) { + u.class = Some(c.name); + } + } + Pick::None => {} // keep whatever was assigned at connect + } let name = s.users.get(&uid)?.class.clone()?; let class = named(s, &name)?; if let Some(pw) = &class.password { - let ok = s.users.get(&uid).and_then(|u| u.pass.clone()); - if ok.as_deref() != Some(pw.as_str()) { + let ok = sent + .as_deref() + .map(|p| crate::modules::password_hash::verify(pw, p)) + .unwrap_or(false); + if !ok { return Some("Password mismatch for your connection class".to_string()); } } + if class.ssl_trusted && !has_cert { + return Some("Your connection class requires a client certificate".to_string()); + } if let Some(m) = class.modes { crate::coremods::core_mode::svs_set_user_modes(s, uid, &m); } None } +// --- per-class getters consulted by the core / other modules ----------------- + fn class_of(s: &Server, uid: Uid) -> Option { let name = s.users.get(&uid).and_then(|u| u.class.clone())?; named(s, &name) @@ -132,3 +399,57 @@ pub fn reg_timeout(s: &Server, uid: Uid) -> Option { pub fn max_chans(s: &Server, uid: Uid) -> Option { class_of(s, uid)?.maxchans } +pub fn recvq(s: &Server, uid: Uid) -> Option { + class_of(s, uid)?.recvq +} +pub fn hardsendq(s: &Server, uid: Uid) -> Option { + class_of(s, uid)?.hardsendq +} +pub fn softsendq(s: &Server, uid: Uid) -> Option { + class_of(s, uid)?.softsendq +} +/// Per-class flood override: `(message cap, window secs, fakelag)`. `fakelag=false` +/// means flooders are killed rather than rate-limited. +pub fn flood_over(s: &Server, uid: Uid) -> Option<(Option, Option, bool)> { + let c = class_of(s, uid)?; + Some((c.penaltythreshold, c.commandrate, c.fakelag)) +} +/// Whether reverse-DNS should be resolved for this client's class (default yes). +pub fn resolve_hostnames(s: &Server, uid: Uid) -> bool { + class_of(s, uid).map(|c| c.resolvehostnames).unwrap_or(true) +} +/// `(useident, requireident)` for this client's class. +pub fn ident_policy(s: &Server, uid: Uid) -> (bool, bool) { + class_of(s, uid) + .map(|c| (c.useident, c.requireident)) + .unwrap_or((false, false)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn cidr_v4_ranges() { + let base = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0)); + assert!(cidr_contains(base, 8, "10.9.9.9".parse().unwrap())); + assert!(!cidr_contains(base, 8, "11.0.0.1".parse().unwrap())); + let net = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 0)); + assert!(cidr_contains(net, 24, "192.168.1.200".parse().unwrap())); + assert!(!cidr_contains(net, 24, "192.168.2.1".parse().unwrap())); + // a /32 is an exact host + let host = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5)); + assert!(cidr_contains(host, 32, "203.0.113.5".parse().unwrap())); + assert!(!cidr_contains(host, 32, "203.0.113.6".parse().unwrap())); + } + + #[test] + fn mask_glob_and_cidr() { + assert!(mask_match("10.0.0.0/8", "10.1.2.3", "")); + assert!(!mask_match("10.0.0.0/8", "192.0.2.1", "")); + assert!(mask_match("*.example.com", "192.0.2.1", "host.example.com")); + assert!(mask_match("192.0.2.*", "192.0.2.7", "")); + assert!(!mask_match("nomatch/33", "1.2.3.4", "")); // unparseable → no match + } +} diff --git a/src/modules/flood.rs b/src/modules/flood.rs index 627f641..0a0e4e3 100644 --- a/src/modules/flood.rs +++ b/src/modules/flood.rs @@ -1,7 +1,10 @@ //! Per-user message-rate limit (`flood_messages` within `flood_seconds`); opers -//! exempt. Recent message times live in the user's typed +//! exempt. A connection class can override the limit (`penaltythreshold` / +//! `commandrate`) and, with `fakelag=no`, have flooders disconnected instead of +//! rate-limited. Recent message times live in the user's typed //! [`crate::extensible::Extensible`] slot, so the state is freed when the user quits. +use crate::modules::connclass; use crate::module::{ModResult, Module}; use crate::server::{now, Server}; use crate::Uid; @@ -32,8 +35,11 @@ impl Module for Flood { _text: &str, ) -> ModResult { let now = now(); - let max = srv.conf_num("flood_messages", FLOOD_MAX); - let window = srv.conf_num("flood_seconds", FLOOD_WINDOW); + // a connection class may raise the limit and/or opt out of fake lag + let (cls_max, cls_window, fakelag) = + connclass::flood_over(srv, uid).unwrap_or((None, None, true)); + let max = cls_max.unwrap_or_else(|| srv.conf_num("flood_messages", FLOOD_MAX)); + let window = cls_window.unwrap_or_else(|| srv.conf_num("flood_seconds", FLOOD_WINDOW)); let (over, warn) = { let Some(u) = srv.users.get_mut(&uid) else { return ModResult::Passthru; @@ -50,6 +56,12 @@ impl Module for Flood { (over, warn) }; if over { + if !fakelag { + // fakelag disabled: disconnect the flooder instead of throttling + srv.send(uid, "ERROR :Closing link: (Excess flood)".to_string()); + srv.mark_quit(uid, "Excess flood".to_string()); + return ModResult::Deny; + } if warn { let nick = srv .users diff --git a/src/modules/ident.rs b/src/modules/ident.rs new file mode 100644 index 0000000..666aca0 --- /dev/null +++ b/src/modules/ident.rs @@ -0,0 +1,161 @@ +//! ident — optional RFC 1413 (Ident) lookups. When a connection class (or the +//! global `useident = yes`) asks for one, we ask the client's host (port 113) who +//! owns the connection; a confirmed reply becomes the visible username without the +//! `~` that marks an unverified ident. `requireident` refuses clients whose ident +//! can't be confirmed. The lookup runs on a short-lived worker thread (like the +//! resolver) and reports back as `Event::Ident`, so the core never blocks. + +use std::io::{Read, Write}; +use std::net::{IpAddr, SocketAddr, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use crate::ircd::Event; +use crate::modules::connclass; +use crate::server::Server; +use crate::users::ident_of; +use crate::Uid; + +/// Default per-lookup timeout; overridable with `ident_timeout` (seconds). +const IDENT_TIMEOUT: u64 = 5; +/// Cap on concurrent lookups so a connection flood can't spawn unbounded threads. +const MAX_ACTIVE: usize = 256; +static ACTIVE: AtomicUsize = AtomicUsize::new(0); + +/// Per-user lookup result stored on `User.ext`: `Some(name)` confirmed, `None` not. +struct IdentResult(Option); + +/// `(useident, requireident)` combining the client's class with the global default. +fn policy(s: &Server, uid: Uid) -> (bool, bool) { + let (cu, cr) = connclass::ident_policy(s, uid); + ( + cu || s.conf_bool("useident", false), + cr || s.conf_bool("requireident", false), + ) +} + +/// Start an ident lookup for a freshly-connected client if its class (or the global +/// config) wants one. Sets `ident_pending` to hold registration until the reply +/// arrives. Returns whether a lookup was started. +pub fn dispatch(s: &mut Server, uid: Uid) -> bool { + let (useident, requireident) = policy(s, uid); + if !useident && !requireident { + return false; + } + let (ip, their_port, our_port) = match s.users.get(&uid) { + Some(u) => (u.addr.ip(), u.addr.port(), u.port), + None => return false, + }; + if ACTIVE.fetch_add(1, Ordering::Relaxed) >= MAX_ACTIVE { + ACTIVE.fetch_sub(1, Ordering::Relaxed); + return false; // too many in flight: skip (treated as no ident) + } + let timeout = Duration::from_secs(s.conf_num("ident_timeout", IDENT_TIMEOUT)); + if let Some(u) = s.users.get_mut(&uid) { + u.ident_pending = true; + } + s.notice_star(uid, "Checking Ident"); + let tx = s.event_tx.clone(); + std::thread::spawn(move || { + let ident = lookup(ip, their_port, our_port, timeout); + ACTIVE.fetch_sub(1, Ordering::Relaxed); + let _ = tx.send(Event::Ident { uid, ident }); + }); + true +} + +/// The blocking RFC 1413 exchange: connect to `:113`, ask about the connection +/// pair, and return the confirmed username (unsanitised) or `None`. +fn lookup(ip: IpAddr, their_port: u16, our_port: u16, timeout: Duration) -> Option { + let mut stream = TcpStream::connect_timeout(&SocketAddr::new(ip, 113), timeout).ok()?; + stream.set_read_timeout(Some(timeout)).ok()?; + stream.set_write_timeout(Some(timeout)).ok()?; + // the query is ", " + let query = format!("{their_port}, {our_port}\r\n"); + stream.write_all(query.as_bytes()).ok()?; + let mut buf = Vec::new(); + let mut chunk = [0u8; 256]; + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if buf.len() > 512 || buf.contains(&b'\n') { + break; + } + } + Err(_) => break, + } + } + parse_reply(&String::from_utf8_lossy(&buf)) +} + +/// Parse an ident reply: `, : USERID : : `. +fn parse_reply(reply: &str) -> Option { + let fields: Vec<&str> = reply.split(':').collect(); + if fields.len() < 4 || !fields[1].trim().eq_ignore_ascii_case("USERID") { + return None; + } + let name = fields[3].trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +/// A lookup finished: stash the result and clear the registration hold. The result +/// is applied to the visible ident at registration (so a late USER can't clobber it). +pub fn on_result(s: &mut Server, uid: Uid, ident: Option) { + match &ident { + Some(name) => s.notice_star(uid, &format!("Received Ident response: {name}")), + None => s.notice_star(uid, "No Ident response"), + } + if let Some(u) = s.users.get_mut(&uid) { + *u.ext.get_or_insert_with(|| IdentResult(None)) = IdentResult(ident); + u.ident_pending = false; + } +} + +/// At registration, apply a confirmed ident (dropping the leading `~`) and enforce +/// `requireident`. Returns `Some(reason)` to reject. +pub fn finalize(s: &mut Server, uid: Uid) -> Option { + let confirmed = s + .users + .get(&uid) + .and_then(|u| u.ext.get::().map(|r| r.0.clone())); + if let Some(Some(name)) = confirmed { + let clean = ident_of(&name); // sanitised, no `~` + if let Some(u) = s.users.get_mut(&uid) { + u.ident = clean; + } + } + let (_, requireident) = policy(s, uid); + if requireident { + let unverified = s + .users + .get(&uid) + .map(|u| u.ident.is_empty() || u.ident.starts_with('~')) + .unwrap_or(true); + if unverified { + return Some("Your connection class requires a valid ident response".to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_userid_and_error() { + assert_eq!( + parse_reply("6193, 6667 : USERID : UNIX : reverse\r\n").as_deref(), + Some("reverse") + ); + assert_eq!(parse_reply("6193, 6667 : ERROR : NO-USER"), None); + assert_eq!(parse_reply("garbage"), None); + // case-insensitive reply type, trailing charset field tolerated + assert_eq!( + parse_reply("1,2 : userid : UNIX,US-ASCII : bob").as_deref(), + Some("bob") + ); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index f402d7f..b55c5bd 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -37,6 +37,7 @@ pub mod hashident; pub mod hidelist; pub mod hidemode; pub mod hidewhois; +pub mod ident; pub mod irccloudtags; pub mod jsonlog; pub mod jwt; diff --git a/src/modules/rpc/server.rs b/src/modules/rpc/server.rs index 0f30161..9d1c1a9 100644 --- a/src/modules/rpc/server.rs +++ b/src/modules/rpc/server.rs @@ -57,7 +57,10 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result, secure: bool, certfp: Option, + local_port: u16, ) { let uuid = self.next_uuid(); self.uuid_local.insert(uuid.clone(), uid); @@ -314,8 +315,10 @@ impl Server { account: None, signon: now(), addr, + port: local_port, registered: false, dns_pending: false, + ident_pending: false, waitpong: None, class: None, pass: None, @@ -358,13 +361,24 @@ impl Server { self.remove_user(uid, &reason); return; } + // push any per-class queue caps (recvq/hardsendq/softsendq) to the reactor + let caps = ( + crate::modules::connclass::recvq(self, uid), + crate::modules::connclass::hardsendq(self, uid), + crate::modules::connclass::softsendq(self, uid), + ); + if caps.0.is_some() || caps.1.is_some() || caps.2.is_some() { + if let Some(u) = self.users.get(&uid) { + u.out.set_limits(caps.0, caps.1, caps.2); + } + } - // Pre-registration connection notices. The ident-113 notices are cosmetic - // (ident is archaic and firewalled); the hostname lookup is real (see - // `resolver`) and its result arrives later as an Event. - self.notice_star(uid, "Checking Ident"); - self.notice_star(uid, "No Ident response"); - let do_rdns = self.resolve_hosts; + // ident: optionally ask the client's host who owns the connection (only when + // the class or global config wants it — see modules::ident). Holds + // registration via ident_pending until the reply arrives. + crate::modules::ident::dispatch(self, uid); + // a connection class may opt out of reverse-DNS (resolvehostnames=no) + let do_rdns = self.resolve_hosts && crate::modules::connclass::resolve_hostnames(self, uid); let zones = self.dnsbl_zones.clone(); // DNSBL runs if any zones are configured if do_rdns { self.notice_star(uid, "Looking up your hostname..."); @@ -1057,8 +1071,10 @@ mod tests { account: None, signon: 0, addr: "127.0.0.1:1".parse().unwrap(), + port: 6667, registered: true, dns_pending: false, + ident_pending: false, waitpong: None, class: None, pass: None, diff --git a/src/socketengine.rs b/src/socketengine.rs index ecdaaba..56525e5 100644 --- a/src/socketengine.rs +++ b/src/socketengine.rs @@ -26,19 +26,28 @@ use crate::ircd::Event; use crate::tls::TlsBackend; use crate::Uid; -/// Longest single line we'll buffer before dropping it (crude flood guard). -const MAX_LINE: usize = 16 * 1024; -/// Most bytes we'll queue to a slow client before dropping them (backpressure). -const MAX_WBUF: usize = 1 << 20; // 1 MiB +/// Default recvq: longest single line we'll buffer before dropping it. Overridable +/// globally (`max_line`) and per connection class (`recvq`). +pub const DEFAULT_MAX_LINE: usize = 16 * 1024; +/// Default hardsendq: most bytes we'll queue to a slow client before dropping them +/// and closing. Overridable globally (`max_sendq`) and per class (`hardsendq`). +pub const DEFAULT_MAX_SENDQ: usize = 1 << 20; // 1 MiB /// How long a TLS thread blocks on a read before draining its write queue. const TLS_POLL: Duration = Duration::from_millis(100); /// A queued output action the core hands the reactor: a line to write to a -/// connection, or a request to flush-then-close it (sent when the core drops the -/// [`OutSink`], e.g. on quit). +/// connection, a request to flush-then-close it (sent when the core drops the +/// [`OutSink`], e.g. on quit), or a per-connection queue-limit override (from the +/// assigned connection class). pub enum Out { Line(usize, String), Close(usize), + Limits { + token: usize, + recvq: Option, + hardsendq: Option, + softsendq: Option, + }, } /// The core's handle to one connection's output. Thread-model connections (TLS, @@ -68,6 +77,30 @@ impl OutSink { } } } + + /// Override this connection's queue limits (from its connection class). Only the + /// reactor (plaintext client) model honours these; thread-model connections (TLS, + /// links) use the global defaults. + pub fn set_limits( + &self, + recvq: Option, + hardsendq: Option, + softsendq: Option, + ) { + if let OutSink::Reactor { token, tx, waker } = self { + if tx + .send(Out::Limits { + token: *token, + recvq, + hardsendq, + softsendq, + }) + .is_ok() + { + let _ = waker.wake(); + } + } + } } impl Drop for OutSink { @@ -94,8 +127,13 @@ struct Conn { rbuf: Vec, // bytes read, awaiting a newline wbuf: Vec, // bytes queued to write wpos: usize, // how far into wbuf we've written + want_read: bool, want_write: bool, - closing: bool, // flush wbuf, then close + closing: bool, // flush wbuf, then close + paused: bool, // reads paused (softsendq backpressure); ⟺ pending > softsendq + recvq: usize, // max buffered unterminated-line bytes before dropping + hardsendq: usize, // max queued output bytes before dropping + closing + softsendq: usize, // queued output above this pauses reads until it drains } impl Conn { @@ -104,9 +142,35 @@ impl Conn { } } +/// Reregister `t`'s epoll interest to match its current read/write intent, but only +/// if it changed. A paused connection drops READABLE (so the client stops being +/// serviced) while keeping WRITABLE to drain the backlog that paused it. +fn set_interest(poll: &mut Poll, c: &mut Conn, t: usize) { + let want_read = !c.paused; + let want_write = !c.wbuf.is_empty() || c.paused; + if want_read == c.want_read && want_write == c.want_write { + return; + } + c.want_read = want_read; + c.want_write = want_write; + let interest = match (want_read, want_write) { + (true, true) => Interest::READABLE | Interest::WRITABLE, + (false, true) => Interest::WRITABLE, + // never both-false (paused ⟹ backlog ⟹ want_write); READABLE is a safe floor + _ => Interest::READABLE, + }; + let _ = poll.registry().reregister(&mut c.stream, Token(t), interest); +} + /// Run the client plaintext reactor on this thread. `listener` is an already-bound /// mio listener (bound in `main` so a bind failure is fatal and fails fast). -pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc) { +pub fn run_reactor( + mut listener: MioListener, + core: Sender, + counter: Arc, + max_line: usize, + max_sendq: usize, +) { let mut poll = match Poll::new() { Ok(p) => p, Err(e) => { @@ -158,6 +222,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< let addr = stream .peer_addr() .unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap()); + let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0); conns.insert( token, Conn { @@ -166,8 +231,13 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< rbuf: Vec::new(), wbuf: Vec::new(), wpos: 0, + want_read: true, want_write: false, closing: false, + paused: false, + recvq: max_line, + hardsendq: max_sendq, + softsendq: max_sendq, }, ); let out = OutSink::Reactor { @@ -183,6 +253,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< sock: None, secure: false, certfp: None, + local_port, link: false, outbound: false, websocket: false, @@ -203,8 +274,8 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< match msg { Out::Line(t, line) => { if let Some(c) = conns.get_mut(&t) { - if c.pending() + line.len() + 2 > MAX_WBUF { - // slow client: drop queued data and close + if c.pending() + line.len() + 2 > c.hardsendq { + // hardsendq: drop queued data and close c.wbuf.clear(); c.wpos = 0; c.closing = true; @@ -215,6 +286,11 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< } c.wbuf.extend_from_slice(line.as_bytes()); c.wbuf.extend_from_slice(b"\r\n"); + // softsendq: over the soft cap, stop reading + // their commands until the backlog drains + if c.pending() > c.softsendq { + c.paused = true; + } } touched.insert(t); } @@ -225,6 +301,24 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< touched.insert(t); } } + Out::Limits { + token, + recvq, + hardsendq, + softsendq, + } => { + if let Some(c) = conns.get_mut(&token) { + if let Some(v) = recvq { + c.recvq = v; + } + if let Some(v) = hardsendq { + c.hardsendq = v; + } + if let Some(v) = softsendq { + c.softsendq = v; + } + } + } } } for t in touched { @@ -267,7 +361,7 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: lines.push((c.uid, l.to_string())); } } - if c.rbuf.len() > MAX_LINE { + if c.rbuf.len() > c.recvq { c.rbuf.clear(); // overlong line with no newline: drop it } } @@ -290,10 +384,13 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: } } -/// Write as much of `t`'s queued output as the socket accepts, adjust WRITABLE -/// interest, and close once a `closing` connection's buffer is drained. +/// Write as much of `t`'s queued output as the socket accepts, adjust epoll +/// interest, and close once a `closing` connection's buffer is drained. If the +/// backlog dropped back under softsendq, un-pause reads and catch up (edge-triggered: +/// data that arrived while paused won't re-fire, so read it here). fn flush_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: &Sender) { let mut close = false; + let mut unpaused = false; if let Some(c) = conns.get_mut(&t) { while c.wpos < c.wbuf.len() { match c.stream.write(&c.wbuf[c.wpos..]) { @@ -311,25 +408,19 @@ fn flush_conn(poll: &mut Poll, conns: &mut HashMap, t: usize, core: c.wbuf.clear(); c.wpos = 0; } - // re-arm WRITABLE only while there's a backlog (edge-triggered) - let want = !c.wbuf.is_empty(); - if want != c.want_write { - c.want_write = want; - let interest = if want { - Interest::READABLE | Interest::WRITABLE - } else { - Interest::READABLE - }; - let _ = poll - .registry() - .reregister(&mut c.stream, Token(t), interest); + if c.paused && c.pending() <= c.softsendq { + c.paused = false; + unpaused = true; } + set_interest(poll, c, t); if c.closing && c.wbuf.is_empty() { close = true; } } if close { close_conn(poll, conns, t, core); + } else if unpaused { + read_conn(poll, conns, t, core); // catch reads missed while paused } } @@ -354,6 +445,7 @@ pub fn accept_loop( tls: Option>, counter: Arc, link: bool, + max_line: usize, ) { for conn in listener.incoming() { let Ok(stream) = conn else { continue }; @@ -361,6 +453,7 @@ pub fn accept_loop( continue; }; let _ = stream.set_nodelay(true); + let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0); let uid = counter.fetch_add(1, Ordering::Relaxed); match &tls { @@ -381,6 +474,7 @@ pub fn accept_loop( sock: Some(shutdown), secure: false, certfp: None, + local_port, link, outbound: false, websocket: false, @@ -390,12 +484,12 @@ pub fn accept_loop( break; // core gone } let core_tx = core.clone(); - thread::spawn(move || reader_loop(reader, uid, core_tx)); + thread::spawn(move || reader_loop(reader, uid, core_tx, max_line)); } Some(backend) => { let backend = backend.clone(); let core_tx = core.clone(); - thread::spawn(move || tls_conn(backend, stream, uid, addr, core_tx, link)); + thread::spawn(move || tls_conn(backend, stream, uid, addr, core_tx, link, max_line)); } } } @@ -403,7 +497,7 @@ pub fn accept_loop( /// Dial an outbound server link and wire it to the core (an `outbound` link that /// introduces itself first). Used for auto-connecting to a configured uplink. -pub fn connect_link(addr: &str, core: Sender, counter: Arc) { +pub fn connect_link(addr: &str, core: Sender, counter: Arc, max_line: usize) { let stream = match TcpStream::connect(addr) { Ok(s) => s, Err(e) => { @@ -430,6 +524,7 @@ pub fn connect_link(addr: &str, core: Sender, counter: Arc) { sock: Some(shutdown), secure: false, certfp: None, + local_port: 0, link: true, outbound: true, websocket: false, @@ -438,12 +533,12 @@ pub fn connect_link(addr: &str, core: Sender, counter: Arc) { { return; } - thread::spawn(move || reader_loop(reader, uid, core)); + thread::spawn(move || reader_loop(reader, uid, core, max_line)); } // --- plaintext link: two blocking threads ----------------------------------- -fn reader_loop(stream: TcpStream, uid: Uid, core: Sender) { +fn reader_loop(stream: TcpStream, uid: Uid, core: Sender, max_line: usize) { let mut buf = BufReader::new(stream); let mut line = String::new(); loop { @@ -451,7 +546,7 @@ fn reader_loop(stream: TcpStream, uid: Uid, core: Sender) { match buf.read_line(&mut line) { Ok(0) => break, // EOF Ok(_) => { - if line.len() > MAX_LINE { + if line.len() > max_line { continue; } let l = line.trim_end_matches(['\r', '\n']); @@ -493,11 +588,13 @@ fn tls_conn( addr: SocketAddr, core: Sender, link: bool, + max_line: usize, ) { // Keep a raw handle so the core can force the socket shut later. let Ok(shutdown) = stream.try_clone() else { return; }; + let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0); let mut conn = match backend.accept(stream) { Ok(c) => c, Err(_) => { @@ -515,6 +612,7 @@ fn tls_conn( sock: Some(shutdown), secure: true, certfp, + local_port, link, outbound: false, websocket: false, @@ -548,7 +646,7 @@ fn tls_conn( break 'io; } } - if acc.len() > MAX_LINE { + if acc.len() > max_line { acc.clear(); // overlong line with no newline: drop it } } diff --git a/src/users.rs b/src/users.rs index 43a8bc2..73493a3 100644 --- a/src/users.rs +++ b/src/users.rs @@ -290,8 +290,10 @@ pub struct User { pub account: Option, // logged-in account name (set by services) pub signon: u64, // unix secs at registration (WHOIS 317) pub addr: SocketAddr, + pub port: u16, // listener port the client connected to (connectclass port=, ident) pub registered: bool, pub dns_pending: bool, // holding registration for a reverse-DNS lookup + pub ident_pending: bool, // holding registration for an ident (RFC1413) lookup pub waitpong: Option, // conn_waitpong: cookie the client must PONG before registering pub class: Option, // connectclass: assigned connection class name pub pass: Option, // password sent via PASS (for connectclass passwords) diff --git a/src/websocket.rs b/src/websocket.rs index bf3fe0b..258f2a1 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -241,6 +241,7 @@ fn ws_session( .unwrap_or(addr); let secure = tls_secure || hs.secure; let send_opcode = if hs.binary { OP_BIN } else { OP_TEXT }; + let local_port = shutdown.local_addr().map(|a| a.port()).unwrap_or(0); let (out_tx, out_rx) = std::sync::mpsc::channel::(); if core @@ -251,6 +252,7 @@ fn ws_session( sock: Some(shutdown), secure, certfp: None, + local_port, link: false, outbound: false, websocket: true,