diff --git a/api/app.py b/api/app.py index 6782157..2043563 100644 --- a/api/app.py +++ b/api/app.py @@ -41,6 +41,7 @@ from container_manager import ContainerManager from config_manager import ConfigManager from service_bus import ServiceBus, EventType from log_manager import LogManager +from cell_link_manager import CellLinkManager import firewall_manager # 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) app.vault_manager = VaultManager(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) def _apply_startup_enforcement(): @@ -1091,6 +1096,70 @@ def check_wireguard_port(): except Exception as e: 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/', 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//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 @app.route('/api/peers', methods=['GET']) def get_peers(): diff --git a/api/cell_link_manager.py b/api/cell_link_manager.py new file mode 100644 index 0000000..511a8fd --- /dev/null +++ b/api/cell_link_manager.py @@ -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} diff --git a/api/network_manager.py b/api/network_manager.py index 23fcc8d..ef9d815 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -453,6 +453,63 @@ class NetworkManager(BaseServiceManager): warnings.append(f"cell_name DNS update failed: {e}") 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: """Test DNS resolution for a domain using Python socket.""" import socket diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index 7a7d3f7..a26472b 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -354,6 +354,35 @@ class WireGuardManager(BaseServiceManager): logger.error(f'add_peer failed: {e}') 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: """Remove the [Peer] block matching public_key from wg0.conf.""" try: diff --git a/tests/test_cell_link_manager.py b/tests/test_cell_link_manager.py new file mode 100644 index 0000000..056f5b4 --- /dev/null +++ b/tests/test_cell_link_manager.py @@ -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() diff --git a/tests/test_network_manager.py b/tests/test_network_manager.py index 7e8864c..4655604 100644 --- a/tests/test_network_manager.py +++ b/tests/test_network_manager.py @@ -272,5 +272,56 @@ test2 1800 IN CNAME test1 self.assertIn('192.168.1.10', 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__': unittest.main() \ No newline at end of file diff --git a/tests/test_wireguard_manager.py b/tests/test_wireguard_manager.py index f9ee222..225ea99 100644 --- a/tests/test_wireguard_manager.py +++ b/tests/test_wireguard_manager.py @@ -323,6 +323,51 @@ PersistentKeepalive = 30 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): """Test that port/address/network are read from wg0.conf, not hardcoded.""" diff --git a/webui/src/App.jsx b/webui/src/App.jsx index e335991..5d34688 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/webui/src/pages/CellNetwork.jsx b/webui/src/pages/CellNetwork.jsx new file mode 100644 index 0000000..271f144 --- /dev/null +++ b/webui/src/pages/CellNetwork.jsx @@ -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 ( + + ); +} + +function StatusDot({ online }) { + if (online === null || online === undefined) { + return ; + } + return online + ? + : ; +} + +function Toast({ toasts }) { + return ( +
+ {toasts.map(t => ( +
+ {t.msg} +
+ ))} +
+ ); +} + +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 ( +
+ + +
+

Cell Network

+

+ Connect multiple PIC cells into a mesh — site-to-site WireGuard tunnels with automatic DNS forwarding. +

+
+ +
+ + {/* ── This cell's invite ─────────────────────────────────────────── */} +
+
+
+ +

Your Cell's Invite

+
+ +
+ + {inviteLoading ? ( +
+
+
+ ) : invite ? ( +
+
+
+ Cell + {invite.cell_name} +
+
+ Domain + {invite.domain} +
+
+ Endpoint + {invite.endpoint || '(no external IP)'} +
+
+ VPN subnet + {invite.vpn_subnet} +
+
+ +
+
+ Invite JSON + +
+
+                  {inviteJson}
+                
+
+ + {inviteQr && ( +
+

Or scan with phone camera

+ Invite QR +
+ )} + +

+ Share this JSON with the admin of another PIC cell. They paste it in "Connect to Cell" on their side. +

+
+ ) : ( +

Could not load invite.

+ )} +
+ + {/* ── Connect to another cell ────────────────────────────────────── */} +
+
+ +

Connect to Another Cell

+
+ +
+

+ Paste the invite JSON from the other cell's "Your Cell's Invite" panel: +

+