* feat(ssh): support full multi-hop jump chain (#356) * fix(edit): validate jump cycle for new server saves
This commit is contained in:
53
lib/core/utils/jump_chain.dart
Normal file
53
lib/core/utils/jump_chain.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||||
|
|
||||||
|
/// Returns `true` when assigning [candidateJumpId] to [currentServerId]
|
||||||
|
/// would create a jump-server cycle.
|
||||||
|
bool wouldCreateJumpCycle({
|
||||||
|
required String? currentServerId,
|
||||||
|
required String? candidateJumpId,
|
||||||
|
required Map<String, Spi> serversById,
|
||||||
|
}) {
|
||||||
|
if (candidateJumpId == null || candidateJumpId.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final visited = <String>{};
|
||||||
|
var checkingId = candidateJumpId;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (currentServerId != null && checkingId == currentServerId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!visited.add(checkingId)) {
|
||||||
|
// Existing malformed cycle is treated as invalid to prevent linking into it.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextId = serversById[checkingId]?.jumpId;
|
||||||
|
if (nextId == null || nextId.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
checkingId = nextId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects all reachable jump servers from [spi.jumpId], keyed by server id.
|
||||||
|
Map<String, Spi> collectJumpServers({
|
||||||
|
required Spi spi,
|
||||||
|
required Map<String, Spi> serversById,
|
||||||
|
}) {
|
||||||
|
final chain = <String, Spi>{};
|
||||||
|
final visited = <String>{};
|
||||||
|
var jumpId = spi.jumpId;
|
||||||
|
|
||||||
|
while (jumpId != null && jumpId.isNotEmpty && visited.add(jumpId)) {
|
||||||
|
final jumpSpi = serversById[jumpId];
|
||||||
|
if (jumpSpi == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chain[jumpSpi.id] = jumpSpi;
|
||||||
|
jumpId = jumpSpi.jumpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
@@ -33,7 +33,10 @@ enum GenSSHClientStatus { socket, key, pwd }
|
|||||||
String getPrivateKey(String id) {
|
String getPrivateKey(String id) {
|
||||||
final pki = Stores.key.fetchOne(id);
|
final pki = Stores.key.fetchOne(id);
|
||||||
if (pki == null) {
|
if (pki == null) {
|
||||||
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(id));
|
throw SSHErr(
|
||||||
|
type: SSHErrType.noPrivateKey,
|
||||||
|
message: l10n.privateKeyNotFoundFmt(id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return pki.key;
|
return pki.key;
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,12 @@ Future<SSHClient> genClient(
|
|||||||
|
|
||||||
/// Only pass this param if using multi-threading and key login
|
/// Only pass this param if using multi-threading and key login
|
||||||
String? jumpPrivateKey,
|
String? jumpPrivateKey,
|
||||||
|
|
||||||
|
/// Prefer this map in isolate mode, fallback to [Stores.key] otherwise.
|
||||||
|
Map<String, String>? privateKeysByKeyId,
|
||||||
|
|
||||||
|
/// Prefer this map in isolate mode, fallback to [Stores.server] otherwise.
|
||||||
|
Map<String, Spi>? jumpSpisById,
|
||||||
Duration timeout = const Duration(seconds: 5),
|
Duration timeout = const Duration(seconds: 5),
|
||||||
|
|
||||||
/// [Spi] of the jump server
|
/// [Spi] of the jump server
|
||||||
@@ -59,10 +68,23 @@ Future<SSHClient> genClient(
|
|||||||
Map<String, String>? knownHostFingerprints,
|
Map<String, String>? knownHostFingerprints,
|
||||||
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
|
void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted,
|
||||||
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
|
Future<bool> Function(HostKeyPromptInfo info)? onHostKeyPrompt,
|
||||||
|
Set<String>? visitedServerIds,
|
||||||
}) async {
|
}) async {
|
||||||
|
final chainVisitedServerIds = visitedServerIds ?? <String>{};
|
||||||
|
final currentServerId = _hostIdentifier(spi);
|
||||||
|
if (!chainVisitedServerIds.add(currentServerId)) {
|
||||||
|
throw SSHErr(
|
||||||
|
type: SSHErrType.connect,
|
||||||
|
message:
|
||||||
|
'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
onStatus?.call(GenSSHClientStatus.socket);
|
onStatus?.call(GenSSHClientStatus.socket);
|
||||||
|
|
||||||
final hostKeyCache = Map<String, String>.from(knownHostFingerprints ?? _loadKnownHostFingerprints());
|
final hostKeyCache = Map<String, String>.from(
|
||||||
|
knownHostFingerprints ?? _loadKnownHostFingerprints(),
|
||||||
|
);
|
||||||
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
|
final hostKeyPersist = onHostKeyAccepted ?? _persistHostKeyFingerprint;
|
||||||
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
|
final hostKeyPrompt = onHostKeyPrompt ?? _defaultHostKeyPrompt;
|
||||||
|
|
||||||
@@ -74,16 +96,33 @@ Future<SSHClient> genClient(
|
|||||||
// Multi-thread or key login
|
// Multi-thread or key login
|
||||||
if (jumpSpi != null) return jumpSpi;
|
if (jumpSpi != null) return jumpSpi;
|
||||||
// Main thread
|
// Main thread
|
||||||
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
|
final jumpId = spi.jumpId;
|
||||||
|
if (jumpId != null) {
|
||||||
|
return jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId);
|
||||||
|
}
|
||||||
}();
|
}();
|
||||||
if (jumpSpi_ != null) {
|
if (jumpSpi_ != null) {
|
||||||
|
String? nextJumpPrivateKey;
|
||||||
|
final jumpSpiKeyId = jumpSpi_.keyId;
|
||||||
|
if (jumpSpi != null &&
|
||||||
|
jumpSpi.id == jumpSpi_.id &&
|
||||||
|
jumpPrivateKey != null) {
|
||||||
|
// Isolate mode may preload first-hop key and pass it via [jumpPrivateKey].
|
||||||
|
nextJumpPrivateKey = jumpPrivateKey;
|
||||||
|
} else if (jumpSpiKeyId != null) {
|
||||||
|
nextJumpPrivateKey = privateKeysByKeyId?[jumpSpiKeyId];
|
||||||
|
}
|
||||||
|
|
||||||
final jumpClient = await genClient(
|
final jumpClient = await genClient(
|
||||||
jumpSpi_,
|
jumpSpi_,
|
||||||
privateKey: jumpPrivateKey,
|
privateKey: nextJumpPrivateKey,
|
||||||
|
privateKeysByKeyId: privateKeysByKeyId,
|
||||||
|
jumpSpisById: jumpSpisById,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
knownHostFingerprints: hostKeyCache,
|
knownHostFingerprints: hostKeyCache,
|
||||||
onHostKeyAccepted: hostKeyPersist,
|
onHostKeyAccepted: hostKeyPersist,
|
||||||
onHostKeyPrompt: onHostKeyPrompt,
|
onHostKeyPrompt: hostKeyPrompt,
|
||||||
|
visitedServerIds: chainVisitedServerIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await jumpClient.forwardLocal(spi.ip, spi.port);
|
return await jumpClient.forwardLocal(spi.ip, spi.port);
|
||||||
@@ -126,7 +165,7 @@ Future<SSHClient> genClient(
|
|||||||
// printTrace: debugPrint,
|
// printTrace: debugPrint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
privateKey ??= getPrivateKey(keyId);
|
privateKey ??= privateKeysByKeyId?[keyId] ?? getPrivateKey(keyId);
|
||||||
|
|
||||||
onStatus?.call(GenSSHClientStatus.key);
|
onStatus?.call(GenSSHClientStatus.key);
|
||||||
return SSHClient(
|
return SSHClient(
|
||||||
@@ -141,7 +180,8 @@ Future<SSHClient> genClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
|
typedef _HostKeyPersistCallback =
|
||||||
|
void Function(String storageKey, String fingerprintHex);
|
||||||
|
|
||||||
class HostKeyPromptInfo {
|
class HostKeyPromptInfo {
|
||||||
HostKeyPromptInfo({
|
HostKeyPromptInfo({
|
||||||
@@ -191,7 +231,9 @@ class _HostKeyVerifier {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
Loggers.app.warning('User rejected new SSH host key for ${spi.name} ($keyType).');
|
Loggers.app.warning(
|
||||||
|
'User rejected new SSH host key for ${spi.name} ($keyType).',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
_cache[storageKey] = fingerprintHex;
|
_cache[storageKey] = fingerprintHex;
|
||||||
@@ -224,7 +266,9 @@ class _HostKeyVerifier {
|
|||||||
|
|
||||||
_cache[storageKey] = fingerprintHex;
|
_cache[storageKey] = fingerprintHex;
|
||||||
persistCallback?.call(storageKey, fingerprintHex);
|
persistCallback?.call(storageKey, fingerprintHex);
|
||||||
Loggers.app.warning('Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.');
|
Loggers.app.warning(
|
||||||
|
'Updated stored SSH host key for ${spi.name} ($keyType) after user confirmation.',
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,7 +301,9 @@ void _persistHostKeyFingerprint(String storageKey, String fingerprintHex) {
|
|||||||
Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
|
Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
|
||||||
final ctx = AppNavigator.context;
|
final ctx = AppNavigator.context;
|
||||||
if (ctx == null) {
|
if (ctx == null) {
|
||||||
Loggers.app.warning('Host key prompt skipped: navigator context unavailable.');
|
Loggers.app.warning(
|
||||||
|
'Host key prompt skipped: navigator context unavailable.',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,10 +325,14 @@ Future<bool> _defaultHostKeyPrompt(HostKeyPromptInfo info) async {
|
|||||||
SelectableText('${libL10n.addr}: $hostLine'),
|
SelectableText('${libL10n.addr}: $hostLine'),
|
||||||
SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'),
|
SelectableText('${l10n.sshHostKeyType}: ${info.keyType}'),
|
||||||
SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)),
|
SelectableText(l10n.sshHostKeyFingerprintMd5Hex(info.fingerprintHex)),
|
||||||
SelectableText(l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64)),
|
SelectableText(
|
||||||
|
l10n.sshHostKeyFingerprintMd5Base64(info.fingerprintBase64),
|
||||||
|
),
|
||||||
if (info.previousFingerprintHex != null) ...[
|
if (info.previousFingerprintHex != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SelectableText(l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!)),
|
SelectableText(
|
||||||
|
l10n.sshHostKeyStoredFingerprint(info.previousFingerprintHex!),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -299,18 +349,35 @@ Future<void> ensureKnownHostKey(
|
|||||||
Spi spi, {
|
Spi spi, {
|
||||||
Duration timeout = const Duration(seconds: 5),
|
Duration timeout = const Duration(seconds: 5),
|
||||||
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
SSHUserInfoRequestHandler? onKeyboardInteractive,
|
||||||
|
Map<String, Spi>? jumpSpisById,
|
||||||
|
Set<String>? visitedServerIds,
|
||||||
}) async {
|
}) async {
|
||||||
|
final chainVisitedServerIds = visitedServerIds ?? <String>{};
|
||||||
|
final currentServerId = _hostIdentifier(spi);
|
||||||
|
if (!chainVisitedServerIds.add(currentServerId)) {
|
||||||
|
throw SSHErr(
|
||||||
|
type: SSHErrType.connect,
|
||||||
|
message:
|
||||||
|
'Invalid jump chain: cycle detected at ${spi.name} ($currentServerId)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final cache = _loadKnownHostFingerprints();
|
final cache = _loadKnownHostFingerprints();
|
||||||
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
|
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
|
final jumpId = spi.jumpId;
|
||||||
|
final jumpSpi = jumpId != null
|
||||||
|
? (jumpSpisById?[jumpId] ?? Stores.server.box.get(jumpId))
|
||||||
|
: null;
|
||||||
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
|
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
|
||||||
await ensureKnownHostKey(
|
await ensureKnownHostKey(
|
||||||
jumpSpi,
|
jumpSpi,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
onKeyboardInteractive: onKeyboardInteractive,
|
onKeyboardInteractive: onKeyboardInteractive,
|
||||||
|
jumpSpisById: jumpSpisById,
|
||||||
|
visitedServerIds: chainVisitedServerIds,
|
||||||
);
|
);
|
||||||
cache.addAll(_loadKnownHostFingerprints());
|
cache.addAll(_loadKnownHostFingerprints());
|
||||||
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;
|
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;
|
||||||
@@ -351,4 +418,5 @@ String _fingerprintToHex(Uint8List fingerprint) {
|
|||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fingerprintToBase64(Uint8List fingerprint) => base64.encode(fingerprint);
|
String _fingerprintToBase64(Uint8List fingerprint) =>
|
||||||
|
base64.encode(fingerprint);
|
||||||
|
|||||||
@@ -8,19 +8,57 @@ class SftpReq {
|
|||||||
String? privateKey;
|
String? privateKey;
|
||||||
Spi? jumpSpi;
|
Spi? jumpSpi;
|
||||||
String? jumpPrivateKey;
|
String? jumpPrivateKey;
|
||||||
|
Map<String, Spi>? jumpSpisById;
|
||||||
|
Map<String, String>? privateKeysByKeyId;
|
||||||
Map<String, String>? knownHostFingerprints;
|
Map<String, String>? knownHostFingerprints;
|
||||||
|
|
||||||
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
|
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
|
||||||
|
privateKeysByKeyId = {};
|
||||||
|
|
||||||
final keyId = spi.keyId;
|
final keyId = spi.keyId;
|
||||||
if (keyId != null) {
|
if (keyId != null) {
|
||||||
privateKey = getPrivateKey(keyId);
|
privateKey = getPrivateKey(keyId);
|
||||||
|
privateKeysByKeyId![keyId] = privateKey!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final allServers = {
|
||||||
|
for (final server in Stores.server.fetch()) server.id: server,
|
||||||
|
};
|
||||||
|
jumpSpisById = collectJumpServers(spi: spi, serversById: allServers);
|
||||||
|
|
||||||
if (spi.jumpId != null) {
|
if (spi.jumpId != null) {
|
||||||
jumpSpi = Stores.server.box.get(spi.jumpId);
|
jumpSpi = jumpSpisById?[spi.jumpId];
|
||||||
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
|
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
|
||||||
|
if (jumpSpi?.keyId case final jumpKeyId?) {
|
||||||
|
if (jumpPrivateKey != null) {
|
||||||
|
privateKeysByKeyId![jumpKeyId] = jumpPrivateKey!;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final jump in jumpSpisById?.values ?? const <Spi>[]) {
|
||||||
|
final jumpKeyId = jump.keyId;
|
||||||
|
if (jumpKeyId == null || privateKeysByKeyId!.containsKey(jumpKeyId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final key = Stores.key.fetchOne(jumpKeyId)?.key;
|
||||||
|
if (key == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
privateKeysByKeyId![jumpKeyId] = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jumpSpisById != null && jumpSpisById!.isEmpty) {
|
||||||
|
jumpSpisById = null;
|
||||||
|
}
|
||||||
|
if (privateKeysByKeyId != null && privateKeysByKeyId!.isEmpty) {
|
||||||
|
privateKeysByKeyId = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
knownHostFingerprints = Map<String, String>.from(Stores.setting.sshKnownHostFingerprints.get());
|
knownHostFingerprints = Map<String, String>.from(
|
||||||
|
Stores.setting.sshKnownHostFingerprints.get(),
|
||||||
|
);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
Loggers.app.warning('Failed to load SSH known host fingerprints', e, s);
|
Loggers.app.warning('Failed to load SSH known host fingerprints', e, s);
|
||||||
knownHostFingerprints = null;
|
knownHostFingerprints = null;
|
||||||
@@ -46,8 +84,11 @@ class SftpReqStatus {
|
|||||||
Exception? error;
|
Exception? error;
|
||||||
Duration? spentTime;
|
Duration? spentTime;
|
||||||
|
|
||||||
SftpReqStatus({required this.req, required this.notifyListeners, this.completer})
|
SftpReqStatus({
|
||||||
: id = DateTime.now().microsecondsSinceEpoch {
|
required this.req,
|
||||||
|
required this.notifyListeners,
|
||||||
|
this.completer,
|
||||||
|
}) : id = DateTime.now().microsecondsSinceEpoch {
|
||||||
worker = SftpWorker(onNotify: onNotify, req: req)..init();
|
worker = SftpWorker(onNotify: onNotify, req: req)..init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:dartssh2/dartssh2.dart';
|
import 'package:dartssh2/dartssh2.dart';
|
||||||
import 'package:easy_isolate/easy_isolate.dart';
|
import 'package:easy_isolate/easy_isolate.dart';
|
||||||
import 'package:fl_lib/fl_lib.dart';
|
import 'package:fl_lib/fl_lib.dart';
|
||||||
|
import 'package:server_box/core/utils/jump_chain.dart';
|
||||||
import 'package:server_box/core/utils/server.dart';
|
import 'package:server_box/core/utils/server.dart';
|
||||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
@@ -28,7 +29,11 @@ class SftpWorker {
|
|||||||
/// the threads
|
/// the threads
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (worker.isInitialized) worker.dispose();
|
if (worker.isInitialized) worker.dispose();
|
||||||
await worker.init(mainMessageHandler, isolateMessageHandler, errorHandler: print);
|
await worker.init(
|
||||||
|
mainMessageHandler,
|
||||||
|
isolateMessageHandler,
|
||||||
|
errorHandler: print,
|
||||||
|
);
|
||||||
worker.sendMessage(req);
|
worker.sendMessage(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +44,11 @@ class SftpWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the messages coming from the main
|
/// Handle the messages coming from the main
|
||||||
Future<void> isolateMessageHandler(dynamic data, SendPort mainSendPort, SendErrorFunction sendError) async {
|
Future<void> isolateMessageHandler(
|
||||||
|
dynamic data,
|
||||||
|
SendPort mainSendPort,
|
||||||
|
SendErrorFunction sendError,
|
||||||
|
) async {
|
||||||
switch (data) {
|
switch (data) {
|
||||||
case final SftpReq val:
|
case final SftpReq val:
|
||||||
switch (val.type) {
|
switch (val.type) {
|
||||||
@@ -56,7 +65,11 @@ Future<void> isolateMessageHandler(dynamic data, SendPort mainSendPort, SendErro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sendError) async {
|
Future<void> _download(
|
||||||
|
SftpReq req,
|
||||||
|
SendPort mainSendPort,
|
||||||
|
SendErrorFunction sendError,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
mainSendPort.send(SftpWorkerStatus.preparing);
|
mainSendPort.send(SftpWorkerStatus.preparing);
|
||||||
final watch = Stopwatch()..start();
|
final watch = Stopwatch()..start();
|
||||||
@@ -65,12 +78,17 @@ Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen
|
|||||||
privateKey: req.privateKey,
|
privateKey: req.privateKey,
|
||||||
jumpSpi: req.jumpSpi,
|
jumpSpi: req.jumpSpi,
|
||||||
jumpPrivateKey: req.jumpPrivateKey,
|
jumpPrivateKey: req.jumpPrivateKey,
|
||||||
|
privateKeysByKeyId: req.privateKeysByKeyId,
|
||||||
|
jumpSpisById: req.jumpSpisById,
|
||||||
knownHostFingerprints: req.knownHostFingerprints,
|
knownHostFingerprints: req.knownHostFingerprints,
|
||||||
);
|
);
|
||||||
mainSendPort.send(SftpWorkerStatus.sshConnectted);
|
mainSendPort.send(SftpWorkerStatus.sshConnectted);
|
||||||
|
|
||||||
/// Create the directory if not exists
|
/// Create the directory if not exists
|
||||||
final dirPath = req.localPath.substring(0, req.localPath.lastIndexOf(Pfs.seperator));
|
final dirPath = req.localPath.substring(
|
||||||
|
0,
|
||||||
|
req.localPath.lastIndexOf(Pfs.seperator),
|
||||||
|
);
|
||||||
await Directory(dirPath).create(recursive: true);
|
await Directory(dirPath).create(recursive: true);
|
||||||
|
|
||||||
/// Use [FileMode.write] to overwrite the file
|
/// Use [FileMode.write] to overwrite the file
|
||||||
@@ -92,7 +110,9 @@ Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen
|
|||||||
|
|
||||||
while (totalRead < size) {
|
while (totalRead < size) {
|
||||||
final remaining = size - totalRead;
|
final remaining = size - totalRead;
|
||||||
final chunkSize = remaining > defaultChunkSize ? defaultChunkSize : remaining;
|
final chunkSize = remaining > defaultChunkSize
|
||||||
|
? defaultChunkSize
|
||||||
|
: remaining;
|
||||||
dprint('Size: $size, Total Read: $totalRead, Chunk Size: $chunkSize');
|
dprint('Size: $size, Total Read: $totalRead, Chunk Size: $chunkSize');
|
||||||
|
|
||||||
final fileData = file.read(offset: totalRead, length: chunkSize);
|
final fileData = file.read(offset: totalRead, length: chunkSize);
|
||||||
@@ -113,7 +133,11 @@ Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendError) async {
|
Future<void> _upload(
|
||||||
|
SftpReq req,
|
||||||
|
SendPort mainSendPort,
|
||||||
|
SendErrorFunction sendError,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
mainSendPort.send(SftpWorkerStatus.preparing);
|
mainSendPort.send(SftpWorkerStatus.preparing);
|
||||||
final watch = Stopwatch()..start();
|
final watch = Stopwatch()..start();
|
||||||
@@ -122,6 +146,8 @@ Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE
|
|||||||
privateKey: req.privateKey,
|
privateKey: req.privateKey,
|
||||||
jumpSpi: req.jumpSpi,
|
jumpSpi: req.jumpSpi,
|
||||||
jumpPrivateKey: req.jumpPrivateKey,
|
jumpPrivateKey: req.jumpPrivateKey,
|
||||||
|
privateKeysByKeyId: req.privateKeysByKeyId,
|
||||||
|
jumpSpisById: req.jumpSpisById,
|
||||||
knownHostFingerprints: req.knownHostFingerprints,
|
knownHostFingerprints: req.knownHostFingerprints,
|
||||||
);
|
);
|
||||||
mainSendPort.send(SftpWorkerStatus.sshConnectted);
|
mainSendPort.send(SftpWorkerStatus.sshConnectted);
|
||||||
@@ -139,7 +165,10 @@ Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendE
|
|||||||
// If remote exists, overwrite it
|
// If remote exists, overwrite it
|
||||||
final file = await sftp.open(
|
final file = await sftp.open(
|
||||||
req.remotePath,
|
req.remotePath,
|
||||||
mode: SftpFileOpenMode.truncate | SftpFileOpenMode.create | SftpFileOpenMode.write,
|
mode:
|
||||||
|
SftpFileOpenMode.truncate |
|
||||||
|
SftpFileOpenMode.create |
|
||||||
|
SftpFileOpenMode.write,
|
||||||
);
|
);
|
||||||
final writer = file.write(
|
final writer = file.write(
|
||||||
localFile,
|
localFile,
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ part of 'edit.dart';
|
|||||||
final _hostReg = RegExp(r'^[a-zA-Z0-9\.\-_:%;]+$');
|
final _hostReg = RegExp(r'^[a-zA-Z0-9\.\-_:%;]+$');
|
||||||
|
|
||||||
extension _Actions on _ServerEditPageState {
|
extension _Actions on _ServerEditPageState {
|
||||||
|
bool _isInvalidJumpSelection(String? candidateJumpId) {
|
||||||
|
final currentServer = spi;
|
||||||
|
return wouldCreateJumpCycle(
|
||||||
|
currentServerId: currentServer?.id,
|
||||||
|
candidateJumpId: candidateJumpId,
|
||||||
|
serversById: ref.read(serversProvider).servers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onTapSSHDiscovery() async {
|
Future<void> _onTapSSHDiscovery() async {
|
||||||
try {
|
try {
|
||||||
final result = await SshDiscoveryPage.route.go(context);
|
final result = await SshDiscoveryPage.route.go(context);
|
||||||
@@ -16,7 +25,9 @@ extension _Actions on _ServerEditPageState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _processDiscoveredServers(List<SshDiscoveryResult> discoveredServers) async {
|
Future<void> _processDiscoveredServers(
|
||||||
|
List<SshDiscoveryResult> discoveredServers,
|
||||||
|
) async {
|
||||||
if (discoveredServers.length == 1) {
|
if (discoveredServers.length == 1) {
|
||||||
// Single server - populate the current form
|
// Single server - populate the current form
|
||||||
final server = discoveredServers.first;
|
final server = discoveredServers.first;
|
||||||
@@ -30,7 +41,11 @@ extension _Actions on _ServerEditPageState {
|
|||||||
// Multiple servers - show import dialog
|
// Multiple servers - show import dialog
|
||||||
final shouldImport = await context.showRoundDialog<bool>(
|
final shouldImport = await context.showRoundDialog<bool>(
|
||||||
title: libL10n.import,
|
title: libL10n.import,
|
||||||
child: Text(libL10n.askContinue('${libL10n.found} ${discoveredServers.length} ${libL10n.servers}')),
|
child: Text(
|
||||||
|
libL10n.askContinue(
|
||||||
|
'${libL10n.found} ${discoveredServers.length} ${libL10n.servers}',
|
||||||
|
),
|
||||||
|
),
|
||||||
actions: Btnx.cancelOk,
|
actions: Btnx.cancelOk,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,7 +61,9 @@ extension _Actions on _ServerEditPageState {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.'),
|
Text(
|
||||||
|
'${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.',
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(libL10n.setting),
|
Text(libL10n.setting),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -64,8 +81,12 @@ extension _Actions on _ServerEditPageState {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldProceed == true) {
|
if (shouldProceed == true) {
|
||||||
final username = usernameController.text.isNotEmpty ? usernameController.text : defaultUsername;
|
final username = usernameController.text.isNotEmpty
|
||||||
final keyId = keyIdController.text.isNotEmpty ? keyIdController.text : null;
|
? usernameController.text
|
||||||
|
: defaultUsername;
|
||||||
|
final keyId = keyIdController.text.isNotEmpty
|
||||||
|
? keyIdController.text
|
||||||
|
: null;
|
||||||
final servers = discoveredServers
|
final servers = discoveredServers
|
||||||
.map(
|
.map(
|
||||||
(result) => Spi(
|
(result) => Spi(
|
||||||
@@ -74,7 +95,9 @@ extension _Actions on _ServerEditPageState {
|
|||||||
port: result.port,
|
port: result.port,
|
||||||
user: username,
|
user: username,
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
pwd: _passwordController.text.isEmpty ? null : _passwordController.text,
|
pwd: _passwordController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: _passwordController.text,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -122,7 +145,8 @@ extension _Actions on _ServerEditPageState {
|
|||||||
void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async {
|
void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async {
|
||||||
dprint('Error importing SSH config: $e');
|
dprint('Error importing SSH config: $e');
|
||||||
// Check if it's a permission error and offer file picker as fallback
|
// Check if it's a permission error and offer file picker as fallback
|
||||||
if (e is PathAccessException || e.toString().contains('Operation not permitted')) {
|
if (e is PathAccessException ||
|
||||||
|
e.toString().contains('Operation not permitted')) {
|
||||||
final useFilePicker = await context.showRoundDialog<bool>(
|
final useFilePicker = await context.showRoundDialog<bool>(
|
||||||
title: l10n.sshConfigImport,
|
title: l10n.sshConfigImport,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -164,10 +188,15 @@ extension _Actions on _ServerEditPageState {
|
|||||||
children: [
|
children: [
|
||||||
Text(l10n.sshConfigFoundServers('${summary.total}')),
|
Text(l10n.sshConfigFoundServers('${summary.total}')),
|
||||||
if (summary.hasDuplicates)
|
if (summary.hasDuplicates)
|
||||||
Text(l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'), style: UIs.textGrey),
|
Text(
|
||||||
|
l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'),
|
||||||
|
style: UIs.textGrey,
|
||||||
|
),
|
||||||
Text(l10n.sshConfigServersToImport('${summary.toImport}')),
|
Text(l10n.sshConfigServersToImport('${summary.toImport}')),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...resolved.map((s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})')),
|
...resolved.map(
|
||||||
|
(s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -205,7 +234,10 @@ extension _Actions on _ServerEditPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onTapCustomItem() async {
|
void _onTapCustomItem() async {
|
||||||
final res = await KvEditor.route.go(context, KvEditorArgs(data: _customCmds.value));
|
final res = await KvEditor.route.go(
|
||||||
|
context,
|
||||||
|
KvEditorArgs(data: _customCmds.value),
|
||||||
|
);
|
||||||
if (res == null) return;
|
if (res == null) return;
|
||||||
_customCmds.value = res;
|
_customCmds.value = res;
|
||||||
}
|
}
|
||||||
@@ -250,6 +282,11 @@ extension _Actions on _ServerEditPageState {
|
|||||||
if (_portController.text.isEmpty) {
|
if (_portController.text.isEmpty) {
|
||||||
_portController.text = '22';
|
_portController.text = '22';
|
||||||
}
|
}
|
||||||
|
if (_isInvalidJumpSelection(_jumpServer.value)) {
|
||||||
|
context.showSnackBar('${l10n.invalid}: ${l10n.jumpServer}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final customCmds = _customCmds.value;
|
final customCmds = _customCmds.value;
|
||||||
final custom = ServerCustom(
|
final custom = ServerCustom(
|
||||||
pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull,
|
pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull,
|
||||||
@@ -261,10 +298,17 @@ extension _Actions on _ServerEditPageState {
|
|||||||
scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull,
|
scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull,
|
||||||
);
|
);
|
||||||
|
|
||||||
final wolEmpty = _wolMacCtrl.text.isEmpty && _wolIpCtrl.text.isEmpty && _wolPwdCtrl.text.isEmpty;
|
final wolEmpty =
|
||||||
|
_wolMacCtrl.text.isEmpty &&
|
||||||
|
_wolIpCtrl.text.isEmpty &&
|
||||||
|
_wolPwdCtrl.text.isEmpty;
|
||||||
final wol = wolEmpty
|
final wol = wolEmpty
|
||||||
? null
|
? null
|
||||||
: WakeOnLanCfg(mac: _wolMacCtrl.text, ip: _wolIpCtrl.text, pwd: _wolPwdCtrl.text.selfNotEmptyOrNull);
|
: WakeOnLanCfg(
|
||||||
|
mac: _wolMacCtrl.text,
|
||||||
|
ip: _wolIpCtrl.text,
|
||||||
|
pwd: _wolPwdCtrl.text.selfNotEmptyOrNull,
|
||||||
|
);
|
||||||
if (wol != null) {
|
if (wol != null) {
|
||||||
final wolValidation = wol.validate();
|
final wolValidation = wol.validate();
|
||||||
if (!wolValidation.$2) {
|
if (!wolValidation.$2) {
|
||||||
@@ -274,7 +318,9 @@ extension _Actions on _ServerEditPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final spi = Spi(
|
final spi = Spi(
|
||||||
name: _nameController.text.isEmpty ? _ipController.text : _nameController.text,
|
name: _nameController.text.isEmpty
|
||||||
|
? _ipController.text
|
||||||
|
: _nameController.text,
|
||||||
ip: _ipController.text,
|
ip: _ipController.text,
|
||||||
port: int.parse(_portController.text),
|
port: int.parse(_portController.text),
|
||||||
user: _usernameController.text,
|
user: _usernameController.text,
|
||||||
@@ -291,7 +337,9 @@ extension _Actions on _ServerEditPageState {
|
|||||||
envs: _env.value.isEmpty ? null : _env.value,
|
envs: _env.value.isEmpty ? null : _env.value,
|
||||||
id: widget.args?.spi.id ?? ShortId.generate(),
|
id: widget.args?.spi.id ?? ShortId.generate(),
|
||||||
customSystemType: _systemType.value,
|
customSystemType: _systemType.value,
|
||||||
disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(),
|
disabledCmdTypes: _disabledCmdTypes.value.isEmpty
|
||||||
|
? null
|
||||||
|
: _disabledCmdTypes.value.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.spi == null) {
|
if (this.spi == null) {
|
||||||
@@ -421,7 +469,10 @@ extension _Utils on _ServerEditPageState {
|
|||||||
if (spi.keyId == null) {
|
if (spi.keyId == null) {
|
||||||
_passwordController.text = spi.pwd ?? '';
|
_passwordController.text = spi.pwd ?? '';
|
||||||
} else {
|
} else {
|
||||||
_keyIdx.value = ref.read(privateKeyProvider).keys.indexWhere((e) => e.id == spi.keyId);
|
_keyIdx.value = ref
|
||||||
|
.read(privateKeyProvider)
|
||||||
|
.keys
|
||||||
|
.indexWhere((e) => e.id == spi.keyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List in dart is passed by pointer, so you need to copy it here
|
/// List in dart is passed by pointer, so you need to copy it here
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:icons_plus/icons_plus.dart';
|
import 'package:icons_plus/icons_plus.dart';
|
||||||
import 'package:server_box/core/extension/context/locale.dart';
|
import 'package:server_box/core/extension/context/locale.dart';
|
||||||
import 'package:server_box/core/route.dart';
|
import 'package:server_box/core/route.dart';
|
||||||
|
import 'package:server_box/core/utils/jump_chain.dart';
|
||||||
import 'package:server_box/core/utils/server_dedup.dart';
|
import 'package:server_box/core/utils/server_dedup.dart';
|
||||||
import 'package:server_box/core/utils/ssh_config.dart';
|
import 'package:server_box/core/utils/ssh_config.dart';
|
||||||
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
||||||
@@ -32,13 +33,17 @@ class ServerEditPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
const ServerEditPage({super.key, this.args});
|
const ServerEditPage({super.key, this.args});
|
||||||
|
|
||||||
static const route = AppRoute<bool, SpiRequiredArgs>(page: ServerEditPage.new, path: '/servers/edit');
|
static const route = AppRoute<bool, SpiRequiredArgs>(
|
||||||
|
page: ServerEditPage.new,
|
||||||
|
path: '/servers/edit',
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<ServerEditPage> createState() => _ServerEditPageState();
|
ConsumerState<ServerEditPage> createState() => _ServerEditPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayoutMixin {
|
class _ServerEditPageState extends ConsumerState<ServerEditPage>
|
||||||
|
with AfterLayoutMixin {
|
||||||
late final spi = widget.args?.spi;
|
late final spi = widget.args?.spi;
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
final _ipController = TextEditingController();
|
final _ipController = TextEditingController();
|
||||||
@@ -140,7 +145,10 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
|
|||||||
final children = [
|
final children = [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ListView(scrollDirection: Axis.horizontal, children: topItems.joinWith(UIs.width13).toList()),
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: topItems.joinWith(UIs.width13).toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Input(
|
Input(
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
|
|||||||
@@ -354,8 +354,8 @@ extension _Widgets on _ServerEditPageState {
|
|||||||
.watch(serversProvider)
|
.watch(serversProvider)
|
||||||
.servers
|
.servers
|
||||||
.values
|
.values
|
||||||
.where((e) => e.jumpId == null)
|
|
||||||
.where((e) => e.id != spi?.id)
|
.where((e) => e.id != spi?.id)
|
||||||
|
.where((e) => !_isInvalidJumpSelection(e.id))
|
||||||
.toList();
|
.toList();
|
||||||
final choice = _jumpServer.listenVal((val) {
|
final choice = _jumpServer.listenVal((val) {
|
||||||
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
|
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
|
||||||
|
|||||||
91
test/jump_chain_test.dart
Normal file
91
test/jump_chain_test.dart
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:server_box/core/utils/jump_chain.dart';
|
||||||
|
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||||
|
|
||||||
|
Spi _spi({required String id, required String name, String? jumpId}) {
|
||||||
|
return Spi(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
ip: '$name.example.com',
|
||||||
|
port: 22,
|
||||||
|
user: 'root',
|
||||||
|
jumpId: jumpId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('JumpChain', () {
|
||||||
|
test('wouldCreateJumpCycle returns false for valid chain', () {
|
||||||
|
final servers = <String, Spi>{
|
||||||
|
'A': _spi(id: 'A', name: 'a'),
|
||||||
|
'B': _spi(id: 'B', name: 'b', jumpId: 'A'),
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = wouldCreateJumpCycle(
|
||||||
|
currentServerId: 'C',
|
||||||
|
candidateJumpId: 'B',
|
||||||
|
serversById: servers,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wouldCreateJumpCycle detects cycle back to current server', () {
|
||||||
|
final servers = <String, Spi>{
|
||||||
|
'A': _spi(id: 'A', name: 'a', jumpId: 'B'),
|
||||||
|
'B': _spi(id: 'B', name: 'b', jumpId: 'C'),
|
||||||
|
'C': _spi(id: 'C', name: 'c'),
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = wouldCreateJumpCycle(
|
||||||
|
currentServerId: 'C',
|
||||||
|
candidateJumpId: 'A',
|
||||||
|
serversById: servers,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wouldCreateJumpCycle treats existing malformed loop as invalid', () {
|
||||||
|
final servers = <String, Spi>{
|
||||||
|
'A': _spi(id: 'A', name: 'a', jumpId: 'B'),
|
||||||
|
'B': _spi(id: 'B', name: 'b', jumpId: 'A'),
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = wouldCreateJumpCycle(
|
||||||
|
currentServerId: 'C',
|
||||||
|
candidateJumpId: 'A',
|
||||||
|
serversById: servers,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wouldCreateJumpCycle validates new server with null current id', () {
|
||||||
|
final servers = <String, Spi>{
|
||||||
|
'A': _spi(id: 'A', name: 'a', jumpId: 'B'),
|
||||||
|
'B': _spi(id: 'B', name: 'b', jumpId: 'A'),
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = wouldCreateJumpCycle(
|
||||||
|
currentServerId: null,
|
||||||
|
candidateJumpId: 'A',
|
||||||
|
serversById: servers,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectJumpServers collects reachable jump servers', () {
|
||||||
|
final target = _spi(id: 'T', name: 'target', jumpId: 'A');
|
||||||
|
final servers = <String, Spi>{
|
||||||
|
'A': _spi(id: 'A', name: 'a', jumpId: 'B'),
|
||||||
|
'B': _spi(id: 'B', name: 'b'),
|
||||||
|
};
|
||||||
|
|
||||||
|
final chain = collectJumpServers(spi: target, serversById: servers);
|
||||||
|
|
||||||
|
expect(chain.keys.toList(), ['A', 'B']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user