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:
parent
0c7976e80d
commit
6f76f9722c
6 changed files with 4194 additions and 4177 deletions
125
src/engine/dispatch.rs
Normal file
125
src/engine/dispatch.rs
Normal 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
256
src/engine/kicker.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
4183
src/engine/mod.rs
4183
src/engine/mod.rs
File diff suppressed because it is too large
Load diff
196
src/engine/register.rs
Normal file
196
src/engine/register.rs
Normal 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
149
src/engine/sasl.rs
Normal 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
3462
src/engine/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue