feat: proxy cmd support

Fixes #949
This commit is contained in:
lollipopkit🏳️‍⚧️
2025-10-25 20:35:47 +08:00
parent ffda27d057
commit b6ab8f1db5
20 changed files with 1493 additions and 55 deletions

View 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');
}
}

View 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');
}
}

View 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));
}
}

View File

@@ -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,
);
}

View File

@@ -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;
}