mirror of
https://github.com/baoweise-bot/aimili-vpngate.git
synced 2026-09-05 23:56:55 +08:00
feat: initial open-source release with cleaned codebase
This commit is contained in:
+37
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# AimiliVPN 🌐
|
||||||
|
|
||||||
|
Bilingual: [中文](#中文) | [English](#english)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 中文
|
||||||
|
|
||||||
|
[](https://t.me/arestemple)
|
||||||
|
[](https://339936.xyz)
|
||||||
|
[](https://www.youtube.com/watch?v=s-ATfXR8BpI)
|
||||||
|
[](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
|
||||||
|
|
||||||
|
[](https://t.me/arestemple)
|
||||||
|
[](https://339936.xyz)
|
||||||
|
[](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.
|
||||||
+926
@@ -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 <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=AimiliVPN OpenVPN Manager with HTTP/SOCKS5 Proxy
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
ExecStart=/usr/bin/python3 vpngate_manager.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
EnvironmentFile=-/etc/default/aimilivpn
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e " -> 正在重新加载 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
|
||||||
+165
@@ -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)
|
||||||
+422
@@ -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", "")
|
||||||
+3519
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user