From 35349a90ebeb50514f7b0cc4ff27627ce717cd16 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: Thu, 15 Jan 2026 10:10:14 +0800 Subject: [PATCH] opt.: deduplicate & merge --- lib/core/utils/server.dart | 220 +++++++++++++++---------- lib/view/page/server/edit/actions.dart | 29 +++- test/jump_server_test.dart | 70 +++++--- 3 files changed, 205 insertions(+), 114 deletions(-) diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 300ea5c4..1a4fbc98 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -38,6 +38,75 @@ String getPrivateKey(String id) { return pki.key; } +List resolveMergedJumpChain( + Spi target, { + List? jumpChain, +}) { + final injectedSpiMap = {}; + if (jumpChain != null) { + for (final s in jumpChain) { + injectedSpiMap[s.id] = s; + injectedSpiMap[s.oldId] = s; + } + } + + Spi resolveSpi(String id) { + final injected = injectedSpiMap[id]; + if (injected != null) return injected; + if (jumpChain != null) { + throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $id'); + } + final fromStore = Stores.server.box.get(id); + if (fromStore == null) { + throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $id'); + } + return fromStore; + } + + return _resolveMergedJumpChainInternal(target, resolveSpi: resolveSpi); +} + +List _resolveMergedJumpChainInternal( + Spi target, { + required Spi Function(String id) resolveSpi, +}) { + final roots = target.jumpChainIds ?? (target.jumpId == null ? const [] : [target.jumpId!]); + if (roots.isEmpty) return const []; + + final seen = {}; + final stack = {}; + final out = []; + + String normId(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId; + + void dfs(String id) { + final hop = resolveSpi(id); + final norm = normId(hop); + + if (stack.contains(norm)) { + throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at $norm'); + } + if (seen.contains(norm)) return; + + stack.add(norm); + final deps = hop.jumpChainIds ?? (hop.jumpId == null ? const [] : [hop.jumpId!]); + for (final dep in deps) { + dfs(dep); + } + stack.remove(norm); + + if (seen.add(norm)) { + out.add(hop); + } + } + + for (final r in roots) { + dfs(r); + } + + return out; +} + Future genClient( Spi spi, { void Function(GenSSHClientStatus)? onStatus, @@ -110,101 +179,53 @@ Future _genClientInternal( if (socketOverride != null) return socketOverride; if (followJumpConfig) { - final hopIds = spi.jumpChainIds; + final injectedSpiMap = {}; + final injectedKeyMap = {}; - // Explicit hop list on this node - if (hopIds != null && hopIds.isNotEmpty) { - SSHClient? currentClient; - - for (var i = 0; i < hopIds.length; i++) { - final hopId = hopIds[i]; - final hopSpi = jumpChain?.firstWhereOrNull((e) => e.id == hopId || e.oldId == hopId) ?? - Stores.server.box.get(hopId); - if (hopSpi == null) { - throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $hopId'); + if (jumpChain != null) { + for (var i = 0; i < jumpChain.length; i++) { + final s = jumpChain[i]; + injectedSpiMap[s.id] = s; + injectedSpiMap[s.oldId] = s; + if (jumpPrivateKeys != null && i < jumpPrivateKeys.length) { + injectedKeyMap[s.id] = jumpPrivateKeys[i]; + injectedKeyMap[s.oldId] = jumpPrivateKeys[i]; } - - if (currentClient == null) { - // First hop: connect directly - final hopKeyId = hopSpi.keyId; - final hopPrivateKey = hopKeyId == null - ? null - : (jumpPrivateKeys != null && i < jumpPrivateKeys.length ? jumpPrivateKeys[i] : null) ?? - getPrivateKey(hopKeyId); - - final hopSocket = await SSHSocket.connect(hopSpi.ip, hopSpi.port, timeout: timeout); - currentClient = await _genClientInternal( - hopSpi, - privateKey: hopPrivateKey, - jumpChain: jumpChain, - jumpPrivateKeys: jumpPrivateKeys, - timeout: timeout, - onKeyboardInteractive: onKeyboardInteractive, - knownHostFingerprints: hostKeyCache, - onHostKeyAccepted: hostKeyPersist, - onHostKeyPrompt: hostKeyPrompt, - visited: visited, - socketOverride: hopSocket, - ); - } else { - final forwarded = await currentClient.forwardLocal(hopSpi.ip, hopSpi.port); - - final hopKeyId = hopSpi.keyId; - final hopPrivateKey = hopKeyId == null - ? null - : (jumpPrivateKeys != null && i < jumpPrivateKeys.length ? jumpPrivateKeys[i] : null) ?? - getPrivateKey(hopKeyId); - - currentClient = await _genClientInternal( - hopSpi, - privateKey: hopPrivateKey, - jumpChain: jumpChain, - jumpPrivateKeys: jumpPrivateKeys, - timeout: timeout, - onKeyboardInteractive: onKeyboardInteractive, - knownHostFingerprints: hostKeyCache, - onHostKeyAccepted: hostKeyPersist, - onHostKeyPrompt: hostKeyPrompt, - visited: visited, - socketOverride: forwarded, - ); - } - } - - if (currentClient != null) { - return await currentClient.forwardLocal(spi.ip, spi.port); } } - // Legacy single hop - final hopId = spi.jumpId; - Spi? hopSpi; - String? hopPrivateKey; - - if (hopId != null) { + Spi resolveSpi(String id) { + final injected = injectedSpiMap[id]; + if (injected != null) return injected; if (jumpChain != null) { - final idx = jumpChain.indexWhere((e) => e.id == hopId || e.oldId == hopId); - if (idx == -1) { - throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $hopId'); - } - hopSpi = jumpChain[idx]; - hopPrivateKey = jumpPrivateKeys != null && idx < jumpPrivateKeys.length ? jumpPrivateKeys[idx] : null; - - if (hopSpi.keyId != null && hopPrivateKey == null) { - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.privateKeyNotFoundFmt(hopSpi.keyId ?? ''), - ); - } - } else { - hopSpi = Stores.server.box.get(hopId); + throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $id'); } + final fromStore = Stores.server.box.get(id); + if (fromStore == null) { + throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $id'); + } + return fromStore; } - if (hopSpi != null) { - final hopClient = await _genClientInternal( - hopSpi, - privateKey: hopPrivateKey, + String? resolveHopPrivateKey(Spi hop) { + final keyId = hop.keyId; + if (keyId == null) return null; + final injected = injectedKeyMap[hop.id] ?? injectedKeyMap[hop.oldId]; + return injected ?? getPrivateKey(keyId); + } + + final hops = _resolveMergedJumpChainInternal(spi, resolveSpi: resolveSpi); + if (hops.isNotEmpty) { + // Build multi-hop forward chain with dedup/merge. + final firstHop = hops.first; + final firstKey = resolveHopPrivateKey(firstHop); + if (firstHop.keyId != null && firstKey == null) { + throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(firstHop.keyId ?? '')); + } + + var currentClient = await _genClientInternal( + firstHop, + privateKey: firstKey, jumpChain: jumpChain, jumpPrivateKeys: jumpPrivateKeys, timeout: timeout, @@ -213,9 +234,34 @@ Future _genClientInternal( onHostKeyAccepted: hostKeyPersist, onHostKeyPrompt: hostKeyPrompt, visited: visited, + followJumpConfig: false, ); - return await hopClient.forwardLocal(spi.ip, spi.port); + for (var i = 1; i < hops.length; i++) { + final hop = hops[i]; + final forwarded = await currentClient.forwardLocal(hop.ip, hop.port); + final hopKey = resolveHopPrivateKey(hop); + if (hop.keyId != null && hopKey == null) { + throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(hop.keyId ?? '')); + } + + currentClient = await _genClientInternal( + hop, + privateKey: hopKey, + jumpChain: jumpChain, + jumpPrivateKeys: jumpPrivateKeys, + timeout: timeout, + onKeyboardInteractive: onKeyboardInteractive, + knownHostFingerprints: hostKeyCache, + onHostKeyAccepted: hostKeyPersist, + onHostKeyPrompt: hostKeyPrompt, + visited: visited, + socketOverride: forwarded, + followJumpConfig: false, + ); + } + + return await currentClient.forwardLocal(spi.ip, spi.port); } } diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index 6839de01..cef03d5e 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -222,13 +222,28 @@ extension _Actions on _ServerEditPageState { return; } - if (this.spi != null) { - final ok = await context.showRoundDialog( - title: libL10n.attention, - child: Text(libL10n.askContinue('${l10n.jumpServer} ${libL10n.setting}')), - actions: Btnx.cancelOk, - ); - if (ok != true) return; + final oldSpi = this.spi; + if (oldSpi != null) { + final originalJumpChain = oldSpi.jumpChainIds ?? (oldSpi.jumpId == null ? const [] : [oldSpi.jumpId!]); + final currentJumpChain = _jumpChain.value; + + final jumpChainChanged = () { + if (originalJumpChain.isEmpty && currentJumpChain.isEmpty) return false; + if (originalJumpChain.length != currentJumpChain.length) return true; + for (var i = 0; i < originalJumpChain.length; i++) { + if (originalJumpChain[i] != currentJumpChain[i]) return true; + } + return false; + }(); + + if (jumpChainChanged) { + final ok = await context.showRoundDialog( + title: libL10n.attention, + child: Text(libL10n.askContinue('${l10n.jumpServer} ${libL10n.setting}')), + actions: Btnx.cancelOk, + ); + if (ok != true) return; + } } if (_keyIdx.value == null && _passwordController.text.isEmpty) { diff --git a/test/jump_server_test.dart b/test/jump_server_test.dart index a1cc1cdd..1922acb1 100644 --- a/test/jump_server_test.dart +++ b/test/jump_server_test.dart @@ -5,7 +5,7 @@ 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 { + test('resolveMergedJumpChain throws when injected chain misses jump server', () { const spi = Spi( name: 'target', ip: '10.0.0.10', @@ -15,13 +15,8 @@ void main() { jumpId: 'missing', ); - await expectLater( - () => genClient( - spi, - jumpChain: const [], - jumpPrivateKeys: const [], - knownHostFingerprints: const {}, - ), + expect( + () => resolveMergedJumpChain(spi, jumpChain: const []), throwsA( isA().having( (e) => e.type, @@ -32,23 +27,58 @@ void main() { ); }); - test('genClient detects jump loop', () async { - const spi = Spi( - name: 'loop', + test('resolveMergedJumpChain merges and dedups', () { + const c = Spi(name: 'c', ip: '10.0.0.30', port: 22, user: 'root', id: 'c'); + const d = Spi(name: 'd', ip: '10.0.0.40', port: 22, user: 'root', id: 'd'); + const b = Spi( + name: 'b', ip: '10.0.0.20', port: 22, user: 'root', - id: 'loop_id', - jumpId: 'loop_id', + id: 'b', + jumpChainIds: ['c', 'd'], + ); + const target = Spi( + name: 'target', + ip: '10.0.0.10', + port: 22, + user: 'root', + id: 't', + jumpChainIds: ['b', 'c'], ); - await expectLater( - () => genClient( - spi, - jumpChain: const [spi], - jumpPrivateKeys: const [null], - knownHostFingerprints: const {}, - ), + final chain = resolveMergedJumpChain(target, jumpChain: const [b, c, d]); + expect(chain.map((e) => e.id).toList(), ['c', 'd', 'b']); + }); + + test('resolveMergedJumpChain detects jump loop', () { + const b = Spi( + name: 'b', + ip: '10.0.0.20', + port: 22, + user: 'root', + id: 'b', + jumpChainIds: ['c'], + ); + const c = Spi( + name: 'c', + ip: '10.0.0.30', + port: 22, + user: 'root', + id: 'c', + jumpChainIds: ['b'], + ); + const target = Spi( + name: 'target', + ip: '10.0.0.10', + port: 22, + user: 'root', + id: 't', + jumpChainIds: ['b'], + ); + + expect( + () => resolveMergedJumpChain(target, jumpChain: const [b, c]), throwsA( isA().having( (e) => e.type,