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 type { FastifyInstance } from 'fastify'
|
||||||
import { networkInterfaces } from 'node:os'
|
import { networkInterfaces } from 'node:os'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { db, getConfig, setConfig, logEvent } from '../db/index.js'
|
import { 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 {
|
/** Parses "a.b.c.d/n" into a 32-bit base int + prefix length. */
|
||||||
id: number
|
function parseCidr(cidr: string): { base: number; prefix: number } | null {
|
||||||
type: string
|
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(cidr.trim())
|
||||||
name: string
|
if (!match) return null
|
||||||
config_json: string
|
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 ipToInt(ip: string): number | null {
|
||||||
function detectHostMeshIp(): string | 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 addrs of Object.values(networkInterfaces())) {
|
||||||
for (const addr of addrs ?? []) {
|
for (const addr of addrs ?? []) {
|
||||||
if (addr.family !== 'IPv4') continue
|
if (addr.family !== 'IPv4') continue
|
||||||
const parts = addr.address.split('.').map(Number)
|
const ipInt = ipToInt(addr.address)
|
||||||
if (parts[0] === 100 && parts[1] !== undefined && parts[1] >= 64 && parts[1] <= 127) {
|
if (ipInt === null) continue
|
||||||
return addr.address
|
if (((ipInt & mask) >>> 0) === ((cidr.base & mask) >>> 0)) return addr.address
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
@ -29,56 +36,53 @@ function detectHostMeshIp(): string | null {
|
||||||
|
|
||||||
function meshStatusPayload() {
|
function meshStatusPayload() {
|
||||||
const required = getConfig('mesh.required') === 'true'
|
const required = getConfig('mesh.required') === 'true'
|
||||||
const meshIntegrationId = getConfig('mesh.integrationId')
|
const cidr = getConfig('mesh.cidr') ?? null
|
||||||
const verifiedAt = getConfig('mesh.verifiedAt')
|
const verifiedAt = getConfig('mesh.verifiedAt')
|
||||||
const overridden = getConfig('mesh.overrideUntil') === 'permanent'
|
const overridden = getConfig('mesh.overrideUntil') === 'permanent'
|
||||||
|
const parsed = cidr ? parseCidr(cidr) : null
|
||||||
return {
|
return {
|
||||||
required,
|
required,
|
||||||
verified: !!verifiedAt,
|
verified: !!verifiedAt,
|
||||||
verifiedAt: verifiedAt ?? null,
|
verifiedAt: verifiedAt ?? null,
|
||||||
overridden,
|
overridden,
|
||||||
meshIntegrationId: meshIntegrationId ? Number(meshIntegrationId) : null,
|
cidr,
|
||||||
hostMeshIp: detectHostMeshIp(),
|
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) {
|
export async function systemRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => {
|
app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => {
|
||||||
return meshStatusPayload()
|
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) => {
|
app.post('/api/system/mesh/verify', { onRequest: [app.requireAdmin] }, async (req, reply) => {
|
||||||
const parsed = verifySchema.safeParse(req.body)
|
const parsed = verifySchema.safeParse(req.body)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
|
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(
|
const cidr = parseCidr(parsed.data.cidr)
|
||||||
parsed.data.integrationId,
|
if (!cidr) return reply.code(400).send({ error: 'Invalid CIDR — expected format like 100.64.0.0/10' })
|
||||||
'netbird'
|
|
||||||
) as IntegrationRow | undefined
|
|
||||||
if (!row) return reply.code(404).send({ error: 'NetBird integration not found' })
|
|
||||||
|
|
||||||
const adapter = adapterRegistry[row.type as IntegrationType]
|
const hostMeshIp = findHostIpInCidr(cidr)
|
||||||
const config = JSON.parse(row.config_json)
|
setConfig('mesh.cidr', parsed.data.cidr)
|
||||||
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(
|
if (hostMeshIp) {
|
||||||
result.ok ? 'connected' : 'error',
|
|
||||||
row.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
setConfig('mesh.integrationId', String(row.id))
|
|
||||||
setConfig('mesh.verifiedAt', new Date().toISOString())
|
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 {
|
} 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) => {
|
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}`),
|
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
|
||||||
|
|
||||||
getMeshStatus: () => apiFetch<MeshStatus>('/system/mesh-status'),
|
getMeshStatus: () => apiFetch<MeshStatus>('/system/mesh-status'),
|
||||||
verifyMesh: (integrationId: number) =>
|
verifyMesh: (cidr: string) =>
|
||||||
apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', {
|
apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ integrationId }),
|
body: JSON.stringify({ cidr }),
|
||||||
}),
|
}),
|
||||||
overrideMesh: () => apiFetch<MeshStatus>('/system/mesh/override', { method: 'POST' }),
|
overrideMesh: () => apiFetch<MeshStatus>('/system/mesh/override', { method: 'POST' }),
|
||||||
setMeshRequired: (required: boolean) =>
|
setMeshRequired: (required: boolean) =>
|
||||||
|
|
@ -252,7 +252,7 @@ export interface MeshStatus {
|
||||||
verified: boolean
|
verified: boolean
|
||||||
verifiedAt: string | null
|
verifiedAt: string | null
|
||||||
overridden: boolean
|
overridden: boolean
|
||||||
meshIntegrationId: number | null
|
cidr: string | null
|
||||||
hostMeshIp: string | null
|
hostMeshIp: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,7 @@ const goldButton: React.CSSProperties = {
|
||||||
|
|
||||||
export default function MeshGate() {
|
export default function MeshGate() {
|
||||||
const { refreshMeshStatus } = useAuth()
|
const { refreshMeshStatus } = useAuth()
|
||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [cidr, setCidr] = useState('')
|
||||||
const [apiKey, setApiKey] = useState('')
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null)
|
const [testResult, setTestResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
@ -56,13 +55,7 @@ export default function MeshGate() {
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const { integration } = await api.createIntegration({
|
const result = await api.verifyMesh(cidr)
|
||||||
type: 'netbird',
|
|
||||||
name: 'NetBird',
|
|
||||||
config: baseUrl ? { baseUrl } : {},
|
|
||||||
secrets: apiKey ? { apiKey } : {},
|
|
||||||
})
|
|
||||||
const result = await api.verifyMesh(integration.id)
|
|
||||||
setTestResult(result)
|
setTestResult(result)
|
||||||
if (result.ok) await refreshMeshStatus()
|
if (result.ok) await refreshMeshStatus()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -103,21 +96,20 @@ export default function MeshGate() {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
||||||
ArchNest expects a verified NetBird mesh before the rest of the app can be configured.
|
ArchNest expects this host to be on a private mesh network before the rest of the app
|
||||||
Connect your mesh below to continue.
|
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>
|
</p>
|
||||||
|
|
||||||
<label style={fieldLabel}>NetBird Base URL</label>
|
<label style={fieldLabel}>Mesh Network CIDR</label>
|
||||||
<input
|
<input
|
||||||
style={fieldInput}
|
style={fieldInput}
|
||||||
value={baseUrl}
|
value={cidr}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setCidr(e.target.value)}
|
||||||
placeholder="https://api.netbird.io"
|
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>}
|
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<p style={{ fontSize: '12px', color: testResult.ok ? '#2ECC71' : '#E74C3C', marginTop: '12px' }}>
|
<p style={{ fontSize: '12px', color: testResult.ok ? '#2ECC71' : '#E74C3C', marginTop: '12px' }}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue