mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 12:48:28 +08:00
feat: 跳板机&代理功能支持
This commit is contained in:
+13193
-1
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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '即将上线:脚本库',
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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' }`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user