Reorganise the engine into a concern-split module tree

engine/mod.rs had grown past 5k lines, most of it one test module and
a few large method clusters. Lift the tests into engine/tests.rs, and
move the cohesive method groups into sibling files — sasl, kicker
(chat policing), register (account-authority relay), and dispatch
(service routing and fantasy). The Engine type, its master event
handler, shared helpers, and the small state structs stay in mod.rs;
each sibling adds its own impl Engine block. Pure reorganisation: no
behaviour change, tests unchanged and green.
This commit is contained in:
Jean Chevronnet 2026-07-14 14:05:56 +00:00
parent 0c7976e80d
commit 6f76f9722c
No known key found for this signature in database
6 changed files with 4194 additions and 4177 deletions

125
src/engine/dispatch.rs Normal file
View file

@ -0,0 +1,125 @@
use super::*;
impl Engine {
// Route a PRIVMSG addressed to a service (by uid or nick) into that service,
// handing it the sender's resolved nick, login state, and the account store.
pub(crate) fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
let account = self.network.account_of(from).map(str::to_string);
let privs = account.as_deref().map(|a| self.oper_privs(a)).unwrap_or_default();
let mut ctx = ServiceCtx::default();
// Mark the log so we can tell exactly which events this command appends.
let audit_mark = self.db.log_len();
let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs };
if to.starts_with('#') || to.starts_with('&') {
// A community !votekick/!voteban is handled on its own; otherwise the
// line runs through fantasy (!op …), the kickers, and triggers.
if let Some(vote_actions) = self.handle_vote(from, to, text) {
ctx.actions.extend(vote_actions);
} else {
self.fantasy(&sender, to, text, &mut ctx);
let kicks = self.kicker_check(from, to, text);
let kicked = kicks.iter().any(|a| matches!(a, NetAction::Kick { .. }));
ctx.actions.extend(kicks);
// Don't reward a line the bot just kicked for with a response.
if !kicked {
if let Some(resp) = self.trigger_response(from, to, text) {
ctx.actions.push(resp);
}
}
}
// Record activity in bot channels (surfaced by StatServ).
if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) {
self.network.record_line(to, &nick);
self.bump("botserv.messages");
}
} else {
// A services ignore silences a user's commands entirely — but an oper
// can never be ignored (else they could lock themselves out).
if !privs.any() {
let host = self.network.host_of(from).unwrap_or("").to_string();
if self.db.is_ignored(&nick, &host) {
return Vec::new();
}
}
let mut matched: Option<String> = None;
{
let Self { services, network, db, .. } = self;
for svc in services.iter_mut() {
if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) {
let args: Vec<&str> = text.split_whitespace().collect();
svc.on_command(&sender, &args, &mut ctx, network, db);
matched = Some(svc.nick().to_ascii_lowercase());
break;
}
}
}
if let Some(nick) = matched {
self.bump(&format!("{nick}.command"));
}
}
// Fold any counters the command recorded into the shared registry.
for key in std::mem::take(&mut ctx.stats) {
self.bump(&key);
}
// A command may have changed the bot registry (BotServ BOT ADD/DEL).
let mut out = ctx.actions;
out.extend(self.reconcile_bots());
// Announce whatever this command changed to the staff audit channel.
out.extend(self.audit_feed(audit_mark, &nick, account.as_deref()));
out
}
// fantasy word becomes the command and the channel is injected as its first
// argument — then re-source the resulting actions from the bot, so the bot is
// the visible actor.
fn fantasy(&mut self, sender: &Sender, chan: &str, text: &str, ctx: &mut ServiceCtx) {
let Some(rest) = text.strip_prefix(FANTASY_PREFIX) else { return };
let mut words = rest.split_whitespace();
let Some(cmd) = words.next() else { return };
// Fantasy only works through an assigned, live bot.
let Some(botnick) = self.db.channel(chan).and_then(|c| c.assigned_bot.clone()) else { return };
let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return };
// A dice command (!roll/!calc/…) goes to DiceServ, if it's loaded, and its
// reply is spoken into the channel by the bot; everything else is a channel
// command handled by ChanServ.
if matches!(cmd.to_ascii_uppercase().as_str(), "ROLL" | "CALC" | "EXROLL" | "EXCALC") {
let Some(dsuid) = self.service_uid("DiceServ") else { return };
let mark = ctx.actions.len();
let args: Vec<&str> = std::iter::once(cmd).chain(words).collect();
self.route_to(&dsuid, sender, &args, ctx);
// Turn DiceServ's private notices into the bot speaking to the channel.
for a in ctx.actions[mark..].iter_mut() {
if let NetAction::Notice { text, .. } = a {
*a = NetAction::Privmsg { from: botuid.clone(), to: chan.to_string(), text: std::mem::take(text) };
}
}
return;
}
let Some(csuid) = self.service_uid("ChanServ") else { return };
// Rewrite `!cmd args…` into `CMD #channel args…` for ChanServ.
let mark = ctx.actions.len();
let args: Vec<&str> = [cmd, chan].into_iter().chain(words).collect();
self.route_to(&csuid, sender, &args, ctx);
// Make the bot the source of everything ChanServ just emitted.
for a in ctx.actions[mark..].iter_mut() {
resource_action(a, &csuid, &botuid);
}
}
// The uid of a loaded service by nick, if present.
fn service_uid(&self, nick: &str) -> Option<String> {
self.services.iter().find(|s| s.nick().eq_ignore_ascii_case(nick)).map(|s| s.uid().to_string())
}
// Dispatch a command to a specific service (by uid), borrowing the disjoint
// engine fields it needs.
fn route_to(&mut self, uid: &str, sender: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
let Self { services, network, db, .. } = self;
if let Some(svc) = services.iter_mut().find(|s| s.uid() == uid) {
svc.on_command(sender, args, ctx, network, db);
}
}
}

