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 @@
-
-
-
- ← Network
-
Firewall & tunnels
-
Review reachability before exposing a service. Transport access and app authentication are separate controls.
+
+
+
+ ← Network
+
Firewalls & tunnels
+
Choose how your devices and apps connect to this node.