From 46d95fca61dd6f423b716d16fcd9a86c8aabbfd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 21:15:55 +0000 Subject: [PATCH 01/13] Add mesh prerequisite gate (NetBird verification before app config) Implements the design in docs/mesh-prerequisite-gate.md per the user's DECIDE A-D answers: a permanent admin override, B1 (reachable) verification with host mesh IP shown informationally, members allowed in with a notice instead of being blocked, and mesh.required defaulting off so the live production instance is unaffected. - system_config kv table + getConfig/setConfig helpers - /api/system/mesh-status, /mesh/verify, /mesh/override, /mesh/required - AuthContext gains a 'needs-mesh' status (admins only) and exposes meshStatus for a member-facing banner - MeshGate page reuses the integration create+test flow to connect NetBird --- backend/src/db/index.ts | 20 +++++ backend/src/routes/system.ts | 96 ++++++++++++++++++++ backend/src/server.ts | 2 + src/App.tsx | 20 +++++ src/lib/AuthContext.tsx | 23 ++++- src/lib/api.ts | 19 ++++ src/pages/MeshGate.tsx | 166 +++++++++++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 backend/src/routes/system.ts create mode 100644 src/pages/MeshGate.tsx 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..e357f2a --- /dev/null +++ b/backend/src/routes/system.ts @@ -0,0 +1,96 @@ +import type { FastifyInstance } from 'fastify' +import { networkInterfaces } from 'node:os' +import { z } from 'zod' +import { db, getConfig, setConfig, logEvent } from '../db/index.js' +import { loadSecrets } from '../db/secrets.js' +import { adapterRegistry } from '../integrations/registry.js' +import type { IntegrationType } from '../integrations/types.js' + +interface IntegrationRow { + id: number + type: string + name: string + config_json: string +} + +/** NetBird's default CGNAT mesh range (100.64.0.0/10) — informational only (DECIDE B2). */ +function detectHostMeshIp(): string | null { + for (const addrs of Object.values(networkInterfaces())) { + for (const addr of addrs ?? []) { + if (addr.family !== 'IPv4') continue + const parts = addr.address.split('.').map(Number) + if (parts[0] === 100 && parts[1] !== undefined && parts[1] >= 64 && parts[1] <= 127) { + return addr.address + } + } + } + return null +} + +function meshStatusPayload() { + const required = getConfig('mesh.required') === 'true' + const meshIntegrationId = getConfig('mesh.integrationId') + const verifiedAt = getConfig('mesh.verifiedAt') + const overridden = getConfig('mesh.overrideUntil') === 'permanent' + return { + required, + verified: !!verifiedAt, + verifiedAt: verifiedAt ?? null, + overridden, + meshIntegrationId: meshIntegrationId ? Number(meshIntegrationId) : null, + hostMeshIp: detectHostMeshIp(), + } +} + +const verifySchema = z.object({ integrationId: z.number().int() }) + +export async function systemRoutes(app: FastifyInstance) { + app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => { + return meshStatusPayload() + }) + + 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 row = db.prepare('SELECT * FROM integrations WHERE id = ? AND type = ?').get( + parsed.data.integrationId, + 'netbird' + ) as IntegrationRow | undefined + if (!row) return reply.code(404).send({ error: 'NetBird integration not found' }) + + const adapter = adapterRegistry[row.type as IntegrationType] + const config = JSON.parse(row.config_json) + const secrets = loadSecrets(row.id) + const result = await adapter.testConnection(config, secrets) + + db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run( + result.ok ? 'connected' : 'error', + row.id + ) + + if (result.ok) { + setConfig('mesh.integrationId', String(row.id)) + setConfig('mesh.verifiedAt', new Date().toISOString()) + logEvent('mesh_verified', `Mesh verified via ${row.name}`, row.type) + } else { + logEvent('mesh_verify_failed', `Mesh verification failed via ${row.name}: ${result.message}`, row.type) + } + + return { ...result, hostMeshIp: detectHostMeshIp() } + }) + + 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..1e4242d 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: (integrationId: number) => + apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', { + method: 'POST', + body: JSON.stringify({ integrationId }), + }), + 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,15 @@ export interface AuthSession { current: boolean } +export interface MeshStatus { + required: boolean + verified: boolean + verifiedAt: string | null + overridden: boolean + meshIntegrationId: number | 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..1281fb7 --- /dev/null +++ b/src/pages/MeshGate.tsx @@ -0,0 +1,166 @@ +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 [baseUrl, setBaseUrl] = useState('') + const [apiKey, setApiKey] = 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 { integration } = await api.createIntegration({ + type: 'netbird', + name: 'NetBird', + config: baseUrl ? { baseUrl } : {}, + secrets: apiKey ? { apiKey } : {}, + }) + const result = await api.verifyMesh(integration.id) + 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 a verified NetBird mesh before the rest of the app can be configured. + Connect your mesh below to continue. +

