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:
+218
-13
@@ -8,9 +8,11 @@ Each connection is stored in data/cell_links.json and manifests as:
|
||||
- An iptables FORWARD rule set (service-level access control)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -23,6 +25,8 @@ _DEFAULT_PERMISSIONS = {
|
||||
'outbound': {s: False for s in VALID_SERVICES},
|
||||
}
|
||||
|
||||
_PUSH_TIMEOUT = 5 # seconds
|
||||
|
||||
|
||||
def _default_perms() -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -46,12 +50,34 @@ class CellLinkManager:
|
||||
try:
|
||||
with open(self.links_file) as f:
|
||||
links = json.load(f)
|
||||
# Lazy migration: inject permissions field if missing
|
||||
changed = False
|
||||
for link in links:
|
||||
if 'permissions' not in link:
|
||||
link['permissions'] = _default_perms()
|
||||
changed = True
|
||||
# Phase 1 migration: permission-sync tracking fields
|
||||
if 'remote_api_url' not in link:
|
||||
link['remote_api_url'] = (
|
||||
f"http://{link['dns_ip']}:3000"
|
||||
if link.get('dns_ip') else None
|
||||
)
|
||||
changed = True
|
||||
if 'last_push_status' not in link:
|
||||
link['last_push_status'] = 'never'
|
||||
changed = True
|
||||
if 'last_push_at' not in link:
|
||||
link['last_push_at'] = None
|
||||
changed = True
|
||||
if 'last_push_error' not in link:
|
||||
link['last_push_error'] = None
|
||||
changed = True
|
||||
if 'pending_push' not in link:
|
||||
# Existing links predate sync — mark pending so startup replay syncs them
|
||||
link['pending_push'] = True
|
||||
changed = True
|
||||
if 'last_remote_update_at' not in link:
|
||||
link['last_remote_update_at'] = None
|
||||
changed = True
|
||||
if changed:
|
||||
self._save(links)
|
||||
return links
|
||||
@@ -63,6 +89,169 @@ class CellLinkManager:
|
||||
with open(self.links_file, 'w') as f:
|
||||
json.dump(links, f, indent=2)
|
||||
|
||||
# ── Sync helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _local_identity(self) -> Dict[str, str]:
|
||||
"""Return this cell's name and WG public key for outbound peer-sync calls."""
|
||||
from app import config_manager
|
||||
identity = config_manager.configs.get('_identity', {})
|
||||
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
||||
keys = self.wireguard_manager.get_keys()
|
||||
return {'cell_name': cell_name, 'public_key': keys.get('public_key', '')}
|
||||
|
||||
def _push_permissions_to_remote(self, link: Dict[str, Any],
|
||||
from_cell: str,
|
||||
from_public_key: str) -> Dict[str, Any]:
|
||||
"""POST mirrored permissions to the remote cell's peer-sync endpoint.
|
||||
|
||||
Returns {'ok': bool, 'error': str|None}. Never raises.
|
||||
|
||||
The body inverts inbound/outbound: our inbound (what we share with them)
|
||||
becomes their outbound (what they receive from us), and vice-versa.
|
||||
"""
|
||||
url = link.get('remote_api_url')
|
||||
if not url:
|
||||
return {'ok': False, 'error': 'no remote_api_url'}
|
||||
|
||||
perms = link.get('permissions') or _default_perms()
|
||||
body = {
|
||||
'version': 1,
|
||||
'from_cell': from_cell,
|
||||
'from_public_key': from_public_key,
|
||||
'permissions': {
|
||||
'outbound': dict(perms.get('inbound', {})),
|
||||
'inbound': dict(perms.get('outbound', {})),
|
||||
},
|
||||
'sent_at': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
payload = json.dumps(body).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
url.rstrip('/') + '/api/cells/peer-sync/permissions',
|
||||
data=payload,
|
||||
method='POST',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_PUSH_TIMEOUT) as resp:
|
||||
if 200 <= resp.status < 300:
|
||||
return {'ok': True, 'error': None}
|
||||
return {'ok': False, 'error': f'HTTP {resp.status}'}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {'ok': False, 'error': f'HTTP {e.code}'}
|
||||
except urllib.error.URLError as e:
|
||||
return {'ok': False, 'error': str(e.reason)[:200]}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e)[:200]}
|
||||
|
||||
def _record_push_result(self, cell_name: str, result: Dict[str, Any]) -> None:
|
||||
"""Persist last_push_* and pending_push after a push attempt."""
|
||||
links = self._load()
|
||||
for link in links:
|
||||
if link['cell_name'] == cell_name:
|
||||
if result.get('ok'):
|
||||
link['last_push_status'] = 'ok'
|
||||
link['last_push_at'] = datetime.utcnow().isoformat()
|
||||
link['last_push_error'] = None
|
||||
link['pending_push'] = False
|
||||
else:
|
||||
link['last_push_status'] = 'failed'
|
||||
link['last_push_error'] = result.get('error')
|
||||
link['pending_push'] = True
|
||||
break
|
||||
self._save(links)
|
||||
|
||||
def _try_push(self, cell_name: str, link: Dict[str, Any]) -> None:
|
||||
"""Mark pending, push, record result. Non-fatal."""
|
||||
# Mark dirty before pushing — a crash mid-push leaves it pending for replay
|
||||
links = self._load()
|
||||
for l in links:
|
||||
if l['cell_name'] == cell_name:
|
||||
l['pending_push'] = True
|
||||
break
|
||||
self._save(links)
|
||||
|
||||
try:
|
||||
identity = self._local_identity()
|
||||
result = self._push_permissions_to_remote(
|
||||
link, identity['cell_name'], identity['public_key']
|
||||
)
|
||||
self._record_push_result(cell_name, result)
|
||||
if not result['ok']:
|
||||
logger.warning(
|
||||
f"Permission push to '{cell_name}' failed "
|
||||
f"(will retry on startup): {result['error']}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Permission push to '{cell_name}' skipped (non-fatal): {e}")
|
||||
|
||||
def apply_remote_permissions(self, from_public_key: str,
|
||||
permissions: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Store permissions pushed by a remote cell (identified by WG public key).
|
||||
|
||||
Validates service names, persists, and re-applies local iptables rules.
|
||||
Returns the updated link record.
|
||||
"""
|
||||
links = self._load()
|
||||
link = next((l for l in links if l['public_key'] == from_public_key), None)
|
||||
if not link:
|
||||
raise ValueError(f"No connection found for public_key '{from_public_key[:16]}...'")
|
||||
|
||||
in_raw = permissions.get('inbound', {}) if isinstance(permissions, dict) else {}
|
||||
out_raw = permissions.get('outbound', {}) if isinstance(permissions, dict) else {}
|
||||
clean_inbound = {s: bool(in_raw.get(s, False)) for s in VALID_SERVICES}
|
||||
clean_outbound = {s: bool(out_raw.get(s, False)) for s in VALID_SERVICES}
|
||||
|
||||
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
||||
link['last_remote_update_at'] = datetime.utcnow().isoformat()
|
||||
self._save(links)
|
||||
|
||||
inbound_list = [s for s, v in clean_inbound.items() if v]
|
||||
try:
|
||||
import firewall_manager as _fm
|
||||
_fm.apply_cell_rules(link['cell_name'], link['vpn_subnet'], inbound_list)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"apply_cell_rules after remote push for '{link['cell_name']}' "
|
||||
f"failed (non-fatal): {e}"
|
||||
)
|
||||
|
||||
return link
|
||||
|
||||
def replay_pending_pushes(self) -> Dict[str, int]:
|
||||
"""Retry permission pushes for any link with pending_push=True.
|
||||
|
||||
Called from _apply_startup_enforcement. Returns counts for logging.
|
||||
"""
|
||||
summary = {'attempted': 0, 'ok': 0, 'failed': 0}
|
||||
try:
|
||||
identity = self._local_identity()
|
||||
except Exception as e:
|
||||
logger.warning(f"replay_pending_pushes: cannot resolve identity ({e})")
|
||||
return summary
|
||||
|
||||
for link in self._load():
|
||||
if not link.get('pending_push'):
|
||||
continue
|
||||
summary['attempted'] += 1
|
||||
result = self._push_permissions_to_remote(
|
||||
link, identity['cell_name'], identity['public_key']
|
||||
)
|
||||
self._record_push_result(link['cell_name'], result)
|
||||
if result.get('ok'):
|
||||
summary['ok'] += 1
|
||||
logger.info(f"replay: synced permissions to '{link['cell_name']}'")
|
||||
else:
|
||||
summary['failed'] += 1
|
||||
logger.warning(
|
||||
f"replay: push to '{link['cell_name']}' failed: {result.get('error')}"
|
||||
)
|
||||
if summary['attempted']:
|
||||
logger.info(
|
||||
f"replay_pending_pushes: {summary['attempted']} attempted, "
|
||||
f"{summary['ok']} ok, {summary['failed']} failed"
|
||||
)
|
||||
return summary
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
|
||||
@@ -85,11 +274,7 @@ class CellLinkManager:
|
||||
|
||||
def add_connection(self, invite: Dict[str, Any],
|
||||
inbound_services: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""Import a remote cell's invite and establish the connection.
|
||||
|
||||
inbound_services: which of THIS cell's services to share with the remote
|
||||
cell immediately. Defaults to none (all-deny).
|
||||
"""
|
||||
"""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):
|
||||
@@ -125,17 +310,37 @@ class CellLinkManager:
|
||||
'domain': invite['domain'],
|
||||
'connected_at': datetime.utcnow().isoformat(),
|
||||
'permissions': perms,
|
||||
'remote_api_url': f"http://{invite['dns_ip']}:3000",
|
||||
'last_push_status': 'never',
|
||||
'last_push_at': None,
|
||||
'last_push_error': None,
|
||||
'pending_push': True,
|
||||
'last_remote_update_at': None,
|
||||
}
|
||||
links.append(link)
|
||||
self._save(links)
|
||||
|
||||
# Apply iptables rules for the new cell (non-fatal if it fails)
|
||||
try:
|
||||
import firewall_manager as _fm
|
||||
_fm.apply_cell_rules(name, invite['vpn_subnet'], inbound)
|
||||
except Exception as e:
|
||||
logger.warning(f"apply_cell_rules for {name} failed (non-fatal): {e}")
|
||||
|
||||
# Initial push so the remote immediately knows our permission state
|
||||
try:
|
||||
identity = self._local_identity()
|
||||
result = self._push_permissions_to_remote(
|
||||
link, identity['cell_name'], identity['public_key']
|
||||
)
|
||||
self._record_push_result(name, result)
|
||||
if not result['ok']:
|
||||
logger.warning(
|
||||
f"Initial push to '{name}' failed "
|
||||
f"(will retry on startup): {result['error']}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Initial push to '{name}' skipped (non-fatal): {e}")
|
||||
|
||||
return link
|
||||
|
||||
def remove_connection(self, cell_name: str):
|
||||
@@ -145,7 +350,6 @@ class CellLinkManager:
|
||||
if not link:
|
||||
raise ValueError(f"Cell '{cell_name}' not found")
|
||||
|
||||
# Clear firewall rules first (non-fatal)
|
||||
try:
|
||||
import firewall_manager as _fm
|
||||
_fm.clear_cell_rules(cell_name)
|
||||
@@ -163,7 +367,7 @@ class CellLinkManager:
|
||||
outbound: Dict[str, bool]) -> Dict[str, Any]:
|
||||
"""Update service sharing permissions for a cell connection.
|
||||
|
||||
Validates service names, persists, and re-applies iptables rules.
|
||||
Validates, persists, re-applies iptables, then pushes to remote.
|
||||
Returns the updated link record.
|
||||
"""
|
||||
links = self._load()
|
||||
@@ -171,13 +375,11 @@ class CellLinkManager:
|
||||
if not link:
|
||||
raise ValueError(f"Cell '{cell_name}' not found")
|
||||
|
||||
# Validate and normalise — only known services, boolean values
|
||||
clean_inbound = {s: bool(inbound.get(s, False)) for s in VALID_SERVICES}
|
||||
clean_inbound = {s: bool(inbound.get(s, False)) for s in VALID_SERVICES}
|
||||
clean_outbound = {s: bool(outbound.get(s, False)) for s in VALID_SERVICES}
|
||||
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
|
||||
self._save(links)
|
||||
|
||||
# Re-apply firewall rules
|
||||
inbound_list = [s for s, v in clean_inbound.items() if v]
|
||||
try:
|
||||
import firewall_manager as _fm
|
||||
@@ -185,6 +387,9 @@ class CellLinkManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"apply_cell_rules for {cell_name} failed (non-fatal): {e}")
|
||||
|
||||
# Push mirrored state to the remote cell (non-fatal)
|
||||
self._try_push(cell_name, link)
|
||||
|
||||
return link
|
||||
|
||||
def get_permissions(self, cell_name: str) -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user