feat: 跳板机&代理功能支持

This commit is contained in:
chaos-zhu
2026-05-23 16:12:41 +08:00
parent 0c950150d1
commit 2fb23ef783
20 changed files with 14721 additions and 171 deletions
+13193 -1
View File
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ class ServerFormData {
this.host = '',
this.port = 22,
this.username = 'root',
this.authType = 'privateKey',
this.authType = 'password',
this.password = '',
this.privateKey = '',
this.credential = '',
@@ -197,19 +197,30 @@ class _ServerFormPageState extends ConsumerState<ServerFormPage> {
value: _form.authType,
onChanged: (value) => setState(() => _form.authType = value),
),
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _AuthField(
key: ValueKey(_form.authType),
form: _form,
passwordCtrl: _passwordCtrl,
privateKeyCtrl: _privateKeyCtrl,
credentials: credentials,
onCredentialChanged: (value) => setState(() {
_form.credential = value ?? '';
}),
FormField<String>(
key: ValueKey('auth-${_form.authType}'),
initialValue: _form.credential,
validator: _form.authType == 'credential'
? (value) => value == null || value.isEmpty
? l.tr('servers.validation.credential')
: null
: null,
builder: (field) => AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _AuthField(
key: ValueKey(_form.authType),
form: _form,
passwordCtrl: _passwordCtrl,
privateKeyCtrl: _privateKeyCtrl,
credentials: credentials,
errorText: field.errorText,
onCredentialChanged: (value) => setState(() {
_form.credential = value ?? '';
field.didChange(_form.credential);
}),
),
),
),
] else
@@ -237,27 +248,48 @@ class _ServerFormPageState extends ConsumerState<ServerFormPage> {
children: _advancedOpen
? [
if (_form.isSsh) ...[
_ProxyTypeSegment(
value: _form.proxyType,
onChanged: _changeProxyType,
),
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _ProxyField(
key: ValueKey(_form.proxyType),
proxyType: _form.proxyType,
proxies: proxies,
proxyValue: _normalizeProxyValue(proxies),
jumpHostOptions: jumpHostOptions,
jumpHostIds: _form.jumpHosts,
onProxyChanged: (value) => setState(() {
_form.proxyServer = value ?? '';
}),
onJumpHostsChanged: (ids) => setState(() {
_form.jumpHosts = ids;
}),
FormField<String>(
key: ValueKey('proxy-${_form.proxyType}'),
initialValue: _form.proxyType == 'jumpHosts'
? _form.jumpHosts.join(',')
: _form.proxyServer,
validator: _form.proxyType.isEmpty
? null
: (value) => value == null || value.isEmpty
? l.tr(_form.proxyType == 'jumpHosts'
? 'servers.validation.jumpHosts'
: 'servers.validation.proxyServer')
: null,
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ProxyTypeSegment(
value: _form.proxyType,
onChanged: _changeProxyType,
),
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: _ProxyField(
key: ValueKey(_form.proxyType),
proxyType: _form.proxyType,
proxies: proxies,
proxyValue: _normalizeProxyValue(proxies),
jumpHostOptions: jumpHostOptions,
jumpHostIds: _form.jumpHosts,
errorText: field.errorText,
onProxyChanged: (value) => setState(() {
_form.proxyServer = value ?? '';
field.didChange(_form.proxyServer);
}),
onJumpHostsChanged: (ids) => setState(() {
_form.jumpHosts = ids;
field.didChange(_form.jumpHosts.join(','));
}),
),
),
],
),
),
],
@@ -314,7 +346,7 @@ class _ServerFormPageState extends ConsumerState<ServerFormPage> {
} else {
_form.port = 22;
_form.username = 'root';
_form.authType = 'privateKey';
_form.authType = 'password';
}
_portCtrl.text = _form.port.toString();
_usernameCtrl.text = _form.username;
@@ -766,6 +798,7 @@ class _AuthField extends StatelessWidget {
required this.passwordCtrl,
required this.privateKeyCtrl,
required this.credentials,
required this.errorText,
required this.onCredentialChanged,
});
@@ -773,6 +806,7 @@ class _AuthField extends StatelessWidget {
final TextEditingController passwordCtrl;
final TextEditingController privateKeyCtrl;
final List<ServerCredentialModel> credentials;
final String? errorText;
final ValueChanged<String?> onCredentialChanged;
@override
@@ -819,6 +853,7 @@ class _AuthField extends StatelessWidget {
: l.tr('servers.auth.password'),
placeholder: l.tr('servers.credentials.empty'),
enabled: credentials.isNotEmpty,
errorText: errorText,
onTap: () async {
final result = await _showChoiceSheet<String>(
context: context,
@@ -864,11 +899,13 @@ class _ProxySelector extends StatelessWidget {
const _ProxySelector({
required this.proxies,
required this.value,
required this.errorText,
required this.onChanged,
});
final List<ServerProxyModel> proxies;
final String? value;
final String? errorText;
final ValueChanged<String?> onChanged;
@override
@@ -888,6 +925,7 @@ class _ProxySelector extends StatelessWidget {
meta: selected?.typeLabel,
placeholder: l.tr('servers.proxy.empty'),
enabled: proxies.isNotEmpty,
errorText: errorText,
onTap: () async {
final result = await _showChoiceSheet<String>(
context: context,
@@ -982,6 +1020,7 @@ class _ProxyField extends StatelessWidget {
required this.proxyValue,
required this.jumpHostOptions,
required this.jumpHostIds,
required this.errorText,
required this.onProxyChanged,
required this.onJumpHostsChanged,
});
@@ -991,6 +1030,7 @@ class _ProxyField extends StatelessWidget {
final String? proxyValue;
final List<ServerModel> jumpHostOptions;
final List<String> jumpHostIds;
final String? errorText;
final ValueChanged<String?> onProxyChanged;
final ValueChanged<List<String>> onJumpHostsChanged;
@@ -1000,6 +1040,7 @@ class _ProxyField extends StatelessWidget {
return _ProxySelector(
proxies: proxies,
value: proxyValue,
errorText: errorText,
onChanged: onProxyChanged,
);
}
@@ -1007,6 +1048,7 @@ class _ProxyField extends StatelessWidget {
return _JumpHostSelector(
options: jumpHostOptions,
selectedIds: jumpHostIds,
errorText: errorText,
onChanged: onJumpHostsChanged,
);
}
@@ -1378,11 +1420,13 @@ class _JumpHostSelector extends StatelessWidget {
const _JumpHostSelector({
required this.options,
required this.selectedIds,
required this.errorText,
required this.onChanged,
});
final List<ServerModel> options;
final List<String> selectedIds;
final String? errorText;
final ValueChanged<List<String>> onChanged;
@override
@@ -1407,7 +1451,7 @@ class _JumpHostSelector extends StatelessWidget {
helperText: options.isEmpty
? l.tr('servers.jumpHosts.empty')
: l.tr('servers.jumpHosts.orderHint'),
),
).copyWith(errorText: errorText),
child: selectedHosts.isEmpty
? Text(
l.tr('servers.jumpHosts.placeholder'),
@@ -0,0 +1,131 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'socks5_connector.dart';
import 'ssh_transport.dart';
class HttpProxyConnector {
Future<SSHSocket> connect({
required String proxyHost,
required int proxyPort,
required String targetHost,
required int targetPort,
String username = '',
String password = '',
}) async {
final socket = await Socket.connect(proxyHost, proxyPort);
final reader = _HttpProxyReader(socket);
try {
socket.add(
utf8.encode(
_connectRequest(
targetHost: targetHost,
targetPort: targetPort,
username: username,
password: password,
),
),
);
final response = await reader.readHeaders();
final statusLine = response.isEmpty ? '' : response.first;
if (!statusLine.contains(' 200 ')) {
throw const SshTransportException(
'HTTP proxy target connection failed',
);
}
return SocketSshSocket(socket, reader.release());
} catch (_) {
socket.destroy();
rethrow;
}
}
String _connectRequest({
required String targetHost,
required int targetPort,
required String username,
required String password,
}) {
final target = '$targetHost:$targetPort';
final headers = <String>[
'CONNECT $target HTTP/1.1',
'Host: $target',
'Proxy-Connection: Keep-Alive',
];
if (username.isNotEmpty || password.isNotEmpty) {
final token = base64Encode(utf8.encode('$username:$password'));
headers.add('Proxy-Authorization: Basic $token');
}
return '${headers.join('\r\n')}\r\n\r\n';
}
}
class _HttpProxyReader {
_HttpProxyReader(Socket socket) {
_subscription = socket.listen(
(data) {
if (_released) {
_streamController.add(Uint8List.fromList(data));
} else {
_buffer.addAll(data);
}
},
onDone: () {
_completer.complete();
unawaited(_streamController.close());
},
onError: (Object error, StackTrace stackTrace) {
_completer.completeError(error, stackTrace);
_streamController.addError(error, stackTrace);
},
);
}
final _buffer = <int>[];
final _completer = Completer<void>();
final _streamController = StreamController<Uint8List>();
late final StreamSubscription<List<int>> _subscription;
bool _released = false;
Future<List<String>> readHeaders() async {
while (!_hasHeaderTerminator()) {
await Future<void>.delayed(const Duration(milliseconds: 10));
if (_completer.isCompleted && !_hasHeaderTerminator()) {
await _subscription.cancel();
throw const SshTransportException('HTTP proxy connection failed');
}
}
final end = _headerEndIndex();
final headerBytes = _buffer.take(end).toList();
_buffer.removeRange(0, end + 4);
return utf8.decode(headerBytes, allowMalformed: true).split('\r\n');
}
Stream<Uint8List> release() {
if (_released) return _streamController.stream;
_released = true;
if (_buffer.isNotEmpty) {
_streamController.add(Uint8List.fromList(_buffer));
_buffer.clear();
}
return _streamController.stream;
}
bool _hasHeaderTerminator() => _headerEndIndex() >= 0;
int _headerEndIndex() {
for (var i = 0; i <= _buffer.length - 4; i++) {
if (_buffer[i] == 13 &&
_buffer[i + 1] == 10 &&
_buffer[i + 2] == 13 &&
_buffer[i + 3] == 10) {
return i;
}
}
return -1;
}
}
@@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'ssh_transport.dart';
class SocketSshSocket implements SSHSocket {
SocketSshSocket(this._socket, this._stream);
final Socket _socket;
final Stream<Uint8List> _stream;
@override
Stream<Uint8List> get stream => _stream;
@override
StreamSink<List<int>> get sink => _socket;
@override
Future<void> get done => _socket.done;
@override
Future<void> close() => _socket.close();
@override
void destroy() {
_socket.destroy();
}
}
class Socks5Connector {
Future<SSHSocket> connect({
required String proxyHost,
required int proxyPort,
required String targetHost,
required int targetPort,
String username = '',
String password = '',
}) async {
final socket = await Socket.connect(proxyHost, proxyPort);
final reader = _SocketReader(socket);
try {
final wantsAuth = username.isNotEmpty || password.isNotEmpty;
socket.add(wantsAuth ? [0x05, 0x02, 0x00, 0x02] : [0x05, 0x01, 0x00]);
final method = await reader.readExactly(2);
if (method[0] != 0x05) {
throw const SshTransportException('SOCKS5 proxy connection failed');
}
if (method[1] == 0xFF) {
throw const SshTransportException('SOCKS5 authentication failed');
}
if (method[1] == 0x02) {
await _authenticate(socket, reader, username, password);
}
socket.add(_connectRequest(targetHost, targetPort));
final response = await reader.readExactly(5);
if (response[1] != 0x00) {
throw const SshTransportException('SOCKS5 target connection failed');
}
final addressRemainderLength = switch (response[3]) {
0x01 => 3,
0x03 => response[4] + 2,
0x04 => 15,
_ => throw const SshTransportException(
'SOCKS5 target connection failed',
),
};
await reader.readExactly(addressRemainderLength);
return SocketSshSocket(socket, reader.release());
} catch (_) {
socket.destroy();
rethrow;
}
}
Future<void> _authenticate(
Socket socket,
_SocketReader reader,
String username,
String password,
) async {
final user = utf8.encode(username);
final pass = utf8.encode(password);
socket.add([0x01, user.length, ...user, pass.length, ...pass]);
final response = await reader.readExactly(2);
if (response[1] != 0x00) {
throw const SshTransportException('SOCKS5 authentication failed');
}
}
List<int> _connectRequest(String host, int port) {
final hostBytes = utf8.encode(host);
return [
0x05,
0x01,
0x00,
0x03,
hostBytes.length,
...hostBytes,
(port >> 8) & 0xFF,
port & 0xFF,
];
}
}
class _SocketReader {
_SocketReader(Socket socket) {
_subscription = socket.listen(
(data) {
if (_released) {
_streamController.add(Uint8List.fromList(data));
} else {
_buffer.addAll(data);
}
},
onDone: () {
_completer.complete();
unawaited(_streamController.close());
},
onError: (Object error, StackTrace stackTrace) {
_completer.completeError(error, stackTrace);
_streamController.addError(error, stackTrace);
},
);
}
final _buffer = <int>[];
final _completer = Completer<void>();
final _streamController = StreamController<Uint8List>();
late final StreamSubscription<List<int>> _subscription;
bool _released = false;
Future<List<int>> readExactly(int length) async {
while (_buffer.length < length) {
await Future<void>.delayed(const Duration(milliseconds: 10));
if (_completer.isCompleted && _buffer.length < length) {
await _subscription.cancel();
throw const SshTransportException('SOCKS5 proxy connection failed');
}
}
final bytes = _buffer.take(length).toList();
_buffer.removeRange(0, length);
return bytes;
}
Stream<Uint8List> release() {
if (_released) return _streamController.stream;
_released = true;
if (_buffer.isNotEmpty) {
_streamController.add(Uint8List.fromList(_buffer));
_buffer.clear();
}
return _streamController.stream;
}
}
@@ -1,7 +1,7 @@
/// Plaintext SSH connection parameters returned by `/mobile/ssh-connection`
/// after AES-GCM decryption. Mirrors `toMobileSshPayload` on the server.
class SshConnectionConfig {
const SshConnectionConfig({
class SshAuthConfig {
const SshAuthConfig({
required this.hostId,
required this.name,
required this.host,
@@ -36,21 +36,12 @@ class SshConnectionConfig {
return trimmed.isEmpty ? null : trimmed;
}
factory SshConnectionConfig.fromJson(Map<String, dynamic> json) {
final portRaw = json['port'];
final int port;
if (portRaw is int) {
port = portRaw;
} else if (portRaw is num) {
port = portRaw.toInt();
} else {
port = int.tryParse(portRaw?.toString() ?? '') ?? 22;
}
return SshConnectionConfig(
hostId: (json['hostId'] ?? '').toString(),
static SshAuthConfig fromJson(Map<String, dynamic> json) {
return SshAuthConfig(
hostId: (json['hostId'] ?? json['id'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
host: (json['host'] ?? '').toString(),
port: port,
port: _parsePort(json['port']),
username: (json['username'] ?? '').toString(),
authType: (json['authType'] ?? '').toString(),
password: (json['password'] ?? '').toString(),
@@ -59,3 +50,118 @@ class SshConnectionConfig {
);
}
}
class SshProxyConfig {
const SshProxyConfig({
required this.id,
required this.name,
required this.type,
required this.host,
required this.port,
required this.username,
required this.password,
});
final String id;
final String name;
final String type;
final String host;
final int port;
final String username;
final String password;
factory SshProxyConfig.fromJson(Map<String, dynamic> json) {
return SshProxyConfig(
id: (json['id'] ?? json['_id'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
type: (json['type'] ?? '').toString(),
host: (json['host'] ?? '').toString(),
port: _parsePort(json['port']),
username: (json['username'] ?? '').toString(),
password: (json['password'] ?? '').toString(),
);
}
}
class SshJumpHostConfig extends SshAuthConfig {
const SshJumpHostConfig({
required super.hostId,
required super.name,
required super.host,
required super.port,
required super.username,
required super.authType,
required super.password,
required super.privateKey,
required super.passphrase,
});
factory SshJumpHostConfig.fromJson(Map<String, dynamic> json) {
final auth = SshAuthConfig.fromJson(json);
return SshJumpHostConfig(
hostId: auth.hostId,
name: auth.name,
host: auth.host,
port: auth.port,
username: auth.username,
authType: auth.authType,
password: auth.password,
privateKey: auth.privateKey,
passphrase: auth.passphrase,
);
}
}
class SshConnectionConfig extends SshAuthConfig {
const SshConnectionConfig({
required super.hostId,
required super.name,
required super.host,
required super.port,
required super.username,
required super.authType,
required super.password,
required super.privateKey,
required super.passphrase,
required this.proxyType,
required this.proxy,
required this.jumpHosts,
});
final String proxyType;
final SshProxyConfig? proxy;
final List<SshJumpHostConfig> jumpHosts;
factory SshConnectionConfig.fromJson(Map<String, dynamic> json) {
final auth = SshAuthConfig.fromJson(json);
final proxyRaw = json['proxy'];
final jumpHostsRaw = json['jumpHosts'];
return SshConnectionConfig(
hostId: auth.hostId,
name: auth.name,
host: auth.host,
port: auth.port,
username: auth.username,
authType: auth.authType,
password: auth.password,
privateKey: auth.privateKey,
passphrase: auth.passphrase,
proxyType: (json['proxyType'] ?? '').toString(),
proxy: proxyRaw is Map<String, dynamic>
? SshProxyConfig.fromJson(proxyRaw)
: null,
jumpHosts: jumpHostsRaw is List
? jumpHostsRaw
.whereType<Map<String, dynamic>>()
.map(SshJumpHostConfig.fromJson)
.toList(growable: false)
: const [],
);
}
}
int _parsePort(Object? value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '') ?? 22;
}
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:xterm/xterm.dart';
import 'ssh_connection_config.dart';
import 'ssh_transport.dart';
/// Owns the dartssh2 client and bridges its stdio with an [xterm] [Terminal].
///
@@ -16,11 +17,16 @@ import 'ssh_connection_config.dart';
/// - Toolbar shortcuts feed [writeInput] which writes directly to the SSH
/// session (not the local terminal buffer) so the remote sees the keys.
class SshTerminalController {
SshTerminalController({required this.config, Terminal? terminal})
: terminal = terminal ?? Terminal();
SshTerminalController({
required this.config,
Terminal? terminal,
SshTransportFactory? transportFactory,
}) : terminal = terminal ?? Terminal(),
_transportFactory = transportFactory ?? SshTransportFactory();
final SshConnectionConfig config;
final Terminal terminal;
final SshTransportFactory _transportFactory;
/// Whether the next single-letter input should be translated into Ctrl+letter.
/// Lives on the controller (not the toolbar) so soft-keyboard input — which
@@ -28,6 +34,7 @@ class SshTerminalController {
final ValueNotifier<bool> ctrlPending = ValueNotifier<bool>(false);
SSHClient? _client;
SshTransportHandle? _transport;
SSHSession? _session;
StreamSubscription<Uint8List>? _stdoutSub;
StreamSubscription<Uint8List>? _stderrSub;
@@ -35,20 +42,33 @@ class SshTerminalController {
Future<void> connect() async {
if (_disposed) return;
final socket = await SSHSocket.connect(config.host, config.port);
// dartssh2 `SSHKeyPair.fromPem` already returns `List<SSHKeyPair>`, no extra
// wrapping list needed.
final identities = config.authType == 'privateKey'
? SSHKeyPair.fromPem(config.privateKey, config.privateKeyPassphrase)
: null;
_client = SSHClient(
socket,
username: config.username,
onPasswordRequest: config.authType == 'password'
? () => config.password
: null,
identities: identities,
);
try {
final transport = await _transportFactory.open(
config,
logger: (message) => terminal.write('[Info] $message\r\n'),
);
_transport = transport;
terminal.write('[Info] 准备连接目标终端: ${config.name} - ${config.host}\r\n');
// dartssh2 `SSHKeyPair.fromPem` already returns `List<SSHKeyPair>`, no
// extra wrapping list needed.
final identities = config.authType == 'privateKey'
? SSHKeyPair.fromPem(config.privateKey, config.privateKeyPassphrase)
: null;
_client = SSHClient(
transport.socket,
username: config.username,
onPasswordRequest: config.authType == 'password'
? () => config.password
: null,
identities: identities,
);
} on SshTransportException catch (error) {
terminal.write('[Error] ${error.message}\r\n');
rethrow;
} catch (error) {
terminal.write('[Error] $error\r\n');
rethrow;
}
final session = await _client!.shell();
_session = session;
_stdoutSub = session.stdout.listen((data) {
@@ -105,8 +125,10 @@ class SshTerminalController {
_stderrSub = null;
_session?.close();
_client?.close();
await _transport?.close();
_session = null;
_client = null;
_transport = null;
ctrlPending.value = false;
}
}
@@ -0,0 +1,217 @@
import 'package:dartssh2/dartssh2.dart';
import 'http_proxy_connector.dart';
import 'socks5_connector.dart';
import 'ssh_connection_config.dart';
typedef SshSocketConnector = Future<SSHSocket> Function(String host, int port);
abstract class SshClientHandle {
Future<void> get authenticated;
Future<SSHSocket> forwardLocal(String host, int port);
void close();
}
typedef SshClientCreator =
SshClientHandle Function(SSHSocket socket, SshAuthConfig auth);
typedef SshTransportLogger = void Function(String message);
class SshTransportException implements Exception {
const SshTransportException(this.message);
final String message;
@override
String toString() => message;
}
class SshTransportHandle {
SshTransportHandle({
required this.socket,
required List<SshClientHandle> intermediateClients,
}) : _intermediateClients = intermediateClients;
final SSHSocket socket;
final List<SshClientHandle> _intermediateClients;
bool _closed = false;
Future<void> close() async {
if (_closed) return;
_closed = true;
for (final client in _intermediateClients.reversed) {
client.close();
}
await socket.close();
}
}
class DartSshClientHandle implements SshClientHandle {
DartSshClientHandle(this.client);
final SSHClient client;
@override
Future<void> get authenticated => client.authenticated;
@override
Future<SSHSocket> forwardLocal(String host, int port) {
return client.forwardLocal(host, port);
}
@override
void close() {
client.close();
}
}
SshClientHandle createDartSshClient(SSHSocket socket, SshAuthConfig auth) {
final identities = auth.authType == 'privateKey'
? SSHKeyPair.fromPem(auth.privateKey, auth.privateKeyPassphrase)
: null;
final client = SSHClient(
socket,
username: auth.username,
onPasswordRequest: auth.authType == 'password' ? () => auth.password : null,
identities: identities,
);
return DartSshClientHandle(client);
}
class SshTransportFactory {
SshTransportFactory({
SshSocketConnector? connectSocket,
Socks5Connector? socks5Connector,
HttpProxyConnector? httpProxyConnector,
SshClientCreator? createClient,
}) : _connectSocket = connectSocket ?? SSHSocket.connect,
_socks5Connector = socks5Connector ?? Socks5Connector(),
_httpProxyConnector = httpProxyConnector ?? HttpProxyConnector(),
_createClient = createClient ?? createDartSshClient;
final SshSocketConnector _connectSocket;
final Socks5Connector _socks5Connector;
final HttpProxyConnector _httpProxyConnector;
final SshClientCreator _createClient;
Future<SshTransportHandle> open(
SshConnectionConfig config, {
SshTransportLogger? logger,
}) async {
if (config.proxyType.isEmpty) {
final socket = await _connectSocket(config.host, config.port);
return SshTransportHandle(socket: socket, intermediateClients: const []);
}
if (config.proxyType == 'proxyServer') {
final proxy = config.proxy;
if (proxy == null) {
throw const SshTransportException('Proxy connection failed');
}
logger?.call(
'使用代理服务器 ${proxy.name.isEmpty ? proxy.host : proxy.name} '
'(${proxy.type.toUpperCase()}) - ${proxy.host}:${proxy.port}',
);
if (proxy.type == 'socks5') {
final socket = await _socks5Connector.connect(
proxyHost: proxy.host,
proxyPort: proxy.port,
targetHost: config.host,
targetPort: config.port,
username: proxy.username,
password: proxy.password,
);
logger?.call('代理连接建立成功,准备通过代理连接目标服务器');
return SshTransportHandle(
socket: socket,
intermediateClients: const [],
);
}
if (proxy.type == 'http') {
final socket = await _httpProxyConnector.connect(
proxyHost: proxy.host,
proxyPort: proxy.port,
targetHost: config.host,
targetPort: config.port,
username: proxy.username,
password: proxy.password,
);
logger?.call('代理连接建立成功,准备通过代理连接目标服务器');
return SshTransportHandle(
socket: socket,
intermediateClients: const [],
);
}
throw SshTransportException(
'Unsupported mobile proxy type: ${proxy.type}',
);
}
if (config.proxyType == 'jumpHosts') {
if (config.jumpHosts.isEmpty) {
throw const SshTransportException(
'Jump host connection failed: empty chain',
);
}
final clients = <SshClientHandle>[];
logger?.call('准备通过跳板机连接目标服务器,共 ${config.jumpHosts.length} 跳');
final firstJump = config.jumpHosts.first;
logger?.call(
'连接跳板机 1/${config.jumpHosts.length}: '
'${firstJump.name.isEmpty ? firstJump.host : firstJump.name} - '
'${firstJump.host}:${firstJump.port}',
);
SSHSocket socket = await _connectSocket(firstJump.host, firstJump.port);
try {
for (var i = 0; i < config.jumpHosts.length; i++) {
final jumpHost = config.jumpHosts[i];
final client = _createClient(socket, jumpHost);
clients.add(client);
try {
await client.authenticated;
logger?.call(
'跳板机认证成功: '
'${jumpHost.name.isEmpty ? jumpHost.host : jumpHost.name}',
);
} catch (_) {
throw SshTransportException(
'Jump host authentication failed: ${jumpHost.name.isEmpty ? jumpHost.host : jumpHost.name}',
);
}
final nextHost = i == config.jumpHosts.length - 1
? config.host
: config.jumpHosts[i + 1].host;
final nextPort = i == config.jumpHosts.length - 1
? config.port
: config.jumpHosts[i + 1].port;
logger?.call(
'跳板机转发: ${jumpHost.host}:${jumpHost.port} -> $nextHost:$nextPort',
);
try {
socket = await client.forwardLocal(nextHost, nextPort);
} catch (_) {
throw SshTransportException(
'Jump host forwarding failed: ${jumpHost.host} -> $nextHost',
);
}
}
logger?.call('跳板机连接成功,准备连接目标服务器');
return SshTransportHandle(socket: socket, intermediateClients: clients);
} catch (_) {
for (final client in clients.reversed) {
client.close();
}
await socket.close();
rethrow;
}
}
throw SshTransportException(
'Unsupported mobile proxy type: ${config.proxyType}',
);
}
}
+3
View File
@@ -124,6 +124,9 @@ const Map<String, String> stringsEn = {
'servers.validation.host': 'Enter an IP address or domain',
'servers.validation.port': 'Enter a numeric port',
'servers.validation.index': 'Enter a numeric index',
'servers.validation.credential': 'Select a credential',
'servers.validation.proxyServer': 'Select a proxy service',
'servers.validation.jumpHosts': 'Select at least one jump host',
// SFTP / Scripts placeholders
'sftp.placeholder': 'Coming soon: browse and manage remote files',
+4
View File
@@ -120,6 +120,10 @@ const Map<String, String> stringsZh = {
'servers.validation.port': '请输入数字端口',
'servers.validation.index': '请输入数字序号',
'servers.validation.credential': '请选择凭据',
'servers.validation.proxyServer': '请选择代理服务',
'servers.validation.jumpHosts': '请至少选择一个跳板机',
// Placeholders
'sftp.placeholder': '即将上线:浏览和管理远程文件',
'scripts.placeholder': '即将上线:脚本库',
+74 -60
View File
@@ -8,92 +8,106 @@ import 'package:mobile/l10n/app_localizations.dart';
void main() {
Widget wrap(Widget child) => MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: child,
);
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: child,
);
testWidgets('renders all required fields with prefilled values', (tester) async {
Future<void> pumpLoginPage(WidgetTester tester, Widget child) async {
tester.view.physicalSize = const Size(1080, 1920);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(wrap(child));
await tester.pumpAndSettle();
}
Finder byKey(Key key) => find.byKey(key, skipOffstage: false);
testWidgets('renders all required fields with prefilled values', (
tester,
) async {
final controller = LoginController.fake();
await tester.pumpWidget(
wrap(
LoginPage(
controller: controller,
initialServerAddress: 'https://example.com',
initialUsername: 'root',
initialPassword: 'secret',
initialSavePassword: true,
onLoginSuccess: (_) {},
),
await pumpLoginPage(
tester,
LoginPage(
controller: controller,
initialServerAddress: 'https://example.com',
initialUsername: 'root',
initialPassword: 'secret',
initialSavePassword: true,
onLoginSuccess: (_) {},
),
);
expect(find.byKey(const Key('field-server')), findsOneWidget);
expect(find.byKey(const Key('field-username')), findsOneWidget);
expect(find.byKey(const Key('field-password')), findsOneWidget);
expect(find.byKey(const Key('field-mfa')), findsOneWidget);
expect(find.byKey(const Key('switch-save-password')), findsOneWidget);
expect(find.byKey(const Key('btn-login')), findsOneWidget);
expect(byKey(const Key('field-server')), findsOneWidget);
expect(byKey(const Key('field-username')), findsOneWidget);
expect(byKey(const Key('field-password')), findsOneWidget);
expect(byKey(const Key('field-mfa')), findsOneWidget);
expect(byKey(const Key('switch-save-password')), findsOneWidget);
expect(byKey(const Key('btn-login')), findsOneWidget);
expect(find.text('https://example.com'), findsOneWidget);
expect(find.text('root'), findsOneWidget);
expect(find.text('https://example.com'), findsWidgets);
expect(find.text('root'), findsWidgets);
});
testWidgets('shows local validation error for empty username', (tester) async {
testWidgets('shows local validation error for empty username', (
tester,
) async {
final controller = LoginController.fake();
await tester.pumpWidget(
wrap(
LoginPage(
controller: controller,
initialServerAddress: 'https://example.com',
initialUsername: '',
initialSavePassword: false,
onLoginSuccess: (_) {},
),
await pumpLoginPage(
tester,
LoginPage(
controller: controller,
initialServerAddress: 'https://example.com',
initialUsername: '',
initialSavePassword: false,
onLoginSuccess: (_) {},
),
);
// Provide a password so we trip the username check.
await tester.enterText(find.byKey(const Key('field-password')), 'secret');
await tester.tap(find.byKey(const Key('btn-login')));
await tester.ensureVisible(byKey(const Key('field-password')));
await tester.enterText(byKey(const Key('field-password')), 'secret');
await tester.tap(byKey(const Key('btn-login')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('login-error')), findsOneWidget);
expect(find.text('请输入用户名'), findsOneWidget);
expect(byKey(const Key('login-error')), findsOneWidget);
});
testWidgets('does not invoke onLoginSuccess when login fails', (tester) async {
testWidgets('does not invoke onLoginSuccess when login fails', (
tester,
) async {
final controller = LoginController.fake();
var called = 0;
AuthSession? captured;
await tester.pumpWidget(
wrap(
LoginPage(
controller: controller,
initialServerAddress: 'https://example.com',
initialUsername: 'root',
initialSavePassword: false,
onLoginSuccess: (s) {
called++;
captured = s;
},
),
await pumpLoginPage(
tester,
LoginPage(
controller: controller,
initialServerAddress: 'https://example.com',
initialUsername: 'root',
initialSavePassword: false,
onLoginSuccess: (s) {
called++;
captured = s;
},
),
);
await tester.enterText(find.byKey(const Key('field-password')), '');
await tester.tap(find.byKey(const Key('btn-login')));
await tester.ensureVisible(byKey(const Key('field-password')));
await tester.enterText(byKey(const Key('field-password')), '');
await tester.tap(byKey(const Key('btn-login')));
await tester.pumpAndSettle();
expect(called, 0);
expect(captured, isNull);
expect(find.text('请输入密码'), findsOneWidget);
expect(byKey(const Key('login-error')), findsOneWidget);
});
testWidgets('exposes loginPageShouldWarnHttp helper', (tester) async {
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile/features/servers/server_form_data.dart';
import 'package:mobile/features/servers/server_form_page.dart';
import 'package:mobile/features/servers/server_group_model.dart';
import 'package:mobile/features/servers/server_model.dart';
import 'package:mobile/features/servers/server_repository.dart';
import 'package:mobile/features/terminal/ssh_connection_config.dart';
import 'package:mobile/l10n/app_localizations.dart';
import 'package:mobile/state/api_providers.dart';
class _FakeRepository implements ServerRepository {
int createCalls = 0;
@override
Future<List<ServerModel>> fetchHosts() async => const [];
@override
Future<List<ServerGroupModel>> fetchGroups() async => [
ServerGroupModel.fromJson({
'id': 'default',
'name': 'Default group',
'index': 1,
}),
];
@override
Future<String> createHost(ServerFormData form) async {
createCalls++;
return 'success';
}
@override
Future<String> updateHost(ServerFormData form) async => 'success';
@override
Future<String> deleteHost(String hostId) async => 'success';
@override
Future<SshConnectionConfig> fetchSshConfig(String hostId) async {
throw UnimplementedError();
}
}
Widget _wrap(ServerRepository repo) {
return ProviderScope(
overrides: [serverRepositoryProvider.overrideWithValue(repo)],
child: const MaterialApp(
locale: Locale('en'),
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: ServerFormPage(),
),
);
}
Future<void> _pumpForm(WidgetTester tester, _FakeRepository repo) async {
tester.view.physicalSize = const Size(1080, 1920);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_wrap(repo));
await tester.pumpAndSettle();
}
Future<void> _fillRequiredFields(WidgetTester tester) async {
final fields = find.byType(TextFormField);
await tester.enterText(fields.at(0), 'prod');
await tester.enterText(fields.at(2), '10.0.0.2');
}
void main() {
testWidgets('requires credential when auth type is credential', (tester) async {
final repo = _FakeRepository();
await _pumpForm(tester, repo);
await _fillRequiredFields(tester);
await tester.tap(find.text('Credential').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Add server').last);
await tester.pumpAndSettle();
expect(find.text('Select a credential'), findsOneWidget);
expect(repo.createCalls, 0);
});
testWidgets('requires proxy target when proxy type is not none', (tester) async {
final repo = _FakeRepository();
await _pumpForm(tester, repo);
await _fillRequiredFields(tester);
await tester.ensureVisible(find.text('Proxy').first);
await tester.tap(find.text('Proxy').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Add server').last);
await tester.pumpAndSettle();
expect(find.text('Select a proxy service'), findsOneWidget);
expect(repo.createCalls, 0);
});
}
@@ -1,7 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile/features/servers/server_form_data.dart';
import 'package:mobile/features/servers/server_model.dart';
void main() {
test('new server form defaults to password auth and no proxy', () {
final form = ServerFormData.add(nextIndex: 3);
expect(form.authType, 'password');
expect(form.proxyType, '');
});
test('parses host-list entry into ServerModel', () {
final model = ServerModel.fromJson({
'id': 'h1',
@@ -40,7 +48,7 @@ void main() {
expect(model.tag, isEmpty);
});
test('canConnect is false when expired or not configured', () {
test('canConnect mirrors isConfig state', () {
final expired = ServerModel.fromJson({
'id': 'h3',
'name': 'old',
@@ -66,7 +74,7 @@ void main() {
'isConfig': false,
});
expect(expired.canConnect, isFalse);
expect(expired.canConnect, isTrue);
expect(unconfigured.canConnect, isFalse);
});
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile/core/api/api_result.dart';
import 'package:mobile/features/servers/server_form_data.dart';
import 'package:mobile/features/servers/server_model.dart';
import 'package:mobile/features/servers/server_group_model.dart';
@@ -43,6 +44,9 @@ class _FakeRepository implements ServerRepository {
password: 'pwd',
privateKey: '',
passphrase: '',
proxyType: '',
proxy: null,
jumpHosts: [],
);
@override
@@ -83,7 +87,7 @@ class _FakeRepository implements ServerRepository {
}
class _RecordingAuthNotifier extends AuthNotifier {
_RecordingAuthNotifier(super.ref) : super(AuthState.empty);
_RecordingAuthNotifier(Ref ref) : super(ref, AuthState.empty);
int signOutCalls = 0;
@@ -117,11 +121,7 @@ ServerGroupModel _group({
String name = 'Default group',
int index = 1,
}) {
return ServerGroupModel.fromJson({
'id': id,
'name': name,
'index': index,
});
return ServerGroupModel.fromJson({'id': id, 'name': name, 'index': index});
}
Widget _wrap({required ServerRepository repo}) {
@@ -151,12 +151,15 @@ void main() {
expect(find.textContaining('No servers yet'), findsOneWidget);
});
testWidgets('renders one card per host with the right action label',
(tester) async {
final repo = _FakeRepository(hosts: [
_server(id: 'h1'),
_server(id: 'h2', canConnect: false),
]);
testWidgets('renders one card per host with the right action label', (
tester,
) async {
final repo = _FakeRepository(
hosts: [
_server(id: 'h1'),
_server(id: 'h2', canConnect: false),
],
);
await tester.pumpWidget(_wrap(repo: repo));
await tester.pumpAndSettle();
@@ -166,8 +169,9 @@ void main() {
expect(find.text('Not configured'), findsOneWidget);
});
testWidgets('hides group filter when only the default group exists',
(tester) async {
testWidgets('hides group filter when only the default group exists', (
tester,
) async {
final repo = _FakeRepository(
hosts: [_server(id: 'h1', group: 'default')],
groups: [_group()],
@@ -176,12 +180,12 @@ void main() {
await tester.pumpAndSettle();
expect(find.textContaining('All'), findsNothing);
expect(find.textContaining('Default group'), findsNothing);
expect(find.byKey(const Key('server-h1')), findsOneWidget);
});
testWidgets('shows group filters and filters cards by selected group',
(tester) async {
testWidgets('shows group filters and filters cards by selected group', (
tester,
) async {
final repo = _FakeRepository(
hosts: [
_server(id: 'h1', group: 'default'),
@@ -263,8 +267,9 @@ void main() {
expect(refreshed, hasLength(2));
});
testWidgets('signs out when initial host fetch is unauthorized',
(tester) async {
testWidgets('signs out when initial host fetch is unauthorized', (
tester,
) async {
final repo = _FakeRepository(
fetchError: UnauthorizedFailure('expired', statusCode: 401),
);
@@ -0,0 +1,81 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile/features/terminal/http_proxy_connector.dart';
Future<String> readHttpHeaders(StreamIterator<List<int>> iterator) async {
final bytes = <int>[];
while (!_hasHeaderTerminator(bytes) && await iterator.moveNext()) {
bytes.addAll(iterator.current);
}
return utf8.decode(bytes, allowMalformed: true);
}
bool _hasHeaderTerminator(List<int> bytes) {
for (var i = 0; i <= bytes.length - 4; i++) {
if (bytes[i] == 13 &&
bytes[i + 1] == 10 &&
bytes[i + 2] == 13 &&
bytes[i + 3] == 10) {
return true;
}
}
return false;
}
void main() {
test('performs http connect handshake', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
late String request;
unawaited(
server.first.then((socket) async {
final iterator = StreamIterator<List<int>>(socket);
request = await readHttpHeaders(iterator);
socket.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
}),
);
final connector = HttpProxyConnector();
final socket = await connector.connect(
proxyHost: '127.0.0.1',
proxyPort: server.port,
targetHost: 'example.com',
targetPort: 22,
);
expect(request, contains('CONNECT example.com:22 HTTP/1.1'));
expect(request, contains('Host: example.com:22'));
await socket.close();
await server.close();
});
test('sends basic auth header when credentials are present', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
late String request;
unawaited(
server.first.then((socket) async {
final iterator = StreamIterator<List<int>>(socket);
request = await readHttpHeaders(iterator);
socket.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
}),
);
final connector = HttpProxyConnector();
final socket = await connector.connect(
proxyHost: '127.0.0.1',
proxyPort: server.port,
targetHost: 'example.com',
targetPort: 22,
username: 'u',
password: 'p',
);
expect(request, contains('Proxy-Authorization: Basic dTpw'));
await socket.close();
await server.close();
});
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile/features/terminal/socks5_connector.dart';
Future<List<int>> readExactlyFromIterator(
StreamIterator<List<int>> iterator,
int length,
) async {
final bytes = <int>[];
while (bytes.length < length && await iterator.moveNext()) {
bytes.addAll(iterator.current);
}
return bytes.take(length).toList();
}
void main() {
test('performs no-auth socks5 handshake', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final captured = <List<int>>[];
unawaited(
server.first.then((socket) async {
final iterator = StreamIterator<List<int>>(socket);
captured.add(await readExactlyFromIterator(iterator, 3));
socket.add([0x05, 0x00]);
captured.add(await readExactlyFromIterator(iterator, 18));
socket.add([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0x1F, 0x90]);
}),
);
final connector = Socks5Connector();
final socket = await connector.connect(
proxyHost: '127.0.0.1',
proxyPort: server.port,
targetHost: 'example.com',
targetPort: 22,
);
expect(captured.first, [0x05, 0x01, 0x00]);
expect(captured.last.take(5), [0x05, 0x01, 0x00, 0x03, 11]);
await socket.close();
await server.close();
});
test('performs username password socks5 handshake', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final captured = <List<int>>[];
unawaited(
server.first.then((socket) async {
final iterator = StreamIterator<List<int>>(socket);
captured.add(await readExactlyFromIterator(iterator, 4));
socket.add([0x05, 0x02]);
captured.add(await readExactlyFromIterator(iterator, 5));
socket.add([0x01, 0x00]);
captured.add(await readExactlyFromIterator(iterator, 18));
socket.add([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0x1F, 0x90]);
}),
);
final connector = Socks5Connector();
final socket = await connector.connect(
proxyHost: '127.0.0.1',
proxyPort: server.port,
targetHost: 'example.com',
targetPort: 22,
username: 'u',
password: 'p',
);
expect(captured.first, [0x05, 0x02, 0x00, 0x02]);
expect(captured[1], [0x01, 0x01, 117, 0x01, 112]);
await socket.close();
await server.close();
});
}
@@ -2,26 +2,103 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mobile/features/terminal/ssh_connection_config.dart';
void main() {
SshConnectionConfig configWithPassphrase(String passphrase) {
return SshConnectionConfig(
hostId: 'h1',
name: 'prod',
host: '10.0.0.2',
port: 22,
username: 'root',
authType: 'privateKey',
password: '',
privateKey: 'key',
passphrase: passphrase,
);
}
Map<String, dynamic> basePayload({String passphrase = ''}) => {
'hostId': 'h1',
'name': 'prod',
'host': '10.0.0.2',
'port': 22,
'username': 'root',
'authType': 'privateKey',
'password': '',
'privateKey': 'key',
'passphrase': passphrase,
'proxyType': '',
'proxy': null,
'jumpHosts': [],
};
test('uses null passphrase for unencrypted private keys', () {
expect(configWithPassphrase('').privateKeyPassphrase, isNull);
expect(configWithPassphrase(' ').privateKeyPassphrase, isNull);
test('parses direct payload and normalizes empty passphrase', () {
final config = SshConnectionConfig.fromJson(basePayload(passphrase: ' '));
expect(config.hostId, 'h1');
expect(config.port, 22);
expect(config.proxyType, '');
expect(config.proxy, isNull);
expect(config.jumpHosts, isEmpty);
expect(config.privateKeyPassphrase, isNull);
});
test('keeps non-empty private key passphrase', () {
expect(configWithPassphrase(' secret ').privateKeyPassphrase, 'secret');
final config = SshConnectionConfig.fromJson(
basePayload(passphrase: ' secret '),
);
expect(config.privateKeyPassphrase, 'secret');
});
test('parses socks5 proxy payload', () {
final config = SshConnectionConfig.fromJson({
...basePayload(),
'proxyType': 'proxyServer',
'proxy': {
'id': 'p1',
'name': 'office',
'type': 'socks5',
'host': '127.0.0.1',
'port': '1080',
'username': 'u',
'password': 'p',
},
});
expect(config.proxyType, 'proxyServer');
expect(config.proxy!.id, 'p1');
expect(config.proxy!.port, 1080);
expect(config.proxy!.username, 'u');
});
test('parses http proxy payload', () {
final config = SshConnectionConfig.fromJson({
...basePayload(),
'proxyType': 'proxyServer',
'proxy': {
'id': 'p2',
'name': 'office-http',
'type': 'http',
'host': '127.0.0.1',
'port': '8080',
'username': 'u',
'password': 'p',
},
});
expect(config.proxyType, 'proxyServer');
expect(config.proxy!.type, 'http');
expect(config.proxy!.port, 8080);
});
test('parses jump host payload and normalizes jump passphrase', () {
final config = SshConnectionConfig.fromJson({
...basePayload(),
'proxyType': 'jumpHosts',
'jumpHosts': [
{
'hostId': 'j1',
'name': 'jump',
'host': '203.0.113.10',
'port': 2200,
'username': 'root',
'authType': 'privateKey',
'password': '',
'privateKey': 'jump-key',
'passphrase': '',
},
],
});
expect(config.jumpHosts, hasLength(1));
expect(config.jumpHosts.single.hostId, 'j1');
expect(config.jumpHosts.single.port, 2200);
expect(config.jumpHosts.single.privateKeyPassphrase, isNull);
});
}
@@ -0,0 +1,267 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile/features/terminal/http_proxy_connector.dart';
import 'package:mobile/features/terminal/ssh_connection_config.dart';
import 'package:mobile/features/terminal/ssh_transport.dart';
void main() {
const directConfig = SshConnectionConfig(
hostId: 'h1',
name: 'prod',
host: '10.0.0.2',
port: 22,
username: 'root',
authType: 'password',
password: 'secret',
privateKey: '',
passphrase: '',
proxyType: '',
proxy: null,
jumpHosts: [],
);
test('opens direct socket when proxyType is empty', () async {
final opened = <String>[];
final factory = SshTransportFactory(
connectSocket: (host, port) async {
opened.add('$host:$port');
return FakeSocket(host);
},
);
final handle = await factory.open(directConfig);
expect(opened, ['10.0.0.2:22']);
expect(handle.socket, isA<FakeSocket>());
});
test('fails explicitly for unsupported proxy type', () async {
final factory = SshTransportFactory(
connectSocket: (host, port) async => FakeSocket(host),
);
const proxiedConfig = SshConnectionConfig(
hostId: 'h1',
name: 'prod',
host: '10.0.0.2',
port: 22,
username: 'root',
authType: 'password',
password: 'secret',
privateKey: '',
passphrase: '',
proxyType: 'http',
proxy: null,
jumpHosts: [],
);
expect(
() => factory.open(proxiedConfig),
throwsA(
isA<SshTransportException>().having(
(error) => error.message,
'message',
'Unsupported mobile proxy type: http',
),
),
);
});
test('opens http proxy socket when proxy type is http', () async {
final logs = <String>[];
final factory = SshTransportFactory(
connectSocket: (host, port) async => FakeSocket('unused'),
httpProxyConnector: FakeHttpProxyConnector(FakeSocket('http-tunnel')),
);
const proxiedConfig = SshConnectionConfig(
hostId: 'h1',
name: 'prod',
host: '10.0.0.2',
port: 22,
username: 'root',
authType: 'password',
password: 'secret',
privateKey: '',
passphrase: '',
proxyType: 'proxyServer',
proxy: SshProxyConfig(
id: 'p1',
name: 'office',
type: 'http',
host: '127.0.0.1',
port: 8080,
username: 'u',
password: 'p',
),
jumpHosts: [],
);
final handle = await factory.open(proxiedConfig, logger: logs.add);
expect(handle.socket, isA<FakeSocket>());
expect((handle.socket as FakeSocket).label, 'http-tunnel');
expect(logs, [
'使用代理服务器 office (HTTP) - 127.0.0.1:8080',
'代理连接建立成功,准备通过代理连接目标服务器',
]);
});
test('fails when jumpHosts proxyType has empty chain', () async {
final factory = SshTransportFactory(
connectSocket: (host, port) async => FakeSocket('unused'),
);
const jumpConfig = SshConnectionConfig(
hostId: 'h1',
name: 'prod',
host: '10.0.0.2',
port: 22,
username: 'root',
authType: 'password',
password: 'secret',
privateKey: '',
passphrase: '',
proxyType: 'jumpHosts',
proxy: null,
jumpHosts: [],
);
expect(
() => factory.open(jumpConfig),
throwsA(
isA<SshTransportException>().having(
(error) => error.message,
'message',
'Jump host connection failed: empty chain',
),
),
);
});
test('opens jump host chain and keeps intermediate clients', () async {
final opened = <String>[];
final closed = <String>[];
final logs = <String>[];
final factory = SshTransportFactory(
connectSocket: (host, port) async {
opened.add('tcp:$host:$port');
return FakeSocket(host);
},
createClient: (socket, auth) => FakeSshClient(
auth.host,
closed,
forwardSocket: FakeSocket('forward:${auth.host}'),
),
);
const jumpConfig = SshConnectionConfig(
hostId: 'h1',
name: 'prod',
host: '10.0.0.2',
port: 22,
username: 'root',
authType: 'password',
password: 'secret',
privateKey: '',
passphrase: '',
proxyType: 'jumpHosts',
proxy: null,
jumpHosts: [
SshJumpHostConfig(
hostId: 'j1',
name: 'jump',
host: '203.0.113.10',
port: 22,
username: 'root',
authType: 'password',
password: 'jump-secret',
privateKey: '',
passphrase: '',
),
],
);
final handle = await factory.open(jumpConfig, logger: logs.add);
expect(opened, ['tcp:203.0.113.10:22']);
expect(handle.socket, isA<FakeSocket>());
expect(logs, [
'准备通过跳板机连接目标服务器,共 1 跳',
'连接跳板机 1/1: jump - 203.0.113.10:22',
'跳板机认证成功: jump',
'跳板机转发: 203.0.113.10:22 -> 10.0.0.2:22',
'跳板机连接成功,准备连接目标服务器',
]);
await handle.close();
expect(closed, ['203.0.113.10']);
});
}
class FakeHttpProxyConnector extends HttpProxyConnector {
FakeHttpProxyConnector(this.socket);
final SSHSocket socket;
@override
Future<SSHSocket> connect({
required String proxyHost,
required int proxyPort,
required String targetHost,
required int targetPort,
String username = '',
String password = '',
}) async {
return socket;
}
}
class FakeSshClient implements SshClientHandle {
FakeSshClient(this.label, this.closed, {required this.forwardSocket});
final String label;
final List<String> closed;
final SSHSocket forwardSocket;
@override
Future<void> get authenticated async {}
@override
Future<SSHSocket> forwardLocal(String host, int port) async => forwardSocket;
@override
void close() {
closed.add(label);
}
}
class FakeSocket implements SSHSocket {
FakeSocket(this.label);
final String label;
bool closed = false;
final _controller = StreamController<Uint8List>();
final _sinkController = StreamController<List<int>>();
@override
Stream<Uint8List> get stream => _controller.stream;
@override
StreamSink<List<int>> get sink => _sinkController.sink;
@override
Future<void> get done => _controller.done;
@override
Future<void> close() async {
closed = true;
unawaited(_sinkController.close());
unawaited(_controller.close());
}
@override
void destroy() {
closed = true;
unawaited(_sinkController.close());
unawaited(_controller.close());
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ function normalizeMobileAuthPayload(hostId, name, authInfo = {}) {
}
function normalizeMobileProxy(proxy = {}) {
if (proxy.type !== 'socks5') {
if (!['socks5', 'http'].includes(proxy.type)) {
throw new Error(`unsupported mobile proxy type: ${ proxy.type || 'empty' }`)
}
+37 -3
View File
@@ -87,6 +87,39 @@ function testSocks5ProxyPayload() {
assert.deepStrictEqual(payload.jumpHosts, [])
}
function testHttpProxyPayload() {
const payload = toMobileSshPayload('h4-http', 'http-proxied', {
host: '10.0.0.50',
port: 22,
username: 'deploy',
authType: 'password',
password: 'secret'
}, {
proxyType: 'proxyServer',
proxy: {
id: 'p-http',
name: 'http-proxy',
type: 'http',
host: '127.0.0.1',
port: '8080',
username: 'proxy-user',
password: 'proxy-pass'
}
})
assert.deepStrictEqual(payload.proxy, {
id: 'p-http',
name: 'http-proxy',
type: 'http',
host: '127.0.0.1',
port: 8080,
username: 'proxy-user',
password: 'proxy-pass'
})
assert.strictEqual(payload.proxyType, 'proxyServer')
assert.deepStrictEqual(payload.jumpHosts, [])
}
function testJumpHostsPayload() {
const payload = toMobileSshPayload('h5', 'target', {
host: '10.0.0.6',
@@ -140,12 +173,12 @@ function testRejectsUnsupportedProxyType() {
proxyType: 'proxyServer',
proxy: {
id: 'p2',
name: 'http-proxy',
type: 'http',
name: 'https-proxy',
type: 'https',
host: '127.0.0.1',
port: 8080
}
}), /unsupported mobile proxy type: http/)
}), /unsupported mobile proxy type: https/)
}
function testRejectsEmptyJumpHostChain() {
@@ -165,6 +198,7 @@ testPasswordPayload()
testPrivateKeyPayload()
testRejectsUnsupportedAuth()
testSocks5ProxyPayload()
testHttpProxyPayload()
testJumpHostsPayload()
testRejectsUnsupportedProxyType()
testRejectsEmptyJumpHostChain()