From b0d767a809d66ec41b3da6bf89ece248976fae47 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 11:48:17 +0000 Subject: [PATCH] modules: port account_registration (REGISTER/VERIFY over async HTTP API) + async http infra --- src/coremods/core_user.rs | 3 +- src/ircd.rs | 26 +++ src/modules/account_registration.rs | 326 ++++++++++++++++++++++++++++ src/modules/mod.rs | 2 + src/server.rs | 31 +++ src/users.rs | 18 +- 6 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 src/modules/account_registration.rs diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 1b5e520..36ad51a 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -174,12 +174,13 @@ impl Command for Cap { u.cap = true; // hold registration until CAP END u.cap_302 |= cap302; } + let acctreg = crate::modules::account_registration::cap_tokens(s); s.send( uid, format!( ":{} CAP {who} LS :{}", s.name, - Caps::ls_line(cap302, secure) + Caps::ls_line(cap302, secure, &acctreg) ), ); } diff --git a/src/ircd.rs b/src/ircd.rs index f057a56..798f7c3 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -42,6 +42,16 @@ pub enum Event { host: Option, dnsbl: crate::modules::dnsbl::Outcome, }, + /// 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, + }, /// Background timer tick — drives ping/idle timeouts. Tick, } @@ -130,6 +140,22 @@ impl Ircd { } self.try_register(uid); // DNS may have been the last thing we waited on } + 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::Tick => self.on_tick(), } self.drain_hooks(); diff --git a/src/modules/account_registration.rs b/src/modules/account_registration.rs new file mode 100644 index 0000000..ab0a8d0 --- /dev/null +++ b/src/modules/account_registration.rs @@ -0,0 +1,326 @@ +//! account_registration — IRCv3 `draft/account-registration` (the `REGISTER` / +//! `VERIFY` commands and the cap that advertises them), bridged to a configurable +//! HTTP accounts API. reverse's own module: the old Swaygo Django backend is gone, +//! so this talks to whatever `acctregister_registerurl` / `_verifyurl` you point it +//! at, POSTing form-encoded fields with an `X-API-Key` header. +//! +//! The API call runs on a worker thread (`Server::spawn_http`) and its result comes +//! back as `Event::HttpResult` → [`on_http_result`], so a slow endpoint never blocks +//! the core. On success (and when `acctregister_autologin`) the user is logged into +//! the new account. Everything is config-driven; the only per-IP state (rate limit) +//! lives in `Server.ext`. +//! +//! Config (all under flat keys): +//! account_registration = yes enable +//! acctregister_registerurl = POST username,email,password,client_ip,port +//! acctregister_verifyurl = POST username,code +//! acctregister_apikey = sent as X-API-Key +//! acctregister_emailrequired = yes require a real email (advertise email-required) +//! acctregister_beforeconnect = yes allow REGISTER before the handshake completes +//! acctregister_autologin = yes log in on success +//! acctregister_requiretls = yes refuse REGISTER on a plaintext link +//! acctregister_ratecount = 3 max REGISTER attempts per IP … +//! acctregister_ratetime = 3600 … per this many seconds + +use std::collections::HashMap; + +use crate::command::{CmdResult, Command}; +use crate::http::{json_str, urlencode}; +use crate::server::{now, Server}; +use crate::Uid; + +/// Per-IP REGISTER attempt timestamps, for rate limiting. Stored in `Server.ext`. +#[derive(Default)] +struct RateState(HashMap>); + +fn enabled(s: &Server) -> bool { + s.conf_bool("account_registration", false) && s.conf("acctregister_registerurl").is_some() +} + +/// The value tokens advertised on the `draft/account-registration` cap (302). Empty +/// when the module is disabled (so the cap is advertised bare / not at all). +pub fn cap_tokens(s: &Server) -> String { + if !enabled(s) { + return String::new(); + } + let mut toks = vec!["custom-account-name"]; + if s.conf_bool("acctregister_beforeconnect", true) { + toks.push("before-connect"); + } + if s.conf_bool("acctregister_emailrequired", true) { + toks.push("email-required"); + } + toks.join(",") +} + +fn apikey_headers(s: &Server) -> Vec<(String, String)> { + match s.conf("acctregister_apikey") { + Some(k) if !k.is_empty() => vec![("X-API-Key".to_string(), k.to_string())], + _ => Vec::new(), + } +} + +/// Rate-limit a REGISTER from `ip`; true when the attempt is within budget. +fn rate_ok(s: &mut Server, ip: &str) -> bool { + let count = s.conf_num("acctregister_ratecount", 3u32); + let window = s.conf_num("acctregister_ratetime", 3600u64); + if count == 0 { + return true; + } + let n = now(); + let st = s.ext.get_or_insert_with::(RateState::default); + let hist = st.0.entry(ip.to_string()).or_default(); + hist.retain(|&t| n.saturating_sub(t) < window); + if hist.len() as u32 >= count { + return false; + } + hist.push(n); + true +} + +pub fn commands() -> Vec> { + vec![Box::new(Register), Box::new(Verify)] +} + +/// REGISTER ` ` — `` may be `*` for the current +/// nick. IRCv3 `draft/account-registration`. +struct Register; +impl Command for Register { + fn name(&self) -> &'static str { + "REGISTER" + } + fn min_params(&self) -> usize { + 0 + } + fn before_reg(&self) -> bool { + true // allow before-connect; the handler re-checks the config gate + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !enabled(s) { + s.fail( + uid, + "REGISTER", + "TEMPORARILY_UNAVAILABLE", + "Account registration is disabled.", + ); + return CmdResult::Fail; + } + if params.len() < 3 { + s.fail( + uid, + "REGISTER", + "INVALID_PARAMS", + "Syntax: REGISTER ", + ); + return CmdResult::Fail; + } + let registered = s.users.get(&uid).map(|u| u.registered).unwrap_or(false); + if !registered && !s.conf_bool("acctregister_beforeconnect", true) { + s.fail( + uid, + "REGISTER", + "COMPLETE_CONNECTION_REQUIRED", + "Finish connecting before registering.", + ); + return CmdResult::Fail; + } + let (secure, nick, ip, port) = match s.users.get(&uid) { + Some(u) => ( + u.secure, + u.nick.clone(), + u.addr.ip().to_string(), + u.addr.port(), + ), + None => return CmdResult::Fail, + }; + if s.conf_bool("acctregister_requiretls", true) && !secure { + s.fail( + uid, + "REGISTER", + "REG_UNAVAILABLE", + "Registration requires a TLS connection.", + ); + return CmdResult::Fail; + } + let account = if params[0] == "*" { + nick.clone() + } else { + params[0].clone() + }; + if account.is_empty() { + s.fail( + uid, + "REGISTER", + "ACCOUNT_NAME_MUST_BE_NICK", + "Choose an account name (or set a nick first).", + ); + return CmdResult::Fail; + } + let email = params[1].clone(); + if s.conf_bool("acctregister_emailrequired", true) && (email == "*" || email.is_empty()) { + s.fail( + uid, + "REGISTER", + "INVALID_EMAIL", + "A valid email address is required.", + ); + return CmdResult::Fail; + } + if !rate_ok(s, &ip) { + s.fail( + uid, + "REGISTER", + "RATE_LIMITED", + "Too many registration attempts. Please wait.", + ); + return CmdResult::Fail; + } + + let url = s.conf("acctregister_registerurl").unwrap_or("").to_string(); + let body = format!( + "username={}&email={}&password={}&client_ip={}&port={}", + urlencode(&account), + urlencode(&email), + urlencode(¶ms[2]), + urlencode(&ip), + port + ); + s.spawn_http( + uid, + format!("acctreg:register:{account}"), + url, + body, + apikey_headers(s), + ); + CmdResult::Ok + } +} + +/// VERIFY ` ` — confirm a pending registration with an emailed code. +struct Verify; +impl Command for Verify { + fn name(&self) -> &'static str { + "VERIFY" + } + fn min_params(&self) -> usize { + 0 + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !enabled(s) || s.conf("acctregister_verifyurl").is_none() { + s.fail( + uid, + "VERIFY", + "TEMPORARILY_UNAVAILABLE", + "Account verification is disabled.", + ); + return CmdResult::Fail; + } + if params.len() < 2 { + s.fail( + uid, + "VERIFY", + "INVALID_PARAMS", + "Syntax: VERIFY ", + ); + return CmdResult::Fail; + } + let account = params[0].clone(); + let url = s.conf("acctregister_verifyurl").unwrap_or("").to_string(); + let body = format!( + "username={}&code={}", + urlencode(&account), + urlencode(¶ms[1]) + ); + s.spawn_http( + uid, + format!("acctreg:verify:{account}"), + url, + body, + apikey_headers(s), + ); + CmdResult::Ok + } +} + +/// Truthy JSON field? Matches `"key":true` (whitespace-tolerant) in `body`. +fn json_true(body: &str, key: &str) -> bool { + let needle = format!("\"{key}\""); + if let Some(pos) = body.find(&needle) { + let after = &body[pos + needle.len()..]; + if let Some(colon) = after.find(':') { + return after[colon + 1..].trim_start().starts_with("true"); + } + } + false +} + +/// Called from the core when a REGISTER/VERIFY HTTP call finishes. `detail` is +/// `"register:"` or `"verify:"`. +pub fn on_http_result(s: &mut Server, uid: Uid, detail: &str, status: u16, body: &str) { + let Some((kind, account)) = detail.split_once(':') else { + return; + }; + if !s.users.contains_key(&uid) { + return; // user vanished while the request was in flight + } + let verb = if kind == "verify" { + "VERIFY" + } else { + "REGISTER" + }; + let msg = json_str(body, "message") + .or_else(|| json_str(body, "error")) + .unwrap_or_else(|| "Account service response.".to_string()); + + // transport failure + if status == 0 { + s.fail( + uid, + verb, + "TEMPORARILY_UNAVAILABLE", + "The account service is unreachable. Try again later.", + ); + return; + } + let ok = (200..300).contains(&status) && json_true(body, "success"); + if !ok { + let code = json_str(body, "code").unwrap_or_else(|| "REGISTRATION_FAILED".to_string()); + s.send( + uid, + format!(":{} FAIL {verb} {code} {account} :{msg}", s.name), + ); + return; + } + + let autologin = s.conf_bool("acctregister_autologin", true); + if kind == "register" && json_true(body, "verification_required") { + s.send( + uid, + format!( + ":{} REGISTER VERIFICATION_REQUIRED {account} :{msg}", + s.name + ), + ); + return; + } + s.send(uid, format!(":{} {verb} SUCCESS {account} :{msg}", s.name)); + if autologin { + s.set_login(uid, account); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_true_detects_boolean() { + assert!(json_true(r#"{"success": true}"#, "success")); + assert!(json_true(r#"{"success":true,"x":1}"#, "success")); + assert!(!json_true(r#"{"success": false}"#, "success")); + assert!(!json_true(r#"{"other": true}"#, "success")); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 921866d..ac748ca 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -3,6 +3,7 @@ //! is the exception — it's driven straight from the connection lifecycle rather //! than the hook bus, but lives here as its own self-contained unit. +pub mod account_registration; pub mod antimixedutf8; pub mod antirandom; pub mod blockamsg; @@ -71,5 +72,6 @@ pub fn module_commands() -> Vec> { .chain(reputation::commands()) .chain(securitygroups::commands()) .chain(password_hash::commands()) + .chain(account_registration::commands()) .collect() } diff --git a/src/server.rs b/src/server.rs index 3c01a74..9071e1b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -358,6 +358,37 @@ impl Server { } } + /// Fire an HTTP POST on a worker thread and deliver `(status, body)` back to + /// the core as `Event::HttpResult { uid, tag, .. }` — the same self-injection + /// pattern as the DNS resolver, so a slow endpoint never blocks the main loop. + /// `tag` is `":"`; the core routes the reply by its prefix. + pub fn spawn_http( + &self, + uid: Uid, + tag: String, + url: String, + body: String, + headers: Vec<(String, String)>, + ) { + let tx = self.event_tx.clone(); + std::thread::spawn(move || { + let (status, body) = crate::http::post( + &url, + "application/x-www-form-urlencoded", + &body, + &headers, + std::time::Duration::from_secs(10), + ) + .unwrap_or((0, String::new())); + let _ = tx.send(crate::ircd::Event::HttpResult { + uid, + tag, + status, + body, + }); + }); + } + /// A pre-registration `:server NOTICE * :*** ` line. pub(crate) fn notice_star(&self, uid: Uid, msg: &str) { self.send(uid, format!(":{} NOTICE * :*** {msg}", self.name)); diff --git a/src/users.rs b/src/users.rs index 0af54d9..00e27b9 100644 --- a/src/users.rs +++ b/src/users.rs @@ -113,6 +113,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "draft/pre-away", "draft/metadata-2", "draft/multiline", + "draft/account-registration", "cap-notify", ]; @@ -143,6 +144,7 @@ pub struct Caps { pub pre_away: bool, // draft/pre-away — may set AWAY before registration pub metadata: bool, // draft/metadata-2 — wants metadata + change notices pub multiline: bool, // draft/multiline — may send multiline message batches + pub acct_registration: bool, // draft/account-registration — REGISTER/VERIFY understood pub cap_notify: bool, } @@ -153,7 +155,7 @@ impl Caps { /// The `CAP LS` token list; `sasl` carries its mechanisms for 302 clients. /// EXTERNAL is only offered on TLS connections (it needs a client cert). - pub fn ls_line(cap302: bool, secure: bool) -> String { + pub fn ls_line(cap302: bool, secure: bool, acctreg: &str) -> String { SUPPORTED_CAPS .iter() .map(|c| { @@ -167,6 +169,8 @@ impl Caps { format!( "draft/multiline=max-bytes={MLINE_MAX_BYTES},max-lines={MLINE_MAX_LINES}" ) + } else if *c == "draft/account-registration" && cap302 && !acctreg.is_empty() { + format!("draft/account-registration={acctreg}") } else { (*c).to_string() } @@ -199,6 +203,7 @@ impl Caps { "draft/pre-away" => self.pre_away, "draft/metadata-2" => self.metadata, "draft/multiline" => self.multiline, + "draft/account-registration" => self.acct_registration, "cap-notify" => self.cap_notify, _ => false, } @@ -229,6 +234,7 @@ impl Caps { "draft/pre-away" => &mut self.pre_away, "draft/metadata-2" => &mut self.metadata, "draft/multiline" => &mut self.multiline, + "draft/account-registration" => &mut self.acct_registration, "cap-notify" => &mut self.cap_notify, _ => return false, }; @@ -564,12 +570,12 @@ mod tests { assert!(!c.set("bogus-cap", true)); // unknown cap rejected assert!(c.has("server-time") && c.has("multi-prefix") && !c.has("sasl")); assert_eq!(c.enabled(), "server-time multi-prefix"); // SUPPORTED order - assert!(Caps::ls_line(true, false).contains("sasl=PLAIN")); // 302 shows mechs - assert!(!Caps::ls_line(true, false).contains("EXTERNAL")); // plaintext: no EXTERNAL - assert!(Caps::ls_line(true, true).contains("sasl=PLAIN,EXTERNAL")); // TLS offers it + assert!(Caps::ls_line(true, false, "").contains("sasl=PLAIN")); // 302 shows mechs + assert!(!Caps::ls_line(true, false, "").contains("EXTERNAL")); // plaintext: no EXTERNAL + assert!(Caps::ls_line(true, true, "").contains("sasl=PLAIN,EXTERNAL")); // TLS offers it assert!( - Caps::ls_line(false, false).contains("sasl") - && !Caps::ls_line(false, false).contains("sasl=") + Caps::ls_line(false, false, "").contains("sasl") + && !Caps::ls_line(false, false, "").contains("sasl=") ); c.set("server-time", false); assert!(!c.has("server-time"));