Skip to content

Client session resume (via LoC/SASL and draft/resume-0.5) - #95

Open
empus wants to merge 4 commits into
UndernetIRC:mainfrom
empus:feat/resume
Open

empus wants to merge 4 commits into
UndernetIRC:mainfrom
empus:feat/resume

Conversation

@empus

@empus empus commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds same-server, in-memory IRCv3 session resume for secure connections. An eligible client that loses its transport is held in a limbo state for a grace window instead of quitting, and can reattach to the same session — keeping its nick, account, user modes, oper privileges, and channels — with no visible quit/rejoin to the rest of the network. Primarily designed to survive edge reployments or other short-lived interruptions of WebSocket connections.

Off by default (RESUME feature flag); no behavior change unless explicitly enabled.

What it does

  • draft/resume-0.5 capability with a bearer token (128-bit id + 256-bit secret, URL-safe base64), rotated on every resume, constant-time secret comparison. Tokens are never logged or shown.
  • Detach triggers: BRB command for voluntary suspend; automatic detach on unexpected transport loss (EOF, reset, TLS error, abnormal WebSocket close); optional detach on ping timeout (RESUME_DETACH_PINGOUT) for silent losses a proxy never propagates.
  • Account-based auto-reattach (RESUME_AUTO_ACCOUNT): an authenticated client (SASL, login-on-connect, or a services login after connecting) returning with the same nick+account reattaches with no token and no client support — the nick collision is deferred through registration. Per-account opt-out via the 0x080 account flag.
  • State preserved across the connection swap: user modes, oper privileges/snomask, class sendq/flood limits, and pre-detach away.
  • On resume: the client's own view is rebuilt (welcome burst, modes, away, per-channel JOIN/topic/NAMES) and RPL_LOGGEDIN is re-sent for authenticated sessions so a token-path resumer re-learns its account.

Eligibility & limits

  • Requires TLS. RESUME_REQUIRE_WEBSOCKET (default on) further restricts to secure WebSockets.
  • Detached sessions capped by RESUME_MAX_DETACHED; grace window by RESUME_TIMEOUT (10–300s).
  • WHOIS shows a detached session to opers/self; detach/resume/expiry operator notices via RESUME_SERVER_NOTICES.

Testing

  • Python integration tests under tests/pr_resume/ covering detach, resume, account auto-reattach, auto-detach, and the secure-WebSocket requirement.
  • Docker test configs and iauth stubs included.

Docs

doc/readme.resume (feature guide), doc/readme.features, and doc/example.conf.

empus added 4 commits August 1, 2026 21:45
Add same-server, in-memory IRCv3 session resume for secure connections: an
eligible client that loses its transport is held for a grace window instead of
quitting, and may reattach keeping its nick, account, modes, and channels.

- draft/resume-0.5 capability with bearer token (128-bit id + 256-bit secret,
  URL-safe base64), rotated on every resume; constant-time secret compare.
- BRB for voluntary suspend; auto-detach on unexpected transport loss (EOF,
  reset, TLS error, abnormal WebSocket close); optional detach on ping timeout
  (RESUME_DETACH_PINGOUT) for silent losses a proxy never propagated.
- Account-based auto-reattach (RESUME_AUTO_ACCOUNT): an authenticated client
  (SASL, login-on-connect, or a services login after connecting) that returns
  with the same nick+account reattaches with no token and no client support;
  the nick collision is deferred through registration. Per-account opt-out via
  the 0x080 account flag.
- Preserves user modes/oper privileges/snomask/class limits across the
  connection swap; restores pre-detach away.
- Eligibility requires TLS; RESUME_REQUIRE_WEBSOCKET (default TRUE) further
  restricts to secure WebSockets. Detached sessions capped by
  RESUME_MAX_DETACHED; window by RESUME_TIMEOUT (10-300s).
- WHOIS shows a detached session to opers/self; detach/resume/expiry notices
  via RESUME_SERVER_NOTICES; tokens never logged or shown.
- Off by default (RESUME). Docs in doc/readme.resume and doc/readme.features;
  tests under tests/pr_resume/.
…efer iauth nick

- Re-send RPL_LOGGEDIN (900) during replay for an authenticated session that
  resumed via token. A token-path resumer never SASLs on the new connection,
  so it never received 900 and could not tell it was still logged in -- it
  would retry SASL on the next CAP NEW, which fails when services are down
  (the very case where a server-local token still lets it back in). Gated on
  the resumer not having authenticated on this connection, so account-path
  resumers are not sent a duplicate.

- Flush the replay burst with send_queued() before restoring the SendQ
  ceiling, so a large rebuild cannot leave the queue backed up above the
  class limit and trip "Max SendQ exceeded" on the client's next message.
  Document that the ceiling is enforced at queue time, not at dispatch.

- Withhold the deferred resume nick from iauth until it is actually
  committed, and forward it only once the client keeps it, so iauth/dronescan
  never evaluate a nick the client may not end up holding.
Carry state that must survive detach->resume across the connection swap and
clean up the transient's registration state, so a resumed session no longer
leaks or corrupts server-side bookkeeping:

- Drop the transient's in-flight SASL cookie and timer before the swap frees
  it, so the SASL session table / timer can't dangle at a freed client (H0).
