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:
orohimaru2
2026-08-09 18:11:24 +03:00
co-authored by Cursor
parent 01196c066a
commit abbd160dd8
12 changed files with 587 additions and 52 deletions
+125 -25
View File
@@ -103,7 +103,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export 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_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) 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', {}))), 'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))),
# Keep for legacy JS; prefer translations_json on new pages # Keep for legacy JS; prefer translations_json on new pages
'all_translations_json': json.dumps(TRANSLATIONS), 'all_translations_json': json.dumps(TRANSLATIONS),
'releases_repo_url': RELEASES_REPO_URL,
} }
ctx.update(kwargs) ctx.update(kwargs)
return templates.TemplateResponse(template, ctx) return templates.TemplateResponse(template, ctx)
@@ -4273,6 +4274,74 @@ async def api_server_migrate_import(
return JSONResponse({'error': str(e)}, status_code=400) 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"]) @app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"])
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest): async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
"""Download a ZIP with all reconstructable WireGuard/AWG client .conf files.""" """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']): if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404) 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_api import xui_create_vless_config
from managers.xui_servers import get_xui_server, ensure_xui_servers from managers.xui_servers import get_xui_server, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {})) 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) save_data(data)
return result return result
server = data['servers'][server_id] proto_info = server.get('protocols', {}).get(protocol, {})
proto_info = server.get('protocols', {}).get(req.protocol, {})
port = proto_info.get('port', '55424') port = proto_info.get('port', '55424')
ssh = get_ssh(server) ssh = get_ssh(server)
ssh.connect() 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( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, 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, user_ad_tag=req.telemt_ad_tag,
max_tcp_conns=req.telemt_max_conns max_tcp_conns=req.telemt_max_conns
) )
elif protocol_base(req.protocol) == 'wireguard': elif protocol_base(protocol) == 'wireguard':
result = manager.add_client(req.name, get_server_connect_host(server, req.protocol)) result = manager.add_client(req.name, get_server_connect_host(server, protocol))
else: 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() ssh.disconnect()
if result.get('config'): if result.get('config'):
result['vpn_link'] = generate_vpn_link(result['config']) result['vpn_link'] = generate_vpn_link(result['config'])
result['protocol'] = protocol
# Link connection to user if specified # Link connection to user if specified
if req.user_id and result.get('client_id'): 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()), 'id': str(uuid.uuid4()),
'user_id': req.user_id, 'user_id': req.user_id,
'server_id': server_id, 'server_id': server_id,
'protocol': req.protocol, 'protocol': protocol,
'client_id': result['client_id'], 'client_id': result['client_id'],
'name': req.name, 'name': req.name,
'created_at': datetime.now().isoformat(), '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) user = next((u for u in data['users'] if u['id'] == user_id), None)
if not user: if not user:
return JSONResponse({'error': 'User not found'}, status_code=404) 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( return JSONResponse(
{'error': f'Protocol "{req.protocol}" does not support user connections'}, {'error': f'Protocol "{protocol}" does not support user connections'},
status_code=400, 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 from managers.xui_servers import get_xui_server, ensure_xui_servers
ensure_xui_servers(data.setdefault('settings', {})) ensure_xui_servers(data.setdefault('settings', {}))
panel = get_xui_server(data.get('settings') or {}, req.xui_panel_id) 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']): if req.server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404) return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][req.server_id] 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') port = proto_info.get('port', '55424')
ssh = get_ssh(server) ssh = get_ssh(server)
await asyncio.to_thread(ssh.connect) await asyncio.to_thread(ssh.connect)
try: try:
manager = get_protocol_manager(ssh, req.protocol) manager = get_protocol_manager(ssh, protocol)
if req.client_id: if req.client_id:
# Link existing client # Link existing client
config = await asyncio.to_thread( config = await asyncio.to_thread(
_manager_call, manager, 'get_client_config', _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} result = {'client_id': req.client_id, 'config': config}
elif protocol_base(req.protocol) == 'telemt': elif protocol_base(protocol) == 'telemt':
result = await asyncio.to_thread( 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_quota=req.telemt_quota,
telemt_max_ips=req.telemt_max_ips, telemt_max_ips=req.telemt_max_ips,
telemt_expiry=req.telemt_expiry, 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, user_ad_tag=req.telemt_ad_tag,
max_tcp_conns=req.telemt_max_conns, max_tcp_conns=req.telemt_max_conns,
) )
elif protocol_base(req.protocol) == 'wireguard': elif protocol_base(protocol) == 'wireguard':
result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, req.protocol)) result = await asyncio.to_thread(manager.add_client, req.name, get_server_connect_host(server, protocol))
else: else:
result = await asyncio.to_thread( result = await asyncio.to_thread(
_manager_call, manager, 'add_client', _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: finally:
await asyncio.to_thread(ssh.disconnect) 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()), 'id': str(uuid.uuid4()),
'user_id': user_id, 'user_id': user_id,
'server_id': req.server_id, 'server_id': req.server_id,
'protocol': req.protocol, 'protocol': protocol,
'client_id': result['client_id'], 'client_id': result['client_id'],
'name': req.name, 'name': req.name,
'created_at': datetime.now().isoformat(), '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): if user_is_expired(holder):
return JSONResponse({'error': 'Subscription expired'}, status_code=403) 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': if protocol_base(protocol) == 'xui':
from managers.xui_api import xui_create_vless_config from managers.xui_api import xui_create_vless_config
from managers.xui_servers import get_xui_server, ensure_xui_servers from managers.xui_servers import get_xui_server, ensure_xui_servers
@@ -5939,6 +6028,12 @@ async def _create_config_for_protocol(
) -> dict: ) -> dict:
"""Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}.""" """Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}."""
protocol = protocol or 'xui' 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': if protocol_base(protocol) == 'xui':
from managers.xui_api import xui_create_vless_config from managers.xui_api import xui_create_vless_config
from managers.xui_servers import get_xui_server, ensure_xui_servers 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)}" name = f"{name}_{secrets.token_hex(3)}"
try: try:
data = load_data() 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( created = await _create_config_for_protocol(
data, data,
protocol=link.get('protocol') or 'xui', protocol=protocol,
name=name, 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_inbound_id=int(link.get('xui_inbound_id') or 0) or None,
xui_panel_id=link.get('xui_panel_id') or None, xui_panel_id=link.get('xui_panel_id') or None,
duration_days=int(link.get('duration_days') or 0), duration_days=int(link.get('duration_days') or 0),
+245
View File
@@ -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'
+14
View File
@@ -1162,6 +1162,20 @@ a:hover {
text-shadow: 0 0 16px rgba(168, 85, 247, 0.6); 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) ----- */ /* ----- Theme: Reverse Proxy (amber/red, shield/firewall feel) ----- */
.promo-revproxy { .promo-revproxy {
background: background:
+8 -1
View File
@@ -197,7 +197,7 @@
const xuiDefaultInbound = {{ xui_default_inbound | int }}; const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }}; const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru']; const VPN_PROTO_ORDER = ['awg2', 'awg', 'awg_legacy', 'wireguard', 'xray', 'telemt', 'hysteria', 'naiveproxy', 'mieru'];
const PROTO_TITLES = { const PROTO_TITLES = {
awg2: 'AmneziaWG 2.0', awg2: 'AmneziaWG 2.0',
awg: 'AmneziaWG', awg: 'AmneziaWG',
awg_legacy: 'AWG Legacy', awg_legacy: 'AWG Legacy',
@@ -207,6 +207,7 @@
hysteria: 'Hysteria 2', hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy', naiveproxy: 'NaiveProxy',
mieru: 'Mieru', mieru: 'Mieru',
aivpn: 'AIVPN (auto)',
xui: '3x-ui VLESS', xui: '3x-ui VLESS',
}; };
@@ -264,6 +265,12 @@
return; 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 => { installed.forEach(key => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = key; opt.value = key;
+116 -26
View File
@@ -412,24 +412,44 @@
</div> </div>
</div> </div>
<!-- ===== AIVPN teaser (locked, full grid width) ===== --> <!-- ===== AIVPN (smart protocol picker) ===== -->
<div class="promo-block promo-aivpn" aria-disabled="true" role="article"> <div class="promo-block promo-aivpn" id="aivpnCard" role="article">
<div class="promo-orbs" aria-hidden="true"> <div class="promo-orbs" aria-hidden="true">
<span class="promo-orb"></span> <span class="promo-orb"></span>
<span class="promo-orb"></span> <span class="promo-orb"></span>
<span class="promo-orb"></span> <span class="promo-orb"></span>
</div> </div>
<span class="promo-lock-badge">{{ icon('lock') }} {{ _('coming_soon') }}</span> <span class="promo-lock-badge" id="aivpnBadge">{{ icon('brain') }} AIVPN</span>
<div class="promo-content"> <div class="promo-content" style="flex-wrap: wrap;">
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</div> <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-title">AIVPN</div>
<div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div> <div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div>
</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> <span>{{ _('promo_star_cta') }}</span>
</a> </a>
</div> </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> </div>
<!-- DNS Card --> <!-- DNS Card -->
@@ -551,25 +571,6 @@
</div> </div>
</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) --> <!-- Connections Section (renamed from Clients) -->
<div class="clients-section" id="connectionsSection" style="display:none;"> <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_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_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') 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 = [ const MARKETPLACE_APPS = [
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' }, { 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' }, { proto: 'awg', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
@@ -1647,6 +1649,7 @@
case 'naiveproxy': title = 'NaiveProxy'; break; case 'naiveproxy': title = 'NaiveProxy'; break;
case 'mieru': title = 'Mieru'; break; case 'mieru': title = 'Mieru'; break;
case 'wireguard': title = 'WireGuard'; break; case 'wireguard': title = 'WireGuard'; break;
case 'aivpn': title = 'AIVPN'; break;
case 'dns': title = 'AmneziaDNS'; break; case 'dns': title = 'AmneziaDNS'; break;
case 'socks5': title = 'SOCKS5 Proxy'; break; case 'socks5': title = 'SOCKS5 Proxy'; break;
case 'adguard': title = 'AdGuard Home'; break; case 'adguard': title = 'AdGuard Home'; break;
@@ -3051,7 +3054,7 @@
selectedConnectionIds.clear(); selectedConnectionIds.clear();
updateMoveSelection(); updateMoveSelection();
try { 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'; loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) { if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden'); 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 ========== // ========== Init ==========
applyInstalledAppsVisibility(); applyInstalledAppsVisibility();
checkServer(); checkServer();
loadServerStats(); loadServerStats();
loadAivpnSettings(false);
</script> </script>
{% endblock %} {% endblock %}
+7
View File
@@ -1560,6 +1560,7 @@
hysteria: 'Hysteria 2', hysteria: 'Hysteria 2',
naiveproxy: 'NaiveProxy', naiveproxy: 'NaiveProxy',
mieru: 'Mieru', mieru: 'Mieru',
aivpn: 'AIVPN (auto)',
xui: '3x-ui VLESS', xui: '3x-ui VLESS',
}; };
function protoTitle(key) { function protoTitle(key) {
@@ -1602,6 +1603,12 @@
opt.textContent = _('no_protocols'); opt.textContent = _('no_protocols');
protoSel.appendChild(opt); protoSel.appendChild(opt);
} else { } 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 => { installed.forEach(key => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = key; opt.value = key;
+12
View File
@@ -685,6 +685,11 @@
}); });
if (count > 0) { 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 = ''; if (group) group.style.display = '';
} else { } else {
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -766,6 +771,13 @@
}); });
document.getElementById('ucProtocol').addEventListener('change', (e) => { 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') { if (document.getElementById('ucMode').value === 'existing') {
fetchExistingClients(); fetchExistingClients();
} }
+12
View File
@@ -525,6 +525,18 @@
"coming_soon": "Coming soon", "coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub", "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_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_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", "revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
"management": "Management", "management": "Management",
+12
View File
@@ -479,6 +479,18 @@
"coming_soon": "Coming soon", "coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub", "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_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_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", "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.", "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
+12
View File
@@ -479,6 +479,18 @@
"coming_soon": "Coming soon", "coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub", "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_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_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", "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.", "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
+12
View File
@@ -525,6 +525,18 @@
"coming_soon": "Скоро", "coming_soon": "Скоро",
"promo_star_cta": "Поставить звезду на GitHub", "promo_star_cta": "Поставить звезду на GitHub",
"aivpn_subtitle": "ИИ-подбор протокола, который сам выбирает правильный туннель под текущие условия. Поможете звездой — выйдет быстрее.", "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_title": "Reverse Proxy",
"revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.", "revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.",
"management": "Управление", "management": "Управление",
+12
View File
@@ -479,6 +479,18 @@
"coming_soon": "Coming soon", "coming_soon": "Coming soon",
"promo_star_cta": "Star us on GitHub", "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_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_title": "Reverse Proxy",
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", "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.", "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",