Template
Ship AIVPN smart protocol picker and bump to v3.0.0.
Heuristic selection (stealth/balanced/speed) for invites, guest create, and user connections. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -103,7 +103,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.7.2"
|
||||
CURRENT_VERSION = "v3.0.0"
|
||||
RELEASES_REPO_URL = repo_url()
|
||||
RELEASES_API_LATEST = api_latest_url()
|
||||
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
|
||||
@@ -266,6 +266,7 @@ def tpl(request, template, **kwargs):
|
||||
'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))),
|
||||
# Keep for legacy JS; prefer translations_json on new pages
|
||||
'all_translations_json': json.dumps(TRANSLATIONS),
|
||||
'releases_repo_url': RELEASES_REPO_URL,
|
||||
}
|
||||
ctx.update(kwargs)
|
||||
return templates.TemplateResponse(template, ctx)
|
||||
@@ -4273,6 +4274,74 @@ async def api_server_migrate_import(
|
||||
return JSONResponse({'error': str(e)}, status_code=400)
|
||||
|
||||
|
||||
class AivpnSettingsRequest(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
strategy: Optional[str] = None # stealth | balanced | speed
|
||||
probe: Optional[bool] = None
|
||||
|
||||
|
||||
@app.get('/api/servers/{server_id}/aivpn', tags=["Servers"])
|
||||
async def api_aivpn_get(request: Request, server_id: int, probe: bool = False):
|
||||
"""AIVPN settings + ranked protocol recommendation for this server."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
from managers.aivpn_manager import get_aivpn_settings, pick_protocol
|
||||
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]
|
||||
settings = get_aivpn_settings(server)
|
||||
|
||||
live_status = {}
|
||||
try:
|
||||
# Reuse last known protocol metadata; optional live SSH probe of ports only.
|
||||
for key, info in (server.get('protocols') or {}).items():
|
||||
if isinstance(info, dict):
|
||||
live_status[key] = {
|
||||
'installed': bool(info.get('installed')),
|
||||
'port': info.get('port'),
|
||||
'container_running': bool(info.get('running')),
|
||||
'container_exists': bool(info.get('installed')),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
do_probe = bool(probe) if probe else bool(settings.get('probe'))
|
||||
picked = await asyncio.to_thread(
|
||||
pick_protocol,
|
||||
server,
|
||||
strategy=settings.get('strategy'),
|
||||
probe=do_probe,
|
||||
live_status=live_status,
|
||||
)
|
||||
return {
|
||||
'settings': settings,
|
||||
'recommendation': picked,
|
||||
'connect_domain': get_server_connect_host(server, (picked or {}).get('protocol')),
|
||||
}
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/aivpn', tags=["Servers"])
|
||||
async def api_aivpn_save(request: Request, server_id: int, req: AivpnSettingsRequest):
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
from managers.aivpn_manager import set_aivpn_settings, pick_protocol, get_aivpn_settings
|
||||
async with DATA_LOCK:
|
||||
data = load_data()
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
server = data['servers'][server_id]
|
||||
settings = set_aivpn_settings(
|
||||
server,
|
||||
enabled=req.enabled,
|
||||
strategy=req.strategy,
|
||||
probe=req.probe,
|
||||
)
|
||||
save_data(data)
|
||||
picked = pick_protocol(server, probe=False)
|
||||
return {'status': 'success', 'settings': settings, 'recommendation': picked}
|
||||
|
||||
|
||||
@app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"])
|
||||
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
|
||||
"""Download a ZIP with all reconstructable WireGuard/AWG client .conf files."""
|
||||
@@ -4812,7 +4881,13 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
|
||||
if server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
|
||||
if protocol_base(req.protocol) == 'xui':
|
||||
protocol = req.protocol
|
||||
server = data['servers'][server_id]
|
||||
if protocol_base(protocol) == 'aivpn':
|
||||
from managers.aivpn_manager import resolve_provision_protocol
|
||||
protocol = resolve_provision_protocol(server, 'aivpn')
|
||||
|
||||
if protocol_base(protocol) == 'xui':
|
||||
from managers.xui_api import xui_create_vless_config
|
||||
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
||||
ensure_xui_servers(data.setdefault('settings', {}))
|
||||
@@ -4846,16 +4921,15 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
|
||||
save_data(data)
|
||||
return result
|
||||
|
||||
server = data['servers'][server_id]
|
||||
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
||||
proto_info = server.get('protocols', {}).get(protocol, {})
|
||||
port = proto_info.get('port', '55424')
|
||||
ssh = get_ssh(server)
|
||||
ssh.connect()
|
||||
manager = get_protocol_manager(ssh, req.protocol)
|
||||
manager = get_protocol_manager(ssh, protocol)
|
||||
|
||||
if protocol_base(req.protocol) == 'telemt':
|
||||
if protocol_base(protocol) == 'telemt':
|
||||
result = manager.add_client(
|
||||
req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
|
||||
protocol, req.name, get_server_connect_host(server, protocol), port,
|
||||
telemt_quota=req.telemt_quota,
|
||||
telemt_max_ips=req.telemt_max_ips,
|
||||
telemt_expiry=req.telemt_expiry,
|
||||
@@ -4863,14 +4937,15 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
|
||||
user_ad_tag=req.telemt_ad_tag,
|
||||
max_tcp_conns=req.telemt_max_conns
|
||||
)
|
||||
elif protocol_base(req.protocol) == 'wireguard':
|
||||
result = manager.add_client(req.name, get_server_connect_host(server, req.protocol))
|
||||
elif protocol_base(protocol) == 'wireguard':
|
||||
result = manager.add_client(req.name, get_server_connect_host(server, protocol))
|
||||
else:
|
||||
result = manager.add_client(req.protocol, req.name, get_server_connect_host(server, req.protocol), port)
|
||||
result = manager.add_client(protocol, req.name, get_server_connect_host(server, protocol), port)
|
||||
ssh.disconnect()
|
||||
|
||||
if result.get('config'):
|
||||
result['vpn_link'] = generate_vpn_link(result['config'])
|
||||
result['protocol'] = protocol
|
||||
|
||||
# Link connection to user if specified
|
||||
if req.user_id and result.get('client_id'):
|
||||
@@ -4878,7 +4953,7 @@ async def api_add_connection(request: Request, server_id: int, req: AddConnectio
|
||||
'id': str(uuid.uuid4()),
|
||||
'user_id': req.user_id,
|
||||
'server_id': server_id,
|
||||
'protocol': req.protocol,
|
||||
'protocol': protocol,
|
||||
'client_id': result['client_id'],
|
||||
'name': req.name,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
@@ -5316,13 +5391,20 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
|
||||
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
||||
if not user:
|
||||
return JSONResponse({'error': 'User not found'}, status_code=404)
|
||||
if protocol_base(req.protocol) not in CLIENT_VPN_BASES:
|
||||
protocol = req.protocol
|
||||
if protocol_base(protocol) == 'aivpn':
|
||||
from managers.aivpn_manager import resolve_provision_protocol
|
||||
if req.server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
protocol = resolve_provision_protocol(data['servers'][req.server_id], 'aivpn')
|
||||
|
||||
if protocol_base(protocol) not in CLIENT_VPN_BASES:
|
||||
return JSONResponse(
|
||||
{'error': f'Protocol "{req.protocol}" does not support user connections'},
|
||||
{'error': f'Protocol "{protocol}" does not support user connections'},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if protocol_base(req.protocol) == 'xui':
|
||||
if protocol_base(protocol) == 'xui':
|
||||
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
||||
ensure_xui_servers(data.setdefault('settings', {}))
|
||||
panel = get_xui_server(data.get('settings') or {}, req.xui_panel_id)
|
||||
@@ -5378,23 +5460,23 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
|
||||
if req.server_id >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||
server = data['servers'][req.server_id]
|
||||
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
||||
proto_info = server.get('protocols', {}).get(protocol, {})
|
||||
port = proto_info.get('port', '55424')
|
||||
ssh = get_ssh(server)
|
||||
await asyncio.to_thread(ssh.connect)
|
||||
try:
|
||||
manager = get_protocol_manager(ssh, req.protocol)
|
||||
manager = get_protocol_manager(ssh, protocol)
|
||||
|
||||
if req.client_id:
|
||||
# Link existing client
|
||||
config = await asyncio.to_thread(
|
||||
_manager_call, manager, 'get_client_config',
|
||||
req.protocol, req.client_id, get_server_connect_host(server, req.protocol), port,
|
||||
protocol, req.client_id, get_server_connect_host(server, protocol), port,
|
||||
)
|
||||
result = {'client_id': req.client_id, 'config': config}
|
||||
elif protocol_base(req.protocol) == 'telemt':
|
||||
elif protocol_base(protocol) == 'telemt':
|
||||
result = await asyncio.to_thread(
|
||||
manager.add_client, req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
|
||||
manager.add_client, protocol, req.name, get_server_connect_host(server, protocol), port,
|
||||
telemt_quota=req.telemt_quota,
|
||||
telemt_max_ips=req.telemt_max_ips,
|
||||
telemt_expiry=req.telemt_expiry,
|
||||
@@ -5402,12 +5484,12 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
|
||||
user_ad_tag=req.telemt_ad_tag,
|
||||
max_tcp_conns=req.telemt_max_conns,
|
||||
)
|
||||
elif protocol_base(req.protocol) == 'wireguard':
|
||||
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, req.protocol))
|
||||
elif protocol_base(protocol) == 'wireguard':
|
||||
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, protocol))
|
||||
else:
|
||||
result = await asyncio.to_thread(
|
||||
_manager_call, manager, 'add_client',
|
||||
req.protocol, req.name, get_server_connect_host(server, req.protocol), port,
|
||||
protocol, req.name, get_server_connect_host(server, protocol), port,
|
||||
)
|
||||
finally:
|
||||
await asyncio.to_thread(ssh.disconnect)
|
||||
@@ -5417,7 +5499,7 @@ async def api_add_user_connection(request: Request, user_id: str, req: AddUserCo
|
||||
'id': str(uuid.uuid4()),
|
||||
'user_id': user_id,
|
||||
'server_id': req.server_id,
|
||||
'protocol': req.protocol,
|
||||
'protocol': protocol,
|
||||
'client_id': result['client_id'],
|
||||
'name': req.name,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
@@ -5788,6 +5870,13 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
|
||||
if user_is_expired(holder):
|
||||
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
|
||||
|
||||
if protocol_base(protocol) == 'aivpn':
|
||||
from managers.aivpn_manager import resolve_provision_protocol
|
||||
sid = int(guest.get('create_server_id') or 0)
|
||||
if sid >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Guest server not found'}, status_code=400)
|
||||
protocol = resolve_provision_protocol(data['servers'][sid], 'aivpn')
|
||||
|
||||
if protocol_base(protocol) == 'xui':
|
||||
from managers.xui_api import xui_create_vless_config
|
||||
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
||||
@@ -5939,6 +6028,12 @@ async def _create_config_for_protocol(
|
||||
) -> dict:
|
||||
"""Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}."""
|
||||
protocol = protocol or 'xui'
|
||||
if protocol_base(protocol) == 'aivpn':
|
||||
from managers.aivpn_manager import resolve_provision_protocol
|
||||
sid = int(server_id or 0)
|
||||
if sid >= len(data.get('servers') or []):
|
||||
raise RuntimeError('Server not found')
|
||||
protocol = resolve_provision_protocol(data['servers'][sid], 'aivpn')
|
||||
if protocol_base(protocol) == 'xui':
|
||||
from managers.xui_api import xui_create_vless_config
|
||||
from managers.xui_servers import get_xui_server, ensure_xui_servers
|
||||
@@ -6256,11 +6351,16 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request
|
||||
name = f"{name}_{secrets.token_hex(3)}"
|
||||
try:
|
||||
data = load_data()
|
||||
protocol = link.get('protocol') or 'xui'
|
||||
sid = int(link.get('server_id') or 0)
|
||||
if protocol_base(protocol) == 'aivpn' and sid < len(data.get('servers') or []):
|
||||
from managers.aivpn_manager import resolve_provision_protocol
|
||||
protocol = resolve_provision_protocol(data['servers'][sid], 'aivpn')
|
||||
created = await _create_config_for_protocol(
|
||||
data,
|
||||
protocol=link.get('protocol') or 'xui',
|
||||
protocol=protocol,
|
||||
name=name,
|
||||
server_id=int(link.get('server_id') or 0),
|
||||
server_id=sid,
|
||||
xui_inbound_id=int(link.get('xui_inbound_id') or 0) or None,
|
||||
xui_panel_id=link.get('xui_panel_id') or None,
|
||||
duration_days=int(link.get('duration_days') or 0),
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""AIVPN — heuristic protocol picker for Amnezia Web Panel.
|
||||
|
||||
Picks the best installed VPN protocol on a server by strategy
|
||||
(stealth / balanced / speed), optionally probing TCP reachability of ports.
|
||||
This is panel-side selection (no remote AI daemon): invites, guest create,
|
||||
and manual "pick now" use the same scorer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CLIENT_VPN_BASES = frozenset({
|
||||
'awg', 'awg2', 'awg_legacy', 'wireguard',
|
||||
'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru',
|
||||
})
|
||||
|
||||
# Higher = preferred for that strategy (0..100 base).
|
||||
STRATEGY_WEIGHTS = {
|
||||
'stealth': {
|
||||
'xray': 100,
|
||||
'mieru': 96,
|
||||
'hysteria': 92,
|
||||
'naiveproxy': 88,
|
||||
'telemt': 70,
|
||||
'awg2': 58,
|
||||
'awg': 52,
|
||||
'awg_legacy': 48,
|
||||
'wireguard': 40,
|
||||
},
|
||||
'speed': {
|
||||
'awg2': 100,
|
||||
'awg': 96,
|
||||
'wireguard': 92,
|
||||
'hysteria': 86,
|
||||
'mieru': 78,
|
||||
'xray': 70,
|
||||
'naiveproxy': 62,
|
||||
'awg_legacy': 58,
|
||||
'telemt': 45,
|
||||
},
|
||||
'balanced': {
|
||||
'hysteria': 94,
|
||||
'mieru': 92,
|
||||
'xray': 90,
|
||||
'awg2': 88,
|
||||
'awg': 82,
|
||||
'naiveproxy': 78,
|
||||
'wireguard': 70,
|
||||
'telemt': 65,
|
||||
'awg_legacy': 60,
|
||||
},
|
||||
}
|
||||
|
||||
STRATEGIES = frozenset(STRATEGY_WEIGHTS.keys())
|
||||
|
||||
|
||||
def protocol_base(protocol: str) -> str:
|
||||
return str(protocol or '').split('__', 1)[0]
|
||||
|
||||
|
||||
def get_aivpn_settings(server: dict) -> dict:
|
||||
info = server.get('server_info') or {}
|
||||
raw = info.get('aivpn') if isinstance(info, dict) else None
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
strategy = str(raw.get('strategy') or 'balanced').lower()
|
||||
if strategy not in STRATEGIES:
|
||||
strategy = 'balanced'
|
||||
return {
|
||||
'enabled': bool(raw.get('enabled')),
|
||||
'strategy': strategy,
|
||||
'probe': bool(raw.get('probe', True)),
|
||||
}
|
||||
|
||||
|
||||
def set_aivpn_settings(server: dict, *, enabled: Optional[bool] = None,
|
||||
strategy: Optional[str] = None,
|
||||
probe: Optional[bool] = None) -> dict:
|
||||
info = dict(server.get('server_info') or {})
|
||||
cur = get_aivpn_settings(server)
|
||||
if enabled is not None:
|
||||
cur['enabled'] = bool(enabled)
|
||||
if strategy is not None:
|
||||
s = str(strategy).lower()
|
||||
cur['strategy'] = s if s in STRATEGIES else 'balanced'
|
||||
if probe is not None:
|
||||
cur['probe'] = bool(probe)
|
||||
info['aivpn'] = cur
|
||||
server['server_info'] = info
|
||||
return cur
|
||||
|
||||
|
||||
def _tcp_rtt_ms(host: str, port: int, timeout: float = 1.2) -> Optional[float]:
|
||||
if not host or not port:
|
||||
return None
|
||||
try:
|
||||
port = int(port)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if port < 1 or port > 65535:
|
||||
return None
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return round((time.perf_counter() - t0) * 1000, 1)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_protocols(server: dict) -> list[str]:
|
||||
protocols = server.get('protocols') or {}
|
||||
out = []
|
||||
for key, info in protocols.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
if not info.get('installed'):
|
||||
continue
|
||||
if protocol_base(key) not in CLIENT_VPN_BASES:
|
||||
continue
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def score_protocols(
|
||||
server: dict,
|
||||
*,
|
||||
strategy: str = 'balanced',
|
||||
probe: bool = False,
|
||||
live_status: Optional[dict] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return ranked protocol candidates with scores and reasons."""
|
||||
strategy = strategy if strategy in STRATEGIES else 'balanced'
|
||||
weights = STRATEGY_WEIGHTS[strategy]
|
||||
host = (server.get('host') or '').strip()
|
||||
protocols = server.get('protocols') or {}
|
||||
live_status = live_status or {}
|
||||
ranked = []
|
||||
|
||||
for key in _candidate_protocols(server):
|
||||
base = protocol_base(key)
|
||||
info = protocols.get(key) or {}
|
||||
live = live_status.get(key) or {}
|
||||
reasons = []
|
||||
score = float(weights.get(base, 50))
|
||||
reasons.append(f'base:{strategy}={int(score)}')
|
||||
|
||||
running = bool(live.get('container_running') or live.get('running'))
|
||||
exists = bool(
|
||||
live.get('container_exists')
|
||||
or live.get('installed')
|
||||
or info.get('installed')
|
||||
)
|
||||
if running:
|
||||
score += 18
|
||||
reasons.append('+running')
|
||||
elif exists:
|
||||
score -= 8
|
||||
reasons.append('-not_running')
|
||||
else:
|
||||
score -= 40
|
||||
reasons.append('-missing')
|
||||
|
||||
port = live.get('port') or info.get('port')
|
||||
rtt = None
|
||||
if probe and host and port and base != 'telemt':
|
||||
# Telemt often shares 443 with other services; skip noisy probes.
|
||||
rtt = _tcp_rtt_ms(host, int(port))
|
||||
if rtt is None:
|
||||
score -= 25
|
||||
reasons.append('-port_unreachable')
|
||||
elif rtt < 40:
|
||||
score += 12
|
||||
reasons.append(f'+rtt:{rtt}ms')
|
||||
elif rtt < 120:
|
||||
score += 6
|
||||
reasons.append(f'+rtt:{rtt}ms')
|
||||
elif rtt < 250:
|
||||
reasons.append(f'rtt:{rtt}ms')
|
||||
else:
|
||||
score -= 8
|
||||
reasons.append(f'-slow:{rtt}ms')
|
||||
|
||||
ranked.append({
|
||||
'protocol': key,
|
||||
'base': base,
|
||||
'score': round(score, 1),
|
||||
'port': port,
|
||||
'running': running,
|
||||
'rtt_ms': rtt,
|
||||
'reasons': reasons,
|
||||
})
|
||||
|
||||
ranked.sort(key=lambda x: (-x['score'], x['protocol']))
|
||||
return ranked
|
||||
|
||||
|
||||
def pick_protocol(
|
||||
server: dict,
|
||||
*,
|
||||
strategy: Optional[str] = None,
|
||||
probe: Optional[bool] = None,
|
||||
live_status: Optional[dict] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
cfg = get_aivpn_settings(server)
|
||||
strat = strategy or cfg['strategy']
|
||||
do_probe = cfg['probe'] if probe is None else bool(probe)
|
||||
ranked = score_protocols(
|
||||
server,
|
||||
strategy=strat,
|
||||
probe=do_probe,
|
||||
live_status=live_status,
|
||||
)
|
||||
if not ranked:
|
||||
return None
|
||||
best = ranked[0]
|
||||
return {
|
||||
'protocol': best['protocol'],
|
||||
'base': best['base'],
|
||||
'score': best['score'],
|
||||
'strategy': strat if strat in STRATEGIES else 'balanced',
|
||||
'port': best.get('port'),
|
||||
'rtt_ms': best.get('rtt_ms'),
|
||||
'reasons': best.get('reasons') or [],
|
||||
'alternatives': ranked[1:5],
|
||||
'all': ranked,
|
||||
}
|
||||
|
||||
|
||||
def resolve_provision_protocol(server: dict, requested: Optional[str] = None) -> str:
|
||||
"""If requested is 'aivpn' (or empty while AIVPN enabled), pick automatically."""
|
||||
req = (requested or '').strip()
|
||||
cfg = get_aivpn_settings(server)
|
||||
if req and protocol_base(req) != 'aivpn':
|
||||
return req
|
||||
if req == 'aivpn' or (not req and cfg.get('enabled')):
|
||||
picked = pick_protocol(server, probe=False)
|
||||
if picked and picked.get('protocol'):
|
||||
return picked['protocol']
|
||||
return req or 'awg'
|
||||
@@ -1162,6 +1162,20 @@ a:hover {
|
||||
text-shadow: 0 0 16px rgba(168, 85, 247, 0.6);
|
||||
}
|
||||
|
||||
.promo-aivpn .aivpn-panel .btn-primary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
.promo-aivpn .aivpn-panel .btn-primary:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.promo-aivpn .aivpn-panel .btn-secondary {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ----- Theme: Reverse Proxy (amber/red, shield/firewall feel) ----- */
|
||||
.promo-revproxy {
|
||||
background:
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
||||
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
|
||||
const PROTO_TITLES = {
|
||||
const PROTO_TITLES = {
|
||||
awg2: 'AmneziaWG 2.0',
|
||||
awg: 'AmneziaWG',
|
||||
awg_legacy: 'AWG Legacy',
|
||||
@@ -207,6 +207,7 @@
|
||||
hysteria: 'Hysteria 2',
|
||||
naiveproxy: 'NaiveProxy',
|
||||
mieru: 'Mieru',
|
||||
aivpn: 'AIVPN (auto)',
|
||||
xui: '3x-ui VLESS',
|
||||
};
|
||||
|
||||
@@ -264,6 +265,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const aivpnOpt = document.createElement('option');
|
||||
aivpnOpt.value = 'aivpn';
|
||||
aivpnOpt.textContent = _('aivpn_protocol_option') || 'AIVPN (auto)';
|
||||
if (preferProtocol === 'aivpn') aivpnOpt.selected = true;
|
||||
sel.appendChild(aivpnOpt);
|
||||
|
||||
installed.forEach(key => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = key;
|
||||
|
||||
+116
-26
@@ -412,24 +412,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== AIVPN teaser (locked, full grid width) ===== -->
|
||||
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
|
||||
<!-- ===== AIVPN (smart protocol picker) ===== -->
|
||||
<div class="promo-block promo-aivpn" id="aivpnCard" role="article">
|
||||
<div class="promo-orbs" aria-hidden="true">
|
||||
<span class="promo-orb"></span>
|
||||
<span class="promo-orb"></span>
|
||||
<span class="promo-orb"></span>
|
||||
</div>
|
||||
<span class="promo-lock-badge">{{ icon('lock') }} {{ _('coming_soon') }}</span>
|
||||
<div class="promo-content">
|
||||
<span class="promo-lock-badge" id="aivpnBadge">{{ icon('brain') }} AIVPN</span>
|
||||
<div class="promo-content" style="flex-wrap: wrap;">
|
||||
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</div>
|
||||
<div class="promo-text">
|
||||
<div class="promo-text" style="flex: 1; min-width: 200px;">
|
||||
<div class="promo-title">AIVPN</div>
|
||||
<div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div>
|
||||
</div>
|
||||
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
|
||||
<a class="promo-cta" href="{{ releases_repo_url }}" target="_blank" rel="noopener">
|
||||
⭐ <span>{{ _('promo_star_cta') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="aivpn-panel" style="position:relative; z-index:1; margin-top: var(--space-md); display:flex; flex-direction:column; gap: var(--space-sm);">
|
||||
<div style="display:flex; flex-wrap:wrap; gap: var(--space-sm); align-items:center;">
|
||||
<select class="form-input" id="aivpnStrategy" style="width:auto; min-width:140px; background:rgba(0,0,0,0.25); color:#fff; border-color:rgba(255,255,255,0.25);">
|
||||
<option value="balanced">{{ _('aivpn_strategy_balanced') }}</option>
|
||||
<option value="stealth">{{ _('aivpn_strategy_stealth') }}</option>
|
||||
<option value="speed">{{ _('aivpn_strategy_speed') }}</option>
|
||||
</select>
|
||||
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:0.85rem;">
|
||||
<input type="checkbox" id="aivpnEnabled"> {{ _('aivpn_enable') }}
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:6px; color:#fff; font-size:0.85rem;">
|
||||
<input type="checkbox" id="aivpnProbe" checked> {{ _('aivpn_probe') }}
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="saveAivpnSettings()" id="aivpnSaveBtn">{{ _('save') }}</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="runAivpnPick()" id="aivpnPickBtn">{{ _('aivpn_pick_now') }}</button>
|
||||
</div>
|
||||
<div class="text-sm" id="aivpnResult" style="color: rgba(255,255,255,0.9);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DNS Card -->
|
||||
@@ -551,25 +571,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
|
||||
<div class="promo-orbs" aria-hidden="true">
|
||||
<span class="promo-orb"></span>
|
||||
<span class="promo-orb"></span>
|
||||
<span class="promo-orb"></span>
|
||||
</div>
|
||||
<span class="promo-lock-badge">{{ icon('lock') }} Coming soon</span>
|
||||
<div class="promo-content">
|
||||
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</div>
|
||||
<div class="promo-text">
|
||||
<div class="promo-title">AIVPN</div>
|
||||
<div class="promo-subtitle">AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.</div>
|
||||
</div>
|
||||
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
|
||||
⭐ <span>Star us on GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Connections Section (renamed from Clients) -->
|
||||
<div class="clients-section" id="connectionsSection" style="display:none;">
|
||||
@@ -1227,6 +1228,7 @@
|
||||
const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }};
|
||||
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
|
||||
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
|
||||
let aivpnEnabled = {{ (((server.server_info or {}).get('aivpn') or {}).get('enabled') or false) | tojson }};
|
||||
const MARKETPLACE_APPS = [
|
||||
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' },
|
||||
{ proto: 'awg', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
|
||||
@@ -1647,6 +1649,7 @@
|
||||
case 'naiveproxy': title = 'NaiveProxy'; break;
|
||||
case 'mieru': title = 'Mieru'; break;
|
||||
case 'wireguard': title = 'WireGuard'; break;
|
||||
case 'aivpn': title = 'AIVPN'; break;
|
||||
case 'dns': title = 'AmneziaDNS'; break;
|
||||
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
||||
case 'adguard': title = 'AdGuard Home'; break;
|
||||
@@ -3051,7 +3054,7 @@
|
||||
selectedConnectionIds.clear();
|
||||
updateMoveSelection();
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${encodeURIComponent(proto)}`);
|
||||
loading.style.display = 'none';
|
||||
if (!data.clients || data.clients.length === 0) {
|
||||
emptyEl.classList.remove('hidden');
|
||||
@@ -3399,9 +3402,96 @@
|
||||
|
||||
|
||||
|
||||
// ========== AIVPN ==========
|
||||
function formatAivpnRecommendation(rec) {
|
||||
if (!rec || !rec.protocol) {
|
||||
return _('aivpn_no_candidates');
|
||||
}
|
||||
const rtt = (rec.rtt_ms != null) ? ` · ${rec.rtt_ms} ms` : '';
|
||||
const alts = (rec.alternatives || []).slice(0, 3)
|
||||
.map(a => getProtoTitle(a.protocol))
|
||||
.filter(Boolean);
|
||||
const altTxt = alts.length ? ` · ${_('aivpn_alternatives')}: ${alts.join(', ')}` : '';
|
||||
return `${_('aivpn_picked')}: <strong>${getProtoTitle(rec.protocol)}</strong> (score ${Math.round(rec.score || 0)}${rtt})${altTxt}`;
|
||||
}
|
||||
|
||||
function applyAivpnUi(data) {
|
||||
const settings = (data && data.settings) || {};
|
||||
aivpnEnabled = !!settings.enabled;
|
||||
const strat = document.getElementById('aivpnStrategy');
|
||||
const en = document.getElementById('aivpnEnabled');
|
||||
const probe = document.getElementById('aivpnProbe');
|
||||
if (strat && settings.strategy) strat.value = settings.strategy;
|
||||
if (en) en.checked = aivpnEnabled;
|
||||
if (probe) probe.checked = settings.probe !== false;
|
||||
const badge = document.getElementById('aivpnBadge');
|
||||
if (badge) {
|
||||
badge.innerHTML = aivpnEnabled
|
||||
? `${uiIcon('brain')} AIVPN · ${_('aivpn_on')}`
|
||||
: `${uiIcon('brain')} AIVPN`;
|
||||
}
|
||||
const resultEl = document.getElementById('aivpnResult');
|
||||
if (resultEl) resultEl.innerHTML = formatAivpnRecommendation(data && data.recommendation);
|
||||
}
|
||||
|
||||
async function loadAivpnSettings(doProbe) {
|
||||
try {
|
||||
const q = doProbe ? '?probe=1' : '?probe=0';
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn${q}`);
|
||||
applyAivpnUi(data);
|
||||
} catch (err) {
|
||||
console.warn('AIVPN load failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAivpnSettings() {
|
||||
const btn = document.getElementById('aivpnSaveBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn`, 'POST', {
|
||||
enabled: document.getElementById('aivpnEnabled').checked,
|
||||
strategy: document.getElementById('aivpnStrategy').value,
|
||||
probe: document.getElementById('aivpnProbe').checked,
|
||||
});
|
||||
applyAivpnUi(data);
|
||||
showToast(_('settings_saved'), 'success');
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAivpnPick() {
|
||||
const btn = document.getElementById('aivpnPickBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
const resultEl = document.getElementById('aivpnResult');
|
||||
if (resultEl) resultEl.textContent = _('aivpn_picking');
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/aivpn?probe=1`);
|
||||
applyAivpnUi(data);
|
||||
const picked = data.recommendation && data.recommendation.protocol;
|
||||
if (!picked) {
|
||||
showToast(_('aivpn_no_candidates'), 'warning');
|
||||
} else {
|
||||
const sel = document.getElementById('connProtoSelect');
|
||||
if (sel && [...sel.options].some(o => o.value === picked)) {
|
||||
sel.value = picked;
|
||||
loadConnections();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (resultEl) resultEl.textContent = '';
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Init ==========
|
||||
applyInstalledAppsVisibility();
|
||||
checkServer();
|
||||
loadServerStats();
|
||||
loadAivpnSettings(false);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1560,6 +1560,7 @@
|
||||
hysteria: 'Hysteria 2',
|
||||
naiveproxy: 'NaiveProxy',
|
||||
mieru: 'Mieru',
|
||||
aivpn: 'AIVPN (auto)',
|
||||
xui: '3x-ui VLESS',
|
||||
};
|
||||
function protoTitle(key) {
|
||||
@@ -1602,6 +1603,12 @@
|
||||
opt.textContent = _('no_protocols');
|
||||
protoSel.appendChild(opt);
|
||||
} else {
|
||||
const aivpnOpt = document.createElement('option');
|
||||
aivpnOpt.value = 'aivpn';
|
||||
aivpnOpt.textContent = _('aivpn_protocol_option') || 'AIVPN (auto)';
|
||||
if (prefer === 'aivpn') aivpnOpt.selected = true;
|
||||
protoSel.appendChild(aivpnOpt);
|
||||
|
||||
installed.forEach(key => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = key;
|
||||
|
||||
@@ -685,6 +685,11 @@
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
const aivpnOpt = document.createElement('option');
|
||||
aivpnOpt.value = 'aivpn';
|
||||
aivpnOpt.textContent = _('aivpn_protocol_option') || 'AIVPN (auto)';
|
||||
select.insertBefore(aivpnOpt, select.firstChild);
|
||||
count++;
|
||||
if (group) group.style.display = '';
|
||||
} else {
|
||||
const opt = document.createElement('option');
|
||||
@@ -766,6 +771,13 @@
|
||||
});
|
||||
|
||||
document.getElementById('ucProtocol').addEventListener('change', (e) => {
|
||||
if (e.target.value === 'aivpn') {
|
||||
const mode = document.getElementById('ucMode');
|
||||
if (mode && mode.value === 'existing') {
|
||||
mode.value = 'new';
|
||||
toggleUCMode();
|
||||
}
|
||||
}
|
||||
if (document.getElementById('ucMode').value === 'existing') {
|
||||
fetchExistingClients();
|
||||
}
|
||||
|
||||
@@ -525,6 +525,18 @@
|
||||
"coming_soon": "Coming soon",
|
||||
"promo_star_cta": "Star us on GitHub",
|
||||
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||
"aivpn_enable": "Enabled",
|
||||
"aivpn_probe": "Probe ports",
|
||||
"aivpn_pick_now": "Pick now",
|
||||
"aivpn_strategy_balanced": "Balanced",
|
||||
"aivpn_strategy_stealth": "Stealth",
|
||||
"aivpn_strategy_speed": "Speed",
|
||||
"aivpn_picked": "Best protocol",
|
||||
"aivpn_alternatives": "also",
|
||||
"aivpn_no_candidates": "No installed VPN protocols to pick from",
|
||||
"aivpn_picking": "Scoring protocols…",
|
||||
"aivpn_on": "On",
|
||||
"aivpn_protocol_option": "AIVPN (auto)",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||
"management": "Management",
|
||||
|
||||
@@ -479,6 +479,18 @@
|
||||
"coming_soon": "Coming soon",
|
||||
"promo_star_cta": "Star us on GitHub",
|
||||
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||
"aivpn_enable": "Enabled",
|
||||
"aivpn_probe": "Probe ports",
|
||||
"aivpn_pick_now": "Pick now",
|
||||
"aivpn_strategy_balanced": "Balanced",
|
||||
"aivpn_strategy_stealth": "Stealth",
|
||||
"aivpn_strategy_speed": "Speed",
|
||||
"aivpn_picked": "Best protocol",
|
||||
"aivpn_alternatives": "also",
|
||||
"aivpn_no_candidates": "No installed VPN protocols to pick from",
|
||||
"aivpn_picking": "Scoring protocols…",
|
||||
"aivpn_on": "On",
|
||||
"aivpn_protocol_option": "AIVPN (auto)",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||
|
||||
@@ -479,6 +479,18 @@
|
||||
"coming_soon": "Coming soon",
|
||||
"promo_star_cta": "Star us on GitHub",
|
||||
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||
"aivpn_enable": "Enabled",
|
||||
"aivpn_probe": "Probe ports",
|
||||
"aivpn_pick_now": "Pick now",
|
||||
"aivpn_strategy_balanced": "Balanced",
|
||||
"aivpn_strategy_stealth": "Stealth",
|
||||
"aivpn_strategy_speed": "Speed",
|
||||
"aivpn_picked": "Best protocol",
|
||||
"aivpn_alternatives": "also",
|
||||
"aivpn_no_candidates": "No installed VPN protocols to pick from",
|
||||
"aivpn_picking": "Scoring protocols…",
|
||||
"aivpn_on": "On",
|
||||
"aivpn_protocol_option": "AIVPN (auto)",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||
|
||||
@@ -525,6 +525,18 @@
|
||||
"coming_soon": "Скоро",
|
||||
"promo_star_cta": "Поставить звезду на GitHub",
|
||||
"aivpn_subtitle": "ИИ-подбор протокола, который сам выбирает правильный туннель под текущие условия. Поможете звездой — выйдет быстрее.",
|
||||
"aivpn_enable": "Включено",
|
||||
"aivpn_probe": "Проверять порты",
|
||||
"aivpn_pick_now": "Подобрать сейчас",
|
||||
"aivpn_strategy_balanced": "Баланс",
|
||||
"aivpn_strategy_stealth": "Скрытность",
|
||||
"aivpn_strategy_speed": "Скорость",
|
||||
"aivpn_picked": "Лучший протокол",
|
||||
"aivpn_alternatives": "также",
|
||||
"aivpn_no_candidates": "Нет установленных VPN-протоколов для выбора",
|
||||
"aivpn_picking": "Оценка протоколов…",
|
||||
"aivpn_on": "Вкл",
|
||||
"aivpn_protocol_option": "AIVPN (авто)",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.",
|
||||
"management": "Управление",
|
||||
|
||||
@@ -479,6 +479,18 @@
|
||||
"coming_soon": "Coming soon",
|
||||
"promo_star_cta": "Star us on GitHub",
|
||||
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||
"aivpn_enable": "Enabled",
|
||||
"aivpn_probe": "Probe ports",
|
||||
"aivpn_pick_now": "Pick now",
|
||||
"aivpn_strategy_balanced": "Balanced",
|
||||
"aivpn_strategy_stealth": "Stealth",
|
||||
"aivpn_strategy_speed": "Speed",
|
||||
"aivpn_picked": "Best protocol",
|
||||
"aivpn_alternatives": "also",
|
||||
"aivpn_no_candidates": "No installed VPN protocols to pick from",
|
||||
"aivpn_picking": "Scoring protocols…",
|
||||
"aivpn_on": "On",
|
||||
"aivpn_protocol_option": "AIVPN (auto)",
|
||||
"revproxy_title": "Reverse Proxy",
|
||||
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||
|
||||
Reference in New Issue
Block a user