Match standard firewall header and gate private device reads

This commit is contained in:
archipelago
2026-10-08 07:06:31 -04:00
parent 8a70232f18
commit 83632c2439
6 changed files with 115 additions and 37 deletions
+38 -4
View File
@@ -11,8 +11,11 @@ actual device tunnels, mesh connections, router settings and merely saved port
entries. A running mesh no longer produces a false Protected firewall label.
The new device section uses existing WireGuard APIs for add, reveal/copy and remove.
Mutation controls require the new backend's explicit peer_management_verified flag;
older or unavailable backends cannot enable them. Private configuration is fetched
All saved-device reads and changes require known status and the new backend's
explicit peer_management_verified flag. Older or unavailable backends receive no
list/config RPCs: legacy list replies contain private configurations, and legacy
reveal can also write an endpoint. Capability loss clears device/private state and
rejects late replies; batched prop changes cannot briefly enable a private read. Private configuration is fetched
only by an explicit reveal action, QR SVG is sanitized, and cached navigation clears
private details and rejects late replies. Failed creation/removal requires a fresh
list before retrying. Pending/revoking operations get recovery guidance.
@@ -27,8 +30,8 @@ local JSON. They are not exposed as controls that claim to open or close real po
The existing OpenWrt management screen remains linked. Actual node firewall rule
inspection/editing and the complete app exposure workflow are still separate work.
Validation: current focused tests pass 25/25 (22 component cases plus three
background resolver cases), and the full app typecheck passes. Typecheck exposed
Prior checkpoint at b2fee5c5: focused tests passed 25/25 (22 component cases plus three
background resolver cases), and the full app typecheck passed. Typecheck exposed
an unsupported replaceAll call and a test-wrapper assertion; both were corrected
and the changed device test file was rerun successfully (16/16).
@@ -53,3 +56,34 @@ changes are isolated separately; actual ephemeral-kernel helper qualification
passed, but Rust compilation/tests and paired helper deployment remain required
before the new mutation controls can be enabled on a node. Task 18 remains open
for broader host firewall management, persistence and rollback.
## Header/back and legacy privacy follow-up
The header now matches the OpenWrt Gateway sibling: shared BackButton with
`desktop-margin="mb-0"`, an inline `items-center gap-3 mb-6` row and `text-lg`
semibold title. The shared component provides the standard floating mobile back
control; navigation always returns to the named Network/server route.
Current affected checks passed: seven firewall/header cases and seventeen device
cases. The initial capability regression found that a synchronous watcher could
briefly read mixed old/new props while status became unknown; the batched watcher
and response/render guards fixed this, and the full device file passed on rerun.
The unchanged three background cases retain their earlier pass.
The updated browser fixture passed at all four widths (320/390/768/1440), checking
header font/spacing, desktop alignment, floating mobile BackButton, return to
Network, unchanged background, full width, status alignment, QR containment and
zero legacy private RPCs while unverified. These are source-component fixtures
with synthetic RPCs, not live acceptance or a full production Dashboard test.
The app-wide typecheck has not been repeated after this final focused follow-up;
it and production build remain part of integration qualification.
Final follow-up receipts:
- `/tmp/archy-firewall-header-privacy-tests-20261008.log` — seven header/firewall
passes and the initial privacy transition failure (retained).
- `/tmp/archy-firewall-privacy-batched-tests-20261008.log` — all 17 device cases
passed after the transition correction.
- `/tmp/archy-firewall-header-privacy-browser-20261008.log` — four viewport passes.
All fixture browser/server processes stopped after verification. No node, helper,
firewall, wallet or saved-device configuration was changed by these UI checks.
+2
View File
@@ -522,6 +522,8 @@ Operator refinements on 8 October, retained as acceptance requirements:
present the mesh service as proof that the node firewall is protected.
- Fill the main content width and inherit the Network tab background; opening
Firewalls & tunnels must not switch to a different page background.
- Match the established header and shared back button, including desktop title
alignment and mobile back placement. Use the OpenWrt Gateway sibling pattern.
- Use the standard black primary button at the bottom of the Local Network
container, matching other container actions.
- Use plain language throughout and provide real, supported configuration
@@ -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 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 &amp; tunnels</h1>
<p class="text-sm text-white/60 mt-1">Choose how your devices and apps connect to this node.</p>
<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 &amp; tunnels</h1>
</div>
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
<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()
+22 -19
View File
@@ -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" />