mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
@@ -38,6 +38,11 @@ abstract final class ProxyCommandExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final finalCommand = config.getFinalCommand(hostname: hostname, port: port, user: user);
|
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');
|
Loggers.app.info('Executing proxy command: $finalCommand');
|
||||||
|
|
||||||
@@ -46,15 +51,11 @@ abstract final class ProxyCommandExecutor {
|
|||||||
if (config.requiresExecutable && config.executableName != null) {
|
if (config.requiresExecutable && config.executableName != null) {
|
||||||
executablePath = await ExecutableManager.ensureExecutable(config.executableName!);
|
executablePath = await ExecutableManager.ensureExecutable(config.executableName!);
|
||||||
} else {
|
} else {
|
||||||
// Parse command to get executable
|
executablePath = await ExecutableManager.getExecutablePath(executableToken);
|
||||||
final parts = finalCommand.split(' ');
|
|
||||||
final executable = parts.first;
|
|
||||||
executablePath = await ExecutableManager.getExecutablePath(executable);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse command and arguments
|
// Parse command and arguments
|
||||||
final parts = finalCommand.split(' ');
|
final args = tokens.skip(1).toList();
|
||||||
final args = parts.skip(1).toList();
|
|
||||||
|
|
||||||
// Set up environment
|
// Set up environment
|
||||||
final environment = {...Platform.environment, ...?config.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');
|
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
|
// Check if required placeholders are present
|
||||||
if (!config.command.contains('%h')) {
|
if (!config.command.contains('%h')) {
|
||||||
@@ -124,11 +134,8 @@ abstract final class ProxyCommandExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to validate command syntax (dry run)
|
// Try to validate command syntax (dry run)
|
||||||
final parts = testCommand.split(' ');
|
|
||||||
final executable = parts.first;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Process.run(executable, ['--help'], runInShell: true);
|
await Process.run(tokens.first, ['--help']);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Command validation failed: $e';
|
return 'Command validation failed: $e';
|
||||||
}
|
}
|
||||||
@@ -153,4 +160,70 @@ abstract final class ProxyCommandExecutor {
|
|||||||
// TODO: Implement custom preset removal
|
// TODO: Implement custom preset removal
|
||||||
Loggers.app.info('Removing custom proxy preset: $name');
|
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:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:hive_ce_flutter/adapters.dart';
|
||||||
|
|
||||||
part 'proxy_command_config.freezed.dart';
|
part 'proxy_command_config.freezed.dart';
|
||||||
part 'proxy_command_config.g.dart';
|
part 'proxy_command_config.g.dart';
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
|
|||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType],
|
'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType],
|
||||||
'disabledCmdTypes': ?instance.disabledCmdTypes,
|
'disabledCmdTypes': ?instance.disabledCmdTypes,
|
||||||
'proxyCommand': instance.proxyCommand?.toJson(),
|
'proxyCommand': ?instance.proxyCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$SystemTypeEnumMap = {
|
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/app/net_view.dart';
|
||||||
import 'package:server_box/data/model/server/custom.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/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/server_private_info.dart';
|
||||||
import 'package:server_box/data/model/server/snippet.dart';
|
import 'package:server_box/data/model/server/snippet.dart';
|
||||||
import 'package:server_box/data/model/server/system.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<ServerCustom>(),
|
||||||
AdapterSpec<WakeOnLanCfg>(),
|
AdapterSpec<WakeOnLanCfg>(),
|
||||||
AdapterSpec<SystemType>(),
|
AdapterSpec<SystemType>(),
|
||||||
|
AdapterSpec<ProxyCommandConfig>(),
|
||||||
])
|
])
|
||||||
part 'hive_adapters.g.dart';
|
part 'hive_adapters.g.dart';
|
||||||
|
|||||||
@@ -607,3 +607,66 @@ class SystemTypeAdapter extends TypeAdapter<SystemType> {
|
|||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
typeId == other.typeId;
|
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
|
# Generated by Hive CE
|
||||||
# Manual modifications may be necessary for certain migrations
|
# Manual modifications may be necessary for certain migrations
|
||||||
# Check in to version control
|
# Check in to version control
|
||||||
nextTypeId: 10
|
nextTypeId: 11
|
||||||
types:
|
types:
|
||||||
PrivateKeyInfo:
|
PrivateKeyInfo:
|
||||||
typeId: 1
|
typeId: 1
|
||||||
@@ -223,3 +223,27 @@ types:
|
|||||||
index: 1
|
index: 1
|
||||||
windows:
|
windows:
|
||||||
index: 2
|
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(ConnectionStatAdapter());
|
||||||
registerAdapter(NetViewTypeAdapter());
|
registerAdapter(NetViewTypeAdapter());
|
||||||
registerAdapter(PrivateKeyInfoAdapter());
|
registerAdapter(PrivateKeyInfoAdapter());
|
||||||
|
registerAdapter(ProxyCommandConfigAdapter());
|
||||||
registerAdapter(ServerConnectionStatsAdapter());
|
registerAdapter(ServerConnectionStatsAdapter());
|
||||||
registerAdapter(ServerCustomAdapter());
|
registerAdapter(ServerCustomAdapter());
|
||||||
registerAdapter(ServerFuncBtnAdapter());
|
registerAdapter(ServerFuncBtnAdapter());
|
||||||
@@ -32,6 +33,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
|
|||||||
registerAdapter(ConnectionStatAdapter());
|
registerAdapter(ConnectionStatAdapter());
|
||||||
registerAdapter(NetViewTypeAdapter());
|
registerAdapter(NetViewTypeAdapter());
|
||||||
registerAdapter(PrivateKeyInfoAdapter());
|
registerAdapter(PrivateKeyInfoAdapter());
|
||||||
|
registerAdapter(ProxyCommandConfigAdapter());
|
||||||
registerAdapter(ServerConnectionStatsAdapter());
|
registerAdapter(ServerConnectionStatsAdapter());
|
||||||
registerAdapter(ServerCustomAdapter());
|
registerAdapter(ServerCustomAdapter());
|
||||||
registerAdapter(ServerFuncBtnAdapter());
|
registerAdapter(ServerFuncBtnAdapter());
|
||||||
|
|||||||
@@ -280,17 +280,33 @@ extension _Actions on _ServerEditPageState {
|
|||||||
return;
|
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
|
// Determine if this requires an executable
|
||||||
final parts = command.split(' ');
|
final executableToken = tokens.first;
|
||||||
final executable = parts.first;
|
var normalized = p.basename(executableToken).toLowerCase();
|
||||||
final requiresExecutable = !['ssh', 'nc', 'socat'].contains(executable);
|
if (normalized.endsWith('.exe')) {
|
||||||
|
normalized = normalized.substring(0, normalized.length - 4);
|
||||||
|
}
|
||||||
|
const builtinExecutables = {'ssh', 'nc', 'socat'};
|
||||||
|
final requiresExecutable = !builtinExecutables.contains(normalized);
|
||||||
|
|
||||||
proxyCommand = ProxyCommandConfig(
|
proxyCommand = ProxyCommandConfig(
|
||||||
command: command,
|
command: command,
|
||||||
timeout: Duration(seconds: _proxyCommandTimeout.value),
|
timeout: Duration(seconds: _proxyCommandTimeout.value),
|
||||||
retryOnFailure: true,
|
retryOnFailure: true,
|
||||||
requiresExecutable: requiresExecutable,
|
requiresExecutable: requiresExecutable,
|
||||||
executableName: requiresExecutable ? executable : null,
|
executableName: requiresExecutable ? executableToken : null,
|
||||||
);
|
);
|
||||||
} else if (Platform.isIOS && _proxyCommandEnabled.value) {
|
} else if (Platform.isIOS && _proxyCommandEnabled.value) {
|
||||||
context.showSnackBar('ProxyCommand is not supported on iOS');
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:icons_plus/icons_plus.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/extension/context/locale.dart';
|
||||||
import 'package:server_box/core/route.dart';
|
import 'package:server_box/core/route.dart';
|
||||||
import 'package:server_box/core/utils/proxy_command_executor.dart';
|
import 'package:server_box/core/utils/proxy_command_executor.dart';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
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/proxy_command_config.dart';
|
||||||
import 'package:server_box/data/model/server/server_private_info.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));
|
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