feat: connectivity redesign phase 1 — multi-instance connection data model
Unit Tests / test (push) Successful in 12m51s
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:
@@ -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()
|
||||
|
||||
+669
-1
@@ -42,8 +42,12 @@ import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from base_service_manager import BaseServiceManager
|
||||
|
||||
@@ -127,6 +131,41 @@ class ConnectivityManager(BaseServiceManager):
|
||||
REDIRECT_PORTS = {"tor": TOR_TRANS_PORT, "sshuttle": SSHUTTLE_PORT,
|
||||
"proxy": REDSOCKS_PORT}
|
||||
|
||||
# ── Connectivity v2 — instance resource allocation ────────────────────
|
||||
# Connection instance types (the legacy "default" pseudo-exit is excluded —
|
||||
# a peer/service routed via "default" simply has no connection).
|
||||
CONNECTION_TYPES = ("wireguard_ext", "openvpn", "tor", "sshuttle", "proxy")
|
||||
# Types whose egress is a real interface (kill-switch capable). They get an
|
||||
# iface name and no redirect port.
|
||||
IFACE_TYPES = ("wireguard_ext", "openvpn")
|
||||
# Types implemented as a local transparent-proxy REDIRECT. They get a
|
||||
# redirect port and no iface.
|
||||
REDIRECT_TYPES = ("tor", "sshuttle", "proxy")
|
||||
# Only a single Tor instance is supported (one Tor container per cell).
|
||||
SINGLE_INSTANCE_TYPES = ("tor",)
|
||||
|
||||
# fwmark block 0x1000–0x1FFF, stride 0x10.
|
||||
MARK_BASE = 0x1000
|
||||
MARK_STRIDE = 0x10
|
||||
MARK_MAX = 0x1FFF
|
||||
# routing tables 1000+.
|
||||
TABLE_BASE = 1000
|
||||
# transparent-proxy redirect port pool for instances.
|
||||
REDIRECT_PORT_BASE = 9100
|
||||
REDIRECT_PORT_MAX = 9199
|
||||
|
||||
IFACE_PREFIXES = {"wireguard_ext": "wgext_", "openvpn": "ovpn_"}
|
||||
|
||||
CONNECTION_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$')
|
||||
|
||||
DEFAULT_CONNECTION_NAMES = {
|
||||
"wireguard_ext": "WireGuard External",
|
||||
"openvpn": "OpenVPN",
|
||||
"tor": "Tor",
|
||||
"sshuttle": "SSH Tunnel",
|
||||
"proxy": "Proxy",
|
||||
}
|
||||
|
||||
# Store-service ids / container names backing each exit type — used to
|
||||
# report an exit as configured when it was installed via the Service Store
|
||||
# rather than through a legacy config upload.
|
||||
@@ -159,6 +198,17 @@ class ConnectivityManager(BaseServiceManager):
|
||||
self.peer_registry = peer_registry
|
||||
self.vault_manager = vault_manager
|
||||
|
||||
# Serializes connection CRUD + resource allocation across threads.
|
||||
self._conn_lock = threading.RLock()
|
||||
|
||||
# Wire the v1→v2 migration so it runs lazily on first get_connectivity().
|
||||
if self.config_manager is not None and hasattr(
|
||||
self.config_manager, 'register_connectivity_migrator'
|
||||
):
|
||||
self.config_manager.register_connectivity_migrator(
|
||||
self._migrate_connectivity_v1_to_v2
|
||||
)
|
||||
|
||||
# Connectivity configs live under the per-service data dir so that
|
||||
# ${PIC_DATA_DIR}/services/<id>/config bind mounts in store compose
|
||||
# templates can read them (Docker daemon resolves paths on the HOST,
|
||||
@@ -632,6 +682,624 @@ class ConnectivityManager(BaseServiceManager):
|
||||
except Exception as e:
|
||||
logger.warning(f"_persist_exit_config({exit_type}): {e}")
|
||||
|
||||
# ── Connectivity v2 — per-instance config validation ──────────────────
|
||||
#
|
||||
# These validators operate on a single connection instance's config dict,
|
||||
# returning (clean_config, clean_secrets, error). They share the field
|
||||
# rules used by the legacy single-slot configure_* methods so behaviour is
|
||||
# identical; the legacy methods remain the v1 write path until phase 2.
|
||||
|
||||
def _validate_connection_config(
|
||||
self, conn_type: str, config: Dict[str, Any],
|
||||
secrets_in: Optional[Dict[str, Any]],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, str], Optional[str]]:
|
||||
"""Validate one instance's config + secrets for `conn_type`.
|
||||
|
||||
Returns (clean_config, clean_secrets, error). On error the first two
|
||||
values are empty. Secrets are returned separately so callers store them
|
||||
in the vault and never in cell_config.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return {}, {}, 'config must be a JSON object'
|
||||
secrets_in = secrets_in or {}
|
||||
if not isinstance(secrets_in, dict):
|
||||
return {}, {}, 'secrets must be a JSON object'
|
||||
|
||||
if conn_type == 'sshuttle':
|
||||
return self._validate_sshuttle_instance(config, secrets_in)
|
||||
if conn_type == 'proxy':
|
||||
return self._validate_proxy_instance(config, secrets_in)
|
||||
if conn_type == 'wireguard_ext':
|
||||
return self._validate_wg_instance(config, secrets_in)
|
||||
if conn_type == 'openvpn':
|
||||
return self._validate_ovpn_instance(config, secrets_in)
|
||||
if conn_type == 'tor':
|
||||
# Tor has no per-instance config or secret.
|
||||
return {}, {}, None
|
||||
return {}, {}, f'unsupported connection type {conn_type!r}'
|
||||
|
||||
def _validate_sshuttle_instance(
|
||||
self, cfg: Dict[str, Any], secrets_in: Dict[str, Any],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, str], Optional[str]]:
|
||||
for value in cfg.values():
|
||||
if isinstance(value, str) and _contains_strict_hostkey_disable(value):
|
||||
return {}, {}, ('StrictHostKeyChecking=no is not allowed; a '
|
||||
'pinned host key (known_hosts line) is required')
|
||||
|
||||
host = _validate_host(cfg.get('host'))
|
||||
if not host:
|
||||
return {}, {}, 'invalid host: must be a hostname or IP'
|
||||
port = _validate_port(cfg.get('port', 22))
|
||||
if port is None:
|
||||
return {}, {}, 'invalid port: must be 1-65535'
|
||||
user = cfg.get('user')
|
||||
if not isinstance(user, str) or not _SSH_USER_RE.match(user):
|
||||
return {}, {}, 'invalid user: must match ^[a-z_][a-z0-9_-]{0,31}$'
|
||||
auth = cfg.get('auth', 'key')
|
||||
if auth not in ('key', 'password'):
|
||||
return {}, {}, "invalid auth: must be 'key' or 'password'"
|
||||
|
||||
known_hosts = cfg.get('known_hosts')
|
||||
err = self._validate_known_hosts_line(known_hosts)
|
||||
if err:
|
||||
return {}, {}, err
|
||||
known_hosts = known_hosts.strip()
|
||||
|
||||
clean_secrets: Dict[str, str] = {'known_hosts': known_hosts}
|
||||
if auth == 'key':
|
||||
private_key = secrets_in.get('private_key', cfg.get('private_key', ''))
|
||||
if not isinstance(private_key, str) or 'PRIVATE KEY' not in private_key:
|
||||
return {}, {}, ('private_key is required for key auth and must be '
|
||||
'a PEM/OpenSSH private key')
|
||||
clean_secrets['private_key'] = private_key
|
||||
else:
|
||||
password = secrets_in.get('password', cfg.get('password', ''))
|
||||
if not isinstance(password, str) or not password or '\n' in password:
|
||||
return {}, {}, 'password is required for password auth'
|
||||
clean_secrets['password'] = password
|
||||
|
||||
exclude_subnets = cfg.get('exclude_subnets')
|
||||
if exclude_subnets is None:
|
||||
exclude_subnets = self._default_exclude_subnets()
|
||||
if not isinstance(exclude_subnets, list):
|
||||
return {}, {}, 'exclude_subnets must be a list of CIDRs'
|
||||
validated_excludes = []
|
||||
for net in exclude_subnets:
|
||||
try:
|
||||
validated_excludes.append(str(ipaddress.ip_network(str(net), strict=False)))
|
||||
except ValueError:
|
||||
return {}, {}, f'invalid exclude subnet: {net!r}'
|
||||
|
||||
clean_config = {
|
||||
'host': host, 'port': port, 'user': user, 'auth': auth,
|
||||
'exclude_subnets': validated_excludes,
|
||||
}
|
||||
return clean_config, clean_secrets, None
|
||||
|
||||
def _validate_proxy_instance(
|
||||
self, cfg: Dict[str, Any], secrets_in: Dict[str, Any],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, str], Optional[str]]:
|
||||
scheme = cfg.get('scheme')
|
||||
if scheme not in ('http', 'socks5'):
|
||||
return {}, {}, "invalid scheme: must be 'http' or 'socks5'"
|
||||
host = _validate_host(cfg.get('host'))
|
||||
if not host:
|
||||
return {}, {}, 'invalid host: must be a hostname or IP'
|
||||
port = _validate_port(cfg.get('port'))
|
||||
if port is None:
|
||||
return {}, {}, 'invalid port: must be 1-65535'
|
||||
user = cfg.get('user') or ''
|
||||
password = secrets_in.get('password', cfg.get('password') or '')
|
||||
if user and not (isinstance(user, str) and _PROXY_USER_RE.match(user)):
|
||||
return {}, {}, 'invalid user: must match ^[A-Za-z0-9._-]{1,64}$'
|
||||
if password and not (isinstance(password, str)
|
||||
and _PROXY_PASSWORD_RE.match(password)):
|
||||
return {}, {}, ('invalid password: 1-128 printable ASCII characters '
|
||||
'excluding double quotes and backslashes')
|
||||
if password and not user:
|
||||
return {}, {}, 'password requires a user'
|
||||
|
||||
clean_config = {'scheme': scheme, 'host': host, 'port': port, 'user': user}
|
||||
clean_secrets: Dict[str, str] = {}
|
||||
if password:
|
||||
clean_secrets['password'] = password
|
||||
return clean_config, clean_secrets, None
|
||||
|
||||
def _validate_wg_instance(
|
||||
self, cfg: Dict[str, Any], secrets_in: Dict[str, Any],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, str], Optional[str]]:
|
||||
conf_text = secrets_in.get('conf', cfg.get('conf', ''))
|
||||
if not isinstance(conf_text, str) or not conf_text.strip():
|
||||
return {}, {}, 'conf is required: a WireGuard config'
|
||||
try:
|
||||
cleaned = self._validate_wg_conf(conf_text)
|
||||
except ValueError as e:
|
||||
return {}, {}, str(e)
|
||||
return {}, {'conf': cleaned}, None
|
||||
|
||||
def _validate_ovpn_instance(
|
||||
self, cfg: Dict[str, Any], secrets_in: Dict[str, Any],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, str], Optional[str]]:
|
||||
conf_text = secrets_in.get('conf', cfg.get('conf', ''))
|
||||
if not isinstance(conf_text, str) or not conf_text.strip():
|
||||
return {}, {}, 'conf is required: an OpenVPN profile'
|
||||
try:
|
||||
cleaned = self._validate_ovpn(conf_text)
|
||||
except ValueError as e:
|
||||
return {}, {}, str(e)
|
||||
return {}, {'conf': cleaned}, None
|
||||
|
||||
# ── Connectivity v2 — resource allocator ──────────────────────────────
|
||||
|
||||
def _used_resources(self) -> Tuple[set, set, set, set]:
|
||||
"""Return (marks, tables, ifaces, ports) currently used by connections."""
|
||||
marks, tables, ifaces, ports = set(), set(), set(), set()
|
||||
if self.config_manager is None:
|
||||
return marks, tables, ifaces, ports
|
||||
try:
|
||||
conns = self.config_manager.list_connections()
|
||||
except Exception as e:
|
||||
logger.warning(f"_used_resources: list_connections failed: {e}")
|
||||
conns = []
|
||||
for c in conns:
|
||||
if isinstance(c.get('mark'), int):
|
||||
marks.add(c['mark'])
|
||||
if isinstance(c.get('table'), int):
|
||||
tables.add(c['table'])
|
||||
if c.get('iface'):
|
||||
ifaces.add(c['iface'])
|
||||
if isinstance(c.get('redirect_port'), int):
|
||||
ports.add(c['redirect_port'])
|
||||
return marks, tables, ifaces, ports
|
||||
|
||||
def _allocate_resources(
|
||||
self, conn_type: str, conn_id: str,
|
||||
) -> Tuple[int, int, Optional[str], Optional[int]]:
|
||||
"""Allocate (mark, table, iface, redirect_port) for a new connection.
|
||||
|
||||
Lowest-free-overall within each pool (delete frees + cleans rules, so
|
||||
reuse is safe). iface is set only for IFACE_TYPES, redirect_port only
|
||||
for REDIRECT_TYPES.
|
||||
"""
|
||||
marks, tables, ifaces, ports = self._used_resources()
|
||||
|
||||
mark = self.MARK_BASE
|
||||
while mark in marks:
|
||||
mark += self.MARK_STRIDE
|
||||
if mark > self.MARK_MAX:
|
||||
raise ValueError('no free fwmark available in 0x1000–0x1FFF')
|
||||
|
||||
table = self.TABLE_BASE
|
||||
while table in tables:
|
||||
table += 1
|
||||
|
||||
iface: Optional[str] = None
|
||||
if conn_type in self.IFACE_TYPES:
|
||||
hexid = conn_id.split('_')[-1][:8]
|
||||
iface = f"{self.IFACE_PREFIXES[conn_type]}{hexid}"
|
||||
|
||||
redirect_port: Optional[int] = None
|
||||
if conn_type in self.REDIRECT_TYPES:
|
||||
port = self.REDIRECT_PORT_BASE
|
||||
while port in ports and port <= self.REDIRECT_PORT_MAX:
|
||||
port += 1
|
||||
if port > self.REDIRECT_PORT_MAX:
|
||||
raise ValueError('no free redirect port available in 9100–9199')
|
||||
redirect_port = port
|
||||
|
||||
return mark, table, iface, redirect_port
|
||||
|
||||
@staticmethod
|
||||
def _new_conn_id() -> str:
|
||||
return f"conn_{secrets.token_hex(4)}"
|
||||
|
||||
# ── Connectivity v2 — connection CRUD ─────────────────────────────────
|
||||
|
||||
def _compute_state(self, conn_type: str, config: Dict[str, Any],
|
||||
secret_refs: List[str]) -> str:
|
||||
"""'configured' when all required fields/secrets present, else 'added'."""
|
||||
required = self._required_for_type(conn_type, config)
|
||||
for field in required.get('config', ()):
|
||||
if not config.get(field):
|
||||
return 'added'
|
||||
for ref_suffix in required.get('secrets', ()):
|
||||
if not any(r.endswith(f'_{ref_suffix}') for r in secret_refs):
|
||||
return 'added'
|
||||
return 'configured'
|
||||
|
||||
def _required_for_type(self, conn_type: str,
|
||||
config: Dict[str, Any]) -> Dict[str, Tuple[str, ...]]:
|
||||
"""Required non-secret fields and secret suffixes per type."""
|
||||
if conn_type == 'sshuttle':
|
||||
auth_secret = 'private_key' if config.get('auth') != 'password' else 'password'
|
||||
return {'config': ('host', 'user', 'auth'),
|
||||
'secrets': ('known_hosts', auth_secret)}
|
||||
if conn_type == 'proxy':
|
||||
return {'config': ('scheme', 'host', 'port'), 'secrets': ()}
|
||||
if conn_type in ('wireguard_ext', 'openvpn'):
|
||||
return {'config': (), 'secrets': ('conf',)}
|
||||
if conn_type == 'tor':
|
||||
return {'config': (), 'secrets': ()}
|
||||
return {'config': (), 'secrets': ()}
|
||||
|
||||
def create_connection(self, conn_type: str, name: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
secrets: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a named connection instance of `conn_type`.
|
||||
|
||||
Validates the type, name (non-empty + unique per cell), and the
|
||||
per-type field rules; enforces a single Tor; allocates routing
|
||||
resources; stores secrets in the vault under conn_<id>_<field> and
|
||||
records only the refs. Returns the created record (no secret values).
|
||||
"""
|
||||
if conn_type not in self.CONNECTION_TYPES:
|
||||
return {'ok': False, 'error':
|
||||
f'invalid type {conn_type!r}; must be one of {self.CONNECTION_TYPES}'}
|
||||
if not isinstance(name, str) or not self.CONNECTION_NAME_RE.match(name.strip()):
|
||||
return {'ok': False, 'error': 'invalid name: 1-64 chars, '
|
||||
'letters/digits/space/._- and must start alphanumeric'}
|
||||
name = name.strip()
|
||||
config = config or {}
|
||||
|
||||
clean_config, clean_secrets, err = self._validate_connection_config(
|
||||
conn_type, config, secrets)
|
||||
if err:
|
||||
return {'ok': False, 'error': err}
|
||||
|
||||
with self._conn_lock:
|
||||
existing = []
|
||||
if self.config_manager is not None:
|
||||
try:
|
||||
existing = self.config_manager.list_connections()
|
||||
except Exception as e:
|
||||
logger.error(f"create_connection: list failed: {e}")
|
||||
return {'ok': False, 'error': 'failed to read connections'}
|
||||
|
||||
if conn_type in self.SINGLE_INSTANCE_TYPES:
|
||||
if any(c.get('type') == conn_type for c in existing):
|
||||
return {'ok': False, 'error':
|
||||
f'only a single {conn_type} connection is supported'}
|
||||
|
||||
if any(c.get('name', '').strip().lower() == name.lower()
|
||||
for c in existing):
|
||||
return {'ok': False, 'error': f'a connection named {name!r} already exists'}
|
||||
|
||||
conn_id = self._new_conn_id()
|
||||
try:
|
||||
mark, table, iface, redirect_port = self._allocate_resources(
|
||||
conn_type, conn_id)
|
||||
except ValueError as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
|
||||
secret_refs: List[str] = []
|
||||
stored_refs: List[str] = []
|
||||
if clean_secrets:
|
||||
if self.vault_manager is None:
|
||||
return {'ok': False, 'error': 'vault unavailable; cannot store secrets'}
|
||||
for field, value in clean_secrets.items():
|
||||
ref = f"{conn_id}_{field}"
|
||||
try:
|
||||
self.vault_manager.store_secret(ref, value)
|
||||
except Exception as e:
|
||||
logger.error(f"create_connection: vault store {ref}: {e}")
|
||||
for done in stored_refs:
|
||||
try:
|
||||
self.vault_manager.delete_secret(done)
|
||||
except Exception:
|
||||
pass
|
||||
return {'ok': False, 'error': 'failed to store secret in vault'}
|
||||
stored_refs.append(ref)
|
||||
secret_refs.append(ref)
|
||||
|
||||
now = self._now_iso()
|
||||
state = self._compute_state(conn_type, clean_config, secret_refs)
|
||||
record = {
|
||||
'id': conn_id,
|
||||
'type': conn_type,
|
||||
'name': name,
|
||||
'enabled': True,
|
||||
'mark': mark,
|
||||
'table': table,
|
||||
'iface': iface,
|
||||
'redirect_port': redirect_port,
|
||||
'config': clean_config,
|
||||
'secret_refs': secret_refs,
|
||||
'cell_name': None,
|
||||
'status': {
|
||||
'state': state,
|
||||
'health': 'unknown',
|
||||
'last_check': None,
|
||||
'detail': None,
|
||||
},
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}
|
||||
try:
|
||||
self.config_manager.add_connection(record)
|
||||
except Exception as e:
|
||||
logger.error(f"create_connection: persist failed: {e}")
|
||||
for ref in stored_refs:
|
||||
try:
|
||||
self.vault_manager.delete_secret(ref)
|
||||
except Exception:
|
||||
pass
|
||||
return {'ok': False, 'error': 'failed to persist connection'}
|
||||
|
||||
logger.info(f"connectivity: created connection {conn_id} "
|
||||
f"({conn_type}/{name}) mark={hex(mark)} table={table}")
|
||||
return {'ok': True, 'connection': self._public_record(record)}
|
||||
|
||||
def update_connection(self, conn_id: str, name: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
secrets: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing connection's name, config and/or secrets."""
|
||||
with self._conn_lock:
|
||||
if self.config_manager is None:
|
||||
return {'ok': False, 'error': 'config unavailable'}
|
||||
record = self.config_manager.get_connection(conn_id)
|
||||
if record is None:
|
||||
return {'ok': False, 'error': f'connection {conn_id!r} not found'}
|
||||
conn_type = record.get('type')
|
||||
|
||||
fields: Dict[str, Any] = {}
|
||||
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not self.CONNECTION_NAME_RE.match(name.strip()):
|
||||
return {'ok': False, 'error': 'invalid name'}
|
||||
name = name.strip()
|
||||
for c in self.config_manager.list_connections():
|
||||
if c.get('id') != conn_id and \
|
||||
c.get('name', '').strip().lower() == name.lower():
|
||||
return {'ok': False, 'error':
|
||||
f'a connection named {name!r} already exists'}
|
||||
fields['name'] = name
|
||||
|
||||
secret_refs = list(record.get('secret_refs', []))
|
||||
new_config = dict(record.get('config', {}))
|
||||
if config is not None or secrets is not None:
|
||||
merged = dict(record.get('config', {}))
|
||||
if isinstance(config, dict):
|
||||
merged.update(config)
|
||||
clean_config, clean_secrets, err = self._validate_connection_config(
|
||||
conn_type, merged, secrets)
|
||||
if err:
|
||||
return {'ok': False, 'error': err}
|
||||
new_config = clean_config
|
||||
fields['config'] = clean_config
|
||||
if clean_secrets:
|
||||
if self.vault_manager is None:
|
||||
return {'ok': False, 'error': 'vault unavailable'}
|
||||
for field, value in clean_secrets.items():
|
||||
ref = f"{conn_id}_{field}"
|
||||
try:
|
||||
self.vault_manager.store_secret(ref, value)
|
||||
except Exception as e:
|
||||
logger.error(f"update_connection: vault store {ref}: {e}")
|
||||
return {'ok': False, 'error': 'failed to store secret'}
|
||||
if ref not in secret_refs:
|
||||
secret_refs.append(ref)
|
||||
fields['secret_refs'] = secret_refs
|
||||
|
||||
if fields:
|
||||
fields['updated_at'] = self._now_iso()
|
||||
fields['status'] = {
|
||||
**record.get('status', {}),
|
||||
'state': self._compute_state(conn_type, new_config, secret_refs),
|
||||
}
|
||||
self.config_manager.update_connection(conn_id, fields)
|
||||
|
||||
updated = self.config_manager.get_connection(conn_id)
|
||||
return {'ok': True, 'connection': self._public_record(updated)}
|
||||
|
||||
def delete_connection(self, conn_id: str) -> Dict[str, Any]:
|
||||
"""Delete a connection: free resources + vault secrets. Blocked if referenced."""
|
||||
with self._conn_lock:
|
||||
if self.config_manager is None:
|
||||
return {'ok': False, 'error': 'config unavailable'}
|
||||
record = self.config_manager.get_connection(conn_id)
|
||||
if record is None:
|
||||
return {'ok': False, 'error': f'connection {conn_id!r} not found'}
|
||||
|
||||
ref = self._connection_reference(conn_id)
|
||||
if ref:
|
||||
return {'ok': False, 'error':
|
||||
f'connection is in use by {ref}; detach it first'}
|
||||
|
||||
for secret_ref in record.get('secret_refs', []):
|
||||
if self.vault_manager is not None:
|
||||
try:
|
||||
self.vault_manager.delete_secret(secret_ref)
|
||||
except Exception as e:
|
||||
logger.warning(f"delete_connection: vault delete {secret_ref}: {e}")
|
||||
|
||||
self.config_manager.delete_connection(conn_id)
|
||||
logger.info(f"connectivity: deleted connection {conn_id}")
|
||||
return {'ok': True}
|
||||
|
||||
def _connection_reference(self, conn_id: str) -> Optional[str]:
|
||||
"""Return a human description if a peer/egress references this connection.
|
||||
|
||||
Phase 2 wires peers/egress to connection ids; until then nothing
|
||||
references a connection, so this returns None. Kept as the single
|
||||
choke-point so phase 2 only has to fill in the lookups here.
|
||||
"""
|
||||
if self.peer_registry is not None:
|
||||
try:
|
||||
for peer in self.peer_registry.list_peers():
|
||||
if peer.get('connection_id') == conn_id:
|
||||
return f"peer {peer.get('peer')!r}"
|
||||
except Exception as e:
|
||||
logger.debug(f"_connection_reference (peers): {e}")
|
||||
return None
|
||||
|
||||
def list_connections(self) -> List[Dict[str, Any]]:
|
||||
"""Return all connection records (public form, computed status.state)."""
|
||||
if self.config_manager is None:
|
||||
return []
|
||||
try:
|
||||
conns = self.config_manager.list_connections()
|
||||
except Exception as e:
|
||||
logger.warning(f"list_connections: {e}")
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for record in conns:
|
||||
state = self._compute_state(
|
||||
record.get('type'), record.get('config', {}),
|
||||
record.get('secret_refs', []))
|
||||
status = dict(record.get('status', {}))
|
||||
status['state'] = state
|
||||
rec = dict(record)
|
||||
rec['status'] = status
|
||||
out.append(self._public_record(rec))
|
||||
return out
|
||||
|
||||
def get_connection(self, conn_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return one connection record (public form), or None."""
|
||||
if self.config_manager is None:
|
||||
return None
|
||||
record = self.config_manager.get_connection(conn_id)
|
||||
if record is None:
|
||||
return None
|
||||
status = dict(record.get('status', {}))
|
||||
status['state'] = self._compute_state(
|
||||
record.get('type'), record.get('config', {}),
|
||||
record.get('secret_refs', []))
|
||||
rec = dict(record)
|
||||
rec['status'] = status
|
||||
return self._public_record(rec)
|
||||
|
||||
@staticmethod
|
||||
def _public_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Strip any secret values; only secret_refs are exposed."""
|
||||
rec = dict(record)
|
||||
rec.pop('private_key', None)
|
||||
rec.pop('password', None)
|
||||
rec.pop('conf', None)
|
||||
config = dict(rec.get('config', {}))
|
||||
for k in ('private_key', 'password', 'conf'):
|
||||
config.pop(k, None)
|
||||
rec['config'] = config
|
||||
return rec
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
|
||||
# ── Connectivity v2 — v1→v2 migration ─────────────────────────────────
|
||||
|
||||
def _migrate_connectivity_v1_to_v2(
|
||||
self, legacy: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build v2 connection records from the legacy connectivity section.
|
||||
|
||||
Called once by config_manager.get_connectivity() when version<2. For
|
||||
each legacy exit type detected as configured (via _exit_status), creates
|
||||
exactly one connection instance with allocated resources, copying the
|
||||
legacy `config` and repointing any existing per-type vault secret to the
|
||||
new conn_<id>_<field> name. Returns [] when nothing was configured.
|
||||
|
||||
Existing secrets are RE-STORED under the new ref name and the old name
|
||||
deleted, so the only reference that survives is the new one — no secret
|
||||
is ever lost (re-store happens before old delete).
|
||||
"""
|
||||
records: List[Dict[str, Any]] = []
|
||||
exits = legacy.get('exits') if isinstance(legacy, dict) else {}
|
||||
exits = exits if isinstance(exits, dict) else {}
|
||||
|
||||
# Legacy per-type vault secret names → instance secret field.
|
||||
legacy_secret_names = {
|
||||
'sshuttle': [
|
||||
('connectivity_sshuttle_key', 'private_key'),
|
||||
('connectivity_sshuttle_password', 'password'),
|
||||
],
|
||||
}
|
||||
|
||||
used_marks, used_tables, used_ifaces, used_ports = set(), set(), set(), set()
|
||||
|
||||
for conn_type in self.CONNECTION_TYPES:
|
||||
status = self._exit_status(conn_type)
|
||||
if not status.get('configured'):
|
||||
continue
|
||||
|
||||
conn_id = self._new_conn_id()
|
||||
|
||||
mark = self.MARK_BASE
|
||||
while mark in used_marks:
|
||||
mark += self.MARK_STRIDE
|
||||
used_marks.add(mark)
|
||||
|
||||
table = self.TABLE_BASE
|
||||
while table in used_tables:
|
||||
table += 1
|
||||
used_tables.add(table)
|
||||
|
||||
iface = None
|
||||
if conn_type in self.IFACE_TYPES:
|
||||
iface = f"{self.IFACE_PREFIXES[conn_type]}{conn_id.split('_')[-1][:8]}"
|
||||
used_ifaces.add(iface)
|
||||
|
||||
redirect_port = None
|
||||
if conn_type in self.REDIRECT_TYPES:
|
||||
port = self.REDIRECT_PORT_BASE
|
||||
while port in used_ports:
|
||||
port += 1
|
||||
redirect_port = port
|
||||
used_ports.add(port)
|
||||
|
||||
legacy_config = exits.get(conn_type)
|
||||
config = dict(legacy_config) if isinstance(legacy_config, dict) else {}
|
||||
# Never let a stray secret hide in the copied non-secret config.
|
||||
for k in ('private_key', 'password', 'conf'):
|
||||
config.pop(k, None)
|
||||
|
||||
secret_refs: List[str] = []
|
||||
for old_name, field in legacy_secret_names.get(conn_type, []):
|
||||
if self.vault_manager is None:
|
||||
continue
|
||||
try:
|
||||
value = self.vault_manager.get_secret(old_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"migration: read {old_name} failed: {e}")
|
||||
value = None
|
||||
if not value:
|
||||
continue
|
||||
new_ref = f"{conn_id}_{field}"
|
||||
try:
|
||||
self.vault_manager.store_secret(new_ref, value)
|
||||
secret_refs.append(new_ref)
|
||||
self.vault_manager.delete_secret(old_name)
|
||||
except Exception as e:
|
||||
logger.error(f"migration: repoint {old_name}→{new_ref} failed: {e}")
|
||||
|
||||
now = self._now_iso()
|
||||
state = self._compute_state(conn_type, config, secret_refs)
|
||||
records.append({
|
||||
'id': conn_id,
|
||||
'type': conn_type,
|
||||
'name': self.DEFAULT_CONNECTION_NAMES.get(conn_type, conn_type),
|
||||
'enabled': True,
|
||||
'mark': mark,
|
||||
'table': table,
|
||||
'iface': iface,
|
||||
'redirect_port': redirect_port,
|
||||
'config': config,
|
||||
'secret_refs': secret_refs,
|
||||
'cell_name': None,
|
||||
'status': {
|
||||
'state': state,
|
||||
'health': 'unknown',
|
||||
'last_check': None,
|
||||
'detail': None,
|
||||
},
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
})
|
||||
logger.info(f"connectivity: migrated legacy {conn_type} exit → {conn_id}")
|
||||
|
||||
return records
|
||||
|
||||
# ── Routing application ───────────────────────────────────────────────
|
||||
|
||||
def apply_routes(self) -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user