977 lines
36 KiB
Rust
977 lines
36 KiB
Rust
//! Tiny `key = value` config (no XML, no deps).
|
|
//!
|
|
//! ```text
|
|
//! servername = echo.devtronic.pro
|
|
//! network = echoNet
|
|
//! bind = 127.0.0.1:6767
|
|
//! motd = Welcome to echoIRCd
|
|
//! oper = god secret # name + password (+ optional level)
|
|
//! oper = god password=<hash> # same, named form (fp=/type= style)
|
|
//! oper = god * fp=<sha256-cert-fp> # cert-only login (no password)
|
|
//! oper = god password=<hash> fp=<cert-fp> # password AND matching cert
|
|
//! ```
|
|
|
|
use crate::map::HashMap;
|
|
|
|
/// Parse a boolean config value (`yes`/`no`/`true`/`false`/`on`/`off`/`1`/`0`).
|
|
pub fn yesish(v: &str) -> bool {
|
|
!matches!(
|
|
v.to_ascii_lowercase().as_str(),
|
|
"off" | "no" | "false" | "0"
|
|
)
|
|
}
|
|
|
|
/// Whether an oper token is a `key=value` option rather than the positional
|
|
/// password (so `oper = <name> password=<hash> fp=<fp>` isn't misread as having
|
|
/// the literal password `password=<hash>`).
|
|
fn is_oper_option(tok: &str) -> bool {
|
|
tok.starts_with("password=")
|
|
|| tok.starts_with("fp=")
|
|
|| tok.starts_with("certfp=")
|
|
|| tok.starts_with("type=")
|
|
}
|
|
|
|
/// A server-link block: how to authenticate a peer named `name` (and, if
|
|
/// `autoconnect`, where to dial it). Passwords are the shared link secret.
|
|
#[derive(Clone)]
|
|
pub struct LinkBlock {
|
|
pub name: String,
|
|
pub ip: String,
|
|
pub port: u16,
|
|
pub password: String,
|
|
pub autoconnect: bool,
|
|
}
|
|
|
|
/// An oper login: `oper = <name> [<password|*> | password=<hash>] [level]
|
|
/// [fp=<sha256-fingerprint>] [type=<id>]`. The password may be given positionally
|
|
/// or as a `password=` token; `*` (or an absent password with a `fp=`) means no
|
|
/// password is checked (cert-only login); a `fp=` token requires the user's TLS
|
|
/// client-certificate SHA-256 fingerprint to match.
|
|
#[derive(Clone, Default)]
|
|
pub struct OperBlock {
|
|
pub name: String,
|
|
pub password: String,
|
|
pub level: u32,
|
|
pub fingerprint: Option<String>,
|
|
pub oper_type: Option<String>,
|
|
}
|
|
|
|
/// Per-SNI branding: a client that connected via `host` (TLS SNI) is shown
|
|
/// `servername`/`network` instead of the global ones — one daemon, multiple
|
|
/// network identities. Repeatable.
|
|
#[derive(Clone, Default)]
|
|
pub struct BrandBlock {
|
|
pub host: String,
|
|
pub servername: String,
|
|
pub network: String,
|
|
}
|
|
|
|
/// A trusted WEBIRC gateway: after presenting `password` it may rewrite a client's
|
|
/// real host + IP. `ipmask` (empty = any) restricts which source addresses may use
|
|
/// this block.
|
|
#[derive(Clone)]
|
|
pub struct WebircGateway {
|
|
pub password: String,
|
|
pub name: String,
|
|
pub ipmask: String,
|
|
}
|
|
|
|
/// A +G censor rule: substitute `find` -> `replace` in channel text; an empty
|
|
/// `replace` blocks the message instead of rewriting it.
|
|
#[derive(Clone)]
|
|
pub struct CensorRule {
|
|
pub find: String,
|
|
pub replace: String,
|
|
}
|
|
|
|
/// Config for the `antimixedutf8` module (blocks mixed-script look-alike spam).
|
|
#[derive(Clone)]
|
|
pub struct AntiMixedCfg {
|
|
pub enable: bool,
|
|
pub threshold: u32,
|
|
pub minlen: usize,
|
|
pub action: String, // block | kill | gline | kline | zline
|
|
pub duration: u64, // seconds, for the *line actions
|
|
pub reason: String,
|
|
pub block_msg: String, // notice text sent on the "block" action
|
|
pub check_channel: bool,
|
|
pub check_private: bool,
|
|
}
|
|
|
|
impl Default for AntiMixedCfg {
|
|
fn default() -> AntiMixedCfg {
|
|
AntiMixedCfg {
|
|
enable: false,
|
|
threshold: 8,
|
|
minlen: 10,
|
|
action: "block".to_string(),
|
|
duration: 3600,
|
|
reason: "Mixed-script text (spam).".to_string(),
|
|
block_msg: "Your message contains mixed look-alike characters often used by \
|
|
spam. Please rewrite it and try again."
|
|
.to_string(),
|
|
check_channel: true,
|
|
check_private: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Config {
|
|
pub servername: String,
|
|
pub network: String,
|
|
pub bind: Vec<String>, // plaintext client listeners (repeatable; e.g. [::]:6667)
|
|
pub bind_tls: Vec<String>, // TLS client listeners (repeatable; e.g. [::]:6697)
|
|
pub tls_cert: Option<String>, // PEM certificate chain
|
|
pub tls_key: Option<String>, // PEM private key
|
|
pub motd: Vec<String>,
|
|
pub opers: Vec<OperBlock>, // oper logins (see OperBlock)
|
|
pub brands: Vec<BrandBlock>, // per-SNI server/network branding
|
|
pub cloak_key: Option<String>, // secret key for host cloaking (+x); None = off
|
|
pub sid: String, // this server's 3-char server id (S2S)
|
|
pub serverdesc: String, // this server's description
|
|
pub bind_server: Vec<String>, // server-to-server link listeners (repeatable)
|
|
pub links: Vec<LinkBlock>, // peers we accept / dial
|
|
pub conf_path: String, // where this was loaded from (for REHASH)
|
|
pub censor: Vec<CensorRule>, // +G bad words (empty replace = block)
|
|
pub amu: AntiMixedCfg, // antimixedutf8 module config
|
|
pub resolve_hosts: bool, // reverse-DNS clients on connect (default on)
|
|
pub use_resolved_host: bool, // put the resolved hostname in the hostmask (default on)
|
|
pub dnsbl_zones: Vec<crate::modules::dnsbl::DnsblZone>, // DNS blocklists to check on connect
|
|
pub dnsbl_action: String, // mark | kline | gline | zline (on a hit)
|
|
pub dnsbl_reason: String, // ban reason for a DNSBL hit
|
|
pub sasl_server: String, // linked services server that handles SASL ("" = none)
|
|
pub webirc: Vec<WebircGateway>, // trusted web gateways
|
|
/// Every `key = value` line, captured raw so modules read their own settings
|
|
/// via `Server::conf*` — no per-module field bloats this struct or `Server`.
|
|
pub raw: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Config {
|
|
Config {
|
|
servername: "echo.local".to_string(),
|
|
network: "echoNet".to_string(),
|
|
bind: Vec::new(),
|
|
bind_tls: Vec::new(),
|
|
tls_cert: None,
|
|
tls_key: None,
|
|
motd: Vec::new(),
|
|
opers: Vec::new(),
|
|
brands: Vec::new(),
|
|
cloak_key: None,
|
|
sid: "0AA".to_string(),
|
|
serverdesc: "echoIRCd server".to_string(),
|
|
bind_server: Vec::new(),
|
|
links: Vec::new(),
|
|
conf_path: "echoircd.conf".to_string(),
|
|
censor: Vec::new(),
|
|
amu: AntiMixedCfg::default(),
|
|
resolve_hosts: true,
|
|
use_resolved_host: true,
|
|
dnsbl_zones: Vec::new(),
|
|
dnsbl_action: "mark".to_string(),
|
|
dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(),
|
|
sasl_server: String::new(),
|
|
webirc: Vec::new(),
|
|
raw: HashMap::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
/// Read a config file, falling back to defaults for anything missing. A
|
|
/// missing file is not an error — you get the defaults.
|
|
pub fn load(path: &str) -> Config {
|
|
let mut c = Config {
|
|
conf_path: path.to_string(),
|
|
..Config::default()
|
|
};
|
|
if let Ok(text) = std::fs::read_to_string(path) {
|
|
Self::parse_into(&mut c, &text);
|
|
}
|
|
c
|
|
}
|
|
|
|
/// Like [`load`](Config::load) but returns `None` if the file can't be read,
|
|
/// so REHASH can keep the running config instead of resetting to defaults.
|
|
pub fn try_load(path: &str) -> Option<Config> {
|
|
let text = std::fs::read_to_string(path).ok()?;
|
|
let mut c = Config {
|
|
conf_path: path.to_string(),
|
|
..Config::default()
|
|
};
|
|
Self::parse_into(&mut c, &text);
|
|
Some(c)
|
|
}
|
|
|
|
/// A deterministic text dump of every parsed key/value (sorted), used by
|
|
/// `echoircd checkconfig` to compare two configs regardless of source format.
|
|
pub fn dump(&self) -> String {
|
|
let mut keys: Vec<&String> = self.raw.keys().collect();
|
|
keys.sort();
|
|
let mut out = String::new();
|
|
for k in keys {
|
|
for v in &self.raw[k] {
|
|
out.push_str(k);
|
|
out.push_str(" = ");
|
|
out.push_str(v);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// small helper is defined at module scope (see `yesish`).
|
|
|
|
/// Parse `key = value` lines into `c`; unknown keys and comments are ignored.
|
|
fn parse_into(c: &mut Config, text: &str) {
|
|
// Two accepted syntaxes: the brace/block format and the legacy flat
|
|
// `key = value` format. A top-level `name {` selects blocks, which are
|
|
// translated to the flat form and re-parsed through this same path, so
|
|
// the two formats can never diverge.
|
|
if looks_like_blocks(text) {
|
|
let flat = blocks_to_flat(text);
|
|
Self::parse_into(c, &flat);
|
|
return;
|
|
}
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
|
continue;
|
|
}
|
|
let Some((k, v)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
let (k, v) = (k.trim(), v.trim());
|
|
// Every line is captured raw so a module can read its own settings via
|
|
// `Server::conf*` without a typed field bloating config.rs / server.rs.
|
|
c.raw.entry(k.to_string()).or_default().push(v.to_string());
|
|
match k {
|
|
"servername" | "server" => c.servername = v.to_string(),
|
|
"network" => c.network = v.to_string(),
|
|
"bind" => c.bind.push(v.to_string()),
|
|
"bind_tls" => c.bind_tls.push(v.to_string()),
|
|
"tls_cert" => c.tls_cert = Some(v.to_string()),
|
|
"tls_key" => c.tls_key = Some(v.to_string()),
|
|
"cloak_key" => c.cloak_key = Some(v.to_string()),
|
|
"sid" => c.sid = v.to_string(),
|
|
"serverdesc" | "description" => c.serverdesc = v.to_string(),
|
|
"bind_server" => c.bind_server.push(v.to_string()),
|
|
"link" => {
|
|
// link = <name> <ip> <port> <password> [autoconnect]
|
|
let t: Vec<&str> = v.split_whitespace().collect();
|
|
if t.len() >= 4 {
|
|
if let Ok(port) = t[2].parse::<u16>() {
|
|
c.links.push(LinkBlock {
|
|
name: t[0].to_string(),
|
|
ip: t[1].to_string(),
|
|
port,
|
|
password: t[3].to_string(),
|
|
autoconnect: t.get(4).is_some_and(|a| {
|
|
a.eq_ignore_ascii_case("autoconnect")
|
|
|| a.eq_ignore_ascii_case("connect")
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
"motd" => c.motd.push(v.to_string()),
|
|
"brand" => {
|
|
// brand = <host> [servername=<sv>] [network=<nw>]
|
|
let mut it = v.split_whitespace();
|
|
if let Some(host) = it.next() {
|
|
let mut b = BrandBlock {
|
|
host: host.to_ascii_lowercase(),
|
|
..Default::default()
|
|
};
|
|
for tok in it {
|
|
if let Some(s) = tok.strip_prefix("servername=") {
|
|
b.servername = s.to_string();
|
|
} else if let Some(n) = tok.strip_prefix("network=") {
|
|
b.network = n.to_string();
|
|
}
|
|
}
|
|
c.brands.push(b);
|
|
}
|
|
}
|
|
"oper" => {
|
|
let mut it = v.split_whitespace().peekable();
|
|
if let Some(n) = it.next() {
|
|
let mut b = OperBlock {
|
|
name: n.to_string(),
|
|
..Default::default()
|
|
};
|
|
// Back-compat: a bare second token (not a `key=value` option)
|
|
// is the positional password (`*` = cert-only), matching the
|
|
// old `oper = <name> <password|*> ...` form.
|
|
if let Some(tok) = it.peek() {
|
|
if !is_oper_option(tok) {
|
|
b.password = tok.to_string();
|
|
it.next();
|
|
}
|
|
}
|
|
// Named options in any order: `password=` (preferred),
|
|
// `fp=`/`certfp=` the required TLS cert fingerprint, `type=`
|
|
// the oper type; a bare number is the operlevel.
|
|
for tok in it {
|
|
if let Some(pw) = tok.strip_prefix("password=") {
|
|
b.password = pw.to_string();
|
|
} else if let Some(fp) =
|
|
tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp="))
|
|
{
|
|
b.fingerprint = Some(fp.to_ascii_lowercase());
|
|
} else if let Some(t) = tok.strip_prefix("type=") {
|
|
b.oper_type = Some(t.to_string());
|
|
} else if let Ok(l) = tok.parse::<u32>() {
|
|
b.level = l;
|
|
}
|
|
}
|
|
// A block with no credential at all (no password, no cert
|
|
// fingerprint) would let anyone oper up — refuse it, as the
|
|
// old parser did by requiring a password token.
|
|
if !b.password.is_empty() || b.fingerprint.is_some() {
|
|
c.opers.push(b);
|
|
}
|
|
}
|
|
}
|
|
// +G censor word: `badword = <find> [replace]` (no replace ⇒ block)
|
|
"badword" => {
|
|
let mut it = v.splitn(2, char::is_whitespace);
|
|
if let Some(find) = it.next().filter(|f| !f.is_empty()) {
|
|
let replace = it.next().unwrap_or("").trim().to_string();
|
|
c.censor.push(CensorRule { find: find.to_string(), replace });
|
|
}
|
|
}
|
|
"antimixedutf8" | "amu" => {
|
|
c.amu.enable =
|
|
matches!(v.to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1")
|
|
}
|
|
"amu_threshold" => {
|
|
if let Ok(n) = v.parse() {
|
|
c.amu.threshold = n;
|
|
}
|
|
}
|
|
"amu_minlen" => {
|
|
if let Ok(n) = v.parse() {
|
|
c.amu.minlen = n;
|
|
}
|
|
}
|
|
"amu_action" => c.amu.action = v.to_string(),
|
|
"amu_duration" => {
|
|
if let Some(d) = crate::xline::parse_duration(v) {
|
|
c.amu.duration = d;
|
|
}
|
|
}
|
|
"amu_reason" => c.amu.reason = v.to_string(),
|
|
"amu_message" => c.amu.block_msg = v.to_string(),
|
|
"amu_target" => {
|
|
let t = v.to_ascii_lowercase();
|
|
c.amu.check_channel = t == "both" || t == "channel";
|
|
c.amu.check_private = t == "both" || t == "private";
|
|
}
|
|
"resolve_hosts" | "resolvehosts" | "dns" => {
|
|
c.resolve_hosts = !matches!(
|
|
v.to_ascii_lowercase().as_str(),
|
|
"off" | "false" | "no" | "0"
|
|
)
|
|
}
|
|
"use_resolved_host" | "resolved_hostmask" | "hostmask_dns" => {
|
|
c.use_resolved_host = !matches!(
|
|
v.to_ascii_lowercase().as_str(),
|
|
"off" | "false" | "no" | "0"
|
|
)
|
|
}
|
|
"dnsbl" | "dnsbl_zone" => {
|
|
if let Some(z) = crate::modules::dnsbl::parse_zone(v) {
|
|
c.dnsbl_zones.push(z);
|
|
}
|
|
}
|
|
"dnsbl_action" => c.dnsbl_action = v.to_ascii_lowercase(),
|
|
"dnsbl_reason" => c.dnsbl_reason = v.to_string(),
|
|
"sasl_server" | "sasl_target" => c.sasl_server = v.to_string(),
|
|
"webirc" => {
|
|
// webirc = <password> [gateway-name] [ip-mask]
|
|
let mut it = v.split_whitespace();
|
|
if let Some(pass) = it.next() {
|
|
let gw = it.next().unwrap_or("webirc").to_string();
|
|
let mask = it.next().unwrap_or("").to_string();
|
|
c.webirc.push(WebircGateway {
|
|
password: pass.to_string(),
|
|
name: gw,
|
|
ipmask: mask,
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── block/brace config parser ──────────────────────────────────────────────
|
|
// The brace format (`server { name "x"; ... }`) is translated to the flat
|
|
// `key = value` text that `parse_into` already understands, so both syntaxes
|
|
// funnel through one code path and can never diverge. Structural blocks map
|
|
// their short field names onto the internal keys / entity line grammars; every
|
|
// other block (`set`, `limits`, …) is cosmetic grouping whose fields are flat
|
|
// keys, so the long tail of module options needs no per-key mapping.
|
|
|
|
#[derive(PartialEq, Eq)]
|
|
enum Tok {
|
|
Open,
|
|
Close,
|
|
Semi,
|
|
Word(String),
|
|
}
|
|
|
|
/// A file is in block format if some non-comment line is `identifier {` — the
|
|
/// flat `key = value` format never puts a bare identifier before an unquoted `{`.
|
|
fn looks_like_blocks(text: &str) -> bool {
|
|
for raw in text.lines() {
|
|
let line = raw.trim();
|
|
if line.is_empty()
|
|
|| line.starts_with('#')
|
|
|| line.starts_with("//")
|
|
|| line.starts_with(';')
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(pos) = line.find('{') {
|
|
let head = line[..pos].trim();
|
|
if !head.is_empty()
|
|
&& !head.contains('=')
|
|
&& head
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Tokenize the block format: `{ } ;`, quoted strings, and bare words. Line
|
|
/// (`#`, `//`) and block (`/* */`) comments are skipped.
|
|
fn tokenize(text: &str) -> Vec<Tok> {
|
|
let c: Vec<char> = text.chars().collect();
|
|
let n = c.len();
|
|
let mut i = 0;
|
|
let mut toks = Vec::new();
|
|
while i < n {
|
|
let ch = c[i];
|
|
if ch.is_whitespace() {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if ch == '#' || (ch == '/' && i + 1 < n && c[i + 1] == '/') {
|
|
while i < n && c[i] != '\n' {
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if ch == '/' && i + 1 < n && c[i + 1] == '*' {
|
|
i += 2;
|
|
while i + 1 < n && !(c[i] == '*' && c[i + 1] == '/') {
|
|
i += 1;
|
|
}
|
|
i = (i + 2).min(n);
|
|
continue;
|
|
}
|
|
match ch {
|
|
'{' => {
|
|
toks.push(Tok::Open);
|
|
i += 1;
|
|
}
|
|
'}' => {
|
|
toks.push(Tok::Close);
|
|
i += 1;
|
|
}
|
|
';' => {
|
|
toks.push(Tok::Semi);
|
|
i += 1;
|
|
}
|
|
'"' => {
|
|
i += 1;
|
|
let mut s = String::new();
|
|
while i < n {
|
|
if c[i] == '\\' && i + 1 < n {
|
|
s.push(match c[i + 1] {
|
|
'n' => '\n',
|
|
't' => '\t',
|
|
o => o,
|
|
});
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if c[i] == '"' {
|
|
i += 1;
|
|
break;
|
|
}
|
|
s.push(c[i]);
|
|
i += 1;
|
|
}
|
|
toks.push(Tok::Word(s));
|
|
}
|
|
_ => {
|
|
let mut s = String::new();
|
|
while i < n {
|
|
let x = c[i];
|
|
if x.is_whitespace() || x == '{' || x == '}' || x == ';' || x == '"' {
|
|
break;
|
|
}
|
|
if x == '#' {
|
|
break;
|
|
}
|
|
if x == '/' && i + 1 < n && (c[i + 1] == '/' || c[i + 1] == '*') {
|
|
break;
|
|
}
|
|
s.push(x);
|
|
i += 1;
|
|
}
|
|
toks.push(Tok::Word(s));
|
|
}
|
|
}
|
|
}
|
|
toks
|
|
}
|
|
|
|
/// Translate block syntax into the equivalent flat `key = value` text.
|
|
fn blocks_to_flat(text: &str) -> String {
|
|
let toks = tokenize(text);
|
|
let n = toks.len();
|
|
let mut i = 0;
|
|
let mut out = String::new();
|
|
while i < n {
|
|
let name = match &toks[i] {
|
|
Tok::Word(w) => w.clone(),
|
|
_ => {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
};
|
|
i += 1;
|
|
if i < n && toks[i] == Tok::Open {
|
|
i += 1;
|
|
let mut fields: Vec<(String, String)> = Vec::new();
|
|
while i < n && toks[i] != Tok::Close {
|
|
let field = match &toks[i] {
|
|
Tok::Word(w) => w.clone(),
|
|
_ => {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
};
|
|
i += 1;
|
|
let mut vals = Vec::new();
|
|
while i < n && toks[i] != Tok::Semi && toks[i] != Tok::Close {
|
|
if let Tok::Word(w) = &toks[i] {
|
|
vals.push(w.clone());
|
|
}
|
|
i += 1;
|
|
}
|
|
if i < n && toks[i] == Tok::Semi {
|
|
i += 1;
|
|
}
|
|
fields.push((field, vals.join(" ")));
|
|
}
|
|
if i < n {
|
|
i += 1; // consume `}`
|
|
}
|
|
emit_block(&mut out, &name, &fields);
|
|
} else {
|
|
// top-level `key value... ;` with no braces
|
|
let mut vals = Vec::new();
|
|
while i < n && toks[i] != Tok::Semi {
|
|
if let Tok::Word(w) = &toks[i] {
|
|
vals.push(w.clone());
|
|
}
|
|
i += 1;
|
|
}
|
|
if i < n {
|
|
i += 1;
|
|
}
|
|
emit_line(&mut out, &name, &vals.join(" "));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn emit_line(out: &mut String, key: &str, value: &str) {
|
|
out.push_str(key);
|
|
out.push_str(" = ");
|
|
out.push_str(&value.replace('\n', " "));
|
|
out.push('\n');
|
|
}
|
|
|
|
/// Expand one block into flat `key = value` lines.
|
|
fn emit_block(out: &mut String, name: &str, fields: &[(String, String)]) {
|
|
let get = |k: &str| {
|
|
fields
|
|
.iter()
|
|
.find(|(f, _)| f.eq_ignore_ascii_case(k))
|
|
.map(|(_, v)| v.as_str())
|
|
};
|
|
match name.to_ascii_lowercase().as_str() {
|
|
"server" => {
|
|
if let Some(v) = get("name") {
|
|
emit_line(out, "servername", v);
|
|
}
|
|
if let Some(v) = get("network") {
|
|
emit_line(out, "network", v);
|
|
}
|
|
if let Some(v) = get("sid") {
|
|
emit_line(out, "sid", v);
|
|
}
|
|
if let Some(v) = get("description").or_else(|| get("desc")) {
|
|
emit_line(out, "description", v);
|
|
}
|
|
if let Some(v) = get("pidfile") {
|
|
emit_line(out, "pidfile", v);
|
|
}
|
|
}
|
|
"tls" => {
|
|
// backend/cert/key map to their tls_ keys; any other field (sni,
|
|
// handshake_timeout, …) passes through as tls_<field>, repeatable.
|
|
for (f, v) in fields {
|
|
let fl = f.to_ascii_lowercase();
|
|
let key = match fl.as_str() {
|
|
"backend" => "tls_backend".to_string(),
|
|
"cert" => "tls_cert".to_string(),
|
|
"key" => "tls_key".to_string(),
|
|
other => format!("tls_{other}"),
|
|
};
|
|
emit_line(out, &key, v);
|
|
}
|
|
}
|
|
"cloak" => {
|
|
if let Some(v) = get("key") {
|
|
emit_line(out, "cloak_key", v);
|
|
}
|
|
if let Some(v) = get("method") {
|
|
emit_line(out, "cloak_method", v);
|
|
}
|
|
if let Some(v) = get("static_host").or_else(|| get("static")) {
|
|
emit_line(out, "cloak_static_host", v);
|
|
}
|
|
if let Some(v) = get("account_prefix") {
|
|
emit_line(out, "cloak_account_prefix", v);
|
|
}
|
|
if let Some(v) = get("cert_prefix") {
|
|
emit_line(out, "cloak_cert_prefix", v);
|
|
}
|
|
}
|
|
"brand" => {
|
|
if let Some(host) = get("host") {
|
|
let mut line = host.to_string();
|
|
if let Some(v) = get("servername") {
|
|
line.push_str(" servername=");
|
|
line.push_str(v);
|
|
}
|
|
if let Some(v) = get("network") {
|
|
line.push_str(" network=");
|
|
line.push_str(v);
|
|
}
|
|
emit_line(out, "brand", &line);
|
|
}
|
|
}
|
|
"listen" => {
|
|
if let (Some(ip), Some(port)) = (get("ip"), get("port")) {
|
|
let addr = if ip.contains(':') && !ip.starts_with('[') {
|
|
format!("[{ip}]:{port}")
|
|
} else {
|
|
format!("{ip}:{port}")
|
|
};
|
|
let key = if get("type").is_some_and(|t| t.eq_ignore_ascii_case("server")) {
|
|
"bind_server"
|
|
} else if get("wss").is_some_and(yesish) {
|
|
"bind_wss"
|
|
} else if get("tls").is_some_and(yesish) {
|
|
"bind_tls"
|
|
} else {
|
|
"bind"
|
|
};
|
|
emit_line(out, key, &addr);
|
|
}
|
|
}
|
|
"oper" => {
|
|
if let Some(nm) = get("name") {
|
|
let mut line = nm.to_string();
|
|
if let Some(pw) = get("password") {
|
|
line.push_str(" password=");
|
|
line.push_str(pw);
|
|
}
|
|
if let Some(fp) = get("fingerprint")
|
|
.or_else(|| get("fp"))
|
|
.or_else(|| get("certfp"))
|
|
{
|
|
line.push_str(" fp=");
|
|
line.push_str(fp);
|
|
}
|
|
if let Some(t) = get("type") {
|
|
line.push_str(" type=");
|
|
line.push_str(t);
|
|
}
|
|
if let Some(l) = get("level") {
|
|
line.push(' ');
|
|
line.push_str(l);
|
|
}
|
|
emit_line(out, "oper", &line);
|
|
}
|
|
}
|
|
n @ ("opertype" | "class") => {
|
|
if let Some(nm) = get("name") {
|
|
let mut line = nm.to_string();
|
|
for (f, v) in fields {
|
|
if f.eq_ignore_ascii_case("name") {
|
|
continue;
|
|
}
|
|
line.push(' ');
|
|
line.push_str(f);
|
|
line.push('=');
|
|
line.push_str(v);
|
|
}
|
|
emit_line(out, n, &line);
|
|
}
|
|
}
|
|
"link" => {
|
|
if let (Some(nm), Some(ip), Some(port), Some(pw)) =
|
|
(get("name"), get("ip"), get("port"), get("password"))
|
|
{
|
|
let mut line = format!("{nm} {ip} {port} {pw}");
|
|
if get("autoconnect").is_some_and(yesish) {
|
|
line.push_str(" autoconnect");
|
|
}
|
|
emit_line(out, "link", &line);
|
|
// `services yes` also marks the peer as a U-lined services server.
|
|
if get("services").is_some_and(yesish) || get("uline").is_some_and(yesish) {
|
|
emit_line(out, "uline", nm);
|
|
}
|
|
}
|
|
}
|
|
"webirc" => {
|
|
if let Some(pw) = get("password") {
|
|
let gw = get("name").or_else(|| get("gateway")).unwrap_or("webirc");
|
|
let mask = get("mask").or_else(|| get("ipmask")).unwrap_or("");
|
|
emit_line(out, "webirc", format!("{pw} {gw} {mask}").trim_end());
|
|
}
|
|
}
|
|
motd @ ("motd" | "opermotd") => {
|
|
for (f, v) in fields {
|
|
let line = if v.is_empty() {
|
|
f.clone()
|
|
} else {
|
|
format!("{f} {v}")
|
|
};
|
|
emit_line(out, motd, &line);
|
|
}
|
|
}
|
|
// grouping blocks (set, limits, …): each field is a flat key; a bare
|
|
// field with no value (`operprefix;`) enables it.
|
|
_ => {
|
|
for (f, v) in fields {
|
|
emit_line(out, f, if v.is_empty() { "yes" } else { v });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn opers(text: &str) -> Vec<OperBlock> {
|
|
let mut c = Config::default();
|
|
Config::parse_into(&mut c, text);
|
|
c.opers
|
|
}
|
|
|
|
fn cfg(text: &str) -> Config {
|
|
let mut c = Config::default();
|
|
Config::parse_into(&mut c, text);
|
|
c
|
|
}
|
|
|
|
#[test]
|
|
fn block_server_and_set() {
|
|
let c = cfg(r#"
|
|
server {
|
|
name "irc.example.org";
|
|
network "ExampleNet";
|
|
sid 0AB;
|
|
}
|
|
set {
|
|
operprefix yes;
|
|
nicklen 30;
|
|
}
|
|
"#);
|
|
assert_eq!(c.servername, "irc.example.org");
|
|
assert_eq!(c.network, "ExampleNet");
|
|
assert_eq!(c.sid, "0AB");
|
|
assert_eq!(c.raw.get("operprefix").map(|v| v[0].as_str()), Some("yes"));
|
|
assert_eq!(c.raw.get("nicklen").map(|v| v[0].as_str()), Some("30"));
|
|
}
|
|
|
|
#[test]
|
|
fn block_oper_and_credential_guard() {
|
|
let c = cfg(r#"
|
|
oper {
|
|
name "reverse";
|
|
password "$2b$11$abc.def/ghi";
|
|
type netadmin;
|
|
}
|
|
oper { name "ghost"; } // no credential -> dropped
|
|
"#);
|
|
assert_eq!(c.opers.len(), 1);
|
|
assert_eq!(c.opers[0].name, "reverse");
|
|
assert_eq!(c.opers[0].password, "$2b$11$abc.def/ghi");
|
|
assert_eq!(c.opers[0].oper_type.as_deref(), Some("netadmin"));
|
|
}
|
|
|
|
#[test]
|
|
fn block_listen_picks_bind_key() {
|
|
let c = cfg(r#"
|
|
listen { ip "*"; port 6667; }
|
|
listen { ip "*"; port 6697; tls yes; }
|
|
listen { ip 127.0.0.1; port 7700; type server; }
|
|
"#);
|
|
assert!(c.bind.iter().any(|b| b == "*:6667"));
|
|
assert!(c.bind_tls.iter().any(|b| b == "*:6697"));
|
|
assert!(c.bind_server.iter().any(|b| b == "127.0.0.1:7700"));
|
|
}
|
|
|
|
#[test]
|
|
fn block_link_and_comments() {
|
|
let c = cfg(r#"
|
|
# a link block, mixed comment styles
|
|
link {
|
|
name "services.example.org";
|
|
ip 127.0.0.1; /* loopback */
|
|
port 7700;
|
|
password "s3cr3t";
|
|
autoconnect yes;
|
|
}
|
|
"#);
|
|
assert_eq!(c.links.len(), 1);
|
|
assert_eq!(c.links[0].name, "services.example.org");
|
|
assert_eq!(c.links[0].port, 7700);
|
|
assert_eq!(c.links[0].password, "s3cr3t");
|
|
assert!(c.links[0].autoconnect);
|
|
}
|
|
|
|
#[test]
|
|
fn block_and_flat_agree() {
|
|
let flat = cfg("servername = irc.x\noper = reverse password=$2b$11$z type=netadmin\n");
|
|
let block = cfg(
|
|
"server { name \"irc.x\"; }\noper { name reverse; password \"$2b$11$z\"; type netadmin; }\n",
|
|
);
|
|
assert_eq!(flat.servername, block.servername);
|
|
assert_eq!(flat.opers.len(), block.opers.len());
|
|
assert_eq!(flat.opers[0].password, block.opers[0].password);
|
|
assert_eq!(flat.opers[0].oper_type, block.opers[0].oper_type);
|
|
}
|
|
|
|
#[test]
|
|
fn shipped_example_parses() {
|
|
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/echoircd.conf.example");
|
|
let text = std::fs::read_to_string(path).expect("example config present");
|
|
let c = cfg(&text);
|
|
assert_eq!(c.servername, "irc.example.net");
|
|
assert_eq!(c.network, "ExampleNet");
|
|
assert!(c.bind.iter().any(|b| b.ends_with(":6667")));
|
|
assert!(c.bind_tls.iter().any(|b| b.ends_with(":6697")));
|
|
assert!(c.bind_server.iter().any(|b| b.ends_with(":7000")));
|
|
assert_eq!(c.tls_cert.as_deref(), Some("./tls/cert.pem"));
|
|
assert_eq!(c.opers.len(), 1);
|
|
assert_eq!(c.opers[0].name, "admin");
|
|
assert_eq!(c.opers[0].oper_type.as_deref(), Some("netadmin"));
|
|
assert!(c.motd.len() >= 2);
|
|
assert_eq!(
|
|
c.raw.get("resolve_hosts").map(|v| v[0].as_str()),
|
|
Some("yes")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn block_cloak_and_uline() {
|
|
let c = cfg(r#"
|
|
cloak { key "s3cret"; method "sha256"; static_host "user.example.org"; }
|
|
link { name "svc.example.org"; ip 127.0.0.1; port 7700; password "p"; services yes; }
|
|
"#);
|
|
assert_eq!(c.cloak_key.as_deref(), Some("s3cret"));
|
|
assert_eq!(c.raw.get("cloak_method").map(|v| v[0].as_str()), Some("sha256"));
|
|
assert_eq!(
|
|
c.raw.get("cloak_static_host").map(|v| v[0].as_str()),
|
|
Some("user.example.org")
|
|
);
|
|
assert_eq!(c.raw.get("uline").map(|v| v[0].as_str()), Some("svc.example.org"));
|
|
}
|
|
|
|
#[test]
|
|
fn block_repeated_list_field() {
|
|
let c = cfg("modules { alias \"NS NickServ\"; alias \"CS ChanServ\"; }");
|
|
let al = c.raw.get("alias").unwrap();
|
|
assert_eq!(al.len(), 2);
|
|
assert_eq!(al[0], "NS NickServ");
|
|
assert_eq!(al[1], "CS ChanServ");
|
|
}
|
|
|
|
#[test]
|
|
fn block_quoted_string_with_spaces() {
|
|
let c = cfg("server { name \"irc.x\"; description \"A friendly server\"; }");
|
|
assert_eq!(c.serverdesc, "A friendly server");
|
|
}
|
|
|
|
#[test]
|
|
fn positional_password_still_parses() {
|
|
let o = opers("oper = god secret 5 fp=ABC type=netadmin");
|
|
assert_eq!(o.len(), 1);
|
|
assert_eq!(o[0].name, "god");
|
|
assert_eq!(o[0].password, "secret");
|
|
assert_eq!(o[0].level, 5);
|
|
assert_eq!(o[0].fingerprint.as_deref(), Some("abc"));
|
|
assert_eq!(o[0].oper_type.as_deref(), Some("netadmin"));
|
|
}
|
|
|
|
#[test]
|
|
fn star_positional_is_cert_only() {
|
|
let o = opers("oper = god * fp=abc");
|
|
assert_eq!(o[0].password, "*");
|
|
assert_eq!(o[0].fingerprint.as_deref(), Some("abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_positional_password_is_not_a_level() {
|
|
// a bare second token is the password even when numeric (back-compat)
|
|
let o = opers("oper = god 12345");
|
|
assert_eq!(o[0].password, "12345");
|
|
assert_eq!(o[0].level, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn named_password_token() {
|
|
let o = opers("oper = reverse password=$2b$11$abc.def/ghi fp=FF type=netadmin");
|
|
assert_eq!(o.len(), 1);
|
|
assert_eq!(o[0].name, "reverse");
|
|
assert_eq!(o[0].password, "$2b$11$abc.def/ghi");
|
|
assert_eq!(o[0].fingerprint.as_deref(), Some("ff"));
|
|
assert_eq!(o[0].oper_type.as_deref(), Some("netadmin"));
|
|
}
|
|
|
|
#[test]
|
|
fn named_password_order_independent() {
|
|
let o = opers("oper = reverse type=admin fp=aa password=hunter2 3");
|
|
assert_eq!(o[0].password, "hunter2");
|
|
assert_eq!(o[0].level, 3);
|
|
assert_eq!(o[0].fingerprint.as_deref(), Some("aa"));
|
|
assert_eq!(o[0].oper_type.as_deref(), Some("admin"));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_less_block_is_rejected() {
|
|
// no password and no fp would let anyone oper up — must be dropped
|
|
assert!(opers("oper = nobody").is_empty());
|
|
assert!(opers("oper = nobody type=netadmin").is_empty());
|
|
}
|
|
}
|