Compare commits

...

12 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
0a0928e2f6 fix: treat empty jumpChainIds as no jump 2026-01-17 23:55:41 +08:00
lollipopkit🏳️‍⚧️
61f161d8a6 opt. 2026-01-15 20:52:11 +08:00
lollipopkit🏳️‍⚧️
52c80795f4 opt. 2026-01-15 20:21:28 +08:00
lollipopkit🏳️‍⚧️
09f1ab2cf2 fix 2026-01-15 20:09:33 +08:00
lollipopkit🏳️‍⚧️
2eeb55c1d8 fix 2026-01-15 13:46:53 +08:00
lollipopkit🏳️‍⚧️
6738ac94f8 opt. 2026-01-15 13:15:31 +08:00
lollipopkit🏳️‍⚧️
827d40b8b5 opt. 2026-01-15 13:02:17 +08:00
lollipopkit🏳️‍⚧️
928f2becf1 fix 2026-01-15 12:41:10 +08:00
lollipopkit🏳️‍⚧️
7d30af44d6 fix 2026-01-15 10:10:21 +08:00
lollipopkit🏳️‍⚧️
35349a90eb opt.: deduplicate & merge 2026-01-15 10:10:14 +08:00
lollipopkit🏳️‍⚧️
8be9b9b10b impl: jump logic
Fixes #356
2026-01-15 09:42:34 +08:00
lollipopkit🏳️‍⚧️
c51cf62015 feat: jump server chain
Fixes #356
2026-01-14 22:36:47 +08:00
18 changed files with 712 additions and 153 deletions

View File

