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.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."""
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 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
@@ -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
@@ -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": "Отключить каскад на этом сервере?"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user