256
src/engine/kicker.rs Normal file
View file

@ -0,0 +1,256 @@
use super::*;
impl Engine {
// A BotServ kicker verdict for a channel line: if the channel has an active
// kicker the message trips (and the sender isn't an exempt operator), the
// assigned bot kicks them — and, once they've been kicked `ttb` times, bans
// them first. `&mut self` because FLOOD/REPEAT and times-to-ban keep per-user
// counters. Hot path: one HashMap lookup for a channel with no kickers, and
// no heap allocation for a returning speaker.
pub(crate) fn kicker_check(&mut self, from: &str, channel: &str, text: &str) -> Vec<NetAction> {
// `c` borrows self.db; the other fields (network, chatter, badword_cache)
// are disjoint, so they can be touched while it is alive — but we stop
// using `c` before mutating chatter, by copying its config into locals.
let Some(c) = self.db.channel(channel) else { return Vec::new() };
let k = &c.kickers;
if !k.any() {
return Vec::new();
}
if k.dontkickops && self.network.is_op(channel, from) {
return Vec::new();
}
if k.dontkickvoices && self.network.is_voiced(channel, from) {
return Vec::new();
}
let Some(bot) = c.assigned_bot.as_deref() else { return Vec::new() };
let Some(botuid) = self.network.uid_by_nick(bot).map(str::to_string) else { return Vec::new() };
// Stateless kickers first (cheapest, and they don't touch counters), then
// badwords against the channel's compiled regex set (rebuilt only when the
// list's revision changes).
let mut reason: Option<&'static str> = k.violation(text);
if reason.is_none() && k.badwords && !c.badwords.is_empty() {
let rev = c.badwords_rev;
if self.badword_cache.get(channel).map(|cb| cb.rev) != Some(rev) {
let set = db::build_badword_set(&c.badwords);
self.badword_cache.insert(channel.to_string(), CachedBadwords { rev, set });
}
if self.badword_cache.get(channel).unwrap().set.is_match(text) {
reason = Some("Watch your language!");
}
}
// Copy the rest of the config out so `c`'s borrow ends before we mutate
// the counter map below.
let (flood_lines, flood_secs) = k.flood_thresholds();
let (flood, repeat, repeat_times) = (k.flood, k.repeat, k.repeat_threshold());
let (ttb, ban_expire, warn) = (k.ttb, k.ban_expire, k.warn);
// FLOOD/REPEAT (only if nothing has tripped yet), updating counters.
if reason.is_none() && (flood || repeat) {
let now = self.now_secs();
let state = self.chatter_entry(channel, from);
if flood {
if now.saturating_sub(state.flood_start) > flood_secs as u64 {
state.flood_start = now;
state.flood_lines = 0;
}
state.flood_lines = state.flood_lines.saturating_add(1);
if state.flood_lines >= flood_lines {
reason = Some("Stop flooding!");
}
}
if reason.is_none() && repeat {
let h = ci_hash(text);
if h == state.last_hash {
state.repeats = state.repeats.saturating_add(1);
} else {
state.repeats = 0;
state.last_hash = h;
}
if state.repeats >= repeat_times {
reason = Some("Stop repeating yourself!");
}
}
}
let Some(reason) = reason else { return Vec::new() };
// WARN: the first offence gets a private warning from the bot instead of a
// kick; the next one is kicked for real.
if warn {
let already = {
let state = self.chatter_entry(channel, from);
let w = state.warned;
state.warned = true;
w
};
if !already {
self.bump("botserv.warn");
return vec![NetAction::Notice { from: botuid, to: from.to_string(), text: format!("Please mind the channel rules — {reason} Next time you'll be kicked.") }];
}
}
// Times-to-ban: after `ttb` kicks the bot bans (an ideal host mask) before
// kicking, and schedules the unban if BANEXPIRE is set.
let mut out = Vec::new();
if ttb > 0 {
let kicks = {
let state = self.chatter_entry(channel, from);
state.kicks = state.kicks.saturating_add(1);
state.kicks
};
if kicks >= ttb {
self.chatter_entry(channel, from).kicks = 0;
if let Some(host) = self.network.host_of(from).map(str::to_string) {
let mask = format!("*!*@{host}");
out.push(NetAction::ChannelMode { from: botuid.clone(), channel: channel.to_string(), modes: format!("+b {mask}") });
self.bump("botserv.ban");
if ban_expire > 0 {
let at = self.now_secs() + ban_expire as u64;
self.pending_unbans.push(PendingUnban { at, from: botuid.clone(), channel: channel.to_string(), mask });
}
}
}
}
out.push(NetAction::Kick { from: botuid, channel: channel.to_string(), uid: from.to_string(), reason: reason.to_string() });
self.bump("botserv.kick");
out
}
// An auto-response for a channel line: if it matches a TRIGGER pattern, the
// assigned bot says the trigger's response (with $nick substituted). Only the
// first matching trigger fires. Cache rebuilt only when the list changes.
pub(crate) fn trigger_response(&mut self, from: &str, channel: &str, text: &str) -> Option<NetAction> {
let c = self.db.channel(channel)?;
if c.triggers.is_empty() {
return None;
}
let bot = c.assigned_bot.as_deref()?;
let botuid = self.network.uid_by_nick(bot)?.to_string();
let rev = c.triggers_rev;
if self.trigger_cache.get(channel).map(|ct| ct.rev) != Some(rev) {
let entries = c
.triggers
.iter()
.filter_map(|t| {
regex::RegexBuilder::new(&t.pattern)
.case_insensitive(true)
.size_limit(db::BADWORD_SIZE_LIMIT)
.build()
.ok()
.map(|re| TriggerRt { re, response: t.response.clone(), cooldown: t.cooldown, last_fired: 0 })
})
.collect();
self.trigger_cache.insert(channel.to_string(), CachedTriggers { rev, entries });
}
let now = self.now_secs();
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
// First matching trigger wins; $1..$9 fill from capture groups, $nick from
// the speaker. A trigger still cooling down suppresses the response.
let cached = self.trigger_cache.get_mut(channel).unwrap();
let mut response = None;
for e in cached.entries.iter_mut() {
if let Some(caps) = e.re.captures(text) {
if now.saturating_sub(e.last_fired) < e.cooldown as u64 {
return None;
}
e.last_fired = now;
let mut resp = e.response.clone();
for i in 1..caps.len() {
resp = resp.replace(&format!("${i}"), caps.get(i).map_or("", |m| m.as_str()));
}
response = Some(resp.replace("$nick", &nick));
break;
}
}
let response = response?;
self.bump("botserv.trigger");
Some(NetAction::Privmsg { from: botuid, to: channel.to_string(), text: response })
}
// Get (or lazily create) a speaker's counters in a channel. get_mut avoids
// any allocation for a channel/user already being tracked.
fn chatter_entry(&mut self, channel: &str, from: &str) -> &mut ChatterState {
if !self.chatter.contains_key(channel) {
self.chatter.insert(channel.to_string(), HashMap::new());
}
let bucket = self.chatter.get_mut(channel).unwrap();
if !bucket.contains_key(from) {
bucket.insert(from.to_string(), ChatterState::default());
}
bucket.get_mut(from).unwrap()
}
// Drop a user's chatter counters for a channel (on part), and the channel's
// bucket once empty, so kicker state never outlives the people it tracks.
pub(crate) fn forget_chatter(&mut self, channel: &str, uid: &str) {
if let Some(bucket) = self.chatter.get_mut(channel) {
bucket.remove(uid);
if bucket.is_empty() {
self.chatter.remove(channel);
}
}
}
// Drop a user's chatter counters across every channel (on quit).
pub(crate) fn forget_chatter_everywhere(&mut self, uid: &str) {
self.chatter.retain(|_, bucket| {
bucket.remove(uid);
!bucket.is_empty()
});
}
// Community moderation: `!votekick <nick>` / `!voteban <nick>`. Each voter
// (by uid) counts once; when the channel's VOTEKICK threshold is reached the
// assigned bot kicks (or bans then kicks) the target. Returns None if the line
// isn't a vote command, so the caller can fall through to fantasy.
pub(crate) fn handle_vote(&mut self, from: &str, channel: &str, text: &str) -> Option<Vec<NetAction>> {
let (ban, target_raw) = if let Some(t) = text.strip_prefix("!votekick ") {
(false, t)
} else {
(true, text.strip_prefix("!voteban ")?)
};
let target_nick = target_raw.trim();
if target_nick.is_empty() || target_nick.contains(' ') {
return Some(Vec::new());
}
let threshold = self.db.channel(channel).map(|c| c.kickers.votekick).unwrap_or(0);
let botnick = self.db.channel(channel).and_then(|c| c.assigned_bot.clone());
let (Some(botnick), true) = (botnick, threshold > 0) else { return Some(Vec::new()) };
let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return Some(Vec::new()) };
let Some(target_uid) = self.network.uid_by_nick(target_nick).map(str::to_string) else {
return Some(vec![NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("There's no \x02{target_nick}\x02 here to vote on.") }]);
};
let target_display = self.network.nick_of(&target_uid).unwrap_or(target_nick).to_string();
let now = self.now_secs();
let key = (channel.to_ascii_lowercase(), target_display.to_ascii_lowercase());
// Sweep tallies past their window before touching the map: a passed vote
// deletes its own key, but one that never reaches threshold would linger
// forever otherwise, so the map would grow without bound under abuse.
self.votes.retain(|_, vs| now.saturating_sub(vs.started) <= VOTE_TTL);
let vs = self.votes.entry(key.clone()).or_insert(VoteState { ban, voters: std::collections::HashSet::new(), started: now });
vs.ban = ban;
vs.voters.insert(from.to_string());
let count = vs.voters.len() as u16;
if count < threshold {
let verb = if ban { "ban" } else { "kick" };
return Some(vec![NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("\x02{count}\x02/\x02{threshold}\x02 votes to {verb} \x02{target_display}\x02.") }]);
}
self.votes.remove(&key);
self.bump("botserv.votekick");
let mut out = Vec::new();
if ban {
if let Some(host) = self.network.host_of(&target_uid).map(str::to_string) {
out.push(NetAction::ChannelMode { from: botuid.clone(), channel: channel.to_string(), modes: format!("+b *!*@{host}") });
}
}
let verb = if ban { "banned" } else { "kicked" };
out.push(NetAction::Privmsg { from: botuid.clone(), to: channel.to_string(), text: format!("Vote passed — \x02{target_display}\x02 has been {verb}.") });
out.push(NetAction::Kick { from: botuid, channel: channel.to_string(), uid: target_uid, reason: format!("Voted out by the channel ({count} votes)") });
Some(out)
}
}

