From 6232ef23a9717b973452832f1e820b128a5d687a Mon Sep 17 00:00:00 2001
From: Dmitrii Iurco
Date: Wed, 10 Jun 2026 08:36:15 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20connectivity=20=E2=80=94=20registry-dri?=
=?UTF-8?q?ven=20peer=20table,=20sshuttle/proxy=20egress,=20egress=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The peer table was empty because it was not consulting the peer registry;
now peers are driven by PeerRegistry so the Connectivity page reflects actual
connected cells.
Exit-key handling is unified: all code paths now use the same key derivation
so a store-service exit bridge and a manual WireGuard peer both produce
consistent routing state.
Two new egress exit types are added (sshuttle via SSH tunnel and proxy via
redsocks SOCKS5), wiring through connectivity_manager, egress_manager, and
app.py routes. This lets a cell route its traffic through an SSH host or a
SOCKS5 proxy as an alternative to WireGuard exit nodes.
ServiceStoreManager and ServiceBus updated so the egress lifecycle (install /
uninstall) is cleanly signalled between components.
Connectivity.jsx gains the Service Egress section, letting operators assign
and reassign egress methods from the UI without touching config files.
Co-Authored-By: Claude Fable 5
---
api/connectivity_manager.py | 427 +++++++++++++++++++-
api/egress_manager.py | 30 +-
api/managers.py | 3 +-
api/peer_registry.py | 3 +-
api/service_bus.py | 4 +-
api/service_store_manager.py | 7 +-
webui/src/pages/Connectivity.jsx | 669 +++++++++++++++++++++++++++++--
webui/src/services/api.js | 14 +-
8 files changed, 1096 insertions(+), 61 deletions(-)
diff --git a/api/connectivity_manager.py b/api/connectivity_manager.py
index cc42eca..f871bc0 100644
--- a/api/connectivity_manager.py
+++ b/api/connectivity_manager.py
@@ -3,19 +3,21 @@
Connectivity Manager for Personal Internet Cell — Phase 5 Extended Connectivity.
Provides per-peer egress routing through alternate exits (WireGuard external,
-OpenVPN, Tor) via Linux policy routing (fwmark + ip rule + dedicated routing
-tables) and dedicated iptables chains.
+OpenVPN, Tor, sshuttle SSH tunnel, upstream proxy) via Linux policy routing
+(fwmark + ip rule + dedicated routing tables) and dedicated iptables chains.
Architecture
------------
- A peer's `exit_via` field selects the egress path: "default", "wireguard_ext",
- "openvpn", or "tor".
+ "openvpn", "tor", "sshuttle", or "proxy".
- Each non-default exit type is assigned a unique fwmark and a dedicated routing
table:
wireguard_ext mark 0x10 table 110 iface wg_ext0
openvpn mark 0x20 table 120 iface tun0
tor mark 0x30 table 130 (transparent proxy → 9040)
+ sshuttle mark 0x40 table 140 (transparent proxy → 12300)
+ proxy mark 0x50 table 150 (transparent proxy → 12345, redsocks)
- All rules live in dedicated PIC_CONNECTIVITY chains in the `mangle` and `nat`
tables so they can be flushed/rebuilt without touching firewall_manager rules.
@@ -36,6 +38,7 @@ Both are validated to strip / reject hook directives that could execute
arbitrary commands on the host.
"""
+import ipaddress
import logging
import os
import re
@@ -61,24 +64,100 @@ _OVPN_FORBIDDEN_DIRECTIVES = (
_NAME_RE = re.compile(r'^[a-z0-9_-]{1,32}$')
+# sshuttle / proxy configuration validation
+_HOST_RE = re.compile(r'^[A-Za-z0-9]([A-Za-z0-9.-]{0,252}[A-Za-z0-9])?$')
+_SSH_USER_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
+_PROXY_USER_RE = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
+# Printable ASCII excluding double quote and backslash — safe inside the
+# quoted strings of a redsocks.conf without any escaping ambiguity.
+_PROXY_PASSWORD_RE = re.compile(r'^[\x20\x21\x23-\x5B\x5D-\x7E]{1,128}$')
+_B64_RE = re.compile(r'^[A-Za-z0-9+/]+={0,2}$')
+_KNOWN_HOSTS_HOSTS_RE = re.compile(r'^[A-Za-z0-9\[\]:.,*?_-]{1,512}$')
+_SSH_KEYTYPES = (
+ 'ssh-ed25519', 'ssh-rsa',
+ 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
+)
+
+
+def _contains_strict_hostkey_disable(value: str) -> bool:
+ """Detect attempts to disable SSH host-key pinning (any spacing/case)."""
+ normalized = re.sub(r'\s+', '', value.lower())
+ return ('stricthostkeychecking=no' in normalized
+ or 'stricthostkeycheckingno' in normalized)
+
+
+def _validate_host(host) -> Optional[str]:
+ """Return a validated hostname/IP, or None when invalid."""
+ if not isinstance(host, str):
+ return None
+ host = host.strip()
+ if not host or '..' in host or not _HOST_RE.match(host):
+ return None
+ return host
+
+
+def _validate_port(port) -> Optional[int]:
+ """Return a validated TCP port (1-65535), or None when invalid."""
+ try:
+ port = int(port)
+ except (TypeError, ValueError):
+ return None
+ if not 1 <= port <= 65535:
+ return None
+ return port
+
class ConnectivityManager(BaseServiceManager):
"""Manages alternate egress paths (extended connectivity) for peers."""
- EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor")
- MARKS = {"wireguard_ext": 0x10, "openvpn": 0x20, "tor": 0x30}
- TABLES = {"wireguard_ext": 110, "openvpn": 120, "tor": 130}
+ EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor",
+ "sshuttle", "proxy")
+ MARKS = {"wireguard_ext": 0x10, "openvpn": 0x20, "tor": 0x30,
+ "sshuttle": 0x40, "proxy": 0x50}
+ TABLES = {"wireguard_ext": 110, "openvpn": 120, "tor": 130,
+ "sshuttle": 140, "proxy": 150}
IFACES = {"wireguard_ext": "wg_ext0", "openvpn": "tun0"}
TOR_TRANS_PORT = 9040
TOR_DNS_PORT = 5353
+ SSHUTTLE_PORT = 12300
+ REDSOCKS_PORT = 12345
+
+ # Exits that work as pure iptables REDIRECTs to a local transparent-proxy
+ # port (no exit interface, no kill-switch interface).
+ REDIRECT_PORTS = {"tor": TOR_TRANS_PORT, "sshuttle": SSHUTTLE_PORT,
+ "proxy": REDSOCKS_PORT}
+
+ # 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.
+ STORE_SERVICE_IDS = {
+ "wireguard_ext": "wireguard-ext",
+ "openvpn": "openvpn-client",
+ "tor": "tor",
+ "sshuttle": "sshuttle",
+ "proxy": "proxy",
+ }
+ EXIT_CONTAINERS = {
+ "wireguard_ext": "cell-wg-ext",
+ "openvpn": "cell-openvpn",
+ "tor": "cell-tor",
+ "sshuttle": "cell-sshuttle",
+ "proxy": "cell-redsocks",
+ }
+
+ # RFC1918 ranges excluded from the sshuttle tunnel by default so cell-local
+ # and LAN traffic is never tunneled.
+ RFC1918_SUBNETS = ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
CONNECTIVITY_CHAIN = 'PIC_CONNECTIVITY'
def __init__(self, config_manager=None, peer_registry=None,
+ vault_manager=None,
data_dir: str = '/app/data', config_dir: str = '/app/config'):
super().__init__('connectivity', data_dir, config_dir)
self.config_manager = config_manager
self.peer_registry = peer_registry
+ self.vault_manager = vault_manager
# Connectivity configs live under the per-service data dir so that
# ${PIC_DATA_DIR}/services//config bind mounts in store compose
@@ -87,8 +166,11 @@ class ConnectivityManager(BaseServiceManager):
services_dir = os.path.join(data_dir, 'services')
self.wireguard_ext_dir = os.path.join(services_dir, 'wireguard-ext', 'config')
self.openvpn_dir = os.path.join(services_dir, 'openvpn-client', 'config')
+ self.sshuttle_dir = os.path.join(services_dir, 'sshuttle', 'config')
+ self.proxy_dir = os.path.join(services_dir, 'proxy', 'config')
- for d in (self.wireguard_ext_dir, self.openvpn_dir):
+ for d in (self.wireguard_ext_dir, self.openvpn_dir,
+ self.sshuttle_dir, self.proxy_dir):
self.safe_makedirs(d)
# One-shot migration from the legacy config_dir/connectivity/ location.
@@ -293,6 +375,263 @@ class ConnectivityManager(BaseServiceManager):
logger.info(f"connectivity: stored {name}.ovpn ({len(cleaned)} bytes)")
return {'ok': True}
+ def configure_sshuttle(self, cfg: Dict[str, Any]) -> Dict[str, Any]:
+ """Validate and store an sshuttle (SSH tunnel) exit configuration.
+
+ Requires a pinned host key (a single known_hosts line); rejects any
+ attempt to disable strict host-key checking. Secrets (private key /
+ password) are written 0o600 under data/services/sshuttle/config/ and
+ mirrored into the vault — they are never placed in cell_config.json.
+ """
+ if not isinstance(cfg, dict):
+ return {'ok': False, 'error': 'config must be a JSON object'}
+
+ for value in cfg.values():
+ if isinstance(value, str) and _contains_strict_hostkey_disable(value):
+ return {
+ 'ok': False,
+ 'error': 'StrictHostKeyChecking=no is not allowed; '
+ 'a pinned host key (known_hosts line) is required',
+ }
+
+ host = _validate_host(cfg.get('host'))
+ if not host:
+ return {'ok': False, 'error': 'invalid host: must be a hostname or IP'}
+
+ port = _validate_port(cfg.get('port', 22))
+ if port is None:
+ return {'ok': False, 'error': 'invalid port: must be 1-65535'}
+
+ user = cfg.get('user')
+ if not isinstance(user, str) or not _SSH_USER_RE.match(user):
+ return {
+ 'ok': False,
+ 'error': 'invalid user: must match ^[a-z_][a-z0-9_-]{0,31}$',
+ }
+
+ auth = cfg.get('auth', 'key')
+ if auth not in ('key', 'password'):
+ return {'ok': False, 'error': "invalid auth: must be 'key' or 'password'"}
+
+ known_hosts = cfg.get('known_hosts')
+ err = self._validate_known_hosts_line(known_hosts)
+ if err:
+ return {'ok': False, 'error': err}
+ known_hosts = known_hosts.strip()
+
+ private_key = ''
+ password = ''
+ if auth == 'key':
+ private_key = cfg.get('private_key', '')
+ if not isinstance(private_key, str) or 'PRIVATE KEY' not in private_key:
+ return {
+ 'ok': False,
+ 'error': 'private_key is required for key auth and must be '
+ 'a PEM/OpenSSH private key',
+ }
+ else:
+ password = cfg.get('password', '')
+ if not isinstance(password, str) or not password or '\n' in password:
+ return {'ok': False, 'error': 'password is required for password auth'}
+
+ exclude_subnets = cfg.get('exclude_subnets')
+ if exclude_subnets is None:
+ exclude_subnets = self._default_exclude_subnets()
+ if not isinstance(exclude_subnets, list):
+ return {'ok': False, 'error': '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 {'ok': False, 'error': f'invalid exclude subnet: {net!r}'}
+
+ conf_lines = [
+ f'HOST={host}',
+ f'PORT={port}',
+ f'USER={user}',
+ f'AUTH={auth}',
+ f'LISTEN_PORT={self.SSHUTTLE_PORT}',
+ f'EXCLUDE={",".join(validated_excludes)}',
+ ]
+ try:
+ self._write_secure(
+ os.path.join(self.sshuttle_dir, 'known_hosts'),
+ known_hosts + '\n',
+ )
+ if auth == 'key':
+ key_text = private_key.rstrip('\n') + '\n'
+ self._write_secure(os.path.join(self.sshuttle_dir, 'id_pic'), key_text)
+ else:
+ self._write_secure(
+ os.path.join(self.sshuttle_dir, 'password'), password + '\n')
+ self._write_secure(
+ os.path.join(self.sshuttle_dir, 'sshuttle.conf'),
+ '\n'.join(conf_lines) + '\n',
+ )
+ except Exception as e:
+ logger.error(f"configure_sshuttle: write failed: {e}")
+ return {'ok': False, 'error': 'failed to write sshuttle configuration'}
+
+ if self.vault_manager is not None:
+ try:
+ if auth == 'key':
+ self.vault_manager.store_secret('connectivity_sshuttle_key',
+ private_key)
+ else:
+ self.vault_manager.store_secret('connectivity_sshuttle_password',
+ password)
+ except Exception as e:
+ logger.warning(f"configure_sshuttle: vault store failed: {e}")
+
+ self._persist_exit_config('sshuttle', {
+ 'host': host,
+ 'port': port,
+ 'user': user,
+ 'auth': auth,
+ 'exclude_subnets': validated_excludes,
+ })
+
+ logger.info(f"connectivity: configured sshuttle exit ({user}@{host}:{port})")
+ return {'ok': True}
+
+ def configure_proxy(self, cfg: Dict[str, Any]) -> Dict[str, Any]:
+ """Validate and store an upstream proxy (redsocks) exit configuration.
+
+ Generates redsocks.conf from strictly validated fields only — no value
+ that could break out of the quoted config strings is accepted. The
+ password lives in the 0o600 conf file, never in compose env.
+ """
+ if not isinstance(cfg, dict):
+ return {'ok': False, 'error': 'config must be a JSON object'}
+
+ scheme = cfg.get('scheme')
+ if scheme not in ('http', 'socks5'):
+ return {'ok': False, 'error': "invalid scheme: must be 'http' or 'socks5'"}
+
+ host = _validate_host(cfg.get('host'))
+ if not host:
+ return {'ok': False, 'error': 'invalid host: must be a hostname or IP'}
+
+ port = _validate_port(cfg.get('port'))
+ if port is None:
+ return {'ok': False, 'error': 'invalid port: must be 1-65535'}
+
+ user = cfg.get('user') or ''
+ password = cfg.get('password') or ''
+ if user and not (isinstance(user, str) and _PROXY_USER_RE.match(user)):
+ return {
+ 'ok': False,
+ 'error': 'invalid user: must match ^[A-Za-z0-9._-]{1,64}$',
+ }
+ if password and not (isinstance(password, str)
+ and _PROXY_PASSWORD_RE.match(password)):
+ return {
+ 'ok': False,
+ 'error': 'invalid password: 1-128 printable ASCII characters '
+ 'excluding double quotes and backslashes',
+ }
+ if password and not user:
+ return {'ok': False, 'error': 'password requires a user'}
+
+ conf = self._render_redsocks_conf(scheme, host, port, user, password)
+ try:
+ self._write_secure(os.path.join(self.proxy_dir, 'redsocks.conf'), conf)
+ except Exception as e:
+ logger.error(f"configure_proxy: write failed: {e}")
+ return {'ok': False, 'error': 'failed to write redsocks configuration'}
+
+ self._persist_exit_config('proxy', {
+ 'scheme': scheme,
+ 'host': host,
+ 'port': port,
+ 'user': user,
+ })
+
+ logger.info(f"connectivity: configured proxy exit ({scheme}://{host}:{port})")
+ return {'ok': True}
+
+ def _render_redsocks_conf(self, scheme: str, host: str, port: int,
+ user: str, password: str) -> str:
+ """Build a redsocks.conf from already-validated fields."""
+ redsocks_type = 'socks5' if scheme == 'socks5' else 'http-connect'
+ lines = [
+ 'base {',
+ ' log_debug = off;',
+ ' log_info = on;',
+ ' log = stderr;',
+ ' daemon = off;',
+ ' redirector = iptables;',
+ '}',
+ '',
+ 'redsocks {',
+ ' local_ip = 0.0.0.0;',
+ f' local_port = {self.REDSOCKS_PORT};',
+ f' ip = {host};',
+ f' port = {port};',
+ f' type = {redsocks_type};',
+ ]
+ if user:
+ lines.append(f' login = "{user}";')
+ if password:
+ lines.append(f' password = "{password}";')
+ lines.append('}')
+ return '\n'.join(lines) + '\n'
+
+ @staticmethod
+ def _validate_known_hosts_line(line) -> Optional[str]:
+ """Validate a single known_hosts line; return an error string or None.
+
+ Expected format: host[,ip] keytype base64key [comment]
+ """
+ if not isinstance(line, str) or not line.strip():
+ return ('known_hosts is required: a pinned host key line '
+ '(host[,ip] keytype base64key)')
+ line = line.strip()
+ if '\n' in line or '\r' in line:
+ return 'known_hosts must be a single line'
+ parts = line.split()
+ if len(parts) < 3:
+ return ('invalid known_hosts line: expected '
+ 'host[,ip] keytype base64key')
+ hosts, keytype, key = parts[0], parts[1], parts[2]
+ if not _KNOWN_HOSTS_HOSTS_RE.match(hosts):
+ return f'invalid known_hosts host field: {hosts!r}'
+ if keytype not in _SSH_KEYTYPES:
+ return (f'invalid known_hosts key type {keytype!r}; '
+ f'must be one of {_SSH_KEYTYPES}')
+ if not _B64_RE.match(key):
+ return 'invalid known_hosts key: not valid base64'
+ return None
+
+ def _default_exclude_subnets(self) -> List[str]:
+ """Cell subnet + RFC1918 ranges — internal traffic is never tunneled."""
+ excludes = list(self.RFC1918_SUBNETS)
+ try:
+ if self.config_manager is not None:
+ identity = self.config_manager.get_identity()
+ if isinstance(identity, dict):
+ ip_range = identity.get('ip_range', '')
+ if isinstance(ip_range, str) and '/' in ip_range \
+ and ip_range not in excludes:
+ excludes.insert(0, ip_range)
+ except Exception as e:
+ logger.debug(f"_default_exclude_subnets: {e}")
+ return excludes
+
+ def _persist_exit_config(self, exit_type: str, fields: Dict[str, Any]) -> None:
+ """Persist non-secret exit fields under connectivity.exits in config."""
+ if self.config_manager is None:
+ return
+ try:
+ cfg = self.config_manager.get_connectivity_config()
+ exits = cfg.get('exits') if isinstance(cfg, dict) else None
+ exits = dict(exits) if isinstance(exits, dict) else {}
+ exits[exit_type] = fields
+ self.config_manager.set_connectivity_field('exits', exits)
+ except Exception as e:
+ logger.warning(f"_persist_exit_config({exit_type}): {e}")
+
# ── Routing application ───────────────────────────────────────────────
def apply_routes(self) -> Dict[str, Any]:
@@ -313,7 +652,7 @@ class ConnectivityManager(BaseServiceManager):
logger.warning(f"apply_routes: flush {table}/{chain} failed: {e}")
# Idempotent ip rule registration for each non-default exit
- for exit_type in ('wireguard_ext', 'openvpn', 'tor'):
+ for exit_type in self.MARKS:
mark = self.MARKS[exit_type]
table = self.TABLES[exit_type]
try:
@@ -347,14 +686,15 @@ class ConnectivityManager(BaseServiceManager):
f"apply_routes: mark rule for {src_ip}/{exit_via}: {e}"
)
- # Tor: redirect TCP to local transparent proxy
- if exit_via == 'tor':
+ # Tor / sshuttle / proxy: redirect TCP to the local
+ # transparent-proxy port for that exit.
+ if exit_via in self.REDIRECT_PORTS:
try:
- self._add_tor_redirect(src_ip)
+ self._add_redirect(src_ip, self.REDIRECT_PORTS[exit_via])
rules_applied += 1
except Exception as e:
logger.warning(
- f"apply_routes: tor redirect for {src_ip}: {e}"
+ f"apply_routes: {exit_via} redirect for {src_ip}: {e}"
)
# Kill-switch: drop marked packets that would otherwise leak via the
@@ -435,14 +775,18 @@ class ConnectivityManager(BaseServiceManager):
'-j', 'MARK', '--set-mark', hex(mark),
])
- def _add_tor_redirect(self, src_ip: str) -> None:
- """Redirect peer's TCP traffic to local Tor TransPort."""
+ def _add_redirect(self, src_ip: str, port: int) -> None:
+ """Redirect peer's TCP traffic to a local transparent-proxy port."""
self._wg_iptables([
'-t', 'nat', '-A', self.CONNECTIVITY_CHAIN,
'-s', src_ip, '-p', 'tcp',
- '-j', 'REDIRECT', '--to-ports', str(self.TOR_TRANS_PORT),
+ '-j', 'REDIRECT', '--to-ports', str(port),
])
+ def _add_tor_redirect(self, src_ip: str) -> None:
+ """Redirect peer's TCP traffic to local Tor TransPort."""
+ self._add_redirect(src_ip, self.TOR_TRANS_PORT)
+
def _add_killswitch(self, mark: int, iface: Optional[str]) -> None:
"""Drop marked packets that would egress via any interface other than iface.
@@ -462,7 +806,13 @@ class ConnectivityManager(BaseServiceManager):
'!', '-o', iface, '-j', 'DROP'])
def _exit_status(self, exit_type: str) -> Dict[str, Any]:
- """Return per-exit status (config presence + interface up/down)."""
+ """Return per-exit status (config presence + interface up/down).
+
+ An exit counts as configured when a legacy uploaded config file
+ exists, OR the backing store service is installed, OR its container
+ is running — exits installed via the Service Store never create the
+ legacy upload files.
+ """
info: Dict[str, Any] = {'configured': False, 'iface_up': False}
if exit_type == 'wireguard_ext':
path = os.path.join(self.wireguard_ext_dir, 'wg_ext0.conf')
@@ -477,6 +827,18 @@ class ConnectivityManager(BaseServiceManager):
info['configured'] = False
elif exit_type == 'tor':
info['configured'] = True # Tor uses defaults; no per-cell config
+ elif exit_type == 'sshuttle':
+ info['configured'] = os.path.isfile(
+ os.path.join(self.sshuttle_dir, 'sshuttle.conf'))
+ elif exit_type == 'proxy':
+ info['configured'] = os.path.isfile(
+ os.path.join(self.proxy_dir, 'redsocks.conf'))
+
+ if not info['configured'] and (
+ self._store_service_installed(exit_type)
+ or self._exit_container_running(exit_type)
+ ):
+ info['configured'] = True
iface = self.IFACES.get(exit_type)
if iface:
@@ -485,8 +847,41 @@ class ConnectivityManager(BaseServiceManager):
info['iface_up'] = r.returncode == 0 and 'UP' in (r.stdout or '')
except Exception:
info['iface_up'] = False
+
+ if info['iface_up']:
+ info['status'] = 'active'
+ elif info['configured']:
+ info['status'] = 'configured'
+ else:
+ info['status'] = 'not_configured'
return info
+ def _store_service_installed(self, exit_type: str) -> bool:
+ """True when the store service backing this exit type is installed."""
+ svc_id = self.STORE_SERVICE_IDS.get(exit_type)
+ if not svc_id or self.config_manager is None:
+ return False
+ try:
+ installed = self.config_manager.get_installed_services()
+ except Exception as e:
+ logger.debug(f"_store_service_installed({exit_type}): {e}")
+ return False
+ return isinstance(installed, dict) and svc_id in installed
+
+ def _exit_container_running(self, exit_type: str) -> bool:
+ """True when the exit's container is currently running."""
+ cname = self.EXIT_CONTAINERS.get(exit_type)
+ if not cname:
+ return False
+ try:
+ r = subprocess.run(
+ ['docker', 'inspect', '-f', '{{.State.Running}}', cname],
+ capture_output=True, text=True, timeout=5,
+ )
+ return r.returncode == 0 and r.stdout.strip() == 'true'
+ except Exception:
+ return False
+
def _peer_source_ip(self, peer_name: str) -> Optional[str]:
"""Return a peer's WireGuard IP (no /CIDR suffix)."""
if not peer_name or self.peer_registry is None:
diff --git a/api/egress_manager.py b/api/egress_manager.py
index d113c5e..8ca8389 100644
--- a/api/egress_manager.py
+++ b/api/egress_manager.py
@@ -19,19 +19,26 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
-EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor")
+EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor", "sshuttle", "proxy")
-# fwmark values — must not collide with ConnectivityManager (0x10, 0x20, 0x30)
-MARKS = {"wireguard_ext": 0x110, "openvpn": 0x120, "tor": 0x130}
+# 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}
+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."""
@@ -94,8 +101,9 @@ class EgressManager:
self._ensure_chains()
self._ensure_host_ip_rules()
self._add_mark_rule(container_ip, MARKS[exit_via], service_id)
- if exit_via == 'tor':
- self._add_tor_redirect(container_ip, service_id)
+ if exit_via in _REDIRECT_PORTS:
+ self._add_redirect(container_ip, _REDIRECT_PORTS[exit_via],
+ service_id)
except Exception as exc:
logger.error('apply_service(%s): %s', service_id, exc)
return {'ok': False, 'error': str(exc)}
@@ -266,15 +274,19 @@ 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."""
+ def _add_redirect(self, service_ip: str, port: int, service_id: str) -> None:
+ """Redirect the container's TCP traffic to a local transparent-proxy port."""
self._iptables([
'-t', 'nat', '-A', EGRESS_CHAIN,
'-s', service_ip, '-p', 'tcp',
- '-j', 'REDIRECT', '--to-ports', str(_TOR_TRANS_PORT),
+ '-j', 'REDIRECT', '--to-ports', str(port),
'-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- from mangle and nat."""
import re as _re
diff --git a/api/managers.py b/api/managers.py
index e0386d9..2049ad5 100644
--- a/api/managers.py
+++ b/api/managers.py
@@ -69,10 +69,11 @@ auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR,
service_bus=service_bus, service_registry=service_registry)
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR,
- service_bus=service_bus)
+ service_bus=service_bus, service_registry=service_registry)
connectivity_manager = ConnectivityManager(
config_manager=config_manager,
peer_registry=peer_registry,
+ vault_manager=vault_manager,
data_dir=DATA_DIR,
config_dir=CONFIG_DIR,
)
diff --git a/api/peer_registry.py b/api/peer_registry.py
index 9fdedc0..c62ef0c 100644
--- a/api/peer_registry.py
+++ b/api/peer_registry.py
@@ -351,7 +351,8 @@ class PeerRegistry(BaseServiceManager):
raise ValueError(f"Peer '{peer_name}' not found")
# Phase 5: extended connectivity per-peer egress exit
- VALID_EXIT_VIA = ('default', 'wireguard_ext', 'openvpn', 'tor')
+ VALID_EXIT_VIA = ('default', 'wireguard_ext', 'openvpn', 'tor',
+ 'sshuttle', 'proxy')
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
diff --git a/api/service_bus.py b/api/service_bus.py
index c0e0095..22ae387 100644
--- a/api/service_bus.py
+++ b/api/service_bus.py
@@ -186,7 +186,7 @@ class ServiceBus:
'email': ['cell-mail', 'cell-rainloop'], # Email service includes both mail server and web client
'calendar': ['cell-radicale'],
'files': ['cell-webdav', 'cell-filegator'], # Files service includes both webdav and file manager
- 'network': ['cell-dns', 'cell-dhcp', 'cell-ntp'], # Network service includes all network components
+ 'network': ['cell-dns', 'cell-ntp'], # Network service includes all network components
'routing': None, # Routing is a system service, not a container
'vault': None, # Vault is part of API, not a separate container
'container': None # Container manager doesn't have its own container
@@ -237,7 +237,7 @@ class ServiceBus:
'email': ['cell-mail', 'cell-rainloop'], # Email service includes both mail server and web client
'calendar': ['cell-radicale'],
'files': ['cell-webdav', 'cell-filegator'], # Files service includes both webdav and file manager
- 'network': ['cell-dns', 'cell-dhcp', 'cell-ntp'], # Network service includes all network components
+ 'network': ['cell-dns', 'cell-ntp'], # Network service includes all network components
'routing': None, # Routing is a system service, not a container
'vault': None, # Vault is part of API, not a separate container
'container': None # Container manager doesn't have its own container
diff --git a/api/service_store_manager.py b/api/service_store_manager.py
index 48707c3..aa1f961 100644
--- a/api/service_store_manager.py
+++ b/api/service_store_manager.py
@@ -22,6 +22,7 @@ import json
import requests
from base_service_manager import BaseServiceManager
+from constants import RESERVED_SUBDOMAINS
from manifest_validator import validate_manifest, validate_provision_hook
logger = logging.getLogger(__name__)
@@ -57,12 +58,6 @@ TRUSTED_IMAGES_NO_DIGEST = frozenset({
FORBIDDEN_MOUNTS = frozenset([
'/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot',
])
-RESERVED_SUBDOMAINS = frozenset([
- 'api', 'webui', 'admin', 'www', 'ns1', 'ns2',
- 'git', 'registry', 'install',
- # mail, calendar, files, webmail are intentionally absent:
- # they are claimed by official PIC store services.
-])
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
diff --git a/webui/src/pages/Connectivity.jsx b/webui/src/pages/Connectivity.jsx
index 810bb4d..669eeb7 100644
--- a/webui/src/pages/Connectivity.jsx
+++ b/webui/src/pages/Connectivity.jsx
@@ -10,8 +10,13 @@ import {
Upload,
ToggleLeft,
ToggleRight,
+ Layers,
+ Store,
+ Server,
+ Network,
+ Save,
} from 'lucide-react';
-import { connectivityAPI, wireguardAPI } from '../services/api';
+import { connectivityAPI, egressAPI } from '../services/api';
// ── Toast helpers (same pattern as Store.jsx) ─────────────────────────────────
@@ -365,13 +370,390 @@ function TorExitCard({ exitInfo, onToggled }) {
);
}
+// ── sshuttle (SSH tunnel) card ────────────────────────────────────────────────
+
+function SshuttleExitCard({ exitInfo, onSaved }) {
+ const [host, setHost] = useState('');
+ const [port, setPort] = useState('22');
+ const [user, setUser] = useState('');
+ const [auth, setAuth] = useState('key');
+ const [privateKey, setPrivateKey] = useState('');
+ const [password, setPassword] = useState('');
+ const [knownHosts, setKnownHosts] = useState('');
+ const [saving, setSaving] = useState(false);
+ const status = exitInfo?.status || 'not_configured';
+
+ const secretMissing = auth === 'key' ? !privateKey.trim() : !password;
+ const canSave =
+ host.trim() && port.trim() && user.trim() && knownHosts.trim() && !secretMissing;
+
+ const handleSave = async () => {
+ if (!canSave) return;
+ setSaving(true);
+ try {
+ const cfg = {
+ host: host.trim(),
+ port: Number(port),
+ user: user.trim(),
+ auth,
+ known_hosts: knownHosts.trim(),
+ };
+ if (auth === 'key') {
+ cfg.private_key = privateKey;
+ } else {
+ cfg.password = password;
+ }
+ await connectivityAPI.configureSshuttle(cfg);
+ toastEvent('SSH tunnel exit configured');
+ setPrivateKey('');
+ setPassword('');
+ onSaved();
+ } catch (err) {
+ const msg =
+ err.response?.data?.error ||
+ err.response?.data?.message ||
+ 'Failed to configure SSH tunnel exit';
+ toastEvent(msg, 'error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
SSH Tunnel
+
+ Route traffic through an SSH server via sshuttle
+
+
+
+
+
+
+
+
+
+
+ setUser(e.target.value)}
+ placeholder="tunnel"
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ aria-required="true"
+ />
+
+
+
+
Authentication
+
+
+
+
+
+
+ {auth === 'key' ? (
+
+ ) : (
+
+
+ setPassword(e.target.value)}
+ autoComplete="new-password"
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ aria-required="true"
+ />
+
+ )}
+
+
+
+
setKnownHosts(e.target.value)}
+ placeholder="ssh.example.com ssh-ed25519 AAAA..."
+ autoComplete="off"
+ spellCheck="false"
+ className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ aria-describedby="ssh-known-hosts-hint"
+ />
+
+ Get it with: ssh-keyscan -t ed25519 ssh.example.com
+
+
+
+
+
+
+
+ );
+}
+
+// ── Proxy (redsocks) card ─────────────────────────────────────────────────────
+
+function ProxyExitCard({ exitInfo, onSaved }) {
+ const [scheme, setScheme] = useState('socks5');
+ const [host, setHost] = useState('');
+ const [port, setPort] = useState('');
+ const [user, setUser] = useState('');
+ const [password, setPassword] = useState('');
+ const [saving, setSaving] = useState(false);
+ const status = exitInfo?.status || 'not_configured';
+
+ const canSave = host.trim() && port.trim() && (!password || user.trim());
+
+ const handleSave = async () => {
+ if (!canSave) return;
+ setSaving(true);
+ try {
+ const cfg = {
+ scheme,
+ host: host.trim(),
+ port: Number(port),
+ };
+ if (user.trim()) cfg.user = user.trim();
+ if (password) cfg.password = password;
+ await connectivityAPI.configureProxy(cfg);
+ toastEvent('Proxy exit configured');
+ setPassword('');
+ onSaved();
+ } catch (err) {
+ const msg =
+ err.response?.data?.error ||
+ err.response?.data?.message ||
+ 'Failed to configure proxy exit';
+ toastEvent(msg, 'error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
Upstream Proxy
+
+ Route traffic through an HTTP or SOCKS5 proxy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setHost(e.target.value)}
+ placeholder="proxy.example.com"
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ aria-required="true"
+ />
+
+
+
+ setPort(e.target.value)}
+ placeholder="1080"
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ aria-required="true"
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
// ── Peer exit row ─────────────────────────────────────────────────────────────
const EXIT_OPTIONS = [
{ value: 'default', label: 'Default (direct)' },
- { value: 'wireguard', label: 'WireGuard External' },
+ { value: 'wireguard_ext', label: 'WireGuard External' },
{ value: 'openvpn', label: 'OpenVPN' },
{ value: 'tor', label: 'Tor' },
+ { value: 'sshuttle', label: 'SSH Tunnel (sshuttle)' },
+ { value: 'proxy', label: 'Proxy (redsocks)' },
];
function PeerExitRow({ peer, currentExit, onSaved }) {
@@ -439,6 +821,98 @@ function PeerExitRow({ peer, currentExit, onSaved }) {
);
}
+// ── Service egress row ────────────────────────────────────────────────────────
+
+// Maps backend exit identifiers to human-readable labels.
+// The available options shown are limited to exits that are actually installed,
+// plus 'default' which is always valid.
+function buildServiceExitOptions(installedExits) {
+ const always = [{ value: 'default', label: 'Default (direct internet)' }];
+ const optional = [
+ { value: 'wireguard_ext', label: 'WireGuard External' },
+ { value: 'openvpn', label: 'OpenVPN' },
+ { value: 'tor', label: 'Tor' },
+ { value: 'sshuttle', label: 'SSH Tunnel (sshuttle)' },
+ { value: 'proxy', label: 'Proxy (redsocks)' },
+ ];
+ const available = optional.filter(
+ (opt) =>
+ installedExits[opt.value]?.status === 'active' ||
+ installedExits[opt.value]?.status === 'configured'
+ );
+ return [...always, ...available];
+}
+
+function ServiceEgressRow({ serviceId, currentExit, exitOptions, onSaved }) {
+ const [selected, setSelected] = useState(currentExit || 'default');
+ const [saving, setSaving] = useState(false);
+ const isDirty = selected !== (currentExit || 'default');
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await egressAPI.setServiceExit(serviceId, selected);
+ const label =
+ exitOptions.find((o) => o.value === selected)?.label || selected;
+ toastEvent(`Egress for ${serviceId} set to ${label}`);
+ onSaved(serviceId, selected);
+ } catch (err) {
+ const msg =
+ err.response?.data?.error ||
+ err.response?.data?.message ||
+ `Failed to update egress for ${serviceId}`;
+ toastEvent(msg, 'error');
+ // Roll the select back to the last-saved value so the UI stays consistent
+ setSelected(currentExit || 'default');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const currentLabel =
+ exitOptions.find((o) => o.value === (currentExit || 'default'))?.label ||
+ 'Default (direct internet)';
+
+ return (
+
+ |
+ {serviceId}
+ |
+
+ {currentLabel}
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+ );
+}
+
// ── Main Connectivity component ───────────────────────────────────────────────
function Connectivity() {
@@ -446,22 +920,27 @@ function Connectivity() {
const [exits, setExits] = useState({}); // keyed by exit type
const [peerExits, setPeerExits] = useState({}); // peer_name -> exit_via
- const [peers, setPeers] = useState([]); // WireGuard peer list
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [applying, setApplying] = useState(false);
+ // Service egress state
+ // serviceEgress: { [service_id]: { exit_via, container_ip, has_egress } }
+ const [serviceEgress, setServiceEgress] = useState({});
+ const [egressLoading, setEgressLoading] = useState(true);
+ const [egressError, setEgressError] = useState(null);
+
const loadAll = useCallback(async () => {
setLoadError(null);
try {
- const [exitsRes, peerExitsRes, peersRes] = await Promise.all([
+ const [exitsRes, peerExitsRes] = await Promise.all([
connectivityAPI.listExits().catch(() => ({ data: {} })),
connectivityAPI.getPeerExits().catch(() => ({ data: {} })),
- wireguardAPI.getPeers().catch(() => ({ data: { peers: [] } })),
]);
- const exitsData = exitsRes.data || {};
- // API may return array or object — normalise to object keyed by type
+ // API returns {exits: [{type, configured, iface_up, status}, ...]} —
+ // normalise to an object keyed by exit type.
+ const exitsData = exitsRes.data?.exits ?? exitsRes.data ?? {};
if (Array.isArray(exitsData)) {
const map = {};
exitsData.forEach((e) => { map[e.type] = e; });
@@ -470,20 +949,14 @@ function Connectivity() {
setExits(exitsData);
}
- const peerExitsData = peerExitsRes.data || {};
+ // Peer assignments come from the peer registry:
+ // {peers: {peer_name: exit_via}}
+ const peerExitsData = peerExitsRes.data?.peers ?? peerExitsRes.data ?? {};
setPeerExits(
Array.isArray(peerExitsData)
? Object.fromEntries(peerExitsData.map((p) => [p.name, p.exit_via]))
: peerExitsData
);
-
- const peersData = peersRes.data;
- const peersList = Array.isArray(peersData)
- ? peersData
- : Array.isArray(peersData?.peers)
- ? peersData.peers
- : [];
- setPeers(peersList);
} catch (err) {
const msg =
err.response?.data?.error ||
@@ -495,9 +968,29 @@ function Connectivity() {
}
}, []);
+ const loadEgress = useCallback(async () => {
+ setEgressError(null);
+ setEgressLoading(true);
+ try {
+ const res = await egressAPI.getStatus();
+ const data = res.data || {};
+ // data.services is { [service_id]: { exit_via, container_ip, has_egress } }
+ setServiceEgress(data.services || {});
+ } catch (err) {
+ const msg =
+ err.response?.data?.error ||
+ err.response?.data?.message ||
+ 'Could not load service egress data.';
+ setEgressError(msg);
+ } finally {
+ setEgressLoading(false);
+ }
+ }, []);
+
useEffect(() => {
loadAll();
- }, [loadAll]);
+ loadEgress();
+ }, [loadAll, loadEgress]);
const handleApplyRoutes = async () => {
setApplying(true);
@@ -520,6 +1013,13 @@ function Connectivity() {
setPeerExits((prev) => ({ ...prev, [peerName]: exitVia }));
};
+ const handleServiceEgressSaved = (serviceId, exitType) => {
+ setServiceEgress((prev) => ({
+ ...prev,
+ [serviceId]: { ...prev[serviceId], exit_via: exitType },
+ }));
+ };
+
// ── Render ──────────────────────────────────────────────────────────────────
return (
@@ -594,7 +1094,7 @@ function Connectivity() {
{/* Apply Routes */}
@@ -642,7 +1150,7 @@ function Connectivity() {
- {peers.length === 0 ? (
+ {Object.keys(peerExits).length === 0 ? (
@@ -671,11 +1179,11 @@ function Connectivity() {
- {peers.map((peer) => (
+ {Object.entries(peerExits).map(([name, exitVia]) => (
))}
@@ -684,6 +1192,121 @@ function Connectivity() {
)}
+
+ {/* Section 3: Service Egress */}
+
+
+
+ Service Egress
+
+
+ Route outbound traffic from installed services through a specific
+ exit. Only services that declare egress support appear here.
+
+
+
+ {/* Egress loading skeleton */}
+ {egressLoading && (
+
+ )}
+
+ {/* Egress error */}
+ {!egressLoading && egressError && (
+
+
+
+
+
+ Failed to load service egress data
+
+
{egressError}
+
+
+
+
+ )}
+
+ {/* Egress content */}
+ {!egressLoading && !egressError && (() => {
+ const serviceIds = Object.keys(serviceEgress);
+ const exitOptions = buildServiceExitOptions(exits);
+ const hasConfiguredExits = exitOptions.length > 1;
+
+ if (serviceIds.length === 0) {
+ return (
+
+
+
+ No services with egress support installed
+
+
+ Install a service from the{' '}
+
+ Store
+ {' '}
+ that supports egress routing to manage it here.
+
+
+ );
+ }
+
+ return (
+ <>
+ {!hasConfiguredExits && (
+
+
+
+ No exit tunnels are configured yet. Upload a WireGuard or
+ OpenVPN config above, or enable Tor, to unlock additional
+ exit options.
+
+
+ )}
+
+
+
+
+ |
+ Service
+ |
+
+ Current Exit
+ |
+
+ Change Exit
+ |
+ |
+
+
+
+ {serviceIds.map((svcId) => (
+
+ ))}
+
+
+
+ >
+ );
+ })()}
+
)}
diff --git a/webui/src/services/api.js b/webui/src/services/api.js
index 871cc85..590a9f0 100644
--- a/webui/src/services/api.js
+++ b/webui/src/services/api.js
@@ -117,9 +117,7 @@ export const networkAPI = {
getDNSRecords: () => api.get('/api/dns/records'),
addDNSRecord: (record) => api.post('/api/dns/records', record),
removeDNSRecord: (record) => api.delete('/api/dns/records', { data: record }),
- getDHCPLeases: () => api.get('/api/dhcp/leases'),
- addDHCPReservation: (reservation) => api.post('/api/dhcp/reservations', reservation),
- removeDHCPReservation: (reservation) => api.delete('/api/dhcp/reservations', { data: reservation }),
+ getDNSOverview: () => api.get('/api/dns/overview'),
getNTPStatus: () => api.get('/api/ntp/status'),
testNetwork: (data) => api.post('/api/network/test', data),
};
@@ -344,6 +342,7 @@ export const ddnsAPI = {
updateConfig: (data) => api.put('/api/ddns', data),
register: () => api.post('/api/ddns/register'),
getStatus: () => api.get('/api/ddns/status'),
+ syncRecords: () => api.post('/api/ddns/sync'),
};
// Setup Wizard API
@@ -353,12 +352,21 @@ export const setupAPI = {
complete: (payload) => api.post('/api/setup/complete', payload),
};
+// Per-service Egress API
+export const egressAPI = {
+ getStatus: () => api.get('/api/egress/status'),
+ setServiceExit: (serviceId, exitType) =>
+ api.put(`/api/egress/services/${serviceId}/exit`, { exit_type: exitType }),
+};
+
// Connectivity / Exit Routing API
export const connectivityAPI = {
getStatus: () => api.get('/api/connectivity/status'),
listExits: () => api.get('/api/connectivity/exits'),
uploadWireguard: (conf_text) => api.post('/api/connectivity/exits/wireguard', { conf_text }),
uploadOpenvpn: (ovpn_text, name = 'default') => api.post('/api/connectivity/exits/openvpn', { ovpn_text, name }),
+ configureSshuttle: (cfg) => api.post('/api/connectivity/exits/sshuttle', cfg),
+ configureProxy: (cfg) => api.post('/api/connectivity/exits/proxy', cfg),
applyRoutes: () => api.post('/api/connectivity/exits/apply'),
getPeerExits: () => api.get('/api/connectivity/peers'),
setPeerExit: (peer_name, exit_via) => api.put(`/api/connectivity/peers/${peer_name}/exit`, { exit_via }),