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:
2026-05-01 13:12:30 -04:00
parent 37d023659a
commit a3d0cd5a48
5 changed files with 741 additions and 13 deletions
+81
View File
@@ -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