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:
2026-04-21 08:34:21 -04:00
parent 3912452fd6
commit 848f8cfc7c
10 changed files with 872 additions and 1 deletions
+5 -1
View File
@@ -13,7 +13,8 @@ import {
Server,
Key,
Package2,
Settings as SettingsIcon
Settings as SettingsIcon,
Link2
} from 'lucide-react';
import { healthAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext';
@@ -30,6 +31,7 @@ import Logs from './pages/Logs';
import Settings from './pages/Settings';
import Vault from './pages/Vault';
import ContainerDashboard from './components/ContainerDashboard';
import CellNetwork from './pages/CellNetwork';
function App() {
const [isOnline, setIsOnline] = useState(false);
@@ -65,6 +67,7 @@ function App() {
{ name: 'Routing', href: '/routing', icon: Wifi },
{ name: 'Vault', href: '/vault', icon: Key },
{ name: 'Containers', href: '/containers', icon: Package2 },
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
{ name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
];
@@ -121,6 +124,7 @@ function App() {
<Route path="/routing" element={<Routing />} />
<Route path="/vault" element={<Vault />} />
<Route path="/containers" element={<ContainerDashboard />} />
<Route path="/cell-network" element={<CellNetwork />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
</Routes>
+323
View File
@@ -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>
);
}
+9
View File
@@ -181,6 +181,15 @@ export const servicesAPI = {
restartService: (serviceName) => api.post(`/api/services/bus/services/${serviceName}/restart`),
};
// Cell-to-cell connections API
export const cellLinkAPI = {
getInvite: () => api.get('/api/cells/invite'),
listConnections: () => api.get('/api/cells'),
addConnection: (invite) => api.post('/api/cells', invite),
removeConnection: (name) => api.delete(`/api/cells/${name}`),
getStatus: (name) => api.get(`/api/cells/${name}/status`),
};
// Health check
export const healthAPI = {
check: () => api.get('/health'),