21 Commits
Author SHA1 Message Date
baoweise-bot 23909c5337 Update OpenMili description in README.md 2026-09-04 01:04:40 +08:00
baoweise-bot 839d528250 Fix formatting in OpenMili entry of README
Corrected formatting in the OpenMili description for clarity.
2026-09-04 01:03:13 +08:00
baoweise-bot 731965696a Update README.md
docs: simplify provider recommendations
2026-09-04 01:02:34 +08:00
Aimili 64bc4c0037 docs: simplify provider recommendations 2026-08-28 14:13:34 +08:00
Aimili d875529083 docs: add V2.1.5 acceptance report 2026-08-28 08:58:29 +08:00
Aimili 97fc3374f1 fix: refill nodes after exhausted failover 2026-08-28 06:43:02 +08:00
Aimili 6249c92528 release: prepare v2.1.4 2026-08-27 20:44:58 +08:00
Aimili f2f8afda76 fix: clear readiness state on manual disconnect 2026-08-27 20:43:39 +08:00
Aimili a6f106c495 release: prepare v2.1.3 2026-08-27 20:16:38 +08:00
Aimili 21ce55044d fix: close VPS routing and installer audit issues 2026-08-27 20:08:49 +08:00
Aimili f190d746ef fix: improve IP classification and source fallback 2026-08-27 03:16:44 +08:00
Aimili 635ebd4db0 Reorganize README installation and VPS sections 2026-08-27 02:51:43 +08:00
Aimili 032f0e4c66 fix: wait for rebuilt tunnel during updates 2026-08-27 02:44:19 +08:00
Aimili 19c6844d09 release: prepare v2.1.1 2026-08-27 02:29:34 +08:00
Aimili d937826f6d fix: reduce dashboard polling jank 2026-08-27 02:21:59 +08:00
Aimili f9361d0a7d fix: harden cross-browser dashboard interactions 2026-08-27 01:44:09 +08:00
Aimili bbc2457498 fix: prevent web dashboard browser freezes 2026-08-27 01:21:32 +08:00
Aimili cbca9a57af docs: refine VPS promotions and regional notice 2026-08-27 01:04:15 +08:00
Aimili 72ac5a0986 docs: redesign readme and add translations 2026-08-27 00:53:51 +08:00
Aimili e414236900 docs: simplify installation and connection guide 2026-08-27 00:22:21 +08:00
Aimili 0069bb089b Fix formal release shell block 2026-08-26 23:58:58 +08:00
15 changed files with 3021 additions and 607 deletions
+4 -3
View File
@@ -7,9 +7,9 @@ on:
workflow_dispatch:
inputs:
release_tag:
description: Existing formal tag to publish, for example v2.1.0
description: Existing formal tag to publish, for example v2.1.5
required: true
default: v2.1.0
default: v2.1.5
type: string
concurrency:
@@ -146,9 +146,10 @@ jobs:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
title="AimiliVPN V$(cut -d. -f1,2 VERSION) 正式版"
title="AimiliVPN V$(tr -d '\r\n' < VERSION) 正式版"
if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
gh release upload "${RELEASE_TAG}" dist/* --clobber --repo "${GITHUB_REPOSITORY}"
gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --title "${title}" --notes-file RELEASE_NOTES.md
else
gh release create "${RELEASE_TAG}" dist/* --repo "${GITHUB_REPOSITORY}" --title "${title}" --notes-file RELEASE_NOTES.md --verify-tag
fi
+2 -1
View File
@@ -2,7 +2,8 @@ name: Update VPNGate mirror
on:
schedule:
- cron: "*/15 * * * *"
# Offset from quarter-hour peaks because GitHub may delay busy schedules.
- cron: "7,22,37,52 * * * *"
workflow_dispatch:
push:
branches: [main]
+195 -268
View File
@@ -1,108 +1,127 @@
# AimiliVPN 🌐
<div align="center">
[![正式版](https://img.shields.io/badge/正式版-V2.1-16a34a?style=flat-square)](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)
[![主分支](https://img.shields.io/badge/更新通道-main-2563eb?style=flat-square)](https://github.com/baoweise-bot/aimili-vpngate/tree/main)
[![Docker](https://img.shields.io/badge/GHCR-amd64%20%7C%20386%20%7C%20arm64%20%7C%20armv7-0ea5e9?style=flat-square)](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate)
# AimiliVPN
Bilingual: [中文](#中文) | [English](#english)
**面向 Linux VPS 的 VPNGate 节点管理与 HTTP / HTTPS / SOCKS5 代理网关**
---
[![正式版本](https://img.shields.io/github/v/release/baoweise-bot/aimili-vpngate?style=flat-square&label=正式版&color=16a34a)](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)
[![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20386%20%7C%20arm64%20%7C%20armv7-0ea5e9?style=flat-square&logo=docker&logoColor=white)](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate)
[![License](https://img.shields.io/badge/License-GPL--3.0-334155?style=flat-square)](LICENSE)
<a name="中文"></a>
## 中文 (Chinese)
**简体中文** · [English](docs/README.en.md) · [日本語](docs/README.ja.md) · [한국어](docs/README.ko.md)
AimiliVPN 是一款基于官方 VPNGate 开放协议的高性能、零依赖 VPN 代理网关。它以纯 Python 标准库编写,内置美观响应式的管理网页,提供智能并发测速、多路由模式、出站代理网关、实时日志等强大功能。
[快速安装](#quick-install) · [完整安装](#installation) · [连接使用](#connection) · [服务商推荐](#vps) · [社区入口](#community) · [法律声明](#legal)
---
[![项目网站](https://img.shields.io/badge/项目网站-339936.xyz-f97316?style=for-the-badge)](https://339936.xyz)
[![Telegram](https://img.shields.io/badge/Telegram-交流群-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
[![YouTube](https://img.shields.io/badge/YouTube-视频教程-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=s-ATfXR8BpI)
### 📌 当前正式版本:V2.1
</div>
V2.1 是项目启用正式版本标志后的首个稳定版本。仓库、安装器、命令行更新和 Web 更新检测现在全部统一使用 **`main` 主分支正式通道**。
<a id="vps"></a>
## 服务商推荐
#### V2.1 更新进展
- **节点来源容灾**:依次尝试 VPNGate 官方 HTTPS、官方 HTTP、GitHub Pages HTTPS、GitHub Pages HTTP、VPS 本地最近有效快照和仓库内置初始快照。
- **获取与切换修复**:缩短被 VPNGate 域名封锁的 VPS 等待时间;切换新节点前先完成预检,目标失败时保留当前可用连接。
- **节点可视化**:恢复延迟列,优先显示本机实测延迟;没有实测值时显示 VPNGate 官方预估值并明确标注“仅供参考”。
- **国家筛选**:支持带国旗和节点数量的实时多选筛选,选择范围保存到本机,并作用于手动更新和后台周期同步。
- **节点操作**:恢复单节点“检测”按钮,补齐收藏、检测、连接和断开状态逻辑。
- **镜像同步**GitHub Pages 每 15 分钟同步并校验官方节点快照,官方 API 被屏蔽时自动回退。
- **Web 更新检测**:页面顶部显示 `V2.1 正式版`,只检查 GitHub 最新稳定 Release,并根据 Python 源码或 Docker 部署方式显示正确更新命令。
- **正式发布链路**:GitHub 标签或手动重跑会依次执行 Python 兼容测试、四架构 Docker 冒烟测试、GHCR 镜像发布,全部成功后才发布通用源码包与 SHA-256 校验文件。
#### 系统与架构兼容性
| 类型 | 正式支持范围 | 安装或镜像标识 |
| 商家 | 推荐理由 | 入口 |
| --- | --- | --- |
| Linux x64 | Intel/AMD 64 位 VPS | 通用 Python 源码 / Docker `linux/amd64` |
| Linux x86 | Intel/AMD 32 位系统 | 通用 Python 源码 / Docker `linux/386` |
| Linux ARM64 | AArch64、ARMv8 VPS/开发板 | 通用 Python 源码 / Docker `linux/arm64` |
| Linux ARM32 | ARMv7 设备 | 通用 Python 源码 / Docker `linux/arm/v7` |
| Linux 发行版 | Debian、Ubuntu、CentOS、RHEL、Rocky、AlmaLinux、Fedora、Oracle Linux、Amazon Linux、Alpine | 使用同一正式核心 |
| Docker | Linux 主机上的 amd64、386、arm64、arm/v7 | GHCR 多架构镜像 |
| **BandwagonHost** | 电信 CN2 GIA、联通 9929、移动 CMIN2 三网优化线路;低延迟、高稳定性,适合 TikTok 直播、海外带货和长期出海业务。 | [立即查看](https://bandwagonhost.com/aff.php?aff=81790) |
| **RackNerd** | 每月 4000GB 大流量,价格与配置性价比突出;部署成本低,适合需要长期稳定运行的服务。 | [立即查看](https://my.racknerd.com/aff.php?aff=18708) |
| **OpenMili** | OpenMili Ai 中转站 5.6 Sol & Images 2.0 Pro美区原价 0.12倍率 不掺假、不降智,接受压力测试!| [立即查看](https://openmili.com/) |
> AimiliVPN 依赖 Linux 的 TUN、OpenVPN、iptables 和策略路由,因此不发布虚假的 Windows/macOS 原生兼容包。Windows 或 macOS 只能作为代理客户端使用,不能直接运行完整网关;Docker Desktop 同样不等同于具备宿主机 TUN 能力的 Linux 服务器
部分链接为推广链接,通过这些链接购买不会增加你的费用
项目由纯 Python 标准库组成,不需要为 CPU 编译不同的 Python 二进制。GitHub Release 只提供一个通用 Linux 源码包;GHCR 才会实际构建并发布四种 CPU 架构的 Docker 镜像
AimiliVPN 使用 Python 标准库管理 VPNGate 节点,提供节点获取与检测、连接切换、Web 管理后台,以及共用一个端口的 HTTP、HTTPS 网站代理和 SOCKS5 代理服务
---
| 项目 | 默认值或支持范围 |
| --- | --- |
| Web 管理后台 | TCP `8787` + 独立安全路径 + 账号密码 |
| 本机代理 | `127.0.0.1:7928`,支持 HTTP、HTTPS `CONNECT` 和 SOCKS5 |
| 源码部署 | x64、x86、ARM64、ARM32 Linux |
| Docker 镜像 | `linux/amd64``linux/386``linux/arm64``linux/arm/v7` |
| 更新通道 | GitHub `main` 正式分支 / 最新正式 Release |
### 🌟 VPS 优选推荐:跑 AimiliVPN 更稳更省心
[![BandwagonHost 顶级三网优化](https://img.shields.io/badge/BandwagonHost-%E9%A1%B6%E7%BA%A7%E4%B8%89%E7%BD%91%E4%BC%98%E5%8C%96-red?style=for-the-badge)](https://bandwagonhost.com/aff.php?aff=81790)
[![RackNerd 6000GB 流量](https://img.shields.io/badge/RackNerd-6000GB%2F%E6%9C%88%20%E5%A4%A7%E6%B5%81%E9%87%8F-blue?style=for-the-badge)](https://my.racknerd.com/aff.php?aff=18708)
> [!IMPORTANT]
> **网络可用性提示:** 不同地区、数据中心和网络服务商可能限制 DNS、VPNGate API、GitHub 镜像或 VPN 协议。镜像与本地缓存只能提高节点列表的可用性,不能保证所有机型都能建立连接。部署前请确认所在地法律和 VPS 服务商条款允许使用 VPN/TUN。
| 推荐 | 适合谁 | 亮点 | 入口 |
| --- | --- | --- | --- |
| **BandwagonHost 搬瓦工** | 更看重国内访问质量、延迟和线路上限的用户 | **顶级三网优化线路**,适合对网络体验、跨境访问质量和长期稳定性要求更高的场景 | [立即查看](https://bandwagonhost.com/aff.php?aff=81790) |
| **RackNerd** | 想低成本部署、测试、长期挂机的用户 | **每月 6000GB 流量**,价格实惠、配置给得足,适合入门部署和性价比优先的 VPS 需求 | [立即查看](https://my.racknerd.com/aff.php?aff=18708) |
<a id="quick-install"></a>
## 快速安装
---
### 📢 官方交流与反馈
[![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)
---
### 🚀 安装与正式版更新
#### 方法一:从 main 主分支一键安装(推荐)
在 Linux VPS 上以 root 用户执行:
使用 `root` 用户在受支持的 Linux VPS 上执行:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
部署完成后,终端会输出管理网页专属链接。输入 `ml update` 时只会获取并切换到 `origin/main`,不会检测或切换任何测试分支
安装完成后,终端会显示 Web 后台完整地址、随机安全路径、登录账号和密码。输入 `ml` 可打开管理菜单
#### 方法二:GitHub 正式发行包
[Releases 页面](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)提供以下文件:
- `aimilivpn-v2.1.0-linux-source.tar.gz`:适用于支持 Python 3 和项目系统依赖的 Linux x64、x86、ARM64、ARMv7 主机。
- `sha256sums.txt`:源码包的 SHA-256 校验值。
#### 方法三:Docker / Docker Compose
Docker 镜像地址:`ghcr.io/baoweise-bot/aimili-vpngate:2.1`。仓库中的 [`compose.yaml`](./compose.yaml) 已配置主机网络、`NET_ADMIN` 和 TUN 设备:
无人值守安装可显式跳过首次参数询问,并自动生成安全路径和登录凭据:
```bash
AIMILIVPN_NONINTERACTIVE=1 bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
> [!TIP]
> 安装前请在 VPS 控制面板启用 TUN/TAP,并确认 `/dev/net/tun` 存在。Web 默认使用 TCP `8787`,安全组建议只允许自己的 IP 访问。
<a id="installation"></a>
## 完整安装
### 运行条件
- 操作系统:Ubuntu、Debian、Alpine、CentOS、RHEL、Rocky Linux、AlmaLinux、Fedora、Oracle Linux 或 Amazon Linux。
- 权限与组件:`root`、OpenVPN、iptables、策略路由和 TUN/TAP。
- Windows 与 macOS 可作为代理客户端,但不能直接运行完整网关;Docker Desktop 也不等同于具备宿主机 TUN 能力的 Linux VPS。
### 方式一:一键源码安装
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
安装器会部署到 `/opt/aimilivpn` 并注册系统服务。常用命令:
```bash
ml # 打开管理菜单
ml status # 查看状态、Web 地址和账号
ml logs # 查看实时日志
ml restart # 重启服务
ml password # 重设 Web 账号密码
ml update # 从 main 正式分支更新
ml uninstall # 卸载
```
需要先审查脚本时:
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
sudo bash install.sh
```
通用 Linux 源码包与 SHA-256 校验文件可在 [GitHub Releases](https://github.com/baoweise-bot/aimili-vpngate/releases/latest) 下载,版本变更记录也统一放在 Release Notes 中。
### 方式二:Docker Compose
Docker 主机需要 `/dev/net/tun`、host 网络以及 `NET_ADMIN``NET_RAW` 权限。
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose pull
docker compose up -d
docker logs -f aimilivpn
```
无法访问 GHCR 或需要自行审查构建过程时,也可以在 VPS 的仓库目录本地构建:
正式镜像:`ghcr.io/baoweise-bot/aimili-vpngate:2.1`
更新容器:
```bash
docker compose build
docker compose pull
docker compose up -d
```
也可以直接运行:
<details>
<summary><strong>查看 docker run 命令</strong></summary>
```bash
docker run -d \
@@ -112,229 +131,137 @@ docker run -d \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--device /dev/net/tun:/dev/net/tun \
-e UI_HOST=0.0.0.0 \
-e UI_PORT=8787 \
-e LOCAL_PROXY_HOST=127.0.0.1 \
-e LOCAL_PROXY_PORT=7928 \
-v aimilivpn-data:/data \
ghcr.io/baoweise-bot/aimili-vpngate:2.1
```
> Docker 方式只支持具备 `/dev/net/tun` 的 Linux 主机,并需要 `NET_ADMIN`、`NET_RAW` 能力。管理页面默认端口为 `8787`,本机 HTTP/SOCKS5 代理默认端口为 `7928`。容器检测到新版本后会提示重新拉取镜像,不会在容器内执行 `git pull`。
</details>
---
### 💡 快速使用指南 (小白必看)
部署成功后,如何使用它进行科学上网?
#### 第一步:登录 Web 管理后台
打开浏览器,访问部署完成时提示的专属后台地址(含安全后缀),即可进入精美的暗黑玻璃拟物风管理界面。
#### 第二步:获取并连接节点
1. 首次进入后台,节点列表可能正在进行首次自动测速与拉取。
2. 点击 **“更新节点”** 按钮(或通过网页下方的网关/日志进行状态检查),程序会在后台通过多线程并发测速,自动筛选出延迟最低、可连接的 VPNGate 节点。
3. 选择您喜欢的出站路由模式:
- **智能自动配置**(推荐):如果当前连接的节点失效,系统会在数秒内自动漂移连接至其他备用健康节点,无需手动干预。
- **固定国家地区**:只选择指定国家(如日本 JP、韩国 KR、美国 US)的最佳节点。
- **固定 IP 节点**:始终锁定连接到这一个特定节点。
#### 第三步:使用本机代理 (核心步骤)
为了防止代理端口暴露至公网被恶意扫描和滥用,AimiliVPN 的双效代理服务(默认端口 **`7928`**,自适应支持 SOCKS5 和 HTTP 协议)**默认仅绑定在本地回环地址(`127.0.0.1`)**,只接收 VPS 本机上的流量,不对外机提供代理。
* **🐍 Python 脚本中使用代理**:
```python
import requests
proxies = {
"http": "http://127.0.0.1:7928",
"https": "http://127.0.0.1:7928",
}
response = requests.get("https://www.google.com", proxies=proxies)
```
* **🐚 Shell 终端环境中使用代理**:
在命令行执行以下命令,可以让当前终端的后续命令(如 `curl`、`wget` 等)走代理出口:
```bash
export http_proxy="http://127.0.0.1:7928"
export https_proxy="http://127.0.0.1:7928"
```
* **⚙️ 本地其他服务配置**:
将本机的其他代理工具、爬虫框架或服务的出战代理设置为 `127.0.0.1:7928`。
> 💡 **小贴士**:如果您确实需要对公网其他设备开放此代理端口,可以通过设置环境变量 `export LOCAL_PROXY_HOST="::"` 重新启动服务以允许公网接入。
---
### 🛠️ 核心功能与操作说明
* **合并操作面板**:将“更新节点”与“立即检测补齐”合并,一键触发多线程拉取与测速。
* **正式版更新检测**:Web 顶部版本菜单可以检查 GitHub 最新稳定 Release;源码部署提示 `ml update`,Docker 部署提示重新拉取并启动镜像。
* **多国家发现范围**:节点表可实时勾选多个国家;点击“更新节点”后保存范围并影响后台周期拉取。
* **延迟来源区分**:实测延迟正常显示,官方 Ping 回退值使用弱化样式并标注为预估。
* **网关状态面板**
- **系统诊断**:检测网关心跳及后台各个子守护线程(网页服务、VPN连接管理、出站网关服务)是否正常运行。若有脚本未运行,会提示具体的异常原因。
- **本地代理出口检测**:在网页端直接一键检测 VPS 后台对海外的实际连通状况,并回显真实的代理出站 IP 和所在地理位置。
* **日志追踪面板**
- **分类过滤**:可精准筛选查看特定功能的日志(如 VPN 连接日志、API 请求日志、系统异常等)。
- **实时滚动与管理**:日志实时滚动加载,支持一键复制代码、一键导出 `.log` 日志文件到本地。
---
### ⚠️ 小白安装与运行常见问题 (FAQ)
#### 1. 提示 `Cannot allocate tun` 或 `Cannot open tun/tap dev`
* **原因**:VPS 宿主机未启用虚拟网卡(TUN/TAP 设备)。这种情况常见于 LXC 或 OpenVZ 架构的轻量 VPS。
* **解决办法**:请登录您的 VPS 服务商控制面板(如 SolusVM/Proxmox),找到 **Enable TUN/TAP** / **开启 TUN** 选项并启用,然后重启 VPS。如无此选项,请工单联系客服开启。
#### 2. 网页管理后台无法打开(链接超时或拒绝连接)
* **原因 1**:VPS 本身自带防火墙(如 UFW、firewalld 或 iptables)阻断了管理端口(默认 `8787`)或代理端口(默认 `7928`)。
* **解决办法 1**:请在终端放行对应端口:
* **UFW (Ubuntu/Debian)**: `ufw allow 8787/tcp && ufw allow 7928/tcp`
* **Firewalld (CentOS/RHEL)**: `firewall-cmd --zone=public --add-port=8787/tcp --permanent && firewall-cmd --zone=public --add-port=7928/tcp --permanent && firewall-cmd --reload`
* **原因 2**:云服务商的“安全组”或“网络访问控制列表 (ACL)”未放行端口。
* **解决办法 2**:**非常重要!** 登录云服务商控制台(如阿里云、腾讯云、AWS、Oracle Cloud等),找到您 VPS 实例的 **安全组规则 (Security Group)**,在入站规则中添加:
- **协议类型**: `TCP`
- **端口范围**: `8787` (管理网页) 和 `7928` (代理端口)
- **授权对象/源IP**: `0.0.0.0/0` (允许所有人,或指定您自己的家庭公网 IP 提高安全性)
#### 3. 页面提示 `API Domain Blocked` 且备选节点显示为 0
* **原因**:您的 VPS DNS 解析异常,或者官方 VPNGate 域名遭防火墙拦截污染,导致无法下载节点列表。
* **解决办法**
* **设置上游代理**:如果您有其他可用的代理服务,可在网页管理面板中打开“管理员 -> 代理及网络设置”,配置有效的 HTTP/SOCKS5 上游代理,后台会自动通过该代理拉取更新。
* **修改 DNS 解析器**:在终端修改 `/etc/resolv.conf`,将域名服务器替换为公共 DNS(如 `nameserver 8.8.8.8` 和 `nameserver 1.1.1.1`)。
程序会按以下顺序自动回退,不需要用户手动切换:
1. VPNGate 官方 HTTPS
2. VPNGate 官方 HTTP(兼容旧系统,结果不会覆盖 HTTPS 获得的可信缓存)
3. GitHub Pages 镜像 HTTPS
4. GitHub Pages 镜像 HTTP
5. VPS 本地最近有效快照;首次安装时使用仓库附带的初始快照
默认镜像为 `https://baoweise-bot.github.io/aimili-vpngate/vpngate.csv`。仓库管理员需要在 GitHub 的 **Settings -> Pages** 中将 Source 设置为 **GitHub Actions**,定时工作流会每 15 分钟校验并发布一次快照。可通过 `VPNGATE_API_HTTPS_URL`、`VPNGATE_API_HTTP_URL`、`VPNGATE_MIRROR_HTTPS_URL` 和 `VPNGATE_MIRROR_HTTP_URL` 覆盖各节点源。
#### 4. VPN 已成功连接,但客户端设置代理后无法上网 (无流量)
* **原因**:部分系统启用了严格的反向路径过滤(`rp_filter`),导致策略路由的入站/出站数据包被系统误判丢弃。
* **解决办法**:在终端输入 `ml` 命令打开交互菜单,工具会自动检测并提示您将 `rp_filter` 修复为宽松模式(值为 `2`)。
---
### 🎁 捐赠支持项目开发
如果您觉得这个项目对您有所帮助,欢迎捐赠支持我们的后续开发与维护:
* **BNB (BSC / BEP20)**: `0xB6d78c42CEB0687A31B8cfEBE4b51b6eB8953C17`
* **TRX (TRC20)**: `TSdzCW6JvsrqcppodYjhSrku4mYmDJ9pxf`
感谢您的慷慨与支持!❤️
---
<a name="english"></a>
## English
AimiliVPN is a high-performance, zero-dependency VPN proxy gateway built entirely using Python's standard library. It parses official VPNGate servers, benchmarks latency, and routes traffic through a built-in dual-protocol (HTTP/SOCKS5) proxy server.
### 🌟 Recommended VPS Deals
[![BandwagonHost Premium Optimized Routes](https://img.shields.io/badge/BandwagonHost-Premium%20Optimized%20Routes-red?style=for-the-badge)](https://bandwagonhost.com/aff.php?aff=81790)
[![RackNerd 6000GB Bandwidth](https://img.shields.io/badge/RackNerd-6000GB%2Fmonth%20Bandwidth-blue?style=for-the-badge)](https://my.racknerd.com/aff.php?aff=18708)
| Pick | Best for | Highlights | Link |
| --- | --- | --- | --- |
| **BandwagonHost** | Users who care most about China connectivity, latency, and route quality | **Premium China Telecom/Unicom/Mobile optimized routes**, ideal for demanding cross-border networking and long-term use | [View deals](https://bandwagonhost.com/aff.php?aff=81790) |
| **RackNerd** | Budget deployments, testing, and long-running lightweight services | **6000GB monthly bandwidth**, affordable pricing, and generous specs for value-focused VPS use | [View deals](https://my.racknerd.com/aff.php?aff=18708) |
### 📢 Community & Feedback
- **Telegram Group**: [arestemple](https://t.me/arestemple)
- **Discussion Forum**: [339936.xyz](https://339936.xyz)
- **Video Tutorial**: [YouTube Guide](https://www.youtube.com/watch?v=s-ATfXR8BpI)
- **Email Contact**: yaohunse7@gmail.com
---
### 🚀 One-Click Installation
Run the corresponding command on your Linux VPS as root:
#### 🌟 V2.1 Formal Release (main branch only)
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
> 💡 **Quick Note**: Once installed, copy the printed URL from the terminal to access the Web UI. Type the `ml` command in the terminal to summon the interactive CLI management console.
#### Docker / Docker Compose
GitHub publishes prebuilt images for `linux/amd64`, `linux/386`, `linux/arm64`, and `linux/arm/v7` under `ghcr.io/baoweise-bot/aimili-vpngate:2.1`:
<details>
<summary><strong>无法拉取 GHCR 时在 VPS 本地构建</strong></summary>
```bash
docker compose pull
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose build
docker compose up -d
```
To build natively on the VPS instead, run `docker compose build` before `docker compose up -d`. Docker requires a Linux host with `/dev/net/tun`, `NET_ADMIN`, and `NET_RAW` support.
</details>
---
<a id="connection"></a>
## 连接与使用
### 💡 Quick Start Guide
### 1. 登录 Web 后台
#### Step 1: Access the Web UI
Open your browser and navigate to the printed URL (e.g. `http://your_vps_ip:8787/u71e9IXp4TPx`).
源码安装完成后,使用终端输出的地址访问:
#### Step 2: Select Node and Mode
1. Wait for the program to complete its first automatic node speed benchmarks.
2. Under "Admin", you can trigger node fetching. The backend concurrently tests official VPNGate nodes and ranks them by latency.
3. Switch routes mode (Smart Auto, Specific Region, or Specific Server Node) according to your needs.
```text
http://VPS_IP:8787/随机安全路径/
```
#### Step 3: Use Localhost Proxy (Core Step)
To prevent unauthorized scanning and abuse of the proxy port on the public internet, the built-in HTTP/SOCKS5 proxy server (default port **`7928`**) **binds to localhost (`127.0.0.1`) by default**. It is designed to route traffic generated locally on the VPS, rather than acting as a public proxy server.
忘记地址时执行 `ml status`;需要重设账号密码时执行 `ml password`
* **🐍 Proxy in Python**:
```python
import requests
proxies = {
"http": "http://127.0.0.1:7928",
"https": "http://127.0.0.1:7928",
}
response = requests.get("https://www.google.com", proxies=proxies)
```
* **🐚 Proxy in Shell terminal**:
```bash
export http_proxy="http://127.0.0.1:7928"
export https_proxy="http://127.0.0.1:7928"
```
* **⚙️ Other local services**:
Configure your scrapers, frameworks, or utility tools on this VPS to send traffic via `127.0.0.1:7928`.
Docker 用户可以读取首次启动时保存的 Web 配置:
> 💡 **Quick Note**: If you really need to open this proxy port to the public internet, you can set the environment variable `export LOCAL_PROXY_HOST="::"` before running the manager.
```bash
docker exec aimilivpn cat /data/ui_auth.json
```
---
使用其中的 `secret_path``username``password` 登录,并在首次登录后修改安全路径和凭据。
### ⚠️ Common Troubleshooting (FAQ)
### 2. 获取并连接节点
#### 1. Error: `Cannot allocate tun` or `Cannot open tun/tap dev`
* **Reason**: Virtual network adapter (TUN/TAP device) is disabled. This is common in OpenVZ/LXC VPS instances.
* **Solution**: Enable **TUN/TAP** in your VPS SolusVM/KiwiVM control panel, or submit a support ticket to your hosting provider.
1. 登录后台,等待首次节点加载完成,或点击“更新节点”。
2. 按国家筛选节点,并使用“测试”检查本机实测延迟与可用性。
3. 点击目标节点的“切换”;目标预检失败时,程序会尽量保留当前可用连接。
4. 根据需要选择智能自动、固定国家或固定 IP 模式。
5. 在状态区域确认 VPN 已连接,并核对当前出口 IP。
#### 2. Cannot open the Web UI in the browser
* **Reason 1**: The built-in firewall (UFW or firewalld) is blocking ports `8787` (Web UI) and `7928` (Proxy).
* **Solution 1**: Allow the ports in your OS firewall:
* **UFW**: `ufw allow 8787/tcp && ufw allow 7928/tcp`
* **Firewalld**: `firewall-cmd --add-port=8787/tcp --permanent && firewall-cmd --add-port=7928/tcp --permanent && firewall-cmd --reload`
* **Reason 2**: Service provider security group blocking ports.
* **Solution 2**: **Crucial!** Log in to your cloud provider console (AWS, Aliyun, Oracle Cloud, etc.), locate the **Security Group** for your instance, and add an inbound TCP rule to allow ports `8787` and `7928` from `0.0.0.0/0`.
### 3. 在 VPS 本机使用代理
#### 3. "API Domain Blocked" / Candidate nodes pool is empty (0 nodes)
* **Reason**: The official VPNGate domain is blocked or DNS resolution failed on your VPS.
* **Solution**: Add an HTTP/SOCKS5 upstream proxy in the settings panel (Admin -> Proxy Settings), or configure public DNS in `/etc/resolv.conf` (e.g., `nameserver 8.8.8.8`).
HTTP、HTTPS 网站代理和 SOCKS5 共用 `127.0.0.1:7928`。HTTPS 网站通过 HTTP 代理的 `CONNECT` 方法访问,代理地址仍填写 `http://127.0.0.1:7928`
The application automatically tries the official HTTPS endpoint, official HTTP endpoint, GitHub Pages HTTPS mirror, GitHub Pages HTTP mirror, and finally the last valid local snapshot. A validated initial snapshot is bundled for first startup. HTTP results remain supported for older systems but do not replace the cache obtained through HTTPS.
```bash
# HTTP / HTTPS
curl -x http://127.0.0.1:7928 https://api.ipify.org
The default mirror is `https://baoweise-bot.github.io/aimili-vpngate/vpngate.csv`. Repository administrators must select **GitHub Actions** as the Pages source under **Settings -> Pages**. The scheduled workflow validates and publishes a fresh snapshot every 15 minutes. Source URLs can be overridden with `VPNGATE_API_HTTPS_URL`, `VPNGATE_API_HTTP_URL`, `VPNGATE_MIRROR_HTTPS_URL`, and `VPNGATE_MIRROR_HTTP_URL`.
# SOCKS5,并通过代理解析域名
curl --proxy socks5h://127.0.0.1:7928 https://api.ipify.org
```
---
<details>
<summary><strong>查看 Shell 环境变量与 Python 示例</strong></summary>
### 🎁 Donation Support
```bash
export http_proxy="http://127.0.0.1:7928"
export https_proxy="http://127.0.0.1:7928"
curl https://api.ipify.org
```
If you find this project helpful, you can support its development and maintenance via donation:
```python
import requests
* **BNB (BSC / BEP20)**: `0xB6d78c42CEB0687A31B8cfEBE4b51b6eB8953C17`
* **TRX (TRC20)**: `TSdzCW6JvsrqcppodYjhSrku4mYmDJ9pxf`
proxies = {
"http": "http://127.0.0.1:7928",
"https": "http://127.0.0.1:7928",
}
Thank you for your generosity and support! ❤️
response = requests.get("https://api.ipify.org", proxies=proxies, timeout=20)
print(response.text)
```
</details>
### 4. 从电脑或其他设备连接
代理默认只监听 VPS 回环地址。推荐使用 SSH 隧道,不要直接暴露代理端口:
```bash
ssh -N \
-L 8787:127.0.0.1:8787 \
-L 7928:127.0.0.1:7928 \
root@VPS_IP
```
隧道建立后:
- Web`http://127.0.0.1:8787/随机安全路径/`
- HTTP / HTTPS 代理:`127.0.0.1:7928`
- SOCKS5 代理:`127.0.0.1:7928`,支持时选择远程 DNS 或 `socks5h`
> [!WARNING]
> `7928` 默认没有面向公网的用户认证。请勿在没有防火墙、来源 IP 限制或其他可靠访问控制的情况下将其直接开放到公网。
<a id="community"></a>
## 网站、社群与视频
| 入口 | 用途 | 链接 |
| --- | --- | --- |
| 项目网站 / 交流论坛 | 公告、经验交流与讨论 | [339936.xyz](https://339936.xyz) |
| Telegram 群 | 即时交流 | [t.me/arestemple](https://t.me/arestemple) |
| YouTube 教程 | 安装和使用视频 | [观看视频](https://www.youtube.com/watch?v=s-ATfXR8BpI) |
| GitHub Issues | 可复现的问题与功能建议 | [提交 Issue](https://github.com/baoweise-bot/aimili-vpngate/issues) |
<a id="legal"></a>
## 使用范围与法律声明
> [!CAUTION]
> 下载、部署或使用本项目即表示您应自行确认用途符合所在地法律、VPS 所在地法律、网络服务商条款及 VPNGate 的相关规则。以下内容是项目使用边界,不构成法律意见,也不能保证免除任何个人或组织依法应承担的责任。
1. **限定用途**:本项目仅用于合法的网络研究、教育、开发测试、隐私保护和经授权的网络访问,不得用于绕过依法实施的监管措施、未授权访问、攻击、扫描、垃圾信息、欺诈、侵权或其他违法活动。
2. **网络与地区限制**:不同地区和数据中心可能限制 VPNGate、GitHub 镜像或远端 VPN 节点。本项目不承诺任何地区或机型始终可用;仅应在当地法律和服务商条款允许的环境中合理使用。
3. **第三方节点**:VPNGate 节点由第三方志愿者运营,本项目不拥有、不控制也不审核这些节点,无法保证其稳定性、速度、安全性、隐私政策或日志行为。请勿通过不可信节点传输账号密码、金融信息、商业机密等敏感数据。
4. **用户责任**:节点选择、流量内容、部署位置、端口开放和账号安全均由使用者负责。因违法使用、配置不当、第三方节点、服务中断、数据泄露或账号滥用产生的后果,由使用者依法承担。
5. **无保证提供**:软件按“现状”提供,在适用法律允许的最大范围内,维护者不对可用性、适销性、特定用途适用性或间接损失作出保证。无法依法排除的责任不受本声明影响。
6. **不确定时停止使用**:如无法确认当地法律或服务商是否允许,请停止部署和使用,并咨询当地有执业资格的法律专业人士。
<div align="center">
[正式版本](https://github.com/baoweise-bot/aimili-vpngate/releases/latest) · [问题反馈](https://github.com/baoweise-bot/aimili-vpngate/issues) · [GPL-3.0 License](LICENSE)
</div>
+65 -14
View File
@@ -1,19 +1,70 @@
# AimiliVPN V2.1 正式版
# AimiliVPN V2.1.5 正式版
V2.1 是仅从 `main` 主分支发布的首个正式版本标志
V2.1.5 包含 V2.1.4 的全部稳定性、安全性和节点识别改进,并修复正式版重新安装验收中发现的失败切换恢复遗漏
## 本次更新
## V2.1.5 热修复
- 节点来源按“VPNGate 官方 HTTPS -> 官方 HTTP -> GitHub Pages HTTPS -> GitHub Pages HTTP -> VPS 本地快照 -> 内置初始快照”自动回退
- 修复节点获取缓慢、连接断开和切换失败时误伤现有连接的问题
- 恢复节点延迟列,区分本机实测值与 VPNGate 官方预估值
- 加入国旗、实时多选国家筛选、国家范围持久化和单节点测试
- Web 管理端加入正式版更新检测,只检查 GitHub 最新稳定 Release,并根据 Python 源码或 Docker 部署方式显示对应更新命令
- `install.sh``ml update` 统一只更新 `origin/main`
- GitHub Release 提供一个适用于 Linux `amd64``386``arm64``armv7` 的通用 Python 源码包与 SHA-256 校验文件。
- GHCR 提供经过逐架构冒烟测试的 `amd64``386``arm64``arm/v7` Docker 镜像,并同时发布 `2.1.0``2.1``latest` 标签。
- Docker 用户默认拉取 GitHub 预构建镜像,也可以使用仓库中的 Dockerfile 在 VPS 本地构建。
- 修复点击“断开连接”后 OpenVPN、活动节点和 table 100 已清理,但 Web API 的 `tunnel_ready` / `proxy_ready` 仍短暂保留为 `true` 的问题
- 手动断开现在统一复用完整连接状态清理流程;活动节点、连接中状态、隧道、代理出口、出口 IP 与路由规则会同步归零
- 新增手动断开状态回归测试,并在真实 VPS 上完成断开与重新连接复验
- 修复手动切换失败、原节点恢复失败且连续 3 个备用节点也失效后,日志提示后台重新加载但实际没有启动补位任务的问题
- 备用节点耗尽后现在会启动唯一后台节点补齐线程,按退避间隔重新同步并尝试连接,避免一直等到下一轮周期采集
- 新增自动切换耗尽回归测试,并在真实 VPS 上验证补位线程会创建且不会重复启动
## 兼容范围
## IP 类型识别
应用依赖 Linux TUN、OpenVPN、iptables 和策略路由,因此正式支持 Linux 主机。Docker 也必须运行在具备 `/dev/net/tun` 的 Linux 主机上,并授予 `NET_ADMIN``NET_RAW` 能力
- IP 类型缓存升级到新的分类版本,旧分类会自动失效并重新检测
- 明确区分住宅、移动、机房和未知网络,并显示高、中、低置信度及情报来源。
- Sony、KDDI、SoftBank、Korea Telecom 等有明确消费接入运营商信息且未命中机房特征的网络继续识别为住宅。
- `proxy + 数据中心关键词` 冲突节点以及完全缺少运营商信息的节点会调用 `ipapi.is` 进行第二来源复核。
- 第二来源不可用或证据不足时标记为未知/低置信度,不再直接当作住宅。
- 严格住宅路由只接受中、高置信度住宅或移动网络,拒绝未知和低置信度分类。
## 节点获取与连接切换
- 修复官方 HTTPS 超时后直接跳过官方 HTTP 的问题;现在仍会继续尝试 VPNGate 官方真实 HTTP,再进入 GitHub Pages、VPS 最近缓存和内置快照。
- GitHub 镜像状态显示生成时间与陈旧程度,HTTP 地址明确标注为跳转 HTTPS,不再描述成独立明文镜像。
- 连接状态拆分为隧道就绪和代理出口就绪;只有 OpenVPN、策略路由与真实代理出口全部通过后,前端才显示“已连接”。
- 修复批量节点检测失败时用空字段覆盖已有 ISP、物理位置和 IP 类型的问题。
- 固定国家统一保存 ISO 两字母代码,并兼容旧版中文国家名称配置。
- 物理位置国旗使用 IP 情报源国家,节点申报国家与 IP 推测位置分开提示,避免来源冲突造成误解。
## 安装、安全与运维
- 卸载时清理 AimiliVPN 的 sysctl 文件、table 100 路由和全部 table 100 策略规则。
- `ui_auth.json` 创建、迁移和保存时使用私有权限 `0600`,并采用安全写入流程。
- Web 管理端口与代理端口增加双向冲突校验,冲突配置直接拒绝,不再静默改写端口。
- OpenVPN 启用 `remote-cert-tls server`,验证服务器证书用途并移除相关安全警告。
- 新增 `AIMILIVPN_NONINTERACTIVE=1` 无人值守安装模式,首次部署不会停在交互提示。
- 安装完成信息只显示实际默认监听的 IPv4 本地代理地址,不再错误提示未启用的 IPv6 地址。
## 验证与兼容性
- 本地 Python 单元测试 53/53 通过,Python 编译、Dashboard JavaScript 语法、安装脚本语法和补丁格式检查通过。
- Ubuntu 22.04 x86_64 VPS 已完成真实卸载、重新安装、节点源、连接切换、代理出口、Web API、权限和路由残留验证。
- 发布流水线会在 Python 3.9、3.11、3.13 上运行完整测试。
- Docker 会分别构建验证 `linux/amd64``linux/386``linux/arm64``linux/arm/v7`,并发布统一多架构镜像清单。
## 平台限制
- GitHub Actions 定时任务由 GitHub 调度,不保证每 15 分钟准点运行。
- GitHub Pages 会把 HTTP 强制跳转到 HTTPS,不能作为真正的明文 HTTP 镜像。
- 程序保留 VPNGate 官方 HTTP、VPS 最近有效缓存和内置初始快照,以降低上述平台限制的影响。
## 下载与更新
- GitHub Release`aimilivpn-v2.1.5-linux-source.tar.gz``sha256sums.txt`
- GHCR`2.1.5``2.1``latest` 多架构镜像标签。
Python 源码安装更新:
```bash
ml update
```
Docker Compose 更新:
```bash
docker compose pull
docker compose up -d
```
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.1.5
+247
View File
@@ -0,0 +1,247 @@
<div align="center">
# AimiliVPN
**A VPNGate node manager and HTTP / HTTPS / SOCKS5 proxy gateway for Linux VPS hosts**
[![Release](https://img.shields.io/github/v/release/baoweise-bot/aimili-vpngate?style=flat-square&label=stable&color=16a34a)](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)
[![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20386%20%7C%20arm64%20%7C%20armv7-0ea5e9?style=flat-square&logo=docker&logoColor=white)](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate)
[![License](https://img.shields.io/badge/License-GPL--3.0-334155?style=flat-square)](../LICENSE)
[简体中文](../README.md) · **English** · [日本語](README.ja.md) · [한국어](README.ko.md)
[Quick install](#quick-install) · [Installation](#installation) · [Connection](#connection) · [Recommended services](#vps) · [Community](#community) · [Legal notice](#legal)
[![Website](https://img.shields.io/badge/Website-339936.xyz-f97316?style=for-the-badge)](https://339936.xyz)
[![Telegram](https://img.shields.io/badge/Telegram-Community-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
[![YouTube](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=s-ATfXR8BpI)
</div>
AimiliVPN uses Python's standard library to manage VPNGate nodes. It provides node discovery and testing, connection switching, a Web dashboard, and HTTP, HTTPS website proxying, and SOCKS5 access on one local port.
| Item | Default or supported range |
| --- | --- |
| Web dashboard | TCP `8787`, a private path, username, and password |
| Local proxy | `127.0.0.1:7928`, HTTP, HTTPS `CONNECT`, and SOCKS5 |
| Source deployment | x64, x86, ARM64, and ARM32 Linux |
| Docker images | `linux/amd64`, `linux/386`, `linux/arm64`, and `linux/arm/v7` |
| Update channel | GitHub `main` stable branch / latest stable Release |
> [!IMPORTANT]
> **Network availability:** Some regions, data centers, and network providers may restrict DNS, VPNGate APIs, GitHub mirrors, or VPN protocols. Mirrors and local snapshots improve node-list availability but cannot guarantee a successful connection on every host. Confirm that local law and your VPS provider permit VPN/TUN before deployment.
<a id="quick-install"></a>
## Quick Install
Run as `root` on a supported Linux VPS:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
The installer prints the complete Web URL, private path, username, and password. Run `ml` to open the management menu.
> [!TIP]
> Enable TUN/TAP in the VPS control panel and verify that `/dev/net/tun` exists. The Web dashboard uses TCP `8787` by default; restrict firewall access to your own IP whenever possible.
<a id="vps"></a>
## Recommended Services
| Provider | Type | Why we recommend it | Link |
| --- | --- | --- | --- |
| **BandwagonHost** | VPS | Optimized China Telecom CN2 GIA, China Unicom 9929, and China Mobile CMIN2 routes; low latency and strong stability for TikTok Live, cross-border commerce, and long-running global services. | [View offer](https://bandwagonhost.com/aff.php?aff=81790) |
| **RackNerd** | VPS | 4000GB of monthly traffic with strong value for the price; low deployment costs for services that need to run continuously. | [View offer](https://my.racknerd.com/aff.php?aff=18708) |
| **OpenMili** | AI relay | Direct GPT model access without model substitution, a low-cost 1:2 rate, and image generation support. | [Visit OpenMili](https://openmili.com/) |
Some links are affiliate links. Using them does not increase your price.
Before purchasing, confirm that the selected plan permits TUN/TAP, OpenVPN, and the required network protocols. An affiliate link is not a guarantee that a specific plan will work.
<a id="installation"></a>
## Installation
### Requirements
- Ubuntu, Debian, Alpine, CentOS, RHEL, Rocky Linux, AlmaLinux, Fedora, Oracle Linux, or Amazon Linux.
- `root`, OpenVPN, iptables, policy routing, and TUN/TAP.
- Windows and macOS can be proxy clients, but cannot run the full gateway. Docker Desktop is not equivalent to a Linux VPS with host TUN access.
### Option 1: Source installer
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
The installer deploys to `/opt/aimilivpn` and registers a system service.
For unattended installation, explicitly skip the first-run prompts and generate the Web path and credentials automatically:
```bash
AIMILIVPN_NONINTERACTIVE=1 bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
```bash
ml # Open the management menu
ml status # Show status, Web URL, and username
ml logs # Follow logs
ml restart # Restart the service
ml password # Reset Web credentials
ml update # Update from the stable main branch
ml uninstall # Uninstall
```
To inspect the installer first:
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
sudo bash install.sh
```
Universal Linux source archives and SHA-256 checksums are available from [GitHub Releases](https://github.com/baoweise-bot/aimili-vpngate/releases/latest). Version changes are documented in the Release Notes.
### Option 2: Docker Compose
The Docker host must provide `/dev/net/tun`, host networking, `NET_ADMIN`, and `NET_RAW`.
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose pull
docker compose up -d
docker logs -f aimilivpn
```
Image: `ghcr.io/baoweise-bot/aimili-vpngate:2.1`
Update:
```bash
docker compose pull
docker compose up -d
```
<details>
<summary><strong>Show the docker run command</strong></summary>
```bash
docker run -d \
--name aimilivpn \
--restart unless-stopped \
--network host \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--device /dev/net/tun:/dev/net/tun \
-e UI_HOST=0.0.0.0 \
-e UI_PORT=8787 \
-e LOCAL_PROXY_HOST=127.0.0.1 \
-e LOCAL_PROXY_PORT=7928 \
-v aimilivpn-data:/data \
ghcr.io/baoweise-bot/aimili-vpngate:2.1
```
</details>
<details>
<summary><strong>Build locally when GHCR is unavailable</strong></summary>
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose build
docker compose up -d
```
</details>
<a id="connection"></a>
## Connection and Use
### 1. Sign in to the Web dashboard
For a source installation, open the URL printed by the installer:
```text
http://VPS_IP:8787/private_path/
```
Run `ml status` to recover the URL, or `ml password` to reset credentials.
Docker users can read the initial Web configuration with:
```bash
docker exec aimilivpn cat /data/ui_auth.json
```
Use its `secret_path`, `username`, and `password`, then change them after the first sign-in.
### 2. Fetch and connect to a node
1. Sign in and wait for the first node list, or select **Update nodes**.
2. Filter by country and use **Test** to measure reachability and latency from the VPS.
3. Select **Switch** on the target node. A failed target precheck should leave the current usable connection in place when possible.
4. Choose Smart Auto, Fixed Country, or Fixed IP routing.
5. Confirm the VPN state and outbound IP in the status area.
### 3. Use the proxy on the VPS
HTTP, HTTPS website proxying, and SOCKS5 share `127.0.0.1:7928`. HTTPS sites use the HTTP proxy's `CONNECT` method, so the proxy URL remains `http://127.0.0.1:7928`.
```bash
# HTTP / HTTPS
curl -x http://127.0.0.1:7928 https://api.ipify.org
# SOCKS5 with remote DNS resolution
curl --proxy socks5h://127.0.0.1:7928 https://api.ipify.org
```
### 4. Connect from another computer
The proxy listens on the VPS loopback address by default. Use an SSH tunnel instead of exposing it publicly:
```bash
ssh -N \
-L 8787:127.0.0.1:8787 \
-L 7928:127.0.0.1:7928 \
root@VPS_IP
```
After the tunnel is established:
- Web: `http://127.0.0.1:8787/private_path/`
- HTTP / HTTPS proxy: `127.0.0.1:7928`
- SOCKS5 proxy: `127.0.0.1:7928`; enable remote DNS or `socks5h` when available
> [!WARNING]
> Port `7928` has no public-facing user authentication by default. Never expose it directly without a firewall, source-IP restriction, or another reliable access-control layer.
<a id="community"></a>
## Website, Community, and Video
| Destination | Purpose | Link |
| --- | --- | --- |
| Website / forum | Announcements and discussion | [339936.xyz](https://339936.xyz) |
| Telegram group | Real-time community chat | [t.me/arestemple](https://t.me/arestemple) |
| YouTube tutorial | Installation and usage video | [Watch](https://www.youtube.com/watch?v=s-ATfXR8BpI) |
| GitHub Issues | Reproducible bugs and feature requests | [Open an issue](https://github.com/baoweise-bot/aimili-vpngate/issues) |
| Email | Bug reports and contact | [yaohunse7@gmail.com](mailto:yaohunse7@gmail.com) |
<a id="legal"></a>
## Scope of Use and Legal Notice
> [!CAUTION]
> By downloading, deploying, or using this project, you are responsible for confirming compliance with the laws of your location, the laws where the VPS is hosted, provider terms, and applicable VPNGate rules. This section defines project boundaries, is not legal advice, and cannot guarantee exemption from any liability imposed by law.
1. **Permitted purpose:** Use only for lawful network research, education, development testing, privacy protection, and authorized access. Do not use it to evade lawfully imposed controls, gain unauthorized access, attack or scan systems, send spam, commit fraud, infringe rights, or conduct any unlawful activity.
2. **Network and regional restrictions:** Some regions and data centers may restrict VPNGate, GitHub mirrors, or remote VPN nodes. The project does not guarantee continuous availability in any region or on any host. Use it only where local law and provider terms permit.
3. **Third-party nodes:** VPNGate nodes are operated by third-party volunteers. This project does not own, control, or audit them and cannot guarantee availability, speed, security, privacy practices, or logging behavior. Do not send passwords, financial information, trade secrets, or other sensitive data through untrusted nodes.
4. **User responsibility:** The user is responsible for node selection, traffic, deployment location, exposed ports, and account security, and bears legal responsibility for unlawful use, misconfiguration, third-party nodes, outages, data leaks, or account abuse.
5. **No warranty:** The software is provided “as is.” To the maximum extent permitted by applicable law, maintainers disclaim warranties of availability, merchantability, fitness for a particular purpose, and indirect damages. Liability that cannot lawfully be excluded remains unaffected.
6. **Stop if uncertain:** If you cannot confirm that local law and provider policy permit this use, do not deploy or use the software and consult a qualified lawyer in the relevant jurisdiction.
<div align="center">
[Stable Release](https://github.com/baoweise-bot/aimili-vpngate/releases/latest) · [Issues](https://github.com/baoweise-bot/aimili-vpngate/issues) · [GPL-3.0 License](../LICENSE)
</div>
+236
View File
@@ -0,0 +1,236 @@
<div align="center">
# AimiliVPN
**Linux VPS 向け VPNGate ノード管理・HTTP / HTTPS / SOCKS5 プロキシゲートウェイ**
[![Release](https://img.shields.io/github/v/release/baoweise-bot/aimili-vpngate?style=flat-square&label=stable&color=16a34a)](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)
[![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20386%20%7C%20arm64%20%7C%20armv7-0ea5e9?style=flat-square&logo=docker&logoColor=white)](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate)
[![License](https://img.shields.io/badge/License-GPL--3.0-334155?style=flat-square)](../LICENSE)
[简体中文](../README.md) · [English](README.en.md) · **日本語** · [한국어](README.ko.md)
[クイックインストール](#quick-install) · [インストール](#installation) · [接続方法](#connection) · [おすすめサービス](#vps) · [コミュニティ](#community) · [法的通知](#legal)
[![Website](https://img.shields.io/badge/Website-339936.xyz-f97316?style=for-the-badge)](https://339936.xyz)
[![Telegram](https://img.shields.io/badge/Telegram-Community-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
[![YouTube](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=s-ATfXR8BpI)
</div>
AimiliVPN は Python 標準ライブラリで VPNGate ノードを管理し、ノード取得・テスト、接続切り替え、Web 管理画面、HTTP / HTTPS サイト用プロキシと SOCKS5 を提供します。
| 項目 | デフォルトまたは対応範囲 |
| --- | --- |
| Web 管理画面 | TCP `8787`、専用パス、ユーザー名、パスワード |
| ローカルプロキシ | `127.0.0.1:7928`、HTTP、HTTPS `CONNECT`、SOCKS5 |
| ソース版 | x64、x86、ARM64、ARM32 Linux |
| Docker | `linux/amd64``linux/386``linux/arm64``linux/arm/v7` |
> [!IMPORTANT]
> **ネットワーク可用性:** 地域、データセンター、ネットワーク事業者によっては、DNS、VPNGate API、GitHub ミラー、VPN プロトコルが制限される場合があります。ミラーとローカルスナップショットはノード一覧の可用性を高めますが、すべての環境で接続を保証するものではありません。展開前に、現地法と VPS 事業者が VPN/TUN を許可していることを確認してください。
<a id="quick-install"></a>
## クイックインストール
対応する Linux VPS で `root` として実行します。
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
完了後、Web 管理画面の URL、専用パス、ユーザー名、パスワードが表示されます。`ml` で管理メニューを開けます。
> [!TIP]
> VPS の管理画面で TUN/TAP を有効にし、`/dev/net/tun` が存在することを確認してください。Web のデフォルトポートは TCP `8787` です。可能な限り自分の IP だけを許可してください。
<a id="vps"></a>
## おすすめサービス
| サービス | 種類 | おすすめポイント | リンク |
| --- | --- | --- | --- |
| **BandwagonHost** | VPS | China Telecom CN2 GIA、China Unicom 9929、China Mobile CMIN2 の最適化回線。低遅延で安定しており、TikTok ライブ、越境 EC、長期的な海外向けサービスに適しています。 | [詳細を見る](https://bandwagonhost.com/aff.php?aff=81790) |
| **RackNerd** | VPS | 月間 4000GB の大容量トラフィックと高いコストパフォーマンス。継続稼働するサービスの導入コストを抑えられます。 | [詳細を見る](https://my.racknerd.com/aff.php?aff=18708) |
| **OpenMili** | AI 中継 | GPT モデルを差し替えずに提供する AI 中継サービス。低価格な 1:2 レートで、画像生成にも対応しています。 | [OpenMili を見る](https://openmili.com/) |
一部のリンクはアフィリエイトリンクです。リンク経由でも購入価格は上がりません。
購入前に、対象プランが TUN/TAP、OpenVPN、必要なプロトコルを許可していることを確認してください。
<a id="installation"></a>
## インストール
### 動作要件
- Ubuntu、Debian、Alpine、CentOS、RHEL、Rocky Linux、AlmaLinux、Fedora、Oracle Linux、Amazon Linux。
- `root`、OpenVPN、iptables、ポリシールーティング、TUN/TAP。
- Windows と macOS はプロキシクライアントとして利用できますが、ゲートウェイ本体は実行できません。
### 方法 1:ソースインストーラー
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
`/opt/aimilivpn` に配置し、システムサービスを登録します。
```bash
ml # 管理メニュー
ml status # 状態、Web URL、ユーザー名
ml logs # ログ
ml restart # 再起動
ml password # Web 認証情報を再設定
ml update # main 安定版から更新
ml uninstall # アンインストール
```
事前にスクリプトを確認する場合:
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
sudo bash install.sh
```
Linux 共通ソースアーカイブと SHA-256 は [GitHub Releases](https://github.com/baoweise-bot/aimili-vpngate/releases/latest) から取得できます。変更内容は Release Notes に掲載されます。
### 方法 2Docker Compose
Docker ホストには `/dev/net/tun`、host ネットワーク、`NET_ADMIN``NET_RAW` が必要です。
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose pull
docker compose up -d
docker logs -f aimilivpn
```
イメージ:`ghcr.io/baoweise-bot/aimili-vpngate:2.1`
更新:
```bash
docker compose pull
docker compose up -d
```
<details>
<summary><strong>docker run コマンドを表示</strong></summary>
```bash
docker run -d \
--name aimilivpn \
--restart unless-stopped \
--network host \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--device /dev/net/tun:/dev/net/tun \
-e UI_HOST=0.0.0.0 \
-e UI_PORT=8787 \
-e LOCAL_PROXY_HOST=127.0.0.1 \
-e LOCAL_PROXY_PORT=7928 \
-v aimilivpn-data:/data \
ghcr.io/baoweise-bot/aimili-vpngate:2.1
```
</details>
<details>
<summary><strong>GHCR を利用できない場合のローカルビルド</strong></summary>
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose build
docker compose up -d
```
</details>
<a id="connection"></a>
## 接続と利用方法
### 1. Web 管理画面へログイン
ソース版では、インストーラーが表示した URL を開きます。
```text
http://VPS_IP:8787/private_path/
```
URL は `ml status`、認証情報の再設定は `ml password` を使用します。Docker では次のコマンドで初期設定を確認できます。
```bash
docker exec aimilivpn cat /data/ui_auth.json
```
`secret_path``username``password` を使用し、初回ログイン後に変更してください。
### 2. ノードを取得して接続
1. ログイン後、最初の一覧を待つか「ノード更新」を実行します。
2. 国で絞り込み、「テスト」で VPS からの到達性と遅延を確認します。
3. 対象ノードの「切り替え」を選択します。事前確認に失敗した場合、可能な限り現在の接続を維持します。
4. スマート自動、国固定、IP 固定からルーティングモードを選びます。
5. 接続状態と出口 IP を確認します。
### 3. VPS 上でプロキシを使用
HTTP、HTTPS サイト用プロキシ、SOCKS5 は `127.0.0.1:7928` を共有します。
```bash
# HTTP / HTTPS
curl -x http://127.0.0.1:7928 https://api.ipify.org
# SOCKS5、DNS もプロキシ経由
curl --proxy socks5h://127.0.0.1:7928 https://api.ipify.org
```
### 4. 別の端末から接続
プロキシを公開せず、SSH トンネルを利用してください。
```bash
ssh -N \
-L 8787:127.0.0.1:8787 \
-L 7928:127.0.0.1:7928 \
root@VPS_IP
```
- Web`http://127.0.0.1:8787/private_path/`
- HTTP / HTTPS`127.0.0.1:7928`
- SOCKS5`127.0.0.1:7928`。可能な場合はリモート DNS または `socks5h` を使用
> [!WARNING]
> `7928` には、デフォルトで公開用のユーザー認証がありません。ファイアウォール、接続元 IP 制限などの確実なアクセス制御なしで公開しないでください。
<a id="community"></a>
## Web サイト・コミュニティ・動画
| 入口 | 用途 | リンク |
| --- | --- | --- |
| Web サイト / フォーラム | お知らせと交流 | [339936.xyz](https://339936.xyz) |
| Telegram | リアルタイム交流 | [t.me/arestemple](https://t.me/arestemple) |
| YouTube | インストール・利用方法 | [動画を見る](https://www.youtube.com/watch?v=s-ATfXR8BpI) |
| GitHub Issues | 再現可能な不具合と機能要望 | [Issue を作成](https://github.com/baoweise-bot/aimili-vpngate/issues) |
| メール | 不具合報告と連絡 | [yaohunse7@gmail.com](mailto:yaohunse7@gmail.com) |
<a id="legal"></a>
## 利用範囲と法的通知
> [!CAUTION]
> 本プロジェクトをダウンロード、展開、使用する前に、利用地と VPS 所在地の法律、事業者の規約、VPNGate の規則への適合を利用者自身で確認してください。本節は利用範囲を示すもので、法律上の助言ではなく、法的責任の免除を保証するものではありません。
1. **許可される用途:** 合法なネットワーク研究、教育、開発テスト、プライバシー保護、許可されたアクセスに限ります。法的な規制の回避、不正アクセス、攻撃、スキャン、スパム、詐欺、権利侵害、その他の違法行為に使用してはいけません。
2. **ネットワークと地域の制限:** 地域やデータセンターによっては VPNGate、GitHub ミラー、VPN ノードが制限される場合があります。本プロジェクトは、いかなる地域・環境でも継続的な可用性を保証しません。現地法と事業者規約が許可する場合に限り使用してください。
3. **第三者ノード:** VPNGate ノードは第三者のボランティアが運営しています。本プロジェクトはノードを所有・管理・監査せず、可用性、速度、安全性、プライバシー方針、ログ動作を保証しません。機密情報を信頼できないノードで送信しないでください。
4. **利用者の責任:** ノード選択、通信内容、設置場所、公開ポート、アカウント管理は利用者の責任です。違法利用、設定不備、第三者ノード、停止、情報漏えい、不正利用に伴う責任は利用者が負います。
5. **無保証:** 本ソフトウェアは「現状有姿」で提供されます。適用法で認められる最大限の範囲で、保守者は可用性、商品性、特定目的適合性、間接損害を保証しません。法律上排除できない責任には影響しません。
6. **不明な場合:** 現地法や事業者規約で許可されているか確認できない場合は使用を中止し、該当法域の有資格法律専門家に相談してください。
<div align="center">
[Stable Release](https://github.com/baoweise-bot/aimili-vpngate/releases/latest) · [Issues](https://github.com/baoweise-bot/aimili-vpngate/issues) · [GPL-3.0 License](../LICENSE)
</div>
+236
View File
@@ -0,0 +1,236 @@
<div align="center">
# AimiliVPN
**Linux VPS용 VPNGate 노드 관리 및 HTTP / HTTPS / SOCKS5 프록시 게이트웨이**
[![Release](https://img.shields.io/github/v/release/baoweise-bot/aimili-vpngate?style=flat-square&label=stable&color=16a34a)](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)
[![Docker](https://img.shields.io/badge/Docker-amd64%20%7C%20386%20%7C%20arm64%20%7C%20armv7-0ea5e9?style=flat-square&logo=docker&logoColor=white)](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate)
[![License](https://img.shields.io/badge/License-GPL--3.0-334155?style=flat-square)](../LICENSE)
[简体中文](../README.md) · [English](README.en.md) · [日本語](README.ja.md) · **한국어**
[빠른 설치](#quick-install) · [설치](#installation) · [연결](#connection) · [추천 서비스](#vps) · [커뮤니티](#community) · [법적 고지](#legal)
[![Website](https://img.shields.io/badge/Website-339936.xyz-f97316?style=for-the-badge)](https://339936.xyz)
[![Telegram](https://img.shields.io/badge/Telegram-Community-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
[![YouTube](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=s-ATfXR8BpI)
</div>
AimiliVPN은 Python 표준 라이브러리로 VPNGate 노드를 관리하며 노드 검색과 테스트, 연결 전환, Web 관리 화면, HTTP / HTTPS 웹사이트 프록시 및 SOCKS5 접속을 제공합니다.
| 항목 | 기본값 또는 지원 범위 |
| --- | --- |
| Web 관리 화면 | TCP `8787`, 전용 경로, 사용자 이름, 비밀번호 |
| 로컬 프록시 | `127.0.0.1:7928`, HTTP, HTTPS `CONNECT`, SOCKS5 |
| 소스 배포 | x64, x86, ARM64, ARM32 Linux |
| Docker | `linux/amd64`, `linux/386`, `linux/arm64`, `linux/arm/v7` |
> [!IMPORTANT]
> **네트워크 가용성:** 일부 지역, 데이터 센터 및 네트워크 제공업체는 DNS, VPNGate API, GitHub 미러 또는 VPN 프로토콜을 제한할 수 있습니다. 미러와 로컬 스냅샷은 노드 목록 가용성을 높이지만 모든 환경의 연결 성공을 보장하지 않습니다. 배포 전에 현지 법률과 VPS 제공업체가 VPN/TUN을 허용하는지 확인하십시오.
<a id="quick-install"></a>
## 빠른 설치
지원되는 Linux VPS에서 `root`로 실행합니다.
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
설치 후 Web 관리 화면의 전체 URL, 전용 경로, 사용자 이름과 비밀번호가 표시됩니다. `ml` 명령으로 관리 메뉴를 열 수 있습니다.
> [!TIP]
> VPS 제어판에서 TUN/TAP을 활성화하고 `/dev/net/tun`이 존재하는지 확인하십시오. Web 기본 포트는 TCP `8787`이며 가능하면 자신의 IP만 허용하십시오.
<a id="vps"></a>
## 추천 서비스
| 서비스 | 유형 | 추천 이유 | 링크 |
| --- | --- | --- | --- |
| **BandwagonHost** | VPS | China Telecom CN2 GIA, China Unicom 9929, China Mobile CMIN2 최적화 회선. 지연 시간이 짧고 안정적이며 TikTok 라이브, 해외 전자상거래 및 장기 글로벌 서비스에 적합합니다. | [자세히 보기](https://bandwagonhost.com/aff.php?aff=81790) |
| **RackNerd** | VPS | 월 4000GB의 넉넉한 트래픽과 뛰어난 비용 효율. 장기 실행 서비스의 배포 비용을 낮추기 좋습니다. | [자세히 보기](https://my.racknerd.com/aff.php?aff=18708) |
| **OpenMili** | AI 중계 | GPT 모델을 바꾸지 않고 제공하는 AI 중계 서비스. 저렴한 1:2 요율과 이미지 생성 기능을 지원합니다. | [OpenMili 방문](https://openmili.com/) |
일부 링크는 제휴 링크이며, 이를 통해 구매해도 가격은 올라가지 않습니다.
구매 전에 선택한 요금제가 TUN/TAP, OpenVPN 및 필요한 네트워크 프로토콜을 허용하는지 확인하십시오.
<a id="installation"></a>
## 설치
### 요구 사항
- Ubuntu, Debian, Alpine, CentOS, RHEL, Rocky Linux, AlmaLinux, Fedora, Oracle Linux 또는 Amazon Linux.
- `root`, OpenVPN, iptables, 정책 라우팅 및 TUN/TAP.
- Windows와 macOS는 프록시 클라이언트로 사용할 수 있지만 전체 게이트웨이를 실행할 수 없습니다.
### 방법 1: 소스 설치 프로그램
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
`/opt/aimilivpn`에 배포하고 시스템 서비스를 등록합니다.
```bash
ml # 관리 메뉴
ml status # 상태, Web URL, 사용자 이름
ml logs # 로그 보기
ml restart # 서비스 재시작
ml password # Web 인증 정보 재설정
ml update # main 안정 브랜치에서 업데이트
ml uninstall # 제거
```
먼저 설치 프로그램을 검토하려면:
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
sudo bash install.sh
```
공통 Linux 소스 아카이브와 SHA-256 체크섬은 [GitHub Releases](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)에서 받을 수 있습니다. 변경 사항은 Release Notes에 기록됩니다.
### 방법 2: Docker Compose
Docker 호스트는 `/dev/net/tun`, host 네트워크, `NET_ADMIN`, `NET_RAW`를 제공해야 합니다.
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose pull
docker compose up -d
docker logs -f aimilivpn
```
이미지: `ghcr.io/baoweise-bot/aimili-vpngate:2.1`
업데이트:
```bash
docker compose pull
docker compose up -d
```
<details>
<summary><strong>docker run 명령 보기</strong></summary>
```bash
docker run -d \
--name aimilivpn \
--restart unless-stopped \
--network host \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--device /dev/net/tun:/dev/net/tun \
-e UI_HOST=0.0.0.0 \
-e UI_PORT=8787 \
-e LOCAL_PROXY_HOST=127.0.0.1 \
-e LOCAL_PROXY_PORT=7928 \
-v aimilivpn-data:/data \
ghcr.io/baoweise-bot/aimili-vpngate:2.1
```
</details>
<details>
<summary><strong>GHCR을 사용할 수 없을 때 로컬 빌드</strong></summary>
```bash
git clone --branch main --single-branch https://github.com/baoweise-bot/aimili-vpngate.git
cd aimili-vpngate
docker compose build
docker compose up -d
```
</details>
<a id="connection"></a>
## 연결 및 사용
### 1. Web 관리 화면 로그인
소스 설치에서는 설치 프로그램이 출력한 URL을 엽니다.
```text
http://VPS_IP:8787/private_path/
```
URL은 `ml status`, 인증 정보 재설정은 `ml password`를 사용합니다. Docker 사용자는 다음 명령으로 초기 설정을 확인할 수 있습니다.
```bash
docker exec aimilivpn cat /data/ui_auth.json
```
`secret_path`, `username`, `password`로 로그인하고 첫 로그인 후 변경하십시오.
### 2. 노드 가져오기 및 연결
1. 로그인 후 첫 노드 목록을 기다리거나 “노드 업데이트”를 실행합니다.
2. 국가별로 필터링하고 “테스트”로 VPS에서의 연결 가능 여부와 지연 시간을 확인합니다.
3. 대상 노드의 “전환”을 선택합니다. 사전 확인에 실패하면 가능한 경우 현재 연결을 유지합니다.
4. 스마트 자동, 국가 고정 또는 IP 고정 라우팅을 선택합니다.
5. 상태 영역에서 VPN 연결과 출구 IP를 확인합니다.
### 3. VPS에서 프록시 사용
HTTP, HTTPS 웹사이트 프록시와 SOCKS5는 `127.0.0.1:7928`을 공유합니다.
```bash
# HTTP / HTTPS
curl -x http://127.0.0.1:7928 https://api.ipify.org
# SOCKS5 및 원격 DNS
curl --proxy socks5h://127.0.0.1:7928 https://api.ipify.org
```
### 4. 다른 컴퓨터에서 연결
프록시를 공개하지 말고 SSH 터널을 사용하십시오.
```bash
ssh -N \
-L 8787:127.0.0.1:8787 \
-L 7928:127.0.0.1:7928 \
root@VPS_IP
```
- Web: `http://127.0.0.1:8787/private_path/`
- HTTP / HTTPS: `127.0.0.1:7928`
- SOCKS5: `127.0.0.1:7928`, 가능하면 원격 DNS 또는 `socks5h` 사용
> [!WARNING]
> `7928`은 기본적으로 공개 사용자 인증을 제공하지 않습니다. 방화벽, 접속 원본 IP 제한 또는 기타 신뢰할 수 있는 접근 제어 없이 공개하지 마십시오.
<a id="community"></a>
## 웹사이트, 커뮤니티 및 동영상
| 대상 | 용도 | 링크 |
| --- | --- | --- |
| 웹사이트 / 포럼 | 공지와 토론 | [339936.xyz](https://339936.xyz) |
| Telegram 그룹 | 실시간 커뮤니티 | [t.me/arestemple](https://t.me/arestemple) |
| YouTube 튜토리얼 | 설치 및 사용 동영상 | [보기](https://www.youtube.com/watch?v=s-ATfXR8BpI) |
| GitHub Issues | 재현 가능한 버그와 기능 요청 | [Issue 만들기](https://github.com/baoweise-bot/aimili-vpngate/issues) |
| 이메일 | 버그 신고 및 연락 | [yaohunse7@gmail.com](mailto:yaohunse7@gmail.com) |
<a id="legal"></a>
## 사용 범위 및 법적 고지
> [!CAUTION]
> 이 프로젝트를 다운로드, 배포 또는 사용하기 전에 사용 지역과 VPS 소재지의 법률, 제공업체 약관 및 VPNGate 규칙을 준수하는지 직접 확인해야 합니다. 이 내용은 프로젝트의 사용 범위를 설명할 뿐 법률 자문이 아니며 법적 책임의 면제를 보장하지 않습니다.
1. **허용 목적:** 합법적인 네트워크 연구, 교육, 개발 테스트, 개인정보 보호 및 승인된 접속에만 사용하십시오. 법에 따라 시행되는 통제 회피, 무단 접속, 공격, 스캔, 스팸, 사기, 권리 침해 또는 기타 불법 활동에 사용해서는 안 됩니다.
2. **네트워크 및 지역 제한:** 일부 지역과 데이터 센터는 VPNGate, GitHub 미러 또는 원격 VPN 노드를 제한할 수 있습니다. 이 프로젝트는 어떤 지역이나 호스트에서도 지속적인 가용성을 보장하지 않습니다. 현지 법률과 제공업체 약관이 허용하는 환경에서만 사용하십시오.
3. **제3자 노드:** VPNGate 노드는 제3자 자원봉사자가 운영합니다. 이 프로젝트는 해당 노드를 소유, 통제 또는 감사하지 않으며 가용성, 속도, 보안, 개인정보 처리 또는 로그 기록을 보장하지 않습니다. 신뢰할 수 없는 노드로 민감한 정보를 전송하지 마십시오.
4. **사용자 책임:** 노드 선택, 트래픽, 배포 위치, 공개 포트 및 계정 보안은 사용자의 책임입니다. 불법 사용, 잘못된 설정, 제3자 노드, 서비스 중단, 데이터 유출 또는 계정 남용에 대한 법적 책임은 사용자에게 있습니다.
5. **무보증:** 소프트웨어는 “있는 그대로” 제공됩니다. 적용 법률이 허용하는 최대 범위에서 유지관리자는 가용성, 상품성, 특정 목적 적합성 또는 간접 손해를 보증하지 않습니다. 법적으로 제외할 수 없는 책임에는 영향을 주지 않습니다.
6. **불확실한 경우 중단:** 현지 법률이나 제공업체 정책이 이를 허용하는지 확인할 수 없다면 배포 및 사용을 중단하고 해당 관할권의 자격 있는 법률 전문가와 상담하십시오.
<div align="center">
[Stable Release](https://github.com/baoweise-bot/aimili-vpngate/releases/latest) · [Issues](https://github.com/baoweise-bot/aimili-vpngate/issues) · [GPL-3.0 License](../LICENSE)
</div>
+445
View File
@@ -0,0 +1,445 @@
# AimiliVPN V2.1.2 VPS 全功能验收报告
测试日期:2026-08-27Asia/Shanghai
测试版本:`2.1.2`
Git 提交:`f190d74`
测试性质:公开脚本安装、卸载、节点源故障、路由策略、节点失效、IPv6 边界和 Web API 真实验收
> 本报告不包含 VPS SSH 密码、Web 登录凭据、安全路径或完整公网地址。
## 1. 执行摘要
AimiliVPN V2.1.2 的主要工作流在 Ubuntu 22.04 x86_64 VPS 上可以正常运行:公开脚本能够安装,VPNGate 官方源、GitHub Pages 镜像、本地最近快照和程序内置快照均能产生合法候选节点;固定 IP、固定国家、住宅类型、收藏、手动断开和自动切换等核心逻辑可用。
本轮共执行五类节点失效/恢复压力场景。固定 IP 模式可以恢复原节点,固定国家与住宅策略可以保持约束并切换,真实阻断节点远端 IP/端口后可以恢复,自动全部模式可以跨 IP 类型切换,官方与镜像同时失效时也可以依赖本地快照继续恢复连接。
最终重新执行 README 公开一键安装命令后,服务处于 `active`,官方 HTTPS 获取 99 个节点,Web API、活动连接、HTTP/SOCKS5 代理出口、日志和更新检查均正常。
测试同时确认了若干需要修复的问题:卸载残留、凭据文件权限、Web/代理反向端口冲突、固定国家 API 参数契约、连接状态过早显示、IP 类型置信度、GitHub HTTP 伪回退以及 GitHub 定时任务延迟。
## 2. 测试环境
| 项目 | 实际环境 |
| --- | --- |
| 操作系统 | Ubuntu 22.04.1 LTS |
| 内核 | Linux 5.15.0-46-generic |
| 架构 | x86_64 |
| Python | 3.10 |
| OpenVPN | 2.5.11 |
| TUN/TAP | `/dev/net/tun` 可用 |
| 默认 Web | TCP 8787 |
| 默认代理 | `127.0.0.1:7928` |
| 公网 IPv6 | 不可用,无 IPv6 默认路由 |
| 初始 IPv6 内核状态 | `all/default/lo.disable_ipv6=1` |
IPv6 测试只证明回环访问、双栈监听和通过 IPv4 VPN 出口转发可用,不能证明公网 IPv6 入站或公网 IPv6 VPN 出口可用。
## 3. 自动化与静态回归
- `python -m unittest discover -s tests -v`40/40 通过。
- 覆盖 IP 类型缓存迁移、代理/住宅分离、来源回退、强制刷新保护、路由切换、并发代理、会话清理、前端防卡死保护和正式版更新通道。
- 本地工作区在测试开始前为干净状态,提交为 `f190d74`
注意:现有 IP 类型单元测试验证了当前规则的一致性,但没有证明“未识别为机房”等价于“真实住宅”。
## 4. 安装、停止、重启与日志
### 4.1 第一次公开脚本安装
严格执行 README 命令:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
结果:
- 安装目录、`ml` 命令和 systemd unit 创建成功。
- 首次获取 100 个节点,来源为 `official_https`
- 首次活动连接成功。
- HTTP/SOCKS5 代理、Web 登录、六个后台服务均正常。
- `/api/nodes``/api/gateway_status``/api/logs``/api/check_update``/api/test_proxy` 均正常。
- `ml stop/start/restart/logs` 均可执行。
- `ml logs` 在测试工具 `timeout` 下返回 124 是外层超时结束实时日志,不是软件异常。
### 4.2 最终公开脚本重装
在完成卸载残留审计并只清理 AimiliVPN 自身残留后,再次执行相同公开命令。
结果:
- 安装提交:`f190d74`
- 版本:`2.1.2`
- systemd`enabled``active`
- 正式监听:`127.0.0.1:7928` 与 Web 8787。
- 节点来源:`official_https`
- 节点:99 个。
- 活动节点:日本住宅节点。
- 代理出口:成功。
- 网关、日志、更新检查:HTTP 200。
- 最新版检查:`update_available=false`
公开脚本首次安装会询问“是否自定义 Web 参数”。当命令用于无人值守自动化且 stdin 未预先提供输入时,会停在该提示等待。
## 5. 节点源与回退
### 5.1 VPS 直连下载
| 来源 | HTTP 结果 | 数据量 | 节点/国家 | 延迟或行为 |
| --- | --- | ---: | ---: | --- |
| VPNGate 官方 HTTPS | 200 | 约 1.33 MB | 99 / 13 | 首次 9.24 秒,复测 1.98 秒 |
| VPNGate 官方 HTTP | 200 | 约 1.33 MB | 99 / 13 | 1.62 秒 |
| GitHub Pages HTTPS | 200 | 约 1.32 MB | 98 / 6 | 约 0.13 秒 |
| GitHub Pages HTTP | 200 | 约 1.32 MB | 98 / 6 | 实际重定向到 HTTPS |
| 程序内置快照 | 本地 | 约 1.33 MB | 99 / 10 | 校验通过 |
五份 CSV 均通过大小、字段、Base64 和 OpenVPN 配置安全校验。
### 5.2 程序自身隔离测试
在临时 `VPNGATE_DATA_DIR` 中分别只启用一个来源并执行 `fetch_candidates()`
| 场景 | `last_fetch_source` | 结果 |
| --- | --- | --- |
| 仅官方 HTTPS | `official_https` 或在超过 6 秒时转离线快照 | 正常 |
| 仅官方 HTTP | `official_http` | 99 个节点 |
| 仅 GitHub HTTPS | `github_pages_https` | 98 个节点 |
| 仅 GitHub HTTP | `github_pages_http` | 98 个节点,但实际经过 HTTPS |
| 仅本地最近快照 | `local_cache` | 99 个节点 |
| 仅程序内置快照 | `bundled_initial` | 99 个节点 |
### 5.3 官方源被屏蔽
只阻断 `www.vpngate.net` 的 HTTP/HTTPSGitHub 保持可达,再次运行公开安装:
- 自动使用 `github_pages_https`
- 获取 98 个节点。
- 首次连接、代理出口和 Web 均正常。
- 日志明确记录官方 HTTPS/HTTP 失败和 GitHub 镜像成功。
### 5.4 官方与 GitHub 同时被屏蔽
阻断 VPNGate 官方和 GitHub Pages 当前解析到的全部 IPv4 后:
- Web 强制刷新在 11.45 秒内返回。
- `last_fetch_source=local_cache`
- 刷新前健康活动连接保持不变。
- 节点失效后可以使用缓存候选继续切换。
- 第一个缓存候选建立 TUN 后出口测试失败,程序将其淘汰并继续第二个候选。
- 第二个候选最终恢复代理出口。
- 所有测试防火墙规则均已删除。
### 5.5 05:12 节点来源定向复测
在同一台 VPS 上重新独立下载并执行 CSV 字段、Base64 和 OpenVPN 配置校验,结果如下:
| 来源 | 结果 | 原始节点/国家 | 耗时 | 说明 |
| --- | --- | ---: | ---: | --- |
| VPNGate 官方 HTTPS | 通过 | 98 / 11 | 5.05 秒 | 当前仍在 6 秒来源总时限以内 |
| VPNGate 官方 HTTP | 通过 | 98 / 12 | 3.43 秒 | 本次比官方 HTTPS 快 |
| GitHub Pages HTTPS | 通过 | 98 / 6 | 0.07 秒 | 可用,但国家范围比官方实时数据少 |
| GitHub Pages HTTP | 通过 | 98 / 6 | 0.19 秒 | 最终 URL 为 HTTPS,仍不是真正的 HTTP 兼容源 |
| 本地最近快照 | 通过 | 99 / 11 | 本地读取 | 应用黑名单后得到 98 个候选 |
| 程序内置快照 | 通过 | 99 / 10 | 本地读取 | 无网络时可直接产生 99 个候选 |
随后在临时数据目录内执行真实 `fetch_candidates()`,没有修改正式服务的数据:
- 官方 HTTPS/HTTP 均不可达时,自动使用 `github_pages_https`,得到 98 个候选。
- 官方和 GitHub 四个网络源均不可达时,自动使用 `local_cache`,得到 98 个未被当前黑名单排除的候选。
- 四个网络源均不可达且没有本地最近快照时,自动使用 `bundled_initial`,得到 99 个候选。
GitHub Pages 远端元数据生成于 `2026-08-26T19:16:58Z`。复测时为 `2026-08-26T21:12:39Z`,镜像已落后约 1 小时 56 分钟。Actions 最近一次成功运行是对应提交触发的 push;虽然工作流配置为每 15 分钟一次,但 GitHub 的定时任务没有按该频率准点执行。因此镜像可以作为可用性备用,不应被当成严格实时副本。
## 6. IP 类型检测
在一次完整 98 节点样本中,`ip-api.com` 批量请求成功 98/98
| 分类 | 数量 | 主要依据 |
| --- | ---: | --- |
| 住宅 | 81 | 未命中 mobile、hosting 或机房关键词 |
| 移动 | 2 | `mobile=true` |
| 机房 | 15 | 14 个 SoftEther AS365991 个 M247 AS9009 |
实时重新查询与 VPS 缓存的分类差异为 0。
独立 `ipapi.is` 抽样交叉验证:
- SoftEther`is_datacenter=true`
- M247`is_datacenter=true`
- Sony、Korea Telecom、KDDI、SoftBank`is_datacenter=false`
本轮没有在当前 15 个机房节点中复现普通住宅被误标为机房。但当前规则把所有“无法证明是机房或移动”的网络直接标为住宅,置信度过高。企业网、教育网、骨干网或未收录机房也可能被标成住宅。
另一个边界是:当前前端没有“商业 IP”枚举,只有住宅、机房和移动。运营商列出现公司法定名称不等于商业线路。
### 6.1 05:12 IP 类型定向复测
当前正式 VPS 的 99 个节点分类为:住宅 81、机房 14、移动 4。14 个机房节点全部属于同一组 `219.100.37.0/24` 地址,ASN 为 `AS36599`,运营主体字段包含 `SoftEther Corporation`;没有发现 Sony、Korea Telecom、KDDI、SoftBank 等普通接入网节点被当前版本标成机房。
这 14 个 SoftEther 节点暴露出一个真实的判断冲突:
- 主情报源 `ip-api.com` 对它们返回 `proxy=true``hosting=false`
- 当前程序看到 `proxy=true` 且运营商字段包含 `SoftEther` 后,会用本地关键词规则覆盖成 `hosting`
- 独立抽样查询 `ipapi.is` 时,`219.100.37.98` 返回 `is_datacenter=true``is_vpn=true`,支持机房判断。
因此,现有证据不能证明这 14 个节点应当改成住宅,但可以确认当前界面给出的“机房”没有表达数据源冲突和置信度。若用户所说的“住宅被标记为商业”指的是这组 SoftEther 节点,根因就是 `classify_ip_type()``proxy_provider_datacenter` 关键词覆盖规则;如果指的是其他 IP,则需要提供具体地址才能逐条验证。
当前代码和前端均没有“商业 IP”这一枚举,只显示住宅、机房、移动;运营商名称是公司名称也不代表该地址属于商业专线。下一步不建议为了增加住宅数量直接放宽为住宅,建议先增加 `unknown` / `likely_residential`、置信度和第二情报源,仅在两个来源一致时用于严格的住宅路由筛选。
## 7. 路由与功能验证
以下功能均通过:
- 固定日本 + 住宅 IP,活动节点符合规则。
- 跨国家节点被拒绝。
- 同国家机房节点被住宅规则拒绝。
- 空固定国家、非法路由模式返回 400。
- 收藏添加、仅收藏模式、未收藏节点拒绝。
- 固定 IP 持久化,服务重启后节点 ID 和类型保持。
- 收藏移除。
- 国家发现范围 JP:61 个且全部 JP。
- 清空国家范围:恢复 98 个、6 个国家。
- 单节点测试。
- 双节点批量测试。
- 超过 5 个节点的批量测试返回 400。
- 手动断开、重新连接和代理检测。
- 凭据不修改密码时保存。
- 非法安全路径后缀被拒绝。
- 注销后受保护接口返回 401。
- `/api/check` 强制刷新不会破坏健康连接。
固定国家的真实网页下拉框提交中文国家名,例如“日本”。后端接口同时接受 `JP` 字符串,却在后续只按中文国家名比较,因此直接调用 API 传 `JP` 会把日本活动节点误判为跨国节点并断开。
## 8. 五轮节点失效压力测试
### 轮次 1:固定 IP 进程被杀
- 模式:固定 IP。
- 注入:杀掉固定节点 OpenVPN 进程。
- 结果:守护线程恢复同一个固定节点。
- 代理:正常。
### 轮次 2:固定日本 + 住宅
- 初始候选:4 个符合条件的可用节点。
- 注入:杀掉活动 OpenVPN 进程。
- 结果:切换到另一个日本住宅节点。
- 代理:正常。
- `lookup 100` 规则:1 条。
- table 100 默认路由:1 条。
### 轮次 3:真实阻断活动节点远端
- 模式:自动 + 住宅。
- 注入:只阻断当前节点的目标 IP、协议和端口。
- 结果:切换到另一个住宅节点。
- 代理:正常。
- 临时防火墙规则:测试结束后 0 条残留。
### 轮次 4:自动全部
- 模式:自动 + 全部类型。
- 注入:杀掉活动 OpenVPN 进程。
- 恢复耗时:12.17 秒。
- 结果:允许从住宅切换到机房节点,符合全部类型策略。
- 代理:正常。
- 路由规则未叠加。
### 轮次 5:双网络源不可用 + 节点失效
- 官方源和 GitHub Pages 同时被阻断。
- 强制刷新使用 `local_cache`,健康连接保持。
- 杀掉活动节点后开始缓存候选恢复。
- 第一个候选的 OpenVPN/TUN 一度建立,但真实代理出口失败。
- 自动逻辑继续尝试第二个候选并恢复。
- 最终代理、OpenVPN、策略规则和 table 100 均正常。
该轮同时发现状态语义问题:`/api/nodes` 可能在候选仍处于握手或出口验证阶段时短暂返回 `active=true`,前端可能提前显示“活动/已连接”。最终连接逻辑会继续验证并淘汰坏节点,但 UI 状态不够准确。
## 9. IPv6 验证
原始环境:
- `all/default/lo.disable_ipv6=1`
- 无 IPv6 地址。
- 无 IPv6 默认路由。
- Web IPv4 返回 200`::1` 连接失败。
临时将三项开关设置为 0 并重启后:
- `lo` 获得 `::1/128`
- Web IPv4 返回 200。
- Web `http://[::1]:8787` 返回 200。
代理模块使用独立临时端口 7931 绑定 `::`
- IPv4 客户端连接成功。
- IPv6 回环客户端连接成功。
- IPv4 客户端入口经 VPN 访问 `api.ipify.org` 返回 HTTP 200。
- IPv6 客户端入口经 VPN 访问 `api.ipify.org` 返回 HTTP 200。
测试结束后:
- 临时 7931 进程已终止。
- 三项 `disable_ipv6` 恢复为 1。
- 正式服务重启。
- 代理恢复为 `127.0.0.1:7928`,出口正常。
安装完成提示无条件展示 `http://[::1]:7928/`,但默认代理实际绑定 `127.0.0.1`,在本机型上 IPv6 也被禁用。这条安装文案与默认运行状态不一致。
## 10. 端口、更新与 Web 状态
### 10.1 代理端口变更
- 7928 改为 7930:接口返回需要重启,7930 监听成功。
- 等待 VPN 和真实出口恢复约 25.74 秒后,代理测试成功。
- 7930 改回 7928:监听和代理恢复。
仅等待端口监听不足以判断代理可用;服务重启后还需要等待 VPN 隧道完成。
### 10.2 双向端口冲突
- 代理端口设置为 Web 端口:HTTP 400,正确拒绝。
- Web 端口设置为代理端口:HTTP 200,错误接受并重启。
- 下次加载配置时,程序静默把代理端口从 7928 改为 7929 自愈。
服务可以避免永久绑定冲突,但用户提交的配置被静默改变。后端应在 `/api/update_credentials` 中直接拒绝相同端口。
### 10.3 更新流程
执行 `ml update`
- 正确识别当前已是最新版。
- 提示是否强制重新拉取。
- 输入 `N` 后正确显示“已取消更新”。
- 服务和代理保持正常。
## 11. 卸载验证
两次真实 `ml uninstall` 均得到相同结果。
成功删除:
- `/opt/aimilivpn`
- `/usr/bin/ml`
- systemd unit 文件和 enable 链接。
- OpenVPN 进程。
- `tun0`
- table 100 中的路由。
稳定残留:
1. `/etc/sysctl.d/99-aimilivpn.conf`
2. `oif tun0 [detached] lookup 100` 策略规则。
因此当前“完全卸载”并不完整。最终重装前只删除了上述两个 AimiliVPN 自身创建的残留。
## 12. 浏览器与前端
真实浏览器已经使用本次全新安装生成的凭据完成登录页和登录后管理面板检查。凭据仅在测试会话内使用,完成输入后已从浏览器运行变量清除,报告中不记录明文。
- 页面正常加载。
- 登录页账号、密码和登录按钮均可见,认证成功后进入节点管理面板。
- 98 个节点完整显示,分页为 2 页;实测延迟、官方预估延迟提示、国旗、ISP、IP 类型和操作列均正常渲染。
- 国家多选面板显示国旗、国家名和节点数量;选择日本后显示 42 个节点,日本加住宅类型后显示 28 个,且全部为住宅或移动;清空后恢复全部节点。
- 代理设置弹窗的自动、固定 IP、固定地区,以及所有 IP、住宅、机房控件均可正常联动;未保存的试选项可以取消,不会改变后端配置。
- 网关设置显示 Web、代理、OpenVPN、节点同步、出口检测、延迟测速 6 项服务均在运行;手动代理检测返回真实出口 IP。
- 日志查看器能加载最近 300 行,并正确筛选 Proxy 56 行、VPN 最多 300 行、系统 13 行。
- 收藏、收藏面板和取消收藏均正常,取消后后端收藏数量恢复为 0。
- 单节点“检测”能立即进入“检测中”,约 12 秒后更新为“可用”并写入实测延迟。
- 网页切换到 SoftEther 机房节点成功;原住宅节点随后已真实失效,重新检测仍不可用,因此又检测并切换到另一个日本住宅节点,最终代理出口恢复正常。
- 网页“更新节点”正确启动后台刷新,获取 97 个实时节点,健康活动连接没有被断开。
- “检测更新”返回“当前 V2.1.2 正式版已是最新正式版”,页面只展示 main 和正式发行版入口。
- 412 x 893 有效移动视口下,文档宽度与视口宽度均为 412,节点表容器宽 348,没有页面级横向溢出。
- 重新加载后的控制台 0 条 error、0 条 warning;只有一条 verbose 级密码输入框 `autocomplete` 建议。
真实交互额外发现两项问题:
1. 刷新节点并完成优先节点检测后,两个被标成不可用的 SoftEther 节点丢失了已存在的位置和 IP 类型,表格临时显示 `-`。原因是检测结果对象中的空元数据在 `test_multiple_nodes()` 最终合并时覆盖了节点表中的非空值。持续轮询正式节点文件后确认,后台全量富化在检测后约 6.3 分钟将两个节点的元数据补回;因此数据最终可以恢复,但用户会经历数分钟的错误空白和筛选遗漏。
2. `217.138.212.62` 显示罗马尼亚国旗,但物理位置文字为日本东京。国旗来自 VPNGate `country_short=RO`,位置文字来自 IP 情报源,前端没有提示两者来源不同,容易被理解为显示错误。
## 13. 缺陷清单
| 优先级 | 缺陷 | 影响 |
| --- | --- | --- |
| P1 | 卸载留下 sysctl 文件和 detached 策略规则 | “完全卸载”不完整,可能影响后续网络配置 |
| P1 | `ui_auth.json` 全新安装权限为 0644 | 本机普通用户可读取 Web 登录凭据 |
| P1 | OpenVPN 日志警告未启用服务器证书验证方法 | 需要评估并增强对服务端证书身份的验证 |
| P1 | 未识别网络直接标为住宅 | 企业、教育、骨干或未收录机房可能成为假住宅 |
| P2 | Web 端口可设置为代理端口 | 配置被静默改写到 7929,用户体验不确定 |
| P2 | 固定国家 API 接受 ISO 代码但只匹配中文名 | API 客户端传 `JP` 会误断开合法日本节点 |
| P2 | `active=true` 可能早于真实代理出口就绪 | 前端可能短暂显示假已连接状态 |
| P2 | GitHub Pages HTTP 强制跳转 HTTPS | 不能作为旧 TLS 机器的真实 HTTP 兼容镜像 |
| P2 | GitHub Actions 定时同步可能延迟超过一小时 | 镜像节点和国家范围可能明显落后官方源 |
| P2 | 官方 HTTPS 超过 6 秒后跳过同域官方 HTTP | 官方 HTTP 明明较快时仍直接转 GitHub/缓存 |
| P2 | IP 类型只依赖一个外部情报源 | 上游误报、限流或故障会直接影响分类 |
| P2 | 节点检测结果用空元数据覆盖已有位置/IP 类型 | 不可用节点会显示 `-`,住宅/机房筛选也会暂时遗漏这些节点;实测约 6.3 分钟后由后台富化恢复 |
| P3 | 国旗和物理位置使用不同国家来源且不标注 | VPNGate 申报国家与 IP 地理库冲突时出现“罗马尼亚国旗 + 日本位置” |
| P3 | 安装完成页无条件显示 IPv6 代理地址 | 默认只监听 127.0.0.1 时文案不准确 |
| P3 | 首次一键安装需要交互输入 | 无人值守安装可能停在自定义配置提示 |
## 14. 修复建议顺序
1. 卸载时删除 `/etc/sysctl.d/99-aimilivpn.conf`,并无条件循环清除所有 table 100 相关规则和路由。
2. 创建和更新 `ui_auth.json` 后强制 `chmod 600`,同时处理已有安装升级迁移。
3. `/api/update_credentials` 增加 Web/代理端口冲突校验。
4. 统一固定国家存储格式,建议持久化 ISO 3166-1 alpha-2,显示时再翻译中文。
5. 将连接状态拆成 `connecting``tunnel_ready``proxy_ready`;只有真实代理出口通过后才显示已连接。
6. 合并节点检测结果时只更新探测状态、延迟和消息,不允许空的 ISP/IP 类型字段覆盖已有元数据。
7. IP 类型增加 `confidence``unknown/likely_residential`,仅对冲突或高风险节点调用第二情报源复核。
8. 国旗与物理位置统一数据源,或分别标为“节点申报国家”和“IP 推测位置”。
9. 官方 HTTPS 超时后让官方 HTTP 与 GitHub HTTPS 竞速,HTTP 结果继续禁止覆盖可信 HTTPS 缓存。
10. 镜像元数据加入生成时间,前端显示陈旧程度;不要把 GitHub Pages HTTP 描述为真实 HTTP 镜像。
11. 评估 VPNGate 配置兼容性后,增加 `remote-cert-tls server`、证书用途或可验证指纹策略。
12. 安装器增加明确的非交互模式环境变量或命令参数。
## 15. 最终交付状态
报告结束时 VPS 状态:
- 公开 README 一键脚本重新安装完成。
- 版本 `2.1.2`,提交 `f190d74`
- `aimilivpn.service`enabled、active。
- 节点来源:`official_https`
- 最后一次网页刷新节点数量:97。
- 两个被节点检测清空元数据的节点已由后台富化自动恢复,最终缺失数为 0。
- 活动连接:日本住宅节点。
- 代理出口:正常。
- Web 登录、节点表、筛选、收藏、检测、切换、网关、日志、节点刷新和更新检查:正常。
- Web 和代理配置恢复为自动、全部 IP、全部国家、0 个收藏。
- 策略规则:1 条。
- table 100 默认路由:1 条。
- `AIMILI_TEST_*` 防火墙规则:0 条。
- IPv6 内核开关:恢复原始 1/1/1。
- 临时安装日志、临时代理和隔离测试目录:已清理。
## 16. 未覆盖边界
- VPS 没有公网 IPv6,未验证公网 IPv6 入站和 IPv6 VPN 出口。
- 本轮实体机是 Ubuntu x86_64,其他发行版和 ARM/386 仅由 CI 构建与单元测试覆盖,不等于实体机运行验证。
- 没有进行数天级持续运行和 GitHub Actions 长期准点率统计。
- 当前环境没有可连接的 Edge 浏览器实例,无法完成真实 Edge 内核复测;Chromium 实际交互、移动视口和跨浏览器防卡死单元测试已通过。要做 Edge 实机复核,需要在 Codex 的“设置 -> Computer use”中连接 Edge 扩展。
## 17. 缺陷修复与复验
同日根据本报告缺陷清单完成修复并再次在原 VPS 上部署验收。
已修复:
- 卸载会删除 sysctl 文件、table 100 路由及全部 table 100 策略规则。真实卸载后安装目录、`ml`、systemd unit 和 sysctl 文件均不存在,策略规则只剩系统默认三条,table 100 为空。
- `ui_auth.json` 创建、迁移和每次原子写入均使用 `0600`VPS 新安装实测权限为 `600`
- OpenVPN 命令启用 `remote-cert-tls server`。两个真实 VPNGate 节点均通过 `VERIFY KU OK``VERIFY EKU OK`,不再出现“未启用服务器证书验证方法”警告。
- IP 类型缓存升级到新分类版本:明确 mobile/hosting 为高置信度;有运营商信息的普通接入网为中等置信度;`proxy + 数据中心关键词` 冲突项和完全缺少运营商信息的节点由 `ipapi.is` 第二来源复核,复核失败标为 unknown/低置信度,严格住宅路由只接受中、高置信度住宅或移动网络。
- VPS 当前 99 个节点得到住宅 83、机房 15、移动 1;其中 14 个 SoftEther 冲突项经两个来源确认后为高置信度机房,没有依靠关键词单独下结论。
- Web 与代理端口在两个设置接口中均双向拒绝冲突;真实 API 提交返回 HTTP 400。
- 固定国家统一保存 ISO 两字母代码,同时兼容旧中文名称;真实 API 提交 `JP` 后持久化为 `JP`,随后已恢复自动模式。
- 连接状态增加 `tunnel_ready``proxy_ready`。真实节点切换中轮询得到 `is_connecting=true`、活动节点 0;出口验证通过后才显示 1 个活动节点。
- 批量节点检测结果不再携带空 ISP/位置/IP 类型字段,不会覆盖已有富化元数据。
- 物理位置国旗改用 IP 情报源 `geo_country_short`;VPNGate 申报国家仍在节点标题中单独展示,并通过提示说明两类数据来源。
- 官方 HTTPS 超时后仍会继续尝试官方 HTTP。VPS 隔离测试依次验证 `official_http``github_pages_https``local_cache``bundled_initial` 均可返回 99 个节点。
- GitHub 镜像状态增加生成时间和陈旧程度;默认 GitHub HTTP 来源明确标记为“重定向到 HTTPS”,不再描述为独立 HTTP 镜像。
- 安装器支持 `AIMILIVPN_NONINTERACTIVE=1`,真实无人值守安装不会停在首次询问;安装完成提示只展示实际默认监听的 IPv4 本地代理地址。
平台限制:GitHub Actions 的 schedule 不保证准点,GitHub Pages 也由平台强制 HTTP 跳转 HTTPS。这两项无法在客户端代码中消除,只能通过镜像新鲜度提示、官方真实 HTTP、本地缓存和内置快照降低影响。
回归结果:本地 Python 单元测试 51/51 通过,Python 编译检查、Dashboard JavaScript 语法检查、`git diff --check` 和 VPS `bash -n install.sh` 均通过。VPS 未安装 Node.js,远端回归为 50 项通过、1 项前端语法检查按条件跳过;前端语法已由本机 Node.js 独立验证。VPS 最终服务为 active,官方 HTTPS 源正常,代理真实请求返回 HTTP 200。
+168
View File
@@ -0,0 +1,168 @@
# AimiliVPN V2.1.5 正式版验收报告
测试日期:2026-08-27 至 2026-08-28Asia/Shanghai
测试版本:`2.1.5`
Git 提交:`97fc337`
测试环境:Ubuntu 22.04.1 LTS、Linux 5.15、x86_64、Python 3.10、OpenVPN 2.5.11
> 本报告不记录 VPS SSH 密码、Web 登录凭据、安全路径或完整管理地址。
## 1. 结论
V2.1.5 已通过本地自动化、GitHub Actions 多版本/多架构构建,以及 Ubuntu x86_64 VPS 的完整卸载、公开脚本重装和真实网络验收。
本轮原计划复验 V2.1.3,但正式安装检查发现手动断开后 `proxy_ready` 没有同步清零,因此未改写旧标签,而是发布 V2.1.4。随后 V2.1.4 的失败切换压力测试又发现:原节点和 3 个备用节点均失败后,日志提示会后台补齐,实际却直接返回。该遗漏已修复并按不可变发行原则发布为 V2.1.5。
正式 V2.1.5 最终状态:服务 active/enabled,官方 HTTPS 节点源正常,97 个节点覆盖 9 个国家,六项后台服务全部 running,HTTP/SOCKS5 真实出口一致,连接状态、策略路由和断开清理正常。
## 2. 发布产物
- GitHub Actions`33123598394`,结论 `success`
- Python 3.9、3.11、3.13:完整测试通过。
- Docker `linux/amd64``linux/386``linux/arm64``linux/arm/v7`:分别构建并导入验证通过。
- GHCR 多架构索引摘要:`sha256:46b8f73ebb93ef308ef5bd51b649ce5030ab19ea7eff984e7525f9e7cb84044a`
- GitHub Release`v2.1.5`,非草稿、非预发布,并为当前 latest。
- 源码包:`aimilivpn-v2.1.5-linux-source.tar.gz`
- 源码包 SHA-256`e2cbcbb35558c34fed6083047bbb730fc9d43bd3537eb301538a1b01ff7f9363`,下载后与 `sha256sums.txt` 完全一致。
- Actions 使用 `actions/checkout@v7``actions/setup-python@v7` 等当前运行时版本,本次没有 Node.js 20 弃用失败。
## 3. 自动化检查
- `python -m unittest discover -s tests -v`53/53 通过。
- `python -m py_compile`:核心 Python 模块全部通过。
- Dashboard JavaScript:由单元测试提取脚本并执行 Node.js 语法检查,通过。
- `docker compose -f compose.yaml config`:通过。
- `bash -n install.sh`:在 VPS Bash 环境通过。
- `git diff --check`:通过。
- 发行压缩包本地预构建:文件名、版本号和校验文件正确。
## 4. 完整卸载与公开脚本重装
两次执行项目自带 `ml uninstall` 后分别确认:
- `/opt/aimilivpn``ml`、systemd unit 和 `/etc/sysctl.d/99-aimilivpn.conf` 均不存在。
- OpenVPN 进程为 0`tun0` 不存在。
- table 100 路由为 0table 100 策略规则为 0。
- systemd 返回 inactiveunit 文件不存在。
随后严格执行 README 的无人值守 GitHub 安装方式:
```bash
AIMILIVPN_NONINTERACTIVE=1 bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
结果:
- 版本 `2.1.5`,提交 `97fc337`
- 服务 active/enabled,首次连接在安装器等待窗口内完成。
- `ui_auth.json` 权限为 `0600`,所有者为 root。
- 默认 Web 端口 8787、代理端口 7928。
- 更新检查返回当前版本与 latest 均为 `2.1.5``update_available=false`
## 5. 节点源与回退
| 场景 | 实际来源 | 候选数 | 结果 |
| --- | --- | ---: | --- |
| 默认正式服务 | `official_https` | 97 | 通过 |
| 强制官方 HTTPS 失败 | `official_http` | 97 | 通过 |
| 强制两个官方源失败 | `github_pages_https` | 98 | 通过 |
| GitHub Pages HTTP 地址 | 最终跳转 HTTPS | 98 | 通过,但不是真正明文镜像 |
| 四个网络源全部失败 | `local_cache` | 97 | 通过 |
| 网络源失败且无最近缓存 | `bundled_initial` | 99 | 通过 |
四个公开 URL 均从 VPS 返回 HTTP 200 和合法 VPNGate CSV。官方 HTTP/HTTPS 均保留;GitHub Pages HTTP 由平台强制跳转 HTTPS,本地最近有效快照和内置初始快照承担无网络兜底。
国家发现范围实测:选择 `JP` 后得到 58 个节点且全部为日本,配置持久化为 `JP`;清空后恢复 97 个节点、9 个国家。筛选在来源成功判定后执行,没有把“所选国家当前为 0 个节点”误判成来源失败。
## 6. IP 类型识别
正式安装的 97 个节点分类如下:
| 类型 | 数量 | 置信度约束 |
| --- | ---: | --- |
| 住宅 | 81 | 中置信度 |
| 移动 | 1 | 高置信度 |
| 机房 | 15 | 高置信度 |
严格住宅路由可接受节点为 82 个,全部属于住宅/移动且置信度为中或高。主来源冲突节点会使用第二情报源复核,不能复核的项目保持 unknown/低置信度,不会直接放入严格住宅路由。
## 7. 连接、切换与断开
### 7.1 状态门控
真实切换轮询中,连接建立前持续返回:
- `is_connecting=true`
- 活动节点 0
- `tunnel_ready=false`
- `proxy_ready=false`
- `proxy_ok=false`
只有 OpenVPN、策略路由和真实代理出口全部验证通过后,才变为活动节点 1 且三个 readiness 状态为 true。失败节点不会提前显示成已连接。
### 7.2 手动断开
调用 `/api/disconnect` 后实测:
- 活动节点 0`is_connecting=false`
- `tunnel_ready/proxy_ready/proxy_ok` 全部为 false。
- OpenVPN 进程 0`tun0` 不存在。
- table 100 路由和策略规则均为 0。
- `/api/test_proxy` 返回 `ERR_ROUTE_DEV_NOT_FOUND`,没有保留旧出口假状态。
- `connection_enabled=false` 已持久化。
### 7.3 失败恢复与后台补位
V2.1.4 压力测试复现:目标节点失败,原节点恢复失败,随后 3 个备用节点也失败;旧代码只打印“后台重新加载节点”并直接返回,最长可能等到下一轮 21 分钟采集。
V2.1.5 修复后:
- 连续失败分支调用 `schedule_background_refill()`
- 同一时间只允许一个补位线程,退避间隔为 60、120、300 秒。
- 单元测试确认耗尽分支必定调度补位。
- VPS 隔离运行确认补位线程已创建、存活且未被取消。
- 正式安装中,原节点重新拨号失败后系统保持全部 readiness 为 false,后台重新同步节点,最终自动连接到新的日本节点并恢复 HTTP/SOCKS5 出口。
## 8. 代理、路由与服务
- VPS 直连出口与 VPN 代理出口不同。
- HTTP 与 SOCKS5 返回相同 VPN 出口。
- `/api/test_proxy` 返回成功、出口 IP 和实测延迟。
- 成功连接时 table 100 有 1 条默认路由和 1 条策略规则,不重复叠加。
- Web、代理、OpenVPN、节点同步、出口检测、延迟测速共 6 项服务全部 running。
- OpenVPN 日志包含 `VERIFY KU OK``VERIFY EKU OK`,没有旧的服务器证书用途警告。
- 代理端口提交为 Web 端口时返回 HTTP 400;配置仍为 8787/7928,没有静默改写。
## 9. Web 前端
静态、自动化和真实 Chromium 交互检查已通过:
- 延迟列、官方预估延迟提示、国旗、国家多选、测试/收藏/切换按钮存在。
- 国家面板层级、复选框自绘、分页、请求去重、页面隐藏时暂停轮询和日志行数上限均有回归覆盖。
- 登录页和 Dashboard 不再使用 `backdrop-filter` 或固定背景,降低 Edge/Chromium GPU 卡死风险。
- Dashboard 内联 JavaScript 语法通过 Node.js 检查。
- Web API 登录、节点、网关、日志、更新、刷新、断开、连接和代理检测已从 VPS 真实调用通过。
- 使用项目真实 `Handler` 和隔离临时数据启动本地实例,以 97 个模拟节点完成 Chromium 桌面和移动视口登录交互;测试账号仅在本地临时实例中存在,退出后已销毁。
- 节点表实际呈现 7 列,实测延迟与“仅供参考、非本机实测”的官方预估延迟同时正确显示;日本、韩国、美国国旗和中文国家名正常,检测/收藏/切换三个按钮完整。
- 国家多选面板显示国旗、中文国名和节点数量;选择日本后即时缩减为 33 个日本节点,清空后恢复 97 个节点,分页分别显示 50/47 行。
- 层叠上下文实测为 `.toolbar z-index: 50`、国家面板 `z-index: 1000`;面板与表格相交区域的顶层命中元素属于国家面板,未再被表格遮挡。
- Web 更新检测返回“当前 V2.1.5 正式版 已是最新正式版”;网关与日志面板均能打开并渲染后端结果。
- 移动视口下工具栏控件没有越界,国家面板完整位于视口内;经过一次自动轮询周期后页面仍可在约 0.6 秒内完成连续交互,浏览器控制台错误/警告为 0。
安全边界:本轮没有在浏览器中提交正式 VPS 新生成的安全路径、账号或密码,因为该动作需要用户在提交前即时确认。正式 VPS 的同一组 Web API 已通过服务端本机登录会话完成真实调用验收;浏览器交互部分使用上述隔离实例验证,不把未执行的线上凭据提交描述为已完成。
## 10. 当前状态与边界
报告写入时:
- VPS 运行 GitHub 公开脚本安装的 V2.1.5,而不是本地临时目录。
- 服务 active/enabled,活动 VPN、HTTP/SOCKS5 出口正常。
- 节点来源为官方 HTTPS,配置恢复为全部国家、自动路由、全部 IP 类型。
- 临时防火墙规则为 0;没有修改宿主机 Docker 服务或其他项目。
未覆盖边界:
- 实体 VPS 为 Ubuntu x86_64ARM、386 和其他发行版由 CI 构建/导入验证,不等同于对应实体机长期运行。
- VPS 无可用公网 IPv6,未验证公网 IPv6 入站或 IPv6 VPN 出口。
- 未进行数天级持续运行和 GitHub Actions 定时任务准点率统计。
- Edge 实机需要连接 Edge 浏览器扩展;本轮使用可用的 Chromium 浏览器内核进行 Web 交互。
+71 -22
View File
@@ -186,18 +186,27 @@ INSTALL_DIR = "/opt/aimilivpn"
LOG_FILE = "/opt/aimilivpn/vpngate_data/vpngate.log"
def generate_random_password():
import random
import secrets
import string
chars = string.ascii_letters + string.digits
while True:
pwd = "".join(random.choices(chars, k=12))
pwd = "".join(secrets.choice(chars) for _ in range(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 secrets
import string
return "".join(random.choices(string.ascii_letters + string.digits, k=12))
chars = string.ascii_letters + string.digits
return "".join(secrets.choice(chars) for _ in range(12))
def get_app_version():
try:
with open(os.path.join(INSTALL_DIR, "VERSION"), "r", encoding="utf-8") as f:
version = f.read().strip().lstrip("vV")
return version or "2.1.5"
except Exception:
return "2.1.5"
def load_ui_cfg():
import json
@@ -218,8 +227,11 @@ def save_ui_cfg(cfg):
path = "/opt/aimilivpn/vpngate_data/ui_auth.json"
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
if os.path.exists(path):
os.chmod(path, 0o600)
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
os.chmod(path, 0o600)
return True
except Exception:
return False
@@ -406,7 +418,7 @@ def print_status():
openvpn_status = f"{green}[已连接]{reset}" if openvpn_ok else f"{red}[未连接]{reset}"
print_line("=======================================================")
print_line(f" {bold}AimiliVPN 管理终端 v2.0{reset} ")
print_line(f" {bold}AimiliVPN 管理终端 v{get_app_version()}{reset} ")
print_line("=======================================================")
print_line("【核心服务状态】")
print_line(format_line(f"代理网关 (Port {proxy_port})", gateway_status))
@@ -570,10 +582,12 @@ def uninstall_service():
stop_service()
if shutil.which("systemctl"):
subprocess.run(["systemctl", "disable", "aimilivpn.service"])
try:
os.unlink("/lib/systemd/system/aimilivpn.service")
except Exception:
pass
for unit_path in ("/lib/systemd/system/aimilivpn.service", "/etc/systemd/system/aimilivpn.service"):
try:
os.unlink(unit_path)
except FileNotFoundError:
pass
subprocess.run(["systemctl", "daemon-reload"], check=False)
elif shutil.which("rc-service"):
subprocess.run(["rc-update", "del", "aimilivpn"])
try:
@@ -582,9 +596,25 @@ def uninstall_service():
pass
try:
os.unlink("/usr/bin/ml")
except Exception:
except FileNotFoundError:
pass
subprocess.run(["rm", "-rf", INSTALL_DIR])
if shutil.which("ip"):
subprocess.run(["ip", "route", "flush", "table", "100"], check=False)
while subprocess.run(
["ip", "rule", "del", "table", "100"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0:
pass
try:
os.unlink("/etc/sysctl.d/99-aimilivpn.conf")
except FileNotFoundError:
pass
if shutil.which("sysctl"):
subprocess.run(["sysctl", "--system"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
if os.path.realpath(INSTALL_DIR) == "/opt/aimilivpn" and os.path.isdir(INSTALL_DIR):
shutil.rmtree(INSTALL_DIR)
print("AimiliVPN 已卸载!")
sys.exit(0)
else:
@@ -728,7 +758,7 @@ def configure_credentials():
new_uname = input(f"请输入新管理账号 (回车默认 {curr_uname}): ").strip()
if not new_uname:
new_uname = curr_uname
new_pwd = input("请输入新管理密码 (不能为空): ").strip()
new_pwd = input("请输入新管理密码 (不能为空): ")
if not new_pwd:
print("错误: 密码不能为空!")
time.sleep(2)
@@ -955,7 +985,7 @@ mkdir -p "${INSTALL_DIR}/vpngate_data"
is_custom="n"
if [ ! -f "$AUTH_FILE" ]; then
if [ -t 0 ]; then
if [ -t 0 ] && [ "${AIMILIVPN_NONINTERACTIVE:-0}" != "1" ]; then
echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}"
read -p "是否自定义配置?[y/N]: " is_custom
else
@@ -965,22 +995,22 @@ if [ ! -f "$AUTH_FILE" ]; then
# 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)))")
SECRET_PATH=$(python3 -c "import secrets, string; chars = string.ascii_letters + string.digits; print(''.join(secrets.choice(chars) for _ in range(12)))")
# generate random password
UI_PASSWORD=$(python3 -c "
import random, string
import secrets, string
chars = string.ascii_letters + string.digits
while True:
pwd = ''.join(random.choices(chars, k=12))
pwd = ''.join(secrets.choice(chars) for _ in range(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=$(python3 -c "
import random, string
import secrets, string
chars = string.ascii_letters + string.digits
while True:
uname = ''.join(random.choices(chars, k=12))
uname = ''.join(secrets.choice(chars) for _ in range(12))
if uname[0].isalpha() and any(c.islower() for c in uname) and any(c.isupper() for c in uname) and any(c.isdigit() for c in uname):
print(uname)
break
@@ -1041,6 +1071,7 @@ while True:
# when username/password contain quotes, backslashes, or shell metacharacters.
python3 - "$AUTH_FILE" "$UI_PORT" "$SECRET_PATH" "$UI_USERNAME" "$UI_PASSWORD" <<'PY'
import json
import os
import sys
auth_file, ui_port, secret_path, username, password = sys.argv[1:6]
@@ -1052,10 +1083,12 @@ cfg = {
"username": username,
"password": password,
}
with open(auth_file, "w", encoding="utf-8") as f:
fd = os.open(auth_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
PY
fi
chmod 600 "$AUTH_FILE"
# 8. Start service
# 8.5 Optimize network parameters (rp_filter for policy routing)
@@ -1089,6 +1122,22 @@ if [ -d "/proc/sys/net/ipv4/conf" ]; then
fi
echo -e "\n正在启动 AimiliVPN 服务并初始化网络..."
# Avoid treating the previous process' persisted node ID as a successful new
# connection during upgrades. The service will replace this startup state.
if [ -f "${INSTALL_DIR}/vpngate_data/state.json" ]; then
python3 - "${INSTALL_DIR}/vpngate_data/state.json" <<'PY' 2>/dev/null || true
import json
import sys
from pathlib import Path
state_path = Path(sys.argv[1])
state = json.loads(state_path.read_text(encoding="utf-8"))
state["active_openvpn_node_id"] = ""
state["is_connecting"] = True
state["last_check_message"] = "服务正在重启并重新建立加密通道..."
state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
PY
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl restart aimilivpn.service || true
elif command -v rc-service >/dev/null 2>&1; then
@@ -1096,7 +1145,7 @@ elif command -v rc-service >/dev/null 2>&1; then
fi
# Wait and poll for node loading and active connection
echo -e "\n正在等待 AimiliVPN 首次获取节点并建立加密通道 (此过程可能需要 5-30 秒)..."
echo -e "\n正在等待 AimiliVPN 首次获取节点并建立加密通道 (此过程可能需要 5-90 秒)..."
ACTIVE_ID=""
LAST_MSG=""
for i in {1..90}; do
@@ -1106,7 +1155,7 @@ for i in {1..90}; do
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
if [ -n "$ACTIVE_ID" ] && ip link show dev tun0 >/dev/null 2>&1 && pidof openvpn >/dev/null 2>&1; then
echo -e " -> ${GREEN}[已就绪]${PLAIN} 首次节点连接成功,活动节点: ${GREEN}$ACTIVE_ID${PLAIN}"
break
else
@@ -1162,7 +1211,7 @@ if [ -n "$PUBLIC_IPV6" ]; then
fi
echo -e " * 网页管理账号: ${YELLOW}${USERNAME}${PLAIN}"
echo -e " * 网页管理密码: ${YELLOW}${PASSWORD}${PLAIN}"
echo -e " * HTTP/SOCKS5 代理端口: ${BLUE}http://127.0.0.1:${PROXY_PORT}/${PLAIN}${BLUE}http://[::1]:${PROXY_PORT}/${PLAIN}"
echo -e " * HTTP/SOCKS5 代理端口: ${BLUE}http://127.0.0.1:${PROXY_PORT}/${PLAIN}"
echo -e " --------------------------------------------------------"
echo -e " * 快速状态指令: ${YELLOW}ml status${PLAIN}${YELLOW}ml${PLAIN}"
echo -e " * 查看实时日志: ${YELLOW}ml logs${PLAIN}"
+3
View File
@@ -254,6 +254,9 @@ def socks5_client(client: socket.socket, first_byte: bytes) -> None:
return
client.sendall(b"\x01\x00")
else:
if 0 not in methods:
client.sendall(b"\x05\xff")
return
client.sendall(b"\x05\x00")
version, command, _, address_type = recv_exact(client, 4)
if version != 5 or command != 1:
+505 -5
View File
@@ -3,6 +3,10 @@ from __future__ import annotations
import base64
import json
import os
import re
import shutil
import stat
import subprocess
import tempfile
import threading
import unittest
@@ -85,6 +89,8 @@ class ManagerLogicTests(unittest.TestCase):
mock.patch.object(manager, "API_CACHE_FILE", root / "api_snapshot.csv"),
mock.patch.object(manager, "API_CACHE_META_FILE", root / "api_snapshot.meta.json"),
mock.patch.object(manager, "BUNDLED_SNAPSHOT_FILE", root / "bundled_snapshot.csv"),
mock.patch.object(manager.vpn_utils, "DATA_DIR", root),
mock.patch.object(manager.vpn_utils, "IP_CACHE_FILE", root / "ip_cache.json"),
]
for patcher in self.path_patches:
patcher.start()
@@ -98,6 +104,7 @@ class ManagerLogicTests(unittest.TestCase):
manager.last_proxy_failure_node_id = ""
manager.background_refill_thread = None
manager.background_refill_cancel_event.clear()
manager.active_sessions.clear()
def tearDown(self) -> None:
if manager.connection_attempt_lock.locked():
@@ -155,6 +162,347 @@ class ManagerLogicTests(unittest.TestCase):
self.assertEqual(5, sum(node.get("probe_status") == "available" for node in stored))
self.assertEqual(7, sum(node.get("probe_status") == "not_checked" for node in stored))
def test_ip_classification_separates_proxy_use_from_network_type(self) -> None:
residential, residential_reason = manager.vpn_utils.classify_ip_type(
{
"isp": "Sony Network Communications Inc.",
"org": "Sony Network Communications Inc.",
"proxy": True,
"hosting": False,
"mobile": False,
}
)
softether, softether_reason = manager.vpn_utils.classify_ip_type(
{
"isp": "SoftEther",
"org": "SoftEther Corporation",
"proxy": True,
"hosting": False,
"mobile": False,
}
)
hosting, hosting_reason = manager.vpn_utils.classify_ip_type(
{"proxy": True, "hosting": True, "mobile": False}
)
mobile, mobile_reason = manager.vpn_utils.classify_ip_type(
{"proxy": False, "hosting": False, "mobile": True}
)
unknown, unknown_reason = manager.vpn_utils.classify_ip_type(
{"proxy": True, "hosting": False, "mobile": False}
)
self.assertEqual(("residential", "consumer_or_unclassified_network"), (residential, residential_reason))
self.assertEqual(("hosting", "proxy_provider_datacenter"), (softether, softether_reason))
self.assertEqual(("hosting", "hosting_flag"), (hosting, hosting_reason))
self.assertEqual(("mobile", "mobile_flag"), (mobile, mobile_reason))
self.assertEqual(("unknown", "missing_provider_data"), (unknown, unknown_reason))
self.assertEqual("low", manager.vpn_utils.classification_confidence(unknown_reason))
def test_ip_enrichment_reclassifies_legacy_cache_and_keeps_proxy_quality(self) -> None:
ip = "118.240.250.95"
manager.vpn_utils.IP_CACHE_FILE.write_text(
json.dumps(
{
ip: {
"ip_type": "hosting",
"quality": "proxy",
"cached_at": 9999999999,
"classification_version": 1,
}
}
),
encoding="utf-8",
)
api_result = [
{
"status": "success",
"query": ip,
"country": "Japan",
"regionName": "Tokyo",
"city": "Tokyo",
"isp": "Sony Network Communications Inc.",
"org": "Sony Network Communications Inc.",
"as": "AS2527 Sony Network Communications Inc.",
"asname": "Sony Network Communications Inc.",
"proxy": True,
"hosting": False,
"mobile": False,
}
]
response = mock.MagicMock()
response.read.return_value = json.dumps(api_result).encode("utf-8")
response.__enter__.return_value = response
node = {"id": "sony", "ip": ip}
with mock.patch.object(manager.vpn_utils.urllib.request, "urlopen", return_value=response) as urlopen_mock:
manager.vpn_utils.enrich_ip_info([node])
self.assertEqual("residential", node["ip_type"])
self.assertEqual("proxy", node["quality"])
self.assertTrue(node["is_proxy"])
self.assertFalse(node["is_hosting"])
urlopen_mock.assert_called_once()
cache = json.loads(manager.vpn_utils.IP_CACHE_FILE.read_text(encoding="utf-8"))
self.assertEqual(manager.vpn_utils.IP_CLASSIFICATION_VERSION, cache[ip]["classification_version"])
def test_ambiguous_datacenter_uses_secondary_source_and_geo_country(self) -> None:
ip = "219.100.37.98"
primary_payload = [{
"status": "success",
"query": ip,
"country": "Japan",
"countryCode": "JP",
"regionName": "Tokyo",
"city": "Chiyoda",
"isp": "SoftEther",
"org": "SoftEther Corporation",
"as": "AS36599 SoftEther",
"asname": "SOFTETHER",
"proxy": True,
"hosting": False,
"mobile": False,
}]
primary = mock.MagicMock()
primary.read.return_value = json.dumps(primary_payload).encode("utf-8")
primary.__enter__.return_value = primary
secondary = mock.MagicMock()
secondary.read.return_value = json.dumps({"is_datacenter": True, "is_vpn": True}).encode("utf-8")
secondary.__enter__.return_value = secondary
node = {"id": "softether", "ip": ip}
with mock.patch.object(
manager.vpn_utils.urllib.request,
"urlopen",
side_effect=[primary, secondary],
):
manager.vpn_utils.enrich_ip_info([node])
self.assertEqual("hosting", node["ip_type"])
self.assertEqual("high", node["ip_type_confidence"])
self.assertEqual("datacenter", node["quality"])
self.assertTrue(node["is_hosting"])
self.assertEqual(["ip-api.com", "ipapi.is"], node["ip_type_sources"])
self.assertEqual("JP", node["geo_country_short"])
def test_unverified_datacenter_conflict_becomes_unknown(self) -> None:
ip = "203.0.113.10"
primary_payload = [{
"status": "success",
"query": ip,
"country": "Japan",
"countryCode": "JP",
"regionName": "Tokyo",
"city": "Tokyo",
"isp": "Example VPS",
"org": "Example VPS Hosting",
"as": "AS64500 Example",
"asname": "EXAMPLE",
"proxy": True,
"hosting": False,
"mobile": False,
}]
primary = mock.MagicMock()
primary.read.return_value = json.dumps(primary_payload).encode("utf-8")
primary.__enter__.return_value = primary
node = {"id": "ambiguous", "ip": ip}
with mock.patch.object(
manager.vpn_utils.urllib.request,
"urlopen",
side_effect=[primary, TimeoutError("secondary unavailable")],
):
manager.vpn_utils.enrich_ip_info([node])
self.assertEqual("unknown", node["ip_type"])
self.assertEqual("low", node["ip_type_confidence"])
strict = manager.apply_routing_filters([node], {"routing_mode": "auto", "routing_ip_type": "residential"})
self.assertEqual([], strict)
def test_missing_provider_data_uses_secondary_source_or_stays_unknown(self) -> None:
ip = "203.0.113.11"
primary_payload = [{
"status": "success",
"query": ip,
"country": "Japan",
"countryCode": "JP",
"regionName": "Tokyo",
"city": "Tokyo",
"isp": "",
"org": "",
"as": "",
"asname": "",
"proxy": True,
"hosting": False,
"mobile": False,
}]
primary = mock.MagicMock()
primary.read.return_value = json.dumps(primary_payload).encode("utf-8")
primary.__enter__.return_value = primary
node = {"id": "missing-provider", "ip": ip}
with mock.patch.object(
manager.vpn_utils.urllib.request,
"urlopen",
side_effect=[primary, TimeoutError("secondary unavailable")],
):
manager.vpn_utils.enrich_ip_info([node])
self.assertEqual("unknown", node["ip_type"])
self.assertEqual("provider_data_unverified", node["ip_type_reason"])
self.assertEqual("low", node["ip_type_confidence"])
strict = manager.apply_routing_filters(
[node],
{"routing_mode": "auto", "routing_ip_type": "residential"},
)
self.assertEqual([], strict)
def test_strict_residential_filter_requires_medium_or_high_confidence(self) -> None:
nodes = [
{"id": "low", "ip_type": "residential", "ip_type_confidence": "low"},
{"id": "medium", "ip_type": "residential", "ip_type_confidence": "medium"},
{"id": "mobile", "ip_type": "mobile", "ip_type_confidence": "high"},
{"id": "hosting", "ip_type": "hosting", "ip_type_confidence": "high"},
]
strict = manager.apply_routing_filters(
nodes,
{"routing_mode": "auto", "routing_ip_type": "residential"},
)
self.assertEqual(["medium", "mobile"], [node["id"] for node in strict])
def test_background_ip_enrichment_merges_metadata_without_replacing_status(self) -> None:
nodes = self.write_nodes(2)
nodes[0]["probe_status"] = "available"
manager.write_json(manager.NODES_FILE, nodes)
def fake_enrich(items):
for item in items:
item["ip_type"] = "residential"
item["quality"] = "proxy"
item["owner"] = "Consumer ISP"
item["is_proxy"] = True
with mock.patch.object(manager.vpn_utils, "enrich_ip_info", side_effect=fake_enrich):
changed = manager.enrich_stored_nodes()
stored = manager.read_nodes()
self.assertGreater(changed, 0)
self.assertEqual("available", next(node for node in stored if node["id"] == "node-0")["probe_status"])
self.assertTrue(all(node["ip_type"] == "residential" for node in stored))
def test_source_deadline_still_tries_official_http(self) -> None:
csv_text = valid_snapshot()
def fake_fetch(url, verify_ssl=True, deadline_seconds=None):
if url == manager.API_HTTPS_URL:
raise manager.SourceDeadlineExceeded("slow official source")
if url == manager.API_HTTP_URL:
return csv_text
raise AssertionError(f"unexpected source: {url}")
with (
mock.patch.object(manager, "fetch_api_text_with_deadline", side_effect=fake_fetch) as fetch_mock,
mock.patch.object(manager, "load_blacklist", return_value={}),
mock.patch.object(manager, "log_to_json"),
):
nodes = manager.fetch_candidates()
self.assertEqual(1, len(nodes))
self.assertEqual(
[manager.API_HTTPS_URL, manager.API_HTTP_URL],
[call.args[0] for call in fetch_mock.call_args_list],
)
def test_probe_failure_preserves_existing_ip_metadata(self) -> None:
nodes = self.write_nodes(1)
nodes[0].update(
{
"owner": "Existing ISP",
"location": "日本 东京",
"ip_type": "residential",
"ip_type_confidence": "medium",
}
)
manager.write_json(manager.NODES_FILE, nodes)
with (
mock.patch.object(manager.vpn_utils, "ping_latency_ms", return_value=0),
mock.patch.object(manager, "run_openvpn_until_ready", return_value=(False, "offline", None)),
):
manager.test_multiple_nodes([nodes[0]["id"]])
stored = manager.read_nodes()[0]
self.assertEqual("unavailable", stored["probe_status"])
self.assertEqual("Existing ISP", stored["owner"])
self.assertEqual("日本 东京", stored["location"])
self.assertEqual("residential", stored["ip_type"])
self.assertEqual("medium", stored["ip_type_confidence"])
def test_country_matching_accepts_iso_and_legacy_name(self) -> None:
node = {"country": "日本", "country_short": "JP"}
self.assertTrue(manager.country_matches(node["country"], "JP", node["country_short"]))
self.assertTrue(manager.country_matches(node["country"], "日本", node["country_short"]))
self.assertFalse(manager.country_matches(node["country"], "KR", node["country_short"]))
self.assertEqual("JP", manager.normalize_routing_country("日本", [node]))
def test_web_and_proxy_ports_must_be_distinct(self) -> None:
self.assertTrue(manager.ports_conflict(8787, "8787"))
self.assertFalse(manager.ports_conflict(8787, 7928))
def test_ui_connection_requires_tunnel_and_proxy_readiness(self) -> None:
manager.active_openvpn_node_id = "node-1"
manager.active_openvpn_process = FakeProcess()
base_state = {"is_connecting": False, "tunnel_ready": True, "proxy_ready": False, "proxy_ok": False}
self.assertFalse(manager.connection_ready_for_ui(base_state))
ready_state = {**base_state, "proxy_ready": True, "proxy_ok": True}
self.assertTrue(manager.connection_ready_for_ui(ready_state))
def test_manual_disconnect_state_clears_all_readiness_flags(self) -> None:
nodes = self.write_nodes(1)
nodes[0]["active"] = True
manager.write_json(manager.NODES_FILE, nodes)
manager.set_state(
is_connecting=True,
tunnel_ready=True,
proxy_ready=True,
proxy_ok=True,
proxy_ip="198.51.100.20",
)
with mock.patch.object(manager, "stop_active_openvpn") as stop_mock:
manager.clear_active_connection_state("手动断开连接")
stop_mock.assert_called_once_with()
state = manager.get_state()
self.assertFalse(state["is_connecting"])
self.assertFalse(state["tunnel_ready"])
self.assertFalse(state["proxy_ready"])
self.assertFalse(state["proxy_ok"])
self.assertEqual("-", state["proxy_ip"])
self.assertFalse(any(node.get("active") for node in manager.read_nodes()))
def test_ui_auth_json_is_written_private(self) -> None:
auth_file = manager.DATA_DIR / "ui_auth.json"
manager.write_json(auth_file, {"username": "test", "password": "secret"})
if os.name != "nt":
self.assertEqual(0o600, stat.S_IMODE(auth_file.stat().st_mode))
def test_source_deadline_limits_total_fetch_time(self) -> None:
def slow_fetch(url, verify_ssl=True):
threading.Event().wait(0.1)
return valid_snapshot()
with mock.patch.object(manager, "fetch_api_text", side_effect=slow_fetch):
started = manager.time.monotonic()
with self.assertRaises(manager.SourceDeadlineExceeded):
manager.fetch_api_text_with_deadline(
manager.API_HTTPS_URL,
deadline_seconds=0.01,
)
self.assertLess(manager.time.monotonic() - started, 0.08)
def test_node_probe_stops_after_systemic_openvpn_failure(self) -> None:
nodes = self.write_nodes(12)
@@ -283,6 +631,16 @@ class ManagerLogicTests(unittest.TestCase):
connect_mock.assert_called_once_with("old-node")
auto_switch_mock.assert_not_called()
def test_auto_switch_exhaustion_schedules_background_refill(self) -> None:
with (
mock.patch.object(manager, "schedule_background_refill", return_value=True) as schedule_mock,
mock.patch.object(manager, "log_to_json") as log_mock,
):
manager.auto_switch_node(attempt=3)
schedule_mock.assert_called_once_with()
log_mock.assert_called_once_with("INFO", "Main", "连续自动切换失败,已启动唯一后台节点补齐任务")
def test_physical_interface_detection_is_cached(self) -> None:
original_cache = manager.vpn_utils.physical_interface_cache
manager.vpn_utils.physical_interface_cache = (None, 0.0)
@@ -394,9 +752,94 @@ class ManagerLogicTests(unittest.TestCase):
self.assertIn('class="country-option-input"', manager.INDEX_HTML)
self.assertIn('${testBtn}', manager.INDEX_HTML)
def test_web_dashboard_has_browser_freeze_safeguards(self) -> None:
self.assertNotIn("backdrop-filter", manager.LOGIN_HTML)
self.assertNotIn("backdrop-filter", manager.INDEX_HTML)
self.assertNotIn("background-attachment: fixed", manager.INDEX_HTML)
self.assertIn("@media (prefers-reduced-motion: reduce)", manager.LOGIN_HTML)
self.assertIn("@media (prefers-reduced-motion: reduce)", manager.INDEX_HTML)
self.assertIn("const pageSize = 50;", manager.INDEX_HTML)
self.assertIn('id="pagination_container"', manager.INDEX_HTML)
self.assertIn('paginationContainer.style.display = totalPages > 1 ? "flex" : "none";', manager.INDEX_HTML)
self.assertIn("const MAX_RENDERED_LOG_LINES = 300;", manager.INDEX_HTML)
self.assertIn("nodesRequestPromise", manager.INDEX_HTML)
self.assertIn("backgroundPollInFlight", manager.INDEX_HTML)
self.assertIn('let lastNodesSnapshotSignature = "";', manager.INDEX_HTML)
self.assertIn("if (signature === lastNodesSnapshotSignature) return false;", manager.INDEX_HTML)
self.assertIn('typeof document.hidden !== "boolean" || !document.hidden', manager.INDEX_HTML)
self.assertEqual(500, manager.WEB_LOG_MAX_ENTRIES)
def test_web_dashboard_has_cross_browser_interaction_safeguards(self) -> None:
self.assertNotIn("fonts.googleapis.com", manager.LOGIN_HTML)
self.assertNotIn("fonts.googleapis.com", manager.INDEX_HTML)
self.assertIn('const pwd = document.getElementById("password").value;', manager.LOGIN_HTML)
self.assertIn('const password = $("cred_password").value;', manager.INDEX_HTML)
self.assertIn("function fetchWithTimeout", manager.LOGIN_HTML)
self.assertIn("function fetchWithTimeout", manager.INDEX_HTML)
self.assertNotIn("await fetch(", manager.INDEX_HTML)
self.assertIn('role="dialog" aria-modal="true"', manager.INDEX_HTML)
self.assertIn('aria-label="关闭网页安全设置"', manager.INDEX_HTML)
self.assertIn('class="option-card active" data-value="auto" aria-pressed="true"', manager.INDEX_HTML)
self.assertIn('class="vps-recommend-tab"', manager.INDEX_HTML)
self.assertIn('position: static;', manager.INDEX_HTML)
self.assertIn('-webkit-overflow-scrolling: touch;', manager.INDEX_HTML)
self.assertIn('formatUrlHost(window.location.hostname)', manager.INDEX_HTML)
self.assertNotIn('id="status" class="status" style="display: none;"', manager.INDEX_HTML)
self.assertIn('${esc(localProxy)}', manager.INDEX_HTML)
self.assertIn('${esc(statusMessage)}', manager.INDEX_HTML)
def test_dashboard_javascript_is_valid(self) -> None:
if not shutil.which("node"):
self.skipTest("Node.js is not installed; JavaScript syntax check skipped")
scripts = re.findall(r"<script>(.*?)</script>", manager.INDEX_HTML, re.DOTALL)
self.assertTrue(scripts)
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle:
handle.write("\n".join(scripts))
script_path = handle.name
try:
result = subprocess.run(
["node", "--check", script_path],
capture_output=True,
text=True,
timeout=10,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
finally:
Path(script_path).unlink(missing_ok=True)
def test_random_password_uses_cryptographic_randomness(self) -> None:
with mock.patch.object(manager.secrets, "choice", side_effect=list("aA0aA0aA0aA0")) as choice:
password = manager.generate_random_password()
self.assertEqual("aA0aA0aA0aA0", password)
self.assertEqual(12, choice.call_count)
def test_expired_sessions_are_removed(self) -> None:
manager.active_sessions.update({"expired": 99.0, "active": 101.0})
removed = manager.purge_expired_sessions(now=100.0)
self.assertEqual(1, removed)
self.assertEqual({"active": 101.0}, manager.active_sessions)
def test_web_log_reader_only_returns_recent_valid_entries(self) -> None:
log_file = manager.DATA_DIR / "logs" / "current.json"
log_file.parent.mkdir(parents=True)
with log_file.open("w", encoding="utf-8") as f:
for index in range(520):
f.write(json.dumps({"index": index}) + "\n")
f.write("not-json\n")
entries = manager.read_recent_log_entries(log_file)
self.assertEqual(500, len(entries))
self.assertEqual(20, entries[0]["index"])
self.assertEqual(519, entries[-1]["index"])
def test_web_update_controls_only_expose_stable_main_channel(self) -> None:
self.assertEqual("2.1.0", manager.APP_VERSION)
self.assertEqual("V2.1 正式版", manager.APP_VERSION_LABEL)
self.assertEqual("2.1.5", manager.APP_VERSION)
self.assertEqual("V2.1.5 正式版", manager.APP_VERSION_LABEL)
self.assertIn("检测更新", manager.INDEX_HTML)
self.assertIn("/api/check_update", manager.INDEX_HTML)
self.assertIn("/tree/main", manager.INDEX_HTML)
@@ -414,6 +857,37 @@ class ManagerLogicTests(unittest.TestCase):
self.assertNotIn("origin/master", install_text)
self.assertNotIn("bate", install_text.lower())
def test_installer_uses_secure_credentials_and_current_version(self) -> None:
install_text = (manager.ROOT_DIR / "install.sh").read_text(encoding="utf-8")
self.assertNotIn("random.choices", install_text)
self.assertIn("secrets.choice", install_text)
self.assertIn('get_app_version()', install_text)
self.assertNotIn("管理终端 v2.0", install_text)
self.assertIn("5-90 秒", install_text)
self.assertIn('new_pwd = input("请输入新管理密码 (不能为空): ")', install_text)
self.assertIn('state["active_openvpn_node_id"] = ""', install_text)
self.assertIn("ip link show dev tun0", install_text)
self.assertIn("pidof openvpn", install_text)
self.assertIn('chmod 600 "$AUTH_FILE"', install_text)
self.assertIn("AIMILIVPN_NONINTERACTIVE", install_text)
self.assertIn('["ip", "rule", "del", "table", "100"]', install_text)
self.assertIn('/etc/sysctl.d/99-aimilivpn.conf', install_text)
self.assertNotIn('http://[::1]:${PROXY_PORT}', install_text)
def test_openvpn_command_requires_server_certificate_usage(self) -> None:
with mock.patch.object(manager, "get_openvpn_version", return_value=2.5):
command = manager.openvpn_command("node.ovpn", route_nopull=True)
index = command.index("--remote-cert-tls")
self.assertEqual("server", command[index + 1])
def test_release_workflow_uses_full_patch_version(self) -> None:
workflow_text = (manager.ROOT_DIR / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
self.assertIn("default: v2.1.5", workflow_text)
self.assertIn("AimiliVPN V$(tr -d '\\r\\n' < VERSION) 正式版", workflow_text)
self.assertNotIn("cut -d. -f1,2 VERSION", workflow_text)
def test_latest_release_check_ignores_non_version_name_text(self) -> None:
release = {
"tag_name": "v2.2.0",
@@ -437,8 +911,8 @@ class ManagerLogicTests(unittest.TestCase):
def test_latest_release_check_reports_current_formal_version(self) -> None:
release = {
"tag_name": "v2.1.0",
"name": "AimiliVPN V2.1 正式版",
"tag_name": "v2.1.5",
"name": "AimiliVPN V2.1.5 正式版",
"draft": False,
"prerelease": False,
}
@@ -446,7 +920,7 @@ class ManagerLogicTests(unittest.TestCase):
result = manager.check_latest_release()
self.assertFalse(result["update_available"])
self.assertEqual("V2.1 正式版", result["current_version_label"])
self.assertEqual("V2.1.5 正式版", result["current_version_label"])
def test_latest_release_check_reports_source_update_command(self) -> None:
release = {"tag_name": "v2.2.0", "draft": False, "prerelease": False}
@@ -493,6 +967,7 @@ class ManagerLogicTests(unittest.TestCase):
mock.patch.object(manager, "fetch_api_text", side_effect=fake_fetch) as fetch_mock,
mock.patch.object(manager, "load_blacklist", return_value={}),
mock.patch.object(manager, "log_to_json"),
mock.patch.object(manager, "read_mirror_freshness", return_value=(0.0, "")),
):
nodes = manager.fetch_candidates()
@@ -570,6 +1045,31 @@ class ManagerLogicTests(unittest.TestCase):
class ProxyServerConcurrencyTests(unittest.TestCase):
def test_socks5_rejects_client_without_no_auth_method(self) -> None:
class Client:
def __init__(self):
self.incoming = bytearray(b"\x01\x02")
self.sent = bytearray()
self.closed = False
def recv(self, size):
chunk = self.incoming[:size]
del self.incoming[:size]
return bytes(chunk)
def sendall(self, data):
self.sent.extend(data)
def close(self):
self.closed = True
client = Client()
with mock.patch.object(proxy_server, "proxy_auth_enabled", return_value=False):
proxy_server.socks5_client(client, b"\x05")
self.assertEqual(b"\x05\xff", bytes(client.sent))
self.assertTrue(client.closed)
def test_each_proxy_worker_keeps_its_accepted_socket(self) -> None:
class Client:
def __init__(self, name):
+142 -24
View File
@@ -9,17 +9,25 @@ import time
import urllib.parse
import urllib.request
import threading
import concurrent.futures
from pathlib import Path
from typing import Any
ROOT_DIR = 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"
IP_CACHE_FILE = DATA_DIR / "ip_cache.json"
IP_CLASSIFICATION_VERSION = 4
IP_CACHE_TTL_SECONDS = 7 * 24 * 3600
ip_cache_lock = threading.RLock()
physical_interface_lock = threading.Lock()
physical_interface_cache: tuple[str | None, float] = (None, 0.0)
DATACENTER_PROVIDER_PATTERN = re.compile(
r"(?:\b(?:cloud|colo|colocation|data[ -]?center|hosting|servers?|vps)\b|softether)",
re.IGNORECASE,
)
COUNTRY_TRANSLATIONS = {
"Japan": "日本",
"Korea Republic of": "韩国",
@@ -389,6 +397,70 @@ def save_ip_cache(cache: dict[str, dict[str, Any]]) -> None:
except Exception:
pass
def classify_ip_type(item: dict[str, Any]) -> tuple[str, str]:
"""Classify network ownership without confusing VPN use with hosting."""
if item.get("mobile"):
return "mobile", "mobile_flag"
if item.get("hosting"):
return "hosting", "hosting_flag"
provider_text = " ".join(
str(item.get(key) or "")
for key in ("isp", "org", "as", "asname")
)
if not provider_text.strip():
return "unknown", "missing_provider_data"
if item.get("proxy") and DATACENTER_PROVIDER_PATTERN.search(provider_text):
return "hosting", "proxy_provider_datacenter"
# A residential volunteer running VPNGate is commonly marked as a proxy.
# Proxy use is retained in quality/is_proxy and must not change ownership.
return "residential", "consumer_or_unclassified_network"
def classification_confidence(reason: str) -> str:
if reason in {"mobile_flag", "hosting_flag", "secondary_datacenter", "secondary_mobile"}:
return "high"
if reason in {"consumer_or_unclassified_network", "secondary_consumer_network"}:
return "medium"
return "low"
def query_secondary_ip_type(ip: str) -> dict[str, Any] | None:
request = urllib.request.Request(
f"https://api.ipapi.is/?q={urllib.parse.quote(ip)}",
headers={"User-Agent": f"AimiliVPN-IP-Classifier/{IP_CLASSIFICATION_VERSION}"},
)
try:
with urllib.request.urlopen(request, timeout=6) as response:
payload = json.loads(response.read().decode("utf-8", errors="replace"))
if (
not isinstance(payload, dict)
or payload.get("error")
or not any(key in payload for key in ("is_datacenter", "is_mobile"))
):
return None
return payload
except Exception as exc:
print(f"[IP 类型] 第二情报源查询 {ip} 失败: {exc}", flush=True)
return None
def apply_ip_cache_entry(node: dict[str, Any], entry: dict[str, Any]) -> None:
for key in (
"owner",
"asn",
"as_name",
"location",
"ip_type",
"quality",
"is_proxy",
"is_hosting",
"is_mobile",
"ip_type_reason",
"ip_type_confidence",
"ip_type_sources",
"geo_country_short",
):
node[key] = entry.get(key, "")
def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
# 1. Read cache thread-safely
with ip_cache_lock:
@@ -401,14 +473,14 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
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:
cache_entry = cache.get(ip) if isinstance(cache.get(ip), dict) else None
if (
cache_entry
and cache_entry.get("classification_version") == IP_CLASSIFICATION_VERSION
and now - cache_entry.get("cached_at", 0) < IP_CACHE_TTL_SECONDS
):
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", "")
apply_ip_cache_entry(node, cached)
else:
if ip not in ips_to_query:
ips_to_query.append(ip)
@@ -423,9 +495,12 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
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",
"http://ip-api.com/batch?lang=zh-CN&fields=status,message,query,country,countryCode,regionName,city,isp,org,as,asname,proxy,hosting,mobile",
data=payload,
headers={"Content-Type": "application/json", "User-Agent": "vpngate-manager/2.2"},
headers={
"Content-Type": "application/json",
"User-Agent": f"AimiliVPN-IP-Classifier/{IP_CLASSIFICATION_VERSION}",
},
method="POST",
)
try:
@@ -442,19 +517,15 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
if not query_ip:
continue
ip_type = "residential"
if item.get("mobile"):
ip_type = "mobile"
elif item.get("hosting") or item.get("proxy"):
ip_type = "hosting"
ip_type, ip_type_reason = classify_ip_type(item)
quality = "normal"
if item.get("proxy"):
quality = "proxy"
if item.get("mobile"):
quality = "mobile"
elif item.get("hosting"):
quality = "datacenter"
elif item.get("mobile"):
quality = "mobile"
elif item.get("proxy"):
quality = "proxy"
loc = " ".join(part for part in [item.get("country"), item.get("regionName"), item.get("city")] if part)
@@ -463,13 +534,65 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
"asn": item.get("as") or "",
"as_name": item.get("asname") or "",
"location": loc,
"geo_country_short": str(item.get("countryCode") or "").upper(),
"ip_type": ip_type,
"quality": quality,
"is_proxy": bool(item.get("proxy")),
"is_hosting": bool(item.get("hosting")),
"is_mobile": bool(item.get("mobile")),
"ip_type_reason": ip_type_reason,
"ip_type_confidence": classification_confidence(ip_type_reason),
"ip_type_sources": ["ip-api.com"],
"classification_version": IP_CLASSIFICATION_VERSION,
"cached_at": now,
}
except Exception as e:
print(f"[enrich_ip_info] Query failed: {e}", flush=True)
ambiguous_ips = [
ip
for ip, entry in new_entries.items()
if entry.get("ip_type_reason") in {"proxy_provider_datacenter", "missing_provider_data"}
]
if ambiguous_ips:
max_workers = min(4, len(ambiguous_ips))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(query_secondary_ip_type, ip): ip
for ip in ambiguous_ips
}
for future in concurrent.futures.as_completed(future_map):
ip = future_map[future]
try:
secondary = future.result()
except Exception:
secondary = None
entry = new_entries[ip]
if secondary is None:
entry["ip_type"] = "unknown"
entry["ip_type_reason"] = (
"provider_data_unverified"
if entry.get("ip_type_reason") == "missing_provider_data"
else "datacenter_conflict_unverified"
)
entry["ip_type_confidence"] = "low"
continue
entry["ip_type_sources"].append("ipapi.is")
if secondary.get("is_mobile"):
entry["ip_type"] = "mobile"
entry["ip_type_reason"] = "secondary_mobile"
entry["quality"] = "mobile"
entry["is_mobile"] = True
elif secondary.get("is_datacenter"):
entry["ip_type"] = "hosting"
entry["ip_type_reason"] = "secondary_datacenter"
entry["quality"] = "datacenter"
entry["is_hosting"] = True
else:
entry["ip_type"] = "residential"
entry["ip_type_reason"] = "secondary_consumer_network"
entry["ip_type_confidence"] = classification_confidence(entry["ip_type_reason"])
if not new_entries:
return
@@ -484,12 +607,7 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
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", "")
apply_ip_cache_entry(node, cached)
def diagnose_api_failure(api_url: str = "https://www.vpngate.net/api/iphone/") -> tuple[int, str]:
+701 -269
View File
File diff suppressed because it is too large Load Diff