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
+20 -10
View File
@@ -907,13 +907,18 @@ def connectivity_apply_routes():
@app.route('/api/connectivity/peers/<peer_name>/exit', methods=['PUT'])
def connectivity_set_peer_exit(peer_name: str):
"""Assign a peer to an egress exit type."""
"""Assign a peer to a connection by id (or 'default' to clear).
Body: {"connection_id": "<id>|default"}. The legacy {"exit_via": "<type>"}
field is still accepted as a one-release back-compat shim and resolved to
the single connection instance of that type.
"""
try:
data = request.get_json(silent=True) or {}
exit_via = data.get('exit_via')
if not isinstance(exit_via, str):
return jsonify({'ok': False, 'error': 'exit_via is required'}), 400
result = connectivity_manager.set_peer_exit(peer_name, exit_via)
connection_id = data.get('connection_id', data.get('exit_via'))
if not isinstance(connection_id, str) or not connection_id:
return jsonify({'ok': False, 'error': 'connection_id is required'}), 400
result = connectivity_manager.set_peer_exit(peer_name, connection_id)
if result.get('ok'):
return jsonify(result)
return jsonify(result), 400
@@ -995,13 +1000,18 @@ def egress_status():
@app.route('/api/egress/services/<service_id>/exit', methods=['PUT'])
def egress_set_service_exit(service_id: str):
"""Persist and immediately apply a per-service egress override."""
"""Persist and immediately apply a per-service egress override.
Body: {"connection_id": "<id>|default"}. The legacy {"exit_type": "<type>"}
field is still accepted as a one-release back-compat shim and resolved to
the single connection instance of that type.
"""
try:
data = request.get_json(silent=True) or {}
exit_type = data.get('exit_type')
if not isinstance(exit_type, str):
return jsonify({'ok': False, 'error': 'exit_type is required'}), 400
result = egress_manager.set_service_exit(service_id, exit_type)
connection_id = data.get('connection_id', data.get('exit_type'))
if not isinstance(connection_id, str) or not connection_id:
return jsonify({'ok': False, 'error': 'connection_id is required'}), 400
result = egress_manager.set_service_exit(service_id, connection_id)
if result.get('ok'):
return jsonify(result)
return jsonify(result), 400
+127 -46
View File
@@ -357,14 +357,16 @@ class ConnectivityManager(BaseServiceManager):
logger.warning(f"get_peer_exits: {e}")
return out
def set_peer_exit(self, peer_name: str, exit_type: str) -> Dict[str, Any]:
"""Assign a peer to an egress path and apply the rule changes."""
if exit_type not in self.EXIT_TYPES:
return {
'ok': False,
'error': f"invalid exit_type {exit_type!r}; "
f"must be one of {self.EXIT_TYPES}",
}
def set_peer_exit(self, peer_name: str, exit_via: str) -> Dict[str, Any]:
"""Assign a peer to a connection (by id) or 'default' and apply rules.
`exit_via` is a connection id, 'default', or — as a one-release
back-compat shim — a legacy exit *type* string, which is resolved to
the single connection instance of that type. Validation that the id
exists lives in peer_registry.set_peer_exit_via.
"""
if not isinstance(exit_via, str) or not exit_via:
return {'ok': False, 'error': 'connection_id is required'}
if not isinstance(peer_name, str) or not re.match(r'^[A-Za-z0-9_.-]{1,64}$', peer_name):
return {'ok': False, 'error': f'invalid peer_name {peer_name!r}'}
@@ -372,11 +374,16 @@ class ConnectivityManager(BaseServiceManager):
return {'ok': False, 'error': 'peer_registry not available'}
try:
ok = self.peer_registry.set_peer_exit_via(peer_name, exit_type)
ok = self.peer_registry.set_peer_exit_via(peer_name, exit_via)
except Exception as e:
logger.error(f"set_peer_exit: registry update failed: {e}")
return {'ok': False, 'error': str(e)}
if not ok:
# Distinguish "no such peer" from "no such connection".
if self._peer_exists(peer_name):
return {'ok': False, 'error':
f'unknown connection {exit_via!r}; '
f"must be a connection id or 'default'"}
return {'ok': False, 'error': f'peer {peer_name!r} not found'}
try:
@@ -384,7 +391,23 @@ class ConnectivityManager(BaseServiceManager):
except Exception as e:
logger.warning(f"set_peer_exit: apply_routes failed (non-fatal): {e}")
return {'ok': True, 'peer': peer_name, 'exit_via': exit_type}
resolved = 'default'
try:
peer = self.peer_registry.get_peer(peer_name)
if peer:
resolved = peer.get('exit_via', 'default')
except Exception:
pass
return {'ok': True, 'peer': peer_name, 'exit_via': resolved}
def _peer_exists(self, peer_name: str) -> bool:
"""True when a peer with this name is registered."""
if self.peer_registry is None:
return False
try:
return self.peer_registry.get_peer(peer_name) is not None
except Exception:
return False
def upload_wireguard_ext(self, conf_text: str) -> Dict[str, Any]:
"""Validate and store an external WireGuard config."""
@@ -1121,17 +1144,26 @@ class ConnectivityManager(BaseServiceManager):
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.
A peer references a connection through its exit_via field (a connection
id); a service references one through the egress_overrides map. Either
blocks deletion until the reference is detached.
"""
if self.peer_registry is not None:
try:
for peer in self.peer_registry.list_peers():
if peer.get('connection_id') == conn_id:
if peer.get('exit_via') == conn_id:
return f"peer {peer.get('peer')!r}"
except Exception as e:
logger.debug(f"_connection_reference (peers): {e}")
if self.config_manager is not None:
try:
overrides = self.config_manager.configs.get('egress_overrides')
if isinstance(overrides, dict):
for svc_id, cid in overrides.items():
if cid == conn_id:
return f"service {svc_id!r}"
except Exception as e:
logger.debug(f"_connection_reference (egress): {e}")
return None
def list_connections(self) -> List[Dict[str, Any]]:
@@ -1303,7 +1335,14 @@ class ConnectivityManager(BaseServiceManager):
# ── Routing application ───────────────────────────────────────────────
def apply_routes(self) -> Dict[str, Any]:
"""Idempotently rebuild all connectivity rules and policy routing."""
"""Idempotently rebuild all connectivity rules and policy routing.
Connectivity v2: routing is driven by connection *instances*, not by
per-type constants. Each connection carries its own persisted mark,
table, iface and redirect_port; two instances of the same type route
through distinct tables/marks without collision. A peer's exit_via is
the id of the connection it egresses through.
"""
rules_applied = 0
try:
@@ -1319,18 +1358,23 @@ class ConnectivityManager(BaseServiceManager):
except Exception as e:
logger.warning(f"apply_routes: flush {table}/{chain} failed: {e}")
# Idempotent ip rule registration for each non-default exit
for exit_type in self.MARKS:
mark = self.MARKS[exit_type]
table = self.TABLES[exit_type]
connections = self._routing_connections()
# Idempotent ip rule registration: one fwmark→table rule per instance.
for conn in connections:
mark, table = conn.get('mark'), conn.get('table')
if not isinstance(mark, int) or not isinstance(table, int):
continue
try:
self._remove_ip_rule(mark, table)
self._add_ip_rule(mark, table)
rules_applied += 1
except Exception as e:
logger.warning(f"apply_routes: ip rule {exit_type} failed: {e}")
logger.warning(
f"apply_routes: ip rule {conn.get('id')} failed: {e}")
# Per-peer marking + nat redirect (Tor only)
# Per-peer marking + nat redirect, resolved through each peer's
# connection instance.
if self.peer_registry is not None:
try:
peers = self.peer_registry.list_peers()
@@ -1338,45 +1382,82 @@ class ConnectivityManager(BaseServiceManager):
logger.warning(f"apply_routes: list_peers failed: {e}")
peers = []
by_id = {c.get('id'): c for c in connections}
for peer in peers:
exit_via = peer.get('exit_via', 'default')
if exit_via == 'default' or exit_via not in self.MARKS:
conn = self._resolve_peer_connection(peer, by_id)
if conn is None:
continue
src_ip = self._peer_source_ip(peer.get('peer', ''))
if not src_ip:
continue
mark = self.MARKS[exit_via]
try:
self._add_mark_rule(src_ip, mark)
rules_applied += 1
except Exception as e:
logger.warning(
f"apply_routes: mark rule for {src_ip}/{exit_via}: {e}"
)
# Tor / sshuttle / proxy: redirect TCP to the local
# transparent-proxy port for that exit.
if exit_via in self.REDIRECT_PORTS:
try:
self._add_redirect(src_ip, self.REDIRECT_PORTS[exit_via])
rules_applied += 1
except Exception as e:
logger.warning(
f"apply_routes: {exit_via} redirect for {src_ip}: {e}"
)
rules_applied += self._apply_connection_for_src(src_ip, conn)
# Kill-switch: drop marked packets that would otherwise leak via the
# default route if the exit interface is down.
for exit_type, iface in self.IFACES.items():
mark = self.MARKS[exit_type]
# default route if an iface-based exit interface is down.
for conn in connections:
iface = conn.get('iface')
mark = conn.get('mark')
if not iface or not isinstance(mark, int):
continue
try:
self._add_killswitch(mark, iface)
rules_applied += 1
except Exception as e:
logger.warning(f"apply_routes: killswitch {exit_type}: {e}")
logger.warning(
f"apply_routes: killswitch {conn.get('id')}: {e}")
return {'ok': True, 'rules_applied': rules_applied}
def _routing_connections(self) -> List[Dict[str, Any]]:
"""Return the connection instances that drive routing (enabled only)."""
if self.config_manager is None:
return []
try:
conns = self.config_manager.list_connections()
except Exception as e:
logger.warning(f"apply_routes: list_connections failed: {e}")
return []
return [c for c in conns if c.get('enabled', True)]
@staticmethod
def _resolve_peer_connection(
peer: Dict[str, Any], by_id: Dict[str, Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""Resolve a peer's exit_via (a connection id) to its connection record."""
exit_via = peer.get('exit_via', 'default')
if exit_via == 'default':
return None
return by_id.get(exit_via)
def _apply_connection_for_src(
self, src_ip: str, conn: Dict[str, Any],
) -> int:
"""Mark + optionally REDIRECT traffic from src_ip via this connection.
Returns the number of rules applied. iface-based connections only need
the fwmark (policy route + killswitch handle egress); redirect-style
connections additionally REDIRECT TCP to the instance's redirect_port.
"""
applied = 0
mark = conn.get('mark')
if isinstance(mark, int):
try:
self._add_mark_rule(src_ip, mark)
applied += 1
except Exception as e:
logger.warning(
f"apply_routes: mark rule for {src_ip}/{conn.get('id')}: {e}")
redirect_port = conn.get('redirect_port')
if conn.get('type') in self.REDIRECT_TYPES and isinstance(redirect_port, int):
try:
self._add_redirect(src_ip, redirect_port)
applied += 1
except Exception as e:
logger.warning(
f"apply_routes: redirect for {src_ip}/{conn.get('id')}: {e}")
return applied
# ── iptables / ip rule helpers ────────────────────────────────────────
def _wg_iptables(self, args: List[str], timeout: int = 10) -> subprocess.CompletedProcess:
+143 -78
View File
@@ -9,8 +9,13 @@ for install/remove lifecycle hooks.
Rules live on the HOST in PIC_EGRESS chains in the mangle and nat
tables. Container IPs are discovered via docker inspect using the
container_name from the service manifest. Marks are distinct from
ConnectivityManager to prevent rule collisions.
container_name from the service manifest.
Connectivity v2: a service routes through a *connection instance* (by id),
sharing the same fwmark / routing table / redirect port as any peer that
egresses through the same connection. The (mark, table, redirect_port) for a
service are resolved from ConnectivityManager.get_connection(id) EgressManager
no longer owns its own per-type MARKS/TABLES tables.
"""
import logging
import subprocess
@@ -19,34 +24,18 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor", "sshuttle", "proxy")
# fwmark values — must not collide with ConnectivityManager
# (0x10, 0x20, 0x30, 0x40, 0x50)
MARKS = {"wireguard_ext": 0x110, "openvpn": 0x120, "tor": 0x130,
"sshuttle": 0x140, "proxy": 0x150}
# Policy routing table IDs
TABLES = {"wireguard_ext": 210, "openvpn": 220, "tor": 230,
"sshuttle": 240, "proxy": 250}
EGRESS_CHAIN = "PIC_EGRESS"
# Transparent proxy port used by Tor
_TOR_TRANS_PORT = 9040
# Local transparent-proxy ports for redirect-style exits (no exit iface):
# traffic is REDIRECTed to the listener of the corresponding exit container.
_REDIRECT_PORTS = {"tor": _TOR_TRANS_PORT, "sshuttle": 12300, "proxy": 12345}
class EgressManager:
"""Per-service egress enforcement via host iptables fwmark policy-routing."""
def __init__(self, config_manager, service_store_manager=None,
connectivity_manager=None,
data_dir: str = "/app/data", config_dir: str = "/app/config"):
self.config_manager = config_manager
self.service_store_manager = service_store_manager
self.connectivity_manager = connectivity_manager
self._data_dir = data_dir
self._config_dir = config_dir
@@ -60,9 +49,10 @@ class EgressManager:
2. clear_service first (ensures idempotency).
3. If the manifest has no egress block, skip silently.
4. Discover the container IP.
5. Resolve the exit type (override > manifest default > 'default').
6. If exit is 'default', return early with no rules.
7. Otherwise create chains, ensure ip rules, add mark rules.
5. Resolve the connection id (override > manifest default > 'default').
6. If 'default', return early with no rules.
7. Otherwise resolve the connection's (mark, table, redirect_port),
create chains, ensure ip rules, add mark/redirect rules.
"""
manifest = self._get_manifest(service_id)
if manifest is None:
@@ -79,36 +69,39 @@ class EgressManager:
if not container_ip:
return {'ok': False, 'error': 'container IP not discoverable'}
exit_via = self._resolve_exit(service_id, manifest)
connection_id = self._resolve_exit(service_id, manifest)
# Validate exit_via is a known, non-default value
if exit_via not in EXIT_TYPES:
return {
'ok': False,
'error': f'unknown exit_via {exit_via!r}; must be one of {EXIT_TYPES}',
}
if exit_via == 'default':
if connection_id == 'default':
return {'ok': True, 'exit_via': 'default'}
if exit_via not in MARKS:
conn = self._get_connection(connection_id)
if conn is None:
return {
'ok': False,
'error': f'unknown exit_via {exit_via!r}; must be one of {EXIT_TYPES}',
'error': f'unknown connection {connection_id!r}',
}
mark = conn.get('mark')
table = conn.get('table')
if not isinstance(mark, int) or not isinstance(table, int):
return {
'ok': False,
'error': f'connection {connection_id!r} has no routing resources',
}
try:
self._ensure_chains()
self._ensure_host_ip_rules()
self._add_mark_rule(container_ip, MARKS[exit_via], service_id)
if exit_via in _REDIRECT_PORTS:
self._add_redirect(container_ip, _REDIRECT_PORTS[exit_via],
service_id)
self._ensure_host_ip_rule(mark, table)
self._add_mark_rule(container_ip, mark, service_id)
redirect_port = conn.get('redirect_port')
if isinstance(redirect_port, int):
self._add_redirect(container_ip, redirect_port, service_id)
except Exception as exc:
logger.error('apply_service(%s): %s', service_id, exc)
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'exit_via': exit_via, 'container_ip': container_ip}
return {'ok': True, 'exit_via': connection_id,
'container_ip': container_ip}
def clear_service(self, service_id: str) -> Dict[str, Any]:
"""Remove all PIC_EGRESS rules tagged for this service."""
@@ -129,10 +122,13 @@ class EgressManager:
results[svc_id] = self.apply_service(svc_id)
return {'ok': True, 'services': results}
def set_service_exit(self, service_id: str, exit_type: str) -> Dict[str, Any]:
"""Persist a per-service egress override and immediately reapply rules.
def set_service_exit(self, service_id: str, connection_id: str) -> Dict[str, Any]:
"""Persist a per-service egress override (by connection id) and reapply.
exit_type must appear in the manifest's egress.allowed list.
`connection_id` is a real connection id or 'default'. A legacy exit
*type* string is accepted as a one-release back-compat shim and resolved
to the single connection instance of that type. The resolved
connection's type must be in the manifest's egress.allowed list.
"""
manifest = self._get_manifest(service_id)
if manifest is None:
@@ -141,31 +137,91 @@ class EgressManager:
if not self._has_egress(manifest):
return {'ok': False, 'error': f'service {service_id!r} has no egress configuration'}
if connection_id == 'default':
overrides = self._get_egress_overrides()
overrides[service_id] = 'default'
self._set_egress_overrides(overrides)
return self.apply_service(service_id)
resolved = self._resolve_connection_id(connection_id)
if resolved is None:
return {
'ok': False,
'error': f"unknown connection {connection_id!r}; "
f"must be a connection id or 'default'",
}
conn = self._get_connection(resolved)
egress = manifest.get('egress', {})
allowed = egress.get('allowed', list(EXIT_TYPES))
if exit_type not in allowed:
return {
'ok': False,
'error': (
f'exit_type {exit_type!r} is not in the allowed list '
f'for {service_id}: {allowed}'
),
}
if exit_type not in EXIT_TYPES:
return {
'ok': False,
'error': f'unknown exit_type {exit_type!r}; must be one of {EXIT_TYPES}',
}
allowed = egress.get('allowed')
if isinstance(allowed, list) and conn is not None:
if conn.get('type') not in allowed:
return {
'ok': False,
'error': (
f"connection type {conn.get('type')!r} is not in the "
f'allowed list for {service_id}: {allowed}'
),
}
# Persist the override so it survives restarts
overrides = self._get_egress_overrides()
overrides[service_id] = exit_type
overrides[service_id] = resolved
self._set_egress_overrides(overrides)
return self.apply_service(service_id)
def _connections(self) -> List[dict]:
"""Return the v2 connection records, or [] when unavailable."""
if self.connectivity_manager is not None:
try:
conns = self.connectivity_manager.list_connections()
return conns if isinstance(conns, list) else []
except Exception as exc:
logger.warning('egress: list_connections failed: %s', exc)
return []
if self.config_manager is not None:
try:
conns = self.config_manager.list_connections()
return conns if isinstance(conns, list) else []
except Exception as exc:
logger.warning('egress: list_connections failed: %s', exc)
return []
def _get_connection(self, connection_id: str) -> Optional[dict]:
"""Resolve a connection record (with mark/table/redirect_port) by id."""
if self.connectivity_manager is not None:
try:
return self.connectivity_manager.get_connection(connection_id)
except Exception as exc:
logger.warning('egress: get_connection failed: %s', exc)
return None
if self.config_manager is not None:
try:
return self.config_manager.get_connection(connection_id)
except Exception as exc:
logger.warning('egress: get_connection failed: %s', exc)
return None
_LEGACY_EXIT_TYPES = ('wireguard_ext', 'openvpn', 'tor', 'sshuttle', 'proxy')
def _resolve_connection_id(self, value: str) -> Optional[str]:
"""Resolve a value to a valid connection id.
Accepts 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 nothing matches.
"""
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 get_status(self) -> Dict[str, Any]:
"""Return egress status for every installed service that has egress config."""
installed = self.config_manager.get_installed_services()
@@ -201,15 +257,26 @@ class EgressManager:
return bool(manifest.get('has_egress', False) and manifest.get('egress'))
def _resolve_exit(self, service_id: str, manifest: dict) -> str:
"""Determine the effective exit for a service.
"""Determine the effective connection id for a service.
Priority: persisted override > manifest egress.default > 'default'.
Legacy type strings (from old overrides or a manifest default) are
resolved to the single connection instance of that type; if that can't
be resolved the service falls back to 'default'.
"""
overrides = self._get_egress_overrides()
if service_id in overrides:
return overrides[service_id]
egress = manifest.get('egress') or {}
return egress.get('default', 'default')
value = overrides[service_id]
else:
egress = manifest.get('egress') or {}
value = egress.get('default', 'default')
if value == 'default':
return 'default'
if value in self._LEGACY_EXIT_TYPES:
resolved = self._resolve_connection_id(value)
return resolved if resolved is not None else 'default'
return value
def _discover_container_ip(self, container_name: str,
retries: int = 5, delay: float = 0.2) -> Optional[str]:
@@ -254,16 +321,18 @@ class EgressManager:
['-t', table, '-I', 'PREROUTING', '1', '-j', EGRESS_CHAIN]
)
def _ensure_host_ip_rules(self) -> None:
"""Ensure `ip rule fwmark <mark> lookup <table>` exists for each exit."""
for exit_type, mark in MARKS.items():
table = TABLES[exit_type]
# Remove any existing duplicate rules first, then add once
for _ in range(8):
r = self._ip_rule(['del', 'fwmark', hex(mark), 'lookup', str(table)])
if r.returncode != 0:
break
self._ip_rule(['add', 'fwmark', hex(mark), 'lookup', str(table)])
def _ensure_host_ip_rule(self, mark: int, table: int) -> None:
"""Ensure a single `ip rule fwmark <mark> lookup <table>` exists.
Idempotent: drains any duplicate rules first, then adds exactly one.
The mark/table belong to the connection instance the service routes
through, so a peer and a service on the same connection share the rule.
"""
for _ in range(8):
r = self._ip_rule(['del', 'fwmark', hex(mark), 'lookup', str(table)])
if r.returncode != 0:
break
self._ip_rule(['add', 'fwmark', hex(mark), 'lookup', str(table)])
def _add_mark_rule(self, service_ip: str, mark: int, service_id: str) -> None:
"""Mark outbound packets from the service container with fwmark."""
@@ -283,10 +352,6 @@ class EgressManager:
'-m', 'comment', '--comment', self._tag(service_id),
])
def _add_tor_redirect(self, service_ip: str, service_id: str) -> None:
"""Redirect the service container's TCP traffic to the local Tor TransPort."""
self._add_redirect(service_ip, _TOR_TRANS_PORT, service_id)
def _clear_egress_rules(self, service_id: str) -> None:
"""Remove all rules tagged pic-egr-<service_id> from mangle and nat."""
import re as _re
+3 -1
View File
@@ -53,7 +53,8 @@ service_registry = ServiceRegistry(config_manager=config_manager)
network_manager = NetworkManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR,
service_registry=service_registry)
wireguard_manager = WireGuardManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
peer_registry = PeerRegistry(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
peer_registry = PeerRegistry(data_dir=DATA_DIR, config_dir=CONFIG_DIR,
config_manager=config_manager)
email_manager = EmailManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus)
calendar_manager = CalendarManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
file_manager = FileManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
@@ -102,6 +103,7 @@ from egress_manager import EgressManager
egress_manager = EgressManager(
config_manager=config_manager,
service_store_manager=service_store_manager,
connectivity_manager=connectivity_manager,
data_dir=DATA_DIR,
config_dir=CONFIG_DIR,
)
+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(