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:
parent
cdd93f204e
commit
46d95fca61
7 changed files with 342 additions and 4 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
96
backend/src/routes/system.ts
Normal file
96
backend/src/routes/system.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }))
|
||||
|
||||
|
|
|
|||
20
src/App.tsx
20
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 <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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
166
src/pages/MeshGate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue