Summary
On a BOUNCER_ENABLE server, if an account's held session (ghost) was created by a plain-TCP/TLS login, the next WebSocket login for that account resumes it through bounce_revive() — which transplants the socket onto the ghost's existing struct Client but never copies FLAG_WEBSOCKET. Everything written after the transplant (001 onward) therefore goes out as raw IRC bytes inside the WebSocket stream; undici / Node's global WebSocket and every browser abort with Expected RSV1 to be clear. and close 1006, and since each reconnect revives the same ghost the client reconnect-loops forever. It is sticky per account and hits any user who runs a desktop client and a web client (Seance) on one account: one plain-TCP login poisons the account for the browser until the session is destroyed.
Branch ircv3.2-upgrade, commit 3ab3038.
Repro
Minimum, testnet-independent — two logins for the same account, hold enabled (PERSISTENCE STATUS reports ON):
- Log in with SASL as account
A over plain TCP (or TLS — anything that is not a WebSocket listener). Join a channel.
- Disconnect. Hard drop or a clean
QUIT makes no difference: m_quit.c:140 routes a clean QUIT into bounce_hold_client() as well, so either way the session goes to BOUNCE_HOLDING with a TCP-born ghost.
- Connect to a
ws:// or wss:// listener, CAP LS 302 / CAP REQ / SASL PLAIN as A / CAP END.
register_user() finds the held session and calls bounce_revive() (ircd/s_user.c:506). From 001 on, the socket receives unframed IRC text.
A non-SASL WebSocket client never hits this (no account → no session → no ghost). A WebSocket login onto a ghost that was itself created by a WebSocket login works, which is why the bug only appears on mixed-client accounts.
Recorded with a raw RFC 6455 client that checks the RSV bits of every inbound frame:
>> CAP END
<< [len=92] @time=… :irc… 903 * :SASL authentication successful
!!! BAD FRAME HEADER b0=0x40 ("@" of "@time=…001…Welcome")
The 903 is still correctly framed — it is written to the temp client, which has FLAG_WEBSOCKET from its handshake. The break starts exactly at the first line written after the transplant. 0x40 is @ = FIN=0, RSV1=1, opcode=0, hence undici's Expected RSV1 to be clear. / close 1006.
Cause
websocket_handshake() (ircd/websocket.c:346) sets the WebSocket state on the connecting client only: SetWebSocket(cptr) at websocket.c:412, cli_wsorigin at :421, SetWSText/SetWSAutodetect at :440-443. These are bits in struct Flags cli_flags on struct Client (include/client.h:524; FLAG_WEBSOCKET :267, FLAG_WSTEXT/FLAG_WSAUTODETECT :269-270) — i.e. on the temp client, not on the Connection that gets transplanted.
bounce_revive() (ircd/bouncer_session.c:5189) moves the connection-level state onto the ghost's pre-existing struct Client: fd + SSL (:5279-5331), IP/sockhost/port/connectip/realhost (:5398-5446), listener (:5448-5452), I-line confs (:5453-5458), sendQ/recvQ (:5472-5489), CAP sets (:5491-5495), con_active_profile/con_attach_cursor (:5497-5510), timing (:5511-5514), and FLAG_SSL, which it recomputes from the new socket in both directions (:5515-5535). FLAG_WEBSOCKET is never touched, so the ghost keeps whatever WS-ness its previous socket had.
send_queued() (ircd/send.c:1035) → deliver_it() (ircd/s_bsd.c:311) frames output only under if (IsWebSocket(cptr)) (s_bsd.c:331). Ghost has the flag clear → raw bytes.
The subprotocol choice has the same shape: text vs binary is IsWSText(cptr) read at s_bsd.c:338, and FLAG_WSTEXT (+ FLAG_WSAUTODETECT when the client offered no subprotocol) live in the same cli_flags word, so they are lost with the flag. cli_wsorigin (include/client.h:549, the oper-visible WEBSOCKET mark) is likewise on struct Client and stays stale on the ghost.
The inbound direction is broken by the same flag: read_packet() decodes frames only under if (length > 0 && IsWebSocket(cptr)) (ircd/s_bsd.c:1130). Clients die on the output first, but both directions are wrong.
The WebSocket parsing state does live on the Connection — con_ws_frame_buf/con_ws_frame_len, con_ws_frag_buf/con_ws_frag_len/con_ws_frag_opcode, con_ws_hs_buf/con_ws_hs_len (include/client.h:480-486) — but the transplant moves the fd onto ghost_con rather than moving the Connection, so those are not carried either. In practice they are empty at revive time (the handshake buffer is freed as soon as the handshake is decided, ircd/list.c:188-190, and the client's last frame, CAP END, is complete), so this is a latent edge case rather than the observed failure.
The mirror case follows from the same code and is worth fixing in the same patch: a WebSocket-born ghost revived by a plain-TCP login keeps FLAG_WEBSOCKET and gets WebSocket-framed bytes on a raw socket. (Not exercised here.)
Suggested fix
Mirror the WS flags from temp onto ghost in bounce_revive(), next to the Step 10 FLAG_SSL block (bouncer_session.c:5515-5535) and set/clear symmetrically, exactly as that block does for SSL:
if (IsWebSocket(temp)) SetWebSocket(ghost); else ClearWebSocket(ghost);
if (IsWSText(temp)) SetWSText(ghost); else ClearWSText(ghost);
if (IsWSAutodetect(temp)) SetWSAutodetect(ghost); else ClearWSAutodetect(ghost);
ircd_strncpy(cli_wsorigin(ghost), cli_wsorigin(temp), sizeof(cli_wsorigin(ghost)));
Optionally carry con_ws_frame_buf/con_ws_frame_len/con_ws_frag_* alongside the recvQ move in Step 7 (:5472-5489) so a frame split across the transplant is not dropped.
The alias/attach paths do not have this shape and need no change: bounce_attach() (bouncer_session.c:1664) and bounce_setup_local_alias() (:7249) keep the connecting client's own struct Client/struct Connection and transfer channel memberships instead of a socket, and bounce_sync_alias_join() (:6143) only sends to an already-correct alias client. bounce_revive() is the only socket transplant in the tree — socket_del_keepfd/socket_reattach appear nowhere else outside ircd_events.c/ircd_kc_adapter.c (bouncer_session.c:5301, :5321, :5380).
Workaround
Per account, to flip a poisoned ghost back: log in over TCP → PERSISTENCE SET OFF → QUIT (destroys the session rather than holding it) → next login over the WebSocket listener → PERSISTENCE SET DEFAULT. The ghost is then WS-born and every later WebSocket login works.
Context
Found while live-testing the new PERSISTENCE ATTACH cursor (9bc57d4) from Seance against a nefarious2 + X3 testnet. The cursor itself worked exactly as the commit message describes — replay strictly after the cursor msgid, per-channel and per-PM inner batches inside the evilnet.github.io/bouncer-replay wrapper, FAIL PERSISTENCE CURSOR_UNKNOWN on a bogus msgid with the replay still running from the session's own since-time, no wrapper at all when nothing was missed.
While here, two much smaller notes:
PERSISTENCE LIST is advertised in the draft/persistence CAP 302 value (ircd/ircd.c:1315, "attach,detach,list,attach-cursor") but is not a subcommand: the dispatch in m_persistence() (ircd/m_persistence.c:880-914) handles STATUS/GET/SET/PROFILE/ATTACH/REPLAY/DETACH and falls through to FAIL PERSISTENCE INVALID_PARAMETERS LIST :Unknown PERSISTENCE subcommand (:912). Either drop the token or add the subcommand — as-is, feature detection on the value is misleading.
- The auto-replay is capped per target and never signals truncation:
replay_next_channel() (ircd/replay.c:364) and replay_next_pm() (:415) each ask history_query_latest_after() for at most rs->replay_limit (:389, :429), set from FEAT_BOUNCER_AUTO_REPLAY_LIMIT (default 100, ircd/ircd_features.c:1328, floored to 100 at replay.c:751-753). Nothing on the wire distinguishes a complete replay from a truncated one, so a client that missed more than the limit in one channel silently has a hole it cannot detect — a marker on the inner batch (or a count in the closing NOTICE) would let the client backfill with CHATHISTORY.
Summary
On a
BOUNCER_ENABLEserver, if an account's held session (ghost) was created by a plain-TCP/TLS login, the next WebSocket login for that account resumes it throughbounce_revive()— which transplants the socket onto the ghost's existingstruct Clientbut never copiesFLAG_WEBSOCKET. Everything written after the transplant (001 onward) therefore goes out as raw IRC bytes inside the WebSocket stream; undici / Node's globalWebSocketand every browser abort withExpected RSV1 to be clear.and close 1006, and since each reconnect revives the same ghost the client reconnect-loops forever. It is sticky per account and hits any user who runs a desktop client and a web client (Seance) on one account: one plain-TCP login poisons the account for the browser until the session is destroyed.Branch
ircv3.2-upgrade, commit3ab3038.Repro
Minimum, testnet-independent — two logins for the same account, hold enabled (
PERSISTENCE STATUSreportsON):Aover plain TCP (or TLS — anything that is not a WebSocket listener). Join a channel.QUITmakes no difference:m_quit.c:140routes a clean QUIT intobounce_hold_client()as well, so either way the session goes toBOUNCE_HOLDINGwith a TCP-born ghost.ws://orwss://listener,CAP LS 302/CAP REQ/ SASL PLAIN asA/CAP END.register_user()finds the held session and callsbounce_revive()(ircd/s_user.c:506). From 001 on, the socket receives unframed IRC text.A non-SASL WebSocket client never hits this (no account → no session → no ghost). A WebSocket login onto a ghost that was itself created by a WebSocket login works, which is why the bug only appears on mixed-client accounts.
Recorded with a raw RFC 6455 client that checks the RSV bits of every inbound frame:
The 903 is still correctly framed — it is written to the temp client, which has
FLAG_WEBSOCKETfrom its handshake. The break starts exactly at the first line written after the transplant.0x40is@=FIN=0, RSV1=1, opcode=0, hence undici'sExpected RSV1 to be clear./ close 1006.Cause
websocket_handshake()(ircd/websocket.c:346) sets the WebSocket state on the connecting client only:SetWebSocket(cptr)atwebsocket.c:412,cli_wsoriginat:421,SetWSText/SetWSAutodetectat:440-443. These are bits instruct Flags cli_flagsonstruct Client(include/client.h:524;FLAG_WEBSOCKET:267,FLAG_WSTEXT/FLAG_WSAUTODETECT:269-270) — i.e. on the temp client, not on the Connection that gets transplanted.bounce_revive()(ircd/bouncer_session.c:5189) moves the connection-level state onto the ghost's pre-existingstruct Client: fd + SSL (:5279-5331), IP/sockhost/port/connectip/realhost (:5398-5446), listener (:5448-5452), I-line confs (:5453-5458), sendQ/recvQ (:5472-5489), CAP sets (:5491-5495),con_active_profile/con_attach_cursor(:5497-5510), timing (:5511-5514), andFLAG_SSL, which it recomputes from the new socket in both directions (:5515-5535).FLAG_WEBSOCKETis never touched, so the ghost keeps whatever WS-ness its previous socket had.send_queued()(ircd/send.c:1035) →deliver_it()(ircd/s_bsd.c:311) frames output only underif (IsWebSocket(cptr))(s_bsd.c:331). Ghost has the flag clear → raw bytes.The subprotocol choice has the same shape: text vs binary is
IsWSText(cptr)read ats_bsd.c:338, andFLAG_WSTEXT(+FLAG_WSAUTODETECTwhen the client offered no subprotocol) live in the samecli_flagsword, so they are lost with the flag.cli_wsorigin(include/client.h:549, the oper-visible WEBSOCKET mark) is likewise onstruct Clientand stays stale on the ghost.The inbound direction is broken by the same flag:
read_packet()decodes frames only underif (length > 0 && IsWebSocket(cptr))(ircd/s_bsd.c:1130). Clients die on the output first, but both directions are wrong.The WebSocket parsing state does live on the Connection —
con_ws_frame_buf/con_ws_frame_len,con_ws_frag_buf/con_ws_frag_len/con_ws_frag_opcode,con_ws_hs_buf/con_ws_hs_len(include/client.h:480-486) — but the transplant moves the fd ontoghost_conrather than moving the Connection, so those are not carried either. In practice they are empty at revive time (the handshake buffer is freed as soon as the handshake is decided,ircd/list.c:188-190, and the client's last frame,CAP END, is complete), so this is a latent edge case rather than the observed failure.The mirror case follows from the same code and is worth fixing in the same patch: a WebSocket-born ghost revived by a plain-TCP login keeps
FLAG_WEBSOCKETand gets WebSocket-framed bytes on a raw socket. (Not exercised here.)Suggested fix
Mirror the WS flags from
tempontoghostinbounce_revive(), next to the Step 10FLAG_SSLblock (bouncer_session.c:5515-5535) and set/clear symmetrically, exactly as that block does for SSL:Optionally carry
con_ws_frame_buf/con_ws_frame_len/con_ws_frag_*alongside the recvQ move in Step 7 (:5472-5489) so a frame split across the transplant is not dropped.The alias/attach paths do not have this shape and need no change:
bounce_attach()(bouncer_session.c:1664) andbounce_setup_local_alias()(:7249) keep the connecting client's ownstruct Client/struct Connectionand transfer channel memberships instead of a socket, andbounce_sync_alias_join()(:6143) only sends to an already-correct alias client.bounce_revive()is the only socket transplant in the tree —socket_del_keepfd/socket_reattachappear nowhere else outsideircd_events.c/ircd_kc_adapter.c(bouncer_session.c:5301,:5321,:5380).Workaround
Per account, to flip a poisoned ghost back: log in over TCP →
PERSISTENCE SET OFF→QUIT(destroys the session rather than holding it) → next login over the WebSocket listener →PERSISTENCE SET DEFAULT. The ghost is then WS-born and every later WebSocket login works.Context
Found while live-testing the new
PERSISTENCE ATTACHcursor (9bc57d4) from Seance against a nefarious2 + X3 testnet. The cursor itself worked exactly as the commit message describes — replay strictly after the cursor msgid, per-channel and per-PM inner batches inside theevilnet.github.io/bouncer-replaywrapper,FAIL PERSISTENCE CURSOR_UNKNOWNon a bogus msgid with the replay still running from the session's own since-time, no wrapper at all when nothing was missed.While here, two much smaller notes:
PERSISTENCE LISTis advertised in thedraft/persistenceCAP 302 value (ircd/ircd.c:1315,"attach,detach,list,attach-cursor") but is not a subcommand: the dispatch inm_persistence()(ircd/m_persistence.c:880-914) handlesSTATUS/GET/SET/PROFILE/ATTACH/REPLAY/DETACHand falls through toFAIL PERSISTENCE INVALID_PARAMETERS LIST :Unknown PERSISTENCE subcommand(:912). Either drop the token or add the subcommand — as-is, feature detection on the value is misleading.replay_next_channel()(ircd/replay.c:364) andreplay_next_pm()(:415) each askhistory_query_latest_after()for at mostrs->replay_limit(:389,:429), set fromFEAT_BOUNCER_AUTO_REPLAY_LIMIT(default 100,ircd/ircd_features.c:1328, floored to 100 atreplay.c:751-753). Nothing on the wire distinguishes a complete replay from a truncated one, so a client that missed more than the limit in one channel silently has a hole it cannot detect — a marker on the inner batch (or a count in the closing NOTICE) would let the client backfill withCHATHISTORY.