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