From c51cf62015b793d7e859194b1bab0b452f403dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:36:47 +0800 Subject: [PATCH] feat: jump server chain Fixes #356 --- lib/core/utils/server.dart | 110 +++++++++++++++++++---- lib/data/model/sftp/req.dart | 34 +++++-- lib/data/model/sftp/worker.dart | 13 +-- lib/view/page/server/edit/widget.dart | 112 +++++++++++++++++------- lib/view/page/storage/sftp_mission.dart | 2 +- test/jump_server_test.dart | 63 +++++++++++++ 6 files changed, 276 insertions(+), 58 deletions(-) create mode 100644 test/jump_server_test.dart diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 7ee67a38..1d511fce 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -45,14 +45,17 @@ Future 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? 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? jumpPrivateKeys, + Duration timeout = const Duration(seconds: 5), /// Handle keyboard-interactive authentication SSHUserInfoRequestHandler? onKeyboardInteractive, @@ -60,6 +63,39 @@ Future genClient( void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, Future 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: {}, + ); +} + +Future _genClientInternal( + Spi spi, { + void Function(GenSSHClientStatus)? onStatus, + String? privateKey, + List? jumpChain, + List? jumpPrivateKeys, + Duration timeout = const Duration(seconds: 5), + SSHUserInfoRequestHandler? onKeyboardInteractive, + Map? knownHostFingerprints, + void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, + Future Function(HostKeyPromptInfo info)? onHostKeyPrompt, + required Set 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.from(knownHostFingerprints ?? _loadKnownHostFingerprints()); @@ -70,20 +106,42 @@ Future 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 ensureKnownHostKey( Duration timeout = const Duration(seconds: 5), SSHUserInfoRequestHandler? onKeyboardInteractive, }) async { + return _ensureKnownHostKeyInternal( + spi, + timeout: timeout, + onKeyboardInteractive: onKeyboardInteractive, + visited: {}, + ); +} + +Future _ensureKnownHostKeyInternal( + Spi spi, { + Duration timeout = const Duration(seconds: 5), + SSHUserInfoRequestHandler? onKeyboardInteractive, + required Set 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 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; diff --git a/lib/data/model/sftp/req.dart b/lib/data/model/sftp/req.dart index 64c1f798..a80370e8 100644 --- a/lib/data/model/sftp/req.dart +++ b/lib/data/model/sftp/req.dart @@ -6,8 +6,8 @@ class SftpReq { final String localPath; final SftpReqType type; String? privateKey; - Spi? jumpSpi; - String? jumpPrivateKey; + List? jumpChain; + List? jumpPrivateKeys; Map? 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 = []; + final keys = []; + final visited = {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.from(Stores.setting.sshKnownHostFingerprints.get()); @@ -90,4 +114,4 @@ class SftpReqStatus { } } -enum SftpWorkerStatus { preparing, sshConnectted, loading, finished } +enum SftpWorkerStatus { preparing, sshConnected, loading, finished } diff --git a/lib/data/model/sftp/worker.dart b/lib/data/model/sftp/worker.dart index 7449c4f0..49ff885b 100644 --- a/lib/data/model/sftp/worker.dart +++ b/lib/data/model/sftp/worker.dart @@ -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 _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 _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()) { diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index 447e9ee0..b1786b6e 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -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 = {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 = []; + final visited = {}; + 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( - multiple: false, - clearable: true, - value: srv != null ? [srv] : [], - builder: (state, _) => Wrap( - children: List.generate(srvs.length, (index) { - final item = srvs[index]; - return ChoiceChipX( - 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( + multiple: false, + clearable: true, + value: srv != null ? [srv] : [], + builder: (state, _) => Wrap( + children: List.generate(srvs.length, (index) { + final item = srvs[index]; + return ChoiceChipX( + 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; } diff --git a/lib/view/page/storage/sftp_mission.dart b/lib/view/page/storage/sftp_mission.dart index 57588ace..fc24b371 100644 --- a/lib/view/page/storage/sftp_mission.dart +++ b/lib/view/page/storage/sftp_mission.dart @@ -53,7 +53,7 @@ class _SftpMissionPageState extends ConsumerState { 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), }; diff --git a/test/jump_server_test.dart b/test/jump_server_test.dart new file mode 100644 index 00000000..a1cc1cdd --- /dev/null +++ b/test/jump_server_test.dart @@ -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 [], + jumpPrivateKeys: const [], + knownHostFingerprints: const {}, + ), + throwsA( + isA().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], + jumpPrivateKeys: const [null], + knownHostFingerprints: const {}, + ), + throwsA( + isA().having( + (e) => e.type, + 'type', + SSHErrType.connect, + ), + ), + ); + }); + }); +} +