Add reachability fallback for routed meshes (VPC peering, etc.)

A host can be on the mesh's "side" of a routed network (e.g. a VPC peered
into a NetBird/WireGuard mesh) without holding a local IP in the mesh's
own CIDR. Local-IP-in-CIDR stays the primary check; if it fails, the admin
can supply a known peer/gateway IP on the mesh and we verify by pinging
it instead. Adds iputils to the backend image for the ping binary.
This commit is contained in:
Claude 2026-06-20 21:26:25 +00:00
parent 0409159327
commit 800072ffbb
No known key found for this signature in database
4 changed files with 75 additions and 21 deletions

View file

@ -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

View file

@ -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<boolean> {
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) => {

View file

@ -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: (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<MeshStatus>('/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

View file

@ -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<string | null>(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
/>
<label style={{ ...fieldLabel, marginTop: '14px' }}>Peer/Gateway IP on the mesh (optional)</label>
<input
style={fieldInput}
value={testIp}
onChange={(e) => 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"
/>
<p style={{ fontSize: '10px', color: '#5C5F66', marginTop: '6px' }}>
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.
</p>
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
{testResult && (
<p style={{ fontSize: '12px', color: testResult.ok ? '#2ECC71' : '#E74C3C', marginTop: '12px' }}>