Skip to content

feat(tp_sshd): embedded pure-Dart SSH server package - #7

Merged
hhoao merged 14 commits into
mainfrom
worktree-tp-sshd
Sep 19, 2026
Merged

hhoao merged 14 commits into
mainfrom
worktree-tp-sshd

Conversation

@hhoao

@hhoao hhoao commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the protocol-server half of the embedded SSH server spec (docs/specs/2026-09-08-embedded-ssh-server-design.md): QR pairing will no longer depend on a system sshd. Ships in two repos:

dartssh2 submodule (feat/embedded-server, pushed — gitlink bump included):

  • lib/protocol.dart: additive public export of the wire codec / KEX math / algorithm registries (zero behavior change)
  • SSHTransport: server-role ECDH key exchange completion (signs the exchange hash with a host key, role-guarded; client behavior untouched)

New package client/packages/tp_sshd (pure Dart, no Flutter deps):

  • publickey-only fail-closed auth (ed25519 device keys, RFC 4252 §7 byte-exact signature enforcement, 6-attempt throttle)
  • channel multiplexing with RFC 4254 window semantics both directions
  • structured tp1: exec (argv/cwd/env — no shell strings, no quoting), host-info query, pty-backed interactive shells
  • SFTPv3 subsystem over an injected filesystem (256 KiB packet-bound READDIR batching, request-id fidelity)
  • loopback-only tcpip-forward / forwarded-tcpip (TeamBus reverse tunnels)
  • hardening: channel cap (10), crash isolation, bounded close-flush, accept-loop error containment

Everything is dual-tested through the real vendored dartssh2 client (56 package tests + 8 new fork tests; adversarial traffic included: malformed packets, tiny windows, invalid signatures, 6000-entry listings).

Verification

  • client/packages/tp_sshd: dart analyze clean, dart test 56/56, dart format clean
  • client/packages/dartssh2: dart analyze clean, suite green vs baseline (only the 23 pre-existing test.rebex.net network failures)
  • App flutter analyze against the changed dartssh2: 0 errors in app lib//test/ and vendored packages (the full app test suite is blocked in fresh checkouts by a pre-existing native_toolchain_c vswhere GBK bug on zh-CN Windows — unrelated to this change)

Notes for reviewers

  • App integration (EmbeddedSshServer, offer v2, RemoteCommandCodec, UI/l10n) is the deliberately scoped follow-up plan
  • Known deferred items are documented in the plan ledger; noteworthy for the app plan: SETSTAT is acknowledged-but-not-applied (no attribute store in the injected interface), SFTP dispatches concurrently (LocalFilesystem adapter must lock), READLINK/SYMLINK answer OP_UNSUPPORTED
  • Publish ordering honored: dartssh2 feat/embedded-server is pushed before this branch

🤖 Generated with Claude Code

hhoao and others added 14 commits September 8, 2026 13:25
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ttling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 5 of the embedded SSH server plan: the running phase serves
real session traffic.

- SSHServerChannel: per-channel input/extendedInput streams, outgoing
  write/writeExtended/sendEof/close with window-aware chunking (a spent
  send window stalls until a Window_Adjust unblocks it), OpenSSH-style
  deferred receive-window refills, and the onRequest hook Tasks 6-7
  build the session requests on (refuses requests until then).
- SSHServerConnection: channel table keyed by our channel number,
  CHANNEL_OPEN (session-only; other types administratively prohibited),
  Window_Adjust/Data/Extended_Data/EOF/Close routing (unknown ids
  ignored as close races), Channel_Request dispatch, and global
  requests (keepalive@openssh.com answered, everything else refused
  until Task 9).
- Tests: real-client dual pairs (open/failure/keepalive/data/close/
  requests) plus raw-transport window tests with tiny windows, and
  harness helpers (startDualConnection, raw authenticated pair,
  mirror-based client session-channel opener).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 6 of the embedded SSH server plan: session exec speaks only the
structured tp1: grammar.

- server_process.dart: SSHServerProcess (stdout/stderr/stdin/exitCode/
  kill contract for app-backed processes), SSHProcessFactory,
  SSHHostInfo (const, toJson/fromJson wire format), SSHExecRequest,
  and TpExecCodec (encode/tryDecode fail-closed, host-info query
  encode/match, encodeHostInfo).
- server_session.dart: handleSessionRequest serves exec — host-info is
  answered by the server itself (JSON on stdout, exit 0), everything
  else requires a tp1: payload and a processFactory, then pipes
  stdout->channel data, stderr->extended data, channel input->stdin,
  and reports exit-status before EOF/close. Channel close or
  connection teardown kills the process and closes its streams. Non-
  tp1: commands are refused and the channel closed — no shell strings
  ever run.
