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:
+69
@@ -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/<cell_name>', 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/<cell_name>/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():
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user