mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 15:24:35 +01:00
241
lib/core/utils/executable_manager.dart
Normal file
241
lib/core/utils/executable_manager.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Exception thrown when executable management fails
|
||||
class ExecutableException implements Exception {
|
||||
final String message;
|
||||
|
||||
ExecutableException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'ExecutableException: $message';
|
||||
}
|
||||
|
||||
/// Information about an executable
|
||||
class ExecutableInfo {
|
||||
final String name;
|
||||
final String? spokenName;
|
||||
final String? version;
|
||||
|
||||
ExecutableInfo({required this.name, this.version, this.spokenName});
|
||||
}
|
||||
|
||||
/// Generic executable manager for downloading and managing external tools
|
||||
abstract final class ExecutableManager {
|
||||
static const String _executablesDirName = 'executables';
|
||||
static late final Directory _executablesDir;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
_executablesDir = Directory(path.join(appDir.path, _executablesDirName));
|
||||
if (!await _executablesDir.exists()) {
|
||||
await _executablesDir.create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Predefined executables
|
||||
static final Map<String, ExecutableInfo> _predefinedExecutables = {
|
||||
'cloudflared': ExecutableInfo(name: 'cloudflared'),
|
||||
'ssh': ExecutableInfo(name: 'ssh'),
|
||||
'nc': ExecutableInfo(name: 'nc'),
|
||||
'socat': ExecutableInfo(name: 'socat'),
|
||||
};
|
||||
|
||||
/// Check if an executable exists in PATH or local directory
|
||||
static Future<bool> isExecutableAvailable(String name) async {
|
||||
// First check if it's in PATH
|
||||
final pathExecutable = await _lookupExecutableInSystemPath(name);
|
||||
if (pathExecutable != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check local executables directory
|
||||
final localExecutable = _getLocalExecutablePath(name);
|
||||
if (await localExecutable.exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get the path to an executable (either in PATH or local)
|
||||
static Future<String> getExecutablePath(String name) async {
|
||||
// First check if it's in PATH
|
||||
final pathExecutable = await _lookupExecutableInSystemPath(name);
|
||||
if (pathExecutable != null) {
|
||||
return pathExecutable;
|
||||
}
|
||||
|
||||
// Check local executables directory
|
||||
final localExecutable = _getLocalExecutablePath(name);
|
||||
if (await localExecutable.exists()) {
|
||||
return localExecutable.path;
|
||||
}
|
||||
|
||||
throw ExecutableException('Executable $name not found in PATH or local directory');
|
||||
}
|
||||
|
||||
/// Download an executable if it's not available
|
||||
static Future<String> ensureExecutable(String name) async {
|
||||
if (await isExecutableAvailable(name)) {
|
||||
return await getExecutablePath(name);
|
||||
}
|
||||
|
||||
return await getExecutablePath(name);
|
||||
}
|
||||
|
||||
/// Remove a local executable
|
||||
static Future<void> removeExecutable(String name) async {
|
||||
final localExecutable = _getLocalExecutablePath(name);
|
||||
if (await localExecutable.exists()) {
|
||||
await localExecutable.delete();
|
||||
Loggers.app.info('Removed local executable: $name');
|
||||
}
|
||||
}
|
||||
|
||||
/// List all locally downloaded executables
|
||||
static Future<List<String>> listLocalExecutables() async {
|
||||
if (!await _executablesDir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final executables = <String>[];
|
||||
await for (final entity in _executablesDir.list()) {
|
||||
if (entity is File && _isExecutable(entity)) {
|
||||
executables.add(path.basenameWithoutExtension(entity.path));
|
||||
}
|
||||
}
|
||||
return executables;
|
||||
}
|
||||
|
||||
/// Get the size of a local executable
|
||||
static Future<int> getExecutableSize(String name) async {
|
||||
final localExecutable = _getLocalExecutablePath(name);
|
||||
if (await localExecutable.exists()) {
|
||||
return await localExecutable.length();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Get the version of an executable
|
||||
static Future<String?> getExecutableVersion(String name) async {
|
||||
try {
|
||||
final executablePath = await getExecutablePath(name);
|
||||
|
||||
// Try common version flags
|
||||
final versionFlags = ['--version', '-v', '-V', 'version'];
|
||||
|
||||
for (final flag in versionFlags) {
|
||||
try {
|
||||
final result = await Process.run(executablePath, [flag]);
|
||||
if (result.exitCode == 0) {
|
||||
final output = result.stdout.toString().trim();
|
||||
if (output.isNotEmpty) {
|
||||
return output.split('\n').first; // Return first line only
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Try next flag
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Loggers.app.warning('Error getting version for $name: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate an executable by trying to run it with a help flag
|
||||
static Future<bool> validateExecutable(String name) async {
|
||||
try {
|
||||
final executablePath = await getExecutablePath(name);
|
||||
|
||||
// Try to run the executable with a help flag
|
||||
final helpFlags = ['--help', '-h', '-help'];
|
||||
|
||||
for (final flag in helpFlags) {
|
||||
try {
|
||||
final result = await Process.run(executablePath, [flag]);
|
||||
if (result.exitCode == 0 || result.exitCode == 1) {
|
||||
// Help often returns 1
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Try next flag
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Loggers.app.warning('Error validating $name: $e');
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<String?> _lookupExecutableInSystemPath(String name) async {
|
||||
final command = Platform.isWindows ? 'where' : 'which';
|
||||
try {
|
||||
final result = await Process.run(command, [name]);
|
||||
if (result.exitCode != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final stdoutString = result.stdout.toString().trim();
|
||||
if (stdoutString.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final candidate = stdoutString
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.firstWhere((line) => line.isNotEmpty, orElse: () => '');
|
||||
|
||||
if (candidate.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
} catch (e) {
|
||||
Loggers.app.warning('Error checking PATH for $name: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the local path for an executable
|
||||
static File _getLocalExecutablePath(String name) {
|
||||
final extension = Platform.isWindows ? '.exe' : '';
|
||||
return File(path.join(_executablesDir.path, '$name$extension'));
|
||||
}
|
||||
|
||||
/// Check if a file is executable
|
||||
static bool _isExecutable(File file) {
|
||||
if (Platform.isWindows) {
|
||||
return file.path.endsWith('.exe');
|
||||
} else {
|
||||
// Check file permissions
|
||||
final stat = file.statSync();
|
||||
return (stat.mode & 0x111) != 0; // Check execute bits
|
||||
}
|
||||
}
|
||||
|
||||
/// Get predefined executable info
|
||||
static ExecutableInfo? getExecutableInfo(String name) {
|
||||
return _predefinedExecutables[name];
|
||||
}
|
||||
|
||||
/// Add a custom executable definition
|
||||
static void addCustomExecutable(String name, ExecutableInfo info) {
|
||||
// TODO: Implement persistent storage for custom executables
|
||||
Loggers.app.info('Adding custom executable: $name');
|
||||
}
|
||||
|
||||
/// Remove a custom executable definition
|
||||
static void removeCustomExecutable(String name) {
|
||||
// TODO: Implement persistent storage for custom executables
|
||||
Loggers.app.info('Removing custom executable: $name');
|
||||
}
|
||||
}
|
||||
148
lib/core/utils/proxy_command_executor.dart
Normal file
148
lib/core/utils/proxy_command_executor.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:server_box/core/utils/executable_manager.dart';
|
||||
import 'package:server_box/core/utils/proxy_socket.dart';
|
||||
import 'package:server_box/data/model/server/proxy_command_config.dart';
|
||||
|
||||
/// Exception thrown when proxy command execution fails
|
||||
class ProxyCommandException implements Exception {
|
||||
final String message;
|
||||
final int? exitCode;
|
||||
final String? stdout;
|
||||
final String? stderr;
|
||||
|
||||
ProxyCommandException({required this.message, this.exitCode, this.stdout, this.stderr});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ProxyCommandException: $message'
|
||||
'${exitCode != null ? ' (exit code: $exitCode)' : ''}'
|
||||
'${stderr != null ? '\nStderr: $stderr' : ''}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic proxy command executor that handles SSH ProxyCommand functionality
|
||||
abstract final class ProxyCommandExecutor {
|
||||
/// Execute a proxy command and return a socket connected through the proxy
|
||||
static Future<SSHSocket> executeProxyCommand(
|
||||
ProxyCommandConfig config, {
|
||||
required String hostname,
|
||||
required int port,
|
||||
required String user,
|
||||
}) async {
|
||||
final finalCommand = config.getFinalCommand(hostname: hostname, port: port, user: user);
|
||||
|
||||
Loggers.app.info('Executing proxy command: $finalCommand');
|
||||
|
||||
// Ensure executable is available if required
|
||||
String executablePath;
|
||||
if (config.requiresExecutable && config.executableName != null) {
|
||||
executablePath = await ExecutableManager.ensureExecutable(config.executableName!);
|
||||
} else {
|
||||
// Parse command to get executable
|
||||
final parts = finalCommand.split(' ');
|
||||
final executable = parts.first;
|
||||
executablePath = await ExecutableManager.getExecutablePath(executable);
|
||||
}
|
||||
|
||||
// Parse command and arguments
|
||||
final parts = finalCommand.split(' ');
|
||||
final args = parts.skip(1).toList();
|
||||
|
||||
// Set up environment
|
||||
final environment = {...Platform.environment, ...?config.environment};
|
||||
|
||||
// Start the process
|
||||
Process process;
|
||||
try {
|
||||
process = await Process.start(
|
||||
executablePath,
|
||||
args,
|
||||
workingDirectory: config.workingDirectory,
|
||||
environment: environment,
|
||||
);
|
||||
} catch (e) {
|
||||
throw ProxyCommandException(message: 'Failed to start proxy command: $e', exitCode: -1);
|
||||
}
|
||||
|
||||
// Set up timeout handling
|
||||
var timedOut = false;
|
||||
final timeoutTimer = Timer(config.timeout, () {
|
||||
timedOut = true;
|
||||
process.kill();
|
||||
});
|
||||
|
||||
try {
|
||||
// For ProxyCommand, we create a ProxySocket that wraps the process
|
||||
final socket = ProxySocket(process);
|
||||
|
||||
// Monitor the process for immediate failures
|
||||
unawaited(
|
||||
process.exitCode.then((code) {
|
||||
if (code != 0 && !socket.closed && !timedOut) {
|
||||
socket.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return socket;
|
||||
} catch (e) {
|
||||
process.kill();
|
||||
rethrow;
|
||||
} finally {
|
||||
timeoutTimer.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate proxy command configuration
|
||||
static Future<String?> validateConfig(ProxyCommandConfig config) async {
|
||||
final testCommand = config.getFinalCommand(hostname: 'test.example.com', port: 22, user: 'testuser');
|
||||
|
||||
// Check if required placeholders are present
|
||||
if (!config.command.contains('%h')) {
|
||||
return 'Proxy command must contain %h (hostname) placeholder';
|
||||
}
|
||||
|
||||
// If executable is required, check if it exists
|
||||
if (config.requiresExecutable && config.executableName != null) {
|
||||
try {
|
||||
await ExecutableManager.ensureExecutable(config.executableName!);
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Try to validate command syntax (dry run)
|
||||
final parts = testCommand.split(' ');
|
||||
final executable = parts.first;
|
||||
|
||||
try {
|
||||
await Process.run(executable, ['--help'], runInShell: true);
|
||||
} catch (e) {
|
||||
return 'Command validation failed: $e';
|
||||
}
|
||||
|
||||
return null; // No error
|
||||
}
|
||||
|
||||
/// Get available proxy command presets
|
||||
static Map<String, ProxyCommandConfig> getPresets() {
|
||||
return proxyCommandPresets;
|
||||
}
|
||||
|
||||
/// Add a custom preset
|
||||
static Future<void> addCustomPreset(String name, ProxyCommandConfig config) async {
|
||||
// TODO: Implement custom preset storage
|
||||
// This would involve storing custom presets in a persistent storage
|
||||
Loggers.app.info('Adding custom proxy preset: $name');
|
||||
}
|
||||
|
||||
/// Remove a custom preset
|
||||
static Future<void> removeCustomPreset(String name) async {
|
||||
// TODO: Implement custom preset removal
|
||||
Loggers.app.info('Removing custom proxy preset: $name');
|
||||
}
|
||||
}
|
||||
125
lib/core/utils/proxy_socket.dart
Normal file
125
lib/core/utils/proxy_socket.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
|
||||
/// Socket implementation that communicates through a Process stdin/stdout
|
||||
/// This is used for ProxyCommand functionality where the SSH connection
|
||||
/// is proxied through an external command
|
||||
class ProxySocket implements SSHSocket {
|
||||
final Process _process;
|
||||
final StreamController<Uint8List> _incomingController =
|
||||
StreamController<Uint8List>();
|
||||
final StreamController<List<int>> _outgoingController =
|
||||
StreamController<List<int>>();
|
||||
final Completer<void> _doneCompleter = Completer<void>();
|
||||
|
||||
bool _closed = false;
|
||||
late StreamSubscription<Uint8List> _stdoutSubscription;
|
||||
late StreamSubscription<Uint8List> _stderrSubscription;
|
||||
|
||||
ProxySocket(this._process) {
|
||||
// Set up stdout reading
|
||||
_stdoutSubscription = _process.stdout
|
||||
.transform(Uint8ListStreamTransformer())
|
||||
.listen(_onIncomingData,
|
||||
onError: _onError,
|
||||
onDone: _onProcessDone,
|
||||
cancelOnError: true);
|
||||
|
||||
// Set up stderr reading (for logging)
|
||||
_stderrSubscription = _process.stderr
|
||||
.transform(Uint8ListStreamTransformer())
|
||||
.listen((data) {
|
||||
Loggers.app.warning('Proxy stderr: ${String.fromCharCodes(data)}');
|
||||
});
|
||||
|
||||
// Set up outgoing data
|
||||
_outgoingController.stream.listen(_onOutgoingData);
|
||||
|
||||
// Handle process exit
|
||||
_process.exitCode.then((code) {
|
||||
if (!_closed && code != 0) {
|
||||
_onError('Proxy process exited with code: $code');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Uint8List> get stream => _incomingController.stream;
|
||||
|
||||
@override
|
||||
StreamSink<List<int>> get sink => _outgoingController.sink;
|
||||
|
||||
@override
|
||||
Future<void> get done => _doneCompleter.future;
|
||||
|
||||
/// Check if the socket is closed
|
||||
bool get closed => _closed;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
|
||||
await _stdoutSubscription.cancel();
|
||||
await _stderrSubscription.cancel();
|
||||
await _outgoingController.close();
|
||||
await _incomingController.close();
|
||||
|
||||
if (!_doneCompleter.isCompleted) {
|
||||
_doneCompleter.complete();
|
||||
}
|
||||
|
||||
// Kill the process if it's still running
|
||||
try {
|
||||
_process.kill();
|
||||
} catch (e) {
|
||||
Loggers.app.warning('Error killing proxy process: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void destroy() {
|
||||
close();
|
||||
}
|
||||
|
||||
void _onIncomingData(Uint8List data) {
|
||||
if (!_closed) {
|
||||
_incomingController.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onOutgoingData(List<int> data) {
|
||||
if (!_closed) {
|
||||
_process.stdin.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(dynamic error, [StackTrace? stackTrace]) {
|
||||
if (!_closed) {
|
||||
_incomingController.addError(error, stackTrace);
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
void _onProcessDone() {
|
||||
if (!_closed) {
|
||||
_incomingController.close();
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformer to convert `Stream<List<int>>` to `Stream<Uint8List>`
|
||||
class Uint8ListStreamTransformer
|
||||
extends StreamTransformerBase<List<int>, Uint8List> {
|
||||
const Uint8ListStreamTransformer();
|
||||
|
||||
@override
|
||||
Stream<Uint8List> bind(Stream<List<int>> stream) {
|
||||
return stream.map((data) => Uint8List.fromList(data));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:server_box/core/app_navigator.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/core/utils/proxy_command_executor.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
@@ -68,8 +69,7 @@ Future<SSHClient> genClient(
|
||||
|
||||
String? alterUser;
|
||||
|
||||
final socket = await () async {
|
||||
// Proxy
|
||||
// Check for Jump Server first - this needs special handling
|
||||
final jumpSpi_ = () {
|
||||
// Multi-thread or key login
|
||||
if (jumpSpi != null) return jumpSpi;
|
||||
@@ -77,6 +77,7 @@ Future<SSHClient> genClient(
|
||||
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
|
||||
}();
|
||||
if (jumpSpi_ != null) {
|
||||
// For jump server, we establish connection through the jump client
|
||||
final jumpClient = await genClient(
|
||||
jumpSpi_,
|
||||
privateKey: jumpPrivateKey,
|
||||
@@ -86,25 +87,82 @@ Future<SSHClient> genClient(
|
||||
onHostKeyPrompt: onHostKeyPrompt,
|
||||
);
|
||||
|
||||
return await jumpClient.forwardLocal(spi.ip, spi.port);
|
||||
final forwardChannel = await jumpClient.forwardLocal(spi.ip, spi.port);
|
||||
|
||||
final hostKeyVerifier = _HostKeyVerifier(
|
||||
spi: spi,
|
||||
cache: hostKeyCache,
|
||||
persistCallback: hostKeyPersist,
|
||||
prompt: hostKeyPrompt,
|
||||
);
|
||||
|
||||
final keyId = spi.keyId;
|
||||
if (keyId == null) {
|
||||
onStatus?.call(GenSSHClientStatus.pwd);
|
||||
return SSHClient(
|
||||
forwardChannel,
|
||||
username: spi.user,
|
||||
onPasswordRequest: () => spi.pwd,
|
||||
onUserInfoRequest: onKeyboardInteractive,
|
||||
onVerifyHostKey: hostKeyVerifier.call,
|
||||
);
|
||||
}
|
||||
|
||||
privateKey ??= getPrivateKey(keyId);
|
||||
onStatus?.call(GenSSHClientStatus.key);
|
||||
return SSHClient(
|
||||
forwardChannel,
|
||||
username: spi.user,
|
||||
identities: await compute(loadIndentity, privateKey),
|
||||
onUserInfoRequest: onKeyboardInteractive,
|
||||
onVerifyHostKey: hostKeyVerifier.call,
|
||||
);
|
||||
}
|
||||
|
||||
// Direct
|
||||
// For ProxyCommand and direct connections, get SSHSocket
|
||||
SSHSocket? socket;
|
||||
try {
|
||||
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('genClient', e);
|
||||
if (spi.alterUrl == null) rethrow;
|
||||
try {
|
||||
final res = spi.parseAlterUrl();
|
||||
alterUser = res.$2;
|
||||
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('genClient alterUrl', e);
|
||||
rethrow;
|
||||
// ProxyCommand support - Check for ProxyCommand configuration first
|
||||
if (spi.proxyCommand != null) {
|
||||
try {
|
||||
Loggers.app.info('Connecting via ProxyCommand: ${spi.proxyCommand!.command}');
|
||||
socket = await ProxyCommandExecutor.executeProxyCommand(
|
||||
spi.proxyCommand!,
|
||||
hostname: spi.ip,
|
||||
port: spi.port,
|
||||
user: spi.user,
|
||||
);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('ProxyCommand failed', e);
|
||||
if (!spi.proxyCommand!.retryOnFailure) {
|
||||
rethrow;
|
||||
}
|
||||
// If retry is enabled, fall through to direct connection
|
||||
Loggers.app.info('ProxyCommand failed, falling back to direct connection');
|
||||
}
|
||||
}
|
||||
|
||||
// Direct connection (or fallback)
|
||||
socket ??= await () async {
|
||||
try {
|
||||
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('genClient', e);
|
||||
if (spi.alterUrl == null) rethrow;
|
||||
try {
|
||||
final res = spi.parseAlterUrl();
|
||||
alterUser = res.$2;
|
||||
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('genClient alterUrl', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}();
|
||||
} catch (e) {
|
||||
Loggers.app.warning('Failed to establish connection', e);
|
||||
rethrow;
|
||||
}
|
||||
}();
|
||||
|
||||
final hostKeyVerifier = _HostKeyVerifier(
|
||||
spi: spi,
|
||||
@@ -122,8 +180,6 @@ Future<SSHClient> genClient(
|
||||
onPasswordRequest: () => spi.pwd,
|
||||
onUserInfoRequest: onKeyboardInteractive,
|
||||
onVerifyHostKey: hostKeyVerifier.call,
|
||||
// printDebug: debugPrint,
|
||||
// printTrace: debugPrint,
|
||||
);
|
||||
}
|
||||
privateKey ??= getPrivateKey(keyId);
|
||||
@@ -132,12 +188,9 @@ Future<SSHClient> genClient(
|
||||
return SSHClient(
|
||||
socket,
|
||||
username: spi.user,
|
||||
// Must use [compute] here, instead of [Computer.shared.start]
|
||||
identities: await compute(loadIndentity, privateKey),
|
||||
onUserInfoRequest: onKeyboardInteractive,
|
||||
onVerifyHostKey: hostKeyVerifier.call,
|
||||
// printDebug: debugPrint,
|
||||
// printTrace: debugPrint,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,11 +149,28 @@ abstract final class SSHConfig {
|
||||
|
||||
/// Extract jump host from ProxyJump or ProxyCommand
|
||||
static String? _extractJumpHost(String value) {
|
||||
// For ProxyJump, the format is usually: user@host:port
|
||||
// For ProxyCommand, it's more complex and might need custom parsing
|
||||
if (value.contains('@')) {
|
||||
return value.split(' ').first;
|
||||
// Normalize whitespace
|
||||
final parts = value.trim().split(RegExp(r'\s+'));
|
||||
|
||||
// Try to find a token that looks like a user@host[:port]
|
||||
// This covers common patterns like:
|
||||
// - ProxyJump user@host
|
||||
// - ProxyCommand ssh -W %h:%p user@host
|
||||
for (final token in parts) {
|
||||
if (token.contains('@')) {
|
||||
// Strip any surrounding quotes just in case
|
||||
var cleaned = token;
|
||||
if ((cleaned.startsWith("'") && cleaned.endsWith("'")) ||
|
||||
(cleaned.startsWith('"') && cleaned.endsWith('"'))) {
|
||||
cleaned = cleaned.substring(1, cleaned.length - 1);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyJump may also be provided as just a hostname (no user@)
|
||||
// In that case we don't have enough information to build an oldId-style reference,
|
||||
// so we ignore it here and let the user configure a jump server manually.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user