fix(ui): make firewall settings consistent and gate device management

This commit is contained in:
archipelago
2026-10-08 07:06:30 -04:00
parent b29d58213f
commit ccfa91aa57
10 changed files with 454 additions and 99 deletions
+35
View File
@@ -0,0 +1,35 @@
# Firewall and tunnel follow-up — 8 October 2026
Status: source prepared in isolation; not deployed or accepted on a live node.
Task 18 remains open for full firewall rule management, persistence and rollback.
The Network entry uses a right-aligned status and the standard black glass-button.
The settings page fills the dashboard content width. The dashboard background
resolver now inherits the Network image for its detail routes instead of selecting
the Web5 image; the explicit federation background is preserved. Copy distinguishes
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
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.
Read-only operator-node checks confirmed an existing device tunnel and active mesh.
The separate router store had no connection or forwarding entries. This does not
contradict the separately retained manual firewall/mining repair. No live VPN,
firewall, router, app or payment change was performed during these checks.
Backend audit found that existing router add/remove-forward methods only write
local JSON. They are not exposed as controls that claim to open or close real ports.
The existing OpenWrt management screen remains linked. Actual node firewall rule
inspection/editing and the complete app exposure workflow are still separate work.
Validation checkpoint: 21 focused UI tests passed before the final background
resolver and pending-operation display additions. Those additions, the complete
app typecheck, responsive rendered-route/background checks and production build
remain queued behind IndeeHub recovery qualification. No live acceptance is claimed.
The backend peer-safety changes are isolated separately and also require tests and
paired helper deployment before these new mutation controls can be enabled.
+14
View File
@@ -516,6 +516,20 @@ remain open. See `docs/https-app-gate-followup-20261006.md`.
## 18. Firewall and tunnel UI/settings ## 18. Firewall and tunnel UI/settings
Operator refinements on 8 October, retained as acceptance requirements:
- Keep status values aligned to the right edge with the other rows. Do not
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.
- 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
management. Recognize existing connections; a missing saved router entry is
not proof that the node's tunnels or manually repaired firewall are absent.
- Verify changes against the actual saved configuration. Controls must not
claim to apply firewall rules when a backend only writes a local entry.
Status: in progress. The central read-only Firewall & tunnels screen, local Status: in progress. The central read-only Firewall & tunnels screen, local
network entry points, independent status/error handling and responsive route network entry points, independent status/error handling and responsive route
tests are implemented (ef6c10f0, 3f96be2c). Scoped configuration, persistence, tests are implemented (ef6c10f0, 3f96be2c). Scoped configuration, persistence,
+3 -14
View File
@@ -137,7 +137,7 @@ import DashboardRouterView from '@/views/dashboard/DashboardRouterView.vue'
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue' import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue' import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue' import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
import { isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions' import { isDetailRoute, backgroundForRoute } from '@/views/dashboard/useRouteTransitions'
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher' import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
import '@/views/dashboard/dashboard-styles.css' import '@/views/dashboard/dashboard-styles.css'
@@ -163,20 +163,9 @@ const showAltBackground = ref(false)
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/') const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
const isGlitching = ref(false) const isGlitching = ref(false)
const backgroundImage = computed(() => { const backgroundImage = computed(() => backgroundForRoute(route.path))
const mapped = ROUTE_BACKGROUNDS[route.path]
if (mapped) return mapped
// Detail/sub pages inherit their parent tab's background so they stay
// visually "inside" the section instead of snapping to the home backdrop.
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.webp'
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.webp'
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
return 'bg-home.webp'
})
// bg-web5.jpg (web5 + server sections) is bright — the scrim overlay deepens // bg-web5.jpg (Web5 and its explicitly mapped detail screens) is bright — the scrim overlay deepens
// while it's showing so light text keeps its contrast. // while it's showing so light text keeps its contrast.
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg') const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
+4 -5
View File
@@ -101,10 +101,9 @@
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg> <svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
<span class="text-white/80 text-sm">Firewall Active</span> <span class="text-white/80 text-sm">Firewall</span>
</div> </div>
<span class="text-green-400 text-sm font-medium">Protected</span> <span class="ml-auto text-right text-white/60 text-sm">Check status</span>
<svg class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</router-link> </router-link>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg"> <div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -166,9 +165,9 @@
</div> </div>
<router-link <router-link
to="/dashboard/server/firewall-tunnels" to="/dashboard/server/firewall-tunnels"
class="mt-4 w-full flex items-center justify-center gap-2 p-3 rounded-lg bg-white/10 hover:bg-white/15 transition-colors text-sm text-white/90" class="glass-button mt-4 w-full gap-2 rounded-lg text-sm font-medium"
> >
Firewall &amp; tunnels Firewalls &amp; tunnels
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</router-link> </router-link>
</div> </div>
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { backgroundForRoute } from '../useRouteTransitions'
describe('dashboard route backgrounds', () => {
it('keeps the Network tab backdrop while opening firewall settings and returning', () => {
const routes = ['/dashboard/server', '/dashboard/server/firewall-tunnels', '/dashboard/server']
expect(routes.map(backgroundForRoute)).toEqual(['bg-network.jpg', 'bg-network.jpg', 'bg-network.jpg'])
})
it('inherits other Network detail screens without overriding the explicit Web5 federation backdrop', () => {
expect(backgroundForRoute('/dashboard/server/openwrt')).toBe('bg-network.jpg')
expect(backgroundForRoute('/dashboard/server/federation')).toBe('bg-web5.jpg')
})
it('retains other parent sections and app-detail behavior', () => {
expect(backgroundForRoute('/dashboard/cloud/folder')).toBe('bg-cloud.webp')
expect(backgroundForRoute('/dashboard/settings/account')).toBe('bg-settings.webp')
expect(backgroundForRoute('/dashboard/web5/credentials')).toBe('bg-web5.jpg')
expect(backgroundForRoute('/dashboard/apps/bitcoin')).toBe('bg-intro.jpg')
expect(backgroundForRoute('/dashboard')).toBe('bg-home.webp')
})
})
@@ -38,6 +38,16 @@ export const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard/chat': 'bg-aiui.jpg', '/dashboard/chat': 'bg-aiui.jpg',
} }
/** Detail screens share the parent tab image unless explicitly mapped above. */
export function backgroundForRoute(path: string): string {
const mapped = ROUTE_BACKGROUNDS[path]
if (mapped) return mapped
for (const parent of ['/dashboard/cloud', '/dashboard/web5', '/dashboard/server', '/dashboard/settings']) {
if (path.startsWith(`${parent}/`)) return ROUTE_BACKGROUNDS[parent]!
}
return isDetailRoute(path) ? 'bg-intro.jpg' : 'bg-home.webp'
}
export function isDetailRoute(path: string): boolean { export function isDetailRoute(path: string): boolean {
return (path.includes('/apps/') && !path.endsWith('/apps')) || return (path.includes('/apps/') && !path.endsWith('/apps')) ||
(path.includes('/marketplace/') && !path.endsWith('/marketplace')) (path.includes('/marketplace/') && !path.endsWith('/marketplace'))
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), vpnStatus: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
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 vpn = { connected: true, provider: 'tailscale', configured: true, configured_provider: 'nostrvpn', wg_ip: '10.44.0.1' }
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(rpcClient.vpnStatus).mockResolvedValue(vpn as any)
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'fips.status') return { service_active: true, authenticated_peer_count: 5 }
if (method === 'router.info') return { configured: false, router_type: 'unknown' }
if (method === 'router.list-forwards') return { forwards: [] }
throw new Error(method)
})
})
describe('Firewall and tunnel settings', () => {
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')
expect(w.text()).toContain('No router connection is saved in this section')
expect(w.text()).toContain('Not checked')
expect(w.text()).not.toContain('Protected')
expect(w.get('[data-testid="devices"]').attributes('data-configured')).toBe('true')
expect(w.get('[data-testid="devices"]').attributes('data-verified')).toBe('false')
w.unmount()
})
it('only enables device mutations when the backend explicitly verifies management support', async () => {
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ ...vpn, peer_management_verified: true } as any)
const w = mountPage(); await flushPromises()
expect(w.get('[data-testid="devices"]').attributes('data-verified')).toBe('true'); w.unmount()
})
it('clears stale success after failed checks and does not turn missing forward results into an empty list', async () => {
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()
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.')
expect(w.get('[data-testid="devices"]').attributes('data-verified')).toBe('false')
expect(w.get('[role="alert"]').text()).toContain('could not be checked'); w.unmount()
})
it('renders named port entries without claiming they were applied or exposing raw JSON', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'router.list-forwards' ? { forwards: [{ id: 'a', service_name: 'My app', internal_port: 80, external_port: 8080, protocol: 'TCP', unexpected_secret: 'never-render' }] } : {})
const w = mountPage(); await flushPromises()
expect(w.text()).toContain('My app'); expect(w.text()).toContain('Outside port 8080 → app port 80 (TCP)')
expect(w.text()).toContain('do not confirm that your router has opened them')
expect(w.text()).not.toContain('never-render'); w.unmount()
})
it('treats malformed forwards as unavailable, not zero entries', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ forwards: [null] })
const w = mountPage(); await flushPromises()
expect(w.text()).toContain('Could not check saved entries.'); w.unmount()
})
it('does not enable changes from a reply arriving after navigation', async () => {
let resolve!: (x: any) => void
vi.mocked(rpcClient.vpnStatus).mockImplementation(() => new Promise(r => { resolve = r }))
const w = mountPage(); w.unmount(); resolve({ ...vpn, peer_management_verified: true }); await flushPromises()
expect(vi.mocked(rpcClient.call).mock.calls.every(([arg]) => !arg.method.includes('create') && !arg.method.includes('remove'))).toBe(true)
})
})
@@ -1,104 +1,103 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import VpnDeviceSettings from './VpnDeviceSettings.vue'
interface TunnelStatus { interface TunnelStatus { peer_management_verified?: boolean; connected?: boolean; configured?: boolean; configured_provider?: string; provider?: string; ip_address?: string | null; wg_ip?: string | null }
connected?: boolean interface FipsStatus { installed?: boolean; service_active?: boolean; authenticated_peer_count?: number }
provider?: string interface RouterStatus { configured?: boolean; router_type?: string; address?: string }
ip_address?: string interface Forward { id: string; service_name: string; internal_port: number; external_port: number; protocol: string }
wg_ip?: string | null
}
interface FipsStatus {
installed?: boolean
service_active?: boolean
anchor_connected?: boolean
authenticated_peer_count?: number
}
interface Forward { [key: string]: unknown }
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const tunnel = ref<TunnelStatus | null>(null) const tunnel = ref<TunnelStatus | null>(null)
const fips = ref<FipsStatus | null>(null) const fips = ref<FipsStatus | null>(null)
const forwards = ref<Forward[]>([]) const router = ref<RouterStatus | null>(null)
const forwards = ref<Forward[] | null>(null)
const tunnelLabel = computed(() => { let active = true
if (!tunnel.value) return 'Unknown' let generation = 0
return tunnel.value.connected ? 'Connected' : 'Not connected' const tunnelLabel = computed(() => tunnel.value?.connected === true ? 'Connected' : tunnel.value?.connected === false ? 'Not connected' : 'Could not check')
}) const meshLabel = computed(() => fips.value?.service_active === true ? 'Running' : fips.value?.service_active === false ? 'Stopped' : 'Could not check')
const firewallLabel = computed(() => { const serviceNames: Record<string, string> = { tailscale: 'Tailscale', wireguard: 'WireGuard', 'nostr-vpn': 'Nostr VPN', nostrvpn: 'Nostr VPN' }
if (!fips.value) return 'Unknown' const provider = computed(() => serviceNames[tunnel.value?.provider ?? ''] ?? 'Private connection')
if (fips.value.service_active === false) return 'Inactive'
return 'Protected'
})
async function refresh() { async function refresh() {
loading.value = true const current = ++generation
error.value = '' loading.value = true; error.value = ''
const results = await Promise.allSettled([ const results = await Promise.allSettled([
rpcClient.vpnStatus(), rpcClient.vpnStatus(),
rpcClient.call<FipsStatus>({ method: 'fips.status', dedup: true, maxRetries: 1 }), rpcClient.call<FipsStatus>({ method: 'fips.status', dedup: true, maxRetries: 1 }),
rpcClient.call<{ forwards?: Forward[] }>({ method: 'router.list-forwards', dedup: true, maxRetries: 1 }), rpcClient.call<{ forwards: Forward[] }>({ method: 'router.list-forwards', dedup: true, maxRetries: 1 }),
rpcClient.call<RouterStatus>({ method: 'router.info', dedup: true, maxRetries: 1 }),
]) ])
const failures = results.filter((result) => result.status === 'rejected') if (!active || current !== generation) return
if (results[0]?.status === 'fulfilled') tunnel.value = results[0].value tunnel.value = results[0].status === 'fulfilled' ? results[0].value : null
if (results[1]?.status === 'fulfilled') fips.value = results[1].value fips.value = results[1].status === 'fulfilled' ? results[1].value : null
if (results[2]?.status === 'fulfilled') forwards.value = results[2].value.forwards ?? [] const rows = results[2].status === 'fulfilled' ? results[2].value?.forwards : null
if (failures.length === results.length) error.value = 'Could not load network protection status' forwards.value = Array.isArray(rows) && rows.every(r => r && typeof r.id === 'string' && typeof r.service_name === 'string' && Number.isInteger(r.internal_port) && Number.isInteger(r.external_port) && typeof r.protocol === 'string') ? rows : null
else if (failures.length) error.value = 'Some checks are unavailable; verify each route before changing it' router.value = results[3].status === 'fulfilled' ? results[3].value : null
if (results.some(r => r.status === 'rejected') || forwards.value === null) error.value = 'Some information could not be checked. Try refreshing.'
loading.value = false loading.value = false
} }
onMounted(refresh) onMounted(refresh)
function leave() { active = false; generation++; tunnel.value = null }
onDeactivated(leave)
onUnmounted(leave)
onActivated(() => { if (!active) { active = true; void refresh() } })
</script> </script>
<template> <template>
<main class="max-w-5xl mx-auto px-4 sm:px-6 py-6"> <main class="w-full min-w-0 pb-6 text-white" data-testid="firewall-settings">
<div class="flex items-start justify-between gap-4 mb-6"> <div class="flex flex-wrap items-start justify-between gap-4 mb-6">
<div> <div class="min-w-0">
<RouterLink to="/dashboard/server" class="text-xs text-white/50 hover:text-white">← Network</RouterLink> <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">Firewall &amp; tunnels</h1> <h1 class="text-2xl font-semibold text-white mt-2">Firewalls &amp; tunnels</h1>
<p class="text-sm text-white/60 mt-1">Review reachability before exposing a service. Transport access and app authentication are separate controls.</p> <p class="text-sm text-white/60 mt-1">Choose how your devices and apps connect to this node.</p>
</div> </div>
<button class="glass-button px-3 py-2 text-sm shrink-0" :disabled="loading" @click="refresh"> <button class="glass-button text-sm" :disabled="loading" @click="refresh">{{ loading ? 'Checking…' : 'Refresh status' }}</button>
<span v-if="loading" class="inline-flex items-center gap-2"><span class="h-3.5 w-3.5 rounded-full border-2 border-white/30 border-t-white animate-spin" /> Checking…</span>
<span v-else>Refresh checks</span>
</button>
</div> </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>
<div v-if="error" class="mb-4 rounded-lg border border-orange-400/30 bg-orange-500/10 px-4 py-3 text-sm text-orange-200">{{ error }}</div> <div class="grid gap-4 xl:grid-cols-2 mb-6">
<section class="glass-card p-5 min-w-0" aria-labelledby="firewall-title">
<section class="grid gap-4 md:grid-cols-2 mb-6"> <h2 id="firewall-title" class="text-lg font-medium text-white">Firewall</h2>
<div class="glass-card p-5"> <p class="text-sm text-white/60 mt-1">A firewall controls which connections can reach your node.</p>
<h2 class="text-lg font-medium text-white">Firewall</h2> <dl class="mt-4 space-y-3 text-sm">
<p class="text-sm text-white/60 mt-1">Node protection status and mesh reachability.</p> <div class="status-row"><dt>Node firewall</dt><dd>Not checked</dd></div>
<div class="mt-4 flex items-center justify-between"><span class="text-white/70">Node firewall</span><span :class="firewallLabel === 'Protected' ? 'text-green-300' : 'text-orange-300'">{{ firewallLabel }}</span></div> <div class="status-row"><dt>Private mesh</dt><dd>{{ meshLabel }}</dd></div>
<div class="mt-2 flex items-center justify-between"><span class="text-white/70">Authenticated mesh peers</span><span class="text-white">{{ fips?.authenticated_peer_count ?? 'Unknown' }}</span></div> <div class="status-row"><dt>Verified mesh connections</dt><dd>{{ fips?.authenticated_peer_count ?? 'Could not check' }}</dd></div>
</div> </dl>
<div class="glass-card p-5"> <p class="mt-4 text-sm text-white/50">This screen cannot yet check or change the node's firewall rules. A running mesh does not confirm firewall protection.</p>
<h2 class="text-lg font-medium text-white">Tunnel</h2> </section>
<p class="text-sm text-white/60 mt-1">Connection and handshake status.</p> <section class="glass-card p-5 min-w-0" aria-labelledby="tunnel-title">
<div class="mt-4 flex items-center justify-between"><span class="text-white/70">Tunnel</span><span :class="tunnel?.connected ? 'text-green-300' : 'text-orange-300'">{{ tunnelLabel }}</span></div> <h2 id="tunnel-title" class="text-lg font-medium text-white">Private connections</h2>
<div class="mt-2 flex items-center justify-between"><span class="text-white/70">Address</span><span class="text-white font-mono text-xs">{{ tunnel?.wg_ip || tunnel?.ip_address || 'Unknown' }}</span></div> <p class="text-sm text-white/60 mt-1">Tunnels let your devices reach this node through a private connection.</p>
</div> <dl class="mt-4 space-y-3 text-sm">
<div class="status-row"><dt>Connection</dt><dd :class="tunnel?.connected === true ? 'text-green-300' : ''">{{ tunnelLabel }}</dd></div>
<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>
</section>
</div>
<VpnDeviceSettings :status-known="tunnel !== null" :configured="!!tunnel?.wg_ip" :management-verified="tunnel?.peer_management_verified === true" />
<section class="glass-card p-5 mt-6 min-w-0" aria-labelledby="router-title">
<h2 id="router-title" class="text-lg font-medium text-white">Your router</h2>
<p class="text-sm text-white/60 mt-1">Manage a supported OpenWrt router, including its Wi-Fi and internet connection.</p>
<dl class="mt-4 text-sm"><div class="status-row"><dt>Router connection saved here</dt><dd>{{ router?.configured === true ? 'Saved' : router?.configured === false ? 'Not saved here' : 'Could not check' }}</dd></div></dl>
<p v-if="router?.configured === false" class="mt-3 text-sm text-white/50">No router connection is saved in this section. Your router and tunnels may already be configured elsewhere.</p>
<RouterLink to="/dashboard/server/openwrt" class="glass-button mt-4 text-sm">Open router settings</RouterLink>
</section> </section>
<section class="glass-card p-5 mt-6 min-w-0" aria-labelledby="public-title">
<section class="glass-card p-5 mb-6"> <h2 id="public-title" class="text-lg font-medium text-white">Saved public access entries</h2>
<div class="flex items-start justify-between gap-3"><div><h2 class="text-lg font-medium text-white">Public access checks</h2><p class="text-sm text-white/60 mt-1">Existing router forwards are shown for review. No route is changed from this screen yet.</p></div><span class="text-sm text-white/60">{{ forwards.length }} forward{{ forwards.length === 1 ? '' : 's' }}</span></div> <p class="text-sm text-white/60 mt-1">These saved entries describe ports for apps. They do not confirm that your router has opened them.</p>
<div class="mt-4 grid gap-2 sm:grid-cols-2"> <ul v-if="forwards?.length" class="mt-4 grid gap-3 sm:grid-cols-2">
<div v-for="(forward, index) in forwards" :key="index" class="rounded-lg bg-white/5 p-3 text-xs text-white/70 font-mono break-all">{{ JSON.stringify(forward) }}</div> <li v-for="forward in forwards" :key="forward.id" class="rounded-lg bg-white/5 p-3 min-w-0"><h3 class="text-sm text-white break-words">{{ forward.service_name }}</h3><p class="mt-1 text-sm text-white/60">Outside port {{ forward.external_port }} → app port {{ forward.internal_port }} ({{ forward.protocol }})</p></li>
<div v-if="!forwards.length" class="text-sm text-white/45">No router forwards reported.</div> </ul>
</div> <p v-else class="mt-4 text-sm text-white/50">{{ forwards ? 'No entries are saved here.' : 'Could not check saved entries.' }}</p>
<p class="text-xs text-white/45 mt-4">Public forwarding, node firewall, service listener and external reachability are independent checks. A local pass does not claim public access.</p> <p class="mt-4 text-sm text-white/50">Use your router's settings to change public access. Opening an app to the internet may also require that app's own access settings.</p>
</section>
<section class="glass-card p-5">
<h2 class="text-lg font-medium text-white">App exposure</h2>
<p class="text-sm text-white/60 mt-1">App manifests and owned exposure rules will be added here after validation and rollback support are qualified.</p>
<div class="mt-4 rounded-lg border border-white/10 bg-white/5 p-3 text-sm text-white/60">No changes are available yet. Existing app access remains untouched.</div>
</section> </section>
</main> </main>
</template> </template>
<style scoped>
.status-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 1rem; }
.status-row dt { color: rgb(255 255 255 / 0.65); }
.status-row dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h, KeepAlive, nextTick, ref } from 'vue'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
import { rpcClient } from '@/api/rpc-client'
import VpnDeviceSettings from './VpnDeviceSettings.vue'
const peer = { name: 'Phone', ip: '10.44.0.2/32', config: 'PRIVATE-LIST-SECRET' }
const connection = { qr_svg: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect width="10" height="10" /></svg>', config: 'PRIVATE-EXPLICIT-CONFIG', peer_ip: '10.44.0.2/32' }
function view(verified = true) { return mount(VpnDeviceSettings, { props: { configured: true, managementVerified: verified, statusKnown: true } }) }
function button(w: ReturnType<typeof view>, label: string) { const b = w.findAll('button').find(b => b.text() === label); expect(b).toBeDefined(); return b! }
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'vpn.list-peers') return { peers: [peer] }
if (method === 'vpn.peer-config' || method === 'vpn.create-peer') return connection
if (method === 'vpn.remove-peer') return { removed: true }
throw new Error(method)
})
})
describe('Private device management', () => {
it('loads saved device names without rendering private list data or making changes', async () => {
const w = view(); await flushPromises()
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 () => {
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()
})
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()
expect(w.find('svg rect').exists()).toBe(true); expect(w.find('script').exists()).toBe(false)
expect(w.html()).not.toContain('PRIVATE-EXPLICIT-CONFIG')
await button(w, 'Hide details').trigger('click'); expect(w.find('svg').exists()).toBe(false); w.unmount()
})
it.each(['../escape', 'a/b', '.', 'bad\\path', 'Phone', 'phone'])('rejects unsafe or duplicate device name %s without mutation', async name => {
const w = view(); await flushPromises(); await w.get('input').setValue(name)
await w.get('form').trigger('submit'); await flushPromises()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([a]) => a.method === 'vpn.create-peer')).toHaveLength(0); w.unmount()
})
it('adds only after explicit submission, without automatic mutation retries', async () => {
const w = view(); await flushPromises(); await w.get('input').setValue('Laptop')
await w.get('form').trigger('submit'); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'vpn.create-peer', params: { name: 'Laptop' }, maxRetries: 0 })
expect(w.text()).toContain('Connect Laptop'); w.unmount()
})
it('requires a successful refresh after an uncertain creation before retrying', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => { if (method === 'vpn.list-peers') return { peers: [] }; throw new Error('lost response') })
const w = view(); await flushPromises(); await w.get('input').setValue('Laptop'); await w.get('form').trigger('submit'); await flushPromises()
expect(w.text()).toContain('Refresh the list before trying again')
await w.get('form').trigger('submit'); await flushPromises()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([a]) => a.method === 'vpn.create-peer')).toHaveLength(1)
await button(w, 'Refresh devices').trigger('click'); await flushPromises()
expect(button(w, 'Add device').attributes('disabled')).toBeUndefined(); w.unmount()
})
it('requires removal confirmation and keeps devices when the backend does not confirm', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'vpn.list-peers' ? { peers: [peer] } : { removed: false })
const w = view(); await flushPromises(); await w.get('[aria-label="Remove Phone"]').trigger('click')
expect(vi.mocked(rpcClient.call).mock.calls.filter(([a]) => a.method === 'vpn.remove-peer')).toHaveLength(0)
await button(w, 'Remove device').trigger('click'); await flushPromises()
expect(w.text()).toContain('Could not confirm removal'); expect(w.get('[aria-label="Remove Phone"]').exists()).toBe(true); w.unmount()
})
it('keeps a failed list distinct from no devices and disables creation', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('offline'))
const w = view(); await flushPromises()
expect(w.text()).toContain('Could not load devices'); expect(w.text()).not.toContain('No devices have been added')
expect(button(w, 'Add device').attributes('disabled')).toBeDefined(); w.unmount()
})
it('does not show copied until the clipboard succeeds', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } })
const w = view(); await flushPromises(); await button(w, 'Connection details').trigger('click'); await flushPromises()
await button(w, 'Copy connection settings').trigger('click'); await flushPromises()
expect(w.text()).toContain('Could not copy'); expect(w.text()).not.toContain('Copied'); w.unmount()
})
it('clears private details across cached navigation and ignores an old reveal response', async () => {
let resolve!: (value: typeof connection) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'vpn.list-peers') return { peers: [peer] }
return new Promise(r => { resolve = r })
})
const visible = ref(true)
const Host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => visible.value ? h(VpnDeviceSettings, { configured: true, managementVerified: true, statusKnown: true }) : h('div', 'Elsewhere') }) })
const host = mount(Host); await flushPromises()
const details = host.findAll('button').find(b => b.text() === 'Connection details')!
await details.trigger('click')
visible.value = false; await nextTick(); visible.value = true; await nextTick(); await flushPromises()
resolve(connection); await flushPromises()
expect(host.text()).not.toContain('Connect Phone'); expect(host.find('svg').exists()).toBe(false)
host.unmount()
})
it('offers recovery for unfinished operations without revealing an unconfirmed connection', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ peers: [{ ...peer, management_state: 'pending' }] })
const w = view(); await flushPromises()
expect(w.text()).toContain('Setup was not confirmed')
expect(button(w, 'Connection details').attributes('disabled')).toBeDefined()
expect(w.get('[aria-label="Remove Phone"]').attributes('disabled')).toBeUndefined()
w.unmount()
})
})
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client'
const props = defineProps<{ configured: boolean; managementVerified: boolean; statusKnown: boolean }>()
interface Device { name: string; ip: string; management_state?: 'active' | 'pending' | 'revoking' }
interface Connection { qr_svg: string; config: string; peer_ip: string }
const devices = ref<Device[] | null>(null)
const loading = ref(false)
const busy = ref(false)
const error = ref('')
const notice = ref('')
const name = ref('')
const selected = ref('')
const connection = ref<Connection | null>(null)
const removeName = ref('')
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().replaceAll(' ', '-')
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
let viewGeneration = 0
const qr = computed(() => DOMPurify.sanitize(connection.value?.qr_svg ?? '', { USE_PROFILES: { svg: true } }))
async function refresh() {
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 (!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
} catch {
if (active && generation === request) error.value = devices.value ? 'Could not refresh devices. The last list is shown.' : 'Could not load devices. Try again.'
} finally { if (active && generation === request) loading.value = false }
}
async function openConnection(deviceName?: string) {
if (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 (!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.' } }
finally { busy.value = false }
}
async function removeDevice() {
if (busy.value || !props.managementVerified || 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 (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.' } }
finally { busy.value = false }
}
async function copyConnection() {
if (!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.' }
}
onMounted(refresh)
function leave() { active = false; request++; viewGeneration++; connection.value = null; selected.value = ''; name.value = ''; removeName.value = ''; copied.value = false }
onDeactivated(leave)
onUnmounted(leave)
onActivated(() => { if (!active) { active = true; void refresh() } })
</script>
<template>
<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>
</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="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">
<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>
</li>
</ul>
<p v-else-if="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>
</div>
<form class="mt-5 flex flex-wrap items-end gap-3" @submit.prevent="openConnection()">
<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 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" />
<button class="glass-button mt-4 text-sm" @click="copyConnection">{{ copied ? 'Copied' : 'Copy connection settings' }}</button>
</div>
</section>
</template>
<style scoped>
.connection-qr :deep(svg) { max-width: 100%; height: auto; }
</style>