137 lines
3.9 KiB
Dart
137 lines
3.9 KiB
Dart
|
|
import 'dart:async';
|
||
|
|
import 'dart:convert';
|
||
|
|
|
||
|
|
import 'package:dartssh2/dartssh2.dart';
|
||
|
|
|
||
|
|
class SshService {
|
||
|
|
SSHClient? _client;
|
||
|
|
SSHSession? _session;
|
||
|
|
Timer? _keepAliveTimer;
|
||
|
|
|
||
|
|
// Broadcast controller so multiple listeners (UI, loggers) can subscribe
|
||
|
|
final StreamController<String> _outputController = StreamController<String>.broadcast();
|
||
|
|
// Controller to notify about disconnection (true = clean exit/0, false = error/drop)
|
||
|
|
final StreamController<bool> _disconnectController = StreamController.broadcast();
|
||
|
|
|
||
|
|
Stream<String> get outputStream => _outputController.stream;
|
||
|
|
Stream<bool> get disconnectStream => _disconnectController.stream;
|
||
|
|
|
||
|
|
bool get isConnected => _client != null && !(_client?.isClosed ?? true);
|
||
|
|
|
||
|
|
Future<void> connect({
|
||
|
|
required String host,
|
||
|
|
required int port,
|
||
|
|
required String username,
|
||
|
|
String? password,
|
||
|
|
String? privateKey,
|
||
|
|
}) async {
|
||
|
|
try {
|
||
|
|
_outputController.add('Connecting to $host:$port as $username...\n');
|
||
|
|
|
||
|
|
final socket = await SSHSocket.connect(host, port);
|
||
|
|
|
||
|
|
_client = SSHClient(
|
||
|
|
socket,
|
||
|
|
username: username,
|
||
|
|
onPasswordRequest: password != null ? () => password : null,
|
||
|
|
identities: privateKey != null ? SSHKeyPair.fromPem(privateKey) : [],
|
||
|
|
);
|
||
|
|
|
||
|
|
_outputController.add('Authenticating...\n');
|
||
|
|
await _client!.authenticated;
|
||
|
|
_outputController.add('Authenticated. Starting shell...\n');
|
||
|
|
|
||
|
|
_startKeepAlive();
|
||
|
|
await _startShell();
|
||
|
|
} catch (e) {
|
||
|
|
_outputController.add('Connection failed: $e\n');
|
||
|
|
await disconnect();
|
||
|
|
rethrow;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _startKeepAlive() {
|
||
|
|
_keepAliveTimer?.cancel();
|
||
|
|
_keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
|
||
|
|
if (!isConnected) {
|
||
|
|
timer.cancel();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
// Send a window change request with the same size.
|
||
|
|
// This generates valid SSH traffic to keep the connection alive.
|
||
|
|
_session?.resizeTerminal(80, 24);
|
||
|
|
} catch (_) {
|
||
|
|
// Fails silently; the main connection handlers will deal with actual drops.
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _startShell() async {
|
||
|
|
if (_client == null) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
_session = await _client!.shell(
|
||
|
|
pty: const SSHPtyConfig(
|
||
|
|
width: 80,
|
||
|
|
height: 24,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
// Listen for session completion (e.g. exit command)
|
||
|
|
_session!.done.then((_) {
|
||
|
|
final code = _session!.exitCode;
|
||
|
|
disconnect(isClean: code == 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Use transform(utf8.decoder) to correctly handle multi-byte characters split across chunks
|
||
|
|
_session!.stdout.cast<List<int>>().transform(const Utf8Decoder()).listen(
|
||
|
|
(data) => _outputController.add(data),
|
||
|
|
onError: (e) => _outputController.add('Error reading stdout: $e\n'),
|
||
|
|
onDone: () {
|
||
|
|
if (isConnected) _outputController.add('Session stdout closed.\n');
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
_session!.stderr.cast<List<int>>().transform(const Utf8Decoder()).listen(
|
||
|
|
(data) => _outputController.add(data),
|
||
|
|
onError: (e) => _outputController.add('Error reading stderr: $e\n'),
|
||
|
|
);
|
||
|
|
|
||
|
|
// Wait for the connection to complete (unexpected drop or client close)
|
||
|
|
_client!.done.then((_) {
|
||
|
|
_outputController.add('Connection closed.\n');
|
||
|
|
disconnect(isClean: false);
|
||
|
|
});
|
||
|
|
|
||
|
|
} catch (e) {
|
||
|
|
_outputController.add('Failed to start shell: $e\n');
|
||
|
|
await disconnect();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void write(String data) {
|
||
|
|
if (_session != null) {
|
||
|
|
_session!.write(utf8.encode(data));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> disconnect({bool isClean = false}) async {
|
||
|
|
_keepAliveTimer?.cancel();
|
||
|
|
if (_client == null && _session == null) return;
|
||
|
|
|
||
|
|
_session?.close();
|
||
|
|
_client?.close();
|
||
|
|
_client = null;
|
||
|
|
_session = null;
|
||
|
|
|
||
|
|
_disconnectController.add(isClean);
|
||
|
|
}
|
||
|
|
|
||
|
|
void dispose() {
|
||
|
|
_keepAliveTimer?.cancel();
|
||
|
|
_outputController.close();
|
||
|
|
_disconnectController.close();
|
||
|
|
}
|
||
|
|
}
|