feat(cells): Phase 3 tests + Phase 4 UI for cell service-sharing
Phase 3 — tests (50 new, total now 1071): - test_cell_link_manager: atomicity (WG fail → DNS not called, link not persisted), DNS warning non-fatal, inbound_services arg, unknown service filtered, update/get permissions, lazy migration of legacy entries - test_wireguard_manager: subnet overlap rejection (exact, supernet, adjacent non-overlapping, different class-A, honours wg0.conf configured network) - test_firewall_manager: _cell_tag sanitisation, apply_cell_rules emits correct ACCEPT/DROP per service + catch-all DROP, clear_cell_rules no-op and exact line removal, apply_all_cell_rules iterates with correct args - test_cells_endpoints: RuntimeError→400, GET /services, GET/PUT permissions (200/400/404 paths, service name validation, arg forwarding) Phase 4 — UI: - CellNetwork.jsx: replace flat cell list with CellPanel expandable cards; add ServiceShareToggle (ARIA switch, saves immediately), InboundServiceBadge (read-only), DisconnectConfirmModal (replaces window.confirm); relative timestamps; paste validation on blur; WireGuard status merged by public_key - api.js: add cellLinkAPI.getPermissions, updatePermissions, getServices Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -160,3 +160,241 @@ class TestCellLinkManagerConnections(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAddConnectionAtomicity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAddConnectionAtomicity(unittest.TestCase):
|
||||||
|
"""Verify that add_connection rolls back correctly when WG or DNS steps fail."""
|
||||||
|
|
||||||
|
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_wg_fail_does_not_call_dns(self):
|
||||||
|
"""When add_cell_peer returns False, add_cell_dns_forward must NOT be called."""
|
||||||
|
self.wg.add_cell_peer.return_value = False
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
self.nm.add_cell_dns_forward.assert_not_called()
|
||||||
|
|
||||||
|
def test_wg_fail_does_not_persist_link(self):
|
||||||
|
"""When WG fails, list_connections() must still return [] (nothing persisted)."""
|
||||||
|
self.wg.add_cell_peer.return_value = False
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
self.assertEqual(self.mgr.list_connections(), [])
|
||||||
|
|
||||||
|
def test_wg_fail_raises_runtime_error(self):
|
||||||
|
"""add_connection raises RuntimeError (not some other exception) when WG fails."""
|
||||||
|
self.wg.add_cell_peer.return_value = False
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
|
||||||
|
def test_dns_warning_still_persists_link(self):
|
||||||
|
"""When DNS returns warnings (not a hard failure), the link IS still saved."""
|
||||||
|
self.nm.add_cell_dns_forward.return_value = {
|
||||||
|
'restarted': [],
|
||||||
|
'warnings': ['CoreDNS reload timed out'],
|
||||||
|
}
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
links = self.mgr.list_connections()
|
||||||
|
self.assertEqual(len(links), 1)
|
||||||
|
self.assertEqual(links[0]['cell_name'], 'office')
|
||||||
|
|
||||||
|
def test_dns_warning_does_not_raise(self):
|
||||||
|
"""When DNS returns warnings, add_connection completes without raising."""
|
||||||
|
self.nm.add_cell_dns_forward.return_value = {
|
||||||
|
'restarted': [],
|
||||||
|
'warnings': ['CoreDNS reload timed out'],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
except Exception as e:
|
||||||
|
self.fail(f"add_connection raised unexpectedly with DNS warnings: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestAddConnectionPermissions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAddConnectionPermissions(unittest.TestCase):
|
||||||
|
"""Verify that inbound_services controls the permissions field on the saved link."""
|
||||||
|
|
||||||
|
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 _get_link(self):
|
||||||
|
links = self.mgr.list_connections()
|
||||||
|
self.assertEqual(len(links), 1)
|
||||||
|
return links[0]
|
||||||
|
|
||||||
|
def test_add_with_no_inbound_defaults_all_deny(self):
|
||||||
|
"""No inbound_services arg → all inbound permissions False."""
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
link = self._get_link()
|
||||||
|
inbound = link['permissions']['inbound']
|
||||||
|
for service, allowed in inbound.items():
|
||||||
|
self.assertFalse(allowed, f"Expected {service} to be False, got {allowed}")
|
||||||
|
|
||||||
|
def test_add_with_inbound_services_sets_them(self):
|
||||||
|
"""inbound_services=['calendar','files'] → those two True, others False."""
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE, inbound_services=['calendar', 'files'])
|
||||||
|
link = self._get_link()
|
||||||
|
inbound = link['permissions']['inbound']
|
||||||
|
self.assertTrue(inbound['calendar'])
|
||||||
|
self.assertTrue(inbound['files'])
|
||||||
|
self.assertFalse(inbound['mail'])
|
||||||
|
self.assertFalse(inbound['webdav'])
|
||||||
|
|
||||||
|
def test_inbound_invalid_service_ignored(self):
|
||||||
|
"""Passing 'badservice' in inbound_services does not appear in permissions."""
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE, inbound_services=['badservice', 'calendar'])
|
||||||
|
link = self._get_link()
|
||||||
|
inbound = link['permissions']['inbound']
|
||||||
|
self.assertNotIn('badservice', inbound)
|
||||||
|
# valid one was still applied
|
||||||
|
self.assertTrue(inbound['calendar'])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestUpdatePermissions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestUpdatePermissions(unittest.TestCase):
|
||||||
|
"""Tests for the new update_permissions / get_permissions methods."""
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Add a connection so there is something to update
|
||||||
|
self.mgr.add_connection(SAMPLE_INVITE)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
def test_update_sets_inbound_values(self):
|
||||||
|
"""update_permissions with inbound={'calendar': True} persists correctly."""
|
||||||
|
with patch('cell_link_manager.firewall_manager', create=True) as mock_fm:
|
||||||
|
mock_fm.apply_cell_rules = MagicMock()
|
||||||
|
self.mgr.update_permissions('office', {'calendar': True}, {})
|
||||||
|
# Re-read from disk to confirm persistence
|
||||||
|
mgr2 = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
perms = mgr2.get_permissions('office')
|
||||||
|
self.assertTrue(perms['inbound']['calendar'])
|
||||||
|
self.assertFalse(perms['inbound']['files'])
|
||||||
|
|
||||||
|
def test_update_rejects_unknown_service_by_cleaning_it_out(self):
|
||||||
|
"""update_permissions with inbound={'bad': True} — 'bad' must not appear in saved perms."""
|
||||||
|
with patch('cell_link_manager.firewall_manager', create=True) as mock_fm:
|
||||||
|
mock_fm.apply_cell_rules = MagicMock()
|
||||||
|
self.mgr.update_permissions('office', {'bad': True, 'calendar': True}, {})
|
||||||
|
perms = self.mgr.get_permissions('office')
|
||||||
|
self.assertNotIn('bad', perms['inbound'])
|
||||||
|
self.assertTrue(perms['inbound']['calendar'])
|
||||||
|
|
||||||
|
def test_update_nonexistent_cell_raises(self):
|
||||||
|
"""update_permissions on an unknown cell_name raises ValueError."""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.mgr.update_permissions('nosuchcell', {}, {})
|
||||||
|
|
||||||
|
def test_get_permissions_returns_correct(self):
|
||||||
|
"""get_permissions returns the dict that was saved by update_permissions."""
|
||||||
|
with patch('cell_link_manager.firewall_manager', create=True) as mock_fm:
|
||||||
|
mock_fm.apply_cell_rules = MagicMock()
|
||||||
|
self.mgr.update_permissions(
|
||||||
|
'office',
|
||||||
|
inbound={'calendar': True, 'files': False},
|
||||||
|
outbound={'mail': True},
|
||||||
|
)
|
||||||
|
perms = self.mgr.get_permissions('office')
|
||||||
|
self.assertIn('inbound', perms)
|
||||||
|
self.assertIn('outbound', perms)
|
||||||
|
self.assertTrue(perms['inbound']['calendar'])
|
||||||
|
self.assertFalse(perms['inbound']['files'])
|
||||||
|
self.assertTrue(perms['outbound']['mail'])
|
||||||
|
|
||||||
|
def test_get_permissions_nonexistent_cell_raises(self):
|
||||||
|
"""get_permissions on an unknown cell_name raises ValueError."""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.mgr.get_permissions('nosuchcell')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestLoadMigration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLoadMigration(unittest.TestCase):
|
||||||
|
"""Verify _load() lazily injects permissions field when it is missing."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.wg = _make_wg_mock()
|
||||||
|
self.nm = _make_nm_mock()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
def test_load_injects_permissions_if_missing(self):
|
||||||
|
"""Write cell_links.json without permissions; _load should add all-False defaults."""
|
||||||
|
links_file = os.path.join(self.test_dir, 'cell_links.json')
|
||||||
|
legacy_links = [
|
||||||
|
{
|
||||||
|
'cell_name': 'legacy-office',
|
||||||
|
'public_key': 'officepubkey=',
|
||||||
|
'vpn_subnet': '10.1.0.0/24',
|
||||||
|
'dns_ip': '10.1.0.1',
|
||||||
|
'domain': 'legacy-office.cell',
|
||||||
|
# NO 'permissions' key — simulates pre-migration data
|
||||||
|
}
|
||||||
|
]
|
||||||
|
with open(links_file, 'w') as f:
|
||||||
|
json.dump(legacy_links, f)
|
||||||
|
|
||||||
|
mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
links = mgr.list_connections()
|
||||||
|
|
||||||
|
self.assertEqual(len(links), 1)
|
||||||
|
link = links[0]
|
||||||
|
self.assertIn('permissions', link)
|
||||||
|
perms = link['permissions']
|
||||||
|
self.assertIn('inbound', perms)
|
||||||
|
self.assertIn('outbound', perms)
|
||||||
|
for service in ('calendar', 'files', 'mail', 'webdav'):
|
||||||
|
self.assertFalse(perms['inbound'][service])
|
||||||
|
self.assertFalse(perms['outbound'][service])
|
||||||
|
|
||||||
|
def test_load_migration_persists_to_disk(self):
|
||||||
|
"""After migration, re-loading the same file returns the injected permissions."""
|
||||||
|
links_file = os.path.join(self.test_dir, 'cell_links.json')
|
||||||
|
with open(links_file, 'w') as f:
|
||||||
|
json.dump([{
|
||||||
|
'cell_name': 'old-cell',
|
||||||
|
'public_key': 'somepubkey=',
|
||||||
|
'vpn_subnet': '10.2.0.0/24',
|
||||||
|
'dns_ip': '10.2.0.1',
|
||||||
|
'domain': 'old-cell.cell',
|
||||||
|
}], f)
|
||||||
|
|
||||||
|
mgr1 = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
|
||||||
|
mgr1.list_connections() # triggers migration + save
|
||||||
|
|
||||||
|
# Read the file directly and confirm permissions are now on disk
|
||||||
|
with open(links_file) as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
self.assertIn('permissions', raw[0])
|
||||||
|
|||||||
@@ -291,5 +291,230 @@ class TestGetCellConnectionStatus(unittest.TestCase):
|
|||||||
self.assertIn('error', json.loads(r.data))
|
self.assertIn('error', json.loads(r.data))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddCellRuntimeError(unittest.TestCase):
|
||||||
|
"""POST /api/cells — RuntimeError from the manager must now return 400, not 500."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
self.client = app.test_client()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_add_cell_runtime_error_returns_400(self, mock_clm):
|
||||||
|
"""When add_connection raises RuntimeError (WG failure), endpoint returns 400."""
|
||||||
|
mock_clm.add_connection.side_effect = RuntimeError('Failed to add WireGuard peer')
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells',
|
||||||
|
data=json.dumps(_VALID_CELL_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertIn('error', data)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_add_cell_runtime_error_body_contains_message(self, mock_clm):
|
||||||
|
"""The 400 response for a RuntimeError includes the error message."""
|
||||||
|
mock_clm.add_connection.side_effect = RuntimeError('WireGuard peer add failed')
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/cells',
|
||||||
|
data=json.dumps(_VALID_CELL_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertIn('WireGuard', data['error'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestListServices(unittest.TestCase):
|
||||||
|
"""GET /api/cells/services"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
self.client = app.test_client()
|
||||||
|
|
||||||
|
def test_list_services_returns_200(self):
|
||||||
|
"""GET /api/cells/services returns HTTP 200."""
|
||||||
|
r = self.client.get('/api/cells/services')
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
def test_list_services_returns_services_key(self):
|
||||||
|
"""Response body has a 'services' key."""
|
||||||
|
r = self.client.get('/api/cells/services')
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertIn('services', data)
|
||||||
|
|
||||||
|
def test_list_services_returns_list(self):
|
||||||
|
"""'services' value is a non-empty list."""
|
||||||
|
r = self.client.get('/api/cells/services')
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertIsInstance(data['services'], list)
|
||||||
|
self.assertGreater(len(data['services']), 0)
|
||||||
|
|
||||||
|
def test_list_services_includes_known_services(self):
|
||||||
|
"""'services' includes the four known shareable services."""
|
||||||
|
r = self.client.get('/api/cells/services')
|
||||||
|
services = json.loads(r.data)['services']
|
||||||
|
for expected in ('calendar', 'files', 'mail', 'webdav'):
|
||||||
|
self.assertIn(expected, services)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCellPermissions(unittest.TestCase):
|
||||||
|
"""GET /api/cells/<name>/permissions"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
self.client = app.test_client()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_get_permissions_returns_200(self, mock_clm):
|
||||||
|
"""GET /api/cells/office/permissions returns 200 when cell exists."""
|
||||||
|
mock_clm.get_permissions.return_value = {
|
||||||
|
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
}
|
||||||
|
r = self.client.get('/api/cells/office/permissions')
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_get_permissions_response_has_inbound_and_outbound(self, mock_clm):
|
||||||
|
"""Response body contains 'inbound' and 'outbound' keys."""
|
||||||
|
mock_clm.get_permissions.return_value = {
|
||||||
|
'inbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
}
|
||||||
|
r = self.client.get('/api/cells/office/permissions')
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertIn('inbound', data)
|
||||||
|
self.assertIn('outbound', data)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_get_permissions_unknown_cell_returns_404(self, mock_clm):
|
||||||
|
"""ValueError from get_permissions maps to 404."""
|
||||||
|
mock_clm.get_permissions.side_effect = ValueError('cell not found')
|
||||||
|
r = self.client.get('/api/cells/nosuchcell/permissions')
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
self.assertIn('error', json.loads(r.data))
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_get_permissions_passes_cell_name(self, mock_clm):
|
||||||
|
"""The cell_name URL segment is forwarded to get_permissions."""
|
||||||
|
mock_clm.get_permissions.return_value = {'inbound': {}, 'outbound': {}}
|
||||||
|
self.client.get('/api/cells/faraway/permissions')
|
||||||
|
mock_clm.get_permissions.assert_called_once_with('faraway')
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateCellPermissions(unittest.TestCase):
|
||||||
|
"""PUT /api/cells/<name>/permissions"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
self.client = app.test_client()
|
||||||
|
|
||||||
|
_VALID_PERM_BODY = {
|
||||||
|
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
@patch('app.peer_registry')
|
||||||
|
@patch('app.firewall_manager')
|
||||||
|
@patch('app.config_manager')
|
||||||
|
def test_update_permissions_returns_200(self, mock_cfg, mock_fm, mock_pr, mock_clm):
|
||||||
|
"""PUT with valid inbound/outbound returns 200."""
|
||||||
|
mock_cfg.configs = {'_identity': {'domain': 'cell'}}
|
||||||
|
mock_pr.list_peers.return_value = []
|
||||||
|
mock_clm.list_connections.return_value = []
|
||||||
|
mock_clm.update_permissions.return_value = {
|
||||||
|
'cell_name': 'office',
|
||||||
|
'permissions': self._VALID_PERM_BODY,
|
||||||
|
}
|
||||||
|
mock_fm.apply_all_dns_rules.return_value = True
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/office/permissions',
|
||||||
|
data=json.dumps(self._VALID_PERM_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertIn('message', data)
|
||||||
|
self.assertIn('link', data)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_update_permissions_unknown_service_returns_400(self, mock_clm):
|
||||||
|
"""PUT body containing an unknown service name returns 400."""
|
||||||
|
body = {
|
||||||
|
'inbound': {'bad_service': True, 'calendar': True},
|
||||||
|
'outbound': {},
|
||||||
|
}
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/office/permissions',
|
||||||
|
data=json.dumps(body),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
data = json.loads(r.data)
|
||||||
|
self.assertIn('error', data)
|
||||||
|
# update_permissions should NOT have been called when validation fails
|
||||||
|
mock_clm.update_permissions.assert_not_called()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_update_permissions_unknown_cell_returns_404(self, mock_clm):
|
||||||
|
"""ValueError from update_permissions (cell not found) maps to 404."""
|
||||||
|
mock_clm.update_permissions.side_effect = ValueError('cell not found')
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/nosuchcell/permissions',
|
||||||
|
data=json.dumps(self._VALID_PERM_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
self.assertIn('error', json.loads(r.data))
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_update_permissions_no_body_returns_400(self, mock_clm):
|
||||||
|
"""PUT with no JSON body returns 400."""
|
||||||
|
r = self.client.put('/api/cells/office/permissions')
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
self.assertIn('error', json.loads(r.data))
|
||||||
|
mock_clm.update_permissions.assert_not_called()
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
def test_update_permissions_outbound_unknown_service_returns_400(self, mock_clm):
|
||||||
|
"""Unknown service in outbound (not just inbound) also returns 400."""
|
||||||
|
body = {
|
||||||
|
'inbound': {'calendar': True},
|
||||||
|
'outbound': {'hacked': True},
|
||||||
|
}
|
||||||
|
r = self.client.put(
|
||||||
|
'/api/cells/office/permissions',
|
||||||
|
data=json.dumps(body),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
@patch('app.cell_link_manager')
|
||||||
|
@patch('app.peer_registry')
|
||||||
|
@patch('app.firewall_manager')
|
||||||
|
@patch('app.config_manager')
|
||||||
|
def test_update_permissions_passes_inbound_outbound_to_manager(
|
||||||
|
self, mock_cfg, mock_fm, mock_pr, mock_clm):
|
||||||
|
"""update_permissions is called with inbound and outbound dicts from the body."""
|
||||||
|
mock_cfg.configs = {'_identity': {'domain': 'cell'}}
|
||||||
|
mock_pr.list_peers.return_value = []
|
||||||
|
mock_clm.list_connections.return_value = []
|
||||||
|
mock_clm.update_permissions.return_value = {
|
||||||
|
'cell_name': 'office', 'permissions': self._VALID_PERM_BODY
|
||||||
|
}
|
||||||
|
mock_fm.apply_all_dns_rules.return_value = True
|
||||||
|
self.client.put(
|
||||||
|
'/api/cells/office/permissions',
|
||||||
|
data=json.dumps(self._VALID_PERM_BODY),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
mock_clm.update_permissions.assert_called_once_with(
|
||||||
|
'office',
|
||||||
|
self._VALID_PERM_BODY['inbound'],
|
||||||
|
self._VALID_PERM_BODY['outbound'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -406,5 +406,221 @@ class TestUpdateServiceIps(unittest.TestCase):
|
|||||||
self.assertNotIn('172.20.0.21', dest_ips)
|
self.assertNotIn('172.20.0.21', dest_ips)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCellRules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCellRules(unittest.TestCase):
|
||||||
|
"""Tests for apply_cell_rules, clear_cell_rules, _cell_tag, and apply_all_cell_rules."""
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _capture_apply(self, cell_name, vpn_subnet, inbound_services):
|
||||||
|
"""Run apply_cell_rules with _wg_exec mocked; return list of captured iptables arg lists."""
|
||||||
|
calls_made = []
|
||||||
|
|
||||||
|
def fake_wg_exec(args):
|
||||||
|
calls_made.append(args)
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = 0
|
||||||
|
m.stdout = ''
|
||||||
|
return m
|
||||||
|
|
||||||
|
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec):
|
||||||
|
firewall_manager.apply_cell_rules(cell_name, vpn_subnet, inbound_services)
|
||||||
|
|
||||||
|
return [c for c in calls_made if 'iptables' in c]
|
||||||
|
|
||||||
|
def _targets_for_dest(self, iptables_calls, dest_ip):
|
||||||
|
"""Return list of -j targets where -d matches dest_ip."""
|
||||||
|
targets = []
|
||||||
|
for c in iptables_calls:
|
||||||
|
if '-d' in c and dest_ip in c and '-j' in c:
|
||||||
|
targets.append(c[c.index('-j') + 1])
|
||||||
|
return targets
|
||||||
|
|
||||||
|
# ── _cell_tag ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_cell_tag_sanitises_spaces_and_punctuation(self):
|
||||||
|
"""_cell_tag replaces non-alphanumeric chars with dashes."""
|
||||||
|
tag = firewall_manager._cell_tag('my cell!')
|
||||||
|
self.assertTrue(tag.startswith('pic-cell-'))
|
||||||
|
self.assertNotIn(' ', tag)
|
||||||
|
self.assertNotIn('!', tag)
|
||||||
|
|
||||||
|
def test_cell_tag_lowercase(self):
|
||||||
|
"""_cell_tag lowercases the cell name."""
|
||||||
|
tag = firewall_manager._cell_tag('Office')
|
||||||
|
self.assertIn('office', tag)
|
||||||
|
|
||||||
|
def test_cell_tag_has_pic_cell_prefix(self):
|
||||||
|
"""_cell_tag always starts with 'pic-cell-'."""
|
||||||
|
self.assertTrue(firewall_manager._cell_tag('remote').startswith('pic-cell-'))
|
||||||
|
|
||||||
|
def test_cell_tag_distinct_from_peer_tag(self):
|
||||||
|
"""A cell tag must not equal the peer comment for the same string."""
|
||||||
|
cell_tag = firewall_manager._cell_tag('10.0.0.2')
|
||||||
|
peer_tag = firewall_manager._peer_comment('10.0.0.2')
|
||||||
|
self.assertNotEqual(cell_tag, peer_tag)
|
||||||
|
|
||||||
|
# ── apply_cell_rules — catch-all DROP ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_apply_cell_rules_sends_catch_all_drop(self):
|
||||||
|
"""apply_cell_rules always inserts a DROP for the entire vpn_subnet."""
|
||||||
|
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
|
||||||
|
subnet_drops = [
|
||||||
|
c for c in calls
|
||||||
|
if '-s' in c and '10.0.1.0/24' in c
|
||||||
|
and '-j' in c and c[c.index('-j') + 1] == 'DROP'
|
||||||
|
and '-d' not in c # catch-all has no destination
|
||||||
|
]
|
||||||
|
self.assertTrue(subnet_drops, "Expected a catch-all DROP rule for the subnet")
|
||||||
|
|
||||||
|
def test_apply_cell_rules_sends_accept_for_allowed_service(self):
|
||||||
|
"""apply_cell_rules inserts ACCEPT for the calendar VIP when calendar is in inbound."""
|
||||||
|
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
|
||||||
|
calendar_ip = firewall_manager.SERVICE_IPS['calendar']
|
||||||
|
calendar_targets = self._targets_for_dest(calls, calendar_ip)
|
||||||
|
self.assertIn('ACCEPT', calendar_targets)
|
||||||
|
|
||||||
|
def test_apply_cell_rules_sends_drop_for_disallowed_service(self):
|
||||||
|
"""apply_cell_rules inserts DROP for a service not in inbound_services."""
|
||||||
|
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
|
||||||
|
files_ip = firewall_manager.SERVICE_IPS['files']
|
||||||
|
files_targets = self._targets_for_dest(calls, files_ip)
|
||||||
|
self.assertIn('DROP', files_targets)
|
||||||
|
|
||||||
|
# ── apply_cell_rules — empty inbound (all-deny) ───────────────────────────
|
||||||
|
|
||||||
|
def test_apply_cell_rules_empty_inbound_all_drop(self):
|
||||||
|
"""With inbound_services=[], all per-service rules are DROP."""
|
||||||
|
calls = self._capture_apply('office', '10.0.1.0/24', [])
|
||||||
|
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
|
||||||
|
svc_targets = self._targets_for_dest(calls, svc_ip)
|
||||||
|
self.assertTrue(svc_targets,
|
||||||
|
f"Expected at least one rule for {service} ({svc_ip})")
|
||||||
|
self.assertNotIn('ACCEPT', svc_targets,
|
||||||
|
f"{service} should be DROP when not in inbound_services")
|
||||||
|
|
||||||
|
# ── apply_cell_rules — all inbound (all-accept) ───────────────────────────
|
||||||
|
|
||||||
|
def test_apply_cell_rules_all_inbound_all_accept(self):
|
||||||
|
"""With all four services in inbound, all per-service rules are ACCEPT."""
|
||||||
|
all_services = list(firewall_manager.SERVICE_IPS.keys())
|
||||||
|
calls = self._capture_apply('office', '10.0.1.0/24', all_services)
|
||||||
|
for service, svc_ip in firewall_manager.SERVICE_IPS.items():
|
||||||
|
svc_targets = self._targets_for_dest(calls, svc_ip)
|
||||||
|
self.assertIn('ACCEPT', svc_targets,
|
||||||
|
f"{service} should be ACCEPT when in inbound_services")
|
||||||
|
|
||||||
|
# ── apply_cell_rules — all rules tagged ───────────────────────────────────
|
||||||
|
|
||||||
|
def test_apply_cell_rules_all_rules_tagged_with_cell_tag(self):
|
||||||
|
"""Every insertion rule must carry the cell's comment tag."""
|
||||||
|
calls = self._capture_apply('office', '10.0.1.0/24', ['calendar'])
|
||||||
|
tag = firewall_manager._cell_tag('office')
|
||||||
|
for c in calls:
|
||||||
|
if '-I' in c:
|
||||||
|
self.assertIn(tag, c, f"Rule missing cell tag: {c}")
|
||||||
|
|
||||||
|
# ── clear_cell_rules — noop when no matching rules ────────────────────────
|
||||||
|
|
||||||
|
def test_clear_cell_rules_noop_when_no_rules(self):
|
||||||
|
"""When iptables-save returns no pic-cell-office lines, iptables-restore is NOT called."""
|
||||||
|
save_output = '*filter\n:FORWARD ACCEPT [0:0]\nCOMMIT\n'
|
||||||
|
|
||||||
|
def fake_wg_exec(args):
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = 0
|
||||||
|
m.stdout = save_output
|
||||||
|
return m
|
||||||
|
|
||||||
|
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
|
||||||
|
patch('subprocess.run') as mock_restore:
|
||||||
|
firewall_manager.clear_cell_rules('office')
|
||||||
|
|
||||||
|
mock_restore.assert_not_called()
|
||||||
|
|
||||||
|
def test_clear_cell_rules_removes_tagged_lines(self):
|
||||||
|
"""clear_cell_rules removes lines carrying the cell tag and keeps others."""
|
||||||
|
tag = firewall_manager._cell_tag('office')
|
||||||
|
save_output = (
|
||||||
|
'*filter\n'
|
||||||
|
':FORWARD ACCEPT [0:0]\n'
|
||||||
|
f'-A FORWARD -s 10.0.1.0/24 -m comment --comment "{tag}" -j DROP\n'
|
||||||
|
'-A FORWARD -s 10.0.0.2 -m comment --comment "pic-peer-10-0-0-2/32" -j ACCEPT\n'
|
||||||
|
'COMMIT\n'
|
||||||
|
)
|
||||||
|
restored = []
|
||||||
|
|
||||||
|
def fake_wg_exec(args):
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = 0
|
||||||
|
if args == ['iptables-save']:
|
||||||
|
m.stdout = save_output
|
||||||
|
return m
|
||||||
|
|
||||||
|
def fake_restore(cmd, input, **kwargs):
|
||||||
|
restored.append(input)
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = 0
|
||||||
|
return m
|
||||||
|
|
||||||
|
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
|
||||||
|
patch('subprocess.run', side_effect=fake_restore):
|
||||||
|
firewall_manager.clear_cell_rules('office')
|
||||||
|
|
||||||
|
self.assertEqual(len(restored), 1)
|
||||||
|
content = restored[0]
|
||||||
|
self.assertNotIn(tag, content)
|
||||||
|
# peer rule for a different entity must survive
|
||||||
|
self.assertIn('pic-peer-10-0-0-2/32', content)
|
||||||
|
|
||||||
|
# ── apply_all_cell_rules ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_apply_all_cell_rules_calls_apply_for_each(self):
|
||||||
|
"""apply_all_cell_rules calls apply_cell_rules once per link with correct args."""
|
||||||
|
cell_links = [
|
||||||
|
{
|
||||||
|
'cell_name': 'office',
|
||||||
|
'vpn_subnet': '10.1.0.0/24',
|
||||||
|
'permissions': {'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||||
|
'outbound': {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'cell_name': 'cabin',
|
||||||
|
'vpn_subnet': '10.2.0.0/24',
|
||||||
|
'permissions': {'inbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False},
|
||||||
|
'outbound': {}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
with patch.object(firewall_manager, 'apply_cell_rules', return_value=True) as mock_apply:
|
||||||
|
firewall_manager.apply_all_cell_rules(cell_links)
|
||||||
|
|
||||||
|
self.assertEqual(mock_apply.call_count, 2)
|
||||||
|
call_kwargs = {c.args[0]: c.args for c in mock_apply.call_args_list}
|
||||||
|
self.assertIn('office', call_kwargs)
|
||||||
|
self.assertIn('cabin', call_kwargs)
|
||||||
|
office_args = call_kwargs['office']
|
||||||
|
self.assertEqual(office_args[1], '10.1.0.0/24')
|
||||||
|
self.assertIn('calendar', office_args[2])
|
||||||
|
self.assertNotIn('files', office_args[2])
|
||||||
|
|
||||||
|
def test_apply_all_cell_rules_skips_links_with_missing_fields(self):
|
||||||
|
"""Links without cell_name or vpn_subnet are silently skipped."""
|
||||||
|
cell_links = [
|
||||||
|
{'vpn_subnet': '10.1.0.0/24'}, # no cell_name
|
||||||
|
{'cell_name': 'broken'}, # no vpn_subnet
|
||||||
|
{'cell_name': 'office', 'vpn_subnet': '10.3.0.0/24',
|
||||||
|
'permissions': {'inbound': {}, 'outbound': {}}},
|
||||||
|
]
|
||||||
|
with patch.object(firewall_manager, 'apply_cell_rules', return_value=True) as mock_apply:
|
||||||
|
firewall_manager.apply_all_cell_rules(cell_links)
|
||||||
|
|
||||||
|
# Only the complete entry should be processed
|
||||||
|
self.assertEqual(mock_apply.call_count, 1)
|
||||||
|
self.assertEqual(mock_apply.call_args.args[0], 'office')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -629,5 +629,80 @@ class TestWireGuardSysctlAndPortCheck(unittest.TestCase):
|
|||||||
self.assertEqual(statuses, {})
|
self.assertEqual(statuses, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddCellPeerSubnetOverlap(unittest.TestCase):
|
||||||
|
"""Verify that add_cell_peer rejects a vpn_subnet that overlaps the local WG network."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.data_dir = os.path.join(self.test_dir, 'data')
|
||||||
|
self.config_dir = os.path.join(self.test_dir, 'config')
|
||||||
|
os.makedirs(self.data_dir, exist_ok=True)
|
||||||
|
os.makedirs(self.config_dir, exist_ok=True)
|
||||||
|
patcher = patch.object(WireGuardManager, '_syncconf', return_value=None)
|
||||||
|
self.mock_sync = patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.wg = WireGuardManager(self.data_dir, self.config_dir)
|
||||||
|
# Write a known wg0.conf so _get_configured_network() returns 10.0.0.0/24
|
||||||
|
self._write_wg_conf(address='10.0.0.1/24')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
def _write_wg_conf(self, address='10.0.0.1/24', port=51820):
|
||||||
|
conf = (
|
||||||
|
f'[Interface]\n'
|
||||||
|
f'PrivateKey = dummykey\n'
|
||||||
|
f'Address = {address}\n'
|
||||||
|
f'ListenPort = {port}\n'
|
||||||
|
)
|
||||||
|
cf = self.wg._config_file()
|
||||||
|
os.makedirs(os.path.dirname(cf), exist_ok=True)
|
||||||
|
with open(cf, 'w') as f:
|
||||||
|
f.write(conf)
|
||||||
|
|
||||||
|
# Public key is 44 chars ending in '=' — required by validation in add_cell_peer
|
||||||
|
_CELL_PUBKEY = 'cmVtb3RlcHVia2V5X2Zvcl90ZXN0c193Z3Rlc3QxMiE='
|
||||||
|
|
||||||
|
def test_add_cell_peer_overlapping_subnet_returns_false(self):
|
||||||
|
"""vpn_subnet that exactly matches the local WG network must be rejected."""
|
||||||
|
# local is 10.0.0.0/24; remote is also 10.0.0.0/24 — clear overlap
|
||||||
|
ok = self.wg.add_cell_peer(
|
||||||
|
'remote', self._CELL_PUBKEY, '5.6.7.8:51821', '10.0.0.0/24'
|
||||||
|
)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
|
||||||
|
def test_add_cell_peer_partially_overlapping_subnet_returns_false(self):
|
||||||
|
"""A remote subnet that contains the local network (e.g. /16 ⊃ /24) is rejected."""
|
||||||
|
# 10.0.0.0/16 contains 10.0.0.0/24 → overlaps
|
||||||
|
ok = self.wg.add_cell_peer(
|
||||||
|
'remote', self._CELL_PUBKEY, '5.6.7.8:51821', '10.0.0.0/16'
|
||||||
|
)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
|
||||||
|
def test_add_cell_peer_non_overlapping_subnet_accepted(self):
|
||||||
|
"""A remote subnet distinct from the local WG network must be accepted."""
|
||||||
|
# local is 10.0.0.0/24; remote is 10.0.1.0/24 — no overlap
|
||||||
|
ok = self.wg.add_cell_peer(
|
||||||
|
'remote', self._CELL_PUBKEY, '5.6.7.8:51821', '10.0.1.0/24'
|
||||||
|
)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
|
||||||
|
def test_add_cell_peer_no_overlap_different_class_a(self):
|
||||||
|
"""A completely different address space is accepted."""
|
||||||
|
# local is 10.0.0.0/24; remote is 192.168.5.0/24 — no overlap
|
||||||
|
ok = self.wg.add_cell_peer(
|
||||||
|
'remote', self._CELL_PUBKEY, '5.6.7.8:51821', '192.168.5.0/24'
|
||||||
|
)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
|
||||||
|
def test_add_cell_peer_overlap_check_uses_configured_network(self):
|
||||||
|
"""When wg0.conf says 172.16.0.1/12, overlapping that range is rejected."""
|
||||||
|
self._write_wg_conf(address='172.16.0.1/12')
|
||||||
|
ok = self.wg.add_cell_peer(
|
||||||
|
'remote', self._CELL_PUBKEY, '5.6.7.8:51821', '172.16.0.0/12'
|
||||||
|
)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+265
-47
@@ -1,9 +1,25 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi } from 'lucide-react';
|
import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi, Calendar, FolderOpen, Mail, HardDrive, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { cellLinkAPI } from '../services/api';
|
import { cellLinkAPI } from '../services/api';
|
||||||
import { useConfig } from '../contexts/ConfigContext';
|
import { useConfig } from '../contexts/ConfigContext';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
const relativeTime = (ts) => {
|
||||||
|
if (!ts) return null;
|
||||||
|
const diff = Math.floor((Date.now() / 1000) - (typeof ts === 'string' ? new Date(ts).getTime() / 1000 : ts));
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVICE_DEFS = [
|
||||||
|
{ key: 'calendar', label: 'Calendar', Icon: Calendar },
|
||||||
|
{ key: 'files', label: 'Files', Icon: FolderOpen },
|
||||||
|
{ key: 'mail', label: 'Mail', Icon: Mail },
|
||||||
|
{ key: 'webdav', label: 'WebDAV', Icon: HardDrive },
|
||||||
|
];
|
||||||
|
|
||||||
function CopyButton({ text, small }) {
|
function CopyButton({ text, small }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
@@ -52,6 +68,194 @@ function useToasts() {
|
|||||||
return [toasts, add];
|
return [toasts, add];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DisconnectConfirmModal({ cellName, onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Unplug className="h-6 w-6 text-red-500 flex-shrink-0" />
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">Disconnect "{cellName}"?</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
|
This will remove the WireGuard tunnel and all sharing permissions between your cells.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mb-5">
|
||||||
|
The other cell's admin will need to remove the connection on their end too. Shared services will become inaccessible immediately.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button onClick={onCancel} autoFocus className="btn btn-secondary text-sm">Cancel</button>
|
||||||
|
<button onClick={onConfirm} className="btn btn-danger text-sm flex items-center gap-2">
|
||||||
|
<Unplug className="h-4 w-4" /> Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceShareToggle({ serviceKey, label, Icon, enabled, saving, onChange }) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
|
||||||
|
<div
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enabled}
|
||||||
|
aria-label={`Share ${label}`}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => !saving && onChange(!enabled)}
|
||||||
|
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && !saving && onChange(!enabled)}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-1 ${
|
||||||
|
enabled ? 'bg-primary-500' : 'bg-gray-300'
|
||||||
|
} ${saving ? 'opacity-60 cursor-wait' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${
|
||||||
|
enabled ? 'translate-x-5' : ''
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
<Icon className="h-4 w-4 text-gray-500 shrink-0" />
|
||||||
|
<span className="text-sm text-gray-700">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InboundServiceBadge({ label, Icon, active }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CellPanel({ conn, onDisconnect, addToast }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [inboundPerms, setInboundPerms] = useState(conn.permissions?.inbound || {});
|
||||||
|
const [saving, setSaving] = useState({});
|
||||||
|
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = async (serviceKey, newValue) => {
|
||||||
|
setSaving(s => ({ ...s, [serviceKey]: true }));
|
||||||
|
const newInbound = { ...inboundPerms, [serviceKey]: newValue };
|
||||||
|
try {
|
||||||
|
await cellLinkAPI.updatePermissions(conn.cell_name, newInbound, conn.permissions?.outbound || {});
|
||||||
|
setInboundPerms(newInbound);
|
||||||
|
addToast(`${serviceKey} sharing ${newValue ? 'enabled' : 'disabled'}`, 'success');
|
||||||
|
} catch {
|
||||||
|
addToast('Failed to save sharing permission', 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(s => ({ ...s, [serviceKey]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRevokedService = Object.values(inboundPerms).some(v => !v);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{confirmDisconnect && (
|
||||||
|
<DisconnectConfirmModal
|
||||||
|
cellName={conn.cell_name}
|
||||||
|
onConfirm={() => { setConfirmDisconnect(false); onDisconnect(conn.cell_name); }}
|
||||||
|
onCancel={() => setConfirmDisconnect(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="border border-gray-100 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<StatusDot online={conn.online} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="font-medium text-gray-900 truncate">{conn.cell_name}</span>
|
||||||
|
<span className="text-xs text-gray-400 font-mono shrink-0">.{conn.domain}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-3 text-xs text-gray-500 mt-0.5">
|
||||||
|
<span className={conn.online ? 'text-green-600 font-medium' : 'text-gray-400'}>
|
||||||
|
{conn.online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
{conn.last_handshake && (
|
||||||
|
<span>{relativeTime(conn.last_handshake)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open
|
||||||
|
? <ChevronDown className="h-4 w-4 text-gray-400 shrink-0" />
|
||||||
|
: <ChevronRight className="h-4 w-4 text-gray-400 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 py-4 bg-white border-t border-gray-100">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
I share with {conn.cell_name}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{SERVICE_DEFS.map(({ key, label, Icon }) => (
|
||||||
|
<ServiceShareToggle
|
||||||
|
key={key}
|
||||||
|
serviceKey={key}
|
||||||
|
label={label}
|
||||||
|
Icon={Icon}
|
||||||
|
enabled={!!inboundPerms[key]}
|
||||||
|
saving={!!saving[key]}
|
||||||
|
onChange={v => handleToggle(key, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasRevokedService && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Services you stop sharing become unreachable from {conn.cell_name} immediately.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
{conn.cell_name} shares with me
|
||||||
|
</p>
|
||||||
|
{(conn.permissions?.outbound && Object.values(conn.permissions.outbound).some(Boolean)) ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{SERVICE_DEFS.map(({ key, label, Icon }) => (
|
||||||
|
<InboundServiceBadge
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
Icon={Icon}
|
||||||
|
active={!!conn.permissions.outbound[key]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-400">Nothing shared yet.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
|
Inbound sharing is set by the other cell's admin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-100 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
|
||||||
|
{conn.vpn_subnet && <div><dt className="inline text-gray-400">Subnet </dt><dd className="inline font-mono">{conn.vpn_subnet}</dd></div>}
|
||||||
|
{conn.endpoint && <div><dt className="inline text-gray-400">Endpoint </dt><dd className="inline font-mono">{conn.endpoint}</dd></div>}
|
||||||
|
</dl>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDisconnect(true)}
|
||||||
|
className="btn btn-danger flex items-center gap-2 text-sm py-1.5"
|
||||||
|
>
|
||||||
|
<Unplug className="h-4 w-4" />
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CellNetwork() {
|
export default function CellNetwork() {
|
||||||
const { cell_name = 'mycell', domain = 'cell' } = useConfig();
|
const { cell_name = 'mycell', domain = 'cell' } = useConfig();
|
||||||
const [toasts, addToast] = useToasts();
|
const [toasts, addToast] = useToasts();
|
||||||
@@ -64,6 +268,7 @@ export default function CellNetwork() {
|
|||||||
const [connsLoading, setConnsLoading] = useState(true);
|
const [connsLoading, setConnsLoading] = useState(true);
|
||||||
|
|
||||||
const [pasteText, setPasteText] = useState('');
|
const [pasteText, setPasteText] = useState('');
|
||||||
|
const [pasteError, setPasteError] = useState('');
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,17 +294,29 @@ export default function CellNetwork() {
|
|||||||
setConnsLoading(true);
|
setConnsLoading(true);
|
||||||
try {
|
try {
|
||||||
const r = await cellLinkAPI.listConnections();
|
const r = await cellLinkAPI.listConnections();
|
||||||
// Enrich with live status
|
const conns = r.data || [];
|
||||||
const enriched = await Promise.all(
|
|
||||||
(r.data || []).map(async (conn) => {
|
// Fetch all WireGuard peer statuses in one call and index by public_key
|
||||||
try {
|
let statusByKey = {};
|
||||||
const s = await cellLinkAPI.getStatus(conn.cell_name);
|
try {
|
||||||
return { ...conn, online: s.data.online, last_handshake: s.data.last_handshake };
|
const { wireguardAPI } = await import('../services/api');
|
||||||
} catch {
|
const sr = await wireguardAPI.getPeerStatuses();
|
||||||
return { ...conn, online: false };
|
(sr.data?.peers || []).forEach(p => {
|
||||||
}
|
if (p.public_key) statusByKey[p.public_key] = p;
|
||||||
})
|
});
|
||||||
);
|
} catch {
|
||||||
|
// Status enrichment is best-effort; continue without it
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = conns.map(conn => {
|
||||||
|
const wg = conn.public_key ? statusByKey[conn.public_key] : null;
|
||||||
|
return {
|
||||||
|
...conn,
|
||||||
|
online: wg ? wg.online : false,
|
||||||
|
last_handshake: wg ? wg.last_handshake : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
setConnections(enriched);
|
setConnections(enriched);
|
||||||
} catch {
|
} catch {
|
||||||
addToast('Failed to load connections', 'error');
|
addToast('Failed to load connections', 'error');
|
||||||
@@ -108,6 +325,20 @@ export default function CellNetwork() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validatePaste = (text) => {
|
||||||
|
if (!text.trim()) { setPasteError(''); return; }
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(text.trim());
|
||||||
|
if (!p.cell_name || !p.public_key || !p.vpn_subnet) {
|
||||||
|
setPasteError('JSON is missing required fields (cell_name, public_key, vpn_subnet)');
|
||||||
|
} else {
|
||||||
|
setPasteError('');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPasteError('Not valid JSON — paste the complete invite from the other cell');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
if (!pasteText.trim()) return;
|
if (!pasteText.trim()) return;
|
||||||
let parsed;
|
let parsed;
|
||||||
@@ -122,6 +353,7 @@ export default function CellNetwork() {
|
|||||||
await cellLinkAPI.addConnection(parsed);
|
await cellLinkAPI.addConnection(parsed);
|
||||||
addToast(`Connected to cell "${parsed.cell_name}"`);
|
addToast(`Connected to cell "${parsed.cell_name}"`);
|
||||||
setPasteText('');
|
setPasteText('');
|
||||||
|
setPasteError('');
|
||||||
loadConnections();
|
loadConnections();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(e?.response?.data?.error || 'Connection failed', 'error');
|
addToast(e?.response?.data?.error || 'Connection failed', 'error');
|
||||||
@@ -130,8 +362,8 @@ export default function CellNetwork() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Confirmation is handled inside CellPanel via DisconnectConfirmModal
|
||||||
const handleDisconnect = async (name) => {
|
const handleDisconnect = async (name) => {
|
||||||
if (!window.confirm(`Disconnect from cell "${name}"?`)) return;
|
|
||||||
try {
|
try {
|
||||||
await cellLinkAPI.removeConnection(name);
|
await cellLinkAPI.removeConnection(name);
|
||||||
addToast(`Disconnected from "${name}"`);
|
addToast(`Disconnected from "${name}"`);
|
||||||
@@ -230,16 +462,22 @@ export default function CellNetwork() {
|
|||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Paste the invite JSON from the other cell's "Your Cell's Invite" panel:
|
Paste the invite JSON from the other cell's "Your Cell's Invite" panel:
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<div>
|
||||||
value={pasteText}
|
<textarea
|
||||||
onChange={e => setPasteText(e.target.value)}
|
value={pasteText}
|
||||||
placeholder={'{\n "cell_name": "...",\n "public_key": "...",\n ...\n}'}
|
onChange={e => { setPasteText(e.target.value); if (pasteError) validatePaste(e.target.value); }}
|
||||||
rows={8}
|
onBlur={e => validatePaste(e.target.value)}
|
||||||
className="w-full text-xs font-mono border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none bg-white"
|
placeholder={'{\n "cell_name": "...",\n "public_key": "...",\n ...\n}'}
|
||||||
/>
|
rows={8}
|
||||||
|
className={`w-full text-xs font-mono border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none bg-white ${
|
||||||
|
pasteError ? 'border-red-400 focus:ring-red-400' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{pasteError && <p className="text-xs text-red-600 mt-1">{pasteError}</p>}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
disabled={connecting || !pasteText.trim()}
|
disabled={connecting || !pasteText.trim() || !!pasteError}
|
||||||
className="w-full btn btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
|
className="w-full btn btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{connecting
|
{connecting
|
||||||
@@ -286,32 +524,12 @@ export default function CellNetwork() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{connections.map(conn => (
|
{connections.map(conn => (
|
||||||
<div key={conn.cell_name}
|
<CellPanel
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
|
key={conn.cell_name}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
conn={conn}
|
||||||
<StatusDot online={conn.online} />
|
onDisconnect={handleDisconnect}
|
||||||
<div className="min-w-0">
|
addToast={addToast}
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
/>
|
||||||
<span className="font-medium text-gray-900 truncate" title={conn.cell_name}>{conn.cell_name}</span>
|
|
||||||
<span className="text-xs text-gray-400 font-mono shrink-0">.{conn.domain}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 space-x-3 mt-0.5 truncate">
|
|
||||||
<span>Subnet: <span className="font-mono">{conn.vpn_subnet}</span></span>
|
|
||||||
<span>Endpoint: <span className="font-mono">{conn.endpoint || '—'}</span></span>
|
|
||||||
{conn.last_handshake && (
|
|
||||||
<span>Last seen: {new Date(conn.last_handshake * 1000).toLocaleString()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDisconnect(conn.cell_name)}
|
|
||||||
className="text-red-400 hover:text-red-600 p-1.5 rounded hover:bg-red-50"
|
|
||||||
title="Disconnect"
|
|
||||||
>
|
|
||||||
<Unplug className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -278,6 +278,10 @@ export const cellLinkAPI = {
|
|||||||
addConnection: (invite) => api.post('/api/cells', invite),
|
addConnection: (invite) => api.post('/api/cells', invite),
|
||||||
removeConnection: (name) => api.delete(`/api/cells/${name}`),
|
removeConnection: (name) => api.delete(`/api/cells/${name}`),
|
||||||
getStatus: (name) => api.get(`/api/cells/${name}/status`),
|
getStatus: (name) => api.get(`/api/cells/${name}/status`),
|
||||||
|
getPermissions: (cellName) => api.get(`/api/cells/${cellName}/permissions`),
|
||||||
|
updatePermissions: (cellName, inbound, outbound) =>
|
||||||
|
api.put(`/api/cells/${cellName}/permissions`, { inbound, outbound }),
|
||||||
|
getServices: () => api.get('/api/cells/services'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
|
|||||||
Reference in New Issue
Block a user