Match standard firewall header and gate private device reads
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() }))
|
||||
vi.mock('vue-router', async importOriginal => ({ ...await importOriginal<typeof import('vue-router')>(), useRouter: () => ({ push: navigate }) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), vpnStatus: vi.fn() } }))
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import FirewallTunnelSettings from './FirewallTunnelSettings.vue'
|
||||
const mountPage = () => mount(FirewallTunnelSettings, { global: { stubs: { RouterLink: { template: '<a><slot /></a>' }, VpnDeviceSettings: { props: ['configured', 'managementVerified'], template: '<div data-testid="devices" :data-configured="configured" :data-verified="managementVerified" />' } } } })
|
||||
const mountPage = () => mount(FirewallTunnelSettings, { global: { stubs: { BackButton: { props: ['desktopMargin'], template: '<button data-testid="back" @click="$emit(\'click\')">Back</button>' }, RouterLink: { template: '<a><slot /></a>' }, VpnDeviceSettings: { props: ['configured', 'managementVerified'], template: '<div data-testid="devices" :data-configured="configured" :data-verified="managementVerified" />' } } } })
|
||||
const vpn = { connected: true, provider: 'tailscale', configured: true, configured_provider: 'nostrvpn', wg_ip: '10.44.0.1' }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
@@ -16,6 +19,15 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
describe('Firewall and tunnel settings', () => {
|
||||
it('uses the standard OpenWrt sibling header and shared back control to Network', async () => {
|
||||
const w = mountPage(); await flushPromises()
|
||||
expect(w.get('[data-testid="firewall-header"]').classes()).toEqual(expect.arrayContaining(['flex', 'items-center', 'gap-3', 'mb-6']))
|
||||
expect(w.get('h1').classes()).toEqual(expect.arrayContaining(['text-lg', 'font-semibold']))
|
||||
expect(w.findComponent(BackButton).props('desktopMargin')).toBe('mb-0')
|
||||
await w.get('[data-testid="back"]').trigger('click')
|
||||
expect(navigate).toHaveBeenCalledWith({ name: 'server' })
|
||||
w.unmount()
|
||||
})
|
||||
it('recognizes the existing device tunnel independently of router setup and never infers firewall protection from FIPS', async () => {
|
||||
const w = mountPage(); await flushPromises()
|
||||
expect(w.text()).toContain('Your device tunnel is already set up')
|
||||
@@ -35,7 +47,7 @@ describe('Firewall and tunnel settings', () => {
|
||||
const w = mountPage(); await flushPromises()
|
||||
vi.mocked(rpcClient.vpnStatus).mockRejectedValue(new Error('offline'))
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('offline'))
|
||||
await w.get('button').trigger('click'); await flushPromises()
|
||||
await w.findAll('button').find(b => b.text() === 'Refresh status')!.trigger('click'); await flushPromises()
|
||||
expect(w.text()).not.toContain('Your device tunnel is already set up')
|
||||
expect(w.text()).toContain('Could not check saved entries.')
|
||||
expect(w.text()).not.toContain('No entries are saved here.')
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import VpnDeviceSettings from './VpnDeviceSettings.vue'
|
||||
interface TunnelStatus { peer_management_verified?: boolean; connected?: boolean; configured?: boolean; configured_provider?: string; provider?: string; ip_address?: string | null; wg_ip?: string | null }
|
||||
interface FipsStatus { installed?: boolean; service_active?: boolean; authenticated_peer_count?: number }
|
||||
interface RouterStatus { configured?: boolean; router_type?: string; address?: string }
|
||||
interface Forward { id: string; service_name: string; internal_port: number; external_port: number; protocol: string }
|
||||
const navigation = useRouter()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const tunnel = ref<TunnelStatus | null>(null)
|
||||
@@ -46,12 +48,12 @@ onActivated(() => { if (!active) { active = true; void refresh() } })
|
||||
|
||||
<template>
|
||||
<main class="w-full min-w-0 pb-6 text-white" data-testid="firewall-settings">
|
||||
<div class="flex items-center gap-3 mb-6" data-testid="firewall-header">
|
||||
<BackButton desktop-margin="mb-0" @click="navigation.push({ name: 'server' })" />
|
||||
<h1 class="text-lg font-semibold text-white">Firewalls & tunnels</h1>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||
<div class="min-w-0">
|
||||
<RouterLink to="/dashboard/server" class="text-sm text-white/60 hover:text-white">← Network</RouterLink>
|
||||
<h1 class="text-2xl font-semibold text-white mt-2">Firewalls & tunnels</h1>
|
||||
<p class="text-sm text-white/60 mt-1">Choose how your devices and apps connect to this node.</p>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">Choose how your devices and apps connect to this node.</p>
|
||||
<button class="glass-button text-sm" :disabled="loading" @click="refresh">{{ loading ? 'Checking…' : 'Refresh status' }}</button>
|
||||
</div>
|
||||
<p v-if="error" role="alert" class="mb-4 rounded-lg border border-orange-400/30 bg-orange-500/10 p-4 text-sm text-orange-200">{{ error }}</p>
|
||||
@@ -74,7 +76,7 @@ onActivated(() => { if (!active) { active = true; void refresh() } })
|
||||
<div class="status-row"><dt>Detected service</dt><dd>{{ tunnel ? provider : 'Could not check' }}</dd></div>
|
||||
<div class="status-row"><dt>WireGuard device address</dt><dd>{{ tunnel?.wg_ip || (tunnel ? 'Not ready' : 'Could not check') }}</dd></div>
|
||||
</dl>
|
||||
<p v-if="tunnel?.wg_ip" class="mt-4 text-sm text-white/60">Your device tunnel is already set up. Add or manage devices below.</p>
|
||||
<p v-if="tunnel?.wg_ip" class="mt-4 text-sm text-white/60">Your device tunnel is already set up.</p>
|
||||
</section>
|
||||
</div>
|
||||
<VpnDeviceSettings :status-known="tunnel !== null" :configured="!!tunnel?.wg_ip" :management-verified="tunnel?.peer_management_verified === true" />
|
||||
|
||||
@@ -23,13 +23,38 @@ describe('Private device management', () => {
|
||||
expect(w.text()).toContain('Phone'); expect(w.html()).not.toContain('PRIVATE-LIST-SECRET')
|
||||
expect(rpcClient.call).toHaveBeenCalledTimes(1); w.unmount()
|
||||
})
|
||||
it('keeps mutations disabled for older backends while allowing explicit details', async () => {
|
||||
it('does not fetch legacy private lists or connection details without verified capability', async () => {
|
||||
const w = view(false); await flushPromises()
|
||||
expect(button(w, 'Add device').attributes('disabled')).toBeDefined()
|
||||
expect(w.get('[aria-label="Remove Phone"]').attributes('disabled')).toBeDefined()
|
||||
await button(w, 'Connection details').trigger('click'); await flushPromises()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'vpn.peer-config', params: { name: 'Phone' } }))
|
||||
expect(w.text()).toContain('Connect Phone'); w.unmount()
|
||||
expect(button(w, 'Refresh devices').attributes('disabled')).toBeDefined()
|
||||
expect(w.text()).toContain('Device details and changes are unavailable')
|
||||
expect(w.text()).not.toContain('Phone')
|
||||
expect(w.findAll('button').some(b => b.text() === 'Connection details')).toBe(false)
|
||||
await button(w, 'Refresh devices').trigger('click'); await w.get('form').trigger('submit'); await flushPromises()
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
await w.setProps({ managementVerified: true, statusKnown: false }); await flushPromises()
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
await w.setProps({ statusKnown: true }); await flushPromises()
|
||||
expect(rpcClient.call).toHaveBeenCalledTimes(1)
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'vpn.list-peers' }))
|
||||
w.unmount()
|
||||
})
|
||||
it('clears private details and rejects an in-flight reveal when capability is lost', async () => {
|
||||
let resolve!: (value: typeof connection) => void
|
||||
const w = view(); await flushPromises()
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'vpn.list-peers') return { peers: [peer] }
|
||||
return new Promise(r => { resolve = r })
|
||||
})
|
||||
await button(w, 'Connection details').trigger('click')
|
||||
await w.setProps({ managementVerified: false }); await flushPromises()
|
||||
resolve(connection); await flushPromises()
|
||||
expect(w.find('svg').exists()).toBe(false)
|
||||
expect(w.text()).not.toContain('Phone')
|
||||
vi.mocked(rpcClient.call).mockClear()
|
||||
await button(w, 'Refresh devices').trigger('click'); await w.get('form').trigger('submit'); await flushPromises()
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
w.unmount()
|
||||
})
|
||||
it('sanitizes explicit QR content and never renders the private config as text', async () => {
|
||||
const w = view(); await flushPromises(); await button(w, 'Connection details').trigger('click'); await flushPromises()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onActivated, onDeactivated, onUnmounted, ref, watch } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
@@ -19,6 +19,7 @@ const copied = ref(false)
|
||||
const needsRefresh = ref(false)
|
||||
const safeName = (value: string) => /^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/.test(value)
|
||||
const filename = (value: string) => value.toLowerCase().replace(/ /g, '-')
|
||||
const canReadDevices = computed(() => props.statusKnown && props.managementVerified)
|
||||
const canCreate = computed(() => props.statusKnown && props.configured && props.managementVerified && devices.value !== null && !needsRefresh.value && safeName(name.value.trim()) && !devices.value.some(d => filename(d.name) === filename(name.value.trim())))
|
||||
let active = true
|
||||
let request = 0
|
||||
@@ -26,12 +27,13 @@ let viewGeneration = 0
|
||||
const qr = computed(() => DOMPurify.sanitize(connection.value?.qr_svg ?? '', { USE_PROFILES: { svg: true } }))
|
||||
|
||||
async function refresh() {
|
||||
if (!active || !canReadDevices.value) return
|
||||
const generation = ++request
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await rpcClient.call<{ peers: Device[] }>({ method: 'vpn.list-peers', dedup: true, maxRetries: 1 })
|
||||
if (!active || generation !== request) return
|
||||
if (!canReadDevices.value || !active || generation !== request) return
|
||||
if (!Array.isArray(result?.peers) || result.peers.some(p => !p || typeof p.name !== 'string' || typeof p.ip !== 'string' || (p.management_state !== undefined && !['active', 'pending', 'revoking'].includes(p.management_state)))) throw new Error('Invalid device list')
|
||||
devices.value = result.peers.map(p => ({ name: p.name, ip: p.ip, management_state: p.management_state }))
|
||||
needsRefresh.value = false
|
||||
@@ -40,44 +42,45 @@ async function refresh() {
|
||||
} finally { if (active && generation === request) loading.value = false }
|
||||
}
|
||||
async function openConnection(deviceName?: string) {
|
||||
if (busy.value || devices.value === null || (!deviceName && !canCreate.value)) return
|
||||
if (!canReadDevices.value || busy.value || devices.value === null || (!deviceName && !canCreate.value)) return
|
||||
const view = viewGeneration
|
||||
const trimmed = deviceName ?? name.value.trim()
|
||||
if (!safeName(trimmed)) return
|
||||
busy.value = true; error.value = ''; notice.value = ''; connection.value = null; copied.value = false
|
||||
try {
|
||||
const result = await rpcClient.call<Connection>({ method: deviceName ? 'vpn.peer-config' : 'vpn.create-peer', params: { name: trimmed }, maxRetries: 0 })
|
||||
if (!active || view !== viewGeneration) return
|
||||
if (!canReadDevices.value || !active || view !== viewGeneration) return
|
||||
if (!result || typeof result.qr_svg !== 'string' || typeof result.config !== 'string' || typeof result.peer_ip !== 'string') throw new Error('Invalid connection details')
|
||||
selected.value = trimmed; connection.value = result; name.value = ''
|
||||
if (!deviceName) await refresh()
|
||||
} catch { if (active && view === viewGeneration) { needsRefresh.value = !deviceName; error.value = deviceName ? 'Could not load connection details. Try again.' : 'Could not confirm the new device. Refresh the list before trying again.' } }
|
||||
} catch { if (canReadDevices.value && active && view === viewGeneration) { needsRefresh.value = !deviceName; error.value = deviceName ? 'Could not load connection details. Try again.' : 'Could not confirm the new device. Refresh the list before trying again.' } }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function removeDevice() {
|
||||
if (busy.value || !props.managementVerified || needsRefresh.value || !safeName(removeName.value)) return
|
||||
if (busy.value || !canReadDevices.value || needsRefresh.value || !safeName(removeName.value)) return
|
||||
const view = viewGeneration
|
||||
const target = removeName.value
|
||||
busy.value = true; error.value = ''; notice.value = ''
|
||||
try {
|
||||
const result = await rpcClient.call<{ removed: boolean }>({ method: 'vpn.remove-peer', params: { name: target }, maxRetries: 0 })
|
||||
if (result?.removed !== true) throw new Error('Removal unconfirmed')
|
||||
if (!active || view !== viewGeneration) return
|
||||
if (!canReadDevices.value || !active || view !== viewGeneration) return
|
||||
if (selected.value === target) connection.value = null
|
||||
devices.value = devices.value?.filter(d => d.name !== target) ?? null
|
||||
removeName.value = ''; notice.value = `${target} was removed.`
|
||||
await refresh()
|
||||
} catch { if (active && view === viewGeneration) { needsRefresh.value = true; error.value = 'Could not confirm removal. Refresh the list to check before trying again.' } }
|
||||
} catch { if (canReadDevices.value && active && view === viewGeneration) { needsRefresh.value = true; error.value = 'Could not confirm removal. Refresh the list to check before trying again.' } }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function copyConnection() {
|
||||
if (!connection.value) return
|
||||
if (!canReadDevices.value || !connection.value) return
|
||||
const view = viewGeneration
|
||||
try { await navigator.clipboard.writeText(connection.value.config); if (active && view === viewGeneration) copied.value = true }
|
||||
catch { if (active && view === viewGeneration) error.value = 'Could not copy. Use the QR code instead.' }
|
||||
try { await navigator.clipboard.writeText(connection.value.config); if (canReadDevices.value && active && view === viewGeneration) copied.value = true }
|
||||
catch { if (canReadDevices.value && active && view === viewGeneration) error.value = 'Could not copy. Use the QR code instead.' }
|
||||
}
|
||||
onMounted(refresh)
|
||||
function leave() { active = false; request++; viewGeneration++; connection.value = null; selected.value = ''; name.value = ''; removeName.value = ''; copied.value = false }
|
||||
function clearDeviceState() { request++; viewGeneration++; devices.value = null; connection.value = null; selected.value = ''; name.value = ''; removeName.value = ''; copied.value = false; loading.value = false; error.value = ''; notice.value = '' }
|
||||
watch(canReadDevices, allowed => { clearDeviceState(); if (allowed && active) void refresh() }, { immediate: true })
|
||||
function leave() { active = false; clearDeviceState() }
|
||||
onDeactivated(leave)
|
||||
onUnmounted(leave)
|
||||
onActivated(() => { if (!active) { active = true; void refresh() } })
|
||||
@@ -87,19 +90,19 @@ onActivated(() => { if (!active) { active = true; void refresh() } })
|
||||
<section class="glass-card p-5 min-w-0" aria-labelledby="vpn-devices-title">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h2 id="vpn-devices-title" class="text-lg font-medium text-white">Your devices</h2><p class="mt-1 text-sm text-white/60">Give a phone or computer a private way to reach this node using the WireGuard app.</p></div>
|
||||
<button class="glass-button text-sm" :disabled="loading || busy" @click="refresh">{{ loading ? 'Checking…' : 'Refresh devices' }}</button>
|
||||
<button class="glass-button text-sm" :disabled="loading || busy || !canReadDevices" @click="refresh">{{ loading ? 'Checking…' : 'Refresh devices' }}</button>
|
||||
</div>
|
||||
<p v-if="statusKnown && !configured" class="mt-4 text-sm text-white/60">The device tunnel is not ready. Check its status above before adding a device.</p>
|
||||
<p v-if="statusKnown && !managementVerified" class="mt-4 text-sm text-white/60">Device changes are unavailable on this node right now. You can still view saved connection details.</p>
|
||||
<p v-if="statusKnown && !managementVerified" class="mt-4 text-sm text-white/60">Device details and changes are unavailable right now. Refresh the status above to check again.</p>
|
||||
<p v-if="error" role="alert" class="mt-4 text-sm text-orange-200">{{ error }}</p>
|
||||
<p v-if="notice" role="status" class="mt-4 text-sm text-green-300">{{ notice }}</p>
|
||||
<ul v-if="devices?.length" class="mt-4 space-y-2">
|
||||
<ul v-if="canReadDevices && devices?.length" class="mt-4 space-y-2">
|
||||
<li v-for="device in devices" :key="device.name" class="flex flex-wrap items-center justify-between gap-3 rounded-lg bg-white/5 p-3">
|
||||
<div class="min-w-0"><p class="text-sm text-white break-words">{{ device.name }}</p><p class="text-xs text-white/50 break-all">{{ device.ip }}</p><p v-if="device.management_state === 'pending'" class="mt-1 text-xs text-orange-200">Setup was not confirmed. Remove this entry before adding the device again.</p><p v-if="device.management_state === 'revoking'" class="mt-1 text-xs text-orange-200">Removal needs another check. Refresh, then retry removal.</p></div>
|
||||
<div class="flex flex-wrap gap-2"><button class="glass-button text-xs" :disabled="busy || !safeName(device.name) || (device.management_state !== undefined && device.management_state !== 'active')" @click="openConnection(device.name)">Connection details</button><button class="glass-button text-xs" :disabled="busy || !managementVerified || !safeName(device.name)" :aria-label="`Remove ${device.name}`" @click="removeName = device.name">Remove</button></div>
|
||||
<div class="flex flex-wrap gap-2"><button class="glass-button text-xs" :disabled="busy || !canReadDevices || !safeName(device.name) || (device.management_state !== undefined && device.management_state !== 'active')" @click="openConnection(device.name)">Connection details</button><button class="glass-button text-xs" :disabled="busy || !managementVerified || !safeName(device.name)" :aria-label="`Remove ${device.name}`" @click="removeName = device.name">Remove</button></div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="devices" class="mt-4 text-sm text-white/50">No devices have been added.</p>
|
||||
<p v-else-if="canReadDevices && devices" class="mt-4 text-sm text-white/50">No devices have been added.</p>
|
||||
<div v-if="removeName" class="mt-4 rounded-lg border border-orange-400/30 p-4">
|
||||
<p class="text-sm text-white">Remove {{ removeName }}? It will lose this private connection.</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2"><button class="glass-button text-sm" :disabled="busy || needsRefresh" @click="removeDevice">{{ busy ? 'Removing…' : 'Remove device' }}</button><button class="glass-button text-sm" :disabled="busy" @click="removeName = ''">Keep device</button></div>
|
||||
@@ -108,7 +111,7 @@ onActivated(() => { if (!active) { active = true; void refresh() } })
|
||||
<label class="min-w-0 flex-1 text-sm text-white/70" for="vpn-device-name">Device name<input id="vpn-device-name" v-model="name" :disabled="busy || !configured || !managementVerified || devices === null" maxlength="64" placeholder="For example, my phone" pattern="[A-Za-z0-9][A-Za-z0-9 _\-]{0,63}" title="Use letters, numbers, spaces, dashes or underscores." class="mt-2 block w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2.5 text-white" /></label>
|
||||
<button type="submit" class="glass-button text-sm" :disabled="busy || !canCreate">{{ busy ? 'Please wait…' : 'Add device' }}</button>
|
||||
</form>
|
||||
<div v-if="connection" class="mt-5 rounded-lg border border-white/15 p-4">
|
||||
<div v-if="canReadDevices && connection" class="mt-5 rounded-lg border border-white/15 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><h3 class="text-base text-white break-words">Connect {{ selected }}</h3><button class="glass-button text-sm" @click="connection = null">Hide details</button></div>
|
||||
<p class="mt-2 text-sm text-white/60">Open WireGuard on that device and scan this code. Keep these connection details private.</p>
|
||||
<div class="connection-qr mt-4 max-w-full w-fit rounded-lg bg-white p-3" v-html="qr" />
|
||||
|
||||
Reference in New Issue
Block a user