File diff suppressed because it is too large Load diff

196
src/engine/register.rs Normal file
View file

@ -0,0 +1,196 @@
use super::*;
impl Engine {
// ── Account authority (see grpc.rs) ─────────────────────────────────────
// A trusted caller (e.g. a website backend that already did its own login
// check) managing accounts the same way an IRC user does through NickServ,
// minus the command syntax. The bearer token on the gRPC side IS the
// authorization: unlike the mirrored NickServ commands, these do NOT
// re-check the account's own password — Register and Authenticate are the
// two exceptions, since the password is the actual input there.
pub fn authority_pre_check(&mut self, name: &str) -> Result<(), AuthorityStatus> {
if self.db.exists(name) {
return Err(AuthorityStatus::AlreadyExists);
}
if !self.reg_limiter.allow() {
return Err(AuthorityStatus::RateLimited);
}
Ok(())
}
// Provision an account from pre-derived SCRAM verifiers (bulk backfill from
// the authority). No email confirmation — the authority already vouches for it.
pub fn authority_provision(&mut self, name: &str, scram256: &str, scram512: &str, email: Option<String>) -> AuthorityStatus {
match self.db.provision_account(name, scram256, scram512, email) {
Ok(()) => AuthorityStatus::Ok,
Err(RegError::Exists) => AuthorityStatus::AlreadyExists,
Err(_) => AuthorityStatus::Internal,
}
}
pub fn authority_register(&mut self, name: &str, creds: Option<db::Credentials>, email: Option<String>) -> AuthorityStatus {
let Some(creds) = creds else { return AuthorityStatus::Internal };
let addr = email.clone();
let status = match self.db.register_prepared(name, creds, email) {
Ok(()) => AuthorityStatus::Ok,
Err(RegError::Exists) => AuthorityStatus::AlreadyExists,
Err(RegError::Internal) => AuthorityStatus::Internal,
};
if status == AuthorityStatus::Ok && !self.db.is_verified(name) {
if let Some(addr) = addr {
let code = self.db.issue_code(name, db::CodeKind::Confirm);
let mail = fedserv_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code);
self.emit_irc(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
}
}
status
}
pub fn authority_authenticate(&self, name: &str, password: &str) -> Option<String> {
self.db.authenticate(name, password).map(str::to_string)
}
pub fn authority_set_password(&mut self, account: &str, creds: Option<db::Credentials>) -> AuthorityStatus {
let Some(creds) = creds else { return AuthorityStatus::Internal };
// set_credentials's only Err is Internal, which here always means "no such account".
match self.db.set_credentials(account, creds) {
Ok(()) => AuthorityStatus::Ok,
Err(_) => AuthorityStatus::NotFound,
}
}
pub fn authority_set_email(&mut self, account: &str, email: Option<String>) -> AuthorityStatus {
match self.db.set_email(account, email) {
Ok(()) => AuthorityStatus::Ok,
Err(_) => AuthorityStatus::NotFound,
}
}
pub fn authority_confirm(&mut self, account: &str, code: &str) -> AuthorityStatus {
if !self.db.exists(account) {
return AuthorityStatus::NotFound;
}
if self.db.is_verified(account) {
return AuthorityStatus::Ok; // already confirmed — idempotent, not an error
}
if !self.db.take_code(account, db::CodeKind::Confirm, code) {
return AuthorityStatus::Invalid;
}
match self.db.verify_account(account) {
Ok(()) => AuthorityStatus::Ok,
Err(_) => AuthorityStatus::Internal,
}
}
// Drop reuses the exact cleanup a peer's gossiped drop already triggers
// locally (channel release + session logout) — see `handle_account_gone`.
pub fn authority_drop(&mut self, account: &str) -> AuthorityStatus {
match self.db.drop_account(account) {
Ok(true) => {
self.handle_account_gone(account, "was dropped");
AuthorityStatus::Ok
}
Ok(false) => AuthorityStatus::NotFound,
Err(_) => AuthorityStatus::Internal,
}
}
// Unlike Drop, the account itself is untouched — only its active IRC
// sessions are logged out (no channel cleanup, this isn't "account gone").
pub fn authority_force_logout(&mut self, account: &str) -> u32 {
let victims = self.network.uids_logged_into(account);
let n = victims.len() as u32;
let ns = self.nick_service.clone();
for uid in victims {
self.network.clear_account(&uid);
self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() });
if let Some(ns) = &ns {
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You have been logged out of \x02{account}\x02.") });
}
}
n
}
pub fn authority_group_nick(&mut self, nick: &str, account: &str) -> AuthorityStatus {
if self.db.account(nick).is_some() {
return AuthorityStatus::Invalid; // nick is itself a registered account
}
match self.db.group_nick(nick, account) {
Ok(()) => AuthorityStatus::Ok,
Err(_) => AuthorityStatus::NotFound, // group_nick's only Err means the account doesn't exist
}
}
pub fn authority_ungroup_nick(&mut self, nick: &str) -> AuthorityStatus {
match self.db.ungroup_nick(nick) {
Ok(true) => AuthorityStatus::Ok,
Ok(false) => AuthorityStatus::NotFound,
Err(_) => AuthorityStatus::Internal,
}
}
// Authority side of the IRCv3 account-registration relay. Hands the work to
// the link layer (which derives the password off-thread) via DeferRegister.
pub(crate) fn account_request(&mut self, reqid: String, kind: String, account: String, p2: String, p3: String) -> Vec<NetAction> {
if !kind.eq_ignore_ascii_case("REGISTER") {
return Vec::new(); // VERIFY / RESEND / STATUS: later
}
let email = if p2.is_empty() || p2 == "*" { None } else { Some(p2) };
vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind } }]
}
// Cheap gate run before the expensive derivation: reject an already-taken name
// outright, and rate-limit the rest so a REGISTER flood can't pin CPU. Returns
// the rejection response if refused, or None to proceed (spending a token).
pub fn pre_register_check(&mut self, account: &str, reply: &RegReply) -> Option<Vec<NetAction>> {
if self.db.external_accounts() {
return Some(reg_reply(reply, RegOutcome::External, account));
}
if self.db.registrations_frozen() {
return Some(reg_reply(reply, RegOutcome::Frozen, account));
}
if self.db.exists(account) {
return Some(reg_reply(reply, RegOutcome::Exists, account));
}
if !self.reg_limiter.allow() {
return Some(reg_reply(reply, RegOutcome::RateLimited, account));
}
None
}
// Commit credentials the link layer derived off-thread, then answer `reply`.
pub fn complete_register(&mut self, account: &str, creds: Option<db::Credentials>, email: Option<String>, reply: RegReply) -> Vec<NetAction> {
let Some(creds) = creds else {
return reg_reply(&reply, RegOutcome::Internal, account);
};
let addr = email.clone();
let outcome = match self.db.register_prepared(account, creds, email) {
Ok(()) => RegOutcome::Ok,
Err(RegError::Exists) => RegOutcome::Exists,
Err(RegError::Internal) => RegOutcome::Internal,
};
let ok = matches!(outcome, RegOutcome::Ok);
let mut out = reg_reply(&reply, outcome, account);
self.track_accounts(&out);
// If this created an unverified account (email confirmation applies), email a code.
if ok && !self.db.is_verified(account) {
if let (Some(addr), RegReply::NickServ { agent, uid, .. }) = (addr, &reply) {
let code = self.db.issue_code(account, db::CodeKind::Confirm);
let mail = fedserv_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code);
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.".to_string() });
}
}
out
}
// Commit a password change the link layer derived off-thread, then notice the user.
pub fn complete_password_change(&mut self, account: &str, creds: Option<db::Credentials>, agent: &str, uid: &str) -> Vec<NetAction> {
let text = match creds.and_then(|c| self.db.set_credentials(account, c).ok()) {
Some(()) => format!("Your password for \x02{account}\x02 has been changed."),
None => "Sorry, that didn't work. Please try again in a moment.".to_string(),
};
vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }]
}
}

