From ed1f1dcff030e42cd33500beabc5ad160cc0a342 Mon Sep 17 00:00:00 2001 From: Empus Date: Sat, 25 Jul 2026 23:29:49 +1000 Subject: [PATCH 1/4] feat(resume): IRCv3 session resume (draft/resume-0.5) 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/. --- .gitignore | 6 + Dockerfile | 4 + doc/example.conf | 12 + doc/readme.features | 101 +++ doc/readme.resume | 144 ++++ docker-compose.yml | 17 + include/capab.h | 4 +- include/client.h | 16 + include/ircd_features.h | 11 + include/ircd_tls.h | 8 + include/msg.h | 21 + include/numeric.h | 1 + include/resume.h | 179 ++++ include/s_auth.h | 2 + include/s_bsd.h | 1 + include/s_user.h | 1 + include/send.h | 5 + ircd/Makefile.am | 1 + ircd/ircd.c | 8 + ircd/ircd_features.c | 12 + ircd/ircd_relay.c | 11 + ircd/m_account.c | 7 + ircd/m_cap.c | 19 + ircd/m_nick.c | 11 + ircd/m_whois.c | 3 + ircd/parse.c | 15 + ircd/resume.c | 993 ++++++++++++++++++++++ ircd/s_auth.c | 59 +- ircd/s_bsd.c | 46 +- ircd/s_err.c | 2 +- ircd/s_misc.c | 10 + ircd/s_user.c | 60 +- ircd/send.c | 43 + ircd/tls_gnutls.c | 7 + ircd/tls_libtls.c | 8 + ircd/tls_none.c | 15 + ircd/tls_openssl.c | 8 + tests/conftest.py | 22 +- tests/docker/iauth-loc-stub.pl | 38 + tests/docker/ircd-acct-hub.conf | 42 + tests/docker/ircd-hub.conf | 2 + tests/docker/ircd-tls-hub.conf | 13 + tests/pr_resume/__init__.py | 0 tests/pr_resume/test_account.py | 358 ++++++++ tests/pr_resume/test_autodetach.py | 231 +++++ tests/pr_resume/test_detach.py | 155 ++++ tests/pr_resume/test_fix.py | 138 +++ tests/pr_resume/test_require_websocket.py | 137 +++ tests/pr_resume/test_resume.py | 509 +++++++++++ 49 files changed, 3491 insertions(+), 25 deletions(-) create mode 100644 doc/readme.resume create mode 100644 include/resume.h create mode 100644 ircd/resume.c create mode 100644 tests/docker/iauth-loc-stub.pl create mode 100644 tests/docker/ircd-acct-hub.conf create mode 100644 tests/pr_resume/__init__.py create mode 100644 tests/pr_resume/test_account.py create mode 100644 tests/pr_resume/test_autodetach.py create mode 100644 tests/pr_resume/test_detach.py create mode 100644 tests/pr_resume/test_fix.py create mode 100644 tests/pr_resume/test_require_websocket.py create mode 100644 tests/pr_resume/test_resume.py diff --git a/.gitignore b/.gitignore index e5b2ae5c..3c3e70bf 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,9 @@ ircu.tags tests/debug-output/* !tests/debug-output/.gitkeep tests/debug-output/failures/ + +# Python test-harness artifacts +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/Dockerfile b/Dockerfile index e1f6105e..76405425 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,6 +115,10 @@ RUN touch /opt/ircu/lib/ircd.motd && chown ircu:ircu /opt/ircu/lib/ircd.motd COPY tests/docker/iauth-tilded.pl /opt/ircu/bin/iauth-tilded.pl RUN chmod +x /opt/ircu/bin/iauth-tilded.pl && chown ircu:ircu /opt/ircu/bin/iauth-tilded.pl +# iauth login-on-connect stub (used only by the account-resume test config) +COPY tests/docker/iauth-loc-stub.pl /opt/ircu/lib/iauth-loc-stub.pl +RUN chmod 755 /opt/ircu/lib/iauth-loc-stub.pl && chown ircu:ircu /opt/ircu/lib/iauth-loc-stub.pl + COPY tests/docker/ircd-entrypoint.sh /opt/ircu/lib/ircd-entrypoint.sh RUN chmod 755 /opt/ircu/lib/ircd-entrypoint.sh diff --git a/doc/example.conf b/doc/example.conf index 2f857dbf..d9fa9f0c 100644 --- a/doc/example.conf +++ b/doc/example.conf @@ -1161,6 +1161,18 @@ features # "CAP_ACCOUNT_TAG" = "TRUE"; # Deny all client-only message tags by default (IRCv3 CLIENTTAGDENY). # "CLIENTTAGDENY" = "*"; +# Session resume (IRCv3 draft/resume-0.5). Off by default; requires TLS, and +# by default is restricted to secure WebSockets (RESUME_REQUIRE_WEBSOCKET). +# See doc/readme.features. +# "RESUME" = "FALSE"; +# "RESUME_TIMEOUT" = "60"; +# "RESUME_ALLOW_BRB" = "TRUE"; +# "RESUME_MAX_DETACHED" = "5000"; +# "RESUME_SERVER_NOTICES" = "TRUE"; +# "RESUME_AUTO_ACCOUNT" = "TRUE"; +# "RESUME_ACCOUNT_ANY_IP" = "TRUE"; +# "RESUME_DETACH_PINGOUT" = "TRUE"; +# "RESUME_REQUIRE_WEBSOCKET" = "TRUE"; # These were introduced by Undernet CFV-165 to add "Head-In-Sand" (HIS) # behavior to hide most network topology from users. # "HIS_SNOTICES" = "TRUE"; diff --git a/doc/readme.features b/doc/readme.features index c7512ed8..a7bf221b 100644 --- a/doc/readme.features +++ b/doc/readme.features @@ -885,6 +885,107 @@ this list (case-insensitive); a handshake with a non-matching or missing Origin is rejected. When empty (the default), the Origin header is not checked and any origin may connect, which is the traditional behavior. +RESUME + * Type: boolean + * Default: FALSE + +Master switch for IRCv3 session resume (draft/resume-0.5). When enabled, +the server offers the "draft/resume-0.5" capability on eligible secure +connections (see RESUME_REQUIRE_WEBSOCKET) and issues each such client a bearer +token. If an eligible client later loses its transport unexpectedly (EOF, +reset, TLS error, abnormal WebSocket loss), its session is held for a short +window instead of quitting, and the client may reconnect and resume it with its +token, keeping its nick, account, and channel memberships. Enforced exits +(QUIT, KILL, K/G-line, flood, protocol/auth failure, server shutdown) always +exit normally and are never detached; ping timeout detaches eligible clients +only when RESUME_DETACH_PINGOUT is set. When FALSE the capability is +not advertised, no tokens are issued, no client is ever detached, and behavior +is exactly as before. Session state is in memory on a single server only. + +RESUME_TIMEOUT + * Type: integer + * Default: 60 + +How many seconds a detached session is held before it is expired with an +ordinary QUIT. Clamped to the range 10-300. Changing it by rehash affects +only sessions that detach afterwards; already-detached sessions keep their +original deadline. + +RESUME_ALLOW_BRB + * Type: boolean + * Default: TRUE + +Whether an eligible client may send the BRB command to suspend its own +session (for example, before a client software update). The server replies +"BRB " and detaches the session, which can then be resumed like any +other detached session. + +RESUME_MAX_DETACHED + * Type: integer + * Default: 5000 + +Global hard limit on the number of concurrently detached sessions. When the +limit is reached, further transport losses use ordinary disconnect behavior +rather than detaching; existing detached sessions are never evicted. + +RESUME_SERVER_NOTICES + * Type: boolean + * Default: TRUE + +Whether routine server notices are emitted (to the connect/exit notice mask) +for resume detach, resume, and expiry events. Notices never contain token +material. + +RESUME_AUTO_ACCOUNT + * Type: boolean + * Default: TRUE + +Whether an authenticated client (SASL or login-on-connect) that reconnects to +the same server with the same nick and account is reattached to its detached +session automatically, without needing to support the resume capability or +present a token. The account, verified by the server, is the authorization; +the nick selects which detached session (nick is unique, so at most one). When +a capable client presents a token, that path takes precedence. Has no effect +unless RESUME is also TRUE. An account may opt out per-user: if the +authentication service sets the 0x080 bit in the account's flags, that account +is never auto-detached or auto-reattached (the token path still works). + +RESUME_ACCOUNT_ANY_IP + * Type: boolean + * Default: TRUE + +Whether account-based reattach (RESUME_AUTO_ACCOUNT) is allowed from a different +IP address than the detached session used. Because the account is a verified +identity, this is safe and enables roaming (for example a mobile client moving +between networks). When FALSE, account reattach also requires the same IP. +This does not affect the token path, which always requires the same IP. + + +RESUME_DETACH_PINGOUT + * Type: boolean + * Default: TRUE + +Whether a resume-eligible client that stops answering pings is detached (and +held for the resume window) instead of being quit with "Ping timeout". This +catches silent transport losses that no clean close ever reached the server for +-- for example a client behind a proxy that keeps the upstream socket open. +Only resume-eligible clients are affected; everything else still quits on ping +timeout as before. Detection latency is the connection class ping frequency +(timeout at twice that), so put resume WebSocket listeners in a class with a +short ping frequency. Has no effect unless RESUME is also TRUE. + +RESUME_REQUIRE_WEBSOCKET + * Type: boolean + * Default: TRUE + +Whether resume eligibility is restricted to secure WebSocket connections. The +actual security requirement for resume is TLS, so the bearer token cannot be +intercepted; this setting additionally requires that the connection be a +WebSocket. When FALSE, any TLS connection is eligible, extending resume -- +including account-based auto-reattach -- to standard TLS clients. Plain +(non-TLS) connections are never eligible either way. Has no effect unless +RESUME is also TRUE. + IPCHECK_CLONE_LIMIT * Type: integer * Default: 4 diff --git a/doc/readme.resume b/doc/readme.resume new file mode 100644 index 00000000..07d04973 --- /dev/null +++ b/doc/readme.resume @@ -0,0 +1,144 @@ +IRCv3 session resume (draft/resume-0.5) +======================================= + +Overview +-------- +Session resume lets a client that briefly loses its transport reconnect to the +same server and reattach to its existing IRC session, instead of being seen to +QUIT and having to rejoin its channels. This is aimed at web clients behind a +proxy (client -> Cloudflare -> Nginx -> ircu), where the WebSocket transport can +drop while the IRC session should remain valid. + +This design is deliberately conservative: + + * Same server only (no cross-server transfer; session state is in memory). + * Secure transport only: TLS is required so the token cannot be intercepted; + by default eligibility is further restricted to secure WebSockets, but + RESUME_REQUIRE_WEBSOCKET can be cleared to allow any TLS connection. + * No stored message history: output aimed at a detached client is dropped and + the client is warned that it may have missed messages. + * No synthetic QUIT/JOIN churn: other users and servers keep seeing the client + as online throughout a detach/resume. + +It is off by default; see the RESUME* settings in doc/readme.features. + +Client flow +----------- +On connect, a secure-WebSocket client negotiates the capability and is given a +token: + + C: CAP LS 302 + C: CAP REQ :draft/resume-0.5 + S: :server CAP ACK :draft/resume-0.5 + S: :server RESUME TOKEN + C: NICK / USER / CAP END ... + +If the transport is later lost unexpectedly, the session is held for +RESUME_TIMEOUT seconds. The client reconnects with a fresh secure WebSocket +and, during registration, presents its token: + + C: CAP REQ :draft/resume-0.5 + C: NICK / USER ... + C: RESUME + S: :server RESUME SUCCESS : + S: + S: :server WARN RESUME HISTORY_LOST :... (only if output was dropped) + S: :server RESUME TOKEN + +The new connection must pass all normal registration policy (bans, IPCheck, +IAuth, class, PASS, same client IP) before it may adopt the old session. On +success the old token is invalidated and a new one issued. + +Failures use IRCv3 standard replies and are intentionally generic so a caller +cannot probe which sessions exist: + + FAIL RESUME INVALID_TOKEN :... (unknown/expired/used/active/other IP) + FAIL RESUME INSECURE_SESSION :... (not a secure WebSocket) + FAIL RESUME REGISTRATION_IS_COMPLETED :... + +BRB +--- +An eligible client may suspend its own session (for example before a client +update) with: + + C: BRB :reason + S: :server BRB + +The server then detaches the session, which can be resumed like any other. + +Account-based automatic reattach +-------------------------------- +A client does not have to support the capability at all. When RESUME_AUTO_ACCOUNT +is set, an authenticated client that reconnects to the same server -- via SASL, +login-on-connect, or a services login after connecting -- and registers with the +same nick it had, is reattached to its detached session automatically: + + C: NICK oldnick / USER ... / (SASL or PASS login for account "oldaccount") + S: :server RESUME SUCCESS :oldnick + S: + +No token and no client changes are required. The verified account is the +authorization; the nick selects the session (a nick is unique, so it matches at +most one detached session). Reattach happens only if the detached session's +account matches the reconnecting client's account (case-insensitive) and, unless +RESUME_ACCOUNT_ANY_IP is set, the same IP. If a capable client also presents a +token, the token path takes precedence. + +Because the detached session still holds the nick, the reconnecting client keeps +its requested nick visible to iauth and services during registration but is not +itself placed in the nick table until the outcome is known; if the account does +not match, it is asked for another nick as usual. + +A user may opt out: if the authentication service includes the 0x080 bit in the +account's flags (gnuworld's X_NO_AUTO_RESUME account flag, set from a per-user +preference), the account is never auto-detached or auto-reattached. This only +affects the automatic account path; a client that presents a resume token is +unaffected. + +Messaging a detached client +--------------------------- +While detached, a session is still visible but cannot receive output (it is +dropped, and the client is warned it may have missed messages on resume). Two +independent, compile-time hints tell others about this, each disabled by setting +its string empty: + + * RESUME_DETACH_AWAY: an away message set on the session for the duration of + the detach (default "Temporarily detached, messages will be missed."), + preserving and restoring any away the user had. Anyone who messages the + client gets the usual RPL_AWAY. + * RESUME_CANNOTSEND: the context of an ERR_CANNOTSENDTOUSER (531) reply sent to + a client that PRIVMSGs (or CPRIVMSGs, or CTCPs) a detached user, and the + message is not delivered. The 531 carries a fixed "Cannot send message: " + prefix plus this context, so other callers of 531 can supply their own. + Per RFC, no reply is sent for NOTICE/CNOTICE. + +If both are set, a sender gets both the RPL_AWAY and the ERR_CANNOTSENDTOUSER. + +Operator visibility +------------------- + * WHOIS: a detached client is reported with an informational line + ("is temporarily detached (resume window: N seconds)"). By default this is + shown to operators (and the user themselves) only. + * Notices: detach, resume, and expiry emit connect/exit notices when + RESUME_SERVER_NOTICES is set. Tokens never appear in notices or logs. + +Security notes +-------------- + * Tokens are generated from the TLS backend CSPRNG, are opaque, are never sent + to other clients or servers, and are rotated on every successful resume. + * Only a detached session can be resumed; a healthy active session cannot be + taken over. + * The reconnecting connection must come from the same client IP by default. + * Account-based reattach authorizes on the server-verified account, so it is + only as strong as the login (SASL/login-on-connect); it can adopt only a + detached session that shares that account. + * The resumed client keeps its user modes, including operator mode (+o) and + privileges; re-OPER is not required. + +Limitations / draft differences +------------------------------- + * Same-server, in-memory only; sessions do not survive an ircu restart and are + not transferable across a netsplit or to another server. + * No message-history replay; the resumed client is told history may be lost. + * No synthetic QUIT/JOIN is sent to legacy observers on history loss (the + draft permits this); the whole point is to preserve visible presence. diff --git a/docker-compose.yml b/docker-compose.yml index a257afbc..9d715537 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -76,6 +76,7 @@ services: - "16699:6699" - "16700:6700" - "16701:6701" + - "16702:6702" - "14440:4440" - "14441:4441" networks: @@ -100,6 +101,22 @@ services: depends_on: - ircd-tls-hub + # Standalone secure-WebSocket hub with login-on-connect (iauth), for + # account-based resume reattach tests. Isolated from ircd-tls-hub so its + # mandatory iauth does not affect other TLS tests. + ircd-acct-hub: + build: + context: . + args: + IRCD_CONF: tests/docker/ircd-acct-hub.conf + TLS_BACKEND: ${TLS_BACKEND:-openssl} + container_name: ircu-acct-hub + ports: + - "16710:6710" + networks: + ircu-test-net: + ipv4_address: 10.55.0.22 + # The config is baked into the image (IRCD_CONF); tests push config # changes with docker cp (see tests/class_limits/helpers.py). Do NOT # bind-mount the config: macOS VM file sharing propagates host writes diff --git a/include/capab.h b/include/capab.h index 345a65b1..974066bd 100644 --- a/include/capab.h +++ b/include/capab.h @@ -37,6 +37,7 @@ #define CAPFL_PROTO 0x0008 /**< Cap must be acknowledged by client */ #define CAPFL_STICKY 0x0010 /**< Cap may not be cleared once set */ #define CAPFL_STICKY_302 0x0020 /**< Cap may not be cleared once set by users supporting LS 302 */ +#define CAPFL_SECURE_WS 0x0040 /**< Only advertise on a secure (TLS) link */ #define CAPFL_UNAVAILABLE (CAPFL_HIDDEN | CAPFL_PROHIBIT) #define CAPLIST \ @@ -51,7 +52,8 @@ _CAP(SERVER_TIME, FEAT_CAP_SERVER_TIME, 0, "server-time"), \ _CAP(ACCOUNT_TAG, FEAT_CAP_ACCOUNT_TAG, 0, "account-tag"), \ _CAP(CAPNOTIFY, 0, CAPFL_HIDDEN_302 | CAPFL_STICKY_302, "cap-notify"), \ - _CAP(SASL, FEAT_CAP_SASL, CAPFL_UNAVAILABLE, "sasl") + _CAP(SASL, FEAT_CAP_SASL, CAPFL_UNAVAILABLE, "sasl"), \ + _CAP(RESUME, FEAT_RESUME, CAPFL_SECURE_WS, "draft/resume-0.5") /** Client capabilities, counting by index. */ enum Capab { diff --git a/include/client.h b/include/client.h index 3a8b6054..dc4f3df7 100644 --- a/include/client.h +++ b/include/client.h @@ -59,6 +59,7 @@ struct Whowas; struct hostent; struct Privs; struct AuthRequest; +struct ResumeSession; /* * Structures @@ -182,6 +183,7 @@ enum Flag FLAG_SPAMHOLD, /**< user is the sender or recipient of a message on hold */ FLAG_HIDEIDLE, /**< Hide idle time from non-opers */ FLAG_COMMONCHANS, /**< only accepts messages from users in common channels */ + FLAG_DETACH, /**< session detached (transport lost, awaiting resume/expiry) */ FLAG_LAST_FLAG, /**< number of flags */ FLAG_LOCAL_UMODES = FLAG_LOCOP, /**< First local mode flag */ FLAG_GLOBAL_UMODES = FLAG_OPER, /**< First global mode flag */ @@ -265,6 +267,7 @@ struct Connection const struct wline* con_wline; /**< WebIRC authorization for client */ uint64_t con_sasl; /**< SASL session cookie */ struct Timer con_sasl_timer; /**< SASL timeout timer */ + struct ResumeSession* con_resume_claim; /**< session this registering client is resuming */ char* con_rexmit; /**< TLS retransmission data */ size_t con_rexmit_len; /**, TLS retransmission length */ }; @@ -282,6 +285,7 @@ struct Client { struct User* cli_user; /**< Defined if this client is a user */ struct Server* cli_serv; /**< Defined if this client is a server */ struct Whowas* cli_whowas; /**< Pointer to ww struct to be freed on quit */ + struct ResumeSession* cli_resume; /**< Session-resume metadata, if any */ char cli_yxx[4]; /**< Numeric Nick: YY if this is a server, XXX if this is a user */ time_t cli_firsttime; /**< time client was created */ @@ -320,6 +324,8 @@ struct Client { #define cli_serv(cli) ((cli)->cli_serv) /** Get Whowas link for client. */ #define cli_whowas(cli) ((cli)->cli_whowas) +/** Get session-resume metadata for client, if any. */ +#define cli_resume(cli) ((cli)->cli_resume) /** Get client numnick. */ #define cli_yxx(cli) ((cli)->cli_yxx) /** Get time we last read data from the client socket. */ @@ -423,6 +429,8 @@ struct Client { #define cli_sentalong(cli) con_sentalong(cli_connect(cli)) /** Get SASL session cookie for client. */ #define cli_sasl(cli) con_sasl(cli_connect(cli)) +/** Get the resume session this registering client is adopting, if any. */ +#define cli_resume_claim(cli) con_resume_claim(cli_connect(cli)) /** Get SASL timeout timer for client. */ #define cli_sasl_timer(cli) (&con_sasl_timer(cli_connect(cli))) /** Get the WebSocket mode for the client. */ @@ -512,6 +520,8 @@ struct Client { #define con_wline(con) ((con)->con_wline) /** Get the SASL session cookie for the connection. */ #define con_sasl(con) ((con)->con_sasl) +/** Get the resume session a registering connection is adopting, if any. */ +#define con_resume_claim(con) ((con)->con_resume_claim) /** Get the SASL timeout timer for the connection. */ #define con_sasl_timer(con) ((con)->con_sasl_timer) /** Get the WebSocket mode for the connection. */ @@ -657,6 +667,8 @@ struct Client { #define IsPingSent(x) HasFlag(x, FLAG_PINGSENT) /** Return non-zero if the client is using TLS. */ #define IsTLS(x) HasFlag(x, FLAG_TLS) +/** Return non-zero if the client's session is detached (no live transport). */ +#define IsDetached(x) HasFlag(x, FLAG_DETACH) /** Return non-zero if the client is (re-)negotiating TLS. */ #define IsNegotiatingTLS(x) HasFlag(x, FLAG_NEGOTIATING_TLS) /** Return non-zero if the client is the sender or recipient of a message on hold (spamfilter) */ @@ -719,6 +731,10 @@ struct Client { #define SetPingSent(x) SetFlag(x, FLAG_PINGSENT) /** Mark a client as using TLS. */ #define SetTLS(x) SetFlag(x, FLAG_TLS) +/** Mark a client's session as detached. */ +#define SetDetach(x) SetFlag(x, FLAG_DETACH) +/** Clear a client's detached mark. */ +#define ClearDetach(x) ClrFlag(x, FLAG_DETACH) /** Mark a client as (re-)negotiating TLS. */ #define SetNegotiatingTLS(x) SetFlag(x, FLAG_NEGOTIATING_TLS) /** Mark a client as being the sender or recipient of a message on hold (spamfilter). */ diff --git a/include/ircd_features.h b/include/ircd_features.h index 9984d0ad..4234d457 100644 --- a/include/ircd_features.h +++ b/include/ircd_features.h @@ -130,6 +130,17 @@ enum Feature { /* IRCv3 CLIENTTAGDENY: deny-list / allow-list for client-only (+) tags */ FEAT_CLIENTTAGDENY, + /* Session resume (draft/resume-0.5) */ + FEAT_RESUME, + FEAT_RESUME_TIMEOUT, + FEAT_RESUME_ALLOW_BRB, + FEAT_RESUME_MAX_DETACHED, + FEAT_RESUME_SERVER_NOTICES, + FEAT_RESUME_AUTO_ACCOUNT, + FEAT_RESUME_ACCOUNT_ANY_IP, + FEAT_RESUME_DETACH_PINGOUT, + FEAT_RESUME_REQUIRE_WEBSOCKET, + /* HEAD_IN_SAND Features */ FEAT_HIS_SNOTICES, FEAT_HIS_SNOTICES_OPER_ONLY, diff --git a/include/ircd_tls.h b/include/ircd_tls.h index f1c9dd67..7f0ee96a 100644 --- a/include/ircd_tls.h +++ b/include/ircd_tls.h @@ -268,4 +268,12 @@ IOResult ircd_tls_sendv(struct Client *cptr, struct MsgQ *buf, */ int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen); +/** Fill \a buf with \a len cryptographically secure random bytes. + * Drawn from the active TLS backend's CSPRNG; the tls_none build falls back + * to /dev/urandom. This is the only approved source for security tokens -- + * ircrandom() is a non-cryptographic PRNG and must not be used for them. + * \returns 0 on success, -1 on failure (fails closed; never partial). + */ +int ircd_tls_random_bytes(void *buf, size_t len); + #endif /* INCLUDED_ircd_tls_h */ diff --git a/include/msg.h b/include/msg.h index a7dc682c..b9c69f71 100644 --- a/include/msg.h +++ b/include/msg.h @@ -388,6 +388,27 @@ struct Client; #define TOK_AUTHENTICATE "AUTHENTICATE" #define CMD_AUTHENTICATE MSG_AUTHENTICATE, TOK_AUTHENTICATE +#define MSG_RESUME "RESUME" +#define TOK_RESUME "RESUME" +#define CMD_RESUME MSG_RESUME, TOK_RESUME + +#define MSG_BRB "BRB" +#define TOK_BRB "BRB" +#define CMD_BRB MSG_BRB, TOK_BRB + +/* IRCv3 standard replies (server -> client only) */ +#define MSG_FAIL "FAIL" +#define TOK_FAIL "FAIL" +#define CMD_FAIL MSG_FAIL, TOK_FAIL + +#define MSG_WARN "WARN" +#define TOK_WARN "WARN" +#define CMD_WARN MSG_WARN, TOK_WARN + +#define MSG_NOTE "NOTE" +#define TOK_NOTE "NOTE" +#define CMD_NOTE MSG_NOTE, TOK_NOTE + #define MSG_CONFIG "CONFIG" #define TOK_CONFIG "CF" #define CMD_CONFIG MSG_CONFIG, TOK_CONFIG diff --git a/include/numeric.h b/include/numeric.h index 30a52df6..c3fdc295 100644 --- a/include/numeric.h +++ b/include/numeric.h @@ -460,6 +460,7 @@ extern const struct Numeric* get_error_numeric(int err); ERR_WHOLIMEXCEED 523 dalnet */ #define ERR_QUARANTINED 524 /* Undernet extension -Vampire */ #define ERR_INVALIDKEY 525 /* Undernet extension */ +#define ERR_CANNOTSENDTOUSER 531 /* Undernet extension */ #define ERR_TLSCLIFINGERPRINT 532 /* Nefarious & Undernet extension */ #define ERR_NOTLOWEROPLEVEL 560 /* Undernet extension */ diff --git a/include/resume.h b/include/resume.h new file mode 100644 index 00000000..34e02328 --- /dev/null +++ b/include/resume.h @@ -0,0 +1,179 @@ +#ifndef INCLUDED_resume_h +#define INCLUDED_resume_h +/* + * IRC - Internet Relay Chat, include/resume.h + * Copyright (C) 2026 Undernet IRC development team + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief Public interface for IRCv3 session resume (draft/resume-0.5). + * @version $Id$ + */ + +#ifndef INCLUDED_ircd_events_h +#include "ircd_events.h" /* struct Timer */ +#endif +#ifndef INCLUDED_sys_types_h +#include /* size_t */ +#define INCLUDED_sys_types_h +#endif +#ifndef INCLUDED_time_h +#include /* time_t */ +#define INCLUDED_time_h +#endif + +struct Client; + +/* + * Compile-time constants and policy. + * + * These fix struct layout, token entropy, and the token wire format, so they + * are compiled in rather than made runtime features. Operator-tunable policy + * lives in the feature system (see FEAT_RESUME* in ircd_features.h). + */ +/** Bytes of public session id (128 bits). */ +#define RESUME_ID_BYTES 16 +/** Bytes of bearer secret (256 bits). */ +#define RESUME_SECRET_BYTES 32 +/** Maximum accepted token length; longer input is rejected before parsing. */ +#define RESUME_TOKEN_MAX 256 +/** Lower/upper clamp for the RESUME_TIMEOUT feature value, in seconds. */ +#define RESUME_TIMEOUT_MIN 10 +#define RESUME_TIMEOUT_MAX 300 +/** Same-IP resume is required and attempts are capped for every server. */ +#define RESUME_REQUIRE_SAME_IP 1 +#define RESUME_MAX_ATTEMPTS 3 + +/** Account-flags bit (from the auth service) opting an account out of + * account-based auto-reattach; the token path is unaffected. */ +#define RESUME_ACC_NO_AUTO 0x080 + +/** Away set on a detached session (prior away restored on reattach); empty + * disables it. */ +#define RESUME_DETACH_AWAY "Temporarily detached, messages will be missed." + +/** Context appended after the ERR_CANNOTSENDTOUSER (531) prefix when a message + * is sent to a detached client; empty disables the 531 reply. */ +#define RESUME_CANNOTSEND "recipient is temporarily detached" + +/** Who may see that a client is temporarily detached in WHOIS. */ +enum ResumeWhois { + RESUME_WHOIS_OFF = 0, /**< never shown */ + RESUME_WHOIS_OPERS, /**< opers (and the user themselves) only */ + RESUME_WHOIS_ALL /**< any requester */ +}; +/** Fixed WHOIS disclosure policy. */ +#define RESUME_WHOIS_POLICY RESUME_WHOIS_OPERS + +/** Authoritative lifecycle state of a resumable session. */ +enum ResumeState { + RESUME_STATE_NONE = 0, /**< not resumable */ + RESUME_STATE_ATTACHED, /**< live transport present */ + RESUME_STATE_DETACHED, /**< transport lost, awaiting resume or expiry */ + RESUME_STATE_CLAIMING /**< a resume attempt is adopting this session */ +}; + +/** Why a session detached (kept typed, never parsed from display strings). */ +enum ResumeDetachReason { + RESUME_DETACH_NONE = 0, + RESUME_DETACH_EOF, + RESUME_DETACH_RESET, + RESUME_DETACH_TLS_ERROR, + RESUME_DETACH_WS_ABNORMAL, + RESUME_DETACH_PING_TIMEOUT, + RESUME_DETACH_BRB +}; + +/** Per-session resume metadata, associated with the network-visible Client. */ +struct ResumeSession { + enum ResumeState state; /**< authoritative lifecycle state */ + + struct Client *client; /**< owning network-visible client */ + struct Client *claimant; /**< new client adopting this (CLAIMING) */ + int discarding; /**< set when the session is being freed */ + + unsigned char id[RESUME_ID_BYTES]; /**< public lookup key */ + unsigned char secret[RESUME_SECRET_BYTES]; /**< constant-time compared */ + + time_t detached_at; /**< when detach happened (0 if attached) */ + time_t expires_at; /**< detach deadline (0 if attached) */ + + unsigned int generation; /**< bumped on every token rotation */ + unsigned int attempts; /**< resume attempts seen (rate limiting) */ + int history_lost; /**< output was discarded while detached */ + char *saved_away; /**< away held before detach, restored on resume */ + int away_overridden; /**< a detach-away replaced the user's away */ + + enum ResumeDetachReason detach_reason;/**< why the session detached */ + struct Timer expiry_timer; /**< one-shot detach-expiry timer */ + + struct ResumeSession *hnext; /**< resumeTable[] bucket chain */ +}; + +/* + * Module lifecycle / configuration. + */ +extern void resume_init(void); +extern void resume_feat_notify(void); +extern int resume_enabled(void); +extern int resume_conf_timeout(void); +extern int resume_conf_max_detached(void); + +/* + * Eligibility. + */ +extern int resume_is_capable_transport(const struct Client *cptr); + +/* + * Token / session lifecycle. + */ +extern void resume_token_issue(struct Client *cptr); +extern void resume_session_ensure(struct Client *cptr); +extern void resume_session_invalidate(struct Client *cptr); +extern struct ResumeSession *resume_find(const unsigned char *id); + +/* + * Detach / expiry. + */ +extern void resume_detach(struct Client *cptr, enum ResumeDetachReason reason); +extern int resume_try_detach(struct Client *cptr, enum ResumeDetachReason reason); +extern void resume_mark_history_lost(struct Client *cptr); + +/* + * RESUME command and reattachment. + */ +extern int m_resume(struct Client *cptr, struct Client *sptr, + int parc, char *parv[]); +extern int m_brb(struct Client *cptr, struct Client *sptr, + int parc, char *parv[]); +extern int resume_complete(struct Client *new_client); +extern void resume_release_claim(struct Client *cptr); + +/* + * Account-based auto-reattach (same nick + account, no client support needed). + */ +extern int resume_account_deferrable(const struct Client *sptr, + const struct Client *acptr); +extern int resume_account_try_claim(struct Client *new_client, + struct Client *target); + +/* + * WHOIS. + */ +extern void resume_send_whois(struct Client *sptr, struct Client *acptr, + const char *name); + +#endif /* INCLUDED_resume_h */ diff --git a/include/s_auth.h b/include/s_auth.h index 2b6aecef..41f914bb 100644 --- a/include/s_auth.h +++ b/include/s_auth.h @@ -41,6 +41,8 @@ extern int auth_set_user(struct AuthRequest *auth, const char *username, const c extern int auth_set_nick(struct AuthRequest *auth, const char *nickname); extern int auth_set_password(struct AuthRequest *auth, const char *password); extern int auth_set_account(struct AuthRequest *auth, const char *account_info); +extern int auth_defer_resume_nick(struct Client *cptr, const char *nick); +extern void auth_forget_resume_nick(struct Client *cptr); extern int auth_cap_start(struct AuthRequest *auth); extern int auth_cap_done(struct AuthRequest *auth); extern int auth_spoof_user(struct AuthRequest *auth, const char *username, const char *hostname, const char *ip); diff --git a/include/s_bsd.h b/include/s_bsd.h index 8b2231dc..ac1f8aa2 100644 --- a/include/s_bsd.h +++ b/include/s_bsd.h @@ -63,6 +63,7 @@ extern unsigned int deliver_it(struct Client *cptr, struct MsgQ *buf); extern int connect_server(struct ConfItem* aconf, struct Client* by); extern int net_close_unregistered_connections(struct Client* source); extern void close_connection(struct Client *cptr); +extern void detach_connection(struct Client *cptr); extern void add_connection(struct Listener* listener, int fd); extern int read_message(time_t delay); extern void init_server_identity(void); diff --git a/include/s_user.h b/include/s_user.h index 4c4d63c1..a7a78237 100644 --- a/include/s_user.h +++ b/include/s_user.h @@ -67,6 +67,7 @@ typedef void (*InfoFormatter)(struct Client* who, struct Client *sptr, struct Ms extern struct User* make_user(struct Client *cptr); extern void free_user(struct User *user); extern int register_user(struct Client* cptr, struct Client *sptr); +extern void send_welcome(struct Client *sptr); extern void user_count_memory(size_t* count_out, size_t* bytes_out); diff --git a/include/send.h b/include/send.h index fdf1f97f..09cbdcb2 100644 --- a/include/send.h +++ b/include/send.h @@ -50,6 +50,11 @@ extern void sendcmdto_one(struct Client *from, const char *cmd, const char *tok, struct Client *to, const char *pattern, ...); +/* Send an IRCv3 standard reply (FAIL/WARN/NOTE) to one local client */ +extern void sendstdreply(struct Client *to, const char *severity, + const char *command, const char *code, + const char *pattern, ...); + /* Same as above, except it puts the message on the priority queue */ extern void sendcmdto_prio_one(struct Client *from, const char *cmd, const char *tok, struct Client *to, diff --git a/ircd/Makefile.am b/ircd/Makefile.am index 665ea4dc..e2ca448b 100644 --- a/ircd/Makefile.am +++ b/ircd/Makefile.am @@ -129,6 +129,7 @@ ircd_SOURCES = \ parse.c \ querycmds.c \ random.c \ + resume.c \ s_auth.c \ s_bsd.c \ s_conf.c \ diff --git a/ircd/ircd.c b/ircd/ircd.c index ef6a5775..a7eb9068 100644 --- a/ircd/ircd.c +++ b/ircd/ircd.c @@ -49,6 +49,7 @@ #include "opercmds.h" #include "parse.h" #include "res.h" +#include "resume.h" #include "s_auth.h" #include "s_bsd.h" #include "s_conf.h" @@ -451,6 +452,12 @@ static void check_pings(struct Event* ev) { sendto_opmask_butone(0, SNO_OLDSNO, "No response from %s, closing link", cli_name(cptr)); + /* A resume-eligible client that stops answering pings (e.g. a silent + * transport loss a proxy never propagated) detaches and is held for the + * resume window instead of quitting, so it can still reattach. */ + if (feature_bool(FEAT_RESUME_DETACH_PINGOUT) + && resume_try_detach(cptr, RESUME_DETACH_PING_TIMEOUT)) + continue; exit_client_msg(cptr, cptr, &me, "Ping timeout"); continue; } @@ -731,6 +738,7 @@ int main(int argc, char **argv) { initmsgtree(); initstats(); sasl_init(); + resume_init(); /* we need this for now, when we're modular this should be removed -- hikari */ diff --git a/ircd/ircd_features.c b/ircd/ircd_features.c index 420df4c9..8c14f697 100644 --- a/ircd/ircd_features.c +++ b/ircd/ircd_features.c @@ -39,6 +39,7 @@ #include "numeric.h" #include "numnicks.h" #include "random.h" /* random_seed_set */ +#include "resume.h" /* resume_feat_notify */ #include "s_bsd.h" #include "s_debug.h" #include "s_misc.h" @@ -404,6 +405,17 @@ static struct FeatureDesc { * Default "*" denies all; empty (FEAT_NULL) allows all. Rebuilds via notify. */ F_S(CLIENTTAGDENY, FEAT_NULL, "*", feature_notify_clienttagdeny), + /* Session resume (draft/resume-0.5) */ + F_B(RESUME, 0, 0, 0), + F_I(RESUME_TIMEOUT, 0, 60, resume_feat_notify), + F_B(RESUME_ALLOW_BRB, 0, 1, 0), + F_I(RESUME_MAX_DETACHED, 0, 5000, resume_feat_notify), + F_B(RESUME_SERVER_NOTICES, 0, 1, 0), + F_B(RESUME_AUTO_ACCOUNT, 0, 1, 0), + F_B(RESUME_ACCOUNT_ANY_IP, 0, 1, 0), + F_B(RESUME_DETACH_PINGOUT, 0, 1, 0), + F_B(RESUME_REQUIRE_WEBSOCKET, 0, 1, 0), + /* HEAD_IN_SAND Features */ F_B(HIS_SNOTICES, 0, 1, 0), F_B(HIS_SNOTICES_OPER_ONLY, 0, 1, 0), diff --git a/ircd/ircd_relay.c b/ircd/ircd_relay.c index 57f02b00..b0efba6a 100644 --- a/ircd/ircd_relay.c +++ b/ircd/ircd_relay.c @@ -59,6 +59,7 @@ #include "msg.h" #include "numeric.h" #include "numnicks.h" +#include "resume.h" #include "s_debug.h" #include "s_misc.h" #include "s_user.h" @@ -514,6 +515,16 @@ void relay_private_message(struct Client* sptr, const char* name, const char* te */ if (cli_user(acptr) && cli_user(acptr)->away) send_reply(sptr, RPL_AWAY, cli_name(acptr), cli_user(acptr)->away); + /* + * a detached client cannot receive the message; tell the sender + */ + if (IsDetached(acptr)) { + const char *cannot = RESUME_CANNOTSEND; + if (*cannot) { + send_reply(sptr, ERR_CANNOTSENDTOUSER, cli_name(acptr), cannot); + return; + } + } /* * deliver the message */ diff --git a/ircd/m_account.c b/ircd/m_account.c index 9bea20b0..3052e516 100644 --- a/ircd/m_account.c +++ b/ircd/m_account.c @@ -88,6 +88,7 @@ #include "ircd_string.h" #include "msg.h" #include "numnicks.h" +#include "resume.h" #include "s_conf.h" #include "s_debug.h" #include "s_user.h" @@ -185,6 +186,12 @@ int ms_account(struct Client* cptr, struct Client* sptr, int parc, "flags %qu", parv[2], cli_user(acptr)->acc_flags)); } + /* A local secure client that authenticates after connecting (via services, + not login-on-connect/SASL) becomes reattachable by account too. Called + after acc_flags is set so a RESUME_ACC_NO_AUTO opt-out is honored. */ + if (MyConnect(acptr)) + resume_session_ensure(acptr); + /* Flag-only / same-name ACCOUNT updates for already-authed users * confuse peers on u2.10.12.19 and earlier (they protocol_violate on * any second ACCOUNT). u2.10.13.0 tolerates same-name locally; do not diff --git a/ircd/m_cap.c b/ircd/m_cap.c index 91cd7e09..d07aa424 100644 --- a/ircd/m_cap.c +++ b/ircd/m_cap.c @@ -35,6 +35,7 @@ #include "ircd_string.h" #include "msg.h" #include "numeric.h" +#include "resume.h" #include "send.h" #include "s_auth.h" #include "s_user.h" @@ -213,6 +214,15 @@ send_caplist(struct Client *sptr, capset_t set, if (!set && HasFlag(sptr, FLAG_CAP302) && (flags & CAPFL_HIDDEN_302)) continue; + /* Some capabilities need a secure transport: TLS always, plus a WebSocket + * unless RESUME_REQUIRE_WEBSOCKET is cleared. */ + if (flags & CAPFL_SECURE_WS) { + if (!IsTLS(sptr)) + continue; + if (feature_bool(FEAT_RESUME_REQUIRE_WEBSOCKET) && !IsWebsocket(sptr)) + continue; + } + /* This is a little bit subtle, but just involves applying de * Morgan's laws to the obvious check: We must display the * capability if (and only if) it is set in \a rem or \a set, or @@ -330,6 +340,15 @@ cap_req(struct Client *sptr, const char *caplist) cli_capab(sptr) = cs; cli_active(sptr) = as; + /* Issue a resume token when the capability is first gained, or invalidate + the session when it is dropped. */ + if (CapHas(cli_active(sptr), CAP_RESUME)) { + if (!cli_resume(sptr)) + resume_token_issue(sptr); + } else if (cli_resume(sptr)) { + resume_session_invalidate(sptr); + } + return 0; } diff --git a/ircd/m_nick.c b/ircd/m_nick.c index 8b62235c..f0e0902a 100644 --- a/ircd/m_nick.c +++ b/ircd/m_nick.c @@ -93,6 +93,8 @@ #include "msg.h" #include "numeric.h" #include "numnicks.h" +#include "resume.h" +#include "s_auth.h" #include "s_debug.h" #include "s_misc.h" #include "s_user.h" @@ -252,6 +254,15 @@ int m_nick(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) exit_client(cptr, acptr, &me, "Overridden by other sign on"); return set_nick_name(cptr, sptr, nick, parc, parv); } + /* + * If the collision is with a detached, resume-eligible session, defer the + * decision: keep the requested nick (so iauth/dronescan see it) without + * hashing it, and let registration adopt that session once the account is + * known (see auth_defer_resume_nick() and check_auth_finished()). + */ + if (resume_account_deferrable(sptr, acptr)) + return auth_defer_resume_nick(sptr, nick); + /* * NICK is coming from local client connection. Just * send error reply and ignore the command. diff --git a/ircd/m_whois.c b/ircd/m_whois.c index e5a0e1e8..162dd4ec 100644 --- a/ircd/m_whois.c +++ b/ircd/m_whois.c @@ -93,6 +93,7 @@ #include "msg.h" #include "numeric.h" #include "numnicks.h" +#include "resume.h" #include "s_conf.h" #include "s_serv.h" #include "s_user.h" @@ -213,6 +214,8 @@ static void do_whois(struct Client* sptr, struct Client *acptr, int parc) send_reply(sptr, RPL_WHOISSECURE, name, is_secure_path(acptr, sptr) ? " (secure network path)" : ""); + resume_send_whois(sptr, acptr, name); + if (SeeOper(sptr,acptr)) send_reply(sptr, RPL_WHOISOPERATOR, name); diff --git a/ircd/parse.c b/ircd/parse.c index 53f85497..90a3e5fe 100644 --- a/ircd/parse.c +++ b/ircd/parse.c @@ -43,6 +43,7 @@ #include "opercmds.h" #include "querycmds.h" #include "res.h" +#include "resume.h" #include "s_bsd.h" #include "s_conf.h" #include "s_debug.h" @@ -494,6 +495,20 @@ struct Message msgtab[] = { /* UNREG, CLIENT, SERVER, OPER, SERVICE */ { m_unregistered, m_not_oper, m_ignore, mo_close, m_ignore } }, + { + MSG_RESUME, + TOK_RESUME, + 0, MAXPARA, MFLG_SLOW, 0, NULL, + /* UNREG, CLIENT, SERVER, OPER, SERVICE */ + { m_resume, m_resume, m_ignore, m_resume, m_ignore } + }, + { + MSG_BRB, + TOK_BRB, + 0, MAXPARA, MFLG_SLOW, 0, NULL, + /* UNREG, CLIENT, SERVER, OPER, SERVICE */ + { m_unregistered, m_brb, m_ignore, m_brb, m_ignore } + }, { MSG_SILENCE, TOK_SILENCE, diff --git a/ircd/resume.c b/ircd/resume.c new file mode 100644 index 00000000..bfaf5086 --- /dev/null +++ b/ircd/resume.c @@ -0,0 +1,993 @@ +/* + * IRC - Internet Relay Chat, ircd/resume.c + * Copyright (C) 2026 Undernet IRC development team + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ +/** @file + * @brief IRCv3 session resume (draft/resume-0.5). + * + * Issues a secure bearer token to a registered secure-WebSocket client and + * keeps a lookup registry keyed by a random session id. Handles detaching a + * session on transport loss, resuming/reattaching a client to it, and safely + * expiring detached sessions. + */ +#include "config.h" + +#include "resume.h" +#include "capab.h" +#include "channel.h" +#include "client.h" +#include "ircd.h" +#include "ircd_alloc.h" +#include "ircd_features.h" +#include "ircd_log.h" +#include "ircd_reply.h" +#include "ircd_snprintf.h" +#include "ircd_string.h" +#include "ircd_tls.h" +#include "msg.h" +#include "numeric.h" +#include "res.h" +#include "s_auth.h" +#include "s_bsd.h" +#include "s_misc.h" +#include "s_user.h" +#include "send.h" + +#include +#include + +/** Number of buckets in the resume-session table (prime). */ +#define RESUME_HASHSIZE 4001 + +/** Session id -> ResumeSession lookup, chained on ResumeSession.hnext. */ +static struct ResumeSession *resumeTable[RESUME_HASHSIZE]; + +/** Live sessions currently in the table. */ +static unsigned int resume_count; +/** Tokens issued, including rotations (statistics). */ +static unsigned int resume_total_issued; +/** Sessions currently detached. */ +static unsigned int resume_cur_detached; +/** Peak concurrent detached sessions. */ +static unsigned int resume_peak_detached; +/** Total detachments over the server's lifetime. */ +static unsigned int resume_total_detached; +/** Detached sessions that expired without being resumed. */ +static unsigned int resume_total_expired; +/** Sessions successfully resumed onto a new connection. */ +static unsigned int resume_total_resumed; + +/** Cached, clamped copies of integer features. Seeded at init and refreshed + * by resume_feat_notify() on every set/rehash, because the feature framework + * does not fire notify callbacks for defaults at startup. */ +static int resume_timeout_v; +static int resume_max_detached_v; + +/** URL-safe base64 alphabet (RFC 4648 ยง5), no padding. */ +static const char resume_b64url[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/** Encode \a inlen bytes of \a in as unpadded base64url into \a out. + * @return number of characters written (excluding NUL), or -1 if \a out is + * too small. + */ +static int +resume_b64url_encode(const unsigned char *in, size_t inlen, + char *out, size_t outlen) +{ + size_t i, o = 0; + + for (i = 0; i < inlen; i += 3) { + size_t rem = inlen - i; + unsigned int n = (unsigned int)in[i] << 16; + int nchars = (rem >= 3) ? 4 : (rem == 2 ? 3 : 2); + + if (rem > 1) + n |= (unsigned int)in[i + 1] << 8; + if (rem > 2) + n |= (unsigned int)in[i + 2]; + + if (o + (size_t)nchars + 1 > outlen) /* +1 for the NUL */ + return -1; + + out[o++] = resume_b64url[(n >> 18) & 0x3f]; + out[o++] = resume_b64url[(n >> 12) & 0x3f]; + if (nchars > 2) + out[o++] = resume_b64url[(n >> 6) & 0x3f]; + if (nchars > 3) + out[o++] = resume_b64url[n & 0x3f]; + } + + out[o] = '\0'; + return (int)o; +} + +/** Hash a session id to a table bucket (FNV-1a; ids are already random). */ +static unsigned int +resume_hash(const unsigned char *id) +{ + unsigned int h = 2166136261u; + int i; + + for (i = 0; i < RESUME_ID_BYTES; i++) { + h ^= id[i]; + h *= 16777619u; + } + return h % RESUME_HASHSIZE; +} + +/** Insert \a s into the lookup table. */ +static void +resume_hash_add(struct ResumeSession *s) +{ + unsigned int b = resume_hash(s->id); + + s->hnext = resumeTable[b]; + resumeTable[b] = s; + resume_count++; +} + +/** Remove \a s from the lookup table if present. */ +static void +resume_hash_remove(struct ResumeSession *s) +{ + unsigned int b = resume_hash(s->id); + struct ResumeSession **pp = &resumeTable[b]; + + while (*pp) { + if (*pp == s) { + *pp = s->hnext; + s->hnext = NULL; + if (resume_count) + resume_count--; + return; + } + pp = &(*pp)->hnext; + } +} + +struct ResumeSession * +resume_find(const unsigned char *id) +{ + struct ResumeSession *s; + + for (s = resumeTable[resume_hash(id)]; s; s = s->hnext) + if (0 == memcmp(s->id, id, RESUME_ID_BYTES)) /* id is public, not a secret */ + return s; + return NULL; +} + +/** Fill \a s->id with a fresh random id not already in the table. + * @return 0 on success, -1 if the CSPRNG failed repeatedly. + */ +static int +resume_gen_id(struct ResumeSession *s) +{ + int tries; + + for (tries = 0; tries < 8; tries++) { + if (ircd_tls_random_bytes(s->id, RESUME_ID_BYTES) != 0) + return -1; + if (!resume_find(s->id)) + return 0; + } + return -1; +} + +/** Encode the "." bearer token for \a s. */ +static int +resume_token_encode(const struct ResumeSession *s, char *out, size_t outlen) +{ + char idpart[32]; + char secpart[64]; + + if (resume_b64url_encode(s->id, RESUME_ID_BYTES, idpart, sizeof(idpart)) < 0) + return -1; + if (resume_b64url_encode(s->secret, RESUME_SECRET_BYTES, + secpart, sizeof(secpart)) < 0) + return -1; + if ((size_t)ircd_snprintf(0, out, outlen, "%s.%s", idpart, secpart) >= outlen) + return -1; + return 0; +} + +/** Decode one base64url character to its 6-bit value, or -1 if invalid. */ +static int +resume_b64url_val(char c) +{ + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '-') return 62; + if (c == '_') return 63; + return -1; +} + +/** Decode unpadded base64url \a in into \a out (at most \a outcap bytes). + * @return 0 on success with *outlen set, -1 on any invalid input or overflow. + */ +static int +resume_b64url_decode(const char *in, size_t inlen, unsigned char *out, + size_t outcap, size_t *outlen) +{ + size_t i = 0, o = 0; + + while (i < inlen) { + size_t rem = inlen - i; + int v0, v1, v2, v3; + + if (rem < 2) /* a lone trailing character is never valid */ + return -1; + if ((v0 = resume_b64url_val(in[i])) < 0 + || (v1 = resume_b64url_val(in[i + 1])) < 0) + return -1; + if (o >= outcap) + return -1; + out[o++] = (unsigned char)((v0 << 2) | (v1 >> 4)); + + if (rem == 2) + break; + if ((v2 = resume_b64url_val(in[i + 2])) < 0) + return -1; + if (o >= outcap) + return -1; + out[o++] = (unsigned char)(((v1 & 0x0f) << 4) | (v2 >> 2)); + + if (rem == 3) + break; + if ((v3 = resume_b64url_val(in[i + 3])) < 0) + return -1; + if (o >= outcap) + return -1; + out[o++] = (unsigned char)(((v2 & 0x03) << 6) | v3); + i += 4; + } + + *outlen = o; + return 0; +} + +/** Parse a bearer token "." into raw \a id and \a secret. + * Rejects malformed or wrong-length input without allocating. + * @return 0 on success, -1 on any error. + */ +static int +resume_token_parse(const char *token, unsigned char *id, unsigned char *secret) +{ + const char *dot; + size_t tlen, idtext, sectext, idlen, seclen; + + if (BadPtr(token)) + return -1; + tlen = strlen(token); + if (tlen < 3 || tlen > RESUME_TOKEN_MAX) + return -1; + + dot = strchr(token, '.'); + if (!dot || dot == token || dot[1] == '\0') + return -1; + + idtext = (size_t)(dot - token); + sectext = tlen - idtext - 1; + + if (resume_b64url_decode(token, idtext, id, RESUME_ID_BYTES, &idlen) != 0 + || idlen != RESUME_ID_BYTES) + return -1; + if (resume_b64url_decode(dot + 1, sectext, secret, RESUME_SECRET_BYTES, + &seclen) != 0 + || seclen != RESUME_SECRET_BYTES) + return -1; + return 0; +} + +/** Constant-time comparison of \a n bytes. Returns 0 iff equal. */ +static int +resume_ct_memcmp(const void *a, const void *b, size_t n) +{ + const volatile unsigned char *pa = a; + const volatile unsigned char *pb = b; + unsigned char r = 0; + size_t i; + + for (i = 0; i < n; i++) + r |= (unsigned char)(pa[i] ^ pb[i]); + return r; +} + +/* + * Public interface. + */ + +int +resume_enabled(void) +{ + return feature_bool(FEAT_RESUME); +} + +int +resume_conf_timeout(void) +{ + return resume_timeout_v; +} + +int +resume_conf_max_detached(void) +{ + return resume_max_detached_v; +} + +void +resume_feat_notify(void) +{ + int t = feature_int(FEAT_RESUME_TIMEOUT); + + if (t < RESUME_TIMEOUT_MIN) + t = RESUME_TIMEOUT_MIN; + else if (t > RESUME_TIMEOUT_MAX) + t = RESUME_TIMEOUT_MAX; + resume_timeout_v = t; + + resume_max_detached_v = feature_int(FEAT_RESUME_MAX_DETACHED); + if (resume_max_detached_v < 0) + resume_max_detached_v = 0; +} + +int +resume_is_capable_transport(const struct Client *cptr) +{ + /* TLS is the real requirement: the bearer token must not be interceptable. + By default we further restrict to secure WebSockets; clearing + RESUME_REQUIRE_WEBSOCKET allows any TLS connection to resume. */ + if (!MyConnect(cptr) || !IsTLS(cptr)) + return 0; + if (feature_bool(FEAT_RESUME_REQUIRE_WEBSOCKET) && !IsWebsocket(cptr)) + return 0; + return 1; +} + +void +resume_init(void) +{ + memset(resumeTable, 0, sizeof(resumeTable)); + resume_count = 0; + resume_total_issued = 0; + resume_feat_notify(); /* seed cached feature values from their defaults */ +} + +void +resume_token_issue(struct Client *cptr) +{ + struct ResumeSession *s; + char token[RESUME_TOKEN_MAX]; + + if (!resume_enabled() || !resume_is_capable_transport(cptr)) + return; + + s = cli_resume(cptr); + if (!s) { + s = (struct ResumeSession *)MyCalloc(1, sizeof(*s)); + s->state = RESUME_STATE_ATTACHED; + s->client = cptr; + if (resume_gen_id(s) != 0) { + MyFree(s); + return; + } + cli_resume(cptr) = s; + resume_hash_add(s); + } + + /* (Re)generate the bearer secret and bump the generation counter; the old + secret -- and therefore any previously issued token -- stops verifying. */ + if (ircd_tls_random_bytes(s->secret, RESUME_SECRET_BYTES) != 0) + return; + s->generation++; + + if (resume_token_encode(s, token, sizeof(token)) != 0) + return; + + resume_total_issued++; + sendcmdto_one(&me, CMD_RESUME, cptr, "TOKEN :%s", token); +} + +/** Make an authenticated secure client resumable without a token, so it can be + * reattached by account after an unexpected transport loss even if it never + * negotiated the capability. No-op if it already has a session. */ +void +resume_session_ensure(struct Client *cptr) +{ + struct ResumeSession *s; + + if (!resume_enabled() || !feature_bool(FEAT_RESUME_AUTO_ACCOUNT) + || !resume_is_capable_transport(cptr) || !IsAccount(cptr) + || cli_resume(cptr) + || (cli_user(cptr)->acc_flags & RESUME_ACC_NO_AUTO)) + return; + + s = (struct ResumeSession *)MyCalloc(1, sizeof(*s)); + s->state = RESUME_STATE_ATTACHED; + s->client = cptr; + if (resume_gen_id(s) != 0) { + MyFree(s); + return; + } + cli_resume(cptr) = s; + resume_hash_add(s); +} + +/** Free a session's memory. Callers must have already unlinked it from the + * table and from its owning client. */ +static void +resume_session_free(struct ResumeSession *s) +{ + volatile unsigned char *p = s->secret; + size_t i; + + /* Wipe the bearer secret before returning the memory to the heap. The + volatile store defeats dead-store elimination (plain memset can be dropped + since the object is freed immediately after). */ + for (i = 0; i < RESUME_SECRET_BYTES; i++) + p[i] = 0; + + MyFree(s->saved_away); + MyFree(s); +} + +/** Human-readable detach reason for operator notices (never a token/secret). */ +static const char * +resume_reason_name(enum ResumeDetachReason reason) +{ + switch (reason) { + case RESUME_DETACH_EOF: return "transport-eof"; + case RESUME_DETACH_RESET: return "transport-reset"; + case RESUME_DETACH_TLS_ERROR: return "tls-error"; + case RESUME_DETACH_WS_ABNORMAL: return "ws-abnormal"; + case RESUME_DETACH_PING_TIMEOUT: return "ping-timeout"; + case RESUME_DETACH_BRB: return "brb"; + default: return "unknown"; + } +} + +/** Detach-expiry timer callback. + * + * ET_EXPIRE performs the final exit but does NOT free the session: the event + * engine still references the embedded timer and fires ET_DESTROY immediately + * afterwards (see timer_run()). ET_DESTROY -- reached here after expiry and + * also synchronously from timer_del() on a non-expiry teardown -- is the single + * place the session memory is released. + */ +static void +resume_expiry_cb(struct Event *ev) +{ + struct ResumeSession *s = (struct ResumeSession *)t_data(ev_timer(ev)); + struct Client *cptr; + + switch (ev_type(ev)) { + case ET_EXPIRE: + cptr = s->client; + assert(cptr != NULL); + + /* Unlink first so the exit path's invalidate hook is a no-op; the session + memory is released on the ET_DESTROY that timer_run() fires next. */ + resume_hash_remove(s); + cli_resume(cptr) = NULL; + s->client = NULL; + s->state = RESUME_STATE_NONE; + s->discarding = 1; + + if (resume_cur_detached) + resume_cur_detached--; + resume_total_expired++; + + if (feature_bool(FEAT_RESUME_SERVER_NOTICES)) { + static time_t rate; + sendto_opmask_butone_ratelimited(0, SNO_CONNEXIT, &rate, + "RESUME: expired %s, sending QUIT", cli_name(cptr)); + } + + exit_client(cptr, cptr, &me, "Resume timeout"); + break; + + case ET_DESTROY: + /* Only free when the session is actually being torn down. A successful + resume disarms this timer with timer_del() (discarding == 0), which also + fires ET_DESTROY but must leave the now-reattached session alive. */ + if (s->discarding) + resume_session_free(s); + break; + + default: + break; + } +} + +void +resume_session_invalidate(struct Client *cptr) +{ + struct ResumeSession *s = cli_resume(cptr); + + if (!s) + return; + + cli_resume(cptr) = NULL; + resume_hash_remove(s); + if (IsDetached(cptr) && resume_cur_detached) + resume_cur_detached--; + + /* If a resume attempt was mid-flight, drop its dangling claim pointer. */ + if (s->state == RESUME_STATE_CLAIMING && s->claimant) + cli_resume_claim(s->claimant) = NULL; + + s->discarding = 1; + if (t_active(&s->expiry_timer)) + timer_del(&s->expiry_timer); /* fires ET_DESTROY -> resume_session_free */ + else + resume_session_free(s); +} + +void +resume_mark_history_lost(struct Client *cptr) +{ + struct ResumeSession *s = cli_resume(cptr); + + if (s) + s->history_lost = 1; +} + +/** Broadcast \a cptr's current away state to servers and away-notify peers. */ +static void +resume_away_notify(struct Client *cptr) +{ + const char *away = cli_user(cptr)->away; + + sendcmdto_serv_butone(cptr, CMD_AWAY, cptr, away ? ":%s" : "", away); + sendcmdto_capflag_common_channels_butone(cptr, CMD_AWAY, cptr, + CAP_AWAYNOTIFY, 0, + away ? ":%s" : "", away); +} + +/** Set the temporary detach away, preserving any away the client already had. + * Disabled when RESUME_DETACH_AWAY is empty. */ +static void +resume_set_detach_away(struct Client *cptr, struct ResumeSession *s) +{ + if (!RESUME_DETACH_AWAY[0]) + return; + MyFree(s->saved_away); /* none expected while attached */ + s->saved_away = cli_user(cptr)->away; /* take ownership; may be NULL */ + DupString(cli_user(cptr)->away, RESUME_DETACH_AWAY); + s->away_overridden = 1; + resume_away_notify(cptr); +} + +/** Restore the client's pre-detach away (or clear it) and notify peers. Keyed + * on whether we actually overrode the away, so a rehash of RESUME_DETACH_AWAY + * between detach and resume cannot strand the detach message. */ +static void +resume_restore_away(struct Client *cptr, struct ResumeSession *s) +{ + if (!s->away_overridden) /* nothing was overridden on detach */ + return; + MyFree(cli_user(cptr)->away); /* the detach message */ + cli_user(cptr)->away = s->saved_away; /* prior away, or NULL */ + s->saved_away = NULL; + s->away_overridden = 0; + resume_away_notify(cptr); +} + +void +resume_detach(struct Client *cptr, enum ResumeDetachReason reason) +{ + struct ResumeSession *s = cli_resume(cptr); + int timeout = resume_conf_timeout(); + + assert(s != NULL); + assert(s->state == RESUME_STATE_ATTACHED); + assert(!IsDetached(cptr)); + + detach_connection(cptr); /* release transport, keep the Client visible */ + + SetDetach(cptr); + s->state = RESUME_STATE_DETACHED; + s->detach_reason = reason; + s->detached_at = CurrentTime; + s->expires_at = CurrentTime + timeout; + s->history_lost = 0; + + resume_set_detach_away(cptr, s); + + timer_add(timer_init(&s->expiry_timer), resume_expiry_cb, s, + TT_RELATIVE, timeout); + + resume_cur_detached++; + resume_total_detached++; + if (resume_cur_detached > resume_peak_detached) + resume_peak_detached = resume_cur_detached; + + if (feature_bool(FEAT_RESUME_SERVER_NOTICES)) { + static time_t rate; + sendto_opmask_butone_ratelimited(0, SNO_CONNEXIT, &rate, + "RESUME: detached %s, reason=%s, expires=%ds", + cli_name(cptr), resume_reason_name(reason), timeout); + } +} + +int +resume_try_detach(struct Client *cptr, enum ResumeDetachReason reason) +{ + if (!resume_enabled()) + return 0; + + /* Only an eligible, registered, secure-WebSocket local user with a live + resume session may detach; everything else exits normally. */ + if (!IsUser(cptr) || !MyConnect(cptr) || IsDetached(cptr) + || HasFlag(cptr, FLAG_KILLED)) + return 0; + if (!resume_is_capable_transport(cptr)) + return 0; + if (!cli_resume(cptr) || cli_resume(cptr)->state != RESUME_STATE_ATTACHED) + return 0; + + /* Respect the global cap; when full, fall back to an ordinary disconnect. */ + if (resume_cur_detached >= (unsigned int)resume_conf_max_detached()) + return 0; + + /* The client is being kept alive, so it is not a dead socket. */ + ClrFlag(cptr, FLAG_DEADSOCKET); + resume_detach(cptr, reason); + return 1; +} + +int +m_brb(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) +{ + if (!resume_enabled() || !feature_bool(FEAT_RESUME_ALLOW_BRB) + || !IsUser(sptr) || !resume_is_capable_transport(sptr) + || IsDetached(sptr) || !cli_resume(sptr) + || cli_resume(sptr)->state != RESUME_STATE_ATTACHED + || resume_cur_detached >= (unsigned int)resume_conf_max_detached()) { + sendstdreply(sptr, MSG_FAIL, "BRB", "CANNOT_BRB", + "Cannot suspend this connection"); + return 0; + } + + /* Tell the client how long its session will be held, flush it out before + the transport is torn down, then detach on the user's behalf. */ + sendcmdto_one(&me, CMD_BRB, sptr, "%d", resume_conf_timeout()); + send_queued(sptr); + resume_detach(sptr, RESUME_DETACH_BRB); + return 0; +} + +void +resume_send_whois(struct Client *sptr, struct Client *acptr, const char *name) +{ + struct ResumeSession *s; + int remaining; + + if (!IsDetached(acptr) || RESUME_WHOIS_POLICY == RESUME_WHOIS_OFF) + return; + if (RESUME_WHOIS_POLICY == RESUME_WHOIS_OPERS + && !(IsAnOper(sptr) || sptr == acptr)) + return; + + s = cli_resume(acptr); + if (!s) + return; + + remaining = (int)(s->expires_at - CurrentTime); + if (remaining < 0) + remaining = 0; + + send_reply(sptr, SND_EXPLICIT | RPL_WHOISWEBIRC, + "%s :is temporarily detached (resume window: %d seconds)", + name, remaining); +} + +/** Swap the Connections of \a old_client (detached shell) and \a new_client + * (live WSS/TLS), so the old client adopts the live transport and the new + * client is left holding the dead shell to be freed alongside it. + * + * con_socket is embedded and its event generator references &con_socket, so we + * keep both Connection objects intact and only re-point the ownership + * back-pointers -- avoiding any socket/timer re-registration. + */ +static void +resume_adopt(struct Client *old_client, struct Client *new_client) +{ + struct Connection *newcon = cli_connect(new_client); + struct Connection *oldshell = cli_connect(old_client); + + /* Oper privileges and snomask live on the Connection, so the swap below would + drop them -- leaving a resumed oper with +o but no privs. Carry them over. */ + *con_privs(newcon) = *con_privs(oldshell); + con_snomask(newcon) = con_snomask(oldshell); + + /* A resumed oper keeps no Operator block on the new connection; restore the + sendq/flood limits it had resolved on the old one (a class-derived byte + count, not transport state). */ + if (IsAnOper(old_client)) { + con_max_sendq(newcon) = con_max_sendq(oldshell); + con_max_flood(newcon) = con_max_flood(oldshell); + } + + cli_connect(old_client) = newcon; + cli_connect(new_client) = oldshell; + con_client(newcon) = old_client; + con_client(oldshell) = new_client; + con_resume_claim(newcon) = NULL; + + /* The live fd now belongs to the old client. */ + if (-1 < cli_fd(old_client)) + LocalClientArray[cli_fd(old_client)] = old_client; +} + +/** Reconstruct a resumed client's own local view: the registration welcome + * burst, its user modes and away state, and for each channel it belongs to a + * self JOIN, topic, and NAMES. Everything is sent only to \a cptr -- no + * broadcast -- so peers see nothing. + */ +static void +resume_replay(struct Client *cptr) +{ + struct Membership *member; + + send_welcome(cptr); + + /* Echo the client's own user modes as a MODE message, like registration. + "old" is empty, so every mode the client holds (including +r) is shown. */ + { + struct Flags old; + memset(&old, 0, sizeof(old)); + send_umode(cptr, cptr, &old, ALL_UMODES); + } + + if (cli_user(cptr)->away) + send_reply(cptr, RPL_NOWAWAY); + + for (member = cli_user(cptr)->channel; member; + member = member->next_channel) { + struct Channel *chptr = member->channel; + char modebuf[MODEBUFLEN]; + char parabuf[MODEBUFLEN]; + + sendjointo_one(cptr, chptr, cptr); + + *modebuf = *parabuf = '\0'; + channel_modes(cptr, modebuf, parabuf, sizeof(parabuf), chptr, member); + send_reply(cptr, RPL_CHANNELMODEIS, chptr->chname, modebuf, parabuf); + + if (chptr->topic[0]) { + send_reply(cptr, RPL_TOPIC, chptr->chname, chptr->topic); + send_reply(cptr, RPL_TOPICWHOTIME, chptr->chname, chptr->topic_nick, + chptr->topic_time); + } + do_names(cptr, chptr, NAMES_ALL | NAMES_EON); + } +} + +/** Finish a validated resume from check_auth_finished(): adopt the new + * connection onto the detached client, dispose the temporary client, rotate + * the token, and confirm success. + * @return CPTR_KILLED -- the temporary client (auth->client) is gone. + */ +int +resume_complete(struct Client *new_client) +{ + struct ResumeSession *s = cli_resume_claim(new_client); + struct Client *old_client; + + assert(s != NULL); + assert(s->state == RESUME_STATE_CLAIMING); + old_client = s->client; + assert(old_client != NULL); + + /* Release the temporary client's registration bookkeeping while its + connection is still its own. */ + if (cli_auth(new_client)) + destroy_auth_request(cli_auth(new_client)); + + cli_resume_claim(new_client) = NULL; + s->claimant = NULL; + + resume_adopt(old_client, new_client); + + /* The adopted connection came from an unfinished registration; switch it to + dispatching commands as the client's real type. An oper must get + OPER_HANDLER or the parser routes its commands to the non-oper handlers, + leaving it with privileges it cannot use. */ + cli_handler(old_client) = IsAnOper(old_client) ? OPER_HANDLER : CLIENT_HANDLER; + + /* The old client is live again on the new transport. */ + ClearDetach(old_client); + s->state = RESUME_STATE_ATTACHED; + s->detach_reason = RESUME_DETACH_NONE; + s->detached_at = 0; + s->expires_at = 0; + if (resume_cur_detached) + resume_cur_detached--; + + /* Disarm the expiry timer WITHOUT freeing the now-reattached session + (discarding == 0, so the timer's ET_DESTROY is a no-op). */ + if (t_active(&s->expiry_timer)) + timer_del(&s->expiry_timer); + + resume_total_resumed++; + + /* The rebuild below is a bounded server burst; lift the SendQ ceiling so a + client in many/large channels is not disconnected mid-replay. */ + { + struct Connection *con = cli_connect(old_client); + unsigned int saved_sendq = con_max_sendq(con); + + con_max_sendq(con) = UINT_MAX; + + sendcmdto_one(&me, CMD_RESUME, old_client, "SUCCESS :%s", + cli_name(old_client)); + resume_restore_away(old_client, s); + resume_replay(old_client); + if (s->history_lost) + sendstdreply(old_client, MSG_WARN, "RESUME", "HISTORY_LOST", + "Messages may have been missed while you were disconnected"); + s->history_lost = 0; + /* Rotate the bearer token only for clients that negotiated the capability. + An account-path resumer that never enabled draft/resume-0.5 must not be + handed a token it did not opt into (mirrors the gating in m_cap.c). */ + if (CapHas(cli_active(old_client), CAP_RESUME)) + resume_token_issue(old_client); + + con_max_sendq(con) = saved_sendq; + } + + if (feature_bool(FEAT_RESUME_SERVER_NOTICES)) { + static time_t rate; + sendto_opmask_butone_ratelimited(0, SNO_CONNEXIT, &rate, + "RESUME: resumed %s", cli_name(old_client)); + } + + /* Dispose the temporary client (unregistered -> no QUIT); it now holds the + dead shell, which is freed with it. */ + return exit_client(new_client, new_client, &me, "Resumed onto new session"); +} + +void +resume_release_claim(struct Client *cptr) +{ + struct ResumeSession *s = cli_resume_claim(cptr); + + if (!s) + return; + + cli_resume_claim(cptr) = NULL; + if (s->state == RESUME_STATE_CLAIMING && s->claimant == cptr) { + /* The target stays detached with its expiry timer still armed. */ + s->state = RESUME_STATE_DETACHED; + s->claimant = NULL; + } +} + +/** Whether an unregistered secure client's nick collision with \a acptr should + * be deferred for a possible account-based reattach. The account is not known + * yet, so only the collision target's eligibility is checked here. */ +int +resume_account_deferrable(const struct Client *sptr, const struct Client *acptr) +{ + struct ResumeSession *s; + + if (!resume_enabled() || !feature_bool(FEAT_RESUME_AUTO_ACCOUNT)) + return 0; + if (IsRegistered(sptr) || !resume_is_capable_transport(sptr)) + return 0; + if (!acptr || !IsDetached(acptr)) + return 0; + + s = cli_resume(acptr); + return s && s->state == RESUME_STATE_DETACHED; +} + +/** Claim \a target's detached session for \a new_client if the two share an + * account (and IP, unless waived). Mirrors the token path's authorization; + * on success the caller drives resume_complete() from check_auth_finished(). + * @return 1 if claimed, 0 otherwise. */ +int +resume_account_try_claim(struct Client *new_client, struct Client *target) +{ + struct ResumeSession *s; + + if (!resume_enabled() || !feature_bool(FEAT_RESUME_AUTO_ACCOUNT)) + return 0; + if (!target || target == new_client || !IsDetached(target)) + return 0; + + s = cli_resume(target); + if (!s || s->state != RESUME_STATE_DETACHED) + return 0; + if (!IsAccount(new_client) || !IsAccount(target) + || ircd_strcmp(cli_account(new_client), cli_account(target)) != 0) + return 0; + if (cli_user(new_client)->acc_flags & RESUME_ACC_NO_AUTO) + return 0; /* account opted out of auto-reattach */ + if (!feature_bool(FEAT_RESUME_ACCOUNT_ANY_IP) + && irc_in_addr_cmp(&cli_ip(new_client), &cli_ip(target))) + return 0; + + s->state = RESUME_STATE_CLAIMING; + s->claimant = new_client; + cli_resume_claim(new_client) = s; + return 1; +} + +int +m_resume(struct Client *cptr, struct Client *sptr, int parc, char *parv[]) +{ + unsigned char id[RESUME_ID_BYTES]; + unsigned char secret[RESUME_SECRET_BYTES]; + struct ResumeSession *s; + + if (!resume_enabled()) + return 0; /* feature off: ignore silently */ + + if (IsRegistered(sptr)) { + sendstdreply(sptr, MSG_FAIL, "RESUME", "REGISTRATION_IS_COMPLETED", + "Cannot resume connection, connection registration has " + "completed"); + return 0; + } + + if (!resume_is_capable_transport(sptr)) { + sendstdreply(sptr, MSG_FAIL, "RESUME", "INSECURE_SESSION", + "Cannot resume connection, you are not connected with secure " + "WebSockets"); + return 0; + } + + /* Bound resume attempts per connection. */ + if (cli_resume(sptr)) { + if (cli_resume(sptr)->attempts >= RESUME_MAX_ATTEMPTS) { + sendstdreply(sptr, MSG_FAIL, "RESUME", "CANNOT_RESUME", + "Cannot resume connection"); + return 0; + } + cli_resume(sptr)->attempts++; + } + + if (parc < 2 || resume_token_parse(parv[1], id, secret) != 0) { + sendstdreply(sptr, MSG_FAIL, "RESUME", "INVALID_TOKEN", + "Cannot resume connection, token is not valid"); + return 0; + } + + /* One generic failure for unknown/expired/used/active/other-IP tokens so a + caller cannot probe which sessions exist. */ + 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)))) { + sendstdreply(sptr, MSG_FAIL, "RESUME", "INVALID_TOKEN", + "Cannot resume connection, token is not valid"); + return 0; + } + + /* Claim the session, then drive registration to completion. The adoption + runs from check_auth_finished() only after all normal auth, ban, and + policy checks have passed (see resume_complete()). */ + s->state = RESUME_STATE_CLAIMING; + s->claimant = sptr; + cli_resume_claim(sptr) = s; + + return auth_cap_done(cli_auth(sptr)); +} diff --git a/ircd/s_auth.c b/ircd/s_auth.c index 240e1d0e..9064d228 100644 --- a/ircd/s_auth.c +++ b/ircd/s_auth.c @@ -38,6 +38,7 @@ #include "s_auth.h" #include "class.h" #include "client.h" +#include "hash.h" #include "IPcheck.h" #include "ircd.h" #include "ircd_alloc.h" @@ -58,6 +59,7 @@ #include "querycmds.h" #include "random.h" #include "res.h" +#include "resume.h" #include "s_bsd.h" #include "s_conf.h" #include "s_debug.h" @@ -108,6 +110,7 @@ struct AuthRequest { struct AuthRequestFlags flags; /**< current state of request */ unsigned int cookie; /**< cookie the user must PONG */ unsigned short port; /**< client's remote port number */ + char resume_wantnick[NICKLEN + 1]; /**< nick deferred for account reattach */ }; /** Array of message text (with length) pairs for AUTH status @@ -655,7 +658,33 @@ static int check_auth_finished(struct AuthRequest *auth, int bitclr) cli_user(auth->client)->account); } memset(cli_passwd(cptr), 0, sizeof(cli_passwd(cptr))); - res = register_user(cptr, cptr); + + /* Resolve a nick collision deferred for account-based reattach: adopt the + detached session if this login owns it, take the nick if it has since + freed, or ask for another nick. */ + if (auth->resume_wantnick[0] && !cli_resume_claim(cptr)) { + struct Client *held = FindClient(auth->resume_wantnick); + if (IsAccount(cptr) && resume_account_try_claim(cptr, held)) { + /* claimed -- resume_complete() runs below */ + } else if (!held) { + hAddClient(cptr); + auth->resume_wantnick[0] = '\0'; + } else { + send_reply(cptr, ERR_NICKNAMEINUSE, auth->resume_wantnick); + cli_name(cptr)[0] = '\0'; + auth->resume_wantnick[0] = '\0'; + FlagSet(&auth->flags, AR_NEEDS_NICK); + return 0; + } + } + + /* If this client presented a valid resume token, adopt the detached + session now that all auth, ban, and policy checks have passed; + otherwise register normally. */ + if (cli_resume_claim(cptr)) + res = resume_complete(cptr); + else + res = register_user(cptr, cptr); } } if (res == 0) @@ -1401,6 +1430,34 @@ int auth_set_nick(struct AuthRequest *auth, const char *nickname) return check_auth_finished(auth, AR_NEEDS_NICK); } +/** Defer a registering secure client whose nick collides with a detached, + * resume-eligible session (see resume_account_deferrable()). The requested + * nick is set on the client so iauth/dronescan see it, but it is deliberately + * NOT added to the hash -- the detached session keeps it -- and it is remembered + * so check_auth_finished() can adopt that session once the account is known. + * @return the auth_set_nick() result (registration proceeds normally). */ +int auth_defer_resume_nick(struct Client *cptr, const char *nick) +{ + struct AuthRequest *auth = cli_auth(cptr); + + assert(auth != NULL); + /* If the client already registered an earlier nick, unhash it: a deferred + client must stay out of the nick table (the detached session holds it). */ + if (cli_name(cptr)[0]) + hRemClient(cptr); + ircd_strncpy(auth->resume_wantnick, nick, NICKLEN); + ircd_strncpy(cli_name(cptr), nick, NICKLEN); + return auth_set_nick(auth, nick); +} + +/** Forget any nick deferred for account reattach (e.g. the client picked a + * different, concrete nick during registration). */ +void auth_forget_resume_nick(struct Client *cptr) +{ + if (cli_auth(cptr)) + cli_auth(cptr)->resume_wantnick[0] = '\0'; +} + /** Record a user's password. * @param[in] auth Authorization request for client. * @param[in] password Client's password. diff --git a/ircd/s_bsd.c b/ircd/s_bsd.c index 942e782b..c0b05c6c 100644 --- a/ircd/s_bsd.c +++ b/ircd/s_bsd.c @@ -49,6 +49,7 @@ #include "parse.h" #include "querycmds.h" #include "res.h" +#include "resume.h" #include "sasl.h" #include "s_auth.h" #include "s_conf.h" @@ -484,6 +485,41 @@ void close_connection(struct Client *cptr) } } +/** Tear down a client's live transport while keeping the Client alive. + * + * Used by session resume when an eligible client loses its transport: the fd, + * TLS object, socket, and I/O queues are released, but -- unlike + * close_connection() -- FLAG_DEADSOCKET is NOT set (so the main loop will not + * reap the client), and the listener, conf attachments, and clone/IPCheck + * accounting are left in place so the session stays network-visible and + * correctly counted until it is resumed or expires. The Connection shell is + * retained (con_client stays set), so the socket's queued ET_DESTROY will not + * free it. + * @param[in] cptr Client whose transport should be detached. + */ +void detach_connection(struct Client *cptr) +{ + if (-1 < cli_fd(cptr)) { + LocalClientArray[cli_fd(cptr)] = 0; + if (IsTLS(cptr) && s_tls(&cli_socket(cptr))) { + ircd_tls_close(s_tls(&cli_socket(cptr)), NULL); + s_tls(&cli_socket(cptr)) = NULL; + } + close(cli_fd(cptr)); + socket_del(&(cli_socket(cptr))); /* queue a socket delete */ + cli_fd(cptr) = -1; + } + + MsgQClear(&(cli_sendQ(cptr))); + client_drop_sendq(cli_connect(cptr)); + DBufClear(&(cli_recvQ(cptr))); + + for ( ; HighestFd > 0; --HighestFd) { + if (LocalClientArray[HighestFd]) + break; + } +} + /** Close all unregistered connections. * @param source Oper who requested the close. * @return Number of closed connections. @@ -1223,7 +1259,15 @@ static void client_sock_callback(struct Event* ev) assert(0 == cptr || 0 == cli_connect(cptr) || con == cli_connect(cptr)); if (fallback) { - const char* msg = (cli_error(cptr)) ? strerror(cli_error(cptr)) : fallback; + const char* msg; + + /* An eligible client that lost its transport unexpectedly detaches its + session instead of exiting, so it can resume within the grace window. */ + if (resume_try_detach(cptr, cli_error(cptr) ? RESUME_DETACH_RESET + : RESUME_DETACH_EOF)) + return; + + msg = (cli_error(cptr)) ? strerror(cli_error(cptr)) : fallback; if (!msg) msg = "Unknown error"; diff --git a/ircd/s_err.c b/ircd/s_err.c index 4991301e..cd63c887 100644 --- a/ircd/s_err.c +++ b/ircd/s_err.c @@ -1094,7 +1094,7 @@ static Numeric replyTable[] = { /* 530 */ { 0 }, /* 531 */ - { 0 }, + { ERR_CANNOTSENDTOUSER, "%s :Cannot send message: %s", "531" }, /* 532 */ { ERR_TLSCLIFINGERPRINT, ":TLS certificate fingerprint did not match", "532" }, /* 533 */ diff --git a/ircd/s_misc.c b/ircd/s_misc.c index d71fb5d7..7ab4d878 100644 --- a/ircd/s_misc.c +++ b/ircd/s_misc.c @@ -46,6 +46,7 @@ #include "parse.h" #include "querycmds.h" #include "res.h" +#include "resume.h" #include "s_auth.h" #include "s_bsd.h" #include "s_conf.h" @@ -197,6 +198,15 @@ static void exit_one_client(struct Client* bcptr, const char* comment) cli_sasl(bcptr) = 0; } + /* Invalidate any resume session and its token on final client removal. */ + if (cli_resume(bcptr)) + resume_session_invalidate(bcptr); + + /* If this client was mid-resume, release its claim so the target session + stays detached and can still be resumed or expire normally. */ + if (MyConnect(bcptr) && cli_resume_claim(bcptr)) + resume_release_claim(bcptr); + if (IsUser(bcptr)) { /* * clear out uping requests diff --git a/ircd/s_user.c b/ircd/s_user.c index bcce0032..e2200880 100644 --- a/ircd/s_user.c +++ b/ircd/s_user.c @@ -50,6 +50,7 @@ #include "parse.h" #include "querycmds.h" #include "random.h" +#include "resume.h" #include "s_auth.h" #include "s_bsd.h" #include "s_conf.h" @@ -338,6 +339,34 @@ int hunt_server_prio_cmd(struct Client *from, const char *cmd, const char *tok, * @param[in,out] sptr Client who has been fully introduced. * @return Zero or CPTR_KILLED. */ +/** Send the local registration welcome burst (001-005, LUSERS, MOTD) to a + * single client. Contains no state mutations, so it is reused to reconstruct + * a resumed client's view. + * @param[in] sptr Locally connected client to greet. + */ +void send_welcome(struct Client *sptr) +{ + char *parv[4]; + + parv[0] = cli_name(sptr); + parv[1] = parv[2] = parv[3] = NULL; + + send_reply(sptr, + RPL_WELCOME, + feature_str(FEAT_NETWORK), + feature_str(FEAT_PROVIDER) ? " via " : "", + feature_str(FEAT_PROVIDER) ? feature_str(FEAT_PROVIDER) : "", + cli_name(sptr)); + send_reply(sptr, RPL_YOURHOST, cli_name(&me), version); + send_reply(sptr, RPL_CREATED, creation); + send_reply(sptr, RPL_MYINFO, cli_name(&me), version, infousermodes, + infochanmodes, infochanmodeswithparams); + send_supported(sptr); + m_lusers(sptr, sptr, 1, parv); + update_load(); + motd_signon(sptr); +} + int register_user(struct Client *cptr, struct Client *sptr) { char* parv[4]; @@ -368,23 +397,7 @@ int register_user(struct Client *cptr, struct Client *sptr) SetUser(sptr); cli_handler(sptr) = CLIENT_HANDLER; SetLocalNumNick(sptr); - send_reply(sptr, - RPL_WELCOME, - feature_str(FEAT_NETWORK), - feature_str(FEAT_PROVIDER) ? " via " : "", - feature_str(FEAT_PROVIDER) ? feature_str(FEAT_PROVIDER) : "", - cli_name(sptr)); - /* - * This is a duplicate of the NOTICE but see below... - */ - send_reply(sptr, RPL_YOURHOST, cli_name(&me), version); - send_reply(sptr, RPL_CREATED, creation); - send_reply(sptr, RPL_MYINFO, cli_name(&me), version, infousermodes, - infochanmodes, infochanmodeswithparams); - send_supported(sptr); - m_lusers(sptr, sptr, 1, parv); - update_load(); - motd_signon(sptr); + send_welcome(sptr); if (cli_snomask(sptr) & SNO_NOISY) set_snomask(sptr, cli_snomask(sptr) & SNO_NOISY, SNO_ADD); if (feature_bool(FEAT_CONNEXIT_NOTICES)) @@ -395,6 +408,10 @@ int register_user(struct Client *cptr, struct Client *sptr) cli_info(sptr), NumNick(cptr) /* two %s's */); IPcheck_connect_succeeded(sptr); + + /* Make an authenticated secure client reattachable by account even if it + never negotiated the resume capability. */ + resume_session_ensure(sptr); } else { struct Client *acptr = user->server; @@ -577,6 +594,8 @@ int set_nick_name(struct Client* cptr, struct Client* sptr, * if client is on any channels where it is currently * banned. If so, do not allow the nick change to occur. */ + /* A concrete nick choice abandons any nick deferred for account reattach. */ + auth_forget_resume_nick(sptr); if (MyUser(sptr)) { const char* channel_name; struct Membership *member; @@ -818,6 +837,13 @@ int whisper(struct Client* source, const char* nick, const char* channel, { if (cli_user(dest)->away) send_reply(source, RPL_AWAY, cli_name(dest), cli_user(dest)->away); + if (IsDetached(dest)) { + const char *cannot = RESUME_CANNOTSEND; + if (*cannot) { + send_reply(source, ERR_CANNOTSENDTOUSER, cli_name(dest), cannot); + return 0; + } + } sendcmdto_one(source, CMD_PRIVATE, dest, "%C :%s", dest, text); } return 0; diff --git a/ircd/send.c b/ircd/send.c index 02950c93..8dad07c5 100644 --- a/ircd/send.c +++ b/ircd/send.c @@ -40,6 +40,7 @@ #include "websocket.h" #include "numnicks.h" #include "parse.h" +#include "resume.h" #include "s_bsd.h" #include "s_debug.h" #include "s_misc.h" @@ -353,6 +354,17 @@ void send_buffer(struct Client* to, struct Client* from, struct MsgBuf* buf, int if (cli_from(to)) to = cli_from(to); + /* + * A detached session has no transport: discard output aimed at it here, + * at the single lowest local-send boundary, rather than growing its sendQ. + * Note the loss so the client can be warned on resume. Delivery to every + * other recipient (and to servers) is unaffected -- this is per-target. + */ + if (IsDetached(to)) { + resume_mark_history_lost(to); + return; + } + if (!can_send(to)) /* * This socket has already been marked as dead @@ -531,6 +543,37 @@ void sendcmdto_one(struct Client *from, const char *cmd, const char *tok, msgq_clean(mb); } +/** + * Send an IRCv3 standard reply to a single local client. + * Emits ":me :", e.g. + * ":irc.example.net FAIL RESUME INVALID_TOKEN :Cannot resume connection". + * Standard replies are server-to-client only, so no server token form is used. + * @param[in] to Destination client. + * @param[in] severity "FAIL", "WARN", or "NOTE" (see MSG_FAIL etc.). + * @param[in] command Subject command, e.g. "RESUME". + * @param[in] code Machine-readable code, e.g. "HISTORY_LOST". + * @param[in] pattern Format string for the human-readable description. + */ +void sendstdreply(struct Client *to, const char *severity, const char *command, + const char *code, const char *pattern, ...) +{ + struct VarData vd; + struct MsgBuf *mb; + + to = cli_from(to); + + vd.vd_format = pattern; /* set up the struct VarData for %v */ + va_start(vd.vd_args, pattern); + + mb = msgq_make(to, "%:#C %s %s %s :%v", &me, severity, command, code, &vd); + + va_end(vd.vd_args); + + send_buffer(to, NULL, mb, 0, NULL, NULL); + + msgq_clean(mb); +} + /** * Send a (prefixed) command to a single client in the priority queue. * @param[in] from Client sending the command. diff --git a/ircd/tls_gnutls.c b/ircd/tls_gnutls.c index d4103edd..8569aa05 100644 --- a/ircd/tls_gnutls.c +++ b/ircd/tls_gnutls.c @@ -686,3 +686,10 @@ int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) gnutls_free(encoded.data); return 0; } + +int ircd_tls_random_bytes(void *buf, size_t len) +{ + if (!buf || len == 0) + return -1; + return (gnutls_rnd(GNUTLS_RND_RANDOM, buf, len) == 0) ? 0 : -1; +} diff --git a/ircd/tls_libtls.c b/ircd/tls_libtls.c index f85e5c3f..689fd699 100644 --- a/ircd/tls_libtls.c +++ b/ircd/tls_libtls.c @@ -684,3 +684,11 @@ int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) { return ircd_sha1_base64(data, len, out, outlen); } + +int ircd_tls_random_bytes(void *buf, size_t len) +{ + if (!buf || len == 0) + return -1; + arc4random_buf(buf, len); + return 0; +} diff --git a/ircd/tls_none.c b/ircd/tls_none.c index 7bb55709..6d5f7abf 100644 --- a/ircd/tls_none.c +++ b/ircd/tls_none.c @@ -25,6 +25,7 @@ #include "ircd_sha1.h" #include "client.h" #include +#include #include const char *ircd_tls_version = NULL; @@ -106,3 +107,17 @@ int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) { return ircd_sha1_base64(data, len, out, outlen); } + +int ircd_tls_random_bytes(void *buf, size_t len) +{ + FILE *f; + size_t got; + + if (!buf || len == 0) + return -1; + if (!(f = fopen("/dev/urandom", "rb"))) + return -1; + got = fread(buf, 1, len, f); + fclose(f); + return (got == len) ? 0 : -1; +} diff --git a/ircd/tls_openssl.c b/ircd/tls_openssl.c index 45556252..f1598269 100644 --- a/ircd/tls_openssl.c +++ b/ircd/tls_openssl.c @@ -44,6 +44,7 @@ #include #include /* IOV_MAX */ #include /* write() on failure of ssl_accept() */ +#include /* INT_MAX */ const char *ircd_tls_version = OPENSSL_VERSION_TEXT; @@ -948,3 +949,10 @@ int ircd_tls_sha1_base64(const void *data, size_t len, char *out, size_t outlen) BIO_free_all(b64); return 0; } + +int ircd_tls_random_bytes(void *buf, size_t len) +{ + if (!buf || len == 0 || len > (size_t)INT_MAX) + return -1; + return (RAND_bytes((unsigned char *)buf, (int)len) == 1) ? 0 : -1; +} diff --git a/tests/conftest.py b/tests/conftest.py index b55bb654..751105e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,7 @@ "tls_port_ca": 16699, "wss_port": 16700, "wss_cf_port": 16701, + "ws_plain_port": 16702, "server_port": 14440, "server_tls_ca_port": 14441, "name": "tls-hub.test.net", @@ -44,6 +45,11 @@ "server_tls_ca_port": 14412, "name": "tls-leaf.test.net", } +ACCT_HUB = { + "host": "127.0.0.1", + "wss_port": 16710, + "name": "acct-hub.test.net", +} DNS_HUB = { "host": "127.0.0.1", @@ -84,7 +90,9 @@ def docker_compose(*args, check=True): ["docker", "compose"] + list(args), capture_output=True, text=True, - timeout=600, + # Allow for a cold rebuild of several images (ircu recompiles when the + # build context changes) before the network is brought up. + timeout=1500, cwd=REPO_ROOT, env=compose_env(), ) @@ -230,9 +238,11 @@ def _start_topology_network(): def _start_topology_tls_network(): - _start_services("ircd-tls-hub", "ircd-tls-leaf") + _start_services("ircd-tls-hub", "ircd-tls-leaf", "ircd-acct-hub") _wait_tls_hub_ports() + wait_for_port(TLS_HUB["host"], TLS_HUB["ws_plain_port"]) wait_for_port(TLS_LEAF["host"], TLS_LEAF["server_port"]) + wait_for_port(ACCT_HUB["host"], ACCT_HUB["wss_port"]) # Allow autoconnect TLS links hub <-> tls-leaf time.sleep(20) @@ -449,8 +459,12 @@ def ircd_tls_hub(): @pytest.fixture(scope="session") def ircd_tls_network(): - """Connection info for the TLS-enabled hub and leaf containers.""" - return {"hub": TLS_HUB, "leaf": TLS_LEAF} + """Connection info for the TLS-enabled hub, leaf, and account-hub containers. + + The container lifecycle is handled by _ircd_topology, which starts the + tls_network topology (including ircd-acct-hub for the resume account tests). + """ + return {"hub": TLS_HUB, "leaf": TLS_LEAF, "acct": ACCT_HUB} @pytest.fixture(scope="session") diff --git a/tests/docker/iauth-loc-stub.pl b/tests/docker/iauth-loc-stub.pl new file mode 100644 index 00000000..bb08b743 --- /dev/null +++ b/tests/docker/iauth-loc-stub.pl @@ -0,0 +1,38 @@ +#!/usr/bin/perl +# iauth login-on-connect stub for account-based resume tests. +# +# Logs every client into an account named after the username it sends in USER, +# then approves it. This lets tests give a reconnecting client a verified +# account during registration without SASL/services. See doc/readme.iauth. +# +# ircd->iauth: " " +# iauth->ircd: " " (id/ip/port are verified) +# We reply R (DoneAccount: set account + accept). +use strict; +use warnings; + +$| = 1; # autoflush stdout, or replies never reach the ircd + +print "O RU\n"; # R: iauth required; U: Undernet extensions (sends U, n, H). + +my (%ip, %port, %account); +while (my $line = ) { + chomp $line; + my @f = split /\s+/, $line; + next if @f < 2; + my ($id, $cmd) = @f[0, 1]; + + if ($cmd eq 'C' && @f >= 4) { + ($ip{$id}, $port{$id}) = ($f[2], $f[3]); # client ip + port + } elsif ($cmd eq 'U' && @f >= 3) { + $account{$id} = $f[2]; # username -> account + } elsif ($cmd eq 'n' && defined $ip{$id}) { + my $acct = $account{$id} // $f[2] // ''; # fall back to the nick + # Test convention: accounts named "optout*" carry the resume opt-out + # flag (0x080), standing in for a service-set X_NO_AUTO_RESUME. + $acct .= ':0:128' if $acct =~ /^optout/i; + print "R $id $ip{$id} $port{$id} $acct\n"; # log in + accept + } elsif ($cmd eq 'D') { + delete $ip{$id}; delete $port{$id}; delete $account{$id}; + } +} diff --git a/tests/docker/ircd-acct-hub.conf b/tests/docker/ircd-acct-hub.conf new file mode 100644 index 00000000..520cb82b --- /dev/null +++ b/tests/docker/ircd-acct-hub.conf @@ -0,0 +1,42 @@ +General { + name = "acct-hub.test.net"; + vhost = "0.0.0.0"; + description = "Account Resume Test Hub"; + numeric = 30; + tls certfile = "certs/hub.pem"; + tls keyfile = "certs/hub.key"; +}; + +Admin { + Location = "Test Network"; + Location = "Account Hub"; + Contact = "test@test.net"; +}; + +Class { + name = "Local"; + pingfreq = 1 minutes 30 seconds; + sendq = 160000; + maxlinks = 100; +}; + +Client { ip = "*"; class = "Local"; }; + +# Login-on-connect: every client is logged into an account named after its +# USER username, so account-based resume reattach can be exercised. +IAuth { program = "/usr/bin/perl" "/opt/ircu/lib/iauth-loc-stub.pl"; }; + +Port { + port = 6710; + websocket = yes; + tls = yes; + tls systemca = no; +}; + +Features { + "NODNS" = "TRUE"; + "TLS_SYSTEMCA" = "FALSE"; + "PPATH" = "ircd-acct-hub.pid"; + "RESUME" = "TRUE"; + "RESUME_TIMEOUT" = "10"; +}; diff --git a/tests/docker/ircd-hub.conf b/tests/docker/ircd-hub.conf index 9fdfd5a5..2b31d4cb 100644 --- a/tests/docker/ircd-hub.conf +++ b/tests/docker/ircd-hub.conf @@ -136,4 +136,6 @@ Features { "PINGFREQUENCY" = "3"; # RFC6455 server Ping interval for WebSocket ports (see readme.features) "WEBSOCKET_KEEPALIVE" = "2"; +# Session resume (advertised only on secure WebSocket links) + "RESUME" = "TRUE"; }; diff --git a/tests/docker/ircd-tls-hub.conf b/tests/docker/ircd-tls-hub.conf index 14fc8890..3971362a 100644 --- a/tests/docker/ircd-tls-hub.conf +++ b/tests/docker/ircd-tls-hub.conf @@ -36,6 +36,8 @@ Operator { host = "*@*"; password = "$PLAIN$operpass"; name = "testoper"; + # PRIV_SET: lets resume tests flip features via the SET command. + set = yes; }; # Fingerprint-pinned oper block. Used by the security regression tests to @@ -191,6 +193,13 @@ Port { tls systemca = no; }; +# Plain WebSocket (no TLS): lets resume tests verify the capability is NOT +# advertised on an insecure WebSocket, without needing the separate hub. +Port { + port = 6702; + websocket = yes; +}; + Port { port = 6701; websocket = yes; @@ -205,4 +214,8 @@ Features { "CONFIG_OPERCMDS" = "TRUE"; "TLS_SYSTEMCA" = "FALSE"; "PPATH" = "ircd-tls-hub.pid"; +# Session resume (advertised only on secure WebSocket links) + "RESUME" = "TRUE"; +# Short detach window so the expiry test completes quickly (min clamp is 10s). + "RESUME_TIMEOUT" = "10"; }; diff --git a/tests/pr_resume/__init__.py b/tests/pr_resume/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/pr_resume/test_account.py b/tests/pr_resume/test_account.py new file mode 100644 index 00000000..694e2e48 --- /dev/null +++ b/tests/pr_resume/test_account.py @@ -0,0 +1,358 @@ +"""Account-based auto-reattach (RESUME_AUTO_ACCOUNT). + +An authenticated client that reconnects with the same nick + account is +reattached to its detached session with no client support and no token. + +The security path needs no account and runs against the shared tls-hub: a +same-nick reconnect that is not authenticated must not hijack the session, and +the deferred collision must leave that session intact (still resumable by +token). + +The authenticated paths run against the dedicated acct-hub, whose iauth stub +logs every client into an account named after its USER username (login-on- +connect), so a reconnecting client gets a verified account during registration. +""" + +import asyncio +import ssl + +import pytest + +from irc_ws_client import IRCWebSocketClient + +RESUME_CAP = "draft/resume-0.5" +ERR_NICKNAMEINUSE = "433" + +pytestmark = [pytest.mark.tls, pytest.mark.asyncio] + + +def _tls_ctx(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +async def _drain_cap_and_token(c): + """Consume CAP LS, request resume, and return the issued token.""" + await c.send("CAP LS 302") + while True: + m = await c.recv(timeout=10.0) + if m.command == "CAP" and (len(m.params) < 4 or m.params[-2] != "*"): + break + await c.send(f"CAP REQ :{RESUME_CAP}") + for _ in range(10): + m = await c.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + return m.params[-1] + raise AssertionError("no resume token issued") + + +async def _ws_register_with_resume(hub, nick): + c = IRCWebSocketClient() + await c.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + token = await _drain_cap_and_token(c) + await c.send(f"NICK {nick}") + await c.send(f"USER {nick} 0 * :{nick}") + await c.send("CAP END") + while True: + m = await c.recv(timeout=10.0) + if m.command in ("376", "422"): + break + return c, token + + +async def _acct_connect(acct): + c = IRCWebSocketClient() + await c.connect(f"wss://{acct['host']}:{acct['wss_port']}/", ssl=_tls_ctx()) + return c + + +async def _acct_register(acct, nick, account): + """Register a secure-WS client with no resume capability; the iauth stub + logs it into `account` (its USER username). Returns once registered.""" + c = await _acct_connect(acct) + await c.send(f"NICK {nick}") + await c.send(f"USER {account} 0 * :{nick}") + while True: + m = await c.recv(timeout=15.0) + if m.command in ("376", "422"): + return c + + +async def _acct_register_with_resume(acct, nick, account): + """Register a resume-capable client (gets a token) that the iauth stub also + logs into `account`. Returns (client, token).""" + c = await _acct_connect(acct) + token = await _drain_cap_and_token(c) + await c.send(f"NICK {nick}") + await c.send(f"USER {account} 0 * :{nick}") + await c.send("CAP END") + while True: + m = await c.recv(timeout=15.0) + if m.command in ("376", "422"): + return c, token + + +async def _saw_command_from(client, command, nick, timeout): + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + prefix = nick + "!" + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return False + try: + m = await client.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + return False + if m.command == command and m.prefix and m.prefix.startswith(prefix): + return True + + +async def test_unauthenticated_same_nick_does_not_hijack(ircd_tls_network): + hub = ircd_tls_network["hub"] + + alice, token = await _ws_register_with_resume(hub, "acctzz") + await alice.send("JOIN #acct") + await alice.wait_for("JOIN", timeout=5.0) + + alice._ws.transport.abort() # abrupt loss -> auto-detach; session held + + # A brand-new, UNAUTHENTICATED secure-WS client claiming the same nick must + # not adopt the session: the collision is deferred, then resolved as an + # ordinary nick-in-use once registration would complete. + imposter = await _acct_connect(hub) + await imposter.send("NICK acctzz") + await imposter.send("USER acctzz 0 * :imposter") + + got_inuse = False + for _ in range(20): + m = await imposter.recv(timeout=10.0) + assert not (m.command == "RESUME" and m.params[:1] == ["SUCCESS"]), ( + "unauthenticated same-nick reconnect hijacked the session" + ) + if m.command == ERR_NICKNAMEINUSE: + got_inuse = True + break + assert got_inuse, "deferred collision was not resolved as nick-in-use" + await imposter.disconnect() + + # The detached session survived the deferred collision intact: the rightful + # owner can still resume it with its token. + a2 = IRCWebSocketClient() + await a2.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a2) + await a2.send("NICK rtmp") + await a2.send("USER rtmp 0 * :temp") + await a2.send(f"RESUME {token}") + + success = False + for _ in range(40): + m = await a2.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + success = True + assert m.params[-1] == "acctzz" + break + if m.command == "FAIL" and "RESUME" in m.params: + raise AssertionError("session was lost after the deferred collision") + assert success, "rightful owner could not resume the surviving session" + await a2.disconnect() + + +async def test_account_reattach_same_account(ircd_tls_network): + acct = ircd_tls_network["acct"] + + # An observer sharing the channel confirms the reattach is seamless. + obs = await _acct_register(acct, "aracobs", "obsacct") + await obs.send("JOIN #ar") + await obs.wait_for("JOIN", timeout=5.0) + + # An authenticated client with NO resume capability -- resumable purely by + # account (resume_session_ensure). + alice = await _acct_register(acct, "araccy", "araccount") + await alice.send("JOIN #ar") + assert await _saw_command_from(obs, "JOIN", "araccy", 10.0) + + alice._ws.transport.abort() # abrupt loss -> auto-detach + assert not await _saw_command_from(obs, "QUIT", "araccy", 2.0) + + # Reconnect with the SAME nick and SAME account: no token, no resume cap. + a2 = await _acct_connect(acct) + await a2.send("NICK araccy") + await a2.send("USER araccount 0 * :re") + + success = False + saw_self_join = False + for _ in range(80): + m = await a2.recv(timeout=15.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + success = True + assert m.params[-1] == "araccy" + elif (m.command == "JOIN" and m.prefix + and m.prefix.startswith("araccy!") and m.params[-1:] == ["#ar"]): + saw_self_join = True + if success and saw_self_join: + break + assert success, "authenticated same-account reconnect did not reattach" + assert saw_self_join, "reattached client did not get its channel back" + + # No churn for peers, and the reattached connection *is* araccy. + assert not await _saw_command_from(obs, "QUIT", "araccy", 2.0) + await a2.send("PRIVMSG #ar :back") + assert await _saw_command_from(obs, "PRIVMSG", "araccy", 10.0) + + await a2.disconnect() + await obs.disconnect() + + +async def test_account_reattach_wrong_account_rejected(ircd_tls_network): + acct = ircd_tls_network["acct"] + + bob = await _acct_register(acct, "arwrong", "acct_one") + await bob.send("JOIN #arw") + await bob.wait_for("JOIN", timeout=5.0) + + bob._ws.transport.abort() # auto-detach; session held + + # Same nick, DIFFERENT account: must not adopt the session. + other = await _acct_connect(acct) + await other.send("NICK arwrong") + await other.send("USER acct_two 0 * :other") + + got_inuse = False + for _ in range(30): + m = await other.recv(timeout=15.0) + assert not (m.command == "RESUME" and m.params[:1] == ["SUCCESS"]), ( + "a different account reattached another user's session" + ) + if m.command == ERR_NICKNAMEINUSE: + got_inuse = True + break + assert got_inuse, "wrong-account reconnect was not rejected as nick-in-use" + await other.disconnect() + + +async def test_deferred_collision_after_prior_nick_keeps_hash_consistent( + ircd_tls_network): + hub = ircd_tls_network["hub"] + + # A detached session holds the nick "dfrx". + alice, _tok = await _ws_register_with_resume(hub, "dfrx") + alice._ws.transport.abort() + + # A client that first registers (and is hashed under) a temp nick, then + # sends the detached nick, which is deferred. The deferral must unhash the + # temp nick, or the client's hash entry is left dangling when it exits. + imp = IRCWebSocketClient() + await imp.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await imp.send("NICK dfrtmp") # free -> hashed under dfrtmp + await imp.send("NICK dfrx") # collides with detached session -> deferred + await imp.send("USER dfrx 0 * :imp") + for _ in range(20): + m = await imp.recv(timeout=10.0) + if m.command == ERR_NICKNAMEINUSE: # unauthenticated -> rejected + break + await imp.disconnect() + + # The temp nick must be cleanly reusable afterwards (no stale hash entry). + other = IRCWebSocketClient() + await other.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await other.send("NICK dfrtmp") + await other.send("USER dfrtmp 0 * :o") + registered = False + for _ in range(20): + m = await other.recv(timeout=10.0) + if m.command in ("376", "422"): + registered = True + break + assert registered, "temp nick unusable after deferred collision (hash desync)" + await other.disconnect() + + +async def test_account_optout_flag_is_not_detached(ircd_tls_network): + acct = ircd_tls_network["acct"] + + obs = await _acct_register(acct, "optobs", "obswatch") + await obs.send("JOIN #opt") + await obs.wait_for("JOIN", timeout=5.0) + + # An account whose flags carry the resume opt-out bit (X_NO_AUTO_RESUME). + user = await _acct_register(acct, "optme", "optoutacc") + await user.send("JOIN #opt") + assert await _saw_command_from(obs, "JOIN", "optme", 10.0) + + # Abrupt transport loss is normally auto-detached; an opted-out account is + # not, so peers see an ordinary QUIT instead. + user._ws.transport.abort() + assert await _saw_command_from(obs, "QUIT", "optme", 10.0), ( + "opted-out account was detached instead of disconnecting normally" + ) + await obs.disconnect() + + +async def test_account_optout_blocks_account_reattach_of_token_session( + ircd_tls_network): + acct = ircd_tls_network["acct"] + + obs = await _acct_register(acct, "optcobs", "obswatch2") + await obs.send("JOIN #optc") + await obs.wait_for("JOIN", timeout=5.0) + + # A resume-capable client with an opted-out account still gets a token + # session, so it detaches on loss (the token path is unaffected)... + c, _token = await _acct_register_with_resume(acct, "optcap", "optoutcap") + await c.send("JOIN #optc") + assert await _saw_command_from(obs, "JOIN", "optcap", 10.0) + c._ws.transport.abort() + assert not await _saw_command_from(obs, "QUIT", "optcap", 3.0), ( + "opt-out wrongly suppressed detach for a token session" + ) + + # ...but a no-token reconnect with the same nick+account must NOT be + # adopted via the account path. + c2 = await _acct_connect(acct) + await c2.send("NICK optcap") + await c2.send("USER optoutcap 0 * :re") + for _ in range(30): + m = await c2.recv(timeout=15.0) + assert not (m.command == "RESUME" and m.params[:1] == ["SUCCESS"]), ( + "opted-out account was reattached via the account path" + ) + if m.command == ERR_NICKNAMEINUSE: + break + await c2.disconnect() + await obs.disconnect() + + +async def test_account_reattach_issues_no_token(ircd_tls_network): + """Regression: an account-path resumer that never negotiated the capability + must NOT be handed a RESUME TOKEN (the token path is opt-in via the cap).""" + acct = ircd_tls_network["acct"] + + alice = await _acct_register(acct, "notoky", "notokacct") + await alice.send("JOIN #notok") + await alice.wait_for("JOIN", timeout=10.0) + + alice._ws.transport.abort() # abrupt loss -> auto-detach; session held + + # Reconnect: same nick + account, no resume cap, no token presented. + a2 = await _acct_connect(acct) + await a2.send("NICK notoky") + await a2.send("USER notokacct 0 * :re") + + loop = asyncio.get_running_loop() + deadline = loop.time() + 20.0 + saw_success = False + saw_token = False + while loop.time() < deadline: + try: + m = await a2.recv(timeout=3.0) + except (asyncio.TimeoutError, ConnectionError): + break # idle -> the resume burst is complete + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + saw_success = True + elif m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + saw_token = True + assert saw_success, "account reattach did not succeed" + assert not saw_token, "account-path resumer was wrongly issued a RESUME TOKEN" diff --git a/tests/pr_resume/test_autodetach.py b/tests/pr_resume/test_autodetach.py new file mode 100644 index 00000000..4f0dc026 --- /dev/null +++ b/tests/pr_resume/test_autodetach.py @@ -0,0 +1,231 @@ +"""M5 tests: automatic detach on unexpected transport loss. + +When an eligible secure-WebSocket client loses its transport abnormally (here, +an abrupt TCP reset with no WebSocket close handshake -- the Cloudflare/Nginx +drop case), the server detaches the session instead of exiting it: peers see no +QUIT, WHOIS reports the client as detached, and a new connection can resume it. +A clean client QUIT still exits normally. + +This exercises the production trigger directly (no RESUMEDETACH test command). +""" + +import asyncio +import ssl + +import pytest + +from irc_client import IRCClient +from irc_ws_client import IRCWebSocketClient + +RESUME_CAP = "draft/resume-0.5" + +pytestmark = [pytest.mark.tls, pytest.mark.asyncio] + + +def _tls_ctx(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +async def _oper_up(client): + await client.send("OPER testoper operpass") + while True: + msg = await client.recv(timeout=10.0) + if msg.command == "381": + return + if msg.command in ("464", "491"): + raise AssertionError(f"OPER failed: {msg}") + + +async def _drain_cap_and_token(c): + await c.send("CAP LS 302") + while True: + m = await c.recv(timeout=10.0) + if m.command == "CAP" and (len(m.params) < 4 or m.params[-2] != "*"): + break + await c.send(f"CAP REQ :{RESUME_CAP}") + for _ in range(10): + m = await c.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + return m.params[-1] + raise AssertionError("no resume token issued") + + +async def _ws_register_with_resume(hub, nick): + c = IRCWebSocketClient() + await c.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + token = await _drain_cap_and_token(c) + await c.send(f"NICK {nick}") + await c.send(f"USER {nick} 0 * :{nick}") + await c.send("CAP END") + while True: + m = await c.recv(timeout=10.0) + if m.command in ("376", "422"): + break + return c, token + + +async def _saw_command_from(client, command, nick, timeout): + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + prefix = nick + "!" + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return False + try: + m = await client.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + return False + if m.command == command and m.prefix and m.prefix.startswith(prefix): + return True + + +async def _whois_reports_detached(observer, nick, timeout=10.0): + await observer.send(f"WHOIS {nick}") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + detached = False + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return detached + try: + m = await observer.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + return detached + if m.command == "320" and "detached" in m.params[-1].lower(): + detached = True + if m.command == "318": + return detached + + +async def test_transport_reset_auto_detaches_then_resumes(ircd_tls_network): + hub = ircd_tls_network["hub"] + + obs = IRCClient() + await obs.connect(hub["host"], hub["port"]) + await obs.register("adzobs", "adzobs", "Observer") + await _oper_up(obs) + await obs.send("JOIN #adz") + await obs.wait_for("JOIN", timeout=5.0) + + adz, token = await _ws_register_with_resume(hub, "adz") + await adz.send("JOIN #adz") + assert await _saw_command_from(obs, "JOIN", "adz", 10.0) + + # Abrupt transport loss: reset the TCP connection with no WS close handshake. + adz._ws.transport.abort() + + # The server must detach, not exit: peers see no QUIT. + assert not await _saw_command_from(obs, "QUIT", "adz", 5.0), ( + "peer saw a QUIT after an abrupt transport loss (should auto-detach)" + ) + # And WHOIS reports the detachment. + assert await _whois_reports_detached(obs, "adz"), ( + "auto-detached client not reported as detached in WHOIS" + ) + + # A fresh connection resumes the auto-detached session. + a2 = IRCWebSocketClient() + await a2.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a2) + await a2.send("NICK adztmp") + await a2.send("USER adztmp 0 * :tmp") + await a2.send(f"RESUME {token}") + + success = False + for _ in range(80): + m = await a2.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + success = True + assert m.params[-1] == "adz" + if m.command == "RESUME" and m.params[:1] == ["TOKEN"] and success: + break + assert success, "could not resume the auto-detached session" + + await a2.send("PRIVMSG #adz :recovered") + assert await _saw_command_from(obs, "PRIVMSG", "adz", 10.0) + + try: + await a2.disconnect() + except Exception: + pass + await obs.send("QUIT :done") + await obs.disconnect() + + +async def test_brb_suspends_and_resumes(ircd_tls_network): + """A client-initiated BRB detaches the session and can be resumed.""" + hub = ircd_tls_network["hub"] + + obs = IRCClient() + await obs.connect(hub["host"], hub["port"]) + await obs.register("brbobs", "brbobs", "Observer") + await obs.send("JOIN #brb") + await obs.wait_for("JOIN", timeout=5.0) + + brbc, token = await _ws_register_with_resume(hub, "brbby") + await brbc.send("JOIN #brb") + assert await _saw_command_from(obs, "JOIN", "brbby", 10.0) + + await brbc.send("BRB :back soon") + got_brb = False + for _ in range(10): + m = await brbc.recv(timeout=5.0) + if m.command == "BRB": + got_brb = True + assert int(m.params[-1]) > 0 # server tells the client its window + break + assert got_brb, "did not receive BRB acknowledgement" + + # Peers see no QUIT; the session was suspended, not exited. + assert not await _saw_command_from(obs, "QUIT", "brbby", 4.0) + + # A fresh connection resumes the BRB'd session. + a2 = IRCWebSocketClient() + await a2.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a2) + await a2.send("NICK brbtmp") + await a2.send("USER brbtmp 0 * :tmp") + await a2.send(f"RESUME {token}") + success = False + for _ in range(80): + m = await a2.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + success = True + if m.command == "RESUME" and m.params[:1] == ["TOKEN"] and success: + break + assert success, "could not resume the BRB'd session" + + try: + await a2.disconnect() + except Exception: + pass + await obs.send("QUIT :done") + await obs.disconnect() + + +async def test_clean_quit_still_exits(ircd_tls_network): + """A normal QUIT from an eligible client must still exit (not detach).""" + hub = ircd_tls_network["hub"] + + obs = IRCClient() + await obs.connect(hub["host"], hub["port"]) + await obs.register("adzobs2", "adzobs2", "Observer2") + await obs.send("JOIN #adz2") + await obs.wait_for("JOIN", timeout=5.0) + + adz, _ = await _ws_register_with_resume(hub, "adzq") + await adz.send("JOIN #adz2") + assert await _saw_command_from(obs, "JOIN", "adzq", 10.0) + + await adz.send("QUIT :leaving") + assert await _saw_command_from(obs, "QUIT", "adzq", 10.0), ( + "a clean QUIT did not propagate (should exit, not detach)" + ) + + await obs.send("QUIT :done") + await obs.disconnect() diff --git a/tests/pr_resume/test_detach.py b/tests/pr_resume/test_detach.py new file mode 100644 index 00000000..61fddcd0 --- /dev/null +++ b/tests/pr_resume/test_detach.py @@ -0,0 +1,155 @@ +"""M2 tests for session-resume detach and expiry. + +A secure-WebSocket client that negotiated draft/resume-0.5 can be detached +(transport released, session kept) and must: + + * NOT produce a network QUIT while detached, + * remain visible to peers (WHOIS reports it as temporarily detached), + * produce exactly one QUIT when the resume window expires without a resume. + +Detach is triggered by an abrupt transport reset (the production path). The +TLS-hub test config sets RESUME_TIMEOUT=10 so expiry is observable quickly. +""" + +import asyncio +import ssl + +import pytest + +from irc_client import IRCClient +from irc_ws_client import IRCWebSocketClient + +RESUME_CAP = "draft/resume-0.5" + +pytestmark = [pytest.mark.tls, pytest.mark.asyncio] + + +def _tls_ctx(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +async def _oper_up(client): + await client.send("OPER testoper operpass") + while True: + msg = await client.recv(timeout=10.0) + if msg.command == "381": # RPL_YOUREOPER + return + if msg.command in ("464", "491"): + raise AssertionError(f"OPER failed: {msg}") + + +async def _ws_register_with_resume(hub, nick): + """Register a WSS client that has negotiated and been issued a resume token.""" + c = IRCWebSocketClient() + await c.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + + await c.send("CAP LS 302") + while True: + m = await c.recv(timeout=10.0) + if m.command == "CAP" and (len(m.params) < 4 or m.params[-2] != "*"): + break + + await c.send(f"CAP REQ :{RESUME_CAP}") + token = None + for _ in range(10): + m = await c.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + token = m.params[-1] + break + + await c.send(f"NICK {nick}") + await c.send(f"USER {nick} 0 * :{nick}") + await c.send("CAP END") + while True: + m = await c.recv(timeout=10.0) + if m.command in ("376", "422"): + break + return c, token + + +async def _saw_command_from(client, command, nick, timeout): + """Return True if a prefixed by nick! arrives within timeout.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + prefix = nick + "!" + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return False + try: + m = await client.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + return False + if m.command == command and m.prefix and m.prefix.startswith(prefix): + return True + + +async def _whois_reports_detached(observer, nick, timeout=10.0): + await observer.send(f"WHOIS {nick}") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + detached = False + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return detached + try: + m = await observer.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + return detached + if m.command == "320" and "detached" in m.params[-1].lower(): + detached = True + if m.command == "318": # RPL_ENDOFWHOIS + return detached + + +async def test_detach_keeps_client_visible_then_expires(ircd_tls_network): + hub = ircd_tls_network["hub"] + + # Observer + operator on the same server, sharing a channel with the target. + obs = IRCClient() + await obs.connect(hub["host"], hub["port"]) + await obs.register("obs", "obs", "Observer") + await _oper_up(obs) + await obs.send("JOIN #resume") + await obs.wait_for("JOIN", timeout=5.0) # own JOIN echo + + # Resume-capable WSS client joins the channel. + alice, token = await _ws_register_with_resume(hub, "alice") + assert token, "resume token was not issued" + await alice.send("JOIN #resume") + assert await _saw_command_from(obs, "JOIN", "alice", timeout=10.0), ( + "observer never saw alice join" + ) + + # Detach alice (stand-in for transport loss). + alice._ws.transport.abort() # abrupt transport loss -> auto-detach + + # No QUIT should reach peers while detached (window is 10s; check well under). + assert not await _saw_command_from(obs, "QUIT", "alice", timeout=4.0), ( + "peer saw a QUIT while the session was only detached" + ) + + # Still network-visible: WHOIS reports the temporary detachment (to opers). + assert await _whois_reports_detached(obs, "alice"), ( + "WHOIS did not report alice as temporarily detached" + ) + + # After the window expires, exactly one QUIT is emitted. + assert await _saw_command_from(obs, "QUIT", "alice", timeout=15.0), ( + "no QUIT emitted after the resume window expired" + ) + # And alice is gone: a second QUIT must not appear. + assert not await _saw_command_from(obs, "QUIT", "alice", timeout=3.0), ( + "a second QUIT appeared for alice" + ) + + await obs.send("QUIT :done") + await obs.disconnect() + try: + await alice.disconnect() + except Exception: + pass diff --git a/tests/pr_resume/test_fix.py b/tests/pr_resume/test_fix.py new file mode 100644 index 00000000..2de3bba2 --- /dev/null +++ b/tests/pr_resume/test_fix.py @@ -0,0 +1,138 @@ +"""M1 tests for IRCv3 session resume (draft/resume-0.5). + +Milestone 1 scope: a registered secure-WebSocket (WSS + TLS) client is offered +the ``draft/resume-0.5`` capability and, once it is acknowledged, receives a +``RESUME TOKEN`` line. The capability MUST NOT be offered on any other +transport. Detach/resume/expiry are later milestones and are not tested here. + +The positive path runs against the TLS hub's WSS port (websocket + tls); the +negatives pin down the ``IsWebsocket && IsTLS`` gate: plain WebSocket (websocket, +no tls) and a plain TCP connection (neither) must not see the capability. +""" + +import ssl + +import pytest + +from irc_client import IRCClient +from irc_ws_client import IRCWebSocketClient + +RESUME_CAP = "draft/resume-0.5" + + +def _tls_ctx(): + """Unverified client TLS context (the test PKI uses self-signed certs).""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +async def _collect_cap_ls(client): + """Send CAP LS 302 and return the full set of advertised capability names.""" + await client.send("CAP LS 302") + caps = set() + while True: + msg = await client.recv(timeout=10.0) + if msg.command != "CAP": + continue + # :server CAP LS [*] : + more = len(msg.params) >= 4 and msg.params[-2] == "*" + for tok in msg.params[-1].split(): + caps.add(tok.split("=", 1)[0]) # strip any =value + if not more: + return caps + + +# -------------------------------------------------------------------------- +# Positive path: WSS + TLS +# -------------------------------------------------------------------------- + +@pytest.mark.tls +@pytest.mark.asyncio +async def test_resume_cap_advertised_on_wss(ircd_tls_network): + """draft/resume-0.5 is offered on a secure WebSocket connection.""" + hub = ircd_tls_network["hub"] + client = IRCWebSocketClient() + await client.connect( + f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx() + ) + try: + caps = await _collect_cap_ls(client) + assert RESUME_CAP in caps, f"{RESUME_CAP} missing from WSS CAP LS: {caps}" + finally: + await client.disconnect() + + +@pytest.mark.tls +@pytest.mark.asyncio +async def test_resume_token_issued_after_ack(ircd_tls_network): + """After ACKing the capability the client receives one RESUME TOKEN line.""" + hub = ircd_tls_network["hub"] + client = IRCWebSocketClient() + await client.connect( + f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx() + ) + try: + caps = await _collect_cap_ls(client) + assert RESUME_CAP in caps + + await client.send(f"CAP REQ :{RESUME_CAP}") + acked = False + token = None + # Expect a CAP ... ACK and a RESUME TOKEN line, in some order. + for _ in range(10): + msg = await client.recv(timeout=10.0) + if msg.command == "CAP" and "ACK" in msg.params: + assert RESUME_CAP in msg.params[-1] + acked = True + elif msg.command == "RESUME" and msg.params[:1] == ["TOKEN"]: + token = msg.params[-1] + break + assert acked, "capability was not ACKed" + assert token, "no RESUME TOKEN was issued" + # Token is .: one dot, both parts set. + assert token.count(".") == 1, f"malformed token: {token!r}" + id_part, secret_part = token.split(".") + assert id_part and secret_part + finally: + await client.disconnect() + + +# -------------------------------------------------------------------------- +# Negatives: the capability is transport-gated to WSS + TLS +# -------------------------------------------------------------------------- + +@pytest.mark.tls +@pytest.mark.asyncio +async def test_resume_cap_hidden_on_plain_websocket(ircd_tls_network): + """WebSocket without TLS must not see draft/resume-0.5 (TLS half of gate).""" + hub = ircd_tls_network["hub"] + client = IRCWebSocketClient() + await client.connect(f"ws://{hub['host']}:{hub['ws_plain_port']}/") + try: + caps = await _collect_cap_ls(client) + assert RESUME_CAP not in caps, ( + f"{RESUME_CAP} leaked onto a plaintext WebSocket: {caps}" + ) + finally: + await client.disconnect() + + +@pytest.mark.tls +@pytest.mark.asyncio +async def test_resume_cap_hidden_on_plain_tcp(ircd_tls_network): + """A plain TCP client (neither WebSocket nor TLS) must not see the cap.""" + hub = ircd_tls_network["hub"] + client = IRCClient() + await client.connect(hub["host"], hub["port"]) + try: + await client.send("CAP LS 302") + msg = await client.wait_for("CAP", timeout=5.0) + caps = {t.split("=", 1)[0] for t in msg.params[-1].split()} + assert RESUME_CAP not in caps, ( + f"{RESUME_CAP} leaked onto a plaintext TCP connection: {caps}" + ) + finally: + await client.send("QUIT :done") + await client.disconnect() diff --git a/tests/pr_resume/test_require_websocket.py b/tests/pr_resume/test_require_websocket.py new file mode 100644 index 00000000..7feb84d7 --- /dev/null +++ b/tests/pr_resume/test_require_websocket.py @@ -0,0 +1,137 @@ +"""RESUME_REQUIRE_WEBSOCKET: eligibility on plain TLS (non-WebSocket) links. + +The security requirement for resume is TLS; RESUME_REQUIRE_WEBSOCKET (default +TRUE) further restricts eligibility to secure WebSockets. When it is cleared, +the capability must be advertised on a direct TLS connection and a full +token-based resume must work over it. + +These run against the shared tls-hub, so the test flips the feature at runtime +(SET, an oper command) and restores it in a finally block. +""" + +import ssl + +import pytest + +from irc_client import IRCClient +from irc_ws_client import IRCWebSocketClient + +RESUME_CAP = "draft/resume-0.5" + +pytestmark = [pytest.mark.tls, pytest.mark.asyncio] + + +def _tls_ctx(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +async def _oper_up(client): + await client.send("OPER testoper operpass") + while True: + m = await client.recv(timeout=10.0) + if m.command == "381": + return + if m.command in ("464", "491"): + raise AssertionError(f"OPER failed: {m}") + + +async def _cap_ls(client): + """Return the set of advertised capability names after a CAP LS 302.""" + await client.send("CAP LS 302") + caps = set() + while True: + m = await client.recv(timeout=10.0) + if m.command == "CAP" and len(m.params) >= 3 and m.params[1] == "LS": + caps.update(m.params[-1].split()) + # A non-"*" third param marks the final LS line. + if len(m.params) < 4 or m.params[2] != "*": + return caps + + +async def _set_feature(hub, name, value): + """Flip a feature at runtime via an opered client; returns the oper client + so the caller can restore it.""" + op = IRCClient() + await op.connect_tls(hub["host"], hub["tls_port"], ssl_context=_tls_ctx()) + await op.register("wsfeat", "wsfeat", "wsfeat") + await _oper_up(op) + await op.send(f"SET {name} {value}") + # Drain the SET acknowledgement notice(s). + for _ in range(5): + try: + await op.recv(timeout=2.0) + except Exception: + break + return op + + +async def test_cap_hidden_on_direct_tls_by_default(ircd_tls_network): + """With RESUME_REQUIRE_WEBSOCKET on (default), a direct TLS client is not + offered draft/resume-0.5 even though it is TLS.""" + hub = ircd_tls_network["hub"] + c = IRCClient() + await c.connect_tls(hub["host"], hub["tls_port"], ssl_context=_tls_ctx()) + caps = await _cap_ls(c) + assert RESUME_CAP not in caps, ( + f"{RESUME_CAP} must not be advertised on a direct TLS link by default" + ) + + +async def test_resume_over_direct_tls_when_websocket_not_required(ircd_tls_network): + """With RESUME_REQUIRE_WEBSOCKET cleared, a direct TLS (non-WS) client is + offered the capability and can complete a full token resume.""" + hub = ircd_tls_network["hub"] + op = await _set_feature(hub, "RESUME_REQUIRE_WEBSOCKET", "FALSE") + try: + # 1) The capability is now advertised on a direct TLS link. + alice = IRCClient() + await alice.connect_tls(hub["host"], hub["tls_port"], ssl_context=_tls_ctx()) + caps = await _cap_ls(alice) + assert RESUME_CAP in caps, ( + f"{RESUME_CAP} should be advertised on direct TLS when " + "RESUME_REQUIRE_WEBSOCKET is FALSE" + ) + + # 2) Register with the capability and capture the issued token. + await alice.send(f"CAP REQ :{RESUME_CAP}") + token = None + await alice.send("NICK tlsrez") + await alice.send("USER tlsrez 0 * :tlsrez") + await alice.send("CAP END") + for _ in range(40): + m = await alice.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + token = m.params[-1] + if m.command in ("376", "422") and token: + break + assert token, "no resume token issued on direct TLS" + + await alice.send("JOIN #tlsrez") + await alice.wait_for("JOIN", timeout=5.0) + + # 3) Drop the transport and resume over a fresh direct TLS connection. + alice._writer.transport.abort() # abrupt loss -> auto-detach + + a2 = IRCClient() + await a2.connect_tls(hub["host"], hub["tls_port"], ssl_context=_tls_ctx()) + await a2.send(f"CAP REQ :{RESUME_CAP}") + await a2.send("NICK tlstmp") + await a2.send("USER tlstmp 0 * :temp") + await a2.send(f"RESUME {token}") + + success = False + for _ in range(80): + m = await a2.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + success = True + assert m.params[-1] == "tlsrez" + break + if m.command == "FAIL" and "RESUME" in m.params: + raise AssertionError(f"resume over direct TLS failed: {m}") + assert success, "resume over direct TLS did not succeed" + finally: + await op.send("SET RESUME_REQUIRE_WEBSOCKET TRUE") + await op.disconnect() diff --git a/tests/pr_resume/test_resume.py b/tests/pr_resume/test_resume.py new file mode 100644 index 00000000..f95dc50a --- /dev/null +++ b/tests/pr_resume/test_resume.py @@ -0,0 +1,509 @@ +"""M3 tests: the RESUME command and connection reattachment. + +A new secure-WebSocket connection presenting a valid token for a detached +session adopts that session: it keeps the original nick, account, and channel +memberships, receives RESUME SUCCESS and a rotated token, and peers see no +churn. Invalid or already-used tokens fail with a single generic reply. + +Detach is triggered by an abrupt transport reset (the production path). +""" + +import asyncio +import ssl + +import pytest + +from irc_client import IRCClient +from irc_ws_client import IRCWebSocketClient + +RESUME_CAP = "draft/resume-0.5" + +pytestmark = [pytest.mark.tls, pytest.mark.asyncio] + + +def _tls_ctx(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +async def _oper_up(client): + await client.send("OPER testoper operpass") + while True: + msg = await client.recv(timeout=10.0) + if msg.command == "381": + return + if msg.command in ("464", "491"): + raise AssertionError(f"OPER failed: {msg}") + + +async def _drain_cap_and_token(c): + """Consume CAP LS, request resume, and consume the issued token.""" + await c.send("CAP LS 302") + while True: + m = await c.recv(timeout=10.0) + if m.command == "CAP" and (len(m.params) < 4 or m.params[-2] != "*"): + break + await c.send(f"CAP REQ :{RESUME_CAP}") + for _ in range(10): + m = await c.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + return m.params[-1] + raise AssertionError("no resume token issued") + + +async def _ws_register_with_resume(hub, nick): + c = IRCWebSocketClient() + await c.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + token = await _drain_cap_and_token(c) + await c.send(f"NICK {nick}") + await c.send(f"USER {nick} 0 * :{nick}") + await c.send("CAP END") + while True: + m = await c.recv(timeout=10.0) + if m.command in ("376", "422"): + break + return c, token + + +async def _saw_command_from(client, command, nick, timeout): + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + prefix = nick + "!" + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return False + try: + m = await client.recv(timeout=remaining) + except (asyncio.TimeoutError, ConnectionError): + return False + if m.command == command and m.prefix and m.prefix.startswith(prefix): + return True + + +async def test_resume_reattaches_detached_session(ircd_tls_network): + hub = ircd_tls_network["hub"] + + obs = IRCClient() + await obs.connect(hub["host"], hub["port"]) + await obs.register("rezobs", "rezobs", "Observer") + await _oper_up(obs) + await obs.send("JOIN #rez") + await obs.wait_for("JOIN", timeout=5.0) + + alice, token = await _ws_register_with_resume(hub, "rezzy") + await alice.send("JOIN #rez") + assert await _saw_command_from(obs, "JOIN", "rezzy", 10.0) + + alice._ws.transport.abort() # abrupt transport loss -> auto-detach + # No QUIT while detached. + assert not await _saw_command_from(obs, "QUIT", "rezzy", 2.0) + + # New connection resumes the detached session with the old token. + a2 = IRCWebSocketClient() + await a2.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a2) + await a2.send("NICK rtmp") + await a2.send("USER rtmp 0 * :temp") + await a2.send(f"RESUME {token}") + + # Collect the resume response: SUCCESS, then the replayed view (welcome + # burst, self JOIN + NAMES for #rez), then the rotated TOKEN last. + success = False + new_token = None + saw_welcome = False # RPL_WELCOME 001 + saw_self_join = False # self JOIN #rez + saw_names = False # RPL_NAMREPLY 353 for #rez + for _ in range(80): + m = await a2.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + success = True + assert m.params[-1] == "rezzy" + elif m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + new_token = m.params[-1] + elif m.command == "001": + saw_welcome = True + elif m.command == "JOIN" and m.prefix and m.prefix.startswith("rezzy!"): + if m.params and m.params[-1] == "#rez": + saw_self_join = True + elif m.command == "353" and "#rez" in m.params: + saw_names = True + if new_token: # TOKEN is sent last + break + assert success, "did not receive RESUME SUCCESS" + assert new_token and new_token != token, "token was not rotated" + # State replay reconstructed the client's own view (M4). + assert saw_welcome, "resumed client did not get the welcome burst (001)" + assert saw_self_join, "resumed client did not get its self JOIN for #rez" + assert saw_names, "resumed client did not get NAMES for #rez" + + # Peers never saw a QUIT/JOIN churn for alice during the resume. + assert not await _saw_command_from(obs, "QUIT", "rezzy", 2.0) + + # The resumed connection *is* alice: a message from it shows alice as source + # on the channel she still belongs to. + await a2.send("PRIVMSG #rez :back online") + assert await _saw_command_from(obs, "PRIVMSG", "rezzy", 10.0), ( + "resumed connection did not act as alice on her channel" + ) + + # The old token is now invalid (rotated on success): reusing it fails. + a3 = IRCWebSocketClient() + await a3.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a3) + await a3.send("NICK rtmp2") + await a3.send("USER rtmp2 0 * :temp2") + await a3.send(f"RESUME {token}") + failed = False + for _ in range(15): + m = await a3.recv(timeout=10.0) + if m.command == "FAIL" and "RESUME" in m.params: + failed = True + break + if m.command in ("376", "422"): # registered normally instead + break + assert failed, "reused (rotated) token was not rejected" + + for c in (a2, a3): + try: + await c.disconnect() + except Exception: + pass + await obs.send("QUIT :done") + await obs.disconnect() + + +async def test_resume_invalid_token_fails(ircd_tls_network): + hub = ircd_tls_network["hub"] + c = IRCWebSocketClient() + await c.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(c) + await c.send("NICK nobody") + await c.send("USER nobody 0 * :n") + await c.send("RESUME bogus.token") + failed = False + for _ in range(15): + m = await c.recv(timeout=10.0) + if m.command == "FAIL" and "RESUME" in m.params: + assert "INVALID_TOKEN" in m.params + failed = True + break + if m.command in ("376", "422"): + break + assert failed, "invalid token did not produce FAIL RESUME" + await c.disconnect() + + +DETACH_AWAY = "Temporarily detached, messages will be missed." + + +async def _whois_away(observer, nick, timeout=10.0): + """WHOIS `nick` and return its RPL_AWAY (301) text, or None if not away.""" + await observer.send(f"WHOIS {nick}") + away = None + for _ in range(40): + m = await observer.recv(timeout=timeout) + if m.command == "301" and len(m.params) >= 3 and m.params[1] == nick: + away = m.params[-1] + if m.command == "318": # end of WHOIS + break + return away + + +async def _wait_whois_away(observer, nick, expected, timeout=15.0): + """Poll WHOIS until `nick`'s away equals `expected` (state changes such as + detach/reattach are processed asynchronously), returning the last value.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + last = object() + while loop.time() < deadline: + last = await _whois_away(observer, nick) + if last == expected: + return last + await asyncio.sleep(0.3) + return last + + +async def test_resume_restores_away_modes_and_channel_state(ircd_tls_network): + hub = ircd_tls_network["hub"] + + obs = IRCClient() + await obs.connect(hub["host"], hub["port"]) + await obs.register("rzwobs", "rzwobs", "Observer") + + # A secure-WS client (so it holds +z), opered (+o), away, ops+moderates a chan. + alice = IRCWebSocketClient() + await alice.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + token = await _drain_cap_and_token(alice) + await alice.send("NICK rzw") + await alice.send("USER rzw 0 * :rzw") + await alice.send("CAP END") + while True: + m = await alice.recv(timeout=10.0) + if m.command in ("376", "422"): + break + await _oper_up(alice) # +o + await alice.send("AWAY :gone fishing") + await alice.send("JOIN #rzw") + await alice.send("MODE #rzw +m") + await alice.send("TOPIC #rzw :hi there") + await alice.wait_for("TOPIC", timeout=10.0) # settle: all prior applied + + # Baseline: peers see alice's own away. + assert await _wait_whois_away(obs, "rzw", "gone fishing") == "gone fishing" + + alice._ws.transport.abort() # unexpected loss -> detach + + # While detached, the away is the temporary detach message. + assert await _wait_whois_away(obs, "rzw", DETACH_AWAY) == DETACH_AWAY + + # Resume, and verify the full replayed view. + a2 = IRCWebSocketClient() + await a2.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a2) + await a2.send("NICK rzwtmp") + await a2.send("USER rzwtmp 0 * :t") + await a2.send(f"RESUME {token}") + + saw = dict(success=False, usermode=False, nowaway=False, + join=False, chanmode=False, names=False) + new_token = None + for _ in range(120): + m = await a2.recv(timeout=10.0) + c = m.command + if c == "RESUME" and m.params[:1] == ["SUCCESS"]: + saw["success"] = True + elif (c == "MODE" and m.prefix and m.prefix.startswith("rzw!") + and m.params[:1] == ["rzw"] + and "z" in m.params[-1] and "o" in m.params[-1]): + saw["usermode"] = True # user modes echoed (incl. +z and +o) + elif c == "306": # RPL_NOWAWAY + saw["nowaway"] = True + elif (c == "JOIN" and m.prefix and m.prefix.startswith("rzw!") + and m.params[-1:] == ["#rzw"]): + saw["join"] = True + elif c == "324" and "#rzw" in m.params: # RPL_CHANNELMODEIS + if "m" in "".join(m.params[2:]): + saw["chanmode"] = True + elif c == "353" and "#rzw" in m.params: # RPL_NAMREPLY (members) + if "rzw" in m.params[-1]: + saw["names"] = True + elif c == "RESUME" and m.params[:1] == ["TOKEN"]: + new_token = m.params[-1] + if new_token: + break + + assert saw["success"], "no RESUME SUCCESS" + assert saw["usermode"], "user modes (+z, +o) not echoed on resume" + assert saw["nowaway"], "away state not restored on resume" + assert saw["join"], "channel not rejoined on resume" + assert saw["chanmode"], "channel modes (+m) not replayed on resume" + assert saw["names"], "channel members not replayed on resume" + + # The original away is restored (not left as the detach message). + assert await _wait_whois_away(obs, "rzw", "gone fishing") == "gone fishing" + + await a2.disconnect() + await obs.disconnect() + + +async def test_message_to_detached_client_gets_cannotsend(ircd_tls_network): + hub = ircd_tls_network["hub"] + + sender = IRCClient() + await sender.connect(hub["host"], hub["port"]) + await sender.register("dmsndr", "dmsndr", "Sender") + + alice, _tok = await _ws_register_with_resume(hub, "dmz") + alice._ws.transport.abort() # detach + assert await _wait_whois_away(sender, "dmz", DETACH_AWAY) == DETACH_AWAY + + # PRIVMSG to a detached client: sender gets RPL_AWAY (301) and + # ERR_CANNOTSENDTOUSER (531); the message is not delivered. + await sender.send("PRIVMSG dmz :hello") + got_531 = got_301 = False + text_531 = "" + for _ in range(15): + m = await sender.recv(timeout=10.0) + if m.command == "531" and "dmz" in m.params: + got_531 = True + text_531 = m.params[-1] + if m.command == "301" and "dmz" in m.params: + got_301 = True + if got_531: + break + assert got_301, "no RPL_AWAY (301) for a message to a detached client" + assert got_531, "no ERR_CANNOTSENDTOUSER (531) for PRIVMSG to detached client" + assert "Cannot send message:" in text_531 and "temporarily detached" in text_531 + + # NOTICE must NOT trigger an auto-reply (RFC); no 531. + await sender.send("NOTICE dmz :hi") + saw_531_for_notice = False + for _ in range(5): + try: + m = await sender.recv(timeout=2.0) + except (asyncio.TimeoutError, ConnectionError): + break + if m.command == "531": + saw_531_for_notice = True + break + assert not saw_531_for_notice, "NOTICE to a detached client wrongly got a 531" + + await sender.disconnect() + + +async def test_resume_clears_detach_away_when_not_previously_away( + ircd_tls_network): + hub = ircd_tls_network["hub"] + + obs = IRCClient() + await obs.connect(hub["host"], hub["port"]) + await obs.register("rzcobs", "rzcobs", "Observer") + + alice, token = await _ws_register_with_resume(hub, "rzc") + await alice.send("JOIN #rzc") + await alice.wait_for("JOIN", timeout=5.0) + assert await _wait_whois_away(obs, "rzc", None) is None # not away + + alice._ws.transport.abort() # detach + assert await _wait_whois_away(obs, "rzc", DETACH_AWAY) == DETACH_AWAY # temporary away set + + a2 = IRCWebSocketClient() + await a2.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a2) + await a2.send("NICK rzctmp") + await a2.send("USER rzctmp 0 * :t") + await a2.send(f"RESUME {token}") + + success = False + saw_nowaway = False + new_token = None + for _ in range(80): + m = await a2.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + success = True + elif m.command == "306": + saw_nowaway = True + elif m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + new_token = m.params[-1] + if new_token: + break + assert success, "no RESUME SUCCESS" + assert not saw_nowaway, "resumed client was wrongly left away" + assert await _wait_whois_away(obs, "rzc", None) is None # detach away cleared + + await a2.disconnect() + await obs.disconnect() + + +async def test_resume_preserves_oper_privileges(ircd_tls_network): + """Regression: a resumed oper keeps +o AND usable privileges. + + The bug restored the +o umode but not con_privs / OPER_HANDLER, so oper + commands failed with ERR_NOPRIVILEGES (481) after resume. PRIVS is a + side-effect-free oper-only command: 270 on success, 481 if the handler or + privileges were not carried across the connection swap. + """ + hub = ircd_tls_network["hub"] + + alice, token = await _ws_register_with_resume(hub, "operez") + await _oper_up(alice) # global oper (holds PRIV_REHASH etc.) + + alice._ws.transport.abort() # abrupt loss -> auto-detach + + a2 = IRCWebSocketClient() + await a2.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(a2) + await a2.send("NICK otmp") + await a2.send("USER otmp 0 * :temp") + await a2.send(f"RESUME {token}") + + saw_success = False + saw_oper_mode = False + for _ in range(80): + m = await a2.recv(timeout=10.0) + if m.command == "RESUME" and m.params[:1] == ["SUCCESS"]: + saw_success = True + elif (m.command == "MODE" and m.prefix and m.prefix.startswith("operez!") + and "o" in "".join(m.params)): + saw_oper_mode = True + elif m.command == "RESUME" and m.params[:1] == ["TOKEN"]: + break + assert saw_success, "no RESUME SUCCESS" + assert saw_oper_mode, "+o umode not restored on resume" + + # The regression check: an oper-only command must work, not return 481. + await a2.send("PRIVS") + outcome = None + for _ in range(20): + m = await a2.recv(timeout=10.0) + if m.command == "481": + outcome = "481" + break + if m.command == "270": # RPL_PRIVS + outcome = "270" + break + assert outcome == "270", ( + f"resumed oper PRIVS returned {outcome!r}; privileges/handler not restored" + ) + + +async def test_resume_on_insecure_websocket_is_refused(ircd_tls_network): + """RESUME over a non-TLS WebSocket is refused with INSECURE_SESSION, which + is checked before the token is even parsed.""" + hub = ircd_tls_network["hub"] + c = IRCWebSocketClient() + await c.connect(f"ws://{hub['host']}:{hub['ws_plain_port']}/") + await c.send("NICK inseckz") + await c.send("USER inseckz 0 * :inseckz") + await c.send("RESUME aaaa.bbbb") + saw = None + for _ in range(40): + m = await c.recv(timeout=10.0) + if m.command == "FAIL" and "RESUME" in m.params: + saw = m.params + break + assert saw and "INSECURE_SESSION" in saw, f"expected INSECURE_SESSION, got {saw}" + + +async def test_resume_after_registration_is_refused(ircd_tls_network): + """RESUME sent after registration completes is refused with + REGISTRATION_IS_COMPLETED.""" + hub = ircd_tls_network["hub"] + c, token = await _ws_register_with_resume(hub, "regdonez") # fully registered + await c.send(f"RESUME {token}") + saw = None + for _ in range(20): + m = await c.recv(timeout=10.0) + if m.command == "FAIL" and "RESUME" in m.params: + saw = m.params + break + assert saw and "REGISTRATION_IS_COMPLETED" in saw, ( + f"expected REGISTRATION_IS_COMPLETED, got {saw}" + ) + + +async def test_resume_attempts_are_capped(ircd_tls_network): + """After RESUME_MAX_ATTEMPTS invalid presentations on one connection the + server returns CANNOT_RESUME instead of INVALID_TOKEN. A capability client + holds its own session, so the per-connection attempt counter engages.""" + hub = ircd_tls_network["hub"] + c = IRCWebSocketClient() + await c.connect(f"wss://{hub['host']}:{hub['wss_port']}/", ssl=_tls_ctx()) + await _drain_cap_and_token(c) # CAP REQ resume -> token issued -> cli_resume + await c.send("NICK maxatt") + await c.send("USER maxatt 0 * :maxatt") + + codes = [] + for _ in range(5): # still pre-registration (no CAP END) + await c.send("RESUME zzzzzzzzzzzzzzzzzzzzzz.zzzzzzzzzzzz") + while True: + m = await c.recv(timeout=10.0) + if m.command == "FAIL" and m.params[:1] == ["RESUME"]: + codes.append(m.params[1]) + break + + assert codes[:3] == ["INVALID_TOKEN"] * 3, f"first 3 should be INVALID_TOKEN: {codes}" + assert codes[3] == "CANNOT_RESUME", f"4th attempt should be CANNOT_RESUME: {codes}" From e342fb7fade13c5ba9c48a9ce03a03204c122f3c Mon Sep 17 00:00:00 2001 From: Empus Date: Mon, 27 Jul 2026 21:33:22 +1000 Subject: [PATCH 2/4] fix(resume): send RPL_LOGGEDIN on token replay, flush replay burst, defer 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. --- ircd/m_nick.c | 8 ++++---- ircd/resume.c | 29 +++++++++++++++++++++++++---- ircd/s_auth.c | 16 +++++++++++----- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/ircd/m_nick.c b/ircd/m_nick.c index f0e0902a..838d987f 100644 --- a/ircd/m_nick.c +++ b/ircd/m_nick.c @@ -255,10 +255,10 @@ int m_nick(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) return set_nick_name(cptr, sptr, nick, parc, parv); } /* - * If the collision is with a detached, resume-eligible session, defer the - * decision: keep the requested nick (so iauth/dronescan see it) without - * hashing it, and let registration adopt that session once the account is - * known (see auth_defer_resume_nick() and check_auth_finished()). + * Collision with a detached, resume-eligible session: defer the decision. + * Keep the nick without hashing or forwarding it to iauth, and let + * registration adopt that session once the account is known (see + * auth_defer_resume_nick() and check_auth_finished()). */ if (resume_account_deferrable(sptr, acptr)) return auth_defer_resume_nick(sptr, nick); diff --git a/ircd/resume.c b/ircd/resume.c index bfaf5086..df8f7e8a 100644 --- a/ircd/resume.c +++ b/ircd/resume.c @@ -739,12 +739,21 @@ resume_adopt(struct Client *old_client, struct Client *new_client) * burst, its user modes and away state, and for each channel it belongs to a * self JOIN, topic, and NAMES. Everything is sent only to \a cptr -- no * broadcast -- so peers see nothing. + * + * \a send_loggedin re-sends RPL_LOGGEDIN so a token-path resumer (which never + * SASLs on the new connection) re-learns it is still logged in. */ static void -resume_replay(struct Client *cptr) +resume_replay(struct Client *cptr, int send_loggedin) { struct Membership *member; + /* The account numeric precedes the welcome, as at registration. */ + if (send_loggedin) + send_reply(cptr, RPL_LOGGEDIN, cli_name(cptr), cli_user(cptr)->username, + cli_user(cptr)->host, cli_user(cptr)->account, + cli_user(cptr)->account); + send_welcome(cptr); /* Echo the client's own user modes as a MODE message, like registration. @@ -789,12 +798,18 @@ resume_complete(struct Client *new_client) { struct ResumeSession *s = cli_resume_claim(new_client); struct Client *old_client; + int send_loggedin; assert(s != NULL); assert(s->state == RESUME_STATE_CLAIMING); old_client = s->client; assert(old_client != NULL); + /* A token-path resumer did not SASL here, so it never got RPL_LOGGEDIN; + re-send it for an accounted session (matters when only the server-local + token, not services, let it back in). A SASL resumer already has it. */ + send_loggedin = IsAccount(old_client) && !HasFlag(new_client, FLAG_SASL); + /* Release the temporary client's registration bookkeeping while its connection is still its own. */ if (cli_auth(new_client)) @@ -827,8 +842,10 @@ resume_complete(struct Client *new_client) resume_total_resumed++; - /* The rebuild below is a bounded server burst; lift the SendQ ceiling so a - client in many/large channels is not disconnected mid-replay. */ + /* Lift the SendQ ceiling so the bounded replay burst does not disconnect a + client in many/large channels. The limit is enforced at queue time + (send_buffer()), not at flush (send_queued()), so raising it only across + the burst is safe. */ { struct Connection *con = cli_connect(old_client); unsigned int saved_sendq = con_max_sendq(con); @@ -838,7 +855,7 @@ resume_complete(struct Client *new_client) sendcmdto_one(&me, CMD_RESUME, old_client, "SUCCESS :%s", cli_name(old_client)); resume_restore_away(old_client, s); - resume_replay(old_client); + resume_replay(old_client, send_loggedin); if (s->history_lost) sendstdreply(old_client, MSG_WARN, "RESUME", "HISTORY_LOST", "Messages may have been missed while you were disconnected"); @@ -849,6 +866,10 @@ resume_complete(struct Client *new_client) if (CapHas(cli_active(old_client), CAP_RESUME)) resume_token_issue(old_client); + /* Flush before restoring the ceiling, so a large replay does not leave the + SendQ above the class limit and trip "Max SendQ exceeded" on the next + inbound message. */ + send_queued(old_client); con_max_sendq(con) = saved_sendq; } diff --git a/ircd/s_auth.c b/ircd/s_auth.c index 9064d228..e1fc0d69 100644 --- a/ircd/s_auth.c +++ b/ircd/s_auth.c @@ -464,7 +464,9 @@ static void iauth_notify(struct AuthRequest *auth, enum AuthRequestFlag flag) break; case AR_NEEDS_NICK: - if (IAuthHas(iauth, IAUTH_UNDERNET)) + /* Withhold a nick deferred for resume until it is committed, so iauth does + not evaluate a nick the client may not keep (see auth_defer_resume_nick()). */ + if (IAuthHas(iauth, IAUTH_UNDERNET) && !auth->resume_wantnick[0]) sendto_iauth(auth->client, "n %s", cli_name(sptr)); break; @@ -667,8 +669,11 @@ static int check_auth_finished(struct AuthRequest *auth, int bitclr) if (IsAccount(cptr) && resume_account_try_claim(cptr, held)) { /* claimed -- resume_complete() runs below */ } else if (!held) { + /* Nick freed up; the client keeps it -- forward the now-committed + nick to iauth (it was withheld while deferred). */ hAddClient(cptr); auth->resume_wantnick[0] = '\0'; + iauth_notify(auth, AR_NEEDS_NICK); } else { send_reply(cptr, ERR_NICKNAMEINUSE, auth->resume_wantnick); cli_name(cptr)[0] = '\0'; @@ -1431,10 +1436,11 @@ int auth_set_nick(struct AuthRequest *auth, const char *nickname) } /** Defer a registering secure client whose nick collides with a detached, - * resume-eligible session (see resume_account_deferrable()). The requested - * nick is set on the client so iauth/dronescan see it, but it is deliberately - * NOT added to the hash -- the detached session keeps it -- and it is remembered - * so check_auth_finished() can adopt that session once the account is known. + * resume-eligible session (see resume_account_deferrable()). The nick is set + * on the client for registration bookkeeping but is NOT hashed (the detached + * session keeps it) nor forwarded to iauth (withheld until committed); it is + * remembered so check_auth_finished() can adopt that session once the account + * is known. * @return the auth_set_nick() result (registration proceeds normally). */ int auth_defer_resume_nick(struct Client *cptr, const char *nick) { From f5ecaef2da6eb0e4803ba8398de0d3badb8f6e1d Mon Sep 17 00:00:00 2001 From: Empus Date: Sun, 2 Aug 2026 00:22:39 +1000 Subject: [PATCH 3/4] fix(resume): address review findings on the connection swap 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). --- ircd/resume.c | 33 +++++++++++++++++++++++++++------ ircd/s_bsd.c | 1 + 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/ircd/resume.c b/ircd/resume.c index df8f7e8a..d5486b28 100644 --- a/ircd/resume.c +++ b/ircd/resume.c @@ -45,6 +45,7 @@ #include "s_bsd.h" #include "s_misc.h" #include "s_user.h" +#include "sasl.h" #include "send.h" #include @@ -716,12 +717,27 @@ resume_adopt(struct Client *old_client, struct Client *new_client) *con_privs(newcon) = *con_privs(oldshell); con_snomask(newcon) = con_snomask(oldshell); - /* A resumed oper keeps no Operator block on the new connection; restore the - sendq/flood limits it had resolved on the old one (a class-derived byte - count, not transport state). */ - if (IsAnOper(old_client)) { - con_max_sendq(newcon) = con_max_sendq(oldshell); - con_max_flood(newcon) = con_max_flood(oldshell); + /* Carry the session's resolved sendq/flood limits (the new connection has none). */ + con_max_sendq(newcon) = con_max_sendq(oldshell); + con_max_flood(newcon) = con_max_flood(oldshell); + + /* Carry the accumulated nick-change penalty, so a BRB/reconnect can't reset it. */ + con_nextnick(newcon) = con_nextnick(oldshell); + + /* Keep the local-count bucket and byte stats balanced across the swap. */ + strcpy(con_sockhost(newcon), con_sockhost(oldshell)); + con_sendM(newcon) = con_sendM(oldshell); + con_receiveM(newcon) = con_receiveM(oldshell); + con_sendB(newcon) = con_sendB(oldshell); + con_receiveB(newcon) = con_receiveB(oldshell); + + /* Move the session's conf attachments (incl. any Operator block) onto the live + connection and hand the transient's own to the shell, so class link-counts + stay balanced -- the transient's is freed when new_client exits. */ + { + struct SLink *tmp = con_confs(newcon); + con_confs(newcon) = con_confs(oldshell); + con_confs(oldshell) = tmp; } cli_connect(old_client) = newcon; @@ -815,6 +831,11 @@ resume_complete(struct Client *new_client) if (cli_auth(new_client)) destroy_auth_request(cli_auth(new_client)); + /* Drop any in-flight SASL cookie/timer before the swap frees new_client. */ + sasl_stop_timeout(new_client); + sasl_session_remove(cli_sasl(new_client)); + cli_sasl(new_client) = 0; + cli_resume_claim(new_client) = NULL; s->claimant = NULL; diff --git a/ircd/s_bsd.c b/ircd/s_bsd.c index c0b05c6c..47bff77d 100644 --- a/ircd/s_bsd.c +++ b/ircd/s_bsd.c @@ -500,6 +500,7 @@ void close_connection(struct Client *cptr) void detach_connection(struct Client *cptr) { if (-1 < cli_fd(cptr)) { + auth_send_exit(cptr); /* tell iauth this fd is gone before we close it */ LocalClientArray[cli_fd(cptr)] = 0; if (IsTLS(cptr) && s_tls(&cli_socket(cptr))) { ircd_tls_close(s_tls(&cli_socket(cptr)), NULL); From 032a3bbc66de76424946f157198f5c883bfeceb6 Mon Sep 17 00:00:00 2001 From: Empus Date: Sun, 2 Aug 2026 02:18:05 +1000 Subject: [PATCH 4/4] fix(resume): forward deferred nick to iauth so account-reattach completes 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. --- ircd/m_nick.c | 6 +++--- ircd/s_auth.c | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ircd/m_nick.c b/ircd/m_nick.c index 838d987f..e1d3a4be 100644 --- a/ircd/m_nick.c +++ b/ircd/m_nick.c @@ -256,9 +256,9 @@ int m_nick(struct Client* cptr, struct Client* sptr, int parc, char* parv[]) } /* * Collision with a detached, resume-eligible session: defer the decision. - * Keep the nick without hashing or forwarding it to iauth, and let - * registration adopt that session once the account is known (see - * auth_defer_resume_nick() and check_auth_finished()). + * Keep the nick out of the hash (iauth still sees it, so registration can + * finish) and let registration adopt that session once the account is known + * (see auth_defer_resume_nick() and check_auth_finished()). */ if (resume_account_deferrable(sptr, acptr)) return auth_defer_resume_nick(sptr, nick); diff --git a/ircd/s_auth.c b/ircd/s_auth.c index e1fc0d69..d17aeb39 100644 --- a/ircd/s_auth.c +++ b/ircd/s_auth.c @@ -464,9 +464,9 @@ static void iauth_notify(struct AuthRequest *auth, enum AuthRequestFlag flag) break; case AR_NEEDS_NICK: - /* Withhold a nick deferred for resume until it is committed, so iauth does - not evaluate a nick the client may not keep (see auth_defer_resume_nick()). */ - if (IAuthHas(iauth, IAUTH_UNDERNET) && !auth->resume_wantnick[0]) + /* Send the nick even while deferred for resume: iauth needs it to finish + the registration handshake and reply (login-on-connect stalls otherwise). */ + if (IAuthHas(iauth, IAUTH_UNDERNET)) sendto_iauth(auth->client, "n %s", cli_name(sptr)); break;