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:
+69
@@ -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():
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user