Add double-VPN cascade from entry server to exit server.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 06:29:50 +03:00
co-authored by Cursor
parent e3ddc042c0
commit 8fa7f27223
5 changed files with 707 additions and 3 deletions
+188 -1
View File
@@ -46,6 +46,7 @@ from managers.awg_manager import AWGManager
from managers.xray_manager import XrayManager
from managers.wireguard_manager import WireGuardManager
from managers.backup_manager import BackupManager
from managers.cascade_manager import CascadeManager
import telegram_bot as tg_bot
# Configure logging
@@ -1443,6 +1444,13 @@ class ProtocolRequest(BaseModel):
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):
protocol: str = 'awg'
tail: Optional[int] = 200
@@ -1948,7 +1956,7 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/')
server = data['servers'][server_id]
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"])
@@ -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"])
async def api_protocol_backups_list(request: Request, server_id: int, req: ProtocolRequest):
"""List backups created on the remote server for one protocol."""
+319
View File
@@ -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'}
+160
View File
@@ -477,6 +477,67 @@
</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) -->
<div class="clients-section" id="connectionsSection" style="display:none;">
<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 ==========
applyInstalledAppsVisibility();
fillCascadeExitProtocols();
loadCascadeStatus();
checkServer();
loadServerStats();
</script>
+20 -1
View File
@@ -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.",
"restoring_protocol_backup": "Restoring… please wait",
"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
View File
@@ -519,5 +519,24 @@
"restore_protocol_backup_confirm": "Восстановить этот бекап на сервер? Текущие файлы протокола будут перезаписаны, контейнер перезапустится.",
"restoring_protocol_backup": "Восстановление… подождите",
"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": "Отключить каскад на этом сервере?"
}