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:
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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 exit → no 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 False → skipped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 block → skipped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
@@ -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'})
|
||||
|
||||
Reference in New Issue
Block a user