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:
+103
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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] PrivateKey = ... [Peer] 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 dev tun proto udp 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;
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user