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. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
05c004d32d
commit
ef80b77760
5 changed files with 78 additions and 28 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
53
src/irc.rs
53
src/irc.rs
|
|
@ -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 -------------------------------------------------
|
||||||
|
|
|
||||||
20
src/main.rs
20
src/main.rs
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue