Files
pic/api/routes/cells.py
T
roof 0b103ffafb feat(cells): fix PIC-to-PIC connection + add service-sharing permissions
Phase 1 — connection fixes:
- routing_manager.stop(): remove iptables -F / -t nat -F nuclear flush that
  would wipe WireGuard MASQUERADE and all peer rules on any UI stop action
- wireguard_manager.add_cell_peer(): reject vpn_subnet that overlaps the local
  WG network (routing blackhole — was the root cause of no handshake)
- wireguard_manager._syncconf(): pass Endpoint to 'wg set' so cell peers with
  static endpoints are synced to the kernel (not just AllowedIPs)

Phase 2 — service-sharing permissions backend:
- firewall_manager: add _cell_tag(), clear_cell_rules(), apply_cell_rules(),
  apply_all_cell_rules() — iptables FORWARD rules for cell-to-cell traffic
  using 'pic-cell-<name>' comment tags, distinct from 'pic-peer-*'
- app.py startup enforcement: call apply_all_cell_rules(cell_links) so rules
  survive API restarts
- cell_link_manager: permissions schema {inbound, outbound} per service;
  lazy migration for existing entries; update_permissions(), get_permissions();
  apply_cell_rules wired into add_connection/remove_connection
- routes/cells.py: GET /api/cells/services, GET+PUT /api/cells/<n>/permissions;
  RuntimeError now returns 400 (not 500) from add_connection

Removed broken 'test' cell (subnet 10.0.0.0/24 collided with local WG network).
Second PIC must use a distinct subnet (e.g. 10.0.1.0/24) before reconnecting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:35:24 -04:00

127 lines
5.2 KiB
Python

import logging
import os
from flask import Blueprint, request, jsonify
from cell_link_manager import VALID_SERVICES
logger = logging.getLogger('picell')
bp = Blueprint('cells', __name__)
@bp.route('/api/cells/invite', methods=['GET'])
def get_cell_invite():
try:
from app import cell_link_manager, config_manager
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'))
return jsonify(cell_link_manager.generate_invite(cell_name, domain))
except Exception as e:
logger.error(f"Error generating cell invite: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/cells/services', methods=['GET'])
def list_shareable_services():
"""Return the list of services that can be shared between cells."""
try:
from firewall_manager import SERVICE_IPS
return jsonify({'services': list(SERVICE_IPS.keys())})
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/cells', methods=['GET'])
def list_cell_connections():
try:
from app import cell_link_manager
return jsonify(cell_link_manager.list_connections())
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/cells', methods=['POST'])
def add_cell_connection():
try:
from app import cell_link_manager
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
inbound_services = data.get('inbound_services', [])
link = cell_link_manager.add_connection(data, inbound_services=inbound_services)
return jsonify({'message': f"Connected to cell '{data['cell_name']}'", 'link': link}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except RuntimeError 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
@bp.route('/api/cells/<cell_name>', methods=['DELETE'])
def remove_cell_connection(cell_name):
try:
from app import cell_link_manager
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
@bp.route('/api/cells/<cell_name>/status', methods=['GET'])
def get_cell_connection_status(cell_name):
try:
from app import cell_link_manager
return jsonify(cell_link_manager.get_connection_status(cell_name))
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/cells/<cell_name>/permissions', methods=['GET'])
def get_cell_permissions(cell_name):
try:
from app import cell_link_manager
perms = cell_link_manager.get_permissions(cell_name)
return jsonify(perms)
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/cells/<cell_name>/permissions', methods=['PUT'])
def update_cell_permissions(cell_name):
try:
from app import cell_link_manager, firewall_manager, peer_registry
from app import COREFILE_PATH
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'No data provided'}), 400
# Validate service names in inbound/outbound
for direction in ('inbound', 'outbound'):
for service in data.get(direction, {}):
if service not in VALID_SERVICES:
return jsonify({'error': f'Unknown service: {service!r}'}), 400
inbound = data.get('inbound', {})
outbound = data.get('outbound', {})
link = cell_link_manager.update_permissions(cell_name, inbound, outbound)
# Regenerate Corefile so outbound DNS changes take effect
try:
from app import config_manager
domain = config_manager.configs.get('_identity', {}).get('domain', 'cell')
peers = peer_registry.list_peers()
cell_links = cell_link_manager.list_connections()
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, domain,
cell_links=cell_links)
except Exception as e:
logger.warning(f"DNS regen after permission update failed (non-fatal): {e}")
return jsonify({'message': f"Permissions updated for '{cell_name}'", 'link': link})
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error updating cell permissions: {e}")
return jsonify({'error': str(e)}), 500