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..e1d3a4be 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); } + /* + * Collision with a detached, resume-eligible session: defer the decision. + * 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); + /* * 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..d5486b28 --- /dev/null +++ b/ircd/resume.c @@ -0,0 +1,1035 @@ +/* + * 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 "sasl.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); + + /* 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; + 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. + * + * \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, 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. + "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; + 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)) + 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; + + 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++; + + /* 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); + + 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, send_loggedin); + 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); + + /* 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; + } + + 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..d17aeb39 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 @@ -461,6 +464,8 @@ static void iauth_notify(struct AuthRequest *auth, enum AuthRequestFlag flag) break; case AR_NEEDS_NICK: + /* 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; @@ -655,7 +660,36 @@ 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) { + /* 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'; + 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 +1435,35 @@ 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 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) +{ + 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..47bff77d 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,42 @@ 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)) { + 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); + 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 +1260,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}"