From 5c427cf80a4be6698659d6b67db1ebcaaad6de0b Mon Sep 17 00:00:00 2001 From: baoweise-bot Date: Sun, 31 May 2026 13:43:51 +0800 Subject: [PATCH] feat: allow custom proxy port configuration and improve API fetch reliability with multi-strategy connection attempts and thread-safe logging. --- install.sh | 76 ++- vpngate_manager.py | 1201 ++++++++++++++++++++++++++++++++------------ 2 files changed, 929 insertions(+), 348 deletions(-) diff --git a/install.sh b/install.sh index e5c3e4a..dccc248 100644 --- a/install.sh +++ b/install.sh @@ -383,10 +383,11 @@ def print_status(): cfg = load_ui_cfg() ui_port = cfg.get("port", 8787) secret_path = cfg.get("secret_path", "EJsW2EeBo9lY") + proxy_port = cfg.get("proxy_port", 7928) state = load_state() is_connecting = state.get("is_connecting", False) - gateway_ok = check_port_listening(7928) + gateway_ok = check_port_listening(proxy_port) service_ok = check_service_active("aimilivpn.service") openvpn_ok = check_openvpn_process() pid = get_service_pid("aimilivpn.service") @@ -413,7 +414,7 @@ def print_status(): print_line(f" {bold}AimiliVPN 管理终端 v2.0{reset} ") print_line("=======================================================") print_line("【核心服务状态】") - print_line(format_line("代理网关 (Port 7928)", gateway_status)) + print_line(format_line(f"代理网关 (Port {proxy_port})", gateway_status)) print_line(format_line(f"管理后台 (Port {ui_port})", backend_status)) print_line(format_line("连接核心 (OpenVPN)", openvpn_status)) @@ -453,15 +454,15 @@ def print_status(): else: print_line(format_line("节点状态", "无活动连接")) print_line() - local_proxy = state.get("local_proxy", "http://127.0.0.1:7928") + local_proxy = state.get("local_proxy", f"http://127.0.0.1:{proxy_port}") import urllib.parse try: parsed = urllib.parse.urlsplit(local_proxy) proxy_host = parsed.hostname or "127.0.0.1" - proxy_port = parsed.port or 7928 + proxy_port = parsed.port or proxy_port except Exception: proxy_host = "127.0.0.1" - proxy_port = 7928 + proxy_port = proxy_port if proxy_host == "::": socks_addr = "127.0.0.1" @@ -657,26 +658,50 @@ def configure_web(): def configure_port(): cfg = load_ui_cfg() - print("\033[H\033[J", end="") - print("=======================================================") - print(" 管理端口配置 ") - print("=======================================================") - print(f"当前网页管理端口为: {cfg.get('port', 8787)}") - try: - val = input("请输入新的管理端口 (1-65535, 按回车取消): ").strip() - if val: - port = int(val) - if 1 <= port <= 65535: - cfg['port'] = port - save_ui_cfg(cfg) - print(f"管理端口已更新为: {port}") - ask_restart() - else: - print("错误: 端口范围必须在 1 至 65535 之间。") + while True: + print("\033[H\033[J", end="") + print("=======================================================") + print(" 端口配置菜单 ") + print("=======================================================") + print(f"1) 网页管理端口: {cfg.get('port', 8787)}") + print(f"2) 代理出站端口: {cfg.get('proxy_port', 7928)}") + print("3) 返回主菜单") + print("-------------------------------------------------------") + key = input("请选择操作 (1-3): ").strip() + if key == '1': + try: + val = input("请输入新的网页管理端口 (1-65535, 按回车取消): ").strip() + if val: + port = int(val) + if 1 <= port <= 65535: + cfg['port'] = port + save_ui_cfg(cfg) + print(f"网页管理端口已更新为: {port}") + ask_restart() + else: + print("错误: 端口范围必须在 1 至 65535 之间。") + time.sleep(2) + except ValueError: + print("错误: 输入必须是数字。") time.sleep(2) - except ValueError: - print("错误: 输入必须是数字。") - time.sleep(2) + elif key == '2': + try: + val = input("请输入新的代理出站端口 (1024-65535, 按回车取消): ").strip() + if val: + port = int(val) + if 1024 <= port <= 65535: + cfg['proxy_port'] = port + save_ui_cfg(cfg) + print(f"代理出站端口已更新为: {port}") + ask_restart() + else: + print("错误: 端口范围必须在 1024 至 65535 之间。") + time.sleep(2) + except ValueError: + print("错误: 输入必须是数字。") + time.sleep(2) + elif key == '3' or key == 'q' or key == '\x03': + break def configure_credentials(): cfg = load_ui_cfg() @@ -771,6 +796,7 @@ def getch_timeout(timeout=1.0): def get_status_state(): cfg = load_ui_cfg() state = load_state() + proxy_port = cfg.get("proxy_port", 7928) return ( cfg.get("port", 8787), cfg.get("secret_path", "EJsW2EeBo9lY"), @@ -784,7 +810,7 @@ def get_status_state(): state.get("proxy_ip", "-"), state.get("proxy_latency_ms", 0), state.get("proxy_ok", False), - check_port_listening(7928), + check_port_listening(proxy_port), check_service_active("aimilivpn.service"), check_openvpn_process(), get_service_pid("aimilivpn.service") diff --git a/vpngate_manager.py b/vpngate_manager.py index 2ffb188..1e7113e 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -107,6 +107,11 @@ is_connecting = True last_active_ping_time = 0.0 last_active_latency = 0 +last_collector_heartbeat = 0.0 +last_checker_heartbeat = 0.0 +last_pinger_heartbeat = 0.0 +server_start_time = time.time() + def ensure_dirs() -> None: DATA_DIR.mkdir(exist_ok=True) CONFIG_DIR.mkdir(exist_ok=True) @@ -203,9 +208,10 @@ _last_cleanup_time = 0.0 def cleanup_old_logs(logs_dir: Path) -> None: global _last_cleanup_time now = time.time() - if now - _last_cleanup_time < 3600: - return - _last_cleanup_time = now + with lock: + if now - _last_cleanup_time < 3600: + return + _last_cleanup_time = now try: three_days_sec = 3 * 24 * 60 * 60 for path in logs_dir.glob("*.json"): @@ -217,11 +223,13 @@ def cleanup_old_logs(logs_dir: Path) -> None: today_str = time.strftime("%Y-%m-%d", time.localtime()) today_time = time.mktime(time.strptime(today_str, "%Y-%m-%d")) if today_time - file_time >= three_days_sec: - path.unlink() + with lock: + path.unlink() print(f"[清理] 已删除3天前的旧日志文件: {path.name}", flush=True) except Exception: if now - path.stat().st_mtime > three_days_sec: - path.unlink() + with lock: + path.unlink() except Exception as e: print(f"[清理错误] 清理旧日志失败: {e}", flush=True) @@ -237,8 +245,9 @@ def log_to_json(level: str, module: str, message: str) -> None: "module": module, "message": message } - with open(log_file, "a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") + with lock: + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") cleanup_old_logs(logs_dir) except Exception as e: print(f"[Log Error] Failed to write JSON log: {e}", flush=True) @@ -283,16 +292,24 @@ def parse_int(value: Any) -> int: except (TypeError, ValueError): return 0 -def fetch_api_text() -> str: +def fetch_api_text(url: str | None = None, use_ssl_verify: bool = True) -> str: + if url is None: + url = API_URL request = urllib.request.Request( - API_URL, + url, headers={ "User-Agent": "Mozilla/5.0 vpngate-openvpn-manager/2.0", "Accept": "text/plain,*/*", }, ) - with urllib.request.urlopen(request, timeout=12) as response: - return response.read().decode("utf-8", errors="replace") + if url.startswith("https://") and not use_ssl_verify: + import ssl + ctx = ssl._create_unverified_context() + with urllib.request.urlopen(request, timeout=12, context=ctx) as response: + return response.read().decode("utf-8", errors="replace") + else: + with urllib.request.urlopen(request, timeout=12) as response: + return response.read().decode("utf-8", errors="replace") def parse_vpngate_rows(text: str) -> list[dict[str, str]]: lines = [line for line in text.splitlines() if line and not line.startswith("*")] @@ -355,38 +372,61 @@ def fetch_candidates() -> list[dict[str, Any]]: has_cache = len(cached_nodes()) > 0 max_attempts = 1 if has_cache else 2 - log_to_json("INFO", "Main", f"开始拉取官方 API 节点列表 (最大尝试次数: {max_attempts})...") - for i in range(max_attempts): - if i > 0: - time.sleep(1.5) - try: - api_text = fetch_api_text() - rows = parse_vpngate_rows(api_text) - for row in rows[:MAX_SCAN_ROWS]: - ip = row.get("IP", "") - if not ip or ip in seen_ips: - continue - encoded = row.get("OpenVPN_ConfigData_Base64", "") - if not encoded: - continue - config_text = decode_config(encoded) - node = row_to_node(row, config_text) - candidates.append(node) - seen_ips.add(ip) - except Exception as e: - print(f"[fetch_candidates] Fetch {i+1} failed: {e}", flush=True) - log_to_json("WARNING", "Main", f"第 {i+1} 次拉取 API 节点失败: {e}") - if i == max_attempts - 1 and not candidates: - err_code, diag_msg = vpn_utils.diagnose_api_failure(API_URL) - full_err_msg = f"获取官方 API 节点失败: {e} | 诊断结果: {diag_msg}" - print(f"[错误代码 {err_code}] {full_err_msg}", flush=True) - log_to_json("ERROR", "Main", f"[错误代码 {err_code}] {full_err_msg}") - set_state( - last_fetch_status="error", - last_fetch_error_code=err_code, - last_fetch_message=diag_msg - ) - raise RuntimeError(diag_msg) from e + # 尝试 URLs 队列: 1. HTTPS(验证证书) 2. HTTPS(不验证证书) 3. HTTP + attempts_targets = [ + (API_URL, True), + (API_URL, False) + ] + if API_URL.startswith("https://"): + attempts_targets.append((API_URL.replace("https://", "http://"), True)) + + log_to_json("INFO", "Main", "开始拉取官方 API 节点列表...") + + last_err = None + for url, verify_ssl in attempts_targets: + for i in range(max_attempts): + if i > 0: + time.sleep(1.5) + try: + msg = f"尝试拉取 {url} (SSL验证: {verify_ssl}, 第 {i+1} 次尝试)..." + print(f"[fetch_candidates] {msg}", flush=True) + log_to_json("INFO", "Main", msg) + api_text = fetch_api_text(url, verify_ssl) + rows = parse_vpngate_rows(api_text) + for row in rows[:MAX_SCAN_ROWS]: + ip = row.get("IP", "") + if not ip or ip in seen_ips: + continue + encoded = row.get("OpenVPN_ConfigData_Base64", "") + if not encoded: + continue + config_text = decode_config(encoded) + node = row_to_node(row, config_text) + candidates.append(node) + seen_ips.add(ip) + if candidates: + break + except Exception as e: + last_err = e + print(f"[fetch_candidates] 拉取失败 (URL: {url}, 验证: {verify_ssl}): {e}", flush=True) + log_to_json("WARNING", "Main", f"拉取失败 (URL: {url}, 验证: {verify_ssl}): {e}") + if candidates: + break + + if not candidates: + err_code, diag_msg = vpn_utils.diagnose_api_failure(API_URL) + full_err_msg = f"获取官方 API 节点最终失败: {last_err} | 诊断结果: {diag_msg}" + print(f"[错误代码 {err_code}] {full_err_msg}", flush=True) + log_to_json("ERROR", "Main", f"[错误代码 {err_code}] {full_err_msg}") + set_state( + last_fetch_status="error", + last_fetch_error_code=err_code, + last_fetch_message=diag_msg + ) + if last_err: + raise RuntimeError(diag_msg) from last_err + else: + raise RuntimeError(diag_msg) set_state( last_fetch_at=time.time(), @@ -1132,7 +1172,9 @@ def maintain_valid_nodes(force: bool = False) -> str: def collector_loop() -> None: + global last_collector_heartbeat while True: + last_collector_heartbeat = time.time() success = False try: res = maintain_valid_nodes(force=False) @@ -1695,6 +1737,72 @@ INDEX_HTML = r""" .stat:nth-child(2) .stat-icon { color: var(--warning); } .stat:nth-child(3) .stat-icon { color: var(--success); } + /* New style additions */ + .header-badge-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + text-decoration: none; + font-size: 12px; + font-weight: 600; + transition: all 0.2s ease; + height: 24px; + box-sizing: border-box; + } + .header-badge-link:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--border-color-hover); + color: var(--text-primary); + transform: translateY(-1px); + } + .flex-row-container { + display: flex; + gap: 20px; + flex-wrap: wrap; + margin-bottom: 24px; + } + .flex-row-container > * { + flex: 1; + min-width: 320px; + margin-bottom: 0 !important; + } + .vps-promo-tab { + position: fixed; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 38px; + background: var(--primary-gradient); + border: 1px solid var(--border-color-hover); + border-right: none; + border-radius: 8px 0 0 8px; + padding: 16px 6px; + color: white; + font-weight: 700; + font-size: 13px; + line-height: 1.4; + text-align: center; + cursor: pointer; + z-index: 999; + box-shadow: -4px 0 20px rgba(99, 102, 241, 0.3); + transition: all 0.3s ease; + writing-mode: vertical-rl; + text-orientation: mixed; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + } + .vps-promo-tab:hover { + padding-right: 10px; + box-shadow: -4px 0 25px rgba(99, 102, 241, 0.5); + } + .ad-section { background: var(--bg-surface); backdrop-filter: blur(12px); @@ -2227,21 +2335,28 @@ INDEX_HTML = r""" AimiliVPN 节点管理系统 -
服务加载中...
+
+ + + + Telegram + - - - -
- - -
- -
- - -
- -
- - -
+
+
+ +
-
-
安全验证 (必须输入当前账号密码)
- -
- - -
- -
- - -
+
+ +
- - + +
- -