mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-14 04:05:18 +01:00
@@ -90,6 +90,8 @@ Future<SSHClient> _genClientInternal(
|
||||
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)) {
|
||||
@@ -105,46 +107,116 @@ Future<SSHClient> _genClientInternal(
|
||||
String? alterUser;
|
||||
|
||||
final socket = await () async {
|
||||
// Proxy
|
||||
final jumpId = spi.jumpId;
|
||||
Spi? jumpSpi_;
|
||||
String? jumpPrivateKey;
|
||||
if (socketOverride != null) return socketOverride;
|
||||
|
||||
if (jumpId != null) {
|
||||
if (jumpChain != null) {
|
||||
final idx = jumpChain.indexWhere((e) => e.id == jumpId || e.oldId == jumpId);
|
||||
if (idx == -1) {
|
||||
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $jumpId');
|
||||
}
|
||||
jumpSpi_ = jumpChain[idx];
|
||||
jumpPrivateKey = jumpPrivateKeys != null && idx < jumpPrivateKeys.length ? jumpPrivateKeys[idx] : null;
|
||||
if (followJumpConfig) {
|
||||
final hopIds = spi.jumpChainIds;
|
||||
|
||||
if (jumpSpi_.keyId != null && jumpPrivateKey == null) {
|
||||
throw SSHErr(
|
||||
type: SSHErrType.noPrivateKey,
|
||||
message: l10n.privateKeyNotFoundFmt(jumpSpi_.keyId ?? ''),
|
||||
);
|
||||
// Explicit hop list on this node
|
||||
if (hopIds != null && hopIds.isNotEmpty) {
|
||||
SSHClient? currentClient;
|
||||
|
||||
for (var i = 0; i < hopIds.length; i++) {
|
||||
final hopId = hopIds[i];
|
||||
final hopSpi = jumpChain?.firstWhereOrNull((e) => e.id == hopId || e.oldId == hopId) ??
|
||||
Stores.server.box.get(hopId);
|
||||
if (hopSpi == null) {
|
||||
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $hopId');
|
||||
}
|
||||
|
||||
if (currentClient == null) {
|
||||
// First hop: connect directly
|
||||
final hopKeyId = hopSpi.keyId;
|
||||
final hopPrivateKey = hopKeyId == null
|
||||
? null
|
||||
: (jumpPrivateKeys != null && i < jumpPrivateKeys.length ? jumpPrivateKeys[i] : null) ??
|
||||
getPrivateKey(hopKeyId);
|
||||
|
||||
final hopSocket = await SSHSocket.connect(hopSpi.ip, hopSpi.port, timeout: timeout);
|
||||
currentClient = await _genClientInternal(
|
||||
hopSpi,
|
||||
privateKey: hopPrivateKey,
|
||||
jumpChain: jumpChain,
|
||||
jumpPrivateKeys: jumpPrivateKeys,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
knownHostFingerprints: hostKeyCache,
|
||||
onHostKeyAccepted: hostKeyPersist,
|
||||
onHostKeyPrompt: hostKeyPrompt,
|
||||
visited: visited,
|
||||
socketOverride: hopSocket,
|
||||
);
|
||||
} else {
|
||||
final forwarded = await currentClient.forwardLocal(hopSpi.ip, hopSpi.port);
|
||||
|
||||
final hopKeyId = hopSpi.keyId;
|
||||
final hopPrivateKey = hopKeyId == null
|
||||
? null
|
||||
: (jumpPrivateKeys != null && i < jumpPrivateKeys.length ? jumpPrivateKeys[i] : null) ??
|
||||
getPrivateKey(hopKeyId);
|
||||
|
||||
currentClient = await _genClientInternal(
|
||||
hopSpi,
|
||||
privateKey: hopPrivateKey,
|
||||
jumpChain: jumpChain,
|
||||
jumpPrivateKeys: jumpPrivateKeys,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
knownHostFingerprints: hostKeyCache,
|
||||
onHostKeyAccepted: hostKeyPersist,
|
||||
onHostKeyPrompt: hostKeyPrompt,
|
||||
visited: visited,
|
||||
socketOverride: forwarded,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentClient != null) {
|
||||
return await currentClient.forwardLocal(spi.ip, spi.port);
|
||||
}
|
||||
} else {
|
||||
jumpSpi_ = Stores.server.box.get(jumpId);
|
||||
}
|
||||
}
|
||||
|
||||
if (jumpSpi_ != null) {
|
||||
final jumpClient = await _genClientInternal(
|
||||
jumpSpi_,
|
||||
privateKey: jumpPrivateKey,
|
||||
jumpChain: jumpChain,
|
||||
jumpPrivateKeys: jumpPrivateKeys,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
knownHostFingerprints: hostKeyCache,
|
||||
onHostKeyAccepted: hostKeyPersist,
|
||||
onHostKeyPrompt: hostKeyPrompt,
|
||||
visited: visited,
|
||||
);
|
||||
// Legacy single hop
|
||||
final hopId = spi.jumpId;
|
||||
Spi? hopSpi;
|
||||
String? hopPrivateKey;
|
||||
|
||||
return await jumpClient.forwardLocal(spi.ip, spi.port);
|
||||
if (hopId != null) {
|
||||
if (jumpChain != null) {
|
||||
final idx = jumpChain.indexWhere((e) => e.id == hopId || e.oldId == hopId);
|
||||
if (idx == -1) {
|
||||
throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $hopId');
|
||||
}
|
||||
hopSpi = jumpChain[idx];
|
||||
hopPrivateKey = jumpPrivateKeys != null && idx < jumpPrivateKeys.length ? jumpPrivateKeys[idx] : null;
|
||||
|
||||
if (hopSpi.keyId != null && hopPrivateKey == null) {
|
||||
throw SSHErr(
|
||||
type: SSHErrType.noPrivateKey,
|
||||
message: l10n.privateKeyNotFoundFmt(hopSpi.keyId ?? ''),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
hopSpi = Stores.server.box.get(hopId);
|
||||
}
|
||||
}
|
||||
|
||||
if (hopSpi != null) {
|
||||
final hopClient = await _genClientInternal(
|
||||
hopSpi,
|
||||
privateKey: hopPrivateKey,
|
||||
jumpChain: jumpChain,
|
||||
jumpPrivateKeys: jumpPrivateKeys,
|
||||
timeout: timeout,
|
||||
onKeyboardInteractive: onKeyboardInteractive,
|
||||
knownHostFingerprints: hostKeyCache,
|
||||
onHostKeyAccepted: hostKeyPersist,
|
||||
onHostKeyPrompt: hostKeyPrompt,
|
||||
visited: visited,
|
||||
);
|
||||
|
||||
return await hopClient.forwardLocal(spi.ip, spi.port);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct
|
||||
@@ -171,32 +243,36 @@ Future<SSHClient> _genClientInternal(
|
||||
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,
|
||||
);
|
||||
return await buildClient(socket);
|
||||
}
|
||||
|
||||
typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,31 +15,29 @@ class SftpReq {
|
||||
if (keyId != null) {
|
||||
privateKey = getPrivateKey(keyId);
|
||||
}
|
||||
if (spi.jumpId != null) {
|
||||
if (spi.jumpChainIds != null || spi.jumpId != null) {
|
||||
final chain = <Spi>[];
|
||||
final keys = <String?>[];
|
||||
final visited = <String>{spi.id.isNotEmpty ? spi.id : spi.oldId};
|
||||
|
||||
var currentJumpId = spi.jumpId;
|
||||
while (currentJumpId != null) {
|
||||
final jumpSpi = Stores.server.box.get(currentJumpId);
|
||||
if (jumpSpi == null) break;
|
||||
final hopIds = spi.jumpChainIds ?? (spi.jumpId == null ? const <String>[] : [spi.jumpId!]);
|
||||
for (final hopId in hopIds) {
|
||||
final hopSpi = Stores.server.box.get(hopId);
|
||||
if (hopSpi == null) break;
|
||||
|
||||
// Prevent infinite loops if user mis-configured jump servers.
|
||||
final jumpId = jumpSpi.id.isNotEmpty ? jumpSpi.id : jumpSpi.oldId;
|
||||
if (!visited.add(jumpId)) {
|
||||
final hopKey = hopSpi.id.isNotEmpty ? hopSpi.id : hopSpi.oldId;
|
||||
if (!visited.add(hopKey)) {
|
||||
throw SSHErr(
|
||||
type: SSHErrType.connect,
|
||||
message: 'Jump loop detected while building SFTP chain: ${jumpSpi.name}',
|
||||
message: 'Jump loop detected while building SFTP chain: ${hopSpi.name}',
|
||||
);
|
||||
}
|
||||
|
||||
chain.add(jumpSpi);
|
||||
keys.add(jumpSpi.keyId != null ? getPrivateKey(jumpSpi.keyId!) : null);
|
||||
currentJumpId = jumpSpi.jumpId;
|
||||
chain.add(hopSpi);
|
||||
keys.add(hopSpi.keyId != null ? getPrivateKey(hopSpi.keyId!) : null);
|
||||
}
|
||||
|
||||
// Always set when `spi.jumpId != null` so the isolate won't fallback to Stores.
|
||||
// Always set when a jump is configured so the isolate won't fallback to Stores.
|
||||
jumpChain = chain;
|
||||
jumpPrivateKeys = keys;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ final class ServersNotifierProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$serversNotifierHash() => r'dc5da44f9bd8d8dcfba3e6e932cca3e2f379e582';
|
||||
String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb';
|
||||
|
||||
abstract class _$ServersNotifier extends $Notifier<ServersState> {
|
||||
ServersState build();
|
||||
|
||||
@@ -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 == null && spi.jumpId == null) {
|
||||
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
|
||||
} else {
|
||||
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
|
||||
|
||||
@@ -58,7 +58,7 @@ final class ServerNotifierProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$serverNotifierHash() => r'04b1beef4d96242fd10d5b523c6f5f17eb774bae';
|
||||
String _$serverNotifierHash() => r'52e806bcc32a7818d1ec2b07a3c683b06885c9f8';
|
||||
|
||||
final class ServerNotifierFamily extends $Family
|
||||
with
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -222,6 +222,15 @@ extension _Actions on _ServerEditPageState {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.spi != null) {
|
||||
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 +286,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 +431,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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
176
lib/view/page/server/edit/jump_chain.dart
Normal file
176
lib/view/page/server/edit/jump_chain.dart
Normal 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 visited = <String>{selfId};
|
||||
final queue = [..._jumpChain.value, candidateId];
|
||||
|
||||
for (final hopId in queue) {
|
||||
if (hopId == selfId) return true;
|
||||
if (!visited.add(hopId)) return true;
|
||||
}
|
||||
|
||||
for (final hopId in queue) {
|
||||
final extra = flattenHopIds(hopId, visited: visited);
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ extension _Widgets on _ServerEditPageState {
|
||||
return ExpandTile(
|
||||
title: Text(l10n.more),
|
||||
children: [
|
||||
_buildJumpChain(),
|
||||
Input(
|
||||
controller: _logoUrlCtrl,
|
||||
type: TextInputType.url,
|
||||
@@ -347,100 +348,6 @@ extension _Widgets on _ServerEditPageState {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJumpServer() {
|
||||
const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7);
|
||||
final servers = ref.watch(serversProvider).servers;
|
||||
final selfId = spi?.id;
|
||||
|
||||
bool wouldCreateCycle(Spi candidate) {
|
||||
if (selfId == null) return false;
|
||||
if (candidate.id == selfId) return true;
|
||||
|
||||
final visited = <String>{selfId};
|
||||
var current = candidate;
|
||||
while (true) {
|
||||
final jumpId = current.jumpId;
|
||||
if (jumpId == null) return false;
|
||||
if (jumpId == selfId) return true;
|
||||
if (!visited.add(jumpId)) {
|
||||
// Candidate already contains a loop; treat as invalid to avoid infinite jump.
|
||||
return true;
|
||||
}
|
||||
final next = servers[jumpId];
|
||||
if (next == null) return false;
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
String? buildJumpChainText(String jumpId) {
|
||||
final chain = <Spi>[];
|
||||
final visited = <String>{};
|
||||
var currentId = jumpId;
|
||||
|
||||
while (true) {
|
||||
if (!visited.add(currentId)) {
|
||||
break;
|
||||
}
|
||||
final srv = servers[currentId];
|
||||
if (srv == null) break;
|
||||
chain.add(srv);
|
||||
final nextId = srv.jumpId;
|
||||
if (nextId == null) break;
|
||||
currentId = nextId;
|
||||
}
|
||||
|
||||
if (chain.isEmpty) return null;
|
||||
// Display as actual connection order: farthest -> ... -> nearest.
|
||||
return chain.reversed.map((e) => e.name).join(' → ');
|
||||
}
|
||||
|
||||
final srvs = servers.values.where((e) => e.id != selfId && !wouldCreateCycle(e)).toList();
|
||||
final body = _jumpServer.listenVal((val) {
|
||||
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
|
||||
final chainText = val == null ? null : buildJumpChainText(val);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (chainText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 7),
|
||||
child: Text(chainText, style: UIs.textGrey),
|
||||
),
|
||||
Choice<Spi>(
|
||||
multiple: false,
|
||||
clearable: true,
|
||||
value: srv != null ? [srv] : [],
|
||||
builder: (state, _) => Wrap(
|
||||
children: List<Widget>.generate(srvs.length, (index) {
|
||||
final item = srvs[index];
|
||||
return ChoiceChipX<Spi>(
|
||||
label: item.name,
|
||||
state: state,
|
||||
value: item,
|
||||
onSelected: (srv, on) {
|
||||
if (on) {
|
||||
_jumpServer.value = srv.id;
|
||||
} else {
|
||||
_jumpServer.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
return ExpandTile(
|
||||
leading: const Icon(Icons.map),
|
||||
initiallyExpanded: _jumpServer.value != null,
|
||||
childrenPadding: padding,
|
||||
title: Text(l10n.jumpServer),
|
||||
children: [body],
|
||||
).cardx;
|
||||
}
|
||||
|
||||
Widget _buildWriteScriptTip() {
|
||||
return Btn.tile(
|
||||
|
||||
Reference in New Issue
Block a user