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
+3
View File
@@ -267,6 +267,9 @@ def _apply_startup_enforcement():
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(), firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(),
cell_links=cell_links) cell_links=cell_links)
logger.info(f"Applied enforcement rules for {len(peers)} peers, {len(cell_links)} cells on startup") logger.info(f"Applied enforcement rules for {len(peers)} peers, {len(cell_links)} cells on startup")
sync_summary = cell_link_manager.replay_pending_pushes()
if sync_summary.get('attempted'):
logger.info(f"Startup permission sync: {sync_summary}")
except Exception as e: except Exception as e:
logger.warning(f"Startup enforcement failed (non-fatal): {e}") logger.warning(f"Startup enforcement failed (non-fatal): {e}")
+217 -12
View File
@@ -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) - An iptables FORWARD rule set (service-level access control)
""" """
import os
import json import json
import logging import logging
import os
import urllib.error
import urllib.request
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -23,6 +25,8 @@ _DEFAULT_PERMISSIONS = {
'outbound': {s: False for s in VALID_SERVICES}, 'outbound': {s: False for s in VALID_SERVICES},
} }
_PUSH_TIMEOUT = 5 # seconds
def _default_perms() -> Dict[str, Any]: def _default_perms() -> Dict[str, Any]:
return { return {
@@ -46,12 +50,34 @@ class CellLinkManager:
try: try:
with open(self.links_file) as f: with open(self.links_file) as f:
links = json.load(f) links = json.load(f)
# Lazy migration: inject permissions field if missing
changed = False changed = False
for link in links: for link in links:
if 'permissions' not in link: if 'permissions' not in link:
link['permissions'] = _default_perms() link['permissions'] = _default_perms()
changed = True 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: if changed:
self._save(links) self._save(links)
return links return links
@@ -63,6 +89,169 @@ class CellLinkManager:
with open(self.links_file, 'w') as f: with open(self.links_file, 'w') as f:
json.dump(links, f, indent=2) 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 ──────────────────────────────────────────────────────────── # ── Public API ────────────────────────────────────────────────────────────
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]: 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], def add_connection(self, invite: Dict[str, Any],
inbound_services: Optional[List[str]] = None) -> Dict[str, Any]: inbound_services: Optional[List[str]] = None) -> Dict[str, Any]:
"""Import a remote cell's invite and establish the connection. """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).
"""
links = self._load() links = self._load()
name = invite['cell_name'] name = invite['cell_name']
if any(l['cell_name'] == name for l in links): if any(l['cell_name'] == name for l in links):
@@ -125,17 +310,37 @@ class CellLinkManager:
'domain': invite['domain'], 'domain': invite['domain'],
'connected_at': datetime.utcnow().isoformat(), 'connected_at': datetime.utcnow().isoformat(),
'permissions': perms, '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) links.append(link)
self._save(links) self._save(links)
# Apply iptables rules for the new cell (non-fatal if it fails)
try: try:
import firewall_manager as _fm import firewall_manager as _fm
_fm.apply_cell_rules(name, invite['vpn_subnet'], inbound) _fm.apply_cell_rules(name, invite['vpn_subnet'], inbound)
except Exception as e: except Exception as e:
logger.warning(f"apply_cell_rules for {name} failed (non-fatal): {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 return link
def remove_connection(self, cell_name: str): def remove_connection(self, cell_name: str):
@@ -145,7 +350,6 @@ class CellLinkManager:
if not link: if not link:
raise ValueError(f"Cell '{cell_name}' not found") raise ValueError(f"Cell '{cell_name}' not found")
# Clear firewall rules first (non-fatal)
try: try:
import firewall_manager as _fm import firewall_manager as _fm
_fm.clear_cell_rules(cell_name) _fm.clear_cell_rules(cell_name)
@@ -163,7 +367,7 @@ class CellLinkManager:
outbound: Dict[str, bool]) -> Dict[str, Any]: outbound: Dict[str, bool]) -> Dict[str, Any]:
"""Update service sharing permissions for a cell connection. """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. Returns the updated link record.
""" """
links = self._load() links = self._load()
@@ -171,13 +375,11 @@ class CellLinkManager:
if not link: if not link:
raise ValueError(f"Cell '{cell_name}' not found") 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} clean_outbound = {s: bool(outbound.get(s, False)) for s in VALID_SERVICES}
link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound} link['permissions'] = {'inbound': clean_inbound, 'outbound': clean_outbound}
self._save(links) self._save(links)
# Re-apply firewall rules
inbound_list = [s for s, v in clean_inbound.items() if v] inbound_list = [s for s, v in clean_inbound.items() if v]
try: try:
import firewall_manager as _fm import firewall_manager as _fm
@@ -185,6 +387,9 @@ class CellLinkManager:
except Exception as e: except Exception as e:
logger.warning(f"apply_cell_rules for {cell_name} failed (non-fatal): {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 return link
def get_permissions(self, cell_name: str) -> Dict[str, Any]: def get_permissions(self, cell_name: str) -> Dict[str, Any]:
+81
View File
@@ -1,10 +1,46 @@
import ipaddress
import logging import logging
import os import os
from datetime import datetime
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from cell_link_manager import VALID_SERVICES from cell_link_manager import VALID_SERVICES
logger = logging.getLogger('picell') logger = logging.getLogger('picell')
bp = Blueprint('cells', __name__) 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']) @bp.route('/api/cells/invite', methods=['GET'])
def get_cell_invite(): def get_cell_invite():
try: try:
@@ -124,3 +160,48 @@ def update_cell_permissions(cell_name):
except Exception as e: except Exception as e:
logger.error(f"Error updating cell permissions: {e}") logger.error(f"Error updating cell permissions: {e}")
return jsonify({'error': str(e)}), 500 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
+270
View File
@@ -398,3 +398,273 @@ class TestLoadMigration(unittest.TestCase):
with open(links_file) as f: with open(links_file) as f:
raw = json.load(f) raw = json.load(f)
self.assertIn('permissions', raw[0]) self.assertIn('permissions', raw[0])
class TestPermissionSync(unittest.TestCase):
"""Tests for Phase 1: permission sync between connected PIC cells."""
INVITE = {
'cell_name': 'office',
'public_key': 'officepubkey=',
'endpoint': '5.6.7.8:51820',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'domain': 'office.cell',
'version': 1,
}
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.wg = _make_wg_mock()
self.nm = _make_nm_mock()
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
def tearDown(self):
shutil.rmtree(self.test_dir)
def _add_office(self, push_ok=True):
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
return_value={'ok': push_ok, 'error': None if push_ok else 'conn refused'}), \
patch('firewall_manager.apply_cell_rules'):
return self.mgr.add_connection(self.INVITE)
# ── add_connection ────────────────────────────────────────────────────────
def test_add_connection_includes_sync_fields(self):
link = self._add_office()
self.assertIn('remote_api_url', link)
self.assertIn('pending_push', link)
self.assertIn('last_push_status', link)
self.assertIn('last_push_at', link)
self.assertIn('last_remote_update_at', link)
def test_add_connection_sets_remote_api_url_from_dns_ip(self):
link = self._add_office()
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
def test_add_connection_triggers_push(self):
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock), \
patch('firewall_manager.apply_cell_rules'):
self.mgr.add_connection(self.INVITE)
push_mock.assert_called_once()
call_args = push_mock.call_args[0]
self.assertEqual(call_args[1], 'home') # from_cell
self.assertEqual(call_args[2], 'homepubkey=') # from_public_key
def test_add_connection_push_failure_does_not_abort_add(self):
link = self._add_office(push_ok=False)
conns = self.mgr.list_connections()
self.assertEqual(len(conns), 1)
self.assertEqual(conns[0]['cell_name'], 'office')
self.assertTrue(conns[0]['pending_push'])
def test_add_connection_push_success_clears_pending(self):
self._add_office(push_ok=True)
link = self.mgr.list_connections()[0]
self.assertFalse(link['pending_push'])
self.assertEqual(link['last_push_status'], 'ok')
# ── update_permissions ────────────────────────────────────────────────────
def test_update_permissions_push_succeeds_clears_pending(self):
self._add_office()
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock), \
patch('firewall_manager.apply_cell_rules'):
self.mgr.update_permissions('office',
{'calendar': True}, {'files': False})
link = self.mgr.list_connections()[0]
self.assertFalse(link['pending_push'])
self.assertEqual(link['last_push_status'], 'ok')
self.assertIsNotNone(link['last_push_at'])
def test_update_permissions_push_failure_keeps_local_save(self):
self._add_office()
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
return_value={'ok': False, 'error': 'timeout'}), \
patch('firewall_manager.apply_cell_rules'):
result = self.mgr.update_permissions('office',
{'calendar': True}, {})
# Local save must have happened — calendar is True
self.assertTrue(result['permissions']['inbound']['calendar'])
link = self.mgr.list_connections()[0]
self.assertTrue(link['pending_push'])
self.assertEqual(link['last_push_status'], 'failed')
def test_update_permissions_does_not_raise_on_push_exception(self):
self._add_office()
with patch('cell_link_manager.CellLinkManager._local_identity',
side_effect=RuntimeError('no app context')), \
patch('firewall_manager.apply_cell_rules'):
# Must not raise
result = self.mgr.update_permissions('office', {}, {})
self.assertIn('permissions', result)
# ── _push_permissions_to_remote (unit) ────────────────────────────────────
def test_push_mirrors_inbound_outbound(self):
"""Our inbound (what we share) must become their outbound in the body."""
self._add_office()
link = self.mgr.list_connections()[0]
link['permissions'] = {
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False},
}
sent_body = {}
def fake_urlopen(req, timeout=None):
import json as _j
sent_body.update(_j.loads(req.data))
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
resp.status = 200
return resp
with patch('urllib.request.urlopen', fake_urlopen):
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
self.assertTrue(result['ok'])
pushed_perms = sent_body['permissions']
# Our inbound=calendar:True → their outbound=calendar:True
self.assertTrue(pushed_perms['outbound']['calendar'])
# Our outbound=files:True → their inbound=files:True
self.assertTrue(pushed_perms['inbound']['files'])
def test_push_http_error_returns_not_ok(self):
self._add_office()
link = self.mgr.list_connections()[0]
with patch('urllib.request.urlopen',
side_effect=__import__('urllib.error', fromlist=['HTTPError']).HTTPError(
url='', code=503, msg='Service Unavailable', hdrs=None, fp=None)):
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
self.assertFalse(result['ok'])
self.assertIn('503', result['error'])
def test_push_no_remote_api_url_returns_not_ok(self):
self._add_office()
link = self.mgr.list_connections()[0]
link['remote_api_url'] = None
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
self.assertFalse(result['ok'])
# ── apply_remote_permissions ──────────────────────────────────────────────
def test_apply_remote_permissions_stores_by_pubkey(self):
self._add_office()
with patch('firewall_manager.apply_cell_rules'):
updated = self.mgr.apply_remote_permissions(
'officepubkey=',
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False}},
)
self.assertTrue(updated['permissions']['inbound']['calendar'])
self.assertTrue(updated['permissions']['outbound']['files'])
# Persisted to disk
link = self.mgr.list_connections()[0]
self.assertTrue(link['permissions']['inbound']['calendar'])
self.assertIsNotNone(link['last_remote_update_at'])
def test_apply_remote_permissions_unknown_pubkey_raises(self):
self._add_office()
with self.assertRaises(ValueError):
self.mgr.apply_remote_permissions('nosuchkey=', {})
def test_apply_remote_permissions_calls_apply_cell_rules(self):
self._add_office()
with patch('firewall_manager.apply_cell_rules') as mock_rules:
self.mgr.apply_remote_permissions(
'officepubkey=',
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {}},
)
mock_rules.assert_called_once_with('office', '10.1.0.0/24', ['calendar'])
# ── replay_pending_pushes ─────────────────────────────────────────────────
def test_replay_retries_pending_links(self):
self._add_office(push_ok=False) # leaves pending_push=True
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock):
summary = self.mgr.replay_pending_pushes()
push_mock.assert_called_once()
self.assertEqual(summary['attempted'], 1)
self.assertEqual(summary['ok'], 1)
self.assertFalse(self.mgr.list_connections()[0]['pending_push'])
def test_replay_skips_non_pending_links(self):
self._add_office(push_ok=True) # pending_push=False after success
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock):
summary = self.mgr.replay_pending_pushes()
push_mock.assert_not_called()
self.assertEqual(summary['attempted'], 0)
def test_replay_push_failure_leaves_pending(self):
self._add_office(push_ok=False)
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
return_value={'ok': False, 'error': 'timeout'}):
summary = self.mgr.replay_pending_pushes()
self.assertEqual(summary['failed'], 1)
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
def test_replay_identity_failure_returns_empty_summary(self):
self._add_office(push_ok=False)
with patch('cell_link_manager.CellLinkManager._local_identity',
side_effect=RuntimeError('no app context')):
summary = self.mgr.replay_pending_pushes()
self.assertEqual(summary['attempted'], 0)
# ── _load migration ───────────────────────────────────────────────────────
def test_load_migration_injects_sync_fields_on_legacy_record(self):
legacy = [{
'cell_name': 'office',
'public_key': 'officepubkey=',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'domain': 'office.cell',
'permissions': {'inbound': {}, 'outbound': {}},
}]
links_file = os.path.join(self.test_dir, 'cell_links.json')
with open(links_file, 'w') as f:
json.dump(legacy, f)
links = self.mgr.list_connections()
link = links[0]
self.assertIn('remote_api_url', link)
self.assertIn('pending_push', link)
self.assertIn('last_push_status', link)
self.assertIn('last_push_at', link)
self.assertIn('last_remote_update_at', link)
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
self.assertTrue(link['pending_push']) # pre-existing → marked pending
self.assertEqual(link['last_push_status'], 'never')
# Fields persisted to disk after migration
with open(links_file) as f:
raw = json.load(f)
self.assertIn('pending_push', raw[0])
if __name__ == '__main__':
unittest.main()
+169
View File
@@ -516,5 +516,174 @@ class TestUpdateCellPermissions(unittest.TestCase):
) )
class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
"""POST /api/cells/peer-sync/permissions — machine-to-machine permission sync."""
_KNOWN_LINK = {
'cell_name': 'office',
'public_key': 'officepubkey=',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'domain': 'office.cell',
'permissions': {'inbound': {}, 'outbound': {}},
'pending_push': False,
'remote_api_url': 'http://10.1.0.1:3000',
}
_VALID_BODY = {
'version': 1,
'from_cell': 'office',
'from_public_key': 'officepubkey=',
'permissions': {
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
},
'sent_at': '2026-05-01T00:00:00Z',
}
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
@patch('app.cell_link_manager')
def test_valid_source_ip_returns_200(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(self._VALID_BODY),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertTrue(data.get('ok'))
@patch('app.cell_link_manager')
def test_valid_source_calls_apply_remote_permissions(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(self._VALID_BODY),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
mock_clm.apply_remote_permissions.assert_called_once_with(
'officepubkey=', self._VALID_BODY['permissions']
)
@patch('app.cell_link_manager')
def test_unknown_source_ip_returns_403(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(self._VALID_BODY),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.9.9.9'},
)
self.assertEqual(r.status_code, 403)
mock_clm.apply_remote_permissions.assert_not_called()
@patch('app.cell_link_manager')
def test_pubkey_mismatch_returns_403(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
body = dict(self._VALID_BODY, from_public_key='wrongkey=')
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(body),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 403)
mock_clm.apply_remote_permissions.assert_not_called()
@patch('app.cell_link_manager')
def test_xff_header_used_for_source_ip(self, mock_clm):
"""Caddy appends source IP as last X-Forwarded-For entry."""
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(self._VALID_BODY),
content_type='application/json',
environ_base={'REMOTE_ADDR': '172.20.0.5'}, # docker bridge — not in cell subnet
headers={'X-Forwarded-For': '192.168.1.1, 10.1.0.5'}, # last entry is real source
)
self.assertEqual(r.status_code, 200)
@patch('app.cell_link_manager')
def test_missing_version_returns_400(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
body = {k: v for k, v in self._VALID_BODY.items() if k != 'version'}
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(body),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 400)
@patch('app.cell_link_manager')
def test_wrong_version_returns_400(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
body = dict(self._VALID_BODY, version=99)
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(body),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 400)
@patch('app.cell_link_manager')
def test_unknown_service_name_returns_400(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
body = dict(self._VALID_BODY)
body['permissions'] = {'inbound': {'hacked': True}, 'outbound': {}}
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(body),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 400)
mock_clm.apply_remote_permissions.assert_not_called()
@patch('app.cell_link_manager')
def test_no_body_returns_400(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
r = self.client.post(
'/api/cells/peer-sync/permissions',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 400)
@patch('app.cell_link_manager')
def test_apply_remote_permissions_exception_returns_500(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
mock_clm.apply_remote_permissions.side_effect = IOError('disk full')
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(self._VALID_BODY),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 500)
self.assertIn('error', json.loads(r.data))
@patch('app.cell_link_manager')
def test_value_error_from_apply_returns_404(self, mock_clm):
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
mock_clm.apply_remote_permissions.side_effect = ValueError('no link')
r = self.client.post(
'/api/cells/peer-sync/permissions',
data=json.dumps(self._VALID_BODY),
content_type='application/json',
environ_base={'REMOTE_ADDR': '10.1.0.5'},
)
self.assertEqual(r.status_code, 404)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()