mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-14 04:05:18 +01:00
@@ -45,14 +45,17 @@ Future<SSHClient> genClient(
|
||||
/// Only pass this param if using multi-threading and key login
|
||||
String? privateKey,
|
||||
|
||||
/// Only pass this param if using multi-threading and key login
|
||||
String? jumpPrivateKey,
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
|
||||
/// [Spi] of the jump server
|
||||
/// Pre-resolved jump chain (in `spi.jumpId` order: immediate -> farthest).
|
||||
///
|
||||
/// Must pass this param if using multi-threading and key login
|
||||
Spi? jumpSpi,
|
||||
/// This is mainly used when `Stores` is unavailable (e.g. in an isolate).
|
||||
List<Spi>? jumpChain,
|
||||
|
||||
/// Private keys for [jumpChain], aligned by index.
|
||||
///
|
||||
/// If a jump server uses key auth (`keyId != null`), you must provide the
|
||||
/// decrypted key pem here (or `genClient` will try to read from `Stores`).
|
||||
List<String?>? jumpPrivateKeys,
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
|
||||
/// Handle keyboard-interactive authentication
|
||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
||||
@@ -60,6 +63,39 @@ Future<SSHClient> genClient(
|
||||
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
|
||||
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
|
||||
}) async {
|
||||
return _genClientInternal(
|
||||
spi,
|
||||
onStatus: onStatus,
|
||||
privateKey: privateKey,
|
||||
jumpChain: jumpChain,
|
||||
jumpPrivateKeys: jumpPrivateKeys,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
knownHostFingerprints: knownHostFingerprints,
|
||||
onHostKeyAccepted: onHostKeyAccepted,
|
||||
onHostKeyPrompt: onHostKeyPrompt,
|
||||
visited: <String>{},
|
||||
);
|
||||
}
|
||||
|
||||
Future<SSHClient> _genClientInternal(
|
||||
Spi spi, {
|
||||
void Function(GenSSHClientStatus)? onStatus,
|
||||
String? privateKey,
|
||||
List<Spi>? jumpChain,
|
||||
List<String?>? jumpPrivateKeys,
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
||||
Map<String, String>? knownHostFingerprints,
|
||||
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
|
||||
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
|
||||
required Set<String> visited,
|
||||
}) async {
|
||||
final identifier = _hostIdentifier(spi);
|
||||
if (!visited.add(identifier)) {
|
||||
throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at ${spi.name} ($identifier)');
|
||||
}
|
||||
|
||||
onStatus?.call(GenSSHClientStatus.socket);
|
||||
|
||||
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
|
||||
@@ -70,20 +106,42 @@ Future<SSHClient> genClient(
|
||||
|
||||
final socket = await () async {
|
||||
// Proxy
|
||||
final jumpSpi_ = () {
|
||||
// Multi-thread or key login
|
||||
if (jumpSpi != null) return jumpSpi;
|
||||
// Main thread
|
||||
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
|
||||
}();
|
||||
final jumpId = spi.jumpId;
|
||||
Spi? jumpSpi_;
|
||||
String? jumpPrivateKey;
|
||||
|
||||
if (jumpId != null) {
|
||||
if (jumpChain != null) {
|
||||
final idx = jumpChain.indexWhere((e) => e.id == jumpId || e.oldId == jumpId);
|
||||
if (idx == -1) {
|
||||
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $jumpId');
|
||||
}
|
||||
jumpSpi_ = jumpChain[idx];
|
||||
jumpPrivateKey = jumpPrivateKeys != null && idx < jumpPrivateKeys.length ? jumpPrivateKeys[idx] : null;
|
||||
|
||||
if (jumpSpi_.keyId != null && jumpPrivateKey == null) {
|
||||
throw SSHErr(
|
||||
type: SSHErrType.noPrivateKey,
|
||||
message: l10n.privateKeyNotFoundFmt(jumpSpi_.keyId ?? ''),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
jumpSpi_ = Stores.server.box.get(jumpId);
|
||||
}
|
||||
}
|
||||
|
||||
if (jumpSpi_ != null) {
|
||||
final jumpClient = await genClient(
|
||||
final jumpClient = await _genClientInternal(
|
||||
jumpSpi_,
|
||||
privateKey: jumpPrivateKey,
|
||||
jumpChain: jumpChain,
|
||||
jumpPrivateKeys: jumpPrivateKeys,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
knownHostFingerprints: hostKeyCache,
|
||||
onHostKeyAccepted: hostKeyPersist,
|
||||
onHostKeyPrompt: onHostKeyPrompt,
|
||||
onHostKeyPrompt: hostKeyPrompt,
|
||||
visited: visited,
|
||||
);
|
||||
|
||||
return await jumpClient.forwardLocal(spi.ip, spi.port);
|
||||
@@ -300,6 +358,25 @@ Future<void> ensureKnownHostKey(
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
||||
}) async {
|
||||
return _ensureKnownHostKeyInternal(
|
||||
spi,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
visited: <String>{},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _ensureKnownHostKeyInternal(
|
||||
Spi spi, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
||||
required Set<String> visited,
|
||||
}) async {
|
||||
final identifier = _hostIdentifier(spi);
|
||||
if (!visited.add(identifier)) {
|
||||
throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at ${spi.name} ($identifier)');
|
||||
}
|
||||
|
||||
final cache = _loadKnownHostFingerprints();
|
||||
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
|
||||
return;
|
||||
@@ -307,10 +384,11 @@ Future<void> ensureKnownHostKey(
|
||||
|
||||
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
|
||||
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
|
||||
await ensureKnownHostKey(
|
||||
await _ensureKnownHostKeyInternal(
|
||||
jumpSpi,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
visited: visited,
|
||||
);
|
||||
cache.addAll(_loadKnownHostFingerprints());
|
||||
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;
|
||||
|
||||
@@ -6,8 +6,8 @@ class SftpReq {
|
||||
final String localPath;
|
||||
final SftpReqType type;
|
||||
String? privateKey;
|
||||
Spi? jumpSpi;
|
||||
String? jumpPrivateKey;
|
||||
List<Spi>? jumpChain;
|
||||
List<String?>? jumpPrivateKeys;
|
||||
Map<String, String>? knownHostFingerprints;
|
||||
|
||||
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
|
||||
@@ -16,8 +16,32 @@ class SftpReq {
|
||||
privateKey = getPrivateKey(keyId);
|
||||
}
|
||||
if (spi.jumpId != null) {
|
||||
jumpSpi = Stores.server.box.get(spi.jumpId);
|
||||
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
|
||||
final chain = <Spi>[];
|
||||
final keys = <String?>[];
|
||||
final visited = <String>{spi.id.isNotEmpty ? spi.id : spi.oldId};
|
||||
|
||||
var currentJumpId = spi.jumpId;
|
||||
while (currentJumpId != null) {
|
||||
final jumpSpi = Stores.server.box.get(currentJumpId);
|
||||
if (jumpSpi == null) break;
|
||||
|
||||
// Prevent infinite loops if user mis-configured jump servers.
|
||||
final jumpId = jumpSpi.id.isNotEmpty ? jumpSpi.id : jumpSpi.oldId;
|
||||
if (!visited.add(jumpId)) {
|
||||
throw SSHErr(
|
||||
type: SSHErrType.connect,
|
||||
message: 'Jump loop detected while building SFTP chain: ${jumpSpi.name}',
|
||||
);
|
||||
}
|
||||
|
||||
chain.add(jumpSpi);
|
||||
keys.add(jumpSpi.keyId != null ? getPrivateKey(jumpSpi.keyId!) : null);
|
||||
currentJumpId = jumpSpi.jumpId;
|
||||
}
|
||||
|
||||
// Always set when `spi.jumpId != null` so the isolate won't fallback to Stores.
|
||||
jumpChain = chain;
|
||||
jumpPrivateKeys = keys;
|
||||
}
|
||||
try {
|
||||
knownHostFingerprints = Map<String, String>.from(Stores.setting.sshKnownHostFingerprints.get());
|
||||
@@ -90,4 +114,4 @@ class SftpReqStatus {
|
||||
}
|
||||
}
|
||||
|
||||
enum SftpWorkerStatus { preparing, sshConnectted, loading, finished }
|
||||
enum SftpWorkerStatus { preparing, sshConnected, loading, finished }
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:easy_isolate/easy_isolate.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:server_box/core/utils/server.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
|
||||
@@ -63,11 +64,11 @@ Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen
|
||||
final client = await genClient(
|
||||
req.spi,
|
||||
privateKey: req.privateKey,
|
||||
jumpSpi: req.jumpSpi,
|
||||
jumpPrivateKey: req.jumpPrivateKey,
|
||||
jumpChain: req.jumpChain,
|
||||
jumpPrivateKeys: req.jumpPrivateKeys,
|
||||
knownHostFingerprints: req.knownHostFingerprints,
|
||||
);
|
||||
mainSendPort.send(SftpWorkerStatus.sshConnectted);
|
||||
mainSendPort.send(SftpWorkerStatus.sshConnected);
|
||||
|
||||
/// Create the directory if not exists
|
||||
final dirPath = req.localPath.substring(0, req.localPath.lastIndexOf(Pfs.seperator));
|
||||
@@ -120,11 +121,11 @@ Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE
|
||||
final client = await genClient(
|
||||
req.spi,
|
||||
privateKey: req.privateKey,
|
||||
jumpSpi: req.jumpSpi,
|
||||
jumpPrivateKey: req.jumpPrivateKey,
|
||||
jumpChain: req.jumpChain,
|
||||
jumpPrivateKeys: req.jumpPrivateKeys,
|
||||
knownHostFingerprints: req.knownHostFingerprints,
|
||||
);
|
||||
mainSendPort.send(SftpWorkerStatus.sshConnectted);
|
||||
mainSendPort.send(SftpWorkerStatus.sshConnected);
|
||||
|
||||
final local = File(req.localPath);
|
||||
if (!await local.exists()) {
|
||||
|
||||
@@ -349,36 +349,88 @@ extension _Widgets on _ServerEditPageState {
|
||||
|
||||
Widget _buildJumpServer() {
|
||||
const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7);
|
||||
final srvs = ref
|
||||
.watch(serversProvider)
|
||||
.servers
|
||||
.values
|
||||
.where((e) => e.jumpId == null)
|
||||
.where((e) => e.id != spi?.id)
|
||||
.toList();
|
||||
final choice = _jumpServer.listenVal((val) {
|
||||
final servers = ref.watch(serversProvider).servers;
|
||||
final selfId = spi?.id;
|
||||
|
||||
bool wouldCreateCycle(Spi candidate) {
|
||||
if (selfId == null) return false;
|
||||
if (candidate.id == selfId) return true;
|
||||
|
||||
final visited = <String>{selfId};
|
||||
var current = candidate;
|
||||
while (true) {
|
||||
final jumpId = current.jumpId;
|
||||
if (jumpId == null) return false;
|
||||
if (jumpId == selfId) return true;
|
||||
if (!visited.add(jumpId)) {
|
||||
// Candidate already contains a loop; treat as invalid to avoid infinite jump.
|
||||
return true;
|
||||
}
|
||||
final next = servers[jumpId];
|
||||
if (next == null) return false;
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
String? buildJumpChainText(String jumpId) {
|
||||
final chain = <Spi>[];
|
||||
final visited = <String>{};
|
||||
var currentId = jumpId;
|
||||
|
||||
while (true) {
|
||||
if (!visited.add(currentId)) {
|
||||
break;
|
||||
}
|
||||
final srv = servers[currentId];
|
||||
if (srv == null) break;
|
||||
chain.add(srv);
|
||||
final nextId = srv.jumpId;
|
||||
if (nextId == null) break;
|
||||
currentId = nextId;
|
||||
}
|
||||
|
||||
if (chain.isEmpty) return null;
|
||||
// Display as actual connection order: farthest -> ... -> nearest.
|
||||
return chain.reversed.map((e) => e.name).join(' → ');
|
||||
}
|
||||
|
||||
final srvs = servers.values.where((e) => e.id != selfId && !wouldCreateCycle(e)).toList();
|
||||
final body = _jumpServer.listenVal((val) {
|
||||
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
|
||||
return Choice<Spi>(
|
||||
multiple: false,
|
||||
clearable: true,
|
||||
value: srv != null ? [srv] : [],
|
||||
builder: (state, _) => Wrap(
|
||||
children: List<Widget>.generate(srvs.length, (index) {
|
||||
final item = srvs[index];
|
||||
return ChoiceChipX<Spi>(
|
||||
label: item.name,
|
||||
state: state,
|
||||
value: item,
|
||||
onSelected: (srv, on) {
|
||||
if (on) {
|
||||
_jumpServer.value = srv.id;
|
||||
} else {
|
||||
_jumpServer.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
final chainText = val == null ? null : buildJumpChainText(val);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (chainText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 7),
|
||||
child: Text(chainText, style: UIs.textGrey),
|
||||
),
|
||||
Choice<Spi>(
|
||||
multiple: false,
|
||||
clearable: true,
|
||||
value: srv != null ? [srv] : [],
|
||||
builder: (state, _) => Wrap(
|
||||
children: List<Widget>.generate(srvs.length, (index) {
|
||||
final item = srvs[index];
|
||||
return ChoiceChipX<Spi>(
|
||||
label: item.name,
|
||||
state: state,
|
||||
value: item,
|
||||
onSelected: (srv, on) {
|
||||
if (on) {
|
||||
_jumpServer.value = srv.id;
|
||||
} else {
|
||||
_jumpServer.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
return ExpandTile(
|
||||
@@ -386,7 +438,7 @@ extension _Widgets on _ServerEditPageState {
|
||||
initiallyExpanded: _jumpServer.value != null,
|
||||
childrenPadding: padding,
|
||||
title: Text(l10n.jumpServer),
|
||||
children: [choice],
|
||||
children: [body],
|
||||
).cardx;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class _SftpMissionPageState extends ConsumerState<SftpMissionPage> {
|
||||
return switch (status.status) {
|
||||
const (SftpWorkerStatus.finished) => _buildFinished(status),
|
||||
const (SftpWorkerStatus.loading) => _buildLoading(status),
|
||||
const (SftpWorkerStatus.sshConnectted) => _buildConnected(status),
|
||||
const (SftpWorkerStatus.sshConnected) => _buildConnected(status),
|
||||
const (SftpWorkerStatus.preparing) => _buildPreparing(status),
|
||||
_ => _buildDefault(status),
|
||||
};
|
||||
|
||||
63
test/jump_server_test.dart
Normal file
63
test/jump_server_test.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:server_box/core/utils/server.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
|
||||
void main() {
|
||||
group('Jump server', () {
|
||||
test('genClient throws when injected chain misses jump server', () async {
|
||||
const spi = Spi(
|
||||
name: 'target',
|
||||
ip: '10.0.0.10',
|
||||
port: 22,
|
||||
user: 'root',
|
||||
id: 't',
|
||||
jumpId: 'missing',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
() => genClient(
|
||||
spi,
|
||||
jumpChain: const <Spi>[],
|
||||
jumpPrivateKeys: const <String?>[],
|
||||
knownHostFingerprints: const <String, String>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<SSHErr>().having(
|
||||
(e) => e.type,
|
||||
'type',
|
||||
SSHErrType.connect,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('genClient detects jump loop', () async {
|
||||
const spi = Spi(
|
||||
name: 'loop',
|
||||
ip: '10.0.0.20',
|
||||
port: 22,
|
||||
user: 'root',
|
||||
id: 'loop_id',
|
||||
jumpId: 'loop_id',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
() => genClient(
|
||||
spi,
|
||||
jumpChain: const <Spi>[spi],
|
||||
jumpPrivateKeys: const <String?>[null],
|
||||
knownHostFingerprints: const <String, String>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<SSHErr>().having(
|
||||
(e) => e.type,
|
||||
'type',
|
||||
SSHErrType.connect,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user