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/db/index.ts b/backend/src/db/index.ts index ac264c6..a6a9104 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -112,8 +112,28 @@ db.exec(` reported_at TEXT, received_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS system_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) +export function getConfig(key: string): string | undefined { + const row = db.prepare('SELECT value FROM system_config WHERE key = ?').get(key) as + | { value: string } + | undefined + return row?.value +} + +export function setConfig(key: string, value: string) { + db.prepare( + `INSERT INTO system_config (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')` + ).run(key, value) +} + export function logEvent(type: string, title: string, source?: string | null) { db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null) } diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts new file mode 100644 index 0000000..626132e --- /dev/null +++ b/backend/src/routes/system.ts @@ -0,0 +1,138 @@ +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()) + if (!match) return null + 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 } +} + +function ipToInt(ip: string): number | 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]! +} + +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 { + for (const addrs of Object.values(networkInterfaces())) { + for (const addr of addrs ?? []) { + 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), testIp: z.string().min(1).optional() }) + +export async function systemRoutes(app: FastifyInstance) { + app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => { + return meshStatusPayload() + }) + + // Verification is mesh-tech-agnostic: any overlay network (NetBird, WireGuard, + // 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) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const cidr = parseCidr(parsed.data.cidr) + if (!cidr) return reply.code(400).send({ error: 'Invalid CIDR — expected format like 100.64.0.0/10' }) + + const hostMeshIp = findHostIpInCidr(cidr) + setConfig('mesh.cidr', 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}` + } + + 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) => { + setConfig('mesh.overrideUntil', 'permanent') + logEvent('mesh_override', `Mesh gate skipped by admin (${(req.user as { username: string }).username})`) + return meshStatusPayload() + }) + + app.put('/api/system/mesh/required', { onRequest: [app.requireAdmin] }, async (req) => { + const { required } = req.body as { required: boolean } + setConfig('mesh.required', required ? 'true' : 'false') + logEvent('mesh_required_changed', `Mesh requirement set to ${required ? 'on' : 'off'}`) + return meshStatusPayload() + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 7d4653b..f20d074 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,6 +18,7 @@ import { guacamoleRoutes } from './routes/guacamole.js' import { metricsRoutes } from './routes/metrics.js' import { transferRoutes } from './routes/transfer.js' import { dataRoutes } from './routes/data.js' +import { systemRoutes } from './routes/system.js' import { startAutoStartTunnels } from './tunnels/manager.js' import { db } from './db/index.js' @@ -99,6 +100,7 @@ await app.register(guacamoleRoutes) await app.register(metricsRoutes) await app.register(transferRoutes) await app.register(dataRoutes) +await app.register(systemRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/src/App.tsx b/src/App.tsx index 628d871..2902e14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import Settings from './pages/Settings' import Help from './pages/Help' import Login from './pages/Login' import Enrollment from './pages/Enrollment' +import MeshGate from './pages/MeshGate' import { useAuth } from './lib/AuthContext' function App() { @@ -29,11 +30,14 @@ function App() { } if (status === 'needs-setup' || status === 'enrolling') return if (status === 'logged-out') return + if (status === 'needs-mesh') return return } function Dashboard() { + const { user, meshStatus } = useAuth() + const showMeshNotice = !!meshStatus && meshStatus.required && !meshStatus.verified && !meshStatus.overridden && user?.role !== 'admin' const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const sidebarWidth = sidebarCollapsed ? 64 : 200 const location = useLocation() @@ -79,6 +83,22 @@ function Dashboard() { + {showMeshNotice && ( +
+ Mesh setup is still in progress — an admin needs to finish verifying the network. Some features may be limited. +
+ )} +
Promise completeSetup: (username: string, password: string) => Promise finishEnrollment: () => Promise + refreshMeshStatus: () => Promise logout: () => void setUser: (user: AuthUser) => void } @@ -18,13 +20,19 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState('loading') const [user, setUser] = useState(null) + const [meshStatus, setMeshStatus] = useState(null) async function refresh() { if (getToken()) { try { const { user } = await api.me() setUser(user) - setStatus('logged-in') + const mesh = await api.getMeshStatus().catch(() => null) + setMeshStatus(mesh) + // Members are let in with a "setup in progress" notice instead of being gated — + // only an admin can actually fix mesh config, so only admins see the full gate. + const gateBlocks = !!mesh && mesh.required && !mesh.verified && !mesh.overridden + setStatus(gateBlocks && user.role === 'admin' ? 'needs-mesh' : 'logged-in') return } catch { setToken(null) @@ -34,6 +42,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setStatus(needsSetup ? 'needs-setup' : 'logged-out') } + async function refreshMeshStatus() { + await refresh() + } + useEffect(() => { refresh() }, []) @@ -61,11 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { api.logout().catch(() => {}) setToken(null) setUser(null) + setMeshStatus(null) setStatus('logged-out') } return ( - + {children} ) diff --git a/src/lib/api.ts b/src/lib/api.ts index 0c3c374..d945e03 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -58,6 +58,16 @@ export const api = { logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }), listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`), + getMeshStatus: () => apiFetch('/system/mesh-status'), + verifyMesh: (cidr: string, testIp?: string) => + apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', { + method: 'POST', + body: JSON.stringify({ cidr, testIp }), + }), + overrideMesh: () => apiFetch('/system/mesh/override', { method: 'POST' }), + setMeshRequired: (required: boolean) => + apiFetch('/system/mesh/required', { method: 'PUT', body: JSON.stringify({ required }) }), + listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'), createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) => apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }), @@ -237,6 +247,16 @@ export interface AuthSession { current: boolean } +export interface MeshStatus { + required: boolean + verified: boolean + verifiedAt: string | null + verifiedVia: 'local-ip' | 'reachable' | null + overridden: boolean + cidr: string | null + hostMeshIp: string | null +} + export interface LoginEvent { id: number username: string | null diff --git a/src/pages/MeshGate.tsx b/src/pages/MeshGate.tsx new file mode 100644 index 0000000..87fae76 --- /dev/null +++ b/src/pages/MeshGate.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react' +import { Network, ShieldAlert } from 'lucide-react' +import { useAuth } from '../lib/AuthContext' +import { api, ApiError } from '../lib/api' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.12)', + borderRadius: '14px', + padding: '32px', +} + +const fieldLabel: React.CSSProperties = { + fontSize: '11px', + color: '#7A7D85', + marginBottom: '6px', + display: 'block', +} + +const fieldInput: React.CSSProperties = { + width: '100%', + height: '36px', + borderRadius: '8px', + border: '1px solid rgba(200,164,52,0.12)', + backgroundColor: 'rgba(255,255,255,0.03)', + color: '#E8E6E0', + fontSize: '13px', + padding: '0 12px', + outline: 'none', +} + +const goldButton: React.CSSProperties = { + height: '38px', + borderRadius: '8px', + border: 'none', + fontSize: '13px', + fontWeight: 600, + color: '#0A0B0D', + backgroundColor: '#C8A434', + boxShadow: '0 0 14px rgba(200,164,52,0.2)', + padding: '0 20px', +} + +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) + const [overriding, setOverriding] = useState(false) + + async function handleVerify(e: React.FormEvent) { + e.preventDefault() + setError(null) + setTestResult(null) + setSubmitting(true) + try { + const result = await api.verifyMesh(cidr, testIp || undefined) + setTestResult(result) + if (result.ok) await refreshMeshStatus() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to verify mesh') + } finally { + setSubmitting(false) + } + } + + async function handleOverride() { + setOverriding(true) + try { + await api.overrideMesh() + await refreshMeshStatus() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to skip mesh setup') + } finally { + setOverriding(false) + } + } + + return ( +
+
+
+
+ +

+ Mesh Setup Required +

+
+

+ ArchNest expects this host to be on a private mesh network before the rest of the app + 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. +

+ + + setCidr(e.target.value)} + placeholder="e.g. 100.64.0.0/10 (NetBird/Tailscale CGNAT range)" + 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 && ( +

+ {testResult.message} + {testResult.hostMeshIp && ` — this host's mesh IP: ${testResult.hostMeshIp}`} +

+ )} + + + +
+ +
+

+ Mesh provider down, or setting this up later? You can skip this check for now — it stays + skipped until you re-enable it from Settings. +

+ +
+
+
+
+
+ ) +}