4 Commits
11 changed files with 1325 additions and 169 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.3
required: true required: true
default: v2.1.1 default: v2.1.3
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]
+32 -30
View File
@@ -18,38 +18,9 @@
</div> </div>
AimiliVPN 使用 Python 标准库管理 VPNGate 节点,提供节点获取与检测、连接切换、Web 管理后台,以及共用一个端口的 HTTP、HTTPS 网站代理和 SOCKS5 代理服务。
| 项目 | 默认值或支持范围 |
| --- | --- |
| Web 管理后台 | TCP `8787` + 独立安全路径 + 账号密码 |
| 本机代理 | `127.0.0.1:7928`,支持 HTTP、HTTPS `CONNECT` 和 SOCKS5 |
| 源码部署 | x64、x86、ARM64、ARM32 Linux |
| Docker 镜像 | `linux/amd64``linux/386``linux/arm64``linux/arm/v7` |
| 更新通道 | GitHub `main` 正式分支 / 最新正式 Release |
> [!IMPORTANT]
> **网络可用性提示:** 不同地区、数据中心和网络服务商可能限制 DNS、VPNGate API、GitHub 镜像或 VPN 协议。镜像与本地缓存只能提高节点列表的可用性,不能保证所有机型都能建立连接。部署前请确认所在地法律和 VPS 服务商条款允许使用 VPN/TUN。
<a id="quick-install"></a>
## 快速安装
使用 `root` 用户在受支持的 Linux VPS 上执行:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
安装完成后,终端会显示 Web 后台完整地址、随机安全路径、登录账号和密码。输入 `ml` 可打开管理菜单。
> [!TIP]
> 安装前请在 VPS 控制面板启用 TUN/TAP,并确认 `/dev/net/tun` 存在。Web 默认使用 TCP `8787`,安全组建议只允许自己的 IP 访问。
<a id="vps"></a> <a id="vps"></a>
## VPS 推荐 ## VPS 推荐
以下链接含推广参数,通过链接购买不会增加您的费用。
<table> <table>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
@@ -85,7 +56,38 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma
</tr> </tr>
</table> </table>
购买前请确认具体套餐支持 TUN/TAP、OpenVPN 和所需网络协议。推广链接不代表对具体机型可用性的保证 AimiliVPN 使用 Python 标准库管理 VPNGate 节点,提供节点获取与检测、连接切换、Web 管理后台,以及共用一个端口的 HTTP、HTTPS 网站代理和 SOCKS5 代理服务
| 项目 | 默认值或支持范围 |
| --- | --- |
| Web 管理后台 | TCP `8787` + 独立安全路径 + 账号密码 |
| 本机代理 | `127.0.0.1:7928`,支持 HTTP、HTTPS `CONNECT` 和 SOCKS5 |
| 源码部署 | x64、x86、ARM64、ARM32 Linux |
| Docker 镜像 | `linux/amd64``linux/386``linux/arm64``linux/arm/v7` |
| 更新通道 | GitHub `main` 正式分支 / 最新正式 Release |
> [!IMPORTANT]
> **网络可用性提示:** 不同地区、数据中心和网络服务商可能限制 DNS、VPNGate API、GitHub 镜像或 VPN 协议。镜像与本地缓存只能提高节点列表的可用性,不能保证所有机型都能建立连接。部署前请确认所在地法律和 VPS 服务商条款允许使用 VPN/TUN。
<a id="quick-install"></a>
## 快速安装
使用 `root` 用户在受支持的 Linux VPS 上执行:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
安装完成后,终端会显示 Web 后台完整地址、随机安全路径、登录账号和密码。输入 `ml` 可打开管理菜单。
无人值守安装可显式跳过首次参数询问,并自动生成安全路径和登录凭据:
```bash
AIMILIVPN_NONINTERACTIVE=1 bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
```
> [!TIP]
> 安装前请在 VPS 控制面板启用 TUN/TAP,并确认 `/dev/net/tun` 存在。Web 默认使用 TCP `8787`,安全组建议只允许自己的 IP 访问。
<a id="installation"></a> <a id="installation"></a>
## 完整安装 ## 完整安装
+38 -42
View File
@@ -1,63 +1,59 @@
# AimiliVPN V2.1.1 正式版 # AimiliVPN V2.1.3 正式版
V2.1.1 是一次稳定性、浏览器兼容性和 VPS 实际使用体验为重点的维护更新。本次版本已在 Ubuntu 22.04 VPS 上通过安装、节点获取、代理出口、节点切换、断开与恢复测试 V2.1.3 是一次完整的稳定性、安全性和节点识别修复版本。重点解决节点切换期间状态显示过早、未知网络误判住宅、部分 VPS 节点源回退不合理,以及安装卸载残留等问题
## Bug 修复 ## IP 类型识别
- 修复 Edge 和部分 Chromium 浏览器打开管理页面后高占用、卡顿甚至浏览器失去响应的问题:移除高成本的 `backdrop-filter`、固定背景和远程字体依赖,并为“减少动态效果”系统设置提供兼容处理 - IP 类型缓存升级到新的分类版本,旧分类会自动失效并重新检测
- 修复前端每 10 秒轮询时,即使节点和状态完全没有变化,仍会重新排序、生成表格 HTML 和刷新页面的问题;相同快照现在会直接跳过渲染 - 明确区分住宅、移动、机房和未知网络,并显示高、中、低置信度及情报来源
- 修复节点列表已经按每页 50 条分页,但分页栏始终隐藏,导致第 51 条之后的节点无法查看的问题 - Sony、KDDI、SoftBank、Korea Telecom 等有明确消费接入运营商信息且未命中机房特征的网络继续识别为住宅
- 修复多个轮询请求可能重叠、页面切到后台后仍持续刷新,以及日志窗口重复拉取和渲染过多内容造成的卡顿 - `proxy + 数据中心关键词` 冲突节点以及完全缺少运营商信息的节点会调用 `ipapi.is` 进行第二来源复核
- 修复连接、断开、单节点检测、收藏和设置操作可能被连续点击重复提交的问题,并为网络请求增加合理的超时与失败恢复 - 第二来源不可用或证据不足时标记为未知/低置信度,不再直接当作住宅
- 修复弹窗关闭、键盘焦点、Esc、Tab 循环和移动端滚动在不同浏览器中的不一致行为 - 严格住宅路由只接受中、高置信度住宅或移动网络,拒绝未知和低置信度分类
- 修复登录和管理密码被意外删除首尾空格的问题;前端和 `ml password` 现在保持一致。
- 修复 SOCKS5 客户端未提供“无需认证”方法时,服务端仍错误接受连接的问题。
- 修复 `ml status` 一直显示旧版 `v2.0`,以及首次安装提示 5-30 秒但实际最多等待 90 秒的问题。
- 修复 `ml update` 重启服务后可能读取上一次连接留下的节点 ID、过早提示“已就绪”的竞态;现在会清理旧状态,并确认 OpenVPN 进程和 `tun0` 均已建立后才报告成功。
- 修复正式版 GitHub Actions 发布脚本的 Shell 块问题。
## 性能与体验优化 ## 节点获取与连接切换
- 节点接口继续移除不需要发送到浏览器的 OpenVPN 配置正文,约百个节点的响应保持在较小体积 - 修复官方 HTTPS 超时后直接跳过官方 HTTP 的问题;现在仍会继续尝试 VPNGate 官方真实 HTTP,再进入 GitHub Pages、VPS 最近缓存和内置快照
- 节点请求自动合并;页面不可见时暂停普通轮询;节点表最多渲染 50 行,日志窗口最多渲染最近 300 行,服务端最多读取最近 500 条日志 - GitHub 镜像状态显示生成时间与陈旧程度,HTTP 地址明确标注为跳转 HTTPS,不再描述成独立明文镜像
- 国家多选、状态筛选、IP 类型筛选、收藏面板和分页均在前端即时响应,不需要重新请求节点数据 - 连接状态拆分为隧道就绪和代理出口就绪;只有 OpenVPN、策略路由与真实代理出口全部通过后,前端才显示“已连接”
- 管理弹窗补齐标准对话框语义、可见焦点、焦点恢复和屏幕阅读器状态提示 - 修复批量节点检测失败时用空字段覆盖已有 ISP、物理位置和 IP 类型的问题
- 登录、节点更新、代理检测、节点切换、断开、设置保存和日志接口统一加入超时控制,网络异常时不会无限等待 - 固定国家统一保存 ISO 两字母代码,并兼容旧版中文国家名称配置
- 安装器生成的账号、密码和安全路径改用 Python `secrets` 加密安全随机源 - 物理位置国旗使用 IP 情报源国家,节点申报国家与 IP 推测位置分开提示,避免来源冲突造成误解
## 文档与功能更新 ## 安装、安全与运维
- Web 管理端版本标识和更新检测升级为完整的 `V2.1.1 正式版`,仍只跟踪 GitHub `main` 稳定分支和正式 Release - 卸载时清理 AimiliVPN 的 sysctl 文件、table 100 路由和全部 table 100 策略规则
- README 重新整理快速安装、代理连接、VPS 推荐、项目推广和法律使用边界,删除重复的版本流水账与虚拟币赞助内容 - `ui_auth.json` 创建、迁移和保存时使用私有权限 `0600`,并采用安全写入流程
- 保留简体中文主文档,并提供 English、日本語、한국어说明 - Web 管理端口与代理端口增加双向冲突校验,冲突配置直接拒绝,不再静默改写端口
- 正式版继续提供 HTTP 和 SOCKS5 两种代理协议,兼容不同 VPS 和客户端环境 - OpenVPN 启用 `remote-cert-tls server`,验证服务器证书用途并移除相关安全警告
- 新增 `AIMILIVPN_NONINTERACTIVE=1` 无人值守安装模式,首次部署不会停在交互提示。
- 安装完成信息只显示实际默认监听的 IPv4 本地代理地址,不再错误提示未启用的 IPv6 地址。
## 验证结果 ## 验证与兼容性
- Ubuntu 22.04 x86_64 VPS 一键安装成功,服务保持运行且无异常重启 - 本地 Python 单元测试 51/51 通过,Python 编译、Dashboard JavaScript 语法、安装脚本语法和补丁格式检查通过
- HTTP 与 SOCKS5 代理出口验证通过 - Ubuntu 22.04 x86_64 VPS 已完成真实卸载、重新安装、节点源、连接切换、代理出口、Web API、权限和路由残留验证
- 节点测试、切换、断开和恢复原节点的完整流程通过 - 发布流水线会在 Python 3.9、3.11、3.13 上运行完整测试
- 本地 35 项单元测试、Python 编译、前端 JavaScript 语法`install.sh` 语法和 Docker Compose 配置检查通过 - Docker 会分别构建验证 `linux/amd64``linux/386``linux/arm64``linux/arm/v7`,并发布统一多架构镜像清单
- 99 个节点分页为 50/49,国家多选、空状态、收藏、设置、网关、日志和 Web 更新检测均通过浏览器交互测试。
- 11.5 秒空闲轮询观测期间,相同节点快照产生 0 次 DOM 变更。
- 390x844 手机视口和 1440x900 桌面视口没有页面级横向溢出或控件重叠。
## 下载与兼容架构 ## 平台限制
- GitHub Release 提供 `aimilivpn-v2.1.1-linux-source.tar.gz` 通用 Linux Python 源码包和 `sha256sums.txt` - GitHub Actions 定时任务由 GitHub 调度,不保证每 15 分钟准点运行
- GHCR 发布 `2.1.1``2.1``latest` 三组镜像标签 - GitHub Pages 会把 HTTP 强制跳转到 HTTPS,不能作为真正的明文 HTTP 镜像
- Docker 镜像在发布前分别对 `linux/amd64``linux/386``linux/arm64``linux/arm/v7` 执行构建和导入冒烟测试 - 程序保留 VPNGate 官方 HTTP、VPS 最近有效缓存和内置初始快照,以降低上述平台限制的影响
- Docker 需要 Linux、`/dev/net/tun`、host 网络以及 `NET_ADMIN``NET_RAW` 能力。
## 更新方式 ## 下载与更新
Python 源码安装: - GitHub Release`aimilivpn-v2.1.3-linux-source.tar.gz``sha256sums.txt`
- GHCR`2.1.3``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.3
+6
View File
@@ -104,6 +104,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
+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。
+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.3"
except Exception: except Exception:
return "2.1.1" return "2.1.3"
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}"
+361 -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,323 @@ 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_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)
@@ -431,6 +754,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 +804,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.3", manager.APP_VERSION)
self.assertEqual("V2.1.1 正式版", manager.APP_VERSION_LABEL) self.assertEqual("V2.1.3 正式版", 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 +835,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.3", 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 +877,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.3",
"name": "AimiliVPN V2.1.1 正式版", "name": "AimiliVPN V2.1.3 正式版",
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
} }
@@ -532,7 +886,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.3 正式版", 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 +933,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]:
+261 -52
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.3"
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
@@ -2039,6 +2208,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 +2316,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 +2374,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 +2489,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 +2511,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 +4165,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.3 正式版</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.3 正式版</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 +4739,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 +4997,7 @@ function stableSortNodes() {
} }
function render(){ function render(){
const versionLabel = state.app_version_label || "V2.1.1 正式版"; const versionLabel = state.app_version_label || "V2.1.3 正式版";
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 +5005,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 +5035,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 +5047,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 +5175,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 +5204,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 +5805,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 +6624,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 +6886,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 +6926,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 +6951,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 +6995,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
@@ -7027,6 +7229,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 +7238,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 +7293,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 +7347,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()