Skip to content

Commit 5f9fbee

Browse files
committed
feat(ssh): support full multi-hop jump chain (#356)
1 parent c3678f3 commit 5f9fbee

File tree

8 files changed

+371
-44
lines changed

8 files changed

+371
-44
lines changed

lib/core/utils/jump_chain.dart

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import 'package:server_box/data/model/server/server_private_info.dart';
2+
3+
/// Returns `true` when assigning [candidateJumpId] to [currentServerId]
4+
/// would create a jump-server cycle.
5+
bool wouldCreateJumpCycle({
6+
required String currentServerId,
7+
required String? candidateJumpId,
8+
required Map<String, Spi> serversById,
9+
}) {
10+
if (candidateJumpId == null || candidateJumpId.isEmpty) {
11+
return false;
12+
}
13+
14+
final visited = <String>{};
15+
var checkingId = candidateJumpId;
16+
17+
while (true) {
18+
if (checkingId == currentServerId) {
19+
return true;
20+
}
21+
if (!visited.add(checkingId)) {
22+
// Existing malformed cycle is treated as invalid to prevent linking into it.
23+
return true;
24+
}
25+
26+
final nextId = serversById[checkingId]?.jumpId;
27+
if (nextId == null || nextId.isEmpty) {
28+
return false;
29+
}
30+
checkingId = nextId;
31+
}
32+
}
33+
34+
/// Collects all reachable jump servers from [spi.jumpId], keyed by server id.
35+
Map<String, Spi> collectJumpServers({
36+
required Spi spi,
37+
required Map<String, Spi> serversById,
38+
}) {
39+
final chain = <String, Spi>{};
40+
final visited = <String>{};
41+
var jumpId = spi.jumpId;
42+
43+
while (jumpId != null && jumpId.isNotEmpty && visited.add(jumpId)) {
44+
final jumpSpi = serversById[jumpId];
45+
if (jumpSpi == null) {
46+
break;
47+
}
48+
chain[jumpSpi.id] = jumpSpi;
49+
jumpId = jumpSpi.jumpId;
50+
}
51+
52+
return chain;
53+
}

lib/core/utils/server.dart

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ enum GenSSHClientStatus { socket, key, pwd }
3333
String getPrivateKey(String id) {
3434
final pki = Stores.key.fetchOne(id);
3535
if (pki == null) {
36-
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id));
36+
throw SSHErr(
37+
type: SSHErrType.noPrivateKey,
38+
message: l10n.privateKeyNotFoundFmt(id),
39+
);
3740
}
3841
return pki.key;
3942
}
@@ -47,6 +50,12 @@ Future<SSHClient> genClient(
4750

4851
/// Only pass this param if using multi-threading and key login
4952
String? jumpPrivateKey,
53+
54+
/// Prefer this map in isolate mode, fallback to [Stores.key] otherwise.
55+
Map<String, String>? privateKeysByKeyId,
56+
57+
/// Prefer this map in isolate mode, fallback to [Stores.server] otherwise.
58+
Map<String, Spi>? jumpSpisById,
5059
Duration timeout = const Duration(seconds: 5),
5160

5261
/// [Spi] of the jump server
@@ -59,10 +68,23 @@ Future<SSHClient> genClient(
5968
Map<String, String>? knownHostFingerprints,
6069
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
6170
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
71+
Set<String>? visitedServerIds,
6272
}) async {
73+
final chainVisitedServerIds = visitedServerIds ?? <String>{};
74+
final currentServerId = _hostIdentifier(spi);
75+
if (!chainVisitedServerIds.add(currentServerId)) {
76+
throw SSHErr(
77+
type: SSHErrType.connect,
78+
message:
79+
'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)',
80+
);
81+
}
82+
6383
onStatus?.call(GenSSHClientStatus.socket);
6484

65-
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
85+
final hostKeyCache = Map<String, String>.from(
86+
knownHostFingerprints ?? _loadKnownHostFingerprints(),
87+
);
6688
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
6789
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
6890

@@ -74,16 +96,33 @@ Future<SSHClient> genClient(
7496
// Multi-thread or key login
7597
if (jumpSpi != null) return jumpSpi;
7698
// Main thread
77-
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
99+
final jumpId = spi.jumpId;
100+
if (jumpId != null) {
101+
return jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId);
102+
}
78103
}();
79104
if (jumpSpi_ != null) {
105+
String? nextJumpPrivateKey;
106+
final jumpSpiKeyId = jumpSpi_.keyId;
107+
if (jumpSpi != null &&
108+
jumpSpi.id == jumpSpi_.id &&
109+
jumpPrivateKey != null) {
110+
// Isolate mode may preload first-hop key and pass it via [jumpPrivateKey].
111+
nextJumpPrivateKey = jumpPrivateKey;
112+
} else if (jumpSpiKeyId != null) {
113+
nextJumpPrivateKey = privateKeysByKeyId?[jumpSpiKeyId];
114+
}
115+
80116
final jumpClient = await genClient(
81117
jumpSpi_,
82-
privateKey: jumpPrivateKey,
118+
privateKey: nextJumpPrivateKey,
119+
privateKeysByKeyId: privateKeysByKeyId,
120+
jumpSpisById: jumpSpisById,
83121
timeout: timeout,
84122
knownHostFingerprints: hostKeyCache,
85123
onHostKeyAccepted: hostKeyPersist,
86-
onHostKeyPrompt: onHostKeyPrompt,
124+
onHostKeyPrompt: hostKeyPrompt,
125+
visitedServerIds: chainVisitedServerIds,
87126
);
88127

89128
return await jumpClient.forwardLocal(spi.ip, spi.port);
@@ -126,7 +165,7 @@ Future<SSHClient> genClient(
126165
// printTrace: debugPrint,
127166
);
128167
}
129-
privateKey ??= getPrivateKey(keyId);
168+
privateKey ??= privateKeysByKeyId?[keyId] ?? getPrivateKey(keyId);
130169

