12 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
14 changed files with 1549 additions and 310 deletions
+2 -2
View File
@@ -7,9 +7,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release_tag: release_tag:
description: Existing formal tag to publish, for example v2.1.1 description: Existing formal tag to publish, for example v2.1.5
required: true required: true
default: v2.1.1 default: v2.1.5
type: string type: string
concurrency: concurrency:
+2 -1
View File
@@ -2,7 +2,8 @@ name: Update VPNGate mirror
on: on:
schedule: schedule:
- cron: "*/15 * * * *" # Offset from quarter-hour peaks because GitHub may delay busy schedules.
- cron: "7,22,37,52 * * * *"
workflow_dispatch: workflow_dispatch:
push: push:
branches: [main] branches: [main]
+18 -44
View File
@@ -10,7 +10,7 @@
**简体中文** · [English](docs/README.en.md) · [日本語](docs/README.ja.md) · [한국어](docs/README.ko.md) **简体中文** · [English](docs/README.en.md) · [日本語](docs/README.ja.md) · [한국어](docs/README.ko.md)
[快速安装](#quick-install) · [完整安装](#installation) · [连接使用](#connection) · [VPS 推荐](#vps) · [社区入口](#community) · [法律声明](#legal) [快速安装](#quick-install) · [完整安装](#installation) · [连接使用](#connection) · [服务商推荐](#vps) · [社区入口](#community) · [法律声明](#legal)
[![项目网站](https://img.shields.io/badge/项目网站-339936.xyz-f97316?style=for-the-badge)](https://339936.xyz) [![项目网站](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) [![Telegram](https://img.shields.io/badge/Telegram-交流群-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
@@ -18,6 +18,17 @@
</div> </div>
<a id="vps"></a>
## 服务商推荐
| 商家 | 推荐理由 | 入口 |
| --- | --- | --- |
| **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 使用 Python 标准库管理 VPNGate 节点,提供节点获取与检测、连接切换、Web 管理后台,以及共用一个端口的 HTTP、HTTPS 网站代理和 SOCKS5 代理服务。 AimiliVPN 使用 Python 标准库管理 VPNGate 节点,提供节点获取与检测、连接切换、Web 管理后台,以及共用一个端口的 HTTP、HTTPS 网站代理和 SOCKS5 代理服务。
| 项目 | 默认值或支持范围 | | 项目 | 默认值或支持范围 |
@@ -42,51 +53,15 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma
安装完成后,终端会显示 Web 后台完整地址、随机安全路径、登录账号和密码。输入 `ml` 可打开管理菜单。 安装完成后,终端会显示 Web 后台完整地址、随机安全路径、登录账号和密码。输入 `ml` 可打开管理菜单。
无人值守安装可显式跳过首次参数询问,并自动生成安全路径和登录凭据:
```bash
AIMILIVPN_NONINTERACTIVE=1 bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
> [!TIP] > [!TIP]
> 安装前请在 VPS 控制面板启用 TUN/TAP,并确认 `/dev/net/tun` 存在。Web 默认使用 TCP `8787`,安全组建议只允许自己的 IP 访问。 > 安装前请在 VPS 控制面板启用 TUN/TAP,并确认 `/dev/net/tun` 存在。Web 默认使用 TCP `8787`,安全组建议只允许自己的 IP 访问。
<a id="vps"></a>
## VPS 推荐
以下链接含推广参数,通过链接购买不会增加您的费用。
<table>
<tr>
<td width="50%" valign="top">
<h3 align="center">BandwagonHost 搬瓦工</h3>
<p align="center">
<img alt="CN2 GIA" src="https://img.shields.io/badge/电信-CN2_GIA-dc2626?style=flat-square">
<img alt="联通 9929" src="https://img.shields.io/badge/联通-9929-f97316?style=flat-square">
<img alt="移动 CMIN2" src="https://img.shields.io/badge/移动-CMIN2-16a34a?style=flat-square">
</p>
<p><strong>主要特点</strong></p>
<ul>
<li>电信 CN2 GIA、联通 9929、移动 CMIN2 等三网极品优化线路。</li>
<li>低延迟、高稳定性,跨境链路质量出色。</li>
<li>适合 TikTok 直播运营、海外带货和长期出海业务。</li>
</ul>
<p align="center"><a href="https://bandwagonhost.com/aff.php?aff=81790"><img alt="查看 BandwagonHost" src="https://img.shields.io/badge/立即查看-BandwagonHost-dc2626?style=for-the-badge"></a></p>
</td>
<td width="50%" valign="top">
<h3 align="center">RackNerd</h3>
<p align="center">
<img alt="4000GB 每月流量" src="https://img.shields.io/badge/每月流量-4000GB-0284c7?style=flat-square">
<img alt="大流量" src="https://img.shields.io/badge/优势-大流量-0ea5e9?style=flat-square">
<img alt="高性价比" src="https://img.shields.io/badge/价格-高性价比-2563eb?style=flat-square">
</p>
<p><strong>主要特点</strong></p>
<ul>
<li>每月 4000GB 大流量,流量余量更充足。</li>
<li>价格实惠,流量与配置的性价比突出。</li>
<li>部署成本低,适合需要长期稳定运行的服务。</li>
</ul>
<p align="center"><a href="https://my.racknerd.com/aff.php?aff=18708"><img alt="查看 RackNerd" src="https://img.shields.io/badge/立即查看-RackNerd-0284c7?style=for-the-badge"></a></p>
</td>
</tr>
</table>
购买前请确认具体套餐支持 TUN/TAP、OpenVPN 和所需网络协议。推广链接不代表对具体机型可用性的保证。
<a id="installation"></a> <a id="installation"></a>
## 完整安装 ## 完整安装
@@ -271,7 +246,6 @@ ssh -N \
| Telegram 群 | 即时交流 | [t.me/arestemple](https://t.me/arestemple) | | Telegram 群 | 即时交流 | [t.me/arestemple](https://t.me/arestemple) |
| YouTube 教程 | 安装和使用视频 | [观看视频](https://www.youtube.com/watch?v=s-ATfXR8BpI) | | YouTube 教程 | 安装和使用视频 | [观看视频](https://www.youtube.com/watch?v=s-ATfXR8BpI) |
| GitHub Issues | 可复现的问题与功能建议 | [提交 Issue](https://github.com/baoweise-bot/aimili-vpngate/issues) | | GitHub Issues | 可复现的问题与功能建议 | [提交 Issue](https://github.com/baoweise-bot/aimili-vpngate/issues) |
| 电子邮箱 | Bug 反馈与联系 | [yaohunse7@gmail.com](mailto:yaohunse7@gmail.com) |
<a id="legal"></a> <a id="legal"></a>
## 使用范围与法律声明 ## 使用范围与法律声明
+47 -42
View File
@@ -1,63 +1,68 @@
# AimiliVPN V2.1.1 正式版 # AimiliVPN V2.1.5 正式版
V2.1.1 是一次以稳定性、浏览器兼容性和 VPS 实际使用体验为重点的维护更新。本次版本已在 Ubuntu 22.04 VPS 上通过安装、节点获取、代理出口、节点切换、断开与恢复测试 V2.1.5 包含 V2.1.4 的全部稳定性、安全性和节点识别改进,并修复正式版重新安装验收中发现的失败切换恢复遗漏
## Bug 修复 ## V2.1.5 热修复
- 修复 Edge 和部分 Chromium 浏览器打开管理页面后高占用、卡顿甚至浏览器失去响应的问题:移除高成本的 `backdrop-filter`、固定背景和远程字体依赖,并为“减少动态效果”系统设置提供兼容处理 - 修复点击“断开连接”后 OpenVPN、活动节点和 table 100 已清理,但 Web API 的 `tunnel_ready` / `proxy_ready` 仍短暂保留为 `true` 的问题
- 修复前端每 10 秒轮询时,即使节点和状态完全没有变化,仍会重新排序、生成表格 HTML 和刷新页面的问题;相同快照现在会直接跳过渲染 - 手动断开现在统一复用完整连接状态清理流程;活动节点、连接中状态、隧道、代理出口、出口 IP 与路由规则会同步归零
- 修复节点列表已经按每页 50 条分页,但分页栏始终隐藏,导致第 51 条之后的节点无法查看的问题 - 新增手动断开状态回归测试,并在真实 VPS 上完成断开与重新连接复验
- 修复多个轮询请求可能重叠、页面切到后台后仍持续刷新,以及日志窗口重复拉取和渲染过多内容造成的卡顿 - 修复手动切换失败、原节点恢复失败且连续 3 个备用节点也失效后,日志提示后台重新加载但实际没有启动补位任务的问题
- 修复连接、断开、单节点检测、收藏和设置操作可能被连续点击重复提交的问题,并为网络请求增加合理的超时与失败恢复 - 备用节点耗尽后现在会启动唯一后台节点补齐线程,按退避间隔重新同步并尝试连接,避免一直等到下一轮周期采集
- 修复弹窗关闭、键盘焦点、Esc、Tab 循环和移动端滚动在不同浏览器中的不一致行为 - 新增自动切换耗尽回归测试,并在真实 VPS 上验证补位线程会创建且不会重复启动
- 修复登录和管理密码被意外删除首尾空格的问题;前端和 `ml password` 现在保持一致。
- 修复 SOCKS5 客户端未提供“无需认证”方法时,服务端仍错误接受连接的问题。
- 修复 `ml status` 一直显示旧版 `v2.0`,以及首次安装提示 5-30 秒但实际最多等待 90 秒的问题。
- 修复 `ml update` 重启服务后可能读取上一次连接留下的节点 ID、过早提示“已就绪”的竞态;现在会清理旧状态,并确认 OpenVPN 进程和 `tun0` 均已建立后才报告成功。
- 修复正式版 GitHub Actions 发布脚本的 Shell 块问题。
## 性能与体验优化 ## IP 类型识别
- 节点接口继续移除不需要发送到浏览器的 OpenVPN 配置正文,约百个节点的响应保持在较小体积 - IP 类型缓存升级到新的分类版本,旧分类会自动失效并重新检测
- 节点请求自动合并;页面不可见时暂停普通轮询;节点表最多渲染 50 行,日志窗口最多渲染最近 300 行,服务端最多读取最近 500 条日志 - 明确区分住宅、移动、机房和未知网络,并显示高、中、低置信度及情报来源
- 国家多选、状态筛选、IP 类型筛选、收藏面板和分页均在前端即时响应,不需要重新请求节点数据 - Sony、KDDI、SoftBank、Korea Telecom 等有明确消费接入运营商信息且未命中机房特征的网络继续识别为住宅
- 管理弹窗补齐标准对话框语义、可见焦点、焦点恢复和屏幕阅读器状态提示 - `proxy + 数据中心关键词` 冲突节点以及完全缺少运营商信息的节点会调用 `ipapi.is` 进行第二来源复核
- 登录、节点更新、代理检测、节点切换、断开、设置保存和日志接口统一加入超时控制,网络异常时不会无限等待 - 第二来源不可用或证据不足时标记为未知/低置信度,不再直接当作住宅
- 安装器生成的账号、密码和安全路径改用 Python `secrets` 加密安全随机源 - 严格住宅路由只接受中、高置信度住宅或移动网络,拒绝未知和低置信度分类
## 文档与功能更新 ## 节点获取与连接切换
- Web 管理端版本标识和更新检测升级为完整的 `V2.1.1 正式版`,仍只跟踪 GitHub `main` 稳定分支和正式 Release - 修复官方 HTTPS 超时后直接跳过官方 HTTP 的问题;现在仍会继续尝试 VPNGate 官方真实 HTTP,再进入 GitHub Pages、VPS 最近缓存和内置快照
- README 重新整理快速安装、代理连接、VPS 推荐、项目推广和法律使用边界,删除重复的版本流水账与虚拟币赞助内容 - GitHub 镜像状态显示生成时间与陈旧程度,HTTP 地址明确标注为跳转 HTTPS,不再描述成独立明文镜像
- 保留简体中文主文档,并提供 English、日本語、한국어说明 - 连接状态拆分为隧道就绪和代理出口就绪;只有 OpenVPN、策略路由与真实代理出口全部通过后,前端才显示“已连接”
- 正式版继续提供 HTTP 和 SOCKS5 两种代理协议,兼容不同 VPS 和客户端环境 - 修复批量节点检测失败时用空字段覆盖已有 ISP、物理位置和 IP 类型的问题
- 固定国家统一保存 ISO 两字母代码,并兼容旧版中文国家名称配置。
- 物理位置国旗使用 IP 情报源国家,节点申报国家与 IP 推测位置分开提示,避免来源冲突造成误解。
## 验证结果 ## 安装、安全与运维
- Ubuntu 22.04 x86_64 VPS 一键安装成功,服务保持运行且无异常重启 - 卸载时清理 AimiliVPN 的 sysctl 文件、table 100 路由和全部 table 100 策略规则
- HTTP 与 SOCKS5 代理出口验证通过 - `ui_auth.json` 创建、迁移和保存时使用私有权限 `0600`,并采用安全写入流程
- 节点测试、切换、断开和恢复原节点的完整流程通过 - Web 管理端口与代理端口增加双向冲突校验,冲突配置直接拒绝,不再静默改写端口
- 本地 35 项单元测试、Python 编译、前端 JavaScript 语法、`install.sh` 语法和 Docker Compose 配置检查通过 - OpenVPN 启用 `remote-cert-tls server`,验证服务器证书用途并移除相关安全警告
- 99 个节点分页为 50/49,国家多选、空状态、收藏、设置、网关、日志和 Web 更新检测均通过浏览器交互测试 - 新增 `AIMILIVPN_NONINTERACTIVE=1` 无人值守安装模式,首次部署不会停在交互提示
- 11.5 秒空闲轮询观测期间,相同节点快照产生 0 次 DOM 变更 - 安装完成信息只显示实际默认监听的 IPv4 本地代理地址,不再错误提示未启用的 IPv6 地址
- 390x844 手机视口和 1440x900 桌面视口没有页面级横向溢出或控件重叠。
## 下载与兼容架构 ## 验证与兼容
- GitHub Release 提供 `aimilivpn-v2.1.1-linux-source.tar.gz` 通用 Linux Python 源码包和 `sha256sums.txt` - 本地 Python 单元测试 53/53 通过,Python 编译、Dashboard JavaScript 语法、安装脚本语法和补丁格式检查通过
- GHCR 发布 `2.1.1``2.1``latest` 三组镜像标签 - Ubuntu 22.04 x86_64 VPS 已完成真实卸载、重新安装、节点源、连接切换、代理出口、Web API、权限和路由残留验证
- Docker 镜像在发布前分别对 `linux/amd64``linux/386``linux/arm64``linux/arm/v7` 执行构建和导入冒烟测试。 - 发布流水线会在 Python 3.9、3.11、3.13 上运行完整测试。
- Docker 需要 Linux、`/dev/net/tun`、host 网络以及 `NET_ADMIN``NET_RAW` 能力 - Docker 会分别构建验证 `linux/amd64``linux/386``linux/arm64``linux/arm/v7`,并发布统一多架构镜像清单
## 更新方式 ## 平台限制
Python 源码安装: - 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 ```bash
ml update ml update
``` ```
Docker Compose Docker Compose 更新
```bash ```bash
docker compose pull docker compose pull
+1 -1
View File
@@ -1 +1 @@
2.1.1 2.1.5
+14 -37
View File
@@ -10,7 +10,7 @@
[简体中文](../README.md) · **English** · [日本語](README.ja.md) · [한국어](README.ko.md) [简体中文](../README.md) · **English** · [日本語](README.ja.md) · [한국어](README.ko.md)
[Quick install](#quick-install) · [Installation](#installation) · [Connection](#connection) · [VPS offers](#vps) · [Community](#community) · [Legal notice](#legal) [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) [![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) [![Telegram](https://img.shields.io/badge/Telegram-Community-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
@@ -46,44 +46,15 @@ The installer prints the complete Web URL, private path, username, and password.
> 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. > 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> <a id="vps"></a>
## VPS Offers ## Recommended Services
The following are affiliate links. Using them does not increase your price. | 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/) |
<table> Some links are affiliate links. Using them does not increase your price.
<tr>
<td width="50%" valign="top">
<h3 align="center">BandwagonHost</h3>
<p align="center">
<img alt="CN2 GIA" src="https://img.shields.io/badge/China_Telecom-CN2_GIA-dc2626?style=flat-square">
<img alt="China Unicom 9929" src="https://img.shields.io/badge/China_Unicom-9929-f97316?style=flat-square">
<img alt="China Mobile CMIN2" src="https://img.shields.io/badge/China_Mobile-CMIN2-16a34a?style=flat-square">
</p>
<p><strong>Highlights</strong></p>
<ul>
<li>Premium CN2 GIA, China Unicom 9929, and China Mobile CMIN2 optimized routes.</li>
<li>Low latency, high stability, and excellent cross-border route quality.</li>
<li>Strong connectivity for TikTok live operations, cross-border e-commerce, and long-running global services.</li>
</ul>
<p align="center"><a href="https://bandwagonhost.com/aff.php?aff=81790"><img alt="View BandwagonHost" src="https://img.shields.io/badge/View-BandwagonHost-dc2626?style=for-the-badge"></a></p>
</td>
<td width="50%" valign="top">
<h3 align="center">RackNerd</h3>
<p align="center">
<img alt="4000GB monthly traffic" src="https://img.shields.io/badge/Monthly_Traffic-4000GB-0284c7?style=flat-square">
<img alt="High traffic" src="https://img.shields.io/badge/Advantage-High_Traffic-0ea5e9?style=flat-square">
<img alt="Value" src="https://img.shields.io/badge/Pricing-High_Value-2563eb?style=flat-square">
</p>
<p><strong>Highlights</strong></p>
<ul>
<li>4000GB of monthly traffic with generous transfer headroom.</li>
<li>Affordable pricing and a strong traffic-to-cost ratio.</li>
<li>Low deployment cost for services that need to run continuously.</li>
</ul>
<p align="center"><a href="https://my.racknerd.com/aff.php?aff=18708"><img alt="View RackNerd" src="https://img.shields.io/badge/View-RackNerd-0284c7?style=for-the-badge"></a></p>
</td>
</tr>
</table>
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. 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.
@@ -104,6 +75,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma
The installer deploys to `/opt/aimilivpn` and registers a system service. 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 ```bash
ml # Open the management menu ml # Open the management menu
ml status # Show status, Web URL, and username ml status # Show status, Web URL, and username
+8 -37
View File
@@ -10,7 +10,7 @@
[简体中文](../README.md) · [English](README.en.md) · **日本語** · [한국어](README.ko.md) [简体中文](../README.md) · [English](README.en.md) · **日本語** · [한국어](README.ko.md)
[クイックインストール](#quick-install) · [インストール](#installation) · [接続方法](#connection) · [VPS](#vps) · [コミュニティ](#community) · [法的通知](#legal) [クイックインストール](#quick-install) · [インストール](#installation) · [接続方法](#connection) · [おすすめサービス](#vps) · [コミュニティ](#community) · [法的通知](#legal)
[![Website](https://img.shields.io/badge/Website-339936.xyz-f97316?style=for-the-badge)](https://339936.xyz) [![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) [![Telegram](https://img.shields.io/badge/Telegram-Community-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
@@ -45,44 +45,15 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma
> VPS の管理画面で TUN/TAP を有効にし、`/dev/net/tun` が存在することを確認してください。Web のデフォルトポートは TCP `8787` です。可能な限り自分の IP だけを許可してください。 > VPS の管理画面で TUN/TAP を有効にし、`/dev/net/tun` が存在することを確認してください。Web のデフォルトポートは TCP `8787` です。可能な限り自分の IP だけを許可してください。
<a id="vps"></a> <a id="vps"></a>
## VPS の案内 ## おすすめサービス
以下はアフィリエイトリンクです。リンク経由でも購入価格は上がりません。 | サービス | 種類 | おすすめポイント | リンク |
| --- | --- | --- | --- |
| **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/) |
<table> 一部のリンクはアフィリエイトリンクです。リンク経由でも購入価格は上がりません。
<tr>
<td width="50%" valign="top">
<h3 align="center">BandwagonHost</h3>
<p align="center">
<img alt="CN2 GIA" src="https://img.shields.io/badge/China_Telecom-CN2_GIA-dc2626?style=flat-square">
<img alt="China Unicom 9929" src="https://img.shields.io/badge/China_Unicom-9929-f97316?style=flat-square">
<img alt="China Mobile CMIN2" src="https://img.shields.io/badge/China_Mobile-CMIN2-16a34a?style=flat-square">
</p>
<p><strong>主な特長</strong></p>
<ul>
<li>CN2 GIA、China Unicom 9929、China Mobile CMIN2 の高品質な最適化回線。</li>
<li>低遅延、高い安定性、優れた国際経路品質。</li>
<li>TikTok ライブ運営、越境 EC、長期的な海外向け事業に適した回線。</li>
</ul>
<p align="center"><a href="https://bandwagonhost.com/aff.php?aff=81790"><img alt="BandwagonHost" src="https://img.shields.io/badge/View-BandwagonHost-dc2626?style=for-the-badge"></a></p>
</td>
<td width="50%" valign="top">
<h3 align="center">RackNerd</h3>
<p align="center">
<img alt="4000GB monthly traffic" src="https://img.shields.io/badge/Monthly_Traffic-4000GB-0284c7?style=flat-square">
<img alt="High traffic" src="https://img.shields.io/badge/Advantage-High_Traffic-0ea5e9?style=flat-square">
<img alt="Value" src="https://img.shields.io/badge/Pricing-High_Value-2563eb?style=flat-square">
</p>
<p><strong>主な特長</strong></p>
<ul>
<li>月間 4000GB の大容量トラフィック。</li>
<li>手頃な価格と優れたトラフィック対コスト比。</li>
<li>継続稼働するサービスの導入コストを抑えやすい構成。</li>
</ul>
<p align="center"><a href="https://my.racknerd.com/aff.php?aff=18708"><img alt="RackNerd" src="https://img.shields.io/badge/View-RackNerd-0284c7?style=for-the-badge"></a></p>
</td>
</tr>
</table>
購入前に、対象プランが TUN/TAP、OpenVPN、必要なプロトコルを許可していることを確認してください。 購入前に、対象プランが TUN/TAP、OpenVPN、必要なプロトコルを許可していることを確認してください。
+8 -37
View File
@@ -10,7 +10,7 @@
[简体中文](../README.md) · [English](README.en.md) · [日本語](README.ja.md) · **한국어** [简体中文](../README.md) · [English](README.en.md) · [日本語](README.ja.md) · **한국어**
[빠른 설치](#quick-install) · [설치](#installation) · [연결](#connection) · [VPS](#vps) · [커뮤니티](#community) · [법적 고지](#legal) [빠른 설치](#quick-install) · [설치](#installation) · [연결](#connection) · [추천 서비스](#vps) · [커뮤니티](#community) · [법적 고지](#legal)
[![Website](https://img.shields.io/badge/Website-339936.xyz-f97316?style=for-the-badge)](https://339936.xyz) [![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) [![Telegram](https://img.shields.io/badge/Telegram-Community-229ED9?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/arestemple)
@@ -45,44 +45,15 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma
> VPS 제어판에서 TUN/TAP을 활성화하고 `/dev/net/tun`이 존재하는지 확인하십시오. Web 기본 포트는 TCP `8787`이며 가능하면 자신의 IP만 허용하십시오. > VPS 제어판에서 TUN/TAP을 활성화하고 `/dev/net/tun`이 존재하는지 확인하십시오. Web 기본 포트는 TCP `8787`이며 가능하면 자신의 IP만 허용하십시오.
<a id="vps"></a> <a id="vps"></a>
## VPS 안내 ## 추천 서비스
아래 링크는 제휴 링크이며, 이를 통해 구매해도 가격은 올라가지 않습니다. | 서비스 | 유형 | 추천 이유 | 링크 |
| --- | --- | --- | --- |
| **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/) |
<table> 일부 링크는 제휴 링크이며, 이를 통해 구매해도 가격은 올라가지 않습니다.
<tr>
<td width="50%" valign="top">
<h3 align="center">BandwagonHost</h3>
<p align="center">
<img alt="CN2 GIA" src="https://img.shields.io/badge/China_Telecom-CN2_GIA-dc2626?style=flat-square">
<img alt="China Unicom 9929" src="https://img.shields.io/badge/China_Unicom-9929-f97316?style=flat-square">
<img alt="China Mobile CMIN2" src="https://img.shields.io/badge/China_Mobile-CMIN2-16a34a?style=flat-square">
</p>
<p><strong>주요 특징</strong></p>
<ul>
<li>CN2 GIA, China Unicom 9929, China Mobile CMIN2 프리미엄 최적화 회선.</li>
<li>낮은 지연 시간, 높은 안정성, 뛰어난 국제 경로 품질.</li>
<li>TikTok 라이브 운영, 해외 전자상거래 및 장기 글로벌 서비스에 적합한 연결 품질.</li>
</ul>
<p align="center"><a href="https://bandwagonhost.com/aff.php?aff=81790"><img alt="BandwagonHost" src="https://img.shields.io/badge/View-BandwagonHost-dc2626?style=for-the-badge"></a></p>
</td>
<td width="50%" valign="top">
<h3 align="center">RackNerd</h3>
<p align="center">
<img alt="4000GB monthly traffic" src="https://img.shields.io/badge/Monthly_Traffic-4000GB-0284c7?style=flat-square">
<img alt="High traffic" src="https://img.shields.io/badge/Advantage-High_Traffic-0ea5e9?style=flat-square">
<img alt="Value" src="https://img.shields.io/badge/Pricing-High_Value-2563eb?style=flat-square">
</p>
<p><strong>주요 특징</strong></p>
<ul>
<li>월 4000GB의 넉넉한 트래픽.</li>
<li>합리적인 가격과 뛰어난 트래픽 대비 비용 효율.</li>
<li>장기 실행 서비스의 배포 비용을 낮추기 좋은 구성.</li>
</ul>
<p align="center"><a href="https://my.racknerd.com/aff.php?aff=18708"><img alt="RackNerd" src="https://img.shields.io/badge/View-RackNerd-0284c7?style=for-the-badge"></a></p>
</td>
</tr>
</table>
구매 전에 선택한 요금제가 TUN/TAP, OpenVPN 및 필요한 네트워크 프로토콜을 허용하는지 확인하십시오. 구매 전에 선택한 요금제가 TUN/TAP, OpenVPN 및 필요한 네트워크 프로토콜을 허용하는지 확인하십시오.
+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 交互。
+35 -11
View File
@@ -204,9 +204,9 @@ def get_app_version():
try: try:
with open(os.path.join(INSTALL_DIR, "VERSION"), "r", encoding="utf-8") as f: with open(os.path.join(INSTALL_DIR, "VERSION"), "r", encoding="utf-8") as f:
version = f.read().strip().lstrip("vV") version = f.read().strip().lstrip("vV")
return version or "2.1.1" return version or "2.1.5"
except Exception: except Exception:
return "2.1.1" return "2.1.5"
def load_ui_cfg(): def load_ui_cfg():
import json import json
@@ -227,8 +227,11 @@ def save_ui_cfg(cfg):
path = "/opt/aimilivpn/vpngate_data/ui_auth.json" path = "/opt/aimilivpn/vpngate_data/ui_auth.json"
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
try: try:
if os.path.exists(path):
os.chmod(path, 0o600)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2) json.dump(cfg, f, ensure_ascii=False, indent=2)
os.chmod(path, 0o600)
return True return True
except Exception: except Exception:
return False return False
@@ -579,10 +582,12 @@ def uninstall_service():
stop_service() stop_service()
if shutil.which("systemctl"): if shutil.which("systemctl"):
subprocess.run(["systemctl", "disable", "aimilivpn.service"]) subprocess.run(["systemctl", "disable", "aimilivpn.service"])
try: for unit_path in ("/lib/systemd/system/aimilivpn.service", "/etc/systemd/system/aimilivpn.service"):
os.unlink("/lib/systemd/system/aimilivpn.service") try:
except Exception: os.unlink(unit_path)
pass except FileNotFoundError:
pass
subprocess.run(["systemctl", "daemon-reload"], check=False)
elif shutil.which("rc-service"): elif shutil.which("rc-service"):
subprocess.run(["rc-update", "del", "aimilivpn"]) subprocess.run(["rc-update", "del", "aimilivpn"])
try: try:
@@ -591,9 +596,25 @@ def uninstall_service():
pass pass
try: try:
os.unlink("/usr/bin/ml") os.unlink("/usr/bin/ml")
except Exception: except FileNotFoundError:
pass 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 已卸载!") print("AimiliVPN 已卸载!")
sys.exit(0) sys.exit(0)
else: else:
@@ -964,7 +985,7 @@ mkdir -p "${INSTALL_DIR}/vpngate_data"
is_custom="n" is_custom="n"
if [ ! -f "$AUTH_FILE" ]; then if [ ! -f "$AUTH_FILE" ]; then
if [ -t 0 ]; then if [ -t 0 ] && [ "${AIMILIVPN_NONINTERACTIVE:-0}" != "1" ]; then
echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}" echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}"
read -p "是否自定义配置?[y/N]: " is_custom read -p "是否自定义配置?[y/N]: " is_custom
else else
@@ -1050,6 +1071,7 @@ while True:
# when username/password contain quotes, backslashes, or shell metacharacters. # when username/password contain quotes, backslashes, or shell metacharacters.
python3 - "$AUTH_FILE" "$UI_PORT" "$SECRET_PATH" "$UI_USERNAME" "$UI_PASSWORD" <<'PY' python3 - "$AUTH_FILE" "$UI_PORT" "$SECRET_PATH" "$UI_USERNAME" "$UI_PASSWORD" <<'PY'
import json import json
import os
import sys import sys
auth_file, ui_port, secret_path, username, password = sys.argv[1:6] auth_file, ui_port, secret_path, username, password = sys.argv[1:6]
@@ -1061,10 +1083,12 @@ cfg = {
"username": username, "username": username,
"password": password, "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) json.dump(cfg, f, ensure_ascii=False, indent=2)
PY PY
fi fi
chmod 600 "$AUTH_FILE"
# 8. Start service # 8. Start service
# 8.5 Optimize network parameters (rp_filter for policy routing) # 8.5 Optimize network parameters (rp_filter for policy routing)
@@ -1187,7 +1211,7 @@ if [ -n "$PUBLIC_IPV6" ]; then
fi fi
echo -e " * 网页管理账号: ${YELLOW}${USERNAME}${PLAIN}" echo -e " * 网页管理账号: ${YELLOW}${USERNAME}${PLAIN}"
echo -e " * 网页管理密码: ${YELLOW}${PASSWORD}${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 " --------------------------------------------------------"
echo -e " * 快速状态指令: ${YELLOW}ml status${PLAIN}${YELLOW}ml${PLAIN}" echo -e " * 快速状态指令: ${YELLOW}ml status${PLAIN}${YELLOW}ml${PLAIN}"
echo -e " * 查看实时日志: ${YELLOW}ml logs${PLAIN}" echo -e " * 查看实时日志: ${YELLOW}ml logs${PLAIN}"
+395 -6
View File
@@ -3,6 +3,10 @@ from __future__ import annotations
import base64 import base64
import json import json
import os import os
import re
import shutil
import stat
import subprocess
import tempfile import tempfile
import threading import threading
import unittest 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_FILE", root / "api_snapshot.csv"),
mock.patch.object(manager, "API_CACHE_META_FILE", root / "api_snapshot.meta.json"), 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, "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: for patcher in self.path_patches:
patcher.start() patcher.start()
@@ -156,6 +162,347 @@ class ManagerLogicTests(unittest.TestCase):
self.assertEqual(5, sum(node.get("probe_status") == "available" for node in stored)) 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)) 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: def test_node_probe_stops_after_systemic_openvpn_failure(self) -> None:
nodes = self.write_nodes(12) nodes = self.write_nodes(12)
@@ -284,6 +631,16 @@ class ManagerLogicTests(unittest.TestCase):
connect_mock.assert_called_once_with("old-node") connect_mock.assert_called_once_with("old-node")
auto_switch_mock.assert_not_called() 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: def test_physical_interface_detection_is_cached(self) -> None:
original_cache = manager.vpn_utils.physical_interface_cache original_cache = manager.vpn_utils.physical_interface_cache
manager.vpn_utils.physical_interface_cache = (None, 0.0) manager.vpn_utils.physical_interface_cache = (None, 0.0)
@@ -431,6 +788,26 @@ class ManagerLogicTests(unittest.TestCase):
self.assertIn('${esc(localProxy)}', manager.INDEX_HTML) self.assertIn('${esc(localProxy)}', manager.INDEX_HTML)
self.assertIn('${esc(statusMessage)}', 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: def test_random_password_uses_cryptographic_randomness(self) -> None:
with mock.patch.object(manager.secrets, "choice", side_effect=list("aA0aA0aA0aA0")) as choice: with mock.patch.object(manager.secrets, "choice", side_effect=list("aA0aA0aA0aA0")) as choice:
password = manager.generate_random_password() password = manager.generate_random_password()
@@ -461,8 +838,8 @@ class ManagerLogicTests(unittest.TestCase):
self.assertEqual(519, entries[-1]["index"]) self.assertEqual(519, entries[-1]["index"])
def test_web_update_controls_only_expose_stable_main_channel(self) -> None: def test_web_update_controls_only_expose_stable_main_channel(self) -> None:
self.assertEqual("2.1.1", manager.APP_VERSION) self.assertEqual("2.1.5", manager.APP_VERSION)
self.assertEqual("V2.1.1 正式版", manager.APP_VERSION_LABEL) self.assertEqual("V2.1.5 正式版", manager.APP_VERSION_LABEL)
self.assertIn("检测更新", manager.INDEX_HTML) self.assertIn("检测更新", manager.INDEX_HTML)
self.assertIn("/api/check_update", manager.INDEX_HTML) self.assertIn("/api/check_update", manager.INDEX_HTML)
self.assertIn("/tree/main", manager.INDEX_HTML) self.assertIn("/tree/main", manager.INDEX_HTML)
@@ -492,11 +869,22 @@ class ManagerLogicTests(unittest.TestCase):
self.assertIn('state["active_openvpn_node_id"] = ""', install_text) self.assertIn('state["active_openvpn_node_id"] = ""', install_text)
self.assertIn("ip link show dev tun0", install_text) self.assertIn("ip link show dev tun0", install_text)
self.assertIn("pidof openvpn", 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: def test_release_workflow_uses_full_patch_version(self) -> None:
workflow_text = (manager.ROOT_DIR / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") workflow_text = (manager.ROOT_DIR / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
self.assertIn("default: v2.1.1", workflow_text) self.assertIn("default: v2.1.5", workflow_text)
self.assertIn("AimiliVPN V$(tr -d '\\r\\n' < VERSION) 正式版", workflow_text) self.assertIn("AimiliVPN V$(tr -d '\\r\\n' < VERSION) 正式版", workflow_text)
self.assertNotIn("cut -d. -f1,2 VERSION", workflow_text) self.assertNotIn("cut -d. -f1,2 VERSION", workflow_text)
@@ -523,8 +911,8 @@ class ManagerLogicTests(unittest.TestCase):
def test_latest_release_check_reports_current_formal_version(self) -> None: def test_latest_release_check_reports_current_formal_version(self) -> None:
release = { release = {
"tag_name": "v2.1.1", "tag_name": "v2.1.5",
"name": "AimiliVPN V2.1.1 正式版", "name": "AimiliVPN V2.1.5 正式版",
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
} }
@@ -532,7 +920,7 @@ class ManagerLogicTests(unittest.TestCase):
result = manager.check_latest_release() result = manager.check_latest_release()
self.assertFalse(result["update_available"]) self.assertFalse(result["update_available"])
self.assertEqual("V2.1.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: def test_latest_release_check_reports_source_update_command(self) -> None:
release = {"tag_name": "v2.2.0", "draft": False, "prerelease": False} release = {"tag_name": "v2.2.0", "draft": False, "prerelease": False}
@@ -579,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, "fetch_api_text", side_effect=fake_fetch) as fetch_mock,
mock.patch.object(manager, "load_blacklist", return_value={}), mock.patch.object(manager, "load_blacklist", return_value={}),
mock.patch.object(manager, "log_to_json"), mock.patch.object(manager, "log_to_json"),
mock.patch.object(manager, "read_mirror_freshness", return_value=(0.0, "")),
): ):
nodes = manager.fetch_candidates() nodes = manager.fetch_candidates()
+142 -24
View File
@@ -9,17 +9,25 @@ import time
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import threading import threading
import concurrent.futures
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
ROOT_DIR = Path(__file__).resolve().parent 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" 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_CACHE_FILE = DATA_DIR / "ip_cache.json"
IP_CLASSIFICATION_VERSION = 4
IP_CACHE_TTL_SECONDS = 7 * 24 * 3600
ip_cache_lock = threading.RLock() ip_cache_lock = threading.RLock()
physical_interface_lock = threading.Lock() physical_interface_lock = threading.Lock()
physical_interface_cache: tuple[str | None, float] = (None, 0.0) 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 = { COUNTRY_TRANSLATIONS = {
"Japan": "日本", "Japan": "日本",
"Korea Republic of": "韩国", "Korea Republic of": "韩国",
@@ -389,6 +397,70 @@ def save_ip_cache(cache: dict[str, dict[str, Any]]) -> None:
except Exception: except Exception:
pass 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: def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
# 1. Read cache thread-safely # 1. Read cache thread-safely
with ip_cache_lock: 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") ip = node.get("ip") or node.get("remote_host")
if not ip: if not ip:
continue 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] cached = cache[ip]
node["owner"] = cached.get("owner", "") apply_ip_cache_entry(node, cached)
node["asn"] = cached.get("asn", "")
node["as_name"] = cached.get("as_name", "")
node["location"] = cached.get("location", "")
node["ip_type"] = cached.get("ip_type", "")
node["quality"] = cached.get("quality", "")
else: else:
if ip not in ips_to_query: if ip not in ips_to_query:
ips_to_query.append(ip) 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] chunk = ips_to_query[i : i + chunk_size]
payload = json.dumps(chunk).encode("utf-8") payload = json.dumps(chunk).encode("utf-8")
request = urllib.request.Request( 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, 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", method="POST",
) )
try: try:
@@ -442,19 +517,15 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
if not query_ip: if not query_ip:
continue continue
ip_type = "residential" ip_type, ip_type_reason = classify_ip_type(item)
if item.get("mobile"):
ip_type = "mobile"
elif item.get("hosting") or item.get("proxy"):
ip_type = "hosting"
quality = "normal" quality = "normal"
if item.get("proxy"): if item.get("mobile"):
quality = "proxy" quality = "mobile"
elif item.get("hosting"): elif item.get("hosting"):
quality = "datacenter" quality = "datacenter"
elif item.get("mobile"): elif item.get("proxy"):
quality = "mobile" quality = "proxy"
loc = " ".join(part for part in [item.get("country"), item.get("regionName"), item.get("city")] if part) 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 "", "asn": item.get("as") or "",
"as_name": item.get("asname") or "", "as_name": item.get("asname") or "",
"location": loc, "location": loc,
"geo_country_short": str(item.get("countryCode") or "").upper(),
"ip_type": ip_type, "ip_type": ip_type,
"quality": quality, "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, "cached_at": now,
} }
except Exception as e: except Exception as e:
print(f"[enrich_ip_info] Query failed: {e}", flush=True) 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: if not new_entries:
return return
@@ -484,12 +607,7 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
ip = node.get("ip") or node.get("remote_host") ip = node.get("ip") or node.get("remote_host")
if ip in new_entries: if ip in new_entries:
cached = new_entries[ip] cached = new_entries[ip]
node["owner"] = cached.get("owner", "") apply_ip_cache_entry(node, cached)
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", "")
def diagnose_api_failure(api_url: str = "https://www.vpngate.net/api/iphone/") -> tuple[int, str]: def diagnose_api_failure(api_url: str = "https://www.vpngate.net/api/iphone/") -> tuple[int, str]:
+264 -68
View File
@@ -88,6 +88,12 @@ def bounded_int(value: Any, default: int, min_value: int | None = None, max_valu
return default return default
return parsed return parsed
def ports_conflict(web_port: Any, proxy_port: Any) -> bool:
try:
return int(web_port) == int(proxy_port)
except (TypeError, ValueError):
return False
API_HTTPS_URL = os.environ.get("VPNGATE_API_HTTPS_URL", "https://www.vpngate.net/api/iphone/").strip() API_HTTPS_URL = os.environ.get("VPNGATE_API_HTTPS_URL", "https://www.vpngate.net/api/iphone/").strip()
API_HTTP_URL = os.environ.get("VPNGATE_API_HTTP_URL", "http://www.vpngate.net/api/iphone/").strip() API_HTTP_URL = os.environ.get("VPNGATE_API_HTTP_URL", "http://www.vpngate.net/api/iphone/").strip()
MIRROR_HTTPS_URL = os.environ.get( MIRROR_HTTPS_URL = os.environ.get(
@@ -98,6 +104,10 @@ MIRROR_HTTP_URL = os.environ.get(
"VPNGATE_MIRROR_HTTP_URL", "VPNGATE_MIRROR_HTTP_URL",
"http://baoweise-bot.github.io/aimili-vpngate/vpngate.csv", "http://baoweise-bot.github.io/aimili-vpngate/vpngate.csv",
).strip() ).strip()
MIRROR_META_URL = os.environ.get(
"VPNGATE_MIRROR_META_URL",
"https://baoweise-bot.github.io/aimili-vpngate/vpngate.meta.json",
).strip()
# Kept as the primary URL for diagnostics and backwards-compatible state output. # Kept as the primary URL for diagnostics and backwards-compatible state output.
API_URL = API_HTTPS_URL API_URL = API_HTTPS_URL
FETCH_INTERVAL_SECONDS = env_int("FETCH_INTERVAL_SECONDS", 1260, 1) FETCH_INTERVAL_SECONDS = env_int("FETCH_INTERVAL_SECONDS", 1260, 1)
@@ -105,6 +115,7 @@ CHECK_INTERVAL_SECONDS = env_int("CHECK_INTERVAL_SECONDS", 1260, 1)
TARGET_VALID_NODES = env_int("TARGET_VALID_NODES", 3, 1) TARGET_VALID_NODES = env_int("TARGET_VALID_NODES", 3, 1)
MAX_SCAN_ROWS = env_int("MAX_SCAN_ROWS", 300, 1) MAX_SCAN_ROWS = env_int("MAX_SCAN_ROWS", 300, 1)
API_FETCH_TIMEOUT_SECONDS = env_int("API_FETCH_TIMEOUT_SECONDS", 10, 1, 60) API_FETCH_TIMEOUT_SECONDS = env_int("API_FETCH_TIMEOUT_SECONDS", 10, 1, 60)
API_SOURCE_DEADLINE_SECONDS = env_int("API_SOURCE_DEADLINE_SECONDS", 6, 2, 30)
OPENVPN_TEST_TIMEOUT_SECONDS = env_int("OPENVPN_TEST_TIMEOUT_SECONDS", 35, 1) OPENVPN_TEST_TIMEOUT_SECONDS = env_int("OPENVPN_TEST_TIMEOUT_SECONDS", 35, 1)
MANUAL_TEST_NODE_LIMIT = env_int("MANUAL_TEST_NODE_LIMIT", 5, 1, 20) MANUAL_TEST_NODE_LIMIT = env_int("MANUAL_TEST_NODE_LIMIT", 5, 1, 20)
INITIAL_CONNECT_TEST_LIMIT = env_int("INITIAL_CONNECT_TEST_LIMIT", 10, 1, 50) INITIAL_CONNECT_TEST_LIMIT = env_int("INITIAL_CONNECT_TEST_LIMIT", 10, 1, 50)
@@ -130,7 +141,7 @@ UPDATE_COMMAND = (
) )
ROOT_DIR = Path(sys.executable).resolve().parent if globals().get("__compiled__") else Path(__file__).resolve().parent ROOT_DIR = Path(sys.executable).resolve().parent if globals().get("__compiled__") else Path(__file__).resolve().parent
DEFAULT_APP_VERSION = "2.1.1" DEFAULT_APP_VERSION = "2.1.5"
try: try:
_version_text = (ROOT_DIR / "VERSION").read_text(encoding="utf-8").strip() _version_text = (ROOT_DIR / "VERSION").read_text(encoding="utf-8").strip()
except OSError: except OSError:
@@ -175,10 +186,30 @@ last_collector_heartbeat = 0.0
last_checker_heartbeat = 0.0 last_checker_heartbeat = 0.0
last_pinger_heartbeat = 0.0 last_pinger_heartbeat = 0.0
server_start_time = time.time() server_start_time = time.time()
ip_enrichment_wakeup = threading.Event()
IP_ENRICHMENT_FIELDS = (
"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",
)
class ConnectionCancelled(RuntimeError): class ConnectionCancelled(RuntimeError):
pass pass
class SourceDeadlineExceeded(TimeoutError):
pass
def purge_expired_sessions(now: float | None = None) -> int: def purge_expired_sessions(now: float | None = None) -> int:
current_time = time.time() if now is None else now current_time = time.time() if now is None else now
with lock: with lock:
@@ -220,7 +251,17 @@ def write_json(path: Path, data: Any) -> None:
with lock: with lock:
tmp = path.with_suffix(path.suffix + ".tmp") tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
if path.name == "ui_auth.json":
try:
tmp.chmod(0o600)
except OSError:
pass
tmp.replace(path) tmp.replace(path)
if path.name == "ui_auth.json":
try:
path.chmod(0o600)
except OSError:
pass
def read_json(path: Path, default: Any) -> Any: def read_json(path: Path, default: Any) -> Any:
with lock: with lock:
@@ -292,6 +333,10 @@ def load_ui_config() -> dict[str, Any]:
} }
updated = False updated = False
if auth_file.exists(): if auth_file.exists():
try:
auth_file.chmod(0o600)
except OSError:
pass
try: try:
data = json.loads(auth_file.read_text(encoding="utf-8")) data = json.loads(auth_file.read_text(encoding="utf-8"))
for key, val in data.items(): for key, val in data.items():
@@ -464,6 +509,8 @@ def get_state() -> dict[str, Any]:
state.setdefault("last_fetch_status", "not_started") state.setdefault("last_fetch_status", "not_started")
state.setdefault("last_check_message", "") state.setdefault("last_check_message", "")
state.setdefault("pending_node_id", "") state.setdefault("pending_node_id", "")
state.setdefault("tunnel_ready", False)
state.setdefault("proxy_ready", bool(state.get("proxy_ok", False)))
state.setdefault("blacklisted_nodes", 0) state.setdefault("blacklisted_nodes", 0)
state["app_version"] = APP_VERSION state["app_version"] = APP_VERSION
state["app_version_label"] = APP_VERSION_LABEL state["app_version_label"] = APP_VERSION_LABEL
@@ -505,6 +552,8 @@ def clear_active_connection_state(message: str) -> None:
pending_node_id="", pending_node_id="",
active_node_latency="无活动连接", active_node_latency="无活动连接",
proxy_ok=False, proxy_ok=False,
tunnel_ready=False,
proxy_ready=False,
proxy_ip="-", proxy_ip="-",
proxy_latency_ms=0, proxy_latency_ms=0,
proxy_error=message, proxy_error=message,
@@ -776,6 +825,29 @@ def fetch_api_text(url: str | None = None, use_ssl_verify: bool = True) -> str:
with urllib.request.urlopen(request, timeout=API_FETCH_TIMEOUT_SECONDS) as response: with urllib.request.urlopen(request, timeout=API_FETCH_TIMEOUT_SECONDS) as response:
return read_limited(response).decode("utf-8", errors="replace") return read_limited(response).decode("utf-8", errors="replace")
def fetch_api_text_with_deadline(
url: str,
use_ssl_verify: bool = True,
deadline_seconds: int | None = None,
) -> str:
deadline = deadline_seconds or API_SOURCE_DEADLINE_SECONDS
result_queue: queue.Queue[tuple[bool, Any]] = queue.Queue(maxsize=1)
def worker() -> None:
try:
result_queue.put((True, fetch_api_text(url, use_ssl_verify)))
except BaseException as exc:
result_queue.put((False, exc))
threading.Thread(target=worker, daemon=True).start()
try:
ok, value = result_queue.get(timeout=deadline)
except queue.Empty as exc:
raise SourceDeadlineExceeded(f"节点源超过 {deadline} 秒总时限") from exc
if ok:
return str(value)
raise value
def parse_release_version(value: Any) -> tuple[int, int, int]: def parse_release_version(value: Any) -> tuple[int, int, int]:
match = re.search(r"(?i)(?:^|[^a-z0-9])v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", str(value or "").strip()) match = re.search(r"(?i)(?:^|[^a-z0-9])v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", str(value or "").strip())
if not match: if not match:
@@ -908,7 +980,7 @@ def api_network_sources() -> list[tuple[str, str]]:
("official_https", API_HTTPS_URL), ("official_https", API_HTTPS_URL),
("official_http", API_HTTP_URL), ("official_http", API_HTTP_URL),
("github_pages_https", MIRROR_HTTPS_URL), ("github_pages_https", MIRROR_HTTPS_URL),
("github_pages_http", MIRROR_HTTP_URL), ("github_pages_http_redirect_https", MIRROR_HTTP_URL),
] ]
sources: list[tuple[str, str]] = [] sources: list[tuple[str, str]] = []
seen: set[str] = set() seen: set[str] = set()
@@ -948,6 +1020,25 @@ def cache_api_snapshot(text: str, source: str) -> None:
}, },
) )
def read_mirror_freshness() -> tuple[float, str]:
if not MIRROR_META_URL:
return 0.0, ""
try:
raw = fetch_api_text_with_deadline(MIRROR_META_URL, True, deadline_seconds=2)
meta = json.loads(raw)
generated_at = float(meta.get("generated_at", 0) or 0)
if generated_at <= 0:
return 0.0, ""
age_seconds = max(0, int(time.time() - generated_at))
if age_seconds < 3600:
age_text = f"{max(1, age_seconds // 60)} 分钟"
else:
age_text = f"{age_seconds / 3600:.1f} 小时"
return generated_at, f"镜像生成于 {age_text}"
except Exception as exc:
print(f"[镜像元数据] 读取失败: {exc}", flush=True)
return 0.0, "镜像生成时间未知"
def rows_to_candidates( def rows_to_candidates(
rows: list[dict[str, str]], rows: list[dict[str, str]],
blacklist: dict[str, dict[str, Any]], blacklist: dict[str, dict[str, Any]],
@@ -992,7 +1083,6 @@ def fetch_candidates() -> list[dict[str, Any]]:
load_ui_config().get("discovery_countries") load_ui_config().get("discovery_countries")
) )
last_err: Exception | None = None last_err: Exception | None = None
log_to_json("INFO", "Main", "开始按官方、GitHub Pages、本地缓存顺序拉取节点列表...") log_to_json("INFO", "Main", "开始按官方、GitHub Pages、本地缓存顺序拉取节点列表...")
for source_name, url in api_network_sources(): for source_name, url in api_network_sources():
@@ -1000,7 +1090,7 @@ def fetch_candidates() -> list[dict[str, Any]]:
msg = f"尝试节点源 {source_name}: {url}" msg = f"尝试节点源 {source_name}: {url}"
print(f"[fetch_candidates] {msg}", flush=True) print(f"[fetch_candidates] {msg}", flush=True)
log_to_json("INFO", "Main", msg) log_to_json("INFO", "Main", msg)
api_text = fetch_api_text(url, True) api_text = fetch_api_text_with_deadline(url, True)
rows = parse_vpngate_rows(api_text) rows = parse_vpngate_rows(api_text)
candidates = rows_to_candidates(rows, blacklist) candidates = rows_to_candidates(rows, blacklist)
if not candidates: if not candidates:
@@ -1020,14 +1110,21 @@ def fetch_candidates() -> list[dict[str, Any]]:
if discovery_countries if discovery_countries
else f"保留全部 {len(filtered_candidates)}" else f"保留全部 {len(filtered_candidates)}"
) )
mirror_generated_at = 0.0
mirror_freshness = ""
if source_name.startswith("github_pages"):
mirror_generated_at, mirror_freshness = read_mirror_freshness()
source_note = f"{mirror_freshness}" if mirror_freshness else ""
set_state( set_state(
last_fetch_at=time.time(), last_fetch_at=time.time(),
last_fetch_status="ok", last_fetch_status="ok",
last_fetch_source=source_name, last_fetch_source=source_name,
last_fetch_message=( last_fetch_message=(
f"{source_name} 成功获取 {len(candidates)} 个候选节点,{scope_message}" f"{source_name} 成功获取 {len(candidates)} 个候选节点,{scope_message}{source_note}"
), ),
mirror_generated_at=mirror_generated_at,
mirror_freshness=mirror_freshness,
blacklisted_nodes=len(blacklist), blacklisted_nodes=len(blacklist),
) )
log_to_json( log_to_json(
@@ -1151,6 +1248,8 @@ def openvpn_command(config_file: str, route_nopull: bool, dev: str = "tun0") ->
"--auth-user-pass", "--auth-user-pass",
str(AUTH_FILE), str(AUTH_FILE),
"--auth-nocache", "--auth-nocache",
"--remote-cert-tls",
"server",
] ]
) )
@@ -1509,6 +1608,17 @@ def stop_active_openvpn() -> None:
def active_openvpn_running() -> bool: def active_openvpn_running() -> bool:
return active_openvpn_process is not None and active_openvpn_process.poll() is None return active_openvpn_process is not None and active_openvpn_process.poll() is None
def connection_ready_for_ui(state: dict[str, Any] | None = None) -> bool:
current = get_state() if state is None else state
return bool(
active_openvpn_node_id
and active_openvpn_running()
and current.get("tunnel_ready")
and current.get("proxy_ready")
and current.get("proxy_ok")
and not current.get("is_connecting")
)
def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
available_nodes = sorted( available_nodes = sorted(
[n for n in nodes if n.get("probe_status") == "available" or n.get("active")], [n for n in nodes if n.get("probe_status") == "available" or n.get("active")],
@@ -1528,6 +1638,50 @@ def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
) )
return available_nodes + untested_nodes + unavailable_nodes return available_nodes + untested_nodes + unavailable_nodes
def enrich_stored_nodes() -> int:
"""Enrich every listed IP, then merge only metadata into the latest state."""
with lock:
snapshot = read_nodes()
if not snapshot:
return 0
vpn_utils.enrich_ip_info(snapshot)
enriched_by_id = {
str(node.get("id") or ""): node
for node in snapshot
if node.get("id") and node.get("ip_type")
}
if not enriched_by_id:
return 0
changed = 0
with lock:
current_nodes = read_nodes()
for current in current_nodes:
enriched = enriched_by_id.get(str(current.get("id") or ""))
if not enriched:
continue
for field in IP_ENRICHMENT_FIELDS:
new_value = enriched.get(field, "")
if current.get(field, "") != new_value:
current[field] = new_value
changed += 1
if changed:
write_json(NODES_FILE, sort_all_nodes(current_nodes))
return changed
def ip_enrichment_loop() -> None:
while True:
nodes_exist = bool(read_nodes())
if nodes_exist:
try:
enrich_stored_nodes()
except Exception as exc:
print(f"[IP 类型] 后台批量识别失败: {exc}", flush=True)
log_to_json("WARNING", "Main", f"后台批量识别 IP 类型失败: {exc}")
ip_enrichment_wakeup.wait(300 if nodes_exist else 5)
ip_enrichment_wakeup.clear()
def apply_routing_filters( def apply_routing_filters(
nodes: list[dict[str, Any]], nodes: list[dict[str, Any]],
ui_cfg: dict[str, Any], ui_cfg: dict[str, Any],
@@ -1540,7 +1694,7 @@ def apply_routing_filters(
if routing_mode == "fixed_region" and target_country: if routing_mode == "fixed_region" and target_country:
candidates = [ candidates = [
n for n in candidates n for n in candidates
if country_matches(n.get("country"), target_country) if country_matches(n.get("country"), target_country, n.get("country_short"))
] ]
elif routing_mode == "favorites": elif routing_mode == "favorites":
fav_ids = set(ui_cfg.get("favorite_node_ids", [])) fav_ids = set(ui_cfg.get("favorite_node_ids", []))
@@ -1550,7 +1704,10 @@ def apply_routing_filters(
if routing_ip_type == "residential": if routing_ip_type == "residential":
candidates = [ candidates = [
n for n in candidates n for n in candidates
if n.get("ip_type") in ("residential", "mobile") if (
n.get("ip_type") in ("residential", "mobile")
and n.get("ip_type_confidence") in ("medium", "high")
)
or (include_unknown_ip_type and not n.get("ip_type")) or (include_unknown_ip_type and not n.get("ip_type"))
] ]
elif routing_ip_type == "hosting": elif routing_ip_type == "hosting":
@@ -1566,8 +1723,34 @@ def normalized_country_name(country: Any) -> str:
value = str(country or "").strip() value = str(country or "").strip()
return vpn_utils.COUNTRY_TRANSLATIONS.get(value, value) return vpn_utils.COUNTRY_TRANSLATIONS.get(value, value)
def country_matches(node_country: Any, target_country: Any) -> bool: def normalize_routing_country(value: Any, nodes: list[dict[str, Any]] | None = None) -> str:
return bool(target_country) and normalized_country_name(node_country) == normalized_country_name(target_country) target = str(value or "").strip()
if not target:
return ""
upper = target.upper()
if re.fullmatch(r"[A-Z]{2}", upper):
return upper
normalized_target = normalized_country_name(target).casefold()
for node in nodes if nodes is not None else read_nodes():
code = str(node.get("country_short") or "").strip().upper()
if not re.fullmatch(r"[A-Z]{2}", code):
continue
if normalized_country_name(node.get("country")).casefold() == normalized_target:
return code
return target
def country_matches(
node_country: Any,
target_country: Any,
node_country_short: Any = "",
) -> bool:
target = str(target_country or "").strip()
if not target:
return False
target_upper = target.upper()
if re.fullmatch(r"[A-Z]{2}", target_upper):
return str(node_country_short or "").strip().upper() == target_upper
return normalized_country_name(node_country).casefold() == normalized_country_name(target).casefold()
def probe_priority_key(node: dict[str, Any]) -> tuple[int, int, int, int]: def probe_priority_key(node: dict[str, Any]) -> tuple[int, int, int, int]:
ping = parse_int(node.get("ping")) or 999999 ping = parse_int(node.get("ping")) or 999999
@@ -1593,7 +1776,7 @@ def validate_node_allowed_by_routing(node: dict[str, Any], ui_cfg: dict[str, Any
if routing_mode == "fixed_region": if routing_mode == "fixed_region":
target_country = ui_cfg.get("force_country", "") target_country = ui_cfg.get("force_country", "")
if target_country and not country_matches(node.get("country"), target_country): if target_country and not country_matches(node.get("country"), target_country, node.get("country_short")):
raise RuntimeError(f"当前已锁定国家【{target_country}】,不能连接其他国家节点") raise RuntimeError(f"当前已锁定国家【{target_country}】,不能连接其他国家节点")
elif routing_mode == "favorites": elif routing_mode == "favorites":
fav_ids = set(ui_cfg.get("favorite_node_ids", [])) fav_ids = set(ui_cfg.get("favorite_node_ids", []))
@@ -1744,12 +1927,10 @@ def test_node_by_id(node_id: str) -> dict[str, Any]:
node["probe_message"] = message node["probe_message"] = message
node["probed_at"] = time.time() node["probed_at"] = time.time()
if ok: if ok:
node["owner"] = temp_node["owner"] for field in IP_ENRICHMENT_FIELDS:
node["asn"] = temp_node["asn"] value = temp_node.get(field)
node["as_name"] = temp_node["as_name"] if value not in (None, ""):
node["location"] = temp_node["location"] node[field] = value
node["ip_type"] = temp_node["ip_type"]
node["quality"] = temp_node["quality"]
sorted_nodes = sort_all_nodes(nodes) sorted_nodes = sort_all_nodes(nodes)
write_json(NODES_FILE, sorted_nodes) write_json(NODES_FILE, sorted_nodes)
@@ -1796,12 +1977,6 @@ def test_multiple_nodes(node_ids: list[str], target_available: int | None = None
"probe_status": "unavailable", "probe_status": "unavailable",
"probe_message": f"Failed to write configuration: {e}", "probe_message": f"Failed to write configuration: {e}",
"probed_at": time.time(), "probed_at": time.time(),
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
} }
latency = vpn_utils.ping_latency_ms(h, p, fallback_ping) latency = vpn_utils.ping_latency_ms(h, p, fallback_ping)
@@ -1828,12 +2003,6 @@ def test_multiple_nodes(node_ids: list[str], target_available: int | None = None
"probe_status": "available" if ok else "unavailable", "probe_status": "available" if ok else "unavailable",
"probe_message": message, "probe_message": message,
"probed_at": time.time(), "probed_at": time.time(),
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
} }
return temp_node return temp_node
@@ -1953,6 +2122,8 @@ def schedule_background_refill() -> bool:
def auto_switch_node(attempt: int = 0) -> None: def auto_switch_node(attempt: int = 0) -> None:
if attempt >= 3: if attempt >= 3:
print("[自动切换] 连续切换失败已达 3 次,停止切换以防止主线程死锁,将在后台重新加载节点...", flush=True) print("[自动切换] 连续切换失败已达 3 次,停止切换以防止主线程死锁,将在后台重新加载节点...", flush=True)
if schedule_background_refill():
log_to_json("INFO", "Main", "连续自动切换失败,已启动唯一后台节点补齐任务")
return return
ui_cfg = load_ui_config() ui_cfg = load_ui_config()
@@ -2039,6 +2210,9 @@ def connect_node(node_id: str) -> str:
set_state( set_state(
is_connecting=True, is_connecting=True,
pending_node_id=node_id, pending_node_id=node_id,
tunnel_ready=False,
proxy_ready=False,
proxy_ok=False,
active_node_latency="正在连接", active_node_latency="正在连接",
last_check_message=f"正在初始化连接配置: {node_id}", last_check_message=f"正在初始化连接配置: {node_id}",
) )
@@ -2144,6 +2318,7 @@ def connect_node(node_id: str) -> str:
raise ConnectionCancelled("连接操作已取消") raise ConnectionCancelled("连接操作已取消")
active_openvpn_process = process active_openvpn_process = process
active_openvpn_node_id = node_id active_openvpn_node_id = node_id
set_state(tunnel_ready=True, proxy_ready=False)
set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...") set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...")
routing_ready = setup_policy_routing("tun0") routing_ready = setup_policy_routing("tun0")
@@ -2201,6 +2376,8 @@ def connect_node(node_id: str) -> str:
last_check_message=f"Connected {node_id}", last_check_message=f"Connected {node_id}",
active_node_latency=latency_str, active_node_latency=latency_str,
proxy_ok=True, proxy_ok=True,
tunnel_ready=True,
proxy_ready=True,
proxy_ip=res["ip"], proxy_ip=res["ip"],
proxy_latency_ms=res["latency_ms"], proxy_latency_ms=res["latency_ms"],
proxy_error="", proxy_error="",
@@ -2314,6 +2491,10 @@ def maintain_valid_nodes(force: bool = False) -> str:
"location", "location",
"ip_type", "ip_type",
"quality", "quality",
"is_proxy",
"is_hosting",
"is_mobile",
"ip_type_reason",
]: ]:
if previous.get(key) not in (None, ""): if previous.get(key) not in (None, ""):
cand[key] = previous.get(key) cand[key] = previous.get(key)
@@ -2332,6 +2513,7 @@ def maintain_valid_nodes(force: bool = False) -> str:
pass pass
write_json(NODES_FILE, merged) write_json(NODES_FILE, merged)
ip_enrichment_wakeup.set()
initial_tested_ids: set[str] = set() initial_tested_ids: set[str] = set()
fast_results: list[dict[str, Any]] = [] fast_results: list[dict[str, Any]] = []
@@ -3985,12 +4167,12 @@ INDEX_HTML = r"""<!doctype html>
<div class="dropdown"> <div class="dropdown">
<button id="github_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="github_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);"> <button id="github_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="github_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
<span id="github_version_label">V2.1.1 正式版</span> <span id="github_version_label">V2.1.5 正式版</span>
<svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg> <svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
</button> </button>
<div id="github_dropdown" class="dropdown-content github-dropdown"> <div id="github_dropdown" class="dropdown-content github-dropdown">
<div class="version-current"> <div class="version-current">
<div id="current_version_label" class="version-current-label">V2.1.1 正式版</div> <div id="current_version_label" class="version-current-label">V2.1.5 正式版</div>
<div id="deployment_mode_label" class="version-current-meta">Python 源码部署 · 更新通道main</div> <div id="deployment_mode_label" class="version-current-meta">Python 源码部署 · 更新通道main</div>
</div> </div>
<button id="check_update_btn" type="button" onclick="checkForUpdate(event)"> <button id="check_update_btn" type="button" onclick="checkForUpdate(event)">
@@ -4559,10 +4741,12 @@ const translateQuality = q => {
}; };
const translateIpType = t => { const translateIpType = t => {
const dict = {"residential": "住宅 IP", "hosting": "机房 IP", "mobile": "移动网", "proxy": "代理 IP"}; const dict = {"residential": "住宅 IP", "hosting": "机房 IP", "mobile": "移动网", "unknown": "未知", "proxy": "代理 IP"};
return dict[t] || t || "-"; return dict[t] || t || "-";
}; };
const translateConfidence = value => ({high: "", medium: "", low: ""}[value] || "未知");
const translateCountry = c => { const translateCountry = c => {
const dict = { const dict = {
"Japan": "日本", "Japan": "日本",
@@ -4815,7 +4999,7 @@ function stableSortNodes() {
} }
function render(){ function render(){
const versionLabel = state.app_version_label || "V2.1.1 正式版"; const versionLabel = state.app_version_label || "V2.1.5 正式版";
if ($("github_version_label")) $("github_version_label").textContent = versionLabel; if ($("github_version_label")) $("github_version_label").textContent = versionLabel;
if ($("current_version_label")) $("current_version_label").textContent = versionLabel; if ($("current_version_label")) $("current_version_label").textContent = versionLabel;
if ($("deployment_mode_label")) { if ($("deployment_mode_label")) {
@@ -4823,8 +5007,7 @@ function render(){
$("deployment_mode_label").textContent = `${modeLabel}部署 · 更新通道main`; $("deployment_mode_label").textContent = `${modeLabel}部署 · 更新通道main`;
} }
const activeNodeId = state.active_openvpn_node_id; const activeNode = nodes.find(n => n && n.active);
const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId));
// Render separated Active Node Card // Render separated Active Node Card
const activeCardContainer = $("active_node_card"); const activeCardContainer = $("active_node_card");
@@ -4854,7 +5037,9 @@ function render(){
} else if (activeNode) { } else if (activeNode) {
const latencyText = nodeLatencyHtml(activeNode); const latencyText = nodeLatencyHtml(activeNode);
const displayLocation = activeNode.location || translateCountry(activeNode.country) || "-"; const displayLocation = activeNode.location || translateCountry(activeNode.country) || "-";
const activeFlag = countryFlag(activeNode.country_short); const declaredFlag = countryFlag(activeNode.country_short);
const locationFlag = countryFlag(activeNode.geo_country_short || activeNode.country_short);
const ipTypeTitle = `${translateIpType(activeNode.ip_type)} · 置信度${translateConfidence(activeNode.ip_type_confidence)} · 来源${(activeNode.ip_type_sources || []).join(" + ") || "未知"}`;
activeCardHtml = ` activeCardHtml = `
<div class="active-card"> <div class="active-card">
<div class="active-card-info"> <div class="active-card-info">
@@ -4864,16 +5049,16 @@ function render(){
<div class="active-card-details"> <div class="active-card-details">
<div class="active-card-title"> <div class="active-card-title">
<span class="badge available"><span class="badge-pulse"></span>已连接</span> <span class="badge available"><span class="badge-pulse"></span>已连接</span>
<strong>${esc(translateCountry(activeNode.country))} 节点</strong> <strong>${declaredFlag ? `${esc(declaredFlag)} ` : ""}${esc(translateCountry(activeNode.country))} 节点</strong>
</div> </div>
<div class="active-card-value mono" style="font-size: 20px; margin-top: 2px;"> <div class="active-card-value mono" style="font-size: 20px; margin-top: 2px;">
${esc(activeNode.ip || activeNode.remote_host)}:${activeNode.remote_port || ""} ${esc(activeNode.ip || activeNode.remote_host)}:${activeNode.remote_port || ""}
</div> </div>
<div class="active-card-meta" style="margin-top: 4px;"> <div class="active-card-meta" style="margin-top: 4px;">
<span>物理位置: <strong>${activeFlag ? `${esc(activeFlag)} ` : ""}${esc(displayLocation)}</strong></span> <span title="IP 情报源推测位置;节点申报国家见标题">物理位置: <strong>${locationFlag ? `${esc(locationFlag)} ` : ""}${esc(displayLocation)}</strong></span>
<span style="margin-left: 12px;">延时: <strong>${latencyText}</strong></span> <span style="margin-left: 12px;">延时: <strong>${latencyText}</strong></span>
<span style="margin-left: 12px;">运营主体: <strong>${esc(activeNode.owner || activeNode.as_name || "-")}</strong></span> <span style="margin-left: 12px;">运营主体: <strong>${esc(activeNode.owner || activeNode.as_name || "-")}</strong></span>
<span style="margin-left: 12px;">IP 类型: <strong>${esc(translateIpType(activeNode.ip_type))}</strong></span> <span style="margin-left: 12px;" title="${esc(ipTypeTitle)}">IP 类型: <strong>${esc(translateIpType(activeNode.ip_type))}</strong></span>
</div> </div>
</div> </div>
</div> </div>
@@ -4992,7 +5177,11 @@ function render(){
const badgeText = isCurrentlyActive ? '<span class="badge-pulse"></span>已连接' : (isPending ? '<span class="badge-pulse"></span>切换中' : translateStatus(n.probe_status)); const badgeText = isCurrentlyActive ? '<span class="badge-pulse"></span>已连接' : (isPending ? '<span class="badge-pulse"></span>切换中' : translateStatus(n.probe_status));
const latencyText = nodeLatencyHtml(n); const latencyText = nodeLatencyHtml(n);
const displayLocation = n.location || translateCountry(n.country) || "-"; const displayLocation = n.location || translateCountry(n.country) || "-";
const flag = countryFlag(n.country_short); const flag = countryFlag(n.geo_country_short || n.country_short);
const locationTitle = n.location
? `IP 推测位置${displayLocation}节点申报国家${translateCountry(n.country)}`
: `节点申报国家${translateCountry(n.country)}`;
const ipTypeTitle = `${translateIpType(n.ip_type)} · 置信度${translateConfidence(n.ip_type_confidence)} · 来源${(n.ip_type_sources || []).join(" + ") || "未知"}`;
const isTesting = testingNodeIds.has(n.id) || n.probe_status === "testing"; const isTesting = testingNodeIds.has(n.id) || n.probe_status === "testing";
const testSpinner = `<svg style="animation: spin 1s linear infinite; width: 12px; height: 12px; display: inline-block; margin-right: 4px; vertical-align: middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.2" fill="none"></circle><path d="M4 12a8 8 0 018-8" stroke="currentColor" fill="none"></path></svg>`; const testSpinner = `<svg style="animation: spin 1s linear infinite; width: 12px; height: 12px; display: inline-block; margin-right: 4px; vertical-align: middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.2" fill="none"></circle><path d="M4 12a8 8 0 018-8" stroke="currentColor" fill="none"></path></svg>`;
@@ -5017,9 +5206,9 @@ function render(){
<td><span class="badge ${badgeClass}">${badgeText}</span></td> <td><span class="badge ${badgeClass}">${badgeText}</span></td>
<td class="mono" style="white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.ip||n.remote_host)}:${n.remote_port||""}">${esc(n.ip||n.remote_host)}:${n.remote_port||""}</td> <td class="mono" style="white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.ip||n.remote_host)}:${n.remote_port||""}">${esc(n.ip||n.remote_host)}:${n.remote_port||""}</td>
<td style="white-space: nowrap;">${latencyText}</td> <td style="white-space: nowrap;">${latencyText}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(displayLocation)}">${flag ? `<span aria-hidden="true">${esc(flag)}</span> ` : ""}${esc(displayLocation)}</td> <td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(locationTitle)}">${flag ? `<span aria-hidden="true">${esc(flag)}</span> ` : ""}${esc(displayLocation)}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.owner||n.as_name||"-")}">${esc(n.owner||n.as_name||"-")}</td> <td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.owner||n.as_name||"-")}">${esc(n.owner||n.as_name||"-")}</td>
<td style="white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis;" title="${esc(translateIpType(n.ip_type))}">${esc(translateIpType(n.ip_type))}</td> <td style="white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis;" title="${esc(ipTypeTitle)}">${esc(translateIpType(n.ip_type))}</td>
<td> <td>
<div class="table-actions"> <div class="table-actions">
${testBtn} ${testBtn}
@@ -5618,21 +5807,30 @@ function populateRoutingCountries() {
if (!select) return; if (!select) return;
const countMap = {}; const countMap = {};
nodes.forEach(n => { nodes.forEach(n => {
const code = String(n.country_short || "").trim().toUpperCase();
const c = translateCountry(n.country); const c = translateCountry(n.country);
if (c) { if (/^[A-Z]{2}$/.test(code) && c) {
countMap[c] = (countMap[c] || 0) + 1; const current = countMap[code] || {name: c, count: 0};
current.count += 1;
countMap[code] = current;
} }
}); });
const countries = Object.keys(countMap).sort(); const countries = Object.keys(countMap).sort((a, b) => countMap[a].name.localeCompare(countMap[b].name, "zh-CN"));
let html = '<option value="">请选择要锁定的国家...</option>'; let html = '<option value="">请选择要锁定的国家...</option>';
countries.forEach(c => { countries.forEach(code => {
html += `<option value="${esc(c)}">${esc(c)} (${countMap[c]}个节点)</option>`; html += `<option value="${esc(code)}">${esc(countryFlag(code))} ${esc(countMap[code].name)} (${countMap[code].count}个节点)</option>`;
}); });
select.innerHTML = html; select.innerHTML = html;
if (state) { if (state) {
select.value = state.force_country ? translateCountry(state.force_country) : ""; const saved = String(state.force_country || "").trim();
if (/^[A-Za-z]{2}$/.test(saved)) {
select.value = saved.toUpperCase();
} else {
const legacy = countries.find(code => countMap[code].name === translateCountry(saved));
select.value = legacy || "";
}
} }
} }
@@ -6428,9 +6626,11 @@ class Handler(BaseHTTPRequestHandler):
elif effective_path == "/api/nodes": elif effective_path == "/api/nodes":
global last_active_ping_time, last_active_latency, active_openvpn_node_id global last_active_ping_time, last_active_latency, active_openvpn_node_id
nodes = read_nodes() nodes = read_nodes()
active_node = next((n for n in nodes if active_openvpn_node_id and n.get("id") == active_openvpn_node_id), None) connection_state = get_state()
connection_ready = connection_ready_for_ui(connection_state)
active_node = next((n for n in nodes if connection_ready and n.get("id") == active_openvpn_node_id), None)
for n in nodes: for n in nodes:
n["active"] = (active_openvpn_node_id and n.get("id") == active_openvpn_node_id) n["active"] = bool(connection_ready and n.get("id") == active_openvpn_node_id)
if active_node: if active_node:
ip = active_node.get("ip") or active_node.get("remote_host") ip = active_node.get("ip") or active_node.get("remote_host")
if ip: if ip:
@@ -6688,6 +6888,10 @@ class Handler(BaseHTTPRequestHandler):
expected_port = ui_cfg.get("port", 8787) expected_port = ui_cfg.get("port", 8787)
expected_suffix = ui_cfg.get("secret_path", "EJsW2EeBo9lY") expected_suffix = ui_cfg.get("secret_path", "EJsW2EeBo9lY")
if ports_conflict(new_port_int, ui_cfg.get("proxy_port", 7928)):
self.send_json({"ok": False, "error": "网页管理端口不能与代理出站端口相同"}, HTTPStatus.BAD_REQUEST)
return
ui_cfg["username"] = new_username ui_cfg["username"] = new_username
if new_password: if new_password:
ui_cfg["password"] = new_password ui_cfg["password"] = new_password
@@ -6724,7 +6928,7 @@ class Handler(BaseHTTPRequestHandler):
new_proxy_port = payload.get("proxy_port") new_proxy_port = payload.get("proxy_port")
routing_mode = str(payload.get("routing_mode") or "auto").strip() routing_mode = str(payload.get("routing_mode") or "auto").strip()
force_country = str(payload.get("force_country") or "").strip() force_country = normalize_routing_country(payload.get("force_country"), read_nodes())
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip() routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
try: try:
@@ -6749,7 +6953,7 @@ class Handler(BaseHTTPRequestHandler):
expected_proxy_port = ui_cfg.get("proxy_port", 7928) expected_proxy_port = ui_cfg.get("proxy_port", 7928)
fixed_node_id = current_fixed_node_id(ui_cfg) if routing_mode == "fixed_ip" else "" fixed_node_id = current_fixed_node_id(ui_cfg) if routing_mode == "fixed_ip" else ""
if new_proxy_port_int == ui_cfg.get("port", 8787): if ports_conflict(ui_cfg.get("port", 8787), new_proxy_port_int):
self.send_json({"ok": False, "error": "代理出站端口不能与网页管理端口相同"}, HTTPStatus.BAD_REQUEST) self.send_json({"ok": False, "error": "代理出站端口不能与网页管理端口相同"}, HTTPStatus.BAD_REQUEST)
return return
if routing_mode == "fixed_ip" and not fixed_node_id: if routing_mode == "fixed_ip" and not fixed_node_id:
@@ -6793,7 +6997,7 @@ class Handler(BaseHTTPRequestHandler):
try: try:
payload = self.read_json_body() payload = self.read_json_body()
routing_mode = str(payload.get("routing_mode") or "auto").strip() routing_mode = str(payload.get("routing_mode") or "auto").strip()
force_country = str(payload.get("force_country") or "").strip() force_country = normalize_routing_country(payload.get("force_country"), read_nodes())
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip() routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
fav_fail_fallback = False fav_fail_fallback = False
@@ -6944,28 +7148,13 @@ class Handler(BaseHTTPRequestHandler):
DATA_DIR.mkdir(exist_ok=True, parents=True) DATA_DIR.mkdir(exist_ok=True, parents=True)
write_json(auth_file, ui_cfg) write_json(auth_file, ui_cfg)
stop_active_openvpn() clear_active_connection_state("手动断开连接")
with lock:
nodes = read_nodes()
for item in nodes:
item["active"] = False
write_json(NODES_FILE, nodes)
global last_active_ping_time, last_active_latency global last_active_ping_time, last_active_latency
last_active_ping_time = 0.0 last_active_ping_time = 0.0
last_active_latency = 0 last_active_latency = 0
global consecutive_proxy_failures, last_proxy_failure_node_id global consecutive_proxy_failures, last_proxy_failure_node_id
consecutive_proxy_failures = 0 consecutive_proxy_failures = 0
last_proxy_failure_node_id = "" last_proxy_failure_node_id = ""
set_state(
active_openvpn_node_id="",
pending_node_id="",
last_check_message="手动断开连接",
active_node_latency="无活动连接",
proxy_ok=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error="连接已手动断开",
)
self.send_json({"ok": True}) self.send_json({"ok": True})
except Exception as exc: except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
@@ -7027,6 +7216,8 @@ class Handler(BaseHTTPRequestHandler):
if result["ok"]: if result["ok"]:
set_state( set_state(
proxy_ok=True, proxy_ok=True,
tunnel_ready=active_openvpn_running(),
proxy_ready=active_openvpn_running(),
proxy_ip=result["ip"], proxy_ip=result["ip"],
proxy_latency_ms=result["latency_ms"], proxy_latency_ms=result["latency_ms"],
proxy_error="" proxy_error=""
@@ -7034,6 +7225,7 @@ class Handler(BaseHTTPRequestHandler):
else: else:
set_state( set_state(
proxy_ok=False, proxy_ok=False,
proxy_ready=False,
proxy_ip="-", proxy_ip="-",
proxy_latency_ms=0, proxy_latency_ms=0,
proxy_error=result.get("error", "未知错误") proxy_error=result.get("error", "未知错误")
@@ -7088,6 +7280,9 @@ def main() -> None:
"last_fetch_source": "", "last_fetch_source": "",
"last_check_message": "服务已启动,正在初始化网络并获取候选 VPN 节点...", "last_check_message": "服务已启动,正在初始化网络并获取候选 VPN 节点...",
"is_connecting": True, "is_connecting": True,
"tunnel_ready": False,
"proxy_ready": False,
"proxy_ok": False,
"pending_node_id": "", "pending_node_id": "",
"active_node_latency": "正在准备", "active_node_latency": "正在准备",
"blacklisted_nodes": 0, "blacklisted_nodes": 0,
@@ -7139,6 +7334,7 @@ def main() -> None:
print("[警告] 代理网关启动超时,继续执行脚本...", flush=True) print("[警告] 代理网关启动超时,继续执行脚本...", flush=True)
threading.Thread(target=collector_loop, daemon=True).start() threading.Thread(target=collector_loop, daemon=True).start()
threading.Thread(target=ip_enrichment_loop, daemon=True).start()
threading.Thread(target=background_proxy_checker, daemon=True).start() threading.Thread(target=background_proxy_checker, daemon=True).start()
threading.Thread(target=active_node_pinger, daemon=True).start() threading.Thread(target=active_node_pinger, daemon=True).start()