feat: connectivity redesign phase 2 — instance-aware routing + reference connections by id
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:
2026-06-10 17:35:28 -04:00
parent 5b9d20eeac
commit 89aed4efe0
12 changed files with 993 additions and 375 deletions
+1 -1
View File
@@ -276,7 +276,7 @@ class TestDeleteConnection(_Base):
res = self.mgr.create_connection('proxy', 'ref', _proxy_cfg())
cid = res['connection']['id']
self.peer_registry.list_peers.return_value = [
{'peer': 'alice', 'connection_id': cid}]
{'peer': 'alice', 'exit_via': cid}]
out = self.mgr.delete_connection(cid)
self.assertFalse(out['ok'])
self.assertIn('in use', out['error'])
+206 -23
View File
@@ -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
# ---------------------------------------------------------------------------
+77 -47
View File
@@ -37,6 +37,7 @@ def _make_manager(tmp_dir=None, peer_registry=_SENTINEL, config_manager=None):
'exits': {}, 'peer_exit_map': {},
}
config_manager.get_installed_services.return_value = {}
config_manager.list_connections.return_value = []
if peer_registry is _SENTINEL:
peer_registry = MagicMock()
@@ -265,13 +266,26 @@ class TestApplyRoutesProxy(unittest.TestCase):
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_proxy_peer_gets_redirect_to_12345(self):
@staticmethod
def _proxy_conn(redirect_port=9100, mark=0x1000, table=1000):
return {'id': 'conn_proxy', 'type': 'proxy', 'enabled': True,
'mark': mark, 'table': table, 'iface': None,
'redirect_port': redirect_port}
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
cm.get_installed_services.return_value = {}
return cm
def test_proxy_peer_gets_redirect_to_instance_port(self):
conn = self._proxy_conn(redirect_port=9100)
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'bob', 'exit_via': 'proxy'},
]
pr.list_peers.return_value = [{'peer': 'bob', 'exit_via': 'conn_proxy'}]
pr.get_peer.return_value = {'peer': 'bob', 'ip': '172.20.0.60/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([conn]))
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
@@ -281,16 +295,16 @@ class TestApplyRoutesProxy(unittest.TestCase):
]
self.assertEqual(len(redirect_calls), 1)
args = redirect_calls[0].args[0]
self.assertEqual(args[args.index('--to-ports') + 1], '12345')
self.assertEqual(args[args.index('--to-ports') + 1], '9100')
self.assertIn('172.20.0.60', args)
def test_proxy_peer_gets_mark_0x50(self):
def test_proxy_peer_gets_instance_mark(self):
conn = self._proxy_conn(mark=0x1020)
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'bob', 'exit_via': 'proxy'},
]
pr.list_peers.return_value = [{'peer': 'bob', 'exit_via': 'conn_proxy'}]
pr.get_peer.return_value = {'peer': 'bob', 'ip': '172.20.0.60/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([conn]))
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
@@ -300,29 +314,32 @@ class TestApplyRoutesProxy(unittest.TestCase):
]
self.assertEqual(len(mark_calls), 1)
args = mark_calls[0].args[0]
self.assertEqual(args[args.index('--set-mark') + 1], hex(0x50))
self.assertEqual(args[args.index('--set-mark') + 1], hex(0x1020))
def test_ip_rule_added_for_proxy_table_150(self):
mgr = _make_manager(tmp_dir=self.tmp)
def test_ip_rule_added_for_instance_table(self):
conn = self._proxy_conn(mark=0x1030, table=1234)
mgr = _make_manager(tmp_dir=self.tmp, config_manager=self._cm([conn]))
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(returncode=1, stdout='', stderr='')
mgr.apply_routes()
rule_adds = [
c for c in mock_sp.run.call_args_list
if 'rule' in c.args[0] and 'add' in c.args[0]
and hex(0x50) in c.args[0]
and hex(0x1030) in c.args[0]
]
self.assertEqual(len(rule_adds), 1)
self.assertIn('150', rule_adds[0].args[0])
self.assertIn('1234', rule_adds[0].args[0])
def test_tor_redirect_still_uses_9040(self):
"""Regression: tor redirect must be unaffected by the new exits."""
def test_tor_redirect_uses_instance_port(self):
"""A tor connection instance REDIRECTs to its own allocated port."""
conn = {'id': 'conn_tor', 'type': 'tor', 'enabled': True,
'mark': 0x1000, 'table': 1000, 'iface': None,
'redirect_port': 9100}
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'carol', 'exit_via': 'tor'},
]
pr.list_peers.return_value = [{'peer': 'carol', 'exit_via': 'conn_tor'}]
pr.get_peer.return_value = {'peer': 'carol', 'ip': '172.20.0.70/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([conn]))
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
@@ -332,7 +349,7 @@ class TestApplyRoutesProxy(unittest.TestCase):
]
self.assertEqual(len(redirect_calls), 1)
args = redirect_calls[0].args[0]
self.assertEqual(args[args.index('--to-ports') + 1], '9040')
self.assertEqual(args[args.index('--to-ports') + 1], '9100')
# ---------------------------------------------------------------------------
@@ -340,39 +357,32 @@ class TestApplyRoutesProxy(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestEgressManagerMirror(unittest.TestCase):
"""Egress now resolves a service's (mark, table, redirect_port) from the
connection instance it routes through — no per-type MARKS/TABLES tables."""
def test_exit_types_include_sshuttle_and_proxy(self):
self.assertIn('sshuttle', em_module.EXIT_TYPES)
self.assertIn('proxy', em_module.EXIT_TYPES)
def test_marks_do_not_collide_with_connectivity(self):
self.assertEqual(em_module.MARKS['sshuttle'], 0x140)
self.assertEqual(em_module.MARKS['proxy'], 0x150)
self.assertNotIn(em_module.MARKS['sshuttle'],
ConnectivityManager.MARKS.values())
self.assertNotIn(em_module.MARKS['proxy'],
ConnectivityManager.MARKS.values())
def test_tables(self):
self.assertEqual(em_module.TABLES['sshuttle'], 240)
self.assertEqual(em_module.TABLES['proxy'], 250)
def _make_egress(self, exit_via):
def _make_egress(self, connection):
config_manager = MagicMock()
manifest = {
'id': 'svc',
'container_name': 'cell-svc',
'has_egress': True,
'egress': {'default': exit_via, 'allowed': list(em_module.EXIT_TYPES)},
'egress': {'default': connection['id'],
'allowed': [connection['type']]},
}
config_manager.get_installed_services.return_value = {
'svc': {'manifest': manifest},
}
config_manager.configs = {'egress_overrides': {}}
config_manager.list_connections.return_value = [connection]
config_manager.get_connection.side_effect = \
lambda cid: connection if cid == connection['id'] else None
return em_module.EgressManager(config_manager=config_manager)
def test_apply_service_sshuttle_redirects_to_12300(self):
em = self._make_egress('sshuttle')
def test_apply_service_sshuttle_redirects_to_instance_port(self):
conn = {'id': 'conn_ssh', 'type': 'sshuttle', 'enabled': True,
'mark': 0x1000, 'table': 1000, 'iface': None,
'redirect_port': 9100}
em = self._make_egress(conn)
with patch.object(em_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(
returncode=0, stdout='172.21.0.5', stderr='')
@@ -384,10 +394,13 @@ class TestEgressManagerMirror(unittest.TestCase):
]
self.assertEqual(len(redirect_calls), 1)
args = redirect_calls[0].args[0]
self.assertEqual(args[args.index('--to-ports') + 1], '12300')
self.assertEqual(args[args.index('--to-ports') + 1], '9100')
def test_apply_service_proxy_redirects_to_12345(self):
em = self._make_egress('proxy')
def test_apply_service_proxy_redirects_to_instance_port(self):
conn = {'id': 'conn_px', 'type': 'proxy', 'enabled': True,
'mark': 0x1010, 'table': 1001, 'iface': None,
'redirect_port': 9101}
em = self._make_egress(conn)
with patch.object(em_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(
returncode=0, stdout='172.21.0.5', stderr='')
@@ -399,7 +412,24 @@ class TestEgressManagerMirror(unittest.TestCase):
]
self.assertEqual(len(redirect_calls), 1)
args = redirect_calls[0].args[0]
self.assertEqual(args[args.index('--to-ports') + 1], '12345')
self.assertEqual(args[args.index('--to-ports') + 1], '9101')
def test_apply_service_uses_connection_mark(self):
conn = {'id': 'conn_px', 'type': 'proxy', 'enabled': True,
'mark': 0x1010, 'table': 1001, 'iface': None,
'redirect_port': 9101}
em = self._make_egress(conn)
with patch.object(em_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(
returncode=0, stdout='172.21.0.5', stderr='')
em.apply_service('svc')
mark_calls = [
c for c in mock_sp.run.call_args_list
if 'MARK' in c.args[0] and '--set-mark' in c.args[0]
]
self.assertGreater(len(mark_calls), 0)
args = mark_calls[0].args[0]
self.assertEqual(args[args.index('--set-mark') + 1], hex(0x1010))
# ---------------------------------------------------------------------------
+54 -21
View File
@@ -47,6 +47,7 @@ def _make_manager(tmp_dir=None, peer_registry=_SENTINEL, config_manager=None,
'exits': {}, 'peer_exit_map': {},
}
config_manager.get_installed_services.return_value = {}
config_manager.list_connections.return_value = []
if peer_registry is _SENTINEL:
peer_registry = MagicMock()
@@ -344,13 +345,26 @@ class TestApplyRoutesSshuttle(unittest.TestCase):
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_sshuttle_peer_gets_redirect_to_12300(self):
@staticmethod
def _ssh_conn(mark=0x1000, table=1000, redirect_port=9100):
return {'id': 'conn_ssh', 'type': 'sshuttle', 'enabled': True,
'mark': mark, 'table': table, 'iface': None,
'redirect_port': redirect_port}
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
cm.get_installed_services.return_value = {}
return cm
def test_sshuttle_peer_gets_redirect_to_instance_port(self):
conn = self._ssh_conn(redirect_port=9100)
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'alice', 'exit_via': 'sshuttle'},
]
pr.list_peers.return_value = [{'peer': 'alice', 'exit_via': 'conn_ssh'}]
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([conn]))
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
@@ -361,16 +375,16 @@ class TestApplyRoutesSshuttle(unittest.TestCase):
self.assertEqual(len(redirect_calls), 1)
args = redirect_calls[0].args[0]
self.assertIn('--to-ports', args)
self.assertEqual(args[args.index('--to-ports') + 1], '12300')
self.assertEqual(args[args.index('--to-ports') + 1], '9100')
self.assertIn('172.20.0.50', args)
def test_sshuttle_peer_gets_mark_0x40(self):
def test_sshuttle_peer_gets_instance_mark(self):
conn = self._ssh_conn(mark=0x1040)
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'alice', 'exit_via': 'sshuttle'},
]
pr.list_peers.return_value = [{'peer': 'alice', 'exit_via': 'conn_ssh'}]
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([conn]))
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
@@ -380,20 +394,21 @@ class TestApplyRoutesSshuttle(unittest.TestCase):
]
self.assertEqual(len(mark_calls), 1)
args = mark_calls[0].args[0]
self.assertEqual(args[args.index('--set-mark') + 1], hex(0x40))
self.assertEqual(args[args.index('--set-mark') + 1], hex(0x1040))
def test_ip_rule_added_for_sshuttle_table_140(self):
mgr = _make_manager(tmp_dir=self.tmp)
def test_ip_rule_added_for_instance_table(self):
conn = self._ssh_conn(mark=0x1040, table=1399)
mgr = _make_manager(tmp_dir=self.tmp, config_manager=self._cm([conn]))
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(returncode=1, stdout='', stderr='')
mgr.apply_routes()
rule_adds = [
c for c in mock_sp.run.call_args_list
if 'rule' in c.args[0] and 'add' in c.args[0]
and hex(0x40) in c.args[0]
and hex(0x1040) in c.args[0]
]
self.assertEqual(len(rule_adds), 1)
self.assertIn('140', rule_adds[0].args[0])
self.assertIn('1399', rule_adds[0].args[0])
def test_no_killswitch_for_sshuttle(self):
"""sshuttle has no exit iface — _add_killswitch must skip it."""
@@ -481,19 +496,37 @@ class TestSetPeerExitSshuttle(unittest.TestCase):
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_sshuttle_is_a_valid_exit_type(self):
def test_legacy_sshuttle_type_resolves_to_instance(self):
"""Back-compat shim: setting exit to the legacy 'sshuttle' type resolves
to the single sshuttle connection instance."""
conn = {'id': 'conn_ssh', 'type': 'sshuttle'}
cm = MagicMock()
cm.get_identity.return_value = {'ip_range': '172.20.0.0/16'}
cm.list_connections.return_value = [conn]
cm.get_installed_services.return_value = {}
pr = MagicMock()
pr.set_peer_exit_via.return_value = True
pr.list_peers.return_value = []
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
pr.get_peer.return_value = {'peer': 'alice', 'exit_via': 'conn_ssh'}
def _set(name, value):
# mimic the real registry shim resolution
return value in ('default', 'conn_ssh', 'sshuttle')
pr.set_peer_exit_via.side_effect = _set
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr, config_manager=cm)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
result = mgr.set_peer_exit('alice', 'sshuttle')
self.assertTrue(result['ok'])
def test_peer_registry_accepts_sshuttle(self):
def test_peer_registry_accepts_sshuttle_legacy_type(self):
"""The peer registry resolves a legacy 'sshuttle' type to its instance id."""
from peer_registry import PeerRegistry
self.assertIn('sshuttle', PeerRegistry.VALID_EXIT_VIA)
cm = MagicMock()
cm.list_connections.return_value = [{'id': 'conn_ssh', 'type': 'sshuttle'}]
reg = PeerRegistry(data_dir=self.tmp, config_dir=self.tmp, config_manager=cm)
reg.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
self.assertTrue(reg.set_peer_exit_via('alice', 'sshuttle'))
self.assertEqual(reg.get_peer('alice')['exit_via'], 'conn_ssh')
# ---------------------------------------------------------------------------
+192 -120
View File
@@ -1,49 +1,85 @@
"""
Tests for EgressManager per-service egress enforcement via host iptables.
Connectivity v2: a service routes through a connection *instance* (by id),
sharing the connection's fwmark / routing table / redirect port. The egress
override map is service_id connection_id, and (mark, table, redirect_port)
are resolved from ConnectivityManager.get_connection(id). EgressManager no
longer owns its own per-type MARKS/TABLES.
All subprocess calls (iptables, iptables-save, iptables-restore, ip rule,
docker inspect) and config_manager state are mocked so these tests run
without any live infrastructure or root privileges.
docker inspect) and manager state are mocked so these tests run without any
live infrastructure or root privileges.
"""
import os
import sys
import unittest
from unittest.mock import MagicMock, patch, call
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
import egress_manager as em_module
from egress_manager import EgressManager, MARKS, TABLES, EXIT_TYPES, EGRESS_CHAIN
from egress_manager import EgressManager, EGRESS_CHAIN
# ---------------------------------------------------------------------------
# Helpers
# Connection fixtures — mirror the v2 allocator output.
# ---------------------------------------------------------------------------
def _make_manager(installed=None, overrides=None):
"""Build an EgressManager backed by a mock config_manager."""
CONNECTIONS = {
'conn_wg': {
'id': 'conn_wg', 'type': 'wireguard_ext', 'name': 'Work VPN',
'enabled': True, 'mark': 0x1000, 'table': 1000,
'iface': 'wgext_aaaa', 'redirect_port': None,
},
'conn_ovpn': {
'id': 'conn_ovpn', 'type': 'openvpn', 'name': 'OVPN',
'enabled': True, 'mark': 0x1010, 'table': 1001,
'iface': 'ovpn_bbbb', 'redirect_port': None,
},
'conn_tor': {
'id': 'conn_tor', 'type': 'tor', 'name': 'Tor',
'enabled': True, 'mark': 0x1020, 'table': 1002,
'iface': None, 'redirect_port': 9100,
},
}
def _make_manager(installed=None, overrides=None, connections=None):
"""Build an EgressManager backed by mock config + connectivity managers."""
cm = MagicMock()
cm.get_installed_services.return_value = installed or {}
# Wire up configs dict so _get_egress_overrides / _set_egress_overrides work
cm.configs = {'egress_overrides': overrides or {}}
cm._save_all_configs = MagicMock()
return EgressManager(config_manager=cm), cm
conns = connections if connections is not None else CONNECTIONS
conn_list = list(conns.values())
cm.list_connections.return_value = conn_list
cm.get_connection.side_effect = lambda cid: conns.get(cid)
connectivity = MagicMock()
connectivity.list_connections.return_value = conn_list
connectivity.get_connection.side_effect = lambda cid: conns.get(cid)
mgr = EgressManager(config_manager=cm, connectivity_manager=connectivity)
return mgr, cm
def _subprocess_ok(stdout=''):
"""Return a MagicMock simulating a successful subprocess.run result."""
return MagicMock(returncode=0, stdout=stdout, stderr='')
def _subprocess_fail(stderr='error', stdout=''):
"""Return a MagicMock simulating a failed subprocess.run result."""
return MagicMock(returncode=1, stdout=stdout, stderr=stderr)
def _make_manifest(has_egress=True, egress_default='wireguard_ext',
def _make_manifest(has_egress=True, egress_default='conn_wg',
allowed=None, container_name='cell-myapp'):
"""Return a minimal manifest dict with optional egress configuration."""
"""Return a minimal manifest dict with optional egress configuration.
`allowed` is a list of connection *types* (manifests are type-scoped).
"""
m = {
'id': 'myapp',
'name': 'My App',
@@ -53,7 +89,8 @@ def _make_manifest(has_egress=True, egress_default='wireguard_ext',
m['has_egress'] = True
m['egress'] = {
'default': egress_default,
'allowed': allowed if allowed is not None else list(EXIT_TYPES),
'allowed': allowed if allowed is not None
else ['wireguard_ext', 'openvpn', 'tor', 'sshuttle', 'proxy'],
}
else:
m['has_egress'] = False
@@ -61,32 +98,26 @@ def _make_manifest(has_egress=True, egress_default='wireguard_ext',
def _installed_with_manifest(manifest, service_id='myapp'):
"""Return an installed-services dict containing one service record."""
return {service_id: {'id': service_id, 'manifest': manifest}}
# ---------------------------------------------------------------------------
# 1. test_apply_service_default_exit_no_iptables_calls
# 1. default exitno iptables rule-adding
# ---------------------------------------------------------------------------
class TestApplyServiceDefaultExit(unittest.TestCase):
def test_apply_service_default_exit_no_iptables_calls(self):
"""When egress.default is 'default', apply_service must not touch iptables."""
manifest = _make_manifest(egress_default='default')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
with patch('subprocess.run') as mock_run:
# docker inspect must return an IP so we don't fail earlier
mock_run.return_value = _subprocess_ok(stdout='172.20.0.50\n')
result = mgr.apply_service('myapp')
self.assertTrue(result['ok'])
self.assertEqual(result.get('exit_via'), 'default')
# No iptables rule-insertion or mark call should have been made.
# iptables-save from clear_service is allowed; we only check that
# no iptables -A / -I (rule-adding) calls were made.
rule_add_calls = [
c for c in mock_run.call_args_list
if c.args and c.args[0][:1] == ['iptables']
@@ -96,30 +127,25 @@ class TestApplyServiceDefaultExit(unittest.TestCase):
# ---------------------------------------------------------------------------
# 2. test_apply_service_wireguard_ext_adds_mark_rule
# 2. wireguard_ext connection → mark rule with the connection's own mark
# ---------------------------------------------------------------------------
class TestApplyServiceWireguardExt(unittest.TestCase):
def test_apply_service_wireguard_ext_adds_mark_rule(self):
"""wireguard_ext exit must add a mangle MARK rule with 0x110 and the correct comment."""
manifest = _make_manifest(egress_default='wireguard_ext')
manifest = _make_manifest(egress_default='conn_wg')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
calls_made = []
def fake_run(cmd, **kwargs):
calls_made.append(cmd)
# docker inspect → return IP
if 'docker' in cmd and 'inspect' in cmd:
return _subprocess_ok(stdout='172.20.0.50\n')
# iptables-save → empty ruleset
if 'iptables-save' in cmd:
return _subprocess_ok(stdout='')
# iptables-restore → success
if 'iptables-restore' in cmd:
return _subprocess_ok()
# ip rule del → fail (none to delete)
if cmd[:3] == ['ip', 'rule', 'del']:
return _subprocess_fail()
return _subprocess_ok()
@@ -128,29 +154,35 @@ class TestApplyServiceWireguardExt(unittest.TestCase):
result = mgr.apply_service('myapp')
self.assertTrue(result['ok'], result)
self.assertEqual(result['exit_via'], 'wireguard_ext')
self.assertEqual(result['exit_via'], 'conn_wg')
# Find the mangle MARK -A call
mark_calls = [
c for c in calls_made
if 'iptables' in str(c) and 'MARK' in c and '--set-mark' in c
]
self.assertGreater(len(mark_calls), 0, 'No MARK rule was added')
mark_cmd = ' '.join(mark_calls[0])
self.assertIn('0x110', mark_cmd)
self.assertIn('0x1000', mark_cmd) # the connection's mark
self.assertIn('pic-egr-myapp', mark_cmd)
self.assertIn('mangle', mark_cmd)
# The ip rule must point fwmark→the connection's table.
ip_rule_add = [
c for c in calls_made
if c[:3] == ['ip', 'rule', 'add']
]
self.assertGreater(len(ip_rule_add), 0)
self.assertIn('1000', ' '.join(ip_rule_add[0])) # table
# ---------------------------------------------------------------------------
# 3. test_apply_service_openvpn_adds_mark_rule
# 3. openvpn connection → its own mark
# ---------------------------------------------------------------------------
class TestApplyServiceOpenVPN(unittest.TestCase):
def test_apply_service_openvpn_adds_mark_rule(self):
"""openvpn exit must add a mangle MARK rule with 0x120."""
manifest = _make_manifest(egress_default='openvpn')
manifest = _make_manifest(egress_default='conn_ovpn')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
calls_made = []
@@ -171,25 +203,24 @@ class TestApplyServiceOpenVPN(unittest.TestCase):
result = mgr.apply_service('myapp')
self.assertTrue(result['ok'], result)
self.assertEqual(result['exit_via'], 'openvpn')
self.assertEqual(result['exit_via'], 'conn_ovpn')
mark_calls = [
c for c in calls_made
if 'iptables' in str(c) and 'MARK' in c and '--set-mark' in c
]
self.assertGreater(len(mark_calls), 0)
self.assertIn('0x120', ' '.join(mark_calls[0]))
self.assertIn('0x1010', ' '.join(mark_calls[0]))
# ---------------------------------------------------------------------------
# 4. test_apply_service_tor_adds_mark_and_redirect
# 4. tor (redirect-style) connection → mark + REDIRECT to its redirect_port
# ---------------------------------------------------------------------------
class TestApplyServiceTor(unittest.TestCase):
def test_apply_service_tor_adds_mark_and_redirect(self):
"""tor exit must add a mangle MARK 0x130 AND a nat REDIRECT to port 9040."""
manifest = _make_manifest(egress_default='tor')
manifest = _make_manifest(egress_default='conn_tor')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
calls_made = []
@@ -210,14 +241,14 @@ class TestApplyServiceTor(unittest.TestCase):
result = mgr.apply_service('myapp')
self.assertTrue(result['ok'], result)
self.assertEqual(result['exit_via'], 'tor')
self.assertEqual(result['exit_via'], 'conn_tor')
mark_calls = [
c for c in calls_made
if 'iptables' in str(c) and 'MARK' in c and '--set-mark' in c
]
self.assertGreater(len(mark_calls), 0, 'No MARK rule found')
self.assertIn('0x130', ' '.join(mark_calls[0]))
self.assertIn('0x1020', ' '.join(mark_calls[0]))
redirect_calls = [
c for c in calls_made
@@ -225,24 +256,23 @@ class TestApplyServiceTor(unittest.TestCase):
]
self.assertGreater(len(redirect_calls), 0, 'No REDIRECT rule found')
redirect_cmd = ' '.join(redirect_calls[0])
self.assertIn('9040', redirect_cmd)
self.assertIn('9100', redirect_cmd) # the connection's redirect_port
self.assertIn('nat', redirect_cmd)
# ---------------------------------------------------------------------------
# 5. test_apply_service_no_container_ip_returns_error
# 5. no container IP → error
# ---------------------------------------------------------------------------
class TestApplyServiceNoContainerIP(unittest.TestCase):
def test_apply_service_no_container_ip_returns_error(self):
"""When docker inspect returns an empty IP, apply_service must return ok=False."""
manifest = _make_manifest(egress_default='wireguard_ext')
manifest = _make_manifest(egress_default='conn_wg')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
def fake_run(cmd, **kwargs):
if 'docker' in cmd and 'inspect' in cmd:
return _subprocess_ok(stdout='\n') # empty IP
return _subprocess_ok(stdout='\n')
if 'iptables-save' in cmd:
return _subprocess_ok(stdout='')
if 'iptables-restore' in cmd:
@@ -257,14 +287,13 @@ class TestApplyServiceNoContainerIP(unittest.TestCase):
# ---------------------------------------------------------------------------
# 6. test_apply_service_container_ip_retries
# 6. container IP retries
# ---------------------------------------------------------------------------
class TestApplyServiceRetries(unittest.TestCase):
def test_apply_service_container_ip_retries(self):
"""First docker inspect attempt fails; second succeeds — result must be ok=True."""
manifest = _make_manifest(egress_default='wireguard_ext')
manifest = _make_manifest(egress_default='conn_wg')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
inspect_count = [0]
@@ -273,8 +302,8 @@ class TestApplyServiceRetries(unittest.TestCase):
if 'docker' in cmd and 'inspect' in cmd:
inspect_count[0] += 1
if inspect_count[0] == 1:
return _subprocess_ok(stdout='\n') # first attempt: empty
return _subprocess_ok(stdout='172.20.0.50\n') # second: success
return _subprocess_ok(stdout='\n')
return _subprocess_ok(stdout='172.20.0.50\n')
if 'iptables-save' in cmd:
return _subprocess_ok(stdout='')
if 'iptables-restore' in cmd:
@@ -284,7 +313,7 @@ class TestApplyServiceRetries(unittest.TestCase):
return _subprocess_ok()
with patch('subprocess.run', side_effect=fake_run):
with patch('time.sleep'): # skip actual delays
with patch('time.sleep'):
result = mgr.apply_service('myapp')
self.assertTrue(result['ok'], result)
@@ -292,13 +321,12 @@ class TestApplyServiceRetries(unittest.TestCase):
# ---------------------------------------------------------------------------
# 7. test_has_egress_false_skips_rules
# 7. has_egress Falseskipped
# ---------------------------------------------------------------------------
class TestHasEgressFalse(unittest.TestCase):
def test_has_egress_false_skips_rules(self):
"""A manifest with has_egress=False must skip rules and return skipped=True."""
manifest = _make_manifest(has_egress=False)
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
@@ -309,8 +337,6 @@ class TestHasEgressFalse(unittest.TestCase):
self.assertTrue(result['ok'])
self.assertTrue(result.get('skipped'))
# No iptables rule-insertion call should have been made.
# iptables-save from clear_service is permitted; only check no -A/-I.
rule_add_calls = [
c for c in mock_run.call_args_list
if c.args and c.args[0][:1] == ['iptables']
@@ -320,22 +346,18 @@ class TestHasEgressFalse(unittest.TestCase):
# ---------------------------------------------------------------------------
# 8. test_has_egress_missing_egress_block_skips
# 8. has_egress True but no egress blockskipped
# ---------------------------------------------------------------------------
class TestHasEgressMissingBlock(unittest.TestCase):
def test_has_egress_missing_egress_block_skips(self):
"""has_egress=True but no 'egress' dict → must skip (skipped=True)."""
manifest = {
'id': 'myapp',
'container_name': 'cell-myapp',
'has_egress': True,
# 'egress' key intentionally absent
}
mgr, _ = _make_manager(
installed=_installed_with_manifest(manifest)
)
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
with patch('subprocess.run') as mock_run:
mock_run.return_value = _subprocess_ok(stdout='')
@@ -346,23 +368,21 @@ class TestHasEgressMissingBlock(unittest.TestCase):
# ---------------------------------------------------------------------------
# 9. test_clear_service_removes_tagged_rules
# 9. clear_service removes only the tagged rules
# ---------------------------------------------------------------------------
class TestClearService(unittest.TestCase):
def test_clear_service_removes_tagged_rules(self):
"""iptables-restore is called with the tagged lines removed."""
mgr, _ = _make_manager()
mangle_rules = (
'-A PIC_EGRESS -s 172.20.0.50 -j MARK --set-mark 0x110 '
'-A PIC_EGRESS -s 172.20.0.50 -j MARK --set-mark 0x1000 '
'-m comment --comment "pic-egr-myapp"\n'
'-A PIC_EGRESS -s 172.20.0.99 -j MARK --set-mark 0x110 '
'-A PIC_EGRESS -s 172.20.0.99 -j MARK --set-mark 0x1000 '
'-m comment --comment "pic-egr-otherapp"\n'
)
nat_rules = ''
restore_inputs = {}
def fake_run(cmd, input=None, **kwargs):
@@ -382,28 +402,25 @@ class TestClearService(unittest.TestCase):
result = mgr.clear_service('myapp')
self.assertTrue(result['ok'])
# The restored mangle rules must not contain myapp's tag
restored = restore_inputs.get('mangle', '')
self.assertNotIn('pic-egr-myapp', restored)
# But the other service's rules must be preserved
self.assertIn('pic-egr-otherapp', restored)
# ---------------------------------------------------------------------------
# 10. test_set_service_exit_rejects_not_in_allowed
# 10. set_service_exit rejects a connection whose type is not in allowed
# ---------------------------------------------------------------------------
class TestSetServiceExitRejectNotAllowed(unittest.TestCase):
def test_set_service_exit_rejects_not_in_allowed(self):
"""Exit type not in manifest's allowed list must return ok=False."""
def test_set_service_exit_rejects_type_not_in_allowed(self):
manifest = _make_manifest(
egress_default='default',
allowed=['default', 'tor'], # wireguard_ext not in allowed
allowed=['tor'], # wireguard_ext not allowed
)
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
result = mgr.set_service_exit('myapp', 'wireguard_ext')
result = mgr.set_service_exit('myapp', 'conn_wg')
self.assertFalse(result['ok'])
self.assertIn('error', result)
@@ -411,47 +428,71 @@ class TestSetServiceExitRejectNotAllowed(unittest.TestCase):
# ---------------------------------------------------------------------------
# 11. test_set_service_exit_persists_and_applies
# 11. set_service_exit persists the connection id and applies
# ---------------------------------------------------------------------------
class TestSetServiceExitPersistsAndApplies(unittest.TestCase):
def test_set_service_exit_persists_and_applies(self):
"""Valid override must be persisted to config_manager and apply_service called."""
manifest = _make_manifest(egress_default='default', allowed=list(EXIT_TYPES))
manifest = _make_manifest(egress_default='default')
mgr, cm = _make_manager(installed=_installed_with_manifest(manifest))
apply_calls = []
original_apply = mgr.apply_service
mgr.apply_service = lambda sid: apply_calls.append(sid) or {
'ok': True, 'exit_via': 'conn_tor'}
def fake_apply(sid):
apply_calls.append(sid)
return {'ok': True, 'exit_via': 'tor'}
result = mgr.set_service_exit('myapp', 'conn_tor')
mgr.apply_service = fake_apply
self.assertTrue(result['ok'], result)
self.assertIn('myapp', apply_calls)
cm._save_all_configs.assert_called()
self.assertEqual(cm.configs['egress_overrides'].get('myapp'), 'conn_tor')
def test_set_service_exit_default_clears_override(self):
manifest = _make_manifest(egress_default='conn_wg')
mgr, cm = _make_manager(
installed=_installed_with_manifest(manifest),
overrides={'myapp': 'conn_tor'},
)
mgr.apply_service = lambda sid: {'ok': True, 'exit_via': 'default'}
result = mgr.set_service_exit('myapp', 'default')
self.assertTrue(result['ok'])
self.assertEqual(cm.configs['egress_overrides'].get('myapp'), 'default')
def test_set_service_exit_unknown_connection_rejected(self):
manifest = _make_manifest(egress_default='default')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
result = mgr.set_service_exit('myapp', 'conn_ghost')
self.assertFalse(result['ok'])
self.assertIn('unknown connection', result['error'])
def test_set_service_exit_legacy_type_resolves_to_single_instance(self):
"""Back-compat shim: a legacy type resolves to the one instance of it."""
manifest = _make_manifest(egress_default='default', allowed=['tor'])
mgr, cm = _make_manager(installed=_installed_with_manifest(manifest))
mgr.apply_service = lambda sid: {'ok': True}
result = mgr.set_service_exit('myapp', 'tor')
self.assertTrue(result['ok'], result)
# apply_service was called
self.assertIn('myapp', apply_calls)
# override was persisted
cm._save_all_configs.assert_called()
self.assertEqual(cm.configs['egress_overrides'].get('myapp'), 'tor')
self.assertEqual(cm.configs['egress_overrides'].get('myapp'), 'conn_tor')
# ---------------------------------------------------------------------------
# 12. test_apply_all_iterates_installed_services
# 12. apply_all iterates installed services
# ---------------------------------------------------------------------------
class TestApplyAll(unittest.TestCase):
def test_apply_all_iterates_installed_services(self):
"""apply_all must call apply_service for every service with a manifest."""
manifests = {
'svc1': _make_manifest(egress_default='wireguard_ext'),
'svc2': _make_manifest(egress_default='openvpn'),
'svc3': _make_manifest(egress_default='tor'),
'svc1': _make_manifest(egress_default='conn_wg'),
'svc2': _make_manifest(egress_default='conn_ovpn'),
'svc3': _make_manifest(egress_default='conn_tor'),
}
installed = {
sid: {'id': sid, 'manifest': m}
@@ -469,37 +510,57 @@ class TestApplyAll(unittest.TestCase):
# ---------------------------------------------------------------------------
# 13. test_marks_do_not_collide_with_connectivity_manager
# 13. service + peer on the same connection share the same mark
# ---------------------------------------------------------------------------
class TestMarksNoCollision(unittest.TestCase):
class TestSharedMarkWithConnection(unittest.TestCase):
def test_marks_do_not_collide_with_connectivity_manager(self):
"""EgressManager marks must be disjoint from ConnectivityManager marks."""
connectivity_marks = {0x10, 0x20, 0x30}
egress_mark_values = set(MARKS.values())
collision = connectivity_marks & egress_mark_values
self.assertEqual(
collision, set(),
f'Mark collision with ConnectivityManager: {collision}',
)
def test_service_uses_connection_mark_not_a_private_table(self):
"""A service routed via a connection uses that connection's mark/table,
i.e. the SAME resources a peer on that connection would use."""
manifest = _make_manifest(egress_default='conn_wg')
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
captured = {}
def fake_run(cmd, **kwargs):
if 'docker' in cmd and 'inspect' in cmd:
return _subprocess_ok(stdout='172.20.0.50\n')
if 'iptables-save' in cmd:
return _subprocess_ok(stdout='')
if 'iptables-restore' in cmd:
return _subprocess_ok()
if cmd[:3] == ['ip', 'rule', 'del']:
return _subprocess_fail()
if cmd[:3] == ['ip', 'rule', 'add']:
captured['ip_rule'] = ' '.join(cmd)
if 'MARK' in cmd:
captured['mark'] = ' '.join(cmd)
return _subprocess_ok()
with patch('subprocess.run', side_effect=fake_run):
mgr.apply_service('myapp')
# conn_wg has mark 0x1000 / table 1000 — both must appear, proving the
# service inherits the connection's shared routing resources.
self.assertIn('0x1000', captured.get('mark', ''))
self.assertIn('1000', captured.get('ip_rule', ''))
# ---------------------------------------------------------------------------
# 14. test_apply_service_unknown_exit_in_allowed_rejected
# 14. apply_service with an unknown connection id → error
# ---------------------------------------------------------------------------
class TestApplyServiceUnknownExit(unittest.TestCase):
def test_apply_service_unknown_exit_in_allowed_rejected(self):
"""An egress.default value that is not a known EXIT_TYPE must return ok=False."""
def test_apply_service_unknown_connection_rejected(self):
manifest = {
'id': 'myapp',
'container_name': 'cell-myapp',
'has_egress': True,
'egress': {
'default': 'internet_fast_lane', # unknown exit
'allowed': ['internet_fast_lane'],
'default': 'conn_ghost', # no such connection
'allowed': ['wireguard_ext'],
},
}
mgr, _ = _make_manager(installed=_installed_with_manifest(manifest))
@@ -521,7 +582,7 @@ class TestApplyServiceUnknownExit(unittest.TestCase):
# ---------------------------------------------------------------------------
# Additional coverage: _has_egress edge cases
# _has_egress edge cases
# ---------------------------------------------------------------------------
class TestHasEgressLogic(unittest.TestCase):
@@ -530,16 +591,15 @@ class TestHasEgressLogic(unittest.TestCase):
self.mgr, _ = _make_manager()
def test_has_egress_both_required(self):
"""Both has_egress=True and non-empty egress dict required."""
m = {'has_egress': True, 'egress': {'default': 'tor', 'allowed': ['tor']}}
m = {'has_egress': True, 'egress': {'default': 'conn_tor', 'allowed': ['tor']}}
self.assertTrue(self.mgr._has_egress(m))
def test_has_egress_false_field(self):
m = {'has_egress': False, 'egress': {'default': 'tor', 'allowed': ['tor']}}
m = {'has_egress': False, 'egress': {'default': 'conn_tor', 'allowed': ['tor']}}
self.assertFalse(self.mgr._has_egress(m))
def test_has_egress_missing_has_egress_key(self):
m = {'egress': {'default': 'tor', 'allowed': ['tor']}}
m = {'egress': {'default': 'conn_tor', 'allowed': ['tor']}}
self.assertFalse(self.mgr._has_egress(m))
def test_has_egress_empty_egress_dict(self):
@@ -548,29 +608,41 @@ class TestHasEgressLogic(unittest.TestCase):
# ---------------------------------------------------------------------------
# Additional coverage: _resolve_exit
# _resolve_exit (now returns connection ids)
# ---------------------------------------------------------------------------
class TestResolveExit(unittest.TestCase):
def test_override_takes_precedence(self):
mgr, _ = _make_manager(overrides={'myapp': 'openvpn'})
manifest = _make_manifest(egress_default='wireguard_ext')
self.assertEqual(mgr._resolve_exit('myapp', manifest), 'openvpn')
mgr, _ = _make_manager(overrides={'myapp': 'conn_ovpn'})
manifest = _make_manifest(egress_default='conn_wg')
self.assertEqual(mgr._resolve_exit('myapp', manifest), 'conn_ovpn')
def test_manifest_default_used_when_no_override(self):
mgr, _ = _make_manager(overrides={})
manifest = _make_manifest(egress_default='tor')
self.assertEqual(mgr._resolve_exit('myapp', manifest), 'tor')
manifest = _make_manifest(egress_default='conn_tor')
self.assertEqual(mgr._resolve_exit('myapp', manifest), 'conn_tor')
def test_fallback_to_default_when_no_egress_block(self):
mgr, _ = _make_manager(overrides={})
manifest = {'id': 'myapp'}
self.assertEqual(mgr._resolve_exit('myapp', manifest), 'default')
def test_legacy_type_override_migrates_to_connection_id(self):
"""An old override holding a type string resolves to the migrated id."""
mgr, _ = _make_manager(overrides={'myapp': 'wireguard_ext'})
manifest = _make_manifest(egress_default='default')
self.assertEqual(mgr._resolve_exit('myapp', manifest), 'conn_wg')
def test_legacy_type_default_with_no_instance_falls_back(self):
"""A legacy type with no matching instance falls back to 'default'."""
mgr, _ = _make_manager(connections={})
manifest = _make_manifest(egress_default='tor')
self.assertEqual(mgr._resolve_exit('myapp', manifest), 'default')
# ---------------------------------------------------------------------------
# Additional: apply_service with missing manifest
# apply_service with missing manifest
# ---------------------------------------------------------------------------
class TestApplyServiceMissingManifest(unittest.TestCase):
+71 -15
View File
@@ -5,6 +5,7 @@ import os
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
# Add api directory to path
api_dir = Path(__file__).parent.parent / 'api'
@@ -107,35 +108,90 @@ class TestPeerRegistry(unittest.TestCase):
with self.assertRaises(ValueError):
self.registry.set_route_via('nobody', 'exit-cell')
def test_set_peer_exit_via_valid(self):
def _connectivity_cm(self, connections):
"""A mock config_manager exposing v2 connection records."""
cm = MagicMock()
cm.list_connections.return_value = connections
return cm
def test_set_peer_exit_via_valid_connection_id(self):
conns = [{'id': 'conn_wg', 'type': 'wireguard_ext'}]
self.registry.config_manager = self._connectivity_cm(conns)
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
result = self.registry.set_peer_exit_via('alice', 'wireguard_ext')
result = self.registry.set_peer_exit_via('alice', 'conn_wg')
self.assertTrue(result)
peer = self.registry.get_peer('alice')
self.assertEqual(peer['exit_via'], 'wireguard_ext')
self.assertEqual(self.registry.get_peer('alice')['exit_via'], 'conn_wg')
def test_set_peer_exit_via_all_valid_types(self):
def test_set_peer_exit_via_default_always_valid(self):
self.registry.config_manager = self._connectivity_cm([])
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
for exit_type in ('default', 'wireguard_ext', 'openvpn', 'tor'):
result = self.registry.set_peer_exit_via('alice', exit_type)
self.assertTrue(result)
peer = self.registry.get_peer('alice')
self.assertEqual(peer['exit_via'], exit_type)
result = self.registry.set_peer_exit_via('alice', 'default')
self.assertTrue(result)
self.assertEqual(self.registry.get_peer('alice')['exit_via'], 'default')
def test_set_peer_exit_via_invalid_type_returns_false(self):
def test_set_peer_exit_via_legacy_type_resolves_to_instance(self):
"""Back-compat shim: a legacy type resolves to the one instance of it."""
conns = [{'id': 'conn_tor', 'type': 'tor'}]
self.registry.config_manager = self._connectivity_cm(conns)
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
result = self.registry.set_peer_exit_via('alice', 'invalid_exit')
result = self.registry.set_peer_exit_via('alice', 'tor')
self.assertTrue(result)
self.assertEqual(self.registry.get_peer('alice')['exit_via'], 'conn_tor')
def test_set_peer_exit_via_unknown_id_returns_false(self):
self.registry.config_manager = self._connectivity_cm([])
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
result = self.registry.set_peer_exit_via('alice', 'conn_ghost')
self.assertFalse(result)
def test_set_peer_exit_via_nonexistent_peer_returns_false(self):
self.registry.config_manager = self._connectivity_cm([])
result = self.registry.set_peer_exit_via('nobody', 'default')
self.assertFalse(result)
def test_set_peer_exit_via_persists(self):
conns = [{'id': 'conn_tor', 'type': 'tor'}]
cm = self._connectivity_cm(conns)
self.registry.config_manager = cm
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})
self.registry.set_peer_exit_via('alice', 'tor')
reloaded = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir)
self.assertEqual(reloaded.get_peer('alice')['exit_via'], 'tor')
self.registry.set_peer_exit_via('alice', 'conn_tor')
reloaded = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir,
config_manager=cm)
self.assertEqual(reloaded.get_peer('alice')['exit_via'], 'conn_tor')
def test_exit_via_migration_legacy_type_to_id(self):
"""On load, a legacy per-type exit_via becomes the migrated instance id."""
peers_file = os.path.join(self.test_dir, 'peers.json')
with open(peers_file, 'w') as f:
json.dump([{'peer': 'alice', 'ip': '10.0.0.5',
'exit_via': 'wireguard_ext'}], f)
cm = self._connectivity_cm([{'id': 'conn_wg', 'type': 'wireguard_ext'}])
reg = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir,
config_manager=cm)
self.assertEqual(reg.get_peer('alice')['exit_via'], 'conn_wg')
def test_exit_via_migration_unknown_type_to_default(self):
"""A legacy type with no migrated instance falls back to 'default'."""
peers_file = os.path.join(self.test_dir, 'peers.json')
with open(peers_file, 'w') as f:
json.dump([{'peer': 'alice', 'ip': '10.0.0.5',
'exit_via': 'openvpn'}], f)
cm = self._connectivity_cm([]) # no instances
reg = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir,
config_manager=cm)
self.assertEqual(reg.get_peer('alice')['exit_via'], 'default')
def test_exit_via_migration_id_is_idempotent(self):
"""An already-migrated id is left untouched and not re-migrated."""
peers_file = os.path.join(self.test_dir, 'peers.json')
with open(peers_file, 'w') as f:
json.dump([{'peer': 'alice', 'ip': '10.0.0.5',
'exit_via': 'conn_wg'}], f)
cm = self._connectivity_cm([{'id': 'conn_wg', 'type': 'wireguard_ext'}])
reg = PeerRegistry(data_dir=self.test_dir, config_dir=self.test_dir,
config_manager=cm)
self.assertEqual(reg.get_peer('alice')['exit_via'], 'conn_wg')
self.assertFalse(reg._migrate_exit_via_to_connection_id())
def test_update_peer_updates_arbitrary_fields(self):
self.registry.add_peer({'peer': 'alice', 'ip': '10.0.0.5'})