feat: connectivity — registry-driven peer table, sshuttle/proxy egress, egress UI

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 08:36:15 -04:00
parent cc7a223fdf
commit 6232ef23a9
8 changed files with 1096 additions and 61 deletions
+411 -16
View File
@@ -3,19 +3,21 @@
Connectivity Manager for Personal Internet Cell — Phase 5 Extended Connectivity. Connectivity Manager for Personal Internet Cell — Phase 5 Extended Connectivity.
Provides per-peer egress routing through alternate exits (WireGuard external, Provides per-peer egress routing through alternate exits (WireGuard external,
OpenVPN, Tor) via Linux policy routing (fwmark + ip rule + dedicated routing OpenVPN, Tor, sshuttle SSH tunnel, upstream proxy) via Linux policy routing
tables) and dedicated iptables chains. (fwmark + ip rule + dedicated routing tables) and dedicated iptables chains.
Architecture Architecture
------------ ------------
- A peer's `exit_via` field selects the egress path: "default", "wireguard_ext", - 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 - Each non-default exit type is assigned a unique fwmark and a dedicated routing
table: table:
wireguard_ext mark 0x10 table 110 iface wg_ext0 wireguard_ext mark 0x10 table 110 iface wg_ext0
openvpn mark 0x20 table 120 iface tun0 openvpn mark 0x20 table 120 iface tun0
tor mark 0x30 table 130 (transparent proxy → 9040) 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` - 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. 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. arbitrary commands on the host.
""" """
import ipaddress
import logging import logging
import os import os
import re import re
@@ -61,24 +64,100 @@ _OVPN_FORBIDDEN_DIRECTIVES = (
_NAME_RE = re.compile(r'^[a-z0-9_-]{1,32}$') _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): class ConnectivityManager(BaseServiceManager):
"""Manages alternate egress paths (extended connectivity) for peers.""" """Manages alternate egress paths (extended connectivity) for peers."""
EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor") EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor",
MARKS = {"wireguard_ext": 0x10, "openvpn": 0x20, "tor": 0x30} "sshuttle", "proxy")
TABLES = {"wireguard_ext": 110, "openvpn": 120, "tor": 130} 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"} IFACES = {"wireguard_ext": "wg_ext0", "openvpn": "tun0"}
TOR_TRANS_PORT = 9040 TOR_TRANS_PORT = 9040
TOR_DNS_PORT = 5353 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' CONNECTIVITY_CHAIN = 'PIC_CONNECTIVITY'
def __init__(self, config_manager=None, peer_registry=None, def __init__(self, config_manager=None, peer_registry=None,
vault_manager=None,
data_dir: str = '/app/data', config_dir: str = '/app/config'): data_dir: str = '/app/data', config_dir: str = '/app/config'):
super().__init__('connectivity', data_dir, config_dir) super().__init__('connectivity', data_dir, config_dir)
self.config_manager = config_manager self.config_manager = config_manager
self.peer_registry = peer_registry self.peer_registry = peer_registry
self.vault_manager = vault_manager
# Connectivity configs live under the per-service data dir so that # Connectivity configs live under the per-service data dir so that
# ${PIC_DATA_DIR}/services/<id>/config bind mounts in store compose # ${PIC_DATA_DIR}/services/<id>/config bind mounts in store compose
@@ -87,8 +166,11 @@ class ConnectivityManager(BaseServiceManager):
services_dir = os.path.join(data_dir, 'services') services_dir = os.path.join(data_dir, 'services')
self.wireguard_ext_dir = os.path.join(services_dir, 'wireguard-ext', 'config') self.wireguard_ext_dir = os.path.join(services_dir, 'wireguard-ext', 'config')
self.openvpn_dir = os.path.join(services_dir, 'openvpn-client', '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) self.safe_makedirs(d)
# One-shot migration from the legacy config_dir/connectivity/ location. # 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)") logger.info(f"connectivity: stored {name}.ovpn ({len(cleaned)} bytes)")
return {'ok': True} 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 ─────────────────────────────────────────────── # ── Routing application ───────────────────────────────────────────────
def apply_routes(self) -> Dict[str, Any]: def apply_routes(self) -> Dict[str, Any]:
@@ -313,7 +652,7 @@ class ConnectivityManager(BaseServiceManager):
logger.warning(f"apply_routes: flush {table}/{chain} failed: {e}") logger.warning(f"apply_routes: flush {table}/{chain} failed: {e}")
# Idempotent ip rule registration for each non-default exit # 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] mark = self.MARKS[exit_type]
table = self.TABLES[exit_type] table = self.TABLES[exit_type]
try: try:
@@ -347,14 +686,15 @@ class ConnectivityManager(BaseServiceManager):
f"apply_routes: mark rule for {src_ip}/{exit_via}: {e}" f"apply_routes: mark rule for {src_ip}/{exit_via}: {e}"
) )
# Tor: redirect TCP to local transparent proxy # Tor / sshuttle / proxy: redirect TCP to the local
if exit_via == 'tor': # transparent-proxy port for that exit.
if exit_via in self.REDIRECT_PORTS:
try: try:
self._add_tor_redirect(src_ip) self._add_redirect(src_ip, self.REDIRECT_PORTS[exit_via])
rules_applied += 1 rules_applied += 1
except Exception as e: except Exception as e:
logger.warning( 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 # Kill-switch: drop marked packets that would otherwise leak via the
@@ -435,14 +775,18 @@ class ConnectivityManager(BaseServiceManager):
'-j', 'MARK', '--set-mark', hex(mark), '-j', 'MARK', '--set-mark', hex(mark),
]) ])
def _add_tor_redirect(self, src_ip: str) -> None: def _add_redirect(self, src_ip: str, port: int) -> None:
"""Redirect peer's TCP traffic to local Tor TransPort.""" """Redirect peer's TCP traffic to a local transparent-proxy port."""
self._wg_iptables([ self._wg_iptables([
'-t', 'nat', '-A', self.CONNECTIVITY_CHAIN, '-t', 'nat', '-A', self.CONNECTIVITY_CHAIN,
'-s', src_ip, '-p', 'tcp', '-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: def _add_killswitch(self, mark: int, iface: Optional[str]) -> None:
"""Drop marked packets that would egress via any interface other than iface. """Drop marked packets that would egress via any interface other than iface.
@@ -462,7 +806,13 @@ class ConnectivityManager(BaseServiceManager):
'!', '-o', iface, '-j', 'DROP']) '!', '-o', iface, '-j', 'DROP'])
def _exit_status(self, exit_type: str) -> Dict[str, Any]: 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} info: Dict[str, Any] = {'configured': False, 'iface_up': False}
if exit_type == 'wireguard_ext': if exit_type == 'wireguard_ext':
path = os.path.join(self.wireguard_ext_dir, 'wg_ext0.conf') path = os.path.join(self.wireguard_ext_dir, 'wg_ext0.conf')
@@ -477,6 +827,18 @@ class ConnectivityManager(BaseServiceManager):
info['configured'] = False info['configured'] = False
elif exit_type == 'tor': elif exit_type == 'tor':
info['configured'] = True # Tor uses defaults; no per-cell config 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) iface = self.IFACES.get(exit_type)
if iface: if iface:
@@ -485,8 +847,41 @@ class ConnectivityManager(BaseServiceManager):
info['iface_up'] = r.returncode == 0 and 'UP' in (r.stdout or '') info['iface_up'] = r.returncode == 0 and 'UP' in (r.stdout or '')
except Exception: except Exception:
info['iface_up'] = False info['iface_up'] = False
if info['iface_up']:
info['status'] = 'active'
elif info['configured']:
info['status'] = 'configured'
else:
info['status'] = 'not_configured'
return info 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]: def _peer_source_ip(self, peer_name: str) -> Optional[str]:
"""Return a peer's WireGuard IP (no /CIDR suffix).""" """Return a peer's WireGuard IP (no /CIDR suffix)."""
if not peer_name or self.peer_registry is None: if not peer_name or self.peer_registry is None:
+21 -9
View File
@@ -19,19 +19,26 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__) 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) # fwmark values — must not collide with ConnectivityManager
MARKS = {"wireguard_ext": 0x110, "openvpn": 0x120, "tor": 0x130} # (0x10, 0x20, 0x30, 0x40, 0x50)
MARKS = {"wireguard_ext": 0x110, "openvpn": 0x120, "tor": 0x130,
"sshuttle": 0x140, "proxy": 0x150}
# Policy routing table IDs # 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" EGRESS_CHAIN = "PIC_EGRESS"
# Transparent proxy port used by Tor # Transparent proxy port used by Tor
_TOR_TRANS_PORT = 9040 _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: class EgressManager:
"""Per-service egress enforcement via host iptables fwmark policy-routing.""" """Per-service egress enforcement via host iptables fwmark policy-routing."""
@@ -94,8 +101,9 @@ class EgressManager:
self._ensure_chains() self._ensure_chains()
self._ensure_host_ip_rules() self._ensure_host_ip_rules()
self._add_mark_rule(container_ip, MARKS[exit_via], service_id) self._add_mark_rule(container_ip, MARKS[exit_via], service_id)
if exit_via == 'tor': if exit_via in _REDIRECT_PORTS:
self._add_tor_redirect(container_ip, service_id) self._add_redirect(container_ip, _REDIRECT_PORTS[exit_via],
service_id)
except Exception as exc: except Exception as exc:
logger.error('apply_service(%s): %s', service_id, exc) logger.error('apply_service(%s): %s', service_id, exc)
return {'ok': False, 'error': str(exc)} return {'ok': False, 'error': str(exc)}
@@ -266,15 +274,19 @@ class EgressManager:
'-m', 'comment', '--comment', self._tag(service_id), '-m', 'comment', '--comment', self._tag(service_id),
]) ])
def _add_tor_redirect(self, service_ip: str, service_id: str) -> None: def _add_redirect(self, service_ip: str, port: int, service_id: str) -> None:
"""Redirect the service container's TCP traffic to the local Tor TransPort.""" """Redirect the container's TCP traffic to a local transparent-proxy port."""
self._iptables([ self._iptables([
'-t', 'nat', '-A', EGRESS_CHAIN, '-t', 'nat', '-A', EGRESS_CHAIN,
'-s', service_ip, '-p', 'tcp', '-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), '-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: def _clear_egress_rules(self, service_id: str) -> None:
"""Remove all rules tagged pic-egr-<service_id> from mangle and nat.""" """Remove all rules tagged pic-egr-<service_id> from mangle and nat."""
import re as _re import re as _re
+2 -1
View File
@@ -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, caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR,
service_bus=service_bus, service_registry=service_registry) service_bus=service_bus, service_registry=service_registry)
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR, 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( connectivity_manager = ConnectivityManager(
config_manager=config_manager, config_manager=config_manager,
peer_registry=peer_registry, peer_registry=peer_registry,
vault_manager=vault_manager,
data_dir=DATA_DIR, data_dir=DATA_DIR,
config_dir=CONFIG_DIR, config_dir=CONFIG_DIR,
) )
+2 -1
View File
@@ -351,7 +351,8 @@ class PeerRegistry(BaseServiceManager):
raise ValueError(f"Peer '{peer_name}' not found") raise ValueError(f"Peer '{peer_name}' not found")
# Phase 5: extended connectivity per-peer egress exit # 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: 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 """Set the per-peer egress exit type. Returns True if updated, False
+2 -2
View File
@@ -186,7 +186,7 @@ class ServiceBus:
'email': ['cell-mail', 'cell-rainloop'], # Email service includes both mail server and web client 'email': ['cell-mail', 'cell-rainloop'], # Email service includes both mail server and web client
'calendar': ['cell-radicale'], 'calendar': ['cell-radicale'],
'files': ['cell-webdav', 'cell-filegator'], # Files service includes both webdav and file manager '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 'routing': None, # Routing is a system service, not a container
'vault': None, # Vault is part of API, not a separate container 'vault': None, # Vault is part of API, not a separate container
'container': None # Container manager doesn't have its own 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 'email': ['cell-mail', 'cell-rainloop'], # Email service includes both mail server and web client
'calendar': ['cell-radicale'], 'calendar': ['cell-radicale'],
'files': ['cell-webdav', 'cell-filegator'], # Files service includes both webdav and file manager '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 'routing': None, # Routing is a system service, not a container
'vault': None, # Vault is part of API, not a separate container 'vault': None, # Vault is part of API, not a separate container
'container': None # Container manager doesn't have its own container 'container': None # Container manager doesn't have its own container
+1 -6
View File
@@ -22,6 +22,7 @@ import json
import requests import requests
from base_service_manager import BaseServiceManager from base_service_manager import BaseServiceManager
from constants import RESERVED_SUBDOMAINS
from manifest_validator import validate_manifest, validate_provision_hook from manifest_validator import validate_manifest, validate_provision_hook
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -57,12 +58,6 @@ TRUSTED_IMAGES_NO_DIGEST = frozenset({
FORBIDDEN_MOUNTS = frozenset([ FORBIDDEN_MOUNTS = frozenset([
'/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot', '/', '/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._@:/+\-= ]*$') ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$') SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$') BACKEND_RE = re.compile(r'^[A-Za-z0-9._-]+:\d{1,5}$')
+646 -23
View File
@@ -10,8 +10,13 @@ import {
Upload, Upload,
ToggleLeft, ToggleLeft,
ToggleRight, ToggleRight,
Layers,
Store,
Server,
Network,
Save,
} from 'lucide-react'; } from 'lucide-react';
import { connectivityAPI, wireguardAPI } from '../services/api'; import { connectivityAPI, egressAPI } from '../services/api';
// Toast helpers (same pattern as Store.jsx) // 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 (
<div className="bg-white rounded-lg border border-gray-200 p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-50 shrink-0">
<Server className="h-5 w-5 text-emerald-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">SSH Tunnel</h3>
<p className="text-sm text-gray-500">
Route traffic through an SSH server via sshuttle
</p>
</div>
</div>
<StatusBadge status={status} />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2 flex flex-col gap-1.5">
<label htmlFor="ssh-host" className="text-sm font-medium text-gray-700">
Host <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="ssh-host"
type="text"
value={host}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="ssh-port" className="text-sm font-medium text-gray-700">
Port
</label>
<input
id="ssh-port"
type="number"
min="1"
max="65535"
value={port}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="ssh-user" className="text-sm font-medium text-gray-700">
User <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="ssh-user"
type="text"
value={user}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-gray-700">Authentication</span>
<div className="flex rounded-md border border-gray-200 overflow-hidden w-fit" role="group" aria-label="Authentication method">
<button
type="button"
onClick={() => setAuth('key')}
aria-pressed={auth === 'key'}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
auth === 'key'
? 'bg-primary-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
Private key
</button>
<button
type="button"
onClick={() => setAuth('password')}
aria-pressed={auth === 'password'}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
auth === 'password'
? 'bg-primary-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
Password
</button>
</div>
</div>
{auth === 'key' ? (
<div className="flex flex-col gap-1.5">
<label htmlFor="ssh-key" className="text-sm font-medium text-gray-700">
Private key <span className="text-red-500" aria-hidden="true">*</span>
</label>
<textarea
id="ssh-key"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={4}
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 resize-y"
aria-describedby="ssh-key-hint"
/>
<p id="ssh-key-hint" className="text-xs text-gray-400">
Stored encrypted in the vault. Never displayed again after saving.
</p>
</div>
) : (
<div className="flex flex-col gap-1.5">
<label htmlFor="ssh-password" className="text-sm font-medium text-gray-700">
Password <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="ssh-password"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<label htmlFor="ssh-known-hosts" className="text-sm font-medium text-gray-700">
Pinned host key (known_hosts line){' '}
<span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="ssh-known-hosts"
type="text"
value={knownHosts}
onChange={(e) => 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"
/>
<p id="ssh-known-hosts-hint" className="text-xs text-gray-400">
Get it with: ssh-keyscan -t ed25519 ssh.example.com
</p>
</div>
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleSave}
disabled={saving || !canSave}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
aria-label="Save SSH tunnel configuration"
>
{saving ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{saving ? 'Saving…' : 'Save Configuration'}
</button>
</div>
</div>
);
}
// 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 (
<div className="bg-white rounded-lg border border-gray-200 p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-sky-50 shrink-0">
<Network className="h-5 w-5 text-sky-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">Upstream Proxy</h3>
<p className="text-sm text-gray-500">
Route traffic through an HTTP or SOCKS5 proxy
</p>
</div>
</div>
<StatusBadge status={status} />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<label htmlFor="proxy-scheme" className="text-sm font-medium text-gray-700">
Scheme
</label>
<div className="relative">
<select
id="proxy-scheme"
value={scheme}
onChange={(e) => setScheme(e.target.value)}
className="w-full appearance-none bg-white border border-gray-300 text-sm text-gray-800 rounded-md pl-3 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="proxy-host" className="text-sm font-medium text-gray-700">
Host <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="proxy-host"
type="text"
value={host}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="proxy-port" className="text-sm font-medium text-gray-700">
Port <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="proxy-port"
type="number"
min="1"
max="65535"
value={port}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label htmlFor="proxy-user" className="text-sm font-medium text-gray-700">
User
</label>
<input
id="proxy-user"
type="text"
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder="optional"
autoComplete="off"
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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="proxy-password" className="text-sm font-medium text-gray-700">
Password
</label>
<input
id="proxy-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="optional"
autoComplete="new-password"
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"
/>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleSave}
disabled={saving || !canSave}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
aria-label="Save proxy configuration"
>
{saving ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{saving ? 'Saving…' : 'Save Configuration'}
</button>
</div>
</div>
);
}
// Peer exit row // Peer exit row
const EXIT_OPTIONS = [ const EXIT_OPTIONS = [
{ value: 'default', label: 'Default (direct)' }, { value: 'default', label: 'Default (direct)' },
{ value: 'wireguard', label: 'WireGuard External' }, { value: 'wireguard_ext', label: 'WireGuard External' },
{ value: 'openvpn', label: 'OpenVPN' }, { value: 'openvpn', label: 'OpenVPN' },
{ value: 'tor', label: 'Tor' }, { value: 'tor', label: 'Tor' },
{ value: 'sshuttle', label: 'SSH Tunnel (sshuttle)' },
{ value: 'proxy', label: 'Proxy (redsocks)' },
]; ];
function PeerExitRow({ peer, currentExit, onSaved }) { 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 (
<tr className="border-t border-gray-100">
<td className="py-3 px-4 text-sm font-medium text-gray-900 truncate max-w-[180px]">
{serviceId}
</td>
<td className="py-3 px-4">
<span className="text-sm text-gray-500">{currentLabel}</span>
</td>
<td className="py-3 px-4">
<div className="relative inline-block">
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="appearance-none bg-white border border-gray-300 text-sm text-gray-800 rounded-md pl-3 pr-8 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
aria-label={`Change egress exit for ${serviceId}`}
>
{exitOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
</td>
<td className="py-3 px-4 text-right">
<button
onClick={handleSave}
disabled={saving || !isDirty}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-md transition-colors ml-auto"
aria-label={`Save egress assignment for ${serviceId}`}
>
{saving && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{saving ? 'Saving…' : 'Save'}
</button>
</td>
</tr>
);
}
// Main Connectivity component // Main Connectivity component
function Connectivity() { function Connectivity() {
@@ -446,22 +920,27 @@ function Connectivity() {
const [exits, setExits] = useState({}); // keyed by exit type const [exits, setExits] = useState({}); // keyed by exit type
const [peerExits, setPeerExits] = useState({}); // peer_name -> exit_via const [peerExits, setPeerExits] = useState({}); // peer_name -> exit_via
const [peers, setPeers] = useState([]); // WireGuard peer list
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const [applying, setApplying] = useState(false); 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 () => { const loadAll = useCallback(async () => {
setLoadError(null); setLoadError(null);
try { try {
const [exitsRes, peerExitsRes, peersRes] = await Promise.all([ const [exitsRes, peerExitsRes] = await Promise.all([
connectivityAPI.listExits().catch(() => ({ data: {} })), connectivityAPI.listExits().catch(() => ({ data: {} })),
connectivityAPI.getPeerExits().catch(() => ({ data: {} })), connectivityAPI.getPeerExits().catch(() => ({ data: {} })),
wireguardAPI.getPeers().catch(() => ({ data: { peers: [] } })),
]); ]);
const exitsData = exitsRes.data || {}; // API returns {exits: [{type, configured, iface_up, status}, ...]}
// API may return array or object normalise to object keyed by type // normalise to an object keyed by exit type.
const exitsData = exitsRes.data?.exits ?? exitsRes.data ?? {};
if (Array.isArray(exitsData)) { if (Array.isArray(exitsData)) {
const map = {}; const map = {};
exitsData.forEach((e) => { map[e.type] = e; }); exitsData.forEach((e) => { map[e.type] = e; });
@@ -470,20 +949,14 @@ function Connectivity() {
setExits(exitsData); 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( setPeerExits(
Array.isArray(peerExitsData) Array.isArray(peerExitsData)
? Object.fromEntries(peerExitsData.map((p) => [p.name, p.exit_via])) ? Object.fromEntries(peerExitsData.map((p) => [p.name, p.exit_via]))
: peerExitsData : peerExitsData
); );
const peersData = peersRes.data;
const peersList = Array.isArray(peersData)
? peersData
: Array.isArray(peersData?.peers)
? peersData.peers
: [];
setPeers(peersList);
} catch (err) { } catch (err) {
const msg = const msg =
err.response?.data?.error || 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(() => { useEffect(() => {
loadAll(); loadAll();
}, [loadAll]); loadEgress();
}, [loadAll, loadEgress]);
const handleApplyRoutes = async () => { const handleApplyRoutes = async () => {
setApplying(true); setApplying(true);
@@ -520,6 +1013,13 @@ function Connectivity() {
setPeerExits((prev) => ({ ...prev, [peerName]: exitVia })); setPeerExits((prev) => ({ ...prev, [peerName]: exitVia }));
}; };
const handleServiceEgressSaved = (serviceId, exitType) => {
setServiceEgress((prev) => ({
...prev,
[serviceId]: { ...prev[serviceId], exit_via: exitType },
}));
};
// Render // Render
return ( return (
@@ -594,7 +1094,7 @@ function Connectivity() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<WireguardExitCard <WireguardExitCard
exitInfo={exits['wireguard'] || exits['wireguard_external']} exitInfo={exits['wireguard_ext']}
onUploaded={loadAll} onUploaded={loadAll}
/> />
<OpenvpnExitCard <OpenvpnExitCard
@@ -605,6 +1105,14 @@ function Connectivity() {
exitInfo={exits['tor']} exitInfo={exits['tor']}
onToggled={loadAll} onToggled={loadAll}
/> />
<SshuttleExitCard
exitInfo={exits['sshuttle']}
onSaved={loadAll}
/>
<ProxyExitCard
exitInfo={exits['proxy']}
onSaved={loadAll}
/>
</div> </div>
{/* Apply Routes */} {/* Apply Routes */}
@@ -642,7 +1150,7 @@ function Connectivity() {
</p> </p>
</div> </div>
{peers.length === 0 ? ( {Object.keys(peerExits).length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 py-12 text-center"> <div className="bg-white rounded-lg border border-gray-200 py-12 text-center">
<Shield className="h-10 w-10 text-gray-300 mx-auto mb-3" /> <Shield className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-medium text-gray-500"> <p className="text-sm font-medium text-gray-500">
@@ -671,11 +1179,11 @@ function Connectivity() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{peers.map((peer) => ( {Object.entries(peerExits).map(([name, exitVia]) => (
<PeerExitRow <PeerExitRow
key={peer.name} key={name}
peer={peer} peer={{ name }}
currentExit={peerExits[peer.name] || 'default'} currentExit={exitVia || 'default'}
onSaved={handlePeerExitSaved} onSaved={handlePeerExitSaved}
/> />
))} ))}
@@ -684,6 +1192,121 @@ function Connectivity() {
</div> </div>
)} )}
</section> </section>
{/* Section 3: Service Egress */}
<section>
<div className="mb-4">
<h2 className="text-base font-semibold text-gray-900">
Service Egress
</h2>
<p className="text-sm text-gray-500">
Route outbound traffic from installed services through a specific
exit. Only services that declare egress support appear here.
</p>
</div>
{/* Egress loading skeleton */}
{egressLoading && (
<div className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-4 bg-gray-100 rounded w-1/2" />
<div className="h-4 bg-gray-100 rounded w-2/5" />
</div>
)}
{/* Egress error */}
{!egressLoading && egressError && (
<div className="bg-red-50 rounded-lg border border-red-200 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">
Failed to load service egress data
</p>
<p className="text-sm text-red-600 mt-1">{egressError}</p>
</div>
<button
onClick={loadEgress}
className="btn-secondary text-sm shrink-0"
>
Retry
</button>
</div>
</div>
)}
{/* Egress content */}
{!egressLoading && !egressError && (() => {
const serviceIds = Object.keys(serviceEgress);
const exitOptions = buildServiceExitOptions(exits);
const hasConfiguredExits = exitOptions.length > 1;
if (serviceIds.length === 0) {
return (
<div className="bg-white rounded-lg border border-gray-200 py-12 text-center">
<Layers className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-medium text-gray-500">
No services with egress support installed
</p>
<p className="text-xs text-gray-400 mt-1">
Install a service from the{' '}
<a
href="/store"
className="text-primary-600 hover:underline"
>
Store
</a>{' '}
that supports egress routing to manage it here.
</p>
</div>
);
}
return (
<>
{!hasConfiguredExits && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3">
<AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5 shrink-0" />
<p className="text-sm text-yellow-800">
No exit tunnels are configured yet. Upload a WireGuard or
OpenVPN config above, or enable Tor, to unlock additional
exit options.
</p>
</div>
)}
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">
Service
</th>
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">
Current Exit
</th>
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">
Change Exit
</th>
<th className="py-3 px-4" />
</tr>
</thead>
<tbody>
{serviceIds.map((svcId) => (
<ServiceEgressRow
key={svcId}
serviceId={svcId}
currentExit={serviceEgress[svcId]?.exit_via || 'default'}
exitOptions={exitOptions}
onSaved={handleServiceEgressSaved}
/>
))}
</tbody>
</table>
</div>
</>
);
})()}
</section>
</div> </div>
)} )}
</div> </div>
+11 -3
View File
@@ -117,9 +117,7 @@ export const networkAPI = {
getDNSRecords: () => api.get('/api/dns/records'), getDNSRecords: () => api.get('/api/dns/records'),
addDNSRecord: (record) => api.post('/api/dns/records', record), addDNSRecord: (record) => api.post('/api/dns/records', record),
removeDNSRecord: (record) => api.delete('/api/dns/records', { data: record }), removeDNSRecord: (record) => api.delete('/api/dns/records', { data: record }),
getDHCPLeases: () => api.get('/api/dhcp/leases'), getDNSOverview: () => api.get('/api/dns/overview'),
addDHCPReservation: (reservation) => api.post('/api/dhcp/reservations', reservation),
removeDHCPReservation: (reservation) => api.delete('/api/dhcp/reservations', { data: reservation }),
getNTPStatus: () => api.get('/api/ntp/status'), getNTPStatus: () => api.get('/api/ntp/status'),
testNetwork: (data) => api.post('/api/network/test', data), testNetwork: (data) => api.post('/api/network/test', data),
}; };
@@ -344,6 +342,7 @@ export const ddnsAPI = {
updateConfig: (data) => api.put('/api/ddns', data), updateConfig: (data) => api.put('/api/ddns', data),
register: () => api.post('/api/ddns/register'), register: () => api.post('/api/ddns/register'),
getStatus: () => api.get('/api/ddns/status'), getStatus: () => api.get('/api/ddns/status'),
syncRecords: () => api.post('/api/ddns/sync'),
}; };
// Setup Wizard API // Setup Wizard API
@@ -353,12 +352,21 @@ export const setupAPI = {
complete: (payload) => api.post('/api/setup/complete', payload), 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 // Connectivity / Exit Routing API
export const connectivityAPI = { export const connectivityAPI = {
getStatus: () => api.get('/api/connectivity/status'), getStatus: () => api.get('/api/connectivity/status'),
listExits: () => api.get('/api/connectivity/exits'), listExits: () => api.get('/api/connectivity/exits'),
uploadWireguard: (conf_text) => api.post('/api/connectivity/exits/wireguard', { conf_text }), 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 }), 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'), applyRoutes: () => api.post('/api/connectivity/exits/apply'),
getPeerExits: () => api.get('/api/connectivity/peers'), getPeerExits: () => api.get('/api/connectivity/peers'),
setPeerExit: (peer_name, exit_via) => api.put(`/api/connectivity/peers/${peer_name}/exit`, { exit_via }), setPeerExit: (peer_name, exit_via) => api.put(`/api/connectivity/peers/${peer_name}/exit`, { exit_via }),