131170
onStatus?.call(GenSSHClientStatus.key);
132171
return SSHClient(
@@ -141,7 +180,8 @@ Future<SSHClient> genClient(
141180
);
142181
}
143182

144-
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
183+
typedef _HostKeyPersistCallback =
184+
void Function(String storageKey, String fingerprintHex);
145185

146186
class HostKeyPromptInfo {
147187
HostKeyPromptInfo({
@@ -191,7 +231,9 @@ class _HostKeyVerifier {
191231
),
192232
);
193233
if (!accepted) {
194-
Loggers.app.warning('User rejected new SSH host key for ${spi.name} ($keyType).');
234+
Loggers.app.warning(
235+
'User rejected new SSH host key for ${spi.name} ($keyType).',
236+
);
195237
return false;
196238
}
197239
_cache[storageKey] = fingerprintHex;
@@ -224,7 +266,9 @@ class _HostKeyVerifier {
224266

225267
_cache[storageKey] = fingerprintHex;
226268
persistCallback?.call(storageKey, fingerprintHex);
227-
Loggers.app.warning('Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.');
269+
Loggers.app.warning(
270+
'Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.',
271+
);
228272
return true;
229273
}
230274
}
@@ -257,7 +301,9 @@ void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) {
257301
Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
258302
final ctx = AppNavigator.context;
259303
if (ctx == null) {
260-
Loggers.app.warning('Host key prompt skipped: navigator context unavailable.');
304+
Loggers.app.warning(
305+
'Host key prompt skipped: navigator context unavailable.',
306+
);
261307
return false;
262308
}
263309

@@ -279,10 +325,14 @@ Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
279325
SelectableText('${libL10n.addr}: $hostLine'),
280326
SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'),
281327
SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)),
282-
SelectableText(l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64)),
328+
SelectableText(
329+
l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64),
330+
),
283331
if (info.previousFingerprintHex != null) ...[
284332
const SizedBox(height: 12),
285-
SelectableText(l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!)),
333+
SelectableText(
334+
l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!),
335+
),
286336
],
287337
],
288338
),
@@ -299,18 +349,35 @@ Future<void> ensureKnownHostKey(
299349
Spi spi, {
300350
Duration timeout = const Duration(seconds: 5),
301351
SSHUserInfoRequestHandler? onKeyboardInteractive,
352+
Map<String, Spi>? jumpSpisById,
353+
Set<String>? visitedServerIds,
302354
}) async {
355+
final chainVisitedServerIds = visitedServerIds ?? <String>{};
356+
final currentServerId = _hostIdentifier(spi);
357+
if (!chainVisitedServerIds.add(currentServerId)) {
358+
throw SSHErr(
359+
type: SSHErrType.connect,
360+
message:
361+
'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)',
362+
);
363+
}
364+
303365
final cache = _loadKnownHostFingerprints();
304366
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
305367
return;
306368
}
307369

