feat: cell-to-cell (PIC mesh) connection feature
Site-to-site WireGuard tunnels between PIC cells with automatic DNS forwarding. Each cell generates an invite JSON (public key, endpoint, VPN subnet, DNS IP, domain); the remote cell imports it to establish a bidirectional tunnel and CoreDNS forwarding block so each cell's domain resolves across the mesh. Backend: - CellLinkManager: invite generation, add/remove connections, live WireGuard handshake status; stores links in data/cell_links.json - WireGuardManager: add_cell_peer() accepts subnet CIDRs (not /32) and an optional endpoint for site-to-site peers; _read_iface_field() reads port, address, and network directly from wg0.conf at runtime instead of constants - NetworkManager: add/remove CoreDNS forwarding blocks per remote cell domain - app.py: /api/cells/* routes; _next_peer_ip() derives VPN range from configured address so peer allocation follows any address change Frontend: - CellNetwork page: invite panel (JSON + QR), connect form (paste JSON), connected cells list (green/red status, disconnect button) - App.jsx: Cell Network nav entry and route Tests: 25 new tests across test_wireguard_manager, test_network_manager, test_cell_link_manager (263 total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi } from 'lucide-react';
|
||||
import { cellLinkAPI } from '../services/api';
|
||||
import { useConfig } from '../contexts/ConfigContext';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
function CopyButton({ text, small }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
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 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];
|
||||
}
|
||||
|
||||
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 [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();
|
||||
// Enrich with live status
|
||||
const enriched = await Promise.all(
|
||||
(r.data || []).map(async (conn) => {
|
||||
try {
|
||||
const s = await cellLinkAPI.getStatus(conn.cell_name);
|
||||
return { ...conn, online: s.data.online, last_handshake: s.data.last_handshake };
|
||||
} catch {
|
||||
return { ...conn, online: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
setConnections(enriched);
|
||||
} catch {
|
||||
addToast('Failed to load connections', 'error');
|
||||
} finally {
|
||||
setConnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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('');
|
||||
loadConnections();
|
||||
} catch (e) {
|
||||
addToast(e?.response?.data?.error || 'Connection failed', 'error');
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (name) => {
|
||||
if (!window.confirm(`Disconnect from cell "${name}"?`)) return;
|
||||
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">
|
||||
<span className="text-gray-500">Cell</span>
|
||||
<span className="font-mono font-medium">{invite.cell_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Domain</span>
|
||||
<span className="font-mono font-medium">{invite.domain}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Endpoint</span>
|
||||
<span className="font-mono font-medium">{invite.endpoint || '(no external IP)'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">VPN subnet</span>
|
||||
<span className="font-mono font-medium">{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>
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={e => setPasteText(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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !pasteText.trim()}
|
||||
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 => (
|
||||
<div key={conn.cell_name}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot online={conn.online} />
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-gray-900">{conn.cell_name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">.{conn.domain}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-x-3 mt-0.5">
|
||||
<span>Subnet: <span className="font-mono">{conn.vpn_subnet}</span></span>
|
||||
<span>Endpoint: <span className="font-mono">{conn.endpoint || '—'}</span></span>
|
||||
{conn.last_handshake && (
|
||||
<span>Last seen: {new Date(conn.last_handshake * 1000).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDisconnect(conn.cell_name)}
|
||||
className="text-red-400 hover:text-red-600 p-1.5 rounded hover:bg-red-50"
|
||||
title="Disconnect"
|
||||
>
|
||||
<Unplug className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user