1514 lines
62 KiB
Rust
1514 lines
62 KiB
Rust
use std::collections::HashMap;
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
|
|
// Ceiling on a single compiled badword regex, so one pattern can't consume huge
|
|
// memory. Shared by add-time validation and the engine's match-time build.
|
|
pub const BADWORD_SIZE_LIMIT: usize = 1 << 20;
|
|
|
|
/// Compile a channel's badword patterns into one RegexSet. Case-insensitive by
|
|
/// default (a pattern can opt out with `(?-i)`), size-limited, and tolerant of a
|
|
/// bad entry so one pattern can't disable the whole set.
|
|
pub fn build_badword_set(patterns: &[String]) -> regex::RegexSet {
|
|
regex::RegexSetBuilder::new(patterns)
|
|
.case_insensitive(true)
|
|
.size_limit(BADWORD_SIZE_LIMIT)
|
|
.build()
|
|
.unwrap_or_else(|_| regex::RegexSet::empty())
|
|
}
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use rand_core::{OsRng, RngCore};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::broadcast;
|
|
|
|
use super::scram::{self, Hash};
|
|
|
|
// The store is one type, `Db`, whose operations are grouped by domain into
|
|
// sibling files; the type definitions, log plumbing, and shared helpers live
|
|
// here. Each domain file adds its own `impl Db` block.
|
|
mod account;
|
|
mod channel;
|
|
mod event;
|
|
mod network;
|
|
mod store;
|
|
|
|
pub use event::Event;
|
|
pub(crate) use event::{apply, Scope};
|
|
|
|
// Error kinds, the emailed-code purpose, and the module-facing views live in the
|
|
// echo-api SDK crate; re-exported so the engine keeps naming them locally and
|
|
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
|
pub use echo_api::{
|
|
AccountView, AjoinView, AkillView, BotView, Caps, ForbidKind, ForbidView, GroupView, HelpView, IgnoreView, MemoView, NewsKind, NewsView, NotifyView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView, XlineKind,
|
|
};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Account {
|
|
pub name: String,
|
|
pub email: Option<String>,
|
|
pub ts: u64,
|
|
// The node that first registered this account (its "home"). With `ts` it
|
|
// deterministically resolves a concurrent registration of the same name across
|
|
// the federation. Named `home` not `origin` so it can't collide with the log
|
|
// envelope's `origin` when this struct is flattened into a LogEntry. Defaulted
|
|
// for records written before this existed.
|
|
#[serde(default)]
|
|
pub home: String,
|
|
// SCRAM verifiers (`v=1,i=,s=,sk=,sv=`), computed from the password at
|
|
// registration. Absent on accounts registered before SCRAM support.
|
|
#[serde(default)]
|
|
pub scram256: Option<String>,
|
|
#[serde(default)]
|
|
pub scram512: Option<String>,
|
|
// TLS client-certificate fingerprints (lowercase hex) that may log in to
|
|
// this account via SASL EXTERNAL. Each fingerprint maps to one account.
|
|
#[serde(default)]
|
|
pub certfps: Vec<String>,
|
|
// Whether the email on file has been confirmed. Defaults true so accounts
|
|
// predating email confirmation (and those registered without email) count
|
|
// as verified.
|
|
#[serde(default = "verified_default")]
|
|
pub verified: bool,
|
|
// Channels this account is auto-joined to on identify (AJOIN).
|
|
#[serde(default)]
|
|
pub ajoin: Vec<AjoinEntry>,
|
|
// Services suspension, if any (login blocked while set and unexpired).
|
|
#[serde(default)]
|
|
pub suspension: Option<Suspension>,
|
|
// Memos left for this account (MemoServ), oldest first.
|
|
#[serde(default)]
|
|
pub memos: Vec<Memo>,
|
|
// Accounts this user won't receive memos from (MemoServ IGNORE).
|
|
#[serde(default)]
|
|
pub memo_ignore: Vec<String>,
|
|
// MemoServ SET NOTIFY: be told about new memos on login (default on).
|
|
#[serde(default = "verified_default")]
|
|
pub memo_notify: bool,
|
|
// MemoServ SET LIMIT: mailbox cap (None = the network default).
|
|
#[serde(default)]
|
|
pub memo_limit: Option<u32>,
|
|
// Personal greet a bot shows when this account joins a greet-enabled channel.
|
|
#[serde(default)]
|
|
pub greet: String,
|
|
// NickServ SET AUTOOP: when off, this user is never auto-opped on join even
|
|
// where they hold access (they op themselves). Stored inverted so the default
|
|
// (auto-op enabled) is the zero value.
|
|
#[serde(default)]
|
|
pub no_autoop: bool,
|
|
// NickServ SET KILL: when off, this account's nicks are not protected — an
|
|
// unidentified user keeping the nick is never renamed to a guest. Stored
|
|
// inverted so the default (protection enabled) is the zero value.
|
|
#[serde(default)]
|
|
pub no_protect: bool,
|
|
// NickServ SET HIDE STATUS: when set, the last-seen / online line in INFO is
|
|
// shown only to the account's owner and to opers, not to other users. Default
|
|
// (visible) is the zero value.
|
|
#[serde(default)]
|
|
pub hide_status: bool,
|
|
// NickServ SET SNOTICE: when set, service replies to this user come as a
|
|
// server notice ("*** NickServ: …") instead of a normal notice from the
|
|
// pseudoclient. Default (normal notice) is the zero value.
|
|
#[serde(default)]
|
|
pub snotice: bool,
|
|
// Assigned vhost (HostServ), applied to the displayed host on identify.
|
|
#[serde(default)]
|
|
pub vhost: Option<Vhost>,
|
|
// A vhost the user has requested, pending operator approval.
|
|
#[serde(default)]
|
|
pub vhost_request: Option<PendingVhost>,
|
|
// Unix time this account was last active (identified). 0 = never stamped
|
|
// since expiry tracking existed, in which case `ts` (registration) stands in.
|
|
#[serde(default)]
|
|
pub last_seen: u64,
|
|
// Pinned by an operator so inactivity-expiry never drops it.
|
|
#[serde(default)]
|
|
pub noexpire: bool,
|
|
// Whether an impending-expiry warning email has already been sent (reset on
|
|
// the next activity, so each idle spell warns at most once).
|
|
#[serde(default)]
|
|
pub expiry_warned: bool,
|
|
// A staff note (OperServ INFO), shown only to operators.
|
|
#[serde(default)]
|
|
pub oper_note: Option<String>,
|
|
}
|
|
|
|
// A requested vhost awaiting approval.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PendingVhost {
|
|
pub host: String,
|
|
pub ts: u64,
|
|
}
|
|
|
|
// A HostServ virtual host: the displayed host, who assigned it, and when.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Vhost {
|
|
pub host: String,
|
|
pub setter: String,
|
|
pub ts: u64,
|
|
// Absolute unix-seconds expiry, or None for a permanent vhost.
|
|
#[serde(default)]
|
|
pub expires: Option<u64>,
|
|
}
|
|
|
|
fn verified_default() -> bool {
|
|
true
|
|
}
|
|
|
|
// An access-list entry: an account and its level ("op" or "voice").
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChanAccess {
|
|
pub account: String,
|
|
pub level: String,
|
|
}
|
|
|
|
// An auto-kick entry: a nick!user@host mask and why it was added.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChanAkick {
|
|
pub mask: String,
|
|
pub reason: String,
|
|
}
|
|
|
|
// A services suspension on an account: who set it, why, when, and an optional
|
|
// absolute-unix-seconds expiry (None = until manually lifted).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Suspension {
|
|
pub by: String,
|
|
pub reason: String,
|
|
pub ts: u64,
|
|
#[serde(default)]
|
|
pub expires: Option<u64>,
|
|
}
|
|
|
|
// A network ban: the ircd X-line `kind` (e.g. "G" for a user@host G-line, "Q"
|
|
// for a nick Q-line), a mask, who set it, why, when, and an optional
|
|
// absolute-unix-seconds expiry (None = permanent).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Akill {
|
|
#[serde(default = "gline_kind")]
|
|
pub kind: String,
|
|
pub mask: String,
|
|
pub setter: String,
|
|
pub reason: String,
|
|
pub ts: u64,
|
|
#[serde(default)]
|
|
pub expires: Option<u64>,
|
|
}
|
|
|
|
// A spam filter (OperServ SPAMFILTER), pushed to the ircd's m_filter. `expires`
|
|
// is absolute unix seconds (None = permanent), like an Akill.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Filter {
|
|
pub pattern: String,
|
|
pub action: String,
|
|
pub flags: String,
|
|
pub reason: String,
|
|
pub setter: String,
|
|
pub ts: u64,
|
|
#[serde(default)]
|
|
pub expires: Option<u64>,
|
|
}
|
|
|
|
// A registration ban: an account name, channel, or email pattern that may not be
|
|
// registered. `kind` is "NICK", "CHAN", or "EMAIL". Network-wide policy like an
|
|
// AKILL, so it gossips and every node enforces it.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Forbid {
|
|
pub kind: String,
|
|
pub mask: String,
|
|
pub setter: String,
|
|
pub reason: String,
|
|
pub ts: u64,
|
|
}
|
|
|
|
// The default ban kind for records written before bans carried one: a G-line.
|
|
fn gline_kind() -> String {
|
|
"G".to_string()
|
|
}
|
|
|
|
// An OperServ NOTIFY watch entry: a mask (nick!user@host#gecos or #channel) whose
|
|
// matching users have flagged events announced to the staff feed. Network-wide
|
|
// oper policy like a forbid, so it gossips and persists; `expires` None = forever.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Notify {
|
|
pub mask: String,
|
|
pub flags: String,
|
|
pub reason: String,
|
|
pub setter: String,
|
|
pub ts: u64,
|
|
#[serde(default)]
|
|
pub expires: Option<u64>,
|
|
}
|
|
|
|
// A valid 3-char server id for a juped server from a counter: a leading digit
|
|
// (kept high to avoid colliding with typical real SIDs) then two base-36 chars.
|
|
fn jupe_sid(seq: u32) -> String {
|
|
const C: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
let b = C[(seq / 36 % 36) as usize];
|
|
let c = C[(seq % 36) as usize];
|
|
String::from_utf8(vec![b'9', b, c]).unwrap()
|
|
}
|
|
|
|
// A services ignore: services silently drop commands from a matching user. Node-
|
|
// local and in-memory (like the auth throttle) — a fast, transient moderation
|
|
// tool, not persisted or federated.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Ignore {
|
|
pub mask: String,
|
|
pub reason: String,
|
|
pub expires: Option<u64>,
|
|
}
|
|
|
|
// A news item (InfoServ bulletin): a `kind` ("logon" shown to everyone on
|
|
// connect, "oper" shown to operators on login), the text, who set it, and when.
|
|
// Carries a stable id (assigned at add time, embedded in the log) so deletion is
|
|
// order-independent under replay.
|
|
#[derive(Debug, Clone)]
|
|
pub struct News {
|
|
pub id: u64,
|
|
pub kind: String,
|
|
pub text: String,
|
|
pub setter: String,
|
|
pub ts: u64,
|
|
}
|
|
|
|
// A help-desk ticket (HelpServ): who asked, their message, when, the staff member
|
|
// handling it (if taken), and whether it's still open.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HelpTicket {
|
|
pub id: u64,
|
|
pub requester: String,
|
|
pub message: String,
|
|
pub ts: u64,
|
|
pub handler: Option<String>,
|
|
pub open: bool,
|
|
}
|
|
|
|
// A user group (GroupServ): a `!name`, its founder account, and member accounts
|
|
// each with group-access flags. Groups can be granted channel access.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Group {
|
|
pub name: String, // "!name", casefolded
|
|
pub founder: String,
|
|
pub ts: u64,
|
|
pub members: Vec<GroupMember>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GroupMember {
|
|
pub account: String,
|
|
pub flags: String,
|
|
}
|
|
|
|
// An abuse report (ReportServ): who filed it, the nick/channel it's about, why,
|
|
// when, and whether it's still open. Stable id like News.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Report {
|
|
pub id: u64,
|
|
pub reporter: String,
|
|
pub target: String,
|
|
pub reason: String,
|
|
pub ts: u64,
|
|
pub open: bool,
|
|
}
|
|
|
|
// The network-wide lists that aren't accounts/channels/grouped/bots: the network
|
|
// bans and the news items. Bundled so `apply` threads one parameter for them
|
|
// (and stays within its argument budget as more are added).
|
|
// Persisted per-channel BOTSTATS activity: (channel, line count, top talkers).
|
|
pub type ChanStats = Vec<(String, u64, Vec<(String, u64)>)>;
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct NetData {
|
|
pub akills: Vec<Akill>,
|
|
pub filters: Vec<Filter>,
|
|
pub forbids: Vec<Forbid>,
|
|
pub notifies: Vec<Notify>,
|
|
pub news: Vec<News>,
|
|
pub news_seq: u64,
|
|
// Abuse reports (ReportServ), oldest first.
|
|
pub reports: Vec<Report>,
|
|
pub report_seq: u64,
|
|
// User groups (GroupServ).
|
|
pub groups: Vec<Group>,
|
|
// Help-desk tickets (HelpServ), oldest first.
|
|
pub help: Vec<HelpTicket>,
|
|
pub help_seq: u64,
|
|
// Runtime services operators (OperServ OPER), casefolded account -> grant.
|
|
// Merged with the declarative [[oper]] config at the engine.
|
|
pub opers: HashMap<String, OperGrant>,
|
|
// Session-limit exceptions (OperServ SESSION): per-IP-mask allowances.
|
|
pub sess_exceptions: Vec<SessionException>,
|
|
// Server jupes (OperServ JUPE), and the counter for minting their fake SIDs.
|
|
pub jupes: Vec<Jupe>,
|
|
pub jupe_seq: u32,
|
|
// The bot auto-assigned to newly registered channels (BotServ AUTOASSIGN),
|
|
// if any. Casefolded nick; None disables auto-assignment.
|
|
pub default_bot: Option<String>,
|
|
// Persisted shared stat counters (StatServ / gRPC Stats), snapshotted so they
|
|
// survive a restart (live gauges are re-derived, not stored here).
|
|
pub stats: std::collections::BTreeMap<String, u64>,
|
|
// Persisted per-channel BOTSTATS activity: (channel, lines, top talkers).
|
|
pub chan_stats: ChanStats,
|
|
// Persisted snapshot of the recent-incident ring (OperServ LOGSEARCH) and its
|
|
// id counter, so the moderation trail survives a restart.
|
|
pub incidents: Vec<(String, u64, String)>,
|
|
pub incident_seq: u64,
|
|
}
|
|
|
|
// A session-limit exception: an IP-mask glob and the session allowance for IPs
|
|
// that match it (0 = unlimited).
|
|
#[derive(Debug, Clone)]
|
|
pub struct SessionException {
|
|
pub mask: String,
|
|
pub limit: u32,
|
|
pub reason: String,
|
|
}
|
|
|
|
// A runtime operator grant: the privilege names and an optional absolute-unix
|
|
// expiry (None = permanent), evaluated lazily like suspensions/vhosts.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OperGrant {
|
|
pub privs: Vec<String>,
|
|
pub expires: Option<u64>,
|
|
}
|
|
|
|
// A memo left for an account (MemoServ).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Memo {
|
|
pub from: String,
|
|
pub text: String,
|
|
pub ts: u64,
|
|
#[serde(default)]
|
|
pub read: bool,
|
|
// The sender asked to be told when it's read (MemoServ RSEND).
|
|
#[serde(default)]
|
|
pub receipt: bool,
|
|
}
|
|
|
|
// A service bot: a pseudo-client BotServ can assign to sit in channels.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Bot {
|
|
pub nick: String,
|
|
pub user: String,
|
|
pub host: String,
|
|
pub gecos: String,
|
|
// Oper-only to assign, and hidden from BOT LIST for non-admins.
|
|
#[serde(default)]
|
|
pub private: bool,
|
|
}
|
|
|
|
// An auto-join entry: a channel this account is joined to on identify, with an
|
|
// optional key for keyed channels.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AjoinEntry {
|
|
pub channel: String,
|
|
#[serde(default)]
|
|
pub key: String,
|
|
}
|
|
|
|
// A channel's on/off options (ChanServ SET). Typed, not a bag of string flags,
|
|
// so a new option is one field and the compiler proves every place handles it.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ChanSettings {
|
|
// Append "(requested by <nick>)" to ChanServ KICK reasons.
|
|
#[serde(default)]
|
|
pub signkick: bool,
|
|
// Hide the channel from ChanServ LIST.
|
|
#[serde(default)]
|
|
pub private: bool,
|
|
// Forbid using ChanServ to act against someone with equal-or-higher access.
|
|
#[serde(default)]
|
|
pub peace: bool,
|
|
// Strip channel-operator status from anyone without op-level access.
|
|
#[serde(default)]
|
|
pub secureops: bool,
|
|
// Kick anyone without channel access when they join.
|
|
#[serde(default)]
|
|
pub restricted: bool,
|
|
// Stored inverted so it defaults to auto-op ON: when set, access members are
|
|
// NOT auto-opped on join (they must UP). SET AUTOOP toggles it.
|
|
#[serde(default)]
|
|
pub noautoop: bool,
|
|
// Remember the topic and restore it when the channel is recreated.
|
|
#[serde(default)]
|
|
pub keeptopic: bool,
|
|
// Revert topic changes made by users without op-level access.
|
|
#[serde(default)]
|
|
pub topiclock: bool,
|
|
// BotServ: show members' personal greets when they join.
|
|
#[serde(default)]
|
|
pub bot_greet: bool,
|
|
// BotServ: forbid the founder from (un)assigning a bot (admin override only).
|
|
#[serde(default)]
|
|
pub nobot: bool,
|
|
}
|
|
|
|
// A registered channel and who owns it.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChannelInfo {
|
|
pub name: String,
|
|
pub founder: String,
|
|
pub ts: u64,
|
|
// Mode-lock: chars services keep set / unset (besides the implicit +r).
|
|
#[serde(default)]
|
|
pub lock_on: String,
|
|
#[serde(default)]
|
|
pub lock_off: String,
|
|
// Params for locked +modes that take one (e.g. ('f', "mute:7:8s"), ('j', "10:6")).
|
|
// Each char here is also in `lock_on`; applied and re-asserted with its param.
|
|
#[serde(default)]
|
|
pub lock_params: Vec<(char, String)>,
|
|
#[serde(default)]
|
|
pub access: Vec<ChanAccess>,
|
|
#[serde(default)]
|
|
pub akick: Vec<ChanAkick>,
|
|
// LEVELS overrides as (capability, tier) name pairs — a capability granted to
|
|
// that access tier and above. Strings at the persistence seam; typed in the view.
|
|
#[serde(default)]
|
|
pub levels: Vec<(String, String)>,
|
|
// Account that inherits the channel if the founder's account is dropped or
|
|
// expires; None means the channel is released instead.
|
|
#[serde(default)]
|
|
pub successor: Option<String>,
|
|
// Free-text description, shown in INFO.
|
|
#[serde(default)]
|
|
pub desc: String,
|
|
// Message noticed to users as they join.
|
|
#[serde(default)]
|
|
pub entrymsg: String,
|
|
// Channel homepage URL, shown in INFO (empty = none).
|
|
#[serde(default)]
|
|
pub url: String,
|
|
// Channel contact email, shown in INFO (empty = none).
|
|
#[serde(default)]
|
|
pub email: String,
|
|
// On/off options set via ChanServ SET.
|
|
#[serde(default)]
|
|
pub settings: ChanSettings,
|
|
// Last known topic, kept for KEEPTOPIC / TOPICLOCK.
|
|
#[serde(default)]
|
|
pub topic: String,
|
|
// Services suspension, if any (channel frozen while set and unexpired).
|
|
#[serde(default)]
|
|
pub suspension: Option<Suspension>,
|
|
// BotServ bot assigned to sit in this channel, if any (its nick).
|
|
#[serde(default)]
|
|
pub assigned_bot: Option<String>,
|
|
// BotServ kicker configuration (the bot kicks on caps/formatting/etc.).
|
|
#[serde(default)]
|
|
pub kickers: KickerSettings,
|
|
// BADWORDS: user-configured regex patterns the badwords kicker matches.
|
|
#[serde(default)]
|
|
pub badwords: Vec<String>,
|
|
// Bumped whenever `badwords` changes, so the engine's compiled-RegexSet
|
|
// cache knows to rebuild. Session-local; never serialized.
|
|
#[serde(skip)]
|
|
pub badwords_rev: u64,
|
|
// TRIGGERs: regex -> response the bot speaks when a line matches.
|
|
#[serde(default)]
|
|
pub triggers: Vec<Trigger>,
|
|
#[serde(skip)]
|
|
pub triggers_rev: u64,
|
|
// Unix time this channel was last used (a member joined). 0 = never stamped
|
|
// since expiry tracking existed, in which case `ts` (registration) stands in.
|
|
#[serde(default)]
|
|
pub last_used: u64,
|
|
// Pinned by an operator so inactivity-expiry never drops it.
|
|
#[serde(default)]
|
|
pub noexpire: bool,
|
|
// Whether an impending-expiry warning email has already been sent.
|
|
#[serde(default)]
|
|
pub expiry_warned: bool,
|
|
// A staff note (OperServ INFO), shown only to operators.
|
|
#[serde(default)]
|
|
pub oper_note: Option<String>,
|
|
}
|
|
|
|
// A bot auto-response: when a channel line matches `pattern`, the assigned bot
|
|
// says `response` (with $nick replaced by the speaker's nick).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Trigger {
|
|
pub pattern: String,
|
|
pub response: String,
|
|
// Minimum seconds between firings of this trigger (0 = no limit).
|
|
#[serde(default)]
|
|
pub cooldown: u32,
|
|
}
|
|
|
|
// BotServ's per-channel "kickers": the assigned bot kicks a message that trips
|
|
// an enabled rule. Typed, like ChanSettings — a new kicker is one field.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct KickerSettings {
|
|
#[serde(default)]
|
|
pub caps: bool,
|
|
// Only messages at least this long are caps-checked (0 = the default, 10).
|
|
#[serde(default)]
|
|
pub caps_min: u16,
|
|
// Percentage of letters that must be uppercase to trip (0 = default, 25).
|
|
#[serde(default)]
|
|
pub caps_percent: u16,
|
|
#[serde(default)]
|
|
pub bolds: bool,
|
|
#[serde(default)]
|
|
pub colors: bool,
|
|
#[serde(default)]
|
|
pub underlines: bool,
|
|
#[serde(default)]
|
|
pub reverses: bool,
|
|
#[serde(default)]
|
|
pub italics: bool,
|
|
// Kick users who send too many lines too fast.
|
|
#[serde(default)]
|
|
pub flood: bool,
|
|
// Lines within `flood_secs` that trip the flood kicker (0 = default, 6).
|
|
#[serde(default)]
|
|
pub flood_lines: u16,
|
|
#[serde(default)]
|
|
pub flood_secs: u16,
|
|
// Kick users who repeat the same line.
|
|
#[serde(default)]
|
|
pub repeat: bool,
|
|
// Consecutive repeats that trip the repeat kicker (0 = default, 3).
|
|
#[serde(default)]
|
|
pub repeat_times: u16,
|
|
// Kick lines matching one of the channel's badword regexes.
|
|
#[serde(default)]
|
|
pub badwords: bool,
|
|
// Times a user may be kicked (by any kicker) before the bot bans them
|
|
// instead. 0 = never ban, only kick.
|
|
#[serde(default)]
|
|
pub ttb: u16,
|
|
// How long such a ban lasts, in seconds. 0 = until manually removed.
|
|
#[serde(default)]
|
|
pub ban_expire: u32,
|
|
// Warn a user (once) before kicking them the first time.
|
|
#[serde(default)]
|
|
pub warn: bool,
|
|
// Community !votekick/!voteban: votes needed to act (0 = disabled).
|
|
#[serde(default)]
|
|
pub votekick: u16,
|
|
// Don't kick channel operators, whatever they send.
|
|
#[serde(default)]
|
|
pub dontkickops: bool,
|
|
// Don't kick voiced (+v) users.
|
|
#[serde(default)]
|
|
pub dontkickvoices: bool,
|
|
}
|
|
|
|
impl KickerSettings {
|
|
// Any kicker enabled? (dontkickops alone doesn't count.)
|
|
pub fn any(&self) -> bool {
|
|
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat || self.badwords
|
|
}
|
|
|
|
// Resolved thresholds, applying the defaults for a 0 (unset) value.
|
|
pub fn flood_thresholds(&self) -> (u16, u16) {
|
|
(if self.flood_lines < 2 { 6 } else { self.flood_lines }, if self.flood_secs == 0 { 10 } else { self.flood_secs })
|
|
}
|
|
|
|
pub fn repeat_threshold(&self) -> u16 {
|
|
if self.repeat_times == 0 { 3 } else { self.repeat_times }
|
|
}
|
|
|
|
// The reason to kick `text` for, or None if it trips no enabled kicker.
|
|
pub fn violation(&self, text: &str) -> Option<&'static str> {
|
|
if self.bolds && text.contains('\x02') {
|
|
return Some("Don't use bold formatting on this channel.");
|
|
}
|
|
if self.colors && text.contains('\x03') {
|
|
return Some("Don't use colour codes on this channel.");
|
|
}
|
|
if self.underlines && text.contains('\x1f') {
|
|
return Some("Don't use underline formatting on this channel.");
|
|
}
|
|
if self.reverses && text.contains('\x16') {
|
|
return Some("Don't use reverse formatting on this channel.");
|
|
}
|
|
if self.italics && text.contains('\x1d') {
|
|
return Some("Don't use italic formatting on this channel.");
|
|
}
|
|
if self.caps {
|
|
let min = if self.caps_min == 0 { 10 } else { self.caps_min } as usize;
|
|
let percent = if self.caps_percent == 0 { 25 } else { self.caps_percent } as u32;
|
|
if text.chars().count() >= min {
|
|
let upper = text.chars().filter(|c| c.is_ascii_uppercase()).count() as u32;
|
|
let lower = text.chars().filter(|c| c.is_ascii_lowercase()).count() as u32;
|
|
if upper as usize >= min && upper + lower > 0 && upper * 100 / (upper + lower) >= percent {
|
|
return Some("Turn caps lock off!");
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
impl ChannelInfo {
|
|
/// The status mode to give `account` on join, if any: +qo for the founder,
|
|
/// +ao/+o for access-list ops, +v for voices.
|
|
pub fn join_mode(&self, account: &str) -> Option<&'static str> {
|
|
if self.founder.eq_ignore_ascii_case(account) {
|
|
return echo_api::level_caps("founder").auto;
|
|
}
|
|
self.access
|
|
.iter()
|
|
.find(|a| a.account.eq_ignore_ascii_case(account))
|
|
.and_then(|a| echo_api::level_caps(&a.level).auto)
|
|
}
|
|
|
|
/// Whether `account` holds op-level (moderation) access — the founder, or an
|
|
/// access entry whose level resolves to op. A capability check, so it stays
|
|
/// right for SOP, whose join mode is "+ao" rather than literally "+o".
|
|
pub fn is_op(&self, account: &str) -> bool {
|
|
self.founder.eq_ignore_ascii_case(account)
|
|
|| self
|
|
.access
|
|
.iter()
|
|
.find(|a| a.account.eq_ignore_ascii_case(account))
|
|
.is_some_and(|a| echo_api::level_caps(&a.level).op)
|
|
}
|
|
|
|
/// The auto-kick entry matching a live user (plain host mask or extban), if any.
|
|
pub fn akick_match(&self, target: &echo_api::BanTarget) -> Option<&ChanAkick> {
|
|
self.akick.iter().find(|k| echo_api::akick_matches(&k.mask, target))
|
|
}
|
|
|
|
/// The mode string services keep applied: +r plus the lock.
|
|
pub fn lock_modes(&self) -> String {
|
|
let mut s = format!("+r{}", self.lock_on);
|
|
if !self.lock_off.is_empty() {
|
|
s.push('-');
|
|
s.push_str(&self.lock_off);
|
|
}
|
|
// Params for locked +modes that take one, in the order they appear in lock_on.
|
|
for ch in self.lock_on.chars() {
|
|
if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
|
|
s.push(' ');
|
|
s.push_str(p);
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Given a mode change, the modes to send back to restore the lock, if it was
|
|
/// violated. +r is always locked on. Only simple (paramless) modes are checked.
|
|
pub fn enforce(&self, change: &str) -> Option<String> {
|
|
let (mut readd, mut reremove) = (String::new(), String::new());
|
|
let mut adding = true;
|
|
for ch in change.chars() {
|
|
match ch {
|
|
'+' => adding = true,
|
|
'-' => adding = false,
|
|
m if m.is_ascii_alphabetic() => {
|
|
if (m == 'r' || self.lock_on.contains(m)) && !adding && !readd.contains(m) {
|
|
readd.push(m);
|
|
} else if self.lock_off.contains(m) && adding && !reremove.contains(m) {
|
|
reremove.push(m);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if readd.is_empty() && reremove.is_empty() {
|
|
return None;
|
|
}
|
|
let mut s = String::new();
|
|
if !readd.is_empty() {
|
|
s.push('+');
|
|
s.push_str(&readd);
|
|
}
|
|
if !reremove.is_empty() {
|
|
s.push('-');
|
|
s.push_str(&reremove);
|
|
}
|
|
// A re-asserted param mode (e.g. +f) must carry its locked param.
|
|
for ch in readd.chars() {
|
|
if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
|
|
s.push(' ');
|
|
s.push_str(p);
|
|
}
|
|
}
|
|
Some(s)
|
|
}
|
|
}
|
|
|
|
// A durable record: the event plus the metadata a gossip layer needs to address
|
|
// and order it — the origin node, its per-node sequence (`origin:seq` is the
|
|
// cluster-unique id), and a Lamport clock for causal ordering across nodes.
|
|
// Lines written before these existed default them in.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LogEntry {
|
|
#[serde(default)]
|
|
origin: String,
|
|
#[serde(default)]
|
|
seq: u64,
|
|
#[serde(default)]
|
|
lamport: u64,
|
|
#[serde(flatten)]
|
|
event: Event,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl LogEntry {
|
|
pub(crate) fn for_test(origin: &str, seq: u64, lamport: u64, event: Event) -> Self {
|
|
LogEntry { origin: origin.to_string(), seq, lamport, event }
|
|
}
|
|
}
|
|
|
|
// Read-only accessors for consumers outside this module (the gRPC replication
|
|
// layer subscribes to the same broadcast channel gossip does, and translates
|
|
// each entry to a wire message).
|
|
impl LogEntry {
|
|
pub fn origin(&self) -> &str {
|
|
&self.origin
|
|
}
|
|
pub fn seq(&self) -> u64 {
|
|
self.seq
|
|
}
|
|
pub fn lamport(&self) -> u64 {
|
|
self.lamport
|
|
}
|
|
pub fn event(&self) -> &Event {
|
|
&self.event
|
|
}
|
|
}
|
|
|
|
// Append-only log, the sole persistent source of truth: `open` replays it,
|
|
// `append` stamps and writes a locally-authored entry, and `ingest` folds in an
|
|
// entry authored by another node. The version vector (highest seq applied per
|
|
// origin) plus the Lamport clock are the seam a future gossip layer ships entries
|
|
// over — de-duplicating and ordering them — without the services knowing.
|
|
pub struct EventLog {
|
|
path: PathBuf,
|
|
origin: String,
|
|
lamport: u64, // logical clock, ticked on every event
|
|
versions: HashMap<String, u64>, // per-origin highest seq applied (version vector)
|
|
entries: Vec<LogEntry>, // full log, kept so peers can pull what they lack
|
|
outbound: Option<broadcast::Sender<LogEntry>>, // push newly committed entries to peers
|
|
// Operator lockdown (OperServ SET READONLY): while set, locally-authored
|
|
// writes are refused. Ephemeral — it resets on restart and never persists,
|
|
// and gossip ingestion is unaffected so a read-only node stays in sync.
|
|
readonly: bool,
|
|
// Cached append handle: opened once and reused, so we don't pay an open() per
|
|
// event (~20% of the append cost on real disk). Reset to None after compact()
|
|
// replaces the file, since the old handle would point at a discarded inode.
|
|
file: Option<std::fs::File>,
|
|
}
|
|
|
|
impl EventLog {
|
|
fn open(path: PathBuf, origin: String) -> (Self, Vec<Event>) {
|
|
let mut log = Self { path, origin, lamport: 0, versions: HashMap::new(), entries: Vec::new(), outbound: None, readonly: false, file: None };
|
|
if let Ok(data) = std::fs::read_to_string(&log.path) {
|
|
for line in data.lines().filter(|l| !l.trim().is_empty()) {
|
|
match serde_json::from_str::<LogEntry>(line) {
|
|
Ok(entry) => {
|
|
log.absorb(&entry);
|
|
log.entries.push(entry);
|
|
}
|
|
Err(e) => tracing::warn!(%e, "skipping malformed event log line"),
|
|
}
|
|
}
|
|
}
|
|
let events = log.entries.iter().map(|e| e.event.clone()).collect();
|
|
(log, events)
|
|
}
|
|
|
|
// Roll the clock and version vector forward over an entry. Local (channel)
|
|
// entries carry no gossip identity, so they never touch the vector or clock.
|
|
fn absorb(&mut self, entry: &LogEntry) {
|
|
if entry.event.scope() != Scope::Global {
|
|
return;
|
|
}
|
|
self.lamport = self.lamport.max(entry.lamport);
|
|
self.versions.entry(entry.origin.clone()).and_modify(|s| *s = (*s).max(entry.seq)).or_insert(entry.seq);
|
|
}
|
|
|
|
// Seq the next locally-authored event will carry (0-based, per our origin).
|
|
fn next_seq(&self) -> u64 {
|
|
self.versions.get(&self.origin).map_or(0, |s| s + 1)
|
|
}
|
|
|
|
// Persist a locally-authored event. Global events get the next seq + a ticked
|
|
// Lamport clock and are pushed to peers; local (channel) events are written
|
|
// for restart but never gossiped and carry no version-vector identity.
|
|
fn append(&mut self, event: Event) -> std::io::Result<()> {
|
|
// Operator lockdown refuses every locally-authored write at this one seam.
|
|
if self.readonly {
|
|
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "services are read-only"));
|
|
}
|
|
let global = event.scope() == Scope::Global;
|
|
let entry = if global {
|
|
self.lamport += 1;
|
|
LogEntry { origin: self.origin.clone(), seq: self.next_seq(), lamport: self.lamport, event }
|
|
} else {
|
|
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, event }
|
|
};
|
|
if let Err(e) = self.persist(&entry) {
|
|
// Surface the real cause (disk full, permissions, ...) here at the
|
|
// source; callers only see a generic Internal error further up.
|
|
tracing::error!(%e, path = %self.path.display(), "failed to write event to the log");
|
|
return Err(e);
|
|
}
|
|
if global {
|
|
self.versions.insert(entry.origin.clone(), entry.seq);
|
|
self.notify(&entry);
|
|
}
|
|
self.entries.push(entry);
|
|
Ok(())
|
|
}
|
|
|
|
// Ingest an entry authored by another node — the gossip seam. Returns the
|
|
// event to fold into state, or None if already applied (idempotent, so
|
|
// re-delivery converges). Assumes per-origin in-order delivery.
|
|
fn ingest(&mut self, entry: LogEntry) -> std::io::Result<Option<Event>> {
|
|
// A node never accepts another node's channel state — only global (account)
|
|
// identity replicates. This is the guarantee: you can't be handed ownership
|
|
// of a channel that was registered on a network you're not part of.
|
|
if entry.event.scope() != Scope::Global {
|
|
return Ok(None);
|
|
}
|
|
if self.versions.get(&entry.origin).is_some_and(|&s| entry.seq <= s) {
|
|
return Ok(None); // already have it
|
|
}
|
|
self.persist(&entry)?;
|
|
self.lamport = self.lamport.max(entry.lamport) + 1; // Lamport receive rule
|
|
self.versions.insert(entry.origin.clone(), entry.seq);
|
|
let event = entry.event.clone();
|
|
self.notify(&entry);
|
|
self.entries.push(entry);
|
|
Ok(Some(event))
|
|
}
|
|
|
|
// Push a freshly committed entry to connected peers, if any are wired up.
|
|
// Best effort: a lagging subscriber just relies on the periodic digest.
|
|
fn notify(&self, entry: &LogEntry) {
|
|
if let Some(tx) = &self.outbound {
|
|
let _ = tx.send(entry.clone());
|
|
}
|
|
}
|
|
|
|
fn set_outbound(&mut self, tx: broadcast::Sender<LogEntry>) {
|
|
self.outbound = Some(tx);
|
|
}
|
|
|
|
// The events appended at or after `mark`, cloned. Used by the audit feed to
|
|
// see exactly what a just-run command changed (the log only ever grows during
|
|
// a command, so `[mark..]` is that command's footprint).
|
|
fn events_from(&self, mark: usize) -> Vec<Event> {
|
|
self.entries.get(mark..).into_iter().flatten().map(|e| e.event.clone()).collect()
|
|
}
|
|
|
|
// Our version vector: highest seq applied per origin.
|
|
fn version_vector(&self) -> HashMap<String, u64> {
|
|
self.versions.clone()
|
|
}
|
|
|
|
// Global entries a peer is missing, given the version vector it advertised.
|
|
// Local (channel) entries are never offered — they don't leave the node.
|
|
fn missing_for(&self, peer: &HashMap<String, u64>) -> Vec<LogEntry> {
|
|
self.entries
|
|
.iter()
|
|
.filter(|e| e.event.scope() == Scope::Global)
|
|
.filter(|e| peer.get(&e.origin).is_none_or(|&s| e.seq > s))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn persist(&mut self, entry: &LogEntry) -> std::io::Result<()> {
|
|
// Serialise first, and treat a failure as an error rather than writing a
|
|
// blank line, which would silently drop a committed change.
|
|
let line = serde_json::to_string(entry)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
// Reuse the open append handle; open it lazily on the first write.
|
|
if self.file.is_none() {
|
|
self.file = Some(std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?);
|
|
}
|
|
let f = self.file.as_mut().expect("just opened");
|
|
writeln!(f, "{line}")?;
|
|
// fsync: a committed account/channel change is on disk before we report
|
|
// success, so a crash or power loss can't lose it.
|
|
f.sync_all()
|
|
}
|
|
|
|
// How many entries the log currently holds.
|
|
fn len(&self) -> usize {
|
|
self.entries.len()
|
|
}
|
|
|
|
// The highest Lamport clock seen. Monotonic across compaction (unlike `len`,
|
|
// which a snapshot shrinks), so it's the gauge that shows the log advancing.
|
|
fn lamport(&self) -> u64 {
|
|
self.lamport
|
|
}
|
|
|
|
// Rewrite the log to a minimal snapshot: one event per live account and
|
|
// channel, authored under our origin at fresh sequence numbers. Peers
|
|
// re-converge because the register events overwrite and cert replay is
|
|
// idempotent. The version vector resets to our origin; peers re-advertise
|
|
// theirs on the next sync. Written to a temp file and renamed, so a crash
|
|
// leaves the old log.
|
|
fn compact(&mut self, events: Vec<Event>) -> std::io::Result<()> {
|
|
let mut seq = self.next_seq();
|
|
let mut last_global = None;
|
|
let mut snapshot = Vec::with_capacity(events.len());
|
|
for event in events {
|
|
let entry = if event.scope() == Scope::Global {
|
|
self.lamport += 1;
|
|
let e = LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, event };
|
|
last_global = Some(seq);
|
|
seq += 1;
|
|
e
|
|
} else {
|
|
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, event }
|
|
};
|
|
snapshot.push(entry);
|
|
}
|
|
let tmp = self.path.with_extension("compact");
|
|
{
|
|
let mut f = std::fs::File::create(&tmp)?;
|
|
for entry in &snapshot {
|
|
writeln!(f, "{}", serde_json::to_string(entry).unwrap_or_default())?;
|
|
}
|
|
f.sync_all()?;
|
|
}
|
|
std::fs::rename(&tmp, &self.path)?;
|
|
// The cached handle now points at the replaced inode — drop it so the next
|
|
// append reopens the fresh file.
|
|
self.file = None;
|
|
self.versions = HashMap::new();
|
|
if let Some(last) = last_global {
|
|
self.versions.insert(self.origin.clone(), last);
|
|
}
|
|
self.entries = snapshot;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// A side-effect an ingested (gossiped) entry needs the engine to carry out on
|
|
// the local ircd — the peer only changed shared state, so anything the ircd must
|
|
// be told (log a session out, add/lift a network ban) is signalled back here.
|
|
pub enum IngestEffect {
|
|
TakenOver(String),
|
|
Dropped(String),
|
|
// A gossiped suspension that is now in force: local sessions must be logged
|
|
// out, but the account (and its channels) stay put.
|
|
Suspended(String),
|
|
// A gossiped network ban (AKILL/SQLINE/…) that must be pushed to the local
|
|
// ircd now, not just held for the next netburst. `duration` is the remaining
|
|
// seconds (0 = permanent).
|
|
BanAdded { kind: String, mask: String, setter: String, duration: u64, reason: String },
|
|
BanLifted { kind: String, mask: String },
|
|
}
|
|
|
|
// A fingerprint is hex (hash digest), optionally colon-separated. Bound the
|
|
// length so a junk value can't bloat an account.
|
|
fn valid_fp(fp: &str) -> bool {
|
|
let hex = fp.chars().filter(|c| *c != ':').count();
|
|
(32..=128).contains(&hex) && fp.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
|
|
}
|
|
|
|
// The expensive, password-derived half of an account, computed once at
|
|
// registration. Split out from `register` so the derivation (two SCRAM
|
|
// verifiers, ~1s at the default cost) can run off the reactor via spawn_blocking.
|
|
// The SCRAM verifier is the sole password credential: SCRAM logins use it
|
|
// directly, and PLAIN/IDENTIFY verify the plaintext against it (see
|
|
// `scram::verify_plain`), so there is no separate password hash to keep in step.
|
|
pub struct Credentials {
|
|
pub(crate) scram256: String,
|
|
pub(crate) scram512: String,
|
|
}
|
|
|
|
pub struct Db {
|
|
accounts: HashMap<String, Account>, // keyed by casefolded name
|
|
channels: HashMap<String, ChannelInfo>, // keyed by casefolded name
|
|
grouped: HashMap<String, String>, // casefolded alias nick -> canonical account name
|
|
log: EventLog,
|
|
// Which extbans AKICK accepts (`[extban] enabled`). None = all echo knows.
|
|
extban_enabled: Option<std::collections::HashSet<String>>,
|
|
// Masks whose users are never matched by NOTIFY (`[log] notify_exclude`), so
|
|
// relay/pylinked clients don't flood the staff feed. Empty = exclude nobody.
|
|
notify_exclude: Vec<String>,
|
|
// The ircd's live extban set from its CAPAB EXTBANS burst. None until we link;
|
|
// once known, it's authoritative for what AKICK/MODE may set.
|
|
live_extbans: Option<Vec<echo_api::ExtbanCap>>,
|
|
// The ircd's live channel-mode set from its CAPAB CHANMODES burst (param arity
|
|
// + prefix modes, incl custom ones like ojoin's Y). None until we link.
|
|
live_chanmodes: Option<Vec<echo_api::ChanModeCap>>,
|
|
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
|
pub(crate) scram_iterations: u32,
|
|
// Whether outbound email is configured, so email features can gate themselves.
|
|
email_enabled: bool,
|
|
// Display name, accent colour, and optional logo URL for email templates.
|
|
email_brand: String,
|
|
email_accent: String,
|
|
email_logo: String,
|
|
// Node-local, non-persisted email codes, keyed by account.
|
|
codes: HashMap<String, PendingCode>,
|
|
// Node-local, non-persisted brute-force throttle for password authentication.
|
|
auth_fails: HashMap<String, AuthThrottle>,
|
|
// Last vhost REQUEST time per account (in-memory, anti-spam), unix secs.
|
|
vhost_req_times: HashMap<String, u64>,
|
|
// Last ReportServ REPORT time per reporter (in-memory, anti-spam), unix secs.
|
|
report_times: HashMap<String, u64>,
|
|
// Registered service bots (BotServ), keyed by casefolded nick.
|
|
bots: HashMap<String, Bot>,
|
|
// HostServ node config: the self-serve offer menu, the forbidden-pattern
|
|
// blocklist, and the auto-vhost template.
|
|
host_cfg: HostConfig,
|
|
// Network-wide replicated lists: bans (AKILL/SQLINE) and news.
|
|
net: NetData,
|
|
// Services ignores (OperServ IGNORE), node-local and in-memory.
|
|
ignores: Vec<Ignore>,
|
|
// Network defence level (OperServ DEFCON), 5 = normal down to 1 = lockdown.
|
|
// Node-local; the derived restrictions are read by the engine and services.
|
|
defcon: u8,
|
|
// When set, account identity is owned by an external authority (e.g. the
|
|
// website): IRC can't register or change credentials, only authenticate
|
|
// against accounts pushed in (via the gRPC Accounts API). Node-local config.
|
|
external_accounts: bool,
|
|
confusable_check: bool, // reject look-alike / mixed-script REGISTER names
|
|
}
|
|
|
|
// A juped server: the held name, the fake server id we allocated for it, and why.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Jupe {
|
|
pub name: String,
|
|
pub sid: String,
|
|
pub reason: String,
|
|
}
|
|
|
|
// Network-wide HostServ configuration, rebuilt from the event log.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct HostConfig {
|
|
// Specs users may self-assign with TAKE.
|
|
pub offers: Vec<String>,
|
|
// Regex patterns a user-requested/taken vhost may not match (anti-impersonation).
|
|
pub forbidden: Vec<String>,
|
|
// Auto-vhost template, e.g. "$account.users.example" (DEFAULT substitutes).
|
|
pub template: Option<String>,
|
|
}
|
|
|
|
// A pending emailed code and how many wrong guesses remain before it is burned.
|
|
struct PendingCode {
|
|
kind: CodeKind,
|
|
code: String,
|
|
issued: Instant,
|
|
deadline: Instant,
|
|
tries_left: u8,
|
|
}
|
|
|
|
// Failed-authentication state for one account (in memory only, reset on restart).
|
|
struct AuthThrottle {
|
|
fails: u32,
|
|
locked_until: Option<Instant>,
|
|
}
|
|
|
|
// Wrong-code guesses tolerated before a code is invalidated (defence in depth on
|
|
// top of the code's own entropy).
|
|
const CODE_TRIES: u8 = 5;
|
|
|
|
// Minimum gap between emailed codes for one account, so RESEND/RESETPASS can't be
|
|
// used to email-bomb an address (or burn the service's sender reputation).
|
|
const CODE_ISSUE_COOLDOWN: Duration = Duration::from_secs(60);
|
|
// Free password attempts before the exponential backoff kicks in, and its cap.
|
|
const AUTH_FREE_TRIES: u32 = 3;
|
|
const AUTH_MAX_BACKOFF_SECS: u64 = 300;
|
|
|
|
fn key(name: &str) -> String {
|
|
name.to_ascii_lowercase()
|
|
}
|
|
|
|
fn now() -> u64 {
|
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
|
}
|
|
|
|
impl Db {
|
|
pub fn open(path: impl Into<PathBuf>, origin: impl Into<String>) -> Self {
|
|
let (log, events) = EventLog::open(path.into(), origin.into());
|
|
let mut accounts = HashMap::new();
|
|
let mut channels = HashMap::new();
|
|
let mut grouped = HashMap::new();
|
|
let mut bots = HashMap::new();
|
|
let mut host_cfg = HostConfig::default();
|
|
let mut net = NetData::default();
|
|
for event in events {
|
|
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event);
|
|
}
|
|
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
|
Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true }
|
|
}
|
|
|
|
/// Fold an entry authored by another node into the store — the services-side
|
|
/// of the gossip seam. Idempotent (re-delivered entries are dropped). Returns
|
|
/// the account name if this ingest changed its owner (a registration conflict
|
|
/// resolved against the local claim), so the caller can log out stale sessions.
|
|
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<Option<IngestEffect>> {
|
|
// Snapshot the incoming account's local owner AND credential before applying,
|
|
// to tell a takeover (home moved to a *different* account) from a benign
|
|
// re-home (the same account re-asserted), and a remote drop (it disappears).
|
|
let watched: Option<(String, Option<String>, Option<String>)> = match &entry.event {
|
|
Event::AccountRegistered(a) => {
|
|
let c = self.account(&a.name);
|
|
Some((a.name.clone(), c.map(|c| c.home.clone()), c.and_then(|c| c.scram256.clone())))
|
|
}
|
|
Event::AccountDropped { account } => {
|
|
let c = self.account(account);
|
|
Some((account.clone(), c.map(|c| c.home.clone()), c.and_then(|c| c.scram256.clone())))
|
|
}
|
|
_ => None,
|
|
};
|
|
let applied = self.log.ingest(entry)?;
|
|
// A freshly applied suspension may need to terminate local sessions.
|
|
let suspended = applied.as_ref().and_then(|e| match e {
|
|
Event::AccountSuspended { account, .. } => Some(account.clone()),
|
|
_ => None,
|
|
});
|
|
// A freshly applied network ban must be pushed to the local ircd now.
|
|
let ban = applied.as_ref().and_then(|e| match e {
|
|
Event::AkillAdded { kind, mask, setter, reason, expires, .. } => Some(IngestEffect::BanAdded {
|
|
kind: kind.clone(),
|
|
mask: mask.clone(),
|
|
setter: setter.clone(),
|
|
duration: (*expires).map(|x| x.saturating_sub(now())).unwrap_or(0),
|
|
reason: reason.clone(),
|
|
}),
|
|
Event::AkillRemoved { kind, mask } => Some(IngestEffect::BanLifted { kind: kind.clone(), mask: mask.clone() }),
|
|
_ => None,
|
|
});
|
|
if let Some(event) = applied {
|
|
apply(&mut self.accounts, &mut self.channels, &mut self.grouped, &mut self.bots, &mut self.host_cfg, &mut self.net, event);
|
|
}
|
|
if let Some((name, prev_home, prev_verifier)) = watched {
|
|
let cur = self.account(&name);
|
|
match (prev_home, cur.map(|c| c.home.clone())) {
|
|
(Some(prev), Some(cur_home)) if cur_home != prev => {
|
|
// The account's home moved. Treat it as a hostile takeover — which
|
|
// logs the local user out with a "collided with another network"
|
|
// notice — ONLY if the credential changed too. A re-home that keeps
|
|
// the same verifier is the SAME account being re-asserted (an
|
|
// authority re-provision, an import, or an origin change during
|
|
// migration): the local login is still valid, so it must not be
|
|
// disrupted. This is what spuriously logged users out and (before
|
|
// the channel-keep fix) dropped their channels during the cutover.
|
|
if cur.and_then(|c| c.scram256.clone()) != prev_verifier {
|
|
return Ok(Some(IngestEffect::TakenOver(name)));
|
|
}
|
|
}
|
|
(Some(_), None) => return Ok(Some(IngestEffect::Dropped(name))),
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some(account) = suspended {
|
|
// Only if the suspension is actually in force (not an already-expired one).
|
|
if self.is_suspended(&account) {
|
|
return Ok(Some(IngestEffect::Suspended(account)));
|
|
}
|
|
}
|
|
if let Some(effect) = ban {
|
|
return Ok(Some(effect));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Rewrite the log to one entry per account and channel, reclaiming churn.
|
|
pub fn compact(&mut self) -> std::io::Result<()> {
|
|
let before = self.log.len();
|
|
let mut snapshot: Vec<Event> = self.accounts.values().cloned().map(|a| Event::AccountRegistered(Box::new(a))).collect();
|
|
for c in self.channels.values() {
|
|
snapshot.push(Event::ChannelRegistered { name: c.name.clone(), founder: c.founder.clone(), ts: c.ts });
|
|
// ChannelRegistered replays last_used back to the registration ts, so a
|
|
// channel active since registration would have its inactivity-expiry clock
|
|
// reset by every compaction (and could then be wrongly expired). Restore it.
|
|
if c.last_used > c.ts {
|
|
snapshot.push(Event::ChannelUsed { channel: c.name.clone(), ts: c.last_used });
|
|
}
|
|
if !c.lock_on.is_empty() || !c.lock_off.is_empty() {
|
|
snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone(), params: c.lock_params.clone() });
|
|
}
|
|
for a in &c.access {
|
|
snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() });
|
|
}
|
|
// LEVELS overrides live only in c.levels; without this they reset to the
|
|
// tier defaults on every compaction.
|
|
for (cap, role) in &c.levels {
|
|
snapshot.push(Event::ChannelLevelSet { channel: c.name.clone(), cap: cap.clone(), role: role.clone() });
|
|
}
|
|
for k in &c.akick {
|
|
snapshot.push(Event::ChannelAkickAdd { channel: c.name.clone(), mask: k.mask.clone(), reason: k.reason.clone() });
|
|
}
|
|
if !c.desc.is_empty() {
|
|
snapshot.push(Event::ChannelDescSet { channel: c.name.clone(), desc: c.desc.clone() });
|
|
}
|
|
if !c.entrymsg.is_empty() {
|
|
snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() });
|
|
}
|
|
if !c.url.is_empty() {
|
|
snapshot.push(Event::ChannelUrlSet { channel: c.name.clone(), url: c.url.clone() });
|
|
}
|
|
if !c.email.is_empty() {
|
|
snapshot.push(Event::ChannelEmailSet { channel: c.name.clone(), email: c.email.clone() });
|
|
}
|
|
if let Some(s) = &c.successor {
|
|
snapshot.push(Event::ChannelSuccessorSet { channel: c.name.clone(), successor: Some(s.clone()) });
|
|
}
|
|
if c.noexpire {
|
|
snapshot.push(Event::ChannelNoExpire { channel: c.name.clone(), on: true });
|
|
}
|
|
if c.settings != ChanSettings::default() {
|
|
snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings });
|
|
}
|
|
if !c.topic.is_empty() {
|
|
snapshot.push(Event::ChannelTopicSet { channel: c.name.clone(), topic: c.topic.clone() });
|
|
}
|
|
if let Some(s) = &c.suspension {
|
|
snapshot.push(Event::ChannelSuspended { channel: c.name.clone(), by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires });
|
|
}
|
|
if let Some(bot) = &c.assigned_bot {
|
|
snapshot.push(Event::ChannelBotAssigned { channel: c.name.clone(), bot: bot.clone() });
|
|
}
|
|
if c.kickers.any() || c.kickers.dontkickops || c.kickers.ttb > 0 || c.kickers.ban_expire > 0 || c.kickers.votekick > 0 {
|
|
snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() });
|
|
}
|
|
if !c.badwords.is_empty() {
|
|
snapshot.push(Event::ChannelBadwordsSet { channel: c.name.clone(), badwords: c.badwords.clone() });
|
|
}
|
|
if !c.triggers.is_empty() {
|
|
snapshot.push(Event::ChannelTriggersSet { channel: c.name.clone(), triggers: c.triggers.clone() });
|
|
}
|
|
if let Some(note) = &c.oper_note {
|
|
snapshot.push(Event::ChannelOperNoteSet { channel: c.name.clone(), note: Some(note.clone()) });
|
|
}
|
|
}
|
|
for (nick, account) in &self.grouped {
|
|
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
|
|
}
|
|
for b in self.bots.values() {
|
|
snapshot.push(Event::BotAdded(b.clone()));
|
|
}
|
|
if self.net.default_bot.is_some() {
|
|
snapshot.push(Event::DefaultBotSet { bot: self.net.default_bot.clone() });
|
|
}
|
|
if !self.net.stats.is_empty() || !self.net.chan_stats.is_empty() {
|
|
snapshot.push(Event::StatsSet {
|
|
counters: self.net.stats.iter().map(|(k, v)| (k.clone(), *v)).collect(),
|
|
channels: self.net.chan_stats.clone(),
|
|
});
|
|
}
|
|
if !self.net.incidents.is_empty() {
|
|
snapshot.push(Event::IncidentsSet {
|
|
incident_seq: self.net.incident_seq,
|
|
incidents: self.net.incidents.clone(),
|
|
});
|
|
}
|
|
for host in &self.host_cfg.offers {
|
|
snapshot.push(Event::VhostOfferAdded { host: host.clone() });
|
|
}
|
|
for pattern in &self.host_cfg.forbidden {
|
|
snapshot.push(Event::VhostForbidAdded { pattern: pattern.clone() });
|
|
}
|
|
if self.host_cfg.template.is_some() {
|
|
snapshot.push(Event::VhostTemplateSet { template: self.host_cfg.template.clone() });
|
|
}
|
|
// Compaction is a good moment to forget akills that have already expired.
|
|
let now = now();
|
|
for f in &self.net.forbids {
|
|
snapshot.push(Event::ForbidAdded { kind: f.kind.clone(), mask: f.mask.clone(), setter: f.setter.clone(), reason: f.reason.clone(), ts: f.ts });
|
|
}
|
|
for n in self.net.notifies.iter().filter(|n| n.expires.is_none_or(|e| e > now)) {
|
|
snapshot.push(Event::NotifyAdded { mask: n.mask.clone(), flags: n.flags.clone(), reason: n.reason.clone(), setter: n.setter.clone(), ts: n.ts, expires: n.expires });
|
|
}
|
|
for a in self.net.akills.iter().filter(|a| a.expires.is_none_or(|e| e > now)) {
|
|
snapshot.push(Event::AkillAdded { kind: a.kind.clone(), mask: a.mask.clone(), setter: a.setter.clone(), reason: a.reason.clone(), ts: a.ts, expires: a.expires });
|
|
}
|
|
for f in self.net.filters.iter().filter(|f| f.expires.is_none_or(|e| e > now)) {
|
|
snapshot.push(Event::FilterAdded { pattern: f.pattern.clone(), action: f.action.clone(), flags: f.flags.clone(), reason: f.reason.clone(), setter: f.setter.clone(), ts: f.ts, expires: f.expires });
|
|
}
|
|
for n in &self.net.news {
|
|
snapshot.push(Event::NewsAdded { id: n.id, kind: n.kind.clone(), text: n.text.clone(), setter: n.setter.clone(), ts: n.ts });
|
|
}
|
|
for r in &self.net.reports {
|
|
snapshot.push(Event::ReportFiled { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts });
|
|
if !r.open {
|
|
snapshot.push(Event::ReportClosed { id: r.id });
|
|
}
|
|
}
|
|
for g in &self.net.groups {
|
|
snapshot.push(Event::GroupRegistered { name: g.name.clone(), founder: g.founder.clone(), ts: g.ts });
|
|
for m in &g.members {
|
|
snapshot.push(Event::GroupFlagsSet { name: g.name.clone(), account: m.account.clone(), flags: m.flags.clone() });
|
|
}
|
|
}
|
|
for t in &self.net.help {
|
|
snapshot.push(Event::HelpRequested { id: t.id, requester: t.requester.clone(), message: t.message.clone(), ts: t.ts });
|
|
if let Some(h) = &t.handler {
|
|
snapshot.push(Event::HelpTaken { id: t.id, handler: h.clone() });
|
|
}
|
|
if !t.open {
|
|
snapshot.push(Event::HelpClosed { id: t.id });
|
|
}
|
|
}
|
|
for (account, grant) in &self.net.opers {
|
|
snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires });
|
|
}
|
|
for e in &self.net.sess_exceptions {
|
|
snapshot.push(Event::SessionExceptionAdded { mask: e.mask.clone(), limit: e.limit, reason: e.reason.clone() });
|
|
}
|
|
for j in &self.net.jupes {
|
|
snapshot.push(Event::JupeAdded { name: j.name.clone(), sid: j.sid.clone(), reason: j.reason.clone() });
|
|
}
|
|
self.log.compact(snapshot)?;
|
|
tracing::info!(before, after = self.log.len(), "compacted event log");
|
|
Ok(())
|
|
}
|
|
|
|
/// Append a pre-built event during a one-off import, bypassing the live-mutation
|
|
/// methods so original timestamps and ids are preserved verbatim. State is
|
|
/// rebuilt from the log on the next open; only meant for the `import` tool.
|
|
pub fn migrate_append(&mut self, event: Event) -> std::io::Result<()> {
|
|
self.log.append(event)
|
|
}
|
|
|
|
/// Whether the log has grown enough past the live state to be worth compacting.
|
|
pub fn should_compact(&self) -> bool {
|
|
self.log.len() > (self.accounts.len() + self.channels.len()) * 3 + 64
|
|
}
|
|
|
|
/// Wire the log to a broadcast channel; each new entry is pushed to peers.
|
|
pub fn set_outbound(&mut self, tx: broadcast::Sender<LogEntry>) {
|
|
self.log.set_outbound(tx);
|
|
}
|
|
|
|
/// The number of entries in the log, a mark to later diff against.
|
|
pub fn log_len(&self) -> usize {
|
|
self.log.len()
|
|
}
|
|
|
|
/// The highest Lamport clock committed — a monotonic gauge of log progress
|
|
/// (survives compaction), for the health/metrics endpoint.
|
|
pub fn lamport(&self) -> u64 {
|
|
self.log.lamport()
|
|
}
|
|
|
|
/// The events appended since `mark` (a value from an earlier [`log_len`]).
|
|
pub fn events_since(&self, mark: usize) -> Vec<Event> {
|
|
self.log.events_from(mark)
|
|
}
|
|
|
|
/// Our version vector, advertised to peers so they can send what we lack.
|
|
pub fn version_vector(&self) -> HashMap<String, u64> {
|
|
self.log.version_vector()
|
|
}
|
|
|
|
/// The log entries a peer is missing, given the version vector it sent.
|
|
pub fn missing_for(&self, peer: &HashMap<String, u64>) -> Vec<LogEntry> {
|
|
self.log.missing_for(peer)
|
|
}
|
|
|
|
pub fn exists(&self, name: &str) -> bool {
|
|
self.resolved_key(name).is_some()
|
|
}
|
|
|
|
// Resolve a name (a registered account, or a nick grouped to one) to the
|
|
// owning account's storage key.
|
|
fn resolved_key(&self, name: &str) -> Option<String> {
|
|
let k = key(name);
|
|
if self.accounts.contains_key(&k) {
|
|
return Some(k);
|
|
}
|
|
self.grouped.get(&k).map(|acct| key(acct))
|
|
}
|
|
|
|
/// The canonical account name for `name`, whether it is the account itself or
|
|
/// a nick grouped to it.
|
|
pub fn resolve_account(&self, name: &str) -> Option<&str> {
|
|
let k = self.resolved_key(name)?;
|
|
self.accounts.get(&k).map(|a| a.name.as_str())
|
|
}
|
|
|
|
/// Group alias `nick` to an existing `account`.
|
|
pub fn group_nick(&mut self, nick: &str, account: &str) -> Result<(), RegError> {
|
|
if !self.accounts.contains_key(&key(account)) {
|
|
return Err(RegError::Internal);
|
|
}
|
|
self.log.append(Event::NickGrouped { nick: nick.to_string(), account: account.to_string() }).map_err(|_| RegError::Internal)?;
|
|
self.grouped.insert(key(nick), account.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove grouped alias `nick`. Ok(false) if it wasn't grouped.
|
|
pub fn ungroup_nick(&mut self, nick: &str) -> Result<bool, RegError> {
|
|
if !self.grouped.contains_key(&key(nick)) {
|
|
return Ok(false);
|
|
}
|
|
self.log.append(Event::NickUngrouped { nick: nick.to_string() }).map_err(|_| RegError::Internal)?;
|
|
self.grouped.remove(&key(nick));
|
|
Ok(true)
|
|
}
|
|
|
|
/// The alias nicks grouped to `account` (not the account name itself).
|
|
pub fn grouped_nicks(&self, account: &str) -> Vec<String> {
|
|
self.grouped.iter().filter(|(_, a)| a.eq_ignore_ascii_case(account)).map(|(nick, _)| nick.clone()).collect()
|
|
}
|
|
|
|
/// Derive the password-bound half of an account. Pure and CPU-heavy (no
|
|
/// `&self`), so a caller can run it on a blocking thread; the cheap
|
|
/// `register_prepared` then commits the result.
|
|
pub fn derive_credentials(password: &str, iterations: u32) -> Option<Credentials> {
|
|
Some(Credentials {
|
|
scram256: scram::make_verifier(Hash::Sha256, password, iterations),
|
|
scram512: scram::make_verifier(Hash::Sha512, password, iterations),
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
fn glob_match(pattern: &str, text: &str) -> bool {
|
|
let (p, t): (Vec<char>, Vec<char>) = (
|
|
pattern.chars().flat_map(char::to_lowercase).collect(),
|
|
text.chars().flat_map(char::to_lowercase).collect(),
|
|
);
|
|
let (mut pi, mut ti) = (0, 0);
|
|
let (mut star, mut mark) = (None, 0);
|
|
while ti < t.len() {
|
|
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
|
pi += 1;
|
|
ti += 1;
|
|
} else if pi < p.len() && p[pi] == '*' {
|
|
star = Some(pi);
|
|
mark = ti;
|
|
pi += 1;
|
|
} else if let Some(s) = star {
|
|
pi = s + 1;
|
|
mark += 1;
|
|
ti = mark;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
while pi < p.len() && p[pi] == '*' {
|
|
pi += 1;
|
|
}
|
|
pi == p.len()
|
|
}
|
|
|
|
// A random 6-digit code for email verification / password reset.
|
|
// An unguessable emailed code: 8 characters from a 32-symbol unambiguous
|
|
// alphabet (~40 bits). 256 is an exact multiple of 32, so the byte->symbol map
|
|
// is bias-free. Long enough that it can't be brute-forced inside its 15-minute
|
|
// window even without the per-code attempt limit.
|
|
fn gen_code() -> String {
|
|
const ALPHABET: &[u8; 32] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // no 0/O/1/I
|
|
let mut b = [0u8; 8];
|
|
OsRng.fill_bytes(&mut b);
|
|
b.iter().map(|x| ALPHABET[(*x % 32) as usize] as char).collect()
|
|
}
|
|
|
|
|
|
// The module-facing account/channel store. Every method forwards to Db's own
|
|
// (fully-qualified so it is the inherent method, never this trait method), with
|
|
// reads projected into credential-free views. Internal callers keep using the
|
|
// richer inherent methods directly.
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|