Files
pic/webui/src/pages/CellNetwork.jsx
T
roof dc2606541c feat: Phase 4 hardening — retry/backoff, loop detection, sync status UI + tests
Phase 4.1 — Retry/backoff for failed permission pushes:
- _compute_next_retry(): capped exponential backoff with jitter (60s–1h)
- _record_push_result(): tracks push_attempts and next_retry_at per link
- replay_pending_pushes(): skips links still in backoff window, logs deferred count
- _load() migration: adds push_attempts/next_retry_at to existing records

Phase 4.2 — Loop detection (A→B→A routing cycle):
- set_peer_route_via(): returns 409 if target cell already routes peers through us
- apply_remote_permissions(): soft warning when accepting exit-relay that would cycle

Phase 4.3 — Sync staleness indicator in Cell Network UI:
- SyncBadge component: green (synced), amber (pending/failed), gray (never)
- Shows relativeTime of last sync + error message + next retry estimate
- Injected into CellPanel header alongside tunnel online/handshake status

Tests (54 new):
- TestCheckInviteConflicts: subnet overlap, domain conflict, exclude_cell (9 tests)
- TestPushInviteToRemote: success, 4xx, no endpoint, subprocess errors (7 tests)
- TestAcceptInviteNew: new cell, idempotent, healing dns/subnet changes (16 tests)
- TestAddConnectionMutualPairing: push-invite call, non-fatal failure (5 tests)
- TestPeerSyncAcceptInvite endpoint: happy path, field validation, error propagation (16 tests)
- Fixed 2 existing replay tests to clear backoff gate (simulates elapsed window)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 04:18:36 -04:00

629 lines
26 KiB
React

