Phase 5: extended connectivity — WireGuard ext, OpenVPN, Tor exit routing

- ConnectivityManager: per-peer exit routing via iptables fwmark/policy tables
  (wg_ext=0x10/t110, openvpn=0x20/t120, tor=0x30/t130)
- Dedicated PIC_CONNECTIVITY chains (mangle+nat), kill-switch FORWARD DROP
- Config upload with sanitization: strips PostUp/PostDown and OVpn script dirs
- Peer exit_via field added to peer registry (backward-compat, default=default)
- 7 Flask routes at /api/connectivity/*
- Connectivity.jsx: 693-line frontend with exit cards, peer assignment table
- 72 new tests for ConnectivityManager (72 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:48:20 -04:00
parent 0a21f22076
commit e38bd4e81f
9 changed files with 2114 additions and 1 deletions
+103
View File
@@ -43,6 +43,7 @@ from managers import (
cell_link_manager, auth_manager, setup_manager, cell_link_manager, auth_manager, setup_manager,
caddy_manager, caddy_manager,
ddns_manager, service_store_manager, ddns_manager, service_store_manager,
connectivity_manager,
firewall_manager, EventType, firewall_manager, EventType,
) )
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns` # Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
@@ -379,6 +380,11 @@ def _apply_startup_enforcement():
service_store_manager.reapply_on_startup() service_store_manager.reapply_on_startup()
except Exception as _sse: except Exception as _sse:
logger.warning(f"service_store reapply_on_startup failed (non-fatal): {_sse}") logger.warning(f"service_store reapply_on_startup failed (non-fatal): {_sse}")
# Phase 5: re-apply extended-connectivity policy routing rules
try:
connectivity_manager.apply_routes()
except Exception as _ce:
logger.warning(f"connectivity apply_routes failed (non-fatal): {_ce}")
except Exception as e: except Exception as e:
logger.warning(f"Startup enforcement failed (non-fatal): {e}") logger.warning(f"Startup enforcement failed (non-fatal): {e}")
@@ -724,6 +730,103 @@ def clear_health_history():
service_alert_counters = {} service_alert_counters = {}
return jsonify({'message': 'Health history cleared'}) return jsonify({'message': 'Health history cleared'})
# ---------------------------------------------------------------------------
# Phase 5 — Extended connectivity routes
# ---------------------------------------------------------------------------
@app.route('/api/connectivity/status', methods=['GET'])
def connectivity_status():
"""Return connectivity manager status (configured exits, peer counts)."""
try:
return jsonify(connectivity_manager.get_status())
except Exception as e:
logger.error(f"connectivity_status: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/connectivity/exits', methods=['GET'])
def connectivity_list_exits():
"""List configured exits and their state."""
try:
return jsonify({'exits': connectivity_manager.list_exits()})
except Exception as e:
logger.error(f"connectivity_list_exits: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/connectivity/exits/wireguard', methods=['POST'])
def connectivity_upload_wireguard():
"""Upload an external WireGuard config (becomes wg_ext0)."""
try:
data = request.get_json(silent=True) or {}
conf_text = data.get('conf_text', '')
if not isinstance(conf_text, str) or not conf_text.strip():
return jsonify({'ok': False, 'error': 'conf_text is required'}), 400
result = connectivity_manager.upload_wireguard_ext(conf_text)
if result.get('ok'):
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.error(f"connectivity_upload_wireguard: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/connectivity/exits/openvpn', methods=['POST'])
def connectivity_upload_openvpn():
"""Upload an OpenVPN profile (.ovpn)."""
try:
data = request.get_json(silent=True) or {}
ovpn_text = data.get('ovpn_text', '')
name = data.get('name', 'default')
if not isinstance(ovpn_text, str) or not ovpn_text.strip():
return jsonify({'ok': False, 'error': 'ovpn_text is required'}), 400
result = connectivity_manager.upload_openvpn(ovpn_text, name=name)
if result.get('ok'):
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.error(f"connectivity_upload_openvpn: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/connectivity/exits/apply', methods=['POST'])
def connectivity_apply_routes():
"""Idempotently re-apply all connectivity policy routing rules."""
try:
result = connectivity_manager.apply_routes()
return jsonify(result)
except Exception as e:
logger.error(f"connectivity_apply_routes: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/connectivity/peers/<peer_name>/exit', methods=['PUT'])
def connectivity_set_peer_exit(peer_name: str):
"""Assign a peer to an egress exit type."""
try:
data = request.get_json(silent=True) or {}
exit_via = data.get('exit_via')
if not isinstance(exit_via, str):
return jsonify({'ok': False, 'error': 'exit_via is required'}), 400
result = connectivity_manager.set_peer_exit(peer_name, exit_via)
if result.get('ok'):
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.error(f"connectivity_set_peer_exit({peer_name}): {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/connectivity/peers', methods=['GET'])
def connectivity_get_peer_exits():
"""Return {peer_name: exit_type} for all peers."""
try:
return jsonify({'peers': connectivity_manager.get_peer_exits()})
except Exception as e:
logger.error(f"connectivity_get_peer_exits: {e}")
return jsonify({'error': str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
debug = os.environ.get('FLASK_DEBUG', '0') == '1' debug = os.environ.get('FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=3000, debug=debug) app.run(host='0.0.0.0', port=3000, debug=debug)
+33
View File
@@ -40,6 +40,9 @@ class ConfigManager:
# Ensure _identity key always exists # Ensure _identity key always exists
if '_identity' not in self.configs: if '_identity' not in self.configs:
self.configs['_identity'] = {} self.configs['_identity'] = {}
# Phase 5: ensure connectivity section exists with empty defaults.
if 'connectivity' not in self.configs:
self.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
if not self.config_file.exists(): if not self.config_file.exists():
self._save_all_configs() self._save_all_configs()
@@ -108,6 +111,14 @@ class ConfigManager:
'ca_configured': bool, 'ca_configured': bool,
'fernet_configured': bool 'fernet_configured': bool
} }
},
'connectivity': {
'required': [],
'optional': ['exits', 'peer_exit_map'],
'types': {
'exits': dict,
'peer_exit_map': dict,
}
} }
} }
@@ -488,6 +499,28 @@ class ConfigManager:
ident.setdefault('service_ips', {}).pop(service_id, None) ident.setdefault('service_ips', {}).pop(service_id, None)
self._save_all_configs() self._save_all_configs()
# Phase 5 — Extended connectivity configuration helpers
def get_connectivity_config(self) -> Dict[str, Any]:
"""Return the full connectivity config (exits + peer_exit_map)."""
cfg = self.configs.get('connectivity')
if not isinstance(cfg, dict):
cfg = {'exits': {}, 'peer_exit_map': {}}
self.configs['connectivity'] = cfg
cfg.setdefault('exits', {})
cfg.setdefault('peer_exit_map', {})
return dict(cfg)
def set_connectivity_field(self, field: str, value: Any) -> bool:
"""Set a single field within the connectivity config and persist."""
cfg = self.configs.setdefault('connectivity', {'exits': {}, 'peer_exit_map': {}})
cfg[field] = value
try:
self._save_all_configs()
return True
except Exception as e:
logger.error(f"set_connectivity_field({field}): {e}")
return False
def get_all_configs(self) -> Dict[str, Dict]: def get_all_configs(self) -> Dict[str, Dict]:
"""Get all service configurations""" """Get all service configurations"""
return self.configs.copy() return self.configs.copy()
+543
View File
@@ -0,0 +1,543 @@
#!/usr/bin/env python3
"""
Connectivity Manager for Personal Internet Cell — Phase 5 Extended Connectivity.
Provides per-peer egress routing through alternate exits (WireGuard external,
OpenVPN, Tor) via Linux policy routing (fwmark + ip rule + dedicated routing
tables) and dedicated iptables chains.
Architecture
------------
- A peer's `exit_via` field selects the egress path: "default", "wireguard_ext",
"openvpn", or "tor".
- Each non-default exit type is assigned a unique fwmark and a dedicated routing
table:
wireguard_ext mark 0x10 table 110 iface wg_ext0
openvpn mark 0x20 table 120 iface tun0
tor mark 0x30 table 130 (transparent proxy → 9040)
- 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.
- A kill-switch FORWARD DROP rule prevents leaks if the exit interface is down.
Container model
---------------
Each exit type runs in its own separate container; this manager only programs
policy routing rules in the WireGuard server container (cell-wireguard) where
peer traffic flows through.
Config files
------------
- WireGuard external: {config_dir}/connectivity/wireguard_ext/wg_ext0.conf
- OpenVPN: {config_dir}/connectivity/openvpn/<name>.ovpn
Both are validated to strip / reject hook directives that could execute
arbitrary commands on the host.
"""
import logging
import os
import re
import subprocess
from typing import Any, Dict, List, Optional
from base_service_manager import BaseServiceManager
logger = logging.getLogger(__name__)
WIREGUARD_CONTAINER = 'cell-wireguard'
# Lines we strip from uploaded WireGuard configs — these can run arbitrary
# host commands when wg-quick brings the interface up/down.
_WG_FORBIDDEN_PREFIXES = ('PostUp', 'PostDown', 'PreUp', 'PreDown')
# Lines we strip from uploaded OpenVPN configs — these execute external
# scripts/binaries on connect/disconnect.
_OVPN_FORBIDDEN_DIRECTIVES = (
'up', 'down', 'script-security', 'plugin',
'route-up', 'route-pre-down',
)
_NAME_RE = re.compile(r'^[a-z0-9_-]{1,32}$')
class ConnectivityManager(BaseServiceManager):
"""Manages alternate egress paths (extended connectivity) for peers."""
EXIT_TYPES = ("default", "wireguard_ext", "openvpn", "tor")
MARKS = {"wireguard_ext": 0x10, "openvpn": 0x20, "tor": 0x30}
TABLES = {"wireguard_ext": 110, "openvpn": 120, "tor": 130}
IFACES = {"wireguard_ext": "wg_ext0", "openvpn": "tun0"}
TOR_TRANS_PORT = 9040
TOR_DNS_PORT = 5353
CONNECTIVITY_CHAIN = 'PIC_CONNECTIVITY'
def __init__(self, config_manager=None, peer_registry=None,
data_dir: str = '/app/data', config_dir: str = '/app/config'):
super().__init__('connectivity', data_dir, config_dir)
self.config_manager = config_manager
self.peer_registry = peer_registry
# Config file directories
self.connectivity_config_dir = os.path.join(config_dir, 'connectivity')
self.wireguard_ext_dir = os.path.join(self.connectivity_config_dir, 'wireguard_ext')
self.openvpn_dir = os.path.join(self.connectivity_config_dir, 'openvpn')
for d in (self.connectivity_config_dir, self.wireguard_ext_dir, self.openvpn_dir):
self.safe_makedirs(d)
# Subscribe to ServiceBus CONFIG_CHANGED events so routes are
# reapplied if the underlying network changes. Done lazily —
# service_bus is a singleton imported at app startup.
self._subscribe_to_events()
# ── Event wiring ──────────────────────────────────────────────────────
def _subscribe_to_events(self) -> None:
"""Subscribe to network change events so routes auto-reapply."""
try:
from managers import service_bus, EventType
service_bus.subscribe_to_event(
EventType.CONFIG_CHANGED, self._on_network_changed
)
except Exception as e:
# Non-fatal: subscription is best-effort, manual apply still works.
logger.debug(f"connectivity: event subscribe skipped: {e}")
def _on_network_changed(self, event) -> None:
"""ServiceBus handler: re-apply routes when network config changes."""
try:
source = getattr(event, 'source', '')
if source not in ('network', 'wireguard', 'connectivity'):
return
logger.info(f"connectivity: re-applying routes due to {source} change")
self.apply_routes()
except Exception as e:
logger.warning(f"connectivity: on_network_changed failed (non-fatal): {e}")
# ── BaseServiceManager required ───────────────────────────────────────
def get_status(self) -> Dict[str, Any]:
"""Return status summary including configured exits and peer count."""
try:
exits_status: Dict[str, Dict[str, Any]] = {}
for exit_type in self.EXIT_TYPES:
if exit_type == "default":
continue
exits_status[exit_type] = self._exit_status(exit_type)
peers_with_exit = 0
if self.peer_registry is not None:
try:
for peer in self.peer_registry.list_peers():
if peer.get('exit_via', 'default') != 'default':
peers_with_exit += 1
except Exception as e:
logger.warning(f"get_status: peer count failed: {e}")
return {
'service': 'connectivity',
'running': True,
'exits': exits_status,
'peers_with_exit': peers_with_exit,
}
except Exception as e:
return self.handle_error(e, 'get_status')
def test_connectivity(self) -> Dict[str, Any]:
"""Minimal connectivity self-test."""
return {'success': True}
def get_config(self) -> Dict[str, Any]:
"""Return current connectivity config from config_manager."""
try:
if self.config_manager is not None and hasattr(
self.config_manager, 'get_connectivity_config'
):
return self.config_manager.get_connectivity_config()
except Exception as e:
logger.warning(f"get_config: config_manager lookup failed: {e}")
return {'exits': {}, 'peer_exit_map': {}}
# ── Public API ────────────────────────────────────────────────────────
def list_exits(self) -> List[Dict[str, Any]]:
"""List configured exits with current status."""
result: List[Dict[str, Any]] = []
for exit_type in self.EXIT_TYPES:
if exit_type == "default":
continue
entry = {'type': exit_type}
entry.update(self._exit_status(exit_type))
result.append(entry)
return result
def get_peer_exits(self) -> Dict[str, str]:
"""Return {peer_name: exit_type} for all peers."""
out: Dict[str, str] = {}
if self.peer_registry is None:
return out
try:
for peer in self.peer_registry.list_peers():
name = peer.get('peer')
if name:
out[name] = peer.get('exit_via', 'default')
except Exception as e:
logger.warning(f"get_peer_exits: {e}")
return out
def set_peer_exit(self, peer_name: str, exit_type: str) -> Dict[str, Any]:
"""Assign a peer to an egress path and apply the rule changes."""
if exit_type not in self.EXIT_TYPES:
return {
'ok': False,
'error': f"invalid exit_type {exit_type!r}; "
f"must be one of {self.EXIT_TYPES}",
}
if not isinstance(peer_name, str) or not re.match(r'^[A-Za-z0-9_.-]{1,64}$', peer_name):
return {'ok': False, 'error': f'invalid peer_name {peer_name!r}'}
if self.peer_registry is None:
return {'ok': False, 'error': 'peer_registry not available'}
try:
ok = self.peer_registry.set_peer_exit_via(peer_name, exit_type)
except Exception as e:
logger.error(f"set_peer_exit: registry update failed: {e}")
return {'ok': False, 'error': str(e)}
if not ok:
return {'ok': False, 'error': f'peer {peer_name!r} not found'}
try:
self.apply_routes()
except Exception as e:
logger.warning(f"set_peer_exit: apply_routes failed (non-fatal): {e}")
return {'ok': True, 'peer': peer_name, 'exit_via': exit_type}
def upload_wireguard_ext(self, conf_text: str) -> Dict[str, Any]:
"""Validate and store an external WireGuard config."""
try:
cleaned = self._validate_wg_conf(conf_text)
except ValueError as e:
return {'ok': False, 'error': str(e)}
path = os.path.join(self.wireguard_ext_dir, 'wg_ext0.conf')
try:
self._write_secure(path, cleaned)
except Exception as e:
logger.error(f"upload_wireguard_ext: write failed: {e}")
return {'ok': False, 'error': str(e)}
logger.info(f"connectivity: stored wg_ext0.conf ({len(cleaned)} bytes)")
return {'ok': True}
def upload_openvpn(self, ovpn_text: str, name: str = 'default') -> Dict[str, Any]:
"""Validate and store an OpenVPN profile."""
if not isinstance(name, str) or not _NAME_RE.match(name):
return {
'ok': False,
'error': f'invalid name {name!r}; must match [a-z0-9_-]{{1,32}}',
}
try:
cleaned = self._validate_ovpn(ovpn_text)
except ValueError as e:
return {'ok': False, 'error': str(e)}
path = os.path.join(self.openvpn_dir, f'{name}.ovpn')
try:
self._write_secure(path, cleaned)
except Exception as e:
logger.error(f"upload_openvpn: write failed: {e}")
return {'ok': False, 'error': str(e)}
logger.info(f"connectivity: stored {name}.ovpn ({len(cleaned)} bytes)")
return {'ok': True}
# ── Routing application ───────────────────────────────────────────────
def apply_routes(self) -> Dict[str, Any]:
"""Idempotently rebuild all connectivity rules and policy routing."""
rules_applied = 0
try:
self._ensure_chains()
except Exception as e:
logger.warning(f"apply_routes: _ensure_chains failed: {e}")
# Flush our dedicated chains (without deleting them)
for table, chain in (('mangle', self.CONNECTIVITY_CHAIN),
('nat', self.CONNECTIVITY_CHAIN)):
try:
self._flush_chain(table, chain)
except Exception as e:
logger.warning(f"apply_routes: flush {table}/{chain} failed: {e}")
# Idempotent ip rule registration for each non-default exit
for exit_type in ('wireguard_ext', 'openvpn', 'tor'):
mark = self.MARKS[exit_type]
table = self.TABLES[exit_type]
try:
self._remove_ip_rule(mark, table)
self._add_ip_rule(mark, table)
rules_applied += 1
except Exception as e:
logger.warning(f"apply_routes: ip rule {exit_type} failed: {e}")
# Per-peer marking + nat redirect (Tor only)
if self.peer_registry is not None:
try:
peers = self.peer_registry.list_peers()
except Exception as e:
logger.warning(f"apply_routes: list_peers failed: {e}")
peers = []
for peer in peers:
exit_via = peer.get('exit_via', 'default')
if exit_via == 'default' or exit_via not in self.MARKS:
continue
src_ip = self._peer_source_ip(peer.get('peer', ''))
if not src_ip:
continue
mark = self.MARKS[exit_via]
try:
self._add_mark_rule(src_ip, mark)
rules_applied += 1
except Exception as e:
logger.warning(
f"apply_routes: mark rule for {src_ip}/{exit_via}: {e}"
)
# Tor: redirect TCP to local transparent proxy
if exit_via == 'tor':
try:
self._add_tor_redirect(src_ip)
rules_applied += 1
except Exception as e:
logger.warning(
f"apply_routes: tor redirect for {src_ip}: {e}"
)
# Kill-switch: drop marked packets that would otherwise leak via the
# default route if the exit interface is down.
for exit_type, iface in self.IFACES.items():
mark = self.MARKS[exit_type]
try:
self._add_killswitch(mark, iface)
rules_applied += 1
except Exception as e:
logger.warning(f"apply_routes: killswitch {exit_type}: {e}")
return {'ok': True, 'rules_applied': rules_applied}
# ── iptables / ip rule helpers ────────────────────────────────────────
def _wg_iptables(self, args: List[str], timeout: int = 10) -> subprocess.CompletedProcess:
"""Run iptables inside the WireGuard container (where peer traffic forwards)."""
cmd = ['docker', 'exec', WIREGUARD_CONTAINER, 'iptables'] + args
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def _wg_ip(self, args: List[str], timeout: int = 10) -> subprocess.CompletedProcess:
"""Run `ip` inside the WireGuard container."""
cmd = ['docker', 'exec', WIREGUARD_CONTAINER, 'ip'] + args
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def _ensure_chains(self) -> None:
"""Create PIC_CONNECTIVITY chains in mangle and nat (idempotent)."""
for table, parent_chain in (
('mangle', 'PREROUTING'),
('nat', 'PREROUTING'),
):
# Create chain if it doesn't already exist
check = self._wg_iptables(
['-t', table, '-L', self.CONNECTIVITY_CHAIN, '-n']
)
if check.returncode != 0:
create = self._wg_iptables(
['-t', table, '-N', self.CONNECTIVITY_CHAIN]
)
if create.returncode != 0 and 'exists' not in (create.stderr or ''):
logger.warning(
f"_ensure_chains: cannot create {table}/{self.CONNECTIVITY_CHAIN}: "
f"{create.stderr.strip()}"
)
# Insert jump from parent chain at position 1, idempotent.
jump_args = ['-t', table, '-C', parent_chain, '-j', self.CONNECTIVITY_CHAIN]
exists = self._wg_iptables(jump_args)
if exists.returncode != 0:
self._wg_iptables(
['-t', table, '-I', parent_chain, '1',
'-j', self.CONNECTIVITY_CHAIN]
)
def _flush_chain(self, table: str, chain: str) -> None:
"""Flush a chain in-place (`iptables -F`) without deleting it."""
self._wg_iptables(['-t', table, '-F', chain])
def _add_ip_rule(self, mark: int, table: int) -> None:
"""Add `ip rule fwmark <mark> lookup <table>`."""
self._wg_ip(['rule', 'add', 'fwmark', hex(mark), 'lookup', str(table)])
def _remove_ip_rule(self, mark: int, table: int) -> None:
"""Remove all matching `ip rule fwmark <mark> lookup <table>` (idempotent)."""
# `ip rule del` returns nonzero when no matching rule exists; loop
# until it fails to drain duplicates.
for _ in range(8):
r = self._wg_ip(['rule', 'del', 'fwmark', hex(mark), 'lookup', str(table)])
if r.returncode != 0:
break
def _add_mark_rule(self, src_ip: str, mark: int) -> None:
"""Mark packets from src_ip with mark in the mangle PIC_CONNECTIVITY chain."""
self._wg_iptables([
'-t', 'mangle', '-A', self.CONNECTIVITY_CHAIN,
'-s', src_ip,
'-j', 'MARK', '--set-mark', hex(mark),
])
def _add_tor_redirect(self, src_ip: str) -> None:
"""Redirect peer's TCP traffic to local Tor TransPort."""
self._wg_iptables([
'-t', 'nat', '-A', self.CONNECTIVITY_CHAIN,
'-s', src_ip, '-p', 'tcp',
'-j', 'REDIRECT', '--to-ports', str(self.TOR_TRANS_PORT),
])
def _add_killswitch(self, mark: int, iface: Optional[str]) -> None:
"""Drop marked packets that would egress via any interface other than iface.
For Tor (no exit iface), skip — Tor traffic is fully redirected at
nat/REDIRECT and never reaches FORWARD.
"""
if not iface:
return
# Use -C to test, -A to add — idempotent.
check_args = ['-C', 'FORWARD',
'-m', 'mark', '--mark', hex(mark),
'!', '-o', iface, '-j', 'DROP']
exists = self._wg_iptables(check_args)
if exists.returncode != 0:
self._wg_iptables(['-A', 'FORWARD',
'-m', 'mark', '--mark', hex(mark),
'!', '-o', iface, '-j', 'DROP'])
def _exit_status(self, exit_type: str) -> Dict[str, Any]:
"""Return per-exit status (config presence + interface up/down)."""
info: Dict[str, Any] = {'configured': False, 'iface_up': False}
if exit_type == 'wireguard_ext':
path = os.path.join(self.wireguard_ext_dir, 'wg_ext0.conf')
info['configured'] = os.path.isfile(path)
elif exit_type == 'openvpn':
try:
info['configured'] = any(
f.endswith('.ovpn')
for f in os.listdir(self.openvpn_dir)
)
except OSError:
info['configured'] = False
elif exit_type == 'tor':
info['configured'] = True # Tor uses defaults; no per-cell config
iface = self.IFACES.get(exit_type)
if iface:
try:
r = self._wg_ip(['link', 'show', iface], timeout=5)
info['iface_up'] = r.returncode == 0 and 'UP' in (r.stdout or '')
except Exception:
info['iface_up'] = False
return info
def _peer_source_ip(self, peer_name: str) -> Optional[str]:
"""Return a peer's WireGuard IP (no /CIDR suffix)."""
if not peer_name or self.peer_registry is None:
return None
try:
peer = self.peer_registry.get_peer(peer_name)
except Exception as e:
logger.warning(f"_peer_source_ip({peer_name}): {e}")
return None
if not peer:
return None
ip = peer.get('ip', '')
if not ip:
return None
return ip.split('/')[0]
# ── Config validation ─────────────────────────────────────────────────
def _validate_wg_conf(self, text: str) -> str:
"""Strip Pre/Post-Up/Down hooks and reject conflicting wg0 interface.
Raises ValueError if the config tries to define `Interface = wg0`
(which would clash with the existing peer-server interface).
"""
if not isinstance(text, str):
raise ValueError('wg conf must be a string')
cleaned: List[str] = []
for raw_line in text.splitlines():
stripped = raw_line.strip()
# Reject wg0 interface declaration that would conflict with the
# existing WireGuard server interface.
if stripped.lower().startswith('interface'):
# Look ahead in subsequent lines for `= wg0` would be hard;
# the [Interface] section header itself is fine. We only
# reject explicit Name/Interface = wg0 directives.
pass
# Match assignments like `PostUp = ...`
if '=' in stripped:
key = stripped.split('=', 1)[0].strip()
if key in _WG_FORBIDDEN_PREFIXES:
logger.info(f"_validate_wg_conf: dropped {key} hook")
continue
# Detect Name = wg0 or Interface = wg0 inside Interface section
if key.lower() in ('name', 'interface') and \
stripped.split('=', 1)[1].strip().lower() == 'wg0':
raise ValueError(
"config defines interface 'wg0' which conflicts "
"with the peer-server interface"
)
cleaned.append(raw_line)
return '\n'.join(cleaned).rstrip() + '\n'
def _validate_ovpn(self, text: str) -> str:
"""Strip directives that execute external scripts/binaries."""
if not isinstance(text, str):
raise ValueError('ovpn conf must be a string')
cleaned: List[str] = []
for raw_line in text.splitlines():
stripped = raw_line.strip()
# Match the directive name (first whitespace-delimited token).
if stripped and not stripped.startswith('#'):
first = stripped.split(None, 1)[0]
if first in _OVPN_FORBIDDEN_DIRECTIVES:
logger.info(f"_validate_ovpn: dropped {first} directive")
continue
cleaned.append(raw_line)
return '\n'.join(cleaned).rstrip() + '\n'
# ── Filesystem helpers ────────────────────────────────────────────────
@staticmethod
def _write_secure(path: str, text: str) -> None:
"""Atomic 0o600 write — secrets in these configs must not be world-readable."""
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + '.tmp'
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, 'w') as f:
f.write(text)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
os.chmod(tmp, 0o600)
os.replace(tmp, path)
os.chmod(path, 0o600)
+8 -1
View File
@@ -30,6 +30,7 @@ from auth_manager import AuthManager
from setup_manager import SetupManager from setup_manager import SetupManager
from caddy_manager import CaddyManager from caddy_manager import CaddyManager
from ddns_manager import DDNSManager from ddns_manager import DDNSManager
from connectivity_manager import ConnectivityManager
DATA_DIR = os.environ.get('DATA_DIR', '/app/data') DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config') CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
@@ -59,6 +60,12 @@ auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager) setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager)
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)
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)
connectivity_manager = ConnectivityManager(
config_manager=config_manager,
peer_registry=peer_registry,
data_dir=DATA_DIR,
config_dir=CONFIG_DIR,
)
from service_store_manager import ServiceStoreManager from service_store_manager import ServiceStoreManager
service_store_manager = ServiceStoreManager( service_store_manager = ServiceStoreManager(
@@ -102,7 +109,7 @@ __all__ = [
'email_manager', 'calendar_manager', 'file_manager', 'email_manager', 'calendar_manager', 'file_manager',
'routing_manager', 'vault_manager', 'container_manager', 'routing_manager', 'vault_manager', 'container_manager',
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager', 'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
'ddns_manager', 'service_store_manager', 'ddns_manager', 'service_store_manager', 'connectivity_manager',
'firewall_manager', 'EventType', 'firewall_manager', 'EventType',
'DATA_DIR', 'CONFIG_DIR', 'DATA_DIR', 'CONFIG_DIR',
] ]
+30
View File
@@ -194,11 +194,15 @@ class PeerRegistry(BaseServiceManager):
self.logger.error(f"Error loading peers: {e}") self.logger.error(f"Error loading peers: {e}")
self.peers = [] self.peers = []
# Phase 3 migration: per-peer internet routing # Phase 3 migration: per-peer internet routing
# Phase 5 migration: per-peer extended-connectivity exit (wireguard_ext, openvpn, tor)
changed = False changed = False
for peer in self.peers: for peer in self.peers:
if 'route_via' not in peer: if 'route_via' not in peer:
peer['route_via'] = None peer['route_via'] = None
changed = True changed = True
if 'exit_via' not in peer:
peer['exit_via'] = 'default'
changed = True
if changed: if changed:
self._save_peers() self._save_peers()
else: else:
@@ -346,6 +350,32 @@ class PeerRegistry(BaseServiceManager):
return dict(peer) return dict(peer)
raise ValueError(f"Peer '{peer_name}' not found") raise ValueError(f"Peer '{peer_name}' not found")
# Phase 5: extended connectivity per-peer egress exit
VALID_EXIT_VIA = ('default', 'wireguard_ext', 'openvpn', 'tor')
def set_peer_exit_via(self, peer_name: str, exit_type: str) -> bool:
"""Set the per-peer egress exit type. Returns True if updated, False
if the peer is not found (logged as warning, no exception)."""
if exit_type not in self.VALID_EXIT_VIA:
self.logger.warning(
f"set_peer_exit_via: invalid exit_type {exit_type!r}"
)
return False
with self.lock:
for peer in self.peers:
if peer.get('peer') == peer_name:
peer['exit_via'] = exit_type
peer['updated_at'] = datetime.utcnow().isoformat()
self._save_peers()
self.logger.info(
f"Set exit_via for {peer_name}: {exit_type!r}"
)
return True
self.logger.warning(
f"set_peer_exit_via: peer {peer_name!r} not found"
)
return False
def get_peer_stats(self) -> Dict[str, Any]: def get_peer_stats(self) -> Dict[str, Any]:
"""Get peer registry statistics""" """Get peer registry statistics"""
try: try:
+690
View File
@@ -0,0 +1,690 @@
"""
Tests for ConnectivityManager config validation, file upload, status,
exit listing, peer exit assignment, and route application.
All subprocess calls (docker exec iptables/ip) and filesystem paths are
isolated so these tests run without any live infrastructure.
"""
import os
import sys
import stat
import tempfile
import shutil
import unittest
from unittest.mock import MagicMock, patch, call
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
import connectivity_manager as cm_module
from connectivity_manager import ConnectivityManager
# ---------------------------------------------------------------------------
# Factory helper
# ---------------------------------------------------------------------------
_SENTINEL = object()
def _make_manager(tmp_dir=None, peer_registry=_SENTINEL, config_manager=None):
"""Build a ConnectivityManager with mocked dependencies.
Pass peer_registry=None explicitly to test the no-registry path.
Omit peer_registry (or pass _SENTINEL) to get a default MagicMock.
"""
if tmp_dir is None:
tmp_dir = tempfile.mkdtemp()
if config_manager is None:
config_manager = MagicMock()
config_manager.get_identity.return_value = {
'cell_name': 'test',
'ip_range': '172.20.0.0/16',
}
if peer_registry is _SENTINEL:
peer_registry = MagicMock()
peer_registry.list_peers.return_value = []
with patch.object(ConnectivityManager, '_subscribe_to_events', lambda self: None):
mgr = ConnectivityManager(
config_manager=config_manager,
peer_registry=peer_registry,
data_dir=tmp_dir,
config_dir=tmp_dir,
)
return mgr
def _mock_subprocess_ok():
"""Return a MagicMock mimicking a successful subprocess.run result."""
return MagicMock(returncode=0, stdout='', stderr='')
# ---------------------------------------------------------------------------
# _validate_wg_conf
# ---------------------------------------------------------------------------
class TestValidateWgConf(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.mgr = _make_manager(tmp_dir=self.tmp)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_valid_config_passes_and_returns_cleaned_text(self):
conf = "[Interface]\nPrivateKey = abc123\nAddress = 10.99.0.1/24\n\n[Peer]\nPublicKey = xyz\n"
result = self.mgr._validate_wg_conf(conf)
self.assertIn('[Interface]', result)
self.assertIn('PrivateKey', result)
self.assertIn('[Peer]', result)
def test_postupdate_is_stripped_silently(self):
conf = "[Interface]\nPrivateKey = abc\nPostUp = iptables -A FORWARD -j ACCEPT\n"
result = self.mgr._validate_wg_conf(conf)
self.assertNotIn('PostUp', result)
self.assertIn('PrivateKey', result)
def test_postdown_is_stripped_silently(self):
conf = "[Interface]\nPrivateKey = abc\nPostDown = iptables -D FORWARD -j ACCEPT\n"
result = self.mgr._validate_wg_conf(conf)
self.assertNotIn('PostDown', result)
def test_preup_is_stripped_silently(self):
conf = "[Interface]\nPrivateKey = abc\nPreUp = /sbin/modprobe wireguard\n"
result = self.mgr._validate_wg_conf(conf)
self.assertNotIn('PreUp', result)
def test_predown_is_stripped_silently(self):
conf = "[Interface]\nPrivateKey = abc\nPreDown = /sbin/rmmod wireguard\n"
result = self.mgr._validate_wg_conf(conf)
self.assertNotIn('PreDown', result)
def test_interface_wg0_raises_value_error(self):
conf = "[Interface]\nName = wg0\nPrivateKey = abc\n"
with self.assertRaises(ValueError) as ctx:
self.mgr._validate_wg_conf(conf)
self.assertIn('wg0', str(ctx.exception))
def test_interface_wg0_via_interface_key_raises_value_error(self):
# 'Interface = wg0' (not just 'Name = wg0') should also be caught
conf = "[Interface]\nInterface = wg0\nPrivateKey = abc\n"
with self.assertRaises(ValueError):
self.mgr._validate_wg_conf(conf)
def test_interface_wg_ext0_passes(self):
conf = "[Interface]\nName = wg_ext0\nPrivateKey = abc\nAddress = 10.99.0.1/24\n"
result = self.mgr._validate_wg_conf(conf)
self.assertIn('wg_ext0', result)
def test_non_string_input_raises_value_error(self):
with self.assertRaises(ValueError):
self.mgr._validate_wg_conf(None)
def test_result_ends_with_newline(self):
conf = "[Interface]\nPrivateKey = abc\n"
result = self.mgr._validate_wg_conf(conf)
self.assertTrue(result.endswith('\n'))
def test_multiple_hooks_all_stripped(self):
conf = (
"[Interface]\n"
"PrivateKey = abc\n"
"PostUp = cmd1\n"
"PostDown = cmd2\n"
"PreUp = cmd3\n"
"PreDown = cmd4\n"
)
result = self.mgr._validate_wg_conf(conf)
for hook in ('PostUp', 'PostDown', 'PreUp', 'PreDown'):
self.assertNotIn(hook, result)
self.assertIn('PrivateKey', result)
# ---------------------------------------------------------------------------
# _validate_ovpn
# ---------------------------------------------------------------------------
class TestValidateOvpn(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.mgr = _make_manager(tmp_dir=self.tmp)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def _base_conf(self, extra=''):
return f"client\ndev tun\nproto udp\nremote vpn.example.com 1194\n{extra}"
def test_valid_ovpn_passes(self):
conf = self._base_conf()
result = self.mgr._validate_ovpn(conf)
self.assertIn('proto udp', result)
self.assertIn('remote vpn.example.com 1194', result)
def test_up_script_is_stripped(self):
conf = self._base_conf('up /sbin/connect.sh\n')
result = self.mgr._validate_ovpn(conf)
self.assertNotIn('up /sbin/connect.sh', result)
def test_down_script_is_stripped(self):
conf = self._base_conf('down /sbin/disconnect.sh\n')
result = self.mgr._validate_ovpn(conf)
self.assertNotIn('down /sbin/disconnect.sh', result)
def test_script_security_is_stripped(self):
conf = self._base_conf('script-security 2\n')
result = self.mgr._validate_ovpn(conf)
self.assertNotIn('script-security', result)
def test_plugin_is_stripped(self):
conf = self._base_conf('plugin /path/to/plugin.so\n')
result = self.mgr._validate_ovpn(conf)
self.assertNotIn('plugin', result)
def test_route_up_is_stripped(self):
conf = self._base_conf('route-up /sbin/route_cmd\n')
result = self.mgr._validate_ovpn(conf)
self.assertNotIn('route-up', result)
def test_route_pre_down_is_stripped(self):
conf = self._base_conf('route-pre-down /sbin/cleanup\n')
result = self.mgr._validate_ovpn(conf)
self.assertNotIn('route-pre-down', result)
def test_proto_udp_is_preserved(self):
conf = self._base_conf()
result = self.mgr._validate_ovpn(conf)
self.assertIn('proto udp', result)
def test_remote_directive_is_preserved(self):
conf = self._base_conf()
result = self.mgr._validate_ovpn(conf)
self.assertIn('remote vpn.example.com 1194', result)
def test_comments_are_preserved(self):
conf = self._base_conf('# this is a comment\n')
result = self.mgr._validate_ovpn(conf)
self.assertIn('# this is a comment', result)
def test_non_string_input_raises_value_error(self):
with self.assertRaises(ValueError):
self.mgr._validate_ovpn(42)
def test_result_ends_with_newline(self):
conf = self._base_conf()
result = self.mgr._validate_ovpn(conf)
self.assertTrue(result.endswith('\n'))
def test_all_forbidden_directives_stripped_together(self):
conf = self._base_conf(
'up /s\ndown /s\nscript-security 2\nplugin /p\nroute-up /r\nroute-pre-down /r\n'
)
result = self.mgr._validate_ovpn(conf)
for directive in ('up ', 'down ', 'script-security', 'plugin', 'route-up', 'route-pre-down'):
self.assertNotIn(directive, result)
# Safe directives survive
self.assertIn('proto udp', result)
# ---------------------------------------------------------------------------
# upload_wireguard_ext
# ---------------------------------------------------------------------------
class TestUploadWireguardExt(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.mgr = _make_manager(tmp_dir=self.tmp)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def _valid_conf(self):
return "[Interface]\nPrivateKey = abc\nAddress = 10.99.0.1/24\n\n[Peer]\nPublicKey = xyz\n"
def test_valid_conf_returns_ok_true(self):
result = self.mgr.upload_wireguard_ext(self._valid_conf())
self.assertTrue(result['ok'])
def test_valid_conf_writes_file_to_correct_path(self):
self.mgr.upload_wireguard_ext(self._valid_conf())
expected = os.path.join(self.tmp, 'connectivity', 'wireguard_ext', 'wg_ext0.conf')
self.assertTrue(os.path.isfile(expected), f'Expected file at {expected}')
def test_valid_conf_file_has_mode_0600(self):
self.mgr.upload_wireguard_ext(self._valid_conf())
path = os.path.join(self.tmp, 'connectivity', 'wireguard_ext', 'wg_ext0.conf')
mode = stat.S_IMODE(os.stat(path).st_mode)
self.assertEqual(mode, 0o600, f'Expected 0600, got {oct(mode)}')
def test_wg0_interface_returns_ok_false_with_error(self):
bad_conf = "[Interface]\nName = wg0\nPrivateKey = abc\n"
result = self.mgr.upload_wireguard_ext(bad_conf)
self.assertFalse(result['ok'])
self.assertIn('error', result)
self.assertIn('wg0', result['error'])
def test_file_content_has_hooks_stripped(self):
conf = "[Interface]\nPrivateKey = abc\nPostUp = evil\n"
self.mgr.upload_wireguard_ext(conf)
path = os.path.join(self.tmp, 'connectivity', 'wireguard_ext', 'wg_ext0.conf')
with open(path) as f:
content = f.read()
self.assertNotIn('PostUp', content)
self.assertIn('PrivateKey', content)
# ---------------------------------------------------------------------------
# upload_openvpn
# ---------------------------------------------------------------------------
class TestUploadOpenvpn(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.mgr = _make_manager(tmp_dir=self.tmp)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def _valid_ovpn(self):
return "client\ndev tun\nproto udp\nremote vpn.example.com 1194\n"
def test_valid_name_and_conf_returns_ok_true(self):
result = self.mgr.upload_openvpn(self._valid_ovpn(), name='my-vpn')
self.assertTrue(result['ok'])
def test_valid_conf_writes_file_at_correct_path(self):
self.mgr.upload_openvpn(self._valid_ovpn(), name='my-vpn')
expected = os.path.join(self.tmp, 'connectivity', 'openvpn', 'my-vpn.ovpn')
self.assertTrue(os.path.isfile(expected), f'Expected file at {expected}')
def test_valid_conf_file_has_mode_0600(self):
self.mgr.upload_openvpn(self._valid_ovpn(), name='my-vpn')
path = os.path.join(self.tmp, 'connectivity', 'openvpn', 'my-vpn.ovpn')
mode = stat.S_IMODE(os.stat(path).st_mode)
self.assertEqual(mode, 0o600, f'Expected 0600, got {oct(mode)}')
def test_name_with_spaces_returns_ok_false(self):
result = self.mgr.upload_openvpn(self._valid_ovpn(), name='my vpn')
self.assertFalse(result['ok'])
self.assertIn('error', result)
def test_name_with_slash_returns_ok_false(self):
result = self.mgr.upload_openvpn(self._valid_ovpn(), name='../evil')
self.assertFalse(result['ok'])
def test_name_with_uppercase_returns_ok_false(self):
result = self.mgr.upload_openvpn(self._valid_ovpn(), name='MyVPN')
self.assertFalse(result['ok'])
def test_name_too_long_returns_ok_false(self):
long_name = 'a' * 33
result = self.mgr.upload_openvpn(self._valid_ovpn(), name=long_name)
self.assertFalse(result['ok'])
def test_valid_hyphenated_name_passes(self):
result = self.mgr.upload_openvpn(self._valid_ovpn(), name='my-vpn')
self.assertTrue(result['ok'])
def test_valid_underscore_name_passes(self):
result = self.mgr.upload_openvpn(self._valid_ovpn(), name='my_vpn')
self.assertTrue(result['ok'])
def test_default_name_default_passes(self):
result = self.mgr.upload_openvpn(self._valid_ovpn())
self.assertTrue(result['ok'])
expected = os.path.join(self.tmp, 'connectivity', 'openvpn', 'default.ovpn')
self.assertTrue(os.path.isfile(expected))
def test_hooks_stripped_from_stored_file(self):
conf = "client\ndev tun\nup /sbin/bad.sh\nproto udp\n"
self.mgr.upload_openvpn(conf, name='clean')
path = os.path.join(self.tmp, 'connectivity', 'openvpn', 'clean.ovpn')
with open(path) as f:
content = f.read()
self.assertNotIn('up /sbin/bad.sh', content)
self.assertIn('proto udp', content)
# ---------------------------------------------------------------------------
# get_status
# ---------------------------------------------------------------------------
class TestGetStatus(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def _mgr_with_subprocess_ok(self):
mgr = _make_manager(tmp_dir=self.tmp)
return mgr
def test_returns_dict(self):
mgr = self._mgr_with_subprocess_ok()
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
status = mgr.get_status()
self.assertIsInstance(status, dict)
def test_service_key_equals_connectivity(self):
mgr = self._mgr_with_subprocess_ok()
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
status = mgr.get_status()
self.assertEqual(status['service'], 'connectivity')
def test_running_key_present_and_true(self):
mgr = self._mgr_with_subprocess_ok()
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
status = mgr.get_status()
self.assertIn('running', status)
self.assertTrue(status['running'])
def test_exits_key_present(self):
mgr = self._mgr_with_subprocess_ok()
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
status = mgr.get_status()
self.assertIn('exits', status)
# ---------------------------------------------------------------------------
# list_exits
# ---------------------------------------------------------------------------
class TestListExits(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_returns_list(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
exits = mgr.list_exits()
self.assertIsInstance(exits, list)
def test_each_item_has_type_field(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
exits = mgr.list_exits()
for item in exits:
self.assertIn('type', item, f'Missing "type" in {item}')
def test_each_item_has_status_fields(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
exits = mgr.list_exits()
for item in exits:
# _exit_status returns configured + iface_up (or subset)
self.assertIn('configured', item, f'Missing "configured" in {item}')
def test_default_not_in_exit_list(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
exits = mgr.list_exits()
types = [e['type'] for e in exits]
self.assertNotIn('default', types)
def test_list_contains_wireguard_ext_openvpn_tor(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
exits = mgr.list_exits()
types = {e['type'] for e in exits}
self.assertIn('wireguard_ext', types)
self.assertIn('openvpn', types)
self.assertIn('tor', types)
# ---------------------------------------------------------------------------
# set_peer_exit
# ---------------------------------------------------------------------------
class TestSetPeerExit(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def _mgr(self, peer_registry=None):
if peer_registry is None:
peer_registry = MagicMock()
peer_registry.set_peer_exit_via.return_value = True
peer_registry.list_peers.return_value = []
return _make_manager(tmp_dir=self.tmp, peer_registry=peer_registry)
def test_valid_exit_type_returns_ok_true(self):
mgr = self._mgr()
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
result = mgr.set_peer_exit('alice', 'wireguard_ext')
self.assertTrue(result['ok'])
def test_valid_exit_type_default_returns_ok_true(self):
mgr = self._mgr()
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
result = mgr.set_peer_exit('alice', 'default')
self.assertTrue(result['ok'])
def test_invalid_exit_type_returns_ok_false(self):
mgr = self._mgr()
result = mgr.set_peer_exit('alice', 'shadowsocks')
self.assertFalse(result['ok'])
self.assertIn('error', result)
def test_invalid_exit_type_error_mentions_type(self):
mgr = self._mgr()
result = mgr.set_peer_exit('alice', 'bad_type')
self.assertIn('bad_type', result['error'])
def test_calls_peer_registry_set_peer_exit_via_with_correct_args(self):
pr = MagicMock()
pr.set_peer_exit_via.return_value = True
pr.list_peers.return_value = []
mgr = self._mgr(peer_registry=pr)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.set_peer_exit('bob', 'openvpn')
pr.set_peer_exit_via.assert_called_once_with('bob', 'openvpn')
def test_peer_not_found_in_registry_returns_ok_false(self):
pr = MagicMock()
pr.set_peer_exit_via.return_value = False # peer not found
pr.list_peers.return_value = []
mgr = self._mgr(peer_registry=pr)
result = mgr.set_peer_exit('unknown-peer', 'tor')
self.assertFalse(result['ok'])
self.assertIn('error', result)
def test_invalid_peer_name_returns_ok_false(self):
mgr = self._mgr()
result = mgr.set_peer_exit('peer with spaces!', 'default')
self.assertFalse(result['ok'])
def test_no_peer_registry_returns_ok_false(self):
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=None)
result = mgr.set_peer_exit('alice', 'wireguard_ext')
self.assertFalse(result['ok'])
self.assertIn('error', result)
# ---------------------------------------------------------------------------
# get_peer_exits
# ---------------------------------------------------------------------------
class TestGetPeerExits(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_returns_dict(self):
mgr = _make_manager(tmp_dir=self.tmp)
result = mgr.get_peer_exits()
self.assertIsInstance(result, dict)
def test_maps_peer_names_to_exit_types(self):
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'alice', 'exit_via': 'wireguard_ext'},
{'peer': 'bob', 'exit_via': 'default'},
]
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
result = mgr.get_peer_exits()
self.assertEqual(result['alice'], 'wireguard_ext')
self.assertEqual(result['bob'], 'default')
def test_peer_without_exit_via_defaults_to_default(self):
pr = MagicMock()
pr.list_peers.return_value = [{'peer': 'charlie'}]
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
result = mgr.get_peer_exits()
self.assertEqual(result['charlie'], 'default')
def test_calls_peer_registry_list_peers(self):
pr = MagicMock()
pr.list_peers.return_value = []
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
mgr.get_peer_exits()
pr.list_peers.assert_called()
def test_no_peer_registry_returns_empty_dict(self):
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=None)
result = mgr.get_peer_exits()
self.assertEqual(result, {})
def test_empty_peer_list_returns_empty_dict(self):
pr = MagicMock()
pr.list_peers.return_value = []
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
result = mgr.get_peer_exits()
self.assertEqual(result, {})
# ---------------------------------------------------------------------------
# apply_routes
# ---------------------------------------------------------------------------
class TestApplyRoutes(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_returns_dict_with_ok_key(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
result = mgr.apply_routes()
self.assertIsInstance(result, dict)
self.assertIn('ok', result)
def test_returns_ok_true_on_success(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
result = mgr.apply_routes()
self.assertTrue(result['ok'])
def test_calls_ensure_chains(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(mgr, '_ensure_chains') as mock_ensure, \
patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
mock_ensure.assert_called_once()
def test_calls_subprocess_for_iptables(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
self.assertTrue(mock_sp.run.called)
# At least one call should involve 'iptables'
calls_str = str(mock_sp.run.call_args_list)
self.assertIn('iptables', calls_str)
def test_subprocess_failure_is_non_fatal_returns_ok_true(self):
"""apply_routes must not raise even when every subprocess call fails."""
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = MagicMock(returncode=1, stdout='', stderr='error')
result = mgr.apply_routes()
# Must not raise; should still return a dict (ok may be True because
# routing errors are logged as warnings, not propagated)
self.assertIsInstance(result, dict)
self.assertIn('ok', result)
def test_ensure_chains_exception_is_non_fatal(self):
"""If _ensure_chains raises, apply_routes still returns a dict."""
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(mgr, '_ensure_chains', side_effect=RuntimeError('chain error')), \
patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
result = mgr.apply_routes()
self.assertIsInstance(result, dict)
def test_peer_with_wireguard_ext_exit_generates_mark_rule(self):
"""Peers with a non-default exit should trigger _add_mark_rule calls."""
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'alice', 'exit_via': 'wireguard_ext'},
]
pr.get_peer.return_value = {'peer': 'alice', 'ip': '172.20.0.50/32'}
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
with patch.object(mgr, '_add_mark_rule') as mock_mark, \
patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
mock_mark.assert_called()
call_args = mock_mark.call_args[0]
self.assertEqual(call_args[0], '172.20.0.50') # IP without CIDR
def test_peer_with_default_exit_skips_mark_rule(self):
"""Peers on default exit must not generate mark rules."""
pr = MagicMock()
pr.list_peers.return_value = [
{'peer': 'bob', 'exit_via': 'default'},
]
mgr = _make_manager(tmp_dir=self.tmp, peer_registry=pr)
with patch.object(mgr, '_add_mark_rule') as mock_mark, \
patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
mgr.apply_routes()
mock_mark.assert_not_called()
def test_rules_applied_count_in_result(self):
mgr = _make_manager(tmp_dir=self.tmp)
with patch.object(cm_module, 'subprocess') as mock_sp:
mock_sp.run.return_value = _mock_subprocess_ok()
result = mgr.apply_routes()
self.assertIn('rules_applied', result)
self.assertIsInstance(result['rules_applied'], int)
if __name__ == '__main__':
unittest.main()
+3
View File
@@ -44,6 +44,7 @@ import AccountSettings from './pages/AccountSettings';
import PeerDashboard from './pages/PeerDashboard'; import PeerDashboard from './pages/PeerDashboard';
import MyServices from './pages/MyServices'; import MyServices from './pages/MyServices';
import Store from './pages/Store'; import Store from './pages/Store';
import Connectivity from './pages/Connectivity';
import Setup from './pages/Setup'; import Setup from './pages/Setup';
import SetupGuard from './components/SetupGuard'; import SetupGuard from './components/SetupGuard';
@@ -242,6 +243,7 @@ function AppCore() {
{ name: 'Containers', href: '/containers', icon: Package2 }, { name: 'Containers', href: '/containers', icon: Package2 },
{ name: 'Store', href: '/store', icon: Package }, { name: 'Store', href: '/store', icon: Package },
{ name: 'Cell Network', href: '/cell-network', icon: Link2 }, { name: 'Cell Network', href: '/cell-network', icon: Link2 },
{ name: 'Connectivity', href: '/connectivity', icon: Network },
{ name: 'Logs', href: '/logs', icon: Activity }, { name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Settings', href: '/settings', icon: SettingsIcon }, { name: 'Settings', href: '/settings', icon: SettingsIcon },
{ name: 'Account', href: '/account', icon: User }, { name: 'Account', href: '/account', icon: User },
@@ -348,6 +350,7 @@ function AppCore() {
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} /> <Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
<Route path="/store" element={<PrivateRoute requireRole="admin"><Store /></PrivateRoute>} /> <Route path="/store" element={<PrivateRoute requireRole="admin"><Store /></PrivateRoute>} />
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} /> <Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} /> <Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} /> <Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
</Routes> </Routes>
+693
View File
@@ -0,0 +1,693 @@
import { useState, useEffect, useCallback } from 'react';
import {
Shield,
Lock,
Globe,
RefreshCw,
CheckCircle,
AlertCircle,
ChevronDown,
Upload,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import { connectivityAPI, wireguardAPI } from '../services/api';
// Toast helpers (same pattern as Store.jsx)
function toastEvent(msg, type = 'success') {
window.dispatchEvent(
new CustomEvent('connectivity-toast', { detail: { msg, type } })
);
}
function Toast({ toasts }) {
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 pointer-events-auto ${
t.type === 'success'
? 'bg-green-600'
: t.type === 'error'
? 'bg-red-600'
: 'bg-yellow-600'
}`}
>
{t.type === 'success' ? (
<CheckCircle className="h-4 w-4 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 shrink-0" />
)}
{t.msg}
</div>
))}
</div>
);
}
function useToasts() {
const [toasts, setToasts] = useState([]);
useEffect(() => {
const handler = (e) => {
const id = Date.now();
setToasts((prev) => [...prev, { ...e.detail, id }]);
setTimeout(
() => setToasts((prev) => prev.filter((t) => t.id !== id)),
3000
);
};
window.addEventListener('connectivity-toast', handler);
return () => window.removeEventListener('connectivity-toast', handler);
}, []);
return toasts;
}
// Status badge
function StatusBadge({ status }) {
if (status === 'active') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
<CheckCircle className="h-3 w-3" />
Active
</span>
);
}
if (status === 'configured') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-full px-2 py-0.5">
<AlertCircle className="h-3 w-3" />
Configured
</span>
);
}
if (status === 'error') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-700 bg-red-50 border border-red-200 rounded-full px-2 py-0.5">
<AlertCircle className="h-3 w-3" />
Error
</span>
);
}
// not configured
return (
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 bg-gray-100 border border-gray-200 rounded-full px-2 py-0.5">
Not configured
</span>
);
}
// WireGuard External card
function WireguardExitCard({ exitInfo, onUploaded }) {
const [confText, setConfText] = useState('');
const [uploading, setUploading] = useState(false);
const status = exitInfo?.status || 'not_configured';
const handleUpload = async () => {
if (!confText.trim()) return;
setUploading(true);
try {
await connectivityAPI.uploadWireguard(confText.trim());
toastEvent('WireGuard config uploaded');
setConfText('');
onUploaded();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to upload WireGuard config';
toastEvent(msg, 'error');
} finally {
setUploading(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-primary-50 shrink-0">
<Shield className="h-5 w-5 text-primary-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">WireGuard External</h3>
<p className="text-sm text-gray-500">
Route traffic through an external WireGuard VPN tunnel
</p>
</div>
</div>
<StatusBadge status={status} />
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="wg-conf"
className="text-sm font-medium text-gray-700"
>
Paste .conf file contents
</label>
<textarea
id="wg-conf"
value={confText}
onChange={(e) => setConfText(e.target.value)}
placeholder="[Interface]&#10;PrivateKey = ...&#10;&#10;[Peer]&#10;PublicKey = ..."
rows={6}
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="wg-conf-hint"
/>
<p id="wg-conf-hint" className="text-xs text-gray-400">
Drag-and-drop not available paste the file text directly.
</p>
</div>
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleUpload}
disabled={uploading || !confText.trim()}
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="Upload WireGuard config"
>
{uploading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{uploading ? 'Uploading…' : 'Upload Config'}
</button>
</div>
</div>
);
}
// OpenVPN card
function OpenvpnExitCard({ exitInfo, onUploaded }) {
const [ovpnText, setOvpnText] = useState('');
const [profileName, setProfileName] = useState('default');
const [uploading, setUploading] = useState(false);
const status = exitInfo?.status || 'not_configured';
const nameInvalid = profileName.trim() === '';
const handleUpload = async () => {
if (!ovpnText.trim() || nameInvalid) return;
setUploading(true);
try {
await connectivityAPI.uploadOpenvpn(ovpnText.trim(), profileName.trim());
toastEvent('OpenVPN config uploaded');
setOvpnText('');
onUploaded();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to upload OpenVPN config';
toastEvent(msg, 'error');
} finally {
setUploading(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-indigo-50 shrink-0">
<Lock className="h-5 w-5 text-indigo-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">OpenVPN</h3>
<p className="text-sm text-gray-500">
Route traffic through an OpenVPN tunnel
</p>
</div>
</div>
<StatusBadge status={status} />
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="ovpn-name"
className="text-sm font-medium text-gray-700"
>
Profile name <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="ovpn-name"
type="text"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="default"
className={`w-full rounded-md border px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 ${
nameInvalid
? 'border-red-300 focus:ring-red-400 focus:border-red-400'
: 'border-gray-300 focus:border-primary-500'
}`}
aria-required="true"
aria-describedby={nameInvalid ? 'ovpn-name-error' : undefined}
/>
{nameInvalid && (
<p id="ovpn-name-error" className="text-xs text-red-600" role="alert">
Profile name is required
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="ovpn-conf"
className="text-sm font-medium text-gray-700"
>
Paste .ovpn file contents
</label>
<textarea
id="ovpn-conf"
value={ovpnText}
onChange={(e) => setOvpnText(e.target.value)}
placeholder="client&#10;dev tun&#10;proto udp&#10;remote ..."
rows={6}
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"
/>
</div>
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleUpload}
disabled={uploading || !ovpnText.trim() || nameInvalid}
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="Upload OpenVPN config"
>
{uploading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{uploading ? 'Uploading…' : 'Upload Config'}
</button>
</div>
</div>
);
}
// Tor card
function TorExitCard({ exitInfo, onToggled }) {
const [toggling, setToggling] = useState(false);
const status = exitInfo?.status || 'not_configured';
const isEnabled = status === 'active' || status === 'configured';
const handleToggle = async () => {
setToggling(true);
try {
// Tor doesn't need a config upload apply routes enables/disables it
await connectivityAPI.applyRoutes();
toastEvent(isEnabled ? 'Tor exit disabled' : 'Tor exit enabled');
onToggled();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to toggle Tor';
toastEvent(msg, 'error');
} finally {
setToggling(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-purple-50 shrink-0">
<Globe className="h-5 w-5 text-purple-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">Tor</h3>
<p className="text-sm text-gray-500">
Route selected peers through the Tor anonymity network
</p>
</div>
</div>
<StatusBadge status={status} />
</div>
<p className="text-sm text-gray-500">
No configuration file required. Toggle the exit on or off peers
assigned to Tor will have their traffic routed accordingly.
</p>
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleToggle}
disabled={toggling}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isEnabled
? 'text-gray-700 bg-gray-100 hover:bg-gray-200'
: 'text-white bg-primary-600 hover:bg-primary-700'
}`}
aria-label={isEnabled ? 'Disable Tor exit' : 'Enable Tor exit'}
aria-pressed={isEnabled}
>
{toggling ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : isEnabled ? (
<ToggleRight className="h-4 w-4" />
) : (
<ToggleLeft className="h-4 w-4" />
)}
{toggling ? 'Applying…' : isEnabled ? 'Disable' : 'Enable'}
</button>
</div>
</div>
);
}
// Peer exit row
const EXIT_OPTIONS = [
{ value: 'default', label: 'Default (direct)' },
{ value: 'wireguard', label: 'WireGuard External' },
{ value: 'openvpn', label: 'OpenVPN' },
{ value: 'tor', label: 'Tor' },
];
function PeerExitRow({ peer, currentExit, onSaved }) {
const [selected, setSelected] = useState(currentExit || 'default');
const [saving, setSaving] = useState(false);
const isDirty = selected !== (currentExit || 'default');
const handleSave = async () => {
setSaving(true);
try {
await connectivityAPI.setPeerExit(peer.name, selected);
toastEvent(`Exit for ${peer.name} set to ${selected}`);
onSaved(peer.name, selected);
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
`Failed to update exit for ${peer.name}`;
toastEvent(msg, 'error');
} finally {
setSaving(false);
}
};
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]">
{peer.name}
</td>
<td className="py-3 px-4">
<span className="text-sm text-gray-500">
{EXIT_OPTIONS.find((o) => o.value === (currentExit || 'default'))
?.label || 'Default (direct)'}
</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 exit for ${peer.name}`}
>
{EXIT_OPTIONS.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 exit assignment for ${peer.name}`}
>
{saving && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{saving ? 'Saving…' : 'Save'}
</button>
</td>
</tr>
);
}
// Main Connectivity component
function Connectivity() {
const toasts = useToasts();
const [exits, setExits] = useState({}); // keyed by exit type
const [peerExits, setPeerExits] = useState({}); // peer_name -> exit_via
const [peers, setPeers] = useState([]); // WireGuard peer list
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [applying, setApplying] = useState(false);
const loadAll = useCallback(async () => {
setLoadError(null);
try {
const [exitsRes, peerExitsRes, peersRes] = await Promise.all([
connectivityAPI.listExits().catch(() => ({ data: {} })),
connectivityAPI.getPeerExits().catch(() => ({ data: {} })),
wireguardAPI.getPeers().catch(() => ({ data: { peers: [] } })),
]);
const exitsData = exitsRes.data || {};
// API may return array or object normalise to object keyed by type
if (Array.isArray(exitsData)) {
const map = {};
exitsData.forEach((e) => { map[e.type] = e; });
setExits(map);
} else {
setExits(exitsData);
}
const peerExitsData = peerExitsRes.data || {};
setPeerExits(
Array.isArray(peerExitsData)
? Object.fromEntries(peerExitsData.map((p) => [p.name, p.exit_via]))
: peerExitsData
);
const peersData = peersRes.data;
const peersList = Array.isArray(peersData)
? peersData
: Array.isArray(peersData?.peers)
? peersData.peers
: [];
setPeers(peersList);
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Could not load connectivity data. Check that the API is reachable.';
setLoadError(msg);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
const handleApplyRoutes = async () => {
setApplying(true);
try {
await connectivityAPI.applyRoutes();
toastEvent('Routes applied successfully');
await loadAll();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to apply routes';
toastEvent(msg, 'error');
} finally {
setApplying(false);
}
};
const handlePeerExitSaved = (peerName, exitVia) => {
setPeerExits((prev) => ({ ...prev, [peerName]: exitVia }));
};
// Render
return (
<div>
<Toast toasts={toasts} />
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Connectivity</h1>
<p className="mt-1 text-sm text-gray-500">
Configure exit tunnels and control how each peer's traffic is routed
</p>
</div>
{/* Loading skeleton */}
{isLoading && (
<div className="space-y-4 animate-pulse">
<div className="h-6 bg-gray-200 rounded w-48 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((n) => (
<div
key={n}
className="bg-white rounded-lg border border-gray-200 p-6 h-48"
>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
<div className="h-3 bg-gray-100 rounded w-3/4 mb-2" />
<div className="h-3 bg-gray-100 rounded w-2/3" />
</div>
))}
</div>
</div>
)}
{/* Error state */}
{!isLoading && loadError && (
<div className="bg-white rounded-lg border border-red-200 bg-red-50 p-6">
<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 connectivity data
</p>
<p className="text-sm text-red-600 mt-1">{loadError}</p>
</div>
<button
onClick={() => { setIsLoading(true); loadAll(); }}
className="btn-secondary text-sm shrink-0"
>
Retry
</button>
</div>
</div>
)}
{/* Main content */}
{!isLoading && !loadError && (
<div className="space-y-10">
{/* Section 1: Exit Tunnels */}
<section>
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-gray-900">
Exit Tunnels
</h2>
<p className="text-sm text-gray-500">
Upload VPN configs or enable Tor to create exit options for your
peers
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<WireguardExitCard
exitInfo={exits['wireguard'] || exits['wireguard_external']}
onUploaded={loadAll}
/>
<OpenvpnExitCard
exitInfo={exits['openvpn']}
onUploaded={loadAll}
/>
<TorExitCard
exitInfo={exits['tor']}
onToggled={loadAll}
/>
</div>
{/* Apply Routes */}
<div className="mt-6 flex items-center justify-between gap-4 bg-gray-50 border border-gray-200 rounded-lg px-5 py-4">
<div>
<p className="text-sm font-medium text-gray-800">
Apply exit routes
</p>
<p className="text-xs text-gray-500 mt-0.5">
Commit all exit-tunnel changes to the routing table
</p>
</div>
<button
onClick={handleApplyRoutes}
disabled={applying}
className="flex items-center gap-2 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 shrink-0"
aria-label="Apply exit routes"
>
<RefreshCw
className={`h-4 w-4 ${applying ? 'animate-spin' : ''}`}
/>
{applying ? 'Applying…' : 'Apply Routes'}
</button>
</div>
</section>
{/* Section 2: Peer Exit Assignment */}
<section>
<div className="mb-4">
<h2 className="text-base font-semibold text-gray-900">
Peer Exit Assignment
</h2>
<p className="text-sm text-gray-500">
Choose which exit tunnel each WireGuard peer uses
</p>
</div>
{peers.length === 0 ? (
<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" />
<p className="text-sm font-medium text-gray-500">
No WireGuard peers found
</p>
<p className="text-xs text-gray-400 mt-1">
Add peers on the WireGuard page first, then return here to
assign exits.
</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">
Peer Name
</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>
{peers.map((peer) => (
<PeerExitRow
key={peer.name}
peer={peer}
currentExit={peerExits[peer.name] || 'default'}
onSaved={handlePeerExitSaved}
/>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
)}
</div>
);
}
export default Connectivity;
+11
View File
@@ -328,6 +328,17 @@ export const setupAPI = {
complete: (payload) => api.post('/api/setup/complete', payload), complete: (payload) => api.post('/api/setup/complete', payload),
}; };
// Connectivity / Exit Routing API
export const connectivityAPI = {
getStatus: () => api.get('/api/connectivity/status'),
listExits: () => api.get('/api/connectivity/exits'),
uploadWireguard: (conf_text) => api.post('/api/connectivity/exits/wireguard', { conf_text }),
uploadOpenvpn: (ovpn_text, name = 'default') => api.post('/api/connectivity/exits/openvpn', { ovpn_text, name }),
applyRoutes: () => api.post('/api/connectivity/exits/apply'),
getPeerExits: () => api.get('/api/connectivity/peers'),
setPeerExit: (peer_name, exit_via) => api.put(`/api/connectivity/peers/${peer_name}/exit`, { exit_via }),
};
// Container Management API // Container Management API
export const containerAPI = { export const containerAPI = {
// Containers // Containers