From ccfa91aa57f51f1b092231e26653a09b572c921a Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 8 Oct 2026 05:52:13 -0400 Subject: [PATCH] fix(ui): make firewall settings consistent and gate device management --- docs/firewall-tunnel-followup-20261008.md | 35 ++++ docs/post-1.9.0-work-backlog.md | 14 ++ neode-ui/src/views/Dashboard.vue | 17 +- neode-ui/src/views/Server.vue | 9 +- .../__tests__/routeBackground.test.ts | 20 +++ .../views/dashboard/useRouteTransitions.ts | 10 ++ .../server/FirewallTunnelSettings.test.ts | 63 +++++++ .../views/server/FirewallTunnelSettings.vue | 159 +++++++++--------- .../views/server/VpnDeviceSettings.test.ts | 105 ++++++++++++ .../src/views/server/VpnDeviceSettings.vue | 121 +++++++++++++ 10 files changed, 454 insertions(+), 99 deletions(-) create mode 100644 docs/firewall-tunnel-followup-20261008.md create mode 100644 neode-ui/src/views/dashboard/__tests__/routeBackground.test.ts create mode 100644 neode-ui/src/views/server/FirewallTunnelSettings.test.ts create mode 100644 neode-ui/src/views/server/VpnDeviceSettings.test.ts create mode 100644 neode-ui/src/views/server/VpnDeviceSettings.vue diff --git a/docs/firewall-tunnel-followup-20261008.md b/docs/firewall-tunnel-followup-20261008.md new file mode 100644 index 00000000..dc54066b --- /dev/null +++ b/docs/firewall-tunnel-followup-20261008.md @@ -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. diff --git a/docs/post-1.9.0-work-backlog.md b/docs/post-1.9.0-work-backlog.md index 5002e227..4bfc056f 100644 --- a/docs/post-1.9.0-work-backlog.md +++ b/docs/post-1.9.0-work-backlog.md @@ -516,6 +516,20 @@ remain open. See `docs/https-app-gate-followup-20261006.md`. ## 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 network entry points, independent status/error handling and responsive route tests are implemented (ef6c10f0, 3f96be2c). Scoped configuration, persistence, diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index e9822c84..cd5fde36 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -137,7 +137,7 @@ import DashboardRouterView from '@/views/dashboard/DashboardRouterView.vue' import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue' import HealthNotifications from '@/views/dashboard/HealthNotifications.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 '@/views/dashboard/dashboard-styles.css' @@ -163,20 +163,9 @@ const showAltBackground = ref(false) const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/') const isGlitching = ref(false) -const backgroundImage = computed(() => { - 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' -}) +const backgroundImage = computed(() => backgroundForRoute(route.path)) -// 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. const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg') diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 6f5d2be5..76eeac95 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -101,10 +101,9 @@ >
- Firewall Active + Firewall
- Protected - + Check status
@@ -166,9 +165,9 @@
- Firewall & tunnels + Firewalls & tunnels
diff --git a/neode-ui/src/views/dashboard/__tests__/routeBackground.test.ts b/neode-ui/src/views/dashboard/__tests__/routeBackground.test.ts new file mode 100644 index 00000000..cd61cacb --- /dev/null +++ b/neode-ui/src/views/dashboard/__tests__/routeBackground.test.ts @@ -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') + }) +}) diff --git a/neode-ui/src/views/dashboard/useRouteTransitions.ts b/neode-ui/src/views/dashboard/useRouteTransitions.ts index f1bdcacf..50657e4a 100644 --- a/neode-ui/src/views/dashboard/useRouteTransitions.ts +++ b/neode-ui/src/views/dashboard/useRouteTransitions.ts @@ -38,6 +38,16 @@ export const ROUTE_BACKGROUNDS: Record = { '/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 { return (path.includes('/apps/') && !path.endsWith('/apps')) || (path.includes('/marketplace/') && !path.endsWith('/marketplace')) diff --git a/neode-ui/src/views/server/FirewallTunnelSettings.test.ts b/neode-ui/src/views/server/FirewallTunnelSettings.test.ts new file mode 100644 index 00000000..d964a5b9 --- /dev/null +++ b/neode-ui/src/views/server/FirewallTunnelSettings.test.ts @@ -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: '' }, VpnDeviceSettings: { props: ['configured', 'managementVerified'], template: '
' } } } }) +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) + }) +}) diff --git a/neode-ui/src/views/server/FirewallTunnelSettings.vue b/neode-ui/src/views/server/FirewallTunnelSettings.vue index d50d0c2b..e450b984 100644 --- a/neode-ui/src/views/server/FirewallTunnelSettings.vue +++ b/neode-ui/src/views/server/FirewallTunnelSettings.vue @@ -1,104 +1,103 @@ + diff --git a/neode-ui/src/views/server/VpnDeviceSettings.test.ts b/neode-ui/src/views/server/VpnDeviceSettings.test.ts new file mode 100644 index 00000000..028d1522 --- /dev/null +++ b/neode-ui/src/views/server/VpnDeviceSettings.test.ts @@ -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: '', 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, 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() + }) + +}) diff --git a/neode-ui/src/views/server/VpnDeviceSettings.vue b/neode-ui/src/views/server/VpnDeviceSettings.vue new file mode 100644 index 00000000..6b3dfe28 --- /dev/null +++ b/neode-ui/src/views/server/VpnDeviceSettings.vue @@ -0,0 +1,121 @@ + + + +