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.
|
# 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
|
# openssh-client provides the `ssh` binary, which node-pty shells out to for
|
||||||
# certificate-based auth (ssh2 has no OpenSSH certificate support).
|
# 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* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import { networkInterfaces } from 'node:os'
|
import { networkInterfaces } from 'node:os'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getConfig, setConfig, logEvent } from '../db/index.js'
|
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. */
|
/** Parses "a.b.c.d/n" into a 32-bit base int + prefix length. */
|
||||||
function parseCidr(cidr: string): { base: number; prefix: number } | null {
|
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())
|
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]!
|
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. */
|
/** 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 {
|
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' && ipInCidr(addr.address, cidr)) 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
|
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() {
|
function meshStatusPayload() {
|
||||||
const required = getConfig('mesh.required') === 'true'
|
const required = getConfig('mesh.required') === 'true'
|
||||||
const cidr = getConfig('mesh.cidr') ?? null
|
const cidr = getConfig('mesh.cidr') ?? null
|
||||||
const verifiedAt = getConfig('mesh.verifiedAt')
|
const verifiedAt = getConfig('mesh.verifiedAt')
|
||||||
|
const verifiedVia = getConfig('mesh.verifiedVia') ?? null
|
||||||
const overridden = getConfig('mesh.overrideUntil') === 'permanent'
|
const overridden = getConfig('mesh.overrideUntil') === 'permanent'
|
||||||
const parsed = cidr ? parseCidr(cidr) : null
|
const parsed = cidr ? parseCidr(cidr) : null
|
||||||
return {
|
return {
|
||||||
required,
|
required,
|
||||||
verified: !!verifiedAt,
|
verified: !!verifiedAt,
|
||||||
verifiedAt: verifiedAt ?? null,
|
verifiedAt: verifiedAt ?? null,
|
||||||
|
verifiedVia,
|
||||||
overridden,
|
overridden,
|
||||||
cidr,
|
cidr,
|
||||||
hostMeshIp: parsed ? findHostIpInCidr(parsed) : null,
|
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) {
|
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 () => {
|
||||||
|
|
@ -58,8 +82,9 @@ export async function systemRoutes(app: FastifyInstance) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verification is mesh-tech-agnostic: any overlay network (NetBird, WireGuard,
|
// Verification is mesh-tech-agnostic: any overlay network (NetBird, WireGuard,
|
||||||
// ZeroTier, Tailscale...) assigns this host an IP in its own range. We just check
|
// ZeroTier, Tailscale...) assigns this host an IP in its own range — checked first.
|
||||||
// the host has an address inside the admin-supplied CIDR — no vendor API needed.
|
// 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) => {
|
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) {
|
||||||
|
|
@ -71,18 +96,31 @@ export async function systemRoutes(app: FastifyInstance) {
|
||||||
const hostMeshIp = findHostIpInCidr(cidr)
|
const hostMeshIp = findHostIpInCidr(cidr)
|
||||||
setConfig('mesh.cidr', parsed.data.cidr)
|
setConfig('mesh.cidr', parsed.data.cidr)
|
||||||
|
|
||||||
if (hostMeshIp) {
|
let ok = !!hostMeshIp
|
||||||
setConfig('mesh.verifiedAt', new Date().toISOString())
|
let message = hostMeshIp ? `Host is on the mesh (${hostMeshIp})` : 'No host IP found in that range'
|
||||||
logEvent('mesh_verified', `Mesh verified — host has ${hostMeshIp} in ${parsed.data.cidr}`)
|
let via: 'local-ip' | 'reachable' | null = hostMeshIp ? 'local-ip' : null
|
||||||
} else {
|
|
||||||
logEvent('mesh_verify_failed', `Mesh verification failed — no host IP found in ${parsed.data.cidr}`)
|
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 {
|
if (ok) {
|
||||||
ok: !!hostMeshIp,
|
setConfig('mesh.verifiedAt', new Date().toISOString())
|
||||||
message: hostMeshIp ? `Host is on the mesh (${hostMeshIp})` : 'No host IP found in that range',
|
setConfig('mesh.verifiedVia', via ?? 'local-ip')
|
||||||
hostMeshIp,
|
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) => {
|
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: (cidr: string) =>
|
verifyMesh: (cidr: string, testIp?: 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({ cidr }),
|
body: JSON.stringify({ cidr, testIp }),
|
||||||
}),
|
}),
|
||||||
overrideMesh: () => apiFetch<MeshStatus>('/system/mesh/override', { method: 'POST' }),
|
overrideMesh: () => apiFetch<MeshStatus>('/system/mesh/override', { method: 'POST' }),
|
||||||
setMeshRequired: (required: boolean) =>
|
setMeshRequired: (required: boolean) =>
|
||||||
|
|
@ -251,6 +251,7 @@ export interface MeshStatus {
|
||||||
required: boolean
|
required: boolean
|
||||||
verified: boolean
|
verified: boolean
|
||||||
verifiedAt: string | null
|
verifiedAt: string | null
|
||||||
|
verifiedVia: 'local-ip' | 'reachable' | null
|
||||||
overridden: boolean
|
overridden: boolean
|
||||||
cidr: string | null
|
cidr: string | null
|
||||||
hostMeshIp: string | null
|
hostMeshIp: string | null
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const goldButton: React.CSSProperties = {
|
||||||
export default function MeshGate() {
|
export default function MeshGate() {
|
||||||
const { refreshMeshStatus } = useAuth()
|
const { refreshMeshStatus } = useAuth()
|
||||||
const [cidr, setCidr] = useState('')
|
const [cidr, setCidr] = useState('')
|
||||||
|
const [testIp, setTestIp] = 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)
|
||||||
|
|
@ -55,7 +56,7 @@ export default function MeshGate() {
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const result = await api.verifyMesh(cidr)
|
const result = await api.verifyMesh(cidr, testIp || undefined)
|
||||||
setTestResult(result)
|
setTestResult(result)
|
||||||
if (result.ok) await refreshMeshStatus()
|
if (result.ok) await refreshMeshStatus()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -110,6 +111,19 @@ export default function MeshGate() {
|
||||||
required
|
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>}
|
{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