- server_channel.dart: onRequest widened to carry the triggering
  request and report its outcome (Future<bool>), removing the racy
  currentRequest field; new sendExitStatus for RFC 4254 6.10.
- ssh_server.dart: SSHServerConfig.processFactory / hostInfo, both
  nullable (null refuses the corresponding request).
- Tests: real SSHClient.execute() dual pairs (argv/cwd/env delivery
  with exit code 17, plain shell string rejected, host-info answered
  without spawning) and startDualPair grows the two config hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session channels now serve the interactive half of RFC 4254 §6: pty-req
stashes terminal dimensions (TERM in the environment, modes unparsed),
env requests accumulate, shell requires the stashed pty-req and spawns
through the new SSHServerConfig.ptyFactory (SSHServerPty /
SSHPtyDimensions in server_process.dart), window-change resizes the
running pty, and signal delivers the RFC 4254 §6.9 name the client sent
straight to it. Shell serving pipes through the exec plumbing, so
teardown mirrors it: pty exit reports exit-status before EOF and close;
channel/connection teardown kills the pty.

A session channel takes exactly one lifecycle request: exec or shell
once — a second lifecycle request is refused and the channel closed
after its failure reply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tcpip-forward / cancel-tcpip-forward global requests and
forwarded-tcpip channels (RFC 4254 §7). Binds go through the
injected SSHBindServerSocket seam (null = forwarding disabled);
only 127.0.0.1, ::1 and localhost are ever bound, refused before
the seam is consulted otherwise. Accepted connections ride
server-initiated forwarded-tcpip channel opens — the channel
table now has an outbound open path with pending-open tracking.
Port 0 binds ephemeral and the Request_Success payload carries
the actual port. Cleanup is bidirectional: TCP close closes the
channel, channel close destroys the TCP side, cancel releases the
listener, and connection teardown unbinds everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Carried review minors, each with its test:

- Contain connection-stream errors in the SSHServer accept loop: the
  error surfaces on SSHServer.done (marked handled so an unawaited done
  cannot leak a zone error) after live connections are torn down, and
  the server stops accepting.
- Cap simultaneously open channels per connection: new maxChannels
  config (default 10, OpenSSH parity); excess CHANNEL_OPENs are refused
  with reason 4 (resource shortage), and the server-initiated
  (forwarded-tcpip) direction respects the same cap.
- Flush queued window-credit data on SSHServerChannel.close within a
  bounded wait (closeFlushTimeout, default 2 s) so an exec output tail
  is not truncated by a slow peer; a window that never opens falls back
  to dropping the tail after the bound.
- Fail-safe process-contract errors: an exitCode future that errors
  tears the pipes down and closes the channel without inventing an exit
  status; a stdin.add throw is treated as input-side close.

Hardening tests (test/server_hardening_test.dart): auth throttle cuts
the client off after the attempt budget, a garbage connection is closed
without harming the listener, five concurrent connections all
authenticate and exec, and a mid-session rekey through the fork's public
SSHClient.rekey() keeps the channel alive.

Also: direct test for the null-seam tcpip-forward refusal, shared
startRawPair/testProbeRequest test helpers, and the package README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sftp exports

Moves the gitlink from 4602aab to the feat/embedded-server head
(a12443d): the lib/protocol.dart public export, the server-role ECDH
key exchange in SSHTransport, and the sftp primitive exports that
tp_sshd builds on. Not pushed — the submodule push is deferred to the
finish gate with the human.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A stock OpenSSH sftp client exits by sending CHANNEL_EOF on the session
channel and then waiting for the server to close it. The subsystem only
awaited the channel finishing, which requires the client's CHANNEL_CLOSE
— so a bare EOF deadlocked the session and the client never exited.

End the session on the input stream closing instead (the client's EOF,
its close, or connection teardown all close it), release the open
handles, and close the channel — the same onDone pattern the exec/shell
pump already uses. Found driving the package with a real OpenSSH client.

Co-Authored-By: Claude <noreply@anthropic.com>
demo_sshd.dart wires every seam to real system resources — a TCP
listener, ssh-keygen-bootstrapped keys, a jailed dart:io SFTP
filesystem, Process-backed exec, a util-linux `script` pty for shells,
and real ServerSocket binds for tcpip-forward — so the package can be
exercised end-to-end with a stock OpenSSH client. First run prints the
exact ssh/sftp/-R command lines; keys and sandbox live in
~/.tp_sshd_demo.

Co-Authored-By: Claude <noreply@anthropic.com>
@hhoao
hhoao merged commit 966179a into main Sep 19, 2026
1 of 5 checks passed
hhoao added a commit that referenced this pull request Sep 19, 2026
# Conflicts:
#	client/lib/app/app_shell.dart
#	client/test/services/terminal/workspace_shell_connector_test.dart
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.

1 participant