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
+69
View File
@@ -41,6 +41,7 @@ from container_manager import ContainerManager
from config_manager import ConfigManager from config_manager import ConfigManager
from service_bus import ServiceBus, EventType from service_bus import ServiceBus, EventType
from log_manager import LogManager from log_manager import LogManager
from cell_link_manager import CellLinkManager
import firewall_manager import firewall_manager
# Context variable for request info # Context variable for request info
@@ -178,6 +179,10 @@ routing_manager = RoutingManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
cell_manager = CellManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) cell_manager = CellManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
app.vault_manager = VaultManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) app.vault_manager = VaultManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
container_manager = ContainerManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR) container_manager = ContainerManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
cell_link_manager = CellLinkManager(
data_dir=_DATA_DIR, config_dir=_CONFIG_DIR,
wireguard_manager=wireguard_manager, network_manager=network_manager,
)
# Apply firewall + DNS rules from stored peer settings (survives API restarts) # Apply firewall + DNS rules from stored peer settings (survives API restarts)
def _apply_startup_enforcement(): def _apply_startup_enforcement():
@@ -1091,6 +1096,70 @@ def check_wireguard_port():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# ── Cell-to-cell connections ─────────────────────────────────────────────────
@app.route('/api/cells/invite', methods=['GET'])
def get_cell_invite():
"""Generate an invite package for this cell."""
try:
identity = config_manager.configs.get('_identity', {})
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
invite = cell_link_manager.generate_invite(cell_name, domain)
return jsonify(invite)
except Exception as e:
logger.error(f"Error generating cell invite: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/cells', methods=['GET'])
def list_cell_connections():
"""List all connected cells."""
try:
return jsonify(cell_link_manager.list_connections())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/cells', methods=['POST'])
def add_cell_connection():
"""Connect to a remote cell using their invite package."""
try:
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'No data provided'}), 400
for field in ('cell_name', 'public_key', 'vpn_subnet', 'dns_ip', 'domain'):
if field not in data:
return jsonify({'error': f'Missing field: {field}'}), 400
link = cell_link_manager.add_connection(data)
return jsonify({'message': f"Connected to cell '{data['cell_name']}'", 'link': link}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error(f"Error adding cell connection: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/cells/<cell_name>', methods=['DELETE'])
def remove_cell_connection(cell_name):
"""Disconnect from a remote cell."""
try:
cell_link_manager.remove_connection(cell_name)
return jsonify({'message': f"Cell '{cell_name}' disconnected"})
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error removing cell connection: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/cells/<cell_name>/status', methods=['GET'])
def get_cell_connection_status(cell_name):
"""Get live status for a connected cell."""
try:
status = cell_link_manager.get_connection_status(cell_name)
return jsonify(status)
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
# Peer Registry API # Peer Registry API
@app.route('/api/peers', methods=['GET']) @app.route('/api/peers', methods=['GET'])
def get_peers(): def get_peers():
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
CellLinkManager manages site-to-site connections between PIC cells.
Each connection is stored in data/cell_links.json and manifests as:
- A WireGuard [Peer] block (AllowedIPs = remote cell's VPN subnet)
- A CoreDNS forwarding block (remote domain remote cell's DNS IP)
"""
import os
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class CellLinkManager:
def __init__(self, data_dir: str, config_dir: str, wireguard_manager, network_manager):
self.data_dir = data_dir
self.config_dir = config_dir
self.wireguard_manager = wireguard_manager
self.network_manager = network_manager
self.links_file = os.path.join(data_dir, 'cell_links.json')
# ── Storage ───────────────────────────────────────────────────────────────
def _load(self) -> List[Dict[str, Any]]:
if os.path.exists(self.links_file):
try:
with open(self.links_file) as f:
return json.load(f)
except Exception:
return []
return []
def _save(self, links: List[Dict[str, Any]]):
with open(self.links_file, 'w') as f:
json.dump(links, f, indent=2)
# ── Public API ────────────────────────────────────────────────────────────
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
"""Return an invite package describing this cell for another cell to import."""
keys = self.wireguard_manager.get_keys()
srv = self.wireguard_manager.get_server_config()
server_vpn_ip = self.wireguard_manager._get_configured_address().split('/')[0]
return {
'cell_name': cell_name,
'public_key': keys['public_key'],
'endpoint': srv.get('endpoint'),
'vpn_subnet': self.wireguard_manager._get_configured_network(),
'dns_ip': server_vpn_ip,
'domain': domain,
'version': 1,
}
def list_connections(self) -> List[Dict[str, Any]]:
return self._load()
def add_connection(self, invite: Dict[str, Any]) -> Dict[str, Any]:
"""Import a remote cell's invite and establish the connection."""
links = self._load()
name = invite['cell_name']
if any(l['cell_name'] == name for l in links):
raise ValueError(f"Cell '{name}' is already connected")
ok = self.wireguard_manager.add_cell_peer(
name=name,
public_key=invite['public_key'],
endpoint=invite.get('endpoint', ''),
vpn_subnet=invite['vpn_subnet'],
)
if not ok:
raise RuntimeError(f"Failed to add WireGuard peer for cell '{name}'")
dns_result = self.network_manager.add_cell_dns_forward(
domain=invite['domain'],
dns_ip=invite['dns_ip'],
)
if dns_result.get('warnings'):
logger.warning('DNS forward warnings for %s: %s', name, dns_result['warnings'])
link = {
'cell_name': name,
'public_key': invite['public_key'],
'endpoint': invite.get('endpoint'),
'vpn_subnet': invite['vpn_subnet'],
'dns_ip': invite['dns_ip'],
'domain': invite['domain'],
'connected_at': datetime.utcnow().isoformat(),
}
links.append(link)
self._save(links)
return link
def remove_connection(self, cell_name: str):
"""Tear down a cell connection by name."""
links = self._load()
link = next((l for l in links if l['cell_name'] == cell_name), None)
if not link:
raise ValueError(f"Cell '{cell_name}' not found")
self.wireguard_manager.remove_peer(link['public_key'])
self.network_manager.remove_cell_dns_forward(link['domain'])
links = [l for l in links if l['cell_name'] != cell_name]
self._save(links)
def get_connection_status(self, cell_name: str) -> Dict[str, Any]:
"""Return link record enriched with live WireGuard handshake status."""
links = self._load()
link = next((l for l in links if l['cell_name'] == cell_name), None)
if not link:
raise ValueError(f"Cell '{cell_name}' not found")
try:
st = self.wireguard_manager.get_peer_status(link['public_key'])
return {**link, 'online': st.get('online', False),
'last_handshake': st.get('last_handshake')}
except Exception:
return {**link, 'online': False, 'last_handshake': None}
+57
View File
@@ -453,6 +453,63 @@ class NetworkManager(BaseServiceManager):
warnings.append(f"cell_name DNS update failed: {e}") warnings.append(f"cell_name DNS update failed: {e}")
return {'restarted': restarted, 'warnings': warnings} return {'restarted': restarted, 'warnings': warnings}
def add_cell_dns_forward(self, domain: str, dns_ip: str) -> Dict[str, Any]:
"""Append a CoreDNS forwarding block for a remote cell's domain."""
restarted = []
warnings = []
try:
corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
if not os.path.exists(corefile):
warnings.append('Corefile not found')
return {'restarted': restarted, 'warnings': warnings}
with open(corefile) as f:
content = f.read()
marker = f'# cell:{domain}'
if marker in content:
return {'restarted': restarted, 'warnings': warnings} # already present
forward_block = (
f'\n{marker}\n'
f'{domain} {{\n'
f' forward . {dns_ip}\n'
f' log\n'
f'}}\n'
)
with open(corefile, 'a') as f:
f.write(forward_block)
self._reload_dns_service()
restarted.append('cell-dns (reloaded)')
except Exception as e:
warnings.append(f'add_cell_dns_forward failed: {e}')
return {'restarted': restarted, 'warnings': warnings}
def remove_cell_dns_forward(self, domain: str) -> Dict[str, Any]:
"""Remove a CoreDNS forwarding block for a remote cell's domain."""
import re
restarted = []
warnings = []
try:
corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
if not os.path.exists(corefile):
return {'restarted': restarted, 'warnings': warnings}
with open(corefile) as f:
content = f.read()
marker = f'# cell:{domain}'
if marker not in content:
return {'restarted': restarted, 'warnings': warnings}
new_content = re.sub(
rf'\n# cell:{re.escape(domain)}\n{re.escape(domain)}\s*\{{[^}}]*\}}\n',
'',
content,
flags=re.DOTALL,
)
with open(corefile, 'w') as f:
f.write(new_content)
self._reload_dns_service()
restarted.append('cell-dns (reloaded)')
except Exception as e:
warnings.append(f'remove_cell_dns_forward failed: {e}')
return {'restarted': restarted, 'warnings': warnings}
def test_dns_resolution(self, domain: str) -> Dict: def test_dns_resolution(self, domain: str) -> Dict:
"""Test DNS resolution for a domain using Python socket.""" """Test DNS resolution for a domain using Python socket."""
import socket import socket
+29
View File
@@ -354,6 +354,35 @@ class WireGuardManager(BaseServiceManager):
logger.error(f'add_peer failed: {e}') logger.error(f'add_peer failed: {e}')
return False return False
def add_cell_peer(self, name: str, public_key: str, endpoint: str, vpn_subnet: str) -> bool:
"""Add a site-to-site [Peer] block for another PIC cell.
Unlike add_peer(), allows a subnet CIDR as AllowedIPs (whole remote VPN range).
The endpoint is expected to already include the port (e.g. '1.2.3.4:51820').
"""
import ipaddress
try:
ipaddress.ip_network(vpn_subnet, strict=False)
except ValueError as e:
logger.error(f'add_cell_peer: invalid vpn_subnet {vpn_subnet!r}: {e}')
return False
try:
content = self._read_config()
peer_block = (
f'\n[Peer]\n'
f'# cell:{name}\n'
f'PublicKey = {public_key}\n'
f'AllowedIPs = {vpn_subnet}\n'
f'PersistentKeepalive = 25\n'
)
if endpoint:
peer_block += f'Endpoint = {endpoint}\n'
self._write_config(content + peer_block)
return True
except Exception as e:
logger.error(f'add_cell_peer failed: {e}')
return False
def remove_peer(self, public_key: str) -> bool: def remove_peer(self, public_key: str) -> bool:
"""Remove the [Peer] block matching public_key from wg0.conf.""" """Remove the [Peer] block matching public_key from wg0.conf."""
try: try:
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Unit tests for CellLinkManager (cell-to-cell VPN connections)."""
import sys
from pathlib import Path
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
import unittest
import tempfile
import os
import json
import shutil
from unittest.mock import MagicMock, patch
from cell_link_manager import CellLinkManager
def _make_wg_mock():
wg = MagicMock()
wg.get_keys.return_value = {'public_key': 'serverpubkey=', 'private_key': 'serverprivkey='}
wg.get_server_config.return_value = {
'endpoint': '1.2.3.4:51820', 'port': 51820,
'dns_ip': '10.0.0.3', 'split_tunnel_ips': '10.0.0.0/24, 172.20.0.0/16',
}
wg._get_configured_network.return_value = '10.0.0.0/24'
wg._get_configured_address.return_value = '10.0.0.1/24'
wg.add_cell_peer.return_value = True
wg.remove_peer.return_value = True
return wg
def _make_nm_mock():
nm = MagicMock()
nm.add_cell_dns_forward.return_value = {'restarted': ['cell-dns (reloaded)'], 'warnings': []}
nm.remove_cell_dns_forward.return_value = {'restarted': ['cell-dns (reloaded)'], 'warnings': []}
return nm
SAMPLE_INVITE = {
'cell_name': 'office',
'public_key': 'officepubkey=',
'endpoint': '5.6.7.8:51820',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'domain': 'office.cell',
'version': 1,
}
class TestCellLinkManagerInvite(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.wg = _make_wg_mock()
self.nm = _make_nm_mock()
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
def tearDown(self):
shutil.rmtree(self.test_dir)
def test_generate_invite_has_required_fields(self):
invite = self.mgr.generate_invite('mycell', 'home.cell')
for field in ('cell_name', 'public_key', 'endpoint', 'vpn_subnet', 'dns_ip', 'domain', 'version'):
self.assertIn(field, invite, f"Missing field: {field}")
def test_generate_invite_uses_wg_public_key(self):
invite = self.mgr.generate_invite('mycell', 'home.cell')
self.assertEqual(invite['public_key'], 'serverpubkey=')
def test_generate_invite_uses_configured_network(self):
invite = self.mgr.generate_invite('mycell', 'home.cell')
self.assertEqual(invite['vpn_subnet'], '10.0.0.0/24')
def test_generate_invite_dns_ip_is_server_vpn_ip(self):
invite = self.mgr.generate_invite('mycell', 'home.cell')
self.assertEqual(invite['dns_ip'], '10.0.0.1')
def test_generate_invite_uses_supplied_identity(self):
invite = self.mgr.generate_invite('myhome', 'myhome.local')
self.assertEqual(invite['cell_name'], 'myhome')
self.assertEqual(invite['domain'], 'myhome.local')
class TestCellLinkManagerConnections(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.wg = _make_wg_mock()
self.nm = _make_nm_mock()
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
def tearDown(self):
shutil.rmtree(self.test_dir)
def test_add_connection_stores_link(self):
self.mgr.add_connection(SAMPLE_INVITE)
links = self.mgr.list_connections()
self.assertEqual(len(links), 1)
self.assertEqual(links[0]['cell_name'], 'office')
def test_add_connection_calls_add_cell_peer(self):
self.mgr.add_connection(SAMPLE_INVITE)
self.wg.add_cell_peer.assert_called_once_with(
name='office',
public_key='officepubkey=',
endpoint='5.6.7.8:51820',
vpn_subnet='10.1.0.0/24',
)
def test_add_connection_calls_dns_forward(self):
self.mgr.add_connection(SAMPLE_INVITE)
self.nm.add_cell_dns_forward.assert_called_once_with(
domain='office.cell', dns_ip='10.1.0.1'
)
def test_add_connection_duplicate_raises(self):
self.mgr.add_connection(SAMPLE_INVITE)
with self.assertRaises(ValueError):
self.mgr.add_connection(SAMPLE_INVITE)
def test_add_connection_persists_to_disk(self):
self.mgr.add_connection(SAMPLE_INVITE)
# Create a fresh manager reading same dir
mgr2 = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
links = mgr2.list_connections()
self.assertEqual(len(links), 1)
self.assertEqual(links[0]['cell_name'], 'office')
def test_remove_connection_calls_wg_remove_peer(self):
self.mgr.add_connection(SAMPLE_INVITE)
self.mgr.remove_connection('office')
self.wg.remove_peer.assert_called_once_with('officepubkey=')
def test_remove_connection_calls_dns_remove(self):
self.mgr.add_connection(SAMPLE_INVITE)
self.mgr.remove_connection('office')
self.nm.remove_cell_dns_forward.assert_called_once_with('office.cell')
def test_remove_connection_deletes_from_list(self):
self.mgr.add_connection(SAMPLE_INVITE)
self.mgr.remove_connection('office')
self.assertEqual(len(self.mgr.list_connections()), 0)
def test_remove_nonexistent_raises(self):
with self.assertRaises(ValueError):
self.mgr.remove_connection('nobody')
def test_list_connections_empty_by_default(self):
self.assertEqual(self.mgr.list_connections(), [])
def test_multiple_connections(self):
self.mgr.add_connection(SAMPLE_INVITE)
second = {**SAMPLE_INVITE, 'cell_name': 'cabin', 'public_key': 'cabinpubkey=',
'vpn_subnet': '10.2.0.0/24', 'dns_ip': '10.2.0.1', 'domain': 'cabin.cell'}
self.mgr.add_connection(second)
self.assertEqual(len(self.mgr.list_connections()), 2)
if __name__ == '__main__':
unittest.main()
+51
View File
@@ -272,5 +272,56 @@ test2 1800 IN CNAME test1
self.assertIn('192.168.1.10', content) self.assertIn('192.168.1.10', content)
self.assertIn('192.168.1.11', content) self.assertIn('192.168.1.11', content)
class TestCellDnsForwarding(unittest.TestCase):
"""Test add/remove cell DNS forwarding in Corefile."""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.data_dir = os.path.join(self.test_dir, 'data')
self.config_dir = os.path.join(self.test_dir, 'config')
os.makedirs(self.data_dir, exist_ok=True)
os.makedirs(os.path.join(self.config_dir, 'dns'), exist_ok=True)
self.nm = NetworkManager(self.data_dir, self.config_dir)
self.corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
with open(self.corefile, 'w') as f:
f.write('home.cell {\n file /data/home.cell.zone\n log\n}\n\n. {\n forward . 8.8.8.8\n log\n}\n')
def tearDown(self):
shutil.rmtree(self.test_dir)
@patch('subprocess.run')
def test_add_cell_dns_forward_appends_block(self, _mock):
self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1')
with open(self.corefile) as f:
content = f.read()
self.assertIn('remote.cell', content)
self.assertIn('10.1.0.1', content)
self.assertIn('forward . 10.1.0.1', content)
@patch('subprocess.run')
def test_add_cell_dns_forward_idempotent(self, _mock):
self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1')
self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1')
with open(self.corefile) as f:
content = f.read()
self.assertEqual(content.count('forward . 10.1.0.1'), 1)
@patch('subprocess.run')
def test_remove_cell_dns_forward_cleans_block(self, _mock):
self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1')
self.nm.remove_cell_dns_forward('remote.cell')
with open(self.corefile) as f:
content = f.read()
self.assertNotIn('remote.cell', content)
self.assertNotIn('10.1.0.1', content)
@patch('subprocess.run')
def test_remove_nonexistent_forward_is_noop(self, _mock):
before = open(self.corefile).read()
self.nm.remove_cell_dns_forward('nonexistent.cell')
after = open(self.corefile).read()
self.assertEqual(before, after)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+45
View File
@@ -323,6 +323,51 @@ PersistentKeepalive = 30
self.assertFalse(success) self.assertFalse(success)
class TestWireGuardCellPeer(unittest.TestCase):
"""Test add_cell_peer allows subnet CIDRs for site-to-site connections."""
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.data_dir = os.path.join(self.test_dir, 'data')
self.config_dir = os.path.join(self.test_dir, 'config')
os.makedirs(self.data_dir, exist_ok=True)
os.makedirs(self.config_dir, exist_ok=True)
patcher = patch.object(WireGuardManager, '_syncconf', return_value=None)
self.mock_sync = patcher.start()
self.addCleanup(patcher.stop)
self.wg = WireGuardManager(self.data_dir, self.config_dir)
def tearDown(self):
shutil.rmtree(self.test_dir)
def test_add_cell_peer_allows_subnet_cidr(self):
ok = self.wg.add_cell_peer('remote', 'remotepubkey=', '5.6.7.8:51821', '10.1.0.0/24')
self.assertTrue(ok)
content = self.wg._read_config()
self.assertIn('10.1.0.0/24', content)
def test_add_cell_peer_writes_full_endpoint(self):
self.wg.add_cell_peer('remote', 'remotepubkey=', '5.6.7.8:51821', '10.1.0.0/24')
content = self.wg._read_config()
self.assertIn('Endpoint = 5.6.7.8:51821', content)
def test_add_cell_peer_comment_has_cell_prefix(self):
self.wg.add_cell_peer('remote', 'remotepubkey=', '5.6.7.8:51821', '10.1.0.0/24')
content = self.wg._read_config()
self.assertIn('# cell:remote', content)
def test_add_cell_peer_invalid_cidr_returns_false(self):
ok = self.wg.add_cell_peer('remote', 'remotepubkey=', '5.6.7.8:51821', 'not-a-cidr')
self.assertFalse(ok)
def test_add_cell_peer_can_coexist_with_regular_peers(self):
self.wg.add_peer('alice', 'alicepubkey=', '', '10.0.0.2/32')
self.wg.add_cell_peer('remote', 'remotepubkey=', '5.6.7.8:51821', '10.1.0.0/24')
content = self.wg._read_config()
self.assertIn('alicepubkey=', content)
self.assertIn('remotepubkey=', content)
class TestWireGuardConfigReads(unittest.TestCase): class TestWireGuardConfigReads(unittest.TestCase):
"""Test that port/address/network are read from wg0.conf, not hardcoded.""" """Test that port/address/network are read from wg0.conf, not hardcoded."""
+5 -1
View File
@@ -13,7 +13,8 @@ import {
Server, Server,
Key, Key,
Package2, Package2,
Settings as SettingsIcon Settings as SettingsIcon,
Link2
} from 'lucide-react'; } from 'lucide-react';
import { healthAPI } from './services/api'; import { healthAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext'; import { ConfigProvider } from './contexts/ConfigContext';
@@ -30,6 +31,7 @@ import Logs from './pages/Logs';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Vault from './pages/Vault'; import Vault from './pages/Vault';
import ContainerDashboard from './components/ContainerDashboard'; import ContainerDashboard from './components/ContainerDashboard';
import CellNetwork from './pages/CellNetwork';
function App() { function App() {
const [isOnline, setIsOnline] = useState(false); const [isOnline, setIsOnline] = useState(false);
@@ -65,6 +67,7 @@ function App() {
{ name: 'Routing', href: '/routing', icon: Wifi }, { name: 'Routing', href: '/routing', icon: Wifi },
{ name: 'Vault', href: '/vault', icon: Key }, { name: 'Vault', href: '/vault', icon: Key },
{ name: 'Containers', href: '/containers', icon: Package2 }, { name: 'Containers', href: '/containers', icon: Package2 },
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
{ name: 'Logs', href: '/logs', icon: Activity }, { name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Settings', href: '/settings', icon: SettingsIcon }, { name: 'Settings', href: '/settings', icon: SettingsIcon },
]; ];
@@ -121,6 +124,7 @@ function App() {
<Route path="/routing" element={<Routing />} /> <Route path="/routing" element={<Routing />} />
<Route path="/vault" element={<Vault />} /> <Route path="/vault" element={<Vault />} />
<Route path="/containers" element={<ContainerDashboard />} /> <Route path="/containers" element={<ContainerDashboard />} />
<Route path="/cell-network" element={<CellNetwork />} />
<Route path="/logs" element={<Logs />} /> <Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Routes> </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`), 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 // Health check
export const healthAPI = { export const healthAPI = {
check: () => api.get('/health'), check: () => api.get('/health'),