socketengine: run direct TLS in the reactor pool — non-blocking handshake + crypto in the worker threads (Sock::Tls, TlsSession), unifying the client I/O model and spreading TLS work across cores; proxied TLS + links keep the thread path

This commit is contained in:
Jean Chevronnet 2026-08-12 14:03:00 +00:00
parent 7c166e3aae
commit 02ae92e16d
3 changed files with 323 additions and 71 deletions

View file

@ -102,6 +102,10 @@ fn main() {
} }
}); });
// reactor worker pool: shared by the plaintext acceptor and the direct-TLS
// acceptor, so client I/O (framing + TLS crypto) spreads across cores.
let reactors = socketengine::spawn_reactors(tx.clone(), max_line, max_sendq, io_threads);
// optional TLS listener (bind_tls + tls_cert + tls_key). A cert/bind problem // optional TLS listener (bind_tls + tls_cert + tls_key). A cert/bind problem
// disables TLS but never takes the plaintext listener down. // disables TLS but never takes the plaintext listener down.
if let (Some(bind_tls), Some(cert), Some(key)) = (&cfg.bind_tls, &cfg.tls_cert, &cfg.tls_key) { if let (Some(bind_tls), Some(cert), Some(key)) = (&cfg.bind_tls, &cfg.tls_cert, &cfg.tls_key) {
@ -113,6 +117,7 @@ fn main() {
let tls_tx = tx.clone(); let tls_tx = tx.clone();
let tls_counter = counter.clone(); let tls_counter = counter.clone();
let tls_proxy_trust = proxy_trust.clone(); let tls_proxy_trust = proxy_trust.clone();
let tls_reactors = reactors.clone();
thread::spawn(move || { thread::spawn(move || {
socketengine::accept_loop( socketengine::accept_loop(
tls_listener, tls_listener,
@ -122,6 +127,7 @@ fn main() {
false, false,
max_line, max_line,
tls_proxy_trust, tls_proxy_trust,
tls_reactors,
) )
}); });
} }
@ -139,7 +145,17 @@ fn main() {
let s_tx = tx.clone(); let s_tx = tx.clone();
let s_counter = counter.clone(); let s_counter = counter.clone();
thread::spawn(move || { thread::spawn(move || {
socketengine::accept_loop(sl, s_tx, None, s_counter, true, max_line, Vec::new()) // links stay on the thread path: no reactor handoff
socketengine::accept_loop(
sl,
s_tx,
None,
s_counter,
true,
max_line,
Vec::new(),
Vec::new(),
)
}); });
} }
Err(e) => eprintln!("echoircd: cannot bind server port {bind_srv}: {e}"), Err(e) => eprintln!("echoircd: cannot bind server port {bind_srv}: {e}"),
@ -163,17 +179,7 @@ fn main() {
}); });
} }
// client plaintext connections: one mio reactor thread drives them all // client plaintext connections: the acceptor round-robins them across the pool
thread::spawn(move || { thread::spawn(move || socketengine::run_acceptor(client_listener, reactors, counter, proxy_trust));
socketengine::run_reactor_pool(
client_listener,
tx,
counter,
max_line,
max_sendq,
proxy_trust,
io_threads,
)
});
let _ = core.join(); let _ = core.join();
} }

View file

