Wire account registration to the ircd's ENCAP SWACCTREG/SWACCTRES relay
All checks were successful
CI / check (push) Successful in 3m53s

Echo is the authority the ircd account module forwards to: parse
ENCAP <us> SWACCTREG <reqid> <origin> <kind> <account> <p2> :<p3> into an
account request, and answer the origin server with
ENCAP <origin> SWACCTRES <reqid> <kind> <account> <status> <code> :<message>.
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.
This commit is contained in:
Jean Chevronnet 2026-07-15 16:34:20 +00:00
parent 994e8c7347
commit 54ad013e49
No known key found for this signature in database
5 changed files with 72 additions and 36 deletions

View file

@ -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<String> },
// 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 },
}

View file

@ -186,9 +186,14 @@ impl Protocol for InspIrcd {
}
}
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
// account-registration relay from an ircd:
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
"ACCTREGISTER" => {
// ENCAP <target> <subcmd> … — 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 <reqid> <origin> <kind> <account> <p2> :<p3>
Some("SWACCTREG") => {
let a: Vec<&str> = tokens.by_ref().take(5).collect();
if a.len() == 5 {
vec![NetEvent::AccountRequest {
@ -203,11 +208,7 @@ impl Protocol for InspIrcd {
vec![]
}
}
// ENCAP <target> <subcmd> … — we only care about relayed SASL:
// ENCAP <target> SASL <client> <agent> <mode> [data…]
"ENCAP" => {
let _target = tokens.next();
match tokens.next().map(|s| s.to_ascii_uppercase()).as_deref() {
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 <reqid> <kind> <account> <status> <code> :<message>
NetAction::AccountResponse { reqid, kind, account, status, code, message } => {
// Answer the origin server: ENCAP <origin> SWACCTRES <reqid> <kind>
// <account> <status> <code> :<message>
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 <agent> <client> <mode> [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 <us> SWACCTREG <reqid> <origin> <kind> <account> <p2>
// :<p3>, and we answer the origin server with ENCAP <origin> 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() {

View file

@ -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<NetAction> {
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<NetAct
};
vec![NetAction::AccountResponse {
reqid: reqid.clone(),
origin: origin.clone(),
kind: kind.clone(),
account: account.to_string(),
status: status.to_string(),

View file

@ -134,16 +134,17 @@ impl Engine {
// registration). REGISTER hands off to the link layer (which derives the
// password off-thread) via DeferRegister; VERIFY/RESEND/STATUS are answered
// here directly against the emailed-code flow.
pub(crate) fn account_request(&mut self, reqid: String, kind: String, account: String, p2: String, p3: String) -> Vec<NetAction> {
pub(crate) fn account_request(&mut self, reqid: String, origin: String, kind: String, account: String, p2: String, p3: String) -> Vec<NetAction> {
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(),

View file

@ -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 <account> <email> :<password> 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:?}");
}