- Carry the accumulated nick-change penalty (con_nextnick), closing a
  BRB-reconnect nick-flood bypass (M10).
- Move the session's conf attachments (incl. any Operator block) onto the live
  connection so oper-class link accounting stays balanced (L20).
- Carry the sockhost and byte counters so the local-count bucket balances and
  session traffic isn't lost to is_ni (L21).
- Tell iauth the detached fd is gone, so it doesn't leak a record on a
  reusable fd (L19).
…etes

A nick deferred for account-based reattach was withheld from iauth. iauth
(login-on-connect) needs the client's nick to finish its registration handshake
and send its verdict. The account is derived from the username, but it isn't
applied to the client until iauth replies -- and iauth won't reply without the
nick. So the account was never set, the reattach (which needs it) never fired,
and the reconnecting client hung. Forward the nick to iauth even while deferred;
it still stays out of the client nick-hash.
@MrIron-no

Copy link
Copy Markdown
Contributor

Feedback from running this against a real deployment: a bouncer holding a persistent WebSocket uplink (client → Cloudflare → nginx → ircu, wss), reconnecting and resuming across transport drops.

Resume works well in the normal case. But we hit a reproducible FAIL RESUME INVALID_TOKEN in one specific situation, followed later by the session ending with quit (Resume timeout) — i.e. the session really was held and resumable, and the token was valid, yet the resume was refused.

What happens

It occurs when the transport dies with a hard reset rather than a clean EOF, and the client reconnects quickly. Our side saw connection reset by peer and redialled within ~1s, sending RESUME <token> about 1.4s after the drop. The server rejected it with INVALID_TOKEN; ~seconds later the old session expired with quit (Resume timeout).

Root cause

The token check in m_resume (ircd/resume.c) requires the session to already be RESUME_STATE_DETACHED:

s = resume_find(id);
if (!s || s->state != RESUME_STATE_DETACHED
    || resume_ct_memcmp(s->secret, secret, RESUME_SECRET_BYTES) != 0
    || (RESUME_REQUIRE_SAME_IP && irc_in_addr_cmp(&cli_ip(sptr), &cli_ip(s->client)))) {
    → FAIL RESUME INVALID_TOKEN

But a session only transitions to DETACHED when the server's own socket layer notices the transport is gone and calls resume_try_detach(). Across a proxy chain the client-side RST does not reach ircu immediately — Cloudflare/nginx have to propagate the close — so a fast reconnect can present a perfectly valid token while the old session is still RESUME_STATE_ATTACHED. The check then falls into the generic INVALID_TOKEN branch. The server detaches a moment later, holds the session, and finally expires it (Resume timeout) because the one resume attempt already failed and fell back to normal registration.

In other words the resume races the server's own detection of the dead socket, and loses whenever the client notices the drop first (which, for a proxied RST, is the common case).

Suggested fix

When a RESUME arrives with a matching secret and same IP for a session that is still RESUME_STATE_ATTACHED and owned by a local client, force-detach that stale transport and proceed with the resume, instead of returning INVALID_TOKEN. Possession of the constant-time-compared secret is already trusted to adopt a detached session, so it is sufficient authorization to displace the session's own now-dead connection. That removes the dependence on ircu having detected the dead socket first and closes the race.

Happy to help test a patch against the same proxied setup.

@MrIron-no

Copy link
Copy Markdown
Contributor

Follow-up with concrete timing from the same deployment, because it shows the race window is much wider than it first appears.

A drop at 20:10:33 ended with the session expiring at 20:14:09 — about 3m36s later, not the 60s RESUME_TIMEOUT. The reason is that the timeout clock starts at detach, not at the actual transport death:

Span State Duration
20:10:33 → ~20:13:09 transport dead, session still RESUME_STATE_ATTACHED (ircu hasn't noticed) ~2m36s
~20:13:09 → 20:14:09 DETACHED, held for RESUME_TIMEOUT 60s

Why detection took ~2.5 minutes. There are two detach triggers. The socket-error path in s_bsd.c (resume_try_detach(... RESET/EOF)) only fires when ircu's own read fails — but ircu's socket is to nginx, not to the client. When the client↔Cloudflare leg reset, nginx kept the upstream socket to ircu open, so ircu never saw a read error. Detection therefore fell to the RESUME_DETACH_PINGOUT path in ircd.c, which only fires once the client pings out, i.e. after the full class ping-timeout. That is what consumed the ~2.5 minutes before the session detached and the 60s window even began.

Consequence for resume. The session is only resumable during that final DETACHED window. Every resume attempt during the long ATTACHED-but-dead span hits state != RESUME_STATE_DETACHED and returns INVALID_TOKEN. A client that notices the drop immediately (any proxied RST, or an idle WebSocket cut) reconnects and resumes right away — squarely inside that span — and always loses. By the time the session becomes resumable, the client has long since fallen back to a fresh registration.

So the race isn't a millisecond edge; it is as wide as the ping-timeout, minutes long, and lost on essentially every proxied drop where the client detects loss first. That makes the force-detach-on-valid-secret behaviour I suggested above close to required for resume to work behind a proxy: a RESUME bearing the correct secret (and same IP) for a still-ATTACHED local session should displace that session's own dead-but-not-yet-noticed transport immediately, rather than waiting out the ping-timeout.

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