add: cloudflared/Cloudflare Tunnel support

Fixes #949
This commit is contained in:
lollipopkit🏳️‍⚧️
2025-10-31 00:30:58 +08:00
parent 92a4601335
commit 12c8543352
10 changed files with 227 additions and 17 deletions

View File

@@ -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<String> 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<String> tokenizeCommand(String command) => _tokenizeCommand(command);
static List<String> _tokenizeCommand(String command) {
final tokens = <String>[];
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;
}
}

View File

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

View File

@@ -58,7 +58,7 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
'id': instance.id,
'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType],
'disabledCmdTypes': ?instance.disabledCmdTypes,
'proxyCommand': instance.proxyCommand?.toJson(),
'proxyCommand': ?instance.proxyCommand,
};
const _$SystemTypeEnumMap = {

View File

@@ -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<ServerCustom>(),
AdapterSpec<WakeOnLanCfg>(),
AdapterSpec<SystemType>(),
AdapterSpec<ProxyCommandConfig>(),
])
part 'hive_adapters.g.dart';

View File

@@ -607,3 +607,66 @@ class SystemTypeAdapter extends TypeAdapter<SystemType> {
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ProxyCommandConfigAdapter extends TypeAdapter<ProxyCommandConfig> {
@override
final typeId = 10;
@override
ProxyCommandConfig read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ProxyCommandConfig(
command: fields[0] as String,
args: (fields[1] as List?)?.cast<String>(),
workingDirectory: fields[2] as String?,
environment: (fields[3] as Map?)?.cast<String, String>(),
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;
}

View File

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

View File

@@ -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());

View File

@@ -280,17 +280,33 @@ extension _Actions on _ServerEditPageState {
return;
}
List<String> 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');

View File

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

View File

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