feat: connectivity redesign phase 1 — multi-instance connection data model
Unit Tests / test (push) Successful in 12m51s

Migrate from the single-exit-per-type model (one wireguard_exit, one
tor_exit, etc.) to N named connection instances, each carrying its own
resource allocations and vault-backed secret refs.

config_manager.py:
- Connectivity v2 schema: top-level `connections` list, each entry has
  id, name, type, enabled, status, config, secret_ref, and allocated
  resources (mark, table, iface, redirect_port).
- Helpers: get_connectivity / list_connections / get_connection /
  add_connection / update_connection / delete_connection /
  set_connection_status.
- v1→v2 migration: promotes legacy wireguard_exit / tor fields into
  the new list on first load; idempotent on v2 configs.

connectivity_manager.py:
- Resource allocator: per-instance fwmark range 0x1000–0x1FFF, routing
  table range 1000+, interface names, and redirect ports 9100–9199;
  all tracked in config to survive restarts.
- Connection CRUD: create / update / delete / list / get with vault
  secret refs for WireGuard private keys and Tor credentials.
- Single-Tor enforcement: rejects a second tor/tor_bridge instance at
  creation time.
- Per-instance config validation for each connection type.
- apply_routes, peer wiring, and egress hookups are intentionally left
  unchanged in this phase; they land in later phases alongside UI.

tests/test_connectivity_connections.py (new, 473 lines):
- Allocator uniqueness, v1→v2 migration round-trip, CRUD lifecycle,
  single-Tor enforcement, and status transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:34:56 -04:00
parent 8a9f4f50c6
commit 5b9d20eeac
3 changed files with 1274 additions and 1 deletions
+132
View File
@@ -14,6 +14,7 @@ import fnmatch
import yaml
import shutil
import hashlib
import threading
from datetime import datetime
from typing import Dict, List, Optional, Any
from pathlib import Path
@@ -61,6 +62,13 @@ class ConfigManager:
pass
self.service_schemas = self._load_service_schemas()
self.configs = self._load_all_configs()
# Guards concurrent reads/writes of the connectivity v2 section.
self._connectivity_lock = threading.RLock()
# Optional callback invoked to migrate the legacy connectivity section
# to v2 on first access. Wired by ConnectivityManager (which owns the
# resource-allocation logic). Until set, get_connectivity() returns the
# raw (possibly legacy) section without migrating.
self._connectivity_migrator = None
# Ensure _identity key always exists
if '_identity' not in self.configs:
self.configs['_identity'] = {}
@@ -1037,6 +1045,130 @@ class ConfigManager:
logger.error(f"set_connectivity_field({field}): {e}")
return False
# ── Connectivity v2 — named connection instances ──────────────────────
#
# The legacy schema stored at most one exit per type under
# `connectivity.exits` plus a `peer_exit_map`. v2 replaces this with a list
# of named connection instances under `connectivity.connections`, each with
# its own allocated routing resources (mark/table/iface/redirect_port) and
# vault secret references. The legacy keys are kept readable so the one-time
# migration can consume them; the new code path uses `connections`.
def register_connectivity_migrator(self, migrator) -> None:
"""Register the v1→v2 migration callback (owned by ConnectivityManager).
`migrator(legacy_section) -> list[connection_record]` builds the v2
connection records (allocating resources, repointing secrets) from the
legacy section. Called at most once, lazily, on first get_connectivity().
"""
self._connectivity_migrator = migrator
def get_connectivity(self) -> Dict[str, Any]:
"""Return the connectivity v2 dict, running v1→v2 migration if needed.
Idempotent: once `version` is 2 the stored section is returned as-is.
When `version` < 2 and a migrator is registered, the legacy exits are
converted to connection instances exactly once and the result persisted.
"""
with self._connectivity_lock:
cfg = self.configs.get('connectivity')
if not isinstance(cfg, dict):
cfg = {}
if cfg.get('version') == 2 and isinstance(cfg.get('connections'), list):
return self._copy_connectivity(cfg)
connections: List[Dict[str, Any]] = []
if self._connectivity_migrator is not None:
try:
built = self._connectivity_migrator(dict(cfg))
if isinstance(built, list):
connections = built
except Exception as e:
logger.error(f"connectivity v1→v2 migration failed: {e}")
raise
new_cfg = dict(cfg)
new_cfg['version'] = 2
new_cfg['connections'] = connections
self.configs['connectivity'] = new_cfg
self._save_all_configs()
return self._copy_connectivity(new_cfg)
@staticmethod
def _copy_connectivity(cfg: Dict[str, Any]) -> Dict[str, Any]:
"""Deep-ish copy of the connectivity section so callers can't mutate state."""
out = dict(cfg)
out['connections'] = [dict(c) for c in cfg.get('connections', [])]
return out
def list_connections(self) -> List[Dict[str, Any]]:
"""Return a copy of all v2 connection records."""
with self._connectivity_lock:
return self.get_connectivity().get('connections', [])
def get_connection(self, conn_id: str) -> Optional[Dict[str, Any]]:
"""Return a copy of one connection record by id, or None."""
with self._connectivity_lock:
for conn in self.get_connectivity().get('connections', []):
if conn.get('id') == conn_id:
return dict(conn)
return None
def add_connection(self, record: Dict[str, Any]) -> bool:
"""Append a connection record and persist atomically."""
with self._connectivity_lock:
cfg = self.get_connectivity()
conns = cfg.get('connections', [])
conns.append(dict(record))
self.configs['connectivity'] = {
**self.configs.get('connectivity', {}),
'version': 2,
'connections': conns,
}
self._save_all_configs()
return True
def update_connection(self, conn_id: str, fields: Dict[str, Any]) -> bool:
"""Merge `fields` into the connection record with id `conn_id`."""
with self._connectivity_lock:
cfg = self.get_connectivity()
conns = cfg.get('connections', [])
found = False
for conn in conns:
if conn.get('id') == conn_id:
conn.update(fields)
found = True
break
if not found:
return False
self.configs['connectivity'] = {
**self.configs.get('connectivity', {}),
'version': 2,
'connections': conns,
}
self._save_all_configs()
return True
def delete_connection(self, conn_id: str) -> bool:
"""Remove the connection record with id `conn_id`."""
with self._connectivity_lock:
cfg = self.get_connectivity()
conns = cfg.get('connections', [])
remaining = [c for c in conns if c.get('id') != conn_id]
if len(remaining) == len(conns):
return False
self.configs['connectivity'] = {
**self.configs.get('connectivity', {}),
'version': 2,
'connections': remaining,
}
self._save_all_configs()
return True
def set_connection_status(self, conn_id: str, status: Dict[str, Any]) -> bool:
"""Replace the `status` sub-dict of one connection record."""
return self.update_connection(conn_id, {'status': dict(status)})
def get_all_configs(self) -> Dict[str, Dict]:
"""Get all service configurations"""
return self.configs.copy()