feat: Phase 4 hardening — retry/backoff, loop detection, sync status UI + tests

Phase 4.1 — Retry/backoff for failed permission pushes:
- _compute_next_retry(): capped exponential backoff with jitter (60s–1h)
- _record_push_result(): tracks push_attempts and next_retry_at per link
- replay_pending_pushes(): skips links still in backoff window, logs deferred count
- _load() migration: adds push_attempts/next_retry_at to existing records

Phase 4.2 — Loop detection (A→B→A routing cycle):
- set_peer_route_via(): returns 409 if target cell already routes peers through us
- apply_remote_permissions(): soft warning when accepting exit-relay that would cycle

Phase 4.3 — Sync staleness indicator in Cell Network UI:
- SyncBadge component: green (synced), amber (pending/failed), gray (never)
- Shows relativeTime of last sync + error message + next retry estimate
- Injected into CellPanel header alongside tunnel online/handshake status

Tests (54 new):
- TestCheckInviteConflicts: subnet overlap, domain conflict, exclude_cell (9 tests)
- TestPushInviteToRemote: success, 4xx, no endpoint, subprocess errors (7 tests)
- TestAcceptInviteNew: new cell, idempotent, healing dns/subnet changes (16 tests)
- TestAddConnectionMutualPairing: push-invite call, non-fatal failure (5 tests)
- TestPeerSyncAcceptInvite endpoint: happy path, field validation, error propagation (16 tests)
- Fixed 2 existing replay tests to clear backoff gate (simulates elapsed window)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 04:18:36 -04:00
parent 960a4ecc51
commit dc2606541c
5 changed files with 765 additions and 4 deletions
+53 -3
View File
@@ -11,8 +11,9 @@ Each connection is stored in data/cell_links.json and manifests as:
import json import json
import logging import logging
import os import os
import random
import subprocess import subprocess
from datetime import datetime from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,6 +26,15 @@ _DEFAULT_PERMISSIONS = {
} }
_PUSH_TIMEOUT = 5 # seconds _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]: def _default_perms() -> Dict[str, Any]:
@@ -91,6 +101,13 @@ class CellLinkManager:
if 'remote_exit_relay_active' not in link: if 'remote_exit_relay_active' not in link:
link['remote_exit_relay_active'] = False link['remote_exit_relay_active'] = False
changed = True 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: if changed:
self._save(links) self._save(links)
return links return links
@@ -214,10 +231,15 @@ class CellLinkManager:
link['last_push_at'] = datetime.utcnow().isoformat() link['last_push_at'] = datetime.utcnow().isoformat()
link['last_push_error'] = None link['last_push_error'] = None
link['pending_push'] = False link['pending_push'] = False
link['push_attempts'] = 0
link['next_retry_at'] = None
else: else:
link['last_push_status'] = 'failed' link['last_push_status'] = 'failed'
link['last_push_error'] = result.get('error') link['last_push_error'] = result.get('error')
link['pending_push'] = True link['pending_push'] = True
attempts = link.get('push_attempts', 0) + 1
link['push_attempts'] = attempts
link['next_retry_at'] = _compute_next_retry(attempts)
break break
self._save(links) self._save(links)
@@ -270,6 +292,24 @@ class CellLinkManager:
link['last_remote_update_at'] = datetime.utcnow().isoformat() link['last_remote_update_at'] = datetime.utcnow().isoformat()
self._save(links) 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] inbound_list = [s for s, v in clean_inbound.items() if v]
try: try:
import firewall_manager as _fm import firewall_manager as _fm
@@ -295,9 +335,18 @@ class CellLinkManager:
logger.warning(f"replay_pending_pushes: cannot resolve identity ({e})") logger.warning(f"replay_pending_pushes: cannot resolve identity ({e})")
return summary return summary
summary['deferred'] = 0
now_iso = datetime.utcnow().isoformat()
for link in self._load(): for link in self._load():
if not link.get('pending_push'): if not link.get('pending_push'):
continue 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 summary['attempted'] += 1
result = self._push_permissions_to_remote( result = self._push_permissions_to_remote(
link, identity['cell_name'], identity['public_key'] link, identity['cell_name'], identity['public_key']
@@ -311,10 +360,11 @@ class CellLinkManager:
logger.warning( logger.warning(
f"replay: push to '{link['cell_name']}' failed: {result.get('error')}" f"replay: push to '{link['cell_name']}' failed: {result.get('error')}"
) )
if summary['attempted']: if summary['attempted'] or summary.get('deferred'):
logger.info( logger.info(
f"replay_pending_pushes: {summary['attempted']} attempted, " 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 return summary
+7
View File
@@ -237,6 +237,13 @@ def set_peer_route_via(peer_name):
) )
if not link: if not link:
return jsonify({'error': f"Cell {via_cell!r} not connected"}), 404 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( wireguard_manager.update_cell_peer_allowed_ips(
link['public_key'], link['vpn_subnet'], add_default_route=True) link['public_key'], link['vpn_subnet'], add_default_route=True)
wireguard_manager.apply_peer_route_via(peer_ip, via_wg_ip=link['dns_ip']) wireguard_manager.apply_peer_route_via(peer_ip, via_wg_ip=link['dns_ip'])
+514 -1
View File
@@ -210,6 +210,513 @@ if __name__ == '__main__':
unittest.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 # TestAddConnectionAtomicity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -695,7 +1202,10 @@ class TestPermissionSync(unittest.TestCase):
# ── replay_pending_pushes ───────────────────────────────────────────────── # ── replay_pending_pushes ─────────────────────────────────────────────────
def test_replay_retries_pending_links(self): 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']) self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
push_mock = MagicMock(return_value={'ok': True, 'error': None}) 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): def test_replay_push_failure_leaves_pending(self):
self._add_office(push_ok=False) 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', with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \ return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
+165
View File
@@ -737,5 +737,170 @@ class TestSetExitOffer(unittest.TestCase):
self.assertEqual(r.status_code, 404) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+26
View File
@@ -54,6 +54,31 @@ function StatusDot({ online }) {
: <span className="inline-block h-2 w-2 rounded-full bg-red-400 mr-1.5" title="Offline" />; : <span className="inline-block h-2 w-2 rounded-full bg-red-400 mr-1.5" title="Offline" />;
} }
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 (
<span className="inline-flex items-center gap-1 text-xs text-gray-500" title={tip}>
<span className={`inline-block h-2 w-2 rounded-full ${color}`} />
{label}
</span>
);
}
function Toast({ toasts }) { function Toast({ toasts }) {
return ( return (
<div className="fixed bottom-4 right-4 z-50 space-y-2"> <div className="fixed bottom-4 right-4 z-50 space-y-2">
@@ -204,6 +229,7 @@ function CellPanel({ conn, onDisconnect, addToast }) {
{conn.last_handshake && ( {conn.last_handshake && (
<span>{relativeTime(conn.last_handshake)}</span> <span>{relativeTime(conn.last_handshake)}</span>
)} )}
<SyncBadge conn={conn} />
</div> </div>
</div> </div>
</div> </div>