diff --git a/backend/Dockerfile b/backend/Dockerfile index 4909e4e..081d0dd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,7 +14,8 @@ ENV NODE_ENV=production # native modules (better-sqlite3, ssh2, node-pty) compile from source on install. # openssh-client provides the `ssh` binary, which node-pty shells out to for # certificate-based auth (ssh2 has no OpenSSH certificate support). -RUN apk add --no-cache python3 make g++ openssh-client +# iputils provides `ping`, used by the mesh-gate reachability check. +RUN apk add --no-cache python3 make g++ openssh-client iputils COPY package.json package-lock.json* ./ RUN npm install --omit=dev COPY --from=build /app/dist ./dist diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts index f2e9fa7..626132e 100644 --- a/backend/src/routes/system.ts +++ b/backend/src/routes/system.ts @@ -1,8 +1,12 @@ import type { FastifyInstance } from 'fastify' import { networkInterfaces } from 'node:os' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' import { z } from 'zod' import { getConfig, setConfig, logEvent } from '../db/index.js' +const execFileAsync = promisify(execFile) + /** 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()) @@ -20,37 +24,57 @@ function ipToInt(ip: string): number | null { return (parts[0]! << 24) | (parts[1]! << 16) | (parts[2]! << 8) | parts[3]! } +function ipInCidr(ip: string, cidr: { base: number; prefix: number }): boolean { + const mask = cidr.prefix === 0 ? 0 : (-1 << (32 - cidr.prefix)) >>> 0 + const ipInt = ipToInt(ip) + if (ipInt === null) return false + return ((ipInt & mask) >>> 0) === ((cidr.base & mask) >>> 0) +} + /** 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 ipInt = ipToInt(addr.address) - if (ipInt === null) continue - if (((ipInt & mask) >>> 0) === ((cidr.base & mask) >>> 0)) return addr.address + if (addr.family === 'IPv4' && ipInCidr(addr.address, cidr)) return addr.address } } return null } +/** + * Some meshes are routed rather than locally-addressed — e.g. a VPC peered into a + * NetBird mesh, where this host keeps its own 192.x address but can still reach mesh + * peers (100.x) through routing. In that case there's no local mesh IP to find, so we + * fall back to pinging an admin-supplied peer/gateway IP that's known to be on the mesh. + */ +async function canReachIp(ip: string): Promise { + try { + await execFileAsync('ping', ['-c', '1', '-W', '2', ip]) + return true + } catch { + return false + } +} + function meshStatusPayload() { const required = getConfig('mesh.required') === 'true' const cidr = getConfig('mesh.cidr') ?? null const verifiedAt = getConfig('mesh.verifiedAt') + const verifiedVia = getConfig('mesh.verifiedVia') ?? null const overridden = getConfig('mesh.overrideUntil') === 'permanent' const parsed = cidr ? parseCidr(cidr) : null return { required, verified: !!verifiedAt, verifiedAt: verifiedAt ?? null, + verifiedVia, overridden, cidr, hostMeshIp: parsed ? findHostIpInCidr(parsed) : null, } } -const verifySchema = z.object({ cidr: z.string().min(1) }) +const verifySchema = z.object({ cidr: z.string().min(1), testIp: z.string().min(1).optional() }) export async function systemRoutes(app: FastifyInstance) { app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => { @@ -58,8 +82,9 @@ export async function systemRoutes(app: FastifyInstance) { }) // 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. + // ZeroTier, Tailscale...) assigns this host an IP in its own range — checked first. + // If this host doesn't hold a local mesh IP (e.g. a VPC routed into the mesh), fall + // back to pinging an admin-supplied peer/gateway IP known to be on the mesh. app.post('/api/system/mesh/verify', { onRequest: [app.requireAdmin] }, async (req, reply) => { const parsed = verifySchema.safeParse(req.body) if (!parsed.success) { @@ -71,18 +96,31 @@ export async function systemRoutes(app: FastifyInstance) { const hostMeshIp = findHostIpInCidr(cidr) setConfig('mesh.cidr', parsed.data.cidr) - if (hostMeshIp) { - setConfig('mesh.verifiedAt', new Date().toISOString()) - logEvent('mesh_verified', `Mesh verified — host has ${hostMeshIp} in ${parsed.data.cidr}`) - } else { - logEvent('mesh_verify_failed', `Mesh verification failed — no host IP found in ${parsed.data.cidr}`) + let ok = !!hostMeshIp + let message = hostMeshIp ? `Host is on the mesh (${hostMeshIp})` : 'No host IP found in that range' + let via: 'local-ip' | 'reachable' | null = hostMeshIp ? 'local-ip' : null + + if (!ok && parsed.data.testIp) { + if (!ipInCidr(parsed.data.testIp, cidr)) { + return reply.code(400).send({ error: 'Test IP is not inside the given CIDR' }) + } + const reachable = await canReachIp(parsed.data.testIp) + ok = reachable + via = reachable ? 'reachable' : null + message = reachable + ? `Host can reach ${parsed.data.testIp} on the mesh` + : `Host has no local mesh IP and could not reach ${parsed.data.testIp}` } - return { - ok: !!hostMeshIp, - message: hostMeshIp ? `Host is on the mesh (${hostMeshIp})` : 'No host IP found in that range', - hostMeshIp, + if (ok) { + setConfig('mesh.verifiedAt', new Date().toISOString()) + setConfig('mesh.verifiedVia', via ?? 'local-ip') + logEvent('mesh_verified', `Mesh verified (${via}) — ${message}`) + } else { + logEvent('mesh_verify_failed', `Mesh verification failed: ${message}`) } + + return { ok, message, 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 5985007..d945e03 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: (cidr: string) => + verifyMesh: (cidr: string, testIp?: string) => apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', { method: 'POST', - body: JSON.stringify({ cidr }), + body: JSON.stringify({ cidr, testIp }), }), overrideMesh: () => apiFetch('/system/mesh/override', { method: 'POST' }), setMeshRequired: (required: boolean) => @@ -251,6 +251,7 @@ export interface MeshStatus { required: boolean verified: boolean verifiedAt: string | null + verifiedVia: 'local-ip' | 'reachable' | null overridden: boolean cidr: string | null hostMeshIp: string | null diff --git a/src/pages/MeshGate.tsx b/src/pages/MeshGate.tsx index 7bd0e4f..87fae76 100644 --- a/src/pages/MeshGate.tsx +++ b/src/pages/MeshGate.tsx @@ -44,6 +44,7 @@ const goldButton: React.CSSProperties = { export default function MeshGate() { const { refreshMeshStatus } = useAuth() const [cidr, setCidr] = useState('') + const [testIp, setTestIp] = useState('') const [error, setError] = useState(null) const [testResult, setTestResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null) const [submitting, setSubmitting] = useState(false) @@ -55,7 +56,7 @@ export default function MeshGate() { setTestResult(null) setSubmitting(true) try { - const result = await api.verifyMesh(cidr) + const result = await api.verifyMesh(cidr, testIp || undefined) setTestResult(result) if (result.ok) await refreshMeshStatus() } catch (err) { @@ -110,6 +111,19 @@ export default function MeshGate() { required /> + + setTestIp(e.target.value)} + placeholder="e.g. 100.64.0.1 — only needed if this host's own IP isn't in the mesh range" + /> +

+ If this host reaches the mesh through routing instead of holding a local mesh IP + (e.g. a VPC peered into the mesh), give us an address on the mesh we can ping to confirm + reachability. +

+ {error &&

{error}

} {testResult && (