Skip to content

Fix TLS sendq/partial-write handling, auth timer lifecycle, and backend close semantics - #99

Merged
Ratler merged 15 commits into
UndernetIRC:mainfrom
MrIron-no:fix/tls-partialsend
Aug 28, 2026
Merged

Fix TLS sendq/partial-write handling, auth timer lifecycle, and backend close semantics#99
Ratler merged 15 commits into
UndernetIRC:mainfrom
MrIron-no:fix/tls-partialsend

Conversation

@MrIron-no

@MrIron-no MrIron-no commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

TLS clients could be dropped with no IRC ERROR after NOTICE AUTH or during large NAMES / sendq bursts. Root cause: TLS short writes (SSL_MODE_ENABLE_PARTIAL_WRITE and the GnuTLS/libtls equivalents) left con_rexmit pointing mid-message while send_queued() advanced the MsgQ via msgq_delete(count_out); finishing the remainder then called msgq_excise() with a mid-message pointer, which only matches the start of a MsgBuf — default builds abort(), --disable-asserts builds silently corrupt the queue and the TLS stream.

Also fixes crashes/spins found under the same load in the auth/timer path, and aligns close/negotiate semantics across the three TLS backends.

Commits

  1. Fix TLS sendq accounting on partial writes (con_rexmit) — credit partial bytes through count_out so msgq_delete() advances the queue's own Msg.sent; con_rexmit is then always head->msg + head->sent. Drain short writes until a real WANT_WRITE/EAGAIN; don't set FLAG_BLOCKED on TLS short success; clear con_rexmit beside every MsgQClear. msgq_excise() removed. Regression tests: test_tls_auth_drop.py, test_tls_names_burst.py.
  2. Fix AuthRequest freelist/timer lifecycle and double check_auth_finished — defer freelisting while the timeout timer is GEN_MARKED (AR_FREE_PENDING); split start_dns_ident_queries() so start_auth() finishes auth exactly once.
  3. TLS: fail negotiation cleanly when the session is gone!tls → clear flag, return −1 (all backends); OpenSSL returns an explicit 1 on success instead of X509_digest()'s result.
  4. GnuTLS: align close and EOF semantics with OpenSSL and libtlsrecord_recv()==0IO_FAILURE; close_notify only after a completed handshake, tracked via gnutls_session_set_ptr() (gnutls_protocol_get_version() is set before any ClientHello, so it can't be used). Strict stall tests, including an encrypted-alert (TLS 1.3) variant.
  5. GnuTLS: accept peer certificate when fingerprint extraction fails — previously returned "success" without clearing FLAG_NEGOTIATING_TLS, wedging the connection.
  6. Handle ircd_tls_negotiate() failure in completed_connection() — outbound links no longer sit until ping timeout on a handshake failure/timeout.
  7. tests: debug capture robustness and TLS container debug settings.

Verification

  • tests/tls/ (22 tests) pass on OpenSSL; also pass with every SSL_write capped to 16 bytes via a local harness that forces the con_rexmit path on every message. The same harness on main reproduces the bug: first pipelined TLS registration silently dropped, hub exits 134 (SIGABRT at the msgq_excise assert).
  • Strict stalled-handshake tests (no ClientHello, and ClientHello-then-stall) pass on openssl, gnutls and libtls (TLS_BACKEND=… images).
  • Full GnuTLS TLS suite (26 tests incl. client-cert and S2S fingerprint/CA) passes after commit 5; S2S outbound tests pass on openssl and gnutls after commit 6.
  • Every commit builds individually (OpenSSL) and syntax-checks for GnuTLS/libtls.

Notes for reviewers

  • Upstream check_resolver_timeout() never arms res_timeout (t_expire stays 0), so DNS lookups are bounded only by AUTH_TIMEOUT. Not touched here; worth a separate issue. If it is ever fixed, restart_resolver()'s unguarded timer_init(&res_timeout) needs a queued-check.

Follow-up (review response)

A review of the 7 commits above raised seven findings; all are addressed in the commits that follow, plus a regression test and an operator-facing improvement.

Review fixes

  • c794e9c TLS send path"Bogus userid" error if identd is turned off and nothing to slow it down #1 the drained con_rexmit message is now removed by identity (restored, corrected msgq_excise) instead of by a byte count msgq_delete() could misattribute to a priority message that jumped ahead (duplicate bytes / dropped PING); execvp result gets swallowed in s_auth #2 every IO_FAILURE return zeroes count_out so send_queued() dead-links immediately instead of flushing the rest of the sendq as plaintext.
  • 0d1aab7 s_bsdgitignore: Add auto generated files #3 completed_connection()'s TLS-failure branch sets FLAG_DEADSOCKET (no plaintext ERROR into the half-open handshake); channel mode +M #5 tls_handshake_succeeded() acts on completed_connection()'s return instead of leaving a half-set-up link until ping timeout.
  • 7134d2e s_authchannel mode +P #6 the start_auth() freelist guard is replaced with an assert of the invariant AR_FREE_PENDING guarantees (the guard could re-create the 100% CPU timer spin it was meant to prevent).
  • 79a0450 testsHello this possible add the spam #4 the debug-snapshot guard also catches subprocess.SubprocessError (a hung docker daemon no longer aborts the suite); idle reset for CPRIVMSG command #7 the TLS hub config gets permissive IPCHECK_CLONE_LIMIT/PERIOD so the concurrent stress connections from one docker IP are not throttled.

Added

  • c17721a C unit test for msgq_excise (make check) — reproduces the "Bogus userid" error if identd is turned off and nothing to slow it down #1 prio-reorder at the queue level; verified to abort on the pre-fix count-based deletion and pass with the fix.
  • 873ab37 Detailed TLS failure reasonsircd_tls_negotiate() fills a reason buffer with a specific cause, surfaced to the SNO_OLDSNO operator notice and disconnect log on both the inbound and outbound paths (peer still gets a categorical ERROR / the backend TLS alert; a stalled handshake still closes with a plain EOF). Examples:
    • TLS negotiation failed from unknown server: self-signed certificate
    • TLS negotiation failed from unknown server: certificate has expired
    • TLS fingerprint mismatch for server X: presented <fp>, configured <fp>

Verification: full tests/tls/ suite (74 tests) passes on openssl, gnutls and libtls after these commits; operator/peer notices verified against live openssl and gnutls hubs.

TLS short writes (SSL_MODE_ENABLE_PARTIAL_WRITE and the GnuTLS/libtls
equivalents) could leave con_rexmit pointing mid-message while
send_queued() advanced the MsgQ via msgq_delete(count_out).  Finishing
the remainder then called msgq_excise() with a mid-message pointer,
which only matches the start of a MsgBuf: default builds abort(), and
--disable-asserts builds leave the queue wrong and corrupt the TLS
stream.  Clients dropped with no IRC ERROR (after NOTICE AUTH, or under
large NAMES / sendq bursts).

Send path (all TLS backends + deliver_it):
- Credit successful rexmit bytes through count_out so msgq_delete()
  advances the queue's own Msg.sent offset; con_rexmit is then always
  head->msg + head->sent.  msgq_excise() is removed.
- Drain short TLS writes until the message finishes or the backend
  reports a real block (WANT_WRITE / EAGAIN); return IO_BLOCKED only then.
- Do not set FLAG_BLOCKED on TLS short IO_SUCCESS (that busy-loops
  ET_WRITE while POLLOUT stays ready).  Still set it for plain short
  writev.  Credit sendB on IO_BLOCKED when some bytes already went out.
- Clear con_rexmit beside every MsgQClear (dead_link / close_connection /
  dealloc_connection).

Tests: TLS auth-drop pipeline and NAMES-burst regressions (tls_stress
marker, pytest-timeout dependency).
- destroy_auth_request() during timer_run() while the timeout timer is
  GEN_MARKED must not freelist the AuthRequest yet: timer_del() is a
  no-op while marked and timer_run() still owns the Timer.  Defer with
  AR_FREE_PENDING and freelist on ET_DESTROY.  Detach from the client
  first, and timer_del() before freelist reuse so a leftover queue link
  cannot form a self-loop that pegs timer_enqueue() at 100% CPU.
- Split start_dns_ident_queries() from start_dns_ident() so start_auth()
  finishes auth exactly once.  start_dns_ident() remains the deferred
  WebSocket resume path from s_bsd and finishes auth itself.  Previously
  start_auth() called check_auth_finished() twice on one AuthRequest,
  which could use a freelisted request (SIGSEGV after client pointer
  nulling) when the first call completed or exited the client.
ircd_tls_negotiate() returned 1 (success) when s_tls() was already NULL,
so FLAG_NEGOTIATING_TLS stayed set and start_auth() was invoked on every
subsequent ET_WRITE.  Clear the flag and return -1 in all three backends.

OpenSSL: return an explicit 1 on handshake success; the previous
'return res' at the end of the function returned X509_digest()'s result,
which reads as "still negotiating" when the digest fails.
- gnutls_record_recv() == 0 is a peer close -> IO_FAILURE.  It was
  treated as IO_BLOCKED (gnutls_error_is_fatal(0) is false), leaving the
  session open until the client gave up on its SSL shutdown wait (30s
  for asyncio).
- ircd_tls_close(): only send close_notify after a completed handshake,
  matching OpenSSL SSL_is_init_finished() and libtls tls_close().
  ircd_tls_negotiate() marks success via gnutls_session_set_ptr();
  gnutls_protocol_get_version() cannot be used for this since it is
  already set before any ClientHello arrives.  A stalled handshake now
  yields TCP EOF instead of a TLS alert record.

Tests: test_stalled_handshake_times_out asserts a plain EOF (no alert
byte accepted); new test_stalled_handshake_after_clienthello_times_out
drives the client side in a MemoryBIO so an encrypted TLS 1.3 alert
after ServerHello is detected too.  Verified on openssl, gnutls, libtls.
If gnutls_x509_crt_import() or gnutls_x509_crt_get_fingerprint() failed
after a successful handshake, ircd_tls_negotiate() returned 1 without
clearing FLAG_NEGOTIATING_TLS, so send_queued() never sent anything and
the connection wedged until ping timeout.  Log the error, leave the
fingerprint empty and complete the handshake normally, as the OpenSSL
and libtls backends do.
The outbound server-link path discarded the negotiate result and only
looked at FLAG_NEGOTIATING_TLS.  A handshake timeout (which returns -1
without clearing the flag) was read as "still negotiating" and the link
sat until ping timeout; a missing session (flag cleared, -1) fell
through to sending PASS/SERVER on a socket without TLS.  Handle the
result like tls_negotiate_client(): notify opers, close the TLS session
and fail the connection.
- conftest: never abort the suite when the failure snapshot cannot be
  written (e.g. root-owned failures/ from a prior docker run); fall back
  under /tmp.
- debug_support: snapshot whichever hub container is running (tls-hub,
  hub, limits) instead of hard-coding ircu-hub.
- docker-compose: debug volume, ASAN options, seccomp:unconfined and
  core/nofile ulimits for ircd-tls-hub / ircd-tls-leaf, matching the
  other ircd services.
@MrIron-no
MrIron-no force-pushed the fix/tls-partialsend branch from 29bdf73 to b494af6 Compare August 24, 2026 16:06
MrIron-no and others added 8 commits August 26, 2026 09:23
…l error

Follow-up to the TLS PR, addressing review findings #1 and #2.

#1 (correctness): the con_rexmit drain reported a raw byte count that
send_queued() fed to msgq_delete(), which deletes in (partial-normal,
prio, normal) order.  When a whole normal message was deferred (sent==0)
and a priority message (e.g. a check_pings PING) was enqueued before the
next ET_WRITE, msgq_delete() deleted the never-sent PING first and left
the normal message's tail to be re-sent as duplicate bytes -- desyncing
the P10 link and dropping the PING.  Restore identity-based removal: a
corrected msgq_excise() (matching the head message by buffer containment,
checking both queues) removes exactly the drained message, and its bytes
are no longer credited to *count_out.  The in-loop drain stays count-based
(it sends in mapiov order within one synchronous call).

#2 (disclosure): on a fatal mid-drain error the backend frees the TLS
session and returned IO_FAILURE with count_out > 0; deliver_it() reported
that as progress, so send_queued() looped back and flushed the rest of the
sendq -- private messages included -- as plaintext on the raw socket.  Zero
*count_out on every IO_FAILURE return so deliver_it() reports no progress
and the link dead-links immediately, matching pre-PR behaviour.

Applied consistently to the OpenSSL, GnuTLS and libtls backends.
Follow-up to the TLS PR, addressing review findings #3 and #5.

#3: completed_connection()'s TLS-failure branch freed the session but did
not mark the client dead, so exit_client() flushed its ERROR line as
plaintext into the half-open handshake stream.  Set FLAG_DEADSOCKET so
can_send() rejects the write, matching tls_negotiate_client().

#5: tls_handshake_succeeded() discarded completed_connection()'s return,
so a link whose Connect block vanished during the handshake (rehash) was
left half-initialized until the ping timeout.  Exit the client on a 0
return, as the ET_CONNECT path already does.
…iring

Follow-up to the TLS PR, addressing review finding #6.

The defensive timer_del() in start_auth() was a no-op on a still-GEN_MARKED
timer, after which memset() zeroed t_header links timer_run() still owned --
recreating the timer-enqueue self-loop (100% CPU) it was meant to prevent.
AR_FREE_PENDING already guarantees a freelisted request's timer is fully
destroyed before reuse, so assert that invariant rather than silently
repairing a state that should never occur.
Follow-up to the TLS PR, addressing review findings #4 and #7.

#4: the failure-snapshot guard caught only OSError, but the docker
inspect/logs calls raise subprocess.TimeoutExpired (a SubprocessError) on
a hung daemon -- turning every test failure into a pytest internal error.
Catch subprocess.SubprocessError too.

#7: the TLS stress tests open many concurrent connections from the single
docker-network IP; add permissive IPCHECK_CLONE_LIMIT/PERIOD to the TLS
hub config (matching the plaintext hub) so clone throttling does not fail
them spuriously.
Covers review finding #1 at the C level.  A normal message deferred to
con_rexmit (sent == 0) with a priority message enqueued behind it must be
removed by identity, leaving the priority message intact; the test also
checks a mid-message con_rexmit pointer (multi-partial drain), excising a
priority message, and the lone-message case.  Wired into `make check` as
msgq_excise_t.

Verified to abort on the pre-fix count-based deletion, which removes the
priority message in place of the drained one.
ircd_tls_negotiate() collapsed every failure to a bare -1, so operator
notices and disconnect logs read only "TLS negotiation failed".  It now
fills a caller-provided reason buffer (TLS_REASON_LEN) with a specific,
human-readable cause, propagated to the SNO_OLDSNO operator notice and the
disconnect reason on both the inbound (tls_negotiate_client) and outbound
(completed_connection) paths.  A categorical ERROR line is still written to
the peer where the channel is plaintext; TLS-layer rejections reach the
peer as the backend's own TLS alert instead.

Reasons per backend:
- OpenSSL: X509_verify_cert_error_string() for a verification abort
  (SSL_get_verify_result() is set even when SSL_accept() fails), otherwise
  the OpenSSL error reason; plus certificate-required and handshake errors.
- GnuTLS: gnutls_certificate_verification_status_print() for a bad verdict,
  gnutls_strerror() for the verify call or handshake error.
- libtls: tls_error() for the handshake error.

A stalled handshake still closes with a plain TCP EOF (no peer write, which
would corrupt a mid-handshake peer's TLS stream).  The server-link
fingerprint-mismatch notice now reports the presented and configured
fingerprints (m_server.c).

Example operator notices:
  TLS negotiation failed from unknown server: self-signed certificate
  TLS negotiation failed from unknown server: certificate has expired
  TLS negotiation failed to leaf2.test.net: no shared cipher
show_ports() built its flag string in char flags[8], which was sized
for the original C/S + H + 4- + 6- letters (6 + NUL). The TLS ('E')
and Cloudflare ('F') additions push the worst case (hidden TLS
cloudflare listener with both address families unbound after a
rehash) to 8 characters plus NUL, one byte past the buffer. Bump it
to 16.

Add tests/stats_ports covering STATS p locally, hunted to a remote
server, after REHASH, and on a server with TLS/websocket/cloudflare
listeners.
Both crediting error paths in ircd_tls_sendv() zeroed *count_out
before deciding whether the error was fatal, so a non-fatal error
(e.g. a warning alert) mid-batch returned IO_BLOCKED with zero
credit.  send_queued() then deleted nothing from the sendq and the
messages already handed to gnutls_record_send() were retransmitted
on the next ET_WRITE, duplicating lines mid-stream.

Zero the credit only on IO_FAILURE, matching the OpenSSL and libtls
backends.
@Ratler
Ratler merged commit 0200c5d into UndernetIRC:main Aug 28, 2026
1 check passed
@MrIron-no
MrIron-no deleted the fix/tls-partialsend branch August 29, 2026 07:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants