Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions doc/example.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
101 changes: 101 additions & 0 deletions doc/readme.features
Original file line number Diff line number Diff line change
Expand Up @@ -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 <seconds>" 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
Expand Down
144 changes: 144 additions & 0 deletions doc/readme.resume
Original file line number Diff line number Diff line change
@@ -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 <nick> ACK :draft/resume-0.5
S: :server RESUME TOKEN <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 <nick> / USER ...
C: RESUME <token>
S: :server RESUME SUCCESS :<oldnick>
S: <welcome burst, self JOIN/topic/NAMES for each channel>
S: :server WARN RESUME HISTORY_LOST :... (only if output was dropped)
S: :server RESUME TOKEN <new-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 <seconds>

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: <welcome burst, self JOIN/topic/NAMES for each channel>

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.
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ services:
- "16699:6699"
- "16700:6700"
- "16701:6701"
- "16702:6702"
- "14440:4440"
- "14441:4441"
networks:
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion include/capab.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 {
Expand Down
Loading
Loading