Make mesh verification universal (CIDR check, not NetBird-specific)
Replace the NetBird-adapter-based "reachable" check with a vendor-agnostic one: the admin supplies the mesh's IP range (CIDR), and verification just confirms this host has an address inside it. Works identically for NetBird, WireGuard, ZeroTier, Tailscale, or any other mesh tech, with no integration record or vendor API call required.
This commit is contained in:
parent
46d95fca61
commit
0409159327
3 changed files with 55 additions and 59 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ export const api = {
|
|||
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
|
||||
|
||||
getMeshStatus: () => apiFetch<MeshStatus>('/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<MeshStatus>('/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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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() {
|
|||
</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.
|
||||
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.
|
||||
</p>
|
||||
|
||||
<label style={fieldLabel}>NetBird Base URL</label>
|
||||
<label style={fieldLabel}>Mesh Network CIDR</label>
|
||||
<input
|
||||
style={fieldInput}
|
||||
value={baseUrl}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
|
||||
<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' }}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue