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