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:
parent
0409159327
commit
800072ffbb
4 changed files with 75 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue