diff --git a/README.md b/README.md index 8980a3e..735082f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma 安装完成后,终端会显示 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 访问。 diff --git a/docs/README.en.md b/docs/README.en.md index 7b1b9e2..7c5f006 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -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. +For unattended installation, explicitly skip the first-run prompts and generate the Web path and credentials automatically: + +```bash +AIMILIVPN_NONINTERACTIVE=1 bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh) +``` + ```bash ml # Open the management menu ml status # Show status, Web URL, and username diff --git a/docs/TEST_REPORT_V2.1.2_2026-08-27.md b/docs/TEST_REPORT_V2.1.2_2026-08-27.md new file mode 100644 index 0000000..0d3a685 --- /dev/null +++ b/docs/TEST_REPORT_V2.1.2_2026-08-27.md @@ -0,0 +1,445 @@ +# AimiliVPN V2.1.2 VPS 全功能验收报告 + +测试日期:2026-08-27(Asia/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/HTTPS,GitHub 保持可达,再次运行公开安装: + +- 自动使用 `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 AS36599,1 个 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。 diff --git a/install.sh b/install.sh index 929da6d..04ae9f6 100644 --- a/install.sh +++ b/install.sh @@ -227,8 +227,11 @@ def save_ui_cfg(cfg): path = "/opt/aimilivpn/vpngate_data/ui_auth.json" os.makedirs(os.path.dirname(path), exist_ok=True) try: + if os.path.exists(path): + os.chmod(path, 0o600) with open(path, "w", encoding="utf-8") as f: json.dump(cfg, f, ensure_ascii=False, indent=2) + os.chmod(path, 0o600) return True except Exception: return False @@ -579,10 +582,12 @@ def uninstall_service(): stop_service() if shutil.which("systemctl"): subprocess.run(["systemctl", "disable", "aimilivpn.service"]) - try: - os.unlink("/lib/systemd/system/aimilivpn.service") - except Exception: - pass + for unit_path in ("/lib/systemd/system/aimilivpn.service", "/etc/systemd/system/aimilivpn.service"): + try: + os.unlink(unit_path) + except FileNotFoundError: + pass + subprocess.run(["systemctl", "daemon-reload"], check=False) elif shutil.which("rc-service"): subprocess.run(["rc-update", "del", "aimilivpn"]) try: @@ -591,9 +596,25 @@ def uninstall_service(): pass try: os.unlink("/usr/bin/ml") - except Exception: + except FileNotFoundError: pass - subprocess.run(["rm", "-rf", INSTALL_DIR]) + if shutil.which("ip"): + subprocess.run(["ip", "route", "flush", "table", "100"], check=False) + while subprocess.run( + ["ip", "rule", "del", "table", "100"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode == 0: + pass + try: + os.unlink("/etc/sysctl.d/99-aimilivpn.conf") + except FileNotFoundError: + pass + if shutil.which("sysctl"): + subprocess.run(["sysctl", "--system"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + if os.path.realpath(INSTALL_DIR) == "/opt/aimilivpn" and os.path.isdir(INSTALL_DIR): + shutil.rmtree(INSTALL_DIR) print("AimiliVPN 已卸载!") sys.exit(0) else: @@ -964,7 +985,7 @@ mkdir -p "${INSTALL_DIR}/vpngate_data" is_custom="n" if [ ! -f "$AUTH_FILE" ]; then - if [ -t 0 ]; then + if [ -t 0 ] && [ "${AIMILIVPN_NONINTERACTIVE:-0}" != "1" ]; then echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}" read -p "是否自定义配置?[y/N]: " is_custom else @@ -1050,6 +1071,7 @@ while True: # when username/password contain quotes, backslashes, or shell metacharacters. python3 - "$AUTH_FILE" "$UI_PORT" "$SECRET_PATH" "$UI_USERNAME" "$UI_PASSWORD" <<'PY' import json +import os import sys auth_file, ui_port, secret_path, username, password = sys.argv[1:6] @@ -1061,10 +1083,12 @@ cfg = { "username": username, "password": password, } -with open(auth_file, "w", encoding="utf-8") as f: +fd = os.open(auth_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) +with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(cfg, f, ensure_ascii=False, indent=2) PY fi +chmod 600 "$AUTH_FILE" # 8. Start service # 8.5 Optimize network parameters (rp_filter for policy routing) @@ -1187,7 +1211,7 @@ if [ -n "$PUBLIC_IPV6" ]; then fi echo -e " * 网页管理账号: ${YELLOW}${USERNAME}${PLAIN}" echo -e " * 网页管理密码: ${YELLOW}${PASSWORD}${PLAIN}" -echo -e " * HTTP/SOCKS5 代理端口: ${BLUE}http://127.0.0.1:${PROXY_PORT}/${PLAIN} 或 ${BLUE}http://[::1]:${PROXY_PORT}/${PLAIN}" +echo -e " * HTTP/SOCKS5 代理端口: ${BLUE}http://127.0.0.1:${PROXY_PORT}/${PLAIN}" echo -e " --------------------------------------------------------" echo -e " * 快速状态指令: ${YELLOW}ml status${PLAIN} 或 ${YELLOW}ml${PLAIN}" echo -e " * 查看实时日志: ${YELLOW}ml logs${PLAIN}" diff --git a/tests/test_manager_logic.py b/tests/test_manager_logic.py index 14d8186..c62080b 100644 --- a/tests/test_manager_logic.py +++ b/tests/test_manager_logic.py @@ -3,6 +3,10 @@ from __future__ import annotations import base64 import json import os +import re +import shutil +import stat +import subprocess import tempfile import threading import unittest @@ -183,11 +187,16 @@ class ManagerLogicTests(unittest.TestCase): 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" @@ -236,6 +245,132 @@ class ManagerLogicTests(unittest.TestCase): 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" @@ -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.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() 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.MIRROR_HTTPS_URL: + if url == manager.API_HTTP_URL: return csv_text raise AssertionError(f"unexpected source: {url}") @@ -275,10 +410,60 @@ class ManagerLogicTests(unittest.TestCase): self.assertEqual(1, len(nodes)) 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], ) + 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) @@ -569,6 +754,26 @@ class ManagerLogicTests(unittest.TestCase): self.assertIn('${esc(localProxy)}', manager.INDEX_HTML) self.assertIn('${esc(statusMessage)}', manager.INDEX_HTML) + def test_dashboard_javascript_is_valid(self) -> None: + if not shutil.which("node"): + self.skipTest("Node.js is not installed; JavaScript syntax check skipped") + scripts = re.findall(r"", manager.INDEX_HTML, re.DOTALL) + self.assertTrue(scripts) + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write("\n".join(scripts)) + script_path = handle.name + try: + result = subprocess.run( + ["node", "--check", script_path], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + finally: + Path(script_path).unlink(missing_ok=True) + def test_random_password_uses_cryptographic_randomness(self) -> None: with mock.patch.object(manager.secrets, "choice", side_effect=list("aA0aA0aA0aA0")) as choice: password = manager.generate_random_password() @@ -630,6 +835,17 @@ class ManagerLogicTests(unittest.TestCase): self.assertIn('state["active_openvpn_node_id"] = ""', install_text) self.assertIn("ip link show dev tun0", install_text) self.assertIn("pidof openvpn", install_text) + self.assertIn('chmod 600 "$AUTH_FILE"', install_text) + self.assertIn("AIMILIVPN_NONINTERACTIVE", install_text) + self.assertIn('["ip", "rule", "del", "table", "100"]', install_text) + self.assertIn('/etc/sysctl.d/99-aimilivpn.conf', install_text) + self.assertNotIn('http://[::1]:${PROXY_PORT}', install_text) + + def test_openvpn_command_requires_server_certificate_usage(self) -> None: + with mock.patch.object(manager, "get_openvpn_version", return_value=2.5): + command = manager.openvpn_command("node.ovpn", route_nopull=True) + index = command.index("--remote-cert-tls") + self.assertEqual("server", command[index + 1]) def test_release_workflow_uses_full_patch_version(self) -> None: workflow_text = (manager.ROOT_DIR / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") @@ -717,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, "load_blacklist", return_value={}), mock.patch.object(manager, "log_to_json"), + mock.patch.object(manager, "read_mirror_freshness", return_value=(0.0, "")), ): nodes = manager.fetch_candidates() diff --git a/vpn_utils.py b/vpn_utils.py index 796d2a9..8d43336 100644 --- a/vpn_utils.py +++ b/vpn_utils.py @@ -9,13 +9,14 @@ import time import urllib.parse import urllib.request import threading +import concurrent.futures from pathlib import Path from typing import Any ROOT_DIR = Path(__file__).resolve().parent DATA_DIR = Path(os.environ["VPNGATE_DATA_DIR"]).resolve() if os.environ.get("VPNGATE_DATA_DIR") else ROOT_DIR / "vpngate_data" IP_CACHE_FILE = DATA_DIR / "ip_cache.json" -IP_CLASSIFICATION_VERSION = 2 +IP_CLASSIFICATION_VERSION = 4 IP_CACHE_TTL_SECONDS = 7 * 24 * 3600 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 "") 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" @@ -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. 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", @@ -426,6 +455,9 @@ def apply_ip_cache_entry(node: dict[str, Any], entry: dict[str, Any]) -> None: "is_hosting", "is_mobile", "ip_type_reason", + "ip_type_confidence", + "ip_type_sources", + "geo_country_short", ): 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] payload = json.dumps(chunk).encode("utf-8") request = urllib.request.Request( - "http://ip-api.com/batch?lang=zh-CN&fields=status,message,query,country,regionName,city,isp,org,as,asname,proxy,hosting,mobile", + "http://ip-api.com/batch?lang=zh-CN&fields=status,message,query,country,countryCode,regionName,city,isp,org,as,asname,proxy,hosting,mobile", data=payload, headers={ "Content-Type": "application/json", @@ -502,18 +534,65 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None: "asn": item.get("as") or "", "as_name": item.get("asname") or "", "location": loc, + "geo_country_short": str(item.get("countryCode") or "").upper(), "ip_type": ip_type, "quality": quality, "is_proxy": bool(item.get("proxy")), "is_hosting": bool(item.get("hosting")), "is_mobile": bool(item.get("mobile")), "ip_type_reason": ip_type_reason, + "ip_type_confidence": classification_confidence(ip_type_reason), + "ip_type_sources": ["ip-api.com"], "classification_version": IP_CLASSIFICATION_VERSION, "cached_at": now, } except Exception as e: print(f"[enrich_ip_info] Query failed: {e}", flush=True) + ambiguous_ips = [ + ip + for ip, entry in new_entries.items() + if entry.get("ip_type_reason") in {"proxy_provider_datacenter", "missing_provider_data"} + ] + if ambiguous_ips: + max_workers = min(4, len(ambiguous_ips)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + future_map = { + executor.submit(query_secondary_ip_type, ip): ip + for ip in ambiguous_ips + } + for future in concurrent.futures.as_completed(future_map): + ip = future_map[future] + try: + secondary = future.result() + except Exception: + secondary = None + entry = new_entries[ip] + if secondary is None: + entry["ip_type"] = "unknown" + entry["ip_type_reason"] = ( + "provider_data_unverified" + if entry.get("ip_type_reason") == "missing_provider_data" + else "datacenter_conflict_unverified" + ) + entry["ip_type_confidence"] = "low" + continue + entry["ip_type_sources"].append("ipapi.is") + if secondary.get("is_mobile"): + entry["ip_type"] = "mobile" + entry["ip_type_reason"] = "secondary_mobile" + entry["quality"] = "mobile" + entry["is_mobile"] = True + elif secondary.get("is_datacenter"): + entry["ip_type"] = "hosting" + entry["ip_type_reason"] = "secondary_datacenter" + entry["quality"] = "datacenter" + entry["is_hosting"] = True + else: + entry["ip_type"] = "residential" + entry["ip_type_reason"] = "secondary_consumer_network" + entry["ip_type_confidence"] = classification_confidence(entry["ip_type_reason"]) + if not new_entries: return diff --git a/vpngate_manager.py b/vpngate_manager.py index e606604..510ceb4 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -88,6 +88,12 @@ def bounded_int(value: Any, default: int, min_value: int | None = None, max_valu return default 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_HTTP_URL = os.environ.get("VPNGATE_API_HTTP_URL", "http://www.vpngate.net/api/iphone/").strip() MIRROR_HTTPS_URL = os.environ.get( @@ -98,6 +104,10 @@ MIRROR_HTTP_URL = os.environ.get( "VPNGATE_MIRROR_HTTP_URL", "http://baoweise-bot.github.io/aimili-vpngate/vpngate.csv", ).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. API_URL = API_HTTPS_URL FETCH_INTERVAL_SECONDS = env_int("FETCH_INTERVAL_SECONDS", 1260, 1) @@ -189,6 +199,9 @@ IP_ENRICHMENT_FIELDS = ( "is_hosting", "is_mobile", "ip_type_reason", + "ip_type_confidence", + "ip_type_sources", + "geo_country_short", ) class ConnectionCancelled(RuntimeError): @@ -238,7 +251,17 @@ def write_json(path: Path, data: Any) -> None: with lock: tmp = path.with_suffix(path.suffix + ".tmp") 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) + if path.name == "ui_auth.json": + try: + path.chmod(0o600) + except OSError: + pass def read_json(path: Path, default: Any) -> Any: with lock: @@ -310,6 +333,10 @@ def load_ui_config() -> dict[str, Any]: } updated = False if auth_file.exists(): + try: + auth_file.chmod(0o600) + except OSError: + pass try: data = json.loads(auth_file.read_text(encoding="utf-8")) 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_check_message", "") 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["app_version"] = APP_VERSION state["app_version_label"] = APP_VERSION_LABEL @@ -523,6 +552,8 @@ def clear_active_connection_state(message: str) -> None: pending_node_id="", active_node_latency="无活动连接", proxy_ok=False, + tunnel_ready=False, + proxy_ready=False, proxy_ip="-", proxy_latency_ms=0, proxy_error=message, @@ -949,7 +980,7 @@ def api_network_sources() -> list[tuple[str, str]]: ("official_https", API_HTTPS_URL), ("official_http", API_HTTP_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]] = [] 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( rows: list[dict[str, str]], blacklist: dict[str, dict[str, Any]], @@ -1033,17 +1083,9 @@ def fetch_candidates() -> list[dict[str, Any]]: load_ui_config().get("discovery_countries") ) last_err: Exception | None = None - deadline_hosts: set[str] = set() - log_to_json("INFO", "Main", "开始按官方、GitHub Pages、本地缓存顺序拉取节点列表...") 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: msg = f"尝试节点源 {source_name}: {url}" print(f"[fetch_candidates] {msg}", flush=True) @@ -1068,14 +1110,21 @@ def fetch_candidates() -> list[dict[str, Any]]: if discovery_countries 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( last_fetch_at=time.time(), last_fetch_status="ok", last_fetch_source=source_name, 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), ) log_to_json( @@ -1086,8 +1135,6 @@ def fetch_candidates() -> list[dict[str, Any]]: return filtered_candidates except Exception as 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) 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", str(AUTH_FILE), "--auth-nocache", + "--remote-cert-tls", + "server", ] ) @@ -1559,6 +1608,17 @@ def stop_active_openvpn() -> None: def active_openvpn_running() -> bool: 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]]: available_nodes = sorted( [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: 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": fav_ids = set(ui_cfg.get("favorite_node_ids", [])) @@ -1644,7 +1704,10 @@ def apply_routing_filters( if routing_ip_type == "residential": 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")) ] elif routing_ip_type == "hosting": @@ -1660,8 +1723,34 @@ def normalized_country_name(country: Any) -> str: value = str(country or "").strip() return vpn_utils.COUNTRY_TRANSLATIONS.get(value, value) -def country_matches(node_country: Any, target_country: Any) -> bool: - return bool(target_country) and normalized_country_name(node_country) == normalized_country_name(target_country) +def normalize_routing_country(value: Any, nodes: list[dict[str, Any]] | None = None) -> str: + 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]: 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": 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}】,不能连接其他国家节点") elif routing_mode == "favorites": 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["probed_at"] = time.time() if ok: - node["owner"] = temp_node["owner"] - node["asn"] = temp_node["asn"] - node["as_name"] = temp_node["as_name"] - node["location"] = temp_node["location"] - node["ip_type"] = temp_node["ip_type"] - node["quality"] = temp_node["quality"] + for field in IP_ENRICHMENT_FIELDS: + value = temp_node.get(field) + if value not in (None, ""): + node[field] = value sorted_nodes = sort_all_nodes(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_message": f"Failed to write configuration: {e}", "probed_at": time.time(), - "owner": "", - "asn": "", - "as_name": "", - "location": "", - "ip_type": "", - "quality": "", } 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_message": message, "probed_at": time.time(), - "owner": "", - "asn": "", - "as_name": "", - "location": "", - "ip_type": "", - "quality": "", } return temp_node @@ -2133,6 +2208,9 @@ def connect_node(node_id: str) -> str: set_state( is_connecting=True, pending_node_id=node_id, + tunnel_ready=False, + proxy_ready=False, + proxy_ok=False, active_node_latency="正在连接", last_check_message=f"正在初始化连接配置: {node_id}", ) @@ -2238,6 +2316,7 @@ def connect_node(node_id: str) -> str: raise ConnectionCancelled("连接操作已取消") active_openvpn_process = process active_openvpn_node_id = node_id + set_state(tunnel_ready=True, proxy_ready=False) set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...") routing_ready = setup_policy_routing("tun0") @@ -2295,6 +2374,8 @@ def connect_node(node_id: str) -> str: last_check_message=f"Connected {node_id}", active_node_latency=latency_str, proxy_ok=True, + tunnel_ready=True, + proxy_ready=True, proxy_ip=res["ip"], proxy_latency_ms=res["latency_ms"], proxy_error="", @@ -4658,10 +4739,12 @@ const translateQuality = q => { }; 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 || "-"; }; +const translateConfidence = value => ({high: "高", medium: "中", low: "低"}[value] || "未知"); + const translateCountry = c => { const dict = { "Japan": "日本", @@ -4922,8 +5005,7 @@ function render(){ $("deployment_mode_label").textContent = `${modeLabel}部署 · 更新通道:main`; } - const activeNodeId = state.active_openvpn_node_id; - const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId)); + const activeNode = nodes.find(n => n && n.active); // Render separated Active Node Card const activeCardContainer = $("active_node_card"); @@ -4953,7 +5035,9 @@ function render(){ } else if (activeNode) { const latencyText = nodeLatencyHtml(activeNode); 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 = `