feat: connectivity redesign phase 2 — instance-aware routing + reference connections by id
Unit Tests / test (push) Successful in 12m6s
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:
+20
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user