mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-16 23:04:22 +01:00
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user