From b6ab8f1db555eb2fa56f086190c12045f2887b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:35:47 +0800 Subject: [PATCH] feat: proxy cmd support Fixes #949 --- lib/core/utils/executable_manager.dart | 241 ++++++++++++ lib/core/utils/proxy_command_executor.dart | 148 ++++++++ lib/core/utils/proxy_socket.dart | 125 +++++++ lib/core/utils/server.dart | 95 +++-- lib/core/utils/ssh_config.dart | 25 +- .../model/server/proxy_command_config.dart | 74 ++++ .../server/proxy_command_config.freezed.dart | 344 ++++++++++++++++++ .../model/server/proxy_command_config.g.dart | 39 ++ .../model/server/server_private_info.dart | 9 +- .../server/server_private_info.freezed.dart | 75 ++-- .../model/server/server_private_info.g.dart | 6 + lib/data/provider/container.g.dart | 2 +- lib/hive/hive_adapters.g.dart | 7 +- lib/hive/hive_adapters.g.yaml | 4 +- lib/main.dart | 4 + lib/view/page/server/edit/actions.dart | 52 +++ lib/view/page/server/edit/edit.dart | 14 + lib/view/page/server/edit/widget.dart | 126 +++++++ test/proxy_command_test.dart | 137 +++++++ test/ssh_config_test.dart | 21 +- 20 files changed, 1493 insertions(+), 55 deletions(-) create mode 100644 lib/core/utils/executable_manager.dart create mode 100644 lib/core/utils/proxy_command_executor.dart create mode 100644 lib/core/utils/proxy_socket.dart create mode 100644 lib/data/model/server/proxy_command_config.dart create mode 100644 lib/data/model/server/proxy_command_config.freezed.dart create mode 100644 lib/data/model/server/proxy_command_config.g.dart create mode 100644 test/proxy_command_test.dart diff --git a/lib/core/utils/executable_manager.dart b/lib/core/utils/executable_manager.dart new file mode 100644 index 00000000..66c59505 --- /dev/null +++ b/lib/core/utils/executable_manager.dart @@ -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 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 _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 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 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 ensureExecutable(String name) async { + if (await isExecutableAvailable(name)) { + return await getExecutablePath(name); + } + + return await getExecutablePath(name); + } + + /// Remove a local executable + static Future 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> listLocalExecutables() async { + if (!await _executablesDir.exists()) { + return []; + } + + final executables = []; + 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 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 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 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 _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'); + } +} diff --git a/lib/core/utils/proxy_command_executor.dart b/lib/core/utils/proxy_command_executor.dart new file mode 100644 index 00000000..5f4d291b --- /dev/null +++ b/lib/core/utils/proxy_command_executor.dart @@ -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 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 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 getPresets() { + return proxyCommandPresets; + } + + /// Add a custom preset + static Future 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 removeCustomPreset(String name) async { + // TODO: Implement custom preset removal + Loggers.app.info('Removing custom proxy preset: $name'); + } +} diff --git a/lib/core/utils/proxy_socket.dart b/lib/core/utils/proxy_socket.dart new file mode 100644 index 00000000..3f02603c --- /dev/null +++ b/lib/core/utils/proxy_socket.dart @@ -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 _incomingController = + StreamController(); + final StreamController> _outgoingController = + StreamController>(); + final Completer _doneCompleter = Completer(); + + bool _closed = false; + late StreamSubscription _stdoutSubscription; + late StreamSubscription _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 get stream => _incomingController.stream; + + @override + StreamSink> get sink => _outgoingController.sink; + + @override + Future get done => _doneCompleter.future; + + /// Check if the socket is closed + bool get closed => _closed; + + @override + Future 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 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>` to `Stream` +class Uint8ListStreamTransformer + extends StreamTransformerBase, Uint8List> { + const Uint8ListStreamTransformer(); + + @override + Stream bind(Stream> stream) { + return stream.map((data) => Uint8List.fromList(data)); + } +} \ No newline at end of file diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 7ee67a38..86b9b42d 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -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 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 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 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 genClient( onPasswordRequest: () => spi.pwd, onUserInfoRequest: onKeyboardInteractive, onVerifyHostKey: hostKeyVerifier.call, - // printDebug: debugPrint, - // printTrace: debugPrint, ); } privateKey ??= getPrivateKey(keyId); @@ -132,12 +188,9 @@ Future 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, ); } diff --git a/lib/core/utils/ssh_config.dart b/lib/core/utils/ssh_config.dart index 23a62b3e..ff6bebd6 100644 --- a/lib/core/utils/ssh_config.dart +++ b/lib/core/utils/ssh_config.dart @@ -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; } diff --git a/lib/data/model/server/proxy_command_config.dart b/lib/data/model/server/proxy_command_config.dart new file mode 100644 index 00000000..c571b014 --- /dev/null +++ b/lib/data/model/server/proxy_command_config.dart @@ -0,0 +1,74 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'proxy_command_config.freezed.dart'; +part 'proxy_command_config.g.dart'; + +/// ProxyCommand configuration for SSH connections +@freezed +abstract class ProxyCommandConfig with _$ProxyCommandConfig { + const factory ProxyCommandConfig({ + /// Command template with placeholders + /// Available placeholders: %h (hostname), %p (port), %r (user) + required String command, + + /// Command arguments (optional, can be included in command) + List? args, + + /// Working directory for the command + String? workingDirectory, + + /// Environment variables for the command + Map? environment, + + /// Timeout for command execution + @Default(Duration(seconds: 30)) Duration timeout, + + /// Whether to retry on connection failure + @Default(false) bool retryOnFailure, + + /// Maximum retry attempts + @Default(3) int maxRetries, + + /// Whether the proxy command requires executable download + @Default(false) bool requiresExecutable, + + /// Executable name for download management + String? executableName, + + /// Executable download URL + String? executableDownloadUrl, + }) = _ProxyCommandConfig; + + factory ProxyCommandConfig.fromJson(Map json) => _$ProxyCommandConfigFromJson(json); +} + +/// Common proxy command presets +const Map proxyCommandPresets = { + 'cloudflare_access': ProxyCommandConfig( + command: 'cloudflared access ssh --hostname %h', + requiresExecutable: true, + executableName: 'cloudflared', + timeout: Duration(seconds: 15), + ), + 'ssh_via_bastion': ProxyCommandConfig( + command: 'ssh -W %h:%p bastion.example.com', + timeout: Duration(seconds: 10), + ), + 'nc_netcat': ProxyCommandConfig(command: 'nc %h %p', timeout: Duration(seconds: 10)), + 'socat': ProxyCommandConfig( + command: 'socat - PROXY:%h:%p,proxyport=8080', + timeout: Duration(seconds: 10), + ), +}; + +/// Extension for ProxyCommandConfig to add utility methods +extension ProxyCommandConfigExtension on ProxyCommandConfig { + /// Get the final command with placeholders replaced + String getFinalCommand({required String hostname, required int port, required String user}) { + var finalCommand = command; + finalCommand = finalCommand.replaceAll('%h', hostname); + finalCommand = finalCommand.replaceAll('%p', port.toString()); + finalCommand = finalCommand.replaceAll('%r', user); + return finalCommand; + } +} diff --git a/lib/data/model/server/proxy_command_config.freezed.dart b/lib/data/model/server/proxy_command_config.freezed.dart new file mode 100644 index 00000000..216165b2 --- /dev/null +++ b/lib/data/model/server/proxy_command_config.freezed.dart @@ -0,0 +1,344 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'proxy_command_config.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ProxyCommandConfig { + +/// Command template with placeholders +/// Available placeholders: %h (hostname), %p (port), %r (user) + String get command;/// Command arguments (optional, can be included in command) + List? get args;/// Working directory for the command + String? get workingDirectory;/// Environment variables for the command + Map? get environment;/// Timeout for command execution + Duration get timeout;/// Whether to retry on connection failure + bool get retryOnFailure;/// Maximum retry attempts + int get maxRetries;/// Whether the proxy command requires executable download + bool get requiresExecutable;/// Executable name for download management + String? get executableName;/// Executable download URL + String? get executableDownloadUrl; +/// Create a copy of ProxyCommandConfig +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ProxyCommandConfigCopyWith get copyWith => _$ProxyCommandConfigCopyWithImpl(this as ProxyCommandConfig, _$identity); + + /// Serializes this ProxyCommandConfig to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ProxyCommandConfig&&(identical(other.command, command) || other.command == command)&&const DeepCollectionEquality().equals(other.args, args)&&(identical(other.workingDirectory, workingDirectory) || other.workingDirectory == workingDirectory)&&const DeepCollectionEquality().equals(other.environment, environment)&&(identical(other.timeout, timeout) || other.timeout == timeout)&&(identical(other.retryOnFailure, retryOnFailure) || other.retryOnFailure == retryOnFailure)&&(identical(other.maxRetries, maxRetries) || other.maxRetries == maxRetries)&&(identical(other.requiresExecutable, requiresExecutable) || other.requiresExecutable == requiresExecutable)&&(identical(other.executableName, executableName) || other.executableName == executableName)&&(identical(other.executableDownloadUrl, executableDownloadUrl) || other.executableDownloadUrl == executableDownloadUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,command,const DeepCollectionEquality().hash(args),workingDirectory,const DeepCollectionEquality().hash(environment),timeout,retryOnFailure,maxRetries,requiresExecutable,executableName,executableDownloadUrl); + +@override +String toString() { + return 'ProxyCommandConfig(command: $command, args: $args, workingDirectory: $workingDirectory, environment: $environment, timeout: $timeout, retryOnFailure: $retryOnFailure, maxRetries: $maxRetries, requiresExecutable: $requiresExecutable, executableName: $executableName, executableDownloadUrl: $executableDownloadUrl)'; +} + + +} + +/// @nodoc +abstract mixin class $ProxyCommandConfigCopyWith<$Res> { + factory $ProxyCommandConfigCopyWith(ProxyCommandConfig value, $Res Function(ProxyCommandConfig) _then) = _$ProxyCommandConfigCopyWithImpl; +@useResult +$Res call({ + String command, List? args, String? workingDirectory, Map? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl +}); + + + + +} +/// @nodoc +class _$ProxyCommandConfigCopyWithImpl<$Res> + implements $ProxyCommandConfigCopyWith<$Res> { + _$ProxyCommandConfigCopyWithImpl(this._self, this._then); + + final ProxyCommandConfig _self; + final $Res Function(ProxyCommandConfig) _then; + +/// Create a copy of ProxyCommandConfig +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? command = null,Object? args = freezed,Object? workingDirectory = freezed,Object? environment = freezed,Object? timeout = null,Object? retryOnFailure = null,Object? maxRetries = null,Object? requiresExecutable = null,Object? executableName = freezed,Object? executableDownloadUrl = freezed,}) { + return _then(_self.copyWith( +command: null == command ? _self.command : command // ignore: cast_nullable_to_non_nullable +as String,args: freezed == args ? _self.args : args // ignore: cast_nullable_to_non_nullable +as List?,workingDirectory: freezed == workingDirectory ? _self.workingDirectory : workingDirectory // ignore: cast_nullable_to_non_nullable +as String?,environment: freezed == environment ? _self.environment : environment // ignore: cast_nullable_to_non_nullable +as Map?,timeout: null == timeout ? _self.timeout : timeout // ignore: cast_nullable_to_non_nullable +as Duration,retryOnFailure: null == retryOnFailure ? _self.retryOnFailure : retryOnFailure // ignore: cast_nullable_to_non_nullable +as bool,maxRetries: null == maxRetries ? _self.maxRetries : maxRetries // ignore: cast_nullable_to_non_nullable +as int,requiresExecutable: null == requiresExecutable ? _self.requiresExecutable : requiresExecutable // ignore: cast_nullable_to_non_nullable +as bool,executableName: freezed == executableName ? _self.executableName : executableName // ignore: cast_nullable_to_non_nullable +as String?,executableDownloadUrl: freezed == executableDownloadUrl ? _self.executableDownloadUrl : executableDownloadUrl // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ProxyCommandConfig]. +extension ProxyCommandConfigPatterns on ProxyCommandConfig { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ProxyCommandConfig value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ProxyCommandConfig() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ProxyCommandConfig value) $default,){ +final _that = this; +switch (_that) { +case _ProxyCommandConfig(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ProxyCommandConfig value)? $default,){ +final _that = this; +switch (_that) { +case _ProxyCommandConfig() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String command, List? args, String? workingDirectory, Map? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ProxyCommandConfig() when $default != null: +return $default(_that.command,_that.args,_that.workingDirectory,_that.environment,_that.timeout,_that.retryOnFailure,_that.maxRetries,_that.requiresExecutable,_that.executableName,_that.executableDownloadUrl);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String command, List? args, String? workingDirectory, Map? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl) $default,) {final _that = this; +switch (_that) { +case _ProxyCommandConfig(): +return $default(_that.command,_that.args,_that.workingDirectory,_that.environment,_that.timeout,_that.retryOnFailure,_that.maxRetries,_that.requiresExecutable,_that.executableName,_that.executableDownloadUrl);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String command, List? args, String? workingDirectory, Map? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl)? $default,) {final _that = this; +switch (_that) { +case _ProxyCommandConfig() when $default != null: +return $default(_that.command,_that.args,_that.workingDirectory,_that.environment,_that.timeout,_that.retryOnFailure,_that.maxRetries,_that.requiresExecutable,_that.executableName,_that.executableDownloadUrl);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ProxyCommandConfig implements ProxyCommandConfig { + const _ProxyCommandConfig({required this.command, final List? args, this.workingDirectory, final Map? environment, this.timeout = const Duration(seconds: 30), this.retryOnFailure = false, this.maxRetries = 3, this.requiresExecutable = false, this.executableName, this.executableDownloadUrl}): _args = args,_environment = environment; + factory _ProxyCommandConfig.fromJson(Map json) => _$ProxyCommandConfigFromJson(json); + +/// Command template with placeholders +/// Available placeholders: %h (hostname), %p (port), %r (user) +@override final String command; +/// Command arguments (optional, can be included in command) + final List? _args; +/// Command arguments (optional, can be included in command) +@override List? get args { + final value = _args; + if (value == null) return null; + if (_args is EqualUnmodifiableListView) return _args; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +/// Working directory for the command +@override final String? workingDirectory; +/// Environment variables for the command + final Map? _environment; +/// Environment variables for the command +@override Map? get environment { + final value = _environment; + if (value == null) return null; + if (_environment is EqualUnmodifiableMapView) return _environment; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + +/// Timeout for command execution +@override@JsonKey() final Duration timeout; +/// Whether to retry on connection failure +@override@JsonKey() final bool retryOnFailure; +/// Maximum retry attempts +@override@JsonKey() final int maxRetries; +/// Whether the proxy command requires executable download +@override@JsonKey() final bool requiresExecutable; +/// Executable name for download management +@override final String? executableName; +/// Executable download URL +@override final String? executableDownloadUrl; + +/// Create a copy of ProxyCommandConfig +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ProxyCommandConfigCopyWith<_ProxyCommandConfig> get copyWith => __$ProxyCommandConfigCopyWithImpl<_ProxyCommandConfig>(this, _$identity); + +@override +Map toJson() { + return _$ProxyCommandConfigToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProxyCommandConfig&&(identical(other.command, command) || other.command == command)&&const DeepCollectionEquality().equals(other._args, _args)&&(identical(other.workingDirectory, workingDirectory) || other.workingDirectory == workingDirectory)&&const DeepCollectionEquality().equals(other._environment, _environment)&&(identical(other.timeout, timeout) || other.timeout == timeout)&&(identical(other.retryOnFailure, retryOnFailure) || other.retryOnFailure == retryOnFailure)&&(identical(other.maxRetries, maxRetries) || other.maxRetries == maxRetries)&&(identical(other.requiresExecutable, requiresExecutable) || other.requiresExecutable == requiresExecutable)&&(identical(other.executableName, executableName) || other.executableName == executableName)&&(identical(other.executableDownloadUrl, executableDownloadUrl) || other.executableDownloadUrl == executableDownloadUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,command,const DeepCollectionEquality().hash(_args),workingDirectory,const DeepCollectionEquality().hash(_environment),timeout,retryOnFailure,maxRetries,requiresExecutable,executableName,executableDownloadUrl); + +@override +String toString() { + return 'ProxyCommandConfig(command: $command, args: $args, workingDirectory: $workingDirectory, environment: $environment, timeout: $timeout, retryOnFailure: $retryOnFailure, maxRetries: $maxRetries, requiresExecutable: $requiresExecutable, executableName: $executableName, executableDownloadUrl: $executableDownloadUrl)'; +} + + +} + +/// @nodoc +abstract mixin class _$ProxyCommandConfigCopyWith<$Res> implements $ProxyCommandConfigCopyWith<$Res> { + factory _$ProxyCommandConfigCopyWith(_ProxyCommandConfig value, $Res Function(_ProxyCommandConfig) _then) = __$ProxyCommandConfigCopyWithImpl; +@override @useResult +$Res call({ + String command, List? args, String? workingDirectory, Map? environment, Duration timeout, bool retryOnFailure, int maxRetries, bool requiresExecutable, String? executableName, String? executableDownloadUrl +}); + + + + +} +/// @nodoc +class __$ProxyCommandConfigCopyWithImpl<$Res> + implements _$ProxyCommandConfigCopyWith<$Res> { + __$ProxyCommandConfigCopyWithImpl(this._self, this._then); + + final _ProxyCommandConfig _self; + final $Res Function(_ProxyCommandConfig) _then; + +/// Create a copy of ProxyCommandConfig +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? command = null,Object? args = freezed,Object? workingDirectory = freezed,Object? environment = freezed,Object? timeout = null,Object? retryOnFailure = null,Object? maxRetries = null,Object? requiresExecutable = null,Object? executableName = freezed,Object? executableDownloadUrl = freezed,}) { + return _then(_ProxyCommandConfig( +command: null == command ? _self.command : command // ignore: cast_nullable_to_non_nullable +as String,args: freezed == args ? _self._args : args // ignore: cast_nullable_to_non_nullable +as List?,workingDirectory: freezed == workingDirectory ? _self.workingDirectory : workingDirectory // ignore: cast_nullable_to_non_nullable +as String?,environment: freezed == environment ? _self._environment : environment // ignore: cast_nullable_to_non_nullable +as Map?,timeout: null == timeout ? _self.timeout : timeout // ignore: cast_nullable_to_non_nullable +as Duration,retryOnFailure: null == retryOnFailure ? _self.retryOnFailure : retryOnFailure // ignore: cast_nullable_to_non_nullable +as bool,maxRetries: null == maxRetries ? _self.maxRetries : maxRetries // ignore: cast_nullable_to_non_nullable +as int,requiresExecutable: null == requiresExecutable ? _self.requiresExecutable : requiresExecutable // ignore: cast_nullable_to_non_nullable +as bool,executableName: freezed == executableName ? _self.executableName : executableName // ignore: cast_nullable_to_non_nullable +as String?,executableDownloadUrl: freezed == executableDownloadUrl ? _self.executableDownloadUrl : executableDownloadUrl // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/data/model/server/proxy_command_config.g.dart b/lib/data/model/server/proxy_command_config.g.dart new file mode 100644 index 00000000..df4d4340 --- /dev/null +++ b/lib/data/model/server/proxy_command_config.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'proxy_command_config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ProxyCommandConfig _$ProxyCommandConfigFromJson(Map json) => + _ProxyCommandConfig( + command: json['command'] as String, + args: (json['args'] as List?)?.map((e) => e as String).toList(), + workingDirectory: json['workingDirectory'] as String?, + environment: (json['environment'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + timeout: json['timeout'] == null + ? const Duration(seconds: 30) + : Duration(microseconds: (json['timeout'] as num).toInt()), + retryOnFailure: json['retryOnFailure'] as bool? ?? false, + maxRetries: (json['maxRetries'] as num?)?.toInt() ?? 3, + requiresExecutable: json['requiresExecutable'] as bool? ?? false, + executableName: json['executableName'] as String?, + executableDownloadUrl: json['executableDownloadUrl'] as String?, + ); + +Map _$ProxyCommandConfigToJson(_ProxyCommandConfig instance) => + { + 'command': instance.command, + 'args': instance.args, + 'workingDirectory': instance.workingDirectory, + 'environment': instance.environment, + 'timeout': instance.timeout.inMicroseconds, + 'retryOnFailure': instance.retryOnFailure, + 'maxRetries': instance.maxRetries, + 'requiresExecutable': instance.requiresExecutable, + 'executableName': instance.executableName, + 'executableDownloadUrl': instance.executableDownloadUrl, + }; diff --git a/lib/data/model/server/server_private_info.dart b/lib/data/model/server/server_private_info.dart index 045b34af..5dad420c 100644 --- a/lib/data/model/server/server_private_info.dart +++ b/lib/data/model/server/server_private_info.dart @@ -4,6 +4,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/custom.dart'; +import 'package:server_box/data/model/server/proxy_command_config.dart'; import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/model/server/wol_cfg.dart'; import 'package:server_box/data/store/server.dart'; @@ -45,14 +46,18 @@ abstract class Spi with _$Spi { @Default('') @JsonKey(fromJson: Spi.parseId) String id, /// Custom system type (unix or windows). If set, skip auto-detection. - @JsonKey(includeIfNull: false) SystemType? customSystemType, + SystemType? customSystemType, /// Disabled command types for this server - @JsonKey(includeIfNull: false) List? disabledCmdTypes, + List? disabledCmdTypes, + + /// ProxyCommand configuration for SSH connections + ProxyCommandConfig? proxyCommand, }) = _Spi; factory Spi.fromJson(Map json) => _$SpiFromJson(json); + @override String toString() => 'Spi<$oldId>'; diff --git a/lib/data/model/server/server_private_info.freezed.dart b/lib/data/model/server/server_private_info.freezed.dart index 89db2a0c..e39eed9c 100644 --- a/lib/data/model/server/server_private_info.freezed.dart +++ b/lib/data/model/server/server_private_info.freezed.dart @@ -19,8 +19,9 @@ mixin _$Spi { @JsonKey(name: 'pubKeyId') String? get keyId; List? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server String? get jumpId; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal. Map? get envs;@JsonKey(fromJson: Spi.parseId) String get id;/// Custom system type (unix or windows). If set, skip auto-detection. -@JsonKey(includeIfNull: false) SystemType? get customSystemType;/// Disabled command types for this server -@JsonKey(includeIfNull: false) List? get disabledCmdTypes; + SystemType? get customSystemType;/// Disabled command types for this server + List? get disabledCmdTypes;/// ProxyCommand configuration for SSH connections + ProxyCommandConfig? get proxyCommand; /// Create a copy of Spi /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -33,12 +34,12 @@ $SpiCopyWith get copyWith => _$SpiCopyWithImpl(this as Spi, _$identity @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes)&&(identical(other.proxyCommand, proxyCommand) || other.proxyCommand == proxyCommand)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes)); +int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes),proxyCommand); @@ -49,11 +50,11 @@ abstract mixin class $SpiCopyWith<$Res> { factory $SpiCopyWith(Spi value, $Res Function(Spi) _then) = _$SpiCopyWithImpl; @useResult $Res call({ - String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes + String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List? disabledCmdTypes, ProxyCommandConfig? proxyCommand }); - +$ProxyCommandConfigCopyWith<$Res>? get proxyCommand; } /// @nodoc @@ -66,7 +67,7 @@ class _$SpiCopyWithImpl<$Res> /// Create a copy of Spi /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,Object? proxyCommand = freezed,}) { return _then(_self.copyWith( name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable @@ -84,10 +85,23 @@ as WakeOnLanCfg?,envs: freezed == envs ? _self.envs : envs // ignore: cast_nulla as Map?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,customSystemType: freezed == customSystemType ? _self.customSystemType : customSystemType // ignore: cast_nullable_to_non_nullable as SystemType?,disabledCmdTypes: freezed == disabledCmdTypes ? _self.disabledCmdTypes : disabledCmdTypes // ignore: cast_nullable_to_non_nullable -as List?, +as List?,proxyCommand: freezed == proxyCommand ? _self.proxyCommand : proxyCommand // ignore: cast_nullable_to_non_nullable +as ProxyCommandConfig?, )); } +/// Create a copy of Spi +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ProxyCommandConfigCopyWith<$Res>? get proxyCommand { + if (_self.proxyCommand == null) { + return null; + } + return $ProxyCommandConfigCopyWith<$Res>(_self.proxyCommand!, (value) { + return _then(_self.copyWith(proxyCommand: value)); + }); +} } @@ -169,10 +183,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List? disabledCmdTypes, ProxyCommandConfig? proxyCommand)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _Spi() when $default != null: -return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: +return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes,_that.proxyCommand);case _: return orElse(); } @@ -190,10 +204,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List? disabledCmdTypes, ProxyCommandConfig? proxyCommand) $default,) {final _that = this; switch (_that) { case _Spi(): -return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: +return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes,_that.proxyCommand);case _: throw StateError('Unexpected subclass'); } @@ -210,10 +224,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List? disabledCmdTypes, ProxyCommandConfig? proxyCommand)? $default,) {final _that = this; switch (_that) { case _Spi() when $default != null: -return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: +return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes,_that.proxyCommand);case _: return null; } @@ -225,7 +239,7 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId, @JsonSerializable(includeIfNull: false) class _Spi extends Spi { - const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List? disabledCmdTypes}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._(); + const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', this.customSystemType, final List? disabledCmdTypes, this.proxyCommand}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._(); factory _Spi.fromJson(Map json) => _$SpiFromJson(json); @override final String name; @@ -263,11 +277,11 @@ class _Spi extends Spi { @override@JsonKey(fromJson: Spi.parseId) final String id; /// Custom system type (unix or windows). If set, skip auto-detection. -@override@JsonKey(includeIfNull: false) final SystemType? customSystemType; +@override final SystemType? customSystemType; /// Disabled command types for this server final List? _disabledCmdTypes; /// Disabled command types for this server -@override@JsonKey(includeIfNull: false) List? get disabledCmdTypes { +@override List? get disabledCmdTypes { final value = _disabledCmdTypes; if (value == null) return null; if (_disabledCmdTypes is EqualUnmodifiableListView) return _disabledCmdTypes; @@ -275,6 +289,8 @@ class _Spi extends Spi { return EqualUnmodifiableListView(value); } +/// ProxyCommand configuration for SSH connections +@override final ProxyCommandConfig? proxyCommand; /// Create a copy of Spi /// with the given fields replaced by the non-null parameter values. @@ -289,12 +305,12 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes)&&(identical(other.proxyCommand, proxyCommand) || other.proxyCommand == proxyCommand)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes)); +int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes),proxyCommand); @@ -305,11 +321,11 @@ abstract mixin class _$SpiCopyWith<$Res> implements $SpiCopyWith<$Res> { factory _$SpiCopyWith(_Spi value, $Res Function(_Spi) _then) = __$SpiCopyWithImpl; @override @useResult $Res call({ - String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes + String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id, SystemType? customSystemType, List? disabledCmdTypes, ProxyCommandConfig? proxyCommand }); - +@override $ProxyCommandConfigCopyWith<$Res>? get proxyCommand; } /// @nodoc @@ -322,7 +338,7 @@ class __$SpiCopyWithImpl<$Res> /// Create a copy of Spi /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,Object? proxyCommand = freezed,}) { return _then(_Spi( name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable @@ -340,11 +356,24 @@ as WakeOnLanCfg?,envs: freezed == envs ? _self._envs : envs // ignore: cast_null as Map?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,customSystemType: freezed == customSystemType ? _self.customSystemType : customSystemType // ignore: cast_nullable_to_non_nullable as SystemType?,disabledCmdTypes: freezed == disabledCmdTypes ? _self._disabledCmdTypes : disabledCmdTypes // ignore: cast_nullable_to_non_nullable -as List?, +as List?,proxyCommand: freezed == proxyCommand ? _self.proxyCommand : proxyCommand // ignore: cast_nullable_to_non_nullable +as ProxyCommandConfig?, )); } +/// Create a copy of Spi +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ProxyCommandConfigCopyWith<$Res>? get proxyCommand { + if (_self.proxyCommand == null) { + return null; + } + return $ProxyCommandConfigCopyWith<$Res>(_self.proxyCommand!, (value) { + return _then(_self.copyWith(proxyCommand: value)); + }); +} } // dart format on diff --git a/lib/data/model/server/server_private_info.g.dart b/lib/data/model/server/server_private_info.g.dart index a3fc1653..86315d8a 100644 --- a/lib/data/model/server/server_private_info.g.dart +++ b/lib/data/model/server/server_private_info.g.dart @@ -34,6 +34,11 @@ _Spi _$SpiFromJson(Map json) => _Spi( disabledCmdTypes: (json['disabledCmdTypes'] as List?) ?.map((e) => e as String) .toList(), + proxyCommand: json['proxyCommand'] == null + ? null + : ProxyCommandConfig.fromJson( + json['proxyCommand'] as Map, + ), ); Map _$SpiToJson(_Spi instance) => { @@ -53,6 +58,7 @@ Map _$SpiToJson(_Spi instance) => { 'id': instance.id, 'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType], 'disabledCmdTypes': ?instance.disabledCmdTypes, + 'proxyCommand': instance.proxyCommand?.toJson(), }; const _$SystemTypeEnumMap = { diff --git a/lib/data/provider/container.g.dart b/lib/data/provider/container.g.dart index 21c7e085..47eb4d7d 100644 --- a/lib/data/provider/container.g.dart +++ b/lib/data/provider/container.g.dart @@ -58,7 +58,7 @@ final class ContainerNotifierProvider } } -String _$containerNotifierHash() => r'fea65e66499234b0a59bffff8d69c4ab8c93b2fd'; +String _$containerNotifierHash() => r'e6ced8a914631253daabe0de452e0338078cd1d9'; final class ContainerNotifierFamily extends $Family with diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index c456396b..c523270c 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -113,13 +113,14 @@ class SpiAdapter extends TypeAdapter { id: fields[13] == null ? '' : fields[13] as String, customSystemType: fields[14] as SystemType?, disabledCmdTypes: (fields[15] as List?)?.cast(), + proxyCommand: fields[16] as ProxyCommandConfig?, ); } @override void write(BinaryWriter writer, Spi obj) { writer - ..writeByte(16) + ..writeByte(17) ..writeByte(0) ..write(obj.name) ..writeByte(1) @@ -151,7 +152,9 @@ class SpiAdapter extends TypeAdapter { ..writeByte(14) ..write(obj.customSystemType) ..writeByte(15) - ..write(obj.disabledCmdTypes); + ..write(obj.disabledCmdTypes) + ..writeByte(16) + ..write(obj.proxyCommand); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 94d426fe..b79c1bbc 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -27,7 +27,7 @@ types: index: 4 Spi: typeId: 3 - nextIndex: 16 + nextIndex: 17 fields: name: index: 0 @@ -61,6 +61,8 @@ types: index: 14 disabledCmdTypes: index: 15 + proxyCommand: + index: 16 VirtKey: typeId: 4 nextIndex: 45 diff --git a/lib/main.dart b/lib/main.dart index 3d51bf41..44795239 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:logging/logging.dart'; import 'package:server_box/app.dart'; +import 'package:server_box/core/utils/executable_manager.dart'; import 'package:server_box/data/model/app/menu/server_func.dart'; import 'package:server_box/data/model/app/server_detail_card.dart'; import 'package:server_box/data/res/build_data.dart'; @@ -57,6 +58,9 @@ Future _initData() async { await PrefStore.shared.init(); // Call this before accessing any store await Stores.init(); + // Initialize executable manager + await ExecutableManager.initialize(); + // It may effect the following logic, so await it. // DO DB migration before load any provider. await _doDbMigrate(); diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index 7b6d30c7..aed02823 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -265,6 +265,35 @@ extension _Actions on _ServerEditPageState { } } + // ProxyCommand configuration + ProxyCommandConfig? proxyCommand; + if (_proxyCommandEnabled.value) { + final command = _proxyCommandController.text.trim(); + if (command.isEmpty) { + context.showSnackBar('ProxyCommand is enabled but command is empty'); + return; + } + + // Check if command contains required placeholders + if (!command.contains('%h')) { + context.showSnackBar('ProxyCommand must contain %h (hostname) placeholder'); + return; + } + + // Determine if this requires an executable + final parts = command.split(' '); + final executable = parts.first; + final requiresExecutable = !['ssh', 'nc', 'socat'].contains(executable); + + proxyCommand = ProxyCommandConfig( + command: command, + timeout: Duration(seconds: _proxyCommandTimeout.value), + retryOnFailure: true, + requiresExecutable: requiresExecutable, + executableName: requiresExecutable ? executable : null, + ); + } + final spi = Spi( name: _nameController.text.isEmpty ? _ipController.text : _nameController.text, ip: _ipController.text, @@ -284,6 +313,7 @@ extension _Actions on _ServerEditPageState { id: widget.args?.spi.id ?? ShortId.generate(), customSystemType: _systemType.value, disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(), + proxyCommand: proxyCommand, ); if (this.spi == null) { @@ -450,5 +480,27 @@ extension _Utils on _ServerEditPageState { final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName); disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e)); _disabledCmdTypes.value = disabledCmdTypes; + + // Load ProxyCommand configuration + final proxyCommand = spi.proxyCommand; + if (proxyCommand != null) { + _proxyCommandEnabled.value = true; + _proxyCommandController.text = proxyCommand.command; + _proxyCommandTimeout.value = proxyCommand.timeout.inSeconds; + + // Try to match with a preset + final presets = ProxyCommandExecutor.getPresets(); + for (final entry in presets.entries) { + if (entry.value.command == proxyCommand.command) { + _proxyCommandPreset.value = entry.key; + break; + } + } + } else { + _proxyCommandEnabled.value = false; + _proxyCommandController.text = ''; + _proxyCommandTimeout.value = 30; + _proxyCommandPreset.value = null; + } } } diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 75ec62e9..582de702 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -9,11 +9,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; +import 'package:server_box/core/utils/proxy_command_executor.dart'; import 'package:server_box/core/utils/server_dedup.dart'; import 'package:server_box/core/utils/ssh_config.dart'; import 'package:server_box/data/model/app/scripts/cmd_types.dart'; import 'package:server_box/data/model/server/custom.dart'; import 'package:server_box/data/model/server/discovery_result.dart'; +import 'package:server_box/data/model/server/proxy_command_config.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/model/server/wol_cfg.dart'; @@ -74,6 +76,12 @@ class _ServerEditPageState extends ConsumerState with AfterLayou final _systemType = ValueNotifier(null); final _disabledCmdTypes = {}.vn; + // ProxyCommand fields + final _proxyCommandEnabled = ValueNotifier(false); + final _proxyCommandController = TextEditingController(); + final _proxyCommandPreset = nvn(); + final _proxyCommandTimeout = ValueNotifier(30); + @override void dispose() { super.dispose(); @@ -107,6 +115,11 @@ class _ServerEditPageState extends ConsumerState with AfterLayou _tags.dispose(); _systemType.dispose(); _disabledCmdTypes.dispose(); + + _proxyCommandEnabled.dispose(); + _proxyCommandController.dispose(); + _proxyCommandPreset.dispose(); + _proxyCommandTimeout.dispose(); } @override @@ -200,6 +213,7 @@ class _ServerEditPageState extends ConsumerState with AfterLayou _buildAuth(), _buildSystemType(), _buildJumpServer(), + _buildProxyCommand(), _buildMore(), ]; return AutoMultiList(children: children); diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index 447e9ee0..0c5c4d2b 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -449,6 +449,115 @@ extension _Widgets on _ServerEditPageState { ); } + Widget _buildProxyCommand() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + title: Text('ProxyCommand'), + subtitle: Text('Use a proxy command for SSH connection'), + trailing: _proxyCommandEnabled.listenVal( + (enabled) => Switch( + value: enabled, + onChanged: (value) { + _proxyCommandEnabled.value = value; + if (value && _proxyCommandController.text.isEmpty) { + // Set default preset when enabled + _proxyCommandPreset.value = 'cloudflare_access'; + _proxyCommandController.text = 'cloudflared access ssh --hostname %h'; + } + }, + ), + ), + ), + _proxyCommandEnabled.listenVal((enabled) { + if (!enabled) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Preset selection + Text('Presets:', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + _proxyCommandPreset.listenVal((preset) { + final presets = ProxyCommandExecutor.getPresets(); + return Wrap( + spacing: 8, + runSpacing: 8, + children: presets.entries.map((entry) { + final isSelected = preset == entry.key; + return FilterChip( + label: Text(_getPresetDisplayName(entry.key)), + selected: isSelected, + onSelected: (selected) { + if (selected) { + _proxyCommandPreset.value = entry.key; + _proxyCommandController.text = entry.value.command; + } + }, + ); + }).toList(), + ); + }), + + const SizedBox(height: 16), + + // Custom command input + Input( + controller: _proxyCommandController, + type: TextInputType.text, + label: 'Proxy Command', + icon: Icons.settings_ethernet, + hint: 'e.g., cloudflared access ssh --hostname %h', + suggestion: false, + ), + + const SizedBox(height: 8), + + // Help text + Text( + 'Available placeholders:\n' + '• %h - hostname\n' + '• %p - port\n' + '• %r - username', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + ), + + const SizedBox(height: 16), + + // Timeout setting + _proxyCommandTimeout.listenVal((timeout) { + return ListTile( + title: Text('Connection Timeout'), + subtitle: Text('$timeout seconds'), + trailing: DropdownButton( + value: timeout, + items: [10, 30, 60, 120].map((seconds) { + return DropdownMenuItem( + value: seconds, + child: Text('${seconds}s'), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + _proxyCommandTimeout.value = value; + } + }, + ), + ); + }), + ], + ), + ); + }), + ], + ); + } + Widget _buildDelBtn() { return IconButton( onPressed: () { @@ -472,4 +581,21 @@ extension _Widgets on _ServerEditPageState { icon: const Icon(Icons.delete), ); } + + String _getPresetDisplayName(String presetKey) { + switch (presetKey) { + case 'cloudflare_access': + return 'Cloudflare Access'; + case 'ssh_via_bastion': + return 'SSH via Bastion'; + case 'nc_netcat': + return 'Netcat'; + case 'socat': + return 'Socat'; + default: + return presetKey.split('_').map((word) => + word[0].toUpperCase() + word.substring(1) + ).join(' '); + } + } } diff --git a/test/proxy_command_test.dart b/test/proxy_command_test.dart new file mode 100644 index 00000000..4a4fe3e5 --- /dev/null +++ b/test/proxy_command_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/data/model/server/proxy_command_config.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; + +void main() { + group('ProxyCommandConfig Tests', () { + test('should create ProxyCommandConfig with required fields', () { + const config = ProxyCommandConfig( + command: 'cloudflared access ssh --hostname %h', + timeout: Duration(seconds: 30), + requiresExecutable: true, + executableName: 'cloudflared', + ); + + expect(config.command, equals('cloudflared access ssh --hostname %h')); + expect(config.timeout.inSeconds, equals(30)); + expect(config.requiresExecutable, isTrue); + expect(config.executableName, equals('cloudflared')); + }); + + test('should get final command with placeholders replaced', () { + const config = ProxyCommandConfig( + command: 'cloudflared access ssh --hostname %h --port %p', + timeout: Duration(seconds: 30), + ); + + final finalCommand = config.getFinalCommand( + hostname: 'example.com', + port: 22, + user: 'testuser', + ); + + expect(finalCommand, equals('cloudflared access ssh --hostname example.com --port 22')); + }); + + test('should handle all placeholders correctly', () { + const config = ProxyCommandConfig( + command: 'ssh -W %h:%p -l %r bastion.example.com', + timeout: Duration(seconds: 10), + ); + + final finalCommand = config.getFinalCommand( + hostname: 'target.example.com', + port: 2222, + user: 'admin', + ); + + expect(finalCommand, equals('ssh -W target.example.com:2222 -l admin bastion.example.com')); + }); + + test('should validate presets from map', () { + final presets = proxyCommandPresets; + + final cloudflareConfig = presets['cloudflare_access']; + expect(cloudflareConfig, isNotNull); + expect(cloudflareConfig!.command, equals('cloudflared access ssh --hostname %h')); + expect(cloudflareConfig.requiresExecutable, isTrue); + expect(cloudflareConfig.executableName, equals('cloudflared')); + + final sshBastionConfig = presets['ssh_via_bastion']; + expect(sshBastionConfig, isNotNull); + expect(sshBastionConfig!.command, equals('ssh -W %h:%p bastion.example.com')); + expect(sshBastionConfig.requiresExecutable, isFalse); + + final ncConfig = presets['nc_netcat']; + expect(ncConfig, isNotNull); + expect(ncConfig!.command, equals('nc %h %p')); + expect(ncConfig.requiresExecutable, isFalse); + + final socatConfig = presets['socat']; + expect(socatConfig, isNotNull); + expect(socatConfig!.command, equals('socat - PROXY:%h:%p,proxyport=8080')); + expect(socatConfig.requiresExecutable, isFalse); + }); + }); + + group('Spi with ProxyCommand Tests', () { + test('should create Spi with ProxyCommand configuration', () { + final spi = Spi( + name: 'Test Server', + ip: 'example.com', + port: 22, + user: 'testuser', + pwd: 'testpass', + proxyCommand: const ProxyCommandConfig( + command: 'cloudflared access ssh --hostname %h', + timeout: Duration(seconds: 30), + requiresExecutable: true, + executableName: 'cloudflared', + ), + ); + + expect(spi.name, equals('Test Server')); + expect(spi.proxyCommand, isNotNull); + expect(spi.proxyCommand!.command, equals('cloudflared access ssh --hostname %h')); + expect(spi.proxyCommand!.requiresExecutable, isTrue); + }); + + test('should handle Spi without ProxyCommand', () { + final spi = Spi( + name: 'Test Server', + ip: 'example.com', + port: 22, + user: 'testuser', + pwd: 'testpass', + ); + + expect(spi.proxyCommand, isNull); + }); + + test('should serialize and deserialize Spi with ProxyCommand', () { + final originalSpi = Spi( + name: 'Test Server', + ip: 'example.com', + port: 22, + user: 'testuser', + pwd: 'testpass', + proxyCommand: const ProxyCommandConfig( + command: 'cloudflared access ssh --hostname %h', + timeout: Duration(seconds: 30), + requiresExecutable: true, + executableName: 'cloudflared', + ), + ); + + final json = originalSpi.toJson(); + final deserializedSpi = Spi.fromJson(json); + + expect(deserializedSpi.name, equals(originalSpi.name)); + expect(deserializedSpi.ip, equals(originalSpi.ip)); + expect(deserializedSpi.proxyCommand, isNotNull); + expect(deserializedSpi.proxyCommand!.command, equals(originalSpi.proxyCommand!.command)); + expect(deserializedSpi.proxyCommand!.requiresExecutable, equals(originalSpi.proxyCommand!.requiresExecutable)); + expect(deserializedSpi.proxyCommand!.executableName, equals(originalSpi.proxyCommand!.executableName)); + }); + }); +} \ No newline at end of file diff --git a/test/ssh_config_test.dart b/test/ssh_config_test.dart index 9274b40e..5da447d1 100644 --- a/test/ssh_config_test.dart +++ b/test/ssh_config_test.dart @@ -286,6 +286,25 @@ Host jumpserver // ProxyJump is ignored in current implementation }); + test('parseConfig handles ProxyCommand with ssh -W jump host', () async { + await configFile.writeAsString(''' +Host internal + HostName 172.16.0.50 + User admin + ProxyCommand ssh -W %h:%p user@bastion.example.com +'''); + + final servers = await SSHConfig.parseConfig(configFile.path); + expect(servers, hasLength(1)); + + final server = servers.first; + expect(server.name, 'internal'); + expect(server.ip, '172.16.0.50'); + expect(server.user, 'admin'); + // Jump host extracted from ProxyCommand token containing user@host + expect(server.jumpId, 'user@bastion.example.com'); + }); + test('parseConfig returns empty list for non-existent file', () async { final servers = await SSHConfig.parseConfig('/non/existent/path'); expect(servers, isEmpty); @@ -352,4 +371,4 @@ Host internal-server expect(dev.keyId, isNull); }); }); -} \ No newline at end of file +}