@@ -38,6 +38,77 @@ String getPrivateKey(String id) {
return pki.key;
}
List<Spi> resolveMergedJumpChain(
Spi target, {
List<Spi>? jumpChain,
}) {
final injectedSpiMap = <String, Spi>{};
if (jumpChain != null) {
for (final s in jumpChain) {
injectedSpiMap[s.id] = s;
if (s.oldId.isNotEmpty) {
injectedSpiMap[s.oldId] = s;
}
}
}
Spi resolveSpi(String id) {
final injected = injectedSpiMap[id];
if (injected != null) return injected;
if (jumpChain != null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $id');
}
final fromStore = Stores.server.box.get(id);
if (fromStore == null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $id');
}
return fromStore;
}
return _resolveMergedJumpChainInternal(target, resolveSpi: resolveSpi);
}
List<Spi> _resolveMergedJumpChainInternal(
Spi target, {
required Spi Function(String id) resolveSpi,
}) {
final roots = target.jumpChainIds ?? (target.jumpId == null ? const <String>[] : [target.jumpId!]);
if (roots.isEmpty) return const <Spi>[];
final seen = <String>{};
final stack = <String>{};
final out = <Spi>[];
String normId(Spi spi) => spi.id.isNotEmpty ? spi.id : spi.oldId;
void dfs(String id) {
final hop = resolveSpi(id);
final norm = normId(hop);
if (stack.contains(norm)) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump loop detected at $norm');
}
if (seen.contains(norm)) return;
stack.add(norm);
final deps = hop.jumpChainIds ?? (hop.jumpId == null ? const <String>[] : [hop.jumpId!]);
for (final dep in deps) {
dfs(dep);
}
stack.remove(norm);
if (seen.add(norm)) {
out.add(hop);
}
}
for (final r in roots) {
dfs(r);
}
return out;
}
Future<SSHClient> genClient(
Spi spi, {
void Function(GenSSHClientStatus)? onStatus,
@@ -45,14 +116,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 +134,41 @@ 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,
SSHSocket? socketOverride,
bool followJumpConfig = true,
}) 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());
@@ -68,37 +177,126 @@ Future<SSHClient> genClient(
String? alterUser;
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);
}();
if (jumpSpi_ != null) {
final jumpClient = await genClient(
jumpSpi_,
privateKey: jumpPrivateKey,
timeout: timeout,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: onHostKeyPrompt,
);
final (socket, hopClients) = await () async {
if (socketOverride != null) return (socketOverride, <SSHClient>[]);
return await jumpClient.forwardLocal(spi.ip, spi.port);
if (followJumpConfig) {
final injectedSpiMap = <String, Spi>{};
final injectedKeyMap = <String, String?>{};
if (jumpChain != null) {
for (var i = 0; i < jumpChain.length; i++) {
final s = jumpChain[i];
injectedSpiMap[s.id] = s;
if (s.oldId.isNotEmpty) injectedSpiMap[s.oldId] = s;
if (jumpPrivateKeys != null && i < jumpPrivateKeys.length) {
injectedKeyMap[s.id] = jumpPrivateKeys[i];
if (s.oldId.isNotEmpty) injectedKeyMap[s.oldId] = jumpPrivateKeys[i];
}
}
}
Spi resolveSpi(String id) {
final injected = injectedSpiMap[id];
if (injected != null) return injected;
if (jumpChain != null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $id');
}
final fromStore = Stores.server.box.get(id);
if (fromStore == null) {
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $id');
}
return fromStore;
}
String? resolveHopPrivateKey(Spi hop) {
final keyId = hop.keyId;
if (keyId == null) return null;
final injected = injectedKeyMap[hop.id] ?? injectedKeyMap[hop.oldId];
return injected ?? getPrivateKey(keyId);
}
final hops = _resolveMergedJumpChainInternal(spi, resolveSpi: resolveSpi);
if (hops.isNotEmpty) {
// Build multi-hop forward chain with dedup/merge.
final createdClients = <SSHClient>[];
SSHClient? currentClient;
try {
final firstHop = hops.first;
final firstKey = resolveHopPrivateKey(firstHop);
if (firstHop.keyId != null && firstKey == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(firstHop.keyId ?? ''));
}
currentClient = await _genClientInternal(
firstHop,
privateKey: firstKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: hostKeyPrompt,
visited: visited,
followJumpConfig: false,
);
createdClients.add(currentClient);
for (var i = 1; i < hops.length; i++) {
final hop = hops[i];
final forwarded = await currentClient!.forwardLocal(hop.ip, hop.port);
final hopKey = resolveHopPrivateKey(hop);
if (hop.keyId != null && hopKey == null) {
throw SSHErr(type: SSHErrType.noPrivateKey, message: l10n.privateKeyNotFoundFmt(hop.keyId ?? ''));
}
currentClient = await _genClientInternal(
hop,
privateKey: hopKey,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: hostKeyCache,
onHostKeyAccepted: hostKeyPersist,
onHostKeyPrompt: hostKeyPrompt,
visited: visited,
socketOverride: forwarded,
followJumpConfig: false,
);
createdClients.add(currentClient);
}
final forwardedSocket = await currentClient!.forwardLocal(spi.ip, spi.port);
return (forwardedSocket, createdClients);
} catch (e) {
// Close all created clients on error to avoid leaks
for (final client in createdClients) {
try {
client.close();
} catch (_) {
// Ignore close errors during cleanup
}
}
rethrow;
}
// Note: On success, all intermediate clients must remain open
// because the returned socket tunnels through them.
}
}
// Direct
try {
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
return (await SSHSocket.connect(spi.ip, spi.port, timeout: timeout), <SSHClient>[]);
} catch (e) {
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.parseAlterUrl();
alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
return (await SSHSocket.connect(res.$1, res.$3, timeout: timeout), <SSHClient>[]);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;
@@ -113,32 +311,52 @@ Future<SSHClient> genClient(
prompt: hostKeyPrompt,
);
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
Future<SSHClient> buildClient(SSHSocket socket) async {
final keyId = spi.keyId;
if (keyId == null) {
onStatus?.call(GenSSHClientStatus.pwd);
return SSHClient(
socket,
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
return SSHClient(
socket,
username: alterUser ?? spi.user,
onPasswordRequest: () => spi.pwd,
username: spi.user,
// Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey!),
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
}
privateKey ??= getPrivateKey(keyId);
onStatus?.call(GenSSHClientStatus.key);
return SSHClient(
socket,
username: spi.user,
// Must use [compute] here, instead of [Computer.shared.start]
identities: await compute(loadIndentity, privateKey),
onUserInfoRequest: onKeyboardInteractive,
onVerifyHostKey: hostKeyVerifier.call,
// printDebug: debugPrint,
// printTrace: debugPrint,
);
final client = await buildClient(socket);
// Tie hop clients' lifetime to the final client: close all hop clients
// when the target client disconnects to avoid leaking SSH connections.
if (hopClients.isNotEmpty) {
client.done.whenComplete(() {
for (final hopClient in hopClients) {
try {
hopClient.close();
} catch (_) {
// Ignore close errors during cleanup
}
}
});
}
return client;
}
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
@@ -300,20 +518,53 @@ Future<void> ensureKnownHostKey(
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
}) async {
final cache = _loadKnownHostFingerprints();
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return;
}
var cache = _loadKnownHostFingerprints();
final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null;
if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) {
await ensureKnownHostKey(
jumpSpi,
final hops = resolveMergedJumpChain(spi);
// Check each hop's host key, routing through preceding hops
for (var i = 0; i < hops.length; i++) {
final hop = hops[i];
// Preceding hops needed to reach this hop
final precedingHops = i > 0 ? hops.sublist(0, i) : null;
final precedingKeys = precedingHops?.map((h) =>
h.keyId != null ? getPrivateKey(h.keyId!) : null
).toList();
cache = await _ensureKnownHostKeyForSingle(
hop,
cache: cache,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
jumpChain: precedingHops,
jumpPrivateKeys: precedingKeys,
);
cache.addAll(_loadKnownHostFingerprints());
if (_hasKnownHostFingerprintForSpi(spi, cache)) return;
}
// Check the target's host key, routing through all hops
final allKeys = hops.isNotEmpty
? hops.map((h) => h.keyId != null ? getPrivateKey(h.keyId!) : null).toList()
: null;
await _ensureKnownHostKeyForSingle(
spi,
cache: cache,
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
jumpChain: hops.isNotEmpty ? hops : null,
jumpPrivateKeys: allKeys,
);
}
Future<Map<String, String>> _ensureKnownHostKeyForSingle(
Spi spi, {
required Map<String, String> cache,
Duration timeout = const Duration(seconds: 5),
SSHUserInfoRequestHandler? onKeyboardInteractive,
List<Spi>? jumpChain,
List<String?>? jumpPrivateKeys,
}) async {
if (_hasKnownHostFingerprintForSpi(spi, cache)) {
return cache;
}
final client = await genClient(
@@ -321,6 +572,8 @@ Future<void> ensureKnownHostKey(
timeout: timeout,
onKeyboardInteractive: onKeyboardInteractive,
knownHostFingerprints: cache,
jumpChain: jumpChain,
jumpPrivateKeys: jumpPrivateKeys,
);
try {
@@ -328,6 +581,9 @@ Future<void> ensureKnownHostKey(
} finally {
client.close();
}
cache.addAll(_loadKnownHostFingerprints());
return cache;
}
bool _hasKnownHostFingerprintForSpi(Spi spi, Map<String, String> cache) {

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/custom.dart';
@@ -35,8 +36,15 @@ abstract class Spi with _$Spi {
String? alterUrl,
@Default(true) bool autoConnect,
/// [id] of the jump server
/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
String? jumpId,
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@JsonKey(includeIfNull: false) List<String>? jumpChainIds,
ServerCustom? custom,
WakeOnLanCfg? wolCfg,
@@ -79,7 +87,10 @@ extension Spix on Spi {
String? migrateId() {
if (id.isNotEmpty) return null;
ServerStore.instance.delete(oldId);
final newSpi = copyWith(id: ShortId.generate());
final newSpi = copyWith(
id: ShortId.generate(),
jumpChainIds: jumpChainIds ?? (jumpId == null ? null : [jumpId!]),
);
newSpi.save();
return newSpi.id;
}
@@ -94,7 +105,8 @@ extension Spix on Spi {
port == other.port &&
pwd == other.pwd &&
keyId == other.keyId &&
jumpId == other.jumpId;
jumpId == other.jumpId &&
listEquals(jumpChainIds, other.jumpChainIds);
}
/// Returns true if the connection should be re-established.
@@ -137,7 +149,7 @@ extension Spix on Spi {
tags: ['tag1', 'tag2'],
alterUrl: 'user@ip:port',
autoConnect: true,
jumpId: 'jump_server_id',
jumpChainIds: ['jump_server_id'],
custom: ServerCustom(
pveAddr: 'http://localhost:8006',
pveIgnoreCert: false,

View File

@@ -16,8 +16,13 @@ T _$identity<T>(T value) => value;
mixin _$Spi {
String get name; String get ip; int get port; String get user; String? get pwd;/// [id] of private key
@JsonKey(name: 'pubKeyId') String? get keyId; List<String>? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server
String? get jumpId; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal.
@JsonKey(name: 'pubKeyId') String? get keyId; List<String>? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
String? get jumpId;/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@JsonKey(includeIfNull: false) List<String>? get jumpChainIds; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal.
Map<String, String>? get envs;@JsonKey(fromJson: Spi.parseId) String get id;/// Custom system type (unix or windows). If set, skip auto-detection.
@JsonKey(includeIfNull: false) SystemType? get customSystemType;/// Disabled command types for this server
@JsonKey(includeIfNull: false) List<String>? get disabledCmdTypes;
@@ -33,12 +38,12 @@ $SpiCopyWith<Spi> get copyWith => _$SpiCopyWithImpl<Spi>(this as Spi, _$identity
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes));
return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&const DeepCollectionEquality().equals(other.jumpChainIds, jumpChainIds)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes));
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,const DeepCollectionEquality().hash(jumpChainIds),custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes));
@@ -49,7 +54,7 @@ abstract mixin class $SpiCopyWith<$Res> {
factory $SpiCopyWith(Spi value, $Res Function(Spi) _then) = _$SpiCopyWithImpl;
@useResult
$Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId,@JsonKey(includeIfNull: false) List<String>? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
});
@@ -66,7 +71,7 @@ class _$SpiCopyWithImpl<$Res>
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? jumpChainIds = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -78,7 +83,8 @@ as String?,tags: freezed == tags ? _self.tags : tags // ignore: cast_nullable_to
as List<String>?,alterUrl: freezed == alterUrl ? _self.alterUrl : alterUrl // ignore: cast_nullable_to_non_nullable
as String?,autoConnect: null == autoConnect ? _self.autoConnect : autoConnect // ignore: cast_nullable_to_non_nullable
as bool,jumpId: freezed == jumpId ? _self.jumpId : jumpId // ignore: cast_nullable_to_non_nullable
as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as String?,jumpChainIds: freezed == jumpChainIds ? _self.jumpChainIds : jumpChainIds // ignore: cast_nullable_to_non_nullable
as List<String>?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable
as WakeOnLanCfg?,envs: freezed == envs ? _self.envs : envs // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
@@ -169,10 +175,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List<String>? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return orElse();
}
@@ -190,10 +196,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List<String>? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes) $default,) {final _that = this;
switch (_that) {
case _Spi():
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
throw StateError('Unexpected subclass');
}
@@ -210,10 +216,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List<String>? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,) {final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return null;
}
@@ -225,7 +231,7 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,
@JsonSerializable(includeIfNull: false)
class _Spi extends Spi {
const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List<String>? disabledCmdTypes}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._();
const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, @JsonKey(includeIfNull: false) final List<String>? jumpChainIds, this.custom, this.wolCfg, final Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List<String>? disabledCmdTypes}): _tags = tags,_jumpChainIds = jumpChainIds,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._();
factory _Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override final String name;
@@ -246,8 +252,25 @@ class _Spi extends Spi {
@override final String? alterUrl;
@override@JsonKey() final bool autoConnect;
/// [id] of the jump server
/// [id] of the jump server (legacy, single hop)
///
/// Migrated to [jumpChainIds].
@override final String? jumpId;
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
final List<String>? _jumpChainIds;
/// Jump chain hop ids (nearest -> farthest)
///
/// Preferred over [jumpId].
@override@JsonKey(includeIfNull: false) List<String>? get jumpChainIds {
final value = _jumpChainIds;
if (value == null) return null;
if (_jumpChainIds is EqualUnmodifiableListView) return _jumpChainIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override final ServerCustom? custom;
@override final WakeOnLanCfg? wolCfg;
/// It only applies to SSH terminal.
@@ -289,12 +312,12 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&const DeepCollectionEquality().equals(other._jumpChainIds, _jumpChainIds)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes));
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,const DeepCollectionEquality().hash(_jumpChainIds),custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes));
@@ -305,7 +328,7 @@ abstract mixin class _$SpiCopyWith<$Res> implements $SpiCopyWith<$Res> {
factory _$SpiCopyWith(_Spi value, $Res Function(_Spi) _then) = __$SpiCopyWithImpl;
@override @useResult
$Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId,@JsonKey(includeIfNull: false) List<String>? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List<String>? disabledCmdTypes
});
@@ -322,7 +345,7 @@ class __$SpiCopyWithImpl<$Res>
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? jumpChainIds = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) {
return _then(_Spi(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -334,7 +357,8 @@ as String?,tags: freezed == tags ? _self._tags : tags // ignore: cast_nullable_t
as List<String>?,alterUrl: freezed == alterUrl ? _self.alterUrl : alterUrl // ignore: cast_nullable_to_non_nullable
as String?,autoConnect: null == autoConnect ? _self.autoConnect : autoConnect // ignore: cast_nullable_to_non_nullable
as bool,jumpId: freezed == jumpId ? _self.jumpId : jumpId // ignore: cast_nullable_to_non_nullable
as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as String?,jumpChainIds: freezed == jumpChainIds ? _self._jumpChainIds : jumpChainIds // ignore: cast_nullable_to_non_nullable
as List<String>?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable
as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable
as WakeOnLanCfg?,envs: freezed == envs ? _self._envs : envs // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable

View File

@@ -17,6 +17,9 @@ _Spi _$SpiFromJson(Map<String, dynamic> json) => _Spi(
alterUrl: json['alterUrl'] as String?,
autoConnect: json['autoConnect'] as bool? ?? true,
jumpId: json['jumpId'] as String?,
jumpChainIds: (json['jumpChainIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
custom: json['custom'] == null
? null
: ServerCustom.fromJson(json['custom'] as Map<String, dynamic>),
@@ -47,6 +50,7 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
'alterUrl': ?instance.alterUrl,
'autoConnect': instance.autoConnect,
'jumpId': ?instance.jumpId,
'jumpChainIds': ?instance.jumpChainIds,
'custom': ?instance.custom,
'wolCfg': ?instance.wolCfg,
'envs': ?instance.envs,

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) {
@@ -15,9 +15,17 @@ class SftpReq {
if (keyId != null) {
privateKey = getPrivateKey(keyId);
}
if (spi.jumpId != null) {
jumpSpi = Stores.server.box.get(spi.jumpId);
jumpPrivateKey = Stores.key.fetchOne(jumpSpi?.keyId)?.key;
if (spi.jumpChainIds != null || spi.jumpId != null) {
// Use resolveMergedJumpChain to recursively expand nested hop chains
final chain = resolveMergedJumpChain(spi);
final keys = <String?>[];
for (final hop in chain) {
keys.add(hop.keyId != null ? getPrivateKey(hop.keyId!) : null);
}
// Always set when a jump is configured so the isolate won't fallback to Stores.
jumpChain = chain;
jumpPrivateKeys = keys;
}
try {
knownHostFingerprints = Map<String, String>.from(Stores.setting.sshKnownHostFingerprints.get());
@@ -90,4 +98,4 @@ class SftpReqStatus {
}
}
enum SftpWorkerStatus { preparing, sshConnectted, loading, finished }
enum SftpWorkerStatus { preparing, sshConnected, loading, finished }

View File

@@ -63,11 +63,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 +120,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

@@ -41,7 +41,7 @@ final class ServersNotifierProvider
}
}
String _$serversNotifierHash() => r'dc5da44f9bd8d8dcfba3e6e932cca3e2f379e582';
String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb';
abstract class _$ServersNotifier extends $Notifier<ServersState> {
ServersState build();

View File

@@ -135,7 +135,7 @@ class ServerNotifier extends _$ServerNotifier {
final time2 = DateTime.now();
final spentTime = time2.difference(time1).inMilliseconds;
if (spi.jumpId == null) {
if ((spi.jumpChainIds?.isNotEmpty != true) && spi.jumpId == null) {
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
} else {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');

View File

@@ -58,7 +58,7 @@ final class ServerNotifierProvider
}
}
String _$serverNotifierHash() => r'04b1beef4d96242fd10d5b523c6f5f17eb774bae';
String _$serverNotifierHash() => r'52e806bcc32a7818d1ec2b07a3c683b06885c9f8';
final class ServerNotifierFamily extends $Family
with

View File

@@ -89,15 +89,12 @@ class ServerStore extends HiveStore {
// Replace ids in jump server settings.
final spi = get<Spi>(newId);
if (spi != null) {
final jumpId = spi.jumpId; // This could be an oldId.
// Check if this jumpId corresponds to a server that was also migrated.
if (jumpId != null && idMap.containsKey(jumpId)) {
final newJumpId = idMap[jumpId];
if (spi.jumpId != newJumpId) {
final newSpi = spi.copyWith(jumpId: newJumpId);
update(spi, newSpi);
}
}
final jumpChainIds = spi.jumpChainIds ?? (spi.jumpId == null ? null : [spi.jumpId!]);
if (jumpChainIds == null || jumpChainIds.isEmpty) continue;
final newChain = jumpChainIds.map((e) => idMap[e] ?? e).toList();
final newSpi = spi.copyWith(jumpId: null, jumpChainIds: newChain);
update(spi, newSpi);
}
// Replace ids in [Snippet]

View File

@@ -107,6 +107,7 @@ class SpiAdapter extends TypeAdapter<Spi> {
alterUrl: fields[7] as String?,
autoConnect: fields[8] == null ? true : fields[8] as bool,
jumpId: fields[9] as String?,
jumpChainIds: (fields[16] as List?)?.cast<String>(),
custom: fields[10] as ServerCustom?,
wolCfg: fields[11] as WakeOnLanCfg?,
envs: (fields[12] as Map?)?.cast<String, String>(),
@@ -119,7 +120,7 @@ class SpiAdapter extends TypeAdapter<Spi> {
@override
void write(BinaryWriter writer, Spi obj) {
writer
..writeByte(16)
..writeByte(17)
..writeByte(0)
..write(obj.name)
..writeByte(1)
@@ -151,7 +152,9 @@ class SpiAdapter extends TypeAdapter<Spi> {
..writeByte(14)
..write(obj.customSystemType)
..writeByte(15)
..write(obj.disabledCmdTypes);
..write(obj.disabledCmdTypes)
..writeByte(16)
..write(obj.jumpChainIds);
}
@override

View File

@@ -27,7 +27,7 @@ types:
index: 4
Spi:
typeId: 3
nextIndex: 16
nextIndex: 17
fields:
name:
index: 0
@@ -61,6 +61,8 @@ types:
index: 14
disabledCmdTypes:
index: 15
jumpChainIds:
index: 16
VirtKey:
typeId: 4
nextIndex: 45

View File

@@ -222,6 +222,30 @@ extension _Actions on _ServerEditPageState {
return;
}
final oldSpi = this.spi;
if (oldSpi != null) {
final originalJumpChain = oldSpi.jumpChainIds ?? (oldSpi.jumpId == null ? const <String>[] : [oldSpi.jumpId!]);
final currentJumpChain = _jumpChain.value;
final jumpChainChanged = () {
if (originalJumpChain.isEmpty && currentJumpChain.isEmpty) return false;
if (originalJumpChain.length != currentJumpChain.length) return true;
for (var i = 0; i < originalJumpChain.length; i++) {
if (originalJumpChain[i] != currentJumpChain[i]) return true;
}
return false;
}();
if (jumpChainChanged) {
final ok = await context.showRoundDialog<bool>(
title: libL10n.attention,
child: Text(libL10n.askContinue('${l10n.jumpServer} ${libL10n.setting}')),
actions: Btnx.cancelOk,
);
if (ok != true) return;
}
}
if (_keyIdx.value == null && _passwordController.text.isEmpty) {
final ok = await context.showRoundDialog<bool>(
title: libL10n.attention,
@@ -277,7 +301,8 @@ extension _Actions on _ServerEditPageState {
tags: _tags.value.isEmpty ? null : _tags.value.toList(),
alterUrl: _altUrlController.text.selfNotEmptyOrNull,
autoConnect: _autoConnect.value,
jumpId: _jumpServer.value,
jumpId: null,
jumpChainIds: _jumpChain.value.isEmpty ? null : _jumpChain.value,
custom: custom,
wolCfg: wol,
envs: _env.value.isEmpty ? null : _env.value,
@@ -421,7 +446,7 @@ extension _Utils on _ServerEditPageState {
_altUrlController.text = spi.alterUrl ?? '';
_autoConnect.value = spi.autoConnect;
_jumpServer.value = spi.jumpId;
_jumpChain.value = spi.jumpChainIds ?? (spi.jumpId == null ? const <String>[] : [spi.jumpId!]);
final custom = spi.custom;
if (custom != null) {

View File

@@ -25,6 +25,7 @@ import 'package:server_box/view/page/private_key/edit.dart';
import 'package:server_box/view/page/server/discovery/discovery.dart';
part 'actions.dart';
part 'jump_chain.dart';
part 'widget.dart';
class ServerEditPage extends ConsumerStatefulWidget {
@@ -66,7 +67,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
/// -1: non selected, null: password, others: index of private key
final _keyIdx = ValueNotifier<int?>(null);
final _autoConnect = ValueNotifier(true);
final _jumpServer = nvn<String?>();
final _jumpChain = <String>[].vn;
final _pveIgnoreCert = ValueNotifier(false);
final _env = <String, String>{}.vn;
final _customCmds = <String, String>{}.vn;
@@ -100,7 +101,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
_keyIdx.dispose();
_autoConnect.dispose();
_jumpServer.dispose();
_jumpChain.dispose();
_pveIgnoreCert.dispose();
_env.dispose();
_customCmds.dispose();
@@ -199,7 +200,6 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
),
_buildAuth(),
_buildSystemType(),
_buildJumpServer(),
_buildMore(),
];
return AutoMultiList(children: children);

View File

@@ -0,0 +1,176 @@
part of 'edit.dart';
extension _JumpChain on _ServerEditPageState {
Widget _buildJumpChain() {
final serversState = ref.watch(serversProvider);
final servers = serversState.servers;
final selfId = spi?.id;
if (selfId == null) {
return ListTile(
leading: const Icon(Icons.map),
title: Text(l10n.jumpServer),
subtitle: Text(libL10n.empty, style: UIs.textGrey),
).cardx;
}
String serverNameOrId(String id) {
return servers[id]?.name ?? id;
}
List<String> flattenHopIds(String id, {required Set<String> visited}) {
if (!visited.add(id)) return const <String>[];
final spi = servers[id];
if (spi == null) return const <String>[];
final hops = spi.jumpChainIds;
if (hops == null || hops.isEmpty) return const <String>[];
final flat = <String>[];
for (final hopId in hops) {
flat.add(hopId);
flat.addAll(flattenHopIds(hopId, visited: visited));
}
return flat;
}
bool containsCycleWithCandidate(String candidateId) {
final queue = [..._jumpChain.value, candidateId];
final directVisited = <String>{selfId};
for (final hopId in queue) {
if (hopId == selfId) return true;
if (!directVisited.add(hopId)) return true;
}
for (final hopId in queue) {
final extra = flattenHopIds(hopId, visited: <String>{selfId});
for (final id in extra) {
if (id == selfId) return true;
}
}
return false;
}
String? buildTextNearToFar() {
if (_jumpChain.value.isEmpty) return null;
final flat = <String>[];
final visited = <String>{selfId};
for (final hopId in _jumpChain.value) {
flat.add(hopId);
flat.addAll(flattenHopIds(hopId, visited: visited));
}
final names = flat.map(serverNameOrId).toList();
if (names.isEmpty) return null;
return names.join('');
}
String? buildTextFarToNear() {
final text = buildTextNearToFar();
if (text == null) return null;
return text.split('').reversed.join('');
}
return _jumpChain.listenVal((_) {
final nearToFar2 = buildTextNearToFar();
final farToNear2 = buildTextFarToNear();
return ListTile(
leading: const Icon(Icons.map),
title: Text(l10n.jumpServer),
subtitle: (nearToFar2 == null)
? Text(libL10n.empty, style: UIs.textGrey)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${l10n.route}: $nearToFar2', style: UIs.textGrey),
Text('${libL10n.path}: $farToNear2', style: UIs.textGrey),
],
),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () async {
if (serversState.serverOrder.isEmpty) {
context.showSnackBar(libL10n.empty);
return;
}
final candidates = serversState.serverOrder.where((e) => e != selfId).toList();
if (candidates.isEmpty) {
context.showSnackBar(libL10n.empty);
return;
}
// Add a hop
final nextHop = await context.showPickSingleDialog<String>(
title: '${l10n.jumpServer} (+1)',
items: candidates.where((id) => !containsCycleWithCandidate(id)).toList(),
display: serverNameOrId,
clearable: true,
);
if (nextHop == null) return;
_jumpChain.value = [..._jumpChain.value, nextHop];
// If user wants to manage order/remove, offer a simple editor dialog
await context.showRoundDialog<void>(
title: l10n.jumpServer,
child: SizedBox(
width: 320,
child: _jumpChain.listenVal((hops) {
return ListView.builder(
shrinkWrap: true,
itemCount: hops.length,
itemBuilder: (context, index) {
final id = hops[index];
return ListTile(
title: Text(serverNameOrId(id)),
subtitle: Text(id, style: UIs.textGrey),
trailing: Wrap(
spacing: 4,
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 18),
onPressed: index == 0
? null
: () {
final list = [..._jumpChain.value];
final tmp = list[index - 1];
list[index - 1] = list[index];
list[index] = tmp;
_jumpChain.value = list;
},
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 18),
onPressed: index == hops.length - 1
? null
: () {
final list = [..._jumpChain.value];
final tmp = list[index + 1];
list[index + 1] = list[index];
list[index] = tmp;
_jumpChain.value = list;
},
),
IconButton(
icon: const Icon(Icons.delete, size: 18),
onPressed: () {
final list = [..._jumpChain.value]..removeAt(index);
_jumpChain.value = list;
},
),
],
),
);
},
);
}),
),
actions: Btnx.oks,
);
},
).cardx;
});
}
}

View File

@@ -132,6 +132,7 @@ extension _Widgets on _ServerEditPageState {
return ExpandTile(
title: Text(l10n.more),
children: [
_buildJumpChain(),
Input(
controller: _logoUrlCtrl,
type: TextInputType.url,
@@ -347,48 +348,6 @@ 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 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;
}
},
);
}),
),
);
});
return ExpandTile(
leading: const Icon(Icons.map),
initiallyExpanded: _jumpServer.value != null,
childrenPadding: padding,
title: Text(l10n.jumpServer),
children: [choice],
).cardx;
}
Widget _buildWriteScriptTip() {
return Btn.tile(

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,93 @@
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('resolveMergedJumpChain throws when injected chain misses jump server', () {
const spi = Spi(
name: 'target',
ip: '10.0.0.10',
port: 22,
user: 'root',
id: 't',
jumpId: 'missing',
);
expect(
() => resolveMergedJumpChain(spi, jumpChain: const <Spi>[]),
throwsA(
isA<SSHErr>().having(
(e) => e.type,
'type',
SSHErrType.connect,
),
),
);
});
test('resolveMergedJumpChain merges and dedups', () {
const c = Spi(name: 'c', ip: '10.0.0.30', port: 22, user: 'root', id: 'c');
const d = Spi(name: 'd', ip: '10.0.0.40', port: 22, user: 'root', id: 'd');
const b = Spi(
name: 'b',
ip: '10.0.0.20',
port: 22,
user: 'root',
id: 'b',
jumpChainIds: ['c', 'd'],
);
const target = Spi(
name: 'target',
ip: '10.0.0.10',
port: 22,
user: 'root',
id: 't',
jumpChainIds: ['b', 'c'],
);
final chain = resolveMergedJumpChain(target, jumpChain: const <Spi>[b, c, d]);
expect(chain.map((e) => e.id).toList(), ['c', 'd', 'b']);
});
test('resolveMergedJumpChain detects jump loop', () {
const b = Spi(
name: 'b',
ip: '10.0.0.20',
port: 22,
user: 'root',
id: 'b',
jumpChainIds: ['c'],
);
const c = Spi(
name: 'c',
ip: '10.0.0.30',
port: 22,
user: 'root',
id: 'c',
jumpChainIds: ['b'],
);
const target = Spi(
name: 'target',
ip: '10.0.0.10',
port: 22,
user: 'root',
id: 't',
jumpChainIds: ['b'],
);
expect(
() => resolveMergedJumpChain(target, jumpChain: const <Spi>[b, c]),
throwsA(
isA<SSHErr>().having(
(e) => e.type,
'type',
SSHErrType.connect,
),
),
);
});
});
}