From ef80b77760586fb855441325a8a35898ce9d7217 Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 28 Jul 2026 14:57:44 +0000 Subject: [PATCH] Add TLS transport support Connect over TLS (default port 6697) using native-tls with the system OpenSSL trust store for certificate verification. Unify the plaintext and TLS transports behind a single Box, dropping the read/write socket clone that a TLS session cannot support. Add a `tls` config key and IRC_TLS override; the port auto-defaults to 6697 when TLS is enabled. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 7 +++++-- README.md | 23 +++++++++++++++++------ src/bot.rs | 3 +++ src/irc.rs | 53 ++++++++++++++++++++++++++++++++++------------------- src/main.rs | 20 +++++++++++++++++++- 5 files changed, 78 insertions(+), 28 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2d66b3c..8ca62a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ authors = ["a.srenov"] name = "rustbot" path = "src/main.rs" -# Intentionally dependency-free. The IRC protocol layer lives in `src/irc.rs` -# and follows the modern IRC client spec: https://modern.ircdocs.horse/ +# The IRC protocol layer is hand-rolled in `src/irc.rs` (modern IRC client +# spec: https://modern.ircdocs.horse/). The sole dependency is native-tls for +# the optional TLS transport; it wraps the system OpenSSL, so TLS security +# fixes arrive through normal OS updates rather than a vendored crypto crate. [dependencies] +native-tls = "0.2" [profile.release] lto = true diff --git a/README.md b/README.md index 4df5d4a..2715f92 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,13 @@ cargo run -- /etc/rustbot.conf # explicit path RUSTBOT_CONFIG=/etc/rustbot.conf cargo run ``` -Config file keys: `server`, `port`, `nick`, `user`, `realname`, -`channels` (comma/space-separated), `prefix`, `password`. +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. -Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`, -`IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`. +Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, +`IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`, +`IRC_SASL_USER`, `IRC_SASL_PASS`. ```sh IRC_NICK=mybot IRC_CHANNELS='#test' cargo run # override the file for one run @@ -101,9 +102,19 @@ Two of these do real work: `sasl_user`/`sasl_pass` in the config (or `IRC_SASL_USER`/`IRC_SASL_PASS`). base64 is hand-rolled, so this stays dependency-free. +## TLS + +Set `tls = true` and the port defaults to 6697. The transport wraps the socket +with `native-tls`, which verifies certificates against the **system OpenSSL +trust store** — so TLS security fixes arrive via OS updates rather than a +vendored crypto crate. Plaintext still works with `tls = false` / `port = 6667`. + +Plain TCP and TLS share one code path: `Connection` holds a single +`Box`, since the single-threaded event loop never needs the +old read/write socket split (which TLS can't do anyway). + ## Roadmap / natural next steps -- **TLS** (port 6697, or the `tls` STARTTLS cap) — the one remaining item that - needs a dependency (`rustls`/`native-tls`). - **Read timeout + client-sent PING** to detect dead connections faster. +- **STARTTLS** (the `tls` cap) for networks without a dedicated TLS port. - Split commands into a registry/trait once there are many of them. diff --git a/src/bot.rs b/src/bot.rs index 2cdf7fa..929fdb9 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -37,6 +37,8 @@ const HISTORY_GRACE_SECS: u64 = 60; pub struct Config { pub server: String, pub port: u16, + /// Connect over TLS (typically port 6697) instead of plaintext. + pub tls: bool, pub nick: String, pub user: String, pub realname: String, @@ -56,6 +58,7 @@ impl Default for Config { Config { server: "irc.tchatou.fr".to_string(), port: 6667, + tls: false, nick: "rubot".to_string(), user: "rubot".to_string(), realname: "rubot".to_string(), diff --git a/src/irc.rs b/src/irc.rs index 33f3c7e..e35ff58 100644 --- a/src/irc.rs +++ b/src/irc.rs @@ -13,10 +13,12 @@ #![allow(dead_code)] // This is a library-style module; not every helper is used yet. use std::collections::HashMap; -use std::io::{self, BufRead, BufReader, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::TcpStream; use std::time::{SystemTime, UNIX_EPOCH}; +use native_tls::TlsConnector; + /// The source of a message: `nick!user@host`, or a bare nick / server name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Prefix { @@ -182,27 +184,39 @@ fn unescape_tag_value(value: &str) -> String { out } -/// A blocking IRC connection over plain TCP. +/// Any bidirectional byte stream: a plain `TcpStream` or a TLS session. The +/// blanket impl covers both, so [`Connection`] can treat them uniformly. +trait Stream: Read + Write {} +impl Stream for T {} + +/// A blocking IRC connection over plain TCP or TLS. /// -/// Reading and writing use separate clones of the same socket, so the event -/// loop can block on `read_message` while senders write independently. +/// The event loop is single-threaded (read a message, handle it, maybe write a +/// reply, repeat), so one stream serves both directions: reads go through the +/// `BufReader`, writes through `get_mut()`. This is what lets a single TLS +/// session — which cannot be cloned into independent halves — work unchanged. pub struct Connection { - reader: BufReader, - writer: TcpStream, + inner: BufReader>, } impl Connection { - /// Open a plaintext TCP connection to `host:port`. + /// Connect to `host:port`, optionally wrapping the socket in TLS. /// - /// Note: this is cleartext (typically port 6667). TLS (port 6697) would be a - /// natural next step by wrapping the stream in `rustls`/`native-tls`. - pub fn connect(host: &str, port: u16) -> io::Result { - let stream = TcpStream::connect((host, port))?; - let writer = stream.try_clone()?; - Ok(Connection { - reader: BufReader::new(stream), - writer, - }) + /// TLS uses the system trust store (via `native-tls`/OpenSSL), with `host` + /// supplied as the SNI name and for certificate verification. + pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result { + let tcp = TcpStream::connect((host, port))?; + let stream: Box = if use_tls { + let connector = + TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?; + let tls = connector + .connect(host, tcp) + .map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?; + Box::new(tls) + } else { + Box::new(tcp) + }; + Ok(Connection { inner: BufReader::new(stream) }) } /// Read and parse the next message. Returns `Ok(None)` on a clean EOF @@ -210,7 +224,7 @@ impl Connection { pub fn read_message(&mut self) -> io::Result> { loop { let mut line = String::new(); - if self.reader.read_line(&mut line)? == 0 { + if self.inner.read_line(&mut line)? == 0 { return Ok(None); // EOF } let trimmed = line.trim_end_matches(['\r', '\n']); @@ -230,8 +244,9 @@ impl Connection { pub fn send_raw(&mut self, line: &str) -> io::Result<()> { let clean = line.replace(['\r', '\n'], " "); eprintln!(">> {clean}"); - write!(self.writer, "{clean}\r\n")?; - self.writer.flush() + let writer = self.inner.get_mut(); + write!(writer, "{clean}\r\n")?; + writer.flush() } // --- Convenience senders ------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 8ea097f..0224a1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,7 @@ fn main() { /// 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)?; + let conn = Connection::connect(&config.server, config.port, config.tls)?; let mut bot = Bot::new(config.clone(), conn); bot.run() } @@ -62,6 +62,12 @@ fn load_config() -> Config { } apply_env(&mut c); + // Convention: TLS listens on 6697. If TLS is enabled but the port is still + // the plaintext default, switch to the conventional TLS port. + if c.tls && c.port == 6667 { + c.port = 6697; + } + c } @@ -101,6 +107,7 @@ fn apply_config_file(c: &mut Config, path: &Path) -> io::Result<()> { 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(), @@ -127,6 +134,9 @@ fn apply_env(c: &mut Config) { if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) { c.port = p; } + if let Ok(v) = std::env::var("IRC_TLS") { + c.tls = parse_bool(&v); + } if let Ok(v) = std::env::var("IRC_NICK") { c.user = v.clone(); c.nick = v; @@ -151,6 +161,14 @@ fn apply_env(c: &mut Config) { } } +/// Parse a boolean config value. Accepts `1`, `true`, `yes`, `on` (any case). +fn parse_bool(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + /// Split a channel list on commas and/or whitespace, dropping empties. fn split_list(value: &str) -> Vec { value