* feat(ssh): support full multi-hop jump chain (#356) * fix(edit): validate jump cycle for new server saves
This commit is contained in:
@@ -4,6 +4,15 @@ part of 'edit.dart';
|
||||
final _hostReg = RegExp(r'^[a-zA-Z0-9\.\-_:%;]+$');
|
||||
|
||||
extension _Actions on _ServerEditPageState {
|
||||
bool _isInvalidJumpSelection(String? candidateJumpId) {
|
||||
final currentServer = spi;
|
||||
return wouldCreateJumpCycle(
|
||||
currentServerId: currentServer?.id,
|
||||
candidateJumpId: candidateJumpId,
|
||||
serversById: ref.read(serversProvider).servers,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onTapSSHDiscovery() async {
|
||||
try {
|
||||
final result = await SshDiscoveryPage.route.go(context);
|
||||
@@ -16,7 +25,9 @@ extension _Actions on _ServerEditPageState {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processDiscoveredServers(List<SshDiscoveryResult> discoveredServers) async {
|
||||
Future<void> _processDiscoveredServers(
|
||||
List<SshDiscoveryResult> discoveredServers,
|
||||
) async {
|
||||
if (discoveredServers.length == 1) {
|
||||
// Single server - populate the current form
|
||||
final server = discoveredServers.first;
|
||||
@@ -30,7 +41,11 @@ extension _Actions on _ServerEditPageState {
|
||||
// Multiple servers - show import dialog
|
||||
final shouldImport = await context.showRoundDialog<bool>(
|
||||
title: libL10n.import,
|
||||
child: Text(libL10n.askContinue('${libL10n.found} ${discoveredServers.length} ${libL10n.servers}')),
|
||||
child: Text(
|
||||
libL10n.askContinue(
|
||||
'${libL10n.found} ${discoveredServers.length} ${libL10n.servers}',
|
||||
),
|
||||
),
|
||||
actions: Btnx.cancelOk,
|
||||
);
|
||||
|
||||
@@ -46,7 +61,9 @@ extension _Actions on _ServerEditPageState {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.'),
|
||||
Text(
|
||||
'${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(libL10n.setting),
|
||||
const SizedBox(height: 8),
|
||||
@@ -64,8 +81,12 @@ extension _Actions on _ServerEditPageState {
|
||||
);
|
||||
|
||||
if (shouldProceed == true) {
|
||||
final username = usernameController.text.isNotEmpty ? usernameController.text : defaultUsername;
|
||||
final keyId = keyIdController.text.isNotEmpty ? keyIdController.text : null;
|
||||
final username = usernameController.text.isNotEmpty
|
||||
? usernameController.text
|
||||
: defaultUsername;
|
||||
final keyId = keyIdController.text.isNotEmpty
|
||||
? keyIdController.text
|
||||
: null;
|
||||
final servers = discoveredServers
|
||||
.map(
|
||||
(result) => Spi(
|
||||
@@ -74,7 +95,9 @@ extension _Actions on _ServerEditPageState {
|
||||
port: result.port,
|
||||
user: username,
|
||||
keyId: keyId,
|
||||
pwd: _passwordController.text.isEmpty ? null : _passwordController.text,
|
||||
pwd: _passwordController.text.isEmpty
|
||||
? null
|
||||
: _passwordController.text,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -122,7 +145,8 @@ extension _Actions on _ServerEditPageState {
|
||||
void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async {
|
||||
dprint('Error importing SSH config: $e');
|
||||
// Check if it's a permission error and offer file picker as fallback
|
||||
if (e is PathAccessException || e.toString().contains('Operation not permitted')) {
|
||||
if (e is PathAccessException ||
|
||||
e.toString().contains('Operation not permitted')) {
|
||||
final useFilePicker = await context.showRoundDialog<bool>(
|
||||
title: l10n.sshConfigImport,
|
||||
child: Column(
|
||||
@@ -164,10 +188,15 @@ extension _Actions on _ServerEditPageState {
|
||||
children: [
|
||||
Text(l10n.sshConfigFoundServers('${summary.total}')),
|
||||
if (summary.hasDuplicates)
|
||||
Text(l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'), style: UIs.textGrey),
|
||||
Text(
|
||||
l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'),
|
||||
style: UIs.textGrey,
|
||||
),
|
||||
Text(l10n.sshConfigServersToImport('${summary.toImport}')),
|
||||
const SizedBox(height: 16),
|
||||
...resolved.map((s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})')),
|
||||
...resolved.map(
|
||||
(s) => Text('• ${s.name} (${s.user}@${s.ip}:${s.port})'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -205,7 +234,10 @@ extension _Actions on _ServerEditPageState {
|
||||
}
|
||||
|
||||
void _onTapCustomItem() async {
|
||||
final res = await KvEditor.route.go(context, KvEditorArgs(data: _customCmds.value));
|
||||
final res = await KvEditor.route.go(
|
||||
context,
|
||||
KvEditorArgs(data: _customCmds.value),
|
||||
);
|
||||
if (res == null) return;
|
||||
_customCmds.value = res;
|
||||
}
|
||||
@@ -250,6 +282,11 @@ extension _Actions on _ServerEditPageState {
|
||||
if (_portController.text.isEmpty) {
|
||||
_portController.text = '22';
|
||||
}
|
||||
if (_isInvalidJumpSelection(_jumpServer.value)) {
|
||||
context.showSnackBar('${l10n.invalid}: ${l10n.jumpServer}');
|
||||
return;
|
||||
}
|
||||
|
||||
final customCmds = _customCmds.value;
|
||||
final custom = ServerCustom(
|
||||
pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull,
|
||||
@@ -261,10 +298,17 @@ extension _Actions on _ServerEditPageState {
|
||||
scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull,
|
||||
);
|
||||
|
||||
final wolEmpty = _wolMacCtrl.text.isEmpty && _wolIpCtrl.text.isEmpty && _wolPwdCtrl.text.isEmpty;
|
||||
final wolEmpty =
|
||||
_wolMacCtrl.text.isEmpty &&
|
||||
_wolIpCtrl.text.isEmpty &&
|
||||
_wolPwdCtrl.text.isEmpty;
|
||||
final wol = wolEmpty
|
||||
? null
|
||||
: WakeOnLanCfg(mac: _wolMacCtrl.text, ip: _wolIpCtrl.text, pwd: _wolPwdCtrl.text.selfNotEmptyOrNull);
|
||||
: WakeOnLanCfg(
|
||||
mac: _wolMacCtrl.text,
|
||||
ip: _wolIpCtrl.text,
|
||||
pwd: _wolPwdCtrl.text.selfNotEmptyOrNull,
|
||||
);
|
||||
if (wol != null) {
|
||||
final wolValidation = wol.validate();
|
||||
if (!wolValidation.$2) {
|
||||
@@ -274,7 +318,9 @@ extension _Actions on _ServerEditPageState {
|
||||
}
|
||||
|
||||
final spi = Spi(
|
||||
name: _nameController.text.isEmpty ? _ipController.text : _nameController.text,
|
||||
name: _nameController.text.isEmpty
|
||||
? _ipController.text
|
||||
: _nameController.text,
|
||||
ip: _ipController.text,
|
||||
port: int.parse(_portController.text),
|
||||
user: _usernameController.text,
|
||||
@@ -291,7 +337,9 @@ extension _Actions on _ServerEditPageState {
|
||||
envs: _env.value.isEmpty ? null : _env.value,
|
||||
id: widget.args?.spi.id ?? ShortId.generate(),
|
||||
customSystemType: _systemType.value,
|
||||
disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(),
|
||||
disabledCmdTypes: _disabledCmdTypes.value.isEmpty
|
||||
? null
|
||||
: _disabledCmdTypes.value.toList(),
|
||||
);
|
||||
|
||||
if (this.spi == null) {
|
||||
@@ -421,7 +469,10 @@ extension _Utils on _ServerEditPageState {
|
||||
if (spi.keyId == null) {
|
||||
_passwordController.text = spi.pwd ?? '';
|
||||
} else {
|
||||
_keyIdx.value = ref.read(privateKeyProvider).keys.indexWhere((e) => e.id == spi.keyId);
|
||||
_keyIdx.value = ref
|
||||
.read(privateKeyProvider)
|
||||
.keys
|
||||
.indexWhere((e) => e.id == spi.keyId);
|
||||
}
|
||||
|
||||
/// List in dart is passed by pointer, so you need to copy it here
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:icons_plus/icons_plus.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/core/route.dart';
|
||||
import 'package:server_box/core/utils/jump_chain.dart';
|
||||
import 'package:server_box/core/utils/server_dedup.dart';
|
||||
import 'package:server_box/core/utils/ssh_config.dart';
|
||||
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
||||
@@ -32,13 +33,17 @@ class ServerEditPage extends ConsumerStatefulWidget {
|
||||
|
||||
const ServerEditPage({super.key, this.args});
|
||||
|
||||
static const route = AppRoute<bool, SpiRequiredArgs>(page: ServerEditPage.new, path: '/servers/edit');
|
||||
static const route = AppRoute<bool, SpiRequiredArgs>(
|
||||
page: ServerEditPage.new,
|
||||
path: '/servers/edit',
|
||||
);
|
||||
|
||||
@override
|
||||
ConsumerState<ServerEditPage> createState() => _ServerEditPageState();
|
||||
}
|
||||
|
||||
class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayoutMixin {
|
||||
class _ServerEditPageState extends ConsumerState<ServerEditPage>
|
||||
with AfterLayoutMixin {
|
||||
late final spi = widget.args?.spi;
|
||||
final _nameController = TextEditingController();
|
||||
final _ipController = TextEditingController();
|
||||
@@ -140,7 +145,10 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayou
|
||||
final children = [
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView(scrollDirection: Axis.horizontal, children: topItems.joinWith(UIs.width13).toList()),
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: topItems.joinWith(UIs.width13).toList(),
|
||||
),
|
||||
),
|
||||
Input(
|
||||
autoFocus: true,
|
||||
|
||||
@@ -354,8 +354,8 @@ extension _Widgets on _ServerEditPageState {
|
||||
.watch(serversProvider)
|
||||
.servers
|
||||
.values
|
||||
.where((e) => e.jumpId == null)
|
||||
.where((e) => e.id != spi?.id)
|
||||
.where((e) => !_isInvalidJumpSelection(e.id))
|
||||
.toList();
|
||||
final choice = _jumpServer.listenVal((val) {
|
||||
final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value);
|
||||
|
||||
Reference in New Issue
Block a user