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 +

+
+
+ +
+ +
+
+ + setHost(e.target.value)} + placeholder="ssh.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)} + 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" + /> +
+
+ +
+ + 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' ? ( +
+ +