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
This commit is contained in:
Claude 2026-06-20 21:15:55 +00:00
parent cdd93f204e
commit 46d95fca61
No known key found for this signature in database
7 changed files with 342 additions and 4 deletions

View file

@ -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)
}

View file

@ -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()
})
}

View file

@ -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 }))

View file

@ -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 <Enrollment />
if (status === 'logged-out') return <Login />
if (status === 'needs-mesh') return <MeshGate />
return <Dashboard />
}
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() {
<TopBar />
</div>
{showMeshNotice && (
<div
className="relative flex items-center justify-center"
style={{
zIndex: 20,
padding: '6px 16px',
backgroundColor: 'rgba(230,126,34,0.1)',
borderBottom: '1px solid rgba(230,126,34,0.2)',
fontSize: '11px',
color: '#E67E22',
}}
>
Mesh setup is still in progress an admin needs to finish verifying the network. Some features may be limited.
</div>
)}
<section
className="relative flex w-full flex-col overflow-hidden"
style={{ height: `calc(100vh - ${topBarHeight}px)`, scrollbarWidth: 'none', padding: showHero ? `${heroPaddingTop} 24px 24px 24px` : '16px 24px 24px 24px', gap: '20px', zIndex: 1 }}

View file

@ -1,14 +1,16 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, getToken, setToken, type AuthUser } from './api'
import { api, getToken, setToken, type AuthUser, type MeshStatus } from './api'
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in'
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'needs-mesh' | 'logged-in'
interface AuthContextValue {
status: AuthStatus
user: AuthUser | null
meshStatus: MeshStatus | null
login: (username: string, password: string) => Promise<void>
completeSetup: (username: string, password: string) => Promise<void>
finishEnrollment: () => Promise<void>
refreshMeshStatus: () => Promise<void>
logout: () => void
setUser: (user: AuthUser) => void
}
@ -18,13 +20,19 @@ const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>('loading')
const [user, setUser] = useState<AuthUser | null>(null)
const [meshStatus, setMeshStatus] = useState<MeshStatus | null>(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 (
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout, setUser }}>
<AuthContext.Provider
value={{ status, user, meshStatus, login, completeSetup, finishEnrollment, refreshMeshStatus, logout, setUser }}
>
{children}
</AuthContext.Provider>
)

View file

@ -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<MeshStatus>('/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<MeshStatus>('/system/mesh/override', { method: 'POST' }),
setMeshRequired: (required: boolean) =>
apiFetch<MeshStatus>('/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

166
src/pages/MeshGate.tsx Normal file
View file

@ -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<string | null>(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 (
<div className="flex h-screen w-screen items-center justify-center bg-page">
<div style={{ width: '420px' }}>
<form onSubmit={handleVerify} style={cardBase}>
<div className="flex items-center gap-2" style={{ marginBottom: '4px' }}>
<Network size={18} color="#C8A434" />
<h1
style={{
fontSize: '18px',
fontWeight: 700,
letterSpacing: '1px',
textTransform: 'uppercase',
color: '#C8A434',
}}
>
Mesh Setup Required
</h1>
</div>
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
ArchNest expects a verified NetBird mesh before the rest of the app can be configured.
Connect your mesh below to continue.
</p>
<label style={fieldLabel}>NetBird Base URL</label>
<input
style={fieldInput}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.netbird.io"
/>
<label style={{ ...fieldLabel, marginTop: '14px' }}>API Key</label>
<input style={fieldInput} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} required />
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
{testResult && (
<p style={{ fontSize: '12px', color: testResult.ok ? '#2ECC71' : '#E74C3C', marginTop: '12px' }}>
{testResult.message}
{testResult.hostMeshIp && ` — this host's mesh IP: ${testResult.hostMeshIp}`}
</p>
)}
<button type="submit" disabled={submitting} style={{ ...goldButton, width: '100%', marginTop: '22px', opacity: submitting ? 0.6 : 1 }}>
{submitting ? 'Verifying…' : 'Connect & Verify'}
</button>
<div
className="flex items-start gap-2"
style={{ marginTop: '20px', paddingTop: '16px', borderTop: '1px solid rgba(255,255,255,0.06)' }}
>
<ShieldAlert size={14} color="#7A7D85" style={{ flexShrink: 0, marginTop: '1px' }} />
<div>
<p style={{ fontSize: '11px', color: '#7A7D85', marginBottom: '8px' }}>
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.
</p>
<button
type="button"
onClick={handleOverride}
disabled={overriding}
className="cursor-pointer"
style={{
fontSize: '11px',
color: '#7A7D85',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '6px',
padding: '6px 12px',
opacity: overriding ? 0.6 : 1,
}}
>
{overriding ? 'Skipping…' : 'Skip for now'}
</button>
</div>
</div>
</form>
</div>
</div>
)
}