use std::borrow::Cow; use std::sync::Arc; use anyhow::Result; use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; use tokio::sync::{mpsc, Mutex}; use crate::engine::db::Db; use crate::engine::Engine; use crate::proto::{NetAction, Protocol}; // Cap on one uplink line (well above IRC's 512 + generous room for message tags), // so a misbehaving uplink can't grow an unbounded read buffer. const MAX_UPLINK_LINE: usize = 16 * 1024; // Read one newline-delimited uplink line, bounded and decoded UTF-8-LOSSILY. IRC is // byte-oriented and any user's PRIVMSG relayed to a service can carry raw non-UTF-8 // bytes; the old `.lines()` returned an Err on those, which propagated out of the // reactor and CRASHED the whole daemon. `None` on EOF; io::Error only on a genuine // read failure. Bytes past the cap are dropped until the newline. async fn read_uplink_line(reader: &mut R, max: usize) -> std::io::Result> { let mut buf = Vec::new(); let mut byte = [0u8; 1]; loop { if reader.read(&mut byte).await? == 0 { return Ok((!buf.is_empty()).then(|| decode_uplink_line(&buf))); } if byte[0] == b'\n' { return Ok(Some(decode_uplink_line(&buf))); } if buf.len() < max { buf.push(byte[0]); } } } fn decode_uplink_line(buf: &[u8]) -> String { String::from_utf8_lossy(buf.strip_suffix(b"\r").unwrap_or(buf)).into_owned() } // NickServ commands whose arguments are a password and must never be logged. const SECRET_CMDS: &[&str] = &[ "identify", "id", "login", "auth", "register", "regain", "ghost", "release", "recover", "setpass", "spassword", "resetpass", "confirm", "sidentify", ]; /// Scrub credentials from a raw IRC line before it is debug-logged: NickServ /// identify/register/password arguments and SASL payloads. Best-effort and /// deliberately over-redacting — a logged line must never carry a secret. fn redact(line: &str) -> Cow<'_, str> { // A password carried in a PRIVMSG/NOTICE/SQUERY message trailer. for kw in [" PRIVMSG ", " NOTICE ", " SQUERY "] { if let Some(p) = line.find(kw) { let after = p + kw.len(); if let Some(c) = line[after..].find(" :") { let tstart = after + c + 2; // Tokenise the trailer exactly like the dispatcher (`split_whitespace`), // so a TAB or a leading/collapsed space can't sneak a credential past // redaction while still reaching the command handler. let mut words = line[tstart..].split_whitespace(); let mut cmd = words.next().unwrap_or("").to_ascii_lowercase(); // "NS ..." / "NICKSERV ..." — unwrap one level. if matches!(cmd.as_str(), "ns" | "nickserv") { cmd = words.next().unwrap_or("").to_ascii_lowercase(); } let is_set_password = cmd == "set" && words.next().map(|w| w.eq_ignore_ascii_case("password")).unwrap_or(false); // Over-redact the whole trailer when the command is credential-bearing. if SECRET_CMDS.contains(&cmd.as_str()) || is_set_password { return format!("{} :[REDACTED]", line[..tstart].trim_end_matches(" :")).into(); } } } } // SASL: "AUTHENTICATE " and server "... SASL C|S ". The // payload token is the credential; mechanism/control tokens are harmless. if let Some(p) = line.find("AUTHENTICATE ") { let d = p + "AUTHENTICATE ".len(); let tok = line[d..].split(' ').next().unwrap_or(""); if !matches!(tok, "+" | "*" | "") && !tok.starts_with("PLAIN") && !tok.starts_with("EXTERNAL") && !tok.starts_with("SCRAM") { return format!("{}[REDACTED]", &line[..d]).into(); } } if let Some(p) = line.find(" SASL ") { for mode in [" C ", " S "] { if let Some(m) = line[p..].find(mode) { let d = p + m + mode.len(); let tok = line[d..].split(' ').next().unwrap_or(""); if !tok.is_empty() && tok != "+" { return format!("{}[REDACTED]", &line[..d]).into(); } } } } Cow::Borrowed(line) } // One uplink session: connect, handshake + burst, then translate lines forever. // The engine is shared with the gossip layer, so it is locked per operation and // never held across the registration key-stretching await. #[allow(clippy::too_many_arguments)] // the link driver legitimately wires up many collaborators pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver, irc_tx: mpsc::UnboundedSender, email: Option, keycard: Option, dict_server: Option) -> Result<()> { let stream = TcpStream::connect(addr).await?; // Disable Nagle: service replies are small multi-line bursts, and without this // the last segment of a reply is held ~40ms waiting on a delayed ACK, so a // HELP visibly lags before it lands. Every ircd sets this on every socket. stream.set_nodelay(true)?; let (read, mut write) = stream.into_split(); let mut reader = BufReader::new(read); for line in proto.handshake() { send(&mut write, &line).await?; } let startup = engine.lock().await.startup_actions(); for action in startup { for line in proto.serialize(&action) { send(&mut write, &line).await?; } } loop { tokio::select! { // A line from the uplink: translate it and answer. line = read_uplink_line(&mut reader, MAX_UPLINK_LINE) => { let Some(line) = line? else { break }; tracing::debug!(dir = "<<", line = %redact(&line)); for event in proto.parse(&line) { let actions = engine.lock().await.handle(event); for action in actions { let outs = match action { // Registration: derive the password off the reactor so the // ~1s of key stretching can't stall the link, after a cheap // gate that rejects taken names and rate-limits floods. NetAction::DeferRegister { account, password, email, reply } => { let pre = engine.lock().await.pre_register_check(&account, &reply); match pre { Some(rejection) => rejection, None => { let iterations = engine.lock().await.scram_iterations(); let creds = tokio::task::spawn_blocking(move || { Db::derive_credentials(&password, iterations) }) .await?; engine.lock().await.complete_register(&account, creds, email, reply) } } } // Password change: same off-thread derivation as register. NetAction::DeferPassword { account, password, agent, uid } => { let iterations = engine.lock().await.scram_iterations(); let creds = tokio::task::spawn_blocking(move || { Db::derive_credentials(&password, iterations) }) .await?; engine.lock().await.complete_password_change(&account, creds, &agent, &uid) } // Password verify (IDENTIFY / SASL PLAIN): run the ~1s // PBKDF2 off the reactor, then finish under the lock. NetAction::DeferAuthenticate { verifier, password, then } => { let ok = tokio::task::spawn_blocking(move || { crate::engine::scram::verify_plain(crate::engine::scram::Hash::Sha256, &verifier, &password) }) .await?; engine.lock().await.complete_authenticate(ok, then) } NetAction::DeferKeycard { token, account, then } => { // Redeem a website login keycard fully OFF the reactor loop: the // localhost HTTP round-trip can take up to ~7s on a slow Django, // and inline-awaiting it here froze ALL uplink processing for that // long. Keycards arrive mid-SASL (no user commands pending), so // completing asynchronously via irc_tx can't reorder anything. A // redeem panic now fails the login instead of crashing the daemon. let (kc, engine2, tx) = (keycard.clone(), engine.clone(), irc_tx.clone()); tokio::spawn(async move { let ok = tokio::task::spawn_blocking(move || crate::keycard::redeem(kc.as_ref(), &account, &token)) .await .unwrap_or(false); let actions = engine2.lock().await.complete_authenticate(ok, then); for a in actions { let _ = tx.send(a); } }); Vec::new() } // OperServ REHASH: re-read config.toml and apply the // reloadable settings live, reporting back to the oper. NetAction::Rehash { requester, agent } => { engine.lock().await.rehash(&requester, &agent) } action => vec![action], }; for act in outs { if let NetAction::SendEmail { to, subject, text, html } = act { dispatch_email(&email, to, subject, text, html); continue; } if let NetAction::DictLookup { speak_as, target, database, label, query } = act { dispatch_dict(&dict_server, &irc_tx, speak_as, target, database, label, query); continue; } if let NetAction::Shutdown { restart, reason } = act { { let mut e = engine.lock().await; e.persist_stats(); e.persist_incidents(); } let _ = write.flush().await; shutdown(restart, &reason); } for out in proto.serialize(&act) { send(&mut write, &out).await?; } } } } } // A services-initiated action (e.g. a forced logout after a lost // registration conflict), pushed by the engine from the gossip path. Some(action) = irc_rx.recv() => { if let NetAction::SendEmail { to, subject, text, html } = action { dispatch_email(&email, to, subject, text, html); } else if let NetAction::Shutdown { restart, reason } = action { { let mut e = engine.lock().await; e.persist_stats(); e.persist_incidents(); } let _ = write.flush().await; shutdown(restart, &reason); } else { for out in proto.serialize(&action) { send(&mut write, &out).await?; } } } } } tracing::warn!("uplink closed the connection"); Ok(()) } // Run a DictServ lookup off the reactor and speak the result back. The DICT round // trip (up to a few seconds) must never touch the engine lock; the reply is pushed // through irc_tx so the link loop writes it like any services-initiated action. A // #channel target is spoken by the assigned bot (privmsg), a user gets a notice. fn dispatch_dict(server: &Option, irc_tx: &mpsc::UnboundedSender, speak_as: String, target: String, database: String, label: String, query: String) { let tx = irc_tx.clone(); let server = server.clone(); tokio::spawn(async move { // A `wikt-` database routes to Wiktionary (rich monolingual // definitions) instead of the DICT server; its blocking HTTP call runs on // the blocking pool so it never stalls the reactor. let result = if let Some(lang) = database.strip_prefix("wikt-") { let (lang, word) = (lang.to_string(), query.clone()); tokio::task::spawn_blocking(move || crate::wiktionary::lookup(&lang, &word)) .await .unwrap_or_else(|_| "the dictionary is unavailable right now.".to_string()) } else if let Some(server) = server { crate::dict::lookup(&server, &database, &query).await } else { return tracing::warn!("dict lookup requested but no server configured"); }; let text = format!("\x02{query}\x02 ({label}): {result}"); let action = if target.starts_with('#') || target.starts_with('&') { NetAction::Privmsg { from: speak_as, to: target, text } } else { NetAction::Notice { from: speak_as, to: target, text } }; let _ = tx.send(action); }); } // Fire off an email if email is configured: pipe an RFC822 message to the mail // command on a spawned task, so a slow MTA never stalls the link. With an HTML // body it's sent as multipart/alternative (HTML + plaintext fallback). fn dispatch_email(email: &Option, to: String, subject: String, text: String, html: Option) { let Some(email) = email.clone() else { return tracing::warn!(%to, "email requested but not configured"); }; tokio::spawn(async move { // Hard backstop against header injection: never let a control char reach // the raw SMTP header block, whatever the caller did. Bodies (text/html) // may legitimately contain newlines, so only the header values are checked. if [to.as_str(), subject.as_str(), email.from.as_str()].iter().any(|s| s.chars().any(char::is_control)) { return tracing::warn!(%to, "refusing to send an email with control characters in a header"); } let headers = format!("From: {}\r\nTo: {to}\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\n", email.from); let msg = match html { Some(html) => { // Random per-message boundary so body content can never be mistaken // for the delimiter. let mut rb = [0u8; 8]; rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut rb); let b = format!("echo-bnd-{:016x}", u64::from_le_bytes(rb)); format!( "{headers}Content-Type: multipart/alternative; boundary=\"{b}\"\r\n\r\n\ --{b}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{text}\r\n\ --{b}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{html}\r\n--{b}--\r\n" ) } None => format!("{headers}Content-Type: text/plain; charset=utf-8\r\n\r\n{text}\r\n"), }; let child = tokio::process::Command::new("sh") .arg("-c") .arg(&email.command) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn(); let mut child = match child { Ok(c) => c, Err(e) => return tracing::warn!(%e, "mail command failed to start"), }; if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(msg.as_bytes()).await; let _ = stdin.shutdown().await; } let _ = child.wait().await; }); } // Stop the process on an operator's SHUTDOWN/RESTART. Every committed change is // already fsync'd, so exiting loses nothing. A restart exits non-zero so a // supervisor configured to restart-on-failure (systemd Restart=on-failure) // brings us straight back; a plain shutdown exits cleanly and stays down. fn shutdown(restart: bool, reason: &str) -> ! { if restart { tracing::info!(%reason, "operator requested restart, exiting for supervisor to respawn"); std::process::exit(2); } tracing::info!(%reason, "operator requested shutdown, exiting"); std::process::exit(0); } async fn send(write: &mut (impl AsyncWriteExt + Unpin), line: &str) -> Result<()> { tracing::debug!(dir = ">>", line = %redact(line)); write.write_all(line.as_bytes()).await?; write.write_all(b"\r\n").await?; Ok(()) } #[cfg(test)] mod tests { use super::redact; #[test] fn decode_uplink_line_is_lossy_and_strips_cr() { assert_eq!(super::decode_uplink_line(b"PING abc\r"), "PING abc"); // A non-UTF-8 line must decode (lossily), not error — the old `.lines()` // returned Err here and crashed the whole daemon. assert_eq!(super::decode_uplink_line(b"PRIVMSG x :\xff\xfe hi"), "PRIVMSG x :\u{fffd}\u{fffd} hi"); } #[tokio::test] async fn read_uplink_line_survives_bad_utf8_and_bounds_length() { use tokio::io::BufReader; let data: Vec = b"NICK a\r\nPRIVMSG NickServ :\xff\xff q\nQUIT\n".to_vec(); let mut r = BufReader::new(&data[..]); let cap = super::MAX_UPLINK_LINE; assert_eq!(super::read_uplink_line(&mut r, cap).await.unwrap(), Some("NICK a".to_string())); assert_eq!(super::read_uplink_line(&mut r, cap).await.unwrap(), Some("PRIVMSG NickServ :\u{fffd}\u{fffd} q".to_string()), "bad UTF-8 decodes, no crash"); assert_eq!(super::read_uplink_line(&mut r, cap).await.unwrap(), Some("QUIT".to_string())); assert_eq!(super::read_uplink_line(&mut r, cap).await.unwrap(), None, "EOF"); // An over-long line is truncated to the cap, not accumulated unbounded. let long: Vec = [vec![b'X'; 50], vec![b'\n']].concat(); let mut r2 = BufReader::new(&long[..]); assert_eq!(super::read_uplink_line(&mut r2, 10).await.unwrap().unwrap().len(), 10); } #[test] fn redacts_identify_password() { let line = "@time=x;msgid=y :91ZAAAAFQ PRIVMSG 00DAAAAAA :identify reverse hunter2secret!"; let out = redact(line); assert!(!out.contains("hunter2secret"), "password leaked: {out}"); assert!(!out.contains("reverse"), "account leaked: {out}"); assert!(out.contains("PRIVMSG 00DAAAAAA :[REDACTED]"), "{out}"); } #[test] fn redacts_register_and_ns_prefix_and_set_password() { assert!(!redact(":u PRIVMSG NickServ :register mypass a@b.com").contains("mypass")); assert!(!redact(":u PRIVMSG X :NS identify topsecret").contains("topsecret")); assert!(!redact(":u PRIVMSG NickServ :set password newpw").contains("newpw")); } #[test] fn redacts_sasl_payloads() { assert!(!redact("AUTHENTICATE aGVsbG8AdXNlcgBwYXNz").contains("aGVsbG8")); assert!(!redact(":91Z ENCAP 00D SASL 91ZAAAAFQ 00DAAAAAA C cGFzc3dvcmQ=").contains("cGFzc3dvcmQ")); } #[test] fn leaves_ordinary_traffic_and_mechanisms_intact() { let chat = ":91ZAAAAFQ PRIVMSG #chan :hello everyone identify yourself"; assert_eq!(redact(chat), chat, "ordinary channel chat must not be mangled"); assert_eq!(redact("AUTHENTICATE PLAIN"), "AUTHENTICATE PLAIN"); assert_eq!(redact("AUTHENTICATE +"), "AUTHENTICATE +"); } }