diff --git a/README.md b/README.md index 2715f92..3a164d0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ protocol layer built to the [modern IRC client spec](https://modern.ircdocs.hors |---------------|------------------|-----------------------------------------------------------| | `src/irc.rs` | protocol + I/O | Parse/serialize IRC messages (incl. IRCv3 tags), TCP transport | | `src/bot.rs` | behavior | Registration, event loop, command handling | -| `src/main.rs` | wiring | Config from env vars, connect/reconnect supervisor | +| `src/main.rs` | wiring | Layered config, one connect/reconnect supervisor thread per network | The layers are deliberately separable: `irc.rs` knows nothing about the bot, and `bot.rs` knows nothing about how the process is launched. @@ -40,8 +40,37 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run ``` Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, -`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, `sasl_pass`. -Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. +`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, +`sasl_pass`, and `name` (a network's log label). Whole-line `#`/`;` comments +only — inline comments would clash with channel `#`s. + +### Multiple networks + +Group keys under `[name]` section headers to connect to several networks at +once — one supervisor thread each, with independent reconnect/backoff. Keys +*before* the first header are shared defaults every network inherits; each +section overrides them. A file with no headers is a single network (the +historical behaviour), and its logs stay unprefixed. + +```ini +# shared defaults, inherited by every network below +nick = rubot +realname = rubot + +[tchatou] +server = irc.tchatou.fr +tls = true +channels = #devs + +[libera] +server = irc.libera.chat +tls = true +channels = #rust, #rust-beginners +nick = rubot_ # override just for this network +``` + +With more than one network, each network's log lines are tagged with its +section name (`[libera] << …`) so the interleaved output stays readable. Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`, diff --git a/src/bot.rs b/src/bot.rs index 929fdb9..ba1ac21 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -35,6 +35,10 @@ const HISTORY_GRACE_SECS: u64 = 60; /// Everything the bot needs to know to connect and behave. #[derive(Debug, Clone)] pub struct Config { + /// Short label for this network, used in logs and as the thread name. Set + /// via an INI-style `[section]` header (or a `name =` key); defaults to the + /// server host. Only one network? It stays out of the logs entirely. + pub name: String, pub server: String, pub port: u16, /// Connect over TLS (typically port 6697) instead of plaintext. @@ -56,6 +60,7 @@ pub struct Config { impl Default for Config { fn default() -> Config { Config { + name: String::new(), server: "irc.tchatou.fr".to_string(), port: 6667, tls: false, diff --git a/src/irc.rs b/src/irc.rs index e35ff58..a525ed4 100644 --- a/src/irc.rs +++ b/src/irc.rs @@ -197,6 +197,9 @@ impl Stream for T {} /// session — which cannot be cloned into independent halves — work unchanged. pub struct Connection { inner: BufReader>, + /// Prefix prepended to wire logs (`[name] `) so simultaneous networks stay + /// distinguishable. Empty for a single network, keeping its output terse. + log_prefix: String, } impl Connection { @@ -216,7 +219,18 @@ impl Connection { } else { Box::new(tcp) }; - Ok(Connection { inner: BufReader::new(stream) }) + Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() }) + } + + /// Set a short label prepended to this connection's wire logs, so output + /// from multiple simultaneous networks stays distinguishable. An empty + /// label clears it (the terse single-network default). + pub fn set_label(&mut self, label: &str) { + self.log_prefix = if label.is_empty() { + String::new() + } else { + format!("[{label}] ") + }; } /// Read and parse the next message. Returns `Ok(None)` on a clean EOF @@ -231,7 +245,7 @@ impl Connection { if trimmed.is_empty() { continue; } - eprintln!("<< {trimmed}"); + eprintln!("{}<< {trimmed}", self.log_prefix); if let Some(msg) = Message::parse(trimmed) { return Ok(Some(msg)); } @@ -243,7 +257,7 @@ impl Connection { /// any embedded CR/LF are neutralised to prevent command injection. pub fn send_raw(&mut self, line: &str) -> io::Result<()> { let clean = line.replace(['\r', '\n'], " "); - eprintln!(">> {clean}"); + eprintln!("{}>> {clean}", self.log_prefix); let writer = self.inner.get_mut(); write!(writer, "{clean}\r\n")?; writer.flush() diff --git a/src/main.rs b/src/main.rs index 0224a1b..8eec2bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,68 +7,233 @@ //! //! Configuration is layered (see [`load_config`]): built-in defaults, then a //! `key = value` config file, then environment variables — so the same binary -//! can target any network without a rebuild. +//! can target any network without a rebuild. The file may define several +//! `[network]` sections, in which case the bot connects to all of them at once, +//! one supervisor thread each. mod bot; mod irc; use std::fs; -use std::io; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::thread; use std::time::Duration; use bot::{Bot, Config}; use irc::Connection; fn main() { - let config = load_config(); + let configs = load_config(); + if configs.is_empty() { + log("no networks configured; nothing to do"); + std::process::exit(1); + } + + let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect(); log(&format!( - "starting rustbot as {} -> {}:{}", - config.nick, config.server, config.port + "starting rustbot across {} network(s): {}", + configs.len(), + names.join(", ") + )); + + // One supervisor thread per network. Each keeps its own connection alive + // with independent reconnect/backoff, so a failure on one network never + // disturbs the others. Labels appear in the logs only when there is more + // than one network, keeping single-network output terse and unchanged. + let labelled = configs.len() > 1; + let mut handles = Vec::new(); + for config in configs { + let name = config.name.clone(); + let handle = thread::Builder::new() + .name(name.clone()) + .spawn(move || supervise(config, labelled)) + .unwrap_or_else(|e| panic!("failed to spawn thread for {name}: {e}")); + handles.push(handle); + } + for handle in handles { + let _ = handle.join(); + } +} + +/// Keep one network's bot alive across disconnects, with capped exponential +/// backoff. Runs until the process is killed. When `labelled`, log lines are +/// tagged with the network name so multiple networks stay distinguishable. +fn supervise(config: Config, labelled: bool) { + let tag = if labelled { + format!("[{}] ", config.name) + } else { + String::new() + }; + log(&format!( + "{tag}connecting as {} -> {}:{} (tls={})", + config.nick, config.server, config.port, config.tls )); - // Supervisor: keep the bot alive across disconnects with capped backoff. let mut backoff = 1u64; loop { - match run_once(&config) { + match run_once(&config, labelled) { Ok(()) => { - log("server closed the connection; reconnecting shortly"); + log(&format!("{tag}server closed the connection; reconnecting shortly")); backoff = 1; } - Err(e) => log(&format!("session error: {e}; retrying in {backoff}s")), + Err(e) => log(&format!("{tag}session error: {e}; retrying in {backoff}s")), } - std::thread::sleep(Duration::from_secs(backoff)); + thread::sleep(Duration::from_secs(backoff)); backoff = (backoff * 2).min(60); } } -/// Run a single connection lifecycle: connect, register, loop until disconnect. -fn run_once(config: &Config) -> std::io::Result<()> { - let conn = Connection::connect(&config.server, config.port, config.tls)?; +/// Run a single connection lifecycle for one network: connect, register, loop +/// until disconnect. +fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> { + let mut conn = Connection::connect(&config.server, config.port, config.tls)?; + if labelled { + conn.set_label(&config.name); + } let mut bot = Bot::new(config.clone(), conn); bot.run() } -/// Build the [`Config`] by layering sources in increasing priority: -/// built-in defaults, then a config file, then environment variables. -fn load_config() -> Config { - let mut c = Config::default(); +/// Build one [`Config`] per network by layering sources. +/// +/// The config file is INI-flavoured: `key = value` lines, optionally grouped +/// under `[network]` section headers. Keys *before* the first header are shared +/// defaults; each `[section]` defines one network that inherits those defaults +/// and overrides them. A file with **no** sections yields a single network — +/// exactly the historical behaviour. +/// +/// Precedence within a network: built-in defaults < shared file defaults < +/// environment variables < that network's section. +fn load_config() -> Vec { + let path = config_path(); + let content = path.as_ref().and_then(|p| match fs::read_to_string(p) { + Ok(s) => { + log(&format!("loaded config from {}", p.display())); + Some(s) + } + Err(e) => { + log(&format!("could not read config {}: {e}", p.display())); + None + } + }); - if let Some(path) = config_path() { - match apply_config_file(&mut c, &path) { - Ok(()) => log(&format!("loaded config from {}", path.display())), - Err(e) => log(&format!("could not read config {}: {e}", path.display())), + let (toplevel, sections) = match &content { + Some(text) => parse_sections(text), + None => (Vec::new(), Vec::new()), + }; + + // Shared defaults: built-ins, then top-level keys, then environment. Each + // network is derived from this base. + let mut base = Config::default(); + for (line, key, value) in &toplevel { + apply_kv(&mut base, key, value, &loc(&path, *line)); + } + apply_env(&mut base); + + assemble(base, sections, &path) +} + +/// Derive one finished [`Config`] per network from the shared `base` and the +/// parsed sections. With no sections, `base` itself is the single network. +/// `path` is used only to describe locations in warnings. +fn assemble(base: Config, sections: Vec<(String, Vec)>, path: &Option) -> Vec { + let mut configs = Vec::new(); + if sections.is_empty() { + let mut c = base; + finalize(&mut c); + configs.push(c); + } else { + for (name, entries) in sections { + let mut c = base.clone(); + c.name = name; + for (line, key, value) in &entries { + apply_kv(&mut c, key, value, &loc(path, *line)); + } + finalize(&mut c); + configs.push(c); } } - apply_env(&mut c); + configs +} - // Convention: TLS listens on 6697. If TLS is enabled but the port is still - // the plaintext default, switch to the conventional TLS port. +/// Per-network finishing touches: default the label to the server host, and +/// honour the TLS-port convention (6697) when TLS is on but the port is still +/// the plaintext default. +fn finalize(c: &mut Config) { + if c.name.is_empty() { + c.name = c.server.clone(); + } if c.tls && c.port == 6667 { c.port = 6697; } +} - c +/// A parsed `key = value` pair with its 1-based source line, for diagnostics. +type Entry = (usize, String, String); + +/// Split config text into shared top-level entries and `[name]` sections. +/// +/// Blank lines and whole-line `#`/`;` comments are ignored. A `[name]` line +/// opens a section; entries after it belong to that network until the next +/// header. Lines that are neither a header nor `key = value` are reported and +/// skipped. +fn parse_sections(content: &str) -> (Vec, Vec<(String, Vec)>) { + let mut toplevel: Vec = Vec::new(); + let mut sections: Vec<(String, Vec)> = Vec::new(); + + for (i, raw) in content.lines().enumerate() { + let line = raw.trim(); + let lineno = i + 1; + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + // Section header: `[name]`. + if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + sections.push((inner.trim().to_string(), Vec::new())); + continue; + } + let Some((key, value)) = line.split_once('=') else { + log(&format!("line {lineno}: ignoring line without '='")); + continue; + }; + let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string()); + match sections.last_mut() { + Some((_, entries)) => entries.push(entry), + None => toplevel.push(entry), + } + } + (toplevel, sections) +} + +/// Apply a single `key = value` pair onto `c`. `where_` describes the source +/// location for warnings (e.g. `rustbot.conf:12`). +fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) { + match key { + "name" => c.name = value.to_string(), + "server" => c.server = value.to_string(), + "port" => match value.parse() { + Ok(p) => c.port = p, + Err(_) => log(&format!("{where_}: invalid port '{value}'")), + }, + "tls" => c.tls = parse_bool(value), + "nick" => c.nick = value.to_string(), + "user" => c.user = value.to_string(), + "realname" => c.realname = value.to_string(), + "channels" => c.channels = split_list(value), + "prefix" | "command_prefix" => c.command_prefix = value.to_string(), + "password" => c.password = (!value.is_empty()).then(|| value.to_string()), + "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), + "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), + other => log(&format!("{where_}: unknown key '{other}'")), + } +} + +/// Format a source location for a top-level line, including the file path. +fn loc(path: &Option, line: usize) -> String { + match path { + Some(p) => format!("{}:{}", p.display(), line), + None => format!("line {line}"), + } } /// Decide which config file to read: CLI argument, then `RUSTBOT_CONFIG`, then @@ -84,49 +249,12 @@ fn config_path() -> Option { default.exists().then_some(default) } -/// Parse a `key = value` config file onto `c`. +/// Overlay environment variables onto `c` (applied to the shared defaults, so +/// they affect every network unless a `[section]` overrides them). /// -/// Blank lines are ignored. A line whose first non-space character is `#` or -/// `;` is a comment — note that inline comments are *not* supported, because -/// IRC channel names legitimately contain `#`. -fn apply_config_file(c: &mut Config, path: &Path) -> io::Result<()> { - let content = fs::read_to_string(path)?; - for (i, raw) in content.lines().enumerate() { - let line = raw.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with(';') { - continue; - } - let Some((key, value)) = line.split_once('=') else { - log(&format!("{}:{}: ignoring line without '='", path.display(), i + 1)); - continue; - }; - let (key, value) = (key.trim().to_ascii_lowercase(), value.trim()); - match key.as_str() { - "server" => c.server = value.to_string(), - "port" => match value.parse() { - Ok(p) => c.port = p, - Err(_) => log(&format!("{}:{}: invalid port '{value}'", path.display(), i + 1)), - }, - "tls" => c.tls = parse_bool(value), - "nick" => c.nick = value.to_string(), - "user" => c.user = value.to_string(), - "realname" => c.realname = value.to_string(), - "channels" => c.channels = split_list(value), - "prefix" | "command_prefix" => c.command_prefix = value.to_string(), - "password" => c.password = (!value.is_empty()).then(|| value.to_string()), - "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), - "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), - other => log(&format!("{}:{}: unknown key '{other}'", path.display(), i + 1)), - } - } - Ok(()) -} - -/// Overlay environment variables onto `c` (highest priority — handy for -/// one-off overrides without editing the file). -/// -/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`, -/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`. +/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`, +/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`, +/// `IRC_SASL_USER`, `IRC_SASL_PASS`. fn apply_env(c: &mut Config) { if let Ok(v) = std::env::var("IRC_SERVER") { c.server = v; @@ -182,3 +310,71 @@ fn split_list(value: &str) -> Vec { fn log(msg: &str) { eprintln!("[rustbot] {msg}"); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build the network list from config text, mirroring [`load_config`] but + /// without touching the filesystem or environment (so tests are hermetic). + fn build(content: &str) -> Vec { + let (toplevel, sections) = parse_sections(content); + let mut base = Config::default(); + for (line, key, value) in &toplevel { + apply_kv(&mut base, key, value, &format!("line {line}")); + } + assemble(base, sections, &None) + } + + #[test] + fn flat_config_is_a_single_network() { + let cfgs = build("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n"); + assert_eq!(cfgs.len(), 1); + assert_eq!(cfgs[0].server, "irc.example.org"); + assert_eq!(cfgs[0].nick, "flatbot"); + assert_eq!(cfgs[0].channels, vec!["#a", "#b"]); + // The label defaults to the server host. + assert_eq!(cfgs[0].name, "irc.example.org"); + } + + #[test] + fn sections_inherit_top_level_defaults_and_override() { + let cfgs = build( + "nick = shared\nchannels = #lobby\n\ + [libera]\nserver = irc.libera.chat\ntls = true\n\ + [oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n", + ); + assert_eq!(cfgs.len(), 2); + + let libera = &cfgs[0]; + assert_eq!(libera.name, "libera"); + assert_eq!(libera.server, "irc.libera.chat"); + assert_eq!(libera.nick, "shared"); // inherited + assert_eq!(libera.channels, vec!["#lobby"]); // inherited + assert!(libera.tls); + assert_eq!(libera.port, 6697); // TLS-port convention applied per network + + let oftc = &cfgs[1]; + assert_eq!(oftc.name, "oftc"); + assert_eq!(oftc.server, "irc.oftc.net"); + assert_eq!(oftc.nick, "other"); // overridden + assert_eq!(oftc.channels, vec!["#oftc"]); // overridden + assert!(!oftc.tls); // default + } + + #[test] + fn a_section_may_rename_itself() { + let cfgs = build("[net]\nname = pretty\nserver = irc.example.org\n"); + assert_eq!(cfgs.len(), 1); + assert_eq!(cfgs[0].name, "pretty"); + } + + #[test] + fn comments_and_blanks_are_ignored() { + let (toplevel, sections) = parse_sections("# comment\n; also\n\n \nserver = x\n"); + assert!(sections.is_empty()); + assert_eq!(toplevel.len(), 1); + assert_eq!(toplevel[0].1, "server"); + assert_eq!(toplevel[0].2, "x"); + } +}