From 12c8543352f48b4f90a9170d49fafa393d6bea0d 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: Fri, 31 Oct 2025 00:30:58 +0800 Subject: [PATCH] add: cloudflared/Cloudflare Tunnel support Fixes #949 --- lib/core/utils/proxy_command_executor.dart | 93 +++++++++++++++++-- .../model/server/proxy_command_config.dart | 1 + .../model/server/server_private_info.g.dart | 2 +- lib/hive/hive_adapters.dart | 2 + lib/hive/hive_adapters.g.dart | 63 +++++++++++++ lib/hive/hive_adapters.g.yaml | 26 +++++- lib/hive/hive_registrar.g.dart | 2 + lib/view/page/server/edit/actions.dart | 24 ++++- lib/view/page/server/edit/edit.dart | 1 + test/proxy_command_test.dart | 30 +++++- 10 files changed, 227 insertions(+), 17 deletions(-) diff --git a/lib/core/utils/proxy_command_executor.dart b/lib/core/utils/proxy_command_executor.dart index 739d6a0b..62e51999 100644 --- a/lib/core/utils/proxy_command_executor.dart +++ b/lib/core/utils/proxy_command_executor.dart @@ -38,6 +38,11 @@ abstract final class ProxyCommandExecutor { } final finalCommand = config.getFinalCommand(hostname: hostname, port: port, user: user); + final tokens = _tokenizeCommand(finalCommand); + if (tokens.isEmpty) { + throw ProxyCommandException(message: 'ProxyCommand resolved to an empty command'); + } + final executableToken = tokens.first; Loggers.app.info('Executing proxy command: $finalCommand'); @@ -46,15 +51,11 @@ abstract final class ProxyCommandExecutor { 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); + executablePath = await ExecutableManager.getExecutablePath(executableToken); } // Parse command and arguments - final parts = finalCommand.split(' '); - final args = parts.skip(1).toList(); + final args = tokens.skip(1).toList(); // Set up environment final environment = {...Platform.environment, ...?config.environment}; @@ -108,6 +109,15 @@ abstract final class ProxyCommandExecutor { } final testCommand = config.getFinalCommand(hostname: 'test.example.com', port: 22, user: 'testuser'); + late final List tokens; + try { + tokens = _tokenizeCommand(testCommand); + } on ProxyCommandException catch (e) { + return e.message; + } + if (tokens.isEmpty) { + return 'Proxy command must not be empty'; + } // Check if required placeholders are present if (!config.command.contains('%h')) { @@ -124,11 +134,8 @@ abstract final class ProxyCommandExecutor { } // Try to validate command syntax (dry run) - final parts = testCommand.split(' '); - final executable = parts.first; - try { - await Process.run(executable, ['--help'], runInShell: true); + await Process.run(tokens.first, ['--help']); } catch (e) { return 'Command validation failed: $e'; } @@ -153,4 +160,70 @@ abstract final class ProxyCommandExecutor { // TODO: Implement custom preset removal Loggers.app.info('Removing custom proxy preset: $name'); } + + static List tokenizeCommand(String command) => _tokenizeCommand(command); + + static List _tokenizeCommand(String command) { + final tokens = []; + final buffer = StringBuffer(); + String? quote; + var escaped = false; + + void flush() { + if (buffer.isEmpty) return; + tokens.add(buffer.toString()); + buffer.clear(); + } + + for (final rune in command.runes) { + final char = String.fromCharCode(rune); + + if (escaped) { + buffer.write(char); + escaped = false; + continue; + } + + if (quote != null) { + if (char == '\\' && quote == '"') { + escaped = true; + continue; + } + if (char == quote) { + quote = null; + continue; + } + buffer.write(char); + continue; + } + + if (char == '\\') { + escaped = true; + continue; + } + + if (char == '"' || char == "'") { + quote = char; + continue; + } + + if (char.trim().isEmpty) { + flush(); + continue; + } + + buffer.write(char); + } + + if (quote != null) { + throw ProxyCommandException(message: 'ProxyCommand has unmatched quote'); + } + + if (escaped) { + throw ProxyCommandException(message: 'ProxyCommand ends with an incomplete escape sequence'); + } + + flush(); + return tokens; + } } diff --git a/lib/data/model/server/proxy_command_config.dart b/lib/data/model/server/proxy_command_config.dart index c571b014..88adc195 100644 --- a/lib/data/model/server/proxy_command_config.dart +++ b/lib/data/model/server/proxy_command_config.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hive_ce_flutter/adapters.dart'; part 'proxy_command_config.freezed.dart'; part 'proxy_command_config.g.dart'; diff --git a/lib/data/model/server/server_private_info.g.dart b/lib/data/model/server/server_private_info.g.dart index 86315d8a..7b20fe48 100644 --- a/lib/data/model/server/server_private_info.g.dart +++ b/lib/data/model/server/server_private_info.g.dart @@ -58,7 +58,7 @@ Map _$SpiToJson(_Spi instance) => { 'id': instance.id, 'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType], 'disabledCmdTypes': ?instance.disabledCmdTypes, - 'proxyCommand': instance.proxyCommand?.toJson(), + 'proxyCommand': ?instance.proxyCommand, }; const _$SystemTypeEnumMap = { diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 63ecce7e..813fce0e 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -3,6 +3,7 @@ import 'package:server_box/data/model/app/menu/server_func.dart'; import 'package:server_box/data/model/app/net_view.dart'; import 'package:server_box/data/model/server/custom.dart'; import 'package:server_box/data/model/server/private_key_info.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/snippet.dart'; import 'package:server_box/data/model/server/system.dart'; @@ -19,5 +20,6 @@ import 'package:server_box/data/model/ssh/virtual_key.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), + AdapterSpec(), ]) part 'hive_adapters.g.dart'; diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index c523270c..6c888dc7 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -607,3 +607,66 @@ class SystemTypeAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } + +class ProxyCommandConfigAdapter extends TypeAdapter { + @override + final typeId = 10; + + @override + ProxyCommandConfig read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ProxyCommandConfig( + command: fields[0] as String, + args: (fields[1] as List?)?.cast(), + workingDirectory: fields[2] as String?, + environment: (fields[3] as Map?)?.cast(), + timeout: fields[4] == null + ? const Duration(seconds: 30) + : fields[4] as Duration, + retryOnFailure: fields[5] == null ? false : fields[5] as bool, + maxRetries: fields[6] == null ? 3 : (fields[6] as num).toInt(), + requiresExecutable: fields[7] == null ? false : fields[7] as bool, + executableName: fields[8] as String?, + executableDownloadUrl: fields[9] as String?, + ); + } + + @override + void write(BinaryWriter writer, ProxyCommandConfig obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.command) + ..writeByte(1) + ..write(obj.args) + ..writeByte(2) + ..write(obj.workingDirectory) + ..writeByte(3) + ..write(obj.environment) + ..writeByte(4) + ..write(obj.timeout) + ..writeByte(5) + ..write(obj.retryOnFailure) + ..writeByte(6) + ..write(obj.maxRetries) + ..writeByte(7) + ..write(obj.requiresExecutable) + ..writeByte(8) + ..write(obj.executableName) + ..writeByte(9) + ..write(obj.executableDownloadUrl); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProxyCommandConfigAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index b79c1bbc..5352e495 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -1,7 +1,7 @@ # Generated by Hive CE # Manual modifications may be necessary for certain migrations # Check in to version control -nextTypeId: 10 +nextTypeId: 11 types: PrivateKeyInfo: typeId: 1 @@ -223,3 +223,27 @@ types: index: 1 windows: index: 2 + ProxyCommandConfig: + typeId: 10 + nextIndex: 10 + fields: + command: + index: 0 + args: + index: 1 + workingDirectory: + index: 2 + environment: + index: 3 + timeout: + index: 4 + retryOnFailure: + index: 5 + maxRetries: + index: 6 + requiresExecutable: + index: 7 + executableName: + index: 8 + executableDownloadUrl: + index: 9 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index b1da7739..e478a5a0 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -14,6 +14,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(ConnectionStatAdapter()); registerAdapter(NetViewTypeAdapter()); registerAdapter(PrivateKeyInfoAdapter()); + registerAdapter(ProxyCommandConfigAdapter()); registerAdapter(ServerConnectionStatsAdapter()); registerAdapter(ServerCustomAdapter()); registerAdapter(ServerFuncBtnAdapter()); @@ -32,6 +33,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(ConnectionStatAdapter()); registerAdapter(NetViewTypeAdapter()); registerAdapter(PrivateKeyInfoAdapter()); + registerAdapter(ProxyCommandConfigAdapter()); registerAdapter(ServerConnectionStatsAdapter()); registerAdapter(ServerCustomAdapter()); registerAdapter(ServerFuncBtnAdapter()); diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index e1c4e0d0..de48de53 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -280,17 +280,33 @@ extension _Actions on _ServerEditPageState { return; } + List tokens; + try { + tokens = ProxyCommandExecutor.tokenizeCommand(command); + } on ProxyCommandException catch (e) { + context.showSnackBar(e.message); + return; + } + if (tokens.isEmpty) { + context.showSnackBar('ProxyCommand must not be empty'); + return; + } + // Determine if this requires an executable - final parts = command.split(' '); - final executable = parts.first; - final requiresExecutable = !['ssh', 'nc', 'socat'].contains(executable); + final executableToken = tokens.first; + var normalized = p.basename(executableToken).toLowerCase(); + if (normalized.endsWith('.exe')) { + normalized = normalized.substring(0, normalized.length - 4); + } + const builtinExecutables = {'ssh', 'nc', 'socat'}; + final requiresExecutable = !builtinExecutables.contains(normalized); proxyCommand = ProxyCommandConfig( command: command, timeout: Duration(seconds: _proxyCommandTimeout.value), retryOnFailure: true, requiresExecutable: requiresExecutable, - executableName: requiresExecutable ? executable : null, + executableName: requiresExecutable ? executableToken : null, ); } else if (Platform.isIOS && _proxyCommandEnabled.value) { context.showSnackBar('ProxyCommand is not supported on iOS'); diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 582de702..ce00715c 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -7,6 +7,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icons_plus/icons_plus.dart'; +import 'package:path/path.dart' as p; 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'; diff --git a/test/proxy_command_test.dart b/test/proxy_command_test.dart index 4a4fe3e5..91cd1cbe 100644 --- a/test/proxy_command_test.dart +++ b/test/proxy_command_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/proxy_command_executor.dart'; import 'package:server_box/data/model/server/proxy_command_config.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; @@ -134,4 +135,31 @@ void main() { expect(deserializedSpi.proxyCommand!.executableName, equals(originalSpi.proxyCommand!.executableName)); }); }); -} \ No newline at end of file + + group('ProxyCommandExecutor Tokenization', () { + test('tokenizeCommand handles quoted paths', () { + final tokens = ProxyCommandExecutor.tokenizeCommand( + 'ssh -i "/Users/John Doe/.ssh/id_rsa" -W %h:%p bastion.example.com', + ); + + expect( + tokens, + equals([ + 'ssh', + '-i', + '/Users/John Doe/.ssh/id_rsa', + '-W', + '%h:%p', + 'bastion.example.com', + ]), + ); + }); + + test('tokenizeCommand throws on unmatched quote', () { + expect( + () => ProxyCommandExecutor.tokenizeCommand('ssh -i "/Users/John Doe/.ssh/id_rsa'), + throwsA(isA()), + ); + }); + }); +}