diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts index e357f2a..f2e9fa7 100644 --- a/backend/src/routes/system.ts +++ b/backend/src/routes/system.ts @@ -1,27 +1,34 @@ 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' +import { getConfig, setConfig, logEvent } from '../db/index.js' -interface IntegrationRow { - id: number - type: string - name: string - config_json: string +/** Parses "a.b.c.d/n" into a 32-bit base int + prefix length. */ +function parseCidr(cidr: string): { base: number; prefix: number } | null { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(cidr.trim()) + if (!match) return null + const octets = match.slice(1, 5).map(Number) + const prefix = Number(match[5]) + if (octets.some((o) => o < 0 || o > 255) || prefix < 0 || prefix > 32) return null + const base = (octets[0]! << 24) | (octets[1]! << 16) | (octets[2]! << 8) | octets[3]! + return { base, prefix } } -/** NetBird's default CGNAT mesh range (100.64.0.0/10) — informational only (DECIDE B2). */ -function detectHostMeshIp(): string | null { +function ipToInt(ip: string): number | null { + const parts = ip.split('.').map(Number) + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return null + return (parts[0]! << 24) | (parts[1]! << 16) | (parts[2]! << 8) | parts[3]! +} + +/** Finds this host's own IPv4 address that falls within the given mesh CIDR, if any. */ +function findHostIpInCidr(cidr: { base: number; prefix: number }): string | null { + const mask = cidr.prefix === 0 ? 0 : (-1 << (32 - cidr.prefix)) >>> 0 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 - } + const ipInt = ipToInt(addr.address) + if (ipInt === null) continue + if (((ipInt & mask) >>> 0) === ((cidr.base & mask) >>> 0)) return addr.address } } return null @@ -29,56 +36,53 @@ function detectHostMeshIp(): string | null { function meshStatusPayload() { const required = getConfig('mesh.required') === 'true' - const meshIntegrationId = getConfig('mesh.integrationId') + const cidr = getConfig('mesh.cidr') ?? null const verifiedAt = getConfig('mesh.verifiedAt') const overridden = getConfig('mesh.overrideUntil') === 'permanent' + const parsed = cidr ? parseCidr(cidr) : null return { required, verified: !!verifiedAt, verifiedAt: verifiedAt ?? null, overridden, - meshIntegrationId: meshIntegrationId ? Number(meshIntegrationId) : null, - hostMeshIp: detectHostMeshIp(), + cidr, + hostMeshIp: parsed ? findHostIpInCidr(parsed) : null, } } -const verifySchema = z.object({ integrationId: z.number().int() }) +const verifySchema = z.object({ cidr: z.string().min(1) }) export async function systemRoutes(app: FastifyInstance) { app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => { return meshStatusPayload() }) + // Verification is mesh-tech-agnostic: any overlay network (NetBird, WireGuard, + // ZeroTier, Tailscale...) assigns this host an IP in its own range. We just check + // the host has an address inside the admin-supplied CIDR — no vendor API needed. 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 cidr = parseCidr(parsed.data.cidr) + if (!cidr) return reply.code(400).send({ error: 'Invalid CIDR — expected format like 100.64.0.0/10' }) - 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) + const hostMeshIp = findHostIpInCidr(cidr) + setConfig('mesh.cidr', parsed.data.cidr) - 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)) + if (hostMeshIp) { setConfig('mesh.verifiedAt', new Date().toISOString()) - logEvent('mesh_verified', `Mesh verified via ${row.name}`, row.type) + logEvent('mesh_verified', `Mesh verified — host has ${hostMeshIp} in ${parsed.data.cidr}`) } else { - logEvent('mesh_verify_failed', `Mesh verification failed via ${row.name}: ${result.message}`, row.type) + logEvent('mesh_verify_failed', `Mesh verification failed — no host IP found in ${parsed.data.cidr}`) } - return { ...result, hostMeshIp: detectHostMeshIp() } + return { + ok: !!hostMeshIp, + message: hostMeshIp ? `Host is on the mesh (${hostMeshIp})` : 'No host IP found in that range', + hostMeshIp, + } }) app.post('/api/system/mesh/override', { onRequest: [app.requireAdmin] }, async (req) => { diff --git a/src/lib/api.ts b/src/lib/api.ts index 1e4242d..5985007 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -59,10 +59,10 @@ export const api = { listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`), getMeshStatus: () => apiFetch('/system/mesh-status'), - verifyMesh: (integrationId: number) => + verifyMesh: (cidr: string) => apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', { method: 'POST', - body: JSON.stringify({ integrationId }), + body: JSON.stringify({ cidr }), }), overrideMesh: () => apiFetch('/system/mesh/override', { method: 'POST' }), setMeshRequired: (required: boolean) => @@ -252,7 +252,7 @@ export interface MeshStatus { verified: boolean verifiedAt: string | null overridden: boolean - meshIntegrationId: number | null + cidr: string | null hostMeshIp: string | null } diff --git a/src/pages/MeshGate.tsx b/src/pages/MeshGate.tsx index 1281fb7..7bd0e4f 100644 --- a/src/pages/MeshGate.tsx +++ b/src/pages/MeshGate.tsx @@ -43,8 +43,7 @@ const goldButton: React.CSSProperties = { export default function MeshGate() { const { refreshMeshStatus } = useAuth() - const [baseUrl, setBaseUrl] = useState('') - const [apiKey, setApiKey] = useState('') + const [cidr, setCidr] = useState('') const [error, setError] = useState(null) const [testResult, setTestResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null) const [submitting, setSubmitting] = useState(false) @@ -56,13 +55,7 @@ export default function MeshGate() { 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) + const result = await api.verifyMesh(cidr) setTestResult(result) if (result.ok) await refreshMeshStatus() } catch (err) { @@ -103,21 +96,20 @@ export default function MeshGate() {

- ArchNest expects a verified NetBird mesh before the rest of the app can be configured. - Connect your mesh below to continue. + ArchNest expects this host to be on a private mesh network before the rest of the app + can be configured. Works with any mesh — NetBird, WireGuard, ZeroTier, Tailscale, etc. + Enter the mesh's IP range below; we verify by checking this host has an address in it.

- + setBaseUrl(e.target.value)} - placeholder="https://api.netbird.io" + value={cidr} + onChange={(e) => setCidr(e.target.value)} + placeholder="e.g. 100.64.0.0/10 (NetBird/Tailscale CGNAT range)" + required /> - - setApiKey(e.target.value)} required /> - {error &&

{error}

} {testResult && (