Add double-VPN cascade from entry server to exit server.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,7 @@ from managers.awg_manager import AWGManager
|
|||||||
from managers.xray_manager import XrayManager
|
from managers.xray_manager import XrayManager
|
||||||
from managers.wireguard_manager import WireGuardManager
|
from managers.wireguard_manager import WireGuardManager
|
||||||
from managers.backup_manager import BackupManager
|
from managers.backup_manager import BackupManager
|
||||||
|
from managers.cascade_manager import CascadeManager
|
||||||
import telegram_bot as tg_bot
|
import telegram_bot as tg_bot
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -1443,6 +1444,13 @@ class ProtocolRequest(BaseModel):
|
|||||||
protocol: str = 'awg'
|
protocol: str = 'awg'
|
||||||
|
|
||||||
|
|
||||||
|
class CascadeSetupRequest(BaseModel):
|
||||||
|
entry_protocol: str = 'awg2'
|
||||||
|
exit_server_id: Optional[int] = None
|
||||||
|
exit_protocol: str = 'awg2'
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ContainerLogsRequest(BaseModel):
|
class ContainerLogsRequest(BaseModel):
|
||||||
protocol: str = 'awg'
|
protocol: str = 'awg'
|
||||||
tail: Optional[int] = 200
|
tail: Optional[int] = 200
|
||||||
@@ -1948,7 +1956,7 @@ async def server_detail(request: Request, server_id: int):
|
|||||||
return RedirectResponse(url='/')
|
return RedirectResponse(url='/')
|
||||||
server = data['servers'][server_id]
|
server = data['servers'][server_id]
|
||||||
users_list = data.get('users', [])
|
users_list = data.get('users', [])
|
||||||
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list)
|
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list, all_servers=data['servers'])
|
||||||
|
|
||||||
|
|
||||||
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
|
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
|
||||||
@@ -2759,6 +2767,185 @@ CONTAINER_NAMES = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/api/servers/{server_id}/cascade', tags=["Protocols"])
|
||||||
|
async def api_cascade_get(request: Request, server_id: int):
|
||||||
|
"""Return saved cascade settings and live tunnel status for this (entry) server."""
|
||||||
|
if not _check_admin(request):
|
||||||
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
|
try:
|
||||||
|
data = await load_data_async()
|
||||||
|
if server_id >= len(data['servers']):
|
||||||
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
|
server = data['servers'][server_id]
|
||||||
|
cascade = dict(server.get('cascade') or {})
|
||||||
|
live = {'enabled': False, 'up': False}
|
||||||
|
entry_proto = cascade.get('entry_protocol') or ''
|
||||||
|
if cascade.get('enabled') and entry_proto and CascadeManager.is_wg_family(entry_proto):
|
||||||
|
def _status():
|
||||||
|
ssh = get_ssh(server)
|
||||||
|
ssh.connect()
|
||||||
|
try:
|
||||||
|
return CascadeManager(ssh).status(entry_proto)
|
||||||
|
finally:
|
||||||
|
ssh.disconnect()
|
||||||
|
live = await asyncio.to_thread(_status)
|
||||||
|
return {
|
||||||
|
'cascade': cascade,
|
||||||
|
'live': live,
|
||||||
|
'servers': [
|
||||||
|
{
|
||||||
|
'id': i,
|
||||||
|
'name': s.get('name') or s.get('host'),
|
||||||
|
'host': s.get('host'),
|
||||||
|
'protocols': list((s.get('protocols') or {}).keys()),
|
||||||
|
}
|
||||||
|
for i, s in enumerate(data['servers']) if i != server_id
|
||||||
|
],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error reading cascade status")
|
||||||
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/api/servers/{server_id}/cascade/setup', tags=["Protocols"])
|
||||||
|
async def api_cascade_setup(request: Request, server_id: int, req: CascadeSetupRequest):
|
||||||
|
"""Enable or disable double-VPN cascade: clients → this entry → exit server."""
|
||||||
|
if not _check_admin(request):
|
||||||
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
|
if not CascadeManager.is_wg_family(req.entry_protocol):
|
||||||
|
return JSONResponse({'error': 'Cascade supports WireGuard / AmneziaWG only'}, status_code=400)
|
||||||
|
if req.enabled and not CascadeManager.is_wg_family(req.exit_protocol):
|
||||||
|
return JSONResponse({'error': 'Cascade supports WireGuard / AmneziaWG only'}, status_code=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await load_data_async()
|
||||||
|
if server_id >= len(data['servers']):
|
||||||
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
|
entry = data['servers'][server_id]
|
||||||
|
if not req.enabled:
|
||||||
|
def _disable():
|
||||||
|
ssh = get_ssh(entry)
|
||||||
|
ssh.connect()
|
||||||
|
try:
|
||||||
|
return CascadeManager(ssh).disable(req.entry_protocol)
|
||||||
|
finally:
|
||||||
|
ssh.disconnect()
|
||||||
|
|
||||||
|
result = await asyncio.to_thread(_disable)
|
||||||
|
if result.get('status') == 'error':
|
||||||
|
return JSONResponse({'error': result.get('message', 'Failed to disable cascade')}, status_code=500)
|
||||||
|
prev = dict(entry.get('cascade') or {})
|
||||||
|
entry['cascade'] = {
|
||||||
|
'enabled': False,
|
||||||
|
'entry_protocol': req.entry_protocol or prev.get('entry_protocol'),
|
||||||
|
'exit_server_id': prev.get('exit_server_id'),
|
||||||
|
'exit_protocol': prev.get('exit_protocol') or req.exit_protocol,
|
||||||
|
'updated_at': datetime.now().isoformat(timespec='seconds'),
|
||||||
|
}
|
||||||
|
await save_data_async(data)
|
||||||
|
return {'status': 'success', 'cascade': entry['cascade']}
|
||||||
|
|
||||||
|
if req.exit_server_id is None:
|
||||||
|
return JSONResponse({'error': 'exit_server_id is required'}, status_code=400)
|
||||||
|
if req.exit_server_id < 0 or req.exit_server_id >= len(data['servers']):
|
||||||
|
return JSONResponse({'error': 'Exit server not found'}, status_code=404)
|
||||||
|
if req.exit_server_id == server_id:
|
||||||
|
return JSONResponse({'error': 'Entry and exit servers must be different'}, status_code=400)
|
||||||
|
|
||||||
|
exit_srv = data['servers'][req.exit_server_id]
|
||||||
|
|
||||||
|
# Enable: create/reuse peer on exit, then apply tunnel on entry
|
||||||
|
def _enable():
|
||||||
|
exit_ssh = get_ssh(exit_srv)
|
||||||
|
exit_ssh.connect()
|
||||||
|
try:
|
||||||
|
exit_mgr = get_protocol_manager(exit_ssh, req.exit_protocol)
|
||||||
|
clients = _manager_call(exit_mgr, 'get_clients', req.exit_protocol) or []
|
||||||
|
cascade_name = f"cascade-from-{(entry.get('name') or entry.get('host') or 'entry')[:40]}"
|
||||||
|
cascade_name = re.sub(r'[^\w.\-]+', '_', cascade_name).strip('._') or 'cascade-entry'
|
||||||
|
existing = None
|
||||||
|
for c in clients:
|
||||||
|
ud = c.get('userData') or {}
|
||||||
|
if ud.get('clientName') == cascade_name or ud.get('cascade_entry_server_id') == server_id:
|
||||||
|
existing = c
|
||||||
|
break
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
client_id = existing.get('clientId')
|
||||||
|
conf = _manager_call(
|
||||||
|
exit_mgr, 'get_client_config', req.exit_protocol,
|
||||||
|
client_id, exit_srv['host'],
|
||||||
|
(exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
add_res = _manager_call(
|
||||||
|
exit_mgr, 'add_client', req.exit_protocol, cascade_name,
|
||||||
|
exit_srv['host'],
|
||||||
|
(exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'),
|
||||||
|
)
|
||||||
|
if not add_res.get('client_id'):
|
||||||
|
raise RuntimeError(add_res.get('message') or 'Failed to create cascade peer on exit')
|
||||||
|
client_id = add_res['client_id']
|
||||||
|
conf = add_res.get('config')
|
||||||
|
if not conf:
|
||||||
|
conf = _manager_call(
|
||||||
|
exit_mgr, 'get_client_config', req.exit_protocol,
|
||||||
|
client_id, exit_srv['host'],
|
||||||
|
(exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'),
|
||||||
|
)
|
||||||
|
# Mark peer in clientsTable metadata if possible
|
||||||
|
try:
|
||||||
|
table = exit_mgr._get_clients_table(req.exit_protocol) if hasattr(exit_mgr, '_get_clients_table') else []
|
||||||
|
for row in table:
|
||||||
|
if row.get('clientId') == client_id:
|
||||||
|
row.setdefault('userData', {})['cascade_entry_server_id'] = server_id
|
||||||
|
if hasattr(exit_mgr, '_save_clients_table'):
|
||||||
|
exit_mgr._save_clients_table(req.exit_protocol, table)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
exit_ssh.disconnect()
|
||||||
|
|
||||||
|
entry_ssh = get_ssh(entry)
|
||||||
|
entry_ssh.connect()
|
||||||
|
try:
|
||||||
|
applied = CascadeManager(entry_ssh).apply(
|
||||||
|
req.entry_protocol,
|
||||||
|
conf,
|
||||||
|
exit_srv.get('host') or '',
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
entry_ssh.disconnect()
|
||||||
|
|
||||||
|
if applied.get('status') != 'success':
|
||||||
|
raise RuntimeError(applied.get('message') or 'Failed to apply cascade on entry')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'client_id': client_id,
|
||||||
|
'client_name': cascade_name,
|
||||||
|
'applied': applied,
|
||||||
|
}
|
||||||
|
|
||||||
|
info = await asyncio.to_thread(_enable)
|
||||||
|
entry['cascade'] = {
|
||||||
|
'enabled': True,
|
||||||
|
'entry_protocol': req.entry_protocol,
|
||||||
|
'exit_server_id': req.exit_server_id,
|
||||||
|
'exit_protocol': req.exit_protocol,
|
||||||
|
'exit_client_id': info['client_id'],
|
||||||
|
'exit_client_name': info['client_name'],
|
||||||
|
'up': bool((info.get('applied') or {}).get('up')),
|
||||||
|
'updated_at': datetime.now().isoformat(timespec='seconds'),
|
||||||
|
'last_error': None,
|
||||||
|
}
|
||||||
|
await save_data_async(data)
|
||||||
|
return {'status': 'success', 'cascade': entry['cascade'], 'applied': info.get('applied')}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error setting up cascade")
|
||||||
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/servers/{server_id}/backups', tags=["Protocols"])
|
@app.post('/api/servers/{server_id}/backups', tags=["Protocols"])
|
||||||
async def api_protocol_backups_list(request: Request, server_id: int, req: ProtocolRequest):
|
async def api_protocol_backups_list(request: Request, server_id: int, req: ProtocolRequest):
|
||||||
"""List backups created on the remote server for one protocol."""
|
"""List backups created on the remote server for one protocol."""
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""Cascade (double-VPN) between two panel-managed WireGuard/AWG servers.
|
||||||
|
|
||||||
|
Traffic path:
|
||||||
|
Clients → Entry server (accessible) → Exit server (blocked / target) → Internet
|
||||||
|
|
||||||
|
Implemented inside the entry Docker container as a second AWG/WG interface
|
||||||
|
(`cascade.conf`) with source-based routing for the VPN client subnet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
WG_FAMILY = {'awg', 'awg2', 'awg_legacy', 'wireguard'}
|
||||||
|
CASCADE_MARKER = '# amnezia-web-panel-cascade'
|
||||||
|
|
||||||
|
|
||||||
|
class CascadeManager:
|
||||||
|
def __init__(self, ssh_manager):
|
||||||
|
self.ssh = ssh_manager
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def proto_base(protocol: str) -> str:
|
||||||
|
return str(protocol or '').split('__', 1)[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_wg_family(protocol: str) -> bool:
|
||||||
|
return CascadeManager.proto_base(protocol) in WG_FAMILY
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _container_name(protocol: str) -> str:
|
||||||
|
base = CascadeManager.proto_base(protocol)
|
||||||
|
match = re.search(r'__(\d+)$', str(protocol or ''))
|
||||||
|
idx = int(match.group(1)) if match else 1
|
||||||
|
names = {
|
||||||
|
'awg': 'amnezia-awg',
|
||||||
|
'awg2': 'amnezia-awg2',
|
||||||
|
'awg_legacy': 'amnezia-awg-legacy',
|
||||||
|
'wireguard': 'amnezia-wireguard',
|
||||||
|
}
|
||||||
|
name = names.get(base)
|
||||||
|
if not name:
|
||||||
|
return ''
|
||||||
|
return name if idx <= 1 else f'{name}-{idx}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wg_binary(protocol: str) -> str:
|
||||||
|
return 'wg' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _quick_binary(protocol: str) -> str:
|
||||||
|
return 'wg-quick' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg-quick'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _conf_dir(protocol: str) -> str:
|
||||||
|
return '/opt/amnezia/wireguard' if CascadeManager.proto_base(protocol) == 'wireguard' else '/opt/amnezia/awg'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cascade_conf(cls, protocol: str) -> str:
|
||||||
|
return f'{cls._conf_dir(protocol)}/cascade.conf'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cascade_up(cls, protocol: str) -> str:
|
||||||
|
return f'{cls._conf_dir(protocol)}/cascade-up.sh'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _server_conf(protocol: str) -> str:
|
||||||
|
base = CascadeManager.proto_base(protocol)
|
||||||
|
if base == 'wireguard':
|
||||||
|
return '/opt/amnezia/wireguard/wg0.conf'
|
||||||
|
if base == 'awg_legacy':
|
||||||
|
return '/opt/amnezia/awg/wg0.conf'
|
||||||
|
return '/opt/amnezia/awg/awg0.conf'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prepare_exit_client_config(raw_config: str, endpoint_host: str) -> str:
|
||||||
|
"""Adapt a normal client config for use as an entry→exit cascade tunnel."""
|
||||||
|
lines = []
|
||||||
|
in_interface = False
|
||||||
|
has_table = False
|
||||||
|
for raw in (raw_config or '').splitlines():
|
||||||
|
line = raw.rstrip('\n')
|
||||||
|
stripped = line.strip()
|
||||||
|
lower = stripped.lower()
|
||||||
|
if stripped.startswith('[') and stripped.endswith(']'):
|
||||||
|
in_interface = stripped.lower() == '[interface]'
|
||||||
|
lines.append(stripped)
|
||||||
|
continue
|
||||||
|
if in_interface and lower.startswith('dns'):
|
||||||
|
continue
|
||||||
|
if in_interface and lower.startswith('table'):
|
||||||
|
has_table = True
|
||||||
|
lines.append('Table = off')
|
||||||
|
continue
|
||||||
|
if lower.startswith('endpoint'):
|
||||||
|
port = '51820'
|
||||||
|
if '=' in stripped:
|
||||||
|
rhs = stripped.split('=', 1)[1].strip()
|
||||||
|
if ':' in rhs:
|
||||||
|
port = rhs.rsplit(':', 1)[-1]
|
||||||
|
host = (endpoint_host or '').strip()
|
||||||
|
if host:
|
||||||
|
lines.append(f'Endpoint = {host}:{port}')
|
||||||
|
else:
|
||||||
|
lines.append(stripped)
|
||||||
|
continue
|
||||||
|
lines.append(stripped)
|
||||||
|
|
||||||
|
if not has_table:
|
||||||
|
out = []
|
||||||
|
inserted = False
|
||||||
|
for line in lines:
|
||||||
|
out.append(line)
|
||||||
|
if not inserted and line.strip().lower() == '[interface]':
|
||||||
|
out.append('Table = off')
|
||||||
|
inserted = True
|
||||||
|
lines = out
|
||||||
|
return '\n'.join(lines).strip() + '\n'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_endpoint_host(config_text: str) -> str:
|
||||||
|
for line in (config_text or '').splitlines():
|
||||||
|
if line.strip().lower().startswith('endpoint'):
|
||||||
|
rhs = line.split('=', 1)[-1].strip()
|
||||||
|
return rhs.rsplit(':', 1)[0].strip().strip('[]')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def status(self, entry_protocol: str) -> dict:
|
||||||
|
container = self._container_name(entry_protocol)
|
||||||
|
if not container:
|
||||||
|
return {'enabled': False, 'up': False, 'error': 'Unsupported protocol'}
|
||||||
|
conf = self._cascade_conf(entry_protocol)
|
||||||
|
wg_bin = self._wg_binary(entry_protocol)
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {shlex.quote(container)} bash -lc "
|
||||||
|
f"'test -f {shlex.quote(conf)} && echo HAS_CONF; "
|
||||||
|
f"ip -o link show cascade 2>/dev/null | head -1; "
|
||||||
|
f"{wg_bin} show interfaces 2>/dev/null'"
|
||||||
|
)
|
||||||
|
text = out or ''
|
||||||
|
ifaces = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.strip() == 'HAS_CONF':
|
||||||
|
continue
|
||||||
|
ifaces.extend(line.split())
|
||||||
|
up = 'cascade' in ifaces or bool(re.search(r'\bcascade\b', text))
|
||||||
|
return {
|
||||||
|
'enabled': 'HAS_CONF' in text,
|
||||||
|
'up': up,
|
||||||
|
'raw': text.strip()[:2000],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _vpn_subnet(self, entry_protocol: str, container: str) -> str:
|
||||||
|
conf = self._server_conf(entry_protocol)
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {shlex.quote(container)} bash -lc "
|
||||||
|
f"\"grep -E '^Address' {shlex.quote(conf)} | head -1 | cut -d= -f2 | tr -d ' '\""
|
||||||
|
)
|
||||||
|
addr = (out or '').strip()
|
||||||
|
if not addr:
|
||||||
|
return '10.8.1.0/24'
|
||||||
|
if '/' in addr:
|
||||||
|
ip, cidr = addr.split('/', 1)
|
||||||
|
parts = ip.split('.')
|
||||||
|
if len(parts) == 4 and cidr.isdigit() and int(cidr) >= 24:
|
||||||
|
return f"{parts[0]}.{parts[1]}.{parts[2]}.0/{cidr}"
|
||||||
|
return addr
|
||||||
|
parts = addr.split('.')
|
||||||
|
if len(parts) == 4:
|
||||||
|
return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
|
||||||
|
return '10.8.1.0/24'
|
||||||
|
|
||||||
|
def apply(self, entry_protocol: str, exit_client_config: str, exit_host: str) -> dict:
|
||||||
|
container = self._container_name(entry_protocol)
|
||||||
|
if not container:
|
||||||
|
return {'status': 'error', 'message': 'Unsupported entry protocol'}
|
||||||
|
|
||||||
|
exists, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker inspect -f '{{{{.State.Running}}}}' {shlex.quote(container)} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or exists.strip().lower() != 'true':
|
||||||
|
return {'status': 'error', 'message': f'Entry container {container} is not running'}
|
||||||
|
|
||||||
|
cascade_conf_path = self._cascade_conf(entry_protocol)
|
||||||
|
cascade_up_path = self._cascade_up(entry_protocol)
|
||||||
|
cascade_conf = self.prepare_exit_client_config(exit_client_config, exit_host)
|
||||||
|
endpoint_host = self._extract_endpoint_host(cascade_conf) or exit_host
|
||||||
|
subnet = self._vpn_subnet(entry_protocol, container)
|
||||||
|
wg_bin = self._wg_binary(entry_protocol)
|
||||||
|
quick_bin = self._quick_binary(entry_protocol)
|
||||||
|
conf_dir = self._conf_dir(entry_protocol)
|
||||||
|
|
||||||
|
up_script = f"""#!/bin/bash
|
||||||
|
{CASCADE_MARKER}
|
||||||
|
set -e
|
||||||
|
CONF={shlex.quote(cascade_conf_path)}
|
||||||
|
QUICK={shlex.quote(quick_bin)}
|
||||||
|
SUBNET={shlex.quote(subnet)}
|
||||||
|
EXIT_HOST={shlex.quote(endpoint_host)}
|
||||||
|
IFACE=cascade
|
||||||
|
|
||||||
|
"$QUICK" down "$CONF" 2>/dev/null || true
|
||||||
|
ip link delete "$IFACE" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Resolve hostname to IP if needed (pin route must use IP)
|
||||||
|
EXIT_IP="$EXIT_HOST"
|
||||||
|
if ! printf '%s' "$EXIT_IP" | grep -Eq '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then
|
||||||
|
EXIT_IP=$(getent ahostsv4 "$EXIT_HOST" 2>/dev/null | awk '{{print $1; exit}}')
|
||||||
|
fi
|
||||||
|
GW=$(ip route | awk '/default/ {{print $3; exit}}')
|
||||||
|
DEV=$(ip route | awk '/default/ {{print $5; exit}}')
|
||||||
|
if [ -n "$EXIT_IP" ] && [ -n "$GW" ] && [ -n "$DEV" ]; then
|
||||||
|
ip route replace "$EXIT_IP/32" via "$GW" dev "$DEV" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$QUICK" up "$CONF"
|
||||||
|
|
||||||
|
ip route replace default dev "$IFACE" table 200 2>/dev/null || ip route add default dev "$IFACE" table 200
|
||||||
|
ip rule del from "$SUBNET" table 200 2>/dev/null || true
|
||||||
|
ip rule add from "$SUBNET" table 200 priority 100
|
||||||
|
|
||||||
|
for SRC_IF in awg0 wg0; do
|
||||||
|
iptables -C FORWARD -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null \\
|
||||||
|
|| iptables -I FORWARD 1 -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null || true
|
||||||
|
done
|
||||||
|
iptables -C FORWARD -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \\
|
||||||
|
|| iptables -I FORWARD 1 -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
iptables -t nat -C POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE 2>/dev/null \\
|
||||||
|
|| iptables -t nat -A POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE
|
||||||
|
|
||||||
|
echo CASCADE_UP_OK
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(f"docker exec {shlex.quote(container)} mkdir -p {shlex.quote(conf_dir)}")
|
||||||
|
self.ssh.upload_file(cascade_conf, '/tmp/_amnz_cascade.conf')
|
||||||
|
self.ssh.upload_file(up_script, '/tmp/_amnz_cascade_up.sh')
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp /tmp/_amnz_cascade.conf {shlex.quote(container)}:{shlex.quote(cascade_conf_path)} && "
|
||||||
|
f"docker cp /tmp/_amnz_cascade_up.sh {shlex.quote(container)}:{shlex.quote(cascade_up_path)} && "
|
||||||
|
f"docker exec {shlex.quote(container)} chmod 644 {shlex.quote(cascade_conf_path)} && "
|
||||||
|
f"docker exec {shlex.quote(container)} chmod +x {shlex.quote(cascade_up_path)}"
|
||||||
|
)
|
||||||
|
self.ssh.run_command('rm -f /tmp/_amnz_cascade.conf /tmp/_amnz_cascade_up.sh')
|
||||||
|
|
||||||
|
self._ensure_start_hook(container, cascade_up_path)
|
||||||
|
|
||||||
|
out, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {shlex.quote(container)} bash {shlex.quote(cascade_up_path)}",
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
if code != 0 or 'CASCADE_UP_OK' not in (out or ''):
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': (err or out or 'Failed to bring cascade interface up').strip()[:800],
|
||||||
|
}
|
||||||
|
|
||||||
|
st = self.status(entry_protocol)
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'subnet': subnet,
|
||||||
|
'endpoint': endpoint_host,
|
||||||
|
'container': container,
|
||||||
|
'up': bool(st.get('up')),
|
||||||
|
'applied_at': datetime.now().isoformat(timespec='seconds'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ensure_start_hook(self, container: str, cascade_up_path: str) -> None:
|
||||||
|
marker = CASCADE_MARKER
|
||||||
|
installer = f"""#!/bin/bash
|
||||||
|
set -e
|
||||||
|
START=/opt/amnezia/start.sh
|
||||||
|
[ -f "$START" ] || exit 0
|
||||||
|
grep -q '{marker}' "$START" 2>/dev/null && exit 0
|
||||||
|
printf '\\n%s\\n' '{marker}' >> "$START"
|
||||||
|
printf '%s\\n' 'if [ -x {cascade_up_path} ]; then bash {cascade_up_path} || true; fi' >> "$START"
|
||||||
|
"""
|
||||||
|
self.ssh.upload_file(installer, '/tmp/_amnz_cascade_hook.sh')
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp /tmp/_amnz_cascade_hook.sh {shlex.quote(container)}:/tmp/_hook.sh && "
|
||||||
|
f"docker exec {shlex.quote(container)} bash /tmp/_hook.sh && "
|
||||||
|
f"docker exec {shlex.quote(container)} rm -f /tmp/_hook.sh"
|
||||||
|
)
|
||||||
|
self.ssh.run_command('rm -f /tmp/_amnz_cascade_hook.sh')
|
||||||
|
|
||||||
|
def disable(self, entry_protocol: str) -> dict:
|
||||||
|
container = self._container_name(entry_protocol)
|
||||||
|
if not container:
|
||||||
|
return {'status': 'error', 'message': 'Unsupported entry protocol'}
|
||||||
|
conf = self._cascade_conf(entry_protocol)
|
||||||
|
up = self._cascade_up(entry_protocol)
|
||||||
|
quick_bin = self._quick_binary(entry_protocol)
|
||||||
|
# Avoid nested quotes issues: upload a small script
|
||||||
|
script = f"""#!/bin/bash
|
||||||
|
set +e
|
||||||
|
QUICK={shlex.quote(quick_bin)}
|
||||||
|
CONF={shlex.quote(conf)}
|
||||||
|
UP={shlex.quote(up)}
|
||||||
|
"$QUICK" down "$CONF" 2>/dev/null
|
||||||
|
ip link delete cascade 2>/dev/null
|
||||||
|
ip rule del table 200 2>/dev/null
|
||||||
|
ip rule del table 200 2>/dev/null
|
||||||
|
ip route flush table 200 2>/dev/null
|
||||||
|
rm -f "$CONF" "$UP"
|
||||||
|
echo CASCADE_DOWN_OK
|
||||||
|
"""
|
||||||
|
self.ssh.upload_file(script, '/tmp/_amnz_cascade_down.sh')
|
||||||
|
out, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker cp /tmp/_amnz_cascade_down.sh {shlex.quote(container)}:/tmp/_cascade_down.sh && "
|
||||||
|
f"docker exec {shlex.quote(container)} bash /tmp/_cascade_down.sh && "
|
||||||
|
f"docker exec {shlex.quote(container)} rm -f /tmp/_cascade_down.sh",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
self.ssh.run_command('rm -f /tmp/_amnz_cascade_down.sh')
|
||||||
|
if code != 0 and 'CASCADE_DOWN_OK' not in (out or ''):
|
||||||
|
return {'status': 'success', 'message': (err or out or 'Cascade cleared (best effort)').strip()[:400]}
|
||||||
|
return {'status': 'success'}
|
||||||
@@ -477,6 +477,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cascade (double VPN) -->
|
||||||
|
<section class="card" id="cascadeSection" style="margin-bottom: var(--space-xl);">
|
||||||
|
<div class="card-header" style="align-items:flex-start; flex-wrap:wrap; gap:var(--space-sm);">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title" style="margin:0;">{{ _('cascade_title') }}</h2>
|
||||||
|
<p class="form-hint" style="margin:var(--space-xs) 0 0;">{{ _('cascade_desc') }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-warn" id="cascadeStatusBadge">{{ _('cascade_off') }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 0 var(--space-md) var(--space-md);">
|
||||||
|
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md);">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('cascade_entry_protocol') }}</label>
|
||||||
|
<select class="form-select" id="cascadeEntryProtocol">
|
||||||
|
<option value="awg2">AmneziaWG 2.0</option>
|
||||||
|
<option value="awg">AmneziaWG</option>
|
||||||
|
<option value="awg_legacy">AWG Legacy</option>
|
||||||
|
<option value="wireguard">WireGuard</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('cascade_exit_server') }}</label>
|
||||||
|
<select class="form-select" id="cascadeExitServer" onchange="fillCascadeExitProtocols()">
|
||||||
|
<option value="">{{ _('cascade_exit_server_none') }}</option>
|
||||||
|
{% for s in all_servers %}
|
||||||
|
{% if loop.index0 != server_id %}
|
||||||
|
<option value="{{ loop.index0 }}"
|
||||||
|
data-protocols="{% for k in (s.protocols or {}).keys() %}{{ k }}{% if not loop.last %},{% endif %}{% endfor %}">
|
||||||
|
{{ s.name or s.host }} ({{ s.host }})
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{{ _('cascade_exit_protocol') }}</label>
|
||||||
|
<select class="form-select" id="cascadeExitProtocol">
|
||||||
|
<option value="awg2">AmneziaWG 2.0</option>
|
||||||
|
<option value="awg">AmneziaWG</option>
|
||||||
|
<option value="awg_legacy">AWG Legacy</option>
|
||||||
|
<option value="wireguard">WireGuard</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('cascade_hint') }}</div>
|
||||||
|
<div class="flex gap-sm" style="flex-wrap:wrap;">
|
||||||
|
<button type="button" class="btn btn-primary" id="cascadeEnableBtn" onclick="setupCascade(true)">
|
||||||
|
{{ _('cascade_enable') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cascadeDisableBtn" onclick="setupCascade(false)">
|
||||||
|
{{ _('cascade_disable') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="loadCascadeStatus()">{{ _('refresh') }}</button>
|
||||||
|
</div>
|
||||||
|
<div id="cascadeBusy" class="hidden" style="display:none; align-items:center; gap:var(--space-sm); margin-top:var(--space-md);">
|
||||||
|
<div class="spinner" style="width:18px;height:18px;border-width:2px;"></div>
|
||||||
|
<span id="cascadeBusyText">{{ _('cascade_working') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Connections Section (renamed from Clients) -->
|
<!-- Connections Section (renamed from Clients) -->
|
||||||
<div class="clients-section" id="connectionsSection" style="display:none;">
|
<div class="clients-section" id="connectionsSection" style="display:none;">
|
||||||
<div class="clients-header">
|
<div class="clients-header">
|
||||||
@@ -2607,8 +2668,107 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Cascade (double VPN) ==========
|
||||||
|
const CASCADE_PROTOS = ['awg2', 'awg', 'awg_legacy', 'wireguard'];
|
||||||
|
|
||||||
|
function fillCascadeExitProtocols() {
|
||||||
|
const sel = document.getElementById('cascadeExitServer');
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
const protoSel = document.getElementById('cascadeExitProtocol');
|
||||||
|
const raw = (opt && opt.dataset.protocols) ? opt.dataset.protocols.split(',').filter(Boolean) : [];
|
||||||
|
const available = raw.filter(p => CASCADE_PROTOS.includes(protoBase(p)));
|
||||||
|
const prev = protoSel.value;
|
||||||
|
protoSel.innerHTML = '';
|
||||||
|
const list = available.length ? available : CASCADE_PROTOS;
|
||||||
|
list.forEach(p => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = p;
|
||||||
|
o.textContent = getProtoTitle(p);
|
||||||
|
protoSel.appendChild(o);
|
||||||
|
});
|
||||||
|
if (list.includes(prev)) protoSel.value = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCascadeBusy(on, text) {
|
||||||
|
const busy = document.getElementById('cascadeBusy');
|
||||||
|
const busyText = document.getElementById('cascadeBusyText');
|
||||||
|
const en = document.getElementById('cascadeEnableBtn');
|
||||||
|
const dis = document.getElementById('cascadeDisableBtn');
|
||||||
|
if (busyText && text) busyText.textContent = text;
|
||||||
|
if (busy) {
|
||||||
|
busy.classList.toggle('hidden', !on);
|
||||||
|
busy.style.display = on ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (en) en.disabled = !!on;
|
||||||
|
if (dis) dis.disabled = !!on;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCascadeBadge(cascade, live) {
|
||||||
|
const badge = document.getElementById('cascadeStatusBadge');
|
||||||
|
if (!badge) return;
|
||||||
|
const enabled = !!(cascade && cascade.enabled);
|
||||||
|
const up = !!(live && live.up);
|
||||||
|
badge.className = 'badge ' + (enabled && up ? 'badge-success' : (enabled ? 'badge-warn' : 'badge-warn'));
|
||||||
|
if (!enabled) badge.textContent = _('cascade_off');
|
||||||
|
else if (up) badge.textContent = _('cascade_on');
|
||||||
|
else badge.textContent = _('cascade_configured_down');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCascadeStatus() {
|
||||||
|
try {
|
||||||
|
const res = await apiCall(`/api/servers/${SERVER_ID}/cascade`, 'POST', {});
|
||||||
|
const c = res.cascade || {};
|
||||||
|
if (c.entry_protocol) document.getElementById('cascadeEntryProtocol').value = c.entry_protocol;
|
||||||
|
if (c.exit_server_id !== undefined && c.exit_server_id !== null && c.exit_server_id !== '') {
|
||||||
|
document.getElementById('cascadeExitServer').value = String(c.exit_server_id);
|
||||||
|
fillCascadeExitProtocols();
|
||||||
|
}
|
||||||
|
if (c.exit_protocol) {
|
||||||
|
const exitProto = document.getElementById('cascadeExitProtocol');
|
||||||
|
if ([...exitProto.options].some(o => o.value === c.exit_protocol)) {
|
||||||
|
exitProto.value = c.exit_protocol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateCascadeBadge(c, res.live || {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupCascade(enable) {
|
||||||
|
const entryProtocol = document.getElementById('cascadeEntryProtocol').value;
|
||||||
|
const exitServerId = document.getElementById('cascadeExitServer').value;
|
||||||
|
const exitProtocol = document.getElementById('cascadeExitProtocol').value;
|
||||||
|
if (enable && exitServerId === '') {
|
||||||
|
showToast(_('cascade_exit_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enable && !confirm(_('cascade_enable_confirm'))) return;
|
||||||
|
if (!enable && !confirm(_('cascade_disable_confirm'))) return;
|
||||||
|
|
||||||
|
setCascadeBusy(true, enable ? _('cascade_working') : _('cascade_disabling'));
|
||||||
|
showToast(enable ? _('cascade_working') : _('cascade_disabling'), 'info');
|
||||||
|
try {
|
||||||
|
const res = await apiCall(`/api/servers/${SERVER_ID}/cascade/setup`, 'POST', {
|
||||||
|
enabled: !!enable,
|
||||||
|
entry_protocol: entryProtocol,
|
||||||
|
exit_server_id: parseInt(exitServerId || '0', 10),
|
||||||
|
exit_protocol: exitProtocol,
|
||||||
|
});
|
||||||
|
updateCascadeBadge(res.cascade || {}, { up: !!(res.cascade && res.cascade.up) });
|
||||||
|
showToast(enable ? _('cascade_enabled') : _('cascade_disabled'), 'success');
|
||||||
|
await loadCascadeStatus();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(_('error') + ': ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setCascadeBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Init ==========
|
// ========== Init ==========
|
||||||
applyInstalledAppsVisibility();
|
applyInstalledAppsVisibility();
|
||||||
|
fillCascadeExitProtocols();
|
||||||
|
loadCascadeStatus();
|
||||||
checkServer();
|
checkServer();
|
||||||
loadServerStats();
|
loadServerStats();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+20
-1
@@ -519,5 +519,24 @@
|
|||||||
"restore_protocol_backup_confirm": "Restore this backup on the server? Current protocol files will be overwritten and the container will restart.",
|
"restore_protocol_backup_confirm": "Restore this backup on the server? Current protocol files will be overwritten and the container will restart.",
|
||||||
"restoring_protocol_backup": "Restoring… please wait",
|
"restoring_protocol_backup": "Restoring… please wait",
|
||||||
"restore_protocol_backup_done": "Backup restored",
|
"restore_protocol_backup_done": "Backup restored",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh",
|
||||||
|
"cascade_title": "Cascade (double VPN)",
|
||||||
|
"cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.",
|
||||||
|
"cascade_hint": "Entry and exit must both have AmneziaWG/WireGuard installed. Give users the entry server config only.",
|
||||||
|
"cascade_entry_protocol": "Protocol on this server (entry)",
|
||||||
|
"cascade_exit_server": "Exit server",
|
||||||
|
"cascade_exit_server_none": "— Select server —",
|
||||||
|
"cascade_exit_protocol": "Protocol on exit server",
|
||||||
|
"cascade_enable": "Enable cascade",
|
||||||
|
"cascade_disable": "Disable cascade",
|
||||||
|
"cascade_working": "Setting up cascade…",
|
||||||
|
"cascade_disabling": "Disabling cascade…",
|
||||||
|
"cascade_enabled": "Cascade enabled",
|
||||||
|
"cascade_disabled": "Cascade disabled",
|
||||||
|
"cascade_off": "Off",
|
||||||
|
"cascade_on": "Active",
|
||||||
|
"cascade_configured_down": "Configured, tunnel down",
|
||||||
|
"cascade_exit_required": "Select an exit server",
|
||||||
|
"cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.",
|
||||||
|
"cascade_disable_confirm": "Disable cascade on this server?"
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -519,5 +519,24 @@
|
|||||||
"restore_protocol_backup_confirm": "Восстановить этот бекап на сервер? Текущие файлы протокола будут перезаписаны, контейнер перезапустится.",
|
"restore_protocol_backup_confirm": "Восстановить этот бекап на сервер? Текущие файлы протокола будут перезаписаны, контейнер перезапустится.",
|
||||||
"restoring_protocol_backup": "Восстановление… подождите",
|
"restoring_protocol_backup": "Восстановление… подождите",
|
||||||
"restore_protocol_backup_done": "Бекап восстановлен",
|
"restore_protocol_backup_done": "Бекап восстановлен",
|
||||||
"refresh": "Обновить"
|
"refresh": "Обновить",
|
||||||
|
"cascade_title": "Каскад (двойной VPN)",
|
||||||
|
"cascade_desc": "Клиенты подключаются к этому серверу (вход), а интернет выходит через другой сервер (выход) — чтобы обойти блокировку страны.",
|
||||||
|
"cascade_hint": "На входном сервере должен быть установлен AmneziaWG/WireGuard. На выходном — тоже. Пользователям раздавайте конфиг входного сервера.",
|
||||||
|
"cascade_entry_protocol": "Протокол на этом сервере (вход)",
|
||||||
|
"cascade_exit_server": "Сервер выхода",
|
||||||
|
"cascade_exit_server_none": "— Выберите сервер —",
|
||||||
|
"cascade_exit_protocol": "Протокол на сервере выхода",
|
||||||
|
"cascade_enable": "Включить каскад",
|
||||||
|
"cascade_disable": "Отключить каскад",
|
||||||
|
"cascade_working": "Настраиваю каскад…",
|
||||||
|
"cascade_disabling": "Отключаю каскад…",
|
||||||
|
"cascade_enabled": "Каскад включён",
|
||||||
|
"cascade_disabled": "Каскад отключён",
|
||||||
|
"cascade_off": "Выключен",
|
||||||
|
"cascade_on": "Активен",
|
||||||
|
"cascade_configured_down": "Настроен, туннель вниз",
|
||||||
|
"cascade_exit_required": "Выберите сервер выхода",
|
||||||
|
"cascade_enable_confirm": "Включить каскад? На выходном сервере будет создан служебный клиент, а на этом — туннель к нему.",
|
||||||
|
"cascade_disable_confirm": "Отключить каскад на этом сервере?"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user