* 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 * 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. * 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
172 lines
6 KiB
TypeScript
172 lines
6 KiB
TypeScript
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<string | null>(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 (
|
|
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
|
<div style={{ width: '420px' }}>
|
|
<form onSubmit={handleVerify} style={cardBase}>
|
|
<div className="flex items-center gap-2" style={{ marginBottom: '4px' }}>
|
|
<Network size={18} color="#C8A434" />
|
|
<h1
|
|
style={{
|
|
fontSize: '18px',
|
|
fontWeight: 700,
|
|
letterSpacing: '1px',
|
|
textTransform: 'uppercase',
|
|
color: '#C8A434',
|
|
}}
|
|
>
|
|
Mesh Setup Required
|
|
</h1>
|
|
</div>
|
|
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
|
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.
|
|
</p>
|
|
|
|
<label style={fieldLabel}>Mesh Network CIDR</label>
|
|
<input
|
|
style={fieldInput}
|
|
value={cidr}
|
|
onChange={(e) => setCidr(e.target.value)}
|
|
placeholder="e.g. 100.64.0.0/10 (NetBird/Tailscale CGNAT range)"
|
|
required
|
|
/>
|
|
|
|
<label style={{ ...fieldLabel, marginTop: '14px' }}>Peer/Gateway IP on the mesh (optional)</label>
|
|
<input
|
|
style={fieldInput}
|
|
value={testIp}
|
|
onChange={(e) => setTestIp(e.target.value)}
|
|
placeholder="e.g. 100.64.0.1 — only needed if this host's own IP isn't in the mesh range"
|
|
/>
|
|
<p style={{ fontSize: '10px', color: '#5C5F66', marginTop: '6px' }}>
|
|
If this host reaches the mesh through routing instead of holding a local mesh IP
|
|
(e.g. a VPC peered into the mesh), give us an address on the mesh we can ping to confirm
|
|
reachability.
|
|
</p>
|
|
|
|
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
|
{testResult && (
|
|
<p style={{ fontSize: '12px', color: testResult.ok ? '#2ECC71' : '#E74C3C', marginTop: '12px' }}>
|
|
{testResult.message}
|
|
{testResult.hostMeshIp && ` — this host's mesh IP: ${testResult.hostMeshIp}`}
|
|
</p>
|
|
)}
|
|
|
|
<button type="submit" disabled={submitting} style={{ ...goldButton, width: '100%', marginTop: '22px', opacity: submitting ? 0.6 : 1 }}>
|
|
{submitting ? 'Verifying…' : 'Connect & Verify'}
|
|
</button>
|
|
|
|
<div
|
|
className="flex items-start gap-2"
|
|
style={{ marginTop: '20px', paddingTop: '16px', borderTop: '1px solid rgba(255,255,255,0.06)' }}
|
|
>
|
|
<ShieldAlert size={14} color="#7A7D85" style={{ flexShrink: 0, marginTop: '1px' }} />
|
|
<div>
|
|
<p style={{ fontSize: '11px', color: '#7A7D85', marginBottom: '8px' }}>
|
|
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.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleOverride}
|
|
disabled={overriding}
|
|
className="cursor-pointer"
|
|
style={{
|
|
fontSize: '11px',
|
|
color: '#7A7D85',
|
|
background: 'transparent',
|
|
border: '1px solid rgba(255,255,255,0.12)',
|
|
borderRadius: '6px',
|
|
padding: '6px 12px',
|
|
opacity: overriding ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{overriding ? 'Skipping…' : 'Skip for now'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|