Drive the inbound TLS handshake by socket events; kqueue: deliver unread data before EOF - #101
Drive the inbound TLS handshake by socket events; kqueue: deliver unread data before EOF#101MrIron-no wants to merge 7 commits into
Conversation
…e spin add_connection() registered inbound TLS sockets as WRITABLE only and left the handshake to ET_WRITE. A writable socket is level-triggered and always ready, so every event-loop iteration re-ran SSL_accept() -> WANT_READ until the peer's flight arrived: one RTT of 100% CPU per inbound TLS connection and, under -x, a flood of "ssl_handle_error: SSL_get_error=2" lines. History: the 2019 TLS code set READABLE/WRITABLE from ssl_handle_error() on WANT_READ/WANT_WRITE. 7cb71c8 (2025-08-14) moved to the WRITABLE-only registration so start_auth() could be deferred past the handshake; 046b2ed the same day removed the ssl_handle_error() calls because they used SOCK_ACTION_SET semantics from the data path too (an SSL_write WANT_WRITE replaced the mask with WRITABLE only and starved reads). That left the spin as the only thing driving the handshake. Now: - add_connection() registers READABLE (wait for the ClientHello). - ircd_tls_negotiate() gains an `int *wants_write` out-parameter (all four backends: OpenSSL SSL_ERROR_WANT_WRITE, GnuTLS gnutls_record_get_direction(), libtls TLS_WANT_POLLOUT). GnuTLS returns that ask for an immediate gnutls_handshake() retry (warning alert, application data, other non-fatal) report wants_write too, since no read event will follow them. The backends no longer touch socket events. - The two handshake callers (tls_negotiate_client(), completed_connection()) ADD/DEL WRITABLE from that hint only, so update_write() keeps owning WRITABLE for the data path. - A silent peer now generates no events, and inbound TLS clients are not in LocalClientArray until start_auth() runs after the handshake, so check_pings() cannot see them either. Enforce TLS_HANDSHAKE_TIMEOUT from the connection's con_proc timer, armed in add_connection() and cancelled in tls_handshake_succeeded(). con_proc is unused until read_packet() runs, which cannot precede the handshake. Tests: tls/ suite, including test_stalled_handshake_times_out and test_stalled_handshake_after_clienthello_times_out (both fail without the timer).
kqueue sets EV_EOF on EVFILT_READ as soon as the peer's FIN arrives, even while evt->data bytes are still unread. The engine turned that straight into ET_EOF, so a peer's final ERROR/SQUIT line was dropped and the hub reported "Server X closed the connection (<>)" with no reason. Generate ET_READ while data remains; the filter is level-triggered, so the drained socket comes back as EV_EOF with data == 0 and becomes the real ET_EOF. Not buildable or testable on Linux (no sys/event.h); reviewed by hand. tests/tls/test_tls_s2s_burst.py links tls-hub and tls-leaf with the hub in both TLS roles and a populated burst, and requires the link to survive and the burst to arrive. The other S2S TLS tests only link an empty network.
|
@MrIron-no can you take a look at these findings? High — crashes and hangs
Medium — the fix doesn't cover Linux, plus DoS-adjacent issues
Low — maintainability
|
…th leak Review follow-ups for the TLS handshake change (PR UndernetIRC#101). - client_sock_callback ET_READ fell through to read_packet() after tls_handshake_succeeded(). start_auth() can exit_client() synchronously (os_get_peername() failing on a peer that RST'd right after Finished) and completed_connection() can fail for outbound links, after which read_packet() dereferenced the freed client. Return instead, like the ET_WRITE arm; queued application data re-fires the level-triggered readable event. - A dead_link()'d client is only reaped by check_pings(), so a readable dead socket re-fired a no-op ET_READ every loop pass until then (up to PINGFREQUENCY). Exit it from the ET_READ arm, the same context the ET_EOF arm already exits from. - add_connection() allocated the TLS session and the Client before the IPcheck throttle and socket_add() checks, whose early returns only closed the fd: a throttled connect leaked the session (and a Client on plaintext ports), and a socket_add() failure leaked both plus the IPcheck count. Run the throttle check before make_client(), free the session on that path, and release session, IPcheck count and Client on socket_add() failure (the pattern connect_server() already uses).
…n only Review follow-ups for PR UndernetIRC#101. - The per-backend deadline check compared against cli_firsttime, which for outbound links is set in make_client() before the TCP connect. Three lost SYNs (1/3/7 s retransmits) consumed the whole budget, so the first ircd_tls_negotiate() call failed with "TLS handshake timed out" before a TLS byte was sent, and every auto-reconnect failed the same way. Remove the three copies and arm the con_proc timer where the handshake actually starts: add_connection() for inbound, completed_connection() for outbound (tls_handshake_timer_arm()). The timer callback aborts directly through tls_handshake_abort(); it no longer round-trips through the backend, whose "still negotiating" / "completed" arms were unreachable once the deadline had passed. - tls_negotiation_events() now SETs exactly one direction. Holding READABLE while the backend is blocked on a write let a peer that pipelines bytes after ClientHello and stops reading re-run a stalled handshake every loop pass for the full deadline. Errors (RST) are reported regardless of interest; a peer that FINs while we are write-blocked is bounded by the deadline instead of spinning. Safe now that outbound links have the timer too. - tls_handshake_drop() factors the dead-mark + session teardown shared by the failure paths.
Review follow-ups for PR UndernetIRC#101. - completed_connection() carried an inline copy of tls_negotiate_client()'s failure handling. Route the outbound path through tls_negotiate_client() and keep only the operator notice at the call site. - OpenSSL: report every non-WANT_READ block as a write. With a socket BIO SSL_ERROR_SYSCALL+EAGAIN cannot occur, but if a block is ever misclassified a wrong "write" costs one loop pass on the always-ready writable event while a wrong "read" costs the deadline. - GnuTLS: non-fatal, non-blocking results from gnutls_handshake() (a warning alert) mean "call again now" and leave no socket event behind. Loop on them inside ircd_tls_negotiate() (bounded) instead of encoding "retry" as a fake write direction. GNUTLS_E_GOT_APPLICATION_DATA only applies to a rehandshake, which ircu never initiates.
|
Thanks — addressed in c8edcd2, fe30688 and 63f5d94:
|
Found with the misbehaving-peer harness (tests/tls/test_tls_bogus_peer.py). - An outbound TLS link whose handshake failed on a socket event after the connect step (peer closed or sent garbage once we were parked waiting for its flight) was torn down without any operator notice: the "TLS negotiation failed to ..." message was only emitted from completed_connection(), not from the ET_READ path. Move the notice into tls_negotiation_failed(), which both paths and the deadline timer use, so all three report the same way. - exit_client()'s "Link with %s canceled: %s" notices for server links test IsConnecting(victim) but sit inside an IsClient(victim) guard, and IsClient() does not include STAT_CONNECTING -- so they never fired for a connecting link. A link reset between connect() and registration (e.g. ECONNRESET during the TLS handshake) was invisible to the oper who issued the CONNECT. Include IsConnecting() in the guard; the inner branches already skip the ERROR line for connecting clients.
tests/tls/bogus_peer.py drives OpenSSL through ssl.MemoryBIO so a test
decides which bytes hit the wire and when: BogusTLSClient connects to the
hub's client and server TLS ports; BogusTLSServer is the server half and
SidecarBogusServer runs it in a python:3-alpine container on the test
network (the host firewall may not allow container->host traffic), reached
through a new Connect block for bogus.test.net at 10.55.0.40 in
ircd-tls-hub.conf and `CONNECT bogus.test.net <port>`.
tests/tls/test_tls_bogus_peer.py checks, for each scenario, that the link
is torn down at TLS_HANDSHAKE_TIMEOUT or promptly on a hard failure with
nothing written before the close, that the hub does not spin (`docker
stats` CPU while peers are stalled), and that a healthy client keeps
getting PONGs meanwhile:
inbound: silent peers (client and server ports), stall after
ClientHello, ClientHello dribbled one byte at a time, slow but
complete handshake (control), Finished coalesced with the
first application record (control), garbage after
ClientHello, RST and FIN mid-handshake followed by a normal
registration, a peer with a tiny receive window that never
reads (write-blocked server flight), a flood after the
handshake without ever reading.
outbound: silent server, truncated server flight, garbage and immediate
close both during and after the connect step, and a full
handshake against a foreign TLS stack that receives PASS and
SERVER.
BOGUS_TLS_HOST / BOGUS_TLS_PORT / BOGUS_TLS_SERVER_PORT run the inbound
scenarios against a real server (CPU and notice checks are skipped).
|
Two more commits pushed (a323e62, 02a4c5f). Misbehaving-peer harness ( Two things it found, fixed in a323e62:
No spin and no deadline miss was found in any scenario on this branch (Linux/epoll). |
Summary
Two independent fixes, split into two commits.
1. Inbound TLS handshake busy-loop (
dff3c84)add_connection()registered inbound TLS sockets asWRITABLEonly and drove the handshake fromET_WRITE. A writable socket is level-triggered and always ready, so every event-loop iteration re-ranSSL_accept()→WANT_READuntil the peer's flight arrived: one RTT of 100% CPU per inbound TLS connection (user and server ports), and under-xthousands ofssl_handle_error: SSL_get_error=2lines per second.History: the 2019 TLS code set
READABLE/WRITABLEfromssl_handle_error().7cb71c8moved to theWRITABLE-only registration sostart_auth()could be deferred past the handshake;046b2edremoved thessl_handle_error()calls because they usedSOCK_ACTION_SETfrom the data path too (anSSL_writeWANT_WRITEreplaced the mask withWRITABLEonly and starved reads). That left the spin as the only thing driving the handshake.Now:
add_connection()registersREADABLE.ircd_tls_negotiate()gains anint *wants_writeout-parameter (OpenSSLSSL_ERROR_WANT_WRITE, GnuTLSgnutls_record_get_direction()plus the "call again now" returns, libtlsTLS_WANT_POLLOUT). Backends no longer touch socket events.WRITABLEfrom that hint only, soupdate_write()keeps owningWRITABLEfor the data path.LocalClientArrayuntilstart_auth()runs after the handshake, socheck_pings()cannot see them either.TLS_HANDSHAKE_TIMEOUTis enforced from the connection'scon_proctimer, armed inadd_connection()and cancelled intls_handshake_succeeded()(con_procis unused untilread_packet(), which cannot precede the handshake).2. kqueue: unread data dropped on EOF (
eba98ff)kqueue sets
EV_EOFonEVFILT_READas soon as the peer's FIN arrives, even whileevt->databytes are still unread.engine_kqueue.cturned that straight intoET_EOF, so a peer's finalERROR/SQUITline was dropped and the hub reportedServer X closed the connection (<>)with no reason. GenerateET_READwhile data remains; the drained socket comes back asEV_EOFwithdata == 0and becomes the realET_EOF.Not buildable on Linux (no
sys/event.h) — reviewed by hand, needs a BSD build.Also adds
tests/tls/test_tls_s2s_burst.py: links tls-hub and tls-leaf with the hub in both TLS roles and a populated burst.Testing
tests/tls/suite: 75/75 (Linux/epoll, OpenSSL 3.5).test_stalled_handshake_times_outandtest_stalled_handshake_after_clienthello_times_outfail without thecon_proctimer.pr_websocket+cap: 92/92 (sameadd_connectionpath).SSL_writecapped to 16 bytes and withSO_SNDBUFforced to 1 KB (realWANT_WRITEunder an 80-user burst), both link directions; those harness edits are not committed.-fsyntax-only).Review notes
Independently reviewed; timer lifecycle (
GEN_MARKED,FREEFLAG_TIMER, exit from within the timer callback), ADD/DEL semantics, and the ET_WRITE-success-without-read_packet()path were traced and found sound. One pre-existing issue noted for a separate follow-up:client_sock_callbackET_READ runsread_packet()aftertls_handshake_succeeded()may haveexit_client()'d a failed outbound link (present since #99, not widened here).