From bc69686d16f7658dfe7249294da3241c1b423826 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: Sat, 28 Feb 2026 00:12:03 +0800 Subject: [PATCH] feat(ssh): support full multi-hop jump chain (#356) (#1058) * feat(ssh): support full multi-hop jump chain (#356) * fix(edit): validate jump cycle for new server saves --- lib/core/utils/jump_chain.dart | 53 ++++++++++++++ lib/core/utils/server.dart | 96 ++++++++++++++++++++++---- lib/data/model/sftp/req.dart | 49 +++++++++++-- lib/data/model/sftp/worker.dart | 43 ++++++++++-- lib/view/page/server/edit/actions.dart | 81 ++++++++++++++++++---- lib/view/page/server/edit/edit.dart | 14 +++- lib/view/page/server/edit/widget.dart | 2 +- test/jump_chain_test.dart | 91 ++++++++++++++++++++++++ 8 files changed, 385 insertions(+), 44 deletions(-) create mode 100644 lib/core/utils/jump_chain.dart create mode 100644 test/jump_chain_test.dart diff --git a/lib/core/utils/jump_chain.dart b/lib/core/utils/jump_chain.dart new file mode 100644 index 00000000..44368dc5 --- /dev/null +++ b/lib/core/utils/jump_chain.dart @@ -0,0 +1,53 @@ +import 'package:server_box/data/model/server/server_private_info.dart'; + +/// Returns `true` when assigning [candidateJumpId] to [currentServerId] +/// would create a jump-server cycle. +bool wouldCreateJumpCycle({ + required String? currentServerId, + required String? candidateJumpId, + required Map serversById, +}) { + if (candidateJumpId == null || candidateJumpId.isEmpty) { + return false; + } + + final visited = {}; + var checkingId = candidateJumpId; + + while (true) { + if (currentServerId != null && checkingId == currentServerId) { + return true; + } + if (!visited.add(checkingId)) { + // Existing malformed cycle is treated as invalid to prevent linking into it. + return true; + } + + final nextId = serversById[checkingId]?.jumpId; + if (nextId == null || nextId.isEmpty) { + return false; + } + checkingId = nextId; + } +} + +/// Collects all reachable jump servers from [spi.jumpId], keyed by server id. +Map collectJumpServers({ + required Spi spi, + required Map serversById, +}) { + final chain = {}; + final visited = {}; + var jumpId = spi.jumpId; + + while (jumpId != null && jumpId.isNotEmpty && visited.add(jumpId)) { + final jumpSpi = serversById[jumpId]; + if (jumpSpi == null) { + break; + } + chain[jumpSpi.id] = jumpSpi; + jumpId = jumpSpi.jumpId; + } + + return chain; +} diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 2fc55f95..013adc55 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -33,7 +33,10 @@ enum GenSSHClientStatus { socket, key, pwd } String getPrivateKey(String id) { final pki = Stores.key.fetchOne(id); if (pki == null) { - throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id)); + throw SSHErr( + type: SSHErrType.noPrivateKey, + message: l10n.privateKeyNotFoundFmt(id), + ); } return pki.key; } @@ -47,6 +50,12 @@ Future genClient( /// Only pass this param if using multi-threading and key login String? jumpPrivateKey, + + /// Prefer this map in isolate mode, fallback to [Stores.key] otherwise. + Map? privateKeysByKeyId, + + /// Prefer this map in isolate mode, fallback to [Stores.server] otherwise. + Map? jumpSpisById, Duration timeout = const Duration(seconds: 5), /// [Spi] of the jump server @@ -59,10 +68,23 @@ Future genClient( Map? knownHostFingerprints, void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, Future Function(HostKeyPromptInfo info)? onHostKeyPrompt, + Set? visitedServerIds, }) async { + final chainVisitedServerIds = visitedServerIds ?? {}; + final currentServerId = _hostIdentifier(spi); + if (!chainVisitedServerIds.add(currentServerId)) { + throw SSHErr( + type: SSHErrType.connect, + message: + 'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)', + ); + } + onStatus?.call(GenSSHClientStatus.socket); - final hostKeyCache = Map.from(knownHostFingerprints ?? _loadKnownHostFingerprints()); + final hostKeyCache = Map.from( + knownHostFingerprints ?? _loadKnownHostFingerprints(), + ); final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint; final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt; @@ -74,16 +96,33 @@ Future genClient( // 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; + if (jumpId != null) { + return jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId); + } }(); if (jumpSpi_ != null) { + String? nextJumpPrivateKey; + final jumpSpiKeyId = jumpSpi_.keyId; + if (jumpSpi != null && + jumpSpi.id == jumpSpi_.id && + jumpPrivateKey != null) { + // Isolate mode may preload first-hop key and pass it via [jumpPrivateKey]. + nextJumpPrivateKey = jumpPrivateKey; + } else if (jumpSpiKeyId != null) { + nextJumpPrivateKey = privateKeysByKeyId?[jumpSpiKeyId]; + } + final jumpClient = await genClient( jumpSpi_, - privateKey: jumpPrivateKey, + privateKey: nextJumpPrivateKey, + privateKeysByKeyId: privateKeysByKeyId, + jumpSpisById: jumpSpisById, timeout: timeout, knownHostFingerprints: hostKeyCache, onHostKeyAccepted: hostKeyPersist, - onHostKeyPrompt: onHostKeyPrompt, + onHostKeyPrompt: hostKeyPrompt, + visitedServerIds: chainVisitedServerIds, ); return await jumpClient.forwardLocal(spi.ip, spi.port); @@ -126,7 +165,7 @@ Future genClient( // printTrace: debugPrint, ); } - privateKey ??= getPrivateKey(keyId); + privateKey ??= privateKeysByKeyId?[keyId] ?? getPrivateKey(keyId); onStatus?.call(GenSSHClientStatus.key); return SSHClient( @@ -141,7 +180,8 @@ Future genClient( ); } -typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex); +typedef _HostKeyPersistCallback = + void Function(String storageKey, String fingerprintHex); class HostKeyPromptInfo { HostKeyPromptInfo({ @@ -191,7 +231,9 @@ class _HostKeyVerifier { ), ); if (!accepted) { - Loggers.app.warning('User rejected new SSH host key for ${spi.name} ($keyType).'); + Loggers.app.warning( + 'User rejected new SSH host key for ${spi.name} ($keyType).', + ); return false; } _cache[storageKey] = fingerprintHex; @@ -224,7 +266,9 @@ class _HostKeyVerifier { _cache[storageKey] = fingerprintHex; persistCallback?.call(storageKey, fingerprintHex); - Loggers.app.warning('Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.'); + Loggers.app.warning( + 'Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.', + ); return true; } } @@ -257,7 +301,9 @@ void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) { Future _defaultHostKeyPrompt(HostKeyPromptInfo info) async { final ctx = AppNavigator.context; if (ctx == null) { - Loggers.app.warning('Host key prompt skipped: navigator context unavailable.'); + Loggers.app.warning( + 'Host key prompt skipped: navigator context unavailable.', + ); return false; } @@ -279,10 +325,14 @@ Future _defaultHostKeyPrompt(HostKeyPromptInfo info) async { SelectableText('${libL10n.addr}: $hostLine'), SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'), SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)), - SelectableText(l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64)), + SelectableText( + l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64), + ), if (info.previousFingerprintHex != null) ...[ const SizedBox(height: 12), - SelectableText(l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!)), + SelectableText( + l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!), + ), ], ], ), @@ -299,18 +349,35 @@ Future ensureKnownHostKey( Spi spi, { Duration timeout = const Duration(seconds: 5), SSHUserInfoRequestHandler? onKeyboardInteractive, + Map? jumpSpisById, + Set? visitedServerIds, }) async { + final chainVisitedServerIds = visitedServerIds ?? {}; + final currentServerId = _hostIdentifier(spi); + if (!chainVisitedServerIds.add(currentServerId)) { + throw SSHErr( + type: SSHErrType.connect, + message: + 'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)', + ); + } + final cache = _loadKnownHostFingerprints(); if (_hasKnownHostFingerprintForSpi(spi, cache)) { return; } - final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null; + final jumpId = spi.jumpId; + final jumpSpi = jumpId != null + ? (jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId)) + : null; if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) { await ensureKnownHostKey( jumpSpi, timeout: timeout, onKeyboardInteractive: onKeyboardInteractive, + jumpSpisById: jumpSpisById, + visitedServerIds: chainVisitedServerIds, ); cache.addAll(_loadKnownHostFingerprints()); if (_hasKnownHostFingerprintForSpi(spi, cache)) return; @@ -351,4 +418,5 @@ String _fingerprintToHex(Uint8List fingerprint) { return buffer.toString(); } -String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint); +String _fingerprintToBase64(Uint8List fingerprint) => + base64.encode(fingerprint); diff --git a/lib/data/model/sftp/req.dart b/lib/data/model/sftp/req.dart index 64c1f798..05b0aba8 100644 --- a/lib/data/model/sftp/req.dart +++ b/lib/data/model/sftp/req.dart @@ -8,19 +8,57 @@ class SftpReq { String? privateKey; Spi? jumpSpi; String? jumpPrivateKey; + Map? jumpSpisById; + Map? privateKeysByKeyId; Map? knownHostFingerprints; SftpReq(this.spi, this.remotePath, this.localPath, this.type) { + privateKeysByKeyId = {}; + final keyId = spi.keyId; if (keyId != null) { privateKey = getPrivateKey(keyId); + privateKeysByKeyId![keyId] = privateKey!; } + + final allServers = { + for (final server in Stores.server.fetch()) server.id: server, + }; + jumpSpisById = collectJumpServers(spi: spi, serversById: allServers); + if (spi.jumpId != null) { - jumpSpi = Stores.server.box.get(spi.jumpId); + jumpSpi = jumpSpisById?[spi.jumpId]; jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key; + if (jumpSpi?.keyId case final jumpKeyId?) { + if (jumpPrivateKey != null) { + privateKeysByKeyId![jumpKeyId] = jumpPrivateKey!; + } + } } + + for (final jump in jumpSpisById?.values ?? const []) { + final jumpKeyId = jump.keyId; + if (jumpKeyId == null || privateKeysByKeyId!.containsKey(jumpKeyId)) { + continue; + } + final key = Stores.key.fetchOne(jumpKeyId)?.key; + if (key == null) { + continue; + } + privateKeysByKeyId![jumpKeyId] = key; + } + + if (jumpSpisById != null && jumpSpisById!.isEmpty) { + jumpSpisById = null; + } + if (privateKeysByKeyId != null && privateKeysByKeyId!.isEmpty) { + privateKeysByKeyId = null; + } + try { - knownHostFingerprints = Map.from(Stores.setting.sshKnownHostFingerprints.get()); + knownHostFingerprints = Map.from( + Stores.setting.sshKnownHostFingerprints.get(), + ); } catch (e, s) { Loggers.app.warning('Failed to load SSH known host fingerprints', e, s); knownHostFingerprints = null; @@ -46,8 +84,11 @@ class SftpReqStatus { Exception? error; Duration? spentTime; - SftpReqStatus({required this.req, required this.notifyListeners, this.completer}) - : id = DateTime.now().microsecondsSinceEpoch { + SftpReqStatus({ + required this.req, + required this.notifyListeners, + this.completer, + }) : id = DateTime.now().microsecondsSinceEpoch { worker = SftpWorker(onNotify: onNotify, req: req)..init(); } diff --git a/lib/data/model/sftp/worker.dart b/lib/data/model/sftp/worker.dart index 7449c4f0..fe2a88c3 100644 --- a/lib/data/model/sftp/worker.dart +++ b/lib/data/model/sftp/worker.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; 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/jump_chain.dart'; import 'package:server_box/core/utils/server.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/res/store.dart'; @@ -28,7 +29,11 @@ class SftpWorker { /// the threads Future init() async { if (worker.isInitialized) worker.dispose(); - await worker.init(mainMessageHandler, isolateMessageHandler, errorHandler: print); + await worker.init( + mainMessageHandler, + isolateMessageHandler, + errorHandler: print, + ); worker.sendMessage(req); } @@ -39,7 +44,11 @@ class SftpWorker { } /// Handle the messages coming from the main -Future isolateMessageHandler(dynamic data, SendPort mainSendPort, SendErrorFunction sendError) async { +Future isolateMessageHandler( + dynamic data, + SendPort mainSendPort, + SendErrorFunction sendError, +) async { switch (data) { case final SftpReq val: switch (val.type) { @@ -56,7 +65,11 @@ Future isolateMessageHandler(dynamic data, SendPort mainSendPort, SendErro } } -Future _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sendError) async { +Future _download( + SftpReq req, + SendPort mainSendPort, + SendErrorFunction sendError, +) async { try { mainSendPort.send(SftpWorkerStatus.preparing); final watch = Stopwatch()..start(); @@ -65,12 +78,17 @@ Future _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen privateKey: req.privateKey, jumpSpi: req.jumpSpi, jumpPrivateKey: req.jumpPrivateKey, + privateKeysByKeyId: req.privateKeysByKeyId, + jumpSpisById: req.jumpSpisById, knownHostFingerprints: req.knownHostFingerprints, ); mainSendPort.send(SftpWorkerStatus.sshConnectted); /// Create the directory if not exists - final dirPath = req.localPath.substring(0, req.localPath.lastIndexOf(Pfs.seperator)); + final dirPath = req.localPath.substring( + 0, + req.localPath.lastIndexOf(Pfs.seperator), + ); await Directory(dirPath).create(recursive: true); /// Use [FileMode.write] to overwrite the file @@ -92,7 +110,9 @@ Future _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen while (totalRead < size) { final remaining = size - totalRead; - final chunkSize = remaining > defaultChunkSize ? defaultChunkSize : remaining; + final chunkSize = remaining > defaultChunkSize + ? defaultChunkSize + : remaining; dprint('Size: $size, Total Read: $totalRead, Chunk Size: $chunkSize'); final fileData = file.read(offset: totalRead, length: chunkSize); @@ -113,7 +133,11 @@ Future _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen } } -Future _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendError) async { +Future _upload( + SftpReq req, + SendPort mainSendPort, + SendErrorFunction sendError, +) async { try { mainSendPort.send(SftpWorkerStatus.preparing); final watch = Stopwatch()..start(); @@ -122,6 +146,8 @@ Future _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE privateKey: req.privateKey, jumpSpi: req.jumpSpi, jumpPrivateKey: req.jumpPrivateKey, + privateKeysByKeyId: req.privateKeysByKeyId, + jumpSpisById: req.jumpSpisById, knownHostFingerprints: req.knownHostFingerprints, ); mainSendPort.send(SftpWorkerStatus.sshConnectted); @@ -139,7 +165,10 @@ Future _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE // If remote exists, overwrite it final file = await sftp.open( req.remotePath, - mode: SftpFileOpenMode.truncate | SftpFileOpenMode.create | SftpFileOpenMode.write, + mode: + SftpFileOpenMode.truncate | + SftpFileOpenMode.create | + SftpFileOpenMode.write, ); final writer = file.write( localFile, diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index 11c01b2c..c734a299 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -4,6 +4,15 @@ part of 'edit.dart'; final _hostReg = RegExp(r'^[a-zA-Z0-9\.\-_:%;]+$'); extension _Actions on _ServerEditPageState { + bool _isInvalidJumpSelection(String? candidateJumpId) { + final currentServer = spi; + return wouldCreateJumpCycle( + currentServerId: currentServer?.id, + candidateJumpId: candidateJumpId, + serversById: ref.read(serversProvider).servers, + ); + } + Future _onTapSSHDiscovery() async { try { final result = await SshDiscoveryPage.route.go(context); @@ -16,7 +25,9 @@ extension _Actions on _ServerEditPageState { } } - Future _processDiscoveredServers(List discoveredServers) async { + Future _processDiscoveredServers( + List discoveredServers, + ) async { if (discoveredServers.length == 1) { // Single server - populate the current form final server = discoveredServers.first; @@ -30,7 +41,11 @@ extension _Actions on _ServerEditPageState { // Multiple servers - show import dialog final shouldImport = await context.showRoundDialog( title: libL10n.import, - child: Text(libL10n.askContinue('${libL10n.found} ${discoveredServers.length} ${libL10n.servers}')), + child: Text( + libL10n.askContinue( + '${libL10n.found} ${discoveredServers.length} ${libL10n.servers}', + ), + ), actions: Btnx.cancelOk, ); @@ -46,7 +61,9 @@ extension _Actions on _ServerEditPageState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.'), + Text( + '${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.', + ), const SizedBox(height: 8), Text(libL10n.setting), const SizedBox(height: 8), @@ -64,8 +81,12 @@ extension _Actions on _ServerEditPageState { ); if (shouldProceed == true) { - final username = usernameController.text.isNotEmpty ? usernameController.text : defaultUsername; - final keyId = keyIdController.text.isNotEmpty ? keyIdController.text : null; + final username = usernameController.text.isNotEmpty + ? usernameController.text + : defaultUsername; + final keyId = keyIdController.text.isNotEmpty + ? keyIdController.text + : null; final servers = discoveredServers .map( (result) => Spi( @@ -74,7 +95,9 @@ extension _Actions on _ServerEditPageState { port: result.port, user: username, keyId: keyId, - pwd: _passwordController.text.isEmpty ? null : _passwordController.text, + pwd: _passwordController.text.isEmpty + ? null + : _passwordController.text, ), ) .toList(); @@ -122,7 +145,8 @@ extension _Actions on _ServerEditPageState { void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async { dprint('Error importing SSH config: $e'); // Check if it's a permission error and offer file picker as fallback - if (e is PathAccessException || e.toString().contains('Operation not permitted')) { + if (e is PathAccessException || + e.toString().contains('Operation not permitted')) { final useFilePicker = await context.showRoundDialog( title: l10n.sshConfigImport, child: Column( @@ -164,10 +188,15 @@ extension _Actions on _ServerEditPageState { children: [ Text(l10n.sshConfigFoundServers('${summary.total}')), if (summary.hasDuplicates) - Text(l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'), style: UIs.textGrey), + Text( + l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'), + style: UIs.textGrey, + ), Text(l10n.sshConfigServersToImport('${summary.toImport}')), const SizedBox(height: 16), - ...resolved.map((s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})')), + ...resolved.map( + (s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})'), + ), ], ), ), @@ -205,7 +234,10 @@ extension _Actions on _ServerEditPageState { } void _onTapCustomItem() async { - final res = await KvEditor.route.go(context, KvEditorArgs(data: _customCmds.value)); + final res = await KvEditor.route.go( + context, + KvEditorArgs(data: _customCmds.value), + ); if (res == null) return; _customCmds.value = res; } @@ -250,6 +282,11 @@ extension _Actions on _ServerEditPageState { if (_portController.text.isEmpty) { _portController.text = '22'; } + if (_isInvalidJumpSelection(_jumpServer.value)) { + context.showSnackBar('${l10n.invalid}: ${l10n.jumpServer}'); + return; + } + final customCmds = _customCmds.value; final custom = ServerCustom( pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull, @@ -261,10 +298,17 @@ extension _Actions on _ServerEditPageState { scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull, ); - final wolEmpty = _wolMacCtrl.text.isEmpty && _wolIpCtrl.text.isEmpty && _wolPwdCtrl.text.isEmpty; + final wolEmpty = + _wolMacCtrl.text.isEmpty && + _wolIpCtrl.text.isEmpty && + _wolPwdCtrl.text.isEmpty; final wol = wolEmpty ? null - : WakeOnLanCfg(mac: _wolMacCtrl.text, ip: _wolIpCtrl.text, pwd: _wolPwdCtrl.text.selfNotEmptyOrNull); + : WakeOnLanCfg( + mac: _wolMacCtrl.text, + ip: _wolIpCtrl.text, + pwd: _wolPwdCtrl.text.selfNotEmptyOrNull, + ); if (wol != null) { final wolValidation = wol.validate(); if (!wolValidation.$2) { @@ -274,7 +318,9 @@ extension _Actions on _ServerEditPageState { } final spi = Spi( - name: _nameController.text.isEmpty ? _ipController.text : _nameController.text, + name: _nameController.text.isEmpty + ? _ipController.text + : _nameController.text, ip: _ipController.text, port: int.parse(_portController.text), user: _usernameController.text, @@ -291,7 +337,9 @@ extension _Actions on _ServerEditPageState { envs: _env.value.isEmpty ? null : _env.value, id: widget.args?.spi.id ?? ShortId.generate(), customSystemType: _systemType.value, - disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(), + disabledCmdTypes: _disabledCmdTypes.value.isEmpty + ? null + : _disabledCmdTypes.value.toList(), ); if (this.spi == null) { @@ -421,7 +469,10 @@ extension _Utils on _ServerEditPageState { if (spi.keyId == null) { _passwordController.text = spi.pwd ?? ''; } else { - _keyIdx.value = ref.read(privateKeyProvider).keys.indexWhere((e) => e.id == spi.keyId); + _keyIdx.value = ref + .read(privateKeyProvider) + .keys + .indexWhere((e) => e.id == spi.keyId); } /// List in dart is passed by pointer, so you need to copy it here diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 113c2ec8..90bb6ed6 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; +import 'package:server_box/core/utils/jump_chain.dart'; import 'package:server_box/core/utils/server_dedup.dart'; import 'package:server_box/core/utils/ssh_config.dart'; import 'package:server_box/data/model/app/scripts/cmd_types.dart'; @@ -32,13 +33,17 @@ class ServerEditPage extends ConsumerStatefulWidget { const ServerEditPage({super.key, this.args}); - static const route = AppRoute(page: ServerEditPage.new, path: '/servers/edit'); + static const route = AppRoute( + page: ServerEditPage.new, + path: '/servers/edit', + ); @override ConsumerState createState() => _ServerEditPageState(); } -class _ServerEditPageState extends ConsumerState with AfterLayoutMixin { +class _ServerEditPageState extends ConsumerState + with AfterLayoutMixin { late final spi = widget.args?.spi; final _nameController = TextEditingController(); final _ipController = TextEditingController(); @@ -140,7 +145,10 @@ class _ServerEditPageState extends ConsumerState with AfterLayou final children = [ SizedBox( height: 50, - child: ListView(scrollDirection: Axis.horizontal, children: topItems.joinWith(UIs.width13).toList()), + child: ListView( + scrollDirection: Axis.horizontal, + children: topItems.joinWith(UIs.width13).toList(), + ), ), Input( autoFocus: true, diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index 8cf9146c..65855eee 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -354,8 +354,8 @@ extension _Widgets on _ServerEditPageState { .watch(serversProvider) .servers .values - .where((e) => e.jumpId == null) .where((e) => e.id != spi?.id) + .where((e) => !_isInvalidJumpSelection(e.id)) .toList(); final choice = _jumpServer.listenVal((val) { final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value); diff --git a/test/jump_chain_test.dart b/test/jump_chain_test.dart new file mode 100644 index 00000000..2ffd5fb3 --- /dev/null +++ b/test/jump_chain_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/jump_chain.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; + +Spi _spi({required String id, required String name, String? jumpId}) { + return Spi( + id: id, + name: name, + ip: '$name.example.com', + port: 22, + user: 'root', + jumpId: jumpId, + ); +} + +void main() { + group('JumpChain', () { + test('wouldCreateJumpCycle returns false for valid chain', () { + final servers = { + 'A': _spi(id: 'A', name: 'a'), + 'B': _spi(id: 'B', name: 'b', jumpId: 'A'), + }; + + final result = wouldCreateJumpCycle( + currentServerId: 'C', + candidateJumpId: 'B', + serversById: servers, + ); + + expect(result, isFalse); + }); + + test('wouldCreateJumpCycle detects cycle back to current server', () { + final servers = { + 'A': _spi(id: 'A', name: 'a', jumpId: 'B'), + 'B': _spi(id: 'B', name: 'b', jumpId: 'C'), + 'C': _spi(id: 'C', name: 'c'), + }; + + final result = wouldCreateJumpCycle( + currentServerId: 'C', + candidateJumpId: 'A', + serversById: servers, + ); + + expect(result, isTrue); + }); + + test('wouldCreateJumpCycle treats existing malformed loop as invalid', () { + final servers = { + 'A': _spi(id: 'A', name: 'a', jumpId: 'B'), + 'B': _spi(id: 'B', name: 'b', jumpId: 'A'), + }; + + final result = wouldCreateJumpCycle( + currentServerId: 'C', + candidateJumpId: 'A', + serversById: servers, + ); + + expect(result, isTrue); + }); + + test('wouldCreateJumpCycle validates new server with null current id', () { + final servers = { + 'A': _spi(id: 'A', name: 'a', jumpId: 'B'), + 'B': _spi(id: 'B', name: 'b', jumpId: 'A'), + }; + + final result = wouldCreateJumpCycle( + currentServerId: null, + candidateJumpId: 'A', + serversById: servers, + ); + + expect(result, isTrue); + }); + + test('collectJumpServers collects reachable jump servers', () { + final target = _spi(id: 'T', name: 'target', jumpId: 'A'); + final servers = { + 'A': _spi(id: 'A', name: 'a', jumpId: 'B'), + 'B': _spi(id: 'B', name: 'b'), + }; + + final chain = collectJumpServers(spi: target, serversById: servers); + + expect(chain.keys.toList(), ['A', 'B']); + }); + }); +}