From 46d95fca61dd6f423b716d16fcd9a86c8aabbfd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 21:15:55 +0000 Subject: [PATCH] Add mesh prerequisite gate (NetBird verification before app config) Implements the design in docs/mesh-prerequisite-gate.md per the user's DECIDE A-D answers: a permanent admin override, B1 (reachable) verification with host mesh IP shown informationally, members allowed in with a notice instead of being blocked, and mesh.required defaulting off so the live production instance is unaffected. - system_config kv table + getConfig/setConfig helpers - /api/system/mesh-status, /mesh/verify, /mesh/override, /mesh/required - AuthContext gains a 'needs-mesh' status (admins only) and exposes meshStatus for a member-facing banner - MeshGate page reuses the integration create+test flow to connect NetBird --- backend/src/db/index.ts | 20 +++++ backend/src/routes/system.ts | 96 ++++++++++++++++++++ backend/src/server.ts | 2 + src/App.tsx | 20 +++++ src/lib/AuthContext.tsx | 23 ++++- src/lib/api.ts | 19 ++++ src/pages/MeshGate.tsx | 166 +++++++++++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 backend/src/routes/system.ts create mode 100644 src/pages/MeshGate.tsx diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index ac264c6..a6a9104 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -112,8 +112,28 @@ db.exec(` reported_at TEXT, received_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS system_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) +export function getConfig(key: string): string | undefined { + const row = db.prepare('SELECT value FROM system_config WHERE key = ?').get(key) as + | { value: string } + | undefined + return row?.value +} + +export function setConfig(key: string, value: string) { + db.prepare( + `INSERT INTO system_config (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')` + ).run(key, value) +} + export function logEvent(type: string, title: string, source?: string | null) { db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null) } diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts new file mode 100644 index 0000000..e357f2a --- /dev/null +++ b/backend/src/routes/system.ts @@ -0,0 +1,96 @@ +import type { FastifyInstance } from 'fastify' +import { networkInterfaces } from 'node:os' +import { z } from 'zod' +import { db, getConfig, setConfig, logEvent } from '../db/index.js' +import { loadSecrets } from '../db/secrets.js' +import { adapterRegistry } from '../integrations/registry.js' +import type { IntegrationType } from '../integrations/types.js' + +interface IntegrationRow { + id: number + type: string + name: string + config_json: string +} + +/** NetBird's default CGNAT mesh range (100.64.0.0/10) — informational only (DECIDE B2). */ +function detectHostMeshIp(): string | null { + for (const addrs of Object.values(networkInterfaces())) { + for (const addr of addrs ?? []) { + if (addr.family !== 'IPv4') continue + const parts = addr.address.split('.').map(Number) + if (parts[0] === 100 && parts[1] !== undefined && parts[1] >= 64 && parts[1] <= 127) { + return addr.address + } + } + } + return null +} + +function meshStatusPayload() { + const required = getConfig('mesh.required') === 'true' + const meshIntegrationId = getConfig('mesh.integrationId') + const verifiedAt = getConfig('mesh.verifiedAt') + const overridden = getConfig('mesh.overrideUntil') === 'permanent' + return { + required, + verified: !!verifiedAt, + verifiedAt: verifiedAt ?? null, + overridden, + meshIntegrationId: meshIntegrationId ? Number(meshIntegrationId) : null, + hostMeshIp: detectHostMeshIp(), + } +} + +const verifySchema = z.object({ integrationId: z.number().int() }) + +export async function systemRoutes(app: FastifyInstance) { + app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => { + return meshStatusPayload() + }) + + app.post('/api/system/mesh/verify', { onRequest: [app.requireAdmin] }, async (req, reply) => { + const parsed = verifySchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const row = db.prepare('SELECT * FROM integrations WHERE id = ? AND type = ?').get( + parsed.data.integrationId, + 'netbird' + ) as IntegrationRow | undefined + if (!row) return reply.code(404).send({ error: 'NetBird integration not found' }) + + const adapter = adapterRegistry[row.type as IntegrationType] + const config = JSON.parse(row.config_json) + const secrets = loadSecrets(row.id) + const result = await adapter.testConnection(config, secrets) + + db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run( + result.ok ? 'connected' : 'error', + row.id + ) + + if (result.ok) { + setConfig('mesh.integrationId', String(row.id)) + setConfig('mesh.verifiedAt', new Date().toISOString()) + logEvent('mesh_verified', `Mesh verified via ${row.name}`, row.type) + } else { + logEvent('mesh_verify_failed', `Mesh verification failed via ${row.name}: ${result.message}`, row.type) + } + + return { ...result, hostMeshIp: detectHostMeshIp() } + }) + + app.post('/api/system/mesh/override', { onRequest: [app.requireAdmin] }, async (req) => { + setConfig('mesh.overrideUntil', 'permanent') + logEvent('mesh_override', `Mesh gate skipped by admin (${(req.user as { username: string }).username})`) + return meshStatusPayload() + }) + + app.put('/api/system/mesh/required', { onRequest: [app.requireAdmin] }, async (req) => { + const { required } = req.body as { required: boolean } + setConfig('mesh.required', required ? 'true' : 'false') + logEvent('mesh_required_changed', `Mesh requirement set to ${required ? 'on' : 'off'}`) + return meshStatusPayload() + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 7d4653b..f20d074 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,6 +18,7 @@ import { guacamoleRoutes } from './routes/guacamole.js' import { metricsRoutes } from './routes/metrics.js' import { transferRoutes } from './routes/transfer.js' import { dataRoutes } from './routes/data.js' +import { systemRoutes } from './routes/system.js' import { startAutoStartTunnels } from './tunnels/manager.js' import { db } from './db/index.js' @@ -99,6 +100,7 @@ await app.register(guacamoleRoutes) await app.register(metricsRoutes) await app.register(transferRoutes) await app.register(dataRoutes) +await app.register(systemRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/src/App.tsx b/src/App.tsx index 628d871..2902e14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import Settings from './pages/Settings' import Help from './pages/Help' import Login from './pages/Login' import Enrollment from './pages/Enrollment' +import MeshGate from './pages/MeshGate' import { useAuth } from './lib/AuthContext' function App() { @@ -29,11 +30,14 @@ function App() { } if (status === 'needs-setup' || status === 'enrolling') return if (status === 'logged-out') return + if (status === 'needs-mesh') return return } function Dashboard() { + const { user, meshStatus } = useAuth() + const showMeshNotice = !!meshStatus && meshStatus.required && !meshStatus.verified && !meshStatus.overridden && user?.role !== 'admin' const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const sidebarWidth = sidebarCollapsed ? 64 : 200 const location = useLocation() @@ -79,6 +83,22 @@ function Dashboard() { + {showMeshNotice && ( +
+ Mesh setup is still in progress — an admin needs to finish verifying the network. Some features may be limited. +
+ )} +
Promise completeSetup: (username: string, password: string) => Promise finishEnrollment: () => Promise + refreshMeshStatus: () => Promise logout: () => void setUser: (user: AuthUser) => void } @@ -18,13 +20,19 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState('loading') const [user, setUser] = useState(null) + const [meshStatus, setMeshStatus] = useState(null) async function refresh() { if (getToken()) { try { const { user } = await api.me() setUser(user) - setStatus('logged-in') + const mesh = await api.getMeshStatus().catch(() => null) + setMeshStatus(mesh) + // Members are let in with a "setup in progress" notice instead of being gated — + // only an admin can actually fix mesh config, so only admins see the full gate. + const gateBlocks = !!mesh && mesh.required && !mesh.verified && !mesh.overridden + setStatus(gateBlocks && user.role === 'admin' ? 'needs-mesh' : 'logged-in') return } catch { setToken(null) @@ -34,6 +42,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setStatus(needsSetup ? 'needs-setup' : 'logged-out') } + async function refreshMeshStatus() { + await refresh() + } + useEffect(() => { refresh() }, []) @@ -61,11 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { api.logout().catch(() => {}) setToken(null) setUser(null) + setMeshStatus(null) setStatus('logged-out') } return ( - + {children} ) diff --git a/src/lib/api.ts b/src/lib/api.ts index 0c3c374..1e4242d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -58,6 +58,16 @@ export const api = { logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }), listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`), + getMeshStatus: () => apiFetch('/system/mesh-status'), + verifyMesh: (integrationId: number) => + apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', { + method: 'POST', + body: JSON.stringify({ integrationId }), + }), + overrideMesh: () => apiFetch('/system/mesh/override', { method: 'POST' }), + setMeshRequired: (required: boolean) => + apiFetch('/system/mesh/required', { method: 'PUT', body: JSON.stringify({ required }) }), + listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'), createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) => apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }), @@ -237,6 +247,15 @@ export interface AuthSession { current: boolean } +export interface MeshStatus { + required: boolean + verified: boolean + verifiedAt: string | null + overridden: boolean + meshIntegrationId: number | null + hostMeshIp: string | null +} + export interface LoginEvent { id: number username: string | null diff --git a/src/pages/MeshGate.tsx b/src/pages/MeshGate.tsx new file mode 100644 index 0000000..1281fb7 --- /dev/null +++ b/src/pages/MeshGate.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react' +import { Network, ShieldAlert } from 'lucide-react' +import { useAuth } from '../lib/AuthContext' +import { api, ApiError } from '../lib/api' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.12)', + borderRadius: '14px', + padding: '32px', +} + +const fieldLabel: React.CSSProperties = { + fontSize: '11px', + color: '#7A7D85', + marginBottom: '6px', + display: 'block', +} + +const fieldInput: React.CSSProperties = { + width: '100%', + height: '36px', + borderRadius: '8px', + border: '1px solid rgba(200,164,52,0.12)', + backgroundColor: 'rgba(255,255,255,0.03)', + color: '#E8E6E0', + fontSize: '13px', + padding: '0 12px', + outline: 'none', +} + +const goldButton: React.CSSProperties = { + height: '38px', + borderRadius: '8px', + border: 'none', + fontSize: '13px', + fontWeight: 600, + color: '#0A0B0D', + backgroundColor: '#C8A434', + boxShadow: '0 0 14px rgba(200,164,52,0.2)', + padding: '0 20px', +} + +export default function MeshGate() { + const { refreshMeshStatus } = useAuth() + const [baseUrl, setBaseUrl] = useState('') + const [apiKey, setApiKey] = useState('') + const [error, setError] = useState(null) + const [testResult, setTestResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null) + const [submitting, setSubmitting] = useState(false) + const [overriding, setOverriding] = useState(false) + + async function handleVerify(e: React.FormEvent) { + e.preventDefault() + setError(null) + setTestResult(null) + setSubmitting(true) + try { + const { integration } = await api.createIntegration({ + type: 'netbird', + name: 'NetBird', + config: baseUrl ? { baseUrl } : {}, + secrets: apiKey ? { apiKey } : {}, + }) + const result = await api.verifyMesh(integration.id) + setTestResult(result) + if (result.ok) await refreshMeshStatus() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to verify mesh') + } finally { + setSubmitting(false) + } + } + + async function handleOverride() { + setOverriding(true) + try { + await api.overrideMesh() + await refreshMeshStatus() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to skip mesh setup') + } finally { + setOverriding(false) + } + } + + return ( +
+
+
+
+ +

+ Mesh Setup Required +

+
+

+ ArchNest expects a verified NetBird mesh before the rest of the app can be configured. + Connect your mesh below to continue. +

+ + + setBaseUrl(e.target.value)} + placeholder="https://api.netbird.io" + /> + + + setApiKey(e.target.value)} required /> + + {error &&

{error}

} + {testResult && ( +

+ {testResult.message} + {testResult.hostMeshIp && ` — this host's mesh IP: ${testResult.hostMeshIp}`} +

+ )} + + + +
+ +
+

+ Mesh provider down, or setting this up later? You can skip this check for now — it stays + skipped until you re-enable it from Settings. +

+ +
+
+
+
+
+ ) +}