opt.: deduplicate & merge

This commit is contained in:
lollipopkit🏳️‍⚧️
2026-01-15 10:10:14 +08:00
parent 8be9b9b10b
commit 35349a90eb
3 changed files with 205 additions and 114 deletions

View File

@@ -38,6 +38,75 @@ String getPrivateKey(String id) {
return pki.key;
}
List<Spi> resolveMergedJumpChain(
Spi target, {
List<Spi>? jumpChain,
}) {
final injectedSpiMap = <String, Spi>{};
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<Spi> _resolveMergedJumpChainInternal(
Spi target, {
required Spi Function(String id) resolveSpi,
}) {
final roots = target.jumpChainIds ?? (target.jumpId == null ? const <String>[] : [target.jumpId!]);
if (roots.isEmpty) return const <Spi>[];
final seen = <String>{};
final stack = <String>{};
final out = <Spi>[];
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 <String>[] : [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<SSHClient> genClient(
Spi spi, {
void Function(GenSSHClientStatus)? onStatus,
@@ -110,101 +179,53 @@ Future<SSHClient> _genClientInternal(
if (socketOverride != null) return socketOverride;
if (followJumpConfig) {
final hopIds = spi.jumpChainIds;
final injectedSpiMap = <String, Spi>{};
final injectedKeyMap = <String, String?>{};
// 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<SSHClient> _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);
}
}

View File

@@ -222,13 +222,28 @@ extension _Actions on _ServerEditPageState {
return;
}
if (this.spi != null) {
final ok = await context.showRoundDialog<bool>(
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 <String>[] : [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<bool>(
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) {

View File

@@ -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 <Spi>[],
jumpPrivateKeys: const <String?>[],
knownHostFingerprints: const <String, String>{},
),
expect(
() => resolveMergedJumpChain(spi, jumpChain: const <Spi>[]),
throwsA(
isA<SSHErr>().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>[spi],
jumpPrivateKeys: const <String?>[null],
knownHostFingerprints: const <String, String>{},
),
final chain = resolveMergedJumpChain(target, jumpChain: const <Spi>[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 <Spi>[b, c]),
throwsA(
isA<SSHErr>().having(
(e) => e.type,