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
+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()