From a2957ffe021cde553c03f9f50308571e40b2ff52 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 15:28:15 +0000 Subject: [PATCH] email: outbound mail subsystem and RESETPASS Adds a config-driven mailer: a SendEmail action the link layer pipes to a configured command (sendmail -t, msmtp, ...) off-thread. NickServ RESETPASS emails a short-lived code to the address on file; RESETPASS completes the reset. Codes are node-local; the password change itself federates. --- Cargo.lock | 21 ++++++++++++++++ Cargo.toml | 2 +- modules/nickserv/nickserv.rs | 5 +++- modules/nickserv/resetpass.rs | 42 ++++++++++++++++++++++++++++++++ modules/protocol/inspircd.rs | 2 +- modules/protocol/mod.rs | 3 +++ src/config.rs | 12 ++++++++++ src/engine/db.rs | 45 ++++++++++++++++++++++++++++++++--- src/engine/mod.rs | 34 ++++++++++++++++++++++++++ src/engine/service.rs | 5 ++++ src/link.rs | 41 ++++++++++++++++++++++++++++--- src/main.rs | 3 ++- 12 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 modules/nickserv/resetpass.rs diff --git a/Cargo.lock b/Cargo.lock index f4dd70d..12ff2d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "fedserv" version = "0.0.1" @@ -542,6 +552,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -594,6 +614,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 799e8c9..97fe67e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" description = "Federated IRC services daemon (protocol-agnostic core, InspIRCd link first)" [dependencies] -tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync"] } +tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" diff --git a/modules/nickserv/nickserv.rs b/modules/nickserv/nickserv.rs index 5cd3208..8f50447 100644 --- a/modules/nickserv/nickserv.rs +++ b/modules/nickserv/nickserv.rs @@ -26,6 +26,8 @@ mod glist; mod ungroup; #[path = "ghost.rs"] mod ghost; +#[path = "resetpass.rs"] +mod resetpass; pub struct NickServ { pub uid: String, @@ -64,7 +66,8 @@ impl Service for NickServ { Some("GLIST") => glist::handle(me, from, ctx, db), Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db), Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db), - Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 [email], \x02IDENTIFY\x02 [account] , \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 [password], \x02SET\x02 PASSWORD|EMAIL, \x02DROP\x02 , \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST [fingerprint]."), + Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 [email], \x02IDENTIFY\x02 [account] , \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 , \x02DROP\x02 , \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST [fingerprint]."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/modules/nickserv/resetpass.rs b/modules/nickserv/resetpass.rs new file mode 100644 index 0000000..edf6b99 --- /dev/null +++ b/modules/nickserv/resetpass.rs @@ -0,0 +1,42 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// RESETPASS : email a reset code to the address on file. +// RESETPASS : complete the reset with that code. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + if !db.email_enabled() { + ctx.notice(me, from.uid, "Password reset by email isn't available here."); + return; + } + match (args.get(1), args.get(2), args.get(3)) { + (Some(&name), None, None) => { + let Some(canonical) = db.resolve_account(name).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); + return; + }; + let Some(email) = db.account(&canonical).and_then(|a| a.email.clone()) else { + ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset."); + return; + }; + let code = db.issue_reset_code(&canonical); + ctx.send_email( + email, + format!("Password reset for {canonical}"), + format!("Your password reset code for {canonical} is: {code}\nIt expires in 15 minutes. Reset with:\n /msg NickServ RESETPASS {canonical} {code} "), + ); + ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02.")); + } + (Some(&name), Some(&code), Some(&newpass)) => { + let Some(canonical) = db.resolve_account(name).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered.")); + return; + }; + if !db.take_reset_code(&canonical, code) { + ctx.notice(me, from.uid, "Invalid or expired reset code."); + return; + } + ctx.defer_password(&canonical, newpass, me, from.uid); + } + _ => ctx.notice(me, from.uid, "Syntax: RESETPASS [ ]"), + } +} diff --git a/modules/protocol/inspircd.rs b/modules/protocol/inspircd.rs index 7a2f7bf..5e885fe 100644 --- a/modules/protocol/inspircd.rs +++ b/modules/protocol/inspircd.rs @@ -281,7 +281,7 @@ impl Protocol for InspIrcd { } NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles these before serialization. - NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } => vec![], + NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::SendEmail { .. } => vec![], } } diff --git a/modules/protocol/mod.rs b/modules/protocol/mod.rs index e9c6d76..b9cc6f7 100644 --- a/modules/protocol/mod.rs +++ b/modules/protocol/mod.rs @@ -71,6 +71,9 @@ pub enum NetAction { // Internal only: a password change awaiting the same off-thread derivation. // The link layer derives, then calls Engine::complete_password_change. DeferPassword { account: String, password: String, agent: String, uid: String }, + // Internal only: send an email. The link layer pipes it to the configured + // mail command off-thread; never serialized to the ircd. + SendEmail { to: String, subject: String, body: String }, } // How to answer a registration once its credentials have been derived. diff --git a/src/config.rs b/src/config.rs index f2d7869..252568e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,18 @@ pub struct Config { pub gossip: Option, #[serde(default)] pub peer: Vec, + // Outbound email (password resets). Absent = email features are off. + #[serde(default)] + pub email: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Email { + // Sender address stamped on outgoing mail. + pub from: String, + // A shell command the message is piped to on stdin, e.g. "sendmail -t" or + // "msmtp -t". Run via `sh -c`, so redirection and pipes work. + pub command: String, } #[derive(Debug, Deserialize, Clone)] diff --git a/src/engine/db.rs b/src/engine/db.rs index a91fd02..02d3b61 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::rand_core::{OsRng, RngCore}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use serde::{Deserialize, Serialize}; @@ -443,6 +443,10 @@ pub struct Db { log: EventLog, // PBKDF2 cost baked into new SCRAM verifiers; lowered by tests. pub(crate) scram_iterations: u32, + // Whether outbound email is configured, so email features can gate themselves. + email_enabled: bool, + // Node-local, non-persisted password-reset codes: account -> (code, expiry). + reset_codes: HashMap, } fn key(name: &str) -> String { @@ -482,7 +486,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS } + Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, reset_codes: HashMap::new() } } /// Fold an entry authored by another node into the store — the services-side @@ -680,6 +684,34 @@ impl Db { self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice()) } + /// Whether outbound email is configured. + pub fn email_enabled(&self) -> bool { + self.email_enabled + } + + pub fn set_email_enabled(&mut self, on: bool) { + self.email_enabled = on; + } + + /// Issue a fresh password-reset code for `account`, valid for 15 minutes. + pub fn issue_reset_code(&mut self, account: &str) -> String { + let code = gen_code(); + self.reset_codes.insert(key(account), (code.clone(), Instant::now() + Duration::from_secs(900))); + code + } + + /// Consume a reset code for `account`: true if it matches and hasn't expired. + pub fn take_reset_code(&mut self, account: &str, code: &str) -> bool { + let k = key(account); + match self.reset_codes.get(&k) { + Some((c, deadline)) if c == code && *deadline > Instant::now() => { + self.reset_codes.remove(&k); + true + } + _ => false, + } + } + /// Set (or clear) `account`'s email. pub fn set_email(&mut self, account: &str, email: Option) -> Result<(), RegError> { let k = key(account); @@ -1062,6 +1094,13 @@ fn glob_match(pattern: &str, text: &str) -> bool { pi == p.len() } +// A random 6-digit code for email verification / password reset. +fn gen_code() -> String { + let mut b = [0u8; 4]; + OsRng.fill_bytes(&mut b); + format!("{:06}", u32::from_le_bytes(b) % 1_000_000) +} + fn hash_password(password: &str) -> Option { let salt = SaltString::generate(&mut OsRng); Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string()) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 2c788ff..01a385d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1210,6 +1210,40 @@ mod tests { assert!(notice(&to_ns(&mut e, "000AAAAAC", "IDENTIFY sesame"), "isn't registered")); } + // RESETPASS emails a code, and that code + a new password completes the reset; + // the new password then authenticates and the old one no longer does. + #[test] + fn nickserv_resetpass() { + let mut e = engine_with("nsreset", "alice", "sesame"); + e.db.set_email_enabled(true); + e.db.set_email("alice", Some("alice@example.org".into())).unwrap(); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "h".into() }); + let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() }); + + // Request: a code is emailed to the address on file. + let out = to_ns(&mut e, "RESETPASS alice"); + let (to, body) = out.iter().find_map(|a| match a { + NetAction::SendEmail { to, body, .. } => Some((to.clone(), body.clone())), + _ => None, + }).expect("a reset code is emailed"); + assert_eq!(to, "alice@example.org"); + let code = body.split("is: ").nth(1).unwrap().split_whitespace().next().unwrap().to_string(); + + // Complete: the code + a new password defers the change. + let out = to_ns(&mut e, &format!("RESETPASS alice {code} brandnew")); + let (account, password, agent, uid) = out.iter().find_map(|a| match a { + NetAction::DeferPassword { account, password, agent, uid } => Some((account.clone(), password.clone(), agent.clone(), uid.clone())), + _ => None, + }).expect("a valid code defers the new password"); + assert_eq!(password, "brandnew"); + e.complete_password_change(&account, Db::derive_credentials(&password, 4096), &agent, &uid); + assert!(e.db.authenticate("alice", "brandnew").is_some(), "new password works"); + assert!(e.db.authenticate("alice", "sesame").is_none(), "old password rejected"); + + // A used/wrong code is refused. + assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired")))); + } + // GHOST renames off a session using a nick the caller owns. #[test] fn nickserv_ghost() { diff --git a/src/engine/service.rs b/src/engine/service.rs index 53fbd11..70bf334 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -57,6 +57,11 @@ impl ServiceCtx { }); } + // Send an email (the link layer pipes it to the configured mail command). + pub fn send_email(&mut self, to: impl Into, subject: impl Into, body: impl Into) { + self.actions.push(NetAction::SendEmail { to: to.into(), subject: subject.into(), body: body.into() }); + } + // Hand a password change to the engine to finish: its derivation runs off the // reactor, then the engine commits it and notices `uid`, sourced from `agent`. pub fn defer_password(&mut self, account: impl Into, password: impl Into, agent: impl Into, uid: impl Into) { diff --git a/src/link.rs b/src/link.rs index 175c07c..fd4b59b 100644 --- a/src/link.rs +++ b/src/link.rs @@ -12,7 +12,7 @@ use crate::proto::{NetAction, Protocol}; // 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. -pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver) -> Result<()> { +pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver, email: Option) -> Result<()> { let stream = TcpStream::connect(addr).await?; let (read, mut write) = stream.into_split(); let mut lines = BufReader::new(read).lines(); @@ -66,6 +66,10 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: action => vec![action], }; for act in outs { + if let NetAction::SendEmail { to, subject, body } = act { + dispatch_email(&email, to, subject, body); + continue; + } for out in proto.serialize(&act) { send(&mut write, &out).await?; } @@ -76,8 +80,12 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: // 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() => { - for out in proto.serialize(&action) { - send(&mut write, &out).await?; + if let NetAction::SendEmail { to, subject, body } = action { + dispatch_email(&email, to, subject, body); + } else { + for out in proto.serialize(&action) { + send(&mut write, &out).await?; + } } } } @@ -87,6 +95,33 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: Ok(()) } +// 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. +fn dispatch_email(email: &Option, to: String, subject: String, body: String) { + let Some(email) = email.clone() else { + return tracing::warn!(%to, "email requested but not configured"); + }; + tokio::spawn(async move { + let msg = format!("From: {}\r\nTo: {to}\r\nSubject: {subject}\r\n\r\n{body}\r\n", email.from); + 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; + }); +} + async fn send(write: &mut (impl AsyncWriteExt + Unpin), line: &str) -> Result<()> { tracing::debug!(dir = ">>", %line); write.write_all(line.as_bytes()).await?; diff --git a/src/main.rs b/src/main.rs index 81e7bb2..1ec09be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,7 @@ async fn main() -> Result<()> { let mut db = engine::db::Db::open("fedserv.db.jsonl", &cfg.server.sid); db.scram_iterations = cfg.server.scram_iterations; db.set_outbound(gossip_tx.clone()); + db.set_email_enabled(cfg.email.is_some()); let engine = Arc::new(Mutex::new(Engine::new(services, db))); // Channel for services-initiated actions to reach the uplink (drained by the link loop). @@ -81,5 +82,5 @@ async fn main() -> Result<()> { let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port); tracing::info!(server = %cfg.server.name, %addr, "linking to uplink"); - link::run(proto, engine, &addr, irc_rx).await + link::run(proto, engine, &addr, irc_rx, cfg.email.clone()).await }