tls: reload certs on REHASH (renewed cert applies without restart) and add per-hostname SNI via tls_sni

This commit is contained in:
Jean Chevronnet 2026-08-16 23:35:36 +00:00
parent a6293d5bbc
commit 471fc00dfc
4 changed files with 129 additions and 22 deletions

View file

@ -125,10 +125,31 @@ fn main() {
// optional TLS listeners (bind_tls, repeatable + tls_cert + tls_key). A cert/bind // optional TLS listeners (bind_tls, repeatable + tls_cert + tls_key). A cert/bind
// problem disables TLS but never takes the plaintext listeners down. // problem disables TLS but never takes the plaintext listeners down.
if !cfg.bind_tls.is_empty() { if !cfg.bind_tls.is_empty() {
// per-hostname SNI certs: `tls_sni = <hostname> <cert> <key>` (repeatable)
let sni: Vec<(String, String, String)> = cfg
.raw
.get("tls_sni")
.map(|v| {
v.iter()
.filter_map(|line| {
let mut it = line.split_whitespace();
match (it.next(), it.next(), it.next()) {
(Some(h), Some(c), Some(k)) => {
Some((h.to_string(), c.to_string(), k.to_string()))
}
_ => None,
}
})
.collect()
})
.unwrap_or_default();
match (&cfg.tls_cert, &cfg.tls_key) { match (&cfg.tls_cert, &cfg.tls_key) {
(Some(cert), Some(key)) => match OpensslBackend::new(cert, key) { (Some(cert), Some(key)) => match OpensslBackend::new(cert, key, sni) {
Ok(backend) => { Ok(backend) => {
let backend: Arc<dyn TlsBackend> = Arc::new(backend); let backend = Arc::new(backend);
// publish for REHASH-triggered cert reload
let _ = echoircd::tls::TLS_RELOAD.set(backend.clone());
let backend: Arc<dyn TlsBackend> = backend;
for bind_tls in &cfg.bind_tls { for bind_tls in &cfg.bind_tls {
match TcpListener::bind(bind_tls) { match TcpListener::bind(bind_tls) {
Ok(tls_listener) => { Ok(tls_listener) => {

View file

@ -282,6 +282,13 @@ impl Server {
srv.silent_service = silent_service; srv.silent_service = silent_service;
} }
} }
// reload TLS certs from disk so a renewed cert applies without a restart
// (no-op when TLS isn't configured).
if let Some(r) = crate::tls::TLS_RELOAD.get() {
if let Err(e) = r.reload() {
eprintln!("[rehash] TLS cert reload failed: {e}");
}
}
} }
/// Remember an identity for WHOWAS (capped ring, newest first). /// Remember an identity for WHOWAS (capped ring, newest first).

View file

@ -5,16 +5,30 @@
//! This backend is openssl. An alternative backend (e.g. rustls) only has to //! This backend is openssl. An alternative backend (e.g. rustls) only has to
//! implement these same two traits and it slots straight in. //! implement these same two traits and it slots straight in.
use std::collections::HashMap;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream}; use std::net::{Shutdown, TcpStream};
use std::sync::{Arc, OnceLock, RwLock};
use std::time::Duration; use std::time::Duration;
use mio::net::TcpStream as MioStream; use mio::net::TcpStream as MioStream;
use openssl::hash::MessageDigest; use openssl::hash::MessageDigest;
use openssl::ssl::{ use openssl::ssl::{
ErrorCode, Ssl, SslAcceptor, SslFiletype, SslMethod, SslMode, SslStream, SslVerifyMode, ErrorCode, NameType, SniError, Ssl, SslAcceptor, SslAcceptorBuilder, SslContext, SslFiletype,
SslMethod, SslMode, SslStream, SslVerifyMode,
}; };
/// A hot-reloadable TLS certificate source (implemented by the openssl backend);
/// the core calls [`reload`](CertReload::reload) on REHASH so a renewed cert is
/// picked up without a restart.
pub trait CertReload: Send + Sync {
fn reload(&self) -> io::Result<()>;
}
/// The process-wide TLS backend, set once at startup so REHASH can trigger a cert
/// reload without threading a handle through the core thread.
pub static TLS_RELOAD: OnceLock<Arc<dyn CertReload>> = OnceLock::new();
/// A live TLS connection: read/write plaintext, tune the read timeout (the /// A live TLS connection: read/write plaintext, tune the read timeout (the
/// socket engine polls with one to interleave reads and queued writes), and shut /// socket engine polls with one to interleave reads and queued writes), and shut
/// it down. The concrete backend type stays hidden behind this. /// it down. The concrete backend type stays hidden behind this.
@ -62,39 +76,104 @@ fn err<E: std::fmt::Display>(e: E) -> io::Error {
// --- openssl backend -------------------------------------------------------- // --- openssl backend --------------------------------------------------------
/// A PEM certificate chain + private key on disk.
struct CertPaths {
cert: String,
key: String,
}
pub struct OpensslBackend { pub struct OpensslBackend {
acceptor: SslAcceptor, // Swapped atomically by `reload` so renewed certs apply without a restart; read
// only at connection-accept time (infrequent), so the lock is never hot.
acceptor: RwLock<SslAcceptor>,
primary: CertPaths,
sni: Vec<(String, CertPaths)>, // hostname -> cert/key (SNI)
}
/// Apply the common server settings to a builder: the cert/key, an always-accept
/// client-cert request (for SASL EXTERNAL / CertFP; we never validate the chain —
/// services match the fingerprint), and the non-blocking write modes the reactor needs.
fn configure(b: &mut SslAcceptorBuilder, cert: &str, key: &str) -> io::Result<()> {
b.set_private_key_file(key, SslFiletype::PEM).map_err(err)?;
b.set_certificate_chain_file(cert).map_err(err)?;
b.check_private_key().map_err(err)?;
b.set_verify_callback(SslVerifyMode::PEER, |_valid, _ctx| true);
b.set_mode(SslMode::ENABLE_PARTIAL_WRITE | SslMode::ACCEPT_MOVING_WRITE_BUFFER);
Ok(())
}
/// A standalone configured context for one SNI hostname.
fn build_ctx(cert: &str, key: &str) -> io::Result<SslContext> {
let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()).map_err(err)?;
configure(&mut b, cert, key)?;
Ok(b.build().into_context())
}
/// Build the acceptor for the primary cert, with a servername callback that
/// switches to a per-hostname context when the client's SNI matches an `sni` entry.
fn build_acceptor(primary: &CertPaths, sni: &[(String, CertPaths)]) -> io::Result<SslAcceptor> {
let mut map: HashMap<String, SslContext> = HashMap::new();
for (host, cp) in sni {
map.insert(host.to_ascii_lowercase(), build_ctx(&cp.cert, &cp.key)?);
}
let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()).map_err(err)?;
configure(&mut b, &primary.cert, &primary.key)?;
if !map.is_empty() {
b.set_servername_callback(move |ssl, _alert| {
if let Some(name) = ssl.servername(NameType::HOST_NAME) {
if let Some(ctx) = map.get(&name.to_ascii_lowercase()) {
ssl.set_ssl_context(ctx).map_err(|_| SniError::ALERT_FATAL)?;
}
}
Ok(())
});
}
Ok(b.build())
} }
impl OpensslBackend { impl OpensslBackend {
/// Build an acceptor from a PEM certificate chain + private key. /// Build an acceptor from a PEM certificate chain + private key, with optional
pub fn new(cert: &str, key: &str) -> io::Result<OpensslBackend> { /// per-hostname SNI certs `(hostname, cert, key)`.
let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()).map_err(err)?; pub fn new(cert: &str, key: &str, sni: Vec<(String, String, String)>) -> io::Result<OpensslBackend> {
b.set_private_key_file(key, SslFiletype::PEM).map_err(err)?; let primary = CertPaths {
b.set_certificate_chain_file(cert).map_err(err)?; cert: cert.to_string(),
b.check_private_key().map_err(err)?; key: key.to_string(),
// Request (but don't require) a client cert so SASL EXTERNAL / CertFP can };
// read its fingerprint. We never validate the chain — services match the let sni: Vec<(String, CertPaths)> = sni
// fingerprint to an account — so the callback always accepts. .into_iter()
b.set_verify_callback(SslVerifyMode::PEER, |_valid, _ctx| true); .map(|(h, c, k)| (h, CertPaths { cert: c, key: k }))
// The reactor drives writes non-blocking and may retry SSL_write with a moved .collect();
// or grown buffer after a WouldBlock; allow that and partial progress so a slow let acceptor = build_acceptor(&primary, &sni)?;
// TLS reader can't wedge a worker.
b.set_mode(SslMode::ENABLE_PARTIAL_WRITE | SslMode::ACCEPT_MOVING_WRITE_BUFFER);
Ok(OpensslBackend { Ok(OpensslBackend {
acceptor: b.build(), acceptor: RwLock::new(acceptor),
primary,
sni,
}) })
} }
/// Rebuild the acceptor from the cert files on disk (renewed certs) and swap it
/// in; existing connections keep the context they handshook with.
pub fn reload(&self) -> io::Result<()> {
let fresh = build_acceptor(&self.primary, &self.sni)?;
*self.acceptor.write().unwrap() = fresh;
Ok(())
}
}
impl CertReload for OpensslBackend {
fn reload(&self) -> io::Result<()> {
OpensslBackend::reload(self)
}
} }
impl TlsBackend for OpensslBackend { impl TlsBackend for OpensslBackend {
fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>> { fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>> {
let stream = self.acceptor.accept(sock).map_err(err)?; let stream = self.acceptor.read().unwrap().accept(sock).map_err(err)?;
Ok(Box::new(OpensslConn(stream))) Ok(Box::new(OpensslConn(stream)))
} }
fn start(&self, sock: MioStream) -> io::Result<Box<dyn TlsSession>> { fn start(&self, sock: MioStream) -> io::Result<Box<dyn TlsSession>> {
let ssl = Ssl::new(self.acceptor.context()).map_err(err)?; let ssl = Ssl::new(self.acceptor.read().unwrap().context()).map_err(err)?;
// handshake isn't driven here: SslStream::new just binds the socket; the // handshake isn't driven here: SslStream::new just binds the socket; the
// reactor calls accept() as the socket becomes readable/writable. // reactor calls accept() as the socket becomes readable/writable.
let stream = SslStream::new(ssl, sock).map_err(err)?; let stream = SslStream::new(ssl, sock).map_err(err)?;

View file

@ -173,7 +173,7 @@ pub fn maybe_start(cfg: &Config, core: Sender<Event>, counter: Arc<AtomicU64>) {
if let Some(bind) = get("bind_wss").map(str::to_string) { if let Some(bind) = get("bind_wss").map(str::to_string) {
match (get("tls_cert"), get("tls_key")) { match (get("tls_cert"), get("tls_key")) {
(Some(cert), Some(key)) => match OpensslBackend::new(cert, key) { (Some(cert), Some(key)) => match OpensslBackend::new(cert, key, Vec::new()) {
Ok(backend) => match TcpListener::bind(&bind) { Ok(backend) => match TcpListener::bind(&bind) {
Ok(l) => { Ok(l) => {
eprintln!("echoircd WebSocket (wss) on {bind} (openssl)"); eprintln!("echoircd WebSocket (wss) on {bind} (openssl)");