Skip to content

feat(client): add SSHClient.disconnect for protocol-level clean teardown - #250

Open
hhoao wants to merge 1 commit into
vicajilau:mainfrom
hhoao:feat/client-disconnect
Open

hhoao wants to merge 1 commit into
vicajilau:mainfrom
hhoao:feat/client-disconnect

Conversation

@hhoao

@hhoao hhoao commented Sep 6, 2026

Copy link
Copy Markdown

Motivation

close() tears the socket down without sending SSH_MSG_DISCONNECT, so the peer has to infer the teardown from TCP alone. Two consequences:

  • Servers log an unexplained connection loss instead of a clean disconnect.
  • A client that abandons a connection before authentication relies on the server's LoginGraceTime to clean up. OpenSSH ≥ 9.8 penalises exactly that pattern via PerSourcePenalties — half-open pre-auth connections make the source look like an attacker, and subsequent connections are refused with the plaintext "Not allowed at this time" line. A protocol-level disconnect avoids the penalty entirely.

Receiving side already exists (SSHDisconnectError, 3.3.0); this adds the sending side per RFC 4253 §11.1.

Changes

  • SSHClient.disconnect(): sends SSH_Message_Disconnect with byApplication, flushes, then closes. Idempotent and fire-and-forget safe — a transport that already died falls through to close().

Testing

  • New test/src/ssh_client_disconnect_test.dart: asserts the disconnect packet reaches the wire before the close, with the BY_APPLICATION reason code and description; asserts a no-op on an already-closed client.
  • Full suite passes locally (705 tests).

🤖 Generated with Claude Code

@vicajilau vicajilau left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this out and ran it. The feature is right and I want it, but the motivation needs rewriting before it goes in the changelog, because one of the two reasons does not hold up.

What I verified works. It sends before authentication, which is the case you care about: sendPacket buffers during key exchange, but _shouldBypassRekeyBuffer lets messageId <= 4 through and disconnect is 1. Your test covers the earliest case of all, a client constructed against a socket that never answers. 705 tests pass here.

RFC 4253 §11.1 is exact, and it says something in your favour that you did not quote: "All implementations MUST be able to process this message; they SHOULD be able to send this message." This library had only the receiving half since 3.3.0. That alone justifies the change.

I also found support for your first bullet that you did not cite. In OpenSSH's packet.c:

/* Ignore normal client exit notifications */
do_log2(ssh->state->server_side &&
    reason == SSH2_DISCONNECT_BY_APPLICATION ?
    SYSLOG_LEVEL_INFO : SYSLOG_LEVEL_ERROR, "Received disconnect from ...")

Sending BY_APPLICATION specifically is what drops the server's log line from error to info, so your choice of reason code is exactly right.

What does not hold up. "A protocol-level disconnect avoids the penalty entirely" is not what the code does. In sshd.c the penalty comes from the pre-auth child's exit status, not from how the client left:

case EXIT_LOGIN_GRACE:     penalty_type = SRCLIMIT_PENALTY_GRACE_EXCEEDED;
case EXIT_AUTH_ATTEMPTED:  penalty_type = SRCLIMIT_PENALTY_AUTHFAIL;
default:                   penalty_type = SRCLIMIT_PENALTY_NOAUTH;

cleanup_exit(255) with no auth attempted lands in that default. sshd_config.5 says the same thing from the other side: noauth is "how long to refuse clients that disconnect without attempting authentication", and it warns it "may penalise legitimate scanning tools such as ssh-keyscan". ssh-keyscan disconnects cleanly. The penalty is for the behaviour, not the rudeness.

The scale is also smaller than the description suggests: noauth accrues 1s by default and min requires 15s accrued before anything is enforced, so it takes roughly fifteen abandoned connections from one source before a refusal.

I did not trace every path to exit(255), so treat that as unproven rather than disproven. But it is not proven here either, and it should not go into a changelog entry as fact. The log-level argument and the RFC are enough on their own.

Blocking: dart format fails on test/src/ssh_client_disconnect_test.dart, so CI would stop at the formatting step before running anything. The branch is also behind main, and there is no CHANGELOG entry, which this needs since it adds public API and makes the next release 4.2.0.

Two small things. on Object catches Error too, so a programming fault in _sendMessage disappears silently. on Exception would say what you mean. And the doc comment does not say when to prefer disconnect() over close(), which is the first thing a reader will wonder.

Sends SSH_MSG_DISCONNECT (BY_APPLICATION) and then closes, so the peer
sees a protocol-level disconnect instead of inferring the teardown from
TCP alone.

close() tears the socket down without a disconnect message, which leaves
servers to log an unexplained connection loss. RFC 4253 §11.1 asks for
this message, and OpenSSH logs a BY_APPLICATION teardown as info rather
than error (packet.c), so a deliberate client-side close stops showing
up on the server as a dropped connection.

Fire-and-forget safe: a transport that already died falls through to
close().

Co-Authored-By: Claude <noreply@anthropic.com>
@hhoao
hhoao force-pushed the feat/client-disconnect branch from 72f8901 to 386a13e Compare September 7, 2026 06:25
@hhoao

hhoao commented Sep 7, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review — the packet.c citation for BY_APPLICATION downgrading the server log to info is a much stronger argument than the one I led with.

On PerSourcePenalties: you're right, I overstated it. I hadn't checked whether the penalty is driven by the pre-auth child's exit status rather than how the client leaves, and the ssh-keyscan counterexample settles it — the penalty is for the behaviour, not the rudeness. I've dropped that claim from the commit message and the CHANGELOG entry; the feature now stands on the RFC §11.1 SHOULD and the log-level argument alone.

Changes pushed (rebased onto main):

  • dart format applied to the new test file.
  • CHANGELOG entry added under Unreleased (no PerSourcePenalties claim). Happy to fold it into a 4.2.0 heading if you'd prefer that over Unreleased.
  • on Object → on Exception in the catch, so programming errors aren't swallowed.
  • Doc comment now explains when to prefer disconnect() over close(): deliberate client-side close vs. error paths.

Both new tests pass. The 23 interop test failures I see locally are the same on a clean checkout of main — my machine has no Docker for the test sshd — so they're environmental, not from this change.

Co-Authored-By: Claude noreply@anthropic.com

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants