commit 7e7fcecfd025852b0885d79eba36e6a2c8cb7dd5 Author: baoweise Date: Thu May 28 22:27:14 2026 +0800 feat: initial open-source release with cleaned codebase diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27019d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Nuitka compiler intermediate folders +vpngate_manager.build/ +vpngate_manager.dist/ +*.build/ +*.dist/ +aimilivpn + +# Debian building artifacts +*.deb +aimilivpn_* + +# Python caches and bytecode +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Local runtime data folders +vpngate_data/ +logs/ +*.log +*.tmp + +# IDE settings +.vscode/ +.idea/ +.gemini/ +.cursorrules + + +# Scratch and test files containing credentials +scratch/ +*连接配置* + +# Local analysis and doc files +AimiliVPN_Analysis_ZH.md + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..720580b --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. +... +[For brevity, you can obtain the complete license text from https://www.gnu.org/licenses/gpl-3.0.txt] +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e694fdd --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# AimiliVPN 🌐 + +Bilingual: [中文](#中文) | [English](#english) + +--- + +## 中文 + +[![Telegram](https://img.shields.io/badge/TG交流群-arestemple-2CA5E0?style=flat-square&logo=telegram&logoColor=white)](https://t.me/arestemple) +[![Forum](https://img.shields.io/badge/交流论坛-339936.xyz-orange?style=flat-square&logo=discourse&logoColor=white)](https://339936.xyz) +[![YouTube](https://img.shields.io/badge/视频教程-YouTube-red?style=flat-square&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=s-ATfXR8BpI) +[![Email](https://img.shields.io/badge/Bug反馈-yaohunse7@gmail.com-red?style=flat-square&logo=gmail&logoColor=white)](mailto:yaohunse7@gmail.com) + + +--- + +**AimiliVPN** 是一个专为 Linux VPS(如 Ubuntu)设计的智能 VPN 代理网关管理器。它能够自动采集 VPNGate 开放节点,进行多线程可用性测试与延迟过滤,利用 OpenVPN 隧道与策略路由(Policy Routing)实现出站网络,并在本地提供高性能的 HTTP/SOCKS5 代理网关服务,适合用作 Xray 的落地出站代理。 + +--- + +### 🚀 快速开始 + +在您的 **Ubuntu** VPS 机器上,复制并运行以下一行指令即可完成自动安装部署: + +```bash +bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh) +``` + +--- + +### 🛠️ 快捷命令行 (CLI) + +安装成功后,系统会在全局注册 `ml` 快捷管理指令,直接运行 `ml` 可打开图形化交互终端,也可通过以下指令执行: +* **`ml status`** 或 **`ml`**:查看当前运行状态(代理端口、活动 VPN 节点、直连延迟、网页后台登录地址等)。 +* **`ml start`**:启动 AimiliVPN 服务。 +* **`ml stop`**:停止 AimiliVPN 服务(并自动清理策略路由与 OpenVPN 进程)。 +* **`ml restart`**:重启服务。 +* **`ml logs`**:查看实时的 Systemd 服务运行日志。 +* **`ml web`**:切换网页绑定地址(127.0.0.1 仅本地,或 0.0.0.0 允许公网访问)与重置安全后缀。 +* **`ml port`**:修改网页管理控制台监听端口。 +* **`ml password`**:生成新的 12 位安全管理密码。 +* **`ml uninstall`**:完全卸载服务并清理相关环境。 + +#### 💡 首次安装与常见报错解决(小白必看) + +##### 1. 极简系统缺少依赖(Ubuntu 18-26 / Debian 首次安装) +如果系统是全新纯净版,可能会因为缺少 `curl` 或 `ca-certificates` 导致一键安装脚本下载失败。请在安装前执行以下命令补充依赖: +```bash +sudo apt-get update && sudo apt-get install -y curl ca-certificates +``` + +##### 2. Debian 系统兼容运行方法 +本脚本一键包默认限制在 Ubuntu 系统运行。Debian 用户如需运行,可先下载并用 `sed` 临时将系统类型限制替换为 `"ubuntu"` 后再执行安装: +```bash +curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh -o install.sh +sed -i 's/"${ID:-}"/"ubuntu"/g' install.sh +sudo bash install.sh +``` + +##### 3. 包管理器被占用(Apt 锁冲突报错解决) +若一键安装提示 `Could not get lock /var/lib/dpkg/lock-frontend` 等“无法获得锁”的报错,可运行以下命令解除占用并重新安装: +```bash +# 1. 停止自动更新服务并终止相关进程 +sudo systemctl stop unattended-upgrades 2>/dev/null +sudo killall apt apt-get dpkg 2>/dev/null + +# 2. 清理残留锁文件 +sudo rm -f /var/lib/dpkg/lock* /var/lib/apt/lists/lock /var/cache/apt/archives/lock + +# 3. 修复受损包并重新更新源 +sudo dpkg --configure -a +sudo apt-get update +``` +执行完毕后,重新运行一键安装脚本即可。 + +--- + +### ⚙️ 系统架构 + +``` + [ 3x-ui / Xray ] + │ (HTTP / SOCKS5) + ▼ + [ 本地代理服务器 ] (Port 7928) ──(强制绑定 SO_BINDTODEVICE)──► [ tun0 虚拟网卡 ] + │ │ + │ (SSH, Web UI, etc. 依然走物理路由) │ (策略路由表 100) + ▼ ▼ + [ 物理网卡 eth0 ] ◄───────────────────────────────────────── [ OpenVPN 加密隧道 ] + │ │ + ▼ (真实服务器 IP 出站) ▼ (VPNGate 落地节点出站) + (国内直连流量) (解锁流媒体、锁区网站) +``` + +--- + +## English + +[![Telegram](https://img.shields.io/badge/Telegram-arestemple-2CA5E0?style=flat-square&logo=telegram&logoColor=white)](https://t.me/arestemple) +[![Forum](https://img.shields.io/badge/Forum-339936.xyz-orange?style=flat-square&logo=discourse&logoColor=white)](https://339936.xyz) +[![Email](https://img.shields.io/badge/Bug%20Report-yaohunse7@gmail.com-red?style=flat-square&logo=gmail&logoColor=white)](mailto:yaohunse7@gmail.com) + +--- + +**AimiliVPN** is an intelligent VPN proxy gateway manager designed specifically for Linux VPS (e.g. Ubuntu). It automatically collects open VPNGate nodes, conducts multi-threaded availability testing and latency filtering, establishes secure out-of-band routing via OpenVPN and policy routing to **prevent VPS lockouts**, and hosts a high-performance local SOCKS5/HTTP proxy gateway. It is highly optimized to serve as a residential/unlocked egress node for upstream proxies like 3x-ui / Xray. + +### ✨ Key Features + +1. ⚡ **Auto-Collection & Multi-Threaded Probing**: + * Periodically fetches candidate nodes from VPNGate. + * Performs concurrent ping latency and handshake tests to maintain a pool of high-quality nodes. +2. 🔒 **Anti-Lockout Routing (Policy Routing)**: + * Directs traffic from the virtual adapter `tun0` to a customized routing table (Table 100) without altering the system's default gateway. + * Keeps SSH sessions and server administration panels unaffected by the active VPN. +3. 🚫 **Fail-Safe Leak Protection**: + * Outbound socket connections inside the local proxy server are strictly bound to `tun0` via `SO_BINDTODEVICE`. + * If the VPN disconnects, proxy requests are instantly blocked with a `502 Bad Gateway` instead of falling back to the VPS physical IP address. +4. 🖥️ **Modern Web UI Panel**: + * Sleek dark/light responsive console (default port `8787`). + * Provides real-time geolocation, ISP, ASN, latency, and IP-type (residential/datacenter) detection. + * Enables manual node selection, blacklist resets, proxy speed-testing, and logs query. + * Secured by a random secret path suffix (e.g., `/EJsW2EeBo9lY/`) and password authentication. +5. 🛠️ **CLI Utility (ml)**: + * Command-line helper tool `ml` with a menu-driven interface. + * Provides quick statuses, starts/stops the daemon, resets passwords, and changes bind hosts. + +--- + +### 🚀 Quick Start + +To install and deploy AimiliVPN on your **Ubuntu** server, copy and paste the following command: + +```bash +bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh) +``` + +--- + +### 🛠️ CLI Helper Commands + +Once installed, use the global command `ml` to launch the interactive helper menu, or use the shortcuts below: +* **`ml status`** or **`ml`**: Check running system status (active nodes, proxy ports, latency, URLs). +* **`ml start`**: Start the gateway service. +* **`ml stop`**: Stop the gateway service (and clean routing tables). +* **`ml restart`**: Restart the service. +* **`ml logs`**: View real-time Systemd output logs. +* **`ml web`**: Toggle Web UI accessibility (127.0.0.1 or 0.0.0.0) and reset suffix paths. +* **`ml port`**: Update the Web Console port. +* **`ml password`**: Regenerate a secure 12-character administration password. +* **`ml uninstall`**: Completely remove the service and repository files from your VPS. + +#### 💡 Troubleshooting & First-Time Installation Tips + +##### 1. Missing Dependencies on Minimal OS (Ubuntu / Debian) +If you are using a brand new minimal OS, the installation might fail due to missing `curl` or `ca-certificates`. Run the following command to pre-install dependencies: +```bash +sudo apt-get update && sudo apt-get install -y curl ca-certificates +``` + +##### 2. Bypass OS Restrictions for Debian +The script is restricted to Ubuntu by default. For Debian systems, run the following commands to download, patch, and install: +```bash +curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh -o install.sh +sed -i 's/"${ID:-}"/"ubuntu"/g' install.sh +sudo bash install.sh +``` + +##### 3. Package Manager Locked (`apt`/`dpkg` Lock Errors) +If you see `Could not get lock /var/lib/dpkg/lock-frontend` or similar busy errors, run these commands to unlock and retry: +```bash +# 1. Stop automatic upgrades & kill active processes +sudo systemctl stop unattended-upgrades 2>/dev/null +sudo killall apt apt-get dpkg 2>/dev/null + +# 2. Remove lock files +sudo rm -f /var/lib/dpkg/lock* /var/lib/apt/lists/lock /var/cache/apt/archives/lock + +# 3. Repair package states & update +sudo dpkg --configure -a +sudo apt-get update +``` +Once done, re-run the installation script. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..b8024c7 --- /dev/null +++ b/install.sh @@ -0,0 +1,926 @@ +#!/usr/bin/env bash +set -e +export DEBIAN_FRONTEND=noninteractive + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;36m' +PLAIN='\033[0m' + +# 1. Check root permissions +if [[ "$(id -u)" != "0" ]]; then + echo -e "${RED}错误: 必须以 root 权限运行此脚本。请使用: sudo bash $0${PLAIN}" + exit 1 +fi + +# 2. Check OS distribution (Ubuntu only) +if [ -f /etc/os-release ]; then + . /etc/os-release + if [[ "${ID:-}" != "ubuntu" ]]; then + echo -e "${RED}错误: 本系统不是 Ubuntu!目前 AimiliVPN 仅支持 Ubuntu 系统。${PLAIN}" + exit 1 + fi +else + echo -e "${RED}错误: 无法确定操作系统版本,缺少 /etc/os-release 文件。${PLAIN}" + exit 1 +fi + +echo -e "${BLUE}==========================================================${PLAIN}" +echo -e "${BLUE} 欢迎使用 AimiliVPN 一键源码部署与管理脚本${PLAIN}" +echo -e "${BLUE}==========================================================${PLAIN}" + +# 3. Configure GitHub Repository URL +# Default to the official repository (baoweise-bot/aimili-vpngate) +DEFAULT_USER="baoweise-bot" +DEFAULT_REPO="aimili-vpngate" + +# Allow custom repository override via command line arguments +GITHUB_USER="${1:-${DEFAULT_USER}}" +GITHUB_REPO="${2:-${DEFAULT_REPO}}" + +GITHUB_URL="https://github.com/${GITHUB_USER}/${GITHUB_REPO}.git" + +echo -e "\n${YELLOW}[1/4] 正在安装系统基础依赖...${PLAIN}" +echo -e " -> 正在运行 apt-get update 更新软件源清单..." +apt-get update -q || true +echo -e " -> 正在运行 apt-get install 安装基础依赖包 (openvpn, curl, git, iptables, iproute2, psmisc, python3)..." +apt-get install -y openvpn curl git ca-certificates iptables iproute2 psmisc python3 + +# 4. Clone or pull the repository +INSTALL_DIR="/opt/aimilivpn" +echo -e "\n${YELLOW}[2/4] 正在从 GitHub 部署源代码到 ${INSTALL_DIR}...${PLAIN}" +if [ -f "${INSTALL_DIR}/.local_dev" ]; then + echo -e "${GREEN}检测到本地开发模式 (.local_dev),跳过 git pull/reset 保持本地修改。${PLAIN}" +else + if [ -d "${INSTALL_DIR}" ]; then + echo -e " -> 目录 ${INSTALL_DIR} 已存在,正在更新并强制覆盖本地源码..." + cd "${INSTALL_DIR}" + git fetch --all || true + BRANCH="main" + if git rev-parse --verify origin/main >/dev/null 2>&1; then + BRANCH="main" + elif git rev-parse --verify origin/master >/dev/null 2>&1; then + BRANCH="master" + fi + echo -e " -> 正在强制重置本地源码至 origin/${BRANCH} ..." + if git reset --hard "origin/${BRANCH}"; then + echo -e "${GREEN} -> 源码更新成功!${PLAIN}" + else + if git pull; then + echo -e "${GREEN} -> 源码更新成功!${PLAIN}" + else + echo -e "${YELLOW} -> 警告: git pull/reset 失败,将保留当前本地源码并继续安装。${PLAIN}" + fi + fi + else + echo -e " -> 正在克隆 GitHub 仓库 ${GITHUB_URL} ..." + if git clone "${GITHUB_URL}" "${INSTALL_DIR}"; then + echo -e "${GREEN} -> 克隆成功!${PLAIN}" + else + echo -e "${RED} -> 错误: 无法克隆仓库 ${GITHUB_URL},请检查网络!${PLAIN}" + exit 1 + fi + fi +fi + +# 5. Configure Systemd Service (direct python3 run) +echo -e "\n${YELLOW}[3/4] 正在配置 systemd 系统服务...${PLAIN}" +echo -e " -> 正在创建服务配置 /lib/systemd/system/aimilivpn.service ..." +cat > /lib/systemd/system/aimilivpn.service < 正在重新加载 systemd 系统服务列表并启用开机自启..." +systemctl daemon-reload +systemctl enable aimilivpn.service + +# 6. Configure global command shortcut "ml" +echo -e "\n${YELLOW}[4/4] 正在创建全局命令快捷接口 'ml'...${PLAIN}" +echo -e " -> 正在写入管理脚本 /usr/bin/ml ..." +cat > /usr/bin/ml <<'EOF' +#!/usr/bin/env python3 +import sys +import os +import socket +import subprocess +import time +import tty +import termios + +INSTALL_DIR = "/opt/aimilivpn" +LOG_FILE = "/opt/aimilivpn/vpngate_data/vpngate.log" + +def generate_random_password(): + import random + import string + chars = string.ascii_letters + string.digits + while True: + pwd = "".join(random.choices(chars, k=12)) + if any(c.islower() for c in pwd) and any(c.isupper() for c in pwd) and any(c.isdigit() for c in pwd): + return pwd + +def generate_random_suffix(): + import random + import string + return "".join(random.choices(string.ascii_letters + string.digits, k=12)) + +def load_ui_cfg(): + import json + path = "/opt/aimilivpn/vpngate_data/ui_auth.json" + cfg = {"host": "0.0.0.0", "port": 8787, "secret_path": "EJsW2EeBo9lY", "password": ""} + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + for k, v in data.items(): + cfg[k] = v + except Exception: + pass + return cfg + +def save_ui_cfg(cfg): + import json + path = "/opt/aimilivpn/vpngate_data/ui_auth.json" + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + return True + except Exception: + return False + +def load_state(): + import json + path = "/opt/aimilivpn/vpngate_data/state.json" + state = {"active_openvpn_node_id": "", "last_check_message": "", "is_connecting": False} + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + for k, v in data.items(): + state[k] = v + except Exception: + pass + return state + +def get_active_node_info(): + import json + path = "/opt/aimilivpn/vpngate_data/nodes.json" + state = load_state() + active_id = state.get("active_openvpn_node_id") + if not active_id: + return None, None + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + nodes = json.load(f) + for n in nodes: + if n.get("id") == active_id: + ip = n.get("ip") or n.get("remote_host") + loc = n.get("location") or n.get("country") or "未知" + return ip, loc + except Exception: + pass + return None, None + +def ping_ip(ip): + if not ip: + return None + try: + # Run standard linux ping command with 1 packet and 2 seconds timeout + res = subprocess.run(["ping", "-c", "1", "-W", "2", ip], capture_output=True, text=True, timeout=3) + if res.returncode == 0: + out = res.stdout + lines = out.splitlines() + for line in lines: + if "rtt" in line or "min/avg" in line: + parts = line.split("=")[1].strip().split("/") + if len(parts) >= 2: + avg_rtt = float(parts[1]) + return f"{int(avg_rtt)} ms" + return "已响应" + else: + return "检测超时" + except Exception: + return "无法连接" + +def get_public_ip(): + path = "/opt/aimilivpn/vpngate_data/public_ip.txt" + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + ip = f.read().strip() + if ip: + return ip + except Exception: + pass + import urllib.request + try: + req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": "curl/7.68.0"}) + with urllib.request.urlopen(req, timeout=1.5) as r: + ip = r.read().decode().strip() + if ip: + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(ip) + except Exception: + pass + return ip + except Exception: + pass + return "您的服务器公网IP" + +def check_port_listening(port): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(0.2) + try: + s.connect(("127.0.0.1", port)) + s.close() + return True + except Exception: + return False + +def get_service_pid(service_name="aimilivpn.service"): + try: + for pid_dir in os.listdir('/proc'): + if pid_dir.isdigit(): + try: + with open(os.path.join('/proc', pid_dir, 'cmdline'), 'r') as f: + cmd = f.read() + if 'vpngate_manager.py' in cmd: + return pid_dir + except Exception: + continue + except Exception: + pass + return None + +def check_service_active(service_name="aimilivpn.service"): + return get_service_pid(service_name) is not None + +def check_openvpn_process(): + try: + for pid_dir in os.listdir('/proc'): + if pid_dir.isdigit(): + try: + with open(os.path.join('/proc', pid_dir, 'cmdline'), 'r') as f: + cmd = f.read().split('\x00')[0] + if 'openvpn' in cmd: + return True + except Exception: + continue + except Exception: + pass + return False + +def get_display_width(s): + import re + ansi_escape = re.compile(r'\x1b\[[0-9;]*[mGKH]') + s_clean = ansi_escape.sub('', s) + width = 0 + for char in s_clean: + if ord(char) > 127: + width += 2 + else: + width += 1 + return width + +def format_line(label, value, target_width=26): + prefix = " ● " + w = get_display_width(label) + padding = " " * max(0, target_width - w) + return f"{prefix}{label}{padding}: {value}" + +def print_status(): + cfg = load_ui_cfg() + ui_port = cfg.get("port", 8787) + secret_path = cfg.get("secret_path", "EJsW2EeBo9lY") + state = load_state() + is_connecting = state.get("is_connecting", False) + + gateway_ok = check_port_listening(7928) + service_ok = check_service_active("aimilivpn.service") + openvpn_ok = check_openvpn_process() + pid = get_service_pid("aimilivpn.service") + + active_ip, active_loc = get_active_node_info() + latency = state.get("active_node_latency", "测试中...") if active_ip else "无活动连接" + + green = "\033[1;32m" + red = "\033[1;31m" + reset = "\033[0m" + bold = "\033[1m" + yellow = "\033[1;33m" + + backend_status = f"{green}[已激活] (PID: {pid}){reset}" if (service_ok and pid) else f"{red}[未启动]{reset}" + + if is_connecting: + gateway_status = f"{yellow}[切换中...]{reset}" + openvpn_status = f"{yellow}[{state.get('active_node_latency') or '连接中'}...]{reset}" + else: + gateway_status = f"{green}[已激活]{reset}" if gateway_ok else f"{red}[未启动]{reset}" + openvpn_status = f"{green}[已连接]{reset}" if openvpn_ok else f"{red}[未连接]{reset}" + + print("=======================================================") + print(f" {bold}AimiliVPN 管理终端 v2.0{reset} ") + print("=======================================================") + print("【核心服务状态】") + print(format_line("代理网关 (Port 7928)", gateway_status)) + print(format_line(f"管理后台 (Port {ui_port})", backend_status)) + print(format_line("连接核心 (OpenVPN)", openvpn_status)) + + login_ip = "127.0.0.1" if cfg.get("host") == "127.0.0.1" else get_public_ip() + print(format_line("网页登录地址", f"{yellow}http://{login_ip}:{ui_port}/{secret_path}/{reset}")) + print(format_line("网页管理账号", cfg.get("username", "admin"))) + curr_pwd = cfg.get("password", "") + masked_pwd = curr_pwd if len(curr_pwd) <= 4 else curr_pwd[:3] + "********" + curr_pwd[-2:] + print(format_line("网页管理密码", masked_pwd)) + print() + print("【活动节点状态】") + if is_connecting: + connecting_msg = state.get('last_check_message') or '正在建立加密隧道并验证路由规则...' + print(format_line("节点状态", f"{yellow}{connecting_msg}{reset}")) + elif active_ip: + print(format_line("节点 IP", active_ip)) + print(format_line("节点地区", active_loc)) + print(format_line("节点延迟 (直连测试)", latency)) + else: + print(format_line("节点状态", "无活动连接")) + print() + print("【使用方法】") + print(f" export http_proxy=socks5://127.0.0.1:7928") + print(f" export https_proxy=socks5://127.0.0.1:7928") + print("=======================================================") + +def start_service(): + print("正在启动 AimiliVPN 服务...", flush=True) + subprocess.run(["systemctl", "start", "aimilivpn.service"]) + print("已发送启动指令。") + time.sleep(1) + +def stop_service(): + print("正在停止 AimiliVPN 服务...", flush=True) + subprocess.run(["systemctl", "stop", "aimilivpn.service"]) + print("已发送停止指令。") + time.sleep(1) + +def restart_service(): + print("正在重启 AimiliVPN 服务...", flush=True) + subprocess.run(["systemctl", "restart", "aimilivpn.service"]) + print("已发送重启指令。") + time.sleep(1) + +def show_logs(): + print("正在查看 AimiliVPN 日志 (按 Ctrl+C 退出)...", flush=True) + if os.path.exists(LOG_FILE): + try: + subprocess.run(["tail", "-f", "-n", "50", LOG_FILE]) + except KeyboardInterrupt: + pass + else: + print(f"日志文件不存在: {LOG_FILE}") + time.sleep(2) + +def update_service(): + print("正在获取远程更新并检测版本...", flush=True) + if os.path.exists(INSTALL_DIR): + try: + os.chdir(INSTALL_DIR) + if not os.path.exists(".git"): + print("错误: 当前安装目录不是 Git 仓库,无法通过 Git 更新。") + time.sleep(3) + return + + # Fetch remote origin updates + subprocess.run(["git", "fetch", "--all"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # Detect remote branch (check origin/main, then origin/master) + branch = "main" + for b in ["main", "master"]: + chk = subprocess.run(["git", "rev-parse", "--verify", f"origin/{b}"], capture_output=True, text=True) + if chk.returncode == 0: + branch = b + break + + local_commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() + remote_commit = subprocess.run(["git", "rev-parse", f"origin/{branch}"], capture_output=True, text=True).stdout.strip() + + if local_commit == remote_commit: + print("\n【版本状态】当前已是最新版本,无需更新!") + override = input("是否强制重新拉取代码并覆盖安装?(y/N): ").strip().lower() + if override != 'y': + print("已取消更新。") + time.sleep(1.5) + return + else: + print(f"\n【检测到更新】本地版本: {local_commit[:8]},远程最新版本: {remote_commit[:8]}") + confirm = input("是否确认开始更新并重启服务?(Y/n): ").strip().lower() + if confirm not in ('', 'y', 'yes'): + print("已取消更新。") + time.sleep(1.5) + return + + print(f"\n正在强制重置本地代码至 origin/{branch} ...", flush=True) + subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], check=True) + + # Clean up python cache files + print("正在清理 Python 缓存 (pycache)...", flush=True) + subprocess.run(["find", ".", "-type", "d", "-name", "__pycache__", "-exec", "rm", "-rf", "{}", "+"], check=False) + + print("代码拉取成功,正在重新运行安装脚本...", flush=True) + subprocess.run(["bash", "install.sh"]) + print("更新已完成!") + time.sleep(2) + except Exception as e: + print(f"更新失败: {e}") + time.sleep(4) + else: + print(f"未找到安装目录: {INSTALL_DIR}") + time.sleep(2) + +def uninstall_service(): + confirm = input("确定要完全卸载 AimiliVPN 吗?(y/N): ") + if confirm.lower() == 'y': + print("正在完全卸载 AimiliVPN...", flush=True) + subprocess.run(["systemctl", "stop", "aimilivpn.service"]) + subprocess.run(["systemctl", "disable", "aimilivpn.service"]) + try: + os.unlink("/lib/systemd/system/aimilivpn.service") + except Exception: + pass + try: + os.unlink("/usr/bin/ml") + except Exception: + pass + subprocess.run(["rm", "-rf", INSTALL_DIR]) + print("AimiliVPN 已卸载!") + sys.exit(0) + else: + print("已取消卸载。") + time.sleep(1) + +def ask_restart(): + ans = input("配置已保存。是否立即重启服务生效?(Y/n): ").strip().lower() + if ans in ('', 'y', 'yes'): + print("正在重启 AimiliVPN 服务...", flush=True) + subprocess.run(["systemctl", "restart", "aimilivpn.service"]) + print("服务已重启。") + time.sleep(1.5) + +def configure_web(): + cfg = load_ui_cfg() + while True: + print("\033[H\033[J", end="") + print("=======================================================") + print(" 网页绑定与地址后缀配置 ") + print("=======================================================") + print(f" [1] 切换绑定地址 (当前: {cfg.get('host', '0.0.0.0')})") + print(f" [2] 随机重置安全后缀 (当前: {cfg.get('secret_path', '')})") + print(" [3] 返回主菜单") + print("=======================================================") + print("请直接输入数字键 [1-3] 快速执行:", end="", flush=True) + + key = getch() + if key == '1': + print("\033[H\033[J", end="") + print("选择网页登录绑定地址:") + print(" 1. 仅允许本地登录 (127.0.0.1 - 更安全)") + print(" 2. 允许公网IP登录 (0.0.0.0 - 方便远程)") + sel = input("请选择 (1 或 2, 默认2): ").strip() + if sel == '1': + cfg['host'] = "127.0.0.1" + else: + cfg['host'] = "0.0.0.0" + save_ui_cfg(cfg) + print(f"绑定地址已更新为: {cfg['host']}") + ask_restart() + break + elif key == '2': + print("\033[H\033[J", end="") + new_path = generate_random_suffix() + cfg['secret_path'] = new_path + save_ui_cfg(cfg) + print("安全登录后缀已随机重置成功!") + print(f"您的全新安全登录后缀为: {new_path}") + print(f"新的访问路径为: http://{cfg['host']}:{cfg['port']}/{new_path}/") + ask_restart() + break + elif key == '3' or key == 'q' or key == '\x03': + break + +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 之间。") + time.sleep(2) + except ValueError: + print("错误: 输入必须是数字。") + time.sleep(2) + +def configure_credentials(): + cfg = load_ui_cfg() + while True: + print("\033[H\033[J", end="") + print("=======================================================") + print(" 管理账号密码管理 ") + print("=======================================================") + curr_uname = cfg.get('username', 'admin') + curr_pwd = cfg.get('password', '') + masked_pwd = curr_pwd if len(curr_pwd) <= 4 else curr_pwd[:3] + "********" + curr_pwd[-2:] + print(f"当前管理账号: {curr_uname}") + print(f"当前管理密码: {masked_pwd}") + print(" [1] 自定义修改账号密码") + print(" [2] 随机重置安全密码") + print(" [3] 返回主菜单") + print("=======================================================") + print("请直接输入数字键 [1-3] 快速执行:", end="", flush=True) + + key = getch() + if key == '1': + print("\033[H\033[J", end="") + new_uname = input("请输入新管理账号 (回车默认 admin): ").strip() + if not new_uname: + new_uname = "admin" + new_pwd = input("请输入新管理密码 (不能为空): ").strip() + if not new_pwd: + print("错误: 密码不能为空!") + time.sleep(2) + continue + cfg['username'] = new_uname + cfg['password'] = new_pwd + save_ui_cfg(cfg) + print("账号密码修改成功!") + print(f"您的新管理账号: {new_uname}") + print(f"您的新管理密码: {new_pwd}") + input("\n按任意键返回菜单...") + elif key == '2': + print("\033[H\033[J", end="") + new_pwd = generate_random_password() + cfg['password'] = new_pwd + save_ui_cfg(cfg) + print("密码随机重置成功!") + print(f"您的全新12位安全密码为: {new_pwd}") + print("密码已保存在本地,不需要重启服务,刷新浏览器即可登录。") + input("\n按任意键返回菜单...") + elif key == '3' or key == 'q' or key == '\x03': + break + +def getch(): + fd = sys.stdin.fileno() + try: + old_settings = termios.tcgetattr(fd) + except termios.error: + return sys.stdin.read(1) + try: + tty.setraw(sys.stdin.fileno()) + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + return ch + +def getch_timeout(timeout=1.0): + import select + fd = sys.stdin.fileno() + try: + old_settings = termios.tcgetattr(fd) + except termios.error: + try: + r, _, _ = select.select([sys.stdin], [], [], timeout) + if r: + ch = sys.stdin.read(1) + if not ch: + time.sleep(timeout) + return None + return ch + except Exception: + time.sleep(timeout) + return None + try: + tty.setraw(fd) + r, _, _ = select.select([sys.stdin], [], [], timeout) + if r: + ch = sys.stdin.read(1) + if not ch: + return None + return ch + return None + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + +def get_status_state(): + cfg = load_ui_cfg() + state = load_state() + return ( + cfg.get("port", 8787), + cfg.get("secret_path", "EJsW2EeBo9lY"), + cfg.get("username", "admin"), + cfg.get("password", ""), + cfg.get("host", "0.0.0.0"), + state.get("is_connecting", False), + state.get("active_openvpn_node_id", ""), + state.get("last_check_message", ""), + state.get("active_node_latency", ""), + check_port_listening(7928), + check_service_active("aimilivpn.service"), + check_openvpn_process(), + get_service_pid("aimilivpn.service") + ) + +def main(): + if os.geteuid() != 0: + print("错误: 必须以 root 权限运行此命令。") + sys.exit(1) + + if len(sys.argv) > 1: + cmd = sys.argv[1].lower() + if cmd == "start": + start_service() + elif cmd == "stop": + stop_service() + elif cmd == "restart": + restart_service() + elif cmd == "status": + try: + last_state = None + while True: + current_state = get_status_state() + if current_state != last_state: + print("\033[H\033[J", end="") + print_status() + print("\n提示: 正在实时监控状态,自动更新。按 Ctrl+C 退出...") + last_state = current_state + time.sleep(0.5) + except KeyboardInterrupt: + pass + elif cmd == "logs": + show_logs() + elif cmd == "update": + update_service() + elif cmd == "uninstall": + uninstall_service() + elif cmd == "web": + configure_web() + elif cmd == "port": + configure_port() + elif cmd == "password": + configure_credentials() + else: + print("未知命令。可用命令: start, stop, restart, status, logs, update, uninstall, web, port, password") + sys.exit(0) + + options = { + '1': ("启动服务 (ml start)", start_service), + '2': ("停止服务 (ml stop)", stop_service), + '3': ("重启服务 (ml restart)", restart_service), + '4': ("日志监控 (ml logs)", show_logs), + '5': ("网页配置 (ml web)", configure_web), + '6': ("端口配置 (ml port)", configure_port), + '7': ("账号密码 (ml password)", configure_credentials), + '8': ("一键更新 (ml update)", update_service), + '9': ("完全卸载 (ml uninstall)", uninstall_service), + '0': ("退出终端", None) + } + + last_state = None + while True: + current_state = get_status_state() + if current_state != last_state: + print("\033[H\033[J", end="") + print_status() + + bold = "\033[1m" + reset = "\033[0m" + green = "\033[1;32m" + + print(f"【{bold}终端指令菜单栏{reset}】") + for key in sorted(options.keys()): + if key == '0': + continue + name, _ = options[key] + print(f" {green}[{key}]{reset} {name}") + print(f" {green}[0]{reset} {options['0'][0]}") + print("=======================================================") + print("请直接输入数字键 [0-9] 快速选择执行:", end="", flush=True) + last_state = current_state + + try: + key = getch_timeout(0.5) + except KeyboardInterrupt: + break + + if key is None: + continue + + if key == '\x03': + break + + # Reset last_state to force redraw after any key input + last_state = None + + if key in options: + name, func = options[key] + if func is None: + break + print("\033[H\033[J", end="") + print(f"正在执行: {name}...\n") + func() + if func in (start_service, stop_service, restart_service): + continue + if func in (configure_web, configure_port, configure_credentials, show_logs, update_service): + continue + input("\n操作已完成,按回车键返回主菜单...") + +if __name__ == "__main__": + main() +EOF +chmod +x /usr/bin/ml + +# 7. Configure Custom parameters (First-time installation check) +AUTH_FILE="${INSTALL_DIR}/vpngate_data/ui_auth.json" +mkdir -p "${INSTALL_DIR}/vpngate_data" + +if [ ! -f "$AUTH_FILE" ]; then + echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}" + read -p "是否自定义配置?[y/N]: " is_custom + + # Initialize defaults + UI_PORT=8787 + # generate random secret suffix (12 chars alphanumeric) + SECRET_PATH=$(python3 -c "import random, string; print(''.join(random.choices(string.ascii_letters + string.digits, k=12)))") + # generate random password + UI_PASSWORD=$(python3 -c " +import random, string +chars = string.ascii_letters + string.digits +while True: + pwd = ''.join(random.choices(chars, k=12)) + if any(c.islower() for c in pwd) and any(c.isupper() for c in pwd) and any(c.isdigit() for c in pwd): + print(pwd) + break +") + UI_USERNAME="admin" + + if [[ "$is_custom" =~ ^[Yy]$ ]]; then + # Step-by-step custom inputs + # 1. Custom port + while true; do + read -p "请输入自定义管理端口 [1-65535, 默认 8787]: " input_port + if [ -z "$input_port" ]; then + UI_PORT=8787 + break + fi + if [[ "$input_port" =~ ^[0-9]+$ ]] && [ "$input_port" -ge 1 ] && [ "$input_port" -le 65535 ]; then + UI_PORT=$input_port + break + else + echo -e "${RED}输入错误: 端口必须是 1 到 65535 之间的数字!${PLAIN}" + fi + done + + # 2. Custom suffix + while true; do + read -p "请输入网页登录自定义安全后缀 [字母与数字组合, 默认随机]: " input_suffix + if [ -z "$input_suffix" ]; then + break + fi + if [[ "$input_suffix" =~ ^[A-Za-z0-9]+$ ]]; then + SECRET_PATH=$input_suffix + break + else + echo -e "${RED}输入错误: 后缀仅能由英文字母和数字组成!${PLAIN}" + fi + done + + # 3. Custom login username and password + read -p "请输入登录账号 [默认 admin]: " input_user + if [ -n "$input_user" ]; then + UI_USERNAME=$input_user + fi + + while true; do + read -p "请输入登录密码 [默认随机生成, 建议包含字母、数字与符号]: " input_pass + if [ -z "$input_pass" ]; then + break + fi + if [ ${#input_pass} -ge 4 ]; then + UI_PASSWORD=$input_pass + break + else + echo -e "${RED}输入错误: 密码长度不能少于 4 位!${PLAIN}" + fi + done + fi + + # Write config JSON + python3 -c " +import json +cfg = { + 'host': '0.0.0.0', + 'port': int('$UI_PORT'), + 'secret_path': '$SECRET_PATH', + 'username': '$UI_USERNAME', + 'password': '$UI_PASSWORD' +} +with open('$AUTH_FILE', 'w', encoding='utf-8') as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) +" +fi + +# 8. Start service +echo -e "\n正在启动 AimiliVPN 服务并初始化网络..." +systemctl restart aimilivpn.service || true + +# Wait and poll for node loading and active connection +echo -e "\n正在等待 AimiliVPN 首次获取节点并建立加密通道 (此过程可能需要 5-30 秒)..." +ACTIVE_ID="" +LAST_MSG="" +for i in {1..90}; do + if [ -f "${INSTALL_DIR}/vpngate_data/state.json" ]; then + ACTIVE_ID=$(python3 -c "import json; print(json.load(open('${INSTALL_DIR}/vpngate_data/state.json')).get('active_openvpn_node_id', ''))" 2>/dev/null || echo "") + IS_CONN=$(python3 -c "import json; print(json.load(open('${INSTALL_DIR}/vpngate_data/state.json')).get('is_connecting', False))" 2>/dev/null || echo "False") + CUR_MSG=$(python3 -c "import json; print(json.load(open('${INSTALL_DIR}/vpngate_data/state.json')).get('last_check_message', ''))" 2>/dev/null || echo "") + + if [ "$IS_CONN" = "False" ] || [ "$IS_CONN" = "false" ]; then + if [ -n "$ACTIVE_ID" ]; then + echo -e " -> ${GREEN}[已就绪]${PLAIN} 首次节点连接成功,活动节点: ${GREEN}$ACTIVE_ID${PLAIN}" + break + else + if [ -n "$CUR_MSG" ] && [ "$CUR_MSG" != "$LAST_MSG" ]; then + echo -e " -> 提示: ${YELLOW}${CUR_MSG}${PLAIN}" + LAST_MSG="$CUR_MSG" + fi + fi + else + if [ -n "$CUR_MSG" ] && [ "$CUR_MSG" != "$LAST_MSG" ]; then + echo -e " -> 状态: ${YELLOW}${CUR_MSG}${PLAIN}" + LAST_MSG="$CUR_MSG" + fi + fi + else + echo -n "." + fi + sleep 1 +done +if [ -z "$ACTIVE_ID" ]; then + echo -e " -> ${YELLOW}[加载超时]${PLAIN} 首次节点获取或连接超时,将在后台继续尝试..." +fi + +SECRET_PATH="EJsW2EeBo9lY" +USERNAME="admin" +PASSWORD="未配置" +UI_PORT=8787 +AUTH_FILE="${INSTALL_DIR}/vpngate_data/ui_auth.json" +if [ -f "$AUTH_FILE" ]; then + SECRET_PATH=$(python3 -c "import json; print(json.load(open('$AUTH_FILE')).get('secret_path', 'EJsW2EeBo9lY'))" 2>/dev/null || echo "EJsW2EeBo9lY") + USERNAME=$(python3 -c "import json; print(json.load(open('$AUTH_FILE')).get('username', 'admin'))" 2>/dev/null || echo "admin") + PASSWORD=$(python3 -c "import json; print(json.load(open('$AUTH_FILE')).get('password', '未配置'))" 2>/dev/null || echo "未配置") + UI_PORT=$(python3 -c "import json; print(json.load(open('$AUTH_FILE')).get('port', 8787))" 2>/dev/null || echo "8787") +fi + +# Get VPS public IP +echo -e "正在获取 VPS 公网 IP..." +PUBLIC_IP=$(curl -s --max-time 3 https://api.ipify.org || curl -s --max-time 3 https://ifconfig.me || curl -s --max-time 3 icanhazip.com || echo "您的服务器公网IP") +echo -n "$PUBLIC_IP" > "${INSTALL_DIR}/vpngate_data/public_ip.txt" + +echo -e "\n${GREEN}==========================================================${PLAIN}" +echo -e "${GREEN} AimiliVPN 源码一键部署已完成!${PLAIN}" +echo -e "${GREEN}==========================================================${PLAIN}" +echo -e " * 网页控制面板: ${BLUE}http://${PUBLIC_IP}:${UI_PORT}/${SECRET_PATH}/${PLAIN}" +echo -e " * 网页管理账号: ${YELLOW}${USERNAME}${PLAIN}" +echo -e " * 网页管理密码: ${YELLOW}${PASSWORD}${PLAIN}" +echo -e " * HTTP/SOCKS5 代理端口: ${BLUE}http://127.0.0.1:7928/${PLAIN}" +echo -e " --------------------------------------------------------" +echo -e " * 快速状态指令: ${YELLOW}ml status${PLAIN} 或 ${YELLOW}ml${PLAIN}" +echo -e " * 查看实时日志: ${YELLOW}ml logs${PLAIN}" +echo -e " * 停止服务: ${YELLOW}ml stop${PLAIN}" +echo -e " * 重启服务: ${YELLOW}ml restart${PLAIN}" +echo -e "==========================================================" +echo diff --git a/proxy_server.py b/proxy_server.py new file mode 100644 index 0000000..398b903 --- /dev/null +++ b/proxy_server.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import select +import socket +import threading +import urllib.parse +import time +from typing import Any + +def parse_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + +def recv_exact(sock: socket.socket, size: int) -> bytes: + data = b"" + while len(data) < size: + chunk = sock.recv(size - len(data)) + if not chunk: + raise ConnectionError("Unexpected disconnect.") + data += chunk + return data + +def create_connection(address: tuple[str, int], timeout: float = 20) -> socket.socket: + host, port = address + err = None + for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + sock = None + try: + sock = socket.socket(af, socktype, proto) + sock.settimeout(timeout) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b"tun0") + sock.connect(sa) + return sock + except OSError as e: + err = e + if sock is not None: + sock.close() + if err is not None: + raise err + else: + raise OSError("getaddrinfo returns empty list") + +def relay(left: socket.socket, right: socket.socket) -> None: + sockets = [left, right] + while True: + readable, _, errored = select.select(sockets, [], sockets, 120) + if errored: + return + for source in readable: + target = right if source is left else left + data = source.recv(65536) + if not data: + return + target.sendall(data) + +def socks5_client(client: socket.socket, first_byte: bytes) -> None: + upstream = None + try: + methods_count = recv_exact(client, 1)[0] + recv_exact(client, methods_count) + client.sendall(b"\x05\x00") + version, command, _, address_type = recv_exact(client, 4) + if version != 5 or command != 1: + client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00") + return + if address_type == 1: + host = socket.inet_ntoa(recv_exact(client, 4)) + elif address_type == 3: + host = recv_exact(client, recv_exact(client, 1)[0]).decode("idna") + elif address_type == 4: + host = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16)) + else: + client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00") + return + port = int.from_bytes(recv_exact(client, 2), "big") + upstream = create_connection((host, port), timeout=20) + client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + relay(client, upstream) + finally: + client.close() + if upstream: + upstream.close() + +def read_http_header(client: socket.socket, first_byte: bytes) -> bytes: + data = first_byte + while b"\r\n\r\n" not in data and len(data) < 65536: + chunk = client.recv(4096) + if not chunk: + break + data += chunk + return data + +def http_client(client: socket.socket, first_byte: bytes) -> None: + upstream = None + try: + header = read_http_header(client, first_byte) + head, rest = header.split(b"\r\n\r\n", 1) + lines = head.decode("iso-8859-1", errors="replace").split("\r\n") + method, target, version = lines[0].split(" ", 2) + if method.upper() == "CONNECT": + host, _, port_text = target.partition(":") + port = parse_int(port_text) or 443 + upstream = create_connection((host, port), timeout=20) + client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + if rest: + upstream.sendall(rest) + relay(client, upstream) + return + + parsed = urllib.parse.urlsplit(target) + if not parsed.hostname: + client.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") + return + port = parsed.port or (443 if parsed.scheme == "https" else 80) + path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, "")) + headers = [line for line in lines[1:] if not line.lower().startswith(("proxy-connection:", "connection:"))] + request = f"{method} {path} {version}\r\n" + "\r\n".join(headers) + "\r\nConnection: close\r\n\r\n" + upstream = create_connection((parsed.hostname, port), timeout=20) + upstream.sendall(request.encode("iso-8859-1") + rest) + relay(client, upstream) + except Exception: + try: + client.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + except OSError: + pass + finally: + client.close() + if upstream: + upstream.close() + +def proxy_client(client: socket.socket, address: tuple[str, int]) -> None: + try: + client.settimeout(30) + first = recv_exact(client, 1) + if first == b"\x05": + socks5_client(client, first) + else: + http_client(client, first) + except Exception: + try: + client.close() + except OSError: + pass + +def start_proxy_server(host: str, port: int) -> None: + try: + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((host, port)) + server.listen(256) + print(f"HTTP/SOCKS5 proxy listening on {host}:{port}", flush=True) + except Exception as e: + print(f"[ERROR] Failed to start HTTP/SOCKS5 proxy on {host}:{port}: {e}", flush=True) + return + + while True: + try: + client, address = server.accept() + threading.Thread(target=proxy_client, args=(client, address), daemon=True).start() + except Exception as e: + print(f"[ERROR] Proxy accept failed: {e}", flush=True) + time.sleep(0.5) \ No newline at end of file diff --git a/vpn_utils.py b/vpn_utils.py new file mode 100644 index 0000000..6ac8c41 --- /dev/null +++ b/vpn_utils.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json +import os +import re +import socket +import subprocess +import time +import urllib.parse +import urllib.request +import threading +from pathlib import Path +from typing import Any + +ROOT_DIR = Path(__file__).resolve().parent +DATA_DIR = ROOT_DIR / "vpngate_data" +IP_CACHE_FILE = DATA_DIR / "ip_cache.json" + +ip_cache_lock = threading.RLock() + +COUNTRY_TRANSLATIONS = { + "Japan": "日本", + "Korea Republic of": "韩国", + "Korea": "韩国", + "Republic of Korea": "韩国", + "Thailand": "泰国", + "United States": "美国", + "United Kingdom": "英国", + "Russian Federation": "俄罗斯", + "Russian": "俄罗斯", + "Viet Nam": "越南", + "Vietnam": "越南", + "China": "中国", + "Taiwan": "台湾", + "Taiwan Province of China": "台湾", + "Hong Kong": "香港", + "Singapore": "新加坡", + "Malaysia": "马来西亚", + "Indonesia": "印度尼西亚", + "India": "印度", + "Philippines": "菲律宾", + "Australia": "澳大利亚", + "New Zealand": "新西兰", + "Canada": "加拿大", + "Ukraine": "乌克兰", + "France": "法国", + "Germany": "德国", + "Netherlands": "荷兰", + "Sweden": "瑞典", + "Norway": "挪威", + "Spain": "西班牙", + "Turkey": "土耳其", + "South Africa": "南非", + "Brazil": "巴西", + "Argentina": "阿根廷", + "Chile": "智利", + "Mexico": "墨西哥", + "Egypt": "埃及", + "Romania": "罗马尼亚", + "Poland": "波兰", + "Kazakhstan": "哈萨克斯坦", + "Georgia": "格鲁吉亚", + "Mongolia": "蒙古", + "Saudi Arabia": "沙特阿拉伯", + "Iran": "伊朗", + "Iraq": "伊拉克", + "Colombia": "哥伦比亚", + "Cambodia": "柬埔寨", + "Ireland": "爱尔兰", + "Italy": "意大利", + "Switzerland": "瑞士", + "Belgium": "比利时", + "Austria": "奥地利", + "Denmark": "丹麦", + "Finland": "芬兰", + "Portugal": "葡萄牙", + "Greece": "希腊", + "Czech Republic": "捷克", + "Hungary": "匈牙利", + "Israel": "以色列", + "United Arab Emirates": "阿联酋", + "UAE": "阿联酋", + "Macao": "澳门", + "Macau": "澳门", + "Iceland": "冰岛", + "Luxembourg": "卢森堡", +} + +def get_upstream_proxy() -> tuple[str | None, str | None, int | None]: + """ + Returns (proxy_type, host, port) from environment variables. + proxy_type is 'socks' or 'http'. + """ + socks_env = os.environ.get("OPENVPN_UPSTREAM_SOCKS") + if socks_env: + if "://" in socks_env: + parsed = urllib.parse.urlsplit(socks_env) + if parsed.hostname and parsed.port: + return "socks", parsed.hostname, parsed.port + else: + parts = socks_env.split(":") + if len(parts) == 2: + return "socks", parts[0], int(parts[1]) + elif len(parts) == 1: + return "socks", parts[0], 10808 + + http_env = os.environ.get("OPENVPN_UPSTREAM_HTTP") + if http_env: + if "://" in http_env: + parsed = urllib.parse.urlsplit(http_env) + if parsed.hostname and parsed.port: + return "http", parsed.hostname, parsed.port + else: + parts = http_env.split(":") + if len(parts) == 2: + return "http", parts[0], int(parts[1]) + elif len(parts) == 1: + return "http", parts[0], 10808 + + for env_name in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"]: + val = os.environ.get(env_name) + if not val: + continue + if "://" in val: + parsed = urllib.parse.urlsplit(val) + ptype = "socks" if parsed.scheme.startswith("socks") else "http" + if parsed.hostname and parsed.port: + return ptype, parsed.hostname, parsed.port + else: + parts = val.split(":") + if len(parts) == 2: + return "http", parts[0], int(parts[1]) + return None, None, None + +def is_config_tcp(config_text: str) -> bool: + try: + for line in config_text.splitlines(): + line = line.strip() + if not line or line.startswith(("#", ";")): + continue + parts = line.split() + if parts[0].lower() == "proto" and len(parts) >= 2: + if "tcp" in parts[1].lower(): + return True + elif parts[0].lower() == "remote" and len(parts) >= 4: + if "tcp" in parts[3].lower(): + return True + except Exception: + pass + return False + +def parse_remote(config_text: str, fallback_ip: str = "") -> tuple[str, int, str]: + remote_host = fallback_ip + remote_port = 0 + proto = "unknown" + for raw_line in config_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith(("#", ";")): + continue + parts = line.split() + if parts[0].lower() == "proto" and len(parts) >= 2: + proto = parts[1].lower() + elif parts[0].lower() == "remote" and len(parts) >= 3: + remote_host = parts[1] + remote_port = int(parts[2]) if parts[2].isdigit() else 0 + return remote_host, remote_port, proto + +def get_physical_interface() -> str | None: + try: + res = subprocess.run(["ip", "route"], capture_output=True, text=True, timeout=2) + if res.returncode == 0: + routes = [] + for line in res.stdout.splitlines(): + if line.startswith("default via"): + parts = line.split() + try: + gw = parts[2] + dev = parts[parts.index("dev") + 1] + metric = 0 + if "metric" in parts: + metric = int(parts[parts.index("metric") + 1]) + routes.append((gw, dev, metric)) + except (ValueError, IndexError): + continue + if routes: + routes.sort(key=lambda x: x[2], reverse=True) + for gw, dev, metric in routes: + if not dev.startswith(("tun", "tap", "wg", "ppp")): + return dev + return routes[0][1] + except Exception: + pass + return None + +def tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int: + started = time.time() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.settimeout(5) + if dev: + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, dev.encode("utf-8")) + except OSError: + pass + s.connect((host, port)) + return max(1, int((time.time() - started) * 1000)) + except OSError: + return 0 + finally: + try: + s.close() + except Exception: + pass + +def ping_latency_ms(host: str, port: int, fallback_ping: int = 0) -> int: + dev = get_physical_interface() + # 1. Try ping with interface binding + if dev: + try: + cmd = ["ping", "-c", "1", "-W", "2", "-I", dev, host] + res = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=2 + ) + if res.returncode == 0: + match = re.search(r"time=([\d.]+)\s*ms", res.stdout) + if match: + val = int(float(match.group(1))) + if val > 0: + return val + except Exception: + pass + + # 2. Try ping without interface binding + try: + cmd = ["ping", "-c", "1", "-W", "2", host] + res = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=2 + ) + if res.returncode == 0: + match = re.search(r"time=([\d.]+)\s*ms", res.stdout) + if match: + val = int(float(match.group(1))) + if val > 0: + return val + except Exception: + pass + + # 3. Try TCP latency check + tcp_val = tcp_latency_ms(host, port, dev) + if tcp_val > 0: + return tcp_val + + # 4. Fallback + if fallback_ping > 0: + return fallback_ping + return 0 + +def check_and_fix_dns() -> None: + """ + Checks if DNS resolution is broken in WSL. + If names fail but direct IP connections work, appends public DNS nameservers to /etc/resolv.conf. + """ + try: + socket.gethostbyname("www.vpngate.net") + return + except socket.gaierror: + pass + + network_ok = False + for ip in ["8.8.8.8", "1.1.1.1"]: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.settimeout(2) + s.connect((ip, 53)) + network_ok = True + break + except Exception: + pass + finally: + try: + s.close() + except Exception: + pass + + if not network_ok: + return + + resolv_file = Path("/etc/resolv.conf") + if resolv_file.exists(): + try: + content = resolv_file.read_text(encoding="utf-8", errors="replace") + if "nameserver 1.1.1.1" not in content and "nameserver 8.8.8.8" not in content: + print("[dns_heal] Resolving names failed, but IP network is OK. Appending public DNS to /etc/resolv.conf...", flush=True) + with open("/etc/resolv.conf", "a", encoding="utf-8") as f: + f.write("\nnameserver 1.1.1.1\nnameserver 8.8.8.8\n") + except Exception as e: + print(f"[dns_heal] Failed to write DNS fallback: {e}", flush=True) + +def load_ip_cache() -> dict[str, dict[str, Any]]: + with ip_cache_lock: + try: + if IP_CACHE_FILE.exists(): + return json.loads(IP_CACHE_FILE.read_text(encoding="utf-8")) + except Exception: + pass + return {} + +def save_ip_cache(cache: dict[str, dict[str, Any]]) -> None: + with ip_cache_lock: + try: + DATA_DIR.mkdir(exist_ok=True) + IP_CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + pass + +def enrich_ip_info(nodes: list[dict[str, Any]]) -> None: + # 1. Read cache thread-safely + with ip_cache_lock: + cache = load_ip_cache() + + ips_to_query = [] + now = time.time() + + for node in nodes: + ip = node.get("ip") or node.get("remote_host") + if not ip: + continue + if ip in cache and now - cache[ip].get("cached_at", 0) < 7 * 24 * 3600: + cached = cache[ip] + node["owner"] = cached.get("owner", "") + node["asn"] = cached.get("asn", "") + node["as_name"] = cached.get("as_name", "") + node["location"] = cached.get("location", "") + node["ip_type"] = cached.get("ip_type", "") + node["quality"] = cached.get("quality", "") + else: + if ip not in ips_to_query: + ips_to_query.append(ip) + + if not ips_to_query: + return + + # 2. Perform HTTP query outside lock + new_entries = {} + chunk_size = 100 + for i in range(0, len(ips_to_query), chunk_size): + chunk = ips_to_query[i : i + chunk_size] + payload = json.dumps(chunk).encode("utf-8") + request = urllib.request.Request( + "http://ip-api.com/batch?lang=zh-CN&fields=status,message,query,country,regionName,city,isp,org,as,asname,proxy,hosting,mobile", + data=payload, + headers={"Content-Type": "application/json", "User-Agent": "vpngate-manager/2.2"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + data = json.loads(response.read().decode("utf-8", errors="replace")) + for item in data: + if item.get("status") != "success": + continue + query_ip = item.get("query") + if not query_ip: + continue + + ip_type = "residential" + if item.get("mobile"): + ip_type = "mobile" + elif item.get("proxy"): + ip_type = "proxy" + elif item.get("hosting"): + ip_type = "hosting" + + quality = "normal" + if item.get("proxy"): + quality = "proxy" + elif item.get("hosting"): + quality = "datacenter" + elif item.get("mobile"): + quality = "mobile" + + loc = " ".join(part for part in [item.get("country"), item.get("regionName"), item.get("city")] if part) + + new_entries[query_ip] = { + "owner": item.get("org") or item.get("isp") or "", + "asn": item.get("as") or "", + "as_name": item.get("asname") or "", + "location": loc, + "ip_type": ip_type, + "quality": quality, + "cached_at": now, + } + except Exception as e: + print(f"[enrich_ip_info] Query failed: {e}", flush=True) + + if not new_entries: + return + + # 3. Save cache thread-safely (reload & update to avoid overwrite of concurrent queries) + with ip_cache_lock: + cache = load_ip_cache() + cache.update(new_entries) + save_ip_cache(cache) + + # 4. Enrich nodes with newly queried info + for node in nodes: + ip = node.get("ip") or node.get("remote_host") + if ip in new_entries: + cached = new_entries[ip] + node["owner"] = cached.get("owner", "") + node["asn"] = cached.get("asn", "") + node["as_name"] = cached.get("as_name", "") + node["location"] = cached.get("location", "") + node["ip_type"] = cached.get("ip_type", "") + node["quality"] = cached.get("quality", "") \ No newline at end of file diff --git a/vpngate_manager.py b/vpngate_manager.py new file mode 100644 index 0000000..f7d2ec4 --- /dev/null +++ b/vpngate_manager.py @@ -0,0 +1,3519 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import base64 +import csv +import json +import os +import queue +import re +import select +import shlex +import socket +import subprocess +import threading +import time +import urllib.parse +import urllib.request +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +import concurrent.futures +import sys + +import vpn_utils +import proxy_server + +API_URL = "https://www.vpngate.net/api/iphone/" +FETCH_INTERVAL_SECONDS = int(os.environ.get("FETCH_INTERVAL_SECONDS", "960")) +CHECK_INTERVAL_SECONDS = int(os.environ.get("CHECK_INTERVAL_SECONDS", "960")) +TARGET_VALID_NODES = int(os.environ.get("TARGET_VALID_NODES", "3")) +MAX_SCAN_ROWS = int(os.environ.get("MAX_SCAN_ROWS", "300")) +OPENVPN_TEST_TIMEOUT_SECONDS = int(os.environ.get("OPENVPN_TEST_TIMEOUT_SECONDS", "35")) +OPENVPN_CMD = os.environ.get("OPENVPN_CMD", "openvpn") +OPENVPN_AUTH_USER = os.environ.get("OPENVPN_AUTH_USER", "vpn") +OPENVPN_AUTH_PASS = os.environ.get("OPENVPN_AUTH_PASS", "vpn") +LOCAL_PROXY_HOST = os.environ.get("LOCAL_PROXY_HOST", "127.0.0.1") +LOCAL_PROXY_PORT = int(os.environ.get("LOCAL_PROXY_PORT", "7928")) +UI_HOST = os.environ.get("UI_HOST", "0.0.0.0") +UI_PORT = int(os.environ.get("UI_PORT", "8787")) +INVALID_BACKOFF_SECONDS = int(os.environ.get("INVALID_BACKOFF_SECONDS", str(30 * 60))) + +ROOT_DIR = Path(sys.executable).resolve().parent if globals().get("__compiled__") else Path(__file__).resolve().parent +DATA_DIR = Path(os.environ["VPNGATE_DATA_DIR"]).resolve() if os.environ.get("VPNGATE_DATA_DIR") else ROOT_DIR / "vpngate_data" +CONFIG_DIR = DATA_DIR / "configs" +NODES_FILE = DATA_DIR / "nodes.json" +STATE_FILE = DATA_DIR / "state.json" +AUTH_FILE = DATA_DIR / "vpngate_auth.txt" + +lock = threading.RLock() +active_openvpn_process: subprocess.Popen[str] | None = None +active_openvpn_node_id = "" +is_connecting = False +last_active_ping_time = 0.0 +last_active_latency = 0 + +def ensure_dirs() -> None: + DATA_DIR.mkdir(exist_ok=True) + CONFIG_DIR.mkdir(exist_ok=True) + if not AUTH_FILE.exists(): + AUTH_FILE.write_text(f"{OPENVPN_AUTH_USER}\n{OPENVPN_AUTH_PASS}\n", encoding="utf-8") + try: + AUTH_FILE.chmod(0o600) + except OSError: + pass + +def write_json(path: Path, data: Any) -> None: + with lock: + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) + +def read_json(path: Path, default: Any) -> Any: + with lock: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return default + +import hashlib +import random + +def generate_random_password() -> str: + import string + chars = string.ascii_letters + string.digits + while True: + pwd = "".join(random.choices(chars, k=12)) + # Ensure it contains at least one lowercase, one uppercase, and one digit + has_lower = any(c.islower() for c in pwd) + has_upper = any(c.isupper() for c in pwd) + has_digit = any(c.isdigit() for c in pwd) + if has_lower and has_upper and has_digit: + return pwd + +def load_ui_config() -> dict[str, Any]: + with lock: + auth_file = DATA_DIR / "ui_auth.json" + config = { + "username": "admin", + "secret_path": "EJsW2EeBo9lY", + "password": "", + "host": "0.0.0.0", + "port": 8787 + } + updated = False + if auth_file.exists(): + try: + data = json.loads(auth_file.read_text(encoding="utf-8")) + for key, val in data.items(): + config[key] = val + except Exception: + pass + + if not config.get("password"): + config["password"] = generate_random_password() + updated = True + + if not auth_file.exists() or updated: + try: + DATA_DIR.mkdir(exist_ok=True, parents=True) + auth_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + pass + + return config + +def get_session_token(password: str, username: str = "admin") -> str: + salt = "aimilivpn_secure_salt_2026" + return hashlib.sha256((username + ":" + password + salt).encode("utf-8")).hexdigest() + +def cleanup_old_logs(logs_dir: Path) -> None: + try: + now = time.time() + three_days_sec = 3 * 24 * 60 * 60 + for path in logs_dir.glob("*.json"): + match = re.match(r"^(\d{4}-\d{2}-\d{2})\.json$", path.name) + if match: + date_str = match.group(1) + try: + file_time = time.mktime(time.strptime(date_str, "%Y-%m-%d")) + 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() + print(f"[清理] 已删除3天前的旧日志文件: {path.name}", flush=True) + except Exception: + if now - path.stat().st_mtime > three_days_sec: + path.unlink() + except Exception as e: + print(f"[清理错误] 清理旧日志失败: {e}", flush=True) + +def log_to_json(level: str, module: str, message: str) -> None: + try: + logs_dir = DATA_DIR / "logs" + logs_dir.mkdir(exist_ok=True, parents=True) + date_str = time.strftime("%Y-%m-%d", time.localtime()) + log_file = logs_dir / f"{date_str}.json" + entry = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "level": level, + "module": module, + "message": message + } + 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) + +def set_state(**updates: Any) -> None: + state = get_state() + state.update(updates) + write_json(STATE_FILE, state) + +def get_state() -> dict[str, Any]: + global active_openvpn_node_id, is_connecting + state = read_json(STATE_FILE, {}) + state["active_openvpn_node_id"] = active_openvpn_node_id + state["is_connecting"] = is_connecting + state.setdefault("api_url", API_URL) + state.setdefault("target_valid_nodes", TARGET_VALID_NODES) + state.setdefault("fetch_interval_seconds", FETCH_INTERVAL_SECONDS) + state.setdefault("check_interval_seconds", CHECK_INTERVAL_SECONDS) + state.setdefault("local_proxy", f"http://{LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}") + state.setdefault("last_fetch_status", "not_started") + state.setdefault("last_check_message", "") + state.setdefault("blacklisted_nodes", 0) + + # Pre-populate settings inputs in UI + ui_cfg = load_ui_config() + state["username"] = ui_cfg.get("username", "admin") + state["port"] = ui_cfg.get("port", 8787) + state["secret_path"] = ui_cfg.get("secret_path", "EJsW2EeBo9lY") + + return state + +def safe_name(value: str) -> str: + value = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip()) + return value.strip("._") or "node" + +def parse_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + +def fetch_api_text() -> str: + request = urllib.request.Request( + API_URL, + headers={ + "User-Agent": "Mozilla/5.0 vpngate-openvpn-manager/2.0", + "Accept": "text/plain,*/*", + }, + ) + with urllib.request.urlopen(request, timeout=45) 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("*")] + if lines and lines[0].startswith("#"): + lines[0] = lines[0][1:] + return list(csv.DictReader(lines)) + +def decode_config(encoded: str) -> str: + return base64.b64decode(encoded.encode("ascii"), validate=False).decode("utf-8", errors="replace") + +def load_blacklist() -> dict[str, dict[str, Any]]: + return {} + +def mark_blacklisted(node: dict[str, Any], message: str) -> None: + pass + +def row_to_node(row: dict[str, str], config_text: str) -> dict[str, Any]: + ip = row.get("IP", "") + country_short = row.get("CountryShort", "") + remote_host, remote_port, proto = vpn_utils.parse_remote(config_text, ip) + node_id = safe_name("_".join([country_short or "XX", ip or remote_host, str(remote_port), proto])) + config_path = CONFIG_DIR / f"{node_id}.ovpn" + + country_long = row.get("CountryLong", "") + country_zh = vpn_utils.COUNTRY_TRANSLATIONS.get(country_long, vpn_utils.COUNTRY_TRANSLATIONS.get(country_long.strip(), country_long)) + return { + "id": node_id, + "country": country_zh, + "country_short": country_short, + "host_name": row.get("HostName", ""), + "ip": ip, + "score": parse_int(row.get("Score")), + "ping": parse_int(row.get("Ping")), + "speed": parse_int(row.get("Speed")), + "sessions": parse_int(row.get("NumVpnSessions")), + "owner": "", + "asn": "", + "as_name": "", + "location": "", + "ip_type": "", + "quality": "", + "latency_ms": 0, + "config_file": str(config_path), + "config_text": config_text, + "proto": proto, + "remote_host": remote_host, + "remote_port": remote_port, + "fetched_at": time.time(), + "probe_status": "not_checked", + "probe_message": "", + "probed_at": 0, + } + +def fetch_candidates() -> list[dict[str, Any]]: + blacklist = load_blacklist() + candidates: list[dict[str, Any]] = [] + seen_ips = set() + + log_to_json("INFO", "Main", "开始拉取官方 API 节点列表...") + for i in range(3): + 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 == 0 and not candidates: + log_to_json("ERROR", "Main", f"获取官方 API 节点失败: {e}") + raise + + set_state( + last_fetch_at=time.time(), + last_fetch_status="ok", + last_fetch_message=f"Fetched {len(candidates)} unique candidates across multiple attempts.", + blacklisted_nodes=len(blacklist), + ) + log_to_json("INFO", "Main", f"成功获取官方 API 节点,共 {len(candidates)} 个候选节点") + return candidates + +def cached_nodes() -> list[dict[str, Any]]: + return read_json(NODES_FILE, []) + +_openvpn_version = None + +def get_openvpn_version() -> float: + global _openvpn_version + if _openvpn_version is not None: + return _openvpn_version + try: + cmd = shlex.split(OPENVPN_CMD, posix=False) or ["openvpn"] + res = subprocess.run([cmd[0], "--version"], capture_output=True, text=True, timeout=2) + match = re.search(r"OpenVPN\s+(\d+\.\d+)", res.stdout or res.stderr) + if match: + _openvpn_version = float(match.group(1)) + return _openvpn_version + except Exception: + pass + _openvpn_version = 2.4 + return _openvpn_version + +def openvpn_command(config_file: str, route_nopull: bool, dev: str = "tun0") -> list[str]: + command = shlex.split(OPENVPN_CMD, posix=False) or ["openvpn"] + command.extend( + [ + "--config", + config_file, + "--dev", + dev, + "--dev-type", + "tun", + "--pull-filter", + "ignore", + "route-ipv6", + "--pull-filter", + "ignore", + "ifconfig-ipv6", + "--route-delay", + "2", + "--connect-retry-max", + "1", + "--connect-timeout", + "15", + "--auth-user-pass", + str(AUTH_FILE), + "--auth-nocache", + ] + ) + + version = get_openvpn_version() + if version >= 2.5: + command.extend(["--data-ciphers", "AES-128-CBC:AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305"]) + else: + command.extend(["--ncp-ciphers", "AES-128-CBC:AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305"]) + + command.extend(["--verb", "3"]) + + try: + content = Path(config_file).read_text(encoding="utf-8", errors="replace") + if vpn_utils.is_config_tcp(content): + ptype, host, port = vpn_utils.get_upstream_proxy() + if ptype == "socks" and host and port: + command.extend(["--socks-proxy", host, str(port)]) + elif ptype == "http" and host and port: + command.extend(["--http-proxy", host, str(port)]) + except Exception: + pass + + if route_nopull: + command.append("--route-nopull") + return command + +def stop_process(process: subprocess.Popen[str] | None) -> None: + if process is None or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + +def kill_existing_openvpn_processes() -> None: + if not sys.platform.startswith("linux"): + return + try: + # Terminate existing openvpn processes managing tun0 or using our vpngate configuration + subprocess.run(["pkill", "-f", "openvpn.*tun0"], capture_output=True, timeout=2) + subprocess.run(["pkill", "-f", "openvpn.*vpngate_data"], capture_output=True, timeout=2) + print("[Cleanup] Terminated existing AimiliVPN OpenVPN processes.", flush=True) + except Exception as e: + print(f"[Cleanup Error] Failed to kill existing OpenVPN processes: {e}", flush=True) + +def update_handshake_status(line_lower: str) -> None: + status_map = { + "resolving": ("解析域名", "正在解析服务器域名与 IP 地址..."), + "udp link local": ("物理连接", "已创建本地套接字,开始尝试发送数据包..."), + "tcp link local": ("物理连接", "已创建本地套接字,开始尝试发送数据包..."), + "tls: initial packet": ("证书握手", "已成功发送首包,正在与远程服务器建立 TLS 安全通道..."), + "verify ok": ("证书校验", "服务器证书校验成功,正在进行身份验证..."), + "peer connection initiated": ("协商加密", "控制通道已建立,已初始化与服务器的加密对等连接..."), + "push_request": ("请求配置", "正在向服务器发送 PUSH_REQUEST 请求配置参数与 IP 分配..."), + "push_reply": ("应用配置", "已接收服务器 PUSH_REPLY,获取到 IP 分配,正在准备配置网卡..."), + "tun/tap device": ("创建网卡", "正在创建虚拟通道并打开 TUN 虚拟网卡设备..."), + "do_ifconfig": ("网卡配置", "正在为虚拟网卡配置 IP 地址及相关网络属性..."), + } + for key, (short_status, detailed_desc) in status_map.items(): + if key in line_lower: + set_state(active_node_latency=short_status, last_check_message=detailed_desc) + break + +def run_openvpn_until_ready(config_file: str, keep_alive: bool, route_nopull: bool, timeout: int | None = None, dev: str = "tun0") -> tuple[bool, str, subprocess.Popen[str] | None]: + limit = timeout if timeout is not None else OPENVPN_TEST_TIMEOUT_SECONDS + try: + process = subprocess.Popen( + openvpn_command(config_file, route_nopull, dev), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + cwd=str(ROOT_DIR), + ) + except FileNotFoundError: + return False, "openvpn command not found", None + except OSError as exc: + return False, f"openvpn start failed: {exc}", None + + lines: queue.Queue[str | None] = queue.Queue() + startup_done = [False] + + def reader() -> None: + assert process.stdout is not None + for line in process.stdout: + if not startup_done[0]: + lines.put(line.rstrip()) + else: + if keep_alive: + print(f"[OpenVPN] {line.rstrip()}", flush=True) + if not startup_done[0]: + lines.put(None) + + threading.Thread(target=reader, daemon=True).start() + started = time.time() + tail: list[str] = [] + ok = False + message = "OpenVPN did not complete initialization." + while time.time() - started < limit: + try: + line = lines.get(timeout=0.5) + except queue.Empty: + if process.poll() is not None: + break + continue + if line is None: + break + if line: + tail.append(line) + tail = tail[-8:] + if keep_alive: + print(f"[OpenVPN] {line}", flush=True) + lower = line.lower() + if keep_alive: + update_handshake_status(lower) + if "initialization sequence completed" in lower: + ok = True + message = f"OpenVPN connected in {int((time.time() - started) * 1000)} ms." + break + if "auth_failed" in lower or "authentication failed" in lower: + message = "AUTH_FAILED" + break + if "cannot ioctl" in lower or "fatal error" in lower: + message = line[-220:] + break + else: + message = f"OpenVPN timeout after {limit}s." + + if not ok and tail: + message = tail[-1][-220:] + startup_done[0] = True + if not keep_alive or not ok: + stop_process(process) + process = None + return ok, message, process + + +def setup_policy_routing(interface: str = "tun0") -> None: + try: + subprocess.run(["ip", "rule", "del", "table", "100"], capture_output=True, timeout=2) + except Exception: + pass + try: + subprocess.run(["ip", "route", "flush", "table", "100"], capture_output=True, timeout=2) + except Exception: + pass + + success = False + for attempt in range(1, 4): + try: + subprocess.run(["ip", "route", "add", "default", "dev", interface, "table", "100"], check=True, timeout=2) + subprocess.run(["ip", "rule", "add", "oif", interface, "table", "100"], check=True, timeout=2) + print(f"[policy_routing] Enabled policy routing for interface {interface} (attempt {attempt} success)", flush=True) + success = True + break + except Exception as e: + print(f"[policy_routing] Attempt {attempt} failed to enable policy routing: {e}", flush=True) + time.sleep(1) + + if not success: + print("[policy_routing] Failed to enable policy routing after 3 attempts", flush=True) + +def cleanup_policy_routing() -> None: + try: + subprocess.run(["ip", "rule", "del", "table", "100"], capture_output=True, timeout=2) + subprocess.run(["ip", "route", "flush", "table", "100"], capture_output=True, timeout=2) + print("[policy_routing] Cleared policy routing table 100", flush=True) + except Exception: + pass + +def stop_active_openvpn() -> None: + global active_openvpn_process, active_openvpn_node_id + cleanup_policy_routing() + config_to_delete = None + if active_openvpn_node_id: + nodes = read_json(NODES_FILE, []) + node = next((item for item in nodes if item.get("id") == active_openvpn_node_id), None) + if node: + config_to_delete = node.get("config_file") + + stop_process(active_openvpn_process) + active_openvpn_process = None + active_openvpn_node_id = "" + kill_existing_openvpn_processes() + + if config_to_delete: + try: + path = Path(config_to_delete) + if path.exists(): + path.unlink() + except Exception: + pass + +def active_openvpn_running() -> bool: + return active_openvpn_process is not None and active_openvpn_process.poll() is None + +def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: + available_nodes = sorted( + [n for n in nodes if n.get("probe_status") == "available" or n.get("active")], + key=lambda n: (parse_int(n.get("latency_ms")) or 999999, -parse_int(n.get("score"))) + ) + untested_nodes = sorted( + [n for n in nodes if n.get("probe_status") == "not_checked" and not n.get("active")], + key=lambda n: (-parse_int(n.get("score")), parse_int(n.get("ping"))) + ) + unavailable_nodes = sorted( + [n for n in nodes if n.get("probe_status") == "unavailable" and not n.get("active")], + key=lambda n: (-parse_int(n.get("score")), -float(n.get("probed_at", 0))) + ) + return available_nodes + untested_nodes + unavailable_nodes + +active_test_indexes = set() +test_indexes_lock = threading.Lock() + +def get_free_test_index() -> int: + with test_indexes_lock: + for idx in range(2, 100): + if idx not in active_test_indexes: + active_test_indexes.add(idx) + return idx + return 99 + +def release_test_index(idx: int) -> None: + with test_indexes_lock: + active_test_indexes.discard(idx) + +def test_node_by_id(node_id: str) -> dict[str, Any]: + with lock: + nodes = read_json(NODES_FILE, []) + node = next((item for item in nodes if item.get("id") == node_id), None) + if not node: + raise ValueError(f"Node not found: {node_id}") + config_file = str(node["config_file"]) + config_text = node.get("config_text") or "" + h = str(node.get("remote_host") or node.get("ip")) + p = parse_int(node.get("remote_port")) + fallback_ping = parse_int(node.get("ping")) + + temp_path = Path(config_file) + try: + CONFIG_DIR.mkdir(exist_ok=True, parents=True) + temp_path.write_text(config_text, encoding="utf-8") + except Exception as e: + raise RuntimeError(f"Failed to write temp config file: {e}") + + latency = vpn_utils.ping_latency_ms(h, p, fallback_ping) + + idx = get_free_test_index() + try: + ok, message, _ = run_openvpn_until_ready(config_file, keep_alive=False, route_nopull=True, timeout=12, dev=f"tun{idx}") + finally: + release_test_index(idx) + + try: + if temp_path.exists(): + temp_path.unlink() + except Exception: + pass + + temp_node = { + "id": node_id, + "ip": h, + "remote_host": h, + "remote_port": p, + "owner": "", + "asn": "", + "as_name": "", + "location": "", + "ip_type": "", + "quality": "", + } + if ok: + vpn_utils.enrich_ip_info([temp_node]) + + with lock: + nodes = read_json(NODES_FILE, []) + node = next((item for item in nodes if item.get("id") == node_id), None) + if node: + node["latency_ms"] = latency + node["probe_status"] = "available" if ok else "unavailable" + node["probe_message"] = message + node["probed_at"] = time.time() + if ok: + node["owner"] = temp_node["owner"] + node["asn"] = temp_node["asn"] + node["as_name"] = temp_node["as_name"] + node["location"] = temp_node["location"] + node["ip_type"] = temp_node["ip_type"] + node["quality"] = temp_node["quality"] + + sorted_nodes = sort_all_nodes(nodes) + write_json(NODES_FILE, sorted_nodes) + res = next((item for item in sorted_nodes if item.get("id") == node_id), node) + return res + else: + return {} + +def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]: + with lock: + nodes = read_json(NODES_FILE, []) + to_test = [n for n in nodes if n.get("id") in node_ids] + + def test_worker(args: tuple[int, dict[str, Any]]) -> dict[str, Any]: + idx, n_info = args + node_id = n_info["id"] + config_file = n_info["config_file"] + config_text = n_info.get("config_text") or "" + h = str(n_info.get("remote_host") or n_info.get("ip")) + p = parse_int(n_info.get("remote_port")) + fallback_ping = parse_int(n_info.get("ping")) + + temp_path = Path(config_file) + try: + CONFIG_DIR.mkdir(exist_ok=True, parents=True) + temp_path.write_text(config_text, encoding="utf-8") + except Exception: + pass + + latency = vpn_utils.ping_latency_ms(h, p, fallback_ping) + dev_name = f"tun{idx + 1}" + ok, message, _ = run_openvpn_until_ready(config_file, keep_alive=False, route_nopull=True, timeout=12, dev=dev_name) + + try: + if temp_path.exists(): + temp_path.unlink() + except Exception: + pass + + temp_node = { + "id": node_id, + "latency_ms": latency, + "probe_status": "available" if ok else "unavailable", + "probe_message": message, + "probed_at": time.time(), + "owner": "", + "asn": "", + "as_name": "", + "location": "", + "ip_type": "", + "quality": "", + } + if ok: + ip_to_enrich = { + "ip": n_info.get("ip"), + "remote_host": h, + "owner": "", + "asn": "", + "as_name": "", + "location": "", + "ip_type": "", + "quality": "", + } + vpn_utils.enrich_ip_info([ip_to_enrich]) + temp_node.update(ip_to_enrich) + return temp_node + + updated_nodes_map = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(to_test))) as executor: + futures = {executor.submit(test_worker, (idx, n)): n["id"] for idx, n in enumerate(to_test)} + for future in concurrent.futures.as_completed(futures): + nid = futures[future] + try: + res = future.result() + updated_nodes_map[nid] = res + except Exception as e: + updated_nodes_map[nid] = { + "id": nid, + "probe_status": "unavailable", + "probe_message": f"Test exception: {e}", + "latency_ms": 0 + } + + with lock: + current_nodes = read_json(NODES_FILE, []) + for n in current_nodes: + nid = n.get("id") + if nid in updated_nodes_map: + n.update(updated_nodes_map[nid]) + sorted_nodes = sort_all_nodes(current_nodes) + write_json(NODES_FILE, sorted_nodes) + + return list(updated_nodes_map.values()) + +def auto_switch_node() -> None: + # Find the next best available node + with lock: + nodes = read_json(NODES_FILE, []) + candidates = [ + n for n in nodes + if n.get("probe_status") == "available" + and not n.get("active") + ] + candidates.sort(key=lambda n: (parse_int(n.get("latency_ms")) or 999999, -parse_int(n.get("score")))) + + if candidates: + next_node = candidates[0] + msg = f"当前连接已失效或代理连通性检测失败,正在自动切换至最佳备用节点: {next_node['id']}" + print(f"[自动切换] {msg}", flush=True) + log_to_json("INFO", "VPN", msg) + try: + connect_node(next_node["id"]) + except Exception as e: + err_msg = f"切换到备用节点 {next_node['id']} 失败: {e},将尝试下一个..." + print(f"[自动切换] {err_msg}", flush=True) + log_to_json("WARNING", "VPN", err_msg) + auto_switch_node() + else: + msg = "没有可用的备选节点,将自动断开并清理当前连接状态,同时在后台异步获取新节点..." + print(f"[自动切换] {msg}", flush=True) + log_to_json("WARNING", "VPN", msg) + stop_active_openvpn() + with lock: + nodes = read_json(NODES_FILE, []) + for item in nodes: + item["active"] = False + write_json(NODES_FILE, nodes) + set_state(active_openvpn_node_id="", last_check_message="没有可用的备选节点,已断开") + + def bg_fetch_and_switch(): + try: + maintain_valid_nodes(force=False) + auto_switch_node() + except Exception as e: + print(f"[自动切换后台补齐] 获取并测试节点失败: {e}", flush=True) + + threading.Thread(target=bg_fetch_and_switch, daemon=True).start() + +def connect_node(node_id: str) -> str: + global active_openvpn_process, active_openvpn_node_id, is_connecting + with lock: + if is_connecting: + print("[连接] 正在建立其他连接中,跳过此请求", flush=True) + return "Already connecting" + is_connecting = True + set_state(active_openvpn_node_id=node_id, is_connecting=True, active_node_latency="正在连接", last_check_message="正在初始化连接配置...") + + try: + log_to_json("INFO", "VPN", f"开始连接节点: {node_id}") + nodes = read_json(NODES_FILE, []) + node = next((item for item in nodes if item.get("id") == node_id), None) + if not node: + raise ValueError(f"Node not found: {node_id}") + + set_state(active_node_latency="清理连接", last_check_message="正在关闭与清理旧的 VPN 连接及网卡...") + stop_active_openvpn() + + set_state(active_node_latency="写入配置", last_check_message="正在写入 OpenVPN 节点配置文件...") + config_path = Path(node["config_file"]) + try: + CONFIG_DIR.mkdir(exist_ok=True, parents=True) + config_path.write_text(node.get("config_text") or "", encoding="utf-8") + except Exception as e: + raise RuntimeError(f"Failed to write configuration: {e}") + + set_state(active_node_latency="启动核心", last_check_message="正在启动 OpenVPN Core 核心服务并建立连接...") + ok, message, process = run_openvpn_until_ready(str(node["config_file"]), keep_alive=True, route_nopull=True) + if not ok or process is None: + try: + if config_path.exists(): + config_path.unlink() + except Exception: + pass + node["probe_status"] = "unavailable" + node["probe_message"] = message + for item in nodes: + item["active"] = False + write_json(NODES_FILE, nodes) + log_to_json("ERROR", "VPN", f"连接节点 {node_id} 失败: {message}") + set_state(active_openvpn_node_id="", is_connecting=False, active_node_latency="无活动连接", last_check_message=f"连接失败: {message}") + raise RuntimeError(message) + + active_openvpn_process = process + active_openvpn_node_id = node_id + + set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...") + setup_policy_routing("tun0") + + global last_active_ping_time, last_active_latency + last_active_ping_time = time.time() + last_active_latency = 0 + + set_state(active_node_latency="测试延迟", last_check_message="正在直连测试代理出口延迟与可用性...") + try: + ip = node.get("ip") or node.get("remote_host") + port = parse_int(node.get("remote_port")) + fallback = parse_int(node.get("ping")) + latency = vpn_utils.ping_latency_ms(ip, port, fallback) + if latency > 0: + last_active_latency = latency + except Exception: + pass + + for item in nodes: + item["active"] = item.get("id") == node_id + if item["active"]: + item["probe_message"] = f"Active node. HTTP proxy: http://{LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}" + write_json(NODES_FILE, nodes) + latency_str = f"{last_active_latency} ms" if last_active_latency > 0 else "检测超时" + set_state(active_openvpn_node_id=node_id, is_connecting=False, last_check_message=f"Connected {node_id}", active_node_latency=latency_str) + log_to_json("INFO", "VPN", f"节点 {node_id} 连接成功,出口网卡 tun0 已启用") + return f"Connected {node_id}" + finally: + with lock: + is_connecting = False + +def maintain_valid_nodes(force: bool = False) -> str: + global active_openvpn_process, active_openvpn_node_id + ensure_dirs() + if force: + with lock: + stop_active_openvpn() + elif not active_openvpn_running(): + has_active_id = False + with lock: + if active_openvpn_node_id: + has_active_id = True + stop_active_openvpn() + if has_active_id: + print("[维护线程] 检测到当前 OpenVPN 进程已意外退出,准备自动切换节点", flush=True) + auto_switch_node() + + try: + candidates = fetch_candidates() + except Exception as exc: + vpn_utils.check_and_fix_dns() + try: + candidates = fetch_candidates() + except Exception as exc2: + set_state(last_fetch_at=time.time(), last_fetch_status="error", last_fetch_message=str(exc2)) + candidates = [] + + if not candidates: + return "没有拉取到新节点" + + with lock: + active_node = None + if active_openvpn_node_id: + current_nodes = read_json(NODES_FILE, []) + active_node = next((n for n in current_nodes if n.get("id") == active_openvpn_node_id), None) + + merged: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + + if active_node: + merged.append(active_node) + seen_ids.add(active_node["id"]) + + for cand in candidates: + if cand["id"] not in seen_ids: + merged.append(cand) + seen_ids.add(cand["id"]) + + if len(merged) > 1000: + merged = merged[:1000] + + for n in merged: + config_path = Path(n["config_file"]) + if not config_path.exists(): + try: + config_path.write_text(n["config_text"], encoding="utf-8") + except Exception: + pass + + write_json(NODES_FILE, merged) + + # Test the first 10 non-active nodes from the new list + with lock: + current_nodes = read_json(NODES_FILE, []) + to_test = [n for n in current_nodes if not n.get("active")][:10] + to_test_ids = [n["id"] for n in to_test] + + print(f"[维护线程] 正在检测新获取列表的前 10 个节点: {to_test_ids}", flush=True) + test_multiple_nodes(to_test_ids) + + with lock: + merged = read_json(NODES_FILE, []) + if not active_openvpn_running(): + available_candidates = [n for n in merged if n.get("probe_status") == "available"] + if available_candidates: + auto_switch_node() + + valid_nodes_count = len([n for n in merged if n.get("probe_status") == "available"]) + message = f"Fetched {len(candidates)} nodes. Tested first 10 nodes." + set_state( + last_check_at=time.time(), + last_check_message=message, + active_openvpn_node_id=active_openvpn_node_id, + valid_nodes=valid_nodes_count, + ) + return message + + +def collector_loop() -> None: + while True: + try: + maintain_valid_nodes(force=False) + except Exception as exc: + set_state(last_check_at=time.time(), last_check_message=f"check error: {exc}") + time.sleep(CHECK_INTERVAL_SECONDS) + +LOGIN_HTML = r""" + + + + + AimiliVPN - 安全登录 + + + + + + + + + +""" + +INDEX_HTML = r""" + + + + + AimiliVPN 节点池管理系统 + + + +
+
+

