463 lines
16 KiB
Rust
463 lines
16 KiB
Rust
use serde::Deserialize;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Config {
|
|
pub uplink: Uplink,
|
|
pub server: Server,
|
|
// Node-to-node replication. Absent = single node, no gossip.
|
|
#[serde(default)]
|
|
pub gossip: Option<Gossip>,
|
|
#[serde(default)]
|
|
pub peer: Vec<Peer>,
|
|
// Outbound email (password resets). Absent = email features are off.
|
|
#[serde(default)]
|
|
pub email: Option<Email>,
|
|
// Directory replication (gRPC), for websites mirroring the account/channel
|
|
// directory. Absent = the RPC server does not start.
|
|
#[serde(default)]
|
|
pub grpc: Option<Grpc>,
|
|
// JSON-RPC stats endpoint (plain HTTP+JSON) for a website's stats pages.
|
|
// Absent = it does not start. Bind to localhost; a token is required.
|
|
#[serde(default)]
|
|
pub jsonrpc: Option<JsonRpc>,
|
|
// Which service modules to start. Absent = the full standard suite (all the
|
|
// pseudo-clients); listing it trims that set. Every service is first-class.
|
|
#[serde(default)]
|
|
pub modules: Modules,
|
|
// Services operators: accounts granted privileges. Absent = no opers.
|
|
#[serde(default)]
|
|
pub oper: Vec<Oper>,
|
|
// Staff audit feed. Absent = no audit log is emitted.
|
|
#[serde(default)]
|
|
pub log: Option<Log>,
|
|
// Inactivity-expiry. Absent = accounts and channels never expire.
|
|
#[serde(default)]
|
|
pub expire: Option<Expire>,
|
|
// Per-IP session limiting. Absent = unlimited.
|
|
#[serde(default)]
|
|
pub session: Option<Session>,
|
|
// Account authority. Absent = built-in (echo owns accounts). With
|
|
// `external = true`, an outside authority (e.g. the website) owns identity
|
|
// and pushes accounts in; IRC can only authenticate.
|
|
#[serde(default)]
|
|
pub auth: Option<Auth>,
|
|
// Website single-use keycards (passwordless web login). Absent = keycard
|
|
// credentials (`kc_…` over SASL) are not honoured.
|
|
#[serde(default)]
|
|
pub keycard: Option<Keycard>,
|
|
// DictServ dictionary lookups (dict.org / RFC 2229). Absent = the service does
|
|
// not load and echo makes no outbound lookup requests. Opt-in on purpose.
|
|
#[serde(default)]
|
|
pub dictserv: Option<Dict>,
|
|
// Registration policy. Absent = defaults (the look-alike guard is on).
|
|
#[serde(default)]
|
|
pub register: Register,
|
|
// Which InspIRCd matching-extbans AKICK may use. Absent = every extban echo
|
|
// knows (full compatibility). List `enabled` to restrict it — e.g. drop the
|
|
// ones your ircd doesn't provide.
|
|
#[serde(default)]
|
|
pub extban: Option<Extban>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Extban {
|
|
// Enabled extban names ("account", "realname", "country", …). Empty = all.
|
|
#[serde(default)]
|
|
pub enabled: Vec<String>,
|
|
}
|
|
|
|
// Redeeming a website login keycard: a member already authenticated on the
|
|
// website connects with a one-time `kc_…` token instead of their password. We
|
|
// hand it to Django's localhost login-token endpoint, which redeems it and
|
|
// confirms the account. `url` is that endpoint; `api_key` matches its X-API-Key.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Keycard {
|
|
pub url: String,
|
|
pub api_key: String,
|
|
}
|
|
|
|
// DictServ. `server` is a DICT-protocol endpoint (RFC 2229); dict.org hosts the
|
|
// standard databases (WordNet, GCIDE, thesaurus, …).
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Dict {
|
|
#[serde(default = "default_dict_server")]
|
|
pub server: String,
|
|
}
|
|
|
|
fn default_dict_server() -> String {
|
|
"dict.org:2628".to_string()
|
|
}
|
|
|
|
// Registration policy.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
#[serde(default)]
|
|
pub struct Register {
|
|
// Reject look-alike / mixed-script / invisible registration names. On by
|
|
// default; turn it off for a community that legitimately uses mixed or
|
|
// non-Latin names. Reloadable with REHASH.
|
|
pub confusable_check: bool,
|
|
}
|
|
|
|
impl Default for Register {
|
|
fn default() -> Self {
|
|
Self { confusable_check: true }
|
|
}
|
|
}
|
|
|
|
// Account-authority configuration.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Auth {
|
|
#[serde(default)]
|
|
pub external: bool,
|
|
}
|
|
|
|
// Session limiting: the default connections allowed per IP (0/absent = off),
|
|
// which OperServ EXCEPTION entries can raise or lower per IP-mask.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Session {
|
|
#[serde(default)]
|
|
pub default_limit: u32,
|
|
}
|
|
|
|
impl Session {
|
|
pub fn limit(&self) -> Option<u32> {
|
|
(self.default_limit > 0).then_some(self.default_limit)
|
|
}
|
|
}
|
|
|
|
// Inactivity-expiry thresholds, in days. A zero (or omitted) field leaves that
|
|
// kind never expiring, so an operator can expire only accounts, only channels,
|
|
// or both.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Expire {
|
|
#[serde(default)]
|
|
pub accounts_days: u64,
|
|
#[serde(default)]
|
|
pub channels_days: u64,
|
|
// Days before expiry to email a warning to the owner (0 = no warning email).
|
|
#[serde(default)]
|
|
pub warn_days: u64,
|
|
}
|
|
|
|
impl Expire {
|
|
// The thresholds in seconds, or None where that kind is disabled (zero days).
|
|
pub fn account_ttl(&self) -> Option<u64> {
|
|
(self.accounts_days > 0).then(|| self.accounts_days * 86_400)
|
|
}
|
|
pub fn channel_ttl(&self) -> Option<u64> {
|
|
(self.channels_days > 0).then(|| self.channels_days * 86_400)
|
|
}
|
|
// The warning lead time in seconds, or None if warnings are off.
|
|
pub fn warn_ttl(&self) -> Option<u64> {
|
|
(self.warn_days > 0).then(|| self.warn_days * 86_400)
|
|
}
|
|
}
|
|
|
|
// The staff audit feed: notable service actions are announced to this channel
|
|
// so operators can see who did what.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Log {
|
|
pub channel: String,
|
|
// Masks NOTIFY never announces, three kinds: `#channel` mutes that channel,
|
|
// `server:<glob>` (or `via:`) mutes everyone on a matching server — the handle
|
|
// on a relay whose users have clean nicks — and any other mask (nick glob /
|
|
// user@host / extban) mutes a user. E.g. "*/*" silences PyLink relays that
|
|
// suffix nicks, "server:chatnova.relay" a relay that doesn't, "#staff" keeps a
|
|
// broad `#*` watch out of a channel. Reloadable with REHASH.
|
|
#[serde(default)]
|
|
pub notify_exclude: Vec<String>,
|
|
}
|
|
|
|
// One services operator: an account and the privileges it holds.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Oper {
|
|
pub account: String,
|
|
#[serde(default)]
|
|
pub privs: Vec<String>, // fine-grained: auspex, oper, suspend, admin, root
|
|
// A tier shortcut: "operator", "administrator", or "root" — expands to the
|
|
// matching privilege (which implies the lower tiers). Combined with `privs`.
|
|
#[serde(default, rename = "type")]
|
|
pub oper_type: Option<String>,
|
|
}
|
|
|
|
impl Oper {
|
|
// Every privilege-name granted, from both the `type` shortcut and the explicit
|
|
// `privs` list.
|
|
fn all_priv_names(&self) -> Vec<String> {
|
|
let mut names = self.privs.clone();
|
|
if let Some(t) = &self.oper_type {
|
|
names.push(t.clone());
|
|
}
|
|
names
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
// The account -> privileges table (casefolded keys) built from [[oper]].
|
|
pub fn opers(&self) -> std::collections::HashMap<String, echo_api::Privs> {
|
|
let mut map = std::collections::HashMap::new();
|
|
for o in &self.oper {
|
|
map.insert(o.account.to_ascii_lowercase(), echo_api::Privs::from_names(&o.all_priv_names()));
|
|
}
|
|
map
|
|
}
|
|
|
|
// (account, unrecognised-privilege-name) pairs across all [[oper]] blocks, so
|
|
// the caller can warn about a typo that would otherwise silently grant nothing.
|
|
pub fn oper_priv_warnings(&self) -> Vec<(String, String)> {
|
|
self.oper
|
|
.iter()
|
|
.flat_map(|o| {
|
|
let (_, unknown) = echo_api::Privs::parse_names(&o.all_priv_names());
|
|
unknown.into_iter().map(move |name| (o.account.clone(), name))
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
// The service modules to bring up at burst. Each name maps to a compiled-in
|
|
// module crate the daemon knows how to construct (see main.rs). Names not built
|
|
// in are ignored.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Modules {
|
|
#[serde(default = "default_services")]
|
|
pub services: Vec<String>,
|
|
}
|
|
|
|
impl Default for Modules {
|
|
fn default() -> Self {
|
|
Modules { services: default_services() }
|
|
}
|
|
}
|
|
|
|
// The full standard suite: every pseudo-client is a first-class service and
|
|
// comes up by default. An admin trims the list; there is no second tier.
|
|
fn default_services() -> Vec<String> {
|
|
[
|
|
"nickserv", "chanserv", "botserv", "hostserv", "memoserv", "operserv", "statserv",
|
|
"groupserv", "infoserv", "reportserv", "helpserv", "chanfix", "diceserv", "gameserv", "debugserv",
|
|
]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Grpc {
|
|
// Address to accept client connections on, e.g. "127.0.0.1:50051".
|
|
pub bind: String,
|
|
// Bearer token every RPC must present (`authorization: Bearer <token>`).
|
|
pub token: String,
|
|
// Optional server-side TLS (no client cert required, unlike gossip's mTLS —
|
|
// a subscriber is a website backend, not a federation peer).
|
|
#[serde(default)]
|
|
pub tls: Option<ServerTls>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct ServerTls {
|
|
pub cert: String, // certificate chain (PEM)
|
|
pub key: String, // private key (PEM)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct JsonRpc {
|
|
// Address to accept HTTP on, e.g. "127.0.0.1:5601". Keep it on localhost and
|
|
// let a reverse proxy terminate TLS / HTTP-2 / HTTP-3.
|
|
pub bind: String,
|
|
// Bearer token every request must present (`authorization: Bearer <token>`).
|
|
pub token: String,
|
|
// Browser origins allowed to call this cross-site (CORS), e.g.
|
|
// ["https://tchatou.fr", "https://swaygo.fr"]. Empty = no browser access.
|
|
#[serde(default)]
|
|
pub origins: Vec<String>,
|
|
// Terminate TLS here (enabling HTTP/2). Absent = plain HTTP, for when a
|
|
// reverse proxy does TLS. Reuses the same cert/key shape as [grpc].
|
|
#[serde(default)]
|
|
pub tls: Option<ServerTls>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Email {
|
|
// Sender address stamped on outgoing mail.
|
|
pub from: String,
|
|
// A shell command the message is piped to on stdin, e.g. "sendmail -t" or
|
|
// "msmtp -t". Run via `sh -c`, so redirection and pipes work.
|
|
pub command: String,
|
|
// Display name shown in email templates (header/footer).
|
|
#[serde(default = "default_brand")]
|
|
pub brand: String,
|
|
// Accent colour (any CSS colour) for the email template.
|
|
#[serde(default = "default_accent")]
|
|
pub accent: String,
|
|
// Optional logo image URL shown in the email header (must be a hosted image;
|
|
// email clients don't render inline SVG or data URIs).
|
|
#[serde(default)]
|
|
pub logo: String,
|
|
}
|
|
|
|
fn default_brand() -> String {
|
|
"Network Services".to_string()
|
|
}
|
|
|
|
fn default_accent() -> String {
|
|
"#4f46e5".to_string()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Gossip {
|
|
// Address to accept peer connections on. Absent = dial-only node.
|
|
pub bind: Option<String>,
|
|
// Shared secret both nodes must present.
|
|
pub secret: String,
|
|
// Mutual-TLS for the peer link. Absent = plaintext.
|
|
#[serde(default)]
|
|
pub tls: Option<Tls>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Tls {
|
|
pub cert: String, // our certificate chain (PEM)
|
|
pub key: String, // our private key (PEM)
|
|
pub ca: String, // CA bundle that must have signed a peer's certificate (PEM)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct Peer {
|
|
pub addr: String,
|
|
// TLS server name to expect from this peer; must match its certificate.
|
|
#[serde(default = "default_peer_name")]
|
|
pub name: String,
|
|
}
|
|
|
|
fn default_peer_name() -> String {
|
|
"echo".to_string()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Uplink {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Server {
|
|
pub name: String,
|
|
pub sid: String,
|
|
pub description: String,
|
|
#[serde(default = "default_protocol")]
|
|
pub protocol: u32,
|
|
// PBKDF2 cost baked into new SCRAM verifiers at registration. High by
|
|
// default for offline-attack resistance; lower it if registration latency
|
|
// on the single-threaded link matters more than verifier strength.
|
|
#[serde(default = "default_scram_iterations")]
|
|
pub scram_iterations: u32,
|
|
// Nick prefix a user is renamed to on NickServ LOGOUT (they keep the ircd's
|
|
// guest number appended, e.g. Guest12345). Must start with a letter — the
|
|
// ircd rejects a digit-leading SVSNICK and falls back to the raw uuid.
|
|
#[serde(default = "default_guest_nick")]
|
|
pub guest_nick: String,
|
|
// Hostname the service pseudo-clients wear (NickServ!services@<here>). Empty
|
|
// falls back to the server name, so NickServ shows as ...@services.tchatou.fr
|
|
// rather than the generic default.
|
|
#[serde(default)]
|
|
pub service_host: String,
|
|
// User modes the service pseudo-clients (services + bots) are introduced with.
|
|
// Default "iHkBT": invisible, hideoper, servprotect (unkillable — needs the
|
|
// services server U-lined), bot, block-CTCP. Set per the ircd's loaded modules.
|
|
#[serde(default = "default_service_modes")]
|
|
pub service_modes: String,
|
|
// Oper type the service pseudo-clients are flagged with, so WHOIS shows
|
|
// "is a <this>". Default "Network Service". Empty leaves them non-opers.
|
|
#[serde(default = "default_service_oper_type")]
|
|
pub service_oper_type: String,
|
|
// Channel every service pseudo-client (NickServ, ChanServ, …) joins at
|
|
// startup. Default "#services"; empty leaves them out of any channel.
|
|
#[serde(default = "default_services_channel")]
|
|
pub services_channel: String,
|
|
// Emit IRCv3 standard replies (FAIL/WARN/NOTE) for service errors instead of
|
|
// plain notices. Needs m_services_stdrpl loaded on the ircd; off until then,
|
|
// otherwise the replies are dropped. Off = today's notice behaviour.
|
|
#[serde(default)]
|
|
pub standard_replies: bool,
|
|
}
|
|
|
|
fn default_services_channel() -> String {
|
|
"#services".to_string()
|
|
}
|
|
|
|
fn default_service_modes() -> String {
|
|
"ikBT".to_string()
|
|
}
|
|
|
|
fn default_service_oper_type() -> String {
|
|
"Network Service".to_string()
|
|
}
|
|
|
|
fn default_protocol() -> u32 {
|
|
1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3)
|
|
}
|
|
|
|
fn default_guest_nick() -> String {
|
|
"Guest".to_string()
|
|
}
|
|
|
|
fn default_scram_iterations() -> u32 {
|
|
crate::engine::scram::DEFAULT_ITERATIONS
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load(path: &str) -> anyhow::Result<Self> {
|
|
let raw = std::fs::read_to_string(path)?;
|
|
let cfg: Config = toml::from_str(&raw)?;
|
|
cfg.validate()?;
|
|
Ok(cfg)
|
|
}
|
|
|
|
// Catch the common misconfigurations up front with a clear message, rather
|
|
// than failing cryptically at link time.
|
|
fn validate(&self) -> anyhow::Result<()> {
|
|
let sid = &self.server.sid;
|
|
if sid.len() != 3 || !sid.chars().all(|c| c.is_ascii_alphanumeric()) {
|
|
anyhow::bail!("server.sid must be exactly 3 alphanumeric characters (got {sid:?})");
|
|
}
|
|
if self.server.name.trim().is_empty() {
|
|
anyhow::bail!("server.name must not be empty");
|
|
}
|
|
if self.uplink.host.trim().is_empty() {
|
|
anyhow::bail!("uplink.host must not be empty");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::Oper;
|
|
use echo_api::Priv;
|
|
|
|
fn oper(privs: &[&str], oper_type: Option<&str>) -> Oper {
|
|
Oper {
|
|
account: "reverse".into(),
|
|
privs: privs.iter().map(|s| s.to_string()).collect(),
|
|
oper_type: oper_type.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oper_type_shortcut_resolves_to_the_tier_it_names() {
|
|
// `type = "root"` must grant the full root privilege set. This is the seam
|
|
// that, if it read the (now empty) `privs` field instead, would silently
|
|
// strip a `type`-only oper of every privilege.
|
|
let privs = echo_api::Privs::from_names(&oper(&[], Some("root")).all_priv_names());
|
|
assert!(privs.has(Priv::Root) && privs.has(Priv::Admin) && privs.has(Priv::Oper) && privs.has(Priv::Auspex));
|
|
assert_eq!(privs.tier(), "Services Root");
|
|
|
|
// The tier aliases resolve too, and `type` combines with explicit `privs`.
|
|
let admin = echo_api::Privs::from_names(&oper(&[], Some("administrator")).all_priv_names());
|
|
assert!(admin.has(Priv::Admin) && !admin.has(Priv::Root));
|
|
let combined = echo_api::Privs::from_names(&oper(&["auspex"], Some("suspend")).all_priv_names());
|
|
assert!(combined.contains(Priv::Auspex) && combined.contains(Priv::Suspend));
|
|
}
|
|
}
|