//! The core: owns the [`Server`] state, the command table and the module list, //! and turns a stream of [`Event`]s into IRC. Runs on one thread, so no state is //! ever locked. use std::collections::HashMap; use std::net::{SocketAddr, TcpStream}; use std::sync::mpsc::{Receiver, Sender}; use crate::command::Command; use crate::config::Config; use crate::coremods::command_table; use crate::message; use crate::module::{Hook, ModResult, Module}; use crate::numeric::{ERR_NEEDMOREPARAMS, ERR_NOTREGISTERED, ERR_PASSWDMISMATCH, ERR_UNKNOWNCOMMAND}; use crate::server::Server; use crate::socketengine::OutSink; use crate::Uid; /// What the I/O threads hand to the core. pub enum Event { Connect { uid: Uid, addr: SocketAddr, out: OutSink, 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 }, Line { uid: Uid, line: String, }, Disconnect { uid: Uid, }, /// A client's connect-time DNS work finished: the reverse-DNS hostname /// (`None` = none confirmed) and the DNSBL outcome. ResolvedHost { uid: Uid, 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 /// failure. HttpResult { uid: Uid, tag: String, status: u16, body: String, }, /// An inbound JSON-RPC request from the HTTP control interface (see /// `crate::modules::rpc`). Handled inline on the core thread; the reply JSON is /// sent back to the waiting httpd thread over `reply`. RpcRequest { method: String, params: String, id: String, reply: std::sync::mpsc::Sender, }, /// Background timer tick — drives ping/idle timeouts. Tick, } /// Insert an extra IRCv3 tag into a wire line's tag block, creating the `@…` /// block if the line has none. Used to fold `label=`/`batch=` onto captured lines. fn with_extra_tag(line: &str, tag: &str) -> String { if let Some(rest) = line.strip_prefix('@') { match rest.split_once(' ') { Some((tags, body)) => format!("@{tags};{tag} {body}"), None => format!("@{rest};{tag}"), } } else { format!("@{tag} {line}") } } pub struct Ircd { server: Server, commands: HashMap<&'static str, Box>, modules: Vec>, } impl Ircd { pub fn new( cfg: Config, event_tx: Sender, conn_counter: std::sync::Arc, ) -> Ircd { let mut server = Server::new(cfg, event_tx, conn_counter); server.load_xlines(); // restore persisted bans crate::modules::metadata::load(&mut server); // restore channel metadata crate::modules::reputation::load(&mut server); // restore per-IP reputation crate::modules::geoip::init(&mut server); // load the GeoIP database Ircd { server, commands: command_table(), modules: crate::modules::default_modules(), } } /// Run until the event channel closes (i.e. the listener is gone). pub fn run(mut self, rx: Receiver) { for ev in rx { match ev { Event::Connect { uid, addr, out, sock, secure, certfp, local_port, link, outbound, websocket, } => { if link { self.server.add_link(uid, addr, out, sock, outbound); } else { 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; } } } } Event::Line { uid, line } => { if self.server.links.contains_key(&uid) { if let Some(msg) = message::parse(&line) { self.server.on_link(uid, &msg); } } else { self.on_line(uid, &line); } } Event::Disconnect { uid } => { if self.server.links.contains_key(&uid) { self.server.close_link(uid, "Connection closed"); } else { self.quit_user(uid, "Connection closed"); } } Event::ResolvedHost { uid, host, dnsbl } => { self.server.on_resolved(uid, host, dnsbl); // now that the notice block has printed, replay the handshake // lines we held while resolving for line in self.server.take_deferred(uid) { if !self.server.users.contains_key(&uid) { break; // a replayed QUIT/ban already dropped them } self.on_line(uid, &line); } self.try_register(uid); // DNS may have been the last thing we waited on } Event::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, status, body, } => { if let Some(detail) = tag.strip_prefix("acctreg:") { crate::modules::account_registration::on_http_result( &mut self.server, uid, detail, status, &body, ); } } Event::RpcRequest { method, params, id, reply, } => { let resp = crate::modules::rpc::dispatch(&mut self.server, &method, ¶ms, &id); let _ = reply.send(resp); } Event::Tick => self.on_tick(), } self.drain_hooks(); } } fn on_line(&mut self, uid: Uid, line: &str) { let Some(msg) = message::parse(line) else { return; }; // stash this line's client-only tags for TAGMSG / PRIVMSG relay self.server.line_ctags = msg.ctags.clone(); // any valid line means the connection is alive if let Some(u) = self.server.users.get_mut(&uid) { u.last_active = crate::server::now(); u.ping_sent = false; } let registered = self .server .users .get(&uid) .map(|u| u.registered) .unwrap_or(false); // Hold the handshake while the connect-time DNS/DNSBL lookups run, so the // "*** ..." notices print as one block; replayed in Event::ResolvedHost. if !registered && self.server.defer_if_resolving(uid, line) { return; } // labeled-response: if the client tagged this command with `label` and // negotiated the cap, capture its own replies and wrap them with the label. let label = msg.label.clone().filter(|_| { self.server .users .get(&uid) .map(|u| u.caps.labeled_response) .unwrap_or(false) }); if let Some(label) = label { *self.server.label_capture.borrow_mut() = Some((uid, Vec::new())); self.dispatch(uid, &msg, registered); let lines = self .server .label_capture .borrow_mut() .take() .map(|(_, l)| l) .unwrap_or_default(); self.emit_labeled(uid, &label, lines); } else { self.dispatch(uid, &msg, registered); } } /// Run one parsed command: module gates, handler dispatch, post-hooks, and the /// registration/quit follow-ups. Output goes through `Server::send`, so it's /// transparently captured when a labeled command wraps this call. fn dispatch(&mut self, uid: Uid, msg: &message::Message, registered: bool) { // abbreviation: with `abbreviation = yes`, an unknown verb that is a unique // prefix of exactly one command resolves to it (e.g. WHOI -> WHOIS). let typed = msg.command.as_str(); let cmd: &str = if self.commands.contains_key(typed) || !(registered && self.server.conf_bool("abbreviation", false)) { typed } else { let mut it = self.commands.keys().filter(|k| k.starts_with(typed)); match (it.next(), it.next()) { (Some(full), None) => full, // exactly one match _ => typed, // none or ambiguous } }; // SHUN: a shunned user stays connected but their commands are silently // dropped — except keepalive and quit, so they still time out cleanly. if registered && !matches!(cmd, "PING" | "PONG" | "QUIT") && self.server.user_shunned(uid) { return; } // draft/multiline: a PRIVMSG/NOTICE tagged for an open batch is buffered, // not delivered on its own — it's assembled and sent when the BATCH closes. if let Some(bref) = &msg.batch { if matches!(cmd, "PRIVMSG" | "NOTICE") && msg.params.len() >= 2 { let consumed = crate::modules::multiline::accumulate( &mut self.server, uid, bref, cmd == "NOTICE", &msg.params[1], msg.concat, ); if consumed { return; } } } // module pre-command gate for m in &mut self.modules { if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny { return; } } // message pre-hook (PRIVMSG/NOTICE) if matches!(cmd, "PRIVMSG" | "NOTICE") && msg.params.len() >= 2 { let (target, text) = (msg.params[0].clone(), msg.params[1].clone()); for m in &mut self.modules { if m.on_pre_message(&mut self.server, uid, &target, &text) == ModResult::Deny { return; } } } let Some(handler) = self.commands.get(cmd) else { if registered { // config `showfile = ` streams a text file as its own // command (e.g. /RULES). if crate::modules::showfile::maybe_show(&mut self.server, uid, cmd) { return; } // config `alias = `: `alias = NS NickServ` makes // `/NS help` -> PRIVMSG NickServ :help if let Some(target) = self.server.conf_all("alias").iter().find_map(|line| { let mut it = line.split_whitespace(); match (it.next(), it.next()) { (Some(n), Some(t)) if n.eq_ignore_ascii_case(cmd) => Some(t.to_string()), _ => None, } }) { if !msg.params.is_empty() { let text = msg.params.join(" "); crate::coremods::core_message::deliver( &mut self.server, uid, &[target, text], false, ); } return; } self.server .numeric(uid, ERR_UNKNOWNCOMMAND, &format!("{cmd} :Unknown command")); } return; }; if !registered && !handler.before_reg() { self.server .numeric(uid, ERR_NOTREGISTERED, ":You have not registered"); return; } if msg.params.len() < handler.min_params() { self.server.numeric( uid, ERR_NEEDMOREPARAMS, &format!("{cmd} :Not enough parameters"), ); return; } let _ = handler.handle(&mut self.server, uid, &msg.params); for m in &mut self.modules { m.on_post_command(&mut self.server, uid, cmd); } // a command may have asked to quit (QUIT) if let Some(reason) = self.server.take_quit(uid) { self.quit_user(uid, &reason); return; } // …or completed the registration handshake if !registered { self.try_register(uid); } } /// Emit a labeled command's captured replies (labeled-response): `ACK` if it /// produced none, the single line label-tagged if one, else a `BATCH`-wrapped /// group. Runs after the capture is taken, so these go straight to the wire. fn emit_labeled(&mut self, uid: Uid, label: &str, lines: Vec) { let server = self.server.name.clone(); match lines.len() { 0 => self .server .send(uid, format!("@label={label} :{server} ACK")), 1 => { let l = with_extra_tag(&lines[0], &format!("label={label}")); self.server.send(uid, l); } _ => { let bref = self.server.next_msgid().replace('-', ""); // batch ref: alnum only self.server.send( uid, format!("@label={label} :{server} BATCH +{bref} labeled-response"), ); for l in lines { self.server .send(uid, with_extra_tag(&l, &format!("batch={bref}"))); } self.server.send(uid, format!(":{server} BATCH -{bref}")); } } } /// 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 && !u.ident_pending && u.waitpong.is_none() }) .unwrap_or(false); if ready { self.complete_registration(uid); } } fn complete_registration(&mut self, uid: Uid) { for m in &mut self.modules { if m.on_user_register(&mut self.server, uid) == ModResult::Deny { self.server.send( uid, "ERROR :Closing link (registration refused)".to_string(), ); self.server.remove_user(uid, "Registration refused"); return; } } // x-line: refuse a banned host / ip before welcoming let (ident, host, ip) = { let u = &self.server.users[&uid]; (u.ident.clone(), u.host.clone(), u.addr.ip().to_string()) }; if let Some(reason) = self.server.matched_xline(&ident, &host, &ip) { self.server .send(uid, format!("ERROR :Closing link: ({reason})")); 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; } // R-line: refuse a user whose nick!user@host realname matches a banned regex // (checked after ident is finalised so the matchtext is the real username). let rl = { let u = &self.server.users[&uid]; self.server.matched_rline( &u.nick, &u.ident, &u.host, &u.addr.ip().to_string(), &u.realname, ) }; if let Some(reason) = rl { 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 .numeric(uid, ERR_PASSWDMISMATCH, &format!(":{reason}")); self.server .send(uid, format!("ERROR :Closing link: ({reason})")); self.server.remove_user(uid, &reason); return; } self.server.welcome(uid); } fn quit_user(&mut self, uid: Uid, reason: &str) { if !self.server.users.contains_key(&uid) { return; } let registered = self.server.users[&uid].registered; if registered { // fire the quit hook while the user still exists for m in &mut self.modules { m.on_user_quit(&mut self.server, uid, reason); } } self.server.remove_user(uid, reason); } /// Background timer: PING idle clients, reap the unresponsive and the /// never-registered. fn on_tick(&mut self) { self.server.ping_links(); // keepalive on every server link self.server.purge_xlines(); // drop expired server bans self.server.purge_tbans(); // lift expired timed channel bans (TBAN) for m in &mut self.modules { m.on_tick(&mut self.server); // timer-driven modules (e.g. reputation) } let now = crate::server::now(); let (to_ping, to_quit) = self.server.idle_check(now); for uid in to_ping { let token = self.server.name.clone(); self.server.send(uid, format!("PING :{token}")); if let Some(u) = self.server.users.get_mut(&uid) { u.ping_sent = true; } } for uid in to_quit { let reg = self .server .users .get(&uid) .map(|u| u.registered) .unwrap_or(false); let reason = if reg { "Ping timeout" } else { "Registration timeout" }; self.server .send(uid, format!("ERROR :Closing link: ({reason})")); self.quit_user(uid, reason); } } /// Fire queued notify-hooks. Draining a queue (not iterating in place) lets a /// hook enqueue more work (e.g. a module forcing a join) without surprises. fn drain_hooks(&mut self) { while let Some(hook) = self.server.events.pop_front() { match hook { Hook::Connect(uid) => { for m in &mut self.modules { m.on_user_connect(&mut self.server, uid); } // burst to links after modules (so the cloak is already set) self.server.introduce_to_links(uid); } Hook::Join(uid, chan) => { for m in &mut self.modules { m.on_join(&mut self.server, uid, &chan); } } Hook::Part(uid, chan, reason) => { for m in &mut self.modules { m.on_part(&mut self.server, uid, &chan, &reason); } } Hook::Quit(uid, reason) => { for m in &mut self.modules { m.on_user_quit(&mut self.server, uid, &reason); } } } } } }