import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dartssh2/dartssh2.dart'; import 'package:flutter/foundation.dart'; import 'package:xterm/xterm.dart'; import '../core/failure.dart'; import '../models/server_profile.dart'; import 'host_verifier.dart'; import 'vault.dart'; String failureCode(Object error) { if (error is Failure) return error.code; if (error is TimeoutException) return 'timeout'; if ((error is SSHHandshakeError && error.message == 'Handshake timed out') || (error is SSHAuthAbortError && error.message == 'Authentication timed out')) { return 'timeout'; } if (error is SSHAuthError) return 'authFailed'; if (error is SSHHostkeyError) return 'hostRejected'; if (error is SocketException) return 'networkError'; if (error is SftpStatusError) { return error.code == 3 ? 'permissionDenied' : 'fileError'; } if (error is SSHKeyDecodeError) return 'invalidKey'; return 'operationFailed'; } class SshService extends ChangeNotifier { final Vault vault; final Duration connectTimeout, handshakeTimeout, authTimeout; SSHClient? _client; SSHSession? _shell; SSHSocket? _pendingSocket; ServerProfile? profile; Terminal terminal = Terminal(maxLines: 3000); bool connecting = false; bool get connected => _client != null && _shell != null; String? error; int _epoch = 0; int? handshakeMs; bool ctrlNext = false, altNext = false; SshService( this.vault, { this.connectTimeout = const Duration(seconds: 15), this.handshakeTimeout = const Duration(seconds: 90), this.authTimeout = const Duration(seconds: 30), }); SSHClient get client { if (!connected || !vault.unlocked) throw const Failure('disconnected'); return _client!; } Future connect( ServerProfile target, { required String credential, String? passphrase, required TrustPrompt prompt, }) async { disconnect(); final epoch = _epoch; final vaultEpoch = vault.generation; connecting = true; profile = target; error = null; notifyListeners(); SSHClient? candidate; Object? hostFailure; void check() { if (_epoch != epoch || !vault.unlocked || vaultEpoch != vault.generation) { throw const Failure('locked'); } } try { final identities = target.auth == AuthMethod.privateKey ? SSHKeyPair.fromPem(credential, passphrase) : null; check(); final watch = Stopwatch()..start(); final socket = await SSHSocket.connect( target.host, target.port, timeout: connectTimeout, ); if (_epoch != epoch || !vault.unlocked) { socket.close(); throw const Failure('locked'); } _pendingSocket = socket; candidate = SSHClient( socket, username: target.username, identities: identities, onPasswordRequest: target.auth == AuthMethod.password ? () { check(); return credential; } : null, handshakeTimeout: handshakeTimeout, authTimeout: authTimeout, onVerifyHostKey: (type, fingerprint) async { watch.stop(); try { check(); final accepted = await HostVerifier( vault, ).verify(target.endpoint, type, utf8.decode(fingerprint), prompt); check(); return accepted; } catch (e) { hostFailure = e; return false; } finally { watch.start(); } }, ); unawaited( candidate.done.then( (_) { if (_epoch == epoch) _lost('disconnected'); }, onError: (Object e) { if (_epoch == epoch) _lost(failureCode(hostFailure ?? e)); }, ), ); await candidate.authenticated; check(); handshakeMs = watch.elapsedMilliseconds; final shell = await candidate .shell( pty: const SSHPtyConfig( type: 'xterm-256color', width: 80, height: 24, ), ) .timeout(const Duration(seconds: 15)); check(); _client = candidate; _shell = shell; _pendingSocket = null; terminal = Terminal(maxLines: 3000); terminal.onOutput = (output) { final hadModifier = ctrlNext || altNext; if (ctrlNext && output.length == 1) { final n = output.codeUnitAt(0); if (n >= 0x40 && n <= 0x7f) output = String.fromCharCode(n & 0x1f); } if (altNext) output = '\x1b$output'; ctrlNext = false; altNext = false; if (connected && vault.unlocked) { _shell!.write(Uint8List.fromList(utf8.encode(output))); } if (hadModifier) notifyListeners(); }; terminal.onResize = (w, h, pw, ph) { if (connected) _shell!.resizeTerminal(w, h, pw, ph); }; void consume(Stream stream) { stream .cast>() .transform(const Utf8Decoder(allowMalformed: true)) .listen( (s) { if (_epoch == epoch && vault.unlocked) terminal.write(s); }, onError: (Object e) { if (_epoch == epoch) _lost(failureCode(e)); }, ); } consume(shell.stdout); consume(shell.stderr); unawaited( shell.done.then( (_) { if (_epoch == epoch) _lost('disconnected'); }, onError: (Object e) { if (_epoch == epoch) _lost(failureCode(e)); }, ), ); } catch (e) { if (candidate != null) unawaited(candidate.close()); if (_epoch == epoch) { _pendingSocket?.close(); _pendingSocket = null; error = failureCode(hostFailure ?? e); } } finally { if (_epoch == epoch) { connecting = false; notifyListeners(); } } } void _lost(String code) { disconnect(); error = code; notifyListeners(); } void disconnect({bool clear = false}) { ctrlNext = false; altNext = false; _epoch++; connecting = false; final c = _client; _client = null; _shell?.close(); _shell = null; _pendingSocket?.close(); _pendingSocket = null; if (c != null) unawaited(c.close()); if (clear) { profile = null; terminal = Terminal(maxLines: 3000); error = null; handshakeMs = null; } notifyListeners(); } @override void dispose() { disconnect(clear: true); super.dispose(); } }