From 9fa6be81955db57730374d55a0d95de863e7570f Mon Sep 17 00:00:00 2001 From: 0xJacky Date: Wed, 2 Sep 2026 18:25:29 +0800 Subject: [PATCH] fix(app): harden nginx host setup control mode and wizard steps - Render the control-mode radio group only while editing, so the read-only view can no longer mutate the store without passing the 2FA-gated Edit flow - Show a dedicated message when no private key exists at the given path instead of the raw transport error - Share parseHostAddress between the SSH target and host key steps so bracketed IPv6 loopback targets no longer trigger the remote-address warning - Keep a stale host key scan or connection test from clearing the loading state of a newer in-flight run - Align the E2E spec with the read-only control mode view Co-Authored-By: Claude Fable 5.1 --- .../components/NginxHostSetup/hostAddress.ts | 25 ++++++++++++++++ .../steps/AuthenticationMethod.vue | 4 ++- .../NginxHostSetup/steps/ConnectionTest.vue | 6 ++-- .../NginxHostSetup/steps/HostKeyTrust.vue | 23 +++----------- .../NginxHostSetup/steps/StepSshTarget.vue | 5 +++- .../views/preference/tabs/NginxSettings.vue | 2 +- e2e/tests/nginx-host-setup.spec.ts | 30 ++++++++----------- 7 files changed, 52 insertions(+), 43 deletions(-) create mode 100644 app/src/views/preference/components/NginxHostSetup/hostAddress.ts diff --git a/app/src/views/preference/components/NginxHostSetup/hostAddress.ts b/app/src/views/preference/components/NginxHostSetup/hostAddress.ts new file mode 100644 index 00000000..367526e1 --- /dev/null +++ b/app/src/views/preference/components/NginxHostSetup/hostAddress.ts @@ -0,0 +1,25 @@ +export interface HostAddress { + host: string + port: string +} + +// Splits an SSH target such as "host:22", "[::1]:22" or "::1" into its host +// and port parts, defaulting the port to 22. Bare IPv6 literals without a +// bracket are treated as a host with no port. +export function parseHostAddress(address: string): HostAddress { + const bracketed = address.match(/^\[([^\]]+)\](?::(\d+))?$/) + if (bracketed) { + return { + host: bracketed[1], + port: bracketed[2] ?? '22', + } + } + + const colonCount = (address.match(/:/g) ?? []).length + if (colonCount === 1) { + const [host, port] = address.split(':') + return { host, port: port || '22' } + } + + return { host: address, port: '22' } +} diff --git a/app/src/views/preference/components/NginxHostSetup/steps/AuthenticationMethod.vue b/app/src/views/preference/components/NginxHostSetup/steps/AuthenticationMethod.vue index f913816d..c537b3c3 100644 --- a/app/src/views/preference/components/NginxHostSetup/steps/AuthenticationMethod.vue +++ b/app/src/views/preference/components/NginxHostSetup/steps/AuthenticationMethod.vue @@ -117,8 +117,10 @@ async function loadPublicKey(showError = true) { if (isKeyMissing(error)) { // A missing key is a normal starting state, not a failure to report. isKeyStateKnown.value = true + // The 404 carries no message, so a dedicated text replaces the raw + // transport error. if (showError) - keyError.value = getErrorMessage(error) + keyError.value = $gettext('No private key was found at this path') return } isKeyStateKnown.value = false diff --git a/app/src/views/preference/components/NginxHostSetup/steps/ConnectionTest.vue b/app/src/views/preference/components/NginxHostSetup/steps/ConnectionTest.vue index 2a6da9f9..edd1a4d2 100644 --- a/app/src/views/preference/components/NginxHostSetup/steps/ConnectionTest.vue +++ b/app/src/views/preference/components/NginxHostSetup/steps/ConnectionTest.vue @@ -47,9 +47,9 @@ async function testConnection() { connectionError.value = getErrorMessage(error) } finally { - // Always clear the flag. The stale check above already stops a late - // response from writing its result. - isTestingConnection.value = false + // A stale run must not clear the loading state of a newer test. + if (requestID === testRequestID) + isTestingConnection.value = false } } diff --git a/app/src/views/preference/components/NginxHostSetup/steps/HostKeyTrust.vue b/app/src/views/preference/components/NginxHostSetup/steps/HostKeyTrust.vue index 4380b54a..e0a5542a 100644 --- a/app/src/views/preference/components/NginxHostSetup/steps/HostKeyTrust.vue +++ b/app/src/views/preference/components/NginxHostSetup/steps/HostKeyTrust.vue @@ -5,6 +5,7 @@ import { computed, onActivated, ref, watch } from 'vue' import hostSetup from '@/api/host_setup' import { getErrorMessage } from '@/lib/http' import CodeBlock from '../CodeBlock.vue' +import { parseHostAddress } from '../hostAddress' const props = defineProps<{ params: SetupParams }>() const emit = defineEmits<{ invalidated: [] }>() @@ -44,24 +45,6 @@ function shellQuote(value: string) { return `'${value.replaceAll('\'', '\'"\'"\'')}'` } -function parseHostAddress(address: string) { - const bracketed = address.match(/^\[([^\]]+)\](?::(\d+))?$/) - if (bracketed) { - return { - host: bracketed[1], - port: bracketed[2] ?? '22', - } - } - - const colonCount = (address.match(/:/g) ?? []).length - if (colonCount === 1) { - const [host, port] = address.split(':') - return { host, port: port || '22' } - } - - return { host: address, port: '22' } -} - function sshKeyscanCommand() { const { host, port } = parseHostAddress(props.params.host_address) return `ssh-keyscan -p ${shellQuote(port)} ${shellQuote(host)}` @@ -127,7 +110,9 @@ async function scan(useManual = false) { scanError.value = getErrorMessage(error) } finally { - scanning.value = false + // A stale run must not clear the loading state of a newer scan. + if (currentScan === scanID) + scanning.value = false } } diff --git a/app/src/views/preference/components/NginxHostSetup/steps/StepSshTarget.vue b/app/src/views/preference/components/NginxHostSetup/steps/StepSshTarget.vue index 9dba7427..3a33c152 100644 --- a/app/src/views/preference/components/NginxHostSetup/steps/StepSshTarget.vue +++ b/app/src/views/preference/components/NginxHostSetup/steps/StepSshTarget.vue @@ -4,6 +4,7 @@ import { CheckCircleOutlined, ReloadOutlined } from '@ant-design/icons-vue' import { computed, onActivated, ref } from 'vue' import hostSetup from '@/api/host_setup' import { getErrorMessage } from '@/lib/http' +import { parseHostAddress } from '../hostAddress' import { useHostSetupWizard } from '../useHostSetupWizard' import AuthenticationMethod from './AuthenticationMethod.vue' @@ -67,7 +68,9 @@ onActivated(() => { const remoteWarning = computed(() => { if (params.value.access_mode !== 'mounted') return false - const host = (hostInput.value.split(':')[0] || '').trim() + // Bracketed IPv6 literals such as [::1]:22 must resolve to the address, not + // to the opening bracket. + const host = parseHostAddress(hostInput.value.trim()).host.trim() if (!host || host === 'host.docker.internal' || host === 'localhost' || host === '::1') return false if (/^127\./.test(host)) diff --git a/app/src/views/preference/tabs/NginxSettings.vue b/app/src/views/preference/tabs/NginxSettings.vue index bf2ed3ac..30e6986e 100644 --- a/app/src/views/preference/tabs/NginxSettings.vue +++ b/app/src/views/preference/tabs/NginxSettings.vue @@ -254,7 +254,7 @@ async function openSSHSetup() { -