Add mesh prerequisite gate (#33)
* 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>
This commit is contained in:
parent
cdd93f204e
commit
70f88efdc8
8 changed files with 393 additions and 5 deletions
|
|
@ -14,7 +14,8 @@ ENV NODE_ENV=production
|
|||
# native modules (better-sqlite3, ssh2, node-pty) compile from source on install.
|
||||
# openssh-client provides the `ssh` binary, which node-pty shells out to for
|
||||
# certificate-based auth (ssh2 has no OpenSSH certificate support).
|
||||
RUN apk add --no-cache python3 make g++ openssh-client
|
||||
# iputils provides `ping`, used by the mesh-gate reachability check.
|
||||
RUN apk add --no-cache python3 make g++ openssh-client iputils
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
138
backend/src/routes/system.ts
Normal file
138
backend/src/routes/system.ts
Normal file
|
|
@ -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<boolean> {
|
||||
try {
|
||||
await execFileAsync('ping', ['-c', '1', '-W', '2', ip])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function meshStatusPayload() {
|
||||
const required = getConfig('mesh.required') === 'true'
|
||||
const cidr = getConfig('mesh.cidr') ?? null
|
||||
const verifiedAt = getConfig('mesh.verifiedAt')
|
||||
const verifiedVia = getConfig('mesh.verifiedVia') ?? null
|
||||
const overridden = getConfig('mesh.overrideUntil') === 'permanent'
|
||||
const parsed = cidr ? parseCidr(cidr) : null
|
||||
return {
|
||||
required,
|
||||
verified: !!verifiedAt,
|
||||
verifiedAt: verifiedAt ?? null,
|
||||
verifiedVia,
|
||||
overridden,
|
||||
cidr,
|
||||
hostMeshIp: parsed ? findHostIpInCidr(parsed) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const verifySchema = z.object({ cidr: z.string().min(1), 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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }))
|
||||
|
||||
|
|
|
|||
20
src/App.tsx
20
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 <Enrollment />
|
||||
if (status === 'logged-out') return <Login />
|
||||
if (status === 'needs-mesh') return <MeshGate />
|
||||
|
||||
return <Dashboard />
|
||||
}
|
||||
|
||||
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() {
|
|||
<TopBar />
|
||||
</div>
|
||||
|
||||
{showMeshNotice && (
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{
|
||||
zIndex: 20,
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'rgba(230,126,34,0.1)',
|
||||
borderBottom: '1px solid rgba(230,126,34,0.2)',
|
||||
fontSize: '11px',
|
||||
color: '#E67E22',
|
||||
}}
|
||||
>
|
||||
Mesh setup is still in progress — an admin needs to finish verifying the network. Some features may be limited.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className="relative flex w-full flex-col overflow-hidden"
|
||||
style={{ height: `calc(100vh - ${topBarHeight}px)`, scrollbarWidth: 'none', padding: showHero ? `${heroPaddingTop} 24px 24px 24px` : '16px 24px 24px 24px', gap: '20px', zIndex: 1 }}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api, getToken, setToken, type AuthUser } from './api'
|
||||
import { api, getToken, setToken, type AuthUser, type MeshStatus } from './api'
|
||||
|
||||
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in'
|
||||
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'needs-mesh' | 'logged-in'
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus
|
||||
user: AuthUser | null
|
||||
meshStatus: MeshStatus | null
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
completeSetup: (username: string, password: string) => Promise<void>
|
||||
finishEnrollment: () => Promise<void>
|
||||
refreshMeshStatus: () => Promise<void>
|
||||
logout: () => void
|
||||
setUser: (user: AuthUser) => void
|
||||
}
|
||||
|
|
@ -18,13 +20,19 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
|||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>('loading')
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [meshStatus, setMeshStatus] = useState<MeshStatus | null>(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 (
|
||||
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout, setUser }}>
|
||||
<AuthContext.Provider
|
||||
value={{ status, user, meshStatus, login, completeSetup, finishEnrollment, refreshMeshStatus, logout, setUser }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<MeshStatus>('/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<MeshStatus>('/system/mesh/override', { method: 'POST' }),
|
||||
setMeshRequired: (required: boolean) =>
|
||||
apiFetch<MeshStatus>('/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
|
||||
|
|
|
|||
172
src/pages/MeshGate.tsx
Normal file
172
src/pages/MeshGate.tsx
Normal file
|
|
@ -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<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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue