Release v2.2.0: restore safe Cascade double-VPN for servers.
Reintroduce entry-to-exit AmneziaWG/WireGuard cascade inspired by ryderams/amneziawg-cascade, with handshake and egress checks and no remote curl|bash.
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, normalize_settings, DEFAULT_SETTINGS as CASCADE_DEFAULT_SETTINGS
|
||||
import telegram_bot as tg_bot
|
||||
|
||||
# Configure logging
|
||||
@@ -101,7 +102,7 @@ else:
|
||||
application_path = os.path.dirname(__file__)
|
||||
|
||||
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
|
||||
CURRENT_VERSION = "v2.1.0"
|
||||
CURRENT_VERSION = "v2.2.0"
|
||||
RELEASES_REPO_URL = "https://git.evilfox.cc/test2/Amnezia-Web-Panel-main"
|
||||
RELEASES_API_LATEST = "https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest"
|
||||
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
||||
@@ -1742,6 +1743,24 @@ class HysteriaSettingsRequest(BaseModel):
|
||||
email: Optional[str] = None
|
||||
renew_ssl: bool = False
|
||||
|
||||
class CascadeSetupRequest(BaseModel):
|
||||
entry_protocol: str = 'awg2'
|
||||
exit_server_id: Optional[int] = None
|
||||
exit_protocol: str = 'awg2'
|
||||
enabled: bool = True
|
||||
# Per-step cascade tuning
|
||||
pin_exit_route: Optional[bool] = True
|
||||
remove_eth_masquerade: Optional[bool] = True
|
||||
force_forward: Optional[bool] = True
|
||||
mss_clamp: Optional[bool] = True
|
||||
wait_handshake: Optional[bool] = True
|
||||
handshake_timeout_sec: Optional[int] = 25
|
||||
allowed_ips: Optional[str] = '0.0.0.0/0'
|
||||
keep_exit_dns: Optional[bool] = False
|
||||
vpn_subnet_override: Optional[str] = ''
|
||||
table_id: Optional[int] = 200
|
||||
rule_priority: Optional[int] = 100
|
||||
verify_egress: Optional[bool] = True
|
||||
|
||||
class ProtocolRequest(BaseModel):
|
||||
protocol: str = 'awg'
|
||||
@@ -3231,6 +3250,239 @@ async def api_hysteria_update_settings(request: Request, server_id: int, req: Hy
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
@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 asyncio.to_thread(load_data)
|
||||
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,
|
||||
'defaults': CASCADE_DEFAULT_SETTINGS,
|
||||
'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 asyncio.to_thread(load_data)
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
entry = data['servers'][server_id]
|
||||
settings = normalize_settings({
|
||||
'pin_exit_route': req.pin_exit_route,
|
||||
'remove_eth_masquerade': req.remove_eth_masquerade,
|
||||
'force_forward': req.force_forward,
|
||||
'mss_clamp': req.mss_clamp,
|
||||
'wait_handshake': req.wait_handshake,
|
||||
'handshake_timeout_sec': req.handshake_timeout_sec,
|
||||
'allowed_ips': req.allowed_ips,
|
||||
'keep_exit_dns': req.keep_exit_dns,
|
||||
'vpn_subnet_override': req.vpn_subnet_override,
|
||||
'table_id': req.table_id,
|
||||
'rule_priority': req.rule_priority,
|
||||
'verify_egress': req.verify_egress,
|
||||
})
|
||||
if not req.enabled:
|
||||
def _disable():
|
||||
ssh = get_ssh(entry)
|
||||
ssh.connect()
|
||||
try:
|
||||
return CascadeManager(ssh).disable(
|
||||
req.entry_protocol,
|
||||
settings=dict((entry.get('cascade') or {}).get('settings') or settings),
|
||||
)
|
||||
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,
|
||||
'settings': settings,
|
||||
'updated_at': datetime.now().isoformat(timespec='seconds'),
|
||||
}
|
||||
async with DATA_LOCK:
|
||||
await asyncio.to_thread(save_data, 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]
|
||||
|
||||
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'),
|
||||
)
|
||||
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 '',
|
||||
settings=settings,
|
||||
)
|
||||
finally:
|
||||
entry_ssh.disconnect()
|
||||
|
||||
if applied.get('status') != 'success':
|
||||
raise RuntimeError(applied.get('message') or 'Failed to apply cascade on entry')
|
||||
|
||||
if settings.get('wait_handshake') and not applied.get('handshake'):
|
||||
try:
|
||||
entry_ssh2 = get_ssh(entry)
|
||||
entry_ssh2.connect()
|
||||
try:
|
||||
CascadeManager(entry_ssh2).disable(req.entry_protocol, settings=settings)
|
||||
finally:
|
||||
entry_ssh2.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(
|
||||
applied.get('message')
|
||||
or 'Cascade apply reported success but exit handshake is missing'
|
||||
)
|
||||
|
||||
peer_ip = applied.get('cascade_peer_ip') or ''
|
||||
if not peer_ip and conf:
|
||||
peer_ip = CascadeManager._extract_address_ip(conf)
|
||||
if peer_ip and hasattr(CascadeManager, 'ensure_exit_return_route'):
|
||||
exit_ssh2 = get_ssh(exit_srv)
|
||||
exit_ssh2.connect()
|
||||
try:
|
||||
route_res = CascadeManager(exit_ssh2).ensure_exit_return_route(
|
||||
req.exit_protocol, peer_ip
|
||||
)
|
||||
applied['exit_return_route'] = route_res
|
||||
finally:
|
||||
exit_ssh2.disconnect()
|
||||
|
||||
return {
|
||||
'client_id': client_id,
|
||||
'client_name': cascade_name,
|
||||
'applied': applied,
|
||||
}
|
||||
|
||||
info = await asyncio.to_thread(_enable)
|
||||
applied = info.get('applied') or {}
|
||||
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'],
|
||||
'settings': settings,
|
||||
'up': bool(applied.get('up')),
|
||||
'handshake': bool(applied.get('handshake')),
|
||||
'updated_at': datetime.now().isoformat(timespec='seconds'),
|
||||
'last_error': None,
|
||||
'diagnostics': applied.get('diagnostics') or '',
|
||||
}
|
||||
async with DATA_LOCK:
|
||||
await asyncio.to_thread(save_data, data)
|
||||
return {'status': 'success', 'cascade': entry['cascade'], 'applied': 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}/uninstall', tags=["Protocols"])
|
||||
async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest):
|
||||
if not _check_admin(request):
|
||||
|
||||
Reference in New Issue
Block a user