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, #[serde(default)] pub peer: Vec, // Outbound email (password resets). Absent = email features are off. #[serde(default)] pub email: Option, // Directory replication (gRPC), for websites mirroring the account/channel // directory. Absent = the RPC server does not start. #[serde(default)] pub grpc: Option, // 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, // 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, // Staff audit feed. Absent = no audit log is emitted. #[serde(default)] pub log: Option, // Inactivity-expiry. Absent = accounts and channels never expire. #[serde(default)] pub expire: Option, // Per-IP session limiting. Absent = unlimited. #[serde(default)] pub session: Option, // 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, // Website single-use keycards (passwordless web login). Absent = keycard // credentials (`kc_…` over SASL) are not honoured. #[serde(default)] pub keycard: Option, // 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, // 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, } #[derive(Debug, Deserialize, Clone)] pub struct Extban { // Enabled extban names ("account", "realname", "country", …). Empty = all. #[serde(default)] pub enabled: Vec, } // 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 { (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 { (self.accounts_days > 0).then(|| self.accounts_days * 86_400) } pub fn channel_ttl(&self) -> Option { (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 { (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:` (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, } // 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, // 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, } impl Oper { // Every privilege-name granted, from both the `type` shortcut and the explicit // `privs` list. fn all_priv_names(&self) -> Vec { 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 { 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, } 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 { [ "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 `). 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, } #[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 `). 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, // 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, } #[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, // Shared secret both nodes must present. pub secret: String, // Mutual-TLS for the peer link. Absent = plaintext. #[serde(default)] pub tls: Option, } #[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@). 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 ". 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 { 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(()) } }