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 <noreply@anthropic.com>
This commit is contained in:
0xJacky
2026-09-02 18:25:29 +08:00
parent 69cfff126b
commit 9fa6be8195
7 changed files with 52 additions and 43 deletions

View File

@@ -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' }
}

View File

@@ -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

View File

@@ -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
}
}
</script>

View File

@@ -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
}
}

View File

@@ -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))

View File

@@ -254,7 +254,7 @@ async function openSSHSetup() {
</AButton>
</template>
</AAlert>
<template v-else>
<template v-if="isEditingControl">
<ARadioGroup
:value="selectedMode"
@update:value="onModeChange"

View File

@@ -27,7 +27,7 @@ function controlModeItem(page: Page) {
return page.locator('.ant-form-item').filter({ has: page.getByText('Nginx Control Mode', { exact: true }) }).first()
}
test('Nginx control mode exposes Host via SSH and switches the summary tag without saving', async ({ page }) => {
test('Nginx control mode stays read-only until Edit passes the 2FA guard', async ({ page }) => {
const requests = trackApiRequests(page)
await gotoRoute(page, '/preference')
@@ -36,32 +36,26 @@ test('Nginx control mode exposes Host via SSH and switches the summary tag witho
const item = controlModeItem(page)
await expect(item).toBeVisible()
await expect(item.locator('.ant-tag').filter({ hasText: /^Local$/ })).toBeVisible()
const editButton = item.getByRole('button', { name: /Edit/ })
await expect(editButton).toBeVisible()
const localRadio = item.getByRole('radio', { name: 'Local / Bundled' })
const containerRadio = item.getByRole('radio', { name: 'External Container' })
const sshRadio = item.getByRole('radio', { name: 'Host via SSH' })
await expect(localRadio).toBeVisible()
await expect(containerRadio).toBeVisible()
await expect(sshRadio).toBeVisible()
await expect(localRadio).toBeChecked()
await sshRadio.check()
await expect(sshRadio).toBeChecked()
await expect(item.locator('.ant-tag').filter({ hasText: 'Host via SSH' })).toBeVisible()
await expect(item.locator('.ant-tag').filter({ hasText: /^Local$/ })).toHaveCount(0)
await localRadio.check()
await expect(localRadio).toBeChecked()
await expect(item.locator('.ant-tag').filter({ hasText: /^Local$/ })).toBeVisible()
// The mode radios only mount in edit mode, so the read-only view cannot
// mutate the settings store without going through the guarded Edit flow.
await expect(item.locator('.ant-radio-group')).toHaveCount(0)
await expect(item.getByRole('radio')).toHaveCount(0)
// Editing the control mode is protected by a secure session. With 2FA
// disabled on the demo account the UI guides to 2FA settings instead.
await item.getByRole('button', { name: /Edit/ }).click()
await editButton.click()
const guard = page.locator('.ant-modal').filter({ hasText: 'Two-factor authentication required' })
await expect(guard).toBeVisible()
await guard.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(guard).toBeHidden()
// The guard cancelled the edit, so the view stays read-only.
await expect(item.locator('.ant-tag').filter({ hasText: /^Local$/ })).toBeVisible()
await expect(item.getByRole('radio')).toHaveCount(0)
expectReadOnlyTraffic(requests)
})