"""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'