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:
@@ -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()
|
||||
Reference in New Issue
Block a user