From 54ad013e49bafeeff5c97e32176ffb84b5984df1 Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 15 Jul 2026 16:34:20 +0000 Subject: [PATCH] Wire account registration to the ircd's ENCAP SWACCTREG/SWACCTRES relay Echo is the authority the ircd account module forwards to: parse ENCAP SWACCTREG : into an account request, and answer the origin server with ENCAP SWACCTRES :. Thread the origin through the reply so responses route back to the requesting server. Replaces the placeholder ACCTREGISTER/ACCTREGRESULT names that nothing on the wire actually spoke. --- api/src/lib.rs | 7 +-- modules/protocol/inspircd/src/lib.rs | 79 ++++++++++++++++++++-------- src/engine/mod.rs | 7 +-- src/engine/register.rs | 5 +- src/engine/tests.rs | 10 ++-- 5 files changed, 72 insertions(+), 36 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index c68d134..f1c9b00 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -55,7 +55,7 @@ pub enum NetAction { IntroduceUser { uid: String, nick: String, ident: String, host: String, gecos: String }, Privmsg { from: String, to: String, text: String }, Notice { from: String, to: String, text: String }, - AccountResponse { reqid: String, kind: String, account: String, status: String, code: String, message: String }, + AccountResponse { reqid: String, origin: String, kind: String, account: String, status: String, code: String, message: String }, // A SASL exchange step back to the ircd, sourced from our SASL agent. mode = C/D. Sasl { agent: String, client: String, mode: String, data: Vec }, // Publish network state to the uplink: target "*" is server-global (e.g. the @@ -115,8 +115,9 @@ pub enum NetAction { // How to answer a registration once its credentials have been derived. #[derive(Debug, Clone)] pub enum RegReply { - // IRCv3 account-registration relay: answer the requesting ircd. - Relay { reqid: String, kind: String }, + // IRCv3 account-registration relay: answer the requesting ircd. `origin` is + // the server the request came from, which the response is ENCAP'd back to. + Relay { reqid: String, kind: String, origin: String }, // NickServ REGISTER: NOTICE the requesting user, logging them in on success. NickServ { agent: String, uid: String, nick: String }, } diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index 98d61a1..de36e89 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -186,28 +186,29 @@ impl Protocol for InspIrcd { } } "QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }], - // account-registration relay from an ircd: - // ACCTREGISTER : - "ACCTREGISTER" => { - let a: Vec<&str> = tokens.by_ref().take(5).collect(); - if a.len() == 5 { - vec![NetEvent::AccountRequest { - reqid: a[0].to_string(), - origin: a[1].to_string(), - kind: a[2].to_string(), - account: a[3].to_string(), - p2: a[4].to_string(), - p3: trailing(rest), - }] - } else { - vec![] - } - } - // ENCAP … — we only care about relayed SASL: - // ENCAP SASL [data…] + // ENCAP … — we care about relayed SASL and the + // account-registration relay (the ircd's account module forwards a + // leaf's REGISTER/VERIFY/RESEND/STATUS to us, the authority server). "ENCAP" => { let _target = tokens.next(); match tokens.next().map(|s| s.to_ascii_uppercase()).as_deref() { + // SWACCTREG : + Some("SWACCTREG") => { + let a: Vec<&str> = tokens.by_ref().take(5).collect(); + if a.len() == 5 { + vec![NetEvent::AccountRequest { + reqid: a[0].to_string(), + origin: a[1].to_string(), + kind: a[2].to_string(), + account: a[3].to_string(), + p2: a[4].to_string(), + p3: trailing(rest), + }] + } else { + vec![] + } + } + // ENCAP SASL [data…] Some("SASL") => { let p: Vec<&str> = tokens.collect(); if p.len() >= 3 { @@ -248,11 +249,12 @@ impl Protocol for InspIrcd { NetAction::Notice { from, to, text } => { vec![format!(":{} NOTICE {} :{}", from, to, text)] } - // ACCTREGRESULT : - NetAction::AccountResponse { reqid, kind, account, status, code, message } => { + // Answer the origin server: ENCAP SWACCTRES + // : + NetAction::AccountResponse { reqid, origin, kind, account, status, code, message } => { vec![self.sourced(format!( - "ACCTREGRESULT {} {} {} {} {} :{}", - reqid, kind, account, status, code, message + "ENCAP {} SWACCTRES {} {} {} {} {} :{}", + origin, reqid, kind, account, status, code, message ))] } // ENCAP * SASL [data…] @@ -494,6 +496,37 @@ mod tests { assert!(!lines[0].contains('\n') && !lines[0].contains('\r')); } + // Account-registration relay wire format, matching the ircd's account module: + // a leaf forwards ENCAP SWACCTREG + // :, and we answer the origin server with ENCAP SWACCTRES. The + // password is the trailing field so it may contain spaces. + #[test] + fn account_registration_relay_roundtrip() { + let ev = proto().parse(":0IR ENCAP 42S SWACCTREG req1 0IR REGISTER neo neo@example.org :trinity pass"); + match ev.as_slice() { + [NetEvent::AccountRequest { reqid, origin, kind, account, p2, p3 }] => { + assert_eq!(reqid, "req1"); + assert_eq!(origin, "0IR"); + assert_eq!(kind, "REGISTER"); + assert_eq!(account, "neo"); + assert_eq!(p2, "neo@example.org"); + assert_eq!(p3, "trinity pass", "the password is trailing, spaces preserved"); + } + other => panic!("expected one AccountRequest, got {other:?}"), + } + + let lines = proto().serialize(&NetAction::AccountResponse { + reqid: "req1".into(), + origin: "0IR".into(), + kind: "REGISTER".into(), + account: "neo".into(), + status: "verification_required".into(), + code: "VERIFICATION_REQUIRED".into(), + message: "check your email".into(), + }); + assert_eq!(lines, vec![":42S ENCAP 0IR SWACCTRES req1 REGISTER neo verification_required VERIFICATION_REQUIRED :check your email".to_string()]); + } + // A network ban serializes to ADDLINE / DELLINE, sourced from our server. #[test] fn serializes_network_bans() { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index eead1dd..a49d2b2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -809,8 +809,8 @@ impl Engine { Vec::new() } NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text), - NetEvent::AccountRequest { reqid, kind, account, p2, p3, .. } => { - self.account_request(reqid, kind, account, p2, p3) + NetEvent::AccountRequest { reqid, origin, kind, account, p2, p3 } => { + self.account_request(reqid, origin, kind, account, p2, p3) } NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data), _ => Vec::new(), @@ -1146,7 +1146,7 @@ enum RegOutcome { // so both the pre-check rejection and the post-derivation result share one place. fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec { match reply { - RegReply::Relay { reqid, kind } => { + RegReply::Relay { reqid, kind, origin } => { let (status, code, message) = match outcome { RegOutcome::Ok => ("success", "*", "Account registered."), RegOutcome::VerifyRequired => ("verification_required", "VERIFICATION_REQUIRED", "Registered — check your email for a code, then VERIFY."), @@ -1158,6 +1158,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec Vec { + pub(crate) fn account_request(&mut self, reqid: String, origin: String, kind: String, account: String, p2: String, p3: String) -> Vec { if kind.eq_ignore_ascii_case("REGISTER") { let email = if p2.is_empty() || p2 == "*" { None } else { Some(p2) }; - return vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind } }]; + return vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind, origin } }]; } // Relay-only replies, built directly (REGISTER goes through complete_register). let resp = |status: &str, code: &str, message: &str| { vec![NetAction::AccountResponse { reqid: reqid.clone(), + origin: origin.clone(), kind: kind.clone(), account: account.clone(), status: status.to_string(), diff --git a/src/engine/tests.rs b/src/engine/tests.rs index ccd3813..0dd9299 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -611,7 +611,7 @@ let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); // REGISTER : relayed from the ircd. - let reg = e.account_request("r1".into(), "REGISTER".into(), "neo".into(), "neo@example.org".into(), "trinity".into()); + let reg = e.account_request("r1".into(), "leaf".into(), "REGISTER".into(), "neo".into(), "neo@example.org".into(), "trinity".into()); let (account, password, email, reply) = reg.iter().find_map(|a| match a { NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())), _ => None, @@ -623,16 +623,16 @@ let code = body.split("CONFIRM ").nth(1).unwrap().split_whitespace().next().unwrap().to_string(); // VERIFY with the code succeeds; a stale/duplicate VERIFY then fails. - let ok = e.account_request("r2".into(), "VERIFY".into(), "neo".into(), code, String::new()); + let ok = e.account_request("r2".into(), "leaf".into(), "VERIFY".into(), "neo".into(), code, String::new()); assert!(ok.iter().any(|a| matches!(a, NetAction::AccountResponse { status, .. } if status == "success")), "{ok:?}"); assert!(e.db.is_verified("neo"), "verified after VERIFY"); - let again = e.account_request("r3".into(), "VERIFY".into(), "neo".into(), "000000".into(), String::new()); + let again = e.account_request("r3".into(), "leaf".into(), "VERIFY".into(), "neo".into(), "000000".into(), String::new()); assert!(again.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "INVALID_CODE")), "{again:?}"); // STATUS reports verified; VERIFY on an unknown account is a clean error. - let st = e.account_request("r4".into(), "STATUS".into(), "neo".into(), String::new(), String::new()); + let st = e.account_request("r4".into(), "leaf".into(), "STATUS".into(), "neo".into(), String::new(), String::new()); assert!(st.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "VERIFIED")), "{st:?}"); - let unknown = e.account_request("r5".into(), "VERIFY".into(), "ghost".into(), "x".into(), String::new()); + let unknown = e.account_request("r5".into(), "leaf".into(), "VERIFY".into(), "ghost".into(), "x".into(), String::new()); assert!(unknown.iter().any(|a| matches!(a, NetAction::AccountResponse { code, .. } if code == "ACCOUNT_UNKNOWN")), "{unknown:?}"); }