feat: jump server chain

Fixes #356
This commit is contained in:
lollipopkit🏳️‍⚧️
2026-01-14 22:36:47 +08:00
parent 8589b3b4d7
commit c51cf62015
6 changed files with 276 additions and 58 deletions

View File

@@ -45,14 +45,17 @@ Future<SSHClient> genClient(
/// Only pass this param if using multi-threading and key login
String? privateKey,
/// Only pass this param if using multi-threading and key login
String? jumpPrivateKey,
Duration timeout = const Duration(seconds: 5),
/// [Spi] of the jump server
/// Pre-resolved jump chain (in `spi.jumpId` order: immediate -> farthest).
///
/// Must pass this param if using multi-threading and key login
Spi? jumpSpi,
/// This is mainly used when `Stores` is unavailable (e.g. in an isolate).
List<Spi>? jumpChain,
/// Private keys for [jumpChain], aligned by index.
///
/// If a jump server uses key auth (`keyId != null`), you must provide the
/// decrypted key pem here (or `genClient` will try to read from `Stores`).
List<String?>? jumpPrivateKeys,
Duration timeout = const Duration(seconds: 5),
/// Handle keyboard-interactive authentication
SSHUserInfoRequestHandler? onKeyboardInteractive,
@@ -60,6 +63,39 @@ Future<SSHClient> genClient(
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
}) async {
return _genClientInternal(
spi,
onStatus: onStatus,
privateKey: privateKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: knownHostFingerprints,
onHostKeyAccepted: onHostKeyAccepted,
onHostKeyPrompt: onHostKeyPrompt,
visited: <String>{},
);
}
Future<SSHClient> _genClientInternal(
Spi spi, {
void Function(GenSSHClientStatus)? onStatus,
String? privateKey,
List<Spi>? jumpChain,
List<String?>? jumpPrivateKeys,
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
Map<String, String>? knownHostFingerprints,
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
required Set<String> visited,
}) async {
final identifier = _hostIdentifier(spi);
if (!visited.add(identifier)) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at ${spi.name} ($identifier)');
}
onStatus?.call(GenSSHClientStatus.socket);
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
@@ -70,20 +106,42 @@ Future<SSHClient> genClient(
final socket = await () async {
// Proxy
final jumpSpi_ = () {
// 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;
Spi? jumpSpi_;
String? jumpPrivateKey;
if (jumpId != null) {
if (jumpChain != null) {
final idx = jumpChain.indexWhere((e) => e.id == jumpId || e.oldId == jumpId);
if (idx == -1) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $jumpId');
}
jumpSpi_ = jumpChain[idx];
jumpPrivateKey = jumpPrivateKeys != null && idx < jumpPrivateKeys.length ? jumpPrivateKeys[idx] : null;
if (jumpSpi_.keyId != null && jumpPrivateKey == null) {
throw SSHErr(
type: SSHErrType.noPrivateKey,
message: l10n.privateKeyNotFoundFmt(jumpSpi_.keyId ?? ''),
);
}
} else {
jumpSpi_ = Stores.server.box.get(jumpId);
}
}
if (jumpSpi_ != null) {
final jumpClient = await genClient(
final jumpClient = await _genClientInternal(
jumpSpi_,
privateKey: jumpPrivateKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: onHostKeyPrompt,
onHostKeyPrompt: hostKeyPrompt,
visited: visited,
);
return await jumpClient.forwardLocal(spi.ip, spi.port);
@@ -300,6 +358,25 @@ Future<void> ensureKnownHostKey(
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
}) async {
return _ensureKnownHostKeyInternal(
spi,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
visited: <String>{},
);
}
Future<void> _ensureKnownHostKeyInternal(
Spi spi, {
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
required Set<String> visited,
}) async {
final identifier = _hostIdentifier(spi);
if (!visited.add(identifier)) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at ${spi.name} ($identifier)');
}
final cache = _loadKnownHostFingerprints();
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return;
@@ -307,10 +384,11 @@ Future<void> ensureKnownHostKey(
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
await ensureKnownHostKey(
await _ensureKnownHostKeyInternal(
jumpSpi,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
visited: visited,
);
cache.addAll(_loadKnownHostFingerprints());
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;

View File

@@ -6,8 +6,8 @@ class SftpReq {
final String localPath;
final SftpReqType type;
String? privateKey;
Spi? jumpSpi;
String? jumpPrivateKey;
List<Spi>? jumpChain;
List<String?>? jumpPrivateKeys;
Map<String, String>? knownHostFingerprints;
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
@@ -16,8 +16,32 @@ class SftpReq {
privateKey = getPrivateKey(keyId);
}
if (spi.jumpId != null) {
jumpSpi = Stores.server.box.get(spi.jumpId);
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
final chain = <Spi>[];
final keys = <String?>[];
final visited = <String>{spi.id.isNotEmpty ? spi.id : spi.oldId};
var currentJumpId = spi.jumpId;
while (currentJumpId != null) {
final jumpSpi = Stores.server.box.get(currentJumpId);
if (jumpSpi == null) break;
// Prevent infinite loops if user mis-configured jump servers.
final jumpId = jumpSpi.id.isNotEmpty ? jumpSpi.id : jumpSpi.oldId;
if (!visited.add(jumpId)) {
throw SSHErr(
type: SSHErrType.connect,
message: 'Jump loop detected while building SFTP chain: ${jumpSpi.name}',
);
}
chain.add(jumpSpi);
keys.add(jumpSpi.keyId != null ? getPrivateKey(jumpSpi.keyId!) : null);
currentJumpId = jumpSpi.jumpId;
}
// Always set when `spi.jumpId != null` so the isolate won't fallback to Stores.
jumpChain = chain;
jumpPrivateKeys = keys;
}
try {
knownHostFingerprints = Map<String, String>.from(Stores.setting.sshKnownHostFingerprints.get());
@@ -90,4 +114,4 @@ class SftpReqStatus {
}
}
enum SftpWorkerStatus { preparing, sshConnectted, loading, finished }
enum SftpWorkerStatus { preparing, sshConnected, loading, finished }

View File

@@ -7,6 +7,7 @@ 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/server.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/res/store.dart';
@@ -63,11 +64,11 @@ Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen
final client = await genClient(
req.spi,
privateKey: req.privateKey,
jumpSpi: req.jumpSpi,
jumpPrivateKey: req.jumpPrivateKey,
jumpChain: req.jumpChain,
jumpPrivateKeys: req.jumpPrivateKeys,
knownHostFingerprints: req.knownHostFingerprints,
);
mainSendPort.send(SftpWorkerStatus.sshConnectted);
mainSendPort.send(SftpWorkerStatus.sshConnected);
/// Create the directory if not exists
final dirPath = req.localPath.substring(0, req.localPath.lastIndexOf(Pfs.seperator));
@@ -120,11 +121,11 @@ Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE
final client = await genClient(
req.spi,
privateKey: req.privateKey,
jumpSpi: req.jumpSpi,
jumpPrivateKey: req.jumpPrivateKey,
jumpChain: req.jumpChain,
jumpPrivateKeys: req.jumpPrivateKeys,
knownHostFingerprints: req.knownHostFingerprints,
);
mainSendPort.send(SftpWorkerStatus.sshConnectted);
mainSendPort.send(SftpWorkerStatus.sshConnected);
final local = File(req.localPath);
if (!await local.exists()) {

View File

@@ -349,36 +349,88 @@ extension _Widgets on _ServerEditPageState {
Widget _buildJumpServer() {
const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7);
final srvs = ref
.watch(serversProvider)
.servers
.values
.where((e) => e.jumpId == null)
.where((e) => e.id != spi?.id)
.toList();
final choice = _jumpServer.listenVal((val) {
final servers = ref.watch(serversProvider).servers;
final selfId = spi?.id;
bool wouldCreateCycle(Spi candidate) {
if (selfId == null) return false;
if (candidate.id == selfId) return true;
final visited = <String>{selfId};
var current = candidate;
while (true) {
final jumpId = current.jumpId;
if (jumpId == null) return false;
if (jumpId == selfId) return true;
if (!visited.add(jumpId)) {
// Candidate already contains a loop; treat as invalid to avoid infinite jump.
return true;
}
final next = servers[jumpId];
if (next == null) return false;
current = next;
}
}
String? buildJumpChainText(String jumpId) {
final chain = <Spi>[];
final visited = <String>{};
var currentId = jumpId;
while (true) {
if (!visited.add(currentId)) {
break;
}
final srv = servers[currentId];
if (srv == null) break;
chain.add(srv);
final nextId = srv.jumpId;
if (nextId == null) break;
currentId = nextId;
}
if (chain.isEmpty) return null;
// Display as actual connection order: farthest -> ... -> nearest.
return chain.reversed.map((e) => e.name).join('');
}
final srvs = servers.values.where((e) => e.id != selfId && !wouldCreateCycle(e)).toList();
final body = _jumpServer.listenVal((val) {
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
return Choice<Spi>(
multiple: false,
clearable: true,
value: srv != null ? [srv] : [],
builder: (state, _) => Wrap(
children: List<Widget>.generate(srvs.length, (index) {
final item = srvs[index];
return ChoiceChipX<Spi>(
label: item.name,
state: state,
value: item,
onSelected: (srv, on) {
if (on) {
_jumpServer.value = srv.id;
} else {
_jumpServer.value = null;
}
},
);
}),
),
final chainText = val == null ? null : buildJumpChainText(val);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (chainText != null)
Padding(
padding: const EdgeInsets.only(bottom: 7),
child: Text(chainText, style: UIs.textGrey),
),
Choice<Spi>(
multiple: false,
clearable: true,
value: srv != null ? [srv] : [],
builder: (state, _) => Wrap(
children: List<Widget>.generate(srvs.length, (index) {
final item = srvs[index];
return ChoiceChipX<Spi>(
label: item.name,
state: state,
value: item,
onSelected: (srv, on) {
if (on) {
_jumpServer.value = srv.id;
} else {
_jumpServer.value = null;
}
},
);
}),
),
),
],
);
});
return ExpandTile(
@@ -386,7 +438,7 @@ extension _Widgets on _ServerEditPageState {
initiallyExpanded: _jumpServer.value != null,
childrenPadding: padding,
title: Text(l10n.jumpServer),
children: [choice],
children: [body],
).cardx;
}

View File

@@ -53,7 +53,7 @@ class _SftpMissionPageState extends ConsumerState<SftpMissionPage> {
return switch (status.status) {
const (SftpWorkerStatus.finished) => _buildFinished(status),
const (SftpWorkerStatus.loading) => _buildLoading(status),
const (SftpWorkerStatus.sshConnectted) => _buildConnected(status),
const (SftpWorkerStatus.sshConnected) => _buildConnected(status),
const (SftpWorkerStatus.preparing) => _buildPreparing(status),
_ => _buildDefault(status),
};

View File

@@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/data/model/app/error.dart';
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 {
const spi = Spi(
name: 'target',
ip: '10.0.0.10',
port: 22,
user: 'root',
id: 't',
jumpId: 'missing',
);
await expectLater(
() => genClient(
spi,
jumpChain: const <Spi>[],
jumpPrivateKeys: const <String?>[],
knownHostFingerprints: const <String, String>{},
),
throwsA(
isA<SSHErr>().having(
(e) => e.type,
'type',
SSHErrType.connect,
),
),
);
});
test('genClient detects jump loop', () async {
const spi = Spi(
name: 'loop',
ip: '10.0.0.20',
port: 22,
user: 'root',
id: 'loop_id',
jumpId: 'loop_id',
);
await expectLater(
() => genClient(
spi,
jumpChain: const <Spi>[spi],
jumpPrivateKeys: const <String?>[null],
knownHostFingerprints: const <String, String>{},
),
throwsA(
isA<SSHErr>().having(
(e) => e.type,
'type',
SSHErrType.connect,
),
),
);
});
});
}