diff --git a/api/app.py b/api/app.py index e94510f..e052556 100644 --- a/api/app.py +++ b/api/app.py @@ -267,6 +267,9 @@ def _apply_startup_enforcement(): firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(), cell_links=cell_links) 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: logger.warning(f"Startup enforcement failed (non-fatal): {e}") diff --git a/api/cell_link_manager.py b/api/cell_link_manager.py index 0757274..76a83e4 100644 --- a/api/cell_link_manager.py +++ b/api/cell_link_manager.py @@ -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]: diff --git a/api/routes/cells.py b/api/routes/cells.py index 79c8add..b787ceb 100644 --- a/api/routes/cells.py +++ b/api/routes/cells.py @@ -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 diff --git a/tests/test_cell_link_manager.py b/tests/test_cell_link_manager.py index 0ab5484..61fefea 100644 --- a/tests/test_cell_link_manager.py +++ b/tests/test_cell_link_manager.py @@ -398,3 +398,273 @@ class TestLoadMigration(unittest.TestCase): with open(links_file) as f: raw = json.load(f) 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() diff --git a/tests/test_cells_endpoints.py b/tests/test_cells_endpoints.py index f3a579d..6240606 100644 --- a/tests/test_cells_endpoints.py +++ b/tests/test_cells_endpoints.py @@ -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__': unittest.main()