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