feat(cells): Phase 1 — permission sync between connected PICs
When PIC A updates service sharing permissions, it immediately pushes the mirrored state to PIC B over the WireGuard tunnel so B's UI shows what A is sharing with it in real time. Architecture: - Push model: update_permissions() → _push_permissions_to_remote() → POST /api/cells/peer-sync/permissions on remote cell - Auth: source IP must be inside a known cell's vpn_subnet (WireGuard tunnel proves identity) + body's from_public_key must match stored key - Mirror semantics: our inbound (what we share) → their outbound view - Non-fatal: push failures set pending_push=True; replay_pending_pushes() retries at startup so offline cells catch up on reconnect - add_connection() also pushes initial state so remote sees permissions immediately on the first connect New fields on cell_links.json records (lazy-migrated): remote_api_url, last_push_status, last_push_at, last_push_error, pending_push, last_remote_update_at New endpoint: POST /api/cells/peer-sync/permissions 30 new tests (1101 total). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,46 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify
|
||||
from cell_link_manager import VALID_SERVICES
|
||||
logger = logging.getLogger('picell')
|
||||
bp = Blueprint('cells', __name__)
|
||||
|
||||
|
||||
def _authenticate_peer_cell(req):
|
||||
"""Return the cell_links record whose vpn_subnet contains the request source IP.
|
||||
|
||||
Source IP is taken from the last X-Forwarded-For entry (appended by Caddy)
|
||||
when present, falling back to request.remote_addr. Also verifies the
|
||||
body's from_public_key matches the matched link — defence-in-depth against
|
||||
overlapping subnets.
|
||||
Returns the matching link dict on success, None on failure.
|
||||
"""
|
||||
from app import cell_link_manager
|
||||
|
||||
candidate = req.remote_addr or ''
|
||||
xff = req.headers.get('X-Forwarded-For', '')
|
||||
if xff:
|
||||
last = xff.split(',')[-1].strip()
|
||||
if last:
|
||||
candidate = last
|
||||
try:
|
||||
src_ip = ipaddress.ip_address(candidate.strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for link in cell_link_manager.list_connections():
|
||||
subnet = link.get('vpn_subnet')
|
||||
if not subnet:
|
||||
continue
|
||||
try:
|
||||
if src_ip in ipaddress.ip_network(subnet, strict=False):
|
||||
return link
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
@bp.route('/api/cells/invite', methods=['GET'])
|
||||
def get_cell_invite():
|
||||
try:
|
||||
@@ -124,3 +160,48 @@ def update_cell_permissions(cell_name):
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating cell permissions: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/api/cells/peer-sync/permissions', methods=['POST'])
|
||||
def peer_sync_permissions():
|
||||
"""Machine-to-machine endpoint: a connected cell pushes its mirrored permission state.
|
||||
|
||||
Auth: source IP must be inside a known cell's vpn_subnet AND the body's
|
||||
from_public_key must match that cell's stored public key.
|
||||
No session/CSRF required — the WireGuard tunnel is the authentication layer.
|
||||
"""
|
||||
try:
|
||||
link = _authenticate_peer_cell(request)
|
||||
if not link:
|
||||
logger.warning(f"peer-sync: rejected from {request.remote_addr} — no matching cell")
|
||||
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
if data.get('version') != 1:
|
||||
return jsonify({'ok': False, 'error': 'unsupported or missing version'}), 400
|
||||
|
||||
sender_pubkey = data.get('from_public_key', '')
|
||||
if not sender_pubkey or sender_pubkey != link.get('public_key'):
|
||||
logger.warning(
|
||||
f"peer-sync: pubkey mismatch from {request.remote_addr} "
|
||||
f"(claimed cell={data.get('from_cell')!r})"
|
||||
)
|
||||
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
|
||||
|
||||
perms = data.get('permissions') or {}
|
||||
if not isinstance(perms, dict):
|
||||
return jsonify({'ok': False, 'error': 'permissions must be an object'}), 400
|
||||
for direction in ('inbound', 'outbound'):
|
||||
for svc in (perms.get(direction) or {}):
|
||||
if svc not in VALID_SERVICES:
|
||||
return jsonify({'ok': False, 'error': f'unknown service: {svc!r}'}), 400
|
||||
|
||||
from app import cell_link_manager
|
||||
cell_link_manager.apply_remote_permissions(sender_pubkey, perms)
|
||||
return jsonify({'ok': True, 'applied_at': datetime.utcnow().isoformat()})
|
||||
except ValueError as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"peer-sync error: {e}")
|
||||
return jsonify({'ok': False, 'error': 'internal error'}), 500
|
||||
|
||||
Reference in New Issue
Block a user