feat: connectivity redesign phase 2 — instance-aware routing + reference connections by id
Unit Tests / test (push) Successful in 12m6s
Unit Tests / test (push) Successful in 12m6s
apply_routes now iterates over connection instances rather than types:
each instance gets its own fwmark, routing table, interface, and
redirect_port via _routing_connections / _resolve_peer_connection /
_apply_connection_for_src; kill-switch is enforced per iface-instance.
Old per-type MARKS/TABLES constants are kept only as migration scaffolding.
peer_registry: exit_via is now stored as a connection id (or 'default');
_migrate_exit_via_to_connection_id runs on _load_peers to upgrade legacy
type-string values; set_peer_exit_via validates against known connection
ids; VALID_EXIT_VIA removed; config_manager wired in from managers.py.
egress_manager: egress_overrides keyed by service_id → connection_id;
local MARKS/TABLES/EXIT_TYPES/_REDIRECT_PORTS/_add_tor_redirect removed;
(mark, table, redirect_port) resolved at apply-time via
connectivity_manager.get_connection; manifest egress.allowed still
enforced by connection type.
api/app.py + api.js: PUT peer/service exit endpoints accept {connection_id};
back-compat shim resolves a legacy type string to its single active instance.
Tests extended: two same-type instances produce distinct marks/tables/ports;
peer exit_via and egress override id migrations round-trip correctly;
single-instance behaviour is equivalent to the old type-keyed path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ def _make_manager(tmp_dir=None, peer_registry=_SENTINEL, config_manager=None):
|
||||
'cell_name': 'test',
|
||||
'ip_range': '172.20.0.0/16',
|
||||
}
|
||||
config_manager.list_connections.return_value = []
|
||||
|
||||
if peer_registry is _SENTINEL:
|
||||
peer_registry = MagicMock()
|
||||
@@ -530,49 +531,58 @@ class TestSetPeerExit(unittest.TestCase):
|
||||
peer_registry.list_peers.return_value = []
|
||||
return _make_manager(tmp_dir=self.tmp, peer_registry=peer_registry)
|
||||
|
||||
def test_valid_exit_type_returns_ok_true(self):
|
||||
def test_valid_connection_id_returns_ok_true(self):
|
||||
mgr = self._mgr()
|
||||
with patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
result = mgr.set_peer_exit('alice', 'wireguard_ext')
|
||||
result = mgr.set_peer_exit('alice', 'conn_abcd')
|
||||
self.assertTrue(result['ok'])
|
||||
|
||||
def test_valid_exit_type_default_returns_ok_true(self):
|
||||
def test_default_returns_ok_true(self):
|
||||
mgr = self._mgr()
|
||||
with patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
result = mgr.set_peer_exit('alice', 'default')
|
||||
self.assertTrue(result['ok'])
|
||||
|
||||
def test_invalid_exit_type_returns_ok_false(self):
|
||||
def test_empty_connection_id_returns_ok_false(self):
|
||||
mgr = self._mgr()
|
||||
result = mgr.set_peer_exit('alice', 'shadowsocks')
|
||||
result = mgr.set_peer_exit('alice', '')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_invalid_exit_type_error_mentions_type(self):
|
||||
mgr = self._mgr()
|
||||
result = mgr.set_peer_exit('alice', 'bad_type')
|
||||
self.assertIn('bad_type', result['error'])
|
||||
def test_unknown_connection_for_existing_peer_returns_ok_false(self):
|
||||
"""When the peer exists but the connection id is rejected by the
|
||||
registry, set_peer_exit reports an unknown-connection error."""
|
||||
pr = MagicMock()
|
||||
pr.set_peer_exit_via.return_value = False
|
||||
pr.get_peer.return_value = {'peer': 'alice', 'ip': '10.0.0.5'}
|
||||
pr.list_peers.return_value = []
|
||||
mgr = self._mgr(peer_registry=pr)
|
||||
result = mgr.set_peer_exit('alice', 'conn_ghost')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('unknown connection', result['error'])
|
||||
|
||||
def test_calls_peer_registry_set_peer_exit_via_with_correct_args(self):
|
||||
pr = MagicMock()
|
||||
pr.set_peer_exit_via.return_value = True
|
||||
pr.list_peers.return_value = []
|
||||
pr.get_peer.return_value = {'peer': 'bob', 'exit_via': 'conn_xyz'}
|
||||
mgr = self._mgr(peer_registry=pr)
|
||||
with patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
mgr.set_peer_exit('bob', 'openvpn')
|
||||
pr.set_peer_exit_via.assert_called_once_with('bob', 'openvpn')
|
||||
mgr.set_peer_exit('bob', 'conn_xyz')
|
||||
pr.set_peer_exit_via.assert_called_once_with('bob', 'conn_xyz')
|
||||
|
||||
def test_peer_not_found_in_registry_returns_ok_false(self):
|
||||
pr = MagicMock()
|
||||
pr.set_peer_exit_via.return_value = False # peer not found
|
||||
pr.get_peer.return_value = None # peer truly absent
|
||||
pr.list_peers.return_value = []
|
||||
mgr = self._mgr(peer_registry=pr)
|
||||
result = mgr.set_peer_exit('unknown-peer', 'tor')
|
||||
result = mgr.set_peer_exit('unknown-peer', 'conn_tor')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('not found', result['error'])
|
||||
|
||||
def test_invalid_peer_name_returns_ok_false(self):
|
||||
mgr = self._mgr()
|
||||
@@ -706,14 +716,22 @@ class TestApplyRoutes(unittest.TestCase):
|
||||
result = mgr.apply_routes()
|
||||
self.assertIsInstance(result, dict)
|
||||
|
||||
def test_peer_with_wireguard_ext_exit_generates_mark_rule(self):
|
||||
"""Peers with a non-default exit should trigger _add_mark_rule calls."""
|
||||
def _cm_with_connections(self, connections):
|
||||
cm = MagicMock()
|
||||
cm.get_identity.return_value = {'cell_name': 't', 'ip_range': '172.20.0.0/16'}
|
||||
cm.list_connections.return_value = connections
|
||||
return cm
|
||||
|
||||
def test_peer_with_connection_exit_generates_mark_rule(self):
|
||||
"""A peer whose exit_via is a connection id gets that connection's mark."""
|
||||
conns = [{'id': 'conn_wg', 'type': 'wireguard_ext', 'enabled': True,
|
||||
'mark': 0x1000, 'table': 1000, 'iface': 'wgext_x',
|
||||
'redirect_port': None}]
|
||||
pr = MagicMock()
|
||||
pr.list_peers.return_value = [
|
||||
{'peer': 'alice', 'exit_via': 'wireguard_ext'},
|
||||
]
|
||||
pr.list_peers.return_value = [{'peer': 'alice', 'exit_via': 'conn_wg'}]
|
||||
pr.get_peer.return_value = {'peer': 'alice', 'ip': '172.20.0.50/32'}
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr,
|
||||
config_manager=self._cm_with_connections(conns))
|
||||
with patch.object(mgr, '_add_mark_rule') as mock_mark, \
|
||||
patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
@@ -721,14 +739,14 @@ class TestApplyRoutes(unittest.TestCase):
|
||||
mock_mark.assert_called()
|
||||
call_args = mock_mark.call_args[0]
|
||||
self.assertEqual(call_args[0], '172.20.0.50') # IP without CIDR
|
||||
self.assertEqual(call_args[1], 0x1000) # the connection's mark
|
||||
|
||||
def test_peer_with_default_exit_skips_mark_rule(self):
|
||||
"""Peers on default exit must not generate mark rules."""
|
||||
pr = MagicMock()
|
||||
pr.list_peers.return_value = [
|
||||
{'peer': 'bob', 'exit_via': 'default'},
|
||||
]
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
|
||||
pr.list_peers.return_value = [{'peer': 'bob', 'exit_via': 'default'}]
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr,
|
||||
config_manager=self._cm_with_connections([]))
|
||||
with patch.object(mgr, '_add_mark_rule') as mock_mark, \
|
||||
patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
@@ -744,6 +762,171 @@ class TestApplyRoutes(unittest.TestCase):
|
||||
self.assertIsInstance(result['rules_applied'], int)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_routes — instance-aware routing (connectivity v2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplyRoutesInstances(unittest.TestCase):
|
||||
"""apply_routes must drive routing from connection instances, so two
|
||||
instances of the same type route through distinct tables/marks without
|
||||
collision, and each peer gets its own connection's mark."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _cm(self, connections):
|
||||
cm = MagicMock()
|
||||
cm.get_identity.return_value = {'cell_name': 't', 'ip_range': '172.20.0.0/16'}
|
||||
cm.list_connections.return_value = connections
|
||||
return cm
|
||||
|
||||
@staticmethod
|
||||
def _docker_args(call):
|
||||
"""Strip the `docker exec <container> <ip|iptables>` prefix from a call."""
|
||||
args = call.args[0]
|
||||
# args == ['docker', 'exec', CONTAINER, 'ip'|'iptables', <sub-args...>]
|
||||
return args[4:]
|
||||
|
||||
def test_two_wireguard_ext_instances_distinct_tables_no_collision(self):
|
||||
conns = [
|
||||
{'id': 'conn_a', 'type': 'wireguard_ext', 'enabled': True,
|
||||
'mark': 0x1000, 'table': 1000, 'iface': 'wgext_a', 'redirect_port': None},
|
||||
{'id': 'conn_b', 'type': 'wireguard_ext', 'enabled': True,
|
||||
'mark': 0x1010, 'table': 1001, 'iface': 'wgext_b', 'redirect_port': None},
|
||||
]
|
||||
pr = MagicMock()
|
||||
pr.list_peers.return_value = []
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr,
|
||||
config_manager=self._cm(conns))
|
||||
|
||||
with patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
mgr.apply_routes()
|
||||
|
||||
rule_adds = []
|
||||
for c in mock_sp.run.call_args_list:
|
||||
args = self._docker_args(c)
|
||||
if args[:3] == ['rule', 'add', 'fwmark']:
|
||||
rule_adds.append(args)
|
||||
|
||||
# One ip rule per instance, each pointing its own mark at its own table.
|
||||
pairs = {(a[3], a[5]) for a in rule_adds} # (fwmark_hex, table)
|
||||
self.assertIn(('0x1000', '1000'), pairs)
|
||||
self.assertIn(('0x1010', '1001'), pairs)
|
||||
# Marks and tables must be distinct — no collision.
|
||||
marks = [a[3] for a in rule_adds]
|
||||
tables = [a[5] for a in rule_adds]
|
||||
self.assertEqual(len(set(marks)), len(marks))
|
||||
self.assertEqual(len(set(tables)), len(tables))
|
||||
|
||||
def test_two_peers_on_two_instances_get_different_marks(self):
|
||||
conns = [
|
||||
{'id': 'conn_a', 'type': 'wireguard_ext', 'enabled': True,
|
||||
'mark': 0x1000, 'table': 1000, 'iface': 'wgext_a', 'redirect_port': None},
|
||||
{'id': 'conn_b', 'type': 'wireguard_ext', 'enabled': True,
|
||||
'mark': 0x1010, 'table': 1001, 'iface': 'wgext_b', 'redirect_port': None},
|
||||
]
|
||||
pr = MagicMock()
|
||||
pr.list_peers.return_value = [
|
||||
{'peer': 'alice', 'exit_via': 'conn_a'},
|
||||
{'peer': 'bob', 'exit_via': 'conn_b'},
|
||||
]
|
||||
pr.get_peer.side_effect = lambda n: {
|
||||
'alice': {'peer': 'alice', 'ip': '172.20.0.50/32'},
|
||||
'bob': {'peer': 'bob', 'ip': '172.20.0.51/32'},
|
||||
}[n]
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr,
|
||||
config_manager=self._cm(conns))
|
||||
|
||||
marks_by_ip = {}
|
||||
|
||||
def capture(src_ip, mark):
|
||||
marks_by_ip[src_ip] = mark
|
||||
|
||||
with patch.object(mgr, '_add_mark_rule', side_effect=capture), \
|
||||
patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
mgr.apply_routes()
|
||||
|
||||
self.assertEqual(marks_by_ip['172.20.0.50'], 0x1000)
|
||||
self.assertEqual(marks_by_ip['172.20.0.51'], 0x1010)
|
||||
self.assertNotEqual(marks_by_ip['172.20.0.50'], marks_by_ip['172.20.0.51'])
|
||||
|
||||
def test_two_redirect_instances_distinct_ports(self):
|
||||
"""Two redirect-style instances REDIRECT their peers to distinct ports."""
|
||||
conns = [
|
||||
{'id': 'conn_p1', 'type': 'proxy', 'enabled': True,
|
||||
'mark': 0x1000, 'table': 1000, 'iface': None, 'redirect_port': 9100},
|
||||
{'id': 'conn_p2', 'type': 'proxy', 'enabled': True,
|
||||
'mark': 0x1010, 'table': 1001, 'iface': None, 'redirect_port': 9101},
|
||||
]
|
||||
pr = MagicMock()
|
||||
pr.list_peers.return_value = [
|
||||
{'peer': 'alice', 'exit_via': 'conn_p1'},
|
||||
{'peer': 'bob', 'exit_via': 'conn_p2'},
|
||||
]
|
||||
pr.get_peer.side_effect = lambda n: {
|
||||
'alice': {'peer': 'alice', 'ip': '172.20.0.50/32'},
|
||||
'bob': {'peer': 'bob', 'ip': '172.20.0.51/32'},
|
||||
}[n]
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr,
|
||||
config_manager=self._cm(conns))
|
||||
|
||||
ports_by_ip = {}
|
||||
|
||||
def capture(src_ip, port):
|
||||
ports_by_ip[src_ip] = port
|
||||
|
||||
with patch.object(mgr, '_add_redirect', side_effect=capture), \
|
||||
patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
mgr.apply_routes()
|
||||
|
||||
self.assertEqual(ports_by_ip['172.20.0.50'], 9100)
|
||||
self.assertEqual(ports_by_ip['172.20.0.51'], 9101)
|
||||
|
||||
def test_single_instance_equivalence(self):
|
||||
"""An already-migrated cell with one instance per type yields the same
|
||||
effective rules as before: one ip rule (mark→table), one mark per
|
||||
peer, killswitch on the iface-based instance."""
|
||||
conns = [
|
||||
{'id': 'conn_wg', 'type': 'wireguard_ext', 'enabled': True,
|
||||
'mark': 0x1000, 'table': 1000, 'iface': 'wgext_x', 'redirect_port': None},
|
||||
]
|
||||
pr = MagicMock()
|
||||
pr.list_peers.return_value = [{'peer': 'alice', 'exit_via': 'conn_wg'}]
|
||||
pr.get_peer.return_value = {'peer': 'alice', 'ip': '172.20.0.50/32'}
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr,
|
||||
config_manager=self._cm(conns))
|
||||
|
||||
with patch.object(mgr, '_add_killswitch') as mock_ks, \
|
||||
patch.object(mgr, '_add_mark_rule') as mock_mark, \
|
||||
patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
mgr.apply_routes()
|
||||
|
||||
mock_mark.assert_called_once_with('172.20.0.50', 0x1000)
|
||||
mock_ks.assert_called_once_with(0x1000, 'wgext_x')
|
||||
|
||||
def test_disabled_instance_is_skipped(self):
|
||||
conns = [
|
||||
{'id': 'conn_off', 'type': 'wireguard_ext', 'enabled': False,
|
||||
'mark': 0x1000, 'table': 1000, 'iface': 'wgext_x', 'redirect_port': None},
|
||||
]
|
||||
pr = MagicMock()
|
||||
pr.list_peers.return_value = []
|
||||
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr,
|
||||
config_manager=self._cm(conns))
|
||||
with patch.object(mgr, '_add_killswitch') as mock_ks, \
|
||||
patch.object(cm_module, 'subprocess') as mock_sp:
|
||||
mock_sp.run.return_value = _mock_subprocess_ok()
|
||||
mgr.apply_routes()
|
||||
mock_ks.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _exit_status — status string + store-service bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user