Skip to content

fix: keep what a client sends while the SSH relay looks up its host - #1802

Open
Li-Lee wants to merge 1 commit into
Termix-SSH:mainfrom
Li-Lee:fix/ssh-relay-early-pipe
Open

Li-Lee wants to merge 1 commit into
Termix-SSH:mainfrom
Li-Lee:fix/ssh-relay-early-pipe

Conversation

@Li-Lee

@Li-Lee Li-Lee commented Nov 9, 2026

Copy link
Copy Markdown

A desktop app linked to a server, with connections starting on the server, reaches hosts through /plugin-ws/core/ssh-relay. In that setup, Tmux Monitor in the desktop app failed with "Bad packet length" ("Failed to load tmux sessions" in the All hosts list) for every host given by name, while the same hosts worked from the web app. Hosts given by IP worked everywhere.

handleRelay awaits the host lookup and openSshTransport, which resolves a host name through resolveSshConnectConfigHost, before it calls createWebSocketStream(ws). A message that arrives in that gap has no listener and is dropped. The client speaks first: ssh2 sends its identification line as soon as the socket opens. So for a host given by name, the host's sshd never sees the client's greeting. It reads the KEXINIT as its first line, answers Invalid SSH identification string., and ssh2 on the desktop reads that text as a packet: Bad packet length. An IP needs no real lookup, so the stream is attached before any message arrives.

Through the relay on my server, hosts given by .lan names failed 19 of 21 handshakes and hosts given by IP failed none. Holding the client's first write back 300 ms made the named hosts pass.

The stream is now created first thing in handleRelay, so early messages wait in its read buffer (ws pauses the socket when it fills) until the host side is piped in. An error listener comes with it: the stream's own error handler rethrows when nothing else listens, so without one, a socket error during the lookup would be an uncaught exception. The existing pipe.once("error", finish) is unchanged.

Before this change, a protocol error from the client during the lookup (for example an unmasked frame) was already an uncaught exception, because nothing listened on the socket yet. The backend logs it and keeps running. The new tests cover that case too.

Validation:

  • New tests in ssh-relay.test.ts, with the transport held until the test releases it:
    • The client's greeting, sent during the lookup, reaches the host. Fails before the change.
    • A broken frame during the lookup. An uncaught exception before the change, and also with the change minus the new error listener.
    • The host connection is closed when the client left during the lookup. Passes before and after; guards the earlier stream.
  • ssh-relay.test.ts: 9 passed, 20 runs in a row.
  • Full suite on main (02fb265): 4635 passed, 1 skipped. 5 failed, in ssh2-sk-patch, ctx-desktop-local, host-commands and host-modules, and they fail the same way without this change in my checkout (installed with --ignore-scripts, so the postinstall patches did not run).
  • tsc -b --force, eslint and prettier pass, and so do check-test-locations and check-changelog.
  • The same change, applied to the built ssh-relay.js on my 26.11.0 server: 30 of 30 handshakes through the relay to hosts given by name or IP, and the desktop app lists tmux sessions on the named hosts again. Clients leaving during the lookup left no open connections to the host.

This targets main because there is no dev-* branch right now. Happy to retarget.

🤖 Generated with Claude Code

handleRelay awaited the host lookup and openSshTransport, which resolves a
host given by name, before it created the WebSocket stream. A message that
arrived in that gap had no listener and was dropped. The client speaks
first, so the host's sshd lost the client's SSH greeting, read the KEXINIT
as its first line and answered "Invalid SSH identification string.", which
ssh2 on the desktop reports as "Bad packet length".

Create the stream first thing, so early messages wait in its read buffer,
with an error listener so a socket error during the lookup isn't rethrown
with nobody listening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@termix-triage-bot

Copy link
Copy Markdown

AI review | Needs changes

Moves WebSocket stream creation earlier in the SSH relay handler to buffer the client's initial greeting during DNS lookup, preventing dropped packets and handshake failures. Adds an error listener to prevent uncaught exceptions during the lookup phase and includes tests for the new behavior.

  • major Core PRs should target the current dev-* branch, not main. If no dev-* branch exists yet, ignore this.

Reviewed 3ff0112. AI can be wrong, a maintainer still decides. Comment /termix review to run it again.

@Li-Lee

Li-Lee commented Nov 9, 2026

Copy link
Copy Markdown
Author

The lint-and-build failure is Prettier on packaging/flatpak/index.html, which this PR doesn't touch: it came in with 02fb265 on main, and npx prettier --check packaging/flatpak/index.html fails on main itself. The two changed files pass Prettier. On the AI review's note: there's no dev-* branch right now, so this targets main; happy to retarget once one exists.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Open PRs

Development

Successfully merging this pull request may close these issues.

1 participant