feat: connectivity redesign phase 2 — instance-aware routing + reference connections by id
Unit Tests / test (push) Successful in 12m6s

apply_routes now iterates over connection instances rather than types:
each instance gets its own fwmark, routing table, interface, and
redirect_port via _routing_connections / _resolve_peer_connection /
_apply_connection_for_src; kill-switch is enforced per iface-instance.
Old per-type MARKS/TABLES constants are kept only as migration scaffolding.

peer_registry: exit_via is now stored as a connection id (or 'default');
_migrate_exit_via_to_connection_id runs on _load_peers to upgrade legacy
type-string values; set_peer_exit_via validates against known connection
ids; VALID_EXIT_VIA removed; config_manager wired in from managers.py.

egress_manager: egress_overrides keyed by service_id → connection_id;
local MARKS/TABLES/EXIT_TYPES/_REDIRECT_PORTS/_add_tor_redirect removed;
(mark, table, redirect_port) resolved at apply-time via
connectivity_manager.get_connection; manifest egress.allowed still
enforced by connection type.

api/app.py + api.js: PUT peer/service exit endpoints accept {connection_id};
back-compat shim resolves a legacy type string to its single active instance.

Tests extended: two same-type instances produce distinct marks/tables/ports;
peer exit_via and egress override id migrations round-trip correctly;
single-instance behaviour is equivalent to the old type-keyed path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:35:28 -04:00
parent 5b9d20eeac
commit 89aed4efe0
12 changed files with 993 additions and 375 deletions
+96 -10
View File
@@ -17,11 +17,17 @@ logger = logging.getLogger(__name__)
class PeerRegistry(BaseServiceManager):
"""Manages peer registration and management"""
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
config_manager=None):
super().__init__('peer_registry', data_dir, config_dir)
self.lock = RLock()
self.peers = []
self.peers_file = os.path.join(data_dir, 'peers.json')
# config_manager is used to resolve/validate connection ids for the
# per-peer exit (exit_via). It may be wired after construction (the
# singletons in managers.py are built in dependency order), so the
# exit_via→connection-id migration also runs lazily, idempotently.
self.config_manager = config_manager
self._load_peers()
def get_status(self) -> Dict[str, Any]:
@@ -205,6 +211,11 @@ class PeerRegistry(BaseServiceManager):
changed = True
if changed:
self._save_peers()
# Phase 2 (connectivity v2): exit_via is now a connection id (or
# 'default'). Rewrite any legacy per-type exit_via to the id of
# the single migrated connection instance of that type. Runs
# lazily if config_manager is not yet wired.
self._migrate_exit_via_to_connection_id()
else:
self.peers = []
self.logger.info("No peers file found, starting with empty registry")
@@ -350,26 +361,101 @@ class PeerRegistry(BaseServiceManager):
return dict(peer)
raise ValueError(f"Peer '{peer_name}' not found")
# Phase 5: extended connectivity per-peer egress exit
VALID_EXIT_VIA = ('default', 'wireguard_ext', 'openvpn', 'tor',
'sshuttle', 'proxy')
# Connectivity v2: legacy per-type exit values. A peer's exit_via is now a
# connection id (or 'default'); these strings are accepted only as a
# one-release back-compat shim — resolved to the single migrated instance
# of that type via config_manager.list_connections().
_LEGACY_EXIT_TYPES = ('wireguard_ext', 'openvpn', 'tor', 'sshuttle', 'proxy')
def _connections(self) -> List[Dict[str, Any]]:
"""Return the v2 connection records, or [] when unavailable."""
if self.config_manager is None:
return []
try:
conns = self.config_manager.list_connections()
except Exception as e:
self.logger.warning(f"peer_registry: list_connections failed: {e}")
return []
return conns if isinstance(conns, list) else []
def _resolve_exit_via(self, value: str) -> Optional[str]:
"""Resolve an exit_via value to a valid connection id or 'default'.
Accepts 'default', a real connection id, or — as a back-compat shim —
a legacy type string (resolved to the single instance of that type).
Returns None when the value cannot be resolved to anything valid.
"""
if value == 'default':
return 'default'
conns = self._connections()
for c in conns:
if c.get('id') == value:
return value
if value in self._LEGACY_EXIT_TYPES:
matches = [c for c in conns if c.get('type') == value]
if len(matches) == 1:
return matches[0].get('id')
return None
def _migrate_exit_via_to_connection_id(self) -> bool:
"""Rewrite legacy per-type exit_via values to migrated connection ids.
Idempotent: ids and 'default' are left untouched. Legacy type strings
are mapped to the single instance of that type; if no instance exists
the peer falls back to 'default'. Returns True if anything changed.
Runs only when config_manager (and its v2 connections) are available.
"""
if self.config_manager is None:
return False
conns = self._connections()
valid_ids = {c.get('id') for c in conns}
by_type: Dict[str, List[str]] = {}
for c in conns:
by_type.setdefault(c.get('type'), []).append(c.get('id'))
changed = False
with self.lock:
for peer in self.peers:
exit_via = peer.get('exit_via', 'default')
if exit_via == 'default' or exit_via in valid_ids:
continue
new_value = 'default'
if exit_via in self._LEGACY_EXIT_TYPES:
ids = by_type.get(exit_via, [])
if len(ids) == 1:
new_value = ids[0]
peer['exit_via'] = new_value
changed = True
self.logger.info(
f"peer_registry: migrated exit_via {exit_via!r}"
f"{new_value!r} for {peer.get('peer')!r}"
)
if changed:
self._save_peers()
return changed
def set_peer_exit_via(self, peer_name: str, exit_type: str) -> bool:
"""Set the per-peer egress exit type. Returns True if updated, False
if the peer is not found (logged as warning, no exception)."""
if exit_type not in self.VALID_EXIT_VIA:
"""Set the per-peer egress connection id. Returns True if updated, False
if the peer is not found or the id is invalid (logged, no exception).
`exit_type` must be a real connection id or 'default'. A legacy type
string is accepted as a back-compat shim and resolved to the single
instance of that type.
"""
resolved = self._resolve_exit_via(exit_type)
if resolved is None:
self.logger.warning(
f"set_peer_exit_via: invalid exit_type {exit_type!r}"
f"set_peer_exit_via: invalid connection id {exit_type!r}"
)
return False
with self.lock:
for peer in self.peers:
if peer.get('peer') == peer_name:
peer['exit_via'] = exit_type
peer['exit_via'] = resolved
peer['updated_at'] = datetime.utcnow().isoformat()
self._save_peers()
self.logger.info(
f"Set exit_via for {peer_name}: {exit_type!r}"
f"Set exit_via for {peer_name}: {resolved!r}"
)
return True
self.logger.warning(