4 Commits
Author SHA1 Message Date
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
10 changed files with 1040 additions and 123 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.2 description: Existing formal tag to publish, for example v2.1.4
required: true required: true
default: v2.1.2 default: v2.1.4
type: string type: string
concurrency: concurrency:
+6
View File
@@ -80,6 +80,12 @@ 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 访问。
+44 -22
View File
@@ -1,35 +1,57 @@
# AimiliVPN V2.1.2 正式版 # AimiliVPN V2.1.4 正式版
V2.1.2 重点修复 IP 类型误判和部分 VPS 获取节点列表等待过久的问题,并增强 GitHub Pages、VPS 最近缓存和程序内置快照的回退体验 V2.1.4 包含 V2.1.3 的全部稳定性、安全性和节点识别改进,并修复正式版重新安装验收中发现的手动断开状态遗漏
## Bug 修复 ## V2.1.4 热修复
- 修复`proxy=true` 直接等同于机房 IP 的分类错误。住宅宽带用户运行 VPNGate 后可能被风险库标记为代理,但其网络归属仍然是住宅;现在代理属性与住宅/移动/机房类型分开保存 - 修复点击“断开连接”后 OpenVPN、活动节点和 table 100 已清理,但 Web API 的 `tunnel_ready` / `proxy_ready` 仍短暂保留为 `true` 的问题
- 修复 Sony、Korea Telecom、JCOM、SK Broadband、KDDI、Cable TV 等消费宽带节点容易被误标为机房 IP 的问题 - 手动断开现在统一复用完整连接状态清理流程;活动节点、连接中状态、隧道、代理出口、出口 IP 与路由规则会同步归零
- 保留对真实机房网络的识别:`hosting=true` 仍直接判为机房;SoftEther、hosting、cloud、server、data center、VPS 等明确数据中心供应商特征也仍判为机房 - 新增手动断开状态回归测试,并在真实 VPS 上完成断开与重新连接复验
- 修复升级后旧版错误 IP 分类缓存继续生效最多 7 天的问题。分类缓存加入版本号,V2.1.2 会自动重新检测旧缓存,不需要用户手动删除运行数据。
- 修复只给少量连通性检测成功节点补充 IP 类型、节点表中大部分节点长期显示未知的问题。后台任务现在会批量补全整个节点列表,同时只合并运营商和分类字段,不覆盖连接、延迟或检测状态。
- 修复 VPNGate 官方接口持续缓慢传输时可能突破原有 socket 超时、拖慢备用源切换的问题。每个网络节点源现在增加 6 秒总时限。
- 修复官方 HTTPS 已超过总时限后仍继续等待同一主机 HTTP 的重复慢请求;超时后会直接尝试 GitHub Pages HTTPS。证书或 TLS 不兼容等非超时错误仍保留 HTTP 回退,兼容旧系统和不同 VPS 环境。
## 节点源与镜像优化 ## IP 类型识别
- 节点顺序保持为:VPNGate 官方 HTTPS、官方 HTTP、GitHub Pages HTTPS、GitHub Pages HTTP、VPS 最近有效缓存、程序内置初始快照 - IP 类型缓存升级到新的分类版本,旧分类会自动失效并重新检测
- GitHub Pages 定时同步从整刻 15 分钟调整为每小时第 7、22、37、52 分钟,降低 GitHub Actions 高峰期调度延迟概率 - 明确区分住宅、移动、机房和未知网络,并显示高、中、低置信度及情报来源
- GitHub Pages 与官方 HTTPS 获取到的快照继续执行相同的 CSV 字段、大小、Base64 和 OpenVPN 危险指令校验 - Sony、KDDI、SoftBank、Korea Telecom 等有明确消费接入运营商信息且未命中机房特征的网络继续识别为住宅
- HTTP 节点源继续只作为兼容回退,不覆盖最后一次通过 HTTPS 获得的可信本地快照 - `proxy + 数据中心关键词` 冲突节点以及完全缺少运营商信息的节点会调用 `ipapi.is` 进行第二来源复核
- 第二来源不可用或证据不足时标记为未知/低置信度,不再直接当作住宅。
- 严格住宅路由只接受中、高置信度住宅或移动网络,拒绝未知和低置信度分类。
## 验证结果 ## 节点获取与连接切换
- 40 项单元测试通过,覆盖住宅/代理分离、真实机房识别、旧缓存迁移、后台全量富化、连接状态保护和慢速官方源回退 - 修复官方 HTTPS 超时后直接跳过官方 HTTP 的问题;现在仍会继续尝试 VPNGate 官方真实 HTTP,再进入 GitHub Pages、VPS 最近缓存和内置快照
- Python 编译、前端 JavaScript 语法、`install.sh` 语法和 Docker Compose 配置检查通过 - GitHub 镜像状态显示生成时间与陈旧程度,HTTP 地址明确标注为跳转 HTTPS,不再描述成独立明文镜像
- 测试 VPS 上 VPNGate 官方 HTTPS/HTTP、GitHub Pages HTTPS/HTTP、VPS 最近缓存和内置快照均能下载、解析并生成候选节点 - 连接状态拆分为隧道就绪和代理出口就绪;只有 OpenVPN、策略路由与真实代理出口全部通过后,前端才显示“已连接”
- 发布流水线对 Python 3.9、3.11、3.13 运行完整测试,并分别构建验证 `linux/amd64``linux/386``linux/arm64``linux/arm/v7` - 修复批量节点检测失败时用空字段覆盖已有 ISP、物理位置和 IP 类型的问题
- 固定国家统一保存 ISO 两字母代码,并兼容旧版中文国家名称配置。
- 物理位置国旗使用 IP 情报源国家,节点申报国家与 IP 推测位置分开提示,避免来源冲突造成误解。
## 安装、安全与运维
- 卸载时清理 AimiliVPN 的 sysctl 文件、table 100 路由和全部 table 100 策略规则。
- `ui_auth.json` 创建、迁移和保存时使用私有权限 `0600`,并采用安全写入流程。
- Web 管理端口与代理端口增加双向冲突校验,冲突配置直接拒绝,不再静默改写端口。
- OpenVPN 启用 `remote-cert-tls server`,验证服务器证书用途并移除相关安全警告。
- 新增 `AIMILIVPN_NONINTERACTIVE=1` 无人值守安装模式,首次部署不会停在交互提示。
- 安装完成信息只显示实际默认监听的 IPv4 本地代理地址,不再错误提示未启用的 IPv6 地址。
## 验证与兼容性
- 本地 Python 单元测试 52/52 通过,Python 编译、Dashboard JavaScript 语法、安装脚本语法和补丁格式检查通过。
- Ubuntu 22.04 x86_64 VPS 已完成真实卸载、重新安装、节点源、连接切换、代理出口、Web API、权限和路由残留验证。
- 发布流水线会在 Python 3.9、3.11、3.13 上运行完整测试。
- Docker 会分别构建验证 `linux/amd64``linux/386``linux/arm64``linux/arm/v7`,并发布统一多架构镜像清单。
## 平台限制
- GitHub Actions 定时任务由 GitHub 调度,不保证每 15 分钟准点运行。
- GitHub Pages 会把 HTTP 强制跳转到 HTTPS,不能作为真正的明文 HTTP 镜像。
- 程序保留 VPNGate 官方 HTTP、VPS 最近有效缓存和内置初始快照,以降低上述平台限制的影响。
## 下载与更新 ## 下载与更新
- GitHub Release 提供 `aimilivpn-v2.1.2-linux-source.tar.gz``sha256sums.txt` - GitHub Release`aimilivpn-v2.1.4-linux-source.tar.gz``sha256sums.txt`
- GHCR 发布 `2.1.2``2.1``latest` 三组镜像标签。 - GHCR`2.1.4``2.1``latest` 多架构镜像标签。
Python 源码安装更新: Python 源码安装更新:
+1 -1
View File
@@ -1 +1 @@
2.1.2 2.1.4
+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.2" return version or "2.1.4"
except Exception: except Exception:
return "2.1.2" return "2.1.4"
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}"
+250 -9
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
@@ -183,11 +187,16 @@ class ManagerLogicTests(unittest.TestCase):
mobile, mobile_reason = manager.vpn_utils.classify_ip_type( mobile, mobile_reason = manager.vpn_utils.classify_ip_type(
{"proxy": False, "hosting": False, "mobile": True} {"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(("residential", "consumer_or_unclassified_network"), (residential, residential_reason))
self.assertEqual(("hosting", "proxy_provider_datacenter"), (softether, softether_reason)) self.assertEqual(("hosting", "proxy_provider_datacenter"), (softether, softether_reason))
self.assertEqual(("hosting", "hosting_flag"), (hosting, hosting_reason)) self.assertEqual(("hosting", "hosting_flag"), (hosting, hosting_reason))
self.assertEqual(("mobile", "mobile_flag"), (mobile, mobile_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: def test_ip_enrichment_reclassifies_legacy_cache_and_keeps_proxy_quality(self) -> None:
ip = "118.240.250.95" ip = "118.240.250.95"
@@ -236,6 +245,132 @@ class ManagerLogicTests(unittest.TestCase):
cache = json.loads(manager.vpn_utils.IP_CACHE_FILE.read_text(encoding="utf-8")) 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"]) 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: def test_background_ip_enrichment_merges_metadata_without_replacing_status(self) -> None:
nodes = self.write_nodes(2) nodes = self.write_nodes(2)
nodes[0]["probe_status"] = "available" nodes[0]["probe_status"] = "available"
@@ -256,13 +391,13 @@ class ManagerLogicTests(unittest.TestCase):
self.assertEqual("available", next(node for node in stored if node["id"] == "node-0")["probe_status"]) 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)) self.assertTrue(all(node["ip_type"] == "residential" for node in stored))
def test_source_deadline_skips_same_host_http_and_uses_github_https(self) -> None: def test_source_deadline_still_tries_official_http(self) -> None:
csv_text = valid_snapshot() csv_text = valid_snapshot()
def fake_fetch(url, verify_ssl=True, deadline_seconds=None): def fake_fetch(url, verify_ssl=True, deadline_seconds=None):
if url == manager.API_HTTPS_URL: if url == manager.API_HTTPS_URL:
raise manager.SourceDeadlineExceeded("slow official source") raise manager.SourceDeadlineExceeded("slow official source")
if url == manager.MIRROR_HTTPS_URL: if url == manager.API_HTTP_URL:
return csv_text return csv_text
raise AssertionError(f"unexpected source: {url}") raise AssertionError(f"unexpected source: {url}")
@@ -275,10 +410,84 @@ class ManagerLogicTests(unittest.TestCase):
self.assertEqual(1, len(nodes)) self.assertEqual(1, len(nodes))
self.assertEqual( self.assertEqual(
[manager.API_HTTPS_URL, manager.MIRROR_HTTPS_URL], [manager.API_HTTPS_URL, manager.API_HTTP_URL],
[call.args[0] for call in fetch_mock.call_args_list], [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 test_source_deadline_limits_total_fetch_time(self) -> None:
def slow_fetch(url, verify_ssl=True): def slow_fetch(url, verify_ssl=True):
threading.Event().wait(0.1) threading.Event().wait(0.1)
@@ -569,6 +778,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()
@@ -599,8 +828,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.2", manager.APP_VERSION) self.assertEqual("2.1.4", manager.APP_VERSION)
self.assertEqual("V2.1.2 正式版", manager.APP_VERSION_LABEL) self.assertEqual("V2.1.4 正式版", 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)
@@ -630,11 +859,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.2", workflow_text) self.assertIn("default: v2.1.4", 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)
@@ -661,8 +901,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.2", "tag_name": "v2.1.4",
"name": "AimiliVPN V2.1.2 正式版", "name": "AimiliVPN V2.1.4 正式版",
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
} }
@@ -670,7 +910,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.2 正式版", result["current_version_label"]) self.assertEqual("V2.1.4 正式版", 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}
@@ -717,6 +957,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()
+81 -2
View File
@@ -9,13 +9,14 @@ 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 = 2 IP_CLASSIFICATION_VERSION = 4
IP_CACHE_TTL_SECONDS = 7 * 24 * 3600 IP_CACHE_TTL_SECONDS = 7 * 24 * 3600
ip_cache_lock = threading.RLock() ip_cache_lock = threading.RLock()
@@ -407,6 +408,8 @@ def classify_ip_type(item: dict[str, Any]) -> tuple[str, str]:
str(item.get(key) or "") str(item.get(key) or "")
for key in ("isp", "org", "as", "asname") 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): if item.get("proxy") and DATACENTER_PROVIDER_PATTERN.search(provider_text):
return "hosting", "proxy_provider_datacenter" return "hosting", "proxy_provider_datacenter"
@@ -414,6 +417,32 @@ def classify_ip_type(item: dict[str, Any]) -> tuple[str, str]:
# Proxy use is retained in quality/is_proxy and must not change ownership. # Proxy use is retained in quality/is_proxy and must not change ownership.
return "residential", "consumer_or_unclassified_network" 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: def apply_ip_cache_entry(node: dict[str, Any], entry: dict[str, Any]) -> None:
for key in ( for key in (
"owner", "owner",
@@ -426,6 +455,9 @@ def apply_ip_cache_entry(node: dict[str, Any], entry: dict[str, Any]) -> None:
"is_hosting", "is_hosting",
"is_mobile", "is_mobile",
"ip_type_reason", "ip_type_reason",
"ip_type_confidence",
"ip_type_sources",
"geo_country_short",
): ):
node[key] = entry.get(key, "") node[key] = entry.get(key, "")
@@ -463,7 +495,7 @@ 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={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -502,18 +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_proxy": bool(item.get("proxy")),
"is_hosting": bool(item.get("hosting")), "is_hosting": bool(item.get("hosting")),
"is_mobile": bool(item.get("mobile")), "is_mobile": bool(item.get("mobile")),
"ip_type_reason": ip_type_reason, "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, "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
+170 -76
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)
@@ -131,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.2" DEFAULT_APP_VERSION = "2.1.4"
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:
@@ -189,6 +199,9 @@ IP_ENRICHMENT_FIELDS = (
"is_hosting", "is_hosting",
"is_mobile", "is_mobile",
"ip_type_reason", "ip_type_reason",
"ip_type_confidence",
"ip_type_sources",
"geo_country_short",
) )
class ConnectionCancelled(RuntimeError): class ConnectionCancelled(RuntimeError):
@@ -238,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:
@@ -310,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():
@@ -482,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
@@ -523,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,
@@ -949,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()
@@ -989,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]],
@@ -1033,17 +1083,9 @@ 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
deadline_hosts: set[str] = set()
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():
source_host = (urllib.parse.urlsplit(url).hostname or "").lower()
if url.startswith("http://") and source_host in deadline_hosts:
msg = f"跳过同主机慢速 HTTP 节点源 {source_name}: {url}"
print(f"[fetch_candidates] {msg}", flush=True)
log_to_json("WARNING", "Main", msg)
continue
try: try:
msg = f"尝试节点源 {source_name}: {url}" msg = f"尝试节点源 {source_name}: {url}"
print(f"[fetch_candidates] {msg}", flush=True) print(f"[fetch_candidates] {msg}", flush=True)
@@ -1068,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(
@@ -1086,8 +1135,6 @@ def fetch_candidates() -> list[dict[str, Any]]:
return filtered_candidates return filtered_candidates
except Exception as e: except Exception as e:
last_err = e last_err = e
if isinstance(e, SourceDeadlineExceeded) and url.startswith("https://"):
deadline_hosts.add(source_host)
print(f"[fetch_candidates] 节点源 {source_name} 失败: {e}", flush=True) print(f"[fetch_candidates] 节点源 {source_name} 失败: {e}", flush=True)
log_to_json("WARNING", "Main", f"节点源 {source_name} 失败: {e}") log_to_json("WARNING", "Main", f"节点源 {source_name} 失败: {e}")
@@ -1201,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",
] ]
) )
@@ -1559,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")],
@@ -1634,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", []))
@@ -1644,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":
@@ -1660,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
@@ -1687,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", []))
@@ -1838,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)
@@ -1890,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)
@@ -1922,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
@@ -2133,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}",
) )
@@ -2238,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")
@@ -2295,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="",
@@ -4084,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.2 正式版</span> <span id="github_version_label">V2.1.4 正式版</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.2 正式版</div> <div id="current_version_label" class="version-current-label">V2.1.4 正式版</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)">
@@ -4658,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": "日本",
@@ -4914,7 +4997,7 @@ function stableSortNodes() {
} }
function render(){ function render(){
const versionLabel = state.app_version_label || "V2.1.2 正式版"; const versionLabel = state.app_version_label || "V2.1.4 正式版";
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")) {
@@ -4922,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");
@@ -4953,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">
@@ -4963,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>
@@ -5091,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>`;
@@ -5116,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}
@@ -5717,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 || "";
}
} }
} }
@@ -6527,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:
@@ -6787,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
@@ -6823,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:
@@ -6848,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:
@@ -6892,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
@@ -7043,28 +7146,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)
@@ -7126,6 +7214,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=""
@@ -7133,6 +7223,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", "未知错误")
@@ -7187,6 +7278,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,