connclass: cidr/parent/port/limit/globalmax + hashed/trusted-cert passwords, per-class recvq/sendq + fakelag, and rfc1413 ident

This commit is contained in:
Jean Chevronnet 2026-08-10 18:46:42 +00:00
parent f5f888dbaa
commit f371ed0a18
14 changed files with 842 additions and 126 deletions

View file

@ -26,19 +26,28 @@ use crate::ircd::Event;
use crate::tls::TlsBackend;
use crate::Uid;
/// Longest single line we'll buffer before dropping it (crude flood guard).
const MAX_LINE: usize = 16 * 1024;
/// Most bytes we'll queue to a slow client before dropping them (backpressure).
const MAX_WBUF: usize = 1 << 20; // 1 MiB
/// Default recvq: longest single line we'll buffer before dropping it. Overridable
/// globally (`max_line`) and per connection class (`recvq`).
pub const DEFAULT_MAX_LINE: usize = 16 * 1024;
/// Default hardsendq: most bytes we'll queue to a slow client before dropping them
/// and closing. Overridable globally (`max_sendq`) and per class (`hardsendq`).
pub const DEFAULT_MAX_SENDQ: usize = 1 << 20; // 1 MiB
/// How long a TLS thread blocks on a read before draining its write queue.
const TLS_POLL: Duration = Duration::from_millis(100);
/// A queued output action the core hands the reactor: a line to write to a
/// connection, or a request to flush-then-close it (sent when the core drops the
/// [`OutSink`], e.g. on quit).
/// connection, a request to flush-then-close it (sent when the core drops the
/// [`OutSink`], e.g. on quit), or a per-connection queue-limit override (from the
/// assigned connection class).
pub enum Out {
Line(usize, String),
Close(usize),
Limits {
token: usize,
recvq: Option<usize>,
hardsendq: Option<usize>,
softsendq: Option<usize>,
},
}
/// The core's handle to one connection's output. Thread-model connections (TLS,
@ -68,6 +77,30 @@ impl OutSink {
}
}
}
/// Override this connection's queue limits (from its connection class). Only the
/// reactor (plaintext client) model honours these; thread-model connections (TLS,
/// links) use the global defaults.
pub fn set_limits(
&self,
recvq: Option<usize>,
hardsendq: Option<usize>,
softsendq: Option<usize>,
) {
if let OutSink::Reactor { token, tx, waker } = self {
if tx
.send(Out::Limits {
token: *token,
recvq,
hardsendq,
softsendq,
})
.is_ok()
{
let _ = waker.wake();
}
}
}
}
impl Drop for OutSink {
@ -94,8 +127,13 @@ struct Conn {
rbuf: Vec<u8>, // bytes read, awaiting a newline
wbuf: Vec<u8>, // bytes queued to write
wpos: usize, // how far into wbuf we've written
want_read: bool,
want_write: bool,
closing: bool, // flush wbuf, then close
closing: bool, // flush wbuf, then close
paused: bool, // reads paused (softsendq backpressure); ⟺ pending > softsendq
recvq: usize, // max buffered unterminated-line bytes before dropping
hardsendq: usize, // max queued output bytes before dropping + closing
softsendq: usize, // queued output above this pauses reads until it drains
}
impl Conn {
@ -104,9 +142,35 @@ impl Conn {
}
}
/// Reregister `t`'s epoll interest to match its current read/write intent, but only
/// if it changed. A paused connection drops READABLE (so the client stops being
/// serviced) while keeping WRITABLE to drain the backlog that paused it.
fn set_interest(poll: &mut Poll, c: &mut Conn, t: usize) {
let want_read = !c.paused;
let want_write = !c.wbuf.is_empty() || c.paused;
if want_read == c.want_read && want_write == c.want_write {
return;
}
c.want_read = want_read;
c.want_write = want_write;
let interest = match (want_read, want_write) {
(true, true) => Interest::READABLE | Interest::WRITABLE,
(false, true) => Interest::WRITABLE,
// never both-false (paused ⟹ backlog ⟹ want_write); READABLE is a safe floor
_ => Interest::READABLE,
};
let _ = poll.registry().reregister(&mut c.stream, Token(t), interest);
}
/// Run the client plaintext reactor on this thread. `listener` is an already-bound
/// mio listener (bound in `main` so a bind failure is fatal and fails fast).
pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<AtomicU64>) {
pub fn run_reactor(
mut listener: MioListener,
core: Sender<Event>,
counter: Arc<AtomicU64>,
max_line: usize,
max_sendq: usize,
) {
let mut poll = match Poll::new() {
Ok(p) => p,
Err(e) => {
@ -158,6 +222,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
let addr = stream
.peer_addr()
.unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap());
let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0);
conns.insert(
token,
Conn {
@ -166,8 +231,13 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
rbuf: Vec::new(),
wbuf: Vec::new(),
wpos: 0,
want_read: true,
want_write: false,
closing: false,
paused: false,
recvq: max_line,
hardsendq: max_sendq,
softsendq: max_sendq,
},
);
let out = OutSink::Reactor {
@ -183,6 +253,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
sock: None,
secure: false,
certfp: None,
local_port,
link: false,
outbound: false,
websocket: false,
@ -203,8 +274,8 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
match msg {
Out::Line(t, line) => {
if let Some(c) = conns.get_mut(&t) {
if c.pending() + line.len() + 2 > MAX_WBUF {
// slow client: drop queued data and close
if c.pending() + line.len() + 2 > c.hardsendq {
// hardsendq: drop queued data and close
c.wbuf.clear();
c.wpos = 0;
c.closing = true;
@ -215,6 +286,11 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
}
c.wbuf.extend_from_slice(line.as_bytes());
c.wbuf.extend_from_slice(b"\r\n");
// softsendq: over the soft cap, stop reading
// their commands until the backlog drains
if c.pending() > c.softsendq {
c.paused = true;
}
}
touched.insert(t);
}
@ -225,6 +301,24 @@ pub fn run_reactor(mut listener: MioListener, core: Sender<Event>, counter: Arc<
touched.insert(t);
}
}
Out::Limits {
token,
recvq,
hardsendq,
softsendq,
} => {
if let Some(c) = conns.get_mut(&token) {
if let Some(v) = recvq {
c.recvq = v;
}
if let Some(v) = hardsendq {
c.hardsendq = v;
}
if let Some(v) = softsendq {
c.softsendq = v;
}
}
}
}
}
for t in touched {
@ -267,7 +361,7 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core:
lines.push((c.uid, l.to_string()));
}
}
if c.rbuf.len() > MAX_LINE {
if c.rbuf.len() > c.recvq {
c.rbuf.clear(); // overlong line with no newline: drop it
}
}
@ -290,10 +384,13 @@ fn read_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core:
}
}
/// Write as much of `t`'s queued output as the socket accepts, adjust WRITABLE
/// interest, and close once a `closing` connection's buffer is drained.
/// Write as much of `t`'s queued output as the socket accepts, adjust epoll
/// interest, and close once a `closing` connection's buffer is drained. If the
/// 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).
fn flush_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core: &Sender<Event>) {
let mut close = false;
let mut unpaused = false;
if let Some(c) = conns.get_mut(&t) {
while c.wpos < c.wbuf.len() {
match c.stream.write(&c.wbuf[c.wpos..]) {
@ -311,25 +408,19 @@ fn flush_conn(poll: &mut Poll, conns: &mut HashMap<usize, Conn>, t: usize, core:
c.wbuf.clear();
c.wpos = 0;
}
// re-arm WRITABLE only while there's a backlog (edge-triggered)
let want = !c.wbuf.is_empty();
if want != c.want_write {
c.want_write = want;
let interest = if want {
Interest::READABLE | Interest::WRITABLE
} else {
Interest::READABLE
};
let _ = poll
.registry()
.reregister(&mut c.stream, Token(t), interest);
if c.paused && c.pending() <= c.softsendq {
c.paused = false;
unpaused = true;
}
set_interest(poll, c, t);
if c.closing && c.wbuf.is_empty() {
close = true;
}
}
if close {
close_conn(poll, conns, t, core);
} else if unpaused {
read_conn(poll, conns, t, core); // catch reads missed while paused
}
}
@ -354,6 +445,7 @@ pub fn accept_loop(
tls: Option<Arc<dyn TlsBackend>>,
counter: Arc<AtomicU64>,
link: bool,
max_line: usize,
) {
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
@ -361,6 +453,7 @@ pub fn accept_loop(
continue;
};
let _ = stream.set_nodelay(true);
let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0);
let uid = counter.fetch_add(1, Ordering::Relaxed);
match &tls {
@ -381,6 +474,7 @@ pub fn accept_loop(
sock: Some(shutdown),
secure: false,
certfp: None,
local_port,
link,
outbound: false,
websocket: false,
@ -390,12 +484,12 @@ pub fn accept_loop(
break; // core gone
}
let core_tx = core.clone();
thread::spawn(move || reader_loop(reader, uid, core_tx));
thread::spawn(move || reader_loop(reader, uid, core_tx, max_line));
}
Some(backend) => {
let backend = backend.clone();
let core_tx = core.clone();
thread::spawn(move || tls_conn(backend, stream, uid, addr, core_tx, link));
thread::spawn(move || tls_conn(backend, stream, uid, addr, core_tx, link, max_line));
}
}
}
@ -403,7 +497,7 @@ pub fn accept_loop(
/// Dial an outbound server link and wire it to the core (an `outbound` link that
/// introduces itself first). Used for auto-connecting to a configured uplink.
pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>, max_line: usize) {
let stream = match TcpStream::connect(addr) {
Ok(s) => s,
Err(e) => {
@ -430,6 +524,7 @@ pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
sock: Some(shutdown),
secure: false,
certfp: None,
local_port: 0,
link: true,
outbound: true,
websocket: false,
@ -438,12 +533,12 @@ pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
{
return;
}
thread::spawn(move || reader_loop(reader, uid, core));
thread::spawn(move || reader_loop(reader, uid, core, max_line));
}
// --- plaintext link: two blocking threads -----------------------------------
fn reader_loop(stream: TcpStream, uid: Uid, core: Sender<Event>) {
fn reader_loop(stream: TcpStream, uid: Uid, core: Sender<Event>, max_line: usize) {
let mut buf = BufReader::new(stream);
let mut line = String::new();
loop {
@ -451,7 +546,7 @@ fn reader_loop(stream: TcpStream, uid: Uid, core: Sender<Event>) {
match buf.read_line(&mut line) {
Ok(0) => break, // EOF
Ok(_) => {
if line.len() > MAX_LINE {
if line.len() > max_line {
continue;
}
let l = line.trim_end_matches(['\r', '\n']);
@ -493,11 +588,13 @@ fn tls_conn(
addr: SocketAddr,
core: Sender<Event>,
link: bool,
max_line: usize,
) {
// Keep a raw handle so the core can force the socket shut later.
let Ok(shutdown) = stream.try_clone() else {
return;
};
let local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0);
let mut conn = match backend.accept(stream) {
Ok(c) => c,
Err(_) => {
@ -515,6 +612,7 @@ fn tls_conn(
sock: Some(shutdown),
secure: true,
certfp,
local_port,
link,
outbound: false,
websocket: false,
@ -548,7 +646,7 @@ fn tls_conn(
break 'io;
}
}
if acc.len() > MAX_LINE {
if acc.len() > max_line {
acc.clear(); // overlong line with no newline: drop it
}
}