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:
+411
-16
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}$')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user