mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-14 04:05:18 +01:00
opt.: deduplicate & merge
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user