mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-09-02 22:56:13 +08:00
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:
@@ -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' }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -254,7 +254,7 @@ async function openSSHSetup() {
|
||||
</AButton>
|
||||
</template>
|
||||
</AAlert>
|
||||
<template v-else>
|
||||
<template v-if="isEditingControl">
|
||||
<ARadioGroup
|
||||
:value="selectedMode"
|
||||
@update:value="onModeChange"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user