From abbd160dd89d98edd42c0097e7dc40eaa48266d1 Mon Sep 17 00:00:00 2001 From: orohimaru2 Date: Sun, 9 Aug 2026 18:11:24 +0300 Subject: [PATCH] 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 --- app.py | 150 +++++++++++++++++++---- managers/aivpn_manager.py | 245 ++++++++++++++++++++++++++++++++++++++ static/css/style.css | 14 +++ templates/invites.html | 9 +- templates/server.html | 142 ++++++++++++++++++---- templates/settings.html | 7 ++ templates/users.html | 12 ++ translations/en.json | 12 ++ translations/fa.json | 12 ++ translations/fr.json | 12 ++ translations/ru.json | 12 ++ translations/zh.json | 12 ++ 12 files changed, 587 insertions(+), 52 deletions(-) create mode 100644 managers/aivpn_manager.py diff --git a/app.py b/app.py index 6600365..87228d0 100644 --- a/app.py +++ b/app.py @@ -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), diff --git a/managers/aivpn_manager.py b/managers/aivpn_manager.py new file mode 100644 index 0000000..9cc1b3e --- /dev/null +++ b/managers/aivpn_manager.py @@ -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' diff --git a/static/css/style.css b/static/css/style.css index 52fa2dd..3cc610e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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: diff --git a/templates/invites.html b/templates/invites.html index 154acee..38928d6 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -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; diff --git a/templates/server.html b/templates/server.html index d2d85e9..b5fa2aa 100644 --- a/templates/server.html +++ b/templates/server.html @@ -412,24 +412,44 @@ - -
+ +
- {{ icon('lock') }} {{ _('coming_soon') }} -
+ {{ icon('brain') }} AIVPN +
-
+
AIVPN
{{ _('aivpn_subtitle') }}
- + {{ _('promo_star_cta') }}
+
+
+ + + +
+
+ + +
+
+
@@ -551,25 +571,6 @@
-
- - {{ icon('lock') }} Coming soon -
- -
-
AIVPN
-
AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.
-
- - ⭐ Star us on GitHub - -
-
-