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 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/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/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'; +} 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..6504c2b66 --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_channel.dart @@ -0,0 +1,448 @@ +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.closeFlushTimeout = const Duration(seconds: 2), + 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; + + /// 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; + + /// 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; + + /// [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; + + /// 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, with the request that triggered it — so the + /// request cannot go stale between concurrent dispatches. + /// + /// 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). + /// + /// 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 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 || _closePending) return; + _flushOutgoing(); + if (_outgoing.isEmpty) { + if (!_sentEof) { + _sendEof(); + } + _finish(); + return; + } + _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 + /// 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; + final handler = onRequest; + if (handler == null) { + _replyToRequest(request, accepted: false); + return; + } + unawaited(() async { + var accepted = false; + try { + accepted = await handler(this, request); + } on Object { + 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() { + if (isClosed) return; + _closeFlushTimer?.cancel(); + _closeFlushTimer = null; + _outgoing.clear(); + _closeInputStreams(); + _done.complete(); + } + + void _enqueueOutgoing(Uint8List data, int? dataTypeCode) { + if (isClosed || _sentEof || _eofPending || _closePending) { + 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(); + } + // 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() { + _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; + _closeFlushTimer?.cancel(); + _closeFlushTimer = null; + 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 new file mode 100644 index 000000000..c7bde0b9e --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_connection.dart @@ -0,0 +1,638 @@ +import 'dart:async'; +import 'dart:typed_data'; + +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' + show SSHServerAuthRequest, SSHServerConfig, tpServerAlgorithms; + +/// Lifecycle phases of an [SSHServerConnection]. +enum _Phase { + /// Handshake done; the client is trying to authenticate. Everything except + /// the service negotiation and userauth is refused. + auth, + + /// Authenticated; session traffic (channels) is served. Reached when a + /// signed `publickey` userauth request both verified and was trusted. + running, + + /// The connection is gone. + closed, +} + +/// One accepted connection: a server-role [SSHTransport] plus the +/// connection-level state machine, the publickey userauth service, and the +/// auth timeout that bounds the pre-authentication phase. +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); + 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(); + } + + /// The socket this connection serves. + final SSHSocket socket; + + final SSHServerConfig _config; + + 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; + + /// 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; + + /// 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 _forwarder?.close(); + 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: + return _handleRunningMessage(payload); + } + } + + 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: + // 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 + // SSH_MSG_UNIMPLEMENTED. + return false; + } + } + + /// 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_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: + 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). `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', + SSH_Message_Global_Request.decode, + payload, + ); + if (message == null) return; + switch (message.requestName) { + case 'tcpip-forward': + case 'cancel-tcpip-forward': + final forwarder = _forwarder; + if (forwarder != null) { + unawaited(forwarder.handleGlobalRequest(message)); + return; + } + // 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( + 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" (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); 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', + 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; + } + + // 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, + ourChannel: ourChannel, + channelType: message.channelType, + peerInitialWindowSize: message.initialWindowSize, + peerMaximumPacketSize: message.maximumPacketSize, + sendPacket: _transport.sendPacket, + 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( + 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]; + + /// 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); + // 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); + _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). + 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. 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). + /// + /// 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; + _config.printDebug?.call( + 'tp_sshd: closing connection after auth timeout ' + '(${_config.authTimeout})', + ); + unawaited(close()); + } + + void _onTransportClosed() { + _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. + void _disconnect(SSHDisconnectReason reason, String description) { + _transport.sendPacket( + SSH_Message_Disconnect( + reasonCode: reason.code, + description: description, + ).encode(), + ); + 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/server_process.dart b/client/packages/tp_sshd/lib/src/server_process.dart new file mode 100644 index 000000000..bdeeda01c --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_process.dart @@ -0,0 +1,266 @@ +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(); +} + +/// 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 +/// 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..31c8e48c4 --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_session.dart @@ -0,0 +1,395 @@ +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 'server_sftp.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. +/// +/// 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; +/// * the `sftp` subsystem — served by the SFTPv3 server over +/// [SSHServerConfig.sftpFileSystem] (see [serveSftpSubsystem]). +/// +/// 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 +/// 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] +/// 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 { + 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); + case SSHChannelRequestType.subsystem: + return _serveSubsystem(channel, request, 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. 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, +) { + 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 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. 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; + final pty = state.pty; + if (pty == null) return false; + 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; + + 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; +} + +/// 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; +} + +/// 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. +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. +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), + ]; + 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() { + 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().catchError((_) {})); + } + + 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(); + }).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(); + }), + ); +} + +/// 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/server_sftp.dart b/client/packages/tp_sshd/lib/src/server_sftp.dart new file mode 100644 index 000000000..d3a0eff8b --- /dev/null +++ b/client/packages/tp_sshd/lib/src/server_sftp.dart @@ -0,0 +1,547 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:math' as math; +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; + +/// 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 +/// 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; + +/// 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. +/// +/// 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. +/// +/// 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 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) { + 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; + } + // 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. + _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 entry = _handles[_decodeHandle(request.handle)]; + final listing = entry?.listing; + if (listing == null) { + _sendStatus(request.requestId, SftpStatusCode.failure, 'Invalid handle'); + return; + } + // 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'); + } 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(); + // 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 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 ' + '${payload.length} bytes exceeds the $_kMaxPacketLength byte limit', + ); + _channel.close(); + return; + } + 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). 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, + 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; + + /// 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/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/sftp_filesystem.dart b/client/packages/tp_sshd/lib/src/sftp_filesystem.dart new file mode 100644 index 000000000..450e9bf36 --- /dev/null +++ b/client/packages/tp_sshd/lib/src/sftp_filesystem.dart @@ -0,0 +1,113 @@ +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`. +/// +/// ## 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); + + /// 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. + /// + /// 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]. + 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 new file mode 100644 index 000000000..96430a1b7 --- /dev/null +++ b/client/packages/tp_sshd/lib/src/ssh_server.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:typed_data'; + +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'; + +/// 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.expectedUsername, + required this.authenticate, + this.authTimeout = const Duration(seconds: 30), + this.maxAuthAttempts = 6, + this.maxChannels = 10, + this.processFactory, + this.ptyFactory, + this.hostInfo, + this.sftpFileSystem, + this.bindServerSocket, + 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; + + /// 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 — 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 + /// before the server closes it. + final Duration authTimeout; + + /// How many authentication attempts a connection may make before the + /// 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 + /// 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; + + /// 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; + + /// 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; + + /// 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 = {}; + + final _done = Completer(); + + /// 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; + + /// 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 { + 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((_) {}); + } + } + + 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..df9115038 --- /dev/null +++ b/client/packages/tp_sshd/lib/tp_sshd.dart @@ -0,0 +1,16 @@ +export 'package:dartssh2/protocol.dart' + show + SSHAlgorithms, + SSHKexType, + SSHHostkeyType, + SSHCipherType, + SSHMacType, + 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'; +export 'src/sftp_filesystem.dart'; +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..ec3684a16 --- /dev/null +++ b/client/packages/tp_sshd/test/dual_test_utils.dart @@ -0,0 +1,272 @@ +// 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'; + +/// Starts an [SSHServer] over a fresh in-memory socket pair and returns a +/// connected, host-key-accepting [SSHClient] plus the server. +/// +/// 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 [], + String username = 'user', + SSHProcessFactory? processFactory, + SSHPtyFactory? ptyFactory, + SSHHostInfo Function()? hostInfo, + SftpFileSystem? sftpFileSystem, + SSHBindServerSocket? bindServerSocket, +}) async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connections = StreamController(); + final server = await SSHServer.bind( + StreamIterator(connections.stream), + config: SSHServerConfig( + hostKeyPair: hostKeyPair, + expectedUsername: username, + authenticate: authenticate, + processFactory: processFactory, + ptyFactory: ptyFactory, + hostInfo: hostInfo, + sftpFileSystem: sftpFileSystem, + bindServerSocket: bindServerSocket, + ), + ); + connections.add(serverSocket); + final client = _connectClient( + clientSocket, + username: username, + 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, + SSHBindServerSocket? bindServerSocket, +}) async { + final (clientSocket, serverSocket) = loopbackSSHSocketPair(); + final connection = SSHServerConnection( + serverSocket, + config: SSHServerConfig( + hostKeyPair: testHostKey, + expectedUsername: 'user', + authenticate: (_) async => true, + bindServerSocket: bindServerSocket, + ), + ); + 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); +} + +/// 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 +/// 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 = ''' +-----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/memory_sftp_filesystem.dart b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart new file mode 100644 index 000000000..5a56bf314 --- /dev/null +++ b/client/packages/tp_sshd/test/memory_sftp_filesystem.dart @@ -0,0 +1,317 @@ +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()}; + + /// 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()..bytes = Uint8List.fromList(bytes); + } + + /// 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)]; + 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), + onReadLength: fileReadLengths.add, + ); + } + + @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, + 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'); + } + 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 new file mode 100644 index 000000000..fd4250959 --- /dev/null +++ b/client/packages/tp_sshd/test/server_channel_test.dart @@ -0,0 +1,575 @@ +@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. + // + // 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, + ) + .having((error) => error.code, 'wire value', 1), + ), + ); + await server.close(); + 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, + 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, request) async { + requests.add(request.requestType); + return true; + }; + final accepted = await clientController.sendEnv('FOO', 'BAR'); + expect(accepted, isTrue); + expect(requests, ['env']); + + await connection.close(); + await client.close(); + }); + + 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); + // The connection wires every channel to handleSessionRequest, which + // serves the structured exec grammar and the pty half (pty-req, env, + // 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); + + 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('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); + 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(); + }); + }); +} 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..eaa92f81a --- /dev/null +++ b/client/packages/tp_sshd/test/server_exec_test.dart @@ -0,0 +1,265 @@ +@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(); + } +} + +/// 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( + 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(); + }); + + 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 new file mode 100644 index 000000000..e9f1800e3 --- /dev/null +++ b/client/packages/tp_sshd/test/server_forward_test.dart @@ -0,0 +1,271 @@ +@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('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, + 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(); +} 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..4ce755ecc --- /dev/null +++ b/client/packages/tp_sshd/test/server_handshake_test.dart @@ -0,0 +1,97 @@ +@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, + expectedUsername: 'user', + 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, + expectedUsername: 'user', + 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(); + }); + + 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_sftp_test.dart b/client/packages/tp_sshd/test/server_sftp_test.dart new file mode 100644 index 000000000..d40d52c71 --- /dev/null +++ b/client/packages/tp_sshd/test/server_sftp_test.dart @@ -0,0 +1,266 @@ +@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; + + 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('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(); + 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(); + }); + + // 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(); + }); + + // 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 new file mode 100644 index 000000000..291999cca --- /dev/null +++ b/client/packages/tp_sshd/test/server_shell_test.dart @@ -0,0 +1,316 @@ +@TestOn('vm') +library; + +import 'dart:async'; +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'; + +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(); + }); + + // 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; +} 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..e2eefd9c7 --- /dev/null +++ b/client/packages/tp_sshd/test/server_userauth_test.dart @@ -0,0 +1,243 @@ +@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(testProbeRequest().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(); + }); +} 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();