Some checks failed
CI / check (push) Failing after 53s
Config::load now checks the SID is 3 alphanumeric chars and that server.name and uplink.host are set, failing with a clear message instead of a cryptic link-time error. Give the Service and Protocol traits and ServiceCtx proper rustdoc so cargo doc is useful to module authors.
301 lines
9.8 KiB
Rust
301 lines
9.8 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>,
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// 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>, // "auspex" | "suspend" | "admin"
|
|
}
|
|
|
|
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.privs));
|
|
}
|
|
map
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
]
|
|
.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,
|
|
}
|
|
|
|
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(())
|
|
}
|
|
}
|