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 @@
-
-
+
+
-
-
@@ -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')}: ${getProtoTitle(rec.protocol)} (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);
{% endblock %}
diff --git a/templates/settings.html b/templates/settings.html
index b4c9024..e43189b 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -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;
diff --git a/templates/users.html b/templates/users.html
index e51a2df..e968293 100644
--- a/templates/users.html
+++ b/templates/users.html
@@ -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();
}
diff --git a/translations/en.json b/translations/en.json
index 34e03d5..59eaf78 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -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",
diff --git a/translations/fa.json b/translations/fa.json
index 9250e66..2b0945c 100644
--- a/translations/fa.json
+++ b/translations/fa.json
@@ -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.",
diff --git a/translations/fr.json b/translations/fr.json
index e77e458..06d4eaf 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -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.",
diff --git a/translations/ru.json b/translations/ru.json
index b9a7308..6b4b32a 100644
--- a/translations/ru.json
+++ b/translations/ru.json
@@ -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": "Управление",
diff --git a/translations/zh.json b/translations/zh.json
index ed456f8..23066f9 100644
--- a/translations/zh.json
+++ b/translations/zh.json
@@ -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.",