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<dyn Read + Write>, 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.
This commit is contained in:
Jean Chevronnet 2026-07-28 14:57:44 +00:00
parent 05c004d32d
commit ab02a5436e
5 changed files with 78 additions and 28 deletions

View file

@ -10,9 +10,12 @@ authors = ["a.srenov"]
name = "rustbot" name = "rustbot"
path = "src/main.rs" path = "src/main.rs"
# Intentionally dependency-free. The IRC protocol layer lives in `src/irc.rs` # The IRC protocol layer is hand-rolled in `src/irc.rs` (modern IRC client
# and follows the modern IRC client spec: https://modern.ircdocs.horse/ # 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] [dependencies]
native-tls = "0.2"
[profile.release] [profile.release]
lto = true lto = true

View file

@ -39,12 +39,13 @@ cargo run -- /etc/rustbot.conf # explicit path
RUSTBOT_CONFIG=/etc/rustbot.conf cargo run RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
``` ```
Config file keys: `server`, `port`, `nick`, `user`, `realname`, Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
`channels` (comma/space-separated), `prefix`, `password`. `channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, `sasl_pass`.
Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s.
Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`, Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`,
`IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`. `IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`,
`IRC_SASL_USER`, `IRC_SASL_PASS`.
```sh ```sh
IRC_NICK=mybot IRC_CHANNELS='#test' cargo run # override the file for one run 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`). `sasl_user`/`sasl_pass` in the config (or `IRC_SASL_USER`/`IRC_SASL_PASS`).
base64 is hand-rolled, so this stays dependency-free. 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<dyn Read + Write>`, since the single-threaded event loop never needs the
old read/write socket split (which TLS can't do anyway).
## Roadmap / natural next steps ## 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. - **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. - Split commands into a registry/trait once there are many of them.

View file

@ -37,6 +37,8 @@ const HISTORY_GRACE_SECS: u64 = 60;
pub struct Config { pub struct Config {
pub server: String, pub server: String,
pub port: u16, pub port: u16,
/// Connect over TLS (typically port 6697) instead of plaintext.
pub tls: bool,
pub nick: String, pub nick: String,
pub user: String, pub user: String,
pub realname: String, pub realname: String,
@ -56,6 +58,7 @@ impl Default for Config {
Config { Config {
server: "irc.tchatou.fr".to_string(), server: "irc.tchatou.fr".to_string(),
port: 6667, port: 6667,
tls: false,
nick: "rubot".to_string(), nick: "rubot".to_string(),
user: "rubot".to_string(), user: "rubot".to_string(),
realname: "rubot".to_string(), realname: "rubot".to_string(),

View file

@ -13,10 +13,12 @@
#![allow(dead_code)] // This is a library-style module; not every helper is used yet. #![allow(dead_code)] // This is a library-style module; not every helper is used yet.
use std::collections::HashMap; 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::net::TcpStream;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use native_tls::TlsConnector;
/// The source of a message: `nick!user@host`, or a bare nick / server name. /// The source of a message: `nick!user@host`, or a bare nick / server name.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prefix { pub struct Prefix {
@ -182,27 +184,39 @@ fn unescape_tag_value(value: &str) -> String {
out 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<T: Read + Write> 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 /// The event loop is single-threaded (read a message, handle it, maybe write a
/// loop can block on `read_message` while senders write independently. /// 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 { pub struct Connection {
reader: BufReader<TcpStream>, inner: BufReader<Box<dyn Stream>>,
writer: TcpStream,
} }
impl Connection { 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 /// TLS uses the system trust store (via `native-tls`/OpenSSL), with `host`
/// natural next step by wrapping the stream in `rustls`/`native-tls`. /// supplied as the SNI name and for certificate verification.
pub fn connect(host: &str, port: u16) -> io::Result<Connection> { pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result<Connection> {
let stream = TcpStream::connect((host, port))?; let tcp = TcpStream::connect((host, port))?;
let writer = stream.try_clone()?; let stream: Box<dyn Stream> = if use_tls {
Ok(Connection { let connector =
reader: BufReader::new(stream), TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?;
writer, 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 /// 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<Option<Message>> { pub fn read_message(&mut self) -> io::Result<Option<Message>> {
loop { loop {
let mut line = String::new(); 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 return Ok(None); // EOF
} }
let trimmed = line.trim_end_matches(['\r', '\n']); let trimmed = line.trim_end_matches(['\r', '\n']);
@ -230,8 +244,9 @@ impl Connection {
pub fn send_raw(&mut self, line: &str) -> io::Result<()> { pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
let clean = line.replace(['\r', '\n'], " "); let clean = line.replace(['\r', '\n'], " ");
eprintln!(">> {clean}"); eprintln!(">> {clean}");
write!(self.writer, "{clean}\r\n")?; let writer = self.inner.get_mut();
self.writer.flush() write!(writer, "{clean}\r\n")?;
writer.flush()
} }
// --- Convenience senders ------------------------------------------------- // --- Convenience senders -------------------------------------------------

View file

@ -44,7 +44,7 @@ fn main() {
/// Run a single connection lifecycle: connect, register, loop until disconnect. /// Run a single connection lifecycle: connect, register, loop until disconnect.
fn run_once(config: &Config) -> std::io::Result<()> { 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); let mut bot = Bot::new(config.clone(), conn);
bot.run() bot.run()
} }
@ -62,6 +62,12 @@ fn load_config() -> Config {
} }
apply_env(&mut c); 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 c
} }
@ -101,6 +107,7 @@ fn apply_config_file(c: &mut Config, path: &Path) -> io::Result<()> {
Ok(p) => c.port = p, Ok(p) => c.port = p,
Err(_) => log(&format!("{}:{}: invalid port '{value}'", path.display(), i + 1)), Err(_) => log(&format!("{}:{}: invalid port '{value}'", path.display(), i + 1)),
}, },
"tls" => c.tls = parse_bool(value),
"nick" => c.nick = value.to_string(), "nick" => c.nick = value.to_string(),
"user" => c.user = value.to_string(), "user" => c.user = value.to_string(),
"realname" => c.realname = 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()) { if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) {
c.port = p; 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") { if let Ok(v) = std::env::var("IRC_NICK") {
c.user = v.clone(); c.user = v.clone();
c.nick = v; 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. /// Split a channel list on commas and/or whitespace, dropping empties.
fn split_list(value: &str) -> Vec<String> { fn split_list(value: &str) -> Vec<String> {
value value