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()
+51
View File
@@ -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()
+45
View File
@@ -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."""