+ + + setBaseUrl(e.target.value)} + placeholder="https://api.netbird.io" + /> + + + setApiKey(e.target.value)} required /> + + {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. +

+ +
+
+
+
+
+ ) +} From 04091593270cdcec1f985edebc56953284751dd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 21:22:06 +0000 Subject: [PATCH 02/13] Make mesh verification universal (CIDR check, not NetBird-specific) Replace the NetBird-adapter-based "reachable" check with a vendor-agnostic one: the admin supplies the mesh's IP range (CIDR), and verification just confirms this host has an address inside it. Works identically for NetBird, WireGuard, ZeroTier, Tailscale, or any other mesh tech, with no integration record or vendor API call required. --- backend/src/routes/system.ts | 80 +++++++++++++++++++----------------- src/lib/api.ts | 6 +-- src/pages/MeshGate.tsx | 28 +++++-------- 3 files changed, 55 insertions(+), 59 deletions(-) diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts index e357f2a..f2e9fa7 100644 --- a/backend/src/routes/system.ts +++ b/backend/src/routes/system.ts @@ -1,27 +1,34 @@ import type { FastifyInstance } from 'fastify' import { networkInterfaces } from 'node:os' import { z } from 'zod' -import { db, getConfig, setConfig, logEvent } from '../db/index.js' -import { loadSecrets } from '../db/secrets.js' -import { adapterRegistry } from '../integrations/registry.js' -import type { IntegrationType } from '../integrations/types.js' +import { getConfig, setConfig, logEvent } from '../db/index.js' -interface IntegrationRow { - id: number - type: string - name: string - config_json: string +/** 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 } } -/** NetBird's default CGNAT mesh range (100.64.0.0/10) — informational only (DECIDE B2). */ -function detectHostMeshIp(): string | null { +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]! +} + +/** 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 parts = addr.address.split('.').map(Number) - if (parts[0] === 100 && parts[1] !== undefined && parts[1] >= 64 && parts[1] <= 127) { - 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 @@ -29,56 +36,53 @@ function detectHostMeshIp(): string | null { function meshStatusPayload() { const required = getConfig('mesh.required') === 'true' - const meshIntegrationId = getConfig('mesh.integrationId') + const cidr = getConfig('mesh.cidr') ?? null const verifiedAt = getConfig('mesh.verifiedAt') const overridden = getConfig('mesh.overrideUntil') === 'permanent' + const parsed = cidr ? parseCidr(cidr) : null return { required, verified: !!verifiedAt, verifiedAt: verifiedAt ?? null, overridden, - meshIntegrationId: meshIntegrationId ? Number(meshIntegrationId) : null, - hostMeshIp: detectHostMeshIp(), + cidr, + hostMeshIp: parsed ? findHostIpInCidr(parsed) : null, } } -const verifySchema = z.object({ integrationId: z.number().int() }) +const verifySchema = z.object({ cidr: z.string().min(1) }) 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. We just check + // the host has an address inside the admin-supplied CIDR — no vendor API needed. 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 row = db.prepare('SELECT * FROM integrations WHERE id = ? AND type = ?').get( - parsed.data.integrationId, - 'netbird' - ) as IntegrationRow | undefined - if (!row) return reply.code(404).send({ error: 'NetBird integration not found' }) + 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 adapter = adapterRegistry[row.type as IntegrationType] - const config = JSON.parse(row.config_json) - const secrets = loadSecrets(row.id) - const result = await adapter.testConnection(config, secrets) + const hostMeshIp = findHostIpInCidr(cidr) + setConfig('mesh.cidr', parsed.data.cidr) - db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run( - result.ok ? 'connected' : 'error', - row.id - ) - - if (result.ok) { - setConfig('mesh.integrationId', String(row.id)) + if (hostMeshIp) { setConfig('mesh.verifiedAt', new Date().toISOString()) - logEvent('mesh_verified', `Mesh verified via ${row.name}`, row.type) + logEvent('mesh_verified', `Mesh verified — host has ${hostMeshIp} in ${parsed.data.cidr}`) } else { - logEvent('mesh_verify_failed', `Mesh verification failed via ${row.name}: ${result.message}`, row.type) + logEvent('mesh_verify_failed', `Mesh verification failed — no host IP found in ${parsed.data.cidr}`) } - return { ...result, hostMeshIp: detectHostMeshIp() } + return { + ok: !!hostMeshIp, + message: hostMeshIp ? `Host is on the mesh (${hostMeshIp})` : 'No host IP found in that range', + 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 1e4242d..5985007 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: (integrationId: number) => + verifyMesh: (cidr: string) => apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', { method: 'POST', - body: JSON.stringify({ integrationId }), + body: JSON.stringify({ cidr }), }), overrideMesh: () => apiFetch('/system/mesh/override', { method: 'POST' }), setMeshRequired: (required: boolean) => @@ -252,7 +252,7 @@ export interface MeshStatus { verified: boolean verifiedAt: string | null overridden: boolean - meshIntegrationId: number | null + cidr: string | null hostMeshIp: string | null } diff --git a/src/pages/MeshGate.tsx b/src/pages/MeshGate.tsx index 1281fb7..7bd0e4f 100644 --- a/src/pages/MeshGate.tsx +++ b/src/pages/MeshGate.tsx @@ -43,8 +43,7 @@ const goldButton: React.CSSProperties = { export default function MeshGate() { const { refreshMeshStatus } = useAuth() - const [baseUrl, setBaseUrl] = useState('') - const [apiKey, setApiKey] = useState('') + const [cidr, setCidr] = useState('') const [error, setError] = useState(null) const [testResult, setTestResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null) const [submitting, setSubmitting] = useState(false) @@ -56,13 +55,7 @@ export default function MeshGate() { setTestResult(null) setSubmitting(true) try { - const { integration } = await api.createIntegration({ - type: 'netbird', - name: 'NetBird', - config: baseUrl ? { baseUrl } : {}, - secrets: apiKey ? { apiKey } : {}, - }) - const result = await api.verifyMesh(integration.id) + const result = await api.verifyMesh(cidr) setTestResult(result) if (result.ok) await refreshMeshStatus() } catch (err) { @@ -103,21 +96,20 @@ export default function MeshGate() {

- ArchNest expects a verified NetBird mesh before the rest of the app can be configured. - Connect your mesh below to continue. + 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.

- + setBaseUrl(e.target.value)} - placeholder="https://api.netbird.io" + value={cidr} + onChange={(e) => setCidr(e.target.value)} + placeholder="e.g. 100.64.0.0/10 (NetBird/Tailscale CGNAT range)" + required /> - - setApiKey(e.target.value)} required /> - {error &&

{error}

} {testResult && (

From 800072ffbba7c4c2b95e7e136df8dd66c938b838 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 21:26:25 +0000 Subject: [PATCH 03/13] 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. --- backend/Dockerfile | 3 +- backend/src/routes/system.ts | 72 +++++++++++++++++++++++++++--------- src/lib/api.ts | 5 ++- src/pages/MeshGate.tsx | 16 +++++++- 4 files changed, 75 insertions(+), 21 deletions(-) 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 && (

From 4a4a5a01b35c2678999949dd35edf942083455e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 21:44:27 +0000 Subject: [PATCH 04/13] Add Mesh section to Settings for configuring/testing the mesh gate Admins can now toggle mesh.required, run verify/override, and see current mesh status entirely from the app, without hitting the API directly. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_019hu9pZvJY4BgmcQeAw2ugk --- src/pages/Settings.tsx | 174 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 1 deletion(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index dfd0445..5a4130a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser } from '../lib/api' +import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser, type MeshStatus } from '../lib/api' import { useAuth } from '../lib/AuthContext' import { User, @@ -23,6 +23,7 @@ import { LogOut, Users, UserPlus, + Network, } from 'lucide-react' const navSections = [ @@ -30,6 +31,7 @@ const navSections = [ { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'security', label: 'Security', icon: Shield }, { id: 'users', label: 'Users', icon: Users, adminOnly: true }, + { id: 'mesh', label: 'Mesh', icon: Network, adminOnly: true }, { id: 'integrations', label: 'Integrations', icon: Plug }, { id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'data', label: 'Data & Backup', icon: Database }, @@ -1804,11 +1806,181 @@ function UsersSection() { ) } +function MeshSection() { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [toggling, setToggling] = useState(false) + + const [cidr, setCidr] = useState('') + const [testIp, setTestIp] = useState('') + const [verifying, setVerifying] = useState(false) + const [verifyResult, setVerifyResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null) + const [overriding, setOverriding] = useState(false) + + async function load() { + try { + const s = await api.getMeshStatus() + setStatus(s) + setCidr(s.cidr ?? '') + setError('') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to load mesh status') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + async function handleToggle() { + if (!status) return + setToggling(true) + try { + const s = await api.setMeshRequired(!status.required) + setStatus(s) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to update mesh requirement') + } finally { + setToggling(false) + } + } + + async function handleVerify() { + setError('') + setVerifyResult(null) + setVerifying(true) + try { + const result = await api.verifyMesh(cidr, testIp || undefined) + setVerifyResult(result) + load() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to verify mesh') + } finally { + setVerifying(false) + } + } + + async function handleOverride() { + setOverriding(true) + try { + const s = await api.overrideMesh() + setStatus(s) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to skip mesh setup') + } finally { + setOverriding(false) + } + } + + if (loading) { + return ( +

+
+

Loading…

+
+
+ ) + } + + return ( +
+
+
+

Mesh Network Gate

+
+ {status?.required ? 'Required' : 'Not required'} + +
+
+

+ When enabled, admins must verify this host is on a private mesh network (NetBird, WireGuard, + ZeroTier, Tailscale, etc.) before accessing the rest of the app. Members are never blocked — they + just see a notice banner until an admin finishes verification. +

+ {toggling &&

Updating…

} + + {status && ( +
+

+ Verified: {status.verified ? 'Yes' : 'No'} + {status.verifiedVia && ` (${status.verifiedVia})`} +

+ {status.cidr &&

CIDR: {status.cidr}

} + {status.hostMeshIp &&

Host mesh IP: {status.hostMeshIp}

} +

+ Override: {status.overridden ? 'Active (gate skipped)' : 'None'} +

+
+ )} +
+ +
+

Configure & Verify

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

} + {verifyResult && ( +

+ {verifyResult.message} + {verifyResult.hostMeshIp && ` — host mesh IP: ${verifyResult.hostMeshIp}`} +

+ )} + +
+ + {verifying ? 'Verifying…' : 'Verify Connection'} + + +
+
+
+ ) +} + const sectionComponents: Record React.ReactElement> = { profile: ProfileSection, appearance: AppearanceSection, security: SecuritySection, users: UsersSection, + mesh: MeshSection, integrations: IntegrationsSection, notifications: NotificationsSection, data: DataBackupSection, From 628187befb5d06d79645a22f03108d4c629274d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 22:15:43 +0000 Subject: [PATCH 05/13] Show a host-specific Docker remote-API setup script in Settings When adding/editing a Docker integration with a tcp:// or http:// remote URL, display a copyable systemd override + curl verification script scoped to the entered host:port, so enabling the daemon's API doesn't require looking up the steps separately. --- src/pages/Settings.tsx | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 5a4130a..c77e0d7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -784,6 +784,77 @@ function SshHostsSection() { type NewIntegrationDraft = { id: number; type: string; values: Record } +function dockerHostInfo(baseUrl: string): { host: string; port: string } | null { + if (!baseUrl || baseUrl.startsWith('unix://')) return null + try { + const u = new URL(baseUrl.replace(/^tcp:\/\//, 'http://')) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null + if (!u.hostname) return null + return { host: u.hostname, port: u.port || '2375' } + } catch { + return null + } +} + +function DockerSetupHint({ baseUrl }: { baseUrl: string }) { + const [copied, setCopied] = useState(false) + const info = dockerHostInfo(baseUrl) + if (!info) return null + + const script = `sudo mkdir -p /etc/systemd/system/docker.service.d +sudo tee /etc/systemd/system/docker.service.d/override.conf > /dev/null < +
+ + Run this on {info.host} to expose its Docker API on port {info.port}: + + +
+
+        {script}
+      
+ + ) +} + function IntegrationsSection() { const { user } = useAuth() const isAdmin = user?.role === 'admin' @@ -1051,6 +1122,9 @@ function IntegrationsSection() { '••••••••••••', existing, )} + {def.type === 'docker' && ( + + )} ) })} @@ -1100,6 +1174,7 @@ function IntegrationsSection() { (f, value) => setNewDraftField(draft.id, f.key, value), '', )} + {def.type === 'docker' && } ) })} From 36a79abe94a2a6400dc28f62d241fa0fc9920042 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:28:54 +0000 Subject: [PATCH 06/13] Expand Help page with quick-start guide and real-world examples Adds a quick-start ordering card and per-feature example callouts (with icons) so first-time users see concrete use cases, not just descriptions. --- src/pages/Help.tsx | 123 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index d5cf4dc..9d72b0a 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -10,6 +10,11 @@ import { Gauge, Settings, Search, + Lightbulb, + ArrowRightLeft, + ArrowLeftRight, + Shuffle, + Rocket, type LucideIcon, } from 'lucide-react' @@ -25,6 +30,7 @@ interface GuideEntry { title: string description: string tips?: string[] + examples?: { icon?: LucideIcon; label?: string; text: string }[] } const guideEntries: GuideEntry[] = [ @@ -34,6 +40,9 @@ const guideEntries: GuideEntry[] = [ description: 'The home dashboard. Shows overall system health, a rollup of connected integrations, recent activity, and shortcuts into the rest of the app.', tips: ['Click "Connected Integrations" entries to jump straight to Infrastructure.'], + examples: [ + { text: 'First thing in the morning: open Glance to see if anything went offline overnight before digging into any one page.' }, + ], }, { icon: Server, @@ -41,6 +50,9 @@ const guideEntries: GuideEntry[] = [ description: 'Lists every connected integration (Proxmox, AWS, Docker, NetBird, Cloudflare, Uptime Kuma, Weather, SSH hosts) and the live resources/health each one reports.', tips: ['Add new integrations from Settings → Integrations — they show up here automatically.'], + examples: [ + { text: 'Connect Proxmox and AWS, then check VM and EC2 health side by side without opening either provider\'s own dashboard.' }, + ], }, { icon: Bookmark, @@ -50,61 +62,139 @@ const guideEntries: GuideEntry[] = [ 'Icons are auto-detected from the title or URL (e.g. typing "Proxmox" picks up the real Proxmox logo) — pick "Choose manually" if it guesses wrong.', 'Star a bookmark to pin it to the Favorites panel.', ], + examples: [ + { text: 'Organize your router admin page, NAS UI, and internal wiki into "Network", "Storage", and "Docs" categories so new teammates can find them without asking.' }, + ], }, { icon: Terminal, title: 'Terminal', description: 'A full SSH terminal to any host you\'ve added as an integration — supports tabs, split panes, jump hosts, and certificate auth.', tips: ['Session output can be logged; theme and font preferences are remembered between visits.'], + examples: [ + { text: 'SSH into a public-facing jump host, then open a second tab routed through it to reach a private VM that has no public IP of its own.' }, + ], }, { icon: Waypoints, title: 'Tunnels', - description: 'Local, remote, and dynamic (SOCKS5) SSH tunnels. Tunnels can be set to auto-start whenever the backend boots.', + description: + 'Local, remote, and dynamic (SOCKS5) SSH tunnels, each riding on top of an existing SSH Host integration. Tunnels can be set to auto-start whenever the backend boots, and the app keeps retrying if a connection drops.', + tips: ['Pick an SSH host from Settings → Integrations → SSH Hosts first — tunnels are created on top of one.'], + examples: [ + { + icon: ArrowRightLeft, + label: 'Local Forward', + text: 'A database on a remote server only listens on its own localhost. Forward remote 5432 to your local 5432, and connect a DB client as if it were running on your machine.', + }, + { + icon: ArrowLeftRight, + label: 'Remote Forward', + text: 'You\'re running something on your laptop (e.g. a dev server on :3000) and need a remote server to reach it temporarily — remote forward exposes your local port on the remote side.', + }, + { + icon: Shuffle, + label: 'Dynamic (SOCKS5)', + text: 'Point your browser\'s proxy settings at the SOCKS5 port and browse as if you were sitting inside the remote network — useful for reaching a whole subnet of internal services through one SSH host, instead of forwarding each port individually.', + }, + ], }, { icon: FolderOpen, title: 'Files', description: 'Browse, edit, upload, and download files over SFTP on any connected SSH host — and transfer files directly between two hosts without round-tripping through your machine.', tips: ['Use the "Send to another host" action on a file row to start a host-to-host transfer; progress shows live in the panel at the bottom.'], + examples: [ + { text: 'Move a large backup folder from one VPS straight to another, without downloading gigabytes to your laptop first just to re-upload them.' }, + ], }, { icon: Box, title: 'Containers', - description: 'Manage Docker containers on remote hosts — start, stop, view logs, and exec into a running container.', + description: 'Manage Docker containers on remote hosts — start, stop, view logs, and exec into a running container. Containers can come from a direct Docker API connection, an SSH host (runs the `docker` CLI for you), or a lightweight monitoring agent.', + tips: ['No Docker API port to expose? Add the host as an SSH Host integration instead — containers show up the same way, just routed through SSH.'], + examples: [ + { text: 'A container crashed on a VM you don\'t want to manually SSH into — pick the host on the Containers page, hit restart, and watch the new logs without leaving the browser.' }, + ], }, { icon: MonitorSmartphone, title: 'Remote Desktop', description: 'RDP, VNC, and Telnet sessions to remote machines, streamed through the built-in Guacamole proxy — no separate client needed.', + examples: [ + { text: 'Open a VNC session into a headless Ubuntu box to fix a stuck GUI app, entirely inside the browser tab — no VNC viewer install required.' }, + ], }, { icon: Gauge, title: 'Host Metrics', description: 'Live CPU, memory, disk, network, listening-port, firewall, process, and login-activity widgets for any SSH-managed host.', + examples: [ + { text: 'Catch a host slowly running out of disk space days before it takes a service down with it.' }, + ], }, { icon: Settings, title: 'Settings', description: 'Your profile, integrations, appearance, notifications, and full data export/import (back up or migrate every integration, bookmark, and tunnel as a single JSON file).', + examples: [ + { text: 'Export everything before reinstalling the OS on the box ArchNest itself runs on, then import it on the fresh install to get back to where you left off.' }, + ], }, { icon: Search, title: 'Search (top bar)', description: 'The search box at the top of every page looks across pages, integrations, and bookmarks at once — press Enter to jump to the top match.', + examples: [ + { text: 'Type "proxmox" and hit Enter to jump straight to your Proxmox integration, instead of clicking through to Infrastructure first.' }, + ], }, ] +const quickStartSteps = [ + 'Add at least one integration in Settings → Integrations — an SSH Host is the easiest first connection, since Terminal, Files, Tunnels, Host Metrics, and Containers can all use it.', + 'Open Terminal or Files to confirm the connection actually works end to end.', + 'If you need to reach something deeper in a private network (a DB, an internal site, a whole subnet), set up a Tunnel for it instead of opening more ports.', + 'Bookmark the dashboards and tools you check often in BookNest, so the next visit is one click instead of a search.', +] + export default function Help() { return (
-
+

How ArchNest works

- A quick tour of every page and what it's for. Use the sidebar to navigate, or the search bar at the top to jump straight to something. + A quick tour of every page and what it's for, with real examples for each. Use the sidebar to navigate, or the + search bar at the top to jump straight to something.

+
+
+
+ +
+

New here? Start in this order

+
+
    + {quickStartSteps.map((step, i) => ( +
  1. + {step} +
  2. + ))} +
+
+
{guideEntries.map((entry) => { const Icon = entry.icon @@ -136,6 +226,31 @@ export default function Help() { ))} )} + {entry.examples && entry.examples.length > 0 && ( +
+ {entry.examples.map((ex, i) => { + const ExIcon = ex.icon ?? Lightbulb + return ( +
+ +

+ {ex.label && {ex.label}: } + {ex.text} +

+
+ ) + })} +
+ )}
) })} From 5703f930279e14cb4aef046004fcbeebad977151 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:30:00 +0000 Subject: [PATCH 07/13] Update HANDOFF/README for handoff: mesh gate shipped, Docker UX work, no feature queued Corrects the stale 'mesh gate not built' framing (it shipped across 4 commits, all merged) and documents the Docker setup-script hint + Help page expansion done this session. Leaves a clear next-task list for the picking-up agent: decide on merging claude/youthful-cerf-ibvxfb, then check with the user for the next priority. --- HANDOFF.md | 20 ++++++++++++++++---- README.md | 15 ++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 5888957..b6057e6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,6 +1,6 @@ # ArchNest — Handoff Notes -Status snapshot as of **2026-06-20**. Written so a fresh AI session (or human) can pick this up with zero prior context. Branch names rotate every session — always run `git branch --show-current` and work on a fresh feature branch off `main` (recent branches have used a `kiro/` naming pattern). +Status snapshot as of **2026-06-21**. Written so a fresh AI session (or human) can pick this up with zero prior context. Branch names rotate every session — always run `git branch --show-current` and work on a fresh feature branch off `main` (recent branches have used a `kiro/` or `claude/` naming pattern). ## TL;DR @@ -12,8 +12,17 @@ Since then, **Docker container visibility/management was expanded** (shipped, de - **Persistent SSH terminal sessions** (PR #30) — terminals stay connected across in-app page navigation. - **Docker-over-SSH management** + **Docker push-agent monitoring** (PR #31) — see the "Docker: three ways" section below. -### → NEXT TASK for the picking-up agent: the **Mesh Prerequisite Gate** -This is **designed but NOT built**. Full design + the 4 open decisions are in **`docs/mesh-prerequisite-gate.md`** — read it first. It requires a NetBird mesh to be configured/tested/verified before the rest of the app can be configured. **The hard part is lockout-safety** (a failed mesh test must never lock the admin out). **Do not start coding until the user answers DECIDE A–D in that doc** (escape-hatch behavior, what "verified" means, member behavior, and crucially whether to default the gate OFF so it doesn't immediately gate the live production instance). Use `AskUserQuestion`. +**The Mesh Prerequisite Gate is now built and shipped** (no longer the open task): NetBird-mesh-required-before-config, with universal CIDR-based verification (not NetBird-specific), a routed-mesh/VPC-peering reachability fallback, and a dedicated "Mesh" section in Settings to configure/test it. Defaults OFF, so it does not lock the live instance. Commits: `46d95fc` (gate), `0409159` (universal CIDR check), `800072f` (routed-mesh fallback), `4a4a5a0` (Settings UI) — all merged to `main`. + +Most recently (this session, real user dogfooding rather than a planned feature): walked the user through replacing a broken/insecure Docker-TCP-API integration attempt with a working **SSH Host** integration to a real VM ("Portainer VM," running Portainer + a test container), confirmed Docker-over-SSH container management works end to end, and added supporting UX: +- **Docker setup-script hint in Settings** (commit `628187b`, branch `claude/youthful-cerf-ibvxfb`, **pushed but NOT YET merged to `main`** — user explicitly deferred merging once already; revisit with the user before merging) — when editing a Docker (`type: 'docker'`) integration's `baseUrl`, Settings now renders a copyable systemd-override + `curl` verification script scoped to that exact host/port, so users don't have to hand-derive the remote-API-enablement steps themselves. +- **Help page expansion** (commit `36a79ab`, same branch, pushed) — every page entry in `src/pages/Help.tsx` now has at least one real-world example callout (icon + optional label + scenario text), plus a "New here? Start in this order" quick-start card above the grid, aimed at first-time users who don't yet know which page does what. + +### → NEXT TASK for the picking-up agent +No new feature is queued. Pick up from here: +1. **Decide with the user whether to merge `claude/youthful-cerf-ibvxfb` into `main`.** It contains the Docker setup-script hint (`628187b`) and the Help page expansion (`36a79ab`), both already build-clean (`npm run build` passes). Nothing else is blocking it. +2. **Ask the user if removing the unused Docker API integration (the one superseded by the SSH Host setup) is done** — this was a live-instance UI action on their end, not something done via this repo's code. +3. Otherwise, check with the user for the next priority — there is no pending design doc or half-built feature waiting right now (mesh gate and Docker UX work above are both fully shipped or ready-to-merge). ## Standing rules (read before doing anything) @@ -66,6 +75,9 @@ See `TERMIX_MIGRATION.md` for the phase-by-phase record of the original feature 11. **Settings UX fixes** — secret fields show a "· saved" indicator instead of appearing blank/deleted after reload (`secretKeys: string[]` on the integration serializer); SSH host cards default-collapsed if already configured; SSH private-key/cert fields support file upload to avoid paste corruption. 12. **Persistent terminal sessions** (PR #30) — SSH terminal tabs/panes stay connected when you navigate to other pages and back. See `src/lib/TerminalSessionContext.tsx`. 13. **Docker-over-SSH + agent monitoring** (PR #31) — two new ways to see/manage Docker without exposing the Engine TCP socket. See "Docker: three ways" below. +14. **Mesh Prerequisite Gate** (`46d95fc`, `0409159`, `800072f`, `4a4a5a0`) — requires a verified mesh network (universal CIDR check, not NetBird-specific, with a routed-mesh/VPC-peering fallback) before the app can be configured; defaults OFF; configurable/testable from a dedicated Settings → Mesh section. +15. **Docker integration setup-script hint** (`628187b`, on `claude/youthful-cerf-ibvxfb`, not yet merged) — Settings shows a host-specific systemd-override + curl script when configuring a Docker (`type: 'docker'`) integration's `baseUrl`, so enabling the remote Engine API doesn't require looking up the steps elsewhere. +16. **Help page expansion** (`36a79ab`, same branch) — quick-start ordering card + real-world example callouts per page, for first-time users. ## Docker: three ways (PR #31) @@ -122,6 +134,6 @@ Moved to **`ROADMAP.md`** ("Known non-blocking stubs"). Summary: the Infrastruct 1. Read this file, then `ROADMAP.md` (deferred/tiered work), then `docs/` (subsystem design docs — `docker-agent-monitoring.md`, `mesh-prerequisite-gate.md`), then `TERMIX_MIGRATION.md` for feature-level history, then skim `git log --oneline -30`. 2. Frontend: prefer `npm run build` (`tsc -b && vite build`) over a plain `tsc --noEmit` (stricter, catches more). Backend: `npx tsc --noEmit -p .` from `backend/`. Both must pass before any commit. -3. **The next planned feature is the Mesh Prerequisite Gate** — designed in `docs/mesh-prerequisite-gate.md`, NOT built. It has open decisions (A–D) that **must be answered by the user before coding** (especially DECIDE D: defaulting the gate OFF so it doesn't lock the live production instance). Auth Phases 1-3 are done; Phase 4 SSO is a deferred paid AWS add-on (`ROADMAP.md`). +3. **The Mesh Prerequisite Gate is built and shipped** (Settings → Mesh; defaults OFF). **There is no other planned feature queued right now** — check the "→ NEXT TASK" section above first (merge decision on `claude/youthful-cerf-ibvxfb`), then ask the user for the next priority. Auth Phases 1-3 are done; Phase 4 SSO is a deferred paid AWS add-on (`ROADMAP.md`). 4. If asked to add a feature, follow existing patterns: integration adapters in `backend/src/integrations/`, SSH-backed engines in `backend/src/ssh/`, one route file per feature in `backend/src/routes/`, one `api.ts` entry + page component per frontend feature. Subsystem-level work gets a `docs/` design doc first. 5. For anything ambiguous in scope, use `AskUserQuestion` rather than guessing — that's how the auth phases, the Docker agent tiering, and the mesh-gate decisions were all scoped. diff --git a/README.md b/README.md index 4b842fb..27cb045 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,16 @@ backend routes are built and working — there is no pending/on-hold page. Auth is feature-complete for self-hosted (Phases 1-3: user menu wiring, password/sessions/login-log, multi-user roles with a 10-seat cap); Phase 4 (Authentik SSO) is **deferred to a paid AWS add-on** — see `ROADMAP.md`. -Recently shipped: persistent terminal sessions across navigation, and Docker +Recently shipped: persistent terminal sessions across navigation, Docker container visibility/management three ways (Engine TCP API, `docker` CLI over -SSH, and a read-only push agent — see `docs/docker-agent-monitoring.md`). +SSH, and a read-only push agent — see `docs/docker-agent-monitoring.md`), and +the **Mesh Prerequisite Gate** — a universal CIDR-based mesh-verification +requirement (with a routed-mesh/VPC-peering fallback, not NetBird-specific), +configurable from Settings → Mesh and defaulting OFF so it can't lock the live +instance. -The **next planned feature is the Mesh Prerequisite Gate** — requiring a -verified NetBird mesh before the app can be configured. It is **designed but -not built** (`docs/mesh-prerequisite-gate.md`) and has open decisions that need -the user's sign-off before coding (notably defaulting it OFF so it can't lock -the live instance). See `HANDOFF.md` for where to resume. +There is no feature currently in progress. See `HANDOFF.md` for the latest +status and next steps. If you're a fresh AI session: read this file, then `HANDOFF.md` (current task state + standing workflow rules), then `design-decisions.md` (visual From 98b69e47d556bbe816dc18d9440c75ccbca4dbb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:47:52 +0000 Subject: [PATCH 08/13] Improve Containers table/tab readability: bold centered headers, taller rows, filing-cabinet tabs --- src/pages/Containers.tsx | 63 +++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/src/pages/Containers.tsx b/src/pages/Containers.tsx index 8a6f1e9..bef726f 100644 --- a/src/pages/Containers.tsx +++ b/src/pages/Containers.tsx @@ -305,7 +305,7 @@ export default function Containers() {
{/* Intra-page tab bar */} -
+
setActiveTab('list')} /> {detailTabs.map((t) => ( - - - - - - - - + + + + + + + + @@ -354,67 +354,67 @@ export default function Containers() { const busy = busyId === c.id return ( - - - - - - -
NameImageStateCPUMemoryPortsActions
NameImageStateCPUMemoryPortsActions
+ + {c.image} - + + {c.status} + {stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'} + {stats ? `${formatBytes(stats.memUsage)} / ${formatBytes(stats.memLimit)}` : '—'} + {c.ports || '—'} -
+
+
{canManage ? ( <> {c.state === 'running' ? ( <> ) : c.state === 'paused' ? ( ) : ( )} ) : ( @@ -450,17 +450,20 @@ function TabButton({ label, active, onClick, onClose }: { label: string; active: return (
{label} {onClose && ( { e.stopPropagation() onClose() From 9372767443a4ea82ff4394fec23bfb724dcb9117 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:52:58 +0000 Subject: [PATCH 09/13] Make Node Status card scrollable with a 5-column layout and invisible-by-default scrollbar --- src/index.css | 27 +++++++++++++++++++++++++++ src/pages/Infrastructure.tsx | 4 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/index.css b/src/index.css index c5711f9..6e9e00f 100644 --- a/src/index.css +++ b/src/index.css @@ -39,6 +39,33 @@ html, body { overflow: hidden; } +.scrollbar-ghost { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.scrollbar-ghost::-webkit-scrollbar { + width: 6px; +} + +.scrollbar-ghost::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-ghost::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 6px; +} + +.scrollbar-ghost:hover::-webkit-scrollbar-thumb, +.scrollbar-ghost:hover { + scrollbar-color: rgba(200, 164, 52, 0.25) transparent; +} + +.scrollbar-ghost:hover::-webkit-scrollbar-thumb { + background: rgba(200, 164, 52, 0.25); +} + /* Native +
+ +
)} @@ -260,3 +263,83 @@ function TerminalPane({
) } + +/** + * Lets a user pick one of a few Starship prompt looks and install it (Starship + * + a Nerd Font, if not already present) on the active pane's SSH host with + * one click, instead of running a script by hand. + */ +function ShellPromptControl({ hostId }: { hostId: number | null }) { + const [presets, setPresets] = useState([]) + const [status, setStatus] = useState(null) + const [selected, setSelected] = useState('') + const [installing, setInstalling] = useState(false) + const [message, setMessage] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + setStatus(null) + setMessage(null) + setError(null) + if (hostId === null) return + api + .getShellPromptStatus(hostId) + .then(({ presets, status }) => { + setPresets(presets) + setStatus(status) + setSelected(status.configuredPreset ?? presets[0]?.id ?? '') + }) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to check host')) + }, [hostId]) + + if (hostId === null) { + return Connect a host to set up its shell prompt + } + + async function handleInstall() { + if (!selected) return + setInstalling(true) + setError(null) + setMessage(null) + try { + await api.installShellPrompt(hostId!, selected) + setMessage('Installed — open a new terminal session to this host to see it.') + const { status } = await api.getShellPromptStatus(hostId!) + setStatus(status) + } catch (err) { + setError(err instanceof Error ? err.message : 'Install failed') + } finally { + setInstalling(false) + } + } + + return ( +
+ + + + {message && {message}} + {error && {error}} +
+ ) +} From 4674f4e2094aea8be5a650fa8610650d05c448c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 09:21:56 +0000 Subject: [PATCH 13/13] Fix Help page scroll, widen Connected Integrations grid, color-code System Status ring - Help page now scrolls (it sat under a clipped section with no overflow handling). - Connected Integrations on Glance shows 5 per row in a scrollable area with the transparent ghost scrollbar, instead of growing the card unbounded. - System Status KPI ring is now a thicker, vertically centered, multi-segment donut broken down by integration type, each type colored consistently from a shared first-come-first-served palette (src/lib/integrationColors.ts) so e.g. whichever type connects first always gets the same color everywhere it's used. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_019hu9pZvJY4BgmcQeAw2ugk --- src/components/BottomRow.tsx | 2 +- src/components/ProgressRing.tsx | 50 +++++++++++++++++++++++++++++++++ src/components/StatusCards.tsx | 24 ++++++++++++++-- src/lib/integrationColors.ts | 24 ++++++++++++++++ src/pages/Help.tsx | 2 +- 5 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 src/lib/integrationColors.ts diff --git a/src/components/BottomRow.tsx b/src/components/BottomRow.tsx index 54ce9ec..223cd19 100644 --- a/src/components/BottomRow.tsx +++ b/src/components/BottomRow.tsx @@ -70,7 +70,7 @@ export default function BottomRow() { ) : integrations.length === 0 ? (

No integrations added yet — add one in Settings.

) : ( -
+
{integrations.map((i) => (
diff --git a/src/components/ProgressRing.tsx b/src/components/ProgressRing.tsx index bca4d76..c0c8a17 100644 --- a/src/components/ProgressRing.tsx +++ b/src/components/ProgressRing.tsx @@ -49,3 +49,53 @@ export default function ProgressRing({ percentage, size = 56, strokeWidth = 4 }:
) } + +interface RingSegment { + color: string + value: number +} + +interface MultiSegmentRingProps { + segments: RingSegment[] + total: number + size?: number + strokeWidth?: number + centerLabel?: string +} + +export function MultiSegmentRing({ segments, total, size = 64, strokeWidth = 10, centerLabel }: MultiSegmentRingProps) { + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + + let cumulative = 0 + const drawn = segments.filter((s) => s.value > 0) + + return ( +
+ + + {total > 0 && + drawn.map((seg, i) => { + const segLen = (seg.value / total) * circumference + const offset = circumference - cumulative + cumulative += segLen + return ( + + ) + })} + + {centerLabel && {centerLabel}} +
+ ) +} diff --git a/src/components/StatusCards.tsx b/src/components/StatusCards.tsx index 302288c..18c957a 100644 --- a/src/components/StatusCards.tsx +++ b/src/components/StatusCards.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react' import { Server, Plug, BookMarked } from 'lucide-react' -import ProgressRing from './ProgressRing' +import { MultiSegmentRing } from './ProgressRing' import { api, type Integration, type Resource, type Bookmark } from '../lib/api' +import { getIntegrationTypeColors } from '../lib/integrationColors' const cardStyle: React.CSSProperties = { backgroundColor: 'rgba(10, 10, 12, 0.55)', @@ -47,17 +48,34 @@ export default function StatusCards() { const systemLabel = errored > 0 ? 'Issues Detected' : total === 0 ? 'Not Configured' : 'All Systems Operational' const systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100) + const typeColors = getIntegrationTypeColors(integrations ?? []) + const typeCounts = new Map() + for (const i of integrations ?? []) { + typeCounts.set(i.type, (typeCounts.get(i.type) ?? 0) + 1) + } + const typeSegments = [...typeCounts.entries()].map(([type, value]) => ({ type, value, color: typeColors[type] })) + return (
{/* System Status */}
-
+

System Status

0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}

{connected} of {total} integrations connected

+ {typeSegments.length > 0 && ( +
+ {typeSegments.map((s) => ( + + + {s.type} ({s.value}) + + ))} +
+ )}
- +
diff --git a/src/lib/integrationColors.ts b/src/lib/integrationColors.ts new file mode 100644 index 0000000..2dc2404 --- /dev/null +++ b/src/lib/integrationColors.ts @@ -0,0 +1,24 @@ +import type { Integration } from './api' + +// Color-blind-friendly palette (Blue/Orange/Green/Brown/Purple/Red/Teal/Yellow), +// assigned to integration *types* in first-come-first-served order (by integration +// id, i.e. whichever type was connected to ArchNest first). Once a type has a +// color it keeps it everywhere — System Status breakdown, Infrastructure, etc. +const PALETTE = ['#4A90E2', '#E67E22', '#2ECC71', '#8B5E3C', '#9B59B6', '#E74C3C', '#1ABC9C', '#F1C40F'] + +export function getIntegrationTypeColors(integrations: Integration[]): Record { + const sorted = [...integrations].sort((a, b) => a.id - b.id) + const map: Record = {} + let next = 0 + for (const integ of sorted) { + if (!(integ.type in map)) { + map[integ.type] = PALETTE[next % PALETTE.length] + next++ + } + } + return map +} + +export function getIntegrationColor(integrations: Integration[], type: string): string { + return getIntegrationTypeColors(integrations)[type] ?? '#7A7D85' +} diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index 9d72b0a..e82d00e 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -160,7 +160,7 @@ const quickStartSteps = [ export default function Help() { return ( -
+

How ArchNest works