149
src/engine/sasl.rs Normal file
View file

@ -0,0 +1,149 @@
use super::*;
impl Engine {
// SASL agent side of the exchange the ircd relays to us (modes H/S/C/D), per
// IRCv3 SASL 3.2. On success we set the client's account (drives 900) then
// report success (drives 903); a bad credential or unknown mechanism reports
// failure (drives 904). PLAIN and SCRAM-SHA-256/512 are supported.
pub(crate) fn sasl(&mut self, client: String, mode: String, data: Vec<String>) -> Vec<NetAction> {
let agent = match self.services.first() {
Some(s) => s.uid().to_string(),
None => return Vec::new(),
};
let mk = |mode: &str, d: Vec<String>| {
vec![NetAction::Sasl { agent: agent.clone(), client: client.clone(), mode: mode.to_string(), data: d }]
};
match mode.as_str() {
"H" => Vec::new(), // host info
"S" => {
self.sweep_sasl();
if self.sasl_sessions.len() >= MAX_SASL_SESSIONS {
return mk("D", vec!["F".to_string()]); // too many in flight
}
match data.first().map(String::as_str) {
Some("PLAIN") => {
self.stash_sasl(client.clone(), SaslSession::Plain { response: String::new() });
mk("C", vec!["+".to_string()]) // empty challenge -> client sends the payload
}
Some("EXTERNAL") => {
// The ircd appends the client's TLS cert fingerprints after the mechanism.
let fingerprints = data.iter().skip(1).map(|f| f.to_ascii_lowercase()).collect();
self.stash_sasl(client.clone(), SaslSession::External { fingerprints });
mk("C", vec!["+".to_string()]) // client sends its authzid (or "+")
}
Some(mech) if scram::Hash::from_mech(mech).is_some() => {
let hash = scram::Hash::from_mech(mech).unwrap();
self.stash_sasl(client.clone(), SaslSession::Scram { hash, step: ScramStep::ClientFirst });
mk("C", vec!["+".to_string()]) // client sends client-first next
}
_ => mk("D", vec!["F".to_string()]), // unsupported mechanism
}
}
"C" => {
let chunk = data.first().map(String::as_str).unwrap_or("");
match self.sasl_sessions.remove(&client).map(|t| t.session) {
None => mk("D", vec!["F".to_string()]),
Some(SaslSession::Plain { mut response }) => {
// Reassemble the base64 response: append each chunk until
// one is shorter than a full chunk (or a lone "+").
if chunk != "+" {
response.push_str(chunk);
}
if response.len() > MAX_SASL_RESPONSE {
return mk("D", vec!["F".to_string()]);
}
if chunk.len() >= MAX_AUTHENTICATE {
self.stash_sasl(client.clone(), SaslSession::Plain { response });
return Vec::new(); // more chunks still to come
}
match login_plain(&response, &self.db) {
Some(account) => self.sasl_login(&agent, &client, account),
None => mk("D", vec!["F".to_string()]),
}
}
Some(SaslSession::Scram { hash, step }) => self.sasl_scram(&agent, &client, hash, step, chunk),
Some(SaslSession::External { fingerprints }) => self.sasl_external(&agent, &client, fingerprints, chunk),
}
}
"D" => {
self.sasl_sessions.remove(&client);
Vec::new()
}
_ => Vec::new(),
}
}
// One SCRAM step. The client's messages arrive base64-encoded in the C data
// (except the final empty "+" acknowledgement).
fn sasl_scram(&mut self, agent: &str, client: &str, hash: scram::Hash, step: ScramStep, chunk: &str) -> Vec<NetAction> {
let fail = || vec![NetAction::Sasl {
agent: agent.to_string(), client: client.to_string(), mode: "D".to_string(), data: vec!["F".to_string()],
}];
let challenge = |msg: String| vec![NetAction::Sasl {
agent: agent.to_string(), client: client.to_string(), mode: "C".to_string(), data: vec![STANDARD.encode(msg)],
}];
let decode = |chunk: &str| STANDARD.decode(chunk).ok().and_then(|b| String::from_utf8(b).ok());
match step {
ScramStep::ClientFirst => {
let Some(cf) = decode(chunk).as_deref().and_then(scram::parse_client_first) else {
return fail();
};
let Some((account, verifier)) = self.db.scram_lookup(&cf.username, hash.mech()) else {
return fail();
};
let (account, Some(verifier)) = (account.to_string(), scram::parse_verifier(verifier)) else {
return fail();
};
let (server_first, nonce) = scram::server_first(&cf.cnonce, &verifier);
let out = challenge(server_first.clone());
self.stash_sasl(client.to_string(), SaslSession::Scram {
hash,
step: ScramStep::ClientFinal {
account,
verifier,
client_first_bare: cf.client_first_bare,
server_first,
gs2_header: cf.gs2_header,
nonce,
},
});
out
}
ScramStep::ClientFinal { account, verifier, client_first_bare, server_first, gs2_header, nonce } => {
let Some(msg) = decode(chunk) else { return fail() };
match scram::verify_final(hash, &verifier, &client_first_bare, &server_first, &gs2_header, &nonce, &msg) {
Some(server_final) => {
let out = challenge(server_final);
self.stash_sasl(client.to_string(), SaslSession::Scram { hash, step: ScramStep::Ack { account } });
out
}
None => fail(),
}
}
// Client acknowledged our server-final ("+"); apply the login.
ScramStep::Ack { account } => self.sasl_login(agent, client, account),
}
}
// SASL EXTERNAL: the credential is the client's TLS cert fingerprint, which
// the ircd handed us in the S message (already validated at the handshake).
// Match it to an account; an authzid, if given, must name that same account.
fn sasl_external(&self, agent: &str, client: &str, fingerprints: Vec<String>, chunk: &str) -> Vec<NetAction> {
let authzid = if chunk == "+" || chunk.is_empty() {
String::new()
} else {
match STANDARD.decode(chunk).ok().and_then(|b| String::from_utf8(b).ok()) {
Some(a) => a,
None => return sasl_fail(agent, client),
}
};
match fingerprints.iter().find_map(|fp| self.db.certfp_owner(fp)) {
Some(account) if authzid.is_empty() || authzid.eq_ignore_ascii_case(account) => {
let account = account.to_string();
self.sasl_login(agent, client, account)
}
_ => sasl_fail(agent, client),
}
}
}

3462
src/engine/tests.rs Normal file

File diff suppressed because it is too large Load diff