308-
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
370+
final jumpId = spi.jumpId;
371+
final jumpSpi = jumpId != null
372+
? (jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId))
373+
: null;
309374
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
310375
await ensureKnownHostKey(
311376
jumpSpi,
312377
timeout: timeout,
313378
onKeyboardInteractive: onKeyboardInteractive,
379+
jumpSpisById: jumpSpisById,
380+
visitedServerIds: chainVisitedServerIds,
314381
);
315382
cache.addAll(_loadKnownHostFingerprints());
316383
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;
@@ -351,4 +418,5 @@ String _fingerprintToHex(Uint8List fingerprint) {
351418
return buffer.toString();
352419
}
353420

354-
String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint);
421+
String _fingerprintToBase64(Uint8List fingerprint) =>
422+
base64.encode(fingerprint);

lib/data/model/sftp/req.dart

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,57 @@ class SftpReq {
88
String? privateKey;
99
Spi? jumpSpi;
1010
String? jumpPrivateKey;
11+
Map<String, Spi>? jumpSpisById;
12+
Map<String, String>? privateKeysByKeyId;
1113
Map<String, String>? knownHostFingerprints;
1214

1315
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
16+
privateKeysByKeyId = {};
17+
1418
final keyId = spi.keyId;
1519
if (keyId != null) {
1620
privateKey = getPrivateKey(keyId);
21+
privateKeysByKeyId![keyId] = privateKey!;
1722
}
23+
24+
final allServers = {
25+
for (final server in Stores.server.fetch()) server.id: server,
26+
};
27+
jumpSpisById = collectJumpServers(spi: spi, serversById: allServers);
28+
1829
if (spi.jumpId != null) {
19-
jumpSpi = Stores.server.box.get(spi.jumpId);
30+
jumpSpi = jumpSpisById?[spi.jumpId];
2031
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
32+
if (jumpSpi?.keyId case final jumpKeyId?) {
33+
if (jumpPrivateKey != null) {
34+
privateKeysByKeyId![jumpKeyId] = jumpPrivateKey!;
35+
}
36+
}
37+
}
38+
39+
for (final jump in jumpSpisById?.values ?? const <Spi>[]) {
40+
final jumpKeyId = jump.keyId;
41+
if (jumpKeyId == null || privateKeysByKeyId!.containsKey(jumpKeyId)) {
42+
continue;
43+
}
44+
final key = Stores.key.fetchOne(jumpKeyId)?.key;
45+
if (key == null) {
46+
continue;
47+
}
48+
privateKeysByKeyId![jumpKeyId] = key;
49+
}
50+
51+
if (jumpSpisById != null && jumpSpisById!.isEmpty) {
52+
jumpSpisById = null;
2153
}
54+
if (privateKeysByKeyId != null && privateKeysByKeyId!.isEmpty) {
55+
privateKeysByKeyId = null;
56+
}
57+
2258
try {
23-
knownHostFingerprints = Map<String, String>.from(Stores.setting.sshKnownHostFingerprints.get());
59+
knownHostFingerprints = Map<String, String>.from(
60+
Stores.setting.sshKnownHostFingerprints.get(),
61+
);
2462
} catch (e, s) {
2563
Loggers.app.warning('Failed to load SSH known host fingerprints', e, s);
2664
knownHostFingerprints = null;
@@ -46,8 +84,11 @@ class SftpReqStatus {
4684
Exception? error;
4785
Duration? spentTime;
4886

49-
SftpReqStatus({required this.req, required this.notifyListeners, this.completer})
50-
: id = DateTime.now().microsecondsSinceEpoch {
87+
SftpReqStatus({
88+
required this.req,
89+
required this.notifyListeners,
90+
this.completer,
91+
}) : id = DateTime.now().microsecondsSinceEpoch {
5192
worker = SftpWorker(onNotify: onNotify, req: req)..init();
5293
}
5394

0 commit comments

Comments
 (0)