diff --git a/api/cell_link_manager.py b/api/cell_link_manager.py index ab76ce6..15abbb7 100644 --- a/api/cell_link_manager.py +++ b/api/cell_link_manager.py @@ -11,8 +11,9 @@ Each connection is stored in data/cell_links.json and manifests as: import json import logging import os +import random import subprocess -from datetime import datetime +from datetime import datetime, timezone, timedelta from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @@ -25,6 +26,15 @@ _DEFAULT_PERMISSIONS = { } _PUSH_TIMEOUT = 5 # seconds +_BACKOFF_BASE_S = 60 +_BACKOFF_MAX_S = 3600 + + +def _compute_next_retry(attempts: int) -> str: + """Return an ISO timestamp for the earliest next retry using capped exponential backoff.""" + delay = min(_BACKOFF_BASE_S * (2 ** (attempts - 1)), _BACKOFF_MAX_S) + delay += random.uniform(0, _BACKOFF_BASE_S / 2) + return (datetime.utcnow() + timedelta(seconds=delay)).isoformat() def _default_perms() -> Dict[str, Any]: @@ -91,6 +101,13 @@ class CellLinkManager: if 'remote_exit_relay_active' not in link: link['remote_exit_relay_active'] = False changed = True + # Phase 4 migration: retry/backoff state + if 'push_attempts' not in link: + link['push_attempts'] = 0 + changed = True + if 'next_retry_at' not in link: + link['next_retry_at'] = None + changed = True if changed: self._save(links) return links @@ -214,10 +231,15 @@ class CellLinkManager: link['last_push_at'] = datetime.utcnow().isoformat() link['last_push_error'] = None link['pending_push'] = False + link['push_attempts'] = 0 + link['next_retry_at'] = None else: link['last_push_status'] = 'failed' link['last_push_error'] = result.get('error') link['pending_push'] = True + attempts = link.get('push_attempts', 0) + 1 + link['push_attempts'] = attempts + link['next_retry_at'] = _compute_next_retry(attempts) break self._save(links) @@ -270,6 +292,24 @@ class CellLinkManager: link['last_remote_update_at'] = datetime.utcnow().isoformat() self._save(links) + # Soft loop-detection warning: if the remote is asking us to act as exit + # AND we already have a peer routing via that cell, it's a potential cycle. + if use_as_exit_relay: + try: + from peer_registry import PeerRegistry + import os as _os + pr = PeerRegistry(_os.environ.get('DATA_DIR', '/app/data')) + loop_peers = [p['name'] for p in pr.list_peers() + if p.get('route_via') == link['cell_name']] + if loop_peers: + logger.warning( + f"apply_remote_permissions: '{link['cell_name']}' asked us to act as " + f"its exit relay, but we already route peers {loop_peers} via it — " + f"potential routing loop detected" + ) + except Exception: + pass + inbound_list = [s for s, v in clean_inbound.items() if v] try: import firewall_manager as _fm @@ -295,9 +335,18 @@ class CellLinkManager: logger.warning(f"replay_pending_pushes: cannot resolve identity ({e})") return summary + summary['deferred'] = 0 + now_iso = datetime.utcnow().isoformat() for link in self._load(): if not link.get('pending_push'): continue + next_retry = link.get('next_retry_at') + if next_retry and next_retry > now_iso: + summary['deferred'] += 1 + logger.info( + f"replay: skipping '{link['cell_name']}' — backoff until {next_retry}" + ) + continue summary['attempted'] += 1 result = self._push_permissions_to_remote( link, identity['cell_name'], identity['public_key'] @@ -311,10 +360,11 @@ class CellLinkManager: logger.warning( f"replay: push to '{link['cell_name']}' failed: {result.get('error')}" ) - if summary['attempted']: + if summary['attempted'] or summary.get('deferred'): logger.info( f"replay_pending_pushes: {summary['attempted']} attempted, " - f"{summary['ok']} ok, {summary['failed']} failed" + f"{summary['ok']} ok, {summary['failed']} failed, " + f"{summary.get('deferred', 0)} deferred (backoff)" ) return summary diff --git a/api/routes/peers.py b/api/routes/peers.py index 7c011ee..129f7d1 100644 --- a/api/routes/peers.py +++ b/api/routes/peers.py @@ -237,6 +237,13 @@ def set_peer_route_via(peer_name): ) if not link: return jsonify({'error': f"Cell {via_cell!r} not connected"}), 404 + if link.get('remote_exit_relay_active'): + return jsonify({ + 'error': ( + f"Cannot route via '{via_cell}': it is already routing peers " + f"through this cell — enabling both directions would create a loop" + ) + }), 409 wireguard_manager.update_cell_peer_allowed_ips( link['public_key'], link['vpn_subnet'], add_default_route=True) wireguard_manager.apply_peer_route_via(peer_ip, via_wg_ip=link['dns_ip']) diff --git a/tests/test_cell_link_manager.py b/tests/test_cell_link_manager.py index ac93daf..e9715bb 100644 --- a/tests/test_cell_link_manager.py +++ b/tests/test_cell_link_manager.py @@ -210,6 +210,513 @@ if __name__ == '__main__': unittest.main() +# --------------------------------------------------------------------------- +# TestCheckInviteConflicts +# --------------------------------------------------------------------------- + +class TestCheckInviteConflicts(unittest.TestCase): + """Tests for CellLinkManager._check_invite_conflicts.""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.wg = _make_wg_mock() + # wg._get_configured_network returns '10.0.0.0/24' (own subnet) + 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_existing_cell(self, cell_name='cabin', vpn_subnet='10.2.0.0/24', + domain='cabin.cell'): + """Add a cell link directly to disk without going through add_connection.""" + links = [{ + 'cell_name': cell_name, + 'public_key': 'cabinpubkey=', + 'endpoint': '9.9.9.9:51820', + 'vpn_subnet': vpn_subnet, + 'dns_ip': '10.2.0.1', + 'domain': domain, + 'permissions': {'inbound': {}, 'outbound': {}}, + 'remote_api_url': f'http://10.2.0.1:3000', + 'pending_push': False, + 'last_push_status': 'ok', + 'last_push_at': None, + 'last_push_error': None, + 'last_remote_update_at': None, + }] + with open(os.path.join(self.test_dir, 'cell_links.json'), 'w') as f: + json.dump(links, f) + + # --- subnet conflicts --- + + def test_subnet_overlaps_own_subnet_raises(self): + """Invite whose vpn_subnet overlaps our own subnet raises ValueError.""" + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.0.0.0/24'} # same as own + with self.assertRaises(ValueError) as ctx: + self.mgr._check_invite_conflicts(invite) + self.assertIn('subnet', str(ctx.exception).lower()) + + def test_subnet_overlaps_own_subnet_partial_raises(self): + """Invite whose vpn_subnet partially overlaps our own subnet raises ValueError.""" + # Own is 10.0.0.0/24; this /16 contains it + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.0.0.0/16'} + with self.assertRaises(ValueError): + self.mgr._check_invite_conflicts(invite) + + def test_subnet_overlaps_connected_cell_raises(self): + """Invite whose vpn_subnet overlaps an already-connected cell raises ValueError.""" + self._add_existing_cell(vpn_subnet='10.2.0.0/24') + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.2.0.0/24'} + with self.assertRaises(ValueError) as ctx: + self.mgr._check_invite_conflicts(invite) + self.assertIn('cabin', str(ctx.exception)) + + def test_subnet_no_conflict_does_not_raise(self): + """Invite with a non-overlapping subnet passes without error.""" + self._add_existing_cell(vpn_subnet='10.2.0.0/24') + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.3.0.0/24', 'domain': 'other.cell'} + # Should not raise + self.mgr._check_invite_conflicts(invite) + + # --- domain conflicts --- + + def test_domain_matches_own_domain_raises(self): + """Invite with a domain equal to this cell's own domain raises ValueError.""" + with patch('cell_link_manager.CellLinkManager._check_invite_conflicts', + wraps=self.mgr._check_invite_conflicts): + # Patch config_manager inside the function + with patch('cell_link_manager.os.environ.get', return_value='home.cell'): + # Use a fresh invite whose domain matches env-derived own domain + invite = {**SAMPLE_INVITE, + 'vpn_subnet': '10.3.0.0/24', + 'domain': 'home.cell'} + # Manually test via app import patch + import sys + fake_cfg = MagicMock() + fake_cfg.configs = {'_identity': {'domain': 'home.cell'}} + with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}): + with self.assertRaises(ValueError) as ctx: + self.mgr._check_invite_conflicts(invite) + self.assertIn('domain', str(ctx.exception).lower()) + + def test_domain_matches_connected_cell_raises(self): + """Invite with a domain already used by a connected cell raises ValueError.""" + self._add_existing_cell(domain='cabin.cell', vpn_subnet='10.2.0.0/24') + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.3.0.0/24', 'domain': 'cabin.cell'} + with self.assertRaises(ValueError) as ctx: + self.mgr._check_invite_conflicts(invite) + self.assertIn('cabin', str(ctx.exception)) + + # --- exclude_cell parameter --- + + def test_exclude_cell_skips_that_cell_subnet_check(self): + """With exclude_cell set, the named cell is skipped in subnet conflict check.""" + self._add_existing_cell(cell_name='cabin', vpn_subnet='10.2.0.0/24', + domain='cabin.cell') + # Same subnet as cabin — normally a conflict, but excluded + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.2.0.0/24', 'domain': 'cabin.cell'} + # Should not raise because 'cabin' is excluded + self.mgr._check_invite_conflicts(invite, exclude_cell='cabin') + + def test_exclude_cell_skips_that_cell_domain_check(self): + """With exclude_cell set, the named cell is skipped in domain conflict check.""" + self._add_existing_cell(cell_name='cabin', vpn_subnet='10.2.0.0/24', + domain='cabin.cell') + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.9.0.0/24', 'domain': 'cabin.cell'} + # Should not raise — cabin excluded + self.mgr._check_invite_conflicts(invite, exclude_cell='cabin') + + def test_exclude_cell_still_checks_other_cells(self): + """Excluding 'cabin' does not suppress conflict with a different cell.""" + self._add_existing_cell(cell_name='cabin', vpn_subnet='10.2.0.0/24', + domain='cabin.cell') + # Add a second cell manually + with open(os.path.join(self.test_dir, 'cell_links.json')) as f: + links = json.load(f) + links.append({ + 'cell_name': 'office', + 'public_key': 'officepubkey=', + 'vpn_subnet': '10.3.0.0/24', + 'dns_ip': '10.3.0.1', + 'domain': 'office.cell', + 'permissions': {'inbound': {}, 'outbound': {}}, + 'remote_api_url': 'http://10.3.0.1:3000', + 'pending_push': False, + 'last_push_status': 'ok', + 'last_push_at': None, + 'last_push_error': None, + 'last_remote_update_at': None, + }) + with open(os.path.join(self.test_dir, 'cell_links.json'), 'w') as f: + json.dump(links, f) + # Conflicts with 'office', but we only exclude 'cabin' + invite = {**SAMPLE_INVITE, 'vpn_subnet': '10.3.0.0/24', 'domain': 'new.cell'} + with self.assertRaises(ValueError): + self.mgr._check_invite_conflicts(invite, exclude_cell='cabin') + + +# --------------------------------------------------------------------------- +# TestPushInviteToRemote +# --------------------------------------------------------------------------- + +class TestPushInviteToRemote(unittest.TestCase): + """Tests for CellLinkManager._push_invite_to_remote.""" + + 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 _make_link(self, endpoint='192.168.1.50:51820'): + return { + 'cell_name': 'office', + 'public_key': 'officepubkey=', + 'endpoint': endpoint, + 'vpn_subnet': '10.1.0.0/24', + 'dns_ip': '10.1.0.1', + 'domain': 'office.cell', + 'remote_api_url': 'http://10.1.0.1:3000', + } + + def _fake_identity(self): + return {'cell_name': 'home', 'public_key': 'homepubkey='} + + def test_push_invite_success_2xx_returns_ok_true(self): + """curl returning a 2xx status code → {'ok': True}.""" + link = self._make_link() + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '201' + mock_result.stderr = '' + + with patch('cell_link_manager.CellLinkManager._local_identity', + return_value=self._fake_identity()), \ + patch('cell_link_manager.os.environ.get', return_value='home.cell'), \ + patch('subprocess.run', return_value=mock_result): + import sys + fake_cfg = MagicMock() + fake_cfg.configs = {'_identity': {'domain': 'home.cell'}} + with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}): + result = self.mgr._push_invite_to_remote(link) + self.assertTrue(result['ok']) + + def test_push_invite_4xx_returns_ok_false_with_http_error(self): + """curl returning a 4xx status code → {'ok': False, 'error': 'HTTP 4xx'}.""" + link = self._make_link() + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '400' + mock_result.stderr = '' + + with patch('cell_link_manager.CellLinkManager._local_identity', + return_value=self._fake_identity()), \ + patch('subprocess.run', return_value=mock_result): + import sys + fake_cfg = MagicMock() + fake_cfg.configs = {'_identity': {'domain': 'home.cell'}} + with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}): + result = self.mgr._push_invite_to_remote(link) + self.assertFalse(result['ok']) + self.assertIn('400', result['error']) + + def test_push_invite_no_endpoint_returns_ok_false(self): + """Link with no endpoint → {'ok': False, 'error': 'no endpoint'}.""" + link = self._make_link(endpoint='') + result = self.mgr._push_invite_to_remote(link) + self.assertFalse(result['ok']) + self.assertIn('endpoint', result['error'].lower()) + + def test_push_invite_none_endpoint_returns_ok_false(self): + """Link with endpoint=None → {'ok': False, 'error': 'no endpoint'}.""" + link = self._make_link(endpoint='') + link['endpoint'] = None + result = self.mgr._push_invite_to_remote(link) + self.assertFalse(result['ok']) + + def test_push_invite_subprocess_error_returns_ok_false(self): + """subprocess.run raising an exception → {'ok': False, 'error': ...}.""" + link = self._make_link() + with patch('cell_link_manager.CellLinkManager._local_identity', + return_value=self._fake_identity()), \ + patch('subprocess.run', side_effect=OSError('command not found')): + import sys + fake_cfg = MagicMock() + fake_cfg.configs = {'_identity': {'domain': 'home.cell'}} + with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}): + result = self.mgr._push_invite_to_remote(link) + self.assertFalse(result['ok']) + self.assertIsNotNone(result['error']) + + def test_push_invite_curl_nonzero_returncode_returns_ok_false(self): + """curl subprocess returning nonzero returncode → {'ok': False}.""" + link = self._make_link() + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = '' + mock_result.stderr = 'connection refused' + + with patch('cell_link_manager.CellLinkManager._local_identity', + return_value=self._fake_identity()), \ + patch('subprocess.run', return_value=mock_result): + import sys + fake_cfg = MagicMock() + fake_cfg.configs = {'_identity': {'domain': 'home.cell'}} + with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}): + result = self.mgr._push_invite_to_remote(link) + self.assertFalse(result['ok']) + + def test_push_invite_sends_to_correct_lan_host(self): + """The curl URL must use the LAN IP from the endpoint, not the WG dns_ip.""" + link = self._make_link(endpoint='192.168.31.52:51820') + captured = {} + + def fake_run(cmd, **kw): + captured['cmd'] = cmd + r = MagicMock() + r.returncode = 0 + r.stdout = '201' + r.stderr = '' + return r + + with patch('cell_link_manager.CellLinkManager._local_identity', + return_value=self._fake_identity()), \ + patch('subprocess.run', fake_run): + import sys + fake_cfg = MagicMock() + fake_cfg.configs = {'_identity': {'domain': 'home.cell'}} + with patch.dict(sys.modules, {'app': MagicMock(config_manager=fake_cfg)}): + self.mgr._push_invite_to_remote(link) + + url_in_cmd = captured['cmd'][-1] + self.assertIn('192.168.31.52', url_in_cmd) + self.assertIn('accept-invite', url_in_cmd) + # Must NOT use the WG dns_ip (10.1.0.1) + self.assertNotIn('10.1.0.1', url_in_cmd) + + +# --------------------------------------------------------------------------- +# TestAcceptInviteNew +# --------------------------------------------------------------------------- + +class TestAcceptInviteNew(unittest.TestCase): + """Tests for CellLinkManager.accept_invite — new connection path.""" + + 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 test_accept_invite_new_cell_adds_wg_peer(self): + """accept_invite for a new cell calls add_cell_peer on WG manager.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + self.wg.add_cell_peer.assert_called_once_with( + name='office', + public_key='officepubkey=', + endpoint='5.6.7.8:51820', + vpn_subnet='10.1.0.0/24', + ) + + def test_accept_invite_new_cell_adds_dns_forward(self): + """accept_invite for a new cell calls add_cell_dns_forward on NM.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + self.nm.add_cell_dns_forward.assert_called_once_with( + domain='office.cell', dns_ip='10.1.0.1') + + def test_accept_invite_new_cell_saves_link(self): + """accept_invite for a new cell saves the link and returns it.""" + with patch('firewall_manager.apply_cell_rules'): + link = self.mgr.accept_invite(SAMPLE_INVITE) + self.assertEqual(link['cell_name'], 'office') + self.assertEqual(len(self.mgr.list_connections()), 1) + + def test_accept_invite_new_cell_sets_pending_push_true(self): + """New link from accept_invite starts with pending_push=True (no push done).""" + with patch('firewall_manager.apply_cell_rules'): + link = self.mgr.accept_invite(SAMPLE_INVITE) + self.assertTrue(link['pending_push']) + + def test_accept_invite_missing_cell_name_raises(self): + """Invite missing 'cell_name' field raises ValueError.""" + invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'cell_name'} + with self.assertRaises(ValueError) as ctx: + self.mgr.accept_invite(invite) + self.assertIn('cell_name', str(ctx.exception)) + + def test_accept_invite_missing_public_key_raises(self): + """Invite missing 'public_key' field raises ValueError.""" + invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'public_key'} + with self.assertRaises(ValueError) as ctx: + self.mgr.accept_invite(invite) + self.assertIn('public_key', str(ctx.exception)) + + def test_accept_invite_missing_vpn_subnet_raises(self): + """Invite missing 'vpn_subnet' field raises ValueError.""" + invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'vpn_subnet'} + with self.assertRaises(ValueError) as ctx: + self.mgr.accept_invite(invite) + self.assertIn('vpn_subnet', str(ctx.exception)) + + def test_accept_invite_missing_dns_ip_raises(self): + """Invite missing 'dns_ip' field raises ValueError.""" + invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'dns_ip'} + with self.assertRaises(ValueError) as ctx: + self.mgr.accept_invite(invite) + self.assertIn('dns_ip', str(ctx.exception)) + + def test_accept_invite_missing_domain_raises(self): + """Invite missing 'domain' field raises ValueError.""" + invite = {k: v for k, v in SAMPLE_INVITE.items() if k != 'domain'} + with self.assertRaises(ValueError) as ctx: + self.mgr.accept_invite(invite) + self.assertIn('domain', str(ctx.exception)) + + def test_accept_invite_subnet_conflict_raises(self): + """accept_invite raises ValueError when subnet conflicts with own subnet.""" + conflicting = {**SAMPLE_INVITE, 'vpn_subnet': '10.0.0.0/24'} # same as own + with self.assertRaises(ValueError): + self.mgr.accept_invite(conflicting) + + def test_accept_invite_already_connected_no_change_returns_existing(self): + """Calling accept_invite again with identical data returns existing link unchanged.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + self.wg.reset_mock() + result = self.mgr.accept_invite(SAMPLE_INVITE) + self.assertEqual(result['cell_name'], 'office') + # No second WG peer add + self.wg.add_cell_peer.assert_not_called() + + def test_accept_invite_already_connected_dns_ip_change_updates(self): + """accept_invite with changed dns_ip updates the link and DNS forward.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + updated = {**SAMPLE_INVITE, 'dns_ip': '10.1.0.5'} + with patch('firewall_manager.apply_cell_rules'): + result = self.mgr.accept_invite(updated) + self.assertEqual(result['dns_ip'], '10.1.0.5') + self.assertEqual(result['remote_api_url'], 'http://10.1.0.5:3000') + self.nm.remove_cell_dns_forward.assert_called() + self.nm.add_cell_dns_forward.assert_called_with( + domain='office.cell', dns_ip='10.1.0.5') + + def test_accept_invite_already_connected_dns_ip_change_does_not_duplicate(self): + """DNS ip update via accept_invite must not create a second link.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + self.mgr.accept_invite({**SAMPLE_INVITE, 'dns_ip': '10.1.0.5'}) + self.assertEqual(len(self.mgr.list_connections()), 1) + + def test_accept_invite_already_connected_vpn_subnet_change_calls_update_peer_ip(self): + """accept_invite with changed vpn_subnet calls update_peer_ip on WG manager.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + self.wg.update_peer_ip = MagicMock(return_value=True) + updated = {**SAMPLE_INVITE, 'vpn_subnet': '10.5.0.0/24'} + with patch('firewall_manager.apply_cell_rules'): + result = self.mgr.accept_invite(updated) + self.assertEqual(result['vpn_subnet'], '10.5.0.0/24') + self.wg.update_peer_ip.assert_called_once_with('officepubkey=', '10.5.0.0/24') + + def test_accept_invite_already_connected_vpn_subnet_change_reapplies_firewall(self): + """accept_invite with changed vpn_subnet triggers apply_cell_rules.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + self.wg.update_peer_ip = MagicMock(return_value=True) + updated = {**SAMPLE_INVITE, 'vpn_subnet': '10.5.0.0/24'} + with patch('firewall_manager.apply_cell_rules') as mock_rules: + self.mgr.accept_invite(updated) + mock_rules.assert_called() + + def test_accept_invite_does_not_duplicate_on_repeated_call(self): + """Multiple calls with the same invite must leave exactly one link.""" + with patch('firewall_manager.apply_cell_rules'): + self.mgr.accept_invite(SAMPLE_INVITE) + self.mgr.accept_invite(SAMPLE_INVITE) + self.assertEqual(len(self.mgr.list_connections()), 1) + + +# --------------------------------------------------------------------------- +# TestAddConnectionMutualPairing +# --------------------------------------------------------------------------- + +class TestAddConnectionMutualPairing(unittest.TestCase): + """Tests for add_connection's mutual pairing via _push_invite_to_remote.""" + + 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_with_push(self, push_result): + push_mock = MagicMock(return_value=push_result) + perm_push = MagicMock(return_value={'ok': True, 'error': None}) + with patch('cell_link_manager.CellLinkManager._push_invite_to_remote', push_mock), \ + patch('cell_link_manager.CellLinkManager._local_identity', + return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \ + patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', perm_push), \ + patch('firewall_manager.apply_cell_rules'): + link = self.mgr.add_connection(SAMPLE_INVITE) + return link, push_mock + + def test_add_connection_calls_push_invite_to_remote(self): + """add_connection calls _push_invite_to_remote after adding the connection.""" + _, push_mock = self._add_with_push({'ok': True, 'error': None}) + push_mock.assert_called_once() + + def test_add_connection_push_invite_failure_is_nonfatal(self): + """_push_invite_to_remote failure does not prevent connection creation.""" + link, _ = self._add_with_push({'ok': False, 'error': 'connection refused'}) + conns = self.mgr.list_connections() + self.assertEqual(len(conns), 1) + self.assertEqual(conns[0]['cell_name'], 'office') + + def test_add_connection_push_invite_failure_link_still_stored(self): + """Even when push fails, the link is persisted to disk.""" + _, _ = self._add_with_push({'ok': False, 'error': 'timeout'}) + mgr2 = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm) + self.assertEqual(len(mgr2.list_connections()), 1) + + def test_add_connection_with_inbound_services_sets_permissions(self): + """inbound_services passed to add_connection sets permissions correctly.""" + perm_push = MagicMock(return_value={'ok': True, 'error': None}) + push_mock = MagicMock(return_value={'ok': True, 'error': None}) + with patch('cell_link_manager.CellLinkManager._push_invite_to_remote', push_mock), \ + patch('cell_link_manager.CellLinkManager._local_identity', + return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \ + patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', perm_push), \ + patch('firewall_manager.apply_cell_rules'): + link = self.mgr.add_connection(SAMPLE_INVITE, inbound_services=['calendar']) + self.assertTrue(link['permissions']['inbound']['calendar']) + self.assertFalse(link['permissions']['inbound']['files']) + + def test_add_connection_push_invite_exception_is_nonfatal(self): + """Exception from _push_invite_to_remote is caught and does not raise.""" + perm_push = MagicMock(return_value={'ok': True, 'error': None}) + with patch('cell_link_manager.CellLinkManager._push_invite_to_remote', + side_effect=RuntimeError('docker not available')), \ + patch('cell_link_manager.CellLinkManager._local_identity', + return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \ + patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', perm_push), \ + patch('firewall_manager.apply_cell_rules'): + link = self.mgr.add_connection(SAMPLE_INVITE) + self.assertEqual(link['cell_name'], 'office') + + # --------------------------------------------------------------------------- # TestAddConnectionAtomicity # --------------------------------------------------------------------------- @@ -695,7 +1202,10 @@ class TestPermissionSync(unittest.TestCase): # ── replay_pending_pushes ───────────────────────────────────────────────── def test_replay_retries_pending_links(self): - self._add_office(push_ok=False) # leaves pending_push=True + self._add_office(push_ok=False) # leaves pending_push=True + next_retry_at set + links = self.mgr._load() + links[0]['next_retry_at'] = None # simulate backoff window elapsed + self.mgr._save(links) self.assertTrue(self.mgr.list_connections()[0]['pending_push']) push_mock = MagicMock(return_value={'ok': True, 'error': None}) @@ -721,6 +1231,9 @@ class TestPermissionSync(unittest.TestCase): def test_replay_push_failure_leaves_pending(self): self._add_office(push_ok=False) + links = self.mgr._load() + links[0]['next_retry_at'] = None # simulate backoff window elapsed + self.mgr._save(links) 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', diff --git a/tests/test_cells_endpoints.py b/tests/test_cells_endpoints.py index 896c3da..375032c 100644 --- a/tests/test_cells_endpoints.py +++ b/tests/test_cells_endpoints.py @@ -737,5 +737,170 @@ class TestSetExitOffer(unittest.TestCase): self.assertEqual(r.status_code, 404) +class TestPeerSyncAcceptInvite(unittest.TestCase): + """POST /api/cells/peer-sync/accept-invite — machine-to-machine mutual WG pairing.""" + + # A well-formed invite matching SAMPLE_INVITE in cell_link_manager tests + _VALID_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): + app.config['TESTING'] = True + self.client = app.test_client() + + def _post(self, body): + return self.client.post( + '/api/cells/peer-sync/accept-invite', + data=json.dumps(body), + content_type='application/json', + ) + + @patch('app.cell_link_manager') + def test_valid_invite_returns_201_ok_true(self, mock_clm): + """Valid invite returns 201 with {'ok': True}.""" + mock_clm.accept_invite.return_value = {'cell_name': 'office'} + r = self._post({'invite': self._VALID_INVITE}) + self.assertEqual(r.status_code, 201) + data = json.loads(r.data) + self.assertTrue(data.get('ok')) + + @patch('app.cell_link_manager') + def test_valid_invite_returns_cell_name(self, mock_clm): + """Response body contains cell_name from the accepted link.""" + mock_clm.accept_invite.return_value = {'cell_name': 'office'} + r = self._post({'invite': self._VALID_INVITE}) + data = json.loads(r.data) + self.assertEqual(data.get('cell_name'), 'office') + + @patch('app.cell_link_manager') + def test_no_invite_in_body_returns_400(self, mock_clm): + """Empty body (missing invite key) returns 400.""" + r = self._post({}) + self.assertEqual(r.status_code, 400) + data = json.loads(r.data) + self.assertFalse(data.get('ok')) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_invite_not_dict_returns_400(self, mock_clm): + """invite as a string (not a dict) returns 400.""" + r = self._post({'invite': 'not_a_dict'}) + self.assertEqual(r.status_code, 400) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_no_body_at_all_returns_400(self, mock_clm): + """POST with no body at all returns 400.""" + r = self.client.post('/api/cells/peer-sync/accept-invite') + self.assertEqual(r.status_code, 400) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_invite_missing_cell_name_returns_400(self, mock_clm): + """Invite missing 'cell_name' returns 400.""" + invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'cell_name'} + r = self._post({'invite': invite}) + self.assertEqual(r.status_code, 400) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_invite_missing_public_key_returns_400(self, mock_clm): + """Invite missing 'public_key' returns 400.""" + invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'public_key'} + r = self._post({'invite': invite}) + self.assertEqual(r.status_code, 400) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_invite_missing_vpn_subnet_returns_400(self, mock_clm): + """Invite missing 'vpn_subnet' returns 400.""" + invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'vpn_subnet'} + r = self._post({'invite': invite}) + self.assertEqual(r.status_code, 400) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_invite_missing_dns_ip_returns_400(self, mock_clm): + """Invite missing 'dns_ip' returns 400.""" + invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'dns_ip'} + r = self._post({'invite': invite}) + self.assertEqual(r.status_code, 400) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_invite_missing_domain_returns_400(self, mock_clm): + """Invite missing 'domain' returns 400.""" + invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'domain'} + r = self._post({'invite': invite}) + self.assertEqual(r.status_code, 400) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_unsupported_version_returns_400(self, mock_clm): + """Invite with version=99 (unsupported) returns 400.""" + invite = {**self._VALID_INVITE, 'version': 99} + r = self._post({'invite': invite}) + self.assertEqual(r.status_code, 400) + data = json.loads(r.data) + self.assertFalse(data.get('ok')) + mock_clm.accept_invite.assert_not_called() + + @patch('app.cell_link_manager') + def test_version_none_is_accepted(self, mock_clm): + """Invite with no version field (version=None) is valid per spec.""" + mock_clm.accept_invite.return_value = {'cell_name': 'office'} + invite = {k: v for k, v in self._VALID_INVITE.items() if k != 'version'} + r = self._post({'invite': invite}) + # version=None is allowed + self.assertEqual(r.status_code, 201) + + @patch('app.cell_link_manager') + def test_value_error_from_accept_invite_returns_400(self, mock_clm): + """ValueError from accept_invite (e.g. subnet conflict) → 400.""" + mock_clm.accept_invite.side_effect = ValueError('subnet conflict') + r = self._post({'invite': self._VALID_INVITE}) + self.assertEqual(r.status_code, 400) + data = json.loads(r.data) + self.assertFalse(data.get('ok')) + self.assertIn('subnet', data.get('error', '')) + + @patch('app.cell_link_manager') + def test_runtime_error_from_accept_invite_returns_400(self, mock_clm): + """RuntimeError from accept_invite (e.g. WG peer add failed) → 400.""" + mock_clm.accept_invite.side_effect = RuntimeError('WireGuard peer add failed') + r = self._post({'invite': self._VALID_INVITE}) + self.assertEqual(r.status_code, 400) + + @patch('app.cell_link_manager') + def test_unexpected_exception_returns_500(self, mock_clm): + """Unhandled exception in accept_invite → 500.""" + mock_clm.accept_invite.side_effect = IOError('disk full') + r = self._post({'invite': self._VALID_INVITE}) + self.assertEqual(r.status_code, 500) + + @patch('app.cell_link_manager') + def test_accept_invite_calls_manager_with_invite(self, mock_clm): + """The invite dict is passed directly to cell_link_manager.accept_invite.""" + mock_clm.accept_invite.return_value = {'cell_name': 'office'} + self._post({'invite': self._VALID_INVITE}) + mock_clm.accept_invite.assert_called_once_with(self._VALID_INVITE) + + @patch('app.cell_link_manager') + def test_idempotent_already_connected_returns_201(self, mock_clm): + """If accept_invite returns an existing link (idempotent), still 201.""" + existing = {'cell_name': 'office', 'vpn_subnet': '10.1.0.0/24'} + mock_clm.accept_invite.return_value = existing + r = self._post({'invite': self._VALID_INVITE}) + self.assertEqual(r.status_code, 201) + self.assertTrue(json.loads(r.data).get('ok')) + + if __name__ == '__main__': unittest.main() diff --git a/webui/src/pages/CellNetwork.jsx b/webui/src/pages/CellNetwork.jsx index d42fee1..3619539 100644 --- a/webui/src/pages/CellNetwork.jsx +++ b/webui/src/pages/CellNetwork.jsx @@ -54,6 +54,31 @@ function StatusDot({ online }) { : ; } +function SyncBadge({ conn }) { + const { last_push_status, last_push_at, last_push_error, pending_push, next_retry_at } = conn; + let color, label, tip; + if (last_push_status === 'never' || (!last_push_at && !pending_push)) { + color = 'bg-gray-300'; label = 'Sync pending'; + tip = 'Permissions not yet synced to remote cell'; + } else if (!pending_push && last_push_status === 'ok') { + color = 'bg-green-500'; label = `Synced${last_push_at ? ' ' + relativeTime(last_push_at) : ''}`; + tip = `Permissions last synced ${last_push_at ? relativeTime(last_push_at) : ''}`; + } else { + color = 'bg-amber-400'; label = 'Out of sync'; + tip = last_push_error ? `Sync failed: ${last_push_error}` : 'Permissions pending sync'; + if (next_retry_at) { + const retryIn = Math.max(0, Math.round((new Date(next_retry_at) - Date.now()) / 60000)); + tip += ` — next retry in ~${retryIn}m`; + } + } + return ( + + + {label} + + ); +} + function Toast({ toasts }) { return (
@@ -204,6 +229,7 @@ function CellPanel({ conn, onDisconnect, addToast }) { {conn.last_handshake && ( {relativeTime(conn.last_handshake)} )} +