@ -1,13 +1,16 @@
//! The socket engine: the I/O edge. Two coexisting models feed the one core: //! The socket engine: the I/O edge. Two coexisting models feed the one core:
//! //!
//! - **Client plaintext** connections run on a **pool of mio epoll reactors** //! - **Client connections** run on a **pool of mio epoll reactors** — acceptors
//! ([`run_reactor_pool`]) — one acceptor round-robins connections across N worker //! ([`run_acceptor`] for plaintext, [`accept_loop`] for TLS) round-robin connections
//! threads (one per core by default), each driving tens of thousands of sockets, so //! across N worker threads ([`spawn_reactors`], one per core by default), each
//! the daemon scales to hundreds of thousands of users without a thread per //! driving tens of thousands of sockets — plaintext and **direct TLS** alike, the
//! connection. The state core stays single-threaded and there is no async runtime; //! handshake and crypto run non-blocking in the worker — so the daemon scales to
//! workers only frame lines and feed it Events, so the parallel I/O needs no locks. //! hundreds of thousands of users without a thread per connection. The state core
//! - **TLS** and **server links** keep a thread per connection (few of them, and //! stays single-threaded and there is no async runtime; workers only frame lines and
//! a TLS session can't be split across reader+writer threads). //! feed it Events, so the parallel I/O (including TLS crypto) needs no locks.
//! - **Proxied TLS** (a PROXY header before the handshake) and **server links** keep a
//! thread per connection: few of them, and the pre-handshake header wants the
//! simpler blocking path.
//! //!
//! Both hand the core the same [`OutSink`] output handle, so the core never //! Both hand the core the same [`OutSink`] output handle, so the core never
//! knows or cares which model a connection uses. //! knows or cares which model a connection uses.
@ -25,7 +28,7 @@ use mio::net::{TcpListener as MioListener, TcpStream as MioStream};
use mio::{Events, Interest, Poll, Token, Waker}; use mio::{Events, Interest, Poll, Token, Waker};
use crate::ircd::Event; use crate::ircd::Event;
use crate::tls::TlsBackend; use crate::tls::{TlsBackend, TlsSession};
use crate::Uid; use crate::Uid;
/// Default recvq: longest single line we'll buffer before dropping it. Overridable /// Default recvq: longest single line we'll buffer before dropping it. Overridable
@ -123,8 +126,38 @@ const LISTENER: Token = Token(0);
const WAKE: Token = Token(1); const WAKE: Token = Token(1);
const FIRST_CONN: usize = 16; // conn tokens start past the reserved ones const FIRST_CONN: usize = 16; // conn tokens start past the reserved ones
/// A reactor connection's socket: a raw plaintext stream, or a non-blocking TLS
/// session driven by the same reactor. Both expose the underlying mio socket for
/// poll registration, so the read/write/backpressure machinery is identical.
enum Sock {
Plain(MioStream),
Tls(Box<dyn TlsSession>),
}
impl Sock {
/// The underlying socket, for poll (re)register/deregister.
fn source(&mut self) -> &mut MioStream {
match self {
Sock::Plain(s) => s,
Sock::Tls(t) => t.source(),
}
}
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
Sock::Plain(s) => s.read(buf),
Sock::Tls(t) => t.read(buf),
}
}
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Sock::Plain(s) => s.write(buf),
Sock::Tls(t) => t.write(buf),
}
}
}
struct Conn { struct Conn {
stream: MioStream, sock: Sock,
uid: Uid, uid: Uid,
addr: SocketAddr, // peer, or the real client once a PROXY header is parsed addr: SocketAddr, // peer, or the real client once a PROXY header is parsed
local_port: u16, // listener port (for the deferred-Connect case) local_port: u16, // listener port (for the deferred-Connect case)
@ -138,6 +171,7 @@ struct Conn {
recvq: usize, // max buffered unterminated-line bytes before dropping recvq: usize, // max buffered unterminated-line bytes before dropping
hardsendq: usize, // max queued output bytes before dropping + closing hardsendq: usize, // max queued output bytes before dropping + closing
softsendq: usize, // queued output above this pauses reads until it drains softsendq: usize, // queued output above this pauses reads until it drains
handshaking: bool, // TLS: still negotiating; hold reads + the Connect until done
proxy_pending: bool, // hold the Connect event until a PROXY header is consumed proxy_pending: bool, // hold the Connect event until a PROXY header is consumed
pending_out: Option<OutSink>, // the OutSink held for that deferred Connect pending_out: Option<OutSink>, // the OutSink held for that deferred Connect
} }
@ -153,7 +187,9 @@ impl Conn {
/// serviced) while keeping WRITABLE to drain the backlog that paused it. /// serviced) while keeping WRITABLE to drain the backlog that paused it.
fn set_interest(poll: &mut Poll, c: &mut Conn, t: usize) { fn set_interest(poll: &mut Poll, c: &mut Conn, t: usize) {
let want_read = !c.paused; let want_read = !c.paused;
let want_write = !c.wbuf.is_empty() || c.paused; // a TLS handshake may need to write (its flight) as well as read, so keep both
// until it completes; after that, write only when there's a backlog to drain.
let want_write = c.handshaking || !c.wbuf.is_empty() || c.paused;
if want_read == c.want_read && want_write == c.want_write { if want_read == c.want_read && want_write == c.want_write {
return; return;
} }
@ -165,7 +201,7 @@ fn set_interest(poll: &mut Poll, c: &mut Conn, t: usize) {
// never both-false (paused ⟹ backlog ⟹ want_write); READABLE is a safe floor // never both-false (paused ⟹ backlog ⟹ want_write); READABLE is a safe floor
_ => Interest::READABLE, _ => Interest::READABLE,
}; };
let _ = poll.registry().reregister(&mut c.stream, Token(t), interest); let _ = poll.registry().reregister(c.sock.source(), Token(t), interest);
} }
/// Largest PROXY header we'll buffer before giving up (v1 ≤ 107, v2 header ≤ ~232). /// Largest PROXY header we'll buffer before giving up (v1 ≤ 107, v2 header ≤ ~232).
@ -178,11 +214,15 @@ struct Accepted {
addr: SocketAddr, addr: SocketAddr,
local_port: u16, local_port: u16,
via_proxy: bool, via_proxy: bool,
tls: Option<Arc<dyn TlsBackend>>, // Some ⇒ the worker negotiates TLS on this socket
} }
/// The acceptor's handle to one reactor worker: its handoff queue and the waker that /// The acceptor's handle to one reactor worker: its handoff queue and the waker that
/// nudges the worker to adopt whatever was queued. /// nudges the worker to adopt whatever was queued. Cloneable so several acceptors
struct ReactorHandle { /// (plaintext + TLS) can share the same pool, each round-robining independently.
/// Opaque to callers — `main` only holds a `Vec` of these and passes it along.
#[derive(Clone)]
pub struct ReactorHandle {
handoff: Sender<Accepted>, handoff: Sender<Accepted>,
waker: Arc<Waker>, waker: Arc<Waker>,
} }
@ -200,34 +240,53 @@ fn resolve_io_threads(io_threads: usize) -> usize {
.clamp(1, 4) .clamp(1, 4)
} }
/// Drive the client plaintext listener with a pool of reactor threads. One acceptor /// Start the reactor worker pool and return the acceptors' handles to it. Sized by
/// (this thread) owns the listener and round-robins each new connection to a worker; /// `io_threads` (0 = auto: one worker per core, capped). Each worker runs its own poll
/// each worker runs its own poll and connection map on its own core. The state core /// and connection map on its own core; the state core stays single-threaded — workers
/// stays single-threaded — workers only frame lines and feed it Events — so the /// only frame lines and feed it Events — so per-connection I/O (plaintext framing and
/// per-connection I/O scales across cores with no shared locking. /// TLS crypto alike) scales across cores with no shared locking.
pub fn run_reactor_pool( pub fn spawn_reactors(
mut listener: MioListener,
core: Sender<Event>, core: Sender<Event>,
counter: Arc<AtomicU64>,
max_line: usize, max_line: usize,
max_sendq: usize, max_sendq: usize,
proxy_trust: Vec<String>,
io_threads: usize, io_threads: usize,
) { ) -> Vec<ReactorHandle> {
let workers = resolve_io_threads(io_threads); let workers = resolve_io_threads(io_threads);
let mut reactors: Vec<ReactorHandle> = Vec::with_capacity(workers); let mut reactors = Vec::with_capacity(workers);
for _ in 0..workers { for _ in 0..workers {
match spawn_reactor(core.clone(), max_line, max_sendq) { match spawn_reactor(core.clone(), max_line, max_sendq) {
Ok(h) => reactors.push(h), Ok(h) => reactors.push(h),
Err(e) => eprintln!("reactor: cannot start a worker: {e}"), Err(e) => eprintln!("reactor: cannot start a worker: {e}"),
} }
} }
eprintln!("echoircd reactor pool: {} worker thread(s)", reactors.len());
reactors
}
/// Round-robin one accepted connection onto a worker and wake it to adopt the conn.
fn dispatch(reactors: &[ReactorHandle], rr: &mut usize, a: Accepted) {
if reactors.is_empty() { if reactors.is_empty() {
eprintln!("reactor: no worker threads started; plaintext clients disabled"); return; // no workers: drop it (a.stream closes on drop)
}
let idx = *rr % reactors.len();
*rr = rr.wrapping_add(1);
if reactors[idx].handoff.send(a).is_ok() {
let _ = reactors[idx].waker.wake();
}
}
/// The plaintext client acceptor: owns the listener and round-robins each new
/// connection onto a reactor worker.
pub fn run_acceptor(
mut listener: MioListener,
reactors: Vec<ReactorHandle>,
counter: Arc<AtomicU64>,
proxy_trust: Vec<String>,
) {
if reactors.is_empty() {
eprintln!("acceptor: no worker threads; plaintext clients disabled");
return; return;
} }
eprintln!("echoircd plaintext reactor pool: {} thread(s)", reactors.len());
let mut poll = match Poll::new() { let mut poll = match Poll::new() {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@ -266,18 +325,18 @@ pub fn run_reactor_pool(
let via_proxy = proxy_trust.iter().any(|g| { let via_proxy = proxy_trust.iter().any(|g| {
crate::modules::connclass::ip_matches(g, &addr.ip().to_string()) crate::modules::connclass::ip_matches(g, &addr.ip().to_string())
}); });
let idx = rr % reactors.len(); dispatch(
rr = rr.wrapping_add(1); &reactors,
let accepted = Accepted { &mut rr,
stream, Accepted {
uid, stream,
addr, uid,
local_port, addr,
via_proxy, local_port,
}; via_proxy,
if reactors[idx].handoff.send(accepted).is_ok() { tls: None,
let _ = reactors[idx].waker.wake(); },
} );
} }
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break, Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(_) => break, Err(_) => break,
@ -338,10 +397,23 @@ fn reactor_loop(
while let Ok(a) = handoff_rx.try_recv() { while let Ok(a) = handoff_rx.try_recv() {
let token = next_token; let token = next_token;
next_token += 1; next_token += 1;
let mut stream = a.stream; // build the socket: a TLS conn negotiates non-blocking in this
// worker; a plaintext one is ready to read immediately.
let (mut sock, handshaking) = match a.tls {
Some(backend) => match backend.start(a.stream) {
Ok(sess) => (Sock::Tls(sess), true),
Err(_) => continue, // couldn't start TLS: drop it
},
None => (Sock::Plain(a.stream), false),
};
let interest = if handshaking {
Interest::READABLE | Interest::WRITABLE
} else {
Interest::READABLE
};
if poll if poll
.registry() .registry()
.register(&mut stream, Token(token), Interest::READABLE) .register(sock.source(), Token(token), interest)
.is_err() .is_err()
{ {
continue; continue;
@ -354,7 +426,7 @@ fn reactor_loop(
conns.insert( conns.insert(
token, token,
Conn { Conn {
stream, sock,
uid: a.uid, uid: a.uid,
addr: a.addr, addr: a.addr,
local_port: a.local_port, local_port: a.local_port,
@ -362,19 +434,20 @@ fn reactor_loop(
wbuf: Vec::new(), wbuf: Vec::new(),
wpos: 0, wpos: 0,
want_read: true, want_read: true,
want_write: false, want_write: handshaking,
closing: false, closing: false,
paused: false, paused: false,
recvq: max_line, recvq: max_line,
hardsendq: max_sendq, hardsendq: max_sendq,
softsendq: max_sendq, softsendq: max_sendq,
handshaking,
proxy_pending: a.via_proxy, proxy_pending: a.via_proxy,
pending_out: Some(out), pending_out: Some(out),
}, },
); );
// non-proxy: announce immediately (a proxy conn is announced // announce now only if nothing defers it: a TLS conn waits for
// from read_conn once its header lands) // its handshake, a proxy conn for its header.
if !a.via_proxy { if !a.via_proxy && !handshaking {
let out = conns.get_mut(&token).and_then(|c| c.pending_out.take()); let out = conns.get_mut(&token).and_then(|c| c.pending_out.take());
if let Some(out) = out { if let Some(out) = out {
if core if core
@ -481,9 +554,70 @@ fn reactor_loop(
} }
} }
/// Drive a pending TLS handshake for `t`. Returns true once the connection is
/// established — its deferred Connect emitted with the peer's cert fingerprint, so
/// normal reads/writes may proceed — and false while it still needs I/O or was closed
/// on a fatal handshake error. A plaintext (or already-established) conn returns true.
fn try_handshake(
poll: &mut Poll,
conns: &mut HashMap<usize, Conn>,
t: usize,
core: &Sender<Event>,
) -> bool {
let mut close = false;
let mut connect: Option<(Uid, SocketAddr, u16, Option<String>, OutSink)> = None;
if let Some(c) = conns.get_mut(&t) {
if !c.handshaking {
return true;
}
if let Sock::Tls(sess) = &mut c.sock {
match sess.accept() {
Ok(true) => {
c.handshaking = false;
let certfp = sess.peer_cert_fp();
connect = c
.pending_out
.take()
.map(|out| (c.uid, c.addr, c.local_port, certfp, out));
set_interest(poll, c, t); // handshake done: drop the extra WRITABLE
}
Ok(false) => return false, // still negotiating
Err(_) => close = true,
}
} else {
c.handshaking = false; // not TLS (shouldn't happen): treat as established
}
} else {
return false;
}
if let Some((uid, addr, local_port, certfp, out)) = connect {
let _ = core.send(Event::Connect {
uid,
addr,
out,
sock: None,
secure: true,
certfp,
local_port,
link: false,
outbound: false,
websocket: false,
});
}
if close {
close_conn(poll, conns, t, core);
return false;
}
true
}
/// Drain readable bytes from `t` (edge-triggered: read until WouldBlock), frame /// Drain readable bytes from `t` (edge-triggered: read until WouldBlock), frame
/// complete lines and forward them to the core; close on EOF/error. /// complete lines and forward them to the core; close on EOF/error.
fn read_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core: &Sender<Event>) { fn read_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core: &Sender<Event>) {
// a TLS conn must finish negotiating before any application bytes flow
if !try_handshake(poll, conns, t, core) {
return;
}
let mut chunk = [0u8; 8192]; let mut chunk = [0u8; 8192];
let mut lines: Vec<(Uid, String)> = Vec::new(); let mut lines: Vec<(Uid, String)> = Vec::new();
// a deferred Connect (PROXY conn) to emit, before any lines from the same read // a deferred Connect (PROXY conn) to emit, before any lines from the same read
@ -491,7 +625,7 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core:
let mut close = false; let mut close = false;
if let Some(c) = conns.get_mut(&t) { if let Some(c) = conns.get_mut(&t) {
loop { loop {
match c.stream.read(&mut chunk) { match c.sock.read(&mut chunk) {
Ok(0) => { Ok(0) => {
close = true; close = true;
break; break;
@ -597,11 +731,15 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core:
/// backlog dropped back under softsendq, un-pause reads and catch up (edge-triggered: /// backlog dropped back under softsendq, un-pause reads and catch up (edge-triggered:
/// data that arrived while paused won't re-fire, so read it here). /// data that arrived while paused won't re-fire, so read it here).
fn flush_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core: &Sender<Event>) { fn flush_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core: &Sender<Event>) {
// a writable event during a TLS handshake advances it, not the (empty) write queue
if !try_handshake(poll, conns, t, core) {
return;
}
let mut close = false; let mut close = false;
let mut unpaused = false; let mut unpaused = false;
if let Some(c) = conns.get_mut(&t) { if let Some(c) = conns.get_mut(&t) {
while c.wpos < c.wbuf.len() { while c.wpos < c.wbuf.len() {
match c.stream.write(&c.wbuf[c.wpos..]) { match c.sock.write(&c.wbuf[c.wpos..]) {
Ok(0) => break, Ok(0) => break,
Ok(n) => c.wpos += n, Ok(n) => c.wpos += n,
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break, Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
@ -635,11 +773,12 @@ fn flush_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core:
/// Deregister + drop `t`'s socket and tell the core the connection is gone. /// Deregister + drop `t`'s socket and tell the core the connection is gone.
fn close_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core: &Sender<Event>) { fn close_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core: &Sender<Event>) {
if let Some(mut c) = conns.remove(&t) { if let Some(mut c) = conns.remove(&t) {
let _ = poll.registry().deregister(&mut c.stream); let _ = poll.registry().deregister(c.sock.source());
let uid = c.uid; let uid = c.uid;
// a still-pending PROXY conn was never announced to the core, so don't tell // a conn whose Connect was never emitted — a still-pending PROXY header or an
// it about a disconnect for a uid it never saw // unfinished TLS handshake — must not send the core a Disconnect for a uid it
let announced = !c.proxy_pending; // never saw
let announced = !c.proxy_pending && !c.handshaking;
drop(c); // closes the socket drop(c); // closes the socket
if announced { if announced {
let _ = core.send(Event::Disconnect { uid }); let _ = core.send(Event::Disconnect { uid });
@ -649,9 +788,11 @@ fn close_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core:
// === thread model: TLS + server links ======================================== // === thread model: TLS + server links ========================================
/// Accept forever on a thread-per-connection listener (TLS or S2S). `tls` is the /// Accept forever on a listener (TLS or S2S). `tls` is the backend to wrap sockets in
/// backend to wrap sockets in (None ⇒ plaintext link). `counter` is shared with /// (None ⇒ plaintext link). `counter` is shared with the reactor so uids stay unique
/// the reactor so uids stay unique across every listener. /// across every listener. `reactors` is the worker pool: a direct (non-proxy) TLS
/// client is handed off to it to negotiate non-blocking; a proxied TLS client (PROXY
/// header before the handshake) and every server link keep the thread path.
pub fn accept_loop( pub fn accept_loop(
listener: TcpListener, listener: TcpListener,
core: Sender<Event>, core: Sender<Event>,
@ -660,7 +801,9 @@ pub fn accept_loop(
link: bool, link: bool,
max_line: usize, max_line: usize,
proxy_trust: Vec<String>, proxy_trust: Vec<String>,
reactors: Vec<ReactorHandle>,
) { ) {
let mut rr: usize = 0;
for conn in listener.incoming() { for conn in listener.incoming() {
let Ok(stream) = conn else { continue }; let Ok(stream) = conn else { continue };
let Ok(addr) = stream.peer_addr() else { let Ok(addr) = stream.peer_addr() else {
@ -701,6 +844,27 @@ pub fn accept_loop(
thread::spawn(move || reader_loop(reader, uid, core_tx, max_line)); thread::spawn(move || reader_loop(reader, uid, core_tx, max_line));
} }
Some(backend) => { Some(backend) => {
let via_proxy = proxy_trust
.iter()
.any(|g| crate::modules::connclass::ip_matches(g, &addr.ip().to_string()));
// direct TLS clients negotiate in the reactor pool (non-blocking, one
// worker per core); a proxied client keeps the thread path so its
// plaintext PROXY header is read before the handshake.
if !via_proxy && !reactors.is_empty() && stream.set_nonblocking(true).is_ok() {
dispatch(
&reactors,
&mut rr,
Accepted {
stream: MioStream::from_std(stream),
uid,
addr,
local_port,
via_proxy: false,
tls: Some(backend.clone()),
},
);
continue;
}
let backend = backend.clone(); let backend = backend.clone();
let core_tx = core.clone(); let core_tx = core.clone();
let pt = proxy_trust.clone(); let pt = proxy_trust.clone();

View file

@ -9,8 +9,11 @@ use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream}; use std::net::{Shutdown, TcpStream};
use std::time::Duration; use std::time::Duration;
use mio::net::TcpStream as MioStream;
use openssl::hash::MessageDigest; use openssl::hash::MessageDigest;
use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslStream, SslVerifyMode}; use openssl::ssl::{
ErrorCode, Ssl, SslAcceptor, SslFiletype, SslMethod, SslMode, SslStream, SslVerifyMode,
};
/// 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
@ -26,9 +29,31 @@ pub trait TlsConn: Send {
fn peer_cert_fp(&self) -> Option<String>; fn peer_cert_fp(&self) -> Option<String>;
} }
/// A TLS backend: performs the server-side handshake on an accepted socket. /// A non-blocking TLS session the reactor drives itself over a mio socket. The
/// handshake and all reads/writes surface `WouldBlock` (mapped from OpenSSL's
/// WANT_READ/WANT_WRITE) so the worker can register interest and come back later
/// instead of blocking a whole thread on one connection.
pub trait TlsSession: Send {
/// Drive the server handshake: `Ok(true)` once complete, `Ok(false)` while it
/// still needs I/O, `Err` on a fatal handshake failure.
fn accept(&mut self) -> io::Result<bool>;
/// Decrypt application data. `Ok(0)` means the peer sent a clean TLS close.
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
/// Encrypt+queue application data; returns the plaintext bytes accepted.
fn write(&mut self, buf: &[u8]) -> io::Result<usize>;
/// The underlying mio socket, for the reactor's poll (re)registration.
fn source(&mut self) -> &mut MioStream;
/// SHA-256 fingerprint of the peer certificate (CertFP / SASL EXTERNAL), if any.
fn peer_cert_fp(&self) -> Option<String>;
fn shutdown(&mut self);
}
/// A TLS backend: wraps an accepted socket in a TLS session — either blocking
/// ([`accept`], the thread-per-connection path) or non-blocking ([`start`], the
/// reactor path).
pub trait TlsBackend: Send + Sync { pub trait TlsBackend: Send + Sync {
fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>>; fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>>;
fn start(&self, sock: MioStream) -> io::Result<Box<dyn TlsSession>>;
} }
fn err<E: std::fmt::Display>(e: E) -> io::Error { fn err<E: std::fmt::Display>(e: E) -> io::Error {
@ -52,6 +77,10 @@ impl OpensslBackend {
// read its fingerprint. We never validate the chain — services match the // read its fingerprint. We never validate the chain — services match the
// fingerprint to an account — so the callback always accepts. // fingerprint to an account — so the callback always accepts.
b.set_verify_callback(SslVerifyMode::PEER, |_valid, _ctx| true); b.set_verify_callback(SslVerifyMode::PEER, |_valid, _ctx| true);
// The reactor drives writes non-blocking and may retry SSL_write with a moved
// or grown buffer after a WouldBlock; allow that and partial progress so a slow
// 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: b.build(),
}) })
@ -63,6 +92,59 @@ impl TlsBackend for OpensslBackend {
let stream = self.acceptor.accept(sock).map_err(err)?; let stream = self.acceptor.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>> {
let ssl = Ssl::new(self.acceptor.context()).map_err(err)?;
// handshake isn't driven here: SslStream::new just binds the socket; the
// reactor calls accept() as the socket becomes readable/writable.
let stream = SslStream::new(ssl, sock).map_err(err)?;
Ok(Box::new(OpensslSession(stream)))
}
}
struct OpensslSession(SslStream<MioStream>);
/// Map an OpenSSL ssl error to the reactor's io model: WANT_READ/WANT_WRITE ⇒
/// `WouldBlock` (retry when ready), everything else ⇒ a real error.
fn ssl_io_err(e: openssl::ssl::Error) -> io::Error {
match e.code() {
ErrorCode::WANT_READ | ErrorCode::WANT_WRITE => io::ErrorKind::WouldBlock.into(),
_ => e.into_io_error().unwrap_or_else(io::Error::other),
}
}
impl TlsSession for OpensslSession {
fn accept(&mut self) -> io::Result<bool> {
match self.0.accept() {
Ok(()) => Ok(true),
Err(e) => match e.code() {
ErrorCode::WANT_READ | ErrorCode::WANT_WRITE => Ok(false),
_ => Err(e.into_io_error().unwrap_or_else(io::Error::other)),
},
}
}
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self.0.ssl_read(buf) {
Ok(n) => Ok(n),
// a clean TLS close is EOF, like a plaintext socket returning 0
Err(e) if e.code() == ErrorCode::ZERO_RETURN => Ok(0),
Err(e) => Err(ssl_io_err(e)),
}
}
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.ssl_write(buf).map_err(ssl_io_err)
}
fn source(&mut self) -> &mut MioStream {
self.0.get_mut()
}
fn peer_cert_fp(&self) -> Option<String> {
let cert = self.0.ssl().peer_certificate()?;
let digest = cert.digest(MessageDigest::sha256()).ok()?;
Some(digest.iter().map(|b| format!("{b:02x}")).collect())
}
fn shutdown(&mut self) {
let _ = self.0.get_ref().shutdown(Shutdown::Both);
}
} }
struct OpensslConn(SslStream<TcpStream>); struct OpensslConn(SslStream<TcpStream>);