+ + AimiliVPN 节点管理系统 +

+
服务加载中...
+
+
+ + + +
+
+
+
+
+
+ 推荐 购买高性价比 VPS 搭建节点或用作客户端 +
+ + +
+
+ + +
+ +
+ +
+
+
+ 0 + 可用节点池 +
+
+ +
+
+
+
+ 3 + 目标储备数 +
+
+ +
+
+
+
+ 0 + 已激活连接 +
+
+ +
+
+
+ +
+
+
+
+ +
+
+

本地代理出口检测 (Port 7928)

+

+ 测试本地 HTTP/SOCKS5 代理是否成功通过当前 VPN 节点出站,并获取实际出口公网 IP 和延迟。 +

+
+
+
+
+
+ 测试状态: 未检测 +
+
+ 出口 IP: - + +
+
+ +
+
+
+ +
+ + + +
+
+
+ + + + + + + + + + + + + + + +
状态延迟IP 地址 : 端口物理位置ASN运营主体 / ISP网络质量IP 类型操作
+
+ + +
+
+ 显示第 0 - 0 条,共 0 条备选节点 +
+
+ + + + 页码 1 / 1 + + + +
+
+
+ + + +
+ +""" + +def check_proxy_health() -> dict[str, Any]: + # 1. 检测代理服务端口是否在监听 + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1.5) + try: + s.connect(("127.0.0.1", LOCAL_PROXY_PORT)) + s.close() + except Exception as e: + return { + "ok": False, + "error": f"代理服务未运行 (端口 {LOCAL_PROXY_PORT} 连接失败,原因: {e})" + } + + # 2. 检测虚拟网卡 tun0 是否存在 (Linux 下) + tun_path = Path("/sys/class/net/tun0") + if sys.platform.startswith("linux") and not tun_path.exists(): + return { + "ok": False, + "error": "VPN 虚拟网卡 (tun0) 未启用,请确保当前已成功连接 VPN 节点" + } + + # 3. 尝试通过代理请求外网接口 + proxy_url = f"http://127.0.0.1:{LOCAL_PROXY_PORT}" + proxy_handler = urllib.request.ProxyHandler({'http': proxy_url, 'https': proxy_url}) + opener = urllib.request.build_opener(proxy_handler) + try: + t0 = time.perf_counter() + req = urllib.request.Request("https://api.ipify.org?format=json", headers={"User-Agent": "curl/7.68.0"}) + with opener.open(req, timeout=5) as response: + res_data = response.read().decode('utf-8') + latency = int((time.perf_counter() - t0) * 1000) + try: + ip_obj = json.loads(res_data) + ip = ip_obj.get("ip") or res_data.strip() + except Exception: + ip = res_data.strip() + return {"ok": True, "ip": ip, "latency_ms": latency} + except Exception as e: + try: + t0 = time.perf_counter() + req = urllib.request.Request("https://ifconfig.me/ip", headers={"User-Agent": "curl/7.68.0"}) + with opener.open(req, timeout=5) as response: + ip = response.read().decode('utf-8').strip() + latency = int((time.perf_counter() - t0) * 1000) + return {"ok": True, "ip": ip, "latency_ms": latency} + except Exception as e2: + err_msg = str(e) + if "Connection refused" in err_msg or "Failed to receive SOCKS5" in err_msg: + return { + "ok": False, + "error": "代理中转握手失败 (OpenVPN 已连接,但无法建立外部 TCP 握手)" + } + return { + "ok": False, + "error": f"VPN 节点无外网访问权限 (请求超时或线路被拦截,报错: {e})" + } + +def background_proxy_checker() -> None: + time.sleep(2) + while True: + try: + if is_connecting: + time.sleep(5) + continue + + res = check_proxy_health() + if res["ok"]: + set_state( + proxy_ok=True, + proxy_ip=res["ip"], + proxy_latency_ms=res["latency_ms"], + proxy_error="" + ) + log_to_json("INFO", "Proxy", f"代理可用,IP: {res['ip']}, 延迟: {res['latency_ms']} ms") + else: + error_msg = res.get("error", "未知错误") + if active_openvpn_node_id: + print(f"[警告] 7928 端口本地代理当前不可用!原因: {error_msg}", flush=True) + log_to_json("WARNING", "Proxy", f"代理不可用: {error_msg}") + set_state( + proxy_ok=False, + proxy_ip="-", + proxy_latency_ms=0, + proxy_error=error_msg + ) + + # If we intended to have an active VPN node but proxy failed, trigger auto-switch + if active_openvpn_node_id: + with lock: + nodes = read_json(NODES_FILE, []) + active_node = next((n for n in nodes if n.get("id") == active_openvpn_node_id), None) + if active_node: + mark_blacklisted(active_node, f"代理连通性检测失败: {error_msg}") + active_node["probe_status"] = "unavailable" + write_json(NODES_FILE, nodes) + + auto_switch_node() + except Exception as e: + print(f"[错误] 代理后台检测发生异常: {e}", flush=True) + log_to_json("ERROR", "Proxy", f"检测守护线程发生异常: {e}") + time.sleep(30) + +def active_node_pinger() -> None: + global active_openvpn_node_id, is_connecting + while True: + try: + if active_openvpn_running() and active_openvpn_node_id: + nodes = read_json(NODES_FILE, []) + node = next((n for n in nodes if n.get("id") == active_openvpn_node_id), None) + if node: + ip = node.get("ip") or node.get("remote_host") + port = parse_int(node.get("remote_port")) + fallback = parse_int(node.get("ping")) + if ip: + latency = vpn_utils.ping_latency_ms(ip, port, fallback) + if latency > 0: + set_state(active_node_latency=f"{latency} ms") + else: + set_state(active_node_latency="检测超时") + else: + set_state(active_node_latency="检测超时") + else: + set_state(active_node_latency="检测超时") + elif is_connecting: + set_state(active_node_latency="测试中...") + else: + set_state(active_node_latency="无活动连接") + except Exception as e: + print(f"[ERROR] active_node_pinger error: {e}", flush=True) + time.sleep(10) + + +class Handler(BaseHTTPRequestHandler): + def get_secret_path(self) -> str: + auth_file = DATA_DIR / "ui_auth.json" + if not auth_file.exists(): + try: + DATA_DIR.mkdir(exist_ok=True) + auth_file.write_text(json.dumps({"secret_path": "EJsW2EeBo9lY"}), encoding="utf-8") + except Exception: + pass + return "EJsW2EeBo9lY" + try: + creds = json.loads(auth_file.read_text(encoding="utf-8")) + if "secret_path" in creds: + return creds["secret_path"] + elif "password" in creds: + secret_path = creds["password"] + try: + auth_file.write_text(json.dumps({"secret_path": secret_path}), encoding="utf-8") + except Exception: + pass + return secret_path + return "EJsW2EeBo9lY" + except Exception: + return "EJsW2EeBo9lY" + + def is_authorized(self) -> bool: + ui_cfg = load_ui_config() + pwd = ui_cfg.get("password") + uname = ui_cfg.get("username", "admin") + if not pwd: + return True + + cookie_header = self.headers.get("Cookie", "") + cookies = {} + if cookie_header: + for item in cookie_header.split(";"): + item = item.strip() + if "=" in item: + k, v = item.split("=", 1) + cookies[k.strip()] = v.strip() + + expected_token = get_session_token(pwd, uname) + return cookies.get("session") == expected_token + + def validate_path(self) -> str: + secret_path = self.get_secret_path() + if not secret_path: + return self.path + if self.path == f"/{secret_path}": + self.send_response(HTTPStatus.FOUND) + self.send_header("Location", f"/{secret_path}/") + self.end_headers() + return "" + prefix = f"/{secret_path}/" + if self.path.startswith(prefix): + return "/" + self.path[len(prefix):] + self.send_response(HTTPStatus.NOT_FOUND) + self.end_headers() + return "" + + def log_message(self, format: str, *args: Any) -> None: + print(f"[{self.log_date_time_string()}] {format % args}", flush=True) + + def send_bytes(self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None: + self.send_bytes(json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8", status) + + def do_GET(self) -> None: + effective_path = self.validate_path() + if effective_path == "": return + + if not self.is_authorized(): + if effective_path in ("/", "/index.html"): + self.send_bytes(LOGIN_HTML.encode("utf-8"), "text/html; charset=utf-8") + return + else: + self.send_json({"error": "Unauthorized"}, HTTPStatus.UNAUTHORIZED) + return + + if effective_path in ("/", "/index.html"): + self.send_bytes(INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8") + elif effective_path == "/api/nodes": + global last_active_ping_time, last_active_latency, active_openvpn_node_id + nodes = read_json(NODES_FILE, []) + active_node = next((n for n in nodes if active_openvpn_node_id and n.get("id") == active_openvpn_node_id), None) + for n in nodes: + n["active"] = (active_openvpn_node_id and n.get("id") == active_openvpn_node_id) + if active_node: + ip = active_node.get("ip") or active_node.get("remote_host") + if ip: + now = time.time() + if now - last_active_ping_time > 15.0: + last_active_ping_time = now + def bg_ping(ip_addr: str, port: int, fallback: int) -> None: + global last_active_latency + try: + latency = vpn_utils.ping_latency_ms(ip_addr, port, fallback) + if latency > 0: + last_active_latency = latency + except Exception: + pass + threading.Thread( + target=bg_ping, + args=(ip, parse_int(active_node.get("remote_port")), parse_int(active_node.get("ping"))), + daemon=True + ).start() + if last_active_latency > 0: + active_node["latency_ms"] = last_active_latency + stripped_nodes = [] + for n in nodes: + stripped = n.copy() + if "config_text" in stripped: + del stripped["config_text"] + stripped_nodes.append(stripped) + self.send_json({"nodes": stripped_nodes, "state": get_state()}) + elif effective_path.startswith("/configs/"): + filename = urllib.parse.unquote(effective_path.removeprefix("/configs/")) + with lock: + nodes = read_json(NODES_FILE, []) + node = next((n for n in nodes if Path(n.get("config_file", "")).name == filename), None) + if node and node.get("config_text"): + self.send_bytes(node["config_text"].encode("utf-8"), "application/x-openvpn-profile") + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: + effective_path = self.validate_path() + if effective_path == "": return + + if effective_path == "/api/login": + try: + length = parse_int(self.headers.get("Content-Length")) + payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + input_pwd = str(payload.get("password") or "") + input_uname = str(payload.get("username") or "") + + ui_cfg = load_ui_config() + expected_pwd = ui_cfg.get("password", "") + expected_uname = ui_cfg.get("username", "admin") + + if expected_pwd and input_pwd == expected_pwd and input_uname == expected_uname: + token = get_session_token(expected_pwd, expected_uname) + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json; charset=utf-8") + secret_path = self.get_secret_path() + cookie_path = f"/{secret_path}/" if secret_path else "/" + self.send_header("Set-Cookie", f"session={token}; Path={cookie_path}; HttpOnly; SameSite=Lax; Max-Age=2592000") + self.end_headers() + self.wfile.write(json.dumps({"ok": True}).encode("utf-8")) + else: + self.send_json({"ok": False, "error": "用户名或密码不正确,请重新输入"}, HTTPStatus.FORBIDDEN) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + return + + if effective_path == "/api/logout": + try: + secret_path = self.get_secret_path() + cookie_path = f"/{secret_path}/" if secret_path else "/" + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Set-Cookie", f"session=; Path={cookie_path}; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT") + self.end_headers() + self.wfile.write(json.dumps({"ok": True}).encode("utf-8")) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + return + + if not self.is_authorized(): + self.send_json({"error": "Unauthorized"}, HTTPStatus.UNAUTHORIZED) + return + + if effective_path == "/api/update_settings": + try: + length = parse_int(self.headers.get("Content-Length")) + payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + + curr_username = str(payload.get("curr_username") or "") + curr_password = str(payload.get("curr_password") or "") + + new_port = payload.get("port") + new_suffix = str(payload.get("secret_path") or "").strip() + new_username = str(payload.get("new_username") or "").strip() + new_password = str(payload.get("new_password") or "").strip() + + if not curr_username or not curr_password: + self.send_json({"ok": False, "error": "请输入当前账号和密码进行安全验证"}, HTTPStatus.FORBIDDEN) + return + + ui_cfg = load_ui_config() + expected_uname = ui_cfg.get("username", "admin") + expected_pwd = ui_cfg.get("password", "") + + if curr_username != expected_uname or curr_password != expected_pwd: + self.send_json({"ok": False, "error": "当前账号或密码不正确"}, HTTPStatus.FORBIDDEN) + return + + try: + new_port_int = int(new_port) + if not (1 <= new_port_int <= 65535): + raise ValueError() + except (TypeError, ValueError): + self.send_json({"ok": False, "error": "端口范围必须是 1 至 65535"}, HTTPStatus.BAD_REQUEST) + return + + if not new_suffix or not re.match(r"^[A-Za-z0-9]+$", new_suffix): + self.send_json({"ok": False, "error": "安全后缀仅能由英文字母和数字组成"}, HTTPStatus.BAD_REQUEST) + return + + ui_cfg["port"] = new_port_int + ui_cfg["secret_path"] = new_suffix + if new_username: + ui_cfg["username"] = new_username + if new_password: + ui_cfg["password"] = new_password + + auth_file = DATA_DIR / "ui_auth.json" + with lock: + DATA_DIR.mkdir(exist_ok=True, parents=True) + auth_file.write_text(json.dumps(ui_cfg, ensure_ascii=False, indent=2), encoding="utf-8") + + self.send_json({"ok": True, "message": "配置更新成功,系统将在 2 秒内重启..."}) + + def restart_server(): + time.sleep(2) + print("[系统] 管理后台配置更新,进程即将退出以触发自动重启...", flush=True) + os._exit(0) + + threading.Thread(target=restart_server, daemon=True).start() + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + return + + if effective_path == "/api/check": + try: + self.send_json({"ok": True, "message": maintain_valid_nodes(force=True)}) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + elif effective_path == "/api/refresh_nodes": + try: + threading.Thread(target=maintain_valid_nodes, args=(False,), daemon=True).start() + self.send_json({"ok": True, "message": "已在后台启动节点更新流程"}) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + elif effective_path == "/api/test_nodes": + try: + length = parse_int(self.headers.get("Content-Length")) + payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + node_ids = payload.get("ids", []) + tested_nodes = test_multiple_nodes(node_ids) + self.send_json({"ok": True, "nodes": tested_nodes}) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + elif effective_path == "/api/disconnect": + try: + stop_active_openvpn() + with lock: + nodes = read_json(NODES_FILE, []) + for item in nodes: + item["active"] = False + write_json(NODES_FILE, nodes) + global last_active_ping_time, last_active_latency + last_active_ping_time = 0.0 + last_active_latency = 0 + set_state(active_openvpn_node_id="", last_check_message="手动断开连接", active_node_latency="无活动连接") + self.send_json({"ok": True}) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + elif effective_path == "/api/connect": + try: + length = parse_int(self.headers.get("Content-Length")) + payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + self.send_json({"ok": True, "message": connect_node(str(payload.get("id") or ""))}) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + elif effective_path == "/api/test_node": + try: + length = parse_int(self.headers.get("Content-Length")) + payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + node_id = str(payload.get("id") or "") + updated_node = test_node_by_id(node_id) + self.send_json({"ok": True, "node": updated_node}) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + elif effective_path == "/api/test_proxy": + try: + length = parse_int(self.headers.get("Content-Length")) + if length > 0: + self.rfile.read(length) + result = check_proxy_health() + if result["ok"]: + set_state( + proxy_ok=True, + proxy_ip=result["ip"], + proxy_latency_ms=result["latency_ms"], + proxy_error="" + ) + else: + set_state( + proxy_ok=False, + proxy_ip="-", + proxy_latency_ms=0, + proxy_error=result.get("reason", "未知错误") + ) + self.send_json(result) + except Exception as exc: + self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + +class Tee: + def __init__(self, file_path: str): + Path(file_path).parent.mkdir(exist_ok=True, parents=True) + self.file = open(file_path, "a", encoding="utf-8") + self.stdout = sys.stdout + + def write(self, data: str) -> None: + self.stdout.write(data) + self.file.write(data) + self.file.flush() + + def flush(self) -> None: + self.stdout.flush() + self.file.flush() + +def main() -> None: + ensure_dirs() + kill_existing_openvpn_processes() + + log_file = DATA_DIR / "vpngate.log" + tee = Tee(str(log_file)) + sys.stdout = tee + sys.stderr = tee + + write_json( + STATE_FILE, + { + "api_url": API_URL, + "target_valid_nodes": TARGET_VALID_NODES, + "fetch_interval_seconds": FETCH_INTERVAL_SECONDS, + "check_interval_seconds": CHECK_INTERVAL_SECONDS, + "local_proxy": f"http://{LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}", + "active_openvpn_node_id": "", + "last_fetch_status": "starting", + "last_check_message": "service starting", + "blacklisted_nodes": 0, + }, + ) + threading.Thread(target=proxy_server.start_proxy_server, args=(LOCAL_PROXY_HOST, LOCAL_PROXY_PORT), daemon=True).start() + + # Wait for the gateway to officially start + print("[网关] 正在启动代理网关...", flush=True) + gateway_ready = False + for _ in range(30): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.settimeout(0.5) + s.connect((LOCAL_PROXY_HOST, LOCAL_PROXY_PORT)) + gateway_ready = True + break + except Exception: + time.sleep(0.5) + finally: + try: + s.close() + except Exception: + pass + + if gateway_ready: + print("[网关] 代理网关已成功启动监听,启动同步与检测脚本...", flush=True) + else: + print("[警告] 代理网关启动超时,继续执行脚本...", flush=True) + + threading.Thread(target=collector_loop, daemon=True).start() + threading.Thread(target=background_proxy_checker, daemon=True).start() + threading.Thread(target=active_node_pinger, daemon=True).start() + + ui_cfg = load_ui_config() + ui_host = ui_cfg.get("host", UI_HOST) + ui_port = int(ui_cfg.get("port", UI_PORT)) + + print(f"UI: http://{ui_host}:{ui_port}/", flush=True) + print(f"Proxy: http://{LOCAL_PROXY_HOST}:{LOCAL_PROXY_PORT}", flush=True) + ThreadingHTTPServer((ui_host, ui_port), Handler).serve_forever() + +if __name__ == "__main__": + main() \ No newline at end of file