feat(ssh): support full multi-hop jump chain (#356) (#1058)

* feat(ssh): support full multi-hop jump chain (#356)

* fix(edit): validate jump cycle for new server saves
This commit is contained in:
lollipopkit🏳️‍⚧️
2026-02-28 00:12:03 +08:00
committed by GitHub
parent c3678f3df9
commit bc69686d16
8 changed files with 385 additions and 44 deletions

View 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;
}

View File

@@ -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);

View File

@@ -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();
} }

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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
View 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']);
});
});
}