603225694c
Unit Tests / test (push) Successful in 13m5s
instanceable rendering, per-instance up/down on create/delete, store-service-installed gate, per-instance health Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
636 lines
26 KiB
Python
636 lines
26 KiB
Python
"""
|
|
Tests for the connectivity v2 data model — named connection instances.
|
|
|
|
Covers the resource allocator (no mark/table/port collisions, correct iface
|
|
naming, iface vs redirect-port split), the v1→v2 migration (legacy exits →
|
|
one connection each, secret repointing, idempotency, empty case), connection
|
|
CRUD (validation, single-tor, duplicate name, secret refs not values, delete
|
|
frees resources + vault secret, delete blocked when referenced), and computed
|
|
status.state (added vs configured).
|
|
|
|
ConfigManager runs against a tmp dir; the vault is an in-memory fake. No real
|
|
Docker/iptables/subprocess is invoked (apply_routes is never called here).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
|
|
|
|
from config_manager import ConfigManager
|
|
from connectivity_manager import ConnectivityManager
|
|
|
|
VALID_KEY = (
|
|
'-----BEGIN OPENSSH PRIVATE KEY-----\n'
|
|
'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB\n'
|
|
'-----END OPENSSH PRIVATE KEY-----\n'
|
|
)
|
|
VALID_KNOWN_HOSTS = (
|
|
'ssh.example.com,203.0.113.5 ssh-ed25519 '
|
|
'AAAAC3NzaC1lZDI1NTE5AAAAIB5d0o0Yw1xP1Yw1xP1Yw1xP1Yw1xP1Yw1xP1Yw1xP1Y'
|
|
)
|
|
|
|
|
|
class FakeVault:
|
|
"""In-memory stand-in for VaultManager's secret store."""
|
|
|
|
def __init__(self):
|
|
self.store = {}
|
|
|
|
def store_secret(self, name, value):
|
|
self.store[name] = value
|
|
return True
|
|
|
|
def get_secret(self, name):
|
|
return self.store.get(name)
|
|
|
|
def delete_secret(self, name):
|
|
if name in self.store:
|
|
del self.store[name]
|
|
return True
|
|
return False
|
|
|
|
def list_secrets(self):
|
|
return list(self.store.keys())
|
|
|
|
|
|
def _sshuttle_cfg(**overrides):
|
|
cfg = {
|
|
'host': 'ssh.example.com',
|
|
'port': 22,
|
|
'user': 'tunnel',
|
|
'auth': 'key',
|
|
'known_hosts': VALID_KNOWN_HOSTS,
|
|
'exclude_subnets': ['10.0.0.0/8'],
|
|
}
|
|
cfg.update(overrides)
|
|
return cfg
|
|
|
|
|
|
def _proxy_cfg(**overrides):
|
|
cfg = {'scheme': 'http', 'host': 'proxy.example.com', 'port': 3128}
|
|
cfg.update(overrides)
|
|
return cfg
|
|
|
|
|
|
class _Base(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp()
|
|
self.cfg_file = os.path.join(self.tmp, 'cell_config.json')
|
|
self.data_dir = os.path.join(self.tmp, 'data')
|
|
os.makedirs(self.data_dir, exist_ok=True)
|
|
self.cm = ConfigManager(self.cfg_file, self.data_dir)
|
|
self.vault = FakeVault()
|
|
self.peer_registry = MagicMock()
|
|
self.peer_registry.list_peers.return_value = []
|
|
with patch.object(ConnectivityManager, '_subscribe_to_events',
|
|
lambda self: None):
|
|
self.mgr = ConnectivityManager(
|
|
config_manager=self.cm,
|
|
peer_registry=self.peer_registry,
|
|
vault_manager=self.vault,
|
|
data_dir=self.data_dir,
|
|
config_dir=self.tmp,
|
|
)
|
|
# Start CRUD tests from a clean migrated v2 (no legacy exits). Migration
|
|
# is exercised separately in TestMigration; here we want an empty slate
|
|
# so the always-configured Tor default doesn't get auto-migrated in.
|
|
self.cm.configs['connectivity'] = {'version': 2, 'connections': []}
|
|
# P5 gate: a connection's backing store service must be installed before
|
|
# it can be created. Mark all connectivity store services installed so
|
|
# the CRUD tests below exercise allocation/persistence, not the gate
|
|
# (the gate itself is tested explicitly in TestStoreServiceGate). The
|
|
# per-instance container up/down is a no-op here: the manager has no
|
|
# service_composer wired (service_composer=None), so up_connection_instance
|
|
# short-circuits without touching Docker.
|
|
for _svc in ('wireguard-ext', 'openvpn-client', 'tor', 'sshuttle', 'proxy'):
|
|
self.cm.set_installed_service(_svc, {'id': _svc, 'manifest': {}})
|
|
self.cm._save_all_configs()
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resource allocator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAllocator(_Base):
|
|
|
|
def test_iface_types_get_iface_not_port(self):
|
|
for ctype in ('wireguard_ext', 'openvpn'):
|
|
cid = 'conn_deadbeef'
|
|
mark, table, iface, port = self.mgr._allocate_resources(ctype, cid)
|
|
self.assertIsNotNone(iface)
|
|
self.assertIsNone(port)
|
|
self.assertTrue(iface.startswith(
|
|
self.mgr.IFACE_PREFIXES[ctype]))
|
|
self.assertLessEqual(len(iface), 15)
|
|
|
|
def test_redirect_types_get_port_not_iface(self):
|
|
for ctype in ('tor', 'sshuttle', 'proxy'):
|
|
cid = 'conn_abcd1234'
|
|
mark, table, iface, port = self.mgr._allocate_resources(ctype, cid)
|
|
self.assertIsNone(iface)
|
|
self.assertIsNotNone(port)
|
|
self.assertTrue(
|
|
self.mgr.REDIRECT_PORT_BASE <= port <= self.mgr.REDIRECT_PORT_MAX)
|
|
|
|
def test_iface_uses_8hex_id(self):
|
|
_m, _t, iface, _p = self.mgr._allocate_resources(
|
|
'wireguard_ext', 'conn_0123456789')
|
|
self.assertEqual(iface, 'wgext_01234567')
|
|
|
|
def test_no_collisions_across_many_connections(self):
|
|
created = []
|
|
# Mix of iface + redirect types; tor is single-instance so exclude it.
|
|
plan = (['wireguard_ext', 'openvpn', 'sshuttle', 'proxy'] * 4)
|
|
for i, ctype in enumerate(plan):
|
|
if ctype == 'sshuttle':
|
|
res = self.mgr.create_connection(
|
|
ctype, f'ssh-{i}', _sshuttle_cfg(),
|
|
secrets={'private_key': VALID_KEY})
|
|
elif ctype == 'proxy':
|
|
res = self.mgr.create_connection(ctype, f'px-{i}', _proxy_cfg())
|
|
elif ctype == 'wireguard_ext':
|
|
res = self.mgr.create_connection(
|
|
ctype, f'wg-{i}', {},
|
|
secrets={'conf': '[Interface]\nPrivateKey = x\n'})
|
|
else:
|
|
res = self.mgr.create_connection(
|
|
ctype, f'ov-{i}', {},
|
|
secrets={'conf': 'client\nremote vpn.example.com 1194\n'})
|
|
self.assertTrue(res['ok'], res)
|
|
created.append(res['connection'])
|
|
|
|
marks = [c['mark'] for c in created]
|
|
tables = [c['table'] for c in created]
|
|
ifaces = [c['iface'] for c in created if c['iface']]
|
|
ports = [c['redirect_port'] for c in created if c['redirect_port']]
|
|
self.assertEqual(len(marks), len(set(marks)))
|
|
self.assertEqual(len(tables), len(set(tables)))
|
|
self.assertEqual(len(ifaces), len(set(ifaces)))
|
|
self.assertEqual(len(ports), len(set(ports)))
|
|
for m in marks:
|
|
self.assertEqual(m % self.mgr.MARK_STRIDE, 0)
|
|
self.assertTrue(self.mgr.MARK_BASE <= m <= self.mgr.MARK_MAX)
|
|
for t in tables:
|
|
self.assertGreaterEqual(t, self.mgr.TABLE_BASE)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connection CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCreateConnection(_Base):
|
|
|
|
def test_create_proxy_allocates_and_persists(self):
|
|
res = self.mgr.create_connection('proxy', 'Work proxy', _proxy_cfg())
|
|
self.assertTrue(res['ok'], res)
|
|
rec = res['connection']
|
|
self.assertTrue(rec['id'].startswith('conn_'))
|
|
self.assertEqual(rec['type'], 'proxy')
|
|
self.assertIsNotNone(rec['redirect_port'])
|
|
self.assertIsNone(rec['iface'])
|
|
# Persisted to config_manager.
|
|
self.assertEqual(len(self.cm.list_connections()), 1)
|
|
|
|
def test_secret_stored_as_ref_not_value(self):
|
|
res = self.mgr.create_connection(
|
|
'sshuttle', 'tun', _sshuttle_cfg(),
|
|
secrets={'private_key': VALID_KEY})
|
|
self.assertTrue(res['ok'], res)
|
|
rec = res['connection']
|
|
cid = rec['id']
|
|
# Refs recorded, values absent from the record + config.
|
|
self.assertIn(f'{cid}_private_key', rec['secret_refs'])
|
|
self.assertIn(f'{cid}_known_hosts', rec['secret_refs'])
|
|
self.assertNotIn('private_key', rec['config'])
|
|
stored = self.cm.get_connection(cid)
|
|
self.assertNotIn('private_key', stored['config'])
|
|
# Secret value lives only in the vault.
|
|
self.assertEqual(self.vault.get_secret(f'{cid}_private_key'), VALID_KEY)
|
|
|
|
def test_invalid_type_rejected(self):
|
|
res = self.mgr.create_connection('bogus', 'x', {})
|
|
self.assertFalse(res['ok'])
|
|
|
|
def test_invalid_name_rejected(self):
|
|
res = self.mgr.create_connection('proxy', '', _proxy_cfg())
|
|
self.assertFalse(res['ok'])
|
|
|
|
def test_invalid_config_rejected(self):
|
|
res = self.mgr.create_connection('proxy', 'p', _proxy_cfg(scheme='ftp'))
|
|
self.assertFalse(res['ok'])
|
|
self.assertEqual(len(self.cm.list_connections()), 0)
|
|
|
|
def test_single_tor_enforced(self):
|
|
first = self.mgr.create_connection('tor', 'Tor 1', {})
|
|
self.assertTrue(first['ok'], first)
|
|
second = self.mgr.create_connection('tor', 'Tor 2', {})
|
|
self.assertFalse(second['ok'])
|
|
self.assertIn('single', second['error'].lower())
|
|
|
|
def test_duplicate_name_rejected(self):
|
|
a = self.mgr.create_connection('proxy', 'Dup', _proxy_cfg())
|
|
self.assertTrue(a['ok'])
|
|
b = self.mgr.create_connection('proxy', 'dup', _proxy_cfg(port=8080))
|
|
self.assertFalse(b['ok'])
|
|
self.assertIn('already exists', b['error'])
|
|
|
|
def test_state_configured_when_complete(self):
|
|
res = self.mgr.create_connection('proxy', 'full', _proxy_cfg())
|
|
self.assertEqual(res['connection']['status']['state'], 'configured')
|
|
self.assertEqual(res['connection']['status']['health'], 'unknown')
|
|
|
|
def test_state_added_when_missing_secret(self):
|
|
# wireguard_ext with no conf secret → required secret missing → added.
|
|
# (validator requires conf, so create one that omits it must fail; to
|
|
# exercise the 'added' path we build a record directly via migration of
|
|
# an installed-but-unconfigured exit — see TestStatusState.)
|
|
res = self.mgr.create_connection(
|
|
'wireguard_ext', 'wgfull', {},
|
|
secrets={'conf': '[Interface]\nPrivateKey = x\n'})
|
|
self.assertTrue(res['ok'], res)
|
|
self.assertEqual(res['connection']['status']['state'], 'configured')
|
|
|
|
|
|
class TestDeleteConnection(_Base):
|
|
|
|
def test_delete_frees_resources_and_secret(self):
|
|
res = self.mgr.create_connection(
|
|
'sshuttle', 'tun', _sshuttle_cfg(),
|
|
secrets={'private_key': VALID_KEY})
|
|
cid = res['connection']['id']
|
|
self.assertIn(f'{cid}_private_key', self.vault.list_secrets())
|
|
|
|
out = self.mgr.delete_connection(cid)
|
|
self.assertTrue(out['ok'], out)
|
|
self.assertEqual(len(self.cm.list_connections()), 0)
|
|
self.assertNotIn(f'{cid}_private_key', self.vault.list_secrets())
|
|
self.assertNotIn(f'{cid}_known_hosts', self.vault.list_secrets())
|
|
|
|
def test_freed_resources_are_reusable(self):
|
|
a = self.mgr.create_connection('proxy', 'A', _proxy_cfg())
|
|
mark_a = a['connection']['mark']
|
|
self.mgr.delete_connection(a['connection']['id'])
|
|
b = self.mgr.create_connection('proxy', 'B', _proxy_cfg())
|
|
self.assertEqual(b['connection']['mark'], mark_a)
|
|
|
|
def test_delete_blocked_when_referenced(self):
|
|
res = self.mgr.create_connection('proxy', 'ref', _proxy_cfg())
|
|
cid = res['connection']['id']
|
|
self.peer_registry.list_peers.return_value = [
|
|
{'peer': 'alice', 'exit_via': cid}]
|
|
out = self.mgr.delete_connection(cid)
|
|
self.assertFalse(out['ok'])
|
|
self.assertIn('in use', out['error'])
|
|
# Still present, secret intact.
|
|
self.assertEqual(len(self.cm.list_connections()), 1)
|
|
|
|
def test_delete_unknown_returns_error(self):
|
|
out = self.mgr.delete_connection('conn_nope')
|
|
self.assertFalse(out['ok'])
|
|
|
|
|
|
class TestUpdateConnection(_Base):
|
|
|
|
def test_update_name(self):
|
|
res = self.mgr.create_connection('proxy', 'old', _proxy_cfg())
|
|
cid = res['connection']['id']
|
|
out = self.mgr.update_connection(cid, name='new')
|
|
self.assertTrue(out['ok'], out)
|
|
self.assertEqual(self.cm.get_connection(cid)['name'], 'new')
|
|
|
|
def test_update_duplicate_name_rejected(self):
|
|
a = self.mgr.create_connection('proxy', 'A', _proxy_cfg())
|
|
b = self.mgr.create_connection('proxy', 'B', _proxy_cfg(port=8080))
|
|
out = self.mgr.update_connection(b['connection']['id'], name='A')
|
|
self.assertFalse(out['ok'])
|
|
|
|
def test_update_secret_repoints_vault(self):
|
|
res = self.mgr.create_connection(
|
|
'sshuttle', 'tun', _sshuttle_cfg(),
|
|
secrets={'private_key': VALID_KEY})
|
|
cid = res['connection']['id']
|
|
new_key = VALID_KEY.replace('AAAAB', 'BBBBB')
|
|
out = self.mgr.update_connection(
|
|
cid, config=_sshuttle_cfg(), secrets={'private_key': new_key})
|
|
self.assertTrue(out['ok'], out)
|
|
self.assertEqual(self.vault.get_secret(f'{cid}_private_key'), new_key)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list/get enrichment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestListGet(_Base):
|
|
|
|
def test_list_and_get_strip_secret_values(self):
|
|
res = self.mgr.create_connection(
|
|
'sshuttle', 'tun', _sshuttle_cfg(),
|
|
secrets={'private_key': VALID_KEY})
|
|
cid = res['connection']['id']
|
|
listed = self.mgr.list_connections()
|
|
self.assertEqual(len(listed), 1)
|
|
self.assertNotIn('private_key', listed[0]['config'])
|
|
self.assertEqual(listed[0]['status']['health'], 'unknown')
|
|
got = self.mgr.get_connection(cid)
|
|
self.assertEqual(got['id'], cid)
|
|
self.assertNotIn('private_key', got['config'])
|
|
|
|
def test_get_unknown_returns_none(self):
|
|
self.assertIsNone(self.mgr.get_connection('conn_missing'))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Computed status.state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestStatusState(_Base):
|
|
|
|
def test_compute_state_proxy(self):
|
|
self.assertEqual(
|
|
self.mgr._compute_state('proxy', _proxy_cfg(), []), 'configured')
|
|
self.assertEqual(
|
|
self.mgr._compute_state('proxy', {'scheme': 'http'}, []), 'added')
|
|
|
|
def test_compute_state_sshuttle_password_auth(self):
|
|
cfg = _sshuttle_cfg(auth='password')
|
|
cid = 'conn_x'
|
|
self.assertEqual(
|
|
self.mgr._compute_state(cfg['auth'] and 'sshuttle', cfg,
|
|
[f'{cid}_known_hosts', f'{cid}_password']),
|
|
'configured')
|
|
self.assertEqual(
|
|
self.mgr._compute_state('sshuttle', cfg, [f'{cid}_known_hosts']),
|
|
'added')
|
|
|
|
def test_compute_state_tor_always_configured(self):
|
|
self.assertEqual(self.mgr._compute_state('tor', {}, []), 'configured')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# v1 → v2 migration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMigration(_Base):
|
|
|
|
def test_empty_legacy_becomes_version2_empty(self):
|
|
# No exits configured: _exit_status reports nothing configured.
|
|
self.cm.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
|
|
with patch.object(self.mgr, '_exit_status',
|
|
return_value={'configured': False}):
|
|
v2 = self.cm.get_connectivity()
|
|
self.assertEqual(v2['version'], 2)
|
|
self.assertEqual(v2['connections'], [])
|
|
|
|
def test_each_configured_exit_becomes_one_connection(self):
|
|
configured = {'sshuttle', 'proxy', 'tor'}
|
|
|
|
def fake_status(exit_type):
|
|
return {'configured': exit_type in configured}
|
|
|
|
# Seed legacy config + secrets.
|
|
self.cm.configs['connectivity'] = {
|
|
'exits': {
|
|
'sshuttle': {'host': 'h', 'port': 22, 'user': 'u',
|
|
'auth': 'key', 'exclude_subnets': []},
|
|
'proxy': {'scheme': 'http', 'host': 'p', 'port': 3128, 'user': ''},
|
|
},
|
|
'peer_exit_map': {},
|
|
}
|
|
self.vault.store_secret('connectivity_sshuttle_key', VALID_KEY)
|
|
|
|
with patch.object(self.mgr, '_exit_status', side_effect=fake_status):
|
|
v2 = self.cm.get_connectivity()
|
|
|
|
self.assertEqual(v2['version'], 2)
|
|
types = sorted(c['type'] for c in v2['connections'])
|
|
self.assertEqual(types, ['proxy', 'sshuttle', 'tor'])
|
|
# Exactly one per type.
|
|
self.assertEqual(len(v2['connections']), 3)
|
|
|
|
def test_secret_repointed_not_lost(self):
|
|
self.cm.configs['connectivity'] = {
|
|
'exits': {'sshuttle': {'host': 'h', 'port': 22, 'user': 'u',
|
|
'auth': 'key', 'exclude_subnets': []}},
|
|
'peer_exit_map': {},
|
|
}
|
|
self.vault.store_secret('connectivity_sshuttle_key', VALID_KEY)
|
|
|
|
def fake_status(exit_type):
|
|
return {'configured': exit_type == 'sshuttle'}
|
|
|
|
with patch.object(self.mgr, '_exit_status', side_effect=fake_status):
|
|
v2 = self.cm.get_connectivity()
|
|
|
|
conn = next(c for c in v2['connections'] if c['type'] == 'sshuttle')
|
|
cid = conn['id']
|
|
new_ref = f'{cid}_private_key'
|
|
# Old name gone, new name holds the value — nothing lost.
|
|
self.assertIsNone(self.vault.get_secret('connectivity_sshuttle_key'))
|
|
self.assertEqual(self.vault.get_secret(new_ref), VALID_KEY)
|
|
self.assertIn(new_ref, conn['secret_refs'])
|
|
|
|
def test_migration_idempotent(self):
|
|
def fake_status(exit_type):
|
|
return {'configured': exit_type == 'proxy'}
|
|
|
|
self.cm.configs['connectivity'] = {
|
|
'exits': {'proxy': {'scheme': 'http', 'host': 'p', 'port': 3128}},
|
|
'peer_exit_map': {},
|
|
}
|
|
with patch.object(self.mgr, '_exit_status', side_effect=fake_status):
|
|
first = self.cm.get_connectivity()
|
|
ids_first = [c['id'] for c in first['connections']]
|
|
# Second access must not re-run migration / duplicate.
|
|
second = self.cm.get_connectivity()
|
|
ids_second = [c['id'] for c in second['connections']]
|
|
self.assertEqual(ids_first, ids_second)
|
|
self.assertEqual(len(second['connections']), 1)
|
|
|
|
def test_migration_allocates_distinct_resources(self):
|
|
configured = {'wireguard_ext', 'openvpn', 'sshuttle', 'proxy', 'tor'}
|
|
self.cm.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
|
|
|
|
def fake_status(exit_type):
|
|
return {'configured': exit_type in configured}
|
|
|
|
with patch.object(self.mgr, '_exit_status', side_effect=fake_status):
|
|
v2 = self.cm.get_connectivity()
|
|
|
|
conns = v2['connections']
|
|
marks = [c['mark'] for c in conns]
|
|
tables = [c['table'] for c in conns]
|
|
self.assertEqual(len(marks), len(set(marks)))
|
|
self.assertEqual(len(tables), len(set(tables)))
|
|
for c in conns:
|
|
if c['type'] in ('wireguard_ext', 'openvpn'):
|
|
self.assertIsNotNone(c['iface'])
|
|
self.assertIsNone(c['redirect_port'])
|
|
else:
|
|
self.assertIsNone(c['iface'])
|
|
self.assertIsNotNone(c['redirect_port'])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# P5 — per-instance container lifecycle wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PROXY_TEMPLATE = (
|
|
'services:\n'
|
|
' proxy:\n'
|
|
' image: git.pic.ngo/roof/svc-redsocks:latest\n'
|
|
' container_name: cell-proxy-${INSTANCE_ID}\n'
|
|
' network_mode: host\n'
|
|
' cap_add:\n'
|
|
' - NET_ADMIN\n'
|
|
' environment:\n'
|
|
' - REDSOCKS_LOCAL_PORT=${REDIRECT_PORT}\n'
|
|
' volumes:\n'
|
|
' - ${PIC_DATA_DIR}/services/proxy/${INSTANCE_ID}/config:/config\n'
|
|
)
|
|
|
|
_SSHUTTLE_TEMPLATE = (
|
|
'services:\n'
|
|
' sshuttle:\n'
|
|
' image: git.pic.ngo/roof/svc-sshuttle:latest\n'
|
|
' container_name: cell-sshuttle-${INSTANCE_ID}\n'
|
|
' network_mode: host\n'
|
|
' cap_add:\n'
|
|
' - NET_ADMIN\n'
|
|
' environment:\n'
|
|
' - SSHUTTLE_LISTEN_PORT=${REDIRECT_PORT}\n'
|
|
' volumes:\n'
|
|
' - ${PIC_DATA_DIR}/services/sshuttle/${INSTANCE_ID}/config:/config\n'
|
|
)
|
|
|
|
|
|
class _ComposerBase(_Base):
|
|
"""Base that wires a real ServiceComposer (subprocess mocked) + install records
|
|
that carry a compose template, so create/delete drive per-instance up/down."""
|
|
|
|
TEMPLATES = {
|
|
'proxy': _PROXY_TEMPLATE,
|
|
'sshuttle': _SSHUTTLE_TEMPLATE,
|
|
}
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
from service_composer import ServiceComposer
|
|
cm = MagicMock()
|
|
ident = {'cell_name': 'c', 'domain': 'cell.local', 'domain_mode': 'lan'}
|
|
cm.get_identity.return_value = ident
|
|
cm.get_effective_domain.return_value = 'cell.local'
|
|
cm.configs = {}
|
|
self.composer = ServiceComposer(config_manager=cm, data_dir=self.data_dir)
|
|
self.mgr.service_composer = self.composer
|
|
# Re-record install records with compose templates so up_connection_instance
|
|
# finds an image + template (the _Base records had empty manifests).
|
|
svc_template = {
|
|
'wireguard-ext': _SSHUTTLE_TEMPLATE, # content irrelevant; render only
|
|
'openvpn-client': _SSHUTTLE_TEMPLATE,
|
|
'sshuttle': _SSHUTTLE_TEMPLATE,
|
|
'proxy': _PROXY_TEMPLATE,
|
|
'tor': None,
|
|
}
|
|
for svc, tmpl in svc_template.items():
|
|
rec = {'id': svc, 'manifest': {'instanceable': bool(tmpl),
|
|
'requires_host_network': True}}
|
|
if tmpl:
|
|
rec['compose_template'] = tmpl
|
|
self.cm.set_installed_service(svc, rec)
|
|
self.cm._save_all_configs()
|
|
|
|
|
|
class TestCreateBringsUpInstance(_ComposerBase):
|
|
|
|
@patch('service_composer.subprocess.run')
|
|
def test_create_proxy_triggers_up_and_writes_compose(self, mock_run):
|
|
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
|
res = self.mgr.create_connection('proxy', 'Work proxy', _proxy_cfg())
|
|
self.assertTrue(res['ok'], res)
|
|
cid = res['connection']['id']
|
|
inst = cid.split('_')[-1][:12]
|
|
# up_instance ran docker compose up for the per-instance project.
|
|
cmds = [c.args[0] for c in mock_run.call_args_list]
|
|
self.assertTrue(any('up' in cmd and f'pic-conn-{inst}' in cmd
|
|
for cmd in cmds), cmds)
|
|
# Per-instance compose written to its own dir, not the type-level path.
|
|
self.assertTrue(self.composer.has_instance_compose('proxy', inst))
|
|
compose_path = self.composer._instance_compose_path('proxy', inst)
|
|
with open(compose_path) as f:
|
|
content = f.read()
|
|
self.assertIn(f'cell-proxy-{inst}', content)
|
|
self.assertIn(str(res['connection']['redirect_port']), content)
|
|
# redsocks.conf materialized in the per-instance config dir.
|
|
self.assertTrue(os.path.exists(os.path.join(
|
|
self.composer.instance_config_dir('proxy', inst), 'redsocks.conf')))
|
|
|
|
@patch('service_composer.subprocess.run')
|
|
def test_two_proxies_distinct_containers_and_ports(self, mock_run):
|
|
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
|
r1 = self.mgr.create_connection('proxy', 'p1', _proxy_cfg())
|
|
r2 = self.mgr.create_connection('proxy', 'p2', _proxy_cfg())
|
|
self.assertTrue(r1['ok'] and r2['ok'])
|
|
n1 = self.mgr.instance_container_name(r1['connection'])
|
|
n2 = self.mgr.instance_container_name(r2['connection'])
|
|
self.assertNotEqual(n1, n2)
|
|
self.assertNotEqual(r1['connection']['redirect_port'],
|
|
r2['connection']['redirect_port'])
|
|
|
|
@patch('service_composer.subprocess.run')
|
|
def test_delete_brings_down_and_cleans_instance(self, mock_run):
|
|
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
|
res = self.mgr.create_connection('sshuttle', 'tun', _sshuttle_cfg(),
|
|
secrets={'private_key': VALID_KEY})
|
|
cid = res['connection']['id']
|
|
inst = cid.split('_')[-1][:12]
|
|
self.assertTrue(self.composer.has_instance_compose('sshuttle', inst))
|
|
inst_dir = self.composer._instance_dir('sshuttle', inst)
|
|
mock_run.reset_mock()
|
|
d = self.mgr.delete_connection(cid)
|
|
self.assertTrue(d['ok'], d)
|
|
cmds = [c.args[0] for c in mock_run.call_args_list]
|
|
self.assertTrue(any('down' in cmd and f'pic-conn-{inst}' in cmd
|
|
for cmd in cmds), cmds)
|
|
self.assertFalse(os.path.exists(inst_dir))
|
|
|
|
|
|
class TestStoreServiceGate(_Base):
|
|
|
|
def test_create_errors_when_store_service_not_installed(self):
|
|
# _Base marks all installed; remove proxy to hit the gate.
|
|
self.cm.remove_installed_service('proxy')
|
|
res = self.mgr.create_connection('proxy', 'p', _proxy_cfg())
|
|
self.assertFalse(res['ok'])
|
|
self.assertIn('Service Store', res['error'])
|
|
self.assertEqual(len(self.cm.list_connections()), 0)
|
|
|
|
|
|
class TestTorSingleInstance(_ComposerBase):
|
|
|
|
@patch('service_composer.subprocess.run')
|
|
def test_tor_uses_fixed_container_and_no_instance_up(self, mock_run):
|
|
mock_run.return_value = MagicMock(returncode=0, stdout='', stderr='')
|
|
res = self.mgr.create_connection('tor', 'Tor')
|
|
self.assertTrue(res['ok'], res)
|
|
rec = res['connection']
|
|
# Single fixed container name, not cell-tor-<id>.
|
|
self.assertEqual(self.mgr.instance_container_name(rec), 'cell-tor')
|
|
# No per-instance compose written for tor.
|
|
inst = rec['id'].split('_')[-1][:12]
|
|
self.assertFalse(self.composer.has_instance_compose('tor', inst))
|
|
# A second tor is rejected (single instance).
|
|
res2 = self.mgr.create_connection('tor', 'Tor2')
|
|
self.assertFalse(res2['ok'])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|