diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 7ee67a387..7462cb110 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -38,6 +38,77 @@ String getPrivateKey(String id) { return pki.key; } +List resolveMergedJumpChain( + Spi target, { + List? jumpChain, +}) { + final injectedSpiMap = {}; + 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 _resolveMergedJumpChainInternal( + Spi target, { + required Spi Function(String id) resolveSpi, +}) { + final roots = target.jumpChainIds ?? (target.jumpId == null ? const [] : [target.jumpId!]); + if (roots.isEmpty) return const []; + + final seen = {}; + final stack = {}; + final out = []; + + 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 [] : [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 genClient( Spi spi, { void Function(GenSSHClientStatus)? onStatus, @@ -45,14 +116,17 @@ Future 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), + /// Pre-resolved jump chain (in `spi.jumpId` order: immediate -> farthest). + /// + /// This is mainly used when `Stores` is unavailable (e.g. in an isolate). + List? jumpChain, - /// [Spi] of the jump server + /// Private keys for [jumpChain], aligned by index. /// - /// Must pass this param if using multi-threading and key login - Spi? jumpSpi, + /// 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? jumpPrivateKeys, + Duration timeout = const Duration(seconds: 5), /// Handle keyboard-interactive authentication SSHUserInfoRequestHandler? onKeyboardInteractive, @@ -60,6 +134,41 @@ Future genClient( void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, Future 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: {}, + ); +} + +Future _genClientInternal( + Spi spi, { + void Function(GenSSHClientStatus)? onStatus, + String? privateKey, + List? jumpChain, + List? jumpPrivateKeys, + Duration timeout = const Duration(seconds: 5), + SSHUserInfoRequestHandler? onKeyboardInteractive, + Map? knownHostFingerprints, + void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, + Future Function(HostKeyPromptInfo info)? onHostKeyPrompt, + required Set 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.from(knownHostFingerprints ?? _loadKnownHostFingerprints()); @@ -68,37 +177,126 @@ Future 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, []); + + if (followJumpConfig) { + final injectedSpiMap = {}; + final injectedKeyMap = {}; + + 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); + } - return await jumpClient.forwardLocal(spi.ip, spi.port); + final hops = _resolveMergedJumpChainInternal(spi, resolveSpi: resolveSpi); + if (hops.isNotEmpty) { + // Build multi-hop forward chain with dedup/merge. + final createdClients = []; + 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), []); } 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), []); } catch (e) { Loggers.app.warning('genClient alterUrl', e); rethrow; @@ -113,32 +311,52 @@ Future genClient( prompt: hostKeyPrompt, ); - final keyId = spi.keyId; - if (keyId == null) { - onStatus?.call(GenSSHClientStatus.pwd); + Future 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 ensureKnownHostKey( Duration timeout = const Duration(seconds: 5), SSHUserInfoRequestHandler? onKeyboardInteractive, }) async { - final cache = _loadKnownHostFingerprints(); - if (_hasKnownHostFingerprintForSpi(spi, cache)) { - return; - } - - final jumpSpi = spi.jumpId != null ? Stores.server.box.get(spi.jumpId) : null; - if (jumpSpi != null && !_hasKnownHostFingerprintForSpi(jumpSpi, cache)) { - await ensureKnownHostKey( - jumpSpi, + var cache = _loadKnownHostFingerprints(); + + 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> _ensureKnownHostKeyForSingle( + Spi spi, { + required Map cache, + Duration timeout = const Duration(seconds: 5), + SSHUserInfoRequestHandler? onKeyboardInteractive, + List? jumpChain, + List? jumpPrivateKeys, +}) async { + if (_hasKnownHostFingerprintForSpi(spi, cache)) { + return cache; } final client = await genClient( @@ -321,6 +572,8 @@ Future ensureKnownHostKey( timeout: timeout, onKeyboardInteractive: onKeyboardInteractive, knownHostFingerprints: cache, + jumpChain: jumpChain, + jumpPrivateKeys: jumpPrivateKeys, ); try { @@ -328,6 +581,9 @@ Future ensureKnownHostKey( } finally { client.close(); } + + cache.addAll(_loadKnownHostFingerprints()); + return cache; } bool _hasKnownHostFingerprintForSpi(Spi spi, Map cache) { diff --git a/lib/data/model/server/server_private_info.dart b/lib/data/model/server/server_private_info.dart index 045b34afd..d9cad5b0a 100644 --- a/lib/data/model/server/server_private_info.dart +++ b/lib/data/model/server/server_private_info.dart @@ -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? 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, diff --git a/lib/data/model/server/server_private_info.freezed.dart b/lib/data/model/server/server_private_info.freezed.dart index 89db2a0cf..70ae6303d 100644 --- a/lib/data/model/server/server_private_info.freezed.dart +++ b/lib/data/model/server/server_private_info.freezed.dart @@ -16,8 +16,13 @@ T _$identity(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? 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? 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? get jumpChainIds; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal. Map? 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? get disabledCmdTypes; @@ -33,12 +38,12 @@ $SpiCopyWith get copyWith => _$SpiCopyWithImpl(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? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes + String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId,@JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? 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?,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?,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?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable @@ -169,10 +175,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? 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 Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? 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? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? 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? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List? 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? tags, this.alterUrl, this.autoConnect = true, this.jumpId, @JsonKey(includeIfNull: false) final List? jumpChainIds, this.custom, this.wolCfg, final Map? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List? disabledCmdTypes}): _tags = tags,_jumpChainIds = jumpChainIds,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._(); factory _Spi.fromJson(Map 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? _jumpChainIds; +/// Jump chain hop ids (nearest -> farthest) +/// +/// Preferred over [jumpId]. +@override@JsonKey(includeIfNull: false) List? 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 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? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes + String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId,@JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? 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?,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?,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?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable diff --git a/lib/data/model/server/server_private_info.g.dart b/lib/data/model/server/server_private_info.g.dart index a3fc16532..7f4a692fd 100644 --- a/lib/data/model/server/server_private_info.g.dart +++ b/lib/data/model/server/server_private_info.g.dart @@ -17,6 +17,9 @@ _Spi _$SpiFromJson(Map json) => _Spi( alterUrl: json['alterUrl'] as String?, autoConnect: json['autoConnect'] as bool? ?? true, jumpId: json['jumpId'] as String?, + jumpChainIds: (json['jumpChainIds'] as List?) + ?.map((e) => e as String) + .toList(), custom: json['custom'] == null ? null : ServerCustom.fromJson(json['custom'] as Map), @@ -47,6 +50,7 @@ Map _$SpiToJson(_Spi instance) => { 'alterUrl': ?instance.alterUrl, 'autoConnect': instance.autoConnect, 'jumpId': ?instance.jumpId, + 'jumpChainIds': ?instance.jumpChainIds, 'custom': ?instance.custom, 'wolCfg': ?instance.wolCfg, 'envs': ?instance.envs, diff --git a/lib/data/model/sftp/req.dart b/lib/data/model/sftp/req.dart index 64c1f7980..3688ebe40 100644 --- a/lib/data/model/sftp/req.dart +++ b/lib/data/model/sftp/req.dart @@ -6,8 +6,8 @@ class SftpReq { final String localPath; final SftpReqType type; String? privateKey; - Spi? jumpSpi; - String? jumpPrivateKey; + List? jumpChain; + List? jumpPrivateKeys; Map? 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 = []; + 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.from(Stores.setting.sshKnownHostFingerprints.get()); @@ -90,4 +98,4 @@ class SftpReqStatus { } } -enum SftpWorkerStatus { preparing, sshConnectted, loading, finished } +enum SftpWorkerStatus { preparing, sshConnected, loading, finished } diff --git a/lib/data/model/sftp/worker.dart b/lib/data/model/sftp/worker.dart index 7449c4f0a..56466a389 100644 --- a/lib/data/model/sftp/worker.dart +++ b/lib/data/model/sftp/worker.dart @@ -63,11 +63,11 @@ Future _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 _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()) { diff --git a/lib/data/provider/server/all.g.dart b/lib/data/provider/server/all.g.dart index 2fefa0530..bf1fc4133 100644 --- a/lib/data/provider/server/all.g.dart +++ b/lib/data/provider/server/all.g.dart @@ -41,7 +41,7 @@ final class ServersNotifierProvider } } -String _$serversNotifierHash() => r'dc5da44f9bd8d8dcfba3e6e932cca3e2f379e582'; +String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb'; abstract class _$ServersNotifier extends $Notifier { ServersState build(); diff --git a/lib/data/provider/server/single.dart b/lib/data/provider/server/single.dart index 5b7df3bb5..bb7b9c323 100644 --- a/lib/data/provider/server/single.dart +++ b/lib/data/provider/server/single.dart @@ -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.'); diff --git a/lib/data/provider/server/single.g.dart b/lib/data/provider/server/single.g.dart index bfc9f2bb1..959526d95 100644 --- a/lib/data/provider/server/single.g.dart +++ b/lib/data/provider/server/single.g.dart @@ -58,7 +58,7 @@ final class ServerNotifierProvider } } -String _$serverNotifierHash() => r'04b1beef4d96242fd10d5b523c6f5f17eb774bae'; +String _$serverNotifierHash() => r'52e806bcc32a7818d1ec2b07a3c683b06885c9f8'; final class ServerNotifierFamily extends $Family with diff --git a/lib/data/store/server.dart b/lib/data/store/server.dart index 065495a8b..2feae6e36 100644 --- a/lib/data/store/server.dart +++ b/lib/data/store/server.dart @@ -89,15 +89,12 @@ class ServerStore extends HiveStore { // Replace ids in jump server settings. final spi = get(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] diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index c456396be..3877b8e3c 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -107,6 +107,7 @@ class SpiAdapter extends TypeAdapter { 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(), custom: fields[10] as ServerCustom?, wolCfg: fields[11] as WakeOnLanCfg?, envs: (fields[12] as Map?)?.cast(), @@ -119,7 +120,7 @@ class SpiAdapter extends TypeAdapter { @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 { ..writeByte(14) ..write(obj.customSystemType) ..writeByte(15) - ..write(obj.disabledCmdTypes); + ..write(obj.disabledCmdTypes) + ..writeByte(16) + ..write(obj.jumpChainIds); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 94d426fe0..85c24d210 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -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 diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index 7b6d30c73..cef03d5ed 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -222,6 +222,30 @@ extension _Actions on _ServerEditPageState { return; } + final oldSpi = this.spi; + if (oldSpi != null) { + final originalJumpChain = oldSpi.jumpChainIds ?? (oldSpi.jumpId == null ? const [] : [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( + 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( 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 [] : [spi.jumpId!]); final custom = spi.custom; if (custom != null) { diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 75ec62e93..ee807d950 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -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 with AfterLayou /// -1: non selected, null: password, others: index of private key final _keyIdx = ValueNotifier(null); final _autoConnect = ValueNotifier(true); - final _jumpServer = nvn(); + final _jumpChain = [].vn; final _pveIgnoreCert = ValueNotifier(false); final _env = {}.vn; final _customCmds = {}.vn; @@ -100,7 +101,7 @@ class _ServerEditPageState extends ConsumerState with AfterLayou _keyIdx.dispose(); _autoConnect.dispose(); - _jumpServer.dispose(); + _jumpChain.dispose(); _pveIgnoreCert.dispose(); _env.dispose(); _customCmds.dispose(); @@ -199,7 +200,6 @@ class _ServerEditPageState extends ConsumerState with AfterLayou ), _buildAuth(), _buildSystemType(), - _buildJumpServer(), _buildMore(), ]; return AutoMultiList(children: children); diff --git a/lib/view/page/server/edit/jump_chain.dart b/lib/view/page/server/edit/jump_chain.dart new file mode 100644 index 000000000..7cea42ae6 --- /dev/null +++ b/lib/view/page/server/edit/jump_chain.dart @@ -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 flattenHopIds(String id, {required Set visited}) { + if (!visited.add(id)) return const []; + final spi = servers[id]; + if (spi == null) return const []; + + final hops = spi.jumpChainIds; + if (hops == null || hops.isEmpty) return const []; + + final flat = []; + 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 = {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: {selfId}); + for (final id in extra) { + if (id == selfId) return true; + } + } + return false; + } + + String? buildTextNearToFar() { + if (_jumpChain.value.isEmpty) return null; + final flat = []; + final visited = {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( + 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( + 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; + }); + } +} diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index 447e9ee0f..c63b0b0ac 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -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( - multiple: false, - clearable: true, - value: srv != null ? [srv] : [], - builder: (state, _) => Wrap( - children: List.generate(srvs.length, (index) { - final item = srvs[index]; - return ChoiceChipX( - 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( diff --git a/lib/view/page/storage/sftp_mission.dart b/lib/view/page/storage/sftp_mission.dart index 57588ace3..fc24b371b 100644 --- a/lib/view/page/storage/sftp_mission.dart +++ b/lib/view/page/storage/sftp_mission.dart @@ -53,7 +53,7 @@ class _SftpMissionPageState extends ConsumerState { 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), }; diff --git a/test/jump_server_test.dart b/test/jump_server_test.dart new file mode 100644 index 000000000..1922acb13 --- /dev/null +++ b/test/jump_server_test.dart @@ -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 []), + throwsA( + isA().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 [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 [b, c]), + throwsA( + isA().having( + (e) => e.type, + 'type', + SSHErrType.connect, + ), + ), + ); + }); + }); +} +