import { useState, useEffect } from 'react';
import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi, Calendar, FolderOpen, Mail, HardDrive, ChevronDown, ChevronRight, ArrowUpFromLine } from 'lucide-react';
import { cellLinkAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
import QRCode from 'qrcode';
const relativeTime = (ts) => {
if (!ts) return null;
const diff = Math.floor((Date.now() / 1000) - (typeof ts === 'string' ? new Date(ts).getTime() / 1000 : ts));
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
};
const SERVICE_DEFS = [
{ key: 'calendar', label: 'Calendar', Icon: Calendar },
{ key: 'files', label: 'Files', Icon: FolderOpen },
{ key: 'mail', label: 'Mail', Icon: Mail },
{ key: 'webdav', label: 'WebDAV', Icon: HardDrive },
];
function CopyButton({ text, small }) {
const [copied, setCopied] = useState(false);
const copy = () => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
} else {
const el = document.createElement('textarea');
el.value = text;
el.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const sz = small ? 'h-3.5 w-3.5' : 'h-4 w-4';
return (
<button onClick={copy} className="text-gray-400 hover:text-gray-600 ml-1.5" title="Copy">
{copied ? <CheckCheck className={`${sz} text-green-500`} /> : <Copy className={sz} />}
</button>
);
}
function StatusDot({ online }) {
if (online === null || online === undefined) {
return <span className="inline-block h-2 w-2 rounded-full bg-gray-300 mr-1.5" title="Unknown" />;
}
return online
? <span className="inline-block h-2 w-2 rounded-full bg-green-500 mr-1.5" title="Online" />
: <span className="inline-block h-2 w-2 rounded-full bg-red-400 mr-1.5" title="Offline" />;
}
function SyncBadge({ conn }) {
const { last_push_status, last_push_at, last_push_error, pending_push, next_retry_at } = conn;
let color, label, tip;
if (last_push_status === 'never' || (!last_push_at && !pending_push)) {
color = 'bg-gray-300'; label = 'Sync pending';
tip = 'Permissions not yet synced to remote cell';
} else if (!pending_push && last_push_status === 'ok') {
color = 'bg-green-500'; label = `Synced${last_push_at ? ' ' + relativeTime(last_push_at) : ''}`;
tip = `Permissions last synced ${last_push_at ? relativeTime(last_push_at) : ''}`;
} else {
color = 'bg-amber-400'; label = 'Out of sync';
tip = last_push_error ? `Sync failed: ${last_push_error}` : 'Permissions pending sync';
if (next_retry_at) {
const retryIn = Math.max(0, Math.round((new Date(next_retry_at) - Date.now()) / 60000));
tip += ` — next retry in ~${retryIn}m`;
}
}
return (
<span className="inline-flex items-center gap-1 text-xs text-gray-500" title={tip}>
<span className={`inline-block h-2 w-2 rounded-full ${color}`} />
{label}
</span>
);
}
function Toast({ toasts }) {
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2">
{toasts.map(t => (
<div key={t.id} className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
t.type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{t.msg}
</div>
))}
</div>
);
}
function useToasts() {
const [toasts, setToasts] = useState([]);
const add = (msg, type = 'success') => {
const id = Date.now();
setToasts(p => [...p, { id, msg, type }]);
setTimeout(() => setToasts(p => p.filter(t => t.id !== id)), 4000);
};
return [toasts, add];
}
function DisconnectConfirmModal({ cellName, onConfirm, onCancel }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4">
<div className="flex items-center gap-3 mb-3">
<Unplug className="h-6 w-6 text-red-500 flex-shrink-0" />
<h3 className="text-base font-semibold text-gray-900">Disconnect "{cellName}"?</h3>
</div>
<p className="text-sm text-gray-600 mb-2">
This will remove the WireGuard tunnel and all sharing permissions between your cells.
</p>
<p className="text-xs text-gray-400 mb-5">
The other cell's admin will need to remove the connection on their end too. Shared services will become inaccessible immediately.
</p>
<div className="flex gap-3 justify-end">
<button onClick={onCancel} autoFocus className="btn btn-secondary text-sm">Cancel</button>
<button onClick={onConfirm} className="btn btn-danger text-sm flex items-center gap-2">
<Unplug className="h-4 w-4" /> Disconnect
</button>
</div>
</div>
</div>
);
}
function ServiceShareToggle({ serviceKey, label, Icon, enabled, saving, onChange }) {
return (
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
<div
role="switch"
aria-checked={enabled}
aria-label={`Share ${label}`}
tabIndex={0}
onClick={() => !saving && onChange(!enabled)}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && !saving && onChange(!enabled)}
className={`relative w-10 h-5 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-1 ${
enabled ? 'bg-primary-500' : 'bg-gray-300'
} ${saving ? 'opacity-60 cursor-wait' : 'cursor-pointer'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${
enabled ? 'translate-x-5' : ''
}`} />
</div>
<Icon className="h-4 w-4 text-gray-500 shrink-0" />
<span className="text-sm text-gray-700">{label}</span>
</label>
);
}
function InboundServiceBadge({ label, Icon, active }) {
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
}`}>
<Icon className="h-3 w-3" />
{label}
</span>
);
}
function CellPanel({ conn, onDisconnect, addToast }) {
const [open, setOpen] = useState(false);
const [inboundPerms, setInboundPerms] = useState(conn.permissions?.inbound || {});
const [saving, setSaving] = useState({});
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
const [exitOffered, setExitOffered] = useState(!!conn.exit_offered);
const [savingExit, setSavingExit] = useState(false);
const handleToggle = async (serviceKey, newValue) => {
setSaving(s => ({ ...s, [serviceKey]: true }));
const newInbound = { ...inboundPerms, [serviceKey]: newValue };
try {
await cellLinkAPI.updatePermissions(conn.cell_name, newInbound, conn.permissions?.outbound || {});
setInboundPerms(newInbound);
addToast(`${serviceKey} sharing ${newValue ? 'enabled' : 'disabled'}`, 'success');
} catch {
addToast('Failed to save sharing permission', 'error');
} finally {
setSaving(s => ({ ...s, [serviceKey]: false }));
}
};
const handleExitToggle = async (newValue) => {
setSavingExit(true);
try {
await cellLinkAPI.setExitOffer(conn.cell_name, newValue);
setExitOffered(newValue);
addToast(`Internet sharing ${newValue ? 'offered' : 'withdrawn'}`);
} catch {
addToast('Failed to update internet sharing', 'error');
} finally {
setSavingExit(false);
}
};
const hasRevokedService = Object.values(inboundPerms).some(v => !v);
return (
<>
{confirmDisconnect && (
<DisconnectConfirmModal
cellName={conn.cell_name}
onConfirm={() => { setConfirmDisconnect(false); onDisconnect(conn.cell_name); }}
onCancel={() => setConfirmDisconnect(false)}
/>
)}
<div className="border border-gray-100 rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
>
<div className="flex items-center gap-3 min-w-0">
<StatusDot online={conn.online} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-medium text-gray-900 truncate">{conn.cell_name}</span>
<span className="text-xs text-gray-400 font-mono shrink-0">.{conn.domain}</span>
</div>
<div className="flex flex-wrap gap-x-3 text-xs text-gray-500 mt-0.5">
<span className={conn.online ? 'text-green-600 font-medium' : 'text-gray-400'}>
{conn.online ? 'Online' : 'Offline'}
</span>
{conn.last_handshake && (
<span>{relativeTime(conn.last_handshake)}</span>
)}
<SyncBadge conn={conn} />
</div>
</div>
</div>
{open
? <ChevronDown className="h-4 w-4 text-gray-400 shrink-0" />
: <ChevronRight className="h-4 w-4 text-gray-400 shrink-0" />}
</button>
{open && (
<div className="px-4 py-4 bg-white border-t border-gray-100">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
I share with {conn.cell_name}
</p>
<div className="space-y-1">
{SERVICE_DEFS.map(({ key, label, Icon }) => (
<ServiceShareToggle
key={key}
serviceKey={key}
label={label}
Icon={Icon}
enabled={!!inboundPerms[key]}
saving={!!saving[key]}
onChange={v => handleToggle(key, v)}
/>
))}
</div>
{hasRevokedService && (
<p className="text-xs text-gray-400 mt-2">
Services you stop sharing become unreachable from {conn.cell_name} immediately.
</p>
)}
</div>
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
{conn.cell_name} shares with me
</p>
{(conn.permissions?.outbound && Object.values(conn.permissions.outbound).some(Boolean)) ? (
<div className="flex flex-wrap gap-1.5">
{SERVICE_DEFS.map(({ key, label, Icon }) => (
<InboundServiceBadge
key={key}
label={label}
Icon={Icon}
active={!!conn.permissions.outbound[key]}
/>
))}
</div>
) : (
<p className="text-xs text-gray-400">Nothing shared yet.</p>
)}
<p className="text-xs text-gray-400 mt-3">
Inbound sharing is set by the other cell's admin.
</p>
</div>
</div>
{/* ── Internet sharing ───────────────────────────────────── */}
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-1.5">
<ArrowUpFromLine className="h-3.5 w-3.5" /> Internet Sharing
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<ServiceShareToggle
serviceKey="internet"
label={`Offer my internet to ${conn.cell_name}`}
Icon={Globe}
enabled={exitOffered}
saving={savingExit}
onChange={handleExitToggle}
/>
<div className={`flex items-center gap-1.5 text-sm ${conn.remote_exit_offered ? 'text-green-700' : 'text-gray-400'}`}>
<span className={`inline-block h-2 w-2 rounded-full flex-shrink-0 ${conn.remote_exit_offered ? 'bg-green-500' : 'bg-gray-300'}`} />
{conn.remote_exit_offered
? <>{conn.cell_name} offers internet</>
: <>{conn.cell_name} doesn't offer internet</>}
</div>
</div>
{exitOffered && (
<p className="text-xs text-gray-400 mt-2">
The {conn.cell_name} admin can route their peers' internet through your connection via Peers Edit peer Internet Exit.
</p>
)}
{conn.remote_exit_offered && (
<p className="text-xs text-gray-400 mt-2">
You can route any local peer's internet through {conn.cell_name} via Peers → Edit peer → Internet Exit.
</p>
)}
</div>
<div className="mt-4 pt-3 border-t border-gray-100 flex flex-wrap items-center justify-between gap-3">
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
{conn.vpn_subnet && <div><dt className="inline text-gray-400">Subnet </dt><dd className="inline font-mono">{conn.vpn_subnet}</dd></div>}
{conn.endpoint && <div><dt className="inline text-gray-400">Endpoint </dt><dd className="inline font-mono">{conn.endpoint}</dd></div>}
</dl>
<button
onClick={() => setConfirmDisconnect(true)}
className="btn btn-danger flex items-center gap-2 text-sm py-1.5"
>
<Unplug className="h-4 w-4" />
Disconnect
</button>
</div>
</div>
)}
</div>
</>
);
}
export default function CellNetwork() {
const { cell_name = 'mycell', domain = 'cell' } = useConfig();
const [toasts, addToast] = useToasts();
const [invite, setInvite] = useState(null);
const [inviteQr, setInviteQr] = useState('');
const [inviteLoading, setInviteLoading] = useState(true);
const [connections, setConnections] = useState([]);
const [connsLoading, setConnsLoading] = useState(true);
const [pasteText, setPasteText] = useState('');
const [pasteError, setPasteError] = useState('');
const [connecting, setConnecting] = useState(false);
useEffect(() => {
loadInvite();
loadConnections();
}, []);
const loadInvite = async () => {
setInviteLoading(true);
try {
const r = await cellLinkAPI.getInvite();
setInvite(r.data);
const qr = await QRCode.toDataURL(JSON.stringify(r.data), { width: 200, margin: 1 });
setInviteQr(qr);
} catch (e) {
addToast('Failed to load invite', 'error');
} finally {
setInviteLoading(false);
}
};
const loadConnections = async () => {
setConnsLoading(true);
try {
const r = await cellLinkAPI.listConnections();
const conns = r.data || [];
// Fetch all WireGuard peer statuses in one call and index by public_key
let statusByKey = {};
try {
const { wireguardAPI } = await import('../services/api');
const sr = await wireguardAPI.getPeerStatuses();
// API returns {pubkey: {online, last_handshake, ...}} — no .peers wrapper
const raw = sr.data || {};
const entries = Array.isArray(raw.peers)
? raw.peers.map(p => [p.public_key, p])
: Object.entries(raw);
entries.forEach(([pk, info]) => { if (pk) statusByKey[pk] = info; });
} catch {
// Status enrichment is best-effort; continue without it
}
const enriched = conns.map(conn => {
const wg = conn.public_key ? statusByKey[conn.public_key] : null;
return {
...conn,
online: wg ? wg.online : false,
last_handshake: wg ? wg.last_handshake : null,
};
});
setConnections(enriched);
} catch {
addToast('Failed to load connections', 'error');
} finally {
setConnsLoading(false);
}
};
const validatePaste = (text) => {
if (!text.trim()) { setPasteError(''); return; }
try {
const p = JSON.parse(text.trim());
if (!p.cell_name || !p.public_key || !p.vpn_subnet) {
setPasteError('JSON is missing required fields (cell_name, public_key, vpn_subnet)');
} else {
setPasteError('');
}
} catch {
setPasteError('Not valid JSON paste the complete invite from the other cell');
}
};
const handleConnect = async () => {
if (!pasteText.trim()) return;
let parsed;
try {
parsed = JSON.parse(pasteText.trim());
} catch {
addToast('Invalid JSON paste the full invite from the other cell', 'error');
return;
}
setConnecting(true);
try {
await cellLinkAPI.addConnection(parsed);
addToast(`Connected to cell "${parsed.cell_name}"`);
setPasteText('');
setPasteError('');
loadConnections();
} catch (e) {
addToast(e?.response?.data?.error || 'Connection failed', 'error');
} finally {
setConnecting(false);
}
};
// Confirmation is handled inside CellPanel via DisconnectConfirmModal
const handleDisconnect = async (name) => {
try {
await cellLinkAPI.removeConnection(name);
addToast(`Disconnected from "${name}"`);
loadConnections();
} catch (e) {
addToast(e?.response?.data?.error || 'Disconnect failed', 'error');
}
};
const inviteJson = invite ? JSON.stringify(invite, null, 2) : '';
return (
<div>
<Toast toasts={toasts} />
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Cell Network</h1>
<p className="mt-2 text-gray-600">
Connect multiple PIC cells into a mesh — site-to-site WireGuard tunnels with automatic DNS forwarding.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* ── This cell's invite */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Plug className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Your Cell's Invite</h3>
</div>
<button onClick={loadInvite} className="text-gray-400 hover:text-gray-600" title="Refresh">
<RefreshCw className="h-4 w-4" />
</button>
</div>
{inviteLoading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600" />
</div>
) : invite ? (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-3 space-y-1 text-xs">
<div className="flex justify-between gap-2 min-w-0">
<span className="text-gray-500 shrink-0">Cell</span>
<span className="font-mono font-medium truncate" title={invite.cell_name}>{invite.cell_name}</span>
</div>
<div className="flex justify-between gap-2 min-w-0">
<span className="text-gray-500 shrink-0">Domain</span>
<span className="font-mono font-medium truncate" title={invite.domain}>{invite.domain}</span>
</div>
<div className="flex justify-between gap-2 min-w-0">
<span className="text-gray-500 shrink-0">Endpoint</span>
<span className="font-mono font-medium truncate" title={invite.endpoint || '(no external IP)'}>{invite.endpoint || '(no external IP)'}</span>
</div>
<div className="flex justify-between gap-2 min-w-0">
<span className="text-gray-500 shrink-0">VPN subnet</span>
<span className="font-mono font-medium truncate" title={invite.vpn_subnet}>{invite.vpn_subnet}</span>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600">Invite JSON</span>
<CopyButton text={inviteJson} />
</div>
<pre className="bg-gray-900 text-green-400 text-xs rounded-lg p-3 overflow-x-auto max-h-40">
{inviteJson}
</pre>
</div>
{inviteQr && (
<div className="text-center">
<p className="text-xs text-gray-500 mb-2">Or scan with phone camera</p>
<img src={inviteQr} alt="Invite QR" className="inline-block border rounded-lg p-1 bg-white" />
</div>
)}
<p className="text-xs text-gray-400">
Share this JSON with the admin of another PIC cell. They paste it in "Connect to Cell" on their side.
</p>
</div>
) : (
<p className="text-gray-500 text-sm">Could not load invite.</p>
)}
</div>
{/* ── Connect to another cell ────────────────────────────────────── */}
<div className="card">
<div className="flex items-center gap-2 mb-4">
<Link2 className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Connect to Another Cell</h3>
</div>
<div className="space-y-3">
<p className="text-sm text-gray-600">
Paste the invite JSON from the other cell's "Your Cell's Invite" panel:
</p>
<div>
<textarea
value={pasteText}
onChange={e => { setPasteText(e.target.value); if (pasteError) validatePaste(e.target.value); }}
onBlur={e => validatePaste(e.target.value)}
placeholder={'{\n "cell_name": "...",\n "public_key": "...",\n ...\n}'}
rows={8}
className={`w-full text-xs font-mono border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none bg-white ${
pasteError ? 'border-red-400 focus:ring-red-400' : ''
}`}
/>
{pasteError && <p className="text-xs text-red-600 mt-1">{pasteError}</p>}
</div>
<button
onClick={handleConnect}
disabled={connecting || !pasteText.trim() || !!pasteError}
className="w-full btn btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
>
{connecting
? <><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> Connecting</>
: <><Link2 className="h-4 w-4" /> Connect</>
}
</button>
</div>
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs text-blue-800 font-medium mb-1">How it works</p>
<ol className="text-xs text-blue-700 space-y-1 list-decimal list-inside">
<li>Cell A copies its invite and sends it to Cell B's admin</li>
<li>Cell B pastes the invite and clicks Connect</li>
<li>Cell B copies its invite and sends it back to Cell A</li>
<li>Cell A pastes Cell B's invite and clicks Connect</li>
<li>Both cells can now reach each other's VPN peers and services</li>
</ol>
</div>
</div>
{/* ── Connected cells ────────────────────────────────────────────── */}
<div className="card lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Connected Cells</h3>
</div>
<button onClick={loadConnections} className="text-gray-400 hover:text-gray-600" title="Refresh">
<RefreshCw className="h-4 w-4" />
</button>
</div>
{connsLoading ? (
<div className="flex justify-center py-6">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600" />
</div>
) : connections.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<Wifi className="h-10 w-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">No cells connected yet.</p>
<p className="text-xs mt-1">Use the panels above to establish the first cell link.</p>
</div>
) : (
<div className="space-y-3">
{connections.map(conn => (
<CellPanel
key={conn.cell_name}
conn={conn}
onDisconnect={handleDisconnect}
addToast={addToast}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}