From 0d6d7ac7806c937649c81a9da569aad6461b48e6 Mon Sep 17 00:00:00 2001 From: hhoao Date: Tue, 8 Sep 2026 13:25:55 +0800 Subject: [PATCH 01/14] feat(tp_sshd): server scaffold with fail-closed connection lifecycle Co-Authored-By: Claude Opus 4.8 --- client/packages/tp_sshd/.gitignore | 6 + .../tp_sshd/lib/src/server_connection.dart | 147 +++++++++++++++++ .../packages/tp_sshd/lib/src/ssh_server.dart | 151 ++++++++++++++++++ client/packages/tp_sshd/lib/tp_sshd.dart | 3 + client/packages/tp_sshd/pubspec.yaml | 15 ++ .../tp_sshd/test/dual_test_utils.dart | 70 ++++++++ .../tp_sshd/test/server_handshake_test.dart | 63 ++++++++ .../tp_sshd/test/test_socket_pair.dart | 70 ++++++++ 8 files changed, 525 insertions(+) create mode 100644 client/packages/tp_sshd/.gitignore create mode 100644 client/packages/tp_sshd/lib/src/server_connection.dart create mode 100644 client/packages/tp_sshd/lib/src/ssh_server.dart create mode 100644 client/packages/tp_sshd/lib/tp_sshd.dart create mode 100644 client/packages/tp_sshd/pubspec.yaml create mode 100644 client/packages/tp_sshd/test/dual_test_utils.dart create mode 100644 client/packages/tp_sshd/test/server_handshake_test.dart create mode 100644 client/packages/tp_sshd/test/test_socket_pair.dart diff --git a/client/packages/tp_sshd/.gitignore b/client/packages/tp_sshd/.gitignore new file mode 100644 index 000000000..47dba71b9 --- /dev/null +++ b/client/packages/tp_sshd/.gitignore @@ -0,0 +1,6 @@ +# Files and directories created by pub +.dart_tool/ + +# Omit committing pubspec.lock for library packages: +# https://dart.dev/guides/libraries/private-files#pubspeclock +pubspec.lock diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart new file mode 100644 index 000000000..9cfd38736 --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart' show SSHSocket, SSHTransport; +import 'package:dartssh2/protocol.dart'; + +import 'ssh_server.dart' show SSHServerConfig, tpServerAlgorithms; + +/// Lifecycle phases of an [SSHServerConnection]. +enum _Phase { + /// Handshake done; the client is trying to authenticate. Everything except + /// the service negotiation is refused. + auth, + + /// Authenticated; session traffic (channels) is served. Reached once + /// userauth lands (Task 4+). + running, + + /// The connection is gone. + closed, +} + +/// One accepted connection: a server-role [SSHTransport] plus the +/// connection-level state machine and the auth timeout that bounds the +/// pre-authentication phase. +/// +/// Task 3 scope: the auth phase accepts the `ssh-userauth` service request +/// and fails every authentication attempt closed (there is no userauth +/// service yet — Task 4 adds one driven by [SSHServerConfig.authenticate]). +class SSHServerConnection { + SSHServerConnection( + this.socket, { + required SSHServerConfig config, + }) : _config = config { + // SSHTransport takes its handlers as final constructor-injected fields, + // so the state machine has to be wired in right here. + _transport = SSHTransport( + socket, + isServer: true, + hostKeyPair: config.hostKeyPair, + algorithms: tpServerAlgorithms, + printDebug: config.printDebug, + printTrace: config.printTrace, + onMessage: _handleMessage, + ); + _authTimer = Timer(config.authTimeout, _onAuthTimeout); + // The transport's done future completes with an error when the transport + // is terminated by one; the connection only cares about the timing. + _transport.done.whenComplete(_onTransportClosed).ignore(); + } + + /// The socket this connection serves. + final SSHSocket socket; + + final SSHServerConfig _config; + + late final SSHTransport _transport; + late final Timer _authTimer; + var _phase = _Phase.auth; + + /// Completes when the underlying transport closes, normally or with an + /// error. + Future get done => _transport.done; + + /// Closes the connection and its socket. + Future close() async { + _authTimer.cancel(); + _phase = _Phase.closed; + await _transport.close(); + } + + /// Auth-phase message handling. + /// + /// Returns whether the message was recognized, so the transport answers + /// unrecognized ones with SSH_MSG_UNIMPLEMENTED (RFC 4253 §11). + bool _handleMessage(Uint8List payload) { + switch (_phase) { + case _Phase.closed: + return false; + case _Phase.auth: + return _handleAuthMessage(payload); + case _Phase.running: + // No session traffic is served yet; Task 5 replaces this branch + // with channel handling. + return false; + } + } + + bool _handleAuthMessage(Uint8List payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Service_Request.messageId: + final message = SSH_Message_Service_Request.decode(payload); + if (message.serviceName == 'ssh-userauth') { + _transport.sendPacket( + SSH_Message_Service_Accept(message.serviceName).encode(), + ); + } else { + _disconnect( + SSHDisconnectReason.serviceNotAvailable, + 'Service not available: ${message.serviceName}', + ); + } + return true; + case SSH_Message_Userauth_Request.messageId: + // Fail closed. The server has no userauth service yet, so every + // authentication attempt is answered with a failure carrying no + // continuable methods — every client then terminates its auth + // (dartssh2's SSHClient gives up and errors out) instead of waiting + // for an answer that would never come. Task 4 replaces this with + // real publickey userauth driven by [SSHServerConfig.authenticate]. + _transport.sendPacket( + SSH_Message_Userauth_Failure(methodsLeft: const []).encode(), + ); + return true; + default: + // Unrecognized message: let the transport answer with + // SSH_MSG_UNIMPLEMENTED. + return false; + } + } + + /// Closes connections that never finished authenticating. + void _onAuthTimeout() { + if (_phase != _Phase.auth) return; + _config.printDebug?.call( + 'tp_sshd: closing connection after auth timeout ' + '(${_config.authTimeout})', + ); + unawaited(close()); + } + + void _onTransportClosed() { + _authTimer.cancel(); + _phase = _Phase.closed; + } + + /// Sends a disconnect message and closes the connection. + void _disconnect(SSHDisconnectReason reason, String description) { + _transport.sendPacket( + SSH_Message_Disconnect( + reasonCode: reason.code, + description: description, + ).encode(), + ); + unawaited(close()); + } +} diff --git a/client/packages/tp_sshd/lib/src/ssh_server.dart b/client/packages/tp_sshd/lib/src/ssh_server.dart new file mode 100644 index 000000000..807d54c3f --- /dev/null +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -0,0 +1,151 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart' show SSHKeyPair, SSHSocket; +import 'package:dartssh2/protocol.dart'; + +import 'server_connection.dart'; + +/// The narrow negotiation surface advertised by tp_sshd servers (spec: +/// x25519 KEX, ed25519 host keys, AEAD ciphers). +/// +/// Key exchange is deliberately ECDH-only: a client that cannot speak +/// x25519 fails the exchange by design, and the list must not be broadened +/// with DH-family algorithms. +const SSHAlgorithms tpServerAlgorithms = SSHAlgorithms( + kex: [SSHKexType.x25519Rfc, SSHKexType.x25519], + hostkey: [SSHHostkeyType.ed25519], + cipher: [SSHCipherType.chacha20poly1305, SSHCipherType.aes256gcm], + mac: [SSHMacType.hmacSha256], +); + +/// Configuration for an [SSHServer]. +class SSHServerConfig { + SSHServerConfig({ + required this.hostKeyPair, + required this.authenticate, + this.authTimeout = const Duration(seconds: 30), + this.maxAuthAttempts = 6, + this.printDebug, + this.printTrace, + }); + + /// The host key the server signs its key exchanges with. Must be an + /// ed25519 key: it is the only host key algorithm [tpServerAlgorithms] + /// advertises. + final SSHKeyPair hostKeyPair; + + /// Decides whether an authentication attempt is accepted. Called once per + /// `publickey` userauth request once userauth lands (Task 4); until then + /// the server fails every attempt closed. + final Future Function(SSHServerAuthRequest request) authenticate; + + /// How long a connection may live without completing authentication + /// before the server closes it. + final Duration authTimeout; + + /// How many authentication attempts a connection may make before the + /// server disconnects it. + final int maxAuthAttempts; + + /// Function invoked with debug logging, mirroring [SSHSocket] transports. + final void Function(String? message)? printDebug; + + /// Function invoked with trace logging, mirroring [SSHSocket] transports. + final void Function(String? message)? printTrace; +} + +/// A client's authentication request, as handed to +/// [SSHServerConfig.authenticate]. +class SSHServerAuthRequest { + const SSHServerAuthRequest({ + required this.username, + required this.algorithm, + required this.publicKey, + }); + + /// The username the client is authenticating as. + final String username; + + /// The public key algorithm of the offered key — always `'ssh-ed25519'`, + /// the only one the server advertises. + final String algorithm; + + /// The offered public key as an OpenSSH wire blob. + final Uint8List publicKey; +} + +/// An SSH server driving accepted sockets through the TeamPilot protocol +/// surface. +/// +/// The server does not own listening: [bind] consumes already-accepted +/// sockets from a [StreamIterator], so the embedder decides whether they come +/// from a real [ServerSocket](dart:io), a WebSocket bridge, or an in-memory +/// test pair. +class SSHServer { + SSHServer._(this._config); + + final SSHServerConfig _config; + + /// Live connections, in acceptance order. + final _connections = {}; + + /// The iterator [bind] is draining; kept so [close] can stop the loop. + StreamIterator? _connectionsIterator; + + /// The running accept loop, if any. + Future? _acceptLoop; + + var _isClosed = false; + + /// Starts serving [connections]. + /// + /// Each accepted socket gets its own [SSHServerConnection] running the + /// server-side transport with [tpServerAlgorithms] and the configured host + /// key. The returned server keeps accepting until [close] is called or the + /// stream ends. + static Future bind( + StreamIterator connections, { + required SSHServerConfig config, + }) async { + final server = SSHServer._(config); + server._connectionsIterator = connections; + server._acceptLoop = server._acceptConnections(connections); + return server; + } + + /// Number of connections the server is currently serving. + int get activeConnections => _connections.length; + + Future _acceptConnections(StreamIterator connections) async { + while (await connections.moveNext()) { + if (_isClosed) break; + _spawnConnection(connections.current); + } + } + + void _spawnConnection(SSHSocket socket) { + final connection = SSHServerConnection(socket, config: _config); + _connections.add(connection); + connection.done + .whenComplete(() => _connections.remove(connection)) + .ignore(); + } + + /// Stops accepting connections and closes every live one. + /// + /// The injected connection stream is cancelled (not closed — the embedder + /// owns it), which also unblocks a pending accept. + Future close() async { + final wasClosed = _isClosed; + _isClosed = true; + // Cancelling the iterator completes a pending moveNext with false, which + // ends the accept loop. + await _connectionsIterator?.cancel(); + await _acceptLoop; + if (wasClosed) return; + for (final connection in List.of(_connections)) { + await connection.close(); + } + } +} diff --git a/client/packages/tp_sshd/lib/tp_sshd.dart b/client/packages/tp_sshd/lib/tp_sshd.dart new file mode 100644 index 000000000..10e691f46 --- /dev/null +++ b/client/packages/tp_sshd/lib/tp_sshd.dart @@ -0,0 +1,3 @@ +export 'package:dartssh2/protocol.dart' + show SSHAlgorithms, SSHKexType, SSHHostkeyType, SSHCipherType, SSHMacType; +export 'src/ssh_server.dart'; diff --git a/client/packages/tp_sshd/pubspec.yaml b/client/packages/tp_sshd/pubspec.yaml new file mode 100644 index 000000000..ea9d39c5b --- /dev/null +++ b/client/packages/tp_sshd/pubspec.yaml @@ -0,0 +1,15 @@ +name: tp_sshd +description: Pure-Dart SSH server for TeamPilot's embedded desktop server. +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + dartssh2: + path: ../dartssh2 + +dev_dependencies: + lints: ">=4.0.0 <7.0.0" + test: ^1.25.15 diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart new file mode 100644 index 000000000..22e039db1 --- /dev/null +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'test_socket_pair.dart'; + +/// Starts an [SSHServer] over a fresh in-memory socket pair and returns a +/// connected, host-key-accepting [SSHClient] plus the server. +/// +/// The client authenticates with [clientIdentities] as [username]; the server +/// decides each attempt through [authenticate]. +Future<(SSHClient, SSHServer)> startDualPair({ + required SSHKeyPair hostKeyPair, + required Future Function(SSHServerAuthRequest request) authenticate, + List clientIdentities = const [], + String username = 'user', +}) async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: hostKeyPair, + authenticate: authenticate, + ), + ); + connections.add(serverSocket); + final client = SSHClient( + clientSocket, + username: username, + onVerifyHostKey: (_, __) => true, + identities: clientIdentities, + ); + await client.authenticated; // throws on auth failure — callers rely on that + return (client, server); +} + +/// Throwaway ed25519 host key generated for these tests only +/// (ssh-keygen -t ed25519 -N '' -C 'tp-sshd-task3-throwaway-host'). +const _testHostKeyPem = ''' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBaO+vVZtKIrnAOvc/nSZjaP3FEP93i4OaShgxSseqQVQAAAKDJb4G9yW+B +vQAAAAtzc2gtZWQyNTUxOQAAACBaO+vVZtKIrnAOvc/nSZjaP3FEP93i4OaShgxSseqQVQ +AAAEBbwZXXKRuEXWx0OerUp/Iw3p2CxVjtI3A+1kfLHnq9x1o769Vm0oiucA69z+dJmNo/ +cUQ/3eLg5pKGDFKx6pBVAAAAHHRwLXNzaGQtdGFzazMtdGhyb3dhd2F5LWhvc3QB +-----END OPENSSH PRIVATE KEY----- +'''; + +/// Throwaway ed25519 device key generated for these tests only +/// (ssh-keygen -t ed25519 -N '' -C 'tp-sshd-task3-throwaway-device'). +/// Stands in for the client's signing identity in the dual tests. +const _testDeviceKeyPem = ''' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBfOUUkbEjlCib02adS9nooS27BojdzCrQLvojmh3SKNgAAAKjuDLL17gyy +9QAAAAtzc2gtZWQyNTUxOQAAACBfOUUkbEjlCib02adS9nooS27BojdzCrQLvojmh3SKNg +AAAEDK4vf00uW/7iMbnSbwIEkGtQhz7S6tUatX0XoF7G69j185RSRsSOUKJvTZp1L2eihL +bsGiN3MKtAu+iOaHdIo2AAAAHnRwLXNzaGQtdGFzazMtdGhyb3dhd2F5LWRldmljZQECAw +QFBgc= +-----END OPENSSH PRIVATE KEY----- +'''; + +/// A throwaway ed25519 keypair for tests (public key in OpenSSH wire format +/// via `.toPublicKey().encode()`). +final testHostKey = SSHKeyPair.fromPem(_testHostKeyPem).single; + +/// A throwaway ed25519 keypair used as a client identity for tests. +final testDeviceKey = SSHKeyPair.fromPem(_testDeviceKeyPem).single; diff --git a/client/packages/tp_sshd/test/server_handshake_test.dart b/client/packages/tp_sshd/test/server_handshake_test.dart new file mode 100644 index 000000000..c2567ac73 --- /dev/null +++ b/client/packages/tp_sshd/test/server_handshake_test.dart @@ -0,0 +1,63 @@ +@TestOn('vm') +library; + +import 'dart:async'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; +import 'test_socket_pair.dart'; + +void main() { + test('client handshake reaches auth and fails closed without userauth', () async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + authenticate: (_) async => false, + ), + ); + connections.add(serverSocket); + final client = SSHClient( + clientSocket, + username: 'user', + onVerifyHostKey: (_, __) => true, + ); + // No identities, and the server has no userauth service yet: the + // connection must terminate with an auth failure, not hang or crash. + // (dartssh2's auth errors implement SSHAuthError, not dart:core's + // Exception, so that is what a clean failure looks like here.) + await expectLater( + client.authenticated, + throwsA(isA()), + ); + expect(server.activeConnections, greaterThanOrEqualTo(0)); + await server.close(); + }); + + test('auth timeout closes silent connections', () async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + authenticate: (_) async => false, + authTimeout: const Duration(milliseconds: 150), + ), + ); + connections.add(serverSocket); + // Client transport that never sends a service request after kex. + final transport = SSHTransport(clientSocket); + await expectLater( + transport.done.timeout(const Duration(seconds: 2)), + completes, + ); + await server.close(); + transport.close(); + }); +} diff --git a/client/packages/tp_sshd/test/test_socket_pair.dart b/client/packages/tp_sshd/test/test_socket_pair.dart new file mode 100644 index 000000000..ba4e66610 --- /dev/null +++ b/client/packages/tp_sshd/test/test_socket_pair.dart @@ -0,0 +1,70 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +/// Minimal paired in-memory [SSHSocket]. Each end's sink writes are delivered +/// to the other end's stream, and closing or destroying either end shuts the +/// whole pair down. +/// +/// Ported from the dartssh2 fork's `test/src/ssh_transport_server_kex_test.dart` +/// (Task 2) so both the client and the server side of a tp_sshd test can run +/// in one isolate without real sockets. +class LoopbackSSHSocket implements SSHSocket { + LoopbackSSHSocket._(); + + /// Creates a connected pair of sockets: writes to either end are readable + /// from the other, as `(clientEnd, serverEnd)`. + static (LoopbackSSHSocket, LoopbackSSHSocket) pair() { + final a = LoopbackSSHSocket._(); + final b = LoopbackSSHSocket._(); + a._peer = b; + b._peer = a; + return (a, b); + } + + late final LoopbackSSHSocket _peer; + final _controller = StreamController(); + final _doneCompleter = Completer(); + var _isShutdown = false; + + @override + Stream get stream => _controller.stream; + + @override + // A `StreamSink` is assignable to `StreamSink>` through + // Dart's covariant generics, which is what lets the peer's controller act as + // this end's sink. + StreamSink> get sink => _peer._controller.sink; + + @override + Future get done => _doneCompleter.future; + + @override + Future close() async { + _shutdown(); + _peer._shutdown(); + } + + @override + void destroy() { + _shutdown(); + _peer._shutdown(); + } + + void _shutdown() { + if (_isShutdown) return; + _isShutdown = true; + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + unawaited(_controller.close()); + } + + @override + Future flush() async {} +} + +/// Creates a connected pair of in-memory [SSHSocket]s, as +/// `(clientSocket, serverSocket)`. +(SSHSocket, SSHSocket) loopbackSSHSocketPair() => LoopbackSSHSocket.pair(); From eaf7e2e8c2c225e107ba6f8508aad89577a0e16a Mon Sep 17 00:00:00 2001 From: hhoao Date: Tue, 8 Sep 2026 13:45:27 +0800 Subject: [PATCH 02/14] feat(tp_sshd): publickey userauth with signature enforcement and throttling Co-Authored-By: Claude Opus 4.8 --- .../tp_sshd/lib/src/server_connection.dart | 146 +++++++-- .../tp_sshd/lib/src/server_userauth.dart | 65 ++++ .../packages/tp_sshd/lib/src/ssh_server.dart | 11 +- .../tp_sshd/test/dual_test_utils.dart | 8 +- .../tp_sshd/test/server_handshake_test.dart | 2 + .../tp_sshd/test/server_userauth_test.dart | 286 ++++++++++++++++++ 6 files changed, 494 insertions(+), 24 deletions(-) create mode 100644 client/packages/tp_sshd/lib/src/server_userauth.dart create mode 100644 client/packages/tp_sshd/test/server_userauth_test.dart diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart index 9cfd38736..ef0a8f73a 100644 --- a/client/packages/tp_sshd/lib/src/server_connection.dart +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -4,16 +4,17 @@ import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart' show SSHSocket, SSHTransport; import 'package:dartssh2/protocol.dart'; -import 'ssh_server.dart' show SSHServerConfig, tpServerAlgorithms; +import 'server_userauth.dart'; +import 'ssh_server.dart' show SSHServerAuthRequest, SSHServerConfig, tpServerAlgorithms; /// Lifecycle phases of an [SSHServerConnection]. enum _Phase { /// Handshake done; the client is trying to authenticate. Everything except - /// the service negotiation is refused. + /// the service negotiation and userauth is refused. auth, - /// Authenticated; session traffic (channels) is served. Reached once - /// userauth lands (Task 4+). + /// Authenticated; session traffic (channels) is served. Reached when a + /// signed `publickey` userauth request both verified and was trusted. running, /// The connection is gone. @@ -21,12 +22,8 @@ enum _Phase { } /// One accepted connection: a server-role [SSHTransport] plus the -/// connection-level state machine and the auth timeout that bounds the -/// pre-authentication phase. -/// -/// Task 3 scope: the auth phase accepts the `ssh-userauth` service request -/// and fails every authentication attempt closed (there is no userauth -/// service yet — Task 4 adds one driven by [SSHServerConfig.authenticate]). +/// connection-level state machine, the publickey userauth service, and the +/// auth timeout that bounds the pre-authentication phase. class SSHServerConnection { SSHServerConnection( this.socket, { @@ -58,6 +55,10 @@ class SSHServerConnection { late final Timer _authTimer; var _phase = _Phase.auth; + /// Failed authentication attempts so far, for the + /// [SSHServerConfig.maxAuthAttempts] throttle. + var _authAttempts = 0; + /// Completes when the underlying transport closes, normally or with an /// error. Future get done => _transport.done; @@ -102,15 +103,10 @@ class SSHServerConnection { } return true; case SSH_Message_Userauth_Request.messageId: - // Fail closed. The server has no userauth service yet, so every - // authentication attempt is answered with a failure carrying no - // continuable methods — every client then terminates its auth - // (dartssh2's SSHClient gives up and errors out) instead of waiting - // for an answer that would never come. Task 4 replaces this with - // real publickey userauth driven by [SSHServerConfig.authenticate]. - _transport.sendPacket( - SSH_Message_Userauth_Failure(methodsLeft: const []).encode(), - ); + // The trust decision is the embedder's async authenticate callback, + // so the request is handled detached from the synchronous + // onMessage dispatch. + unawaited(_handleUserauthRequest(payload)); return true; default: // Unrecognized message: let the transport answer with @@ -119,6 +115,118 @@ class SSHServerConnection { } } + /// Handles one `SSH_Message_Userauth_Request` (RFC 4252). + /// + /// Publickey is the only method served, and only for + /// [SSHServerConfig.expectedUsername]. Probing requests (no signature) are + /// answered with `USERAUTH_PK_Ok` when [SSHServerConfig.authenticate] + /// trusts the key; signed requests authenticate only when the RFC 4252 §7 + /// signature verifies AND the key is trusted. Every failure counts toward + /// [SSHServerConfig.maxAuthAttempts]. + Future _handleUserauthRequest(Uint8List payload) async { + final SSH_Message_Userauth_Request message; + try { + message = SSH_Message_Userauth_Request.decode(payload); + } on Object { + _disconnect( + SSHDisconnectReason.protocolError, + 'Malformed userauth request', + ); + return; + } + + if (message.methodName != 'publickey' || + message.user != _config.expectedUsername) { + // Fail closed: no other method is served, and requests for any other + // user are failed (counted), not answered. + _failAuthAttempt(); + return; + } + + final publicKeyAlgorithm = message.publicKeyAlgorithm; + final publicKey = message.publicKey; + if (publicKeyAlgorithm == null || publicKey == null) { + _failAuthAttempt(); + return; + } + + if (message.signature != null) { + // A signed request must prove possession of the private key before + // the embedder is asked whether it trusts the public one. + if (!verifyEd25519UserauthSignature( + sessionId: _transport.sessionId!, + request: message, + )) { + _failAuthAttempt(); + return; + } + } + + var trusted = false; + try { + trusted = await _config.authenticate( + SSHServerAuthRequest( + username: message.user, + algorithm: publicKeyAlgorithm, + publicKey: publicKey, + ), + ); + } on Object { + // A misbehaving authenticate callback is a failed attempt, not an + // unhandled error on the connection. + trusted = false; + } + // The connection may have been closed (or torn down by the throttle) + // while the embedder was deciding. + if (_phase != _Phase.auth) return; + + if (message.signature == null) { + // Public-key probing (RFC 4252 §7.8): tell the client the key is worth + // signing with. + if (trusted) { + _transport.sendPacket( + SSH_Message_Userauth_PK_Ok( + publicKeyAlgorithm: publicKeyAlgorithm, + publicKey: publicKey, + ).encode(), + ); + } else { + _failAuthAttempt(); + } + return; + } + + if (trusted) { + // Authenticated: cancel the auth timeout so it cannot tear down a + // live connection later, then start serving session traffic. + _authTimer.cancel(); + _phase = _Phase.running; + _transport.sendPacket(SSH_Message_Userauth_Success().encode()); + return; + } + _failAuthAttempt(); + } + + /// Counts and answers one failed authentication attempt. + /// + /// After [SSHServerConfig.maxAuthAttempts] failures the connection is + /// disconnected (RFC 4253 §11.1 reason 14) instead of answered. The + /// failure never advertises continuable methods: publickey is the only + /// method there is, and offering it would just invite another attempt. + void _failAuthAttempt() { + _authAttempts += 1; + if (_authAttempts >= _config.maxAuthAttempts) { + _disconnect( + SSHDisconnectReason.noMoreAuthMethodsAvailable, + 'Too many failed authentication attempts', + ); + return; + } + _transport.sendPacket( + SSH_Message_Userauth_Failure(methodsLeft: const []).encode(), + ); + } + /// Closes connections that never finished authenticating. void _onAuthTimeout() { if (_phase != _Phase.auth) return; diff --git a/client/packages/tp_sshd/lib/src/server_userauth.dart b/client/packages/tp_sshd/lib/src/server_userauth.dart new file mode 100644 index 000000000..b5125a2ed --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_userauth.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart' + show + SSHEd25519PublicKey, + SSHEd25519Signature, + SSHMessageWriter, + SSH_Message_Userauth_Request; + +/// The only public key algorithm tp_sshd accepts for userauth: the spec's +/// device-key surface is ed25519 only. +const userauthKeyAlgorithm = 'ssh-ed25519'; + +/// Verifies the RFC 4252 §7 signature of a signed `publickey` +/// [SSH_Message_Userauth_Request]. +/// +/// The signed blob is the request re-encoded without the signature and +/// prefixed with the session identifier — byte-for-byte what the dartssh2 +/// client signs (`SSHTransport.composeChallenge` in the fork): +/// +/// ``` +/// string session identifier +/// byte SSH_MSG_USERAUTH_REQUEST (50) +/// string user name +/// string service name +/// string "publickey" +/// boolean TRUE +/// string public key algorithm name +/// string public key blob +/// ``` +/// +/// Fails closed: a non-ed25519 algorithm, a malformed key or signature +/// frame, and any verification failure all return `false` — the caller +/// counts that as a failed authentication attempt. +bool verifyEd25519UserauthSignature({ + required Uint8List sessionId, + required SSH_Message_Userauth_Request request, +}) { + final publicKey = request.publicKey; + final signature = request.signature; + if (request.publicKeyAlgorithm != userauthKeyAlgorithm || + publicKey == null || + signature == null) { + return false; + } + + try { + final key = SSHEd25519PublicKey.decode(publicKey); + final decodedSignature = SSHEd25519Signature.decode(signature); + + final writer = SSHMessageWriter(); + writer.writeString(sessionId); + writer.writeUint8(SSH_Message_Userauth_Request.messageId); + writer.writeUtf8(request.user); + writer.writeUtf8(request.serviceName); + writer.writeUtf8('publickey'); + writer.writeBool(true); + writer.writeUtf8(request.publicKeyAlgorithm!); + writer.writeString(publicKey); + return key.verify(writer.takeBytes(), decodedSignature); + } on Object { + // A malformed key or signature frame is a failed attempt, not a crash. + return false; + } +} diff --git a/client/packages/tp_sshd/lib/src/ssh_server.dart b/client/packages/tp_sshd/lib/src/ssh_server.dart index 807d54c3f..48ccf2b7c 100644 --- a/client/packages/tp_sshd/lib/src/ssh_server.dart +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -23,6 +23,7 @@ const SSHAlgorithms tpServerAlgorithms = SSHAlgorithms( class SSHServerConfig { SSHServerConfig({ required this.hostKeyPair, + required this.expectedUsername, required this.authenticate, this.authTimeout = const Duration(seconds: 30), this.maxAuthAttempts = 6, @@ -35,9 +36,15 @@ class SSHServerConfig { /// advertises. final SSHKeyPair hostKeyPair; + /// The only username this server authenticates: the user the connection + /// was offered to. A request for any other user counts as a failed + /// authentication attempt. + final String expectedUsername; + /// Decides whether an authentication attempt is accepted. Called once per - /// `publickey` userauth request once userauth lands (Task 4); until then - /// the server fails every attempt closed. + /// `publickey` userauth request — both for public-key probing requests + /// (the answer decides the `USERAUTH_PK_Ok` reply) and for signed ones + /// (where the signature has already been verified when this is called). final Future Function(SSHServerAuthRequest request) authenticate; /// How long a connection may live without completing authentication diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart index 22e039db1..a36c13553 100644 --- a/client/packages/tp_sshd/test/dual_test_utils.dart +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -8,12 +8,13 @@ import 'test_socket_pair.dart'; /// Starts an [SSHServer] over a fresh in-memory socket pair and returns a /// connected, host-key-accepting [SSHClient] plus the server. /// -/// The client authenticates with [clientIdentities] as [username]; the server -/// decides each attempt through [authenticate]. +/// The client authenticates as [username] with [clientIdentities]; the server +/// decides each attempt through [authenticate], which must accept [username] +/// as [SSHServerConfig.expectedUsername]. Future<(SSHClient, SSHServer)> startDualPair({ required SSHKeyPair hostKeyPair, required Future Function(SSHServerAuthRequest request) authenticate, - List clientIdentities = const [], + List clientIdentities = const [], String username = 'user', }) async { final (clientSocket, serverSocket) = loopbackSSHSocketPair(); @@ -22,6 +23,7 @@ Future<(SSHClient, SSHServer)> startDualPair({ StreamIterator(connections.stream), config: SSHServerConfig( hostKeyPair: hostKeyPair, + expectedUsername: username, authenticate: authenticate, ), ); diff --git a/client/packages/tp_sshd/test/server_handshake_test.dart b/client/packages/tp_sshd/test/server_handshake_test.dart index c2567ac73..f499d0c85 100644 --- a/client/packages/tp_sshd/test/server_handshake_test.dart +++ b/client/packages/tp_sshd/test/server_handshake_test.dart @@ -18,6 +18,7 @@ void main() { StreamIterator(connections.stream), config: SSHServerConfig( hostKeyPair: testHostKey, + expectedUsername: 'user', authenticate: (_) async => false, ), ); @@ -46,6 +47,7 @@ void main() { StreamIterator(connections.stream), config: SSHServerConfig( hostKeyPair: testHostKey, + expectedUsername: 'user', authenticate: (_) async => false, authTimeout: const Duration(milliseconds: 150), ), diff --git a/client/packages/tp_sshd/test/server_userauth_test.dart b/client/packages/tp_sshd/test/server_userauth_test.dart new file mode 100644 index 000000000..92978e1d4 --- /dev/null +++ b/client/packages/tp_sshd/test/server_userauth_test.dart @@ -0,0 +1,286 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/protocol.dart'; +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; +import 'test_socket_pair.dart'; + +void main() { + test('valid device key authenticates', () async { + var authenticateCalls = 0; + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (req) async { + authenticateCalls += 1; + expect(req.username, 'user'); + expect(req.algorithm, 'ssh-ed25519'); + expect(req.publicKey, testDeviceKey.toPublicKey().encode()); + return true; + }, + clientIdentities: [testDeviceKey], + ); + // In-memory key pairs sign directly (no probing), so the embedder is + // consulted exactly once, for the signed request. + expect(authenticateCalls, 1); + expect(server.activeConnections, 1); + await server.close(); + await client.close(); + }); + + test('probed device key is answered with PK_Ok and then authenticates', + () async { + var authenticateCalls = 0; + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async { + authenticateCalls += 1; + return true; + }, + clientIdentities: [ + SSHIdentity.custom( + type: 'ssh-ed25519', + publicKey: testDeviceKey.toPublicKey(), + signer: testDeviceKey.sign, + shouldProbe: true, + ), + ], + ); + // The probe and the signed request each consult authenticate. + expect(authenticateCalls, 2); + expect(server.activeConnections, 1); + await server.close(); + await client.close(); + }); + + test('untrusted key is rejected', () async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => false, + ), + ); + connections.add(serverSocket); + final client = SSHClient( + clientSocket, + username: 'user', + onVerifyHostKey: (_, __) => true, + identities: [testDeviceKey], + ); + await expectLater(client.authenticated, throwsA(isA())); + await server.close(); + await client.close(); + }); + + test('server enforces the signature, not just key trust', () async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + // The key itself would be trusted... + authenticate: (_) async => true, + ), + ); + connections.add(serverSocket); + final client = SSHClient( + clientSocket, + username: 'user', + onVerifyHostKey: (_, __) => true, + identities: [ + SSHIdentity.custom( + type: 'ssh-ed25519', + publicKey: testDeviceKey.toPublicKey(), + // ...but the client signs with a well-formed ssh-ed25519 + // signature frame whose 64 bytes do not verify. authenticate() + // returning true must not be enough to authenticate. + signer: (_) => SSHEd25519Signature(Uint8List(64)), + ), + ], + ); + await expectLater(client.authenticated, throwsA(isA())); + await server.close(); + await client.close(); + }); + + test('wrong username counts as a failed attempt', () async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + ), + ); + connections.add(serverSocket); + final client = SSHClient( + clientSocket, + username: 'mallory', + onVerifyHostKey: (_, __) => true, + identities: [testDeviceKey], + ); + // The key is valid and trusted, but the connection was offered to + // 'user', not 'mallory' — the request must be failed, not accepted. + await expectLater(client.authenticated, throwsA(isA())); + await server.close(); + await client.close(); + }); + + test('auth timer does not fire after successful authentication', () async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + authTimeout: const Duration(milliseconds: 150), + ), + ); + connections.add(serverSocket); + final client = SSHClient( + clientSocket, + username: 'user', + onVerifyHostKey: (_, __) => true, + identities: [testDeviceKey], + ); + await client.authenticated; + // The auth timeout must have been cancelled by the success, not merely + // survived by the phase guard: the connection stays up well past it. + await Future.delayed(const Duration(milliseconds: 450)); + expect(server.activeConnections, 1); + expect(client.isClosed, isFalse); + await server.close(); + await client.close(); + }); + + test('malformed userauth message disconnects the connection', () async { + final (server, client) = await startRawPair( + authenticate: (_) async => true, + onReady: (client) { + client.sendPacket(SSH_Message_Service_Request('ssh-userauth').encode()); + // Hand-encoded publickey probe whose public key blob is truncated: + // the string length prefix promises 32 bytes, but the packet ends + // after only 10 of them. + final writer = SSHMessageWriter(); + writer.writeUint8(SSH_Message_Userauth_Request.messageId); + writer.writeUtf8('user'); + writer.writeUtf8('ssh-connection'); + writer.writeUtf8('publickey'); + writer.writeBool(false); + writer.writeUtf8('ssh-ed25519'); + writer.writeString(Uint8List(32)); + final bytes = writer.takeBytes(); + client.sendPacket( + Uint8List.sublistView(bytes, 0, bytes.length - 10), + ); + }, + ); + await expectLater( + client.done.timeout(const Duration(seconds: 5)), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 2, // SSH_DISCONNECT_PROTOCOL_ERROR + ), + ), + ); + await server.close(); + client.close(); + }); + + test('too many failed attempts disconnects the connection', () async { + var failures = 0; + final (server, client) = await startRawPair( + authenticate: (_) async => false, + onServerMessage: (payload) { + if (SSHMessage.readMessageId(payload) == + SSH_Message_Userauth_Failure.messageId) { + failures += 1; + } + return true; + }, + onReady: (client) { + client.sendPacket(SSH_Message_Service_Request('ssh-userauth').encode()); + // Six probes of an untrusted key: the default maxAuthAttempts. + for (var i = 0; i < 6; i++) { + client.sendPacket(_probeRequest().encode()); + } + }, + ); + await expectLater( + client.done.timeout(const Duration(seconds: 5)), + throwsA( + isA().having( + (error) => error.reasonCode, + 'reasonCode', + 14, // SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE + ), + ), + ); + // The first five attempts were answered with a failure; the sixth is + // answered with the disconnect above instead. + expect(failures, 5); + await server.close(); + client.close(); + }); +} + +/// A probing publickey userauth request (RFC 4252 §7, `boolean FALSE`) for +/// the test device key. +SSH_Message_Userauth_Request _probeRequest() { + return SSH_Message_Userauth_Request.publicKey( + username: 'user', + publicKeyAlgorithm: 'ssh-ed25519', + publicKey: testDeviceKey.toPublicKey().encode(), + signature: null, + ); +} + +/// Starts an [SSHServer] plus a raw client-side [SSHTransport], so tests can +/// inject hand-crafted auth traffic a real [SSHClient] would never send. +/// +/// [onReady] runs once the client-side key exchange completes; +/// [onServerMessage] sees everything the server sends back (consumed by +/// default). +Future<(SSHServer, SSHTransport)> startRawPair({ + required Future Function(SSHServerAuthRequest request) authenticate, + required void Function(SSHTransport client) onReady, + bool Function(Uint8List payload)? onServerMessage, +}) async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: authenticate, + ), + ); + connections.add(serverSocket); + late final SSHTransport client; + client = SSHTransport( + clientSocket, + onVerifyHostKey: (_, __) => true, + onReady: () => onReady(client), + onMessage: onServerMessage ?? (_) => true, + ); + return (server, client); +} From 126d9daaaa89c11d4be8c725d63288e8e6470add Mon Sep 17 00:00:00 2001 From: hhoao Date: Tue, 8 Sep 2026 14:25:57 +0800 Subject: [PATCH 03/14] feat(tp_sshd): channel multiplexing and keepalive 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 --- .../tp_sshd/lib/src/server_channel.dart | 381 ++++++++++++++++ .../tp_sshd/lib/src/server_connection.dart | 201 ++++++++- client/packages/tp_sshd/lib/tp_sshd.dart | 10 +- .../tp_sshd/test/dual_test_utils.dart | 148 ++++++- .../tp_sshd/test/server_channel_test.dart | 409 ++++++++++++++++++ 5 files changed, 1143 insertions(+), 6 deletions(-) create mode 100644 client/packages/tp_sshd/lib/src/server_channel.dart create mode 100644 client/packages/tp_sshd/test/server_channel_test.dart diff --git a/client/packages/tp_sshd/lib/src/server_channel.dart b/client/packages/tp_sshd/lib/src/server_channel.dart new file mode 100644 index 000000000..b18e04bed --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_channel.dart @@ -0,0 +1,381 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart'; + +/// Upper bound of a window after an adjustment (RFC 4254 §5.2: a uint32). +const _maximumWindow = 0xffffffff; + +/// One open channel on the server side. Owns window accounting in both +/// directions and the data/EOF/close lifecycle of the channel. +/// +/// Instances are created by [SSHServerConnection] when a client opens a +/// channel; the connection then drives them through [handleWindowAdjust], +/// [handleData], [handleExtendedData], [handleEof], [handleClose] and +/// [handleRequest] as the client's messages arrive. +class SSHServerChannel { + /// The receive-window size the server grants on every channel it opens: + /// 2 MiB, the same generous default typical servers (including OpenSSH) + /// hand out, so a client is never throttled before it gets going. + static const initialReceiveWindow = 2 * 1024 * 1024; + + /// The largest channel packet the server accepts from the peer and the + /// largest it sends: RFC 4253 §6.1's 32768 payload bytes every + /// implementation must accept. + static const maximumPacketSize = 32768; + + SSHServerChannel({ + required this.recipientChannel, + required this.ourChannel, + required this.channelType, + required int peerInitialWindowSize, + required int peerMaximumPacketSize, + required void Function(Uint8List payload) sendPacket, + required void Function(SSHServerChannel channel) onClosed, + this.printDebug, + }) : _sendWindow = peerInitialWindowSize, + // A peer advertising a zero maximum packet size could never be sent + // anything; fall back to the protocol minimum rather than stall + // forever. + _maximumOutgoingPacketSize = + peerMaximumPacketSize > 0 ? peerMaximumPacketSize : maximumPacketSize, + _sendPacket = sendPacket, + _onClosed = onClosed; + + /// The channel number the client assigned to this channel. Every message + /// the server sends on it addresses the client by this id. + final int recipientChannel; + + /// The channel number this server assigned. Every message the client + /// sends on the channel addresses the server by this id. + final int ourChannel; + + /// The channel type as requested by the client (`'session'`). + final String channelType; + + final void Function(Uint8List payload) _sendPacket; + final void Function(SSHServerChannel channel) _onClosed; + final void Function(String? message)? printDebug; + + /// Remaining bytes the client may still send us (the receive direction). + var _receiveWindow = initialReceiveWindow; + + /// Remaining bytes we may still send the client (the send direction). + int _sendWindow; + + /// The packet-size limit the client gave for data we send it. + final int _maximumOutgoingPacketSize; + + final _input = StreamController(); + final _extendedInput = StreamController(); + + /// Outgoing chunks waiting for send-window credit. + final _outgoing = Queue<_OutgoingChunk>(); + + final _done = Completer(); + + var _sentEof = false; + + /// EOF requested via [sendEof] but still behind queued data; sent when + /// the queue drains (no data may follow an EOF, RFC 4254 §5.3). + var _eofPending = false; + + var _receivedEof = false; + var _sentClose = false; + + /// Data the client sends on this channel. + Stream get input => _input.stream; + + /// Extended data (stderr) the client sends on this channel. + Stream get extendedInput => _extendedInput.stream; + + /// Completes when the channel is closed in both directions, or torn down + /// together with the connection. + Future get done => _done.future; + + /// Whether this channel is finished: no more data flows either way. + bool get isClosed => _done.isCompleted; + + /// Whether the client has already sent EOF (no more input will arrive). + bool get receivedEof => _receivedEof; + + /// Hook invoked when the client sends a channel request (e.g. `exec`, + /// `shell`) on this channel. + /// + /// The hook runs detached from the message dispatch; while it runs, the + /// triggering request is readable from [currentRequest]. A hook that + /// completes normally acknowledges the request (CHANNEL_SUCCESS reply + /// when the client asked for one), a hook that throws refuses it + /// (CHANNEL_FAILURE). A channel with no hook refuses every request — + /// session requests are implemented in Tasks 6-7. + Future Function(SSHServerChannel channel)? onRequest; + + /// The channel request currently being dispatched to [onRequest], or the + /// last one dispatched if the hook has already returned. Meaningful only + /// from inside the hook. + SSH_Message_Channel_Request? currentRequest; + + /// Sends [data] to the client as channel data (stdout). + /// + /// Data is chunked to the client's maximum packet size; whatever the + /// client's current window does not cover is queued and flushed as + /// window adjustments arrive. Data offered after [sendEof] or [close] is + /// dropped (with a debug log). + void write(Uint8List data) => _enqueueOutgoing(data, null); + + /// Sends [data] to the client as extended channel data (stderr). See + /// [write] for the chunking, window and EOF semantics. + void writeExtended(Uint8List data) => _enqueueOutgoing( + data, + SSH_Message_Channel_Extended_Data.dataTypeStderr, + ); + + /// Tells the client no more data will be sent on this channel. + /// + /// The EOF follows any data still queued for window credit; further + /// [write]/[writeExtended] calls are dropped. + void sendEof() { + if (isClosed || _sentEof || _eofPending) return; + _flushOutgoing(); + if (_outgoing.isNotEmpty) { + _eofPending = true; + return; + } + _sendEof(); + } + + /// Closes the channel: sends EOF (after flushing what the client's window + /// allows) and then CHANNEL_CLOSE, finishing the channel from our side. + /// Data still waiting for window credit is dropped. + void close() { + if (isClosed) return; + _flushOutgoing(); + if (!_sentEof) { + _sendEof(); + } + _finish(); + } + + /// Applies a window adjustment from the client: grows the send window and + /// flushes whatever data was stalled on it. + void handleWindowAdjust(int bytesToAdd) { + if (isClosed) return; + if (bytesToAdd < 0 || _sendWindow + bytesToAdd > _maximumWindow) { + _failChannel( + 'window adjustment of $bytesToAdd on top of $_sendWindow exceeds ' + 'the maximum window', + ); + return; + } + _sendWindow += bytesToAdd; + _flushOutgoing(); + } + + /// Delivers channel data received from the client. + void handleData(Uint8List data) => _handleIncoming(data, _input); + + /// Delivers extended channel data received from the client. All extended + /// data is surfaced on [extendedInput]; SSH defines only stderr (type 1). + void handleExtendedData(int dataTypeCode, Uint8List data) => + _handleIncoming(data, _extendedInput); + + /// Records the client's EOF: no more input will arrive. + void handleEof() { + if (isClosed || _receivedEof) return; + _receivedEof = true; + _closeInputStreams(); + } + + /// Records the client's CHANNEL_CLOSE: the channel is finished. Our own + /// CHANNEL_CLOSE is echoed if it was not sent yet. + void handleClose() => _finish(); + + /// Dispatches a channel request from the client to [onRequest]. See there + /// for the acknowledge/refuse semantics. + void handleRequest(SSH_Message_Channel_Request request) { + if (isClosed) return; + currentRequest = request; + final handler = onRequest; + if (handler == null) { + _replyToRequest(request, accepted: false); + return; + } + unawaited(() async { + try { + await handler(this); + _replyToRequest(request, accepted: true); + } on Object { + _replyToRequest(request, accepted: false); + } + }()); + } + + /// Tears the channel down without sending anything: the connection's + /// transport is gone. Called by [SSHServerConnection], not by embedders. + void detach() { + if (isClosed) return; + _outgoing.clear(); + _closeInputStreams(); + _done.complete(); + } + + void _enqueueOutgoing(Uint8List data, int? dataTypeCode) { + if (isClosed || _sentEof || _eofPending) { + printDebug?.call( + 'tp_sshd: dropping ${data.length} outgoing bytes on channel ' + '$ourChannel after EOF/close', + ); + return; + } + if (data.isEmpty) return; + _outgoing.add(_OutgoingChunk(data, dataTypeCode)); + _flushOutgoing(); + } + + /// Sends as much queued data as the client's window and packet size + /// allow. A window of zero stalls (returns with the queue intact) until + /// [handleWindowAdjust] unblocks it. + void _flushOutgoing() { + while (_outgoing.isNotEmpty) { + if (_sendWindow <= 0) return; + final chunk = _outgoing.first; + final take = min( + chunk.bytes.length, + min(_sendWindow, _maximumOutgoingPacketSize), + ); + final data = Uint8List.sublistView(chunk.bytes, 0, take); + if (chunk.dataTypeCode == null) { + _sendPacket( + SSH_Message_Channel_Data( + recipientChannel: recipientChannel, + data: data, + ).encode(), + ); + } else { + _sendPacket( + SSH_Message_Channel_Extended_Data( + recipientChannel: recipientChannel, + dataTypeCode: chunk.dataTypeCode!, + data: data, + ).encode(), + ); + } + _sendWindow -= take; + if (take == chunk.bytes.length) { + _outgoing.removeFirst(); + } else { + _outgoing.removeFirst(); + _outgoing.addFirst( + _OutgoingChunk( + Uint8List.sublistView(chunk.bytes, take), + chunk.dataTypeCode, + ), + ); + } + } + if (_eofPending && !_sentEof) { + _sendEof(); + } + } + + void _sendEof() { + _eofPending = false; + _sentEof = true; + _sendPacket( + SSH_Message_Channel_EOF(recipientChannel: recipientChannel).encode(), + ); + } + + /// Admits one inbound data message: checks it against the packet size and + /// window the client was given, surfaces it on [controller], and grants + /// the consumed window back once enough of it has accumulated. + void _handleIncoming(Uint8List data, StreamController controller) { + if (isClosed || data.isEmpty) return; + if (data.length > maximumPacketSize || data.length > _receiveWindow) { + _failChannel( + 'the client sent ${data.length} bytes, over the packet-size/window ' + 'bounds it was given ($_receiveWindow left of the window)', + ); + return; + } + _receiveWindow -= data.length; + controller.add(data); + _grantReceiveWindowIfNeeded(); + } + + /// Grants the consumed receive window back once enough of it has + /// accumulated, using OpenSSH's two refill rules (channels.c): when the + /// window has dropped below half, or when more than three maximum-size + /// packets are outstanding — whichever fires first. Interactive channels + /// stay quiet (no adjustment per keystroke), bulk transfers get frequent + /// refills, and the window can never starve. + void _grantReceiveWindowIfNeeded() { + final consumed = initialReceiveWindow - _receiveWindow; + final belowHalf = _receiveWindow < initialReceiveWindow ~/ 2; + final threePacketsOutstanding = consumed > 3 * maximumPacketSize; + if (!belowHalf && !threePacketsOutstanding) return; + _receiveWindow = initialReceiveWindow; + _sendPacket( + SSH_Message_Channel_Window_Adjust( + recipientChannel: recipientChannel, + bytesToAdd: consumed, + ).encode(), + ); + } + + void _replyToRequest( + SSH_Message_Channel_Request request, { + required bool accepted, + }) { + if (!request.wantReply || isClosed) return; + _sendPacket( + (accepted + ? SSH_Message_Channel_Success(recipientChannel: recipientChannel) + : SSH_Message_Channel_Failure(recipientChannel: recipientChannel)) + .encode(), + ); + } + + /// Sends CHANNEL_CLOSE (unless already sent) and tears the channel down + /// locally. Data still queued for the client's window is dropped. + void _finish() { + if (isClosed) return; + if (!_sentClose) { + _sentClose = true; + _sendPacket( + SSH_Message_Channel_Close(recipientChannel: recipientChannel).encode(), + ); + } + _outgoing.clear(); + _closeInputStreams(); + _done.complete(); + _onClosed(this); + } + + /// Closes this channel after the client violated the channel protocol, + /// leaving the rest of the connection untouched (mirroring the fork's + /// client-side policy). + void _failChannel(String reason) { + printDebug?.call( + 'tp_sshd: closing channel $ourChannel ($channelType): $reason', + ); + _finish(); + } + + void _closeInputStreams() { + if (!_input.isClosed) unawaited(_input.close()); + if (!_extendedInput.isClosed) unawaited(_extendedInput.close()); + } +} + +/// One queued outgoing payload, waiting for send-window credit. +class _OutgoingChunk { + const _OutgoingChunk(this.bytes, this.dataTypeCode); + + final Uint8List bytes; + + /// The extended-data type code, or `null` for plain channel data. + final int? dataTypeCode; +} diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart index ef0a8f73a..1dfa24518 100644 --- a/client/packages/tp_sshd/lib/src/server_connection.dart +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart' show SSHSocket, SSHTransport; import 'package:dartssh2/protocol.dart'; +import 'server_channel.dart'; import 'server_userauth.dart'; import 'ssh_server.dart' show SSHServerAuthRequest, SSHServerConfig, tpServerAlgorithms; @@ -55,6 +56,14 @@ class SSHServerConnection { late final Timer _authTimer; var _phase = _Phase.auth; + /// Open channels on this connection, keyed by the server-assigned channel + /// number (the id the client addresses them by). + final _channels = {}; + + /// The next channel number to assign. A plain counter is enough: channel + /// numbers are only reused after 2^32 opens. + var _nextChannelNumber = 0; + /// Failed authentication attempts so far, for the /// [SSHServerConfig.maxAuthAttempts] throttle. var _authAttempts = 0; @@ -63,10 +72,15 @@ class SSHServerConnection { /// error. Future get done => _transport.done; + /// The channels currently open on this connection, keyed by the + /// server-assigned channel number. + Map get channels => Map.unmodifiable(_channels); + /// Closes the connection and its socket. Future close() async { _authTimer.cancel(); _phase = _Phase.closed; + _teardownChannels(); await _transport.close(); } @@ -81,9 +95,7 @@ class SSHServerConnection { case _Phase.auth: return _handleAuthMessage(payload); case _Phase.running: - // No session traffic is served yet; Task 5 replaces this branch - // with channel handling. - return false; + return _handleRunningMessage(payload); } } @@ -115,6 +127,188 @@ class SSHServerConnection { } } + /// Running-phase message handling (RFC 4254): channel multiplexing and + /// global requests. + /// + /// Returns whether the message was recognized, so the transport answers + /// unrecognized ones with SSH_MSG_UNIMPLEMENTED (RFC 4253 §11). + bool _handleRunningMessage(Uint8List payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Global_Request.messageId: + _handleGlobalRequest(payload); + return true; + case SSH_Message_Channel_Open.messageId: + _handleChannelOpen(payload); + return true; + case SSH_Message_Channel_Window_Adjust.messageId: + case SSH_Message_Channel_Data.messageId: + case SSH_Message_Channel_Extended_Data.messageId: + case SSH_Message_Channel_EOF.messageId: + case SSH_Message_Channel_Close.messageId: + case SSH_Message_Channel_Request.messageId: + _handleChannelMessage(payload); + return true; + default: + return false; + } + } + + /// Answers global requests (RFC 4254 §4). Only keepalive is served; + /// `tcpip-forward`/`cancel-tcpip-forward` arrive with Task 9, so until + /// then everything else is refused. + void _handleGlobalRequest(Uint8List payload) { + final message = _decodeMessage( + 'global request', + SSH_Message_Global_Request.decode, + payload, + ); + if (message == null) return; + if (!message.wantReply) return; + if (message.requestName == 'keepalive@openssh.com') { + _transport.sendPacket(SSH_Message_Request_Success(Uint8List(0)).encode()); + } else { + _transport.sendPacket(SSH_Message_Request_Failure().encode()); + } + } + + /// Serves CHANNEL_OPEN (RFC 4254 §5.1): `session` channels are confirmed + /// with a fresh [SSHServerChannel]; every other type is refused with + /// "administratively prohibited" (forwarded channels arrive in Task 9). + void _handleChannelOpen(Uint8List payload) { + final message = _decodeMessage( + 'channel open', + SSH_Message_Channel_Open.decode, + payload, + ); + if (message == null) return; + + if (message.channelType != 'session') { + _transport.sendPacket( + SSH_Message_Channel_Open_Failure( + recipientChannel: message.senderChannel, + reasonCode: + SSH_Message_Channel_Open_Failure.codeAdministrativelyProhibited, + description: "Channel type '${message.channelType}' is not supported", + ).encode(), + ); + return; + } + + final ourChannel = _nextChannelNumber++; + final channel = SSHServerChannel( + recipientChannel: message.senderChannel, + ourChannel: ourChannel, + channelType: message.channelType, + peerInitialWindowSize: message.initialWindowSize, + peerMaximumPacketSize: message.maximumPacketSize, + sendPacket: _transport.sendPacket, + onClosed: (channel) => _channels.remove(channel.ourChannel), + printDebug: _config.printDebug, + ); + _channels[ourChannel] = channel; + _transport.sendPacket( + SSH_Message_Channel_Confirmation( + recipientChannel: message.senderChannel, + senderChannel: ourChannel, + initialWindowSize: SSHServerChannel.initialReceiveWindow, + maximumPacketSize: SSHServerChannel.maximumPacketSize, + data: Uint8List(0), + ).encode(), + ); + } + + /// Routes a channel-scoped message to its channel by the recipient id the + /// client addressed it to (our channel number). An unknown id is ignored + /// silently: it is indistinguishable from a message racing the close that + /// removed the channel. + void _handleChannelMessage(Uint8List payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Channel_Window_Adjust.messageId: + final message = _decodeMessage( + 'window adjust', + SSH_Message_Channel_Window_Adjust.decode, + payload, + ); + if (message == null) return; + _channelOrNull(message.recipientChannel) + ?.handleWindowAdjust(message.bytesToAdd); + return; + case SSH_Message_Channel_Data.messageId: + final message = _decodeMessage( + 'channel data', + SSH_Message_Channel_Data.decode, + payload, + ); + if (message == null) return; + _channelOrNull(message.recipientChannel)?.handleData(message.data); + return; + case SSH_Message_Channel_Extended_Data.messageId: + final message = _decodeMessage( + 'extended channel data', + SSH_Message_Channel_Extended_Data.decode, + payload, + ); + if (message == null) return; + _channelOrNull(message.recipientChannel) + ?.handleExtendedData(message.dataTypeCode, message.data); + return; + case SSH_Message_Channel_EOF.messageId: + final message = _decodeMessage( + 'channel EOF', + SSH_Message_Channel_EOF.decode, + payload, + ); + if (message == null) return; + _channelOrNull(message.recipientChannel)?.handleEof(); + return; + case SSH_Message_Channel_Close.messageId: + final message = _decodeMessage( + 'channel close', + SSH_Message_Channel_Close.decode, + payload, + ); + if (message == null) return; + _channelOrNull(message.recipientChannel)?.handleClose(); + return; + case SSH_Message_Channel_Request.messageId: + final message = _decodeMessage( + 'channel request', + SSH_Message_Channel_Request.decode, + payload, + ); + if (message == null) return; + _channelOrNull(message.recipientChannel)?.handleRequest(message); + return; + } + } + + SSHServerChannel? _channelOrNull(int ourChannel) => _channels[ourChannel]; + + /// Decodes [payload] with [decode], disconnecting the peer with a + /// protocol error instead of answering when it is malformed. Returns + /// `null` in that case (and after the disconnect, nowhere else). + T? _decodeMessage( + String what, + T Function(Uint8List payload) decode, + Uint8List payload, + ) { + try { + return decode(payload); + } on Object { + _disconnect(SSHDisconnectReason.protocolError, 'Malformed $what'); + return null; + } + } + + /// Detaches every open channel without sending anything: the transport is + /// going away. + void _teardownChannels() { + for (final channel in List.of(_channels.values)) { + channel.detach(); + } + _channels.clear(); + } + /// Handles one `SSH_Message_Userauth_Request` (RFC 4252). /// /// Publickey is the only method served, and only for @@ -240,6 +434,7 @@ class SSHServerConnection { void _onTransportClosed() { _authTimer.cancel(); _phase = _Phase.closed; + _teardownChannels(); } /// Sends a disconnect message and closes the connection. diff --git a/client/packages/tp_sshd/lib/tp_sshd.dart b/client/packages/tp_sshd/lib/tp_sshd.dart index 10e691f46..80531c95d 100644 --- a/client/packages/tp_sshd/lib/tp_sshd.dart +++ b/client/packages/tp_sshd/lib/tp_sshd.dart @@ -1,3 +1,11 @@ export 'package:dartssh2/protocol.dart' - show SSHAlgorithms, SSHKexType, SSHHostkeyType, SSHCipherType, SSHMacType; + show + SSHAlgorithms, + SSHKexType, + SSHHostkeyType, + SSHCipherType, + SSHMacType, + SSH_Message_Channel_Request; +export 'src/server_channel.dart'; +export 'src/server_connection.dart'; export 'src/ssh_server.dart'; diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart index a36c13553..741e6844e 100644 --- a/client/packages/tp_sshd/test/dual_test_utils.dart +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -1,6 +1,14 @@ +// VM-only: reaches the client's private session-channel opener through +// dart:mirrors, like the fork's own channel-open tests do. +library; + import 'dart:async'; +import 'dart:mirrors'; +import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/protocol.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; import 'package:tp_sshd/tp_sshd.dart'; import 'test_socket_pair.dart'; @@ -28,16 +36,152 @@ Future<(SSHClient, SSHServer)> startDualPair({ ), ); connections.add(serverSocket); - final client = SSHClient( + final client = _connectClient( clientSocket, username: username, - onVerifyHostKey: (_, __) => true, identities: clientIdentities, ); await client.authenticated; // throws on auth failure — callers rely on that return (client, server); } +/// Starts a single [SSHServerConnection] over a fresh in-memory socket pair +/// and returns it with a connected, authenticated client. +/// +/// Like [startDualPair], but hands the test the connection object itself, so +/// it can reach the server-side channel table through +/// [SSHServerConnection.channels]. +Future<(SSHClient, SSHServerConnection)> startDualConnection({ + required SSHKeyPair hostKeyPair, + required Future Function(SSHServerAuthRequest request) authenticate, + List clientIdentities = const [], + String username = 'user', +}) async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connection = SSHServerConnection( + serverSocket, + config: SSHServerConfig( + hostKeyPair: hostKeyPair, + expectedUsername: username, + authenticate: authenticate, + ), + ); + final client = _connectClient( + clientSocket, + username: username, + identities: clientIdentities, + ); + await client.authenticated; + return (client, connection); +} + +SSHClient _connectClient( + SSHSocket socket, { + required String username, + required List identities, +}) { + return SSHClient( + socket, + username: username, + onVerifyHostKey: (_, __) => true, + identities: identities, + ); +} + +/// Starts a single [SSHServerConnection] plus a raw client-side +/// [SSHTransport] that authenticates with the test device key. +/// +/// Like [startDualConnection], but the client is a bare transport driving +/// the protocol by hand, so tests can inject channel traffic a real +/// [SSHClient] would never produce (tiny windows, hand-crafted packets). +/// [onServerMessage] sees every message the server sends back (consumed by +/// default). The returned future completes once the server has accepted the +/// authentication. +Future<(SSHServerConnection, SSHTransport)> startRawAuthenticatedConnection({ + void Function(Uint8List payload)? onServerMessage, +}) async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connection = SSHServerConnection( + serverSocket, + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + ), + ); + final authenticated = Completer(); + final publicKey = testDeviceKey.toPublicKey().encode(); + late final SSHTransport client; + client = SSHTransport( + clientSocket, + onVerifyHostKey: (_, __) => true, + onReady: () { + client.sendPacket(SSH_Message_Service_Request('ssh-userauth').encode()); + // The RFC 4252 §7 signed request: the challenge is the session-id + // prefixed request-without-signature, exactly what the server + // re-composes to verify. + final challenge = client.composeChallenge( + username: 'user', + service: 'ssh-connection', + publicKeyAlgorithm: 'ssh-ed25519', + publicKey: publicKey, + ); + final signature = testDeviceKey.sign(challenge); + client.sendPacket( + SSH_Message_Userauth_Request.publicKey( + username: 'user', + publicKeyAlgorithm: 'ssh-ed25519', + publicKey: publicKey, + signature: signature.encode(), + ).encode(), + ); + }, + onMessage: (payload) { + if (SSHMessage.readMessageId(payload) == + SSH_Message_Userauth_Success.messageId && + !authenticated.isCompleted) { + authenticated.complete(); + } + onServerMessage?.call(payload); + return true; + }, + ); + await authenticated.future.timeout(const Duration(seconds: 10)); + return (connection, client); +} + +/// Polls [condition] every 5 ms until it holds, or fails after [timeout]. +/// +/// For awaiting delivery over the in-memory socket pair, where the only +/// observable state is on one side of the pair. +Future waitUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('condition not met within $timeout', timeout); + } + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +/// Opens a session channel on [client] and completes with its controller. +/// +/// dartssh2 exposes no public channel-open-by-type API: `execute`/`shell` +/// open a session channel but then block on the request reply, which the +/// tp_sshd server does not answer until Tasks 6-7. The fork's own tests +/// reach the same private opener through mirrors +/// (`test/src/ssh_client_channel_open_test.dart`), so this harness mirrors +/// that (VM-only) pattern. +Future openClientSessionChannel(SSHClient client) { + final library = reflectClass(SSHClient).owner as LibraryMirror; + final symbol = MirrorSystem.getSymbol('_openSessionChannel', library); + return reflect(client).invoke(symbol, const []).reflectee + as Future; +} + /// Throwaway ed25519 host key generated for these tests only /// (ssh-keygen -t ed25519 -N '' -C 'tp-sshd-task3-throwaway-host'). const _testHostKeyPem = ''' diff --git a/client/packages/tp_sshd/test/server_channel_test.dart b/client/packages/tp_sshd/test/server_channel_test.dart new file mode 100644 index 000000000..998544499 --- /dev/null +++ b/client/packages/tp_sshd/test/server_channel_test.dart @@ -0,0 +1,409 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/protocol.dart'; +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; + +void main() { + test('client can open a session channel', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + // dartssh2 has no public open-by-type API (execute/shell would block on + // the request reply), so the harness opens the session channel through + // the client's own session-channel opener. + final controller = await openClientSessionChannel(client); + expect(controller.channel.channelId, greaterThanOrEqualTo(0)); + // The server-assigned channel number from the confirmation. + expect(controller.remoteId, greaterThanOrEqualTo(0)); + await server.close(); + await client.close(); + }); + + test('unknown channel type gets open-failure', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + // forwardLocal() opens a 'direct-tcpip' channel, which this server does + // not serve until Task 9. + await expectLater( + client.forwardLocal('127.0.0.1', 80), + throwsA( + isA().having( + (error) => error.code, + 'code', + SSH_Message_Channel_Open_Failure.codeAdministrativelyProhibited, + ), + ), + ); + await server.close(); + await client.close(); + }); + + test('keepalive global request is answered', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + // ping() sends keepalive@openssh.com with wantReply and only completes + // once the server replies; silence would hang the future. + await expectLater(client.ping(), completes); + await server.close(); + await client.close(); + }); + + test('other global requests are refused', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + // tcpip-forward is Task 9; until then the Request_Failure reply makes + // forwardRemote return null instead of throwing. + expect(await client.forwardRemote(host: '127.0.0.1', port: 0), isNull); + await server.close(); + await client.close(); + }); + + group('SSHServerChannel', () { + test('routes client data to the input stream', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientChannel = (await openClientSessionChannel(client)).channel; + final SSHServerChannel serverChannel = connection.channels.values.single; + expect(serverChannel.channelType, 'session'); + + final input = expectLater( + serverChannel.input, + emitsInOrder([ + orderedEquals([1, 2, 3]), + orderedEquals([4, 5]), + ]), + ); + clientChannel.addData(Uint8List.fromList([1, 2, 3])); + clientChannel.addData(Uint8List.fromList([4, 5])); + await input; + await connection.close(); + await client.close(); + }); + + test('routes extended client data to the extended input stream', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientChannel = (await openClientSessionChannel(client)).channel; + final SSHServerChannel serverChannel = connection.channels.values.single; + + final input = expectLater( + serverChannel.input, + emitsInOrder([ + orderedEquals([7, 8]), + ]), + ); + final extendedInput = expectLater( + serverChannel.extendedInput, + emitsInOrder([ + orderedEquals([9, 10, 11]), + ]), + ); + clientChannel.addData(Uint8List.fromList([7, 8])); + clientChannel.addData(Uint8List.fromList([9, 10, 11]), type: 1); + await input; + await extendedInput; + await connection.close(); + await client.close(); + }); + + test('writes data back to the client', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientChannel = (await openClientSessionChannel(client)).channel; + final SSHServerChannel serverChannel = connection.channels.values.single; + // The client's channel stream is single-subscription; a StreamIterator + // reads it across both writes. + final clientInput = StreamIterator(clientChannel.stream); + + serverChannel.write(Uint8List.fromList([7, 8, 9])); + expect(await clientInput.moveNext(), isTrue); + expect(clientInput.current.bytes, orderedEquals([7, 8, 9])); + expect(clientInput.current.isExtendedData, isFalse); + + serverChannel.writeExtended(Uint8List.fromList([1, 2])); + expect(await clientInput.moveNext(), isTrue); + expect(clientInput.current.bytes, orderedEquals([1, 2])); + expect(clientInput.current.isExtendedData, isTrue); + await clientInput.cancel(); + + await connection.close(); + await client.close(); + }); + + test('writes are chunked to the peer maximum packet size', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientChannel = (await openClientSessionChannel(client)).channel; + final SSHServerChannel serverChannel = connection.channels.values.single; + + // The client fails any channel whose packets exceed the 32768 bytes it + // advertised, so every event staying under that bound (and the + // reassembled payload matching) proves the server chunks correctly. + final payload = Uint8List(128 * 1024 + 311); + for (var i = 0; i < payload.length; i++) { + payload[i] = (i * 7) & 0xff; + } + final clientInput = StreamIterator(clientChannel.stream); + final received = BytesBuilder(copy: false); + var packets = 0; + serverChannel.write(payload); + while (received.length < payload.length) { + expect(await clientInput.moveNext(), isTrue); + final bytes = clientInput.current.bytes; + expect(bytes.length, lessThanOrEqualTo(32768)); + received.add(bytes); + packets += 1; + } + await clientInput.cancel(); + expect(packets, greaterThan(1)); + expect(received.takeBytes(), orderedEquals(payload)); + + await connection.close(); + await client.close(); + }); + + test('close from the server completes the client channel', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientChannel = (await openClientSessionChannel(client)).channel; + final SSHServerChannel serverChannel = connection.channels.values.single; + + // close() sends EOF then CHANNEL_CLOSE: the client's data stream ends + // and its channel done future completes. + final clientStreamDone = expectLater(clientChannel.stream, emitsDone); + serverChannel.close(); + await clientChannel.done; + await clientStreamDone; + expect(serverChannel.isClosed, isTrue); + expect(connection.channels, isEmpty); + + await connection.close(); + await client.close(); + }); + + test('client close ends the input and is finished by the server', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientChannel = (await openClientSessionChannel(client)).channel; + final SSHServerChannel serverChannel = connection.channels.values.single; + + // The client's close() sends EOF and waits for the server to close too + // (dartssh2 does not send CHANNEL_CLOSE unprompted). The server honors + // the half-close: input ends, but the channel stays open until the + // server finishes it. + final inputDone = expectLater(serverChannel.input, emitsDone); + final clientClosed = clientChannel.close(); + await inputDone; + expect(serverChannel.receivedEof, isTrue); + expect(serverChannel.isClosed, isFalse); + expect(connection.channels, isNotEmpty); + + serverChannel.close(); + await clientClosed; + await serverChannel.done; + expect(connection.channels, isEmpty); + + await connection.close(); + await client.close(); + }); + + test('channel requests reach onRequest and are acknowledged', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientController = await openClientSessionChannel(client); + final SSHServerChannel serverChannel = connection.channels.values.single; + + final requests = []; + serverChannel.onRequest = (channel) async { + requests.add(channel.currentRequest!.requestType); + }; + final accepted = await clientController.sendEnv('FOO', 'BAR'); + expect(accepted, isTrue); + expect(requests, ['env']); + + await connection.close(); + await client.close(); + }); + + test('unhandled channel requests are refused', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + final clientController = await openClientSessionChannel(client); + // No onRequest handler is installed: the request must be refused, not + // left hanging, until Tasks 6-7 implement the session requests. + final accepted = await clientController.sendEnv('FOO', 'BAR'); + expect(accepted, isFalse); + + await connection.close(); + await client.close(); + }); + }); + + group('window accounting', () { + // These drive the protocol with a raw authenticated transport, because + // the windows and packet sizes a real client uses (2 MiB / 32 KiB) make + // stalls and grants too slow to observe over the in-memory pair. + test('outgoing data stalls on a spent window and resumes on adjust', + () async { + SSH_Message_Channel_Confirmation? confirmation; + final dataPackets = []; + final opened = Completer(); + final (connection, client) = await startRawAuthenticatedConnection( + onServerMessage: (payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Channel_Confirmation.messageId: + confirmation = SSH_Message_Channel_Confirmation.decode(payload); + if (!opened.isCompleted) opened.complete(); + case SSH_Message_Channel_Data.messageId: + dataPackets + .add(SSH_Message_Channel_Data.decode(payload).data.length); + } + }, + ); + + // A 10-byte window with 4-byte packets: a real client never offers + // this, which is exactly what makes the stall observable. + client.sendPacket( + SSH_Message_Channel_Open.session( + senderChannel: 7, + initialWindowSize: 10, + maximumPacketSize: 4, + ).encode(), + ); + await opened.future; + + expect(confirmation!.recipientChannel, 7); + expect(confirmation!.senderChannel, 0); // our first channel number + expect( + confirmation!.initialWindowSize, + SSHServerChannel.initialReceiveWindow, + ); + expect( + confirmation!.maximumPacketSize, + SSHServerChannel.maximumPacketSize, + ); + + final SSHServerChannel channel = connection.channels.values.single; + channel.write(Uint8List.fromList(List.generate(25, (i) => i))); + + // 4 + 4 + 2 bytes fit the granted window; the rest waits for credit. + await waitUntil(() => dataPackets.length == 3); + expect(dataPackets, [4, 4, 2]); + // Still stalled: nothing more arrives while the window is spent. + await Future.delayed(const Duration(milliseconds: 100)); + expect(dataPackets.length, 3); + + client.sendPacket( + SSH_Message_Channel_Window_Adjust( + recipientChannel: channel.ourChannel, + bytesToAdd: 15, + ).encode(), + ); + await waitUntil(() => dataPackets.length == 7); + expect(dataPackets, [4, 4, 2, 4, 4, 4, 3]); + + await connection.close(); + client.close(); + }); + + test('the receive window is granted back as the client sends', () async { + final adjusts = []; + final input = BytesBuilder(copy: false); + final opened = Completer(); + final (connection, client) = await startRawAuthenticatedConnection( + onServerMessage: (payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Channel_Confirmation.messageId: + if (!opened.isCompleted) opened.complete(); + case SSH_Message_Channel_Window_Adjust.messageId: + adjusts.add( + SSH_Message_Channel_Window_Adjust.decode(payload).bytesToAdd, + ); + } + }, + ); + + client.sendPacket( + SSH_Message_Channel_Open.session( + senderChannel: 3, + initialWindowSize: 2 * 1024 * 1024, + maximumPacketSize: 32768, + ).encode(), + ); + await opened.future; + final SSHServerChannel channel = connection.channels.values.single; + final inputSubscription = channel.input.listen(input.add); + + client.sendPacket( + SSH_Message_Channel_Data( + recipientChannel: channel.ourChannel, + data: Uint8List.fromList([1, 2, 3, 4]), + ).encode(), + ); + await Future.delayed(const Duration(milliseconds: 100)); + // Small consumption does not grant yet: the refill rules wait for + // half the window or three maximum packets. + expect(adjusts, isEmpty); + + for (var i = 0; i < 4; i++) { + client.sendPacket( + SSH_Message_Channel_Data( + recipientChannel: channel.ourChannel, + data: Uint8List(32768), + ).encode(), + ); + } + await waitUntil(() => input.length == 4 + 4 * 32768); + // 4 + 3 * 32768 = 98308 bytes were consumed when the third bulk packet + // crossed the three-packet threshold, and exactly that was granted. + expect(adjusts, [98308]); + await inputSubscription.cancel(); + + await connection.close(); + client.close(); + }); + }); +} From e1c939e5e5b94cea5e785dca708a1c9a93f4c7df Mon Sep 17 00:00:00 2001 From: hhoao Date: Tue, 8 Sep 2026 14:32:41 +0800 Subject: [PATCH 04/14] fix: address task-5 review findings Co-Authored-By: Claude Opus 4.8 --- .../tp_sshd/lib/src/server_connection.dart | 8 +++++++ .../tp_sshd/test/server_channel_test.dart | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart index 1dfa24518..aa93a3498 100644 --- a/client/packages/tp_sshd/lib/src/server_connection.dart +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -174,6 +174,14 @@ class SSHServerConnection { /// Serves CHANNEL_OPEN (RFC 4254 §5.1): `session` channels are confirmed /// with a fresh [SSHServerChannel]; every other type is refused with /// "administratively prohibited" (forwarded channels arrive in Task 9). + /// + /// The refusal uses reason 1, `codeAdministrativelyProhibited`. The plan + /// text says "reason 3 (admin prohibited)", but reason 3 is + /// `codeUnknownChannelType` in both the fork's API and RFC 4254 §5.1, + /// and it would be the wrong semantic here (the server recognizes + /// `direct-tcpip`, it just does not serve it yet); the named constant for + /// the stated semantic wins per the controller ruling that real fork API + /// names take precedence. void _handleChannelOpen(Uint8List payload) { final message = _decodeMessage( 'channel open', diff --git a/client/packages/tp_sshd/test/server_channel_test.dart b/client/packages/tp_sshd/test/server_channel_test.dart index 998544499..fb6db1b6c 100644 --- a/client/packages/tp_sshd/test/server_channel_test.dart +++ b/client/packages/tp_sshd/test/server_channel_test.dart @@ -37,14 +37,25 @@ void main() { ); // forwardLocal() opens a 'direct-tcpip' channel, which this server does // not serve until Task 9. + // + // The plan text says "reason 3 (admin prohibited)", but reason 3 is + // codeUnknownChannelType in both the fork's API and RFC 4254 §5.1, and + // would be the wrong semantic for a recognized-but-unserved type; the + // named constant for the stated semantic (reason 1, + // codeAdministrativelyProhibited) wins per the controller ruling that + // real fork API names take precedence. Both the constant and the raw + // wire value are pinned so a future constant renumbering cannot slip + // through silently. await expectLater( client.forwardLocal('127.0.0.1', 80), throwsA( - isA().having( - (error) => error.code, - 'code', - SSH_Message_Channel_Open_Failure.codeAdministrativelyProhibited, - ), + isA() + .having( + (error) => error.code, + 'code', + SSH_Message_Channel_Open_Failure.codeAdministrativelyProhibited, + ) + .having((error) => error.code, 'wire value', 1), ), ); await server.close(); From 6eb083f691800bf01a206463d2354712ff685dc4 Mon Sep 17 00:00:00 2001 From: hhoao Date: Tue, 8 Sep 2026 14:55:48 +0800 Subject: [PATCH 05/14] feat(tp_sshd): structured exec with process factory and host-info query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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), 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 --- .../tp_sshd/lib/src/server_channel.dart | 69 +++--- .../tp_sshd/lib/src/server_connection.dart | 8 +- .../tp_sshd/lib/src/server_process.dart | 214 ++++++++++++++++++ .../tp_sshd/lib/src/server_session.dart | 160 +++++++++++++ .../packages/tp_sshd/lib/src/ssh_server.dart | 13 ++ client/packages/tp_sshd/lib/tp_sshd.dart | 2 + .../tp_sshd/test/dual_test_utils.dart | 4 + .../tp_sshd/test/server_channel_test.dart | 13 +- .../tp_sshd/test/server_exec_test.dart | 116 ++++++++++ 9 files changed, 566 insertions(+), 33 deletions(-) create mode 100644 client/packages/tp_sshd/lib/src/server_process.dart create mode 100644 client/packages/tp_sshd/lib/src/server_session.dart create mode 100644 client/packages/tp_sshd/test/server_exec_test.dart diff --git a/client/packages/tp_sshd/lib/src/server_channel.dart b/client/packages/tp_sshd/lib/src/server_channel.dart index b18e04bed..54d7a5df7 100644 --- a/client/packages/tp_sshd/lib/src/server_channel.dart +++ b/client/packages/tp_sshd/lib/src/server_channel.dart @@ -35,14 +35,15 @@ class SSHServerChannel { required void Function(Uint8List payload) sendPacket, required void Function(SSHServerChannel channel) onClosed, this.printDebug, - }) : _sendWindow = peerInitialWindowSize, - // A peer advertising a zero maximum packet size could never be sent - // anything; fall back to the protocol minimum rather than stall - // forever. - _maximumOutgoingPacketSize = - peerMaximumPacketSize > 0 ? peerMaximumPacketSize : maximumPacketSize, - _sendPacket = sendPacket, - _onClosed = onClosed; + }) : _sendWindow = peerInitialWindowSize, + // A peer advertising a zero maximum packet size could never be sent + // anything; fall back to the protocol minimum rather than stall + // forever. + _maximumOutgoingPacketSize = peerMaximumPacketSize > 0 + ? peerMaximumPacketSize + : maximumPacketSize, + _sendPacket = sendPacket, + _onClosed = onClosed; /// The channel number the client assigned to this channel. Every message /// the server sends on it addresses the client by this id. @@ -102,20 +103,18 @@ class SSHServerChannel { bool get receivedEof => _receivedEof; /// Hook invoked when the client sends a channel request (e.g. `exec`, - /// `shell`) on this channel. + /// `shell`) on this channel, with the request that triggered it — so the + /// request cannot go stale between concurrent dispatches. /// - /// The hook runs detached from the message dispatch; while it runs, the - /// triggering request is readable from [currentRequest]. A hook that - /// completes normally acknowledges the request (CHANNEL_SUCCESS reply - /// when the client asked for one), a hook that throws refuses it - /// (CHANNEL_FAILURE). A channel with no hook refuses every request — - /// session requests are implemented in Tasks 6-7. - Future Function(SSHServerChannel channel)? onRequest; - - /// The channel request currently being dispatched to [onRequest], or the - /// last one dispatched if the hook has already returned. Meaningful only - /// from inside the hook. - SSH_Message_Channel_Request? currentRequest; + /// The hook reports the request's outcome through the returned future: + /// `true` acknowledges it (CHANNEL_SUCCESS reply when the client asked for + /// one), `false` — or a thrown error — refuses it (CHANNEL_FAILURE). The + /// reply is sent the moment the future settles; a hook that keeps serving + /// the channel afterwards (streaming output, exit-status, close) must let + /// the future settle first, or the reply is never sent. A channel with no + /// hook refuses every request. + Future Function( + SSHServerChannel channel, SSH_Message_Channel_Request request)? onRequest; /// Sends [data] to the client as channel data (stdout). /// @@ -196,22 +195,38 @@ class SSHServerChannel { /// for the acknowledge/refuse semantics. void handleRequest(SSH_Message_Channel_Request request) { if (isClosed) return; - currentRequest = request; final handler = onRequest; if (handler == null) { _replyToRequest(request, accepted: false); return; } unawaited(() async { + var accepted = false; try { - await handler(this); - _replyToRequest(request, accepted: true); + accepted = await handler(this, request); } on Object { - _replyToRequest(request, accepted: false); + accepted = false; } + _replyToRequest(request, accepted: accepted); }()); } + /// Sends an `exit-status` channel request (RFC 4254 §6.10): the exit + /// status of the process this channel ran. The client never replies to it. + /// + /// Must be sent before [sendEof] and [close] — it is the channel's last + /// word on what its process did, and a client that sees EOF first may stop + /// waiting for it. + void sendExitStatus(int exitStatus) { + if (isClosed) return; + _sendPacket( + SSH_Message_Channel_Request.exitStatus( + recipientChannel: recipientChannel, + exitStatus: exitStatus, + ).encode(), + ); + } + /// Tears the channel down without sending anything: the connection's /// transport is gone. Called by [SSHServerConnection], not by embedders. void detach() { @@ -332,8 +347,8 @@ class SSHServerChannel { if (!request.wantReply || isClosed) return; _sendPacket( (accepted - ? SSH_Message_Channel_Success(recipientChannel: recipientChannel) - : SSH_Message_Channel_Failure(recipientChannel: recipientChannel)) + ? SSH_Message_Channel_Success(recipientChannel: recipientChannel) + : SSH_Message_Channel_Failure(recipientChannel: recipientChannel)) .encode(), ); } diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart index aa93a3498..2876fc6f8 100644 --- a/client/packages/tp_sshd/lib/src/server_connection.dart +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -5,8 +5,10 @@ import 'package:dartssh2/dartssh2.dart' show SSHSocket, SSHTransport; import 'package:dartssh2/protocol.dart'; import 'server_channel.dart'; +import 'server_session.dart'; import 'server_userauth.dart'; -import 'ssh_server.dart' show SSHServerAuthRequest, SSHServerConfig, tpServerAlgorithms; +import 'ssh_server.dart' + show SSHServerAuthRequest, SSHServerConfig, tpServerAlgorithms; /// Lifecycle phases of an [SSHServerConnection]. enum _Phase { @@ -213,6 +215,10 @@ class SSHServerConnection { onClosed: (channel) => _channels.remove(channel.ourChannel), printDebug: _config.printDebug, ); + // Session requests (exec today; shell and pty in Task 7) are served by + // the session layer. The handler refuses everything it does not serve. + channel.onRequest = (channel, request) => + handleSessionRequest(channel, request, config: _config); _channels[ourChannel] = channel; _transport.sendPacket( SSH_Message_Channel_Confirmation( diff --git a/client/packages/tp_sshd/lib/src/server_process.dart b/client/packages/tp_sshd/lib/src/server_process.dart new file mode 100644 index 000000000..5e8fb9c42 --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_process.dart @@ -0,0 +1,214 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +/// A spawned process backing an `exec` channel. Implemented by the app with +/// flutter_pty / `Process.run`; faked in tests. +abstract class SSHServerProcess { + /// The process's standard output, as raw bytes. Must close when the + /// process exits, so the channel can drain it before reporting the exit + /// status. + Stream get stdout; + + /// The process's standard error, as raw bytes. Must close when the + /// process exits, like [stdout]. + Stream get stderr; + + /// The process's standard input. The channel closes it when the client + /// sends EOF. + StreamSink> get stdin; + + /// The process's exit code. Completes when the process exits. + Future get exitCode; + + /// Kills the process. Must be safe to call more than once, after exit, + /// and from connection teardown — the channel never orphans a process. + void kill(); +} + +/// Spawns the [SSHServerProcess] backing one structured `exec` request. +/// +/// Receives the decoded argv, working directory and environment; returns the +/// process, or `null` to refuse the request (an unconfigured factory refuses +/// every exec). The argv is never interpreted by a shell — the factory is +/// handed the argument vector exactly as the client structured it. +typedef SSHProcessFactory = Future Function( + List argv, + String? cwd, + Map env, +); + +/// Snapshot of host facts the server reports for the `tp1:` host-info query. +/// +/// Answered by the app from `Platform` and self-inspection; faked in tests. +/// The query is served by the server itself — no process is ever spawned for +/// it — and [shell] is a display name only, never used to execute commands. +class SSHHostInfo { + const SSHHostInfo({ + required this.platform, + required this.osUser, + required this.elevated, + required this.inDocker, + required this.shell, + }); + + /// Host operating system: `'windows'`, `'macos'` or `'linux'`. + final String platform; + + /// The user the server runs as. + final String osUser; + + /// Whether the server process runs with elevated privileges. + final bool elevated; + + /// Whether the server runs inside a Docker container. + final bool inDocker; + + /// Display name of the user's shell. + final String shell; + + /// Wire format of the host-info answer, one object with exactly the five + /// host facts: + /// `{"platform":…,"osUser":…,"elevated":…,"inDocker":…,"shell":…}`. + Map toJson() => { + 'platform': platform, + 'osUser': osUser, + 'elevated': elevated, + 'inDocker': inDocker, + 'shell': shell, + }; + + /// Parses a host-info answer produced by [toJson]. Throws a + /// [FormatException] on anything else — malformed JSON, a non-object + /// payload, or a missing/mistyped field. + factory SSHHostInfo.fromJson(String source) => _fromMap(_decodeJsonMap( + source, + 'host-info payload', + )); + + static SSHHostInfo _fromMap(Map map) { + final platform = map['platform']; + final osUser = map['osUser']; + final elevated = map['elevated']; + final inDocker = map['inDocker']; + final shell = map['shell']; + if (platform is! String || + osUser is! String || + elevated is! bool || + inDocker is! bool || + shell is! String) { + throw const FormatException( + 'host-info payload is missing or has mistyped fields', + ); + } + return SSHHostInfo( + platform: platform, + osUser: osUser, + elevated: elevated, + inDocker: inDocker, + shell: shell, + ); + } +} + +/// One decoded structured exec request: the process to spawn and the +/// environment it runs in. +class SSHExecRequest { + const SSHExecRequest({required this.argv, this.cwd, this.env = const {}}); + + /// The process to spawn: the executable followed by its arguments. Never + /// re-interpreted by a shell on the server side. + final List argv; + + /// Working directory for the process, or `null` for the server default. + final String? cwd; + + /// Environment variables for the process. + final Map env; +} + +/// `tp1:` payload codec — the only exec grammar this server speaks. +/// +/// A structured exec command is the [prefix] followed by one JSON object: +/// +/// ``` +/// tp1:{"argv":["claude","--version"],"cwd":"C:\\work","env":{"K":"V"}} +/// ``` +/// +/// Anything else — a plain shell string, a missing prefix, malformed JSON, +/// wrong-typed fields — is not this grammar, and the server refuses it rather +/// than handing it to a shell. +class TpExecCodec { + TpExecCodec._(); + + /// The prefix every structured exec command starts with. + static const prefix = 'tp1:'; + + /// The exact host-info query command, `tp1:{"query":"host-info"}`. + static const _hostInfoQuery = '$prefix{"query":"host-info"}'; + + /// Encodes [request] as a structured exec command. The `cwd` and `env` + /// fields are omitted when empty. + static String encode(SSHExecRequest request) { + return '$prefix${jsonEncode({ + 'argv': request.argv, + if (request.cwd != null) 'cwd': request.cwd, + if (request.env.isNotEmpty) 'env': request.env, + })}'; + } + + /// Decodes a structured exec command. Returns `null` unless [command] is a + /// [prefix]-prefixed, well-formed exec payload: the prefix missing, the + /// JSON malformed, `argv` absent/empty/non-string, or `cwd`/`env` mistyped + /// all fail closed. The host-info query is not an exec payload; see + /// [isHostInfoQuery]. + static SSHExecRequest? tryDecode(String command) { + if (!command.startsWith(prefix)) return null; + final Map map; + try { + map = _decodeJsonMap(command.substring(prefix.length), 'exec payload'); + } on FormatException { + return null; + } + final argv = map['argv']; + if (argv is! List || argv.isEmpty || argv.any((e) => e is! String)) { + return null; + } + final cwd = map['cwd']; + if (cwd != null && cwd is! String) return null; + final env = map['env']; + if (env != null && env is! Map) return null; + if (env != null && env.values.any((value) => value is! String)) { + return null; + } + return SSHExecRequest( + argv: List.from(argv), + cwd: cwd as String?, + env: env == null ? const {} : Map.from(env), + ); + } + + /// Whether [command] is the host-info query. + static bool isHostInfoQuery(String command) => command == _hostInfoQuery; + + /// Produces the host-info query command. + static String encodeHostInfoQuery() => _hostInfoQuery; + + /// Encodes [info] as the host-info answer written back on stdout. + static String encodeHostInfo(SSHHostInfo info) => jsonEncode(info.toJson()); +} + +/// Decodes [source] as a JSON object, throwing a [FormatException] naming +/// [what] on anything else. +Map _decodeJsonMap(String source, String what) { + Object? decoded; + try { + decoded = jsonDecode(source); + } on Object { + throw FormatException('malformed JSON in $what'); + } + if (decoded is! Map) { + throw FormatException('$what is not a JSON object'); + } + return decoded; +} diff --git a/client/packages/tp_sshd/lib/src/server_session.dart b/client/packages/tp_sshd/lib/src/server_session.dart new file mode 100644 index 000000000..1f31b860f --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_session.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart' + show SSHChannelRequestType, SSH_Message_Channel_Request; + +import 'server_channel.dart'; +import 'server_process.dart'; +import 'ssh_server.dart' show SSHServerConfig; + +/// Serves session channel requests (RFC 4254 §6) on one open channel. +/// +/// `exec` is the only request type served so far, and it speaks exactly one +/// grammar: the structured `tp1:` payload ([TpExecCodec]). The host-info +/// query inside that grammar is answered by the server itself. A plain +/// shell-string command is not the grammar: it is refused and the channel is +/// closed — this server never hands a raw command line to a shell. +/// +/// The returned future is the request outcome [SSHServerChannel.onRequest] +/// replies with: `true` acknowledges the request, `false` refuses it. The +/// reply is sent once this future settles; the serving that follows (streamed +/// output, exit-status, close) is scheduled behind it via [_afterReply]. +Future handleSessionRequest( + SSHServerChannel channel, + SSH_Message_Channel_Request request, { + required SSHServerConfig config, +}) async { + if (request.requestType != SSHChannelRequestType.exec) return false; + final command = request.command; + if (command == null) return false; + + if (TpExecCodec.isHostInfoQuery(command)) { + return _serveHostInfoQuery(channel, config); + } + + final exec = TpExecCodec.tryDecode(command); + if (exec == null) { + // Not this server's grammar (a plain shell string, or a malformed + // payload): refuse the request, then close the channel once the failure + // reply is on the wire. + _afterReply(channel, channel.close); + return false; + } + + final processFactory = config.processFactory; + if (processFactory == null) return false; + final SSHServerProcess process; + try { + final spawned = await processFactory(exec.argv, exec.cwd, exec.env); + if (spawned == null) return false; + process = spawned; + } on Object { + // A misbehaving factory is a refused request, not a dead connection. + return false; + } + + _afterReply(channel, () => _pipeProcess(channel, process)); + return true; +} + +/// Answers the host-info query from [SSHServerConfig.hostInfo]: writes the +/// JSON snapshot to stdout, reports exit 0, and finishes the channel. No +/// process is ever spawned for it. +Future _serveHostInfoQuery( + SSHServerChannel channel, + SSHServerConfig config, +) async { + final hostInfo = config.hostInfo?.call(); + if (hostInfo == null) return false; + _afterReply(channel, () { + channel.write( + Uint8List.fromList(utf8.encode(TpExecCodec.encodeHostInfo(hostInfo))), + ); + channel.sendExitStatus(0); + channel.close(); + }); + return true; +} + +/// Wires a spawned process to its channel for the rest of the exec. +/// +/// stdout is written as channel data, stderr as extended data, and channel +/// input is piped to the process's stdin. When the process exits, its exit +/// status is reported first (RFC 4254 §6.10 — exit-status strictly before +/// EOF and close). When the channel ends first — the client went away, or +/// the connection was torn down — the process is killed and its streams are +/// closed, so nothing is orphaned. +void _pipeProcess(SSHServerChannel channel, SSHServerProcess process) { + final stdoutDone = Completer(); + final stderrDone = Completer(); + StreamSubscription drain( + Stream stream, + void Function(Uint8List data) write, + Completer done, + ) => + stream.listen( + write, + // An erroring pipe counts as drained: the exit status still needs to + // be reported after whatever output made it through. + onError: (Object _) { + if (!done.isCompleted) done.complete(); + }, + onDone: () { + if (!done.isCompleted) done.complete(); + }, + ); + + final subscriptions = >[ + drain(process.stdout, channel.write, stdoutDone), + drain(process.stderr, channel.writeExtended, stderrDone), + channel.input.listen( + process.stdin.add, + onDone: () => unawaited(process.stdin.close()), + ), + ]; + + var tornDown = false; + void teardown() { + if (tornDown) return; + tornDown = true; + process.kill(); + for (final subscription in subscriptions) { + subscription.cancel(); + } + // Unblock the exit-status continuation too, so it cannot wait forever on + // pipes a cancelled subscription will never finish. + if (!stdoutDone.isCompleted) stdoutDone.complete(); + if (!stderrDone.isCompleted) stderrDone.complete(); + unawaited(process.stdin.close()); + } + + channel.done.whenComplete(teardown); + + unawaited( + process.exitCode.then((exitCode) async { + // Let the pipes drain first: the exit status must not overtake output + // that is still in flight. + await stdoutDone.future; + await stderrDone.future; + channel.sendExitStatus(exitCode); + channel.close(); + }), + ); +} + +/// Runs [action] after the reply to the request currently being dispatched +/// has been sent. +/// +/// [SSHServerChannel.onRequest] hooks settle their request by completing the +/// returned future, and the channel sends the CHANNEL_SUCCESS / +/// CHANNEL_FAILURE reply in the microtask that resumes then. A timer turn is +/// guaranteed to run after every already-scheduled microtask, so work +/// scheduled here lands strictly after that reply: output reaches the client +/// after the answer to the request that produced it, and a refusal can close +/// the channel without suppressing its own failure reply. +void _afterReply(SSHServerChannel channel, void Function() action) { + if (channel.isClosed) return; + unawaited(Future.delayed(Duration.zero, action)); +} diff --git a/client/packages/tp_sshd/lib/src/ssh_server.dart b/client/packages/tp_sshd/lib/src/ssh_server.dart index 48ccf2b7c..d8b0cce07 100644 --- a/client/packages/tp_sshd/lib/src/ssh_server.dart +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -5,6 +5,7 @@ import 'package:dartssh2/dartssh2.dart' show SSHKeyPair, SSHSocket; import 'package:dartssh2/protocol.dart'; import 'server_connection.dart'; +import 'server_process.dart'; /// The narrow negotiation surface advertised by tp_sshd servers (spec: /// x25519 KEX, ed25519 host keys, AEAD ciphers). @@ -27,6 +28,8 @@ class SSHServerConfig { required this.authenticate, this.authTimeout = const Duration(seconds: 30), this.maxAuthAttempts = 6, + this.processFactory, + this.hostInfo, this.printDebug, this.printTrace, }); @@ -55,6 +58,16 @@ class SSHServerConfig { /// server disconnects it. final int maxAuthAttempts; + /// Spawns the process backing a structured `exec` request (see + /// [TpExecCodec]). Receives the decoded argv, working directory and + /// environment; a `null` return — or an unconfigured factory — refuses the + /// request. The server itself never builds a command line. + final SSHProcessFactory? processFactory; + + /// Supplies the host snapshot answered for the `tp1:` host-info query. + /// `null` refuses the query; it is never answered by spawning a process. + final SSHHostInfo Function()? hostInfo; + /// Function invoked with debug logging, mirroring [SSHSocket] transports. final void Function(String? message)? printDebug; diff --git a/client/packages/tp_sshd/lib/tp_sshd.dart b/client/packages/tp_sshd/lib/tp_sshd.dart index 80531c95d..f55f00ef8 100644 --- a/client/packages/tp_sshd/lib/tp_sshd.dart +++ b/client/packages/tp_sshd/lib/tp_sshd.dart @@ -8,4 +8,6 @@ export 'package:dartssh2/protocol.dart' SSH_Message_Channel_Request; export 'src/server_channel.dart'; export 'src/server_connection.dart'; +export 'src/server_process.dart'; +export 'src/server_session.dart'; export 'src/ssh_server.dart'; diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart index 741e6844e..ef8739453 100644 --- a/client/packages/tp_sshd/test/dual_test_utils.dart +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -24,6 +24,8 @@ Future<(SSHClient, SSHServer)> startDualPair({ required Future Function(SSHServerAuthRequest request) authenticate, List clientIdentities = const [], String username = 'user', + SSHProcessFactory? processFactory, + SSHHostInfo Function()? hostInfo, }) async { final (clientSocket, serverSocket) = loopbackSSHSocketPair(); final connections = StreamController(); @@ -33,6 +35,8 @@ Future<(SSHClient, SSHServer)> startDualPair({ hostKeyPair: hostKeyPair, expectedUsername: username, authenticate: authenticate, + processFactory: processFactory, + hostInfo: hostInfo, ), ); connections.add(serverSocket); diff --git a/client/packages/tp_sshd/test/server_channel_test.dart b/client/packages/tp_sshd/test/server_channel_test.dart index fb6db1b6c..28b388e4f 100644 --- a/client/packages/tp_sshd/test/server_channel_test.dart +++ b/client/packages/tp_sshd/test/server_channel_test.dart @@ -265,8 +265,9 @@ void main() { final SSHServerChannel serverChannel = connection.channels.values.single; final requests = []; - serverChannel.onRequest = (channel) async { - requests.add(channel.currentRequest!.requestType); + serverChannel.onRequest = (channel, request) async { + requests.add(request.requestType); + return true; }; final accepted = await clientController.sendEnv('FOO', 'BAR'); expect(accepted, isTrue); @@ -276,15 +277,17 @@ void main() { await client.close(); }); - test('unhandled channel requests are refused', () async { + test('channel requests the session layer does not serve are refused', + () async { final (client, connection) = await startDualConnection( hostKeyPair: testHostKey, authenticate: (_) async => true, clientIdentities: [testDeviceKey], ); final clientController = await openClientSessionChannel(client); - // No onRequest handler is installed: the request must be refused, not - // left hanging, until Tasks 6-7 implement the session requests. + // The connection wires every channel to handleSessionRequest, which + // serves only the structured exec grammar so far; an env request is + // refused instead of left hanging. final accepted = await clientController.sendEnv('FOO', 'BAR'); expect(accepted, isFalse); diff --git a/client/packages/tp_sshd/test/server_exec_test.dart b/client/packages/tp_sshd/test/server_exec_test.dart new file mode 100644 index 000000000..c1344d4e7 --- /dev/null +++ b/client/packages/tp_sshd/test/server_exec_test.dart @@ -0,0 +1,116 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; + +/// Fake process echoing argv back on stdout with a configurable exit code. +class _EchoProcess implements SSHServerProcess { + _EchoProcess(this.argv); + + final List argv; + final _stdin = StreamController>(); + final _stdout = StreamController(); + final _stderr = StreamController(); + + @override + Future get exitCode async => 17; + + @override + void kill() {} + + @override + StreamSink> get stdin => _stdin.sink; + + @override + Stream get stderr => _stderr.stream; + + @override + Stream get stdout => _stdout.stream; + + void start() { + _stdout.add(Uint8List.fromList(utf8.encode(argv.join(' ')))); + _stdout.close(); + _stderr.close(); + } +} + +void main() { + test('structured exec spawns argv and reports exit code', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + processFactory: (argv, cwd, env) async { + expect(cwd, 'C:\\work\\demo'); + expect(env['TEAMPilot_TEST'], '1'); + final process = _EchoProcess(argv)..start(); + return process; + }, + ); + final session = await client.execute( + TpExecCodec.encode( + const SSHExecRequest( + argv: ['claude', '--version'], + cwd: r'C:\work\demo', + env: {'TEAMPilot_TEST': '1'}, + ), + ), + ); + final output = await utf8.decoder.bind(session.stdout).join(); + expect(output, 'claude --version'); + // waitForExit is the fork's public API for the exit status; the raw + // getter only reflects what has already been reported. + expect(await session.waitForExit(), 17); + client.close(); + await server.close(); + }); + + test('plain shell-string exec is rejected', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + processFactory: (argv, cwd, env) async => _EchoProcess(argv)..start(), + ); + await expectLater( + client.execute('rm -rf /'), // no tp1: prefix + throwsA(anything), + ); + client.close(); + await server.close(); + }); + + test('host-info query is answered without spawning', () async { + var factoryCalled = false; + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + hostInfo: () => const SSHHostInfo( + platform: 'windows', + osUser: 'dev', + elevated: false, + inDocker: false, + shell: 'powershell', + ), + processFactory: (argv, cwd, env) async { + factoryCalled = true; + return _EchoProcess(argv)..start(); + }, + ); + final session = await client.execute(TpExecCodec.encodeHostInfoQuery()); + final raw = await utf8.decoder.bind(session.stdout).join(); + final info = SSHHostInfo.fromJson(raw); + expect(info.platform, 'windows'); + expect(factoryCalled, isFalse); + client.close(); + await server.close(); + }); +} From 26095b38552daf28d609d095c944bfeec1af4ed0 Mon Sep 17 00:00:00 2001 From: hhoao Date: Tue, 8 Sep 2026 15:13:38 +0800 Subject: [PATCH 06/14] feat(tp_sshd): interactive shell with pty, resize, and signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tp_sshd/lib/src/server_process.dart | 52 +++++ .../tp_sshd/lib/src/server_session.dart | 192 +++++++++++++++++- .../packages/tp_sshd/lib/src/ssh_server.dart | 6 + .../tp_sshd/test/dual_test_utils.dart | 2 + .../tp_sshd/test/server_channel_test.dart | 7 +- .../tp_sshd/test/server_handshake_test.dart | 3 +- .../tp_sshd/test/server_shell_test.dart | 164 +++++++++++++++ 7 files changed, 417 insertions(+), 9 deletions(-) create mode 100644 client/packages/tp_sshd/test/server_shell_test.dart diff --git a/client/packages/tp_sshd/lib/src/server_process.dart b/client/packages/tp_sshd/lib/src/server_process.dart index 5e8fb9c42..bdeeda01c 100644 --- a/client/packages/tp_sshd/lib/src/server_process.dart +++ b/client/packages/tp_sshd/lib/src/server_process.dart @@ -26,6 +26,58 @@ abstract class SSHServerProcess { void kill(); } +/// Terminal geometry and environment a client asked for with `pty-req` +/// (RFC 4254 §6.2), stashed until the shell request that consumes it. +class SSHPtyDimensions { + const SSHPtyDimensions({ + required this.columns, + required this.rows, + this.pixelWidth = 0, + this.pixelHeight = 0, + this.environment = const {}, + }); + + /// Terminal width in character cells. + final int columns; + + /// Terminal height in character rows. + final int rows; + + /// Terminal width in pixels, `0` when the client does not know. + final int pixelWidth; + + /// Terminal height in pixels, `0` when the client does not know. + final int pixelHeight; + + /// Terminal environment: `TERM` from the `pty-req`, plus any variables + /// the client passed with `env` requests before the shell. Terminal modes + /// are not parsed — they stay the client's raw bytes. + final Map environment; +} + +/// A pseudo-terminal backing a `shell` channel: a [SSHServerProcess] the +/// client can also resize and signal once it is running. Implemented by the +/// app with flutter_pty; faked in tests. +abstract class SSHServerPty extends SSHServerProcess { + /// Resizes the terminal to [columns] x [rows] cells (a `window-change` + /// request, RFC 4254 §6.7). + void resize(int columns, int rows); + + /// Delivers a signal by its RFC 4254 §6.9 name (`'INT'`, `'TERM'`, …, + /// without the `SIG` prefix) — the exact name the client sent, which the + /// fork's own client emits from its `SSHSignal` enum. + void signal(String name); +} + +/// Spawns the [SSHServerPty] backing one `shell` request. +/// +/// Receives the dimensions stashed from the channel's `pty-req` (with `env` +/// request variables merged in); returns the pty, or `null` to refuse the +/// request. An unconfigured factory refuses every shell. +typedef SSHPtyFactory = Future Function( + SSHPtyDimensions initial, +); + /// Spawns the [SSHServerProcess] backing one structured `exec` request. /// /// Receives the decoded argv, working directory and environment; returns the diff --git a/client/packages/tp_sshd/lib/src/server_session.dart b/client/packages/tp_sshd/lib/src/server_session.dart index 1f31b860f..112ac5a54 100644 --- a/client/packages/tp_sshd/lib/src/server_session.dart +++ b/client/packages/tp_sshd/lib/src/server_session.dart @@ -9,12 +9,27 @@ import 'server_channel.dart'; import 'server_process.dart'; import 'ssh_server.dart' show SSHServerConfig; +/// Session state accumulated across the requests of one channel, keyed off +/// the channel itself so it lives and dies with it. +final Expando<_SessionState> _sessionStates = Expando(); + /// Serves session channel requests (RFC 4254 §6) on one open channel. /// -/// `exec` is the only request type served so far, and it speaks exactly one -/// grammar: the structured `tp1:` payload ([TpExecCodec]). The host-info -/// query inside that grammar is answered by the server itself. A plain -/// shell-string command is not the grammar: it is refused and the channel is +/// Two grammars are spoken, one per channel: +/// +/// * the structured `tp1:` exec payload ([TpExecCodec]) — the host-info +/// query inside it is answered by the server itself, and anything else in +/// that grammar is handed to [SSHServerConfig.processFactory]; +/// * the interactive half — `env` requests accumulate, `pty-req` stashes the +/// terminal dimensions, and `shell` spawns the pty through +/// [SSHServerConfig.ptyFactory], after which `window-change` resizes it +/// and `signal` delivers signals to it. +/// +/// A channel takes exactly one lifecycle request, `exec` or `shell` +/// (RFC 4254 §6.5's session channels are single-use): a second one is +/// refused and the channel closed. `shell` additionally requires a prior +/// `pty-req` — this server only serves pty sessions. A plain shell-string +/// command is not the exec grammar: it is refused and the channel is /// closed — this server never hands a raw command line to a shell. /// /// The returned future is the request outcome [SSHServerChannel.onRequest] @@ -26,7 +41,121 @@ Future handleSessionRequest( SSH_Message_Channel_Request request, { required SSHServerConfig config, }) async { - if (request.requestType != SSHChannelRequestType.exec) return false; + final state = _sessionStates[channel] ??= _SessionState(); + switch (request.requestType) { + case SSHChannelRequestType.pty: + return _handlePtyRequest(state, request); + case SSHChannelRequestType.env: + return _handleEnvRequest(state, request); + case SSHChannelRequestType.windowChange: + return _handleWindowChange(state, request); + case SSHChannelRequestType.signal: + return _handleSignalRequest(state, request); + case SSHChannelRequestType.exec: + return _serveExec(channel, request, config: config, state: state); + case SSHChannelRequestType.shell: + return _serveShell(channel, config: config, state: state); + default: + return false; + } +} + +/// State one session channel accumulates between its requests. +class _SessionState { + /// Dimensions stashed by `pty-req`, kept current with any `window-change` + /// that arrives before the shell consumes them. + SSHPtyDimensions? ptyDimensions; + + /// Variables accumulated from `env` requests, merged into the pty + /// environment when the shell starts. + final Map environment = {}; + + /// The pty serving a started shell, if any — the target of the channel's + /// later `window-change` and `signal` requests. + SSHServerPty? pty; + + /// Whether this channel already took its one lifecycle request. + var lifecycleClaimed = false; +} + +/// Stashes the terminal dimensions of a `pty-req` (RFC 4254 §6.2) for the +/// shell request that follows. The terminal type rides along as `TERM`; the +/// encoded terminal modes are not parsed. +bool _handlePtyRequest( + _SessionState state, SSH_Message_Channel_Request request) { + final termType = request.termType; + if (termType == null) return false; + state.ptyDimensions = SSHPtyDimensions( + columns: request.termWidth ?? 80, + rows: request.termHeight ?? 24, + pixelWidth: request.termPixelWidth ?? 0, + pixelHeight: request.termPixelHeight ?? 0, + environment: {'TERM': termType}, + ); + return true; +} + +/// Accumulates one `env` request (RFC 4254 §6.4) into the environment the +/// shell will start with. +bool _handleEnvRequest( + _SessionState state, SSH_Message_Channel_Request request) { + final name = request.variableName; + final value = request.variableValue; + if (name == null || value == null) return false; + state.environment[name] = value; + return true; +} + +/// Applies a `window-change` (RFC 4254 §6.7): resizes the running pty, or — +/// before the shell — refreshes the stashed dimensions so it starts at the +/// size the client last announced. +bool _handleWindowChange( + _SessionState state, + SSH_Message_Channel_Request request, +) { + final columns = request.termWidth; + final rows = request.termHeight; + if (columns == null || rows == null) return false; + final pty = state.pty; + if (pty != null) { + pty.resize(columns, rows); + return true; + } + final dimensions = state.ptyDimensions; + if (dimensions != null) { + state.ptyDimensions = SSHPtyDimensions( + columns: columns, + rows: rows, + pixelWidth: request.termPixelWidth ?? dimensions.pixelWidth, + pixelHeight: request.termPixelHeight ?? dimensions.pixelHeight, + environment: dimensions.environment, + ); + } + return true; +} + +/// Delivers a `signal` request (RFC 4254 §6.9) to the running pty by the +/// name the client sent — the fork's client emits the RFC names directly +/// from its `SSHSignal` enum, so they pass through unchanged. +bool _handleSignalRequest( + _SessionState state, + SSH_Message_Channel_Request request, +) { + final name = request.signalName; + if (name == null) return false; + state.pty?.signal(name); + return true; +} + +/// Serves the structured `exec` request (see [TpExecCodec]). +Future _serveExec( + SSHServerChannel channel, + SSH_Message_Channel_Request request, { + required SSHServerConfig config, + required _SessionState state, +}) async { + if (!_claimLifecycle(channel, state)) return false; + final command = request.command; if (command == null) return false; @@ -59,6 +188,59 @@ Future handleSessionRequest( return true; } +/// Serves the `shell` request (RFC 4254 §6.5): requires the `pty-req` +/// stashed earlier on the channel, then spawns the pty with those +/// dimensions and the accumulated `env` variables, and pipes it like an +/// exec. +Future _serveShell( + SSHServerChannel channel, { + required SSHServerConfig config, + required _SessionState state, +}) async { + if (!_claimLifecycle(channel, state)) return false; + + final dimensions = state.ptyDimensions; + // This server only serves pty sessions; a shell without a pty-req has + // nothing to spawn the pty with. + if (dimensions == null) return false; + final ptyFactory = config.ptyFactory; + if (ptyFactory == null) return false; + + final initial = SSHPtyDimensions( + columns: dimensions.columns, + rows: dimensions.rows, + pixelWidth: dimensions.pixelWidth, + pixelHeight: dimensions.pixelHeight, + environment: {...dimensions.environment, ...state.environment}, + ); + final SSHServerPty pty; + try { + final spawned = await ptyFactory(initial); + if (spawned == null) return false; + pty = spawned; + } on Object { + // A misbehaving factory is a refused request, not a dead connection. + return false; + } + + state.pty = pty; + _afterReply(channel, () => _pipeProcess(channel, pty)); + return true; +} + +/// Claims the channel's one lifecycle request (`exec` or `shell`). A session +/// channel serves a single program (RFC 4254 §6.5); a second lifecycle +/// request is refused, and the channel is closed once that failure reply is +/// on the wire. +bool _claimLifecycle(SSHServerChannel channel, _SessionState state) { + if (state.lifecycleClaimed) { + _afterReply(channel, channel.close); + return false; + } + state.lifecycleClaimed = true; + return true; +} + /// Answers the host-info query from [SSHServerConfig.hostInfo]: writes the /// JSON snapshot to stdout, reports exit 0, and finishes the channel. No /// process is ever spawned for it. diff --git a/client/packages/tp_sshd/lib/src/ssh_server.dart b/client/packages/tp_sshd/lib/src/ssh_server.dart index d8b0cce07..e81dc08da 100644 --- a/client/packages/tp_sshd/lib/src/ssh_server.dart +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -29,6 +29,7 @@ class SSHServerConfig { this.authTimeout = const Duration(seconds: 30), this.maxAuthAttempts = 6, this.processFactory, + this.ptyFactory, this.hostInfo, this.printDebug, this.printTrace, @@ -64,6 +65,11 @@ class SSHServerConfig { /// request. The server itself never builds a command line. final SSHProcessFactory? processFactory; + /// Spawns the pseudo-terminal backing a `shell` request. The request is + /// only served on a channel that stashed a `pty-req` first; a `null` + /// return — or an unconfigured factory — refuses the request. + final SSHPtyFactory? ptyFactory; + /// Supplies the host snapshot answered for the `tp1:` host-info query. /// `null` refuses the query; it is never answered by spawning a process. final SSHHostInfo Function()? hostInfo; diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart index ef8739453..22deae3ea 100644 --- a/client/packages/tp_sshd/test/dual_test_utils.dart +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -25,6 +25,7 @@ Future<(SSHClient, SSHServer)> startDualPair({ List clientIdentities = const [], String username = 'user', SSHProcessFactory? processFactory, + SSHPtyFactory? ptyFactory, SSHHostInfo Function()? hostInfo, }) async { final (clientSocket, serverSocket) = loopbackSSHSocketPair(); @@ -36,6 +37,7 @@ Future<(SSHClient, SSHServer)> startDualPair({ expectedUsername: username, authenticate: authenticate, processFactory: processFactory, + ptyFactory: ptyFactory, hostInfo: hostInfo, ), ); diff --git a/client/packages/tp_sshd/test/server_channel_test.dart b/client/packages/tp_sshd/test/server_channel_test.dart index 28b388e4f..f84006f07 100644 --- a/client/packages/tp_sshd/test/server_channel_test.dart +++ b/client/packages/tp_sshd/test/server_channel_test.dart @@ -286,9 +286,10 @@ void main() { ); final clientController = await openClientSessionChannel(client); // The connection wires every channel to handleSessionRequest, which - // serves only the structured exec grammar so far; an env request is - // refused instead of left hanging. - final accepted = await clientController.sendEnv('FOO', 'BAR'); + // serves the structured exec grammar and the pty half (pty-req, env, + // shell, window-change, signal); a subsystem request is refused + // instead of left hanging. + final accepted = await clientController.sendSubsystem('sftp'); expect(accepted, isFalse); await connection.close(); diff --git a/client/packages/tp_sshd/test/server_handshake_test.dart b/client/packages/tp_sshd/test/server_handshake_test.dart index f499d0c85..6e7dfcd8b 100644 --- a/client/packages/tp_sshd/test/server_handshake_test.dart +++ b/client/packages/tp_sshd/test/server_handshake_test.dart @@ -11,7 +11,8 @@ import 'dual_test_utils.dart'; import 'test_socket_pair.dart'; void main() { - test('client handshake reaches auth and fails closed without userauth', () async { + test('client handshake reaches auth and fails closed without userauth', + () async { final (clientSocket, serverSocket) = loopbackSSHSocketPair(); final connections = StreamController(); final server = await SSHServer.bind( diff --git a/client/packages/tp_sshd/test/server_shell_test.dart b/client/packages/tp_sshd/test/server_shell_test.dart new file mode 100644 index 000000000..54a456241 --- /dev/null +++ b/client/packages/tp_sshd/test/server_shell_test.dart @@ -0,0 +1,164 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; + +/// Fake pty recording resizes and signals, producing output only when the +/// test pushes it. +class _FakePty implements SSHServerPty { + final _stdout = StreamController.broadcast(); + final _stdin = StreamController>(); + final resized = []; + final signaled = []; + + @override + void resize(int columns, int rows) => resized.add('$columns x $rows'); + + @override + void signal(String name) => signaled.add(name); + + @override + Stream get stdout => _stdout.stream; + + @override + Stream get stderr => const Stream.empty(); + + @override + StreamSink> get stdin => _stdin.sink; + + @override + Future get exitCode => Completer().future; + + @override + void kill() {} +} + +/// Fake process that never exits, so the channel it serves stays alive until +/// the test tears it down. +class _HangingProcess implements SSHServerProcess { + final _stdin = StreamController>(); + final _stdout = StreamController(); + + @override + Stream get stdout => _stdout.stream; + + @override + Stream get stderr => const Stream.empty(); + + @override + StreamSink> get stdin => _stdin.sink; + + @override + Future get exitCode => Completer().future; + + @override + void kill() {} +} + +void main() { + test('shell request with pty spawns pty, echoes, resizes, signals', () async { + final pty = _FakePty(); + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ptyFactory: (initial) async { + expect(initial.columns, 120); + expect(initial.rows, 40); + expect(initial.environment['TERM'], 'xterm-256color'); + // `env` requests the client sent before the shell are accumulated + // into the pty environment. + expect(initial.environment['FOO'], 'bar'); + return pty; + }, + ); + final session = await client.shell( + pty: const SSHPtyConfig(width: 120, height: 40), + environment: const {'FOO': 'bar'}, + ); + // The server wires the pipes one turn after its success reply; let that + // land before producing output, or the broadcast stream has no listener. + await pumpEventQueue(); + + pty._stdout.add(Uint8List.fromList(utf8.encode('hello'))); + expect(utf8.decoder.bind(session.stdout).first, completion('hello')); + + session.resizeTerminal(200, 50); + await pumpEventQueue(); + expect(pty.resized, contains('200 x 50')); + + // The fork's SSHSession.kill sends the signal by its RFC 4254 §6.9 + // name; the server hands that name to the pty unchanged. + session.kill(SSHSignal.INT); + await pumpEventQueue(); + expect(pty.signaled, contains('INT')); + + client.close(); + await server.close(); + }); + + test('shell without ptyFactory fails the request', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + await expectLater( + client.shell(pty: const SSHPtyConfig(width: 80, height: 24)), + throwsA(anything), + ); + client.close(); + await server.close(); + }); + + test('shell without a prior pty-req is refused', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ptyFactory: (initial) async => _FakePty(), + ); + // A bare shell has no stashed dimensions to spawn the pty with — and + // this server only serves pty sessions — so it is refused and the + // factory never runs. + final controller = await openClientSessionChannel(client); + expect(await controller.sendShell(), isFalse); + client.close(); + await server.close(); + }); + + test('a second lifecycle request on a session channel is refused', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + processFactory: (argv, cwd, env) async => _HangingProcess(), + ); + final controller = await openClientSessionChannel(client); + expect( + await controller.sendExec( + TpExecCodec.encode(const SSHExecRequest(argv: ['claude'])), + ), + isTrue, + ); + // A session channel serves one program (RFC 4254 §6.5): the second exec + // is refused, and the channel is closed after its failure reply. + expect( + await controller.sendExec( + TpExecCodec.encode(const SSHExecRequest(argv: ['claude'])), + ), + isFalse, + ); + await controller.channel.done; + client.close(); + await server.close(); + }); +} From 6fd1c838514848b69ecb5fc28154096f19c13129 Mon Sep 17 00:00:00 2001 From: hhoao Date: Tue, 8 Sep 2026 15:41:59 +0800 Subject: [PATCH 07/14] feat(tp_sshd): SFTPv3 subsystem over injected filesystem Co-Authored-By: Claude Opus 4.8 --- .../tp_sshd/lib/src/server_session.dart | 32 +- .../packages/tp_sshd/lib/src/server_sftp.dart | 426 ++++++++++++++++++ .../tp_sshd/lib/src/sftp_filesystem.dart | 97 ++++ .../packages/tp_sshd/lib/src/ssh_server.dart | 7 + client/packages/tp_sshd/lib/tp_sshd.dart | 2 + .../tp_sshd/test/dual_test_utils.dart | 2 + .../tp_sshd/test/memory_sftp_filesystem.dart | 282 ++++++++++++ .../tp_sshd/test/server_channel_test.dart | 6 +- .../tp_sshd/test/server_sftp_test.dart | 95 ++++ 9 files changed, 944 insertions(+), 5 deletions(-) create mode 100644 client/packages/tp_sshd/lib/src/server_sftp.dart create mode 100644 client/packages/tp_sshd/lib/src/sftp_filesystem.dart create mode 100644 client/packages/tp_sshd/test/memory_sftp_filesystem.dart create mode 100644 client/packages/tp_sshd/test/server_sftp_test.dart diff --git a/client/packages/tp_sshd/lib/src/server_session.dart b/client/packages/tp_sshd/lib/src/server_session.dart index 112ac5a54..205592141 100644 --- a/client/packages/tp_sshd/lib/src/server_session.dart +++ b/client/packages/tp_sshd/lib/src/server_session.dart @@ -7,6 +7,7 @@ import 'package:dartssh2/protocol.dart' import 'server_channel.dart'; import 'server_process.dart'; +import 'server_sftp.dart'; import 'ssh_server.dart' show SSHServerConfig; /// Session state accumulated across the requests of one channel, keyed off @@ -23,9 +24,12 @@ final Expando<_SessionState> _sessionStates = Expando(); /// * the interactive half — `env` requests accumulate, `pty-req` stashes the /// terminal dimensions, and `shell` spawns the pty through /// [SSHServerConfig.ptyFactory], after which `window-change` resizes it -/// and `signal` delivers signals to it. +/// and `signal` delivers signals to it; +/// * the `sftp` subsystem — served by the SFTPv3 server over +/// [SSHServerConfig.sftpFileSystem] (see [serveSftpSubsystem]). /// -/// A channel takes exactly one lifecycle request, `exec` or `shell` +/// A channel takes exactly one lifecycle request, `exec`, `shell` or the +/// `sftp` subsystem /// (RFC 4254 §6.5's session channels are single-use): a second one is /// refused and the channel closed. `shell` additionally requires a prior /// `pty-req` — this server only serves pty sessions. A plain shell-string @@ -55,6 +59,8 @@ Future handleSessionRequest( return _serveExec(channel, request, config: config, state: state); case SSHChannelRequestType.shell: return _serveShell(channel, config: config, state: state); + case SSHChannelRequestType.subsystem: + return _serveSubsystem(channel, request, config: config, state: state); default: return false; } @@ -228,7 +234,27 @@ Future _serveShell( return true; } -/// Claims the channel's one lifecycle request (`exec` or `shell`). A session +/// Serves the `subsystem` request (RFC 4254 §6.5): the only subsystem this +/// server speaks is `sftp`, and only over a configured filesystem +/// ([SSHServerConfig.sftpFileSystem]). Anything else is refused. +Future _serveSubsystem( + SSHServerChannel channel, + SSH_Message_Channel_Request request, { + required SSHServerConfig config, + required _SessionState state, +}) async { + if (!_claimLifecycle(channel, state)) return false; + final filesystem = config.sftpFileSystem; + if (request.subsystemName != 'sftp' || filesystem == null) return false; + _afterReply( + channel, + () => serveSftpSubsystem(channel, filesystem: filesystem), + ); + return true; +} + +/// Claims the channel's one lifecycle request (`exec`, `shell` or the `sftp` +/// subsystem). A session /// channel serves a single program (RFC 4254 §6.5); a second lifecycle /// request is refused, and the channel is closed once that failure reply is /// on the wire. diff --git a/client/packages/tp_sshd/lib/src/server_sftp.dart b/client/packages/tp_sshd/lib/src/server_sftp.dart new file mode 100644 index 000000000..0393c5985 --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_sftp.dart @@ -0,0 +1,426 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart'; + +import 'server_channel.dart'; +import 'sftp_filesystem.dart'; + +/// The SFTP version this server speaks: 3, the only version the fork's +/// client accepts (draft-ietf-secsh-filexfer-02). +const _kSftpVersion = 3; + +/// Largest SFTP packet accepted or produced, matching the fork client's +/// limit (`SFTP_MAX_MSG_LENGTH` in OpenSSH terms). The 4-byte length prefix +/// is not counted. +const _kMaxPacketLength = 256 * 1024; + +/// `SSH_FX_FILE_ALREADY_EXISTS`, the SFTPv3 code the fork's +/// [SftpStatusCode] does not name (it stops at 8). +const _sshFxFileAlreadyExists = 11; + +/// Serves the SFTPv3 subsystem (draft-ietf-secsh-filexfer-02) on an open +/// session channel over an injected [filesystem]. +/// +/// Wire format, mirrored from the fork's client: every packet is a 4-byte +/// big-endian length followed by a 1-byte type and the type's payload. The +/// client matches replies to requests by request id, so every reply carries +/// the id of the request it answers. End-of-file and end-of-directory are +/// both reported as a `SSH_FX_EOF` status — the fork's client accepts codes +/// 0 (OK) and 1 (EOF) for every checked operation, and treats an empty DATA +/// chunk as a protocol error, so short regions must be answered with the +/// bytes that exist and a following request with EOF. +/// +/// Requests are dispatched as they finish parsing (not one-at-a-time), since +/// the client pipelines reads and writes; every reply is written in a single +/// channel write, so interleaved replies cannot split a packet. +/// +/// Unknown or extended request types answer `SSH_FX_OP_UNSUPPORTED`, and +/// filesystem failures map to status codes through the typed +/// [SftpFileSystemException] hierarchy. When the channel ends, every handle +/// still open is released. +Future serveSftpSubsystem( + SSHServerChannel channel, { + required SftpFileSystem filesystem, +}) { + return _SftpServerSession(channel, filesystem).run(); +} + +class _SftpServerSession { + _SftpServerSession(this._channel, this._filesystem); + + final SSHServerChannel _channel; + final SftpFileSystem _filesystem; + + /// Open handles by id, in allocation order. Handle ids are unique per + /// session, so a stale CLOSE cannot hit a recycled handle. + final _handles = {}; + var _nextHandleId = 0; + + /// Bytes received but not yet parsed into a complete packet. + Uint8List _pending = Uint8List(0); + + Future run() async { + final subscription = _channel.input.listen(_onData); + // The channel finishing — client close, connection teardown — ends the + // session: stop reading and release every handle still open. + await _channel.done; + await subscription.cancel(); + await _releaseAllHandles(); + } + + void _onData(Uint8List data) { + final builder = BytesBuilder(copy: false) + ..add(_pending) + ..add(data); + _pending = builder.takeBytes(); + _drainPackets(); + } + + /// Extracts every complete packet from [_pending], leaving the remainder + /// for the next arrival. + void _drainPackets() { + while (_pending.length >= 4) { + final length = ByteData.sublistView(_pending, 0, 4).getUint32(0); + if (length > _kMaxPacketLength || length < 1) { + // Same policy the fork's client holds its peer to: an oversized or + // empty packet is a broken peer, not a recoverable request. + _channel.printDebug?.call( + 'tp_sshd: closing sftp subsystem: packet length $length is out of ' + 'bounds', + ); + _channel.close(); + return; + } + if (_pending.length < 4 + length) return; + final payload = Uint8List.sublistView(_pending, 4, 4 + length); + _pending = Uint8List.sublistView(_pending, 4 + length); + unawaited(_handlePacket(payload)); + } + } + + Future _handlePacket(Uint8List payload) async { + final type = payload[0]; + try { + switch (type) { + case SftpInitPacket.packetType: + // No extensions are offered: the fork's client then uses the + // standard RENAME instead of posix-rename@openssh.com. + _sendPacket(SftpVersionPacket(_kSftpVersion)); + case SftpOpenPacket.packetType: + await _handleOpen(SftpOpenPacket.decode(payload)); + case SftpClosePacket.packetType: + await _handleClose(SftpClosePacket.decode(payload)); + case SftpReadPacket.packetType: + await _handleRead(SftpReadPacket.decode(payload)); + case SftpWritePacket.packetType: + await _handleWrite(SftpWritePacket.decode(payload)); + case SftpLStatPacket.packetType: + final request = SftpLStatPacket.decode(payload); + await _handleStat(request.requestId, request.path); + case SftpFStatPacket.packetType: + await _handleFStat(SftpFStatPacket.decode(payload)); + case SftpSetStatPacket.packetType: + final request = SftpSetStatPacket.decode(payload); + // Attribute writes are accepted but not applied: the injected + // filesystem has no attribute store to write to. + _sendOk(request.requestId); + case SftpFSetStatPacket.packetType: + await _handleFSetStat(SftpFSetStatPacket.decode(payload)); + case SftpOpenDirPacket.packetType: + await _handleOpenDir(SftpOpenDirPacket.decode(payload)); + case SftpReadDirPacket.packetType: + await _handleReadDir(SftpReadDirPacket.decode(payload)); + case SftpRemovePacket.packetType: + final request = SftpRemovePacket.decode(payload); + await _filesystem.unlink(request.filename); + _sendOk(request.requestId); + case SftpMkdirPacket.packetType: + final request = SftpMkdirPacket.decode(payload); + await _filesystem.mkdir(request.path, request.attributes); + _sendOk(request.requestId); + case SftpRmdirPacket.packetType: + final request = SftpRmdirPacket.decode(payload); + await _filesystem.rmdir(request.path); + _sendOk(request.requestId); + case SftpRealpathPacket.packetType: + await _handleRealpath(SftpRealpathPacket.decode(payload)); + case SftpStatPacket.packetType: + final request = SftpStatPacket.decode(payload); + await _handleStat(request.requestId, request.path); + case SftpRenamePacket.packetType: + final request = SftpRenamePacket.decode(payload); + await _filesystem.rename(request.oldPath, request.newPath); + _sendOk(request.requestId); + case SftpReadlinkPacket.packetType: + case SftpSymlinkPacket.packetType: + // The injected filesystem does not model symbolic links. + _sendStatus( + _requestIdOf(payload), + SftpStatusCode.opUnsupported, + 'Symbolic links are not supported', + ); + case SftpExtendedPacket.packetType: + _sendStatus( + SftpExtendedPacket.decode(payload).requestId, + SftpStatusCode.opUnsupported, + 'Extended requests are not supported', + ); + default: + _sendStatus( + _requestIdOf(payload), + SftpStatusCode.opUnsupported, + 'Unknown packet type: $type', + ); + } + } on Object catch (error) { + // Every request carries its id right after the type byte; use it so + // the client's reply matching still resolves after the failure. (INIT + // has no id and cannot reach here — its handling runs no filesystem + // call.) + _sendStatusError(_requestIdOf(payload), error); + } + } + + Future _handleOpen(SftpOpenPacket request) async { + final handle = await _filesystem.openFile( + request.path, + _openModeFromFlags(request.flags), + request.attrs, + ); + final id = _registerHandle(_SftpHandleEntry.file(request.path, handle)); + _sendPacket(SftpHandlePacket(request.requestId, _encodeHandle(id))); + } + + Future _handleClose(SftpClosePacket request) async { + final entry = _removeHandle(request.handle); + if (entry == null) { + _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); + return; + } + // Release first, acknowledge after: the reply is the client's signal + // that the handle is gone. + await entry.close(); + _sendOk(request.requestId); + } + + Future _handleRead(SftpReadPacket request) async { + final file = _handles[_decodeHandle(request.handle)]?.file; + if (file == null) { + _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); + return; + } + final data = await file.read(request.offset, request.length); + if (data.isEmpty) { + // EOF is a status, never an empty DATA packet — the fork's client + // treats the latter as a protocol error. + _sendStatus(request.requestId, SftpStatusCode.eof, 'End of file'); + } else { + _sendPacket(SftpDataPacket(request.requestId, data)); + } + } + + Future _handleWrite(SftpWritePacket request) async { + final file = _handles[_decodeHandle(request.handle)]?.file; + if (file == null) { + _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); + return; + } + await file.write(request.offset, request.data); + _sendOk(request.requestId); + } + + Future _handleStat(int requestId, String path) async { + final attrs = await _filesystem.stat(path); + _sendPacket(SftpAttrsPacket(requestId, attrs)); + } + + Future _handleFStat(SftpFStatPacket request) async { + final path = _handles[_decodeHandle(request.handle)]?.path; + if (path == null) { + _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); + return; + } + // Both file and directory handles answer FSTAT, off the open path. + final attrs = await _filesystem.stat(path); + _sendPacket(SftpAttrsPacket(request.requestId, attrs)); + } + + Future _handleFSetStat(SftpFSetStatPacket request) async { + if (_handles[_decodeHandle(request.handle)]?.file == null) { + _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); + return; + } + // Like SETSTAT: accepted but not applied. + _sendOk(request.requestId); + } + + Future _handleOpenDir(SftpOpenDirPacket request) async { + final listing = await _filesystem.openDir(request.path); + final id = _registerHandle( + _SftpHandleEntry.dir(request.path, listing), + ); + _sendPacket(SftpHandlePacket(request.requestId, _encodeHandle(id))); + } + + Future _handleReadDir(SftpReadDirPacket request) async { + final listing = _handles[_decodeHandle(request.handle)]?.listing; + if (listing == null) { + _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); + return; + } + final names = await listing.read(); + if (names.isEmpty) { + // The fork's client ends its listdir loop on an EOF status. + _sendStatus(request.requestId, SftpStatusCode.eof, 'End of directory'); + } else { + _sendPacket(SftpNamePacket(request.requestId, names)); + } + } + + Future _handleRealpath(SftpRealpathPacket request) async { + final resolved = await _filesystem.realpath(request.path); + _sendPacket( + SftpNamePacket(request.requestId, [ + SftpName( + filename: resolved, + longname: resolved, + attr: SftpFileAttrs(), + ), + ]), + ); + } + + /// Rebuilds an [SftpFileOpenMode] from the wire flags. The fork's class + /// has no public int constructor, so the mode is folded from its public + /// constants; SFTPv3 always sets at least one of READ/WRITE, which gives + /// the fold its base case. Flag bits beyond the six the fork defines are + /// dropped. + SftpFileOpenMode _openModeFromFlags(int flags) { + final staticModes = [ + SftpFileOpenMode.write, + SftpFileOpenMode.append, + SftpFileOpenMode.create, + SftpFileOpenMode.truncate, + SftpFileOpenMode.exclusive, + ]; + var mode = (flags & SftpFileOpenMode.read.flag) != 0 + ? SftpFileOpenMode.read + : SftpFileOpenMode.write; + for (final staticMode in staticModes) { + if (flags & staticMode.flag != 0) { + mode = mode | staticMode; + } + } + return mode; + } + + int _registerHandle(_SftpHandleEntry entry) { + final id = _nextHandleId++; + _handles[id] = entry; + return id; + } + + _SftpHandleEntry? _removeHandle(Uint8List handle) { + final id = _decodeHandle(handle); + if (id == null) return null; + return _handles.remove(id); + } + + Future _releaseAllHandles() async { + final entries = List.of(_handles.values); + _handles.clear(); + for (final entry in entries) { + try { + await entry.close(); + } on Object catch (error) { + // Teardown is best effort: one misbehaving handle must not stop the + // rest from being released. + _channel.printDebug?.call( + 'tp_sshd: releasing an sftp handle at teardown failed: $error', + ); + } + } + } + + /// The request id of a request payload: the uint32 right after the type + /// byte, which every request carries (INIT, the one exception, has none). + int _requestIdOf(Uint8List payload) { + if (payload.length < 5) return 0; + return ByteData.sublistView(payload, 1, 5).getUint32(0); + } + + Uint8List _encodeHandle(int id) { + final bytes = Uint8List(4); + ByteData.view(bytes.buffer).setUint32(0, id); + return bytes; + } + + int? _decodeHandle(Uint8List handle) { + if (handle.length != 4) return null; + return ByteData.sublistView(handle).getUint32(0); + } + + void _sendOk(int requestId) => _sendStatus(requestId, SftpStatusCode.ok, ''); + + void _sendStatus(int requestId, int code, String message) { + _sendPacket( + SftpStatusPacket( + requestId: requestId, + code: code, + message: message, + ), + ); + } + + /// Maps a filesystem failure onto the wire status code the client sees. + void _sendStatusError(int requestId, Object error) { + if (error is SftpNoSuchFileException) { + _sendStatus(requestId, SftpStatusCode.noSuchFile, error.message); + } else if (error is SftpPermissionDeniedException) { + _sendStatus(requestId, SftpStatusCode.permissionDenied, error.message); + } else if (error is SftpFileExistsException) { + _sendStatus(requestId, _sshFxFileAlreadyExists, error.message); + } else { + _sendStatus(requestId, SftpStatusCode.failure, error.toString()); + } + } + + void _sendPacket(SftpPacket packet) { + final payload = packet.encode(); + final framed = BytesBuilder(copy: false) + ..add(_lengthPrefix(payload.length)) + ..add(payload); + _channel.write(framed.takeBytes()); + } + + Uint8List _lengthPrefix(int length) { + final bytes = Uint8List(4); + ByteData.view(bytes.buffer).setUint32(0, length); + return bytes; + } +} + +/// One open handle: a file or a directory listing, both remembering the path +/// they were opened at (FSTAT answers from it). +class _SftpHandleEntry { + _SftpHandleEntry.file(this.path, SftpHandle file) + : file = file, + listing = null; + + _SftpHandleEntry.dir(this.path, SftpDirListing listing) + : file = null, + listing = listing; + + /// The path the handle was opened at. + final String path; + + /// The open file; `null` for directory handles. + final SftpHandle? file; + + /// The open directory listing; `null` for file handles. + final SftpDirListing? listing; + + Future close() => + file?.close() ?? listing?.close() ?? Future.value(); +} diff --git a/client/packages/tp_sshd/lib/src/sftp_filesystem.dart b/client/packages/tp_sshd/lib/src/sftp_filesystem.dart new file mode 100644 index 000000000..66579138e --- /dev/null +++ b/client/packages/tp_sshd/lib/src/sftp_filesystem.dart @@ -0,0 +1,97 @@ +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart' + show SftpFileAttrs, SftpFileOpenMode, SftpName; + +/// Injected filesystem the SFTP subsystem runs on. The app implements this +/// over its storage layer; tests over an in-memory tree. +/// +/// Paths are absolute and use `/` separators; [realpath] is the authority on +/// normalization. Implementations report failures by throwing the typed +/// [SftpFileSystemException] subclasses (mapped onto wire status codes) — +/// anything else surfaces to the client as a generic `SSH_FX_FAILURE`. +abstract class SftpFileSystem { + /// Attributes of the file or directory at [path]. + Future stat(String path); + + /// Opens [path] for reading. An empty batch from [SftpDirListing.read] + /// later signals end-of-directory. + Future openDir(String path); + + /// Opens [path] as a file in [mode] (read/write/append, with + /// create/truncate/exclusive flags), optionally seeded with [attrs]. + Future openFile( + String path, + SftpFileOpenMode mode, + SftpFileAttrs? attrs, + ); + + /// Creates a directory at [path] (parents are not created). + Future mkdir(String path, SftpFileAttrs attrs); + + /// Removes the (empty, non-root) directory at [path]. + Future rmdir(String path); + + /// Removes the file at [path]. + Future unlink(String path); + + /// Moves the file or directory at [from] to [to]. + Future rename(String from, String to); + + /// The canonical absolute path [path] refers to. + Future realpath(String path); +} + +/// Opaque per-open handle. `read`/`write` take the offset: SFTPv3 is +/// stateless-offset with a handle for lifetime bookkeeping only. +abstract class SftpHandle { + /// Reads up to [length] bytes at [offset]. An empty result means + /// end-of-file; the result must never be longer than [length]. + Future read(int offset, int length); + + /// Writes [data] at [offset]. + Future write(int offset, Uint8List data); + + /// Releases the handle. Called exactly once, when the client closes it or + /// the channel ends. + Future close(); +} + +/// One open directory. [read] hands out entries batch by batch; an empty +/// batch marks the end of the directory. +abstract class SftpDirListing { + /// The next batch of entries, or an empty list once exhausted. + Future> read(); + + /// Releases the listing. Called exactly once, like [SftpHandle.close]. + Future close(); +} + +/// Base of the typed errors an [SftpFileSystem] throws to pick the wire +/// status code its failure is reported with. +class SftpFileSystemException implements Exception { + const SftpFileSystemException(this.message); + + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The path refers to a file that should exist but does not — +/// `SSH_FX_NO_SUCH_FILE` (2). +class SftpNoSuchFileException extends SftpFileSystemException { + const SftpNoSuchFileException(super.message); +} + +/// The operation is not permitted for the caller — +/// `SSH_FX_PERMISSION_DENIED` (3). +class SftpPermissionDeniedException extends SftpFileSystemException { + const SftpPermissionDeniedException(super.message); +} + +/// The path already exists where it must not — +/// `SSH_FX_FILE_ALREADY_EXISTS` (11). +class SftpFileExistsException extends SftpFileSystemException { + const SftpFileExistsException(super.message); +} diff --git a/client/packages/tp_sshd/lib/src/ssh_server.dart b/client/packages/tp_sshd/lib/src/ssh_server.dart index e81dc08da..b842b6f9a 100644 --- a/client/packages/tp_sshd/lib/src/ssh_server.dart +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -6,6 +6,7 @@ import 'package:dartssh2/protocol.dart'; import 'server_connection.dart'; import 'server_process.dart'; +import 'sftp_filesystem.dart'; /// The narrow negotiation surface advertised by tp_sshd servers (spec: /// x25519 KEX, ed25519 host keys, AEAD ciphers). @@ -31,6 +32,7 @@ class SSHServerConfig { this.processFactory, this.ptyFactory, this.hostInfo, + this.sftpFileSystem, this.printDebug, this.printTrace, }); @@ -74,6 +76,11 @@ class SSHServerConfig { /// `null` refuses the query; it is never answered by spawning a process. final SSHHostInfo Function()? hostInfo; + /// The filesystem the `sftp` subsystem serves. A `subsystem` request for + /// `sftp` is only served when this is configured; without it the request + /// is refused. + final SftpFileSystem? sftpFileSystem; + /// Function invoked with debug logging, mirroring [SSHSocket] transports. final void Function(String? message)? printDebug; diff --git a/client/packages/tp_sshd/lib/tp_sshd.dart b/client/packages/tp_sshd/lib/tp_sshd.dart index f55f00ef8..e61288b97 100644 --- a/client/packages/tp_sshd/lib/tp_sshd.dart +++ b/client/packages/tp_sshd/lib/tp_sshd.dart @@ -10,4 +10,6 @@ export 'src/server_channel.dart'; export 'src/server_connection.dart'; export 'src/server_process.dart'; export 'src/server_session.dart'; +export 'src/server_sftp.dart'; +export 'src/sftp_filesystem.dart'; export 'src/ssh_server.dart'; diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart index 22deae3ea..4ee4cbbdf 100644 --- a/client/packages/tp_sshd/test/dual_test_utils.dart +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -27,6 +27,7 @@ Future<(SSHClient, SSHServer)> startDualPair({ SSHProcessFactory? processFactory, SSHPtyFactory? ptyFactory, SSHHostInfo Function()? hostInfo, + SftpFileSystem? sftpFileSystem, }) async { final (clientSocket, serverSocket) = loopbackSSHSocketPair(); final connections = StreamController(); @@ -39,6 +40,7 @@ Future<(SSHClient, SSHServer)> startDualPair({ processFactory: processFactory, ptyFactory: ptyFactory, hostInfo: hostInfo, + sftpFileSystem: sftpFileSystem, ), ); connections.add(serverSocket); diff --git a/client/packages/tp_sshd/test/memory_sftp_filesystem.dart b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart new file mode 100644 index 000000000..9a2fdd61e --- /dev/null +++ b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart @@ -0,0 +1,282 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +/// In-memory [SftpFileSystem] for the dual SFTP tests: a tree of nodes keyed +/// by normalized absolute paths, with the root directory always present. +/// +/// The node model mirrors what the wire exercises: directories, file bytes, +/// and a modification time that every mutation refreshes. Errors are thrown +/// as the typed exceptions the SFTP server maps onto wire status codes. +class MemorySftpFileSystem implements SftpFileSystem { + final _nodes = {'/': _MemoryNode.directory()}; + + @override + Future stat(String path) async { + final node = _nodes[_normalize(path)]; + if (node == null) throw SftpNoSuchFileException(path); + return node.toAttrs(); + } + + @override + Future openDir(String path) async { + final node = _nodes[_normalize(path)]; + if (node == null) throw SftpNoSuchFileException(path); + if (!node.isDirectory) { + throw StateError('not a directory: $path'); + } + return _MemoryDirListing(_dirEntries(path)); + } + + @override + Future openFile( + String path, + SftpFileOpenMode mode, + SftpFileAttrs? attrs, + ) async { + final normalized = _normalize(path); + final parent = _nodes[_parentOf(normalized)]; + if (parent == null || !parent.isDirectory) { + throw SftpNoSuchFileException(path); + } + final node = _nodes[normalized]; + if (node != null) { + if (node.isDirectory) { + throw StateError('is a directory: $path'); + } + if (_hasFlag(mode, SftpFileOpenMode.exclusive)) { + throw SftpFileExistsException(path); + } + if (_hasFlag(mode, SftpFileOpenMode.truncate)) { + node.truncate(); + } + } else { + if (!_hasFlag(mode, SftpFileOpenMode.create)) { + throw SftpNoSuchFileException(path); + } + _nodes[normalized] = _MemoryNode.file(); + } + return _MemoryFileHandle( + _nodes[normalized]!, + append: _hasFlag(mode, SftpFileOpenMode.append), + ); + } + + @override + Future mkdir(String path, SftpFileAttrs attrs) async { + final normalized = _normalize(path); + if (normalized == '/') { + throw SftpFileExistsException(path); + } + final parent = _nodes[_parentOf(normalized)]; + if (parent == null || !parent.isDirectory) { + throw SftpNoSuchFileException(path); + } + if (_nodes.containsKey(normalized)) { + throw SftpFileExistsException(path); + } + _nodes[normalized] = _MemoryNode.directory(); + } + + @override + Future rmdir(String path) async { + final normalized = _normalize(path); + final node = _nodes[normalized]; + if (node == null) throw SftpNoSuchFileException(path); + if (!node.isDirectory) throw StateError('not a directory: $path'); + if (normalized == '/') throw StateError('cannot remove the root'); + if (_childrenOf(normalized).isNotEmpty) { + throw StateError('directory not empty: $path'); + } + _nodes.remove(normalized); + } + + @override + Future unlink(String path) async { + final normalized = _normalize(path); + final node = _nodes[normalized]; + if (node == null) throw SftpNoSuchFileException(path); + if (node.isDirectory) throw StateError('is a directory: $path'); + _nodes.remove(normalized); + } + + @override + Future rename(String from, String to) async { + final fromPath = _normalize(from); + final toPath = _normalize(to); + final node = _nodes[fromPath]; + if (node == null) throw SftpNoSuchFileException(from); + final parent = _nodes[_parentOf(toPath)]; + if (parent == null || !parent.isDirectory) { + throw SftpNoSuchFileException(to); + } + final existing = _nodes[toPath]; + if (existing != null && existing.isDirectory) { + throw StateError('cannot replace a directory: $to'); + } + _nodes.remove(fromPath); + _nodes[toPath] = node; + } + + @override + Future realpath(String path) async => _normalize(path); + + /// Resolves [path] the way a POSIX realpath would: collapses duplicate + /// slashes and `.` segments, resolves `..` against the parent (stopping at + /// the root), and drops trailing slashes. + String _normalize(String path) { + final segments = []; + for (final segment in path.split('/')) { + if (segment.isEmpty || segment == '.') continue; + if (segment == '..') { + if (segments.isNotEmpty) segments.removeLast(); + continue; + } + segments.add(segment); + } + if (segments.isEmpty) return '/'; + return '/${segments.join('/')}'; + } + + String _parentOf(String normalizedPath) { + final index = normalizedPath.lastIndexOf('/'); + return index <= 0 ? '/' : normalizedPath.substring(0, index); + } + + List _childrenOf(String normalizedPath) => _nodes.keys + .where( + (key) => key.startsWith('$normalizedPath/') && key != normalizedPath) + .toList(); + + List _dirEntries(String path) { + final normalized = _normalize(path); + final entries = [ + SftpName( + filename: '.', + longname: '.', + attr: _nodes[normalized]!.toAttrs(), + ), + SftpName( + filename: '..', + longname: '..', + attr: _nodes[_parentOf(normalized)]!.toAttrs(), + ), + ]; + for (final child in _childrenOf(normalized)..sort()) { + final node = _nodes[child]!; + final name = child.substring(child.lastIndexOf('/') + 1); + entries.add(SftpName( + filename: name, + longname: name, + attr: node.toAttrs(), + )); + } + return entries; + } + + static bool _hasFlag(SftpFileOpenMode mode, SftpFileOpenMode flag) => + (mode.flag & flag.flag) != 0; +} + +/// One file or directory in the [MemorySftpFileSystem] tree. +class _MemoryNode { + _MemoryNode.directory() + : isDirectory = true, + bytes = null; + + _MemoryNode.file() + : isDirectory = false, + bytes = Uint8List(0); + + final bool isDirectory; + + /// The file's bytes; always `null` for directories. + Uint8List? bytes; + + var _modifyTime = 1700000000; + + void truncate() { + bytes = Uint8List(0); + _touch(); + } + + void _touch() => _modifyTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + SftpFileAttrs toAttrs() => SftpFileAttrs( + size: isDirectory ? null : bytes!.length, + mode: SftpFileMode.value( + isDirectory ? 0x41ED : 0x81A4, // drwxr-xr-x / -rw-r--r-- + ), + accessTime: _modifyTime, + modifyTime: _modifyTime, + ); +} + +/// Open file handle over a [_MemoryNode]: explicit-offset reads and writes +/// against the node's byte buffer, with appends always landing at the end. +class _MemoryFileHandle extends SftpHandle { + _MemoryFileHandle(this._node, {required bool append}) : _append = append; + + final _MemoryNode _node; + final bool _append; + + @override + Future read(int offset, int length) async { + final bytes = _node.bytes!; + if (offset < 0 || length < 0) { + throw StateError('negative read: offset=$offset length=$length'); + } + if (offset >= bytes.length) return Uint8List(0); + final end = math.min(offset + length, bytes.length); + // Copy: the caller must not observe later writes through this buffer. + return Uint8List.fromList(bytes.sublist(offset, end)); + } + + @override + Future write(int offset, Uint8List data) async { + if (data.isEmpty) return; + if (_append) offset = _node.bytes!.length; + if (offset < 0) throw StateError('negative write offset: $offset'); + var bytes = _node.bytes!; + if (offset > bytes.length) { + // A write past EOF zero-fills the gap, like a POSIX pwrite. + final padded = Uint8List(offset + data.length); + padded.setRange(0, bytes.length, bytes); + bytes = padded; + } else if (offset + data.length != bytes.length) { + final grown = Uint8List(math.max(bytes.length, offset + data.length)); + grown.setRange(0, bytes.length, bytes); + bytes = grown; + } + bytes.setRange(offset, offset + data.length, data); + _node.bytes = bytes; + } + + @override + Future close() async { + // The node outlives the handle; nothing to release. + } +} + +/// One-shot directory listing: the first [read] returns every entry, the +/// next returns none (the SFTP server turns that into end-of-directory). +class _MemoryDirListing extends SftpDirListing { + _MemoryDirListing(this._entries); + + final List _entries; + var _read = false; + + @override + Future> read() async { + if (_read) return const []; + _read = true; + return _entries; + } + + @override + Future close() async { + // The listing is a snapshot; nothing to release. + } +} diff --git a/client/packages/tp_sshd/test/server_channel_test.dart b/client/packages/tp_sshd/test/server_channel_test.dart index f84006f07..79a881f35 100644 --- a/client/packages/tp_sshd/test/server_channel_test.dart +++ b/client/packages/tp_sshd/test/server_channel_test.dart @@ -287,8 +287,10 @@ void main() { final clientController = await openClientSessionChannel(client); // The connection wires every channel to handleSessionRequest, which // serves the structured exec grammar and the pty half (pty-req, env, - // shell, window-change, signal); a subsystem request is refused - // instead of left hanging. + // shell, window-change, signal) plus the sftp subsystem. This pair + // configures no sftpFileSystem, so the subsystem request is refused + // instead of left hanging; with one configured it is served (the + // server_sftp_test.dart dual tests cover that branch). final accepted = await clientController.sendSubsystem('sftp'); expect(accepted, isFalse); diff --git a/client/packages/tp_sshd/test/server_sftp_test.dart b/client/packages/tp_sshd/test/server_sftp_test.dart new file mode 100644 index 000000000..e3cd2be9a --- /dev/null +++ b/client/packages/tp_sshd/test/server_sftp_test.dart @@ -0,0 +1,95 @@ +@TestOn('vm') +library; + +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; +import 'memory_sftp_filesystem.dart'; + +void main() { + late MemorySftpFileSystem fs; + + setUp(() => fs = MemorySftpFileSystem()); + + Future<(SSHClient, SSHServer)> connect() => startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + sftpFileSystem: fs, + ); + + test('subsystem request for sftp is served when a filesystem is configured', + () async { + final (client, server) = await connect(); + final controller = await openClientSessionChannel(client); + expect(await controller.sendSubsystem('sftp'), isTrue); + + client.close(); + await server.close(); + }); + + test('subsystem request for an unknown name is refused', () async { + final (client, server) = await connect(); + final controller = await openClientSessionChannel(client); + expect(await controller.sendSubsystem('not-sftp'), isFalse); + + client.close(); + await server.close(); + }); + + test('mkdir / write / read / stat / list round trip', () async { + final (client, server) = await connect(); + final sftp = await client.sftp(); + await sftp.mkdir('/demo'); + final file = await sftp.open( + '/demo/hello.txt', + mode: SftpFileOpenMode.write | SftpFileOpenMode.create, + ); + await file.writeBytes(Uint8List.fromList('hello tp_sshd'.codeUnits)); + await file.close(); + + final attrs = await sftp.stat('/demo/hello.txt'); + expect(attrs.size, 13); + + final reader = await sftp.open('/demo/hello.txt'); + final readBack = await reader.readBytes(); + await reader.close(); + expect(String.fromCharCodes(readBack), 'hello tp_sshd'); + + final names = await sftp.listdir('/demo'); + expect(names.map((n) => n.filename), contains('hello.txt')); + client.close(); + await server.close(); + }); + + test('rename, remove, rmdir', () async { + final (client, server) = await connect(); + final sftp = await client.sftp(); + await sftp.mkdir('/a'); + final f = await sftp.open( + '/a/x', + mode: SftpFileOpenMode.write | SftpFileOpenMode.create, + ); + await f.writeBytes(Uint8List.fromList([1, 2, 3])); + await f.close(); + await sftp.rename('/a/x', '/a/y'); + await sftp.remove('/a/y'); + await sftp.rmdir('/a'); + await expectLater(sftp.stat('/a/y'), throwsA(anything)); + client.close(); + await server.close(); + }); + + test('missing file returns SSH_FX_NO_SUCH_FILE status, not a crash', + () async { + final (client, server) = await connect(); + final sftp = await client.sftp(); + await expectLater(sftp.stat('/nope'), throwsA(isA())); + client.close(); + await server.close(); + }); +} From 1a680a281d6305b7eeb30fb649ea532af99a04ac Mon Sep 17 00:00:00 2001 From: hhoao Date: Thu, 10 Sep 2026 11:45:26 +0800 Subject: [PATCH 08/14] fix: address task-8 review findings Co-Authored-By: Claude Opus 4.8 --- .../packages/tp_sshd/lib/src/server_sftp.dart | 100 +++++++++++++++++- .../tp_sshd/lib/src/sftp_filesystem.dart | 16 +++ .../tp_sshd/test/memory_sftp_filesystem.dart | 22 ++++ .../tp_sshd/test/server_sftp_test.dart | 32 ++++++ 4 files changed, 167 insertions(+), 3 deletions(-) diff --git a/client/packages/tp_sshd/lib/src/server_sftp.dart b/client/packages/tp_sshd/lib/src/server_sftp.dart index 0393c5985..801353c83 100644 --- a/client/packages/tp_sshd/lib/src/server_sftp.dart +++ b/client/packages/tp_sshd/lib/src/server_sftp.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:dartssh2/protocol.dart'; @@ -15,6 +17,22 @@ const _kSftpVersion = 3; /// is not counted. const _kMaxPacketLength = 256 * 1024; +/// Byte budget for a READDIR NAME packet: the encoded payload of the reply +/// (type + request id + count + the names) must stay at or under the +/// [_kMaxPacketLength] limit, because the client destroys the channel on +/// any larger incoming packet. The only way a single entry can exceed the +/// budget on its own is a filename on the order of 128 KiB, which cannot +/// have been created through this server (the client's own requests are +/// under the limit); such an entry would end the listing early rather than +/// kill the channel. +const _kReadDirMaxPacketLength = _kMaxPacketLength; + +/// Encoded NAME-packet overhead per entry, used to amortize the per-entry +/// size queries: a name costs its two strings (each a 4-byte length prefix +/// plus the UTF-8 bytes) and its attributes (a flags word, plus whichever +/// optional fields the attrs raise). +const _kNameOverhead = 12; + /// `SSH_FX_FILE_ALREADY_EXISTS`, the SFTPv3 code the fork's /// [SftpStatusCode] does not name (it stops at 8). const _sshFxFileAlreadyExists = 11; @@ -31,6 +49,13 @@ const _sshFxFileAlreadyExists = 11; /// chunk as a protocol error, so short regions must be answered with the /// bytes that exist and a following request with EOF. /// +/// READDIR replies are the one place where the server, not the client, +/// controls a packet's size: a filesystem may hand back a whole directory in +/// one batch, but a NAME packet must stay under the 256 KiB limit or the +/// client destroys the channel. Directory handles therefore buffer the +/// leftover names and serve successive READDIR requests bounded batches; +/// the client's read-until-EOF listdir loop is the protocol's own paging. +/// /// Requests are dispatched as they finish parsing (not one-at-a-time), since /// the client pipelines reads and writes; every reply is written in a single /// channel write, so interleaved replies cannot split a packet. @@ -264,12 +289,19 @@ class _SftpServerSession { } Future _handleReadDir(SftpReadDirPacket request) async { - final listing = _handles[_decodeHandle(request.handle)]?.listing; + final entry = _handles[_decodeHandle(request.handle)]; + final listing = entry?.listing; if (listing == null) { _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); return; } - final names = await listing.read(); + // Refill from the listing only once its previous batch is drained: a + // one-shot listing reports end-of-directory on the refill after its + // first (possibly whole-directory) batch. + if (entry!.residualNames.isEmpty) { + entry.residualNames.addAll(await listing.read()); + } + final names = entry.takeReadDirBatch(); if (names.isEmpty) { // The fork's client ends its listdir loop on an EOF status. _sendStatus(request.requestId, SftpStatusCode.eof, 'End of directory'); @@ -388,6 +420,20 @@ class _SftpServerSession { void _sendPacket(SftpPacket packet) { final payload = packet.encode(); + // The fork's client destroys the channel on any incoming packet over + // the limit, so shipping an over-limit reply would kill the session on + // the client's terms — fail it here instead. (READDIR replies are + // batched down by _handleReadDir; READ replies are bounded by the + // client's own request length, which is under the limit by + // construction.) + if (payload.length > _kMaxPacketLength) { + _channel.printDebug?.call( + 'tp_sshd: closing sftp subsystem: outgoing packet of ' + '${payload.length} bytes exceeds the $_kMaxPacketLength byte limit', + ); + _channel.close(); + return; + } final framed = BytesBuilder(copy: false) ..add(_lengthPrefix(payload.length)) ..add(payload); @@ -402,7 +448,9 @@ class _SftpServerSession { } /// One open handle: a file or a directory listing, both remembering the path -/// they were opened at (FSTAT answers from it). +/// they were opened at (FSTAT answers from it). Directory handles also carry +/// the names a listing batch produced but a single READDIR reply could not +/// fit under the packet limit; the next READDIR drains them first. class _SftpHandleEntry { _SftpHandleEntry.file(this.path, SftpHandle file) : file = file, @@ -421,6 +469,52 @@ class _SftpHandleEntry { /// The open directory listing; `null` for file handles. final SftpDirListing? listing; + /// Directory names already read out of [listing] but not yet sent, in + /// order. Empty for file handles and for drained directories. + final residualNames = Queue(); + + /// Removes and returns the longest prefix of [residualNames] whose NAME + /// packet stays within the READDIR byte budget. + List takeReadDirBatch() { + // type byte + request id + name count. + const headerLength = 1 + 4 + 4; + var length = headerLength; + final batch = []; + while (residualNames.isNotEmpty) { + final name = residualNames.first; + final nameLength = + _utf8LengthOf(name.filename) + _utf8LengthOf(name.longname); + final nextLength = + length + nameLength + _kNameOverhead + _encodedAttrsLength(name.attr); + if (nextLength > _kReadDirMaxPacketLength) break; + length = nextLength; + batch.add(residualNames.removeFirst()); + } + return batch; + } + Future close() => file?.close() ?? listing?.close() ?? Future.value(); } + +/// The length of [string] in UTF-8, the encoding the wire uses. +int _utf8LengthOf(String string) => utf8.encode(string).length; + +/// The encoded size of [attrs] on the wire: a 4-byte flags word plus +/// whichever optional fields their flag bits raise (size 8, uid/gid 4 + 4, +/// permissions 4, times 4 + 4, plus the extended-pairs block). +int _encodedAttrsLength(SftpFileAttrs attrs) { + var length = 4; + if (attrs.size != null) length += 8; + if (attrs.userID != null && attrs.groupID != null) length += 8; + if (attrs.mode != null) length += 4; + if (attrs.accessTime != null && attrs.modifyTime != null) length += 8; + final extended = attrs.extended; + if (extended != null) { + length += 4; + for (final pair in extended.entries) { + length += 4 + _utf8LengthOf(pair.key) + 4 + _utf8LengthOf(pair.value); + } + } + return length; +} diff --git a/client/packages/tp_sshd/lib/src/sftp_filesystem.dart b/client/packages/tp_sshd/lib/src/sftp_filesystem.dart index 66579138e..450e9bf36 100644 --- a/client/packages/tp_sshd/lib/src/sftp_filesystem.dart +++ b/client/packages/tp_sshd/lib/src/sftp_filesystem.dart @@ -10,6 +10,16 @@ import 'package:dartssh2/protocol.dart' /// normalization. Implementations report failures by throwing the typed /// [SftpFileSystemException] subclasses (mapped onto wire status codes) — /// anything else surfaces to the client as a generic `SSH_FX_FAILURE`. +/// +/// ## Concurrency +/// +/// The SFTP session dispatches requests as it parses them, without waiting +/// for earlier ones to finish: the fork's client pipelines reads and writes +/// (dozens of requests in flight at once), so any of these methods may run +/// concurrently with any other — including several operations on the same +/// path or the same handle. Implementations must not assume sequential or +/// ordered invocation; one that needs serialization (for example a +/// transactional store) has to do its own locking. abstract class SftpFileSystem { /// Attributes of the file or directory at [path]. Future stat(String path); @@ -61,6 +71,12 @@ abstract class SftpHandle { /// batch marks the end of the directory. abstract class SftpDirListing { /// The next batch of entries, or an empty list once exhausted. + /// + /// A batch may be of any size: the SFTP server re-batches whatever [read] + /// returns so every NAME packet it sends stays under the 256 KiB SFTP + /// packet limit (the client's READDIR-until-EOF loop is the protocol's own + /// paging). An implementation may therefore return a whole directory in + /// one batch and still be fully served. Future> read(); /// Releases the listing. Called exactly once, like [SftpHandle.close]. diff --git a/client/packages/tp_sshd/test/memory_sftp_filesystem.dart b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart index 9a2fdd61e..16b54a006 100644 --- a/client/packages/tp_sshd/test/memory_sftp_filesystem.dart +++ b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart @@ -13,6 +13,28 @@ import 'package:tp_sshd/tp_sshd.dart'; class MemorySftpFileSystem implements SftpFileSystem { final _nodes = {'/': _MemoryNode.directory()}; + /// Creates an empty regular file at [path] (parents must already exist), + /// for tests that need a directory with many entries without opening a + /// connection per file. + void createFile(String path) { + final normalized = _normalize(path); + final parent = _nodes[_parentOf(normalized)]!; + if (!parent.isDirectory) { + throw StateError('not a directory: $path'); + } + _nodes[normalized] = _MemoryNode.file(); + } + + /// Creates an empty directory at [path] (the parent must already exist). + void createDirectory(String path) { + final normalized = _normalize(path); + final parent = _nodes[_parentOf(normalized)]!; + if (!parent.isDirectory) { + throw StateError('not a directory: $path'); + } + _nodes[normalized] = _MemoryNode.directory(); + } + @override Future stat(String path) async { final node = _nodes[_normalize(path)]; diff --git a/client/packages/tp_sshd/test/server_sftp_test.dart b/client/packages/tp_sshd/test/server_sftp_test.dart index e3cd2be9a..6cb8d062e 100644 --- a/client/packages/tp_sshd/test/server_sftp_test.dart +++ b/client/packages/tp_sshd/test/server_sftp_test.dart @@ -92,4 +92,36 @@ void main() { client.close(); await server.close(); }); + + // The in-memory listing is one-shot and returns the whole directory in a + // single batch, so a directory big enough to encode over the 256 KiB SFTP + // packet limit forces the server to page: every NAME packet must stay + // under the limit (the fork's client destroys the channel otherwise), and + // the client's listdir loop must still see every entry across the batches + // until the EOF status ends it. + test( + 'large directory is served as multiple READDIR batches under the packet limit', + () async { + const entryCount = 6000; + fs.createDirectory('/big'); + for (var i = 0; i < entryCount; i++) { + fs.createFile('/big/dir-entry-$i'); + } + // The whole-directory NAME packet would be far over the limit; without + // paging the channel dies and listdir never completes. + final (client, server) = await connect(); + final sftp = await client.sftp(); + final names = await sftp.listdir('/big'); + // A set both checks membership cheaply and, compared against the raw + // count, proves no entry was served twice across the batches. + final filenames = names.map((n) => n.filename).toSet(); + expect(names.length, entryCount + 2); + expect(filenames.length, entryCount + 2); + expect(filenames, containsAll(const ['.', '..'])); + for (var i = 0; i < entryCount; i++) { + expect(filenames, contains('dir-entry-$i')); + } + client.close(); + await server.close(); + }); } From b12cc910473784fbbe07c62107c1a2abf2ec5b2b Mon Sep 17 00:00:00 2001 From: hhoao Date: Thu, 10 Sep 2026 13:22:07 +0800 Subject: [PATCH 09/14] feat(tp_sshd): loopback-only remote port forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tp_sshd/lib/src/server_connection.dart | 171 +++++++- .../tp_sshd/lib/src/server_forward.dart | 411 ++++++++++++++++++ .../packages/tp_sshd/lib/src/ssh_server.dart | 9 + client/packages/tp_sshd/lib/tp_sshd.dart | 1 + .../tp_sshd/test/dual_test_utils.dart | 4 + .../tp_sshd/test/server_forward_test.dart | 249 +++++++++++ 6 files changed, 835 insertions(+), 10 deletions(-) create mode 100644 client/packages/tp_sshd/lib/src/server_forward.dart create mode 100644 client/packages/tp_sshd/test/server_forward_test.dart diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart index 2876fc6f8..94c0b08da 100644 --- a/client/packages/tp_sshd/lib/src/server_connection.dart +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -5,6 +5,7 @@ import 'package:dartssh2/dartssh2.dart' show SSHSocket, SSHTransport; import 'package:dartssh2/protocol.dart'; import 'server_channel.dart'; +import 'server_forward.dart'; import 'server_session.dart'; import 'server_userauth.dart'; import 'ssh_server.dart' @@ -44,6 +45,17 @@ class SSHServerConnection { onMessage: _handleMessage, ); _authTimer = Timer(config.authTimeout, _onAuthTimeout); + final bindServerSocket = config.bindServerSocket; + if (bindServerSocket != null) { + _forwarder = SSHServerForwarder( + bindServerSocket: bindServerSocket, + openForwardedChannel: _openForwardedChannel, + sendPacket: _transport.sendPacket, + printDebug: config.printDebug, + ); + } else { + _forwarder = null; + } // The transport's done future completes with an error when the transport // is terminated by one; the connection only cares about the timing. _transport.done.whenComplete(_onTransportClosed).ignore(); @@ -56,12 +68,17 @@ class SSHServerConnection { late final SSHTransport _transport; late final Timer _authTimer; + late final SSHServerForwarder? _forwarder; var _phase = _Phase.auth; /// Open channels on this connection, keyed by the server-assigned channel /// number (the id the client addresses them by). final _channels = {}; + /// Server-initiated channel opens awaiting the client's verdict, keyed by + /// the channel number the open was sent with (see [_openServerChannel]). + final _pendingOpens = {}; + /// The next channel number to assign. A plain counter is enough: channel /// numbers are only reused after 2^32 opens. var _nextChannelNumber = 0; @@ -83,6 +100,7 @@ class SSHServerConnection { _authTimer.cancel(); _phase = _Phase.closed; _teardownChannels(); + await _forwarder?.close(); await _transport.close(); } @@ -142,6 +160,10 @@ class SSHServerConnection { case SSH_Message_Channel_Open.messageId: _handleChannelOpen(payload); return true; + case SSH_Message_Channel_Confirmation.messageId: + case SSH_Message_Channel_Open_Failure.messageId: + _handleChannelOpenReply(payload); + return true; case SSH_Message_Channel_Window_Adjust.messageId: case SSH_Message_Channel_Data.messageId: case SSH_Message_Channel_Extended_Data.messageId: @@ -155,9 +177,11 @@ class SSHServerConnection { } } - /// Answers global requests (RFC 4254 §4). Only keepalive is served; - /// `tcpip-forward`/`cancel-tcpip-forward` arrive with Task 9, so until - /// then everything else is refused. + /// Answers global requests (RFC 4254 §4). `keepalive` is acknowledged, + /// and `tcpip-forward` / `cancel-tcpip-forward` are handed to the + /// forwarder, which replies asynchronously once the injected bind settles. + /// Everything else — including forwarding when no bind seam is configured — + /// is refused. void _handleGlobalRequest(Uint8List payload) { final message = _decodeMessage( 'global request', @@ -165,23 +189,39 @@ class SSHServerConnection { payload, ); if (message == null) return; - if (!message.wantReply) return; - if (message.requestName == 'keepalive@openssh.com') { - _transport.sendPacket(SSH_Message_Request_Success(Uint8List(0)).encode()); - } else { + switch (message.requestName) { + case 'tcpip-forward': + case 'cancel-tcpip-forward': + final forwarder = _forwarder; + if (forwarder != null) { + unawaited(forwarder.handleGlobalRequest(message)); + return; + } + // No seam configured: fall through to the refusal below. + case 'keepalive@openssh.com': + if (message.wantReply) { + _transport.sendPacket( + SSH_Message_Request_Success(Uint8List(0)).encode(), + ); + } + return; + } + if (message.wantReply) { _transport.sendPacket(SSH_Message_Request_Failure().encode()); } } /// Serves CHANNEL_OPEN (RFC 4254 §5.1): `session` channels are confirmed /// with a fresh [SSHServerChannel]; every other type is refused with - /// "administratively prohibited" (forwarded channels arrive in Task 9). + /// "administratively prohibited" (server-initiated opens — the outbound + /// direction, used for `forwarded-tcpip` — go through + /// [_openServerChannel] instead). /// /// The refusal uses reason 1, `codeAdministrativelyProhibited`. The plan /// text says "reason 3 (admin prohibited)", but reason 3 is /// `codeUnknownChannelType` in both the fork's API and RFC 4254 §5.1, /// and it would be the wrong semantic here (the server recognizes - /// `direct-tcpip`, it just does not serve it yet); the named constant for + /// `direct-tcpip`, it just does not serve it); the named constant for /// the stated semantic wins per the controller ruling that real fork API /// names take precedence. void _handleChannelOpen(Uint8List payload) { @@ -298,6 +338,95 @@ class SSHServerConnection { SSHServerChannel? _channelOrNull(int ourChannel) => _channels[ourChannel]; + /// Serves the client's verdict on a server-initiated channel open + /// (RFC 4254 §5.1): a CHANNEL_OPEN_CONFIRMATION promotes the pending open + /// to a live channel; a CHANNEL_OPEN_FAILURE resolves it to `null`. + void _handleChannelOpenReply(Uint8List payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Channel_Confirmation.messageId: + final message = _decodeMessage( + 'channel confirmation', + SSH_Message_Channel_Confirmation.decode, + payload, + ); + if (message == null) return; + final pending = _pendingOpens.remove(message.recipientChannel); + if (pending == null) return; + // Register the channel synchronously before completing the open: + // CHANNEL_DATA may follow the confirmation in the same transport + // input, and the channel's single-subscription input buffers until + // its pump subscribes (mirroring the fork's own client-side accept + // path). + final channel = SSHServerChannel( + recipientChannel: message.senderChannel, + ourChannel: message.recipientChannel, + channelType: pending.channelType, + peerInitialWindowSize: message.initialWindowSize, + peerMaximumPacketSize: message.maximumPacketSize, + sendPacket: _transport.sendPacket, + onClosed: (channel) => _channels.remove(channel.ourChannel), + printDebug: _config.printDebug, + ); + _channels[channel.ourChannel] = channel; + pending.completer.complete(channel); + return; + case SSH_Message_Channel_Open_Failure.messageId: + final message = _decodeMessage( + 'channel open failure', + SSH_Message_Channel_Open_Failure.decode, + payload, + ); + if (message == null) return; + _pendingOpens + .remove(message.recipientChannel) + ?.completer + .complete(null); + return; + } + } + + /// Opens a server-initiated channel (RFC 4254 §5.1, the direction the + /// client-opened path does not cover): builds the open message through + /// [buildOpen] with the channel number this server allocates, sends it, + /// and completes with the live channel once the client confirms — or + /// `null` when the client refuses, or the connection ends before the + /// verdict arrives. + Future _openServerChannel( + SSH_Message_Channel_Open Function(int senderChannel) buildOpen, + ) { + if (_phase == _Phase.closed) return Future.value(null); + final ourChannel = _nextChannelNumber++; + final open = buildOpen(ourChannel); + final pending = _PendingOpen(open.channelType); + _pendingOpens[ourChannel] = pending; + _transport.sendPacket(open.encode()); + return pending.completer.future; + } + + /// Opens the `forwarded-tcpip` channel for one accepted forwarded + /// connection (RFC 4254 §7.2). The connected address is the host string + /// the client asked to forward — clients match remote forwards by that + /// string — with the port actually bound; the originator is the + /// connecting peer. + Future _openForwardedChannel({ + required String connectedAddress, + required int connectedPort, + required String originatorAddress, + required int originatorPort, + }) { + return _openServerChannel( + (senderChannel) => SSH_Message_Channel_Open.forwardedTcpip( + senderChannel: senderChannel, + initialWindowSize: SSHServerChannel.initialReceiveWindow, + maximumPacketSize: SSHServerChannel.maximumPacketSize, + host: connectedAddress, + port: connectedPort, + originatorIP: originatorAddress, + originatorPort: originatorPort, + ), + ); + } + /// Decodes [payload] with [decode], disconnecting the peer with a /// protocol error instead of answering when it is malformed. Returns /// `null` in that case (and after the disconnect, nowhere else). @@ -315,12 +444,18 @@ class SSHServerConnection { } /// Detaches every open channel without sending anything: the transport is - /// going away. + /// going away. Pending server-initiated opens can never be confirmed + /// after that either, so they resolve to `null` — their waiters (the + /// forwarder's accepted connections) let go instead of hanging. void _teardownChannels() { for (final channel in List.of(_channels.values)) { channel.detach(); } _channels.clear(); + for (final pending in _pendingOpens.values) { + pending.completer.complete(null); + } + _pendingOpens.clear(); } /// Handles one `SSH_Message_Userauth_Request` (RFC 4252). @@ -449,6 +584,9 @@ class SSHServerConnection { _authTimer.cancel(); _phase = _Phase.closed; _teardownChannels(); + // Release the binds too; the transport is already gone, so nothing can + // be replied to anymore and the release runs unwatched. + unawaited(_forwarder?.close()); } /// Sends a disconnect message and closes the connection. @@ -462,3 +600,16 @@ class SSHServerConnection { unawaited(close()); } } + +/// A server-initiated channel open awaiting the client's verdict. +class _PendingOpen { + _PendingOpen(this.channelType); + + /// The channel type that was opened; the confirmation does not carry it + /// back, so the pending open has to remember it. + final String channelType; + + /// Completes with the live channel on confirmation, or `null` when the + /// open is refused or the connection ends first. + final completer = Completer(); +} diff --git a/client/packages/tp_sshd/lib/src/server_forward.dart b/client/packages/tp_sshd/lib/src/server_forward.dart new file mode 100644 index 000000000..357758a3f --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_forward.dart @@ -0,0 +1,411 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart' + show + SSHMessageWriter, + SSH_Message_Global_Request, + SSH_Message_Request_Failure, + SSH_Message_Request_Success; + +import 'server_channel.dart'; + +/// Injection seam for loopback binds: the app passes a `ServerSocket.bind` +/// adapter, tests pass fakes (or a recording wrapper) to observe or refuse +/// binds without sockets. +typedef SSHBindServerSocket = Future Function( + InternetAddress address, int port); + +/// A bound loopback listener behind one accepted `tcpip-forward` request. +/// Implemented by the app over a real `ServerSocket`; faked in tests. +abstract class ServerSocketHandle { + /// The port actually bound — the ephemeral result when 0 was requested. + /// Carried back to the client in the Request_Success payload. + int get port; + + /// Connections accepted on the bound port. + Stream get connections; + + /// Stops listening. Must be safe to call more than once. + Future close(); +} + +/// One accepted TCP connection behind a forwarded port: its bytes ride a +/// `forwarded-tcpip` channel. Implemented by the app over a real [Socket]; +/// faked in tests. +abstract class ForwardConnection { + /// Bytes the connected peer sends; must close when the peer disconnects. + Stream get input; + + /// Bytes to deliver to the connected peer. + StreamSink> get output; + + /// Completes when the connection is over — the peer went away or the + /// socket was destroyed. + Future get done; + + /// The peer's address, reported as the `forwarded-tcpip` originator + /// address (RFC 4254 §7.2). Informational: clients match remote forwards + /// by the connected address and port, not the originator. + InternetAddress get remoteAddress; + + /// The peer's TCP port; see [remoteAddress]. + int get remotePort; + + /// Tears the connection down in both directions, dropping anything not + /// yet delivered. Must be safe to call more than once, and after [done]. + void destroy(); +} + +/// Opens the `forwarded-tcpip` channel for one accepted connection. Provided +/// by the connection — it owns the channel numbers, the channel table and +/// the wire. Completes with `null` when the client refuses the open or the +/// connection is gone before the verdict arrives. +typedef SSHForwardedChannelOpener = Future Function({ + required String connectedAddress, + required int connectedPort, + required String originatorAddress, + required int originatorPort, +}); + +/// Remote port forwarding for one SSH connection (RFC 4254 §7): serves +/// `tcpip-forward` / `cancel-tcpip-forward` global requests by binding +/// loopback ports through the injected [SSHBindServerSocket] seam, and pumps +/// every accepted connection over a server-initiated `forwarded-tcpip` +/// channel. +/// +/// Only loopback is ever bound — `127.0.0.1`, `::1` and `localhost` — and a +/// non-loopback request is refused before the seam is ever consulted. A bind +/// requested with port 0 gets an ephemeral port; the Request_Success payload +/// carries the port actually bound. Cancelling releases the listener (live +/// pumped connections keep running until they — or the connection — end), +/// and the connection's teardown releases every bind it holds. +class SSHServerForwarder { + SSHServerForwarder({ + required SSHBindServerSocket bindServerSocket, + required SSHForwardedChannelOpener openForwardedChannel, + required void Function(Uint8List payload) sendPacket, + this.printDebug, + }) : _bindServerSocket = bindServerSocket, + _openForwardedChannel = openForwardedChannel, + _sendPacket = sendPacket; + + final SSHBindServerSocket _bindServerSocket; + final SSHForwardedChannelOpener _openForwardedChannel; + final void Function(Uint8List payload) _sendPacket; + + /// Function invoked with debug logging, mirroring [SSHServerConfig]. + final void Function(String? message)? printDebug; + + /// The binds this connection owns, keyed by the bound address and the port + /// actually bound (which is what a cancel echoes back). + final _binds = {}; + + var _closed = false; + + /// Serves one forwarding global request. Replies itself — binding is + /// asynchronous, so the reply cannot come from the caller's synchronous + /// dispatch. + Future handleGlobalRequest(SSH_Message_Global_Request request) async { + if (_closed) return; + switch (request.requestName) { + case 'tcpip-forward': + await _handleTcpipForward(request); + case 'cancel-tcpip-forward': + await _handleCancelTcpipForward(request); + } + } + + /// Releases every bind this connection made. Live pumped connections are + /// closed through their channels: connection teardown detaches the + /// channels, and each pump destroys its TCP connection when that happens. + Future close() async { + if (_closed) return; + _closed = true; + final binds = List.of(_binds.values); + _binds.clear(); + for (final bind in binds) { + await _releaseBind(bind); + } + } + + Future _handleTcpipForward(SSH_Message_Global_Request request) async { + final requestedHost = request.bindAddress; + final requestedPort = request.bindPort; + if (requestedHost == null || requestedPort == null) { + _reply(request, success: false); + return; + } + + final bindAddress = _loopbackBindAddress(requestedHost); + if (bindAddress == null) { + printDebug?.call( + 'tp_sshd: refusing tcpip-forward for non-loopback address ' + "'$requestedHost'", + ); + _reply(request, success: false); + return; + } + + final ServerSocketHandle handle; + try { + handle = await _bindServerSocket(bindAddress, requestedPort); + } on Object { + // A bind that fails — port in use, seam unavailable — is a refused + // request, not a dead connection. + _reply(request, success: false); + return; + } + if (_closed) { + // The connection went away while the bind settled. + _discard(handle.close()); + return; + } + + final bind = _ForwardBind( + clientHost: requestedHost, + address: bindAddress, + port: handle.port, + handle: handle, + ); + // Register before replying, so a cancel that races the reply still finds + // the bind. + _binds[bind.key] = bind; + bind.listen((connection) { + unawaited(_serveAcceptedConnection(bind, connection)); + }); + _reply(request, success: true, boundPort: bind.port); + } + + Future _handleCancelTcpipForward( + SSH_Message_Global_Request request, + ) async { + final host = request.bindAddress; + final port = request.bindPort; + final bindAddress = + host == null || port == null ? null : _loopbackBindAddress(host); + if (bindAddress == null) { + // A cancel naming a non-loopback address names a bind this server + // could never have made. + _reply(request, success: false); + return; + } + final bind = _binds.remove('${bindAddress.address}:$port'); + if (bind == null) { + // A cancel naming a bind this connection does not hold — cancelled + // already, never made, another connection's — is refused, so a client + // can tell a released bind from a mistaken cancel. + _reply(request, success: false); + return; + } + await _releaseBind(bind); + _reply(request, success: true); + } + + /// Forwards one accepted connection over its own `forwarded-tcpip` + /// channel (RFC 4254 §7.2) and pumps it until either side ends. + Future _serveAcceptedConnection( + _ForwardBind bind, + ForwardConnection connection, + ) async { + if (_closed) { + connection.destroy(); + return; + } + final channel = await _openForwardedChannel( + // The connected address is the host string exactly as the client + // requested it: the fork's client matches remote forwards by that + // string, not by the interface actually bound underneath. + connectedAddress: bind.clientHost, + connectedPort: bind.port, + originatorAddress: connection.remoteAddress.address, + originatorPort: connection.remotePort, + ); + if (channel == null) { + // The client refused the open, or the connection ended first. + connection.destroy(); + return; + } + await _pump(channel, connection); + } + + /// Pumps one accepted connection against its channel until either side + /// ends: TCP bytes become channel data and back, the TCP side ending + /// closes the channel, and the channel ending destroys the TCP side. + Future _pump( + SSHServerChannel channel, + ForwardConnection connection, + ) { + final subscriptions = >[]; + final finished = Completer(); + var stopped = false; + void stop() { + if (stopped) return; + stopped = true; + for (final subscription in subscriptions) { + unawaited(subscription.cancel()); + } + if (!finished.isCompleted) finished.complete(); + } + + // TCP peer → client. + subscriptions.add( + connection.input.listen( + channel.write, + onError: (Object _) {}, + onDone: () { + // The TCP side is over: no more bytes can arrive to forward, so + // the channel finishes too. + channel.close(); + stop(); + }, + ), + ); + + // Client → TCP peer. + subscriptions.add( + channel.input.listen( + (data) { + try { + connection.output.add(data); + } on Object { + // The TCP side died mid-write; its own completion closes the + // channel. + } + }, + onError: (Object _) {}, + onDone: () { + // The client half-closed its channel: stop writing to the peer. + connection.output.close().then((_) {}, onError: (Object _) {}); + }, + ), + ); + + // The channel ended — the client closed it, the channel protocol + // failed, or the connection was torn down: the TCP connection has + // nowhere left to go. + channel.done.whenComplete(() { + connection.destroy(); + stop(); + }); + + // The TCP connection ended outright — failed, or destroyed from the + // channel side above: finish the channel if it is not already finishing + // itself. + connection.done.whenComplete(() { + channel.close(); + stop(); + }); + + return finished.future; + } + + /// Answers [request] with the global-request reply it asked for, carrying + /// [boundPort] on success. + void _reply( + SSH_Message_Global_Request request, { + required bool success, + int? boundPort, + }) { + if (!request.wantReply) return; + try { + _sendPacket( + success + ? SSH_Message_Request_Success( + _encodeUint32(boundPort ?? 0), + ).encode() + : SSH_Message_Request_Failure().encode(), + ); + } on Object { + // The transport went away while the request was being served; the + // connection's teardown owns the rest. + } + } + + /// Stops serving [bind] and releases its listener. + Future _releaseBind(_ForwardBind bind) async { + try { + await bind.subscription?.cancel(); + await bind.handle.close(); + } on Object { + printDebug?.call( + 'tp_sshd: releasing the forward bound to ${bind.address}:' + '${bind.port} failed', + ); + } + } + + /// Runs [future], discarding its outcome: cleanup must never surface as + /// an unhandled error on a connection that may already be gone. + void _discard(Future future) { + future.then((_) {}, onError: (Object _) {}); + } + + static Uint8List _encodeUint32(int value) { + final writer = SSHMessageWriter()..writeUint32(value); + return writer.takeBytes(); + } + + /// Maps a bind address a client asked for to the loopback interface to + /// bind, or `null` when it is not a loopback spelling. Only `127.0.0.1`, + /// `::1` and `localhost` qualify — the empty string, the wildcard + /// addresses and any other host are refused before the bind seam is ever + /// consulted. `localhost` binds IPv4 loopback, like a single-stack + /// resolver; DNS is never touched, so a name that happens to resolve to + /// loopback is still refused. + static InternetAddress? _loopbackBindAddress(String address) { + switch (address) { + case '127.0.0.1': + return InternetAddress.loopbackIPv4; + case '::1': + return InternetAddress.loopbackIPv6; + case 'localhost': + return InternetAddress.loopbackIPv4; + default: + return null; + } + } +} + +/// One live bind: the listener, the port it actually got, and the address +/// spellings the two protocol directions need. +class _ForwardBind { + _ForwardBind({ + required this.clientHost, + required this.address, + required this.port, + required this.handle, + }); + + /// The host exactly as the client requested it (`'127.0.0.1'`, + /// `'localhost'`, …). The `forwarded-tcpip` opens carry it verbatim, + /// because clients match remote forwards by the string they asked with. + final String clientHost; + + /// The loopback interface bound. + final InternetAddress address; + + /// The port actually bound. + final int port; + + /// The listener itself, through the injected seam. + final ServerSocketHandle handle; + + StreamSubscription? subscription; + + /// The registry key this bind is filed under. + String get key => '${address.address}:$port'; + + /// Starts accepting: every accepted connection is served on its own + /// forwarded-tcpip channel. + void listen(void Function(ForwardConnection connection) serve) { + subscription = handle.connections.listen( + serve, + onError: (Object _) {}, + // The listener is gone — cancelled here, or the seam closed it; + // there is nothing left to serve. + onDone: () {}, + ); + } +} diff --git a/client/packages/tp_sshd/lib/src/ssh_server.dart b/client/packages/tp_sshd/lib/src/ssh_server.dart index b842b6f9a..cdb72525c 100644 --- a/client/packages/tp_sshd/lib/src/ssh_server.dart +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -5,6 +5,7 @@ import 'package:dartssh2/dartssh2.dart' show SSHKeyPair, SSHSocket; import 'package:dartssh2/protocol.dart'; import 'server_connection.dart'; +import 'server_forward.dart'; import 'server_process.dart'; import 'sftp_filesystem.dart'; @@ -33,6 +34,7 @@ class SSHServerConfig { this.ptyFactory, this.hostInfo, this.sftpFileSystem, + this.bindServerSocket, this.printDebug, this.printTrace, }); @@ -81,6 +83,13 @@ class SSHServerConfig { /// is refused. final SftpFileSystem? sftpFileSystem; + /// The bind seam for remote port forwarding (`tcpip-forward`, RFC 4254 + /// §7). The app passes a `ServerSocket.bind` adapter; `null` disables + /// forwarding outright — every `tcpip-forward` request is refused. Only + /// loopback addresses are ever bound, and a non-loopback request is + /// refused before this seam is consulted. + final SSHBindServerSocket? bindServerSocket; + /// Function invoked with debug logging, mirroring [SSHSocket] transports. final void Function(String? message)? printDebug; diff --git a/client/packages/tp_sshd/lib/tp_sshd.dart b/client/packages/tp_sshd/lib/tp_sshd.dart index e61288b97..df9115038 100644 --- a/client/packages/tp_sshd/lib/tp_sshd.dart +++ b/client/packages/tp_sshd/lib/tp_sshd.dart @@ -8,6 +8,7 @@ export 'package:dartssh2/protocol.dart' SSH_Message_Channel_Request; export 'src/server_channel.dart'; export 'src/server_connection.dart'; +export 'src/server_forward.dart'; export 'src/server_process.dart'; export 'src/server_session.dart'; export 'src/server_sftp.dart'; diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart index 4ee4cbbdf..a7202a1b0 100644 --- a/client/packages/tp_sshd/test/dual_test_utils.dart +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -28,6 +28,7 @@ Future<(SSHClient, SSHServer)> startDualPair({ SSHPtyFactory? ptyFactory, SSHHostInfo Function()? hostInfo, SftpFileSystem? sftpFileSystem, + SSHBindServerSocket? bindServerSocket, }) async { final (clientSocket, serverSocket) = loopbackSSHSocketPair(); final connections = StreamController(); @@ -41,6 +42,7 @@ Future<(SSHClient, SSHServer)> startDualPair({ ptyFactory: ptyFactory, hostInfo: hostInfo, sftpFileSystem: sftpFileSystem, + bindServerSocket: bindServerSocket, ), ); connections.add(serverSocket); @@ -107,6 +109,7 @@ SSHClient _connectClient( /// authentication. Future<(SSHServerConnection, SSHTransport)> startRawAuthenticatedConnection({ void Function(Uint8List payload)? onServerMessage, + SSHBindServerSocket? bindServerSocket, }) async { final (clientSocket, serverSocket) = loopbackSSHSocketPair(); final connection = SSHServerConnection( @@ -115,6 +118,7 @@ Future<(SSHServerConnection, SSHTransport)> startRawAuthenticatedConnection({ hostKeyPair: testHostKey, expectedUsername: 'user', authenticate: (_) async => true, + bindServerSocket: bindServerSocket, ), ); final authenticated = Completer(); diff --git a/client/packages/tp_sshd/test/server_forward_test.dart b/client/packages/tp_sshd/test/server_forward_test.dart new file mode 100644 index 000000000..4e987aaa6 --- /dev/null +++ b/client/packages/tp_sshd/test/server_forward_test.dart @@ -0,0 +1,249 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/protocol.dart' + show + SSHMessage, + SSHMessageReader, + SSH_Message_Global_Request, + SSH_Message_Request_Failure, + SSH_Message_Request_Success; +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; + +void main() { + test('forwardRemote binds loopback and pumps bytes both ways', () async { + // Real loopback sockets — the one place unit tests touch the network + // stack, mirroring the fork's own test philosophy. + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + bindServerSocket: _bindRealLoopback, + ); + final forward = await client.forwardRemote(host: '127.0.0.1', port: 0); + // The client learned the actually bound port from the Request_Success + // payload (port 0 — an ephemeral port — was requested). + expect(forward!.host, '127.0.0.1'); + expect(forward.port, greaterThan(0)); + + // Something on the server host connects to the bound loopback port; + // bytes must ride a forwarded-tcpip channel to the client side, and + // replies must ride it back. + final clientReceived = StringBuffer(); + final pingReceived = Completer(); + forward.connections.listen((channel) { + utf8.decoder.bind(channel.stream).listen((data) { + clientReceived.write(data); + if (!pingReceived.isCompleted && + clientReceived.toString().contains('ping')) { + pingReceived.complete(); + channel.sink.add(utf8.encode('pong')); + } + }); + }); + final probe = await Socket.connect('127.0.0.1', forward.port); + final probeReceived = StringBuffer(); + final pongReceived = Completer(); + probe.listen((data) { + probeReceived.write(utf8.decode(data)); + if (!pongReceived.isCompleted && + probeReceived.toString().contains('pong')) { + pongReceived.complete(); + } + }); + probe.add(utf8.encode('ping')); + + await pingReceived.future; + await pongReceived.future; + + probe.destroy(); + await forward.close(); // cancel-tcpip-forward + await client.close(); + await server.close(); + }); + + test('non-loopback bind requests are refused without consulting the seam', + () async { + // A permissive seam that records every call: the loopback-only rule must + // refuse the request before the seam is ever invoked. + final seamAddresses = []; + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + bindServerSocket: (address, port) async { + seamAddresses.add(address.address); + return _RealServerSocketHandle(await ServerSocket.bind(address, port)); + }, + ); + // '' (the client's "all interfaces" default), the wildcard addresses, + // another interface and any other host are all refused. + for (final host in ['', '0.0.0.0', '::', '192.168.1.10', 'example.com']) { + expect( + await client.forwardRemote(host: host, port: 0), + isNull, + reason: 'tcpip-forward for $host should be refused', + ); + } + expect(seamAddresses, isEmpty); + + await client.close(); + await server.close(); + }); + + test('cancel-tcpip-forward releases the bind', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + bindServerSocket: _bindRealLoopback, + ); + final forward = await client.forwardRemote(host: '127.0.0.1', port: 0); + final port = forward!.port; + + final cancelled = await client.cancelForwardRemote(forward); + expect(cancelled, isTrue); + + // The released loopback port refuses connections. + await expectLater( + Socket.connect('127.0.0.1', port), + throwsA(isA()), + ); + + await client.close(); + await server.close(); + }); + + test('connection close releases everything that connection bound', () async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + bindServerSocket: _bindRealLoopback, + ); + // 'localhost' is a loopback spelling too: bound on IPv4 loopback. + final forward = await client.forwardRemote(host: 'localhost', port: 0); + final port = forward!.port; + + // Closing the server side tears the connection — and with it every bind + // the connection made — down to the last socket. + await server.close(); + await client.close(); + + await expectLater( + Socket.connect('127.0.0.1', port), + throwsA(isA()), + ); + }); + + test('tcpip-forward replies with the bound port; unknown cancels are refused', + () async { + var successes = 0; + var failures = 0; + int? boundPort; + final (connection, client) = await startRawAuthenticatedConnection( + bindServerSocket: _bindRealLoopback, + onServerMessage: (payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Request_Success.messageId: + successes += 1; + if (boundPort == null) { + boundPort = SSHMessageReader( + SSH_Message_Request_Success.decode(payload).requestData, + ).readUint32(); + } + case SSH_Message_Request_Failure.messageId: + failures += 1; + } + }, + ); + + // Bind an ephemeral loopback port; the success payload carries the port + // that was actually bound. + client.sendPacket( + SSH_Message_Global_Request.tcpipForward('127.0.0.1', 0).encode(), + ); + await waitUntil(() => successes == 1); + expect(boundPort, greaterThan(0)); + + // Cancelling the live bind succeeds... + client.sendPacket( + SSH_Message_Global_Request.cancelTcpipForward( + bindAddress: '127.0.0.1', + bindPort: boundPort!, + ).encode(), + ); + await waitUntil(() => successes == 2); + + // ...while a cancel naming a bind the server no longer holds is refused + // (a deliberate, testable policy: an unknown cancel is a Failure, so a + // client can tell a released bind from a mistaken one). + client.sendPacket( + SSH_Message_Global_Request.cancelTcpipForward( + bindAddress: '127.0.0.1', + bindPort: boundPort!, + ).encode(), + ); + await waitUntil(() => failures == 1); + + await connection.close(); + client.close(); + }); +} + +/// Binds a real loopback [ServerSocket] through the seam. +Future _bindRealLoopback( + InternetAddress address, + int port, +) async => + _RealServerSocketHandle(await ServerSocket.bind(address, port)); + +/// Adapts a real [ServerSocket] to [ServerSocketHandle]. +class _RealServerSocketHandle implements ServerSocketHandle { + _RealServerSocketHandle(this._socket); + + final ServerSocket _socket; + + @override + int get port => _socket.port; + + @override + Stream get connections => + _socket.map(_RealForwardConnection.new); + + @override + Future close() => _socket.close(); +} + +/// Adapts a real [Socket] to [ForwardConnection]. +class _RealForwardConnection implements ForwardConnection { + _RealForwardConnection(this._socket); + + final Socket _socket; + + @override + Stream get input => _socket; + + @override + StreamSink> get output => _socket; + + @override + Future get done => _socket.done; + + @override + InternetAddress get remoteAddress => _socket.remoteAddress; + + @override + int get remotePort => _socket.remotePort; + + @override + void destroy() => _socket.destroy(); +} From ba49a802e6a12af42318b9e1b3cbde6db8e52b60 Mon Sep 17 00:00:00 2001 From: hhoao Date: Thu, 10 Sep 2026 13:48:43 +0800 Subject: [PATCH 10/14] feat(tp_sshd): hardening sweep and README 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 --- client/packages/tp_sshd/README.md | 53 +++++ .../tp_sshd/lib/src/server_channel.dart | 64 +++++- .../tp_sshd/lib/src/server_connection.dart | 22 ++ .../tp_sshd/lib/src/server_session.dart | 31 ++- .../packages/tp_sshd/lib/src/ssh_server.dart | 39 +++- .../tp_sshd/test/dual_test_utils.dart | 44 ++++ .../tp_sshd/test/server_channel_test.dart | 149 +++++++++++++ .../tp_sshd/test/server_exec_test.dart | 149 +++++++++++++ .../tp_sshd/test/server_forward_test.dart | 22 ++ .../tp_sshd/test/server_handshake_test.dart | 31 +++ .../tp_sshd/test/server_hardening_test.dart | 211 ++++++++++++++++++ .../tp_sshd/test/server_userauth_test.dart | 45 +--- 12 files changed, 802 insertions(+), 58 deletions(-) create mode 100644 client/packages/tp_sshd/README.md create mode 100644 client/packages/tp_sshd/test/server_hardening_test.dart diff --git a/client/packages/tp_sshd/README.md b/client/packages/tp_sshd/README.md new file mode 100644 index 000000000..98b2c2ee8 --- /dev/null +++ b/client/packages/tp_sshd/README.md @@ -0,0 +1,53 @@ +# tp_sshd + +Pure-Dart SSH server for TeamPilot's embedded desktop server. Built on the +dartssh2 protocol primitives ([../dartssh2](../dartssh2) `protocol.dart`). + +## Surface + +- **Auth:** publickey only (ssh-ed25519 device keys), fail-closed, 6-attempt + throttle, 30 s pre-auth timeout. The trust decision is always the + embedder's `authenticate` callback; the signature is always verified first. +- **Channels:** session channels (structured `tp1:` exec + interactive + shell/pty + the `sftp` subsystem) and remote port forwarding + (`tcpip-forward`, loopback binds only). At most 10 simultaneously open + channels per connection (OpenSSH's default; excess opens are refused with + reason 4, resource shortage). +- **KEX:** curve25519-sha256, ed25519 host keys, chacha20-poly1305 / + aes256-gcm, strict KEX (RFC 9142). Peer-initiated mid-session rekey is + supported (verified against the fork's `SSHClient.rekey()`). + +## Usage + +```dart +final server = await SSHServer.bind( + connectionIterator, + config: SSHServerConfig(hostKeyPair: hostKey, authenticate: ...), +); +``` + +`SSHServer` does not own listening: `bind` consumes already-accepted +`SSHSocket`s from a `StreamIterator`, so the embedder decides whether they +come from a real `ServerSocket` (dart:io), a WebSocket bridge, or an +in-memory test pair. A stream error is contained — it surfaces on +`SSHServer.done` after every live connection has been torn down, instead of +escaping as an unhandled zone error. + +Everything the server executes is injected: + +| Seam | Serves | +|------|--------| +| `processFactory` | structured `exec` requests (the `tp1:` argv/cwd/env grammar; no shell ever sees a command line) | +| `ptyFactory` | interactive `shell` requests (a prior `pty-req` is required) | +| `hostInfo` | the `tp1:` host-info query (answered by the server, never by spawning) | +| `sftpFileSystem` | the `sftp` subsystem (SFTPv3 over an injected filesystem) | +| `bindServerSocket` | `tcpip-forward` binds (loopback addresses only; unset refuses forwarding outright) | + +An unset seam refuses its surface — nothing falls back to running real +commands or binding real sockets. The one bounded wait in the package: +`SSHServerChannel.close` gives data still queued for the client's window +credit two seconds to flush before dropping the tail. + +See [test/dual_test_utils.dart](test/dual_test_utils.dart) for a complete +wiring example (in-memory socket pairs, a real `SSHClient` on the other end, +and raw-transport harnesses for protocol-level traffic). diff --git a/client/packages/tp_sshd/lib/src/server_channel.dart b/client/packages/tp_sshd/lib/src/server_channel.dart index 54d7a5df7..6504c2b66 100644 --- a/client/packages/tp_sshd/lib/src/server_channel.dart +++ b/client/packages/tp_sshd/lib/src/server_channel.dart @@ -34,6 +34,7 @@ class SSHServerChannel { required int peerMaximumPacketSize, required void Function(Uint8List payload) sendPacket, required void Function(SSHServerChannel channel) onClosed, + this.closeFlushTimeout = const Duration(seconds: 2), this.printDebug, }) : _sendWindow = peerInitialWindowSize, // A peer advertising a zero maximum packet size could never be sent @@ -56,6 +57,13 @@ class SSHServerChannel { /// The channel type as requested by the client (`'session'`). final String channelType; + /// How long [close] waits for the client to grant the window credit that + /// data still queued for it needs, before the tail is dropped and the + /// channel finished anyway. Two seconds: generous for a healthy peer + /// granting window, short enough not to hold channels open against a + /// stalled one. + final Duration closeFlushTimeout; + final void Function(Uint8List payload) _sendPacket; final void Function(SSHServerChannel channel) _onClosed; final void Function(String? message)? printDebug; @@ -86,6 +94,14 @@ class SSHServerChannel { var _receivedEof = false; var _sentClose = false; + /// [close] was called while data was still queued for window credit: the + /// channel finishes itself once the queue drains, or when the bounded wait + /// in [closeFlushTimeout] gives up on the client's window. + var _closePending = false; + + /// The give-up timer behind a pending close. + Timer? _closeFlushTimer; + /// Data the client sends on this channel. Stream get input => _input.stream; @@ -147,14 +163,36 @@ class SSHServerChannel { /// Closes the channel: sends EOF (after flushing what the client's window /// allows) and then CHANNEL_CLOSE, finishing the channel from our side. - /// Data still waiting for window credit is dropped. + /// + /// Data still queued for window credit gets a bounded chance to go out: + /// [closeFlushTimeout] for the client to grant the credit the tail needs, + /// after which the channel finishes anyway and the rest is dropped. A + /// window that opens in time flushes the whole queue first, so an exec's + /// output tail is not truncated by a merely slow peer. void close() { - if (isClosed) return; + if (isClosed || _closePending) return; _flushOutgoing(); - if (!_sentEof) { - _sendEof(); + if (_outgoing.isEmpty) { + if (!_sentEof) { + _sendEof(); + } + _finish(); + return; } - _finish(); + _closePending = true; + _closeFlushTimer = Timer(closeFlushTimeout, () { + _closeFlushTimer = null; + if (isClosed) return; + printDebug?.call( + 'tp_sshd: dropping ${_outgoing.length} queued outgoing chunks on ' + 'channel $ourChannel after the close flush bound ' + '($closeFlushTimeout) expired', + ); + if (!_sentEof) { + _sendEof(); + } + _finish(); + }); } /// Applies a window adjustment from the client: grows the send window and @@ -231,13 +269,15 @@ class SSHServerChannel { /// transport is gone. Called by [SSHServerConnection], not by embedders. void detach() { if (isClosed) return; + _closeFlushTimer?.cancel(); + _closeFlushTimer = null; _outgoing.clear(); _closeInputStreams(); _done.complete(); } void _enqueueOutgoing(Uint8List data, int? dataTypeCode) { - if (isClosed || _sentEof || _eofPending) { + if (isClosed || _sentEof || _eofPending || _closePending) { printDebug?.call( 'tp_sshd: dropping ${data.length} outgoing bytes on channel ' '$ourChannel after EOF/close', @@ -293,6 +333,16 @@ class SSHServerChannel { if (_eofPending && !_sentEof) { _sendEof(); } + // A close waiting on this queue draining has just been satisfied: the + // whole tail went out, so the channel can finish cleanly. + if (_closePending && _outgoing.isEmpty && !isClosed) { + _closeFlushTimer?.cancel(); + _closeFlushTimer = null; + if (!_sentEof) { + _sendEof(); + } + _finish(); + } } void _sendEof() { @@ -357,6 +407,8 @@ class SSHServerChannel { /// locally. Data still queued for the client's window is dropped. void _finish() { if (isClosed) return; + _closeFlushTimer?.cancel(); + _closeFlushTimer = null; if (!_sentClose) { _sentClose = true; _sendPacket( diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart index 94c0b08da..f8040a19c 100644 --- a/client/packages/tp_sshd/lib/src/server_connection.dart +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -244,6 +244,22 @@ class SSHServerConnection { return; } + // The per-connection channel cap (OpenSSH's default is 10): without it, + // one connection could pin unbounded channel state on the server. Excess + // opens are refused with reason 4, resource shortage (RFC 4254 §5.1's + // "channel resource shortage" case). + if (_channels.length >= _config.maxChannels) { + _transport.sendPacket( + SSH_Message_Channel_Open_Failure( + recipientChannel: message.senderChannel, + reasonCode: SSH_Message_Channel_Open_Failure.codeResourceShortage, + description: 'Too many open channels (${_channels.length}/' + '${_config.maxChannels})', + ).encode(), + ); + return; + } + final ourChannel = _nextChannelNumber++; final channel = SSHServerChannel( recipientChannel: message.senderChannel, @@ -395,6 +411,12 @@ class SSHServerConnection { SSH_Message_Channel_Open Function(int senderChannel) buildOpen, ) { if (_phase == _Phase.closed) return Future.value(null); + // The same per-connection cap bounds the server-initiated direction (the + // forwarder's `forwarded-tcpip` opens): at the cap the open is not even + // attempted, so the channel table cannot be blown through the back door. + if (_channels.length + _pendingOpens.length >= _config.maxChannels) { + return Future.value(null); + } final ourChannel = _nextChannelNumber++; final open = buildOpen(ourChannel); final pending = _PendingOpen(open.channelType); diff --git a/client/packages/tp_sshd/lib/src/server_session.dart b/client/packages/tp_sshd/lib/src/server_session.dart index 205592141..3b775a487 100644 --- a/client/packages/tp_sshd/lib/src/server_session.dart +++ b/client/packages/tp_sshd/lib/src/server_session.dart @@ -317,11 +317,25 @@ void _pipeProcess(SSHServerChannel channel, SSHServerProcess process) { final subscriptions = >[ drain(process.stdout, channel.write, stdoutDone), drain(process.stderr, channel.writeExtended, stderrDone), - channel.input.listen( - process.stdin.add, - onDone: () => unawaited(process.stdin.close()), - ), ]; + StreamSubscription? inputSubscription; + inputSubscription = channel.input.listen( + (data) { + try { + process.stdin.add(data); + } on Object { + // The process's stdin contract broke mid-write (a pipe the process + // already tore down, a sink that fails closed): treat it as the + // input side ending — stop forwarding input to it and release the + // pipe — instead of letting the error escape into the stream + // listener's zone. + inputSubscription?.cancel(); + unawaited(process.stdin.close().catchError((_) {})); + } + }, + onDone: () => unawaited(process.stdin.close().catchError((_) {})), + ); + subscriptions.add(inputSubscription); var tornDown = false; void teardown() { @@ -335,7 +349,7 @@ void _pipeProcess(SSHServerChannel channel, SSHServerProcess process) { // pipes a cancelled subscription will never finish. if (!stdoutDone.isCompleted) stdoutDone.complete(); if (!stderrDone.isCompleted) stderrDone.complete(); - unawaited(process.stdin.close()); + unawaited(process.stdin.close().catchError((_) {})); } channel.done.whenComplete(teardown); @@ -348,6 +362,13 @@ void _pipeProcess(SSHServerChannel channel, SSHServerProcess process) { await stderrDone.future; channel.sendExitStatus(exitCode); channel.close(); + }).catchError((Object _) { + // A process contract that errors instead of exiting is process death + // all the same: tear the pipes down and finish the channel without an + // exit status, rather than orphaning it on a future that never + // settles. + teardown(); + channel.close(); }), ); } diff --git a/client/packages/tp_sshd/lib/src/ssh_server.dart b/client/packages/tp_sshd/lib/src/ssh_server.dart index cdb72525c..96430a1b7 100644 --- a/client/packages/tp_sshd/lib/src/ssh_server.dart +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -30,6 +30,7 @@ class SSHServerConfig { required this.authenticate, this.authTimeout = const Duration(seconds: 30), this.maxAuthAttempts = 6, + this.maxChannels = 10, this.processFactory, this.ptyFactory, this.hostInfo, @@ -63,6 +64,11 @@ class SSHServerConfig { /// server disconnects it. final int maxAuthAttempts; + /// How many channels may be open on one connection at the same time + /// (OpenSSH's default is 10 per session). A CHANNEL_OPEN beyond the cap is + /// refused with reason 4, `resource shortage`, instead of confirmed. + final int maxChannels; + /// Spawns the process backing a structured `exec` request (see /// [TpExecCodec]). Receives the decoded argv, working directory and /// environment; a `null` return — or an unconfigured factory — refuses the @@ -132,6 +138,8 @@ class SSHServer { /// Live connections, in acceptance order. final _connections = {}; + final _done = Completer(); + /// The iterator [bind] is draining; kept so [close] can stop the loop. StreamIterator? _connectionsIterator; @@ -159,10 +167,35 @@ class SSHServer { /// Number of connections the server is currently serving. int get activeConnections => _connections.length; + /// Completes when the server stops accepting. + /// + /// Normally that is [close] or the connection stream ending; if the stream + /// itself errors, this completes with that error instead — after every live + /// connection has been torn down. (The error is marked handled when it is + /// raised, so a caller that never awaits [done] cannot turn a contained + /// stream failure into an unhandled zone error; awaiting still sees it.) + Future get done => _done.future; + Future _acceptConnections(StreamIterator connections) async { - while (await connections.moveNext()) { - if (_isClosed) break; - _spawnConnection(connections.current); + try { + while (await connections.moveNext()) { + if (_isClosed) break; + _spawnConnection(connections.current); + } + _done.complete(); + } on Object catch (error, stackTrace) { + // The connection stream itself died (the embedder's accept source + // broke). That must not surface as an unhandled error in whoever's zone + // happens to be around, and it must not leave live connections hanging + // off a dead listener: stop accepting and tear them all down. + _isClosed = true; + for (final connection in List.of(_connections)) { + unawaited(connection.close()); + } + _done.completeError(error, stackTrace); + // Nobody has to await [done]; a dropped error would surface as an + // unhandled zone error, which is exactly what this containment is for. + _done.future.catchError((_) {}); } } diff --git a/client/packages/tp_sshd/test/dual_test_utils.dart b/client/packages/tp_sshd/test/dual_test_utils.dart index a7202a1b0..ec3684a16 100644 --- a/client/packages/tp_sshd/test/dual_test_utils.dart +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -162,6 +162,50 @@ Future<(SSHServerConnection, SSHTransport)> startRawAuthenticatedConnection({ return (connection, client); } +/// A probing publickey userauth request (RFC 4252 §7, `boolean FALSE`) for +/// the test device key: a well-formed attempt that never verifies as +/// trusted, for driving the auth-failure path. +SSH_Message_Userauth_Request testProbeRequest() { + return SSH_Message_Userauth_Request.publicKey( + username: 'user', + publicKeyAlgorithm: 'ssh-ed25519', + publicKey: testDeviceKey.toPublicKey().encode(), + signature: null, + ); +} + +/// Starts an [SSHServer] plus a raw client-side [SSHTransport], so tests can +/// inject hand-crafted traffic a real [SSHClient] would never send. +/// +/// [onReady] runs once the client-side key exchange completes; +/// [onServerMessage] sees everything the server sends back (consumed by +/// default). +Future<(SSHServer, SSHTransport)> startRawPair({ + required Future Function(SSHServerAuthRequest request) authenticate, + required void Function(SSHTransport client) onReady, + bool Function(Uint8List payload)? onServerMessage, +}) async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: authenticate, + ), + ); + connections.add(serverSocket); + late final SSHTransport client; + client = SSHTransport( + clientSocket, + onVerifyHostKey: (_, __) => true, + onReady: () => onReady(client), + onMessage: onServerMessage ?? (_) => true, + ); + return (server, client); +} + /// Polls [condition] every 5 ms until it holds, or fails after [timeout]. /// /// For awaiting delivery over the in-memory socket pair, where the only diff --git a/client/packages/tp_sshd/test/server_channel_test.dart b/client/packages/tp_sshd/test/server_channel_test.dart index 79a881f35..fd4250959 100644 --- a/client/packages/tp_sshd/test/server_channel_test.dart +++ b/client/packages/tp_sshd/test/server_channel_test.dart @@ -62,6 +62,42 @@ void main() { await client.close(); }); + test('channel opens beyond the per-connection cap are refused', () async { + final (client, connection) = await startDualConnection( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + ); + // The default cap is 10, OpenSSH's own: the first ten session channels + // confirm... + for (var i = 0; i < 10; i++) { + await openClientSessionChannel(client); + } + expect(connection.channels.length, 10); + + // ...and the eleventh is refused with reason 4, resource shortage, so one + // connection cannot pin unbounded channel state on the server. The wire + // value is pinned alongside the constant, like the admin-prohibited test + // above. + await expectLater( + openClientSessionChannel(client), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + SSH_Message_Channel_Open_Failure.codeResourceShortage, + ) + .having((error) => error.code, 'wire value', 4), + ), + ); + // The refused open left the channel table untouched. + expect(connection.channels.length, 10); + + await connection.close(); + await client.close(); + }); + test('keepalive global request is answered', () async { final (client, server) = await startDualPair( hostKeyPair: testHostKey, @@ -366,6 +402,119 @@ void main() { client.close(); }); + test('close flushes queued window-credit data before finishing', () async { + final dataLengths = []; + var eofSeen = false; + var closeSeen = false; + final opened = Completer(); + final (connection, client) = await startRawAuthenticatedConnection( + onServerMessage: (payload) { + switch (SSHMessage.readMessageId(payload)) { + case SSH_Message_Channel_Confirmation.messageId: + if (!opened.isCompleted) opened.complete(); + case SSH_Message_Channel_Data.messageId: + dataLengths + .add(SSH_Message_Channel_Data.decode(payload).data.length); + case SSH_Message_Channel_EOF.messageId: + eofSeen = true; + case SSH_Message_Channel_Close.messageId: + closeSeen = true; + } + }, + ); + + // A 10-byte window: a real client never offers this, which is exactly + // what makes the close-time tail observable. + client.sendPacket( + SSH_Message_Channel_Open.session( + senderChannel: 5, + initialWindowSize: 10, + maximumPacketSize: 32768, + ).encode(), + ); + await opened.future; + final SSHServerChannel channel = connection.channels.values.single; + + // 25 bytes against that window: 10 go out immediately, the 15-byte tail + // queues for credit. + channel.write(Uint8List.fromList(List.generate(25, (i) => i))); + await waitUntil(() => dataLengths.length == 1); + expect(dataLengths, [10]); + + channel.close(); + await Future.delayed(const Duration(milliseconds: 100)); + // The close is waiting on the tail instead of dropping it: no EOF, no + // CHANNEL_CLOSE, and the channel is still live server-side. + expect(eofSeen, isFalse); + expect(closeSeen, isFalse); + expect(channel.isClosed, isFalse); + + // The client grants the window: the whole tail flushes, and only then + // do EOF and CHANNEL_CLOSE follow — the payload is delivered in full. + client.sendPacket( + SSH_Message_Channel_Window_Adjust( + recipientChannel: channel.ourChannel, + bytesToAdd: 15, + ).encode(), + ); + await waitUntil(() => channel.isClosed); + expect(dataLengths, [10, 15]); + expect(eofSeen, isTrue); + expect(closeSeen, isTrue); + expect(connection.channels, isEmpty); + + await connection.close(); + client.close(); + }); + + test('a window that never opens gives up after the close flush bound', + () async { + // Directly constructed, like the channel the connection would build + // for a 4-byte-window peer — but with a short flush bound, so the + // fallback is observable without waiting the product default. + final sentIds = []; + var sentDataBytes = 0; + SSHServerChannel? closedChannel; + final channel = SSHServerChannel( + recipientChannel: 9, + ourChannel: 0, + channelType: 'session', + peerInitialWindowSize: 4, + peerMaximumPacketSize: 32768, + closeFlushTimeout: const Duration(milliseconds: 50), + sendPacket: (payload) { + sentIds.add(SSHMessage.readMessageId(payload)); + if (SSHMessage.readMessageId(payload) == + SSH_Message_Channel_Data.messageId) { + sentDataBytes += + SSH_Message_Channel_Data.decode(payload).data.length; + } + }, + onClosed: (channel) => closedChannel = channel, + ); + + // 10 bytes against the 4-byte window: 4 go out, 6 stall. + channel.write(Uint8List.fromList(List.generate(10, (i) => i))); + channel.close(); + expect(sentIds, [SSH_Message_Channel_Data.messageId]); + expect(sentDataBytes, 4); + + // No window adjustment ever arrives. After the bound the channel + // finishes anyway — EOF and CHANNEL_CLOSE are sent, and the stalled + // tail is dropped rather than blocking the channel forever. + await channel.done.timeout(const Duration(seconds: 2)); + expect( + sentIds, + containsAll([ + SSH_Message_Channel_Data.messageId, + SSH_Message_Channel_EOF.messageId, + SSH_Message_Channel_Close.messageId, + ]), + ); + expect(sentDataBytes, 4); + expect(closedChannel, same(channel)); + }); + test('the receive window is granted back as the client sends', () async { final adjusts = []; final input = BytesBuilder(copy: false); diff --git a/client/packages/tp_sshd/test/server_exec_test.dart b/client/packages/tp_sshd/test/server_exec_test.dart index c1344d4e7..eaa92f81a 100644 --- a/client/packages/tp_sshd/test/server_exec_test.dart +++ b/client/packages/tp_sshd/test/server_exec_test.dart @@ -41,6 +41,96 @@ class _EchoProcess implements SSHServerProcess { } } +/// Fake process whose `exitCode` completes with an error instead of a code: +/// a broken process contract, not a process that exited. +class _ExplodingExitProcess implements SSHServerProcess { + final _stdin = StreamController>(); + final _stdout = StreamController(); + final _stderr = StreamController(); + final _exit = Completer(); + + @override + Future get exitCode => _exit.future; + + @override + void kill() { + // Safe after the contract already broke, like after a real exit. + if (!_exit.isCompleted) _exit.complete(9); + } + + @override + StreamSink> get stdin => _stdin.sink; + + @override + Stream get stderr => _stderr.stream; + + @override + Stream get stdout => _stdout.stream; + + void start() { + _stdout.add(Uint8List.fromList(utf8.encode('partial output'))); + _stdout.close(); + _stderr.close(); + } + + /// Breaks the process contract: exitCode completes with an error from + /// here on. Called by the test once the pipes are wired, so the failure + /// deterministically lands on a live pipe. + void breakContract() { + if (!_exit.isCompleted) { + _exit.completeError(StateError('process contract broke')); + } + } +} + +/// Fake process whose stdin sink throws on every write. +class _ThrowingStdinProcess implements SSHServerProcess { + final _stdout = StreamController(); + final _stderr = StreamController(); + final _exit = Completer(); + + @override + Future get exitCode => _exit.future; + + @override + void kill() { + if (!_exit.isCompleted) _exit.complete(9); + } + + @override + StreamSink> get stdin => _ThrowingStdinSink(); + + @override + Stream get stderr => _stderr.stream; + + @override + Stream get stdout => _stdout.stream; + + void finish(int code) { + _stdout.close(); + _stderr.close(); + if (!_exit.isCompleted) _exit.complete(code); + } +} + +/// A stdin sink that is already gone: every write throws. +class _ThrowingStdinSink implements StreamSink> { + @override + void add(List data) => throw StateError('stdin is gone'); + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) => Future.value(); + + @override + Future close() => Future.value(); + + @override + Future get done => Future.value(); +} + void main() { test('structured exec spawns argv and reports exit code', () async { final (client, server) = await startDualPair( @@ -113,4 +203,63 @@ void main() { client.close(); await server.close(); }); + + test('a process whose exitCode errors finishes the channel fail-safe', + () async { + final process = _ExplodingExitProcess()..start(); + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + processFactory: (argv, cwd, env) async => process, + ); + final session = await client.execute( + TpExecCodec.encode(const SSHExecRequest(argv: ['broken'])), + ); + // Whatever output the process produced is delivered first... + final output = StreamIterator(utf8.decoder.bind(session.stdout)); + expect(await output.moveNext(), isTrue); + expect(output.current, 'partial output'); + await output.cancel(); + // ...then its exitCode contract breaks. The channel finishes instead of + // hanging on a future that errored, and no exit status is invented for a + // process that never exited. + process.breakContract(); + await session.done.timeout(const Duration(seconds: 5)); + expect(await session.waitForExit(), isNull); + // The broken process took its channel, not the connection. + expect(client.isClosed, isFalse); + + client.close(); + await server.close(); + }); + + test('a process stdin that throws on write is treated as input-side close', + () async { + final process = _ThrowingStdinProcess(); + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + processFactory: (argv, cwd, env) async => process, + ); + final session = await client.execute( + TpExecCodec.encode(const SSHExecRequest(argv: ['picky'])), + ); + // Input hits a stdin contract that throws on every write: the throw is + // contained (it must not escape as an unhandled error and kill the + // connection)... + session.stdin.add(Uint8List.fromList(utf8.encode('hello'))); + session.stdin.add(Uint8List.fromList(utf8.encode('again'))); + await Future.delayed(const Duration(milliseconds: 100)); + expect(client.isClosed, isFalse); + + // ...and the channel still finishes normally when the process exits. + process.finish(0); + expect(await session.waitForExit(), 0); + await session.done.timeout(const Duration(seconds: 5)); + + client.close(); + await server.close(); + }); } diff --git a/client/packages/tp_sshd/test/server_forward_test.dart b/client/packages/tp_sshd/test/server_forward_test.dart index 4e987aaa6..e9f1800e3 100644 --- a/client/packages/tp_sshd/test/server_forward_test.dart +++ b/client/packages/tp_sshd/test/server_forward_test.dart @@ -99,6 +99,28 @@ void main() { await server.close(); }); + test('tcpip-forward without a bind seam is refused', () async { + // No bindServerSocket configured: the forwarding surface is off, and a + // tcpip-forward request — even for a perfectly loopback address — gets a + // Request_Failure reply rather than a hang or a bind attempt. + var failures = 0; + final (connection, client) = await startRawAuthenticatedConnection( + onServerMessage: (payload) { + if (SSHMessage.readMessageId(payload) == + SSH_Message_Request_Failure.messageId) { + failures += 1; + } + }, + ); + client.sendPacket( + SSH_Message_Global_Request.tcpipForward('127.0.0.1', 0).encode(), + ); + await waitUntil(() => failures == 1); + + await connection.close(); + client.close(); + }); + test('cancel-tcpip-forward releases the bind', () async { final (client, server) = await startDualPair( hostKeyPair: testHostKey, diff --git a/client/packages/tp_sshd/test/server_handshake_test.dart b/client/packages/tp_sshd/test/server_handshake_test.dart index 6e7dfcd8b..4ce755ecc 100644 --- a/client/packages/tp_sshd/test/server_handshake_test.dart +++ b/client/packages/tp_sshd/test/server_handshake_test.dart @@ -63,4 +63,35 @@ void main() { await server.close(); transport.close(); }); + + test('a connection-stream error is contained and tears down live connections', + () async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + ), + ); + // One good connection is live when the accept source dies mid-stream. + connections.add(serverSocket); + await waitUntil(() => server.activeConnections == 1); + + connections.addError(StateError('accept source broke')); + + // The stream's error surfaces on done — instead of escaping as an + // unhandled zone error that would take the embedding app down — and the + // live connection does not survive a dead listener. + await expectLater(server.done, throwsA(isA())); + await waitUntil(() => server.activeConnections == 0); + // The torn-down connection closed its socket: the client end of the + // in-memory pair saw the shutdown. + await clientSocket.done; + + await connections.close(); + await server.close(); + }); } diff --git a/client/packages/tp_sshd/test/server_hardening_test.dart b/client/packages/tp_sshd/test/server_hardening_test.dart new file mode 100644 index 000000000..175d6453e --- /dev/null +++ b/client/packages/tp_sshd/test/server_hardening_test.dart @@ -0,0 +1,211 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/protocol.dart'; +import 'package:test/test.dart'; +import 'package:tp_sshd/tp_sshd.dart'; + +import 'dual_test_utils.dart'; +import 'test_socket_pair.dart'; + +/// Fake process echoing argv back on stdout once, then exiting 17. +class _EchoProcess implements SSHServerProcess { + _EchoProcess(this.argv); + + final List argv; + final _stdin = StreamController>(); + final _stdout = StreamController(); + final _stderr = StreamController(); + + @override + Future get exitCode async => 17; + + @override + void kill() {} + + @override + StreamSink> get stdin => _stdin.sink; + + @override + Stream get stderr => _stderr.stream; + + @override + Stream get stdout => _stdout.stream; + + void start() { + _stdout.add(Uint8List.fromList(utf8.encode(argv.join(' ')))); + _stdout.close(); + _stderr.close(); + } +} + +/// Fake process that echoes every stdin chunk back on stdout and stays open +/// until killed, for observing a channel across a mid-session rekey. +class _EchoingProcess implements SSHServerProcess { + final _stdin = StreamController>(); + final _stdout = StreamController(); + final _stderr = StreamController(); + final _exit = Completer(); + + _EchoingProcess() { + _stdin.stream.listen((data) => _stdout.add(Uint8List.fromList(data))); + } + + @override + Future get exitCode => _exit.future; + + @override + void kill() { + if (!_exit.isCompleted) _exit.complete(9); + } + + @override + StreamSink> get stdin => _stdin.sink; + + @override + Stream get stderr => _stderr.stream; + + @override + Stream get stdout => _stdout.stream; +} + +void main() { + test('six failed auth attempts disconnect the client', () async { + var failures = 0; + final (server, client) = await startRawPair( + authenticate: (_) async => false, + onServerMessage: (payload) { + if (SSHMessage.readMessageId(payload) == + SSH_Message_Userauth_Failure.messageId) { + failures += 1; + } + return true; + }, + onReady: (client) { + client.sendPacket(SSH_Message_Service_Request('ssh-userauth').encode()); + // Keep the failed attempts coming until the server cuts the + // connection; a server without a throttle would answer all twenty. + for (var i = 0; i < 20; i++) { + try { + client.sendPacket(testProbeRequest().encode()); + } on Object { + break; // the transport is already gone + } + } + }, + ); + // The connection died... + await expectLater( + client.done.timeout(const Duration(seconds: 5)), + throwsA(anything), + ); + await waitUntil(() => server.activeConnections == 0); + // ...after at most six failed attempts were answered along the way. + expect(failures, lessThanOrEqualTo(6)); + await server.close(); + client.close(); + }); + + test('one crashing connection does not kill the listener', () async { + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + processFactory: (argv, cwd, env) async => _EchoProcess(argv)..start(), + ), + ); + + // A raw client that speaks a version banner and then pure garbage — not + // even a key exchange. + final (garbageSocket, garbageServerSocket) = loopbackSSHSocketPair(); + connections.add(garbageServerSocket); + await waitUntil(() => server.activeConnections == 1); + garbageSocket.sink.add(utf8.encode('SSH-2.0-Garbage\r\n')); + // A packet length of 0xffffffff: unparseable, over every bound. + garbageSocket.sink.add(Uint8List.fromList([0xff, 0xff, 0xff, 0xff, 0x01])); + + // The server closes that connection off... + await waitUntil(() => server.activeConnections == 0); + await garbageSocket.done; + + // ...and the listener is unharmed: a fresh, honest connection still + // authenticates and execs on the same server. + final (clientSocket, goodServerSocket) = loopbackSSHSocketPair(); + connections.add(goodServerSocket); + final client = SSHClient( + clientSocket, + username: 'user', + onVerifyHostKey: (_, __) => true, + identities: [testDeviceKey], + ); + final session = await client.execute( + TpExecCodec.encode(const SSHExecRequest(argv: ['still', 'alive'])), + ); + expect(await utf8.decoder.bind(session.stdout).join(), 'still alive'); + + await client.close(); + await server.close(); + await connections.close(); + }); + + test('five concurrent connections all authenticate and exec', () async { + Future oneRound(int index) async { + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + processFactory: (argv, cwd, env) async => _EchoProcess(argv)..start(), + ); + final session = await client.execute( + TpExecCodec.encode(SSHExecRequest(argv: ['round', '$index'])), + ); + expect(await utf8.decoder.bind(session.stdout).join(), 'round $index'); + client.close(); + await server.close(); + } + + // Five sequential socket pairs driven in parallel futures: every one of + // them must authenticate and exec independently. + await Future.wait([for (var i = 0; i < 5; i++) oneRound(i)]); + }); + + test('rekey mid-session keeps the channel alive', () async { + final process = _EchoingProcess(); + final (client, server) = await startDualPair( + hostKeyPair: testHostKey, + authenticate: (_) async => true, + clientIdentities: [testDeviceKey], + processFactory: (argv, cwd, env) async => process, + ); + final session = await client.execute( + TpExecCodec.encode(const SSHExecRequest(argv: ['cat'])), + ); + final output = StreamIterator(utf8.decoder.bind(session.stdout)); + + session.stdin.add(Uint8List.fromList(utf8.encode('before'))); + expect(await output.moveNext(), isTrue); + expect(output.current, 'before'); + + // Force a client-side rekey mid-session through the fork's public + // SSHClient.rekey() API: the exchange runs over the live channel, and + // the channel must keep working on the new keys. + await client.rekey(); + + session.stdin.add(Uint8List.fromList(utf8.encode('after'))); + expect(await output.moveNext(), isTrue); + expect(output.current, 'after'); + expect(client.isClosed, isFalse); + + await output.cancel(); + client.close(); + await server.close(); + }); +} diff --git a/client/packages/tp_sshd/test/server_userauth_test.dart b/client/packages/tp_sshd/test/server_userauth_test.dart index 92978e1d4..e2eefd9c7 100644 --- a/client/packages/tp_sshd/test/server_userauth_test.dart +++ b/client/packages/tp_sshd/test/server_userauth_test.dart @@ -220,7 +220,7 @@ void main() { client.sendPacket(SSH_Message_Service_Request('ssh-userauth').encode()); // Six probes of an untrusted key: the default maxAuthAttempts. for (var i = 0; i < 6; i++) { - client.sendPacket(_probeRequest().encode()); + client.sendPacket(testProbeRequest().encode()); } }, ); @@ -241,46 +241,3 @@ void main() { client.close(); }); } - -/// A probing publickey userauth request (RFC 4252 §7, `boolean FALSE`) for -/// the test device key. -SSH_Message_Userauth_Request _probeRequest() { - return SSH_Message_Userauth_Request.publicKey( - username: 'user', - publicKeyAlgorithm: 'ssh-ed25519', - publicKey: testDeviceKey.toPublicKey().encode(), - signature: null, - ); -} - -/// Starts an [SSHServer] plus a raw client-side [SSHTransport], so tests can -/// inject hand-crafted auth traffic a real [SSHClient] would never send. -/// -/// [onReady] runs once the client-side key exchange completes; -/// [onServerMessage] sees everything the server sends back (consumed by -/// default). -Future<(SSHServer, SSHTransport)> startRawPair({ - required Future Function(SSHServerAuthRequest request) authenticate, - required void Function(SSHTransport client) onReady, - bool Function(Uint8List payload)? onServerMessage, -}) async { - final (clientSocket, serverSocket) = loopbackSSHSocketPair(); - final connections = StreamController(); - final server = await SSHServer.bind( - StreamIterator(connections.stream), - config: SSHServerConfig( - hostKeyPair: testHostKey, - expectedUsername: 'user', - authenticate: authenticate, - ), - ); - connections.add(serverSocket); - late final SSHTransport client; - client = SSHTransport( - clientSocket, - onVerifyHostKey: (_, __) => true, - onReady: () => onReady(client), - onMessage: onServerMessage ?? (_) => true, - ); - return (server, client); -} From 930d99d0aa349f7842d0947dbc9a0e1558a868f6 Mon Sep 17 00:00:00 2001 From: hhoao Date: Thu, 10 Sep 2026 13:49:50 +0800 Subject: [PATCH 11/14] =?UTF-8?q?chore:=20bump=20dartssh2=20submodule=20?= =?UTF-8?q?=E2=80=94=20protocol=20export=20+=20server-role=20kex=20+=20sft?= =?UTF-8?q?p=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/packages/dartssh2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/dartssh2 b/client/packages/dartssh2 index 4602aab4a..a12443df6 160000 --- a/client/packages/dartssh2 +++ b/client/packages/dartssh2 @@ -1 +1 @@ -Subproject commit 4602aab4aaaa76deb51a6e12cee948dd5580e71b +Subproject commit a12443df64663ad131c1aa07ac9b963673132c35 From 3c1d88d2fccd653f00fc0342c61aa6069359e2ab Mon Sep 17 00:00:00 2001 From: hhoao Date: Thu, 10 Sep 2026 14:45:55 +0800 Subject: [PATCH 12/14] fix: address final review findings Co-Authored-By: Claude Opus 4.8 --- .../tp_sshd/lib/src/server_connection.dart | 3 +- .../tp_sshd/lib/src/server_session.dart | 18 ++- .../packages/tp_sshd/lib/src/server_sftp.dart | 25 ++- .../tp_sshd/test/memory_sftp_filesystem.dart | 25 ++- .../tp_sshd/test/server_sftp_test.dart | 124 ++++++++++++++ .../tp_sshd/test/server_shell_test.dart | 152 ++++++++++++++++++ 6 files changed, 330 insertions(+), 17 deletions(-) diff --git a/client/packages/tp_sshd/lib/src/server_connection.dart b/client/packages/tp_sshd/lib/src/server_connection.dart index f8040a19c..c7bde0b9e 100644 --- a/client/packages/tp_sshd/lib/src/server_connection.dart +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -197,7 +197,8 @@ class SSHServerConnection { unawaited(forwarder.handleGlobalRequest(message)); return; } - // No seam configured: fall through to the refusal below. + // No seam configured: this case body ends here, and control continues + // after the switch to the shared wantReply refusal below. case 'keepalive@openssh.com': if (message.wantReply) { _transport.sendPacket( diff --git a/client/packages/tp_sshd/lib/src/server_session.dart b/client/packages/tp_sshd/lib/src/server_session.dart index 3b775a487..31c8e48c4 100644 --- a/client/packages/tp_sshd/lib/src/server_session.dart +++ b/client/packages/tp_sshd/lib/src/server_session.dart @@ -112,9 +112,11 @@ bool _handleEnvRequest( return true; } -/// Applies a `window-change` (RFC 4254 §6.7): resizes the running pty, or — -/// before the shell — refreshes the stashed dimensions so it starts at the -/// size the client last announced. +/// Applies a `window-change` (RFC 4254 §6.7): resizes the running pty. With +/// no live pty on the channel the request is refused — an honest failure +/// rather than a success ack for a resize nothing received — though the +/// dimensions stashed by `pty-req` are still refreshed, so a shell request +/// that arrives afterwards starts at the size the client last announced. bool _handleWindowChange( _SessionState state, SSH_Message_Channel_Request request, @@ -137,19 +139,23 @@ bool _handleWindowChange( environment: dimensions.environment, ); } - return true; + return false; } /// Delivers a `signal` request (RFC 4254 §6.9) to the running pty by the /// name the client sent — the fork's client emits the RFC names directly -/// from its `SSHSignal` enum, so they pass through unchanged. +/// from its `SSHSignal` enum, so they pass through unchanged. With no live +/// pty there is nothing to deliver the signal to, and the request is +/// refused instead of acknowledged as a silent no-op. bool _handleSignalRequest( _SessionState state, SSH_Message_Channel_Request request, ) { final name = request.signalName; if (name == null) return false; - state.pty?.signal(name); + final pty = state.pty; + if (pty == null) return false; + pty.signal(name); return true; } diff --git a/client/packages/tp_sshd/lib/src/server_sftp.dart b/client/packages/tp_sshd/lib/src/server_sftp.dart index 801353c83..5644e33f6 100644 --- a/client/packages/tp_sshd/lib/src/server_sftp.dart +++ b/client/packages/tp_sshd/lib/src/server_sftp.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:dartssh2/protocol.dart'; @@ -17,6 +18,17 @@ const _kSftpVersion = 3; /// is not counted. const _kMaxPacketLength = 256 * 1024; +/// Largest read a READ request may ask the filesystem for. A DATA reply +/// carries a 9-byte header (type byte, request id, and the data's 4-byte +/// length prefix) on top of the bytes, so anything past this could never fit +/// one outgoing packet. The requested length is clamped to it in +/// [_SftpServerSession._handleRead] before the filesystem is read: a client +/// may ask for up to a uint32 of bytes, and without the clamp the injected +/// filesystem would materialize that much data only for the outgoing-packet +/// guard in [_SftpServerSession._sendPacket] to discard it — and close the +/// channel. +const _kMaxReadLength = _kMaxPacketLength - 9; + /// Byte budget for a READDIR NAME packet: the encoded payload of the reply /// (type + request id + count + the names) must stay at or under the /// [_kMaxPacketLength] limit, because the client destroys the channel on @@ -235,7 +247,13 @@ class _SftpServerSession { _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); return; } - final data = await file.read(request.offset, request.length); + // Clamp before reading: a hostile or careless client may ask for up to a + // uint32 of bytes, and the filesystem must not be made to materialize + // more than one reply packet can carry. + final data = await file.read( + request.offset, + math.min(request.length, _kMaxReadLength), + ); if (data.isEmpty) { // EOF is a status, never an empty DATA packet — the fork's client // treats the latter as a protocol error. @@ -423,9 +441,8 @@ class _SftpServerSession { // The fork's client destroys the channel on any incoming packet over // the limit, so shipping an over-limit reply would kill the session on // the client's terms — fail it here instead. (READDIR replies are - // batched down by _handleReadDir; READ replies are bounded by the - // client's own request length, which is under the limit by - // construction.) + // batched down by _handleReadDir; READ replies are clamped to the limit + // by _handleRead before the filesystem is read.) if (payload.length > _kMaxPacketLength) { _channel.printDebug?.call( 'tp_sshd: closing sftp subsystem: outgoing packet of ' diff --git a/client/packages/tp_sshd/test/memory_sftp_filesystem.dart b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart index 16b54a006..5a56bf314 100644 --- a/client/packages/tp_sshd/test/memory_sftp_filesystem.dart +++ b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart @@ -13,16 +13,21 @@ import 'package:tp_sshd/tp_sshd.dart'; class MemorySftpFileSystem implements SftpFileSystem { final _nodes = {'/': _MemoryNode.directory()}; - /// Creates an empty regular file at [path] (parents must already exist), - /// for tests that need a directory with many entries without opening a - /// connection per file. - void createFile(String path) { + /// Lengths of every file read issued through [openFile] handles, in order, + /// so tests can assert what the server asked the filesystem for — not just + /// what made it back onto the wire. + final fileReadLengths = []; + + /// Creates a regular file at [path] (parents must already exist) holding + /// [bytes], for tests that need file content — or a directory with many + /// entries — without opening a connection per file. + void createFile(String path, {List bytes = const []}) { final normalized = _normalize(path); final parent = _nodes[_parentOf(normalized)]!; if (!parent.isDirectory) { throw StateError('not a directory: $path'); } - _nodes[normalized] = _MemoryNode.file(); + _nodes[normalized] = _MemoryNode.file()..bytes = Uint8List.fromList(bytes); } /// Creates an empty directory at [path] (the parent must already exist). @@ -83,6 +88,7 @@ class MemorySftpFileSystem implements SftpFileSystem { return _MemoryFileHandle( _nodes[normalized]!, append: _hasFlag(mode, SftpFileOpenMode.append), + onReadLength: fileReadLengths.add, ); } @@ -239,13 +245,20 @@ class _MemoryNode { /// Open file handle over a [_MemoryNode]: explicit-offset reads and writes /// against the node's byte buffer, with appends always landing at the end. class _MemoryFileHandle extends SftpHandle { - _MemoryFileHandle(this._node, {required bool append}) : _append = append; + _MemoryFileHandle( + this._node, { + required bool append, + void Function(int length)? onReadLength, + }) : _append = append, + _onReadLength = onReadLength; final _MemoryNode _node; final bool _append; + final void Function(int length)? _onReadLength; @override Future read(int offset, int length) async { + _onReadLength?.call(length); final bytes = _node.bytes!; if (offset < 0 || length < 0) { throw StateError('negative read: offset=$offset length=$length'); diff --git a/client/packages/tp_sshd/test/server_sftp_test.dart b/client/packages/tp_sshd/test/server_sftp_test.dart index 6cb8d062e..f10b4af4e 100644 --- a/client/packages/tp_sshd/test/server_sftp_test.dart +++ b/client/packages/tp_sshd/test/server_sftp_test.dart @@ -1,15 +1,81 @@ @TestOn('vm') library; +import 'dart:async'; +import 'dart:collection'; import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/protocol.dart'; +import 'package:dartssh2/src/ssh_channel.dart' show SSHChannel, SSHChannelData; import 'package:test/test.dart'; import 'package:tp_sshd/tp_sshd.dart'; import 'dual_test_utils.dart'; import 'memory_sftp_filesystem.dart'; +/// Hand-driven SFTP wire on one session channel: frames outgoing packets and +/// reassembles the length-prefixed replies out of the channel's data stream, +/// for requests the fork's client never sends (a READ asking for a whole +/// uint32 of bytes). +class _RawSftpWire { + _RawSftpWire(this._channel) { + _channel.stream.listen(_onData); + } + + final SSHChannel _channel; + final _pending = BytesBuilder(copy: false); + final _replies = Queue(); + Completer? _replyWaiter; + + /// Frames [packet] and sends it as channel data. + void send(SftpPacket packet) { + final payload = packet.encode(); + final framed = BytesBuilder(copy: false) + ..add(_lengthPrefix(payload.length)) + ..add(payload); + _channel.addData(framed.takeBytes()); + } + + /// Completes with the next reply payload (type byte first, no length + /// prefix). + Future receive() { + if (_replies.isNotEmpty) return Future.value(_replies.removeFirst()); + _replyWaiter = Completer(); + return _replyWaiter!.future; + } + + void _onData(SSHChannelData data) { + var bytes = (BytesBuilder(copy: false) + ..add(_pending.takeBytes()) + ..add(data.bytes)) + .takeBytes(); + while (bytes.length >= 4) { + final length = ByteData.sublistView(bytes, 0, 4).getUint32(0); + if (bytes.length < 4 + length) break; + _emitReply(Uint8List.sublistView(bytes, 4, 4 + length)); + bytes = Uint8List.sublistView(bytes, 4 + length); + } + _pending.add(bytes); + } + + void _emitReply(Uint8List packet) { + final waiter = _replyWaiter; + if (waiter != null) { + _replyWaiter = null; + waiter.complete(packet); + } else { + _replies.add(packet); + } + } + + Uint8List _lengthPrefix(int length) { + final bytes = Uint8List(4); + ByteData.view(bytes.buffer).setUint32(0, length); + return bytes; + } +} + void main() { late MemorySftpFileSystem fs; @@ -124,4 +190,62 @@ void main() { client.close(); await server.close(); }); + + // A READ whose requested length is a whole uint32 must be clamped to what + // one outgoing packet can carry before the filesystem is asked for + // anything: without the clamp the filesystem would materialize up to 4 GiB + // of data that the outgoing-packet guard would then discard along with the + // channel. The reply is a clamped DATA packet — not an error status — and + // the channel stays alive for further requests. + test('a READ asking for more than one packet is clamped, not an error', + () async { + fs.createFile('/big.bin', bytes: Uint8List(512 * 1024)); + final (client, server) = await connect(); + final controller = await openClientSessionChannel(client); + expect(await controller.sendSubsystem('sftp'), isTrue); + final wire = _RawSftpWire(controller.channel); + + wire.send(SftpInitPacket(3)); + expect((await wire.receive())[0], SftpVersionPacket.packetType); + + wire.send(SftpOpenPacket( + 1, + '/big.bin', + SftpFileOpenMode.read.flag, + SftpFileAttrs(), + )); + final openReply = await wire.receive(); + expect(openReply[0], SftpHandlePacket.packetType); + final handle = SftpHandlePacket.decode(openReply).handle; + + wire.send(SftpReadPacket( + requestId: 2, + handle: handle, + offset: 0, + length: 0xffffffff, + )); + final readReply = await wire.receive(); + expect(readReply[0], SftpDataPacket.packetType); + // The 256 KiB SFTP packet limit minus the DATA header (type byte, + // request id, and the data's 4-byte length prefix). + const clampedLength = 256 * 1024 - 9; + expect(SftpDataPacket.decode(readReply).data.length, clampedLength); + // The clamp happened before the filesystem: the server asked it for the + // clamped length, not the 4 GiB the wire requested. + expect(fs.fileReadLengths, [clampedLength]); + + // The channel survived — a further read at the clamp boundary is served. + wire.send(SftpReadPacket( + requestId: 3, + handle: handle, + offset: clampedLength, + length: 16, + )); + final nextReply = await wire.receive(); + expect(nextReply[0], SftpDataPacket.packetType); + expect(SftpDataPacket.decode(nextReply).data.length, 16); + + client.close(); + await server.close(); + }); } diff --git a/client/packages/tp_sshd/test/server_shell_test.dart b/client/packages/tp_sshd/test/server_shell_test.dart index 54a456241..291999cca 100644 --- a/client/packages/tp_sshd/test/server_shell_test.dart +++ b/client/packages/tp_sshd/test/server_shell_test.dart @@ -6,6 +6,13 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/protocol.dart' + show + SSHChannelRequestType, + SSHMessage, + SSH_Message_Channel_Failure, + SSH_Message_Channel_Request, + SSH_Message_Channel_Success; import 'package:test/test.dart'; import 'package:tp_sshd/tp_sshd.dart'; @@ -161,4 +168,149 @@ void main() { client.close(); await server.close(); }); + + // Protocol honesty: `window-change` and `signal` are no-ops with no live + // pty on the channel, and a client that asks for a reply must not get a + // success ack for one. + // + // The verdict is not observable over the dual pair: the fork's + // `SSH_Message_Channel_Request.decode` hard-codes `wantReply: false` for + // both request types, so no wire reply is ever sent for them whatever the + // session layer answers. The session layer is therefore driven directly, + // on a hand-built channel wired exactly like SSHServerConnection wires it, + // with the outgoing packets captured. + test('signal and window-change without a live pty reply channel failure', + () async { + final sentIds = []; + final channel = _buildSessionChannel( + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + ptyFactory: (initial) async => _FakePty(), + ), + onSendPacket: (payload) => sentIds.add(SSHMessage.readMessageId(payload)), + ); + + channel.handleRequest( + SSH_Message_Channel_Request( + recipientChannel: 0, + requestType: SSHChannelRequestType.signal, + wantReply: true, + signalName: 'INT', + ), + ); + await pumpEventQueue(); + expect(sentIds, [SSH_Message_Channel_Failure.messageId]); + + channel.handleRequest( + SSH_Message_Channel_Request( + recipientChannel: 0, + requestType: SSHChannelRequestType.windowChange, + wantReply: true, + termWidth: 100, + termHeight: 50, + termPixelWidth: 0, + termPixelHeight: 0, + ), + ); + await pumpEventQueue(); + expect(sentIds, [ + SSH_Message_Channel_Failure.messageId, + SSH_Message_Channel_Failure.messageId, + ]); + + channel.detach(); + }); + + test('signal and window-change with a live pty succeed', () async { + final pty = _FakePty(); + final sentIds = []; + final channel = _buildSessionChannel( + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + ptyFactory: (initial) async => pty, + ), + onSendPacket: (payload) => sentIds.add(SSHMessage.readMessageId(payload)), + ); + + // pty-req stashes the dimensions; shell spawns the pty they describe. + channel.handleRequest( + SSH_Message_Channel_Request( + recipientChannel: 0, + requestType: SSHChannelRequestType.pty, + wantReply: true, + termType: 'xterm-256color', + termWidth: 80, + termHeight: 24, + termPixelWidth: 0, + termPixelHeight: 0, + termModes: Uint8List(0), + ), + ); + channel.handleRequest( + SSH_Message_Channel_Request( + recipientChannel: 0, + requestType: SSHChannelRequestType.shell, + wantReply: true, + ), + ); + await pumpEventQueue(); + expect(sentIds, [ + SSH_Message_Channel_Success.messageId, + SSH_Message_Channel_Success.messageId, + ]); + + // With the pty live, both requests are applied and acknowledged. + channel.handleRequest( + SSH_Message_Channel_Request( + recipientChannel: 0, + requestType: SSHChannelRequestType.windowChange, + wantReply: true, + termWidth: 200, + termHeight: 50, + termPixelWidth: 0, + termPixelHeight: 0, + ), + ); + await pumpEventQueue(); + expect(pty.resized, contains('200 x 50')); + channel.handleRequest( + SSH_Message_Channel_Request( + recipientChannel: 0, + requestType: SSHChannelRequestType.signal, + wantReply: true, + signalName: 'INT', + ), + ); + await pumpEventQueue(); + expect(pty.signaled, contains('INT')); + expect(sentIds, everyElement(SSH_Message_Channel_Success.messageId)); + expect(sentIds, hasLength(4)); + + channel.detach(); + }); +} + +/// A session channel built by hand, wired exactly like SSHServerConnection +/// wires the channels it confirms, with every outgoing packet handed to +/// [onSendPacket] instead of a transport. +SSHServerChannel _buildSessionChannel({ + required SSHServerConfig config, + required void Function(Uint8List payload) onSendPacket, +}) { + final channel = SSHServerChannel( + recipientChannel: 0, + ourChannel: 0, + channelType: 'session', + peerInitialWindowSize: 1024, + peerMaximumPacketSize: 32768, + sendPacket: onSendPacket, + onClosed: (_) {}, + ); + channel.onRequest = (channel, request) => + handleSessionRequest(channel, request, config: config); + return channel; } From c7a5d2b293e8fa680a4fc6bc2647748a7eee0c61 Mon Sep 17 00:00:00 2001 From: hhoao Date: Thu, 10 Sep 2026 17:39:32 +0800 Subject: [PATCH 13/14] fix(tp_sshd): end sftp subsystem on client channel EOF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../packages/tp_sshd/lib/src/server_sftp.dart | 18 ++++++++++++++---- .../tp_sshd/test/server_sftp_test.dart | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/client/packages/tp_sshd/lib/src/server_sftp.dart b/client/packages/tp_sshd/lib/src/server_sftp.dart index 5644e33f6..d3a0eff8b 100644 --- a/client/packages/tp_sshd/lib/src/server_sftp.dart +++ b/client/packages/tp_sshd/lib/src/server_sftp.dart @@ -98,12 +98,22 @@ class _SftpServerSession { Uint8List _pending = Uint8List(0); Future run() async { - final subscription = _channel.input.listen(_onData); - // The channel finishing — client close, connection teardown — ends the - // session: stop reading and release every handle still open. - await _channel.done; + final inputEnded = Completer(); + final subscription = _channel.input.listen( + _onData, + // The input stream ends not only when the channel finishes (client + // close, connection teardown) but on the client's bare EOF too — the + // exit path of a stock OpenSSH sftp client, which sends EOF and then + // waits for this side to close the channel. + onDone: inputEnded.complete, + ); + await inputEnded.future; await subscription.cancel(); await _releaseAllHandles(); + // A no-op when the channel already finished; after a bare client EOF it + // is what tells the client the subsystem is over. + _channel.close(); + await _channel.done; } void _onData(Uint8List data) { diff --git a/client/packages/tp_sshd/test/server_sftp_test.dart b/client/packages/tp_sshd/test/server_sftp_test.dart index f10b4af4e..d40d52c71 100644 --- a/client/packages/tp_sshd/test/server_sftp_test.dart +++ b/client/packages/tp_sshd/test/server_sftp_test.dart @@ -107,6 +107,21 @@ void main() { await server.close(); }); + test('a client EOF without a close ends the subsystem and closes the channel', + () async { + final (client, server) = await connect(); + final controller = await openClientSessionChannel(client); + expect(await controller.sendSubsystem('sftp'), isTrue); + + // The exit path of a stock OpenSSH sftp client: EOF on the channel, then + // wait for the server to close it. A subsystem that only waits for the + // client's CHANNEL_CLOSE deadlocks here. + await controller.close().timeout(const Duration(seconds: 5)); + + client.close(); + await server.close(); + }); + test('mkdir / write / read / stat / list round trip', () async { final (client, server) = await connect(); final sftp = await client.sftp(); From 43af7308252a599f6a03fe83c2a2847542b18d5a Mon Sep 17 00:00:00 2001 From: hhoao Date: Thu, 10 Sep 2026 17:39:43 +0800 Subject: [PATCH 14/14] chore(tp_sshd): add real-client demo server under example/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../packages/tp_sshd/example/demo_sshd.dart | 686 ++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 client/packages/tp_sshd/example/demo_sshd.dart diff --git a/client/packages/tp_sshd/example/demo_sshd.dart b/client/packages/tp_sshd/example/demo_sshd.dart new file mode 100644 index 000000000..7f38089c8 --- /dev/null +++ b/client/packages/tp_sshd/example/demo_sshd.dart @@ -0,0 +1,686 @@ +// Demo SSH server wiring every tp_sshd seam to real system resources, so the +// package can be exercised end-to-end with a stock OpenSSH client: +// +// dart run example/demo_sshd.dart +// +// First run bootstraps throwaway keys with `ssh-keygen` and prints the exact +// ssh/sftp command lines to connect. The SFTP subsystem is jailed to a small +// sandbox directory; exec and interactive shells run as the local user, like +// the app's embedded server eventually will. +// +// VM-only: dart:io sockets, processes and signals. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart' show SSHKeyPair, SSHSocket; +import 'package:dartssh2/protocol.dart' + show SftpFileAttrs, SftpFileMode, SftpFileOpenMode, SftpName; +import 'package:tp_sshd/tp_sshd.dart'; + +Future main(List args) async { + final options = _DemoOptions.parse(args); + await _bootstrapKeys(options); + await _bootstrapSandbox(options); + + final hostKey = SSHKeyPair.fromPem( + File('${options.keysDir}/host_key').readAsStringSync(), + ).single; + final authorizedKeyBlob = _publicKeyBlob('${options.keysDir}/device_key.pub'); + + final listener = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + options.port, + ); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: hostKey, + expectedUsername: options.username, + authenticate: (request) async => _bytesEqual( + request.publicKey, + authorizedKeyBlob, + ), + processFactory: (argv, cwd, env) => _spawnProcess(argv, cwd, env), + ptyFactory: _spawnPty, + hostInfo: _hostInfo, + sftpFileSystem: LocalSftpFileSystem(options.root), + bindServerSocket: (address, port) async { + final socket = await ServerSocket.bind(address, port); + return _IoServerSocketHandle(socket); + }, + // Keep the log readable: the transport traces every packet loop. + printDebug: (message) { + if (message == null || message.contains('_processPackets')) return; + print('[debug] $message'); + }, + ), + ); + + listener.listen( + (socket) { + print('-- accepted ${socket.remoteAddress.address}:${socket.remotePort}'); + connections.add(_AcceptedSocket(socket)); + }, + onError: (Object error) => print('!! listener error: $error'), + onDone: () => print('-- listener closed'), + ); + + _printInstructions(options); + + ProcessSignal.sigint.watch().first.then((_) async { + print('\n-- shutting down'); + await listener.close(); + await connections.close(); + await server.close(); + exit(0); + }); + + await server.done; +} + +// --------------------------------------------------------------------------- +// Options, key bootstrap, startup output +// --------------------------------------------------------------------------- + +class _DemoOptions { + _DemoOptions({ + required this.port, + required this.username, + required this.keysDir, + required this.root, + }); + + final int port; + final String username; + final String keysDir; + final String root; + + static _DemoOptions parse(List args) { + var port = 2222; + var username = Platform.environment['USER'] ?? 'demo'; + var keysDir = Platform.environment['HOME'] != null + ? '${Platform.environment['HOME']}/.tp_sshd_demo' + : '.tp_sshd_demo'; + var root = '$keysDir/sftp-root'; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--port': + port = int.parse(args[++i]); + case '--user': + username = args[++i]; + case '--keys': + keysDir = args[++i]; + case '--root': + root = args[++i]; + default: + throw ArgumentError('unknown option: ${args[i]}'); + } + } + return _DemoOptions( + port: port, + username: username, + keysDir: keysDir, + root: root, + ); + } +} + +/// Generates the throwaway host key and client device key on first run, and +/// writes a known_hosts entry covering the demo address. +Future _bootstrapKeys(_DemoOptions options) async { + Directory(options.keysDir).createSync(recursive: true); + await _ensureKey('${options.keysDir}/host_key', 'tp-sshd-demo-host'); + await _ensureKey('${options.keysDir}/device_key', 'tp-sshd-demo-device'); + File('${options.keysDir}/known_hosts').writeAsStringSync( + '[127.0.0.1]:${options.port} ssh-ed25519 ' + '${base64.encode(_publicKeyBlob('${options.keysDir}/host_key.pub'))}\n', + ); +} + +Future _ensureKey(String path, String comment) async { + if (File(path).existsSync()) return; + final result = await Process.run('ssh-keygen', [ + '-t', + 'ed25519', + '-N', + '', + '-C', + comment, + '-f', + path, + ]); + if (result.exitCode != 0) { + throw StateError('ssh-keygen failed: ${result.stderr}'); + } +} + +/// The OpenSSH wire blob of a public key file (its second field, base64). +Uint8List _publicKeyBlob(String pubPath) { + final fields = File(pubPath).readAsStringSync().trim().split(' '); + if (fields.length < 2) { + throw StateError('not an OpenSSH public key: $pubPath'); + } + return base64.decode(fields[1]); +} + +bool _bytesEqual(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +/// Seeds the SFTP sandbox so the very first `sftp` session has content. +Future _bootstrapSandbox(_DemoOptions options) async { + final root = Directory(options.root)..createSync(recursive: true); + final hello = File('${root.path}/hello.txt'); + if (!hello.existsSync()) { + await hello.writeAsString('Hello from the tp_sshd demo SFTP sandbox.\n'); + } + final docs = Directory('${root.path}/docs')..createSync(recursive: true); + final readme = File('${docs.path}/readme.md'); + if (!readme.existsSync()) { + await readme.writeAsString( + '# tp_sshd demo\n\nEverything under `/` in an SFTP session maps to ' + '`${root.path}` on this machine.\n', + ); + } +} + +void _printInstructions(_DemoOptions options) { + final keys = options.keysDir; + final user = options.username; + final port = options.port; + final common = '-p $port -i $keys/device_key ' + '-o UserKnownHostsFile=$keys/known_hosts -o IdentitiesOnly=yes'; + print(''' +tp_sshd demo server listening on 127.0.0.1:$port + username : $user + sftp root: ${options.root} + +Try it with a real OpenSSH client: + + # handshake + publickey auth + structured exec (tp1: grammar, no shell) + ssh $common $user@127.0.0.1 'tp1:{"argv":["echo","hello","structured","exec"]}' + ssh $common $user@127.0.0.1 'tp1:{"query":"host-info"}' + + # plain shell strings are refused by design + ssh $common $user@127.0.0.1 'echo nope' + + # interactive shell through a pty (util-linux `script` backs it) + ssh -t $common $user@127.0.0.1 + + # SFTP: ls/get/put/mkdir, rooted at the sandbox above + sftp $common $user@127.0.0.1 + + # remote port forwarding: serve a local port back over the SSH connection + python3 -m http.server 18080 & + ssh $common -R 19080:127.0.0.1:18080 $user@127.0.0.1 + curl http://127.0.0.1:19080/ +'''); +} + +SSHHostInfo _hostInfo() => SSHHostInfo( + platform: Platform.operatingSystem, + osUser: Platform.environment['USER'] ?? 'unknown', + elevated: false, + inDocker: File('/.dockerenv').existsSync(), + shell: Platform.environment['SHELL'] ?? '/bin/sh', + ); + +// --------------------------------------------------------------------------- +// Socket / forwarding adapters: dart:io types onto the tp_sshd seams +// --------------------------------------------------------------------------- + +/// An accepted [Socket] exposed as the [SSHSocket] the server consumes. +class _AcceptedSocket implements SSHSocket { + _AcceptedSocket(this._socket); + + final Socket _socket; + + @override + Stream get stream => _socket; + + @override + StreamSink> get sink => _socket; + + @override + Future get done => _socket.done; + + @override + Future close() => _socket.close(); + + @override + void destroy() => _socket.destroy(); + + @override + Future flush() => _socket.flush(); +} + +/// A real [ServerSocket] behind one `tcpip-forward` request. +class _IoServerSocketHandle implements ServerSocketHandle { + _IoServerSocketHandle(this._socket); + + final ServerSocket _socket; + + @override + int get port => _socket.port; + + @override + Stream get connections => + _socket.map(_IoForwardConnection.new); + + @override + Future close() => _socket.close(); +} + +/// One accepted forwarded TCP connection. +class _IoForwardConnection implements ForwardConnection { + _IoForwardConnection(this._socket); + + final Socket _socket; + + @override + Stream get input => _socket; + + @override + StreamSink> get output => _socket; + + @override + Future get done => _socket.done; + + @override + InternetAddress get remoteAddress => _socket.remoteAddress; + + @override + int get remotePort => _socket.remotePort; + + @override + void destroy() => _socket.destroy(); +} + +// --------------------------------------------------------------------------- +// Exec / pty seams +// --------------------------------------------------------------------------- + +Future _spawnProcess( + List argv, + String? cwd, + Map env, +) async { + print('-- exec ${jsonEncode(argv)} (cwd: ${cwd ?? 'inherit'})'); + try { + final process = await Process.start( + argv.first, + argv.skip(1).toList(), + workingDirectory: cwd, + environment: {...Platform.environment, ...env}, + ); + return _IoServerProcess(process); + } on Object catch (error) { + print('!! exec refused, spawn failed: $error'); + return null; + } +} + +Future _spawnPty(SSHPtyDimensions initial) async { + final shell = Platform.environment['SHELL'] ?? '/bin/bash'; + print( + '-- shell $shell (${initial.columns}x${initial.rows}, ' + 'TERM=${initial.environment['TERM'] ?? 'unset'})', + ); + try { + // Pure dart:io cannot allocate a pty; util-linux `script` owns one and + // pipes the session through stdio. Demo-grade: no window-change ioctl. + final process = await Process.start( + 'script', + ['-qefc', shell, '/dev/null'], + environment: { + ...Platform.environment, + ...initial.environment, + 'TERM': initial.environment['TERM'] ?? 'xterm-256color', + }, + ); + return _IoServerPty(process); + } on Object catch (error) { + print('!! shell refused, spawn failed: $error'); + return null; + } +} + +class _IoServerProcess implements SSHServerProcess { + _IoServerProcess(this._process); + + final Process _process; + + @override + Stream get stdout => _process.stdout.cast(); + + @override + Stream get stderr => _process.stderr.cast(); + + @override + StreamSink> get stdin => _process.stdin; + + @override + Future get exitCode => _process.exitCode; + + @override + void kill() => _process.kill(); +} + +class _IoServerPty extends _IoServerProcess implements SSHServerPty { + _IoServerPty(super.process); + + static const _signals = { + 'INT': ProcessSignal.sigint, + 'TERM': ProcessSignal.sigterm, + 'HUP': ProcessSignal.sighup, + 'KILL': ProcessSignal.sigkill, + }; + + @override + void resize(int columns, int rows) { + // `script` owns the pty from a separate process; resizing would need an + // ioctl (or `stty` in-session) this demo does not wire up. + print('-- window-change ${columns}x$rows (demo: not applied)'); + } + + @override + void signal(String name) { + final signal = _signals[name]; + if (signal == null) { + print('-- signal $name (demo: unknown, dropped)'); + return; + } + _process.kill(signal); + } +} + +// --------------------------------------------------------------------------- +// SFTP over the local filesystem, jailed to the sandbox root +// --------------------------------------------------------------------------- + +/// [SftpFileSystem] over dart:io, mapping `/` onto [root]. The jail is +/// string-normalized (`.`/`..` resolved before the root is prepended), good +/// enough for a demo sandbox without symlink chasing. Requests on one handle +/// are serialized with a lock chain — the package dispatches SFTP requests +/// concurrently and dart:io position+read/write pairs are not atomic. +class LocalSftpFileSystem implements SftpFileSystem { + LocalSftpFileSystem(this.root); + + final String root; + + @override + Future stat(String path) async { + final fsPath = _resolve(path); + final stat = FileStat.statSync(fsPath); + if (stat.type == FileSystemEntityType.notFound) { + throw SftpNoSuchFileException(path); + } + return _attrsOf(stat); + } + + @override + Future openDir(String path) async { + final fsPath = _resolve(path); + final type = FileSystemEntity.typeSync(fsPath); + if (type == FileSystemEntityType.notFound) { + throw SftpNoSuchFileException(path); + } + if (type != FileSystemEntityType.directory) { + throw SftpFileSystemException('not a directory: $path'); + } + return _IoDirListing(Directory(fsPath)); + } + + @override + Future openFile( + String path, + SftpFileOpenMode mode, + SftpFileAttrs? attrs, + ) async { + final fsPath = _resolve(path); + final type = FileSystemEntity.typeSync(fsPath); + if (type == FileSystemEntityType.directory) { + throw SftpFileSystemException('is a directory: $path'); + } + if (type != FileSystemEntityType.notFound) { + if (mode.flag & SftpFileOpenMode.exclusive.flag != 0) { + throw SftpFileExistsException(path); + } + } else { + if (mode.flag & SftpFileOpenMode.create.flag == 0) { + throw SftpNoSuchFileException(path); + } + final parent = File(fsPath).parent; + if (!parent.existsSync()) { + throw SftpNoSuchFileException(path); + } + } + final wantWrite = mode.flag & SftpFileOpenMode.write.flag != 0; + final file = File(fsPath).openSync( + mode: wantWrite ? FileMode.write : FileMode.read, + ); + if (mode.flag & SftpFileOpenMode.truncate.flag != 0) { + file.truncateSync(0); + } + return _IoSftpHandle( + file, + append: mode.flag & SftpFileOpenMode.append.flag != 0, + ); + } + + @override + Future mkdir(String path, SftpFileAttrs attrs) async { + final fsPath = _resolve(path); + if (FileSystemEntity.typeSync(fsPath) != FileSystemEntityType.notFound) { + throw SftpFileExistsException(path); + } + try { + Directory(fsPath).createSync(); + } on FileSystemException catch (error) { + _throwMapped(error, path); + } + } + + @override + Future rmdir(String path) async { + final fsPath = _resolve(path); + if (fsPath == root) { + throw SftpFileSystemException('cannot remove the root'); + } + if (FileSystemEntity.typeSync(fsPath) == FileSystemEntityType.notFound) { + throw SftpNoSuchFileException(path); + } + try { + Directory(fsPath).deleteSync(); + } on FileSystemException catch (error) { + _throwMapped(error, path); + } + } + + @override + Future unlink(String path) async { + final fsPath = _resolve(path); + final type = FileSystemEntity.typeSync(fsPath); + if (type == FileSystemEntityType.notFound) { + throw SftpNoSuchFileException(path); + } + if (type == FileSystemEntityType.directory) { + throw SftpFileSystemException('is a directory: $path'); + } + try { + File(fsPath).deleteSync(); + } on FileSystemException catch (error) { + _throwMapped(error, path); + } + } + + @override + Future rename(String from, String to) async { + final fsFrom = _resolve(from); + final fsTo = _resolve(to); + if (FileSystemEntity.typeSync(fsFrom) == FileSystemEntityType.notFound) { + throw SftpNoSuchFileException(from); + } + if (FileSystemEntity.typeSync(fsTo) != FileSystemEntityType.notFound) { + throw SftpFileExistsException(to); + } + try { + if (FileSystemEntity.typeSync(fsFrom) == FileSystemEntityType.directory) { + Directory(fsFrom).renameSync(fsTo); + } else { + File(fsFrom).renameSync(fsTo); + } + } on FileSystemException catch (error) { + _throwMapped(error, from); + } + } + + @override + Future realpath(String path) async { + final segments = []; + for (final segment in path.split('/')) { + if (segment.isEmpty || segment == '.') continue; + if (segment == '..') { + if (segments.isNotEmpty) segments.removeLast(); + continue; + } + segments.add(segment); + } + return '/${segments.join('/')}'; + } + + /// The on-disk path for an SFTP path: normalized, then joined under [root]. + String _resolve(String path) => _joinRoot(_normalizeSync(path)); + + String _normalizeSync(String path) { + final segments = []; + for (final segment in path.split('/')) { + if (segment.isEmpty || segment == '.') continue; + if (segment == '..') { + if (segments.isNotEmpty) segments.removeLast(); + continue; + } + segments.add(segment); + } + return '/${segments.join('/')}'; + } + + String _joinRoot(String normalized) => + normalized == '/' ? root : '$root$normalized'; + + static SftpFileAttrs _attrsOf(FileStat stat) => SftpFileAttrs( + size: stat.size, + mode: SftpFileMode.value(stat.mode), + accessTime: stat.accessed.millisecondsSinceEpoch ~/ 1000, + modifyTime: stat.modified.millisecondsSinceEpoch ~/ 1000, + ); + + static Never _throwMapped(FileSystemException error, String path) { + switch (error.osError?.errorCode) { + case 2: // ENOENT + case 20: // ENOTDIR + throw SftpNoSuchFileException(path); + case 13: // EACCES + throw SftpPermissionDeniedException(path); + case 17: // EEXIST + throw SftpFileExistsException(path); + case 39: // ENOTEMPTY + throw SftpFileSystemException('directory not empty: $path'); + default: + throw SftpFileSystemException(error.message); + } + } +} + +class _IoSftpHandle implements SftpHandle { + _IoSftpHandle(this._file, {required this.append}); + + final RandomAccessFile _file; + final bool append; + + /// Serialized operations: the SFTP server dispatches concurrently, but a + /// position+read/write pair on one handle must stay atomic. + Future _lock = Future.value(); + + @override + Future read(int offset, int length) { + return _enqueue(() async { + await _file.setPosition(offset); + return _file.read(length); + }); + } + + @override + Future write(int offset, Uint8List data) { + return _enqueue(() async { + final target = append ? await _file.length() : offset; + await _file.setPosition(target); + await _file.writeFrom(data); + }); + } + + @override + Future close() => _enqueue(_file.close); + + Future _enqueue(Future Function() operation) { + final result = _lock.then((_) => operation()); + _lock = result.then((_) {}, onError: (_) {}); + return result; + } +} + +class _IoDirListing implements SftpDirListing { + _IoDirListing(this._directory); + + final Directory _directory; + bool _closed = false; + + @override + Future> read() async { + if (_closed) return const []; + _closed = true; + final names = []; + await for (final entity in _directory.list()) { + final stat = entity.statSync(); + names.add( + SftpName( + filename: _baseName(entity.path), + longname: _longName(stat, _baseName(entity.path)), + attr: LocalSftpFileSystem._attrsOf(stat), + ), + ); + } + return names; + } + + @override + Future close() async { + _closed = true; + } +} + +String _baseName(String path) { + final index = path.lastIndexOf('/'); + return index < 0 ? path : path.substring(index + 1); +} + +/// A lazy `ls -l`-style long name; clients only display it. +String _longName(FileStat stat, String name) { + final mode = stat.modeString(); + final kind = stat.type == FileSystemEntityType.directory ? 'd' : '-'; + final size = stat.size.toString().padLeft(8); + final mtime = + stat.modified.toIso8601String().substring(0, 16).replaceAll('T', ' '); + final owner = Platform.environment['USER'] ?? 'user'; + return '$kind$mode 1 $owner $size $mtime $name'; +}