Checkpoint verified 1.0.0b1 baseline before final hardening
This commit is contained in:
1 parent
aa7f12d705
commit
9804b73cf0
67 files changed
+5395
-545
No files matched your search
@@ -0,0 +1,6 @@
|
||||
* text=auto eol=lf
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
@@ -0,0 +1,44 @@
|
||||
> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。
|
||||
|
||||
# v0.1.0-alpha 发布前报告
|
||||
|
||||
日期:2026-11-01。版本元数据0.1.0a0,候选ZIP名v0.1.0-alpha。结论:**可内部Alpha试用;不建议现在公开发布,尚缺全新Ubuntu镜像安装验证及发布摘要可信分发确认。** 本轮未公开发布或合并主分支。
|
||||
|
||||
## 修复
|
||||
|
||||
1. 新增标准库离线安装器,venv --without-pip,无sudo/pip/包下载;不覆盖已有目录,失败和取消清理自建目录。包括安装路径含空格验收。
|
||||
2. 增加逐文件SHA256清单、ZIP摘要、路径/符号链接/篡改拒绝,并统一LF,避免Git换行导致校验失效。摘要须来自可信渠道,不能替代发布签名。
|
||||
3. HTTP/API/定位/媒体/吞吐CLI请求采用可终止子进程,覆盖阻塞DNS;网络通常20s、吞吐25s,本机命令8s,DNS探测2–5s。SIGINT/SIGTERM取消活动worker,保留已完成结果。
|
||||
4. 增加统一result_schema_version=1/result_items,五状态及来源、时间、方法、指标、错误、置信范围、限制;保留旧报告字段以避免无关迁移。
|
||||
5. suite及独立quality/ipquality/media/bandwidth命令增量保存;IP逐来源、测速逐样本回调。报告独占创建,日志单条4MiB、总64MiB;失败提示并返回非零,可恢复最后完整事件。
|
||||
6. UDP路由最多3轮、共享90s软预算,显示每跳实际发送、响应、超时与响应缺失统计;明确不等于转发丢包率。真实回环三次回应+一次无回应得到4发3收1超时/25%响应缺失。
|
||||
|
||||
## 测试
|
||||
|
||||
| 环境 | 自动测试 | 实际Alpha验收 |
|
||||
|---|---|---|
|
||||
| Ubuntu22.04.5 / Python3.10.12 | 248通过,5.00s | 23/23 |
|
||||
| Ubuntu24.04.3 / Python3.12.3 | 248通过,5.32s | 23/23 |
|
||||
| Windows开发环境 | 247通过、1跳过 | 非目标安装平台;跳过符号链接创建权限 |
|
||||
|
||||
实际验收覆盖:空HOME/全新prefix离线安装及启动、无pip/user-site、版本及包加载位置、拒绝已有安装、中英文suite/check统一格式、JSON恢复/Markdown导出、源码篡改拒绝、真实HTTP429保留限流、阻塞媒体请求的SIGINT/SIGTERM退出130且保存已完成结果、模块异常不泄密且继续、真实UDP响应/超时统计、指定可读配置前后哈希不变。
|
||||
|
||||
自动测试还覆盖:worker超时杀死/回收、取消不再发起新请求、安装失败与取消清理、清单逃逸、日志预算、原有所有回归。没有将Mock结果当作真实公网测量。两台远端源文件哈希相同并与上传源核对。
|
||||
|
||||
证据位于工作区outputs:Alpha22/Alpha24-pytest.txt、Alpha22/Alpha24-alpha-acceptance.json、Alpha22/Alpha24-alpha-acceptance.txt、Alpha-dual-vm-summary.json。版本测试记录与历史0.5.0/239测试记录区分。
|
||||
|
||||
## 未闭合门槛与剩余风险
|
||||
|
||||
- **全新操作系统镜像安装尚未验证**:本轮是已有Ubuntu上的空HOME及全新环境,不证明系统没有先前安装过Python/venv组件。安装器检测失败会退出,不自动更改系统。09-01全量验收不能因此勾完成。
|
||||
- 公开发布须从可信来源独立提供ZIP摘要;本轮无签名密钥、无远程发布、无远程CI结果。所有构建/测试包仍在本地工作区。
|
||||
- CLI默认网络worker有上限;路由90s为软预算,正在进行的定位请求可能额外耗时最多一个worker上限。不是CPU/内存/所有系统调用的硬隔离沙箱。
|
||||
- 第三方真实网络/地区/数据库字段受出口和目标限制。Alpha本轮主要实测本机回环错误与取消,不宣称海外网络、付费数据库、账号播放或专业吞吐容量已验收。
|
||||
- SIGKILL、断电、磁盘/文件系统损坏以及未完成的探测无法保证结果保存;先前完整日志记录为恢复依据。路径应在可信用户目录,未承诺抵御共享目录所有竞态攻击。
|
||||
- 配置哈希仅覆盖指定可读路径,不是完整系统写入审计。仅系统/数据读取和本地输出写入,不修改SSH/防火墙/路由/DNS,无完整报告上传。
|
||||
- ARM、容器全场景、全新Server/Desktop、真实VPS、所有信号时序、可选带宽worker真实容量未验证。兼容和支持范围必须按README披露。
|
||||
|
||||
## P2与发布建议
|
||||
|
||||
P0/P1已完成主要代码修复和现有VM实测,但干净系统镜像门槛未闭合,故不扩展P2代码。04-09/05-03/08-05保留已有证据与保守边界;09-04仍缺全新安装形态验收。
|
||||
|
||||
建议把当前包作为内部Alpha候选供审阅;补全新镜像验证、确认摘要分发后再决定公开Alpha。保持大功能关闭/未实现声明。用户批准发布是后续动作,本轮不代为执行。
|
||||
@@ -0,0 +1,28 @@
|
||||
> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。
|
||||
|
||||
# v0.1.0-alpha 发布优先项状态
|
||||
|
||||
本轮日期:2026-11-01。与原96项条目编号对应。历史27/53/16统计是本轮前基准,不再作为最新Alpha状态;本轮不以局部改动把全部复合需求勾为完成。
|
||||
|
||||
| 优先级/编号 | 实现与验证 | 本轮状态/剩余限制 |
|
||||
|---|---|---|
|
||||
| P0 09-03 | venv --without-pip、包只复制到隔离site-packages,空HOME、禁用user-site、无pip实测 | Alpha范围通过;需系统Python/venv |
|
||||
| P0 09-01 | 一条python3 install.py --run离线安装并运行;两VM空HOME/新prefix通过 | 实现通过;全新Ubuntu镜像未验证,公开发布前仍需补验 |
|
||||
| P0 09-10 | 可审查安装器、ZIP SHA256、逐文件清单、拒绝路径逃逸/源符号链接/篡改/已有目录 | 机制通过;摘要不是数字签名,发布时必须提供可信摘要渠道 |
|
||||
| P0 10-04 | CLI HTTP/API/定位/媒体/吞吐网络worker时限、DNS子进程、固定本机命令、错误码及隔离 | 回归及实际429/异常通过;带宽未做新的实网容量验收 |
|
||||
| P0 10-06 | SIGINT/SIGTERM取消网络worker、保留完成结果,安装失败/取消只清理新目录 | 两VM真实阻塞请求信号验收及清理故障注入通过;SIGKILL/断电不保证未落盘数据 |
|
||||
| P1 03-02 | 最多3轮重复UDP追踪,逐跳实际发出/响应/超时/RTT/响应缺失比例 | 实际3次响应+1次超时统计通过;不宣称转发丢包率,不含ICMP/TCP |
|
||||
| P1 08-01 | 统一result_items schema1;旧payload和schema保持兼容,check/suite/独立命令可消费 | Alpha报告规范通过;不宣称未来FlexMaster API稳定承诺 |
|
||||
| P1 08-02 | 统一五状态、来源/方法/时间/错误/置信范围;执行和业务证据分开 | 回归及CLI验证通过;cancelled原状态保留、统一项为unknown+CANCELLED |
|
||||
| P1 08-08 | suite及独立网络/IP/媒体/测速增量保存,JSONL恢复、文件独占、写入上限 | 信号/模块错误/恢复/写失败注入通过;正在进行的未完成探测、硬件故障不保证全部保留 |
|
||||
|
||||
## P2 门槛评估
|
||||
|
||||
因全新操作系统镜像安装验证尚缺,不宣称全部发布门槛已闭合。本轮不新增P2实现:
|
||||
|
||||
- 04-09:已有查询时间、来源、部分置信范围;第三方数据库真实更新时间不一定提供,不能伪造。
|
||||
- 05-03:保留地区/片库/未知与账号播放边界;未补账号登录或播放能力,不宣称完整验收。
|
||||
- 08-05:保留基于实际结果的保守排查提示,不推断唯一故障原因。
|
||||
- 09-04:现有两版UbuntuVM已测;全新Server/Desktop安装形态仍需独立验证。
|
||||
|
||||
其余96项需求范围与未实现的大型能力沿用CURRENT_STATUS.md。无公开发布、无主分支合并。
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
## v0.1.0-alpha 候选(2026-11-01)
|
||||
|
||||
隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。
|
||||
|
||||
# 架构与接口演进
|
||||
|
||||
2026-11-01。当前范围是独立 Python CLI,未来 FlexMaster 通过 Python API 复用。以下区分现状与方案,方案尚未视为实现。
|
||||
|
||||
## 现状
|
||||
|
||||
- `checks.py`:Host 只读适配器、Result、inspect;返回 schema_version=1 字典。
|
||||
- `suite.py`:四模块编排、终端渲染和 schema_version=2 报告,当前相互耦合。
|
||||
- `routes.py`:Linux UDP 错误队列路由,emit 回调和 records 可供调用;非通用跨平台实现。
|
||||
- `quality.py`、`media.py`、`ipquality.py`、`bandwidth.py`:可调用函数,但部分收集函数同时输出终端。
|
||||
- `net.py`:有限响应、请求超时;`console.py`:颜色和控制字符过滤;`cli.py`:参数、脱敏和保存。
|
||||
- 无运行依赖,Python >=3.10;setuptools>=77 是构建依赖,尚无上限锁定。
|
||||
|
||||
## 渐进方案(待实现)
|
||||
|
||||
保留文件和现有接口,先增加独立 core/models、core/runner、reports 组件,再逐模块适配。不要一次移动所有代码。
|
||||
|
||||
统一 EvidenceResult:schema_version、check_id、status、observed_at、method、source、metrics(数值+单位)、error_code、confidence、limitations。状态为 success/failure/skipped/unsupported/unknown。failure 表示检查失败,不等于确认存在漏洞。媒体业务状态保留独立字段,不把 catalog_visible 映射为播放成功。旧 pass/warn 不可机械映射为网络成功/失败。
|
||||
|
||||
Runner 接受显式配置和 provider 注入;每模块独立捕获预期错误,未知异常仅记录固定错误码,不保存异常原文中的凭证。KeyboardInterrupt 传给协调器,落盘已有结果再退出 130。同步阶段先使用明确请求预算;不可通过线程超时假装终止底层网络操作。
|
||||
|
||||
Reports 接收结构化对象,不解析 CLI 文本。增量 JSONL 保存完成事件,最终报告独占创建、默认脱敏;禁止覆盖已有文件或通过符号链接写入。持久化失败应保留可用终端结果并返回明确错误。历史比较只比较相同方法、单位、节点及采样参数;用户购买规格单独存为 user_claim,不能当实测。
|
||||
|
||||
## 安全配置
|
||||
|
||||
常规 suite 不跑 CPU 或磁盘基准;测速需 allow_bandwidth=True / CLI --allow-bandwidth。该标志仅授权当前调用,不是永久授权。当前预算沿用 bandwidth.payload_budget,属于 HTTP 载荷预算,不能宣称为链路计费硬上限。CPU/磁盘基准后续单独命令、确认和上限。常规联网仍会向目标及 API 暴露请求来源;--offline 跳过联网。
|
||||
|
||||
第三方可执行文件入口当前暂停;恢复前必须审查版本、许可、网络上传和资源消耗,不自动下载或提权。未来 FlexMaster 默认同样应用安全策略。暂不承诺稳定 API/Schema 兼容性;新格式须先有版本及迁移文档。
|
||||
|
||||
|
||||
## Reliability batch — 2026-11-01 (implemented, untested)
|
||||
|
||||
`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs.
|
||||
|
||||
Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot.
|
||||
|
||||
Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation.
|
||||
|
||||
Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet.
|
||||
|
||||
|
||||
## 资源检测第三组(2026-11-01:已编写,未测试)
|
||||
|
||||
- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。
|
||||
- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。
|
||||
- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。
|
||||
- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。
|
||||
- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。
|
||||
|
||||
限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。
|
||||
|
||||
尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。
|
||||
|
||||
实现语义参考 Linux 内核官方文档:
|
||||
|
||||
- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max)
|
||||
- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒)
|
||||
- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段)
|
||||
|
||||
下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。
|
||||
|
||||
|
||||
## 双栈网络第四组(2026-11-01,已编写但未测试)
|
||||
|
||||
新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。
|
||||
|
||||
分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。
|
||||
|
||||
增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。
|
||||
|
||||
|
||||
## 基础安全第五组(2026-11-01:实现未测试)
|
||||
|
||||
新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。
|
||||
|
||||
更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。
|
||||
|
||||
新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
## 1.0.0b1 — 2026-11-01 公测候选,非最终版本
|
||||
|
||||
终端、语言和参数修复;流媒体改为可达性;新增可选 ICMP/TCP、三城三运营商追踪、BGP 和候选节点探测。完整大陆三网未验证。详见 PUBLIC_BETA_CHANGE_REPORT.md。
|
||||
|
||||
|
||||
## v0.1.0-alpha 候选(2026-11-01)
|
||||
|
||||
隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。
|
||||
|
||||
|
||||
## 报告与IP质量更新(2026-11-01)
|
||||
|
||||
- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。
|
||||
- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。
|
||||
- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。
|
||||
- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。
|
||||
- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。
|
||||
- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。
|
||||
- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。
|
||||
- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。
|
||||
|
||||
证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。
|
||||
|
||||
|
||||
## 最新双机验收(2026-11-01)
|
||||
|
||||
同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。
|
||||
|
||||
|
||||
## Ubuntu 24.04 实测更新(2026-11-01)
|
||||
|
||||
当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。
|
||||
中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。
|
||||
DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。
|
||||
综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。
|
||||
完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。
|
||||
|
||||
# Changelog
|
||||
|
||||
## Resource display correction — 2026-11-01 (untested)
|
||||
|
||||
Resource output now prioritizes process CPU bounds and visible cgroup memory
|
||||
limits. Raw OS CPU/RAM observations remain detailed JSON evidence rather than
|
||||
being presented as dedicated VPS capacity. Recognized containers without a
|
||||
visible memory limit show unknown allocation instead of host MemTotal. Allocation
|
||||
basis and unknown CPU/memory dedication are explicit. Container markers are
|
||||
best-effort evidence, not complete virtualization detection. No local metric
|
||||
certifies provider-side exclusive CPU or reserved memory. Added a container
|
||||
fixture case; no tests executed.
|
||||
|
||||
## Unreleased — 2026-11-01
|
||||
|
||||
- 范围更新为独立 Python VPS CLI,不开发代理部署和 Web UI。
|
||||
- 按任务书逐条登记 101 项需求,记录源码、验证方法及限制。
|
||||
- 新增架构演进、五个参考项目入口源码/许可证研究和测试计划。
|
||||
- 综合流程移除自动 SHA-256 基准,默认跳过带宽测试;CLI --allow-bandwidth / Python allow_bandwidth=True 显式许可才启用测速。
|
||||
- 暂停 NodeQuality/BestTrace/RegionRestrictionCheck 包装入口,避免常规流程执行未经资源/上传审查的第三方程序。
|
||||
- 保留现有 0.5.0 代码历史,在 engineering/safe-cli-core 分支修改。
|
||||
|
||||
验证状态:用户要求本轮可先不测试;未运行自动测试、虚拟机或实网测量。模块故障隔离、增量保存、统一结构化接口和其他 backlog 项尚未完成。版本号保持 0.5.0,未发布。
|
||||
|
||||
|
||||
## Reliability batch — 2026-11-01 (implemented, untested)
|
||||
|
||||
`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs.
|
||||
|
||||
Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot.
|
||||
|
||||
Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation.
|
||||
|
||||
Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet.
|
||||
|
||||
|
||||
## 资源检测第三组(2026-11-01:已编写,未测试)
|
||||
|
||||
- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。
|
||||
- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。
|
||||
- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。
|
||||
- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。
|
||||
- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。
|
||||
|
||||
限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。
|
||||
|
||||
尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。
|
||||
|
||||
实现语义参考 Linux 内核官方文档:
|
||||
|
||||
- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max)
|
||||
- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒)
|
||||
- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段)
|
||||
|
||||
下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。
|
||||
|
||||
|
||||
## 双栈网络第四组(2026-11-01,已编写但未测试)
|
||||
|
||||
新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。
|
||||
|
||||
分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。
|
||||
|
||||
增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。
|
||||
|
||||
|
||||
## 基础安全第五组(2026-11-01:实现未测试)
|
||||
|
||||
新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。
|
||||
|
||||
更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。
|
||||
|
||||
新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 当前状态:1.0.0b1 · P0 修复候选
|
||||
|
||||
2026-11-01;非最终版本,未推送、合并或公开发布。
|
||||
|
||||
- 已读取并核实上轮 22.04 Desktop 的原始报告:22 项中 21 通过,字节码缓存检查失败。
|
||||
- BETA22-001 已修复:安装器、提供的 shell 启动器、联网 worker、DNS 和邮件子解释器均显式带 -B,不依赖父解释器或被 -I 忽略的环境变量。
|
||||
- 增加五种实际 worker 的全新源码副本回归,以及可写负对照。开发机 277 通过、1 项 Windows 权限跳过。
|
||||
- 用户已调整本轮 22.04 方案:使用原 22.04 Desktop 虚拟机的新 HOME/全新安装目录;这不等于新装 Server 镜像。另一验收目标已确认是 24.04.3 Server。
|
||||
- 本轮要求从本轮 ZIP 校验/解压/安装,测试主要命令、缓存、异常、中断和配置不变。当前构建的完成情况以候选包旁 BETA_ACCEPTANCE_REPORT.md / BETA_ACCEPTANCE_RESULTS.json 中的 SHA256 与实际日志为准。
|
||||
- 旧 Alpha、初版 Beta 和第三方模型报告均为历史证据,不自动赋予当前构建通过状态。
|
||||
- 可选 ICMP/TCP 依赖、完整大陆三网、海外 VPS 等能力必须按实机报告的实际覆盖解释,不因入口已存在而标记完成。
|
||||
|
||||
缓存承诺覆盖提供的安装启动器、源码脚本及其 Python 子进程;外部不带 -B 的自行导入、pip 安装预编译不作无缓存承诺。没有删除历史缓存来掩盖结果,验收使用全新目录。
|
||||
@@ -0,0 +1,411 @@
|
||||
# VPS 综合检测工具——完整开发与工程优化任务书
|
||||
|
||||
## 0. 项目背景与最终目标
|
||||
|
||||
我正在独立开发一个基于 Python 的开源 VPS 综合检测工具,主要面向中文 VPS 社区及 Linux 初学者。
|
||||
|
||||
当前已有初步完成的功能:
|
||||
|
||||
- Linux 发行版、内核、CPU、内存、磁盘等基础信息检测。
|
||||
- Traceroute 路由跟踪及终端显示。
|
||||
- 现有代码已在 Ubuntu 22.04 Server 和 Ubuntu 24.04 Desktop 虚拟机环境中进行过测试。
|
||||
|
||||
以上完成情况是我的阶段性描述,请通过实际代码和测试重新核实,不能仅凭我的描述认定全部测试通过。
|
||||
|
||||
本项目的目标不是简单复制其他 VPS 检测脚本,而是开发一个:
|
||||
|
||||
**自主实现为主、安全透明、功能完整、结果准确、适合小白、可长期维护,并能被未来 FlexMaster 项目调用的 VPS 综合诊断工具。**
|
||||
|
||||
项目现阶段只开发 Python CLI 检测工具,不开发代理部署功能和 Web UI。
|
||||
|
||||
未来 FlexMaster 将复用本项目的检测核心,但二者作为独立项目维护。
|
||||
|
||||
---
|
||||
|
||||
## 1. 开发原则(最高优先级)
|
||||
|
||||
1. **自主实现优先。** 能够通过 Python 标准库或成熟 Python 依赖完成的功能,优先自主编写。
|
||||
2. **保留已有成果。** 不推倒重来,不重复开发已经稳定实现的本机检测与 Traceroute。
|
||||
3. **避免重复造轮子。** 对磁盘基准、专业网络吞吐和高级路由诊断,可调用 fio、iperf3、mtr 等成熟工具。
|
||||
4. **合法利用外部资源。** 对地理位置、ASN、IP 风险、信誉信息等本机无法独立获取的数据,可使用合法的第三方 API 或数据库。
|
||||
5. **源码借鉴必须合法。** 先检查 LICENSE、版权声明及第三方依赖许可,不未经确认直接复制 GPL/AGPL 项目的实现代码。
|
||||
6. **检测结果必须真实。** 不伪造延迟、丢包、IP 纯净度、流媒体解锁状态或测速结果。
|
||||
7. **默认安全。** 普通检测只读;性能、磁盘写入、持续测速及较大流量测试必须明确提示并取得用户确认。
|
||||
8. **单模块故障隔离。** 外部 API、网络、权限或依赖出错时,不能导致整个程序崩溃。
|
||||
9. **结构化优先。** 检测逻辑、数据模型、CLI 输出、报告导出相互解耦。
|
||||
10. **面向小白。** 最终实现一条命令启动,中文输出清楚,提供英文选项,并解释结果含义。
|
||||
11. **以证据判定完成。** 每个任务必须有对应实现、测试记录及验收说明。
|
||||
12. **不得擅自执行高风险动作。** 不自动修改 SSH、防火墙、路由、DNS、系统网络配置,不部署代理,不向外上传完整报告。
|
||||
|
||||
---
|
||||
|
||||
## 2. 需要解决的全部功能问题
|
||||
|
||||
请对以下十一类功能进行逐项审查、设计、实现及测试。
|
||||
|
||||
要求建立完整的问题追踪表,记录每项的状态:
|
||||
|
||||
- 已实现且通过测试
|
||||
- 已实现但有缺陷
|
||||
- 部分实现
|
||||
- 尚未实现
|
||||
- 外部条件不足,待实机验证
|
||||
- 不适用,并说明原因
|
||||
|
||||
### 第一类:VPS 本机与硬件检测
|
||||
|
||||
- 准确识别 Linux 发行版、内核、CPU 架构及虚拟化类型。
|
||||
- 识别 CPU 型号、核心数、频率及实际资源配额。
|
||||
- 检测内存、Swap、磁盘容量与资源限制。
|
||||
- 检测 CPU steal、内存压力、资源争用。
|
||||
- 实现可重复、可比较的 CPU 性能基准。
|
||||
- 检测磁盘吞吐、IOPS、读写延迟,严格限制磁盘写入和占用。
|
||||
- 支持用户输入购买规格,并对比实际可见配置;无法核实时明确说明。
|
||||
|
||||
### 第二类:基础网络与 DNS
|
||||
|
||||
- 检测公网 IPv4、IPv6、默认路由和网络接口。
|
||||
- 分别验证 IPv4/IPv6 外网连通性。
|
||||
- 测量 DNS 解析时间、成功率和错误类型。
|
||||
- 区分 DNS、网络路径及目标服务故障。
|
||||
- 测试 TCP 连接、TLS 握手与 UDP 连通性。
|
||||
- 检查 MTU/PMTU、双栈异常与 IPv6 连接问题。
|
||||
- 正确处理 NAT、容器、多网卡环境,避免误判公网出口。
|
||||
|
||||
### 第三类:Traceroute、BGP 与线路分析
|
||||
|
||||
- 保留现有 Traceroute 核心代码并完善。
|
||||
- 支持可行的 ICMP、UDP、TCP 探测方式。
|
||||
- 显示逐跳 IP、RTT、超时及具有明确统计依据的丢包数据。
|
||||
- 查询逐跳 ASN、运营商、推测地区。
|
||||
- 避免将不响应的路由器错误判断为线路故障。
|
||||
- 避免将单个中间节点的高延迟直接认定为拥堵。
|
||||
- 检测中国电信、联通、移动相关线路特征。
|
||||
- 支持 BGP、上游网络与 ASN 信息分析。
|
||||
- 明确区分 VPS → 中国与中国 → VPS 的测试方向。
|
||||
- 地理位置只显示推测结果,不声称是真实物理线路。
|
||||
- 无中国侧测试节点时,不伪造中国到 VPS 的去程检测。
|
||||
|
||||
### 第四类:IP 地理位置、纯净度与信誉
|
||||
|
||||
- 查询国家、地区、城市、ASN、运营商。
|
||||
- 识别住宅、机房、移动网络等 IP 分类。
|
||||
- 查询代理、VPN、Tor 及相关风险标签。
|
||||
- 支持多个数据源交叉验证与冲突提示。
|
||||
- 查询公开黑名单、DNSBL、邮件信誉。
|
||||
- 检测 PTR 等 DNS 邮件相关信息。
|
||||
- 不使用缺乏依据的绝对“纯净度分数”。
|
||||
- 处理 API 限速、失效、付费要求和服务条款。
|
||||
- 每条结果记录来源、查询时间、可信度及限制。
|
||||
|
||||
### 第五类:流媒体与在线服务可用性
|
||||
|
||||
- 检测 Netflix、Disney+、YouTube 等服务。
|
||||
- 检测 ChatGPT 等在线服务的网络与地区可用性。
|
||||
- 区分网络可达、地区识别、受限、需要登录、无法确认及实际播放成功。
|
||||
- 检测 DNS、TLS、HTTP 错误及服务端限制。
|
||||
- 禁止将 HTTP 200 直接判定为成功解锁。
|
||||
- 处理网站检测规则变化、反爬限制和误报。
|
||||
- 明确声明账号权限、订阅资格和实际播放能力可能无法自动验证。
|
||||
- 支持用户自定义合法检测目标。
|
||||
- 不尝试绕过账号验证、反爬或访问限制。
|
||||
|
||||
### 第六类:多地区测速与长期稳定性
|
||||
|
||||
- 研究可信的全球及中国不同运营商测试节点。
|
||||
- 测量延迟、抖动、丢包、下载和上传速率。
|
||||
- 严格区分测试方向、节点地理位置与节点运营商。
|
||||
- 限制带宽、并发、执行时间及流量消耗。
|
||||
- 识别测试节点瓶颈,避免假性低速。
|
||||
- 支持可中断的 24/48 小时低负载监测。
|
||||
- 分析晚高峰性能劣化、间歇性断线、突发丢包。
|
||||
- 关联 CPU steal、内存压力及网络质量的时间变化。
|
||||
- 中断监测时安全退出并保留已有数据。
|
||||
|
||||
### 第七类:基础安全体检
|
||||
|
||||
- 只读检查 SSH 配置和潜在高风险认证方式。
|
||||
- 检查监听端口及可能暴露的服务。
|
||||
- 识别系统防火墙状态,说明云安全组无法从本机完整确认。
|
||||
- 检查系统更新状态及基础安全设置。
|
||||
- 检测重要文件权限风险。
|
||||
- 避免把正常业务配置直接定义为漏洞。
|
||||
- 提供有依据的修复建议,但不自动执行。
|
||||
- 避免泄露密码、Token、私钥及其他敏感信息。
|
||||
|
||||
### 第八类:报告、诊断与历史记录
|
||||
|
||||
- 设计统一的结构化检测结果 Schema。
|
||||
- 区分成功、失败、跳过、不支持和无法判断。
|
||||
- 保存检测时间、方法、来源、错误与置信度说明。
|
||||
- 将技术指标转换成普通用户能理解的中文解释。
|
||||
- 提供基于规则和真实测量结果的诊断建议。
|
||||
- 支持本地 JSON、Markdown 报告,必要时提供 HTML。
|
||||
- 对分享用报告进行脱敏。
|
||||
- 上传失败、断网或意外退出时不丢失已有报告。
|
||||
- 支持历史记录及不同时间、不同服务器的对比。
|
||||
- 生成 VPS 购买验收报告,区分实际测量与用户提供的购买规格。
|
||||
- 不生成未经校准的绝对性能评分。
|
||||
|
||||
### 第九类:安装、兼容性与用户体验
|
||||
|
||||
- 实现面向小白的一键安装与运行。
|
||||
- 检查 Python 版本、依赖及权限。
|
||||
- 使用虚拟环境隔离依赖,不污染系统 Python。
|
||||
- 优先兼容 Ubuntu 22.04 Server、24.04 Desktop。
|
||||
- 评估其他发行版、容器、ARM/x86 架构兼容性。
|
||||
- 提供中文和英文输出,处理终端编码问题。
|
||||
- 尽可能支持非 root 检测。
|
||||
- 缺少依赖时友好提示、降级或跳过。
|
||||
- 提供帮助、参数说明、更新、卸载和离线说明。
|
||||
- 安装脚本可审查,采用可信来源及版本固定/完整性验证。
|
||||
- 不要求用户盲目执行来源不明的 curl | bash。
|
||||
|
||||
### 第十类:可靠性、隐私与自动化测试
|
||||
|
||||
- 验证默认检测确实不修改系统配置。
|
||||
- 区分普通检测与高负载、有写入的基准测试。
|
||||
- 对运行时间、CPU、磁盘、流量设置安全上限。
|
||||
- 处理超时、网络失败、权限错误和依赖缺失。
|
||||
- 保证模块故障隔离。
|
||||
- 处理 Ctrl+C、安全取消与临时文件清理。
|
||||
- 防止命令注入、路径问题、终端控制字符污染和敏感日志泄露。
|
||||
- 使用 Mock、单元测试、集成测试和异常场景测试。
|
||||
- 在两种 Ubuntu 虚拟机及真实 VPS 上验证。
|
||||
- 建立安全的社区错误反馈方式。
|
||||
- 检查第三方许可证、依赖安全和 API 使用条款。
|
||||
|
||||
### 第十一类:开源维护与未来 FlexMaster 集成
|
||||
|
||||
- 每个检测模块既能由 CLI 使用,也能通过 Python API 调用。
|
||||
- 检测核心与终端 UI 解耦。
|
||||
- 为未来 FlexMaster 设计稳定、可复用的接口。
|
||||
- 完善 README、安装说明、贡献指南及测试文档。
|
||||
- 提供标准 GitHub Issue 模板和脱敏日志机制。
|
||||
- 明确支持系统、已知问题和未验证环境。
|
||||
- 建立外部贡献代码审查与自动化测试规范。
|
||||
- 为开源发布准备合适的 LICENSE、依赖声明及第三方版权通知。
|
||||
|
||||
---
|
||||
|
||||
## 3. 参考成熟项目,但不能直接拼接它们
|
||||
|
||||
请实际阅读以下 GitHub 仓库的相关源码、目录结构、检测逻辑和许可证文件:
|
||||
|
||||
1. YABS
|
||||
https://github.com/masonr/yet-another-bench-script
|
||||
|
||||
2. Backtrace
|
||||
https://github.com/oneclickvirt/backtrace
|
||||
|
||||
3. 融合怪 ECS
|
||||
https://github.com/oneclickvirt/ecs
|
||||
|
||||
4. NodeQuality
|
||||
https://github.com/LloydAsp/NodeQuality
|
||||
|
||||
5. OneClickVirt
|
||||
https://github.com/oneclickvirt/oneclickvirt
|
||||
|
||||
重点研究:
|
||||
|
||||
- YABS:fio、iperf3、基准流程及结构化结果。
|
||||
- Backtrace:三网回程、ASN、线路识别。
|
||||
- 融合怪:多模块综合检测与功能覆盖。
|
||||
- NodeQuality:环境隔离、报告组织、异常恢复。
|
||||
- OneClickVirt:自动化工程组织及兼容性测试。
|
||||
|
||||
必须实际检查相关源码,不仅阅读 README。
|
||||
|
||||
每项借鉴方案要说明:
|
||||
|
||||
- 项目与源码链接
|
||||
- 具体文件、函数或调用入口
|
||||
- 采用何种实现方式
|
||||
- 采用原因
|
||||
- 许可证及版权义务
|
||||
- 可能涉及的 API 条款或费用
|
||||
- 安全风险及兼容性问题
|
||||
|
||||
如果可以自主开发,请优先自主实现。确需借助已有项目时,优先选择通过公开接口、系统命令或兼容许可证的独立依赖实现。
|
||||
|
||||
不要未经授权复制 GPL/AGPL 代码;不要认为调用外部程序就自动豁免全部许可证义务。必要时明确标记为待确认。
|
||||
|
||||
---
|
||||
|
||||
## 4. 与现有竞品的竞争策略
|
||||
|
||||
请参考融合怪和 NodeQuality 的功能覆盖,但不要只追求更多测试项目。
|
||||
|
||||
我们的重点竞争优势为:
|
||||
|
||||
**A. Safe by Default**
|
||||
- 只读优先
|
||||
- 低负载模式
|
||||
- 资源限制
|
||||
- 错误隔离
|
||||
- 数据本地优先
|
||||
- 不修改 SSH、防火墙及网络配置
|
||||
|
||||
**B. Explain, Not Just Measure**
|
||||
- 用中文说明检测结果
|
||||
- 提供数据来源和可信度
|
||||
- 解释风险与异常
|
||||
- 给出明确的下一步排查建议
|
||||
- 不编造检测结论
|
||||
|
||||
**C. Compare Over Time**
|
||||
- 本地检测历史
|
||||
- 24/48 小时稳定性监测
|
||||
- 晚高峰异常分析
|
||||
- 多 VPS 比较
|
||||
- 购买验收报告
|
||||
|
||||
请在竞品研究中区分:
|
||||
|
||||
1. 竞争项目已经实现的能力。
|
||||
2. 竞争项目部分实现或存在局限的能力。
|
||||
3. 我们能够通过实际工程实现证明的优势。
|
||||
|
||||
不允许仅凭宣传描述声称优于竞争项目。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术架构与实现要求
|
||||
|
||||
保持 Python 为核心技术栈。
|
||||
|
||||
推荐采用以下逻辑分层,可以根据现有代码合理调整:
|
||||
|
||||
- Core:模块调度、配置、数据模型、异常处理。
|
||||
- Checks:系统、网络、路由、IP、流媒体、安全等检测。
|
||||
- Benchmarks:CPU、磁盘和吞吐性能基准。
|
||||
- Providers:外部 API、数据库、测速节点接口。
|
||||
- Diagnostics:解释规则和异常诊断。
|
||||
- Reports:JSON、Markdown、HTML、脱敏和历史比较。
|
||||
- CLI:终端交互、语言切换和进度展示。
|
||||
- Tests:Mock、单元、集成与回归测试。
|
||||
|
||||
要求:
|
||||
|
||||
- 模块可以独立运行。
|
||||
- 外部依赖允许替换。
|
||||
- 接口返回结构化对象,不依赖解析 CLI 文字。
|
||||
- 所有网络请求具有明确超时。
|
||||
- 默认测试不要求 root。
|
||||
- 高风险或高负载操作使用显式确认机制。
|
||||
- 使用固定版本或受控版本范围管理依赖。
|
||||
- 提供稳定的数据格式与错误编码。
|
||||
- 保持未来与 FlexMaster 的接口兼容性。
|
||||
|
||||
---
|
||||
|
||||
## 6. Work 的执行流程
|
||||
|
||||
### 第一步:代码审查与现状确认
|
||||
|
||||
先查看我现有项目代码、Git 状态、测试、依赖和目录。
|
||||
|
||||
识别哪些功能已经存在、哪些功能不稳定、哪些功能尚未开发。
|
||||
|
||||
不得覆盖已有成果。
|
||||
|
||||
### 第二步:建立问题追踪表
|
||||
|
||||
把上面十一类需求全部登记。
|
||||
|
||||
为每一项标注:
|
||||
|
||||
- 编号及问题描述
|
||||
- 当前代码位置
|
||||
- 实际完成状态
|
||||
- 建议实现方法
|
||||
- 所需依赖/API
|
||||
- 测试方法
|
||||
- 风险及限制
|
||||
- 完成证据
|
||||
|
||||
不要单纯因为某个功能名称出现在代码里就标记完成。
|
||||
|
||||
### 第三步:技术方案与开源研究
|
||||
|
||||
针对未实现或不可靠的功能,先比较自主实现、Python 依赖、系统工具及第三方 API 方案。
|
||||
|
||||
涉及 GPL/AGPL 代码复制、付费 API、上传用户数据或改变原有架构的重大决定时,先征求我确认。
|
||||
|
||||
### 第四步:逐模块开发
|
||||
|
||||
完成代码实现、测试、必要文档和错误处理。
|
||||
|
||||
优先复用现有组件,避免一次性进行无关的大规模重构。
|
||||
|
||||
### 第五步:测试与验证
|
||||
|
||||
在有权限的隔离测试环境中执行安全测试。
|
||||
|
||||
对于 Ubuntu 22.04 Server、24.04 Desktop,以及真实 VPS 的测试,分别记录结果。
|
||||
|
||||
没有真实 VPS 环境时,通过 Mock 和有限的本地测试推进,但必须注明尚未实机验证。
|
||||
|
||||
禁止擅自在生产 VPS 上运行高负载测试。
|
||||
|
||||
### 第六步:汇总与交付
|
||||
|
||||
输出真实完成情况和未解决问题。
|
||||
|
||||
不要为了交付而将模拟、跳过或不确定的检测伪装为通过。
|
||||
|
||||
---
|
||||
|
||||
## 7. 需要创建和维护的项目文件
|
||||
|
||||
建议在仓库中维护:
|
||||
|
||||
- `PROBLEM_BACKLOG.md`:全部问题及实际状态
|
||||
- `ARCHITECTURE.md`:模块架构与数据接口
|
||||
- `OPEN_SOURCE_RESEARCH.md`:开源项目源码研究与许可证分析
|
||||
- `TEST_PLAN.md`:测试场景及验收标准
|
||||
- `SECURITY.md`:安全边界、隐私和漏洞报告
|
||||
- `CHANGELOG.md`:已经完成的实际更新
|
||||
- `README.md`:面向用户的使用说明
|
||||
|
||||
如果文件已存在,应审查并更新,不要无故覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 8. 最终验收标准
|
||||
|
||||
我希望最终获得的不是只有功能菜单的半成品,而是实际可运行、可信的工具。
|
||||
|
||||
验收时需要证明:
|
||||
|
||||
1. 十一类问题都有明确状态及依据。
|
||||
2. 声称完成的功能具有相应测试证据。
|
||||
3. 常规扫描不会擅自更改服务器网络和安全配置。
|
||||
4. 基准和大流量测试有明确授权及资源限制。
|
||||
5. 一个模块失败不影响其他模块运行。
|
||||
6. 结果可在终端查看、导出并安全保存。
|
||||
7. 第三方数据具有来源和不确定性说明。
|
||||
8. 用户能够理解检测结果及异常原因。
|
||||
9. 项目能够通过合理的安装流程运行。
|
||||
10. 代码模块可被未来 FlexMaster 独立调用。
|
||||
11. 开源借鉴内容具备合法的版权和许可证处理方案。
|
||||
12. 未验证的功能不得宣传为已经完成。
|
||||
|
||||
## 9. 本次工作授权边界
|
||||
|
||||
请先完成现有代码审查、问题登记、开源源码研究及架构分析。
|
||||
|
||||
完成这些后,在隔离分支中逐模块推进低风险开发和自动化测试,保留原有代码及 Git 历史。
|
||||
|
||||
**未经我进一步确认,不要执行生产 VPS 测试、安装系统级依赖、修改防火墙/SSH、使用付费 API、上传检测数据、合并主分支或公开发布。**
|
||||
|
||||
每完成一组实质性工作,请向我汇报:
|
||||
|
||||
- 实际完成了什么
|
||||
- 修改了哪些文件
|
||||
- 测试通过了哪些项目
|
||||
- 哪些仍然存在问题
|
||||
- 下一步建议
|
||||
|
||||
最终目标:建立一个具有独立代码价值、可信检测结果、安全工程设计,并能持续维护的 Python VPS 综合诊断工具。
|
||||
@@ -0,0 +1,20 @@
|
||||
# IPQuality 风格功能覆盖与外部资源
|
||||
|
||||
2026-11-01。目标是借鉴清楚的分区展示,优先自主实现,不直接运行或拼接第三方脚本。
|
||||
|
||||
| 分区 | 当前代码 | 限制/后续 |
|
||||
|---|---|---|
|
||||
| 顶部概览 | IP、ASN、运营商、估计位置、网络类型、风险标签、来源冲突数、覆盖率、时间 | 默认隐藏IP;没有取得风险标签不等于安全;不是综合纯净度评分 |
|
||||
| 多数据库依据 | ipapi.is、proxycheck.io、IP2Location、可选AbuseIPDB;来源类型/代理/VPN/Tor对照 | 缺字段/密钥保留未知;并未覆盖上游所有数据库 |
|
||||
| 流媒体/在线服务 | suite下独立模块,保留限制、片库、未知等证据 | 账号、DRM和实际播放未验证;IPQuality本身不作为我们的数据API |
|
||||
| 邮件网络 | `ipquality --mail-probes` / `suite --mail-probes`;PTR、正向一致性、单目标出站TCP/25 | 默认跳过额外探测;不发邮件;不证明入站25、全部邮件服务或投递信誉 |
|
||||
| DNSBL | 明确显示未查询 | 每个来源的使用条款、resolver限制、查询控制地址、NXDOMAIN/限流/错误码需要先实现,不能把空响应当干净 |
|
||||
| IPMap/定位地图 | 尚未接入 | 待选择具体来源并核对公开API/许可/费用/查询隐私;不会自动抓取需要登录或限制访问的网页 |
|
||||
|
||||
## 实际源码研究及许可
|
||||
|
||||
阅读 [xykt/IPQuality LICENSE](https://github.com/xykt/IPQuality/blob/main/LICENSE) 和 [ip.sh](https://github.com/xykt/IPQuality/blob/main/ip.sh):包括邮件端口与 `check_dnsbl_parallel` / `check_dnsbl` 入口及结构化邮件结果组织。根许可证为GNU AGPL v3文本。我们没有复制其实现代码、数据库清单或服务网页抓取逻辑;报告布局和检测需求作为设计参考,检测代码自主编写。
|
||||
|
||||
该入口的DNSBL分类依赖查询返回内容。我们不会照搬“空结果即干净”之类假设,必须区分查询失败、服务拒绝、未列出及命中,再考虑显示统计。也不会认为调用外部程序就自动免除许可责任。若以后确需引入AGPL实现或分发其组件,先单独评估并征求用户确认。
|
||||
|
||||
外部资源可通过受控API适配器、本地合法数据库或明确可选的查询链接接入。每条来源需记录查询时间、来源版本/地址、字段、错误、限制及费用。网页查询链接若携带公网IP,应在用户明确选择时才打开。未具备的资源统一列待接入,不能伪造地图、数据库结果或风险分数。
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"version": "1.0.0b1",
|
||||
"files": {
|
||||
"install.py": "c0f1f65b129c92beddadb3697b8bec4cadc99a1f982bd7a1b1aa5b5cc992c762",
|
||||
"pyproject.toml": "ebca11d7c070a9ab2f565178cd78da96fe303b9eeee4a14eccc8beb99e0a1922",
|
||||
"proxydoctor.sh": "7b5ccc2c96e36b4f6d53dd54915bc90416aba9ace06738e0c1913cf887b92972",
|
||||
"LICENSE": "4538e62067151c3795abf12838a4799f54ef6546a2a8a1697abd1ce21b03336d",
|
||||
".gitattributes": "1cbf58d18ccb72113d6d1bb3996400d557ccc83cda00a11cd63ef5ca38bbf199",
|
||||
"ALPHA_RELEASE_REPORT.md": "f4efc245bcec8f78d1bc7a925dc922271d7b3705f43be6b7611f8d6547af0300",
|
||||
"ALPHA_TASK_STATUS.md": "faec6e515e8941b70b65774e36fb98b10c7b3f1284096bf186fe3345f3b7db7f",
|
||||
"ARCHITECTURE.md": "2d0167efe06df1eb10f0386b3a48c743fcd555a57e7a5247f1af24e6d2524701",
|
||||
"CHANGELOG.md": "98fc7cb3d1ba9f39680f5cbda1511340af05af35f8d3b12eaf2d213bce573e88",
|
||||
"CONTRIBUTING.md": "45fa883a73cd267a732bfd99e542f736c975a1e11ac9075c127262a4500de902",
|
||||
"CURRENT_STATUS.md": "735e4bdef596fe4f25780225b30e975998b4cd1061aa091da673d07d8c85c5e0",
|
||||
"DEVELOPMENT_BRIEF.zh.md": "c80df4f88d60decdacb29864d48760a3517f1016bdb0ba784749365116affc6e",
|
||||
"IPQUALITY_COVERAGE.md": "342c4919b0ba7f05699f16526e8d0153b91e7e1c38876451019cd9ee5f1613c7",
|
||||
"OPEN_SOURCE_RESEARCH.md": "5ae3b119e93a0f7164a50bfd0e65f3c811285c04d626c25d02e950bd9b75d432",
|
||||
"PROBLEM_BACKLOG.md": "a65378b9572aa8e703bbf94df26bd0b8e80b36b1d0249962ed5b1ccfbc309419",
|
||||
"PUBLIC_BETA_CHANGE_REPORT.md": "abe72da4c576e02089e4e564c5f88fd81e5c3034347ca48c2bcb981d0f0dcca4",
|
||||
"README.en.md": "5cd2e7d51221b8290e4c81ca4bbd0d6eb5ff9d38ee9b5e50e8ed43693ca73d64",
|
||||
"README.md": "be116592cf5814c5a3ec9521f7b2fb691ca2c4bde62f4bfd649722086d4e1452",
|
||||
"SECURITY.md": "5b55903153973d4413d53b4715d71df677df20cde4506bac5bc866778b654838",
|
||||
"TEST_PLAN.md": "6036d77b05958e9c088aa9605d3b78986ab3b7ded42abcf65f53118099d9e490",
|
||||
"THIRD_PARTY_NOTICES.md": "b1f24279e2870c4917b85b220a42d1ae31b6e39452cd1aa8dcb55425b0d6a6d1",
|
||||
"USER_CHECKLIST_STATUS.md": "fb983deeca9a1a4b2e219b95163a9e8f0ba02bca7cea778f96455603f2b29df2",
|
||||
"proxydoctor/__init__.py": "e86da25d9f8a2028fe7ece956847b17b4cb82f127ba356aa6ac28b5cdbe89855",
|
||||
"proxydoctor/__main__.py": "f21b83731acbaa16519e17fa795601ccd3543400cd5c59bb40098a488c225d84",
|
||||
"proxydoctor/bandwidth.py": "199a350483c53467cc341b521951e7a533ab04d5655dd8ceb2da94ad85653d34",
|
||||
"proxydoctor/bgp.py": "4c9fea00e616058700f73335fe1995764b85d610a89b22936b4e5dd9f563c0c6",
|
||||
"proxydoctor/carriers.py": "3838b06354e68f57aa514ba4570be087489582821e530a4f2948a15598712295",
|
||||
"proxydoctor/checks.py": "f010c09c834af2f4ac48128113bb5aebef9d1fb0c796891623d02232ccf09e31",
|
||||
"proxydoctor/cli.py": "0a53081310bdc76cc57fc7ef75c645a0f8efee04fa03c4a463ca75b7ddd7e143",
|
||||
"proxydoctor/connectivity.py": "2b6d4cfe69271ad582c3b0eb81bc69d38a9395c80cc12df2804bd01854f22385",
|
||||
"proxydoctor/console.py": "c3de3fd95509a701df296c5d9c7bc167fe39e7b557b505efe477180283508948",
|
||||
"proxydoctor/geo.py": "9547a5e7da6d92d62df0fd5977d39384fcb5c745fac7efed93d283ae88074643",
|
||||
"proxydoctor/i18n.py": "d9f619c0b0e85ef7295aec39bf1a94b1c43d7f19d5fd0fe86f438e84d05f89b1",
|
||||
"proxydoctor/ipquality.py": "4dd1fc1421d11d03d8c803626393ca02d7be81527d4984da57c9ef2140c395bb",
|
||||
"proxydoctor/mailquality.py": "ae1ad2e071bd7038b6ea7fbcbd63b7128eac9e5477f79f43c22172d6a5bb5071",
|
||||
"proxydoctor/media.py": "b0c32211ed5a1b1ba37aac6cd31725be40ec0c2dbe1a5acbaf54a4e1d961229d",
|
||||
"proxydoctor/net.py": "ed53a91e7e048c3e4850b6f72d0d90c74dc8229dc2a36cb2deadf0071df7b68c",
|
||||
"proxydoctor/quality.py": "2e06f0d43cb95909fe4f14f08b39567b94cc1ee4ee03338fb8885fe1ec9fc878",
|
||||
"proxydoctor/reporting.py": "2689e39fe1c1f4f07ac43e3b7b564de34cb36d20e56669ab30c20923f27c727d",
|
||||
"proxydoctor/reports.py": "09bd0952c89397ef601a3421e563bdad1e74b17d07976dfcb4066a0353cea4ba",
|
||||
"proxydoctor/resources.py": "b2b7355ef8f09a16d28956e292dfb848c2ddc927c3773730c437cf53de4a09a4",
|
||||
"proxydoctor/routes.py": "9bec6db8ce78ffa34c41931f7d388e9ac6d59ce52fed5d442371efe844d426ae",
|
||||
"proxydoctor/runner.py": "84462cb56bf335140a3aec08ace9c80dd745a9aeea99be618ac4483cb3430c8e",
|
||||
"proxydoctor/schema.py": "f80098d2dbf0334d460e47754a429a7449532fdd6aa69420ed275edebd5f8830",
|
||||
"proxydoctor/security.py": "363327e43ab13dc8ebb9bb88f49fd14cea4aedca0952e9733159f99a195bb019",
|
||||
"proxydoctor/session.py": "e72383becc82015b4a2ea5a8a6aa535a6b6e6bba90b94f29117346d9a9b0acf7",
|
||||
"proxydoctor/suite.py": "e0f608e71669c7dc92a659ee0a8f083569a640cd83aeaa15077308da68b73c49",
|
||||
"proxydoctor/worker.py": "8dcabf06c2604f846cc8fe5b09a392ff782c30fc7145f6befeef733133ac4eb3",
|
||||
"tests/conftest.py": "9ba6f678d6297f2dc85f42121790246cd86b120dd52e537ee09d4c19f2cdf1b9",
|
||||
"tests/test_alpha_quality.py": "a833be4ec7f0600383495b838ea1e95f50112003d47b2cc2cebbec50d0eba706",
|
||||
"tests/test_bandwidth_sources.py": "c9e41f7f3d25078feef335a549fb8a3229662d552b552f0579369ae61cfc659b",
|
||||
"tests/test_bytecode_policy.py": "b66e5358d5e7adf36d5413b776e494d8c66d1dda368d259a79a02f0ea622d657",
|
||||
"tests/test_checks.py": "4ff2ff8562a13635d24cf6a29dd7063578bcc1a202840aeb76c7619c06cacfb3",
|
||||
"tests/test_connectivity.py": "e4cea940bf3783e312f7acf28b80c7fc709579e3809a415a5822b05431d6fde1",
|
||||
"tests/test_console_geo.py": "24d91b4abf292f666fde27244099bab7d52afff99b835c2fda26bd73a86aba03",
|
||||
"tests/test_i18n.py": "48e1615334c1c1b19a872bf11067e7e38f78ece60eb777d3cc4ad7661cef1c12",
|
||||
"tests/test_ip_panel.py": "817b27b6eb96e91b370418fe1b7e4625253d77091e87e8bf55e8077781044570",
|
||||
"tests/test_mailquality.py": "87907932a22a423ec8134bb60ebf29523019b4c91ae774ab2c2fb65dd5113c44",
|
||||
"tests/test_measurements.py": "d2dfa5d3273a662c6d343b163e449d0baae85ebb3e81d94e75e6882109cd5a04",
|
||||
"tests/test_media.py": "cd97e7d0ac600847ecd91ea0be5a2b1eb1dca573c9f25e697e0529d676351139",
|
||||
"tests/test_nodequality.py": "444eeb659e3bd70e67bb11b89df9de9c856609a102a25036c59b5947a2bd94f1",
|
||||
"tests/test_phase1_delivery.py": "4d83d7464dffbf7eedde97a2750eb93140ebb4c344306c7bcdc76332a2966da9",
|
||||
"tests/test_public_beta.py": "9a8f62bb4c199e2c3bf9a7c639c7180d1367825e76eb96f33e58af10925d394a",
|
||||
"tests/test_quality.py": "a7afc7a3794032f4a9bc53ab8385188168f1fe96615ae2cac1b2d0eaf0b72561",
|
||||
"tests/test_recovery.py": "05803bfbc5de22c9a974335d0d67554af98e082568b91ac542ff5035a47434c2",
|
||||
"tests/test_reporting.py": "9d22378138cecf3a3ec2db52862002d0c0ccd0905785c6a922cc1b5e87bda762",
|
||||
"tests/test_resources.py": "0276cace908748f7acf76bc2beba527c37c564e090fe18ac8c2cd9371615de88",
|
||||
"tests/test_review.py": "9a41c686dd280d041929ca2b465f4b738e05765cfc4289ca719a16865d7c1700",
|
||||
"tests/test_routes.py": "68074a666bc29ac92b8b1ba1f985a12b6949e64cf3c22b20789fa4dc98eab5ed",
|
||||
"tests/test_security.py": "49e5b4a02b6d9d08a57265d5fa7311331b3041e1403d6883a42c1728dc1564fa",
|
||||
"tests/test_server_info.py": "52bd0f409cc01ffaabb3fde25a0781dab286065d475f2c639ed586b89b5608c2",
|
||||
"tests/test_stage1.py": "9413983d55805a663d5b831b64146f191846192788ff014f4db256c5f68d3b9a",
|
||||
"tests/test_suite.py": "61c83fe1bdb54deea884120ae8f9cc17373cdbe2f27bc84b600f86e614b1b5cb",
|
||||
"docs/history-beta-before-p0-20261101.md": "e5dcc486181d3590905aa4ec7e181f0c3a7b7cb253c47578fae475b36094bb6c",
|
||||
"docs/pre-alpha-README.en.md": "82922b373bd7d0ede200368a6a3db68339678ca27bea8554cd41324d65d4a3ea",
|
||||
"docs/pre-alpha-README.md": "0d7819c12bc688f52e765c8bc6827198772548663dcb9f7adb26db59c79ed73d",
|
||||
"docs/status-history-before-2026-11-01.md": "f50bd7cb60fdaac5e2e5c14105fa08e5b19b1f354ae9414732b161d31b8665fd",
|
||||
".github/workflows/tests.yml": "ba27ec460fd71a2f9bbac20be0aa5b7043a14a04ae7c3ae0eaf5237cd72b4b5c"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# 开源源码研究(首轮)
|
||||
|
||||
阅读日期:2026-11-01。通过 GitHub 连接器实际获取根目录、下列源码及 LICENSE;未运行参考脚本、未复制实现。链接为阅读时默认分支,可变化,实施任何复用前还需固定 commit 和复核依赖许可证。本轮是入口级研究,不宣称完成整个仓库审计。
|
||||
|
||||
| 项目 | 实际阅读文件/入口 | 源码观察与采用方案 | 根许可证及处理 | 风险、费用和后续工作 |
|
||||
|---|---|---|---|---|
|
||||
| [YABS](https://github.com/masonr/yet-another-bench-script) | [yabs.sh](https://github.com/masonr/yet-another-bench-script/blob/master/yabs.sh),本地 fio/iperf3 探测、FIO_BIN_TAG/IPERF_BIN_TAG、catch_abort、JSON 参数路径;LICENSE | 现有实现已有本地工具优先、基准跳过、JSON 和中断处理;我们计划独立编写本地工具适配器,消费其 JSON,不复制 shell。分别统计方法/节点便于比较 | 根 LICENSE 为 WTFPL v2;未来 fio/iperf3 的二进制、库及分发许可须单独核查,根许可不覆盖一切 | 脚本含下载和可选结果发送;本项目不采用自动下载/上传。测速节点条款和流量费用未逐项确认,不能默认集成 |
|
||||
| [Backtrace](https://github.com/oneclickvirt/backtrace) | [cmd/main.go](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go):newBacktraceFlagSet、validateStructuredOptions、RunRouteReport 调用及 ResolveOriginASNWithConfig | 已有结构化路由、尝试次数、超时和可选 WHOIS/geofeed;计划自主建立 ASN 特征证据,保留现有 UDP 核心。中国目标只标为 VPS→中国方向 | Apache-2.0;如未来复用须保留许可、版权和适用 NOTICE,并注明修改;本轮无复制 | 入口包含外部访问;bk/bgptools 深层算法与数据库许可尚待审查。ASN 不代表具体城市或必经专线;外部服务限额/费用待核 |
|
||||
| [ECS](https://github.com/oneclickvirt/ecs) | [goecs.go](https://github.com/oneclickvirt/ecs/blob/master/goecs.go):runStructuredCLI、legacyDeadlineWindows、sanitizeECSLog、main | 已有显式结构化 CLI 和编排/日志处理入口,不能声称这些是本项目独有。借鉴分阶段调度理念,自主实现 Python 故障隔离和预算 | GPL v3 文本;不复制其实现;如果未来引入须先评估衍生/分发义务并征求用户确认 | 多模块和外部组件需逐个审核,内部实现尚未逐项研究。数据服务条款及费用待核实;不能以根仓库许可替代依赖审查 |
|
||||
| [NodeQuality](https://github.com/LloydAsp/NodeQuality) | [NodeQuality.sh](https://github.com/LloydAsp/NodeQuality/blob/main/NodeQuality.sh):load_bench_os、chroot_run、clear_mount、sig_cleanup、upload_result、main | 有临时系统、分模块选择、信号清理和报告组织;main 调用 upload_result。只借鉴模块组织和清理思路,采用本地报告、非 root 检测;现有包装入口暂停 | AGPL v3 文本;不复制代码。不因采用外部进程就认定豁免许可责任 | chroot/mount/swap 与远程脚本执行不符合当前默认边界;上传报告需另行明确授权。不能仅依据“临时系统”认定对宿主无影响 |
|
||||
| [OneClickVirt](https://github.com/oneclickvirt/oneclickvirt) | 根目录、[agent-regressions.yml](https://github.com/oneclickvirt/oneclickvirt/blob/main/.github/workflows/agent-regressions.yml):Go race 检查、OS matrix、Cargo locked 与 musl 目标 | 工程已有多环境回归和依赖锁定;借鉴 CI 组织方法,独立编写 Python Mock/Ubuntu 矩阵。该项目是控制面/Agent/Web 工程,不是检测核心的直接替代 | GPL v3 文本;不复制工作流或产品代码。后续引入任何组件单独审核 | 看到 CI 配置不证明运行通过;此工作流不证明所有 Ubuntu、ARM 或本项目兼容。无需接入其部署功能,外部服务费用不适用当前研究 |
|
||||
|
||||
## 竞争判断
|
||||
|
||||
已观察到:YABS 的结构化输出、Backtrace 的结构化路由入口、ECS 的编排和日志入口、NodeQuality 的中断清理、OneClickVirt 的多环境 CI。因此不能以这些名称本身宣称优于竞品。上述源码审查也不足以证明竞品缺少某能力。
|
||||
|
||||
本项目可验证的候选优势:默认跳过基准、明确许可测速、保守地区证据、本地报告和历史比较。前两项本轮已修改但未测试;后两项部分存在或待开发,暂不作优越性宣传。API 条款、数据集许可、费用和深层实现仍须逐项研究后才能集成。
|
||||
@@ -0,0 +1,271 @@
|
||||
> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。
|
||||
|
||||
> 最新Alpha优先项状态见 [ALPHA_TASK_STATUS.md](ALPHA_TASK_STATUS.md)。以下包含本轮前历史统计,不能作为最新发布验收结论。
|
||||
|
||||
|
||||
## 报告与IP质量更新(2026-11-01)
|
||||
|
||||
- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。
|
||||
- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。
|
||||
- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。
|
||||
- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。
|
||||
- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。
|
||||
- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。
|
||||
- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。
|
||||
- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。
|
||||
|
||||
证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。
|
||||
|
||||
|
||||
## 最新双机验收(2026-11-01)
|
||||
|
||||
同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。
|
||||
|
||||
|
||||
## Ubuntu 24.04 实测更新(2026-11-01)
|
||||
|
||||
当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。
|
||||
中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。
|
||||
DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。
|
||||
综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。
|
||||
完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。
|
||||
|
||||
# 全部需求追踪表
|
||||
|
||||
|
||||
更新:2026-11-01。范围:独立 Python CLI;不开发代理部署或 Web UI。
|
||||
|
||||
本轮按用户要求不执行测试。历史 outputs/ 验收报告属于 0.5.0,不能证明本分支修改通过。状态中“部分实现”只表示源码证据;未将存在函数名等同于验收。
|
||||
|
||||
优先级:P0 默认安全与保存/隔离 → P1 结构化本机、双栈网络和证据 → P2 路由/信誉/媒体与报告 → P3 基准、长期监测、安装与集成。
|
||||
|
||||
P0 本轮:综合流程移除自动 CPU 基准;带宽要求显式许可;暂停可上传数据的第三方脚本入口。实现未测试。保存/隔离已编写,待验证;最新汇总见 CURRENT_STATUS.md。
|
||||
|
||||
|
||||
## 第一类:VPS 本机与硬件检测
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 01-01 | 准确识别 Linux 发行版、内核、CPU 架构及虚拟化类型。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 |
|
||||
| 01-02 | 识别 CPU 型号、核心数、频率及实际资源配额。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 |
|
||||
| 01-03 | 检测内存、Swap、磁盘容量与资源限制。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 |
|
||||
| 01-04 | 检测 CPU steal、内存压力、资源争用。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 |
|
||||
| 01-05 | 实现可重复、可比较的 CPU 性能基准。 | 尚未实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 |
|
||||
| 01-06 | 检测磁盘吞吐、IOPS、读写延迟,严格限制磁盘写入和占用。 | 尚未实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 |
|
||||
| 01-07 | 支持用户输入购买规格,并对比实际可见配置;无法核实时明确说明。 | 尚未实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 |
|
||||
|
||||
|
||||
## 第二类:基础网络与 DNS
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 02-01 | 检测公网 IPv4、IPv6、默认路由和网络接口。 | 部分实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 |
|
||||
| 02-02 | 分别验证 IPv4/IPv6 外网连通性。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 |
|
||||
| 02-03 | 测量 DNS 解析时间、成功率和错误类型。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 |
|
||||
| 02-04 | 区分 DNS、网络路径及目标服务故障。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 |
|
||||
| 02-05 | 测试 TCP 连接、TLS 握手与 UDP 连通性。 | 部分实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 |
|
||||
| 02-06 | 检查 MTU/PMTU、双栈异常与 IPv6 连接问题。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 |
|
||||
| 02-07 | 正确处理 NAT、容器、多网卡环境,避免误判公网出口。 | 部分实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 |
|
||||
|
||||
|
||||
## 第三类:Traceroute、BGP 与线路分析
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 03-01 | 保留现有 Traceroute 核心代码并完善。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-02 | 支持可行的 ICMP、UDP、TCP 探测方式。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-03 | 显示逐跳 IP、RTT、超时及具有明确统计依据的丢包数据。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-04 | 查询逐跳 ASN、运营商、推测地区。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-05 | 避免将不响应的路由器错误判断为线路故障。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-06 | 避免将单个中间节点的高延迟直接认定为拥堵。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-07 | 检测中国电信、联通、移动相关线路特征。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-08 | 支持 BGP、上游网络与 ASN 信息分析。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-09 | 明确区分 VPS → 中国与中国 → VPS 的测试方向。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-10 | 地理位置只显示推测结果,不声称是真实物理线路。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
| 03-11 | 无中国侧测试节点时,不伪造中国到 VPS 的去程检测。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 |
|
||||
|
||||
|
||||
## 第四类:IP 地理位置、纯净度与信誉
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 04-01 | 查询国家、地区、城市、ASN、运营商。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-02 | 识别住宅、机房、移动网络等 IP 分类。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-03 | 查询代理、VPN、Tor 及相关风险标签。 | 部分实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-04 | 支持多个数据源交叉验证与冲突提示。 | 部分实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-05 | 查询公开黑名单、DNSBL、邮件信誉。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-06 | 检测 PTR 等 DNS 邮件相关信息。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-07 | 不使用缺乏依据的绝对“纯净度分数”。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-08 | 处理 API 限速、失效、付费要求和服务条款。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
| 04-09 | 每条结果记录来源、查询时间、可信度及限制。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 |
|
||||
|
||||
|
||||
## 第五类:流媒体与在线服务可用性
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 05-01 | 检测 Netflix、Disney+、YouTube 等服务。 | 部分实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-02 | 检测 ChatGPT 等在线服务的网络与地区可用性。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-03 | 区分网络可达、地区识别、受限、需要登录、无法确认及实际播放成功。 | 尚未实现 | P0 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-04 | 检测 DNS、TLS、HTTP 错误及服务端限制。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-05 | 禁止将 HTTP 200 直接判定为成功解锁。 | 部分实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-06 | 处理网站检测规则变化、反爬限制和误报。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-07 | 明确声明账号权限、订阅资格和实际播放能力可能无法自动验证。 | 部分实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-08 | 支持用户自定义合法检测目标。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
| 05-09 | 不尝试绕过账号验证、反爬或访问限制。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 |
|
||||
|
||||
|
||||
## 第六类:多地区测速与长期稳定性
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 06-01 | 研究可信的全球及中国不同运营商测试节点。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-02 | 测量延迟、抖动、丢包、下载和上传速率。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-03 | 严格区分测试方向、节点地理位置与节点运营商。 | 部分实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-04 | 限制带宽、并发、执行时间及流量消耗。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-05 | 识别测试节点瓶颈,避免假性低速。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-06 | 支持可中断的 24/48 小时低负载监测。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-07 | 分析晚高峰性能劣化、间歇性断线、突发丢包。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-08 | 关联 CPU steal、内存压力及网络质量的时间变化。 | 部分实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
| 06-09 | 中断监测时安全退出并保留已有数据。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 |
|
||||
|
||||
|
||||
## 第七类:基础安全体检
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 07-01 | 只读检查 SSH 配置和潜在高风险认证方式。 | 部分实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
| 07-02 | 检查监听端口及可能暴露的服务。 | 部分实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
| 07-03 | 识别系统防火墙状态,说明云安全组无法从本机完整确认。 | 部分实现 | P0 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
| 07-04 | 检查系统更新状态及基础安全设置。 | 部分实现 | P1 | security.py; checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_security.py; checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
| 07-05 | 检测重要文件权限风险。 | 部分实现 | P1 | security.py; checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_security.py; checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
| 07-06 | 避免把正常业务配置直接定义为漏洞。 | 尚未实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
| 07-07 | 提供有依据的修复建议,但不自动执行。 | 部分实现 | P0 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
| 07-08 | 避免泄露密码、Token、私钥及其他敏感信息。 | 尚未实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 |
|
||||
|
||||
|
||||
## 第八类:报告、诊断与历史记录
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 08-01 | 设计统一的结构化检测结果 Schema。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-02 | 区分成功、失败、跳过、不支持和无法判断。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-03 | 保存检测时间、方法、来源、错误与置信度说明。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-04 | 将技术指标转换成普通用户能理解的中文解释。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-05 | 提供基于规则和真实测量结果的诊断建议。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-06 | 支持本地 JSON、Markdown 报告,必要时提供 HTML。 | 部分实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-07 | 对分享用报告进行脱敏。 | 部分实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-08 | 上传失败、断网或意外退出时不丢失已有报告。 | 尚未实现 | P0 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-09 | 支持历史记录及不同时间、不同服务器的对比。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-10 | 生成 VPS 购买验收报告,区分实际测量与用户提供的购买规格。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
| 08-11 | 不生成未经校准的绝对性能评分。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 |
|
||||
|
||||
|
||||
## 第九类:安装、兼容性与用户体验
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 09-01 | 实现面向小白的一键安装与运行。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-02 | 检查 Python 版本、依赖及权限。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-03 | 使用虚拟环境隔离依赖,不污染系统 Python。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-04 | 优先兼容 Ubuntu 22.04 Server、24.04 Desktop。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-05 | 评估其他发行版、容器、ARM/x86 架构兼容性。 | 外部条件不足,待实机验证 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-06 | 提供中文和英文输出,处理终端编码问题。 | 部分实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-07 | 尽可能支持非 root 检测。 | 部分实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-08 | 缺少依赖时友好提示、降级或跳过。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-09 | 提供帮助、参数说明、更新、卸载和离线说明。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-10 | 安装脚本可审查,采用可信来源及版本固定/完整性验证。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
| 09-11 | 不要求用户盲目执行来源不明的 curl | bash。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 |
|
||||
|
||||
|
||||
## 第十类:可靠性、隐私与自动化测试
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 10-01 | 验证默认检测确实不修改系统配置。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-02 | 区分普通检测与高负载、有写入的基准测试。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-03 | 对运行时间、CPU、磁盘、流量设置安全上限。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-04 | 处理超时、网络失败、权限错误和依赖缺失。 | 部分实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-05 | 保证模块故障隔离。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-06 | 处理 Ctrl+C、安全取消与临时文件清理。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-07 | 防止命令注入、路径问题、终端控制字符污染和敏感日志泄露。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-08 | 使用 Mock、单元测试、集成测试和异常场景测试。 | 部分实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-09 | 在两种 Ubuntu 虚拟机及真实 VPS 上验证。 | 外部条件不足,待实机验证 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-10 | 建立安全的社区错误反馈方式。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
| 10-11 | 检查第三方许可证、依赖安全和 API 使用条款。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 |
|
||||
|
||||
|
||||
## 第十一类:开源维护与未来 FlexMaster 集成
|
||||
|
||||
|
||||
| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 11-01 | 每个检测模块既能由 CLI 使用,也能通过 Python API 调用。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
| 11-02 | 检测核心与终端 UI 解耦。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
| 11-03 | 为未来 FlexMaster 设计稳定、可复用的接口。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
| 11-04 | 完善 README、安装说明、贡献指南及测试文档。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
| 11-05 | 提供标准 GitHub Issue 模板和脱敏日志机制。 | 部分实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
| 11-06 | 明确支持系统、已知问题和未验证环境。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
| 11-07 | 建立外部贡献代码审查与自动化测试规范。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
| 11-08 | 为开源发布准备合适的 LICENSE、依赖声明及第三方版权通知。 | 部分实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster |
|
||||
|
||||
共登记 101 项。每行验收前须补测量记录与精确证据;不得批量升级完成状态。
|
||||
|
||||
## P0 第二组实现记录(2026-11-01,未测试)
|
||||
|
||||
- `runner.py`:六模块独立执行边界、固定错误码、取消透传、执行状态与时间。
|
||||
- `reports.py`:独占创建、脱敏增量 JSONL、flush/fsync、最终 JSON、恢复读取 API、存储失败降级。
|
||||
- `suite.py`:接入默认本地保存、Ctrl+C 保存/130、失败模块继续;`quality.py`/`routes.py`/`media.py` 增加结果保存回调。
|
||||
- `tests/test_recovery.py`:新增模块错误、取消、尾部损坏、写入冲突、符号链接及刷盘失败场景,未执行。
|
||||
|
||||
关联第八类保存/中断、第十类故障隔离/取消需求由“尚未实现”推进为“部分实现(未测试)”;尚不能标记已实现且通过测试。完整限制见架构和测试计划的 Reliability batch。
|
||||
|
||||
|
||||
## 资源检测第三组(2026-11-01:已编写,未测试)
|
||||
|
||||
- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。
|
||||
- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。
|
||||
- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。
|
||||
- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。
|
||||
- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。
|
||||
|
||||
限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。
|
||||
|
||||
尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。
|
||||
|
||||
实现语义参考 Linux 内核官方文档:
|
||||
|
||||
- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max)
|
||||
- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒)
|
||||
- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段)
|
||||
|
||||
下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。
|
||||
|
||||
|
||||
## 双栈网络第四组(2026-11-01,已编写但未测试)
|
||||
|
||||
新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。
|
||||
|
||||
分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。
|
||||
|
||||
增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。
|
||||
|
||||
逐项状态仍为部分实现;02类 DNS/TCP/TLS 代码证据新增 connectivity.py。整体进度见 CURRENT_STATUS.md。
|
||||
|
||||
## 基础安全第五组(2026-11-01:实现未测试)
|
||||
|
||||
新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。
|
||||
|
||||
更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。
|
||||
|
||||
新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。
|
||||
@@ -0,0 +1,46 @@
|
||||
## P0 追加修复 · BETA22-001
|
||||
|
||||
本轮修复父解释器 -B 不被新 Python 进程继承的问题。联网、DNS、邮件子解释器均显式传 -B,安装器探测及源码脚本同步。
|
||||
五种实际 worker 在全新副本中运行,配可写负对照;开发机回归 277 通过、1 跳过。没有删除缓存代替修复。
|
||||
用户将 22.04 Server 要求调整为原 22.04 Desktop 上全新 HOME/安装目录;24.04.3 Server 另行测试。实际结果见候选包旁本轮验收报告,历史材料不等于当前候选通过。
|
||||
|
||||
---
|
||||
|
||||
# 1.0 公测候选变更与待测项
|
||||
|
||||
版本:1.0.0b1;非最终版。日期:2026-11-01。
|
||||
基线:用户上传 iprisk-edition,SHA256 3fa8f9c2294774fa0aad98866e8ce3c3ea181bdcf1854515591dd0e7ccf639dc。
|
||||
|
||||
## 已完成代码
|
||||
|
||||
1. 修复三主题的框宽、中文按显示列换行、长字段截断、分组错位、英文残留、颜色参数未传递、--lang=zh 降级以及风险汇总显示绿色。
|
||||
2. 联网子模块复用 Console,路由输出进入框内;保留已有普通主题和命令。
|
||||
3. 可选无密钥来源可省略;限流/异常/请求失败继续显示,恢复有效 AbuseIPDB 结果展示。
|
||||
4. 流媒体 CLI 仅检查公开页面可达性,一次请求,不输出解锁/套餐/播放结论;旧内部分类 API 保留兼容。
|
||||
5. 新增 trace ICMP/TCP 的固定系统 traceroute 适配,限时、取消与逐跳解析;缺依赖不安装、不提权。
|
||||
6. 新增 return-route 三城九方向追踪与 IPv4/IPv6 参数;不冒充全国/双向覆盖。
|
||||
7. 新增 bgp RIPE RIS 前缀验证、起源、邻居、AS路径和 ASN 标签,不推断商业上游或 GIA 等级。
|
||||
8. 新增 cn-nodes 目录与小样本探测;覆盖统计排除香港。新增可选择的三网候选 HTTPS 下载,尚未证明完整大陆三网可用,也没有三网上传。
|
||||
9. 新版标识、安装提示 flush、启动 -B、无 unzip 解压说明;Markdown 安全 JSON 围栏。
|
||||
10. 更新中英文文档和来源记录。未增加产品 Python 依赖;未修改系统网络/安全配置。
|
||||
|
||||
## 证据和限制
|
||||
|
||||
- 开发机自动化:271 通过,1 项 Windows 符号链接权限跳过。增加实际主题 CLI、列宽、语言、可达性、颜色、来源异常、BGP 校验、TCP 参数和香港排除回归覆盖。
|
||||
- 初轮失败 XML 保留;其中包括清单未刷新、边框尾空格和旧接口夹具不适配。修复后全套通过,没有删除失败测试或降低安全要求。
|
||||
- 上海电信及香港移动的 HTTPS 小 JPEG 可访问,南京电信仅明文成功;大陆联通/移动存在解析失败、拒绝或超时。小样本不等于有效多轮带宽或海外可用。
|
||||
- 证据位于工作区 outputs/public-beta-tests.xml、carrier-node-discovery-20261101.json、carrier-node-discovery-round2-20261101.json。
|
||||
- 尚未执行当前版本的 Ubuntu 安装、VM/海外实测。历史 Alpha 结果不能替代新版验收。
|
||||
|
||||
## 下一轮测试
|
||||
|
||||
- 从最终 ZIP 在 Ubuntu 22.04/24.04 安装、重复安装、失败清理、隔离和界面验收。
|
||||
- ICMP/TCP 权限、缺依赖、IPv6、超时、中断、逐跳 IP/RTT/地理位置。
|
||||
- BGP 实网接口、限流、异常响应及路径范围解释。
|
||||
- 海外 VPS 三城九方向完整追踪及恢复;全国/双向仍是后续能力。
|
||||
- 大陆三运营商有效多轮下载和节点开放范围;不将香港替代大陆移动。
|
||||
- 流媒体基础可达性、模块异常、报告保存、Markdown 和中断恢复回归。
|
||||
- 持续复核节点服务条款和可用性;开源许可证不等于第三方服务承诺。
|
||||
|
||||
按用户要求,代码、文档、本地候选包收尾后停止,留待下一阶段测试。
|
||||
结论:可提交下一轮测试的代码候选;不是已通过最终公测验收。未推送、合并或发布。
|
||||
+46
-148
@@ -1,172 +1,70 @@
|
||||
# ProxyDoctor
|
||||
# VPS Check · 1.0 public beta
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
Native Python read-only VPS diagnostics for Ubuntu 22.04 and newer. Python 3.10+;
|
||||
no third-party runtime dependencies. Ubuntu 22.04/24.04 are the current acceptance
|
||||
targets; other versions need separate testing. No automatic sudo, installation,
|
||||
SSH/firewall changes or fetching/executing remote scripts.
|
||||
Read-only Python VPS diagnostics; internal package/command: proxydoctor. **Public beta, not the final release.** Version: `1.0.0b1`. Local candidate only; not publicly published or finally accepted.
|
||||
|
||||
## Run
|
||||
## Install
|
||||
|
||||
Extract the source package and run from its directory:
|
||||
Requires Linux, Python 3.10+ and standard-library venv. Target: Ubuntu 22.04 and newer. No Python runtime dependencies, sudo, pip or automatic system package installation. Check the ZIP against a trusted SHA256 digest and review install.py first; Python extraction works without unzip.
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh
|
||||
bash proxydoctor.sh suite --lang en
|
||||
bash proxydoctor.sh suite --offline --lang en
|
||||
python3 -m proxydoctor check --lang en --port 8388 --json --output check.json
|
||||
sha256sum -c ProxyDoctor-v1.0.0-beta.1.zip.sha256
|
||||
python3 -m zipfile -e ProxyDoctor-v1.0.0-beta.1.zip .
|
||||
cd ProxyDoctor-v1.0.0-beta.1
|
||||
python3 install.py --run
|
||||
PD="$HOME/.local/share/proxydoctor/1.0.0b1/bin/proxydoctor"
|
||||
"$PD" check --theme framed --lang en
|
||||
"$PD" suite --offline --lang en
|
||||
```
|
||||
|
||||
Downloading/cloning does not execute code. The default `suite` contacts external
|
||||
services and streams compact colored rows. `check` defaults to local reads.
|
||||
Ordinary users can run it; missing privileges remain unknown. Report files stay
|
||||
local, do not overwrite existing files and redact IPs. Review before sharing;
|
||||
redaction is not an anonymity guarantee. `--show-ip` displays egress IP in the
|
||||
terminal only. Routes display actual hop IPs in the terminal; exports mask them.
|
||||
An isolated user environment is created. Existing destinations are refused; failures clean only the new directory. Missing prerequisites fail clearly. The supplied launcher and Python workers explicitly use -B. This covers supplied launch paths, not arbitrary external imports or pip precompilation; add -B when invoking Python directly.
|
||||
|
||||
## Four modules
|
||||
|
||||
| Module | Terminal output | Limits |
|
||||
| --- | --- | --- |
|
||||
| Resources/preflight | OS, architecture, CPU/model/count, kernel, memory, disk, SHA-256 sample; route/DNS config, target port, SSH guard/config, local/cloud firewall status | Visible resources may differ from quotas; SHA-256 is not a general CPU benchmark; local reads cannot establish cloud ingress |
|
||||
| Network/IP | Real DNS/TCP probes; ownership/ASN/location/type, provider flags/scores, coverage and disagreements | AAAA answers do not prove IPv6 connectivity; provider scores are separate observations, not safety certification |
|
||||
| Route | Linux IPv4/IPv6 UDP error queue, 30 hops by default, up to 64; IP, latency, estimated location | NAT/filtering or silent routers can prevent completion; geolocation is approximate |
|
||||
| Media | Ten independent service adapters, repeat confirmation, catalog/metadata evidence and explicit unknown reasons | No paid account/DRM playback verification; public offers/catalogs do not prove full unlock |
|
||||
|
||||
Suite JSON schema_version=2 includes local checks, DNS/TCP, per-source bandwidth,
|
||||
IP observations/provenance/conflicts, route hops and media probe evidence.
|
||||
Green indicates acquired data/local success, red known warnings/restrictions,
|
||||
yellow unknown/skipped/insufficient evidence. `--color auto|always|never` and
|
||||
`NO_COLOR` are supported. Use `check --json` for detailed local evidence.
|
||||
`sshd -T` defaults may differ from running instances/Match rules. Future repair
|
||||
features still require separate SSH safeguards.
|
||||
|
||||
## Bandwidth
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh bandwidth --lang en
|
||||
bash proxydoctor.sh bandwidth --lang en --source ovh-rbx --rounds 3 --sample-mib 4
|
||||
bash proxydoctor.sh bandwidth --lang en --source hetzner-fsn --sample-mib 1
|
||||
bash proxydoctor.sh suite --lang en --bandwidth-source cloudflare
|
||||
"$PD" suite --lang en
|
||||
"$PD" quality --color always --lang en
|
||||
"$PD" ipquality --show-ip --lang en
|
||||
"$PD" media --lang en
|
||||
"$PD" cn-nodes --lang en
|
||||
"$PD" bandwidth --china --allow-bandwidth --rounds 3 --sample-mib 4 --lang en
|
||||
"$PD" bandwidth --source mobile-hk --allow-bandwidth --lang en
|
||||
"$PD" trace --target 1.1.1.1 --method icmp --geo --lang en
|
||||
"$PD" trace --target 1.1.1.1 --method tcp --tcp-port 443 --lang en
|
||||
"$PD" return-route --method udp --lang en
|
||||
"$PD" bgp --target 1.1.1.1 --lang en
|
||||
"$PD" suite --route-method tcp --include-return-routes --include-bgp --lang en
|
||||
```
|
||||
|
||||
| ID | Source/node label | Download | Upload | Default |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cloudflare | Cloudflare / Anycast | Yes | Yes | Yes |
|
||||
| ovh-rbx | OVHcloud / FR Roubaix | Yes | No | Yes |
|
||||
| hetzner-fsn | Hetzner / DE Falkenstein | Yes | No | No; slow on current test network |
|
||||
- check/suite support default, iprisk and framed themes, with live results. Explicit language choices override the Linux-console fallback.
|
||||
- System, visible resource quota, Swap, short steal/PSI, SSH/ports/firewall, file permissions and update-cache observations. No benchmarks or exclusive-resource certification.
|
||||
- IPv4/IPv6 DNS/TCP/certificate-verified TLS and native UDP trace. ICMP/TCP require existing system traceroute; no elevation or automatic installation.
|
||||
- IP provider evidence; failed providers remain visible. Optional PTR/outbound TCP25 sends no email.
|
||||
- Ten media services: public webpage reachability only. No unlock or playback verdict in the CLI.
|
||||
- Repeated bounded bandwidth requires --allow-bandwidth. Download means node to this host; upload means this host to node. China candidates currently provide download only; Cloudflare supports upload.
|
||||
- cn-nodes uses small plaintext protocol samples, without credentials or report uploads. Success is not a capacity measurement. Hong Kong Mobile never counts as mainland Mobile coverage.
|
||||
- Return routes: Beijing/Shanghai/Guangzhou × three carriers, this host to China targets. Not nationwide or reverse-direction coverage. Per-route soft probe budget: 30s; optional geo requests have separate limits.
|
||||
- BGP: RIPE RIS prefix, origin ASNs, sampled neighbours and AS paths. Collector observations do not prove local selected packet paths, commercial upstreams or premium tiers.
|
||||
|
||||
Excluded 128 KiB warmup per supported direction; 3–8 measured rounds of 1–20 MiB.
|
||||
Default requested benchmark payload is at most 36.375 MiB, excluding protocol
|
||||
and other module traffic. Medians/ranges/success counts stay separate per source.
|
||||
Short samples (<1s), missing rounds and high variation stay yellow. Wall-time
|
||||
HTTPS throughput includes connection setup and cannot verify advertised capacity.
|
||||
Static files require exact HTTP 206 ranges; ignored Range responses are closed
|
||||
without reading the full body, never falling back to a full-file download.
|
||||
Unsupported upload is skipped, not counted as failure. Bandwidth JSON schema=2.
|
||||
Development-host small JPEG samples succeeded for Shanghai Telecom and Hong Kong Mobile. Mainland Unicom/Mobile do not yet have successful evidence this round. HTTPS throughput never downgrades to HTTP or disables certificate verification. New Linux/overseas network capabilities remain pending real acceptance.
|
||||
|
||||
## IP sources and keys
|
||||
## Reports and safety
|
||||
|
||||
Reports/journals are local (reports/ by default), with no overwrites. --show-ip affects only terminal display. Review redacted exports before sharing.
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh ipquality --lang en --show-ip --json-output ip.json
|
||||
bash proxydoctor.sh ipquality --lang en --no-enrichment
|
||||
bash proxydoctor.sh suite --lang en --no-ip-enrichment
|
||||
"$PD" suite --offline --json-output report.json --lang en
|
||||
"$PD" report --input report.json --output report.md --lang en
|
||||
"$PD" recover --journal report.json.events.jsonl --output recovered.json --lang en
|
||||
```
|
||||
|
||||
Defaults: ipapi.is, proxycheck.io and IP2Location. On an ipapi.is transport failure,
|
||||
ipify can provide an egress address. Subsequent sources query that exact address;
|
||||
mismatched responses are rejected. These services see the source address and the
|
||||
additional sources receive the selected egress IP. Disable extra sources with
|
||||
`--no-ip-enrichment` or all suite networking with `--offline`.
|
||||
Ctrl+C/SIGTERM cancels workers and attempts to save observations. Operations have timeouts; journals have size limits. Power/storage failure and SIGKILL cannot guarantee unfinished data survives. JSON keeps legacy fields and uniform result items; Markdown uses collision-safe JSON fences.
|
||||
|
||||
Seventeen core fields cover ownership/ASN, location/timezone, type/network abuse
|
||||
share and datacenter/proxy/VPN/Tor/recent-abuse/mobile/satellite/anycast/bogon flags.
|
||||
Per-source observations and field provenance are retained. Conflicting booleans
|
||||
become unknown with disagreement evidence. IP2Location's limited free-plan proxy
|
||||
flag is kept separate, not converted into a broad VPN guarantee. proxycheck.io
|
||||
risk/confidence and AbuseIPDB scores are not averaged. Network abuse share is not
|
||||
a single-IP maliciousness probability.
|
||||
Exit codes: 0 completed, 1 findings/incomplete/storage failure, 2 usage/install error, 130 cancelled. Defaults do not modify SSH/firewall/routes/system DNS, benchmark, repair or upload complete reports. Online targets see the request source.
|
||||
|
||||
Keyless limits currently include 100 queries/day for proxycheck.io and 1000/day
|
||||
for IP2Location, subject to provider rules. No automatic quota retries. Anonymous
|
||||
ipapi.is has limited fields; optional keys provide extended fields/AbuseIPDB reports.
|
||||
Enter locally without echoing; never send keys to chat or commit them:
|
||||
P0 automation: 277 passed, one Windows symlink-permission skip. Current VM acceptance is recorded separately in BETA_ACCEPTANCE_REPORT.md and its JSON beside the candidate ZIP, identified by SHA256. Historical Alpha and earlier Beta evidence is not current-build acceptance. Full carrier throughput, ICMP/TCP permission variants and overseas paths require explicit coverage evidence.
|
||||
|
||||
```bash
|
||||
read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY
|
||||
export PROXYDOCTOR_IPAPI_KEY
|
||||
printf '\n'
|
||||
read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
export PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
printf '\n'
|
||||
bash proxydoctor.sh ipquality --lang en
|
||||
```
|
||||
No nationwide carrier coverage, long monitoring, DNSBL, Web UI, proxy deployment, Karing subscriptions or automatic updates/uninstall. Install updates into a new directory; back up reports before removing a confirmed old installation.
|
||||
|
||||
ipapi.is keys travel in HTTPS POST JSON; AbuseIPDB keys use headers, not URLs or
|
||||
reports. Missing fields remain unknown. Complete coverage means defined fields,
|
||||
not every database or a security certification. Zero reports do not guarantee
|
||||
safety. Observed API egress/family may differ from media connections.
|
||||
|
||||
## Route and media
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh suite --lang en --max-hops 64 --route-target 1.1.1.1
|
||||
bash proxydoctor.sh suite --lang en --route-target 2606:4700:4700::1111 --no-geo
|
||||
bash proxydoctor.sh media --lang en --json-output media.json
|
||||
```
|
||||
|
||||
ipwho.is geolocation is cached per run; private/reserved hops are never queried.
|
||||
Media covers Netflix, Disney+, YouTube Premium, Spotify, Prime Video, Max, Apple
|
||||
TV+, Hulu, BBC iPlayer and Paramount+. Clear regional messages and visible priced
|
||||
offers require repeat agreement. Netflix uses structured catalog metadata; BBC
|
||||
also checks public test-channel metadata. No media segments/paid content are
|
||||
fetched and signed media URLs are not retained. BBC's channel result is scoped
|
||||
to that channel. HTTP 200/403 or catalog/offer visibility never proves playback.
|
||||
|
||||
## Exit codes
|
||||
|
||||
`check`: 0=no known warning/failure (unknowns may remain), 1=findings, 2=usage/export
|
||||
error. Online suite/bandwidth/ipquality/media: 1 also covers incomplete/unknown/
|
||||
variable measurements or unverified playback. Offline 0 means local execution
|
||||
completed, not online success. Media intentionally leaves playback untested and
|
||||
returns 1. Ctrl+C returns 130 without changing system settings.
|
||||
|
||||
## Install and contribute
|
||||
|
||||
Source runs directly. Install the supplied wheel into a local virtual environment:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl
|
||||
.venv/bin/proxydoctor suite --offline --lang en
|
||||
```
|
||||
|
||||
If Ubuntu lacks venv support, install its matching OS component separately;
|
||||
ProxyDoctor never installs it automatically. Development: install pytest and run
|
||||
`python -m pytest -q`. An Actions workflow is supplied; remote CI is not claimed
|
||||
before repository publication. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Stage 1 and scope
|
||||
|
||||
This iteration targets code plus Ubuntu 22.04/24.04 VM acceptance as selected by
|
||||
the user; consult the delivered acceptance records for actual results. Overseas
|
||||
VPS tests, real-key extended data, paid-account playback and other Ubuntu releases
|
||||
remain separately unverified. Later stages cover sing-box installation, Karing
|
||||
protocol validation, repair previews/backups/rollback, Web UI and secure subscription
|
||||
transport. No public plaintext subscription server or automatic SSH/firewall changes.
|
||||
|
||||
## License and acknowledgements
|
||||
|
||||
Original code is MIT. Thanks to ECS, NodeQuality, NetQuality/IPQuality, NextTrace
|
||||
and RegionRestrictionCheck for feature/presentation references. No upstream script
|
||||
source/databases are bundled by the default implementation. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
Explicit local-script adapters are opt-in; external behavior/output may not be
|
||||
read-only or redacted.
|
||||
|
||||
Official data references: [Cloudflare](https://github.com/cloudflare/speedtest),
|
||||
[OVHcloud](https://proof.ovh.net/), [Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/),
|
||||
[ipapi.is](https://ipapi.is/developers.html), [proxycheck.io](https://proxycheck.io/api/),
|
||||
[IP2Location](https://www.ip2location.io/ip2location-documentation), [AbuseIPDB](https://docs.abuseipdb.com/),
|
||||
[ipwho.is](https://ipwhois.io/documentation), [ipify](https://www.ipify.org/).
|
||||
[Netflix VPN guidance](https://help.netflix.com/en/node/114701) explains catalog restrictions.
|
||||
See [current status](CURRENT_STATUS.md), [changes and pending acceptance](PUBLIC_BETA_CHANGE_REPORT.md), [security](SECURITY.md) and [third-party notices](THIRD_PARTY_NOTICES.md). MIT project, standard-library runtime, no bundled tracing/speed-test binaries.
|
||||
@@ -1,155 +1,108 @@
|
||||
# ProxyDoctor
|
||||
# VPS 检测工具 · 1.0 公测版
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
面向 Ubuntu VPS 新手的原生 Python 只读诊断工具。支持 Ubuntu 22.04 及更新版本;
|
||||
当前优先验收 22.04、24.04,其他版本逐步实测。Python 3.10+,没有第三方运行依赖。
|
||||
本阶段不安装 sing-box,不修改 SSH/防火墙,不自动提权,不下载执行远程脚本。
|
||||
面向 Ubuntu VPS 的 Python 命令行体检工具。内部项目名及命令仍为 ProxyDoctor / `proxydoctor`。
|
||||
**这是 1.0 公测版,非最终版本。** 包版本 `1.0.0b1`;当前是本地候选包,尚未公开发布或完成最终验收。
|
||||
|
||||
## 快速运行
|
||||
## 安装
|
||||
|
||||
下载源码包并解压,进入项目目录:
|
||||
需要 Linux、Python 3.10+ 和标准库 venv,支持目标为 Ubuntu 22.04 及更新版本。
|
||||
没有第三方 Python 运行依赖,不调用 sudo、pip 或系统包管理器。
|
||||
先从可信来源核对摘要,再解压并阅读 install.py。没有 unzip 也可用 Python 解压:
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh
|
||||
bash proxydoctor.sh suite --lang en
|
||||
bash proxydoctor.sh suite --offline
|
||||
sha256sum -c ProxyDoctor-v1.0.0-beta.1.zip.sha256
|
||||
python3 -m zipfile -e ProxyDoctor-v1.0.0-beta.1.zip .
|
||||
cd ProxyDoctor-v1.0.0-beta.1
|
||||
python3 install.py --run
|
||||
```
|
||||
|
||||
下载或克隆本身不执行程序。默认入口 `suite` 会联网、逐项回显;
|
||||
`check` 默认只读取本机信息。普通用户可运行,权限不足显示未知;程序不调用 sudo。
|
||||
安装到用户目录的隔离环境,首次仅运行离线体检。已有目录不会覆盖;失败只清理本次新建目录。
|
||||
缺少 Python/venv 时明确退出,不自动安装系统依赖。`--prefix` 可指定不存在的用户目录。
|
||||
安装后的启动器及各 Python worker 显式使用 `-B`,忽略用户包路径并禁止这些启动路径生成字节码缓存。此承诺覆盖随包安装器启动命令和源码脚本;自行使用 Python 导入或 `python -m` 时应加 `-B`,pip 的安装期预编译及外部调用方不在此范围。
|
||||
|
||||
```bash
|
||||
python3 -m proxydoctor check
|
||||
python3 -m proxydoctor check --port 8388 --json --output check.json
|
||||
python3 -m proxydoctor suite --json-output suite.json --color always
|
||||
PD="$HOME/.local/share/proxydoctor/1.0.0b1/bin/proxydoctor"
|
||||
"$PD" check --theme framed --lang zh
|
||||
"$PD" suite --offline --theme framed
|
||||
"$PD" suite --lang en
|
||||
"$PD" quality --color always
|
||||
"$PD" ipquality --show-ip
|
||||
"$PD" media
|
||||
"$PD" --help
|
||||
```
|
||||
|
||||
报告文件不会覆盖已有文件,也不会自动创建目录。原生报告仅保存在本地,IP 默认脱敏;
|
||||
脱敏不是匿名保证,分享前仍应检查。`--show-ip` 可在终端显示出口地址,文件仍脱敏。
|
||||
路由模块按用户要求在终端显示实际节点 IP,导出文件中隐藏。
|
||||
`check`、`suite` 支持 default/iprisk/framed 三种主题。结果直接逐项显示在命令行。
|
||||
中英文可选;纯 Linux 文本控制台默认英文,显式 `--lang zh` 或 `--lang=zh` 保留中文。
|
||||
|
||||
## 四模块
|
||||
## 能力与范围
|
||||
|
||||
| 模块 | 直接回显 | 边界 |
|
||||
| 模块 | 本版能力 | 结果范围 |
|
||||
| --- | --- | --- |
|
||||
| VPS 资源与体检 | 系统、架构、CPU、逻辑核心、内核、内存、磁盘、SHA-256 样本;DNS 配置、端口、SSH 保护、SSH 设置和防火墙提示 | 系统可见资源不保证等于商家配额;SHA-256 不是完整 CPU 基准;本机读取不能确认云防火墙放行 |
|
||||
| 节点网络/IP 质量 | 实际 DNS、TCP 请求,IP 归属/ASN/地区/类型、风险标记、来源状态、覆盖率和分歧 | DNS 返回 IPv6 不代表 IPv6 连通;第三方标记不是客观安全保证,不合并不同评分 |
|
||||
| 路由 | 原生 Linux IPv4/IPv6 UDP 追踪,默认 30 跳,可至 64;每行 IP、延迟、估计位置 | 中间节点可能不回包,位置是数据库估计,不保证真实物理位置;NAT/过滤可能导致未到达 |
|
||||
| 流媒体 | Netflix、Disney+、YouTube Premium、Spotify、Prime Video、Max、Apple TV+、Hulu、BBC iPlayer、Paramount+ | 明确地区提示/套餐证据重复确认;片库和元数据不等于账号/DRM 实际播放;规则失效和网络失败显示未知 |
|
||||
| 系统和资源 | 系统、CPU、内存、Swap、根分区、可见配额、短采样 steal/PSI | 无法证明资源独享;不执行 CPU/磁盘基准 |
|
||||
| 网络 | 分 IPv4/IPv6 的 DNS、TCP、证书验证 TLS | 本机到指定目标的当前观测 |
|
||||
| 路由 | 原生 UDP;可选系统 traceroute 的 ICMP/TCP | 默认 30 跳、最多 64 跳;响应缺失不等于转发丢包 |
|
||||
| IP 信息 | 归属、ASN、运营商及分来源风险;可选 PTR/TCP25 | 缺失保持未知;接口失败显示,未配置的可选密钥来源可省略 |
|
||||
| 流媒体 | Netflix、Disney+、YouTube Premium 等 10 项公开网页 | 仅可达性,不输出解锁或播放成功结论 |
|
||||
| 安全 | SSH、端口、防火墙、权限和更新缓存只读检查 | 不修改配置,不是完整漏洞扫描 |
|
||||
| 带宽 | 多来源多轮下载,Cloudflare 支持上传 | 必须明确允许;短样本不报线路容量 |
|
||||
| BGP | RIPE RIS 前缀、起源 ASN、邻居、AS_PATH 样本 | 采集器视角,不等于本机选路或商业上游 |
|
||||
|
||||
JSON 综合报告 schema_version=2,同时包含本机检查、DNS/TCP、每个测速来源、IP 来源观察、路由逐跳及媒体探测依据。
|
||||
绿色表示取得结果或局部通过,红色表示已知风险/限制/冲突,黄色表示未知、跳过或证据不足。
|
||||
支持 `--color auto|always|never` 和 `NO_COLOR`。复杂配置的完整依据可用 `check --json` 查看。
|
||||
`sshd -T` 读取默认配置,不保证与运行中实例或 Match 条件相同。安装与修复阶段仍须独立保护 SSH。
|
||||
## 三网、路由和 BGP
|
||||
|
||||
## 多来源测速
|
||||
以下是新增公测能力,尚待本轮 Linux 与海外 VPS 实测验收。
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh bandwidth
|
||||
bash proxydoctor.sh bandwidth --source ovh-rbx --rounds 3 --sample-mib 4
|
||||
bash proxydoctor.sh bandwidth --source hetzner-fsn --sample-mib 1
|
||||
bash proxydoctor.sh suite --bandwidth-source cloudflare
|
||||
# 固定候选的小流量探测,不运行带宽测试
|
||||
"$PD" cn-nodes
|
||||
# 大陆三运营商候选多轮下载;仍可能不可用,失败如实显示
|
||||
"$PD" bandwidth --china --allow-bandwidth --rounds 3 --sample-mib 4
|
||||
"$PD" bandwidth --source cn-telecom-sh --allow-bandwidth
|
||||
# 香港移动单独标注,不能替代大陆移动
|
||||
"$PD" bandwidth --source mobile-hk --allow-bandwidth
|
||||
# ICMP/TCP 需要系统已经安装 traceroute,缺少则明确退出,不提权安装
|
||||
"$PD" trace --target 1.1.1.1 --method tcp --tcp-port 443 --geo
|
||||
"$PD" trace --target 1.1.1.1 --method icmp --max-hops 64
|
||||
# 北京/上海/广州 × 三网,逐条执行,每条约 30 秒探测软预算
|
||||
"$PD" return-route --method udp
|
||||
"$PD" return-route --method tcp --family 6
|
||||
"$PD" bgp --target 1.1.1.1
|
||||
# 综合入口额外模块需明确开启
|
||||
"$PD" suite --route-method tcp --include-return-routes --include-bgp
|
||||
```
|
||||
|
||||
| 来源参数 | 节点标签 | 下载 | 上传 | 默认 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cloudflare | Cloudflare / Anycast | 是 | 是 | 是 |
|
||||
| ovh-rbx | OVHcloud / 法国 Roubaix | 是 | 不支持 | 是 |
|
||||
| hetzner-fsn | Hetzner / 德国 Falkenstein | 是 | 不支持 | 否,当前网络较慢 |
|
||||
三城探测方向为“本机 → 中国目标”,在海外 VPS 上通常称回程;不包含中国侧到 VPS 的去程或全国各省全部线路。
|
||||
ASN 标签不能证明 CN2 GIA 等套餐等级。测速下载是节点到本机,上传是本机到节点,和逐跳探测方向分别解释。
|
||||
|
||||
每方向 128 KiB 预热不计统计;正式 3–8 轮、每轮 1–20 MiB。默认请求测速载荷
|
||||
最多 36.375 MiB,不含协议开销及其他模块请求。各来源独立给出中位数、范围、成功数;
|
||||
不做跨来源平均。样本短于 1 秒、轮次不足或波动大显示黄色。吞吐包含建连,不能核验商家标称带宽。
|
||||
对大文件只接受精确 HTTP 206 分段响应;若服务器忽略 Range,不读取整文件正文,不回退完整下载。
|
||||
测速超过时限或未完成不记为成功;不支持上传不记为上传失败。带宽子报告 schema_version=2。
|
||||
节点标签来自公开目录,不等于实测身份认证。当前开发机已验证上海电信及香港移动的小图片可访问;大陆联通、移动尚无本轮成功证据。
|
||||
香港不计入大陆三网覆盖。HTTPS 测速不自动降级明文或关闭证书验证。三网候选本轮仅下载,未实现三网上传。
|
||||
`cn-nodes` 使用少量明文测试协议数据,不发送凭据或报告;小样本成功不等于带宽容量。
|
||||
|
||||
## IP 数据与密钥
|
||||
## 报告、安全和中断
|
||||
|
||||
报告默认保存在当前目录的 reports/。支持 `--json-output` 的命令可指定路径;已有文件不会覆盖。
|
||||
保存文件默认脱敏 IP;`--show-ip` 只影响终端。分享前仍须人工复核。
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh ipquality --show-ip --json-output ip.json
|
||||
bash proxydoctor.sh ipquality --no-enrichment
|
||||
bash proxydoctor.sh suite --no-ip-enrichment
|
||||
"$PD" suite --offline --json-output report.json
|
||||
"$PD" report --input report.json --output report.md
|
||||
"$PD" recover --journal report.json.events.jsonl --output recovered.json
|
||||
"$PD" recover --journal report.json.events.jsonl --output recovered.md --format markdown
|
||||
```
|
||||
|
||||
默认查询 ipapi.is、proxycheck.io 和 IP2Location。若 ipapi.is 连接失败,尝试 ipify 获取出口 IP。
|
||||
后续查询固定为该地址,拒绝把其他地址的响应混入结果。所有这些服务会看到请求来源,
|
||||
另外两家会收到查询的出口地址。可用 `--no-ip-enrichment` 关闭额外来源,`--offline` 关闭综合入口联网。
|
||||
Ctrl+C/SIGTERM 尝试取消活动子进程并保存已有记录。API、DNS、命令、测速有限时;恢复日志有大小上限。
|
||||
JSON 保留原模块和统一 result_items 的状态、方法、时间、来源及范围。Markdown 使用防提前闭合的 JSON 代码围栏。
|
||||
断电、SIGKILL、磁盘故障时以最后完整恢复事件为准。
|
||||
|
||||
17 项核心字段包括运营商/ASN、地区/时区、网络类型、网段滥用比例,以及数据中心、代理、VPN、
|
||||
Tor、近期滥用、移动、卫星、Anycast、保留地址标记。按来源记录观察和字段来源;布尔标记冲突显示
|
||||
“来源不一致”,不会自动选成安全。IP2Location 免费模式的有限代理标记单独保留,不替代通用 VPN 判断。
|
||||
proxycheck.io 风险/置信度与 AbuseIPDB 评分分别保存,不求平均,不把网段比例当作单 IP 恶意概率。
|
||||
退出码:0 正常完成,1 有提示/不完整/保存失败,2 参数或安装错误,130 中断。
|
||||
默认只读,不修改 SSH、防火墙、路由、系统 DNS,不自动上传完整报告或修复系统;联网目标会看到请求来源 IP。
|
||||
|
||||
免密钥 API 也有配额:proxycheck.io 当前限 100 次/天,IP2Location 当前限 1000 次/天;以服务官方规则为准。
|
||||
遇到限流不自动重试。ipapi.is 匿名响应只提供基础数据;扩展字段和 AbuseIPDB 举报数据可通过密钥补充。
|
||||
不要把密钥发到聊天、提交到仓库或放进命令参数;在 VPS 本地无回显输入:
|
||||
## 公测状态与开源
|
||||
|
||||
```bash
|
||||
read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY
|
||||
export PROXYDOCTOR_IPAPI_KEY
|
||||
printf '\n'
|
||||
read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
export PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
printf '\n'
|
||||
bash proxydoctor.sh ipquality
|
||||
```
|
||||
P0 修复自动化:277 通过、1 项 Windows 符号链接权限跳过。当前构建的实机验收结果以随候选 ZIP 单独交付的 BETA_ACCEPTANCE_REPORT.md 和结构化结果中的 SHA256 为准;不将旧候选报告当作本包已通过。ICMP/TCP 实际权限、完整大陆三网和海外回程需按该报告的覆盖范围判断。
|
||||
旧 Alpha 结果不是本包验收证据。见 [当前状态](CURRENT_STATUS.md)、[变更和待测项](PUBLIC_BETA_CHANGE_REPORT.md)。
|
||||
尚无全国完整三网、长期监测、CPU/磁盘基准、DNSBL、Web UI、代理部署、Karing 订阅或自动更新卸载。
|
||||
更新安装到新版本目录;卸载前退出进程、备份报告,仅移除自己确认过的安装目录。
|
||||
|
||||
ipapi.is 密钥放 HTTPS POST 正文,AbuseIPDB 放请求头,不写入 URL 或报告。
|
||||
缺少字段和密钥明确显示未知;完整覆盖率只针对定义的核心字段,不代表所有数据库或安全认证。
|
||||
零举报不代表干净 IP。只确认本次接口请求的出口和地址族,不保证每个服务连接都使用同一出口。
|
||||
|
||||
## 路由与媒体复测
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh suite --route-target 1.1.1.1 --max-hops 64
|
||||
bash proxydoctor.sh suite --route-target 2606:4700:4700::1111 --no-geo
|
||||
bash proxydoctor.sh media --color always --json-output media.json
|
||||
```
|
||||
|
||||
路由位置来自 ipwho.is,按运行缓存;私网/保留节点不查询。`--no-geo` 关闭位置查询。
|
||||
媒体重复确认地区提示/带价格的具体套餐;Netflix 检查结构化片库元数据,BBC 额外查询测试频道的公开媒体元数据。
|
||||
不获取付费节目、不下载流媒体、不绕过登录/DRM,不保留媒体签名地址。BBC 的测试频道结果不代表整个平台。
|
||||
HTTP 200、403、页面可达、套餐或片库可见均不作为“完全解锁”证据。
|
||||
|
||||
## 退出码
|
||||
|
||||
- `check`:0=无已知注意/失败项,仍可能含未知;1=有注意/失败项;2=参数/保存错误。
|
||||
- `suite/bandwidth/ipquality/media`:0=该命令定义的范围完整,离线 0 仅表示本地执行完成;1=有未完成/未知/波动或未验证播放;2=参数/保存错误。
|
||||
- 流媒体命令仍保留“账号播放未测”,因此返回 1;这不表示程序崩溃。
|
||||
- Ctrl+C 返回 130,不改系统设置。
|
||||
|
||||
## 安装与开发
|
||||
|
||||
源码可直接运行。也可把提供的 wheel 安装到自己的虚拟环境:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl
|
||||
.venv/bin/proxydoctor suite --offline
|
||||
```
|
||||
|
||||
Ubuntu 若未安装 venv 支持,需另行安装系统对应组件;程序不会自动安装。
|
||||
开发测试:`python -m pip install pytest` 后 `python -m pytest -q`。
|
||||
GitHub Actions 配置已提供,未发布仓库前不宣称远程 CI 已运行。
|
||||
贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),安全反馈见 [SECURITY.md](SECURITY.md)。
|
||||
|
||||
## 阶段一验收与后续
|
||||
|
||||
本轮按用户选择完成代码和 22.04/24.04 虚拟机验收;实际记录以交付验收报告为准。
|
||||
海外 VPS、真实付费账号播放、带真实密钥的扩展数据,以及 Ubuntu 其他版本仍待分别验收;不会把它们标为已通过。
|
||||
之后阶段才实现 sing-box 安装、Karing 协议连通、修复预览/备份/回滚、Web UI 和安全订阅传输。
|
||||
不对外开放明文 HTTP 订阅,不根据只读体检自动修改 SSH/防火墙。
|
||||
|
||||
## 开源与来源
|
||||
|
||||
原创代码使用 MIT 许可证。感谢 ECS、NodeQuality、NetQuality/IPQuality、NextTrace、RegionRestrictionCheck 的功能和展示设计参考;
|
||||
默认实现没有打包或复制上游脚本/数据库。来源和许可范围见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
||||
本地第三方适配器仅在显式指定时运行,第三方行为/输出不保证只读或脱敏。
|
||||
|
||||
数据接口:[Cloudflare](https://github.com/cloudflare/speedtest)、[OVHcloud](https://proof.ovh.net/)、
|
||||
[Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/)、
|
||||
[ipapi.is](https://ipapi.is/developers.html)、[proxycheck.io](https://proxycheck.io/api/)、
|
||||
[IP2Location](https://www.ip2location.io/ip2location-documentation)、[AbuseIPDB](https://docs.abuseipdb.com/)、
|
||||
[ipwho.is](https://ipwhois.io/documentation)、[ipify](https://www.ipify.org/)。
|
||||
[Netflix 官方 VPN 说明](https://help.netflix.com/en/node/114701) 也说明了目录限制,不保证页面可达就能播放。
|
||||
MIT 项目,标准库运行。感谢 [NextTrace](https://github.com/nxtrace/NTrace-core)、[CleanIP sh.cd](https://github.com/CleanIP/sh.cd)、[RIPEstat](https://stat.ripe.net/) 和既有参考项目;详见 [来源和许可](THIRD_PARTY_NOTICES.md)。不打包下载执行第三方检测脚本或测速客户端。
|
||||
+22
@@ -1,3 +1,16 @@
|
||||
|
||||
## v0.1.0-alpha 候选(2026-11-01)
|
||||
|
||||
隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。
|
||||
|
||||
# Current branch safety changes — 2026-11-01
|
||||
|
||||
Routine suites skip CPU and bandwidth benchmarks. Bandwidth requires explicit per-call consent via `--allow-bandwidth` / `allow_bandwidth=True`; payload limits are not a total network billing cap. External executable/script wrappers are disabled pending resource, license and upload review. Online native checks still disclose the request source to their target services; offline mode skips them.
|
||||
|
||||
The updated workflow is not yet tested. Fault isolation and incremental report saving are implemented but untested. Current scope excludes proxy deployment and Web UI. No full-report uploads, paid API use, system dependency installation, production VPS tests, main-branch merges or public releases are authorized by this development brief.
|
||||
|
||||
---
|
||||
|
||||
# Security / 安全反馈
|
||||
|
||||
Do not post vulnerabilities containing passwords, subscription URLs, API keys,
|
||||
@@ -18,3 +31,12 @@ separate review and SSH rollback safeguards.
|
||||
中文:不要公开提交密码、密钥、订阅地址或完整诊断报告。仓库发布并开启私密漏洞报告后
|
||||
优先使用该渠道;未配置前先询问私密联系方式,不公开敏感细节。报告脱敏不是匿名保证,
|
||||
分享前需检查。当前原生检测不修改 SSH/防火墙,不上传报告;联网模式的服务会看到请求来源。
|
||||
|
||||
|
||||
## 基础安全第五组(2026-11-01:实现未测试)
|
||||
|
||||
新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。
|
||||
|
||||
更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。
|
||||
|
||||
新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
## 1.0 公测待验收
|
||||
|
||||
当前包的 Ubuntu 安装、ICMP/TCP、三网有效下载、BGP 实网及海外三城回程均留待下一轮;本轮开发机 271 通过、1 跳过。旧 Alpha 验证仅为历史。具体步骤范围见 PUBLIC_BETA_CHANGE_REPORT.md。
|
||||
|
||||
|
||||
## v0.1.0-alpha 候选(2026-11-01)
|
||||
|
||||
隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。
|
||||
|
||||
|
||||
## 报告与IP质量更新(2026-11-01)
|
||||
|
||||
- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。
|
||||
- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。
|
||||
- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。
|
||||
- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。
|
||||
- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。
|
||||
- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。
|
||||
- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。
|
||||
- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。
|
||||
|
||||
证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。
|
||||
|
||||
|
||||
## 最新双机验收(2026-11-01)
|
||||
|
||||
同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。
|
||||
|
||||
|
||||
## Ubuntu 24.04 实测更新(2026-11-01)
|
||||
|
||||
当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。
|
||||
中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。
|
||||
DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。
|
||||
综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。
|
||||
完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。
|
||||
|
||||
# 测试与验收计划
|
||||
|
||||
本轮按用户指示暂不运行测试,所有新修改标记“未验证”。历史 0.5.0 记录位于工作区 outputs,不能直接用于本分支验收。
|
||||
|
||||
| 批次 | 必须验证的行为 | 方法和环境 | 当前证据 |
|
||||
|---|---|---|---|
|
||||
| P0 默认安全 | suite 默认不调用 hashlib 基准/measure_bandwidth;离线零外网;bandwidth 无许可拒绝;许可后预算显示;所有第三方包装入口拒绝 | Mock 调用计数、CLI 子进程、中文/英文;普通用户 | 新源码,未运行 |
|
||||
| P0 安全持久化 | 每模块异常继续下一模块;Ctrl+C 保留事件;权限不足/磁盘满/中断/符号链接/已有文件均不覆盖 | 临时目录与故障注入;已有 suite 回归适配 | 尚未实现 |
|
||||
| P1 资源 | cgroup v1/v2 嵌套配额/无限/缺文件;Swap/PSI/steal 采样;购买规格与实测分离 | 固定 fixture + Ubuntu 22.04 Server/24.04 Desktop | 待开发和验证 |
|
||||
| P1 网络 | 分 IPv4/IPv6,DNS 超时/拒绝/无记录,TCP/TLS/UDP 限时,NAT/多网卡,不将失败归因唯一 | Mock + 回环服务 + VM | 部分旧测试待复核 |
|
||||
| P2 路由 | 1..64 跳、v4/v6、目标不回应、中间超时不算端到端丢包、ASN 冲突、方向标记 | 固定报文 fixture + VM + 获授权公网节点 | UDP 旧用例存在,新验收待补 |
|
||||
| P2 IP/媒体 | 403/429/200登录/地区禁止/有效元数据/证据冲突;时间和来源;无密钥/错误密钥;不下载受保护内容 | 离线 fixture,必要时有限实网 | 旧用例存在;新规则不宣称验证 |
|
||||
| P3 基准监测 | 磁盘限额与清理、无裸盘写、进程超时、24/48h 中断恢复和时区、流量预算 | 先 Mock 后专用 VM,真实 VPS 须另授权 | 尚未实现 |
|
||||
| P3 发布集成 | 干净 venv 安装/更新/卸载/离线、Schema 兼容、Python API 不依赖 CLI 文本、许可清单 | Ubuntu 双版本和 CI;ARM/真实 VPS 独立记录 | 待验证 |
|
||||
|
||||
每次执行记录 commit、日期、Python/OS/架构、命令、退出码、通过/失败/跳过数、脱敏日志和限制。未知不能算通过;缺海外 VPS 或 API 条件记为待实机验证。无生产测试、无付费 API、无完整报告上传、无系统依赖安装。旧测试如果断言自动测速,应改为显式传 allow_bandwidth=True,再新增默认不测速的独立断言。
|
||||
|
||||
|
||||
## Reliability batch — 2026-11-01 (implemented, untested)
|
||||
|
||||
`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs.
|
||||
|
||||
Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot.
|
||||
|
||||
Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation.
|
||||
|
||||
Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet.
|
||||
|
||||
|
||||
## 资源检测第三组(2026-11-01:已编写,未测试)
|
||||
|
||||
- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。
|
||||
- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。
|
||||
- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。
|
||||
- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。
|
||||
- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。
|
||||
|
||||
限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。
|
||||
|
||||
尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。
|
||||
|
||||
实现语义参考 Linux 内核官方文档:
|
||||
|
||||
- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max)
|
||||
- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒)
|
||||
- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段)
|
||||
|
||||
下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。
|
||||
|
||||
|
||||
## 双栈网络第四组(2026-11-01,已编写但未测试)
|
||||
|
||||
新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。
|
||||
|
||||
分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。
|
||||
|
||||
增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。
|
||||
|
||||
|
||||
## 基础安全第五组(2026-11-01:实现未测试)
|
||||
|
||||
新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。
|
||||
|
||||
更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。
|
||||
|
||||
新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。
|
||||
@@ -37,3 +37,44 @@ API availability, accuracy and terms are controlled by each provider. IP locatio
|
||||
is an estimate, not verified physical hardware location.
|
||||
|
||||
Last reviewed / 最后核对:2026-10-31.
|
||||
# Additional design reference — 2026-11-01
|
||||
|
||||
xykt/IPQuality: https://github.com/xykt/IPQuality . Its root LICENSE contains
|
||||
GNU AGPL v3. We reviewed ip.sh and its mail/DNSBL entry points for functional
|
||||
coverage and report organization. No implementation, DNSBL list or scraped
|
||||
provider data from that project is bundled. Our PTR/SMTP probes and overview
|
||||
renderer are independently written. Provider APIs and any future datasets or
|
||||
maps require their own terms and licensing review.
|
||||
|
||||
## 1.0 公测新增参考与数据服务(2026-11-01)
|
||||
|
||||
- NextTrace / NTrace-core:https://github.com/nxtrace/NTrace-core ,GPL-3.0;仅参考公开三城运营商测试目标域名的事实和用途,没有复制其 Go 实现、打包二进制或调用其定位鉴权服务。目标参考:https://github.com/nxtrace/NTrace-core/blob/main/fast_trace/basic.go 。
|
||||
- CleanIP sh.cd:https://github.com/CleanIP/sh.cd ,MIT,Copyright (c) 2026 CleanIP;参考少量公开测速端点信息和节点失败需诚实展示的设计,没有复制其 shell 执行/上传流程。目录标签不能证明当前运营商、物理位置或服务授权;代码开源不等于测速服务永远开放。
|
||||
- VPSDance/vkit:https://github.com/VPSDance/vkit/blob/main/speedtest.sh ,仅作为寻找公开候选端点的研究线索;未复制脚本、打包或执行。未确认许可的代码不作为本项目实现依赖。
|
||||
- RIPEstat Data API:https://stat.ripe.net/docs/data-api/ripestat-data-api ,查询 network-info、asn-neighbours、bgp-state。使用 HTTPS,逐次查询、有限响应/超时,不下载整个 BGP 表。结果记录 RIPE RIS 采集器视角,按服务条款和限额使用。
|
||||
- 系统 traceroute 是用户系统已有的可选工具。本包不分发、不自动安装,不使用 shell、不自动提权。UDP 模式保持自主标准库实现。
|
||||
- 固定测速端点只发送测试载荷,没有订阅、账号或 API 密钥。HTTPS 下载不禁用证书校验、不自动降级 HTTP;小流量明文协议探测单独标明范围。
|
||||
|
||||
CleanIP 参考数据的许可证声明:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CleanIP
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,102 @@
|
||||
> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。
|
||||
|
||||
> 最新Alpha优先项状态见 [ALPHA_TASK_STATUS.md](ALPHA_TASK_STATUS.md)。以下包含本轮前历史统计,不能作为最新发布验收结论。
|
||||
|
||||
# 用户96项清单逐项状态统计
|
||||
|
||||
更新:2026-11-01。对应本次用户重新贴出的96项清单,编号为“类别-该类条目序号”;不同于原开发任务书101项登记。
|
||||
已完成表示当前明确支持范围内已有实现及对应验证,不表示生产环境或所有场景保证。复合需求仍缺关键部分时列部分完成。
|
||||
证据:两版Ubuntu最终各239项自动回归;前版各17项实际CLI验收;新报告导出/恢复实际执行,以及IP/PTR/TCP25实网记录。具体限制见CURRENT_STATUS.md和IPQUALITY_COVERAGE.md。
|
||||
|
||||
| 类别 | 总数 | 已完成 | 部分完成 | 未实现 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1. VPS本机与硬件检测 | 7 | 1 | 3 | 3 |
|
||||
| 2. 网络基础与DNS | 7 | 1 | 5 | 1 |
|
||||
| 3. Traceroute与线路分析 | 9 | 1 | 6 | 2 |
|
||||
| 4. IP地图、纯净度与信誉 | 9 | 5 | 3 | 1 |
|
||||
| 5. 流媒体与在线服务 | 8 | 3 | 4 | 1 |
|
||||
| 6. 多地区测速与长期稳定性 | 9 | 0 | 5 | 4 |
|
||||
| 7. 基础安全体检 | 8 | 4 | 4 | 0 |
|
||||
| 8. 检测结果、诊断与报告 | 10 | 3 | 5 | 2 |
|
||||
| 9. 安装、兼容性与用户体验 | 10 | 3 | 6 | 1 |
|
||||
| 10. 可靠性、隐私与测试 | 12 | 4 | 8 | 0 |
|
||||
| 11. 开源项目与未来复用 | 7 | 2 | 4 | 1 |
|
||||
| 合计 | 96 | 27 | 53 | 16 |
|
||||
|
||||
当前完成27/96(约28%),部分完成53项,未实现16项。该比例只按条目计数,不按工作量;不等于项目剩余时间或生产就绪度。
|
||||
|
||||
## 对应条目编号
|
||||
|
||||
### 1. VPS本机与硬件检测
|
||||
|
||||
- 已完成:01-03
|
||||
- 部分完成:01-01、01-02、01-04
|
||||
- 未实现:01-05、01-06、01-07
|
||||
|
||||
### 2. 网络基础与DNS
|
||||
|
||||
- 已完成:02-02
|
||||
- 部分完成:02-01、02-03、02-04、02-05、02-07
|
||||
- 未实现:02-06
|
||||
|
||||
### 3. Traceroute与线路分析
|
||||
|
||||
- 已完成:03-09
|
||||
- 部分完成:03-01、03-02、03-03、03-04、03-05、03-08
|
||||
- 未实现:03-06、03-07
|
||||
|
||||
### 4. IP地图、纯净度与信誉
|
||||
|
||||
- 已完成:04-01、04-02、04-03、04-04、04-07
|
||||
- 部分完成:04-06、04-08、04-09
|
||||
- 未实现:04-05
|
||||
|
||||
### 5. 流媒体与在线服务
|
||||
|
||||
- 已完成:05-01、05-05、05-07
|
||||
- 部分完成:05-02、05-03、05-04、05-06
|
||||
- 未实现:05-08
|
||||
|
||||
### 6. 多地区测速与长期稳定性
|
||||
|
||||
- 已完成:无
|
||||
- 部分完成:06-01、06-02、06-03、06-04、06-05
|
||||
- 未实现:06-06、06-07、06-08、06-09
|
||||
|
||||
### 7. 基础安全体检
|
||||
|
||||
- 已完成:07-01、07-03、07-05、07-06
|
||||
- 部分完成:07-02、07-04、07-07、07-08
|
||||
- 未实现:无
|
||||
|
||||
### 8. 检测结果、诊断与报告
|
||||
|
||||
- 已完成:08-04、08-06、08-07
|
||||
- 部分完成:08-01、08-02、08-03、08-05、08-08
|
||||
- 未实现:08-09、08-10
|
||||
|
||||
### 9. 安装、兼容性与用户体验
|
||||
|
||||
- 已完成:09-06、09-07、09-08
|
||||
- 部分完成:09-01、09-02、09-04、09-05、09-09、09-10
|
||||
- 未实现:09-03
|
||||
|
||||
### 10. 可靠性、隐私与测试
|
||||
|
||||
- 已完成:10-01、10-02、10-05、10-09
|
||||
- 部分完成:10-03、10-04、10-06、10-07、10-08、10-10、10-11、10-12
|
||||
- 未实现:无
|
||||
|
||||
### 11. 开源项目与未来复用
|
||||
|
||||
- 已完成:11-04、11-06
|
||||
- 部分完成:11-01、11-02、11-05、11-07
|
||||
- 未实现:11-03
|
||||
|
||||
## 勾选范围说明
|
||||
|
||||
01-03:系统可见内存/Swap/根文件系统和可见配额;不是独享资源证明。02-02:到固定目标的IPv4/IPv6连接测试已具备,测试返回失败/未知也是有效检测结果,不表示网络可达验收通过。
|
||||
04-01~04:查询并保留各来源提供的字段;字段缺失/冲突不伪装成确定结果。05-01:基础请求/可达性检测,不含账号播放;05-07为明确处理不可自动验证的边界。
|
||||
07-01:SSH默认配置中的选定设置;07-05:固定重要路径模式/所有者,不包含ACL/完整目录递归。08-06/07:本地JSON/Markdown与分享导出脱敏功能,仍须人工复核,不保证任意文本匿名。
|
||||
09-04仍为部分完成:两台现有Ubuntu虚拟机已验证,但未分别证明每种安装形态和运行环境。10-01仅在当前只读流程和已测可读配置哈希范围内;不是整个系统写入审计。10-09是已有Mock/单元/实际CLI测试机制,不表示所有异常路径已覆盖。
|
||||
11-04为现有使用/测试/贡献文档能力;一键安装、更新和卸载仍单独未完成。
|
||||
@@ -0,0 +1,14 @@
|
||||
# 当前状态:1.0 公测候选,非最终版本
|
||||
|
||||
2026-11-01;版本 1.0.0b1。未公开发布、推送或合并主分支。
|
||||
|
||||
- 基于用户 iprisk-edition 完善界面、语言、颜色、框线和错误来源展示,保留只读、安全安装和恢复机制。
|
||||
- CLI 流媒体改为仅网页可达性;内部旧分类函数保留兼容,默认不调用。
|
||||
- ICMP/TCP 系统 traceroute 适配、RIPE RIS BGP、三城九方向追踪和三网候选目录已接入代码,Linux/实网验收待执行。
|
||||
- 中国测速候选已接入 HTTPS 下载;上海电信、香港移动小图片可访问。大陆联通/移动未测通;香港不计大陆覆盖,三网候选未提供上传。
|
||||
- 自动化:Windows/Python 3.12.14,271 通过、1 项符号链接权限跳过。证据:工作区 outputs/public-beta-tests.xml。
|
||||
- 用户要求代码完成后停下:本轮未开始 VM 安装、全量联网或大流量测速。旧 Alpha、其他模型测试不能作为本包的新验收证据。
|
||||
|
||||
96 项表和 Alpha 文档保留历史;本轮没有根据代码接入或测试数量批量打勾。
|
||||
03-01/03-06/03-07/06-01 等仍不能标为全部完成。没有全国完整三网、双向探针、长期监测或 Web UI。
|
||||
详见 [README](README.md) 和 [本轮变更与待测项](PUBLIC_BETA_CHANGE_REPORT.md)。
|
||||
@@ -0,0 +1,207 @@
|
||||
See [current status](CURRENT_STATUS.md) for implemented and outstanding work. Family-specific DNS/TCP/TLS checks now back suite/quality; untested.
|
||||
|
||||
# Current development scope (2026-11-01)
|
||||
|
||||
This branch develops an independent Python VPS diagnostics CLI. See [backlog](PROBLEM_BACKLOG.md), [architecture](ARCHITECTURE.md), [source research](OPEN_SOURCE_RESEARCH.md), [test plan](TEST_PLAN.md), and [changelog](CHANGELOG.md). The previous 0.5.0 documentation below is historical context.
|
||||
|
||||
Routine `proxydoctor suite` skips CPU and bandwidth benchmarks. Consent to traffic tests with `proxydoctor bandwidth --allow-bandwidth` or `proxydoctor suite --allow-bandwidth`. The displayed payload budget excludes protocol overhead and other checks. Use `suite --offline` to skip network checks. Third-party executable/script wrappers are temporarily disabled.
|
||||
|
||||
These changes have not been tested or released. Earlier test results do not validate this branch.
|
||||
|
||||
---
|
||||
|
||||
# ProxyDoctor
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
Native Python read-only VPS diagnostics for Ubuntu 22.04 and newer. Python 3.10+;
|
||||
no third-party runtime dependencies. Ubuntu 22.04/24.04 are the current acceptance
|
||||
targets; other versions need separate testing. No automatic sudo, installation,
|
||||
SSH/firewall changes or fetching/executing remote scripts.
|
||||
|
||||
## Run
|
||||
|
||||
Extract the source package and run from its directory:
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh
|
||||
bash proxydoctor.sh suite --lang en
|
||||
bash proxydoctor.sh suite --offline --lang en
|
||||
python3 -m proxydoctor check --lang en --port 8388 --json --output check.json
|
||||
```
|
||||
|
||||
Downloading/cloning does not execute code. The default `suite` contacts external
|
||||
services and streams compact colored rows. `check` defaults to local reads.
|
||||
Ordinary users can run it; missing privileges remain unknown. Report files stay
|
||||
local, do not overwrite existing files and redact IPs. Review before sharing;
|
||||
redaction is not an anonymity guarantee. `--show-ip` displays egress IP in the
|
||||
terminal only. Routes display actual hop IPs in the terminal; exports mask them.
|
||||
|
||||
## Four modules
|
||||
|
||||
| Module | Terminal output | Limits |
|
||||
| --- | --- | --- |
|
||||
| Resources/preflight | OS, architecture, CPU/model/count, kernel, memory, disk, SHA-256 sample; route/DNS config, target port, SSH guard/config, local/cloud firewall status | Visible resources may differ from quotas; SHA-256 is not a general CPU benchmark; local reads cannot establish cloud ingress |
|
||||
| Network/IP | Real DNS/TCP probes; ownership/ASN/location/type, provider flags/scores, coverage and disagreements | AAAA answers do not prove IPv6 connectivity; provider scores are separate observations, not safety certification |
|
||||
| Route | Linux IPv4/IPv6 UDP error queue, 30 hops by default, up to 64; IP, latency, estimated location | NAT/filtering or silent routers can prevent completion; geolocation is approximate |
|
||||
| Media | Ten independent service adapters, repeat confirmation, catalog/metadata evidence and explicit unknown reasons | No paid account/DRM playback verification; public offers/catalogs do not prove full unlock |
|
||||
|
||||
Suite JSON schema_version=2 includes local checks, DNS/TCP, per-source bandwidth,
|
||||
IP observations/provenance/conflicts, route hops and media probe evidence.
|
||||
Green indicates acquired data/local success, red known warnings/restrictions,
|
||||
yellow unknown/skipped/insufficient evidence. `--color auto|always|never` and
|
||||
`NO_COLOR` are supported. Use `check --json` for detailed local evidence.
|
||||
`sshd -T` defaults may differ from running instances/Match rules. Future repair
|
||||
features still require separate SSH safeguards.
|
||||
|
||||
## Bandwidth
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh bandwidth --lang en
|
||||
bash proxydoctor.sh bandwidth --lang en --source ovh-rbx --rounds 3 --sample-mib 4
|
||||
bash proxydoctor.sh bandwidth --lang en --source hetzner-fsn --sample-mib 1
|
||||
bash proxydoctor.sh suite --lang en --bandwidth-source cloudflare
|
||||
```
|
||||
|
||||
| ID | Source/node label | Download | Upload | Default |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cloudflare | Cloudflare / Anycast | Yes | Yes | Yes |
|
||||
| ovh-rbx | OVHcloud / FR Roubaix | Yes | No | Yes |
|
||||
| hetzner-fsn | Hetzner / DE Falkenstein | Yes | No | No; slow on current test network |
|
||||
|
||||
Excluded 128 KiB warmup per supported direction; 3–8 measured rounds of 1–20 MiB.
|
||||
Default requested benchmark payload is at most 36.375 MiB, excluding protocol
|
||||
and other module traffic. Medians/ranges/success counts stay separate per source.
|
||||
Short samples (<1s), missing rounds and high variation stay yellow. Wall-time
|
||||
HTTPS throughput includes connection setup and cannot verify advertised capacity.
|
||||
Static files require exact HTTP 206 ranges; ignored Range responses are closed
|
||||
without reading the full body, never falling back to a full-file download.
|
||||
Unsupported upload is skipped, not counted as failure. Bandwidth JSON schema=2.
|
||||
|
||||
## IP sources and keys
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh ipquality --lang en --show-ip --json-output ip.json
|
||||
bash proxydoctor.sh ipquality --lang en --no-enrichment
|
||||
bash proxydoctor.sh suite --lang en --no-ip-enrichment
|
||||
```
|
||||
|
||||
Defaults: ipapi.is, proxycheck.io and IP2Location. On an ipapi.is transport failure,
|
||||
ipify can provide an egress address. Subsequent sources query that exact address;
|
||||
mismatched responses are rejected. These services see the source address and the
|
||||
additional sources receive the selected egress IP. Disable extra sources with
|
||||
`--no-ip-enrichment` or all suite networking with `--offline`.
|
||||
|
||||
Seventeen core fields cover ownership/ASN, location/timezone, type/network abuse
|
||||
share and datacenter/proxy/VPN/Tor/recent-abuse/mobile/satellite/anycast/bogon flags.
|
||||
Per-source observations and field provenance are retained. Conflicting booleans
|
||||
become unknown with disagreement evidence. IP2Location's limited free-plan proxy
|
||||
flag is kept separate, not converted into a broad VPN guarantee. proxycheck.io
|
||||
risk/confidence and AbuseIPDB scores are not averaged. Network abuse share is not
|
||||
a single-IP maliciousness probability.
|
||||
|
||||
Keyless limits currently include 100 queries/day for proxycheck.io and 1000/day
|
||||
for IP2Location, subject to provider rules. No automatic quota retries. Anonymous
|
||||
ipapi.is has limited fields; optional keys provide extended fields/AbuseIPDB reports.
|
||||
Enter locally without echoing; never send keys to chat or commit them:
|
||||
|
||||
```bash
|
||||
read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY
|
||||
export PROXYDOCTOR_IPAPI_KEY
|
||||
printf '\n'
|
||||
read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
export PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
printf '\n'
|
||||
bash proxydoctor.sh ipquality --lang en
|
||||
```
|
||||
|
||||
ipapi.is keys travel in HTTPS POST JSON; AbuseIPDB keys use headers, not URLs or
|
||||
reports. Missing fields remain unknown. Complete coverage means defined fields,
|
||||
not every database or a security certification. Zero reports do not guarantee
|
||||
safety. Observed API egress/family may differ from media connections.
|
||||
|
||||
## Route and media
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh suite --lang en --max-hops 64 --route-target 1.1.1.1
|
||||
bash proxydoctor.sh suite --lang en --route-target 2606:4700:4700::1111 --no-geo
|
||||
bash proxydoctor.sh media --lang en --json-output media.json
|
||||
```
|
||||
|
||||
ipwho.is geolocation is cached per run; private/reserved hops are never queried.
|
||||
Media covers Netflix, Disney+, YouTube Premium, Spotify, Prime Video, Max, Apple
|
||||
TV+, Hulu, BBC iPlayer and Paramount+. Clear regional messages and visible priced
|
||||
offers require repeat agreement. Netflix uses structured catalog metadata; BBC
|
||||
also checks public test-channel metadata. No media segments/paid content are
|
||||
fetched and signed media URLs are not retained. BBC's channel result is scoped
|
||||
to that channel. HTTP 200/403 or catalog/offer visibility never proves playback.
|
||||
|
||||
## Exit codes
|
||||
|
||||
`check`: 0=no known warning/failure (unknowns may remain), 1=findings, 2=usage/export
|
||||
error. Online suite/bandwidth/ipquality/media: 1 also covers incomplete/unknown/
|
||||
variable measurements or unverified playback. Offline 0 means local execution
|
||||
completed, not online success. Media intentionally leaves playback untested and
|
||||
returns 1. Ctrl+C returns 130 without changing system settings.
|
||||
|
||||
## Install and contribute
|
||||
|
||||
Source runs directly. Install the supplied wheel into a local virtual environment:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl
|
||||
.venv/bin/proxydoctor suite --offline --lang en
|
||||
```
|
||||
|
||||
If Ubuntu lacks venv support, install its matching OS component separately;
|
||||
ProxyDoctor never installs it automatically. Development: install pytest and run
|
||||
`python -m pytest -q`. An Actions workflow is supplied; remote CI is not claimed
|
||||
before repository publication. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Stage 1 and scope
|
||||
|
||||
This iteration targets code plus Ubuntu 22.04/24.04 VM acceptance as selected by
|
||||
the user; consult the delivered acceptance records for actual results. Overseas
|
||||
VPS tests, real-key extended data, paid-account playback and other Ubuntu releases
|
||||
remain separately unverified. Later stages cover sing-box installation, Karing
|
||||
protocol validation, repair previews/backups/rollback, Web UI and secure subscription
|
||||
transport. No public plaintext subscription server or automatic SSH/firewall changes.
|
||||
|
||||
## License and acknowledgements
|
||||
|
||||
Original code is MIT. Thanks to ECS, NodeQuality, NetQuality/IPQuality, NextTrace
|
||||
and RegionRestrictionCheck for feature/presentation references. No upstream script
|
||||
source/databases are bundled by the default implementation. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
Explicit local-script adapters are opt-in; external behavior/output may not be
|
||||
read-only or redacted.
|
||||
|
||||
Official data references: [Cloudflare](https://github.com/cloudflare/speedtest),
|
||||
[OVHcloud](https://proof.ovh.net/), [Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/),
|
||||
[ipapi.is](https://ipapi.is/developers.html), [proxycheck.io](https://proxycheck.io/api/),
|
||||
[IP2Location](https://www.ip2location.io/ip2location-documentation), [AbuseIPDB](https://docs.abuseipdb.com/),
|
||||
[ipwho.is](https://ipwhois.io/documentation), [ipify](https://www.ipify.org/).
|
||||
[Netflix VPN guidance](https://help.netflix.com/en/node/114701) explains catalog restrictions.
|
||||
|
||||
|
||||
## Reliability batch — 2026-11-01 (implemented, untested)
|
||||
|
||||
`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs.
|
||||
|
||||
Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot.
|
||||
|
||||
Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation.
|
||||
|
||||
Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet.
|
||||
|
||||
## Resource observations (unverified)
|
||||
|
||||
`suite` and `check` now include visible CPU quotas, process affinity, visible memory bounds, swap, PSI and short-sample CPU steal. Numeric results are preserved separately from terminal language. The default 0.2-second passive observation does not benchmark the CPU or change cgroup settings. Missing data remains unknown. Visible limits do not establish dedicated capacity; namespace/host scope and long-term contention require separate verification. New tests have not been run.
|
||||
|
||||
Basic security now inspects fixed-path ownership/mode metadata and optional local update-cache age/counts. Password-file contents are not read. Review findings manually; cached counts do not establish patch status. Untested.
|
||||
|
||||
## Local report export and IP overview
|
||||
|
||||
Use `proxydoctor report --input report.json --output report.md` to export a local snapshot; `proxydoctor recover --journal report.json.events.jsonl --output recovered.json` recovers the last complete record. Add `--format markdown --lang en` for English Markdown recovery. Files are never overwritten. Inspect exports before sharing.
|
||||
|
||||
`proxydoctor ipquality --mail-probes` opts into bounded PTR/forward consistency and one outbound TCP/25 target without sending mail. The overview panel precedes provider evidence. DNSBL and IP maps are not yet integrated; see IPQUALITY_COVERAGE.md. Final code passed 239 tests on each Ubuntu VM.
|
||||
@@ -0,0 +1,200 @@
|
||||
最新逐类完成情况和未完成重点见 [当前进度](CURRENT_STATUS.md)。新增双栈 DNS/TCP/TLS 已接入 suite/quality,尚未验证。
|
||||
|
||||
# 当前开发范围(2026-11-01)
|
||||
|
||||
资源首页优先显示本进程 CPU 上限、可见内存限额及其依据。已识别的容器如果没有可读限额,不以宿主机总内存充当自己的额度。系统原始值保留在 JSON 详细依据中;CPU/内存是否独享统一标注“无法从本机确认”。容器识别仍有遗漏可能,KVM 中的系统可见内存也不等于已验证商家独享保障。此轮显示调整未测试。
|
||||
|
||||
本分支按新的 [开发任务书](DEVELOPMENT_BRIEF.zh.md) 推进独立 Python VPS 综合诊断 CLI。代理部署、Web UI 不在当前范围。下面原 0.5.0 说明保留作背景;最新实际状态以 [问题追踪](PROBLEM_BACKLOG.md)、[架构](ARCHITECTURE.md)、[源码研究](OPEN_SOURCE_RESEARCH.md)、[测试计划](TEST_PLAN.md) 和 [更新记录](CHANGELOG.md) 为准。
|
||||
|
||||
常规 `bash proxydoctor.sh` / `proxydoctor suite` 默认跳过 CPU 和带宽基准。需要测速时,使用 `proxydoctor bandwidth --allow-bandwidth` 或 `proxydoctor suite --allow-bandwidth`;执行前显示请求载荷预算,协议开销与其他模块流量另计。`proxydoctor suite --offline` 跳过联网。第三方脚本/可执行文件包装入口暂时禁用。
|
||||
|
||||
本分支修改尚未运行测试,不继承旧版测试通过结论,也尚未发布。
|
||||
|
||||
---
|
||||
|
||||
# ProxyDoctor
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
面向 Ubuntu VPS 新手的原生 Python 只读诊断工具。支持 Ubuntu 22.04 及更新版本;
|
||||
当前优先验收 22.04、24.04,其他版本逐步实测。Python 3.10+,没有第三方运行依赖。
|
||||
本阶段不安装 sing-box,不修改 SSH/防火墙,不自动提权,不下载执行远程脚本。
|
||||
|
||||
## 快速运行
|
||||
|
||||
下载源码包并解压,进入项目目录:
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh
|
||||
bash proxydoctor.sh suite --lang en
|
||||
bash proxydoctor.sh suite --offline
|
||||
```
|
||||
|
||||
下载或克隆本身不执行程序。默认入口 `suite` 会联网、逐项回显;
|
||||
`check` 默认只读取本机信息。普通用户可运行,权限不足显示未知;程序不调用 sudo。
|
||||
|
||||
```bash
|
||||
python3 -m proxydoctor check
|
||||
python3 -m proxydoctor check --port 8388 --json --output check.json
|
||||
python3 -m proxydoctor suite --json-output suite.json --color always
|
||||
```
|
||||
|
||||
报告文件不会覆盖已有文件,也不会自动创建目录。原生报告仅保存在本地,IP 默认脱敏;
|
||||
脱敏不是匿名保证,分享前仍应检查。`--show-ip` 可在终端显示出口地址,文件仍脱敏。
|
||||
路由模块按用户要求在终端显示实际节点 IP,导出文件中隐藏。
|
||||
|
||||
## 四模块
|
||||
|
||||
| 模块 | 直接回显 | 边界 |
|
||||
| --- | --- | --- |
|
||||
| VPS 资源与体检 | 系统、架构、CPU、逻辑核心、内核、内存、磁盘、SHA-256 样本;DNS 配置、端口、SSH 保护、SSH 设置和防火墙提示 | 系统可见资源不保证等于商家配额;SHA-256 不是完整 CPU 基准;本机读取不能确认云防火墙放行 |
|
||||
| 节点网络/IP 质量 | 实际 DNS、TCP 请求,IP 归属/ASN/地区/类型、风险标记、来源状态、覆盖率和分歧 | DNS 返回 IPv6 不代表 IPv6 连通;第三方标记不是客观安全保证,不合并不同评分 |
|
||||
| 路由 | 原生 Linux IPv4/IPv6 UDP 追踪,默认 30 跳,可至 64;每行 IP、延迟、估计位置 | 中间节点可能不回包,位置是数据库估计,不保证真实物理位置;NAT/过滤可能导致未到达 |
|
||||
| 流媒体 | Netflix、Disney+、YouTube Premium、Spotify、Prime Video、Max、Apple TV+、Hulu、BBC iPlayer、Paramount+ | 明确地区提示/套餐证据重复确认;片库和元数据不等于账号/DRM 实际播放;规则失效和网络失败显示未知 |
|
||||
|
||||
JSON 综合报告 schema_version=2,同时包含本机检查、DNS/TCP、每个测速来源、IP 来源观察、路由逐跳及媒体探测依据。
|
||||
绿色表示取得结果或局部通过,红色表示已知风险/限制/冲突,黄色表示未知、跳过或证据不足。
|
||||
支持 `--color auto|always|never` 和 `NO_COLOR`。复杂配置的完整依据可用 `check --json` 查看。
|
||||
`sshd -T` 读取默认配置,不保证与运行中实例或 Match 条件相同。安装与修复阶段仍须独立保护 SSH。
|
||||
|
||||
## 多来源测速
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh bandwidth
|
||||
bash proxydoctor.sh bandwidth --source ovh-rbx --rounds 3 --sample-mib 4
|
||||
bash proxydoctor.sh bandwidth --source hetzner-fsn --sample-mib 1
|
||||
bash proxydoctor.sh suite --bandwidth-source cloudflare
|
||||
```
|
||||
|
||||
| 来源参数 | 节点标签 | 下载 | 上传 | 默认 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cloudflare | Cloudflare / Anycast | 是 | 是 | 是 |
|
||||
| ovh-rbx | OVHcloud / 法国 Roubaix | 是 | 不支持 | 是 |
|
||||
| hetzner-fsn | Hetzner / 德国 Falkenstein | 是 | 不支持 | 否,当前网络较慢 |
|
||||
|
||||
每方向 128 KiB 预热不计统计;正式 3–8 轮、每轮 1–20 MiB。默认请求测速载荷
|
||||
最多 36.375 MiB,不含协议开销及其他模块请求。各来源独立给出中位数、范围、成功数;
|
||||
不做跨来源平均。样本短于 1 秒、轮次不足或波动大显示黄色。吞吐包含建连,不能核验商家标称带宽。
|
||||
对大文件只接受精确 HTTP 206 分段响应;若服务器忽略 Range,不读取整文件正文,不回退完整下载。
|
||||
测速超过时限或未完成不记为成功;不支持上传不记为上传失败。带宽子报告 schema_version=2。
|
||||
|
||||
## IP 数据与密钥
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh ipquality --show-ip --json-output ip.json
|
||||
bash proxydoctor.sh ipquality --no-enrichment
|
||||
bash proxydoctor.sh suite --no-ip-enrichment
|
||||
```
|
||||
|
||||
默认查询 ipapi.is、proxycheck.io 和 IP2Location。若 ipapi.is 连接失败,尝试 ipify 获取出口 IP。
|
||||
后续查询固定为该地址,拒绝把其他地址的响应混入结果。所有这些服务会看到请求来源,
|
||||
另外两家会收到查询的出口地址。可用 `--no-ip-enrichment` 关闭额外来源,`--offline` 关闭综合入口联网。
|
||||
|
||||
17 项核心字段包括运营商/ASN、地区/时区、网络类型、网段滥用比例,以及数据中心、代理、VPN、
|
||||
Tor、近期滥用、移动、卫星、Anycast、保留地址标记。按来源记录观察和字段来源;布尔标记冲突显示
|
||||
“来源不一致”,不会自动选成安全。IP2Location 免费模式的有限代理标记单独保留,不替代通用 VPN 判断。
|
||||
proxycheck.io 风险/置信度与 AbuseIPDB 评分分别保存,不求平均,不把网段比例当作单 IP 恶意概率。
|
||||
|
||||
免密钥 API 也有配额:proxycheck.io 当前限 100 次/天,IP2Location 当前限 1000 次/天;以服务官方规则为准。
|
||||
遇到限流不自动重试。ipapi.is 匿名响应只提供基础数据;扩展字段和 AbuseIPDB 举报数据可通过密钥补充。
|
||||
不要把密钥发到聊天、提交到仓库或放进命令参数;在 VPS 本地无回显输入:
|
||||
|
||||
```bash
|
||||
read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY
|
||||
export PROXYDOCTOR_IPAPI_KEY
|
||||
printf '\n'
|
||||
read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
export PROXYDOCTOR_ABUSEIPDB_KEY
|
||||
printf '\n'
|
||||
bash proxydoctor.sh ipquality
|
||||
```
|
||||
|
||||
ipapi.is 密钥放 HTTPS POST 正文,AbuseIPDB 放请求头,不写入 URL 或报告。
|
||||
缺少字段和密钥明确显示未知;完整覆盖率只针对定义的核心字段,不代表所有数据库或安全认证。
|
||||
零举报不代表干净 IP。只确认本次接口请求的出口和地址族,不保证每个服务连接都使用同一出口。
|
||||
|
||||
## 路由与媒体复测
|
||||
|
||||
```bash
|
||||
bash proxydoctor.sh suite --route-target 1.1.1.1 --max-hops 64
|
||||
bash proxydoctor.sh suite --route-target 2606:4700:4700::1111 --no-geo
|
||||
bash proxydoctor.sh media --color always --json-output media.json
|
||||
```
|
||||
|
||||
路由位置来自 ipwho.is,按运行缓存;私网/保留节点不查询。`--no-geo` 关闭位置查询。
|
||||
媒体重复确认地区提示/带价格的具体套餐;Netflix 检查结构化片库元数据,BBC 额外查询测试频道的公开媒体元数据。
|
||||
不获取付费节目、不下载流媒体、不绕过登录/DRM,不保留媒体签名地址。BBC 的测试频道结果不代表整个平台。
|
||||
HTTP 200、403、页面可达、套餐或片库可见均不作为“完全解锁”证据。
|
||||
|
||||
## 退出码
|
||||
|
||||
- `check`:0=无已知注意/失败项,仍可能含未知;1=有注意/失败项;2=参数/保存错误。
|
||||
- `suite/bandwidth/ipquality/media`:0=该命令定义的范围完整,离线 0 仅表示本地执行完成;1=有未完成/未知/波动或未验证播放;2=参数/保存错误。
|
||||
- 流媒体命令仍保留“账号播放未测”,因此返回 1;这不表示程序崩溃。
|
||||
- Ctrl+C 返回 130,不改系统设置。
|
||||
|
||||
## 安装与开发
|
||||
|
||||
源码可直接运行。也可把提供的 wheel 安装到自己的虚拟环境:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl
|
||||
.venv/bin/proxydoctor suite --offline
|
||||
```
|
||||
|
||||
Ubuntu 若未安装 venv 支持,需另行安装系统对应组件;程序不会自动安装。
|
||||
开发测试:`python -m pip install pytest` 后 `python -m pytest -q`。
|
||||
GitHub Actions 配置已提供,未发布仓库前不宣称远程 CI 已运行。
|
||||
贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),安全反馈见 [SECURITY.md](SECURITY.md)。
|
||||
|
||||
## 阶段一验收与后续
|
||||
|
||||
本轮按用户选择完成代码和 22.04/24.04 虚拟机验收;实际记录以交付验收报告为准。
|
||||
海外 VPS、真实付费账号播放、带真实密钥的扩展数据,以及 Ubuntu 其他版本仍待分别验收;不会把它们标为已通过。
|
||||
之后阶段才实现 sing-box 安装、Karing 协议连通、修复预览/备份/回滚、Web UI 和安全订阅传输。
|
||||
不对外开放明文 HTTP 订阅,不根据只读体检自动修改 SSH/防火墙。
|
||||
|
||||
## 开源与来源
|
||||
|
||||
原创代码使用 MIT 许可证。感谢 ECS、NodeQuality、NetQuality/IPQuality、NextTrace、RegionRestrictionCheck 的功能和展示设计参考;
|
||||
默认实现没有打包或复制上游脚本/数据库。来源和许可范围见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
||||
本地第三方适配器仅在显式指定时运行,第三方行为/输出不保证只读或脱敏。
|
||||
|
||||
数据接口:[Cloudflare](https://github.com/cloudflare/speedtest)、[OVHcloud](https://proof.ovh.net/)、
|
||||
[Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/)、
|
||||
[ipapi.is](https://ipapi.is/developers.html)、[proxycheck.io](https://proxycheck.io/api/)、
|
||||
[IP2Location](https://www.ip2location.io/ip2location-documentation)、[AbuseIPDB](https://docs.abuseipdb.com/)、
|
||||
[ipwho.is](https://ipwhois.io/documentation)、[ipify](https://www.ipify.org/)。
|
||||
[Netflix 官方 VPN 说明](https://help.netflix.com/en/node/114701) 也说明了目录限制,不保证页面可达就能播放。
|
||||
|
||||
## 增量保存与故障隔离(实现未测试)
|
||||
|
||||
综合检测默认在当前目录的 `reports/` 保存脱敏 JSON 和 `.events.jsonl` 恢复日志,也可用 `--json-output` 指定文件。常规体检不修改系统配置,但会写入这些本地报告。已存在的报告和日志不会覆盖。
|
||||
|
||||
各模块独立记录异常后继续;DNS/TCP、路由跳、媒体结果完成后及时保存。Ctrl+C 会尝试保存并返回 130。磁盘写入失败会提示并继续终端检测;恢复时以日志中最后完整的记录为准。模块 success 只表示执行完成,不代表服务解锁或服务器安全。
|
||||
|
||||
本轮新增故障注入测试但未执行。测速/IP 查询尚不能保存模块内部所有未完成样本;媒体取消可能等待正在进行的请求结束;强制终止只能恢复最后一次完整记录。详见 TEST_PLAN.md。
|
||||
|
||||
## 资源检测新功能(未验证)
|
||||
|
||||
`suite` 和 `check` 增加 CPU 可见配额、进程 CPU 范围、内存可见上限、Swap、PSI 压力和短采样 CPU steal。结果直接显示在终端,并保留结构化数值。默认被动观察约 0.2 秒,不跑基准、不写 cgroup 设置。
|
||||
|
||||
缺失数据标为未知;可见配额不等于独享资源,短采样不代表长期状况。完整限制和后续验收见 TEST_PLAN.md。
|
||||
|
||||
基础安全新增重要路径权限/所有者与更新缓存年龄/计数,只读且不读取密码文件内容。风险提示需人工审阅,缓存不证明完整修补;未测试。
|
||||
|
||||
|
||||
## IP概览、Markdown和恢复命令
|
||||
|
||||
```bash
|
||||
# 终端集中显示IP信息;额外邮件探测为可选,不发送邮件
|
||||
bash proxydoctor.sh ipquality --mail-probes --show-ip
|
||||
# 从已有JSON导出,不重新检测
|
||||
bash proxydoctor.sh report --input reports/example.json --output report.md
|
||||
# 恢复最后完整记录,输出文件须不存在
|
||||
bash proxydoctor.sh recover --journal reports/example.json.events.jsonl --output recovered.json
|
||||
bash proxydoctor.sh recover --journal reports/example.json.events.jsonl --output recovered.md --format markdown --lang en
|
||||
```
|
||||
|
||||
`--show-ip`只改变终端IP显示,文件仍脱敏。顶部是概览,详细数据源在下面。DNSBL与IPMap尚未接入,覆盖和限制见 [IP质量功能表](IPQUALITY_COVERAGE.md)。不要将来源评分或PTR/端口结果视为绝对安全/邮件可投递证明。
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
## 报告与IP质量更新(2026-11-01)
|
||||
|
||||
- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。
|
||||
- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。
|
||||
- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。
|
||||
- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。
|
||||
- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。
|
||||
- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。
|
||||
- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。
|
||||
- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。
|
||||
|
||||
证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。
|
||||
|
||||
|
||||
## 最新双机验收(2026-11-01)
|
||||
|
||||
同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。
|
||||
|
||||
|
||||
## Ubuntu 24.04 实测更新(2026-11-01)
|
||||
|
||||
当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。
|
||||
中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。
|
||||
DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。
|
||||
综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。
|
||||
完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。
|
||||
|
||||
# 当前目标与实际进度
|
||||
|
||||
更新:2026-11-01。范围:独立 Python CLI,兼容目标 Ubuntu 22.04 及以上;当前不开发代理部署和 Web UI。
|
||||
|
||||
**重要:新增分支全部未测试。旧 0.5.0 的 outputs 验收记录仅属于旧版,不能视为新目标完成证据。没有计算虚假的完成百分比。**
|
||||
|
||||
| 类别 | 已有/新增源码能力 | 未完成重点 |
|
||||
|---|---|---|
|
||||
| 1 本机资源 | OS/内核/CPU/磁盘;新增 cgroup 配额、affinity、Swap、PSI、steal,优先展示额度而非宿主量 | 虚拟化细分、CPU 频率、购买规格对比、CPU/fio 基准;无法从本机证明商家独享承诺 |
|
||||
| 2 基础网络 | 接口/路由/DNS;本轮新增分 IPv4/IPv6 的多次 DNS、固定数字 IP TCP、验证证书的 TLS、分层错误码与提示 | UDP、PMTU、更完整多网卡/NAT/双栈诊断、全局时限;新实现未验证 |
|
||||
| 3 路由/BGP | 现有原生 v4/v6 UDP 追踪,最多64跳、IP/RTT/估计位置 | TCP/ICMP、多轮统计、逐跳 ASN、中国运营商特征与 BGP;中国侧探针缺失 |
|
||||
| 4 IP 质量 | 多来源归属/风险/冲突,保留证据与来源 | DNSBL/PTR/邮件信誉、条款与限额全面审查、真实密钥验证 |
|
||||
| 5 流媒体 | 多服务保守规则;片库/地区/未知不等于播放 | 新站点规则维护、自定义合法目标、更多实网证据;账号/DRM 未验证 |
|
||||
| 6 测速与监测 | 多来源 HTTPS 多轮测速;显式许可、载荷预算 | 可信中国运营商节点、iperf3、抖动/丢包统计、24/48h监测与趋势关联 |
|
||||
| 7 安全体检 | SSH、端口与防火墙只读;新增固定重要路径权限、所有者、更新缓存计数/年龄;接入suite/check | 全目录/ACL/包版本安全审计尚缺;新增代码未测试;不自动修复 |
|
||||
| 8 报告与历史 | JSON/IP脱敏;新增模块及逐项恢复日志、Ctrl+C保存、独占创建 | Markdown/HTML、历史比较、购买验收报告、完整恢复CLI、所有模块细粒度保存 |
|
||||
| 9 安装体验 | CLI入口、双语、Python>=3.10、无运行依赖 | 隔离一键安装/更新/卸载/校验、ARM和其他发行版实测 |
|
||||
| 10 可靠性 | 新增异常隔离、错误码、取消与保存降级、多个故障注入fixture | 所有新增测试和回归尚未运行;系统级全局预算、更多异常场景、双Ubuntu及真实VPS复验 |
|
||||
| 11 开源/API | 101项登记;架构/测试/研究记录、MIT/第三方声明;新增独立资源/网络API | 统一稳定Schema、所有核心/UI彻底解耦、Issue模板、完整许可/依赖审计;FlexMaster兼容未验收 |
|
||||
|
||||
## 已编写的优先工作
|
||||
|
||||
1. P0:默认无CPU基准、测速许可、暂停第三方可上传流程。
|
||||
2. P0:模块隔离、本地增量恢复日志、Ctrl+C保存、写入失败提示。
|
||||
3. P1:资源配额/压力及面向VPS额度的显示。
|
||||
4. P1:双栈 DNS/TCP/TLS 证据和针对性诊断(本轮)。
|
||||
|
||||
以上为实现进度,均非测试通过结论。先完成重点低风险源码,再按 TEST_PLAN.md 统一运行单元/回归与虚拟机验收。后续优先:统一报告/Markdown→历史与购买规格对比;基础权限/更新缓存已编写但更深安全审计仍待补齐;CPU/磁盘/长期测速等需要明确预算和单独许可。
|
||||
|
||||
5. P1:重要文件权限、更新缓存证据与安全提示已编写,未验证。
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline, dependency-free installer. Review this file before running it."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
VERSION = '1.0.0b1'
|
||||
|
||||
|
||||
def verify(source):
|
||||
manifest = json.loads((source/'MANIFEST.sha256.json').read_text(encoding='utf-8'))
|
||||
if manifest.get('version') != VERSION or not isinstance(manifest.get('files'), dict):
|
||||
raise ValueError('Invalid release manifest')
|
||||
files = manifest['files']
|
||||
required = {'install.py','pyproject.toml','LICENSE','THIRD_PARTY_NOTICES.md',
|
||||
'proxydoctor/__init__.py','proxydoctor/__main__.py','proxydoctor/cli.py'}
|
||||
if not required <= set(files):
|
||||
raise ValueError('Incomplete release manifest')
|
||||
actual = {p.relative_to(source).as_posix() for p in (source/'proxydoctor').rglob('*.py')}
|
||||
if actual != {name for name in files if name.startswith('proxydoctor/') and name.endswith('.py')}:
|
||||
raise ValueError('Unlisted or missing package source')
|
||||
for name, expected in files.items():
|
||||
relative = PurePosixPath(name)
|
||||
if relative.is_absolute() or '..' in relative.parts or '\\' in name:
|
||||
raise ValueError('Unsafe manifest path')
|
||||
path = source.joinpath(*relative.parts)
|
||||
if any(part.is_symlink() for part in (path,*path.parents) if part != source.parent):
|
||||
raise ValueError('Symlink in source path')
|
||||
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
|
||||
raise ValueError('Release integrity check failed')
|
||||
return files
|
||||
|
||||
|
||||
def install(source, prefix):
|
||||
if sys.version_info < (3,10) or sys.platform != 'linux':
|
||||
raise ValueError('Requires Linux and Python 3.10+')
|
||||
files = verify(source)
|
||||
prefix = prefix.expanduser().absolute()
|
||||
if prefix.is_symlink() or os.path.lexists(prefix):
|
||||
raise FileExistsError('Install destination already exists; choose a new version directory')
|
||||
prefix.parent.mkdir(parents=True,exist_ok=True)
|
||||
prefix.mkdir(mode=0o700)
|
||||
owned = prefix.resolve()
|
||||
try:
|
||||
# --without-pip avoids ensurepip/network/package-manager dependencies.
|
||||
subprocess.run([sys.executable,'-I','-B','-m','venv','--without-pip',str(prefix)],
|
||||
check=True,timeout=45,capture_output=True)
|
||||
python = prefix/'bin/python'
|
||||
probe = subprocess.run([str(python),'-I','-B','-c',
|
||||
'import json,sys,sysconfig;print(json.dumps([sys.prefix,sys.base_prefix,sysconfig.get_path("purelib")]))'],
|
||||
check=True,capture_output=True,text=True,timeout=10)
|
||||
active,base,library = json.loads(probe.stdout)
|
||||
if active == base or Path(active).resolve() != owned:
|
||||
raise ValueError('Virtual environment isolation check failed')
|
||||
library = Path(library)
|
||||
library.resolve().relative_to(owned)
|
||||
for name in files:
|
||||
if name.startswith('proxydoctor/') and name.endswith('.py'):
|
||||
destination = library/name
|
||||
destination.parent.mkdir(parents=True,exist_ok=True)
|
||||
shutil.copyfile(source/name,destination)
|
||||
notices = prefix/'share/proxydoctor'
|
||||
notices.mkdir(parents=True)
|
||||
for name in ('LICENSE','THIRD_PARTY_NOTICES.md','MANIFEST.sha256.json'):
|
||||
shutil.copyfile(source/name,notices/name)
|
||||
launcher = prefix/'bin/proxydoctor'
|
||||
# execv avoids shebang/path whitespace issues without shell interpolation.
|
||||
launcher.write_text('#!/bin/sh\nexec '+__import__('shlex').quote(str(python))+' -I -B -m proxydoctor "$@"\n',encoding='utf-8')
|
||||
launcher.chmod(0o700)
|
||||
subprocess.run([str(launcher),'--version'],check=True,timeout=10,capture_output=True)
|
||||
(prefix/'proxydoctor-install.json').write_text(json.dumps({'version':VERSION,'isolated':True}),encoding='utf-8')
|
||||
return launcher
|
||||
except BaseException:
|
||||
# Only remove the new directory reserved by this invocation.
|
||||
if not prefix.is_symlink() and prefix.resolve() == owned:
|
||||
shutil.rmtree(prefix)
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description='Offline isolated ProxyDoctor installer / 离线隔离安装')
|
||||
parser.add_argument('--prefix',type=Path,default=Path.home()/'.local/share/proxydoctor'/VERSION)
|
||||
parser.add_argument('--run',action='store_true',help='Install and run a read-only offline check')
|
||||
args=parser.parse_args()
|
||||
try:
|
||||
launcher=install(Path(__file__).resolve().parent,args.prefix)
|
||||
except KeyboardInterrupt:
|
||||
print('Installation cancelled; new environment cleaned / 安装已取消',file=sys.stderr)
|
||||
return 130
|
||||
except (OSError,ValueError,subprocess.SubprocessError):
|
||||
print('Installation failed. Check Python/venv availability, release checksum and destination. No system packages were installed. / 安装失败,请检查环境、校验和与目录。',file=sys.stderr)
|
||||
return 2
|
||||
print('Installed / 已安装:'+str(launcher),flush=True)
|
||||
print('1.0 public beta; not the final release / 1.0 公测版,非最终版本',flush=True)
|
||||
if args.run:
|
||||
return subprocess.call([str(launcher),'suite','--offline'])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
sys.exit(main())
|
||||
+2
-2
@@ -6,6 +6,6 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
exit 2
|
||||
fi
|
||||
if [ "$#" -eq 0 ]; then
|
||||
exec python3 -u -m proxydoctor suite
|
||||
exec python3 -B -u -m proxydoctor suite
|
||||
fi
|
||||
exec python3 -u -m proxydoctor "$@"
|
||||
exec python3 -B -u -m proxydoctor "$@"
|
||||
@@ -1,3 +1,8 @@
|
||||
"""ProxyDoctor: collect evidence before proposing changes."""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "1.0.0b1"
|
||||
|
||||
|
||||
def release_label(lang='zh'):
|
||||
return ('VPS 检测工具 · 1.0 公测版(非最终版本)' if lang == 'zh' else
|
||||
'VPS Check · 1.0 public beta (not the final release)')
|
||||
@@ -1,4 +1,14 @@
|
||||
from .cli import main
|
||||
import signal
|
||||
|
||||
def cancelled(signum, frame):
|
||||
from .worker import cancel_all
|
||||
cancel_all()
|
||||
raise KeyboardInterrupt
|
||||
|
||||
if hasattr(signal,'SIGTERM'):
|
||||
signal.signal(signal.SIGTERM,cancelled)
|
||||
signal.signal(signal.SIGINT,cancelled)
|
||||
|
||||
try:
|
||||
main()
|
||||
|
||||
@@ -18,6 +18,9 @@ class Source:
|
||||
download_url: str
|
||||
upload_url: str | None = None
|
||||
use_range: bool = False
|
||||
capped_stream: bool = False
|
||||
carrier: str | None = None
|
||||
mainland: bool | None = None
|
||||
|
||||
|
||||
# Fixed official test endpoints only. No arbitrary URLs, account keys or binaries.
|
||||
@@ -26,7 +29,16 @@ SOURCES = {
|
||||
'https://speed.cloudflare.com/__up'),
|
||||
'hetzner-fsn': Source('Hetzner','DE / Falkenstein','https://fsn1-speed.hetzner.com/100MB.bin',use_range=True),
|
||||
'ovh-rbx': Source('OVHcloud','FR / Roubaix','https://lil1-rbx.speedtest.network.ovh.net/files/100Mb.dat',use_range=True),
|
||||
'cn-telecom-sh': Source('China Telecom','CN / Shanghai','https://speedtest1.online.sh.cn:8080/speedtest/random4000x4000.jpg',
|
||||
capped_stream=True,carrier='telecom',mainland=True),
|
||||
'cn-unicom-sh': Source('China Unicom','CN / Shanghai','https://mobile.shunicomtest.com:8080/speedtest/random4000x4000.jpg',
|
||||
capped_stream=True,carrier='unicom',mainland=True),
|
||||
'cn-mobile-fz': Source('China Mobile','CN / Fuzhou','https://csfw.fj.chinamobile.com:8080/speedtest/random4000x4000.jpg',
|
||||
capped_stream=True,carrier='mobile',mainland=True),
|
||||
'mobile-hk': Source('China Mobile Hong Kong','HK / not mainland China','https://speedtestbb.hk.chinamobile.com:8080/speedtest/random4000x4000.jpg',
|
||||
capped_stream=True,carrier='mobile',mainland=False),
|
||||
}
|
||||
CHINA_SOURCES=('cn-telecom-sh','cn-unicom-sh','cn-mobile-fz')
|
||||
# Keep the slower, environment-sensitive Hetzner endpoint explicitly selectable.
|
||||
DEFAULT_SOURCES = ('cloudflare','ovh-rbx')
|
||||
|
||||
@@ -55,6 +67,10 @@ class Sample:
|
||||
|
||||
|
||||
def transfer(direction, size, source='cloudflare'):
|
||||
from .worker import enabled,invoke
|
||||
if enabled():
|
||||
result=invoke('transfer',{'direction':direction,'size':size,'source':source},timeout=25)
|
||||
return Sample(**result) if isinstance(result,dict) else Sample(direction,size,error='request_failed',source=source)
|
||||
if direction not in ('download', 'upload') or not 1 <= size <= 20 * 1024**2:
|
||||
raise ValueError('Invalid transfer limits')
|
||||
if source not in SOURCES:
|
||||
@@ -67,9 +83,9 @@ def transfer(direction, size, source='cloudflare'):
|
||||
headers = {'User-Agent': 'ProxyDoctor/0.4.1', 'Accept-Encoding': 'identity',
|
||||
'Cache-Control': 'no-cache'}
|
||||
url = provider.download_url
|
||||
if not provider.use_range:
|
||||
if not provider.use_range and not provider.capped_stream:
|
||||
url += '?bytes=' + str(size)
|
||||
else:
|
||||
elif provider.use_range:
|
||||
headers['Range'] = f'bytes=0-{size-1}'
|
||||
data = None
|
||||
if direction == 'upload':
|
||||
@@ -104,7 +120,23 @@ def transfer(direction, size, source='cloudflare'):
|
||||
pop = ray.rsplit('-', 1)[-1]
|
||||
if len(pop) == 3 and pop.isalpha():
|
||||
result.pop = pop.upper()
|
||||
body = read_limited(response, size if direction == 'download' else 65536, start + 30)
|
||||
if direction=='download' and provider.capped_stream:
|
||||
# Fixed public test images often ignore Range. Read exactly the
|
||||
# consented payload, then close; never consume the full object.
|
||||
if not response.headers.get('Content-Type','').lower().startswith('image/jpeg'):
|
||||
result.error='unexpected_test_content';return result
|
||||
chunks=[];remaining=size
|
||||
while remaining:
|
||||
if time.monotonic()-start>20:raise TimeoutError()
|
||||
read=getattr(response,'read1',response.read)
|
||||
block=read(min(65536,remaining))
|
||||
if not block:break
|
||||
chunks.append(block);remaining-=len(block)
|
||||
body=b''.join(chunks)
|
||||
if not body.startswith(b'\xff\xd8'):
|
||||
result.error='unexpected_test_content';return result
|
||||
else:
|
||||
body = read_limited(response, size if direction == 'download' else 65536, start + 30)
|
||||
if direction == 'download' and len(body) != size:
|
||||
result.error = 'short_response'
|
||||
return result
|
||||
@@ -147,6 +179,9 @@ def measure(rounds=3, sample_mib=4, on_sample=None, source='cloudflare'):
|
||||
source_ids((source,))
|
||||
provider = SOURCES[source]
|
||||
report = {'source':source,'provider':provider.name,'region':provider.region,
|
||||
'carrier_claim':provider.carrier,'mainland_china':provider.mainland,
|
||||
'provider_identity':'catalogue_claim' if provider.carrier else 'official_test_endpoint',
|
||||
'directions_scope':'download: node_to_this_host; upload: this_host_to_node',
|
||||
'download_url':provider.download_url,'upload_supported':provider.upload_url is not None,
|
||||
'method': 'sequential_https_wall_time',
|
||||
'payload_budget_bytes': payload_budget(rounds,sample_mib,(source,)),
|
||||
@@ -196,17 +231,18 @@ def is_complete(report):
|
||||
for result in source['directions'].values())
|
||||
|
||||
|
||||
def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None):
|
||||
def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None,destination=None,on_result=None,*,out=None):
|
||||
from .console import Console
|
||||
out=Console(lang,color);tr=out.text
|
||||
out=out if out is not None else Console(lang,color);tr=out.text
|
||||
selected=source_ids(sources)
|
||||
budget=payload_budget(rounds,sample_mib,selected)/1024**2
|
||||
print(tr(f' {len(selected)} 个来源 · 每方向 {rounds} 轮 × {sample_mib} MiB · 请求载荷最多 {budget:g} MiB',
|
||||
f' {len(selected)} sources · {rounds} rounds × {sample_mib} MiB/direction · requested payload ≤ {budget:g} MiB'),flush=True)
|
||||
out.line(tr(f'{len(selected)} 个来源 · 每方向 {rounds} 轮 × {sample_mib} MiB · 请求载荷最多 {budget:g} MiB',
|
||||
f'{len(selected)} sources · {rounds} rounds × {sample_mib} MiB/direction · requested payload ≤ {budget:g} MiB'))
|
||||
def source_output(identifier,event,result):
|
||||
provider=SOURCES[identifier]
|
||||
if event=='start':
|
||||
print('\n'+out.paint(f'{provider.name} · {provider.region}','title'),flush=True)
|
||||
out.line(f'{provider.name} · {provider.region}','title')
|
||||
out.row(tr('测速方向','Direction'),tr('下载:节点 → 本机;上传:本机 → 节点','Download: node → this host; upload: this host → node'),'unknown')
|
||||
if not provider.upload_url: out.row(tr('上传','Upload'),tr('不支持(该来源仅提供下载)','Not supported (download-only source)'),'unknown')
|
||||
return
|
||||
for direction,summary in result['directions'].items():
|
||||
@@ -221,8 +257,15 @@ def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None):
|
||||
'insufficient':tr('有效轮次不足','Insufficient successful rounds')}.get(summary['quality'])
|
||||
if note: out.row('',note,'unknown')
|
||||
def sample_output(identifier,direction,number,total,sample):
|
||||
if destination is not None:
|
||||
destination.setdefault('sample_events',[]).append(asdict(sample))
|
||||
if on_result is not None:on_result()
|
||||
label=tr('下载','Download') if direction=='download' else tr('上传','Upload')
|
||||
value=f'{sample.mbps:.2f} Mbps · {sample.elapsed_s:.2f}s' if sample.mbps is not None else tr('未成功','Failed')+' · '+str(sample.error)
|
||||
if sample.pop: value+=' · '+sample.pop
|
||||
out.row(f'{label} {number}/{total}',value,'ok' if sample.mbps is not None else 'unknown')
|
||||
return measure_many(rounds,sample_mib,selected,source_output,sample_output)
|
||||
result=measure_many(rounds,sample_mib,selected,source_output,sample_output)
|
||||
if destination is not None:
|
||||
destination.update(result)
|
||||
return destination
|
||||
return result
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Bounded RIPE RIS observations, not the VPS's selected routing table."""
|
||||
import ipaddress
|
||||
from urllib.parse import urlencode
|
||||
from .net import request_json
|
||||
from .reports import utc_now
|
||||
|
||||
SOURCE='https://stat.ripe.net'
|
||||
ASN_HINTS={4134:'China Telecom 163',4809:'China Telecom CN2',4837:'China Unicom',
|
||||
9929:'China Unicom 9929',9808:'China Mobile',58453:'CMI',58807:'CMIN2'}
|
||||
|
||||
|
||||
def as_number(value):
|
||||
if isinstance(value,bool):return None
|
||||
try:
|
||||
number=int(value)
|
||||
return number if str(number)==str(value) and 1<=number<=4294967295 else None
|
||||
except (TypeError,ValueError):return None
|
||||
|
||||
|
||||
def query(endpoint,resource,**params):
|
||||
url=SOURCE+'/data/'+endpoint+'/data.json?'+urlencode({'resource':resource,**params})
|
||||
payload,state=request_json(url,max_bytes=524288)
|
||||
if state!='ok':return None,state
|
||||
if payload.get('status')!='ok' or not isinstance(payload.get('data'),dict):
|
||||
return None,'invalid_response'
|
||||
return payload['data'],'ok'
|
||||
|
||||
|
||||
def collect(address, destination=None, on_result=None):
|
||||
ip=ipaddress.ip_address(address)
|
||||
if not ip.is_global:raise ValueError('BGP lookup requires a public IP')
|
||||
report=destination if destination is not None else {}
|
||||
report.update(target=str(ip),checked_at=utc_now(),source=SOURCE,status='unknown',
|
||||
prefix=None,origin_asns=[],neighbours=[],paths=[],sources={},
|
||||
limitations=['RIS collector observations are not the local selected route.',
|
||||
'ASN adjacency does not prove a commercial upstream relationship.',
|
||||
'Sampled AS paths do not prove packet paths or CN2 GIA service.'])
|
||||
def save():
|
||||
if on_result:on_result()
|
||||
data,state=query('network-info',str(ip))
|
||||
report['sources']['network-info']=state
|
||||
if state!='ok':save();return report
|
||||
try:
|
||||
prefix=ipaddress.ip_network(data['prefix'])
|
||||
origins=data['asns']
|
||||
if ip not in prefix or not isinstance(origins,list) or not origins or any(as_number(n) is None for n in origins):
|
||||
raise ValueError()
|
||||
report.update(prefix=str(prefix),origin_asns=[int(n) for n in origins],status='partial')
|
||||
except (ValueError,TypeError,KeyError):
|
||||
report['sources']['network-info']='invalid_response';save();return report
|
||||
save()
|
||||
neighbours,state=query('asn-neighbours','AS'+str(report['origin_asns'][0]))
|
||||
report['sources']['asn-neighbours']=state
|
||||
if state=='ok':
|
||||
rows=neighbours.get('neighbours',[])
|
||||
if isinstance(rows,list):
|
||||
report['neighbours']=[{'asn':as_number(row.get('asn')),'type':row.get('type'),
|
||||
'power':row.get('power')} for row in rows[:256]
|
||||
if isinstance(row,dict) and as_number(row.get('asn'))]
|
||||
report['neighbours_query_time']=neighbours.get('query_starttime')
|
||||
save()
|
||||
state_data,state=query('bgp-state',report['prefix'],rrcs='0')
|
||||
report['sources']['bgp-state']=state
|
||||
if state=='ok':
|
||||
rows=state_data.get('bgp_state',[])
|
||||
seen=set()
|
||||
if isinstance(rows,list):
|
||||
for row in rows[:512]:
|
||||
if not isinstance(row,dict) or not isinstance(row.get('path'),list):continue
|
||||
path=row['path']
|
||||
try:prefix=ipaddress.ip_network(row.get('target_prefix'))
|
||||
except (ValueError,TypeError):continue
|
||||
if ip not in prefix or not path or len(path)>128 or any(as_number(n) is None for n in path):continue
|
||||
path=tuple(map(int,path))
|
||||
if path in seen:continue
|
||||
seen.add(path)
|
||||
report['paths'].append({'as_path':list(path),'collector_peer':row.get('source_id'),
|
||||
'prefix':str(prefix),'network_hints':[ASN_HINTS[n] for n in dict.fromkeys(path) if n in ASN_HINTS]})
|
||||
if len(report['paths'])>=20:break
|
||||
report['data_time']=state_data.get('timestamp',state_data.get('query_time'))
|
||||
report['status']='success' if report['paths'] and all(v=='ok' for v in report['sources'].values()) else 'partial'
|
||||
save()
|
||||
return report
|
||||
|
||||
|
||||
def render(report,out):
|
||||
tr=out.text
|
||||
out.row('BGP',report.get('status','unknown'),'ok' if report.get('status')=='success' else 'unknown')
|
||||
out.row(tr('公告前缀','Announced prefix'),report.get('prefix') or tr('未知','Unknown'),'unknown')
|
||||
out.row(tr('起源 ASN','Origin ASNs'),', '.join('AS'+str(n) for n in report.get('origin_asns',[])) or '---')
|
||||
out.row(tr('观测邻居','Observed neighbours'),len(report.get('neighbours',[])),'unknown')
|
||||
for row in report.get('paths',[]):
|
||||
out.row('RIS AS_PATH',' → '.join(map(str,row['as_path'])),'unknown')
|
||||
if row['network_hints']:out.row(tr('ASN 标签','ASN hints'),' / '.join(row['network_hints']),'unknown')
|
||||
for name,state in report.get('sources',{}).items():out.row(name,state,'ok' if state=='ok' else 'unknown')
|
||||
out.row(tr('范围','Scope'),tr('RIS 采集器视角;不等于本机实际选路或商业上游','RIS collector view; local path and commercial upstream unconfirmed'),'unknown')
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Fixed public test endpoints; provider labels are claims until corroborated.
|
||||
|
||||
Endpoint references: NextTrace and CleanIP sh.cd (see THIRD_PARTY_NOTICES.md).
|
||||
No remote scripts or automatic registry downloads are executed.
|
||||
"""
|
||||
from dataclasses import dataclass,asdict
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from .reports import utc_now
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CarrierNode:
|
||||
identifier:str
|
||||
carrier:str
|
||||
city:str
|
||||
host:str
|
||||
port:int=8080
|
||||
mainland:bool=True
|
||||
|
||||
|
||||
NODES=(CarrierNode('ct-sh','telecom','Shanghai','speedtest1.online.sh.cn'),
|
||||
CarrierNode('ct-nj','telecom','Nanjing','5gnanjing.speedtest.jsinfo.net'),
|
||||
CarrierNode('ct-cd','telecom','Chengdu','speedtest1.sc.189.cn'),
|
||||
CarrierNode('cu-sh','unicom','Shanghai','mobile.shunicomtest.com'),
|
||||
CarrierNode('cu-bj','unicom','Beijing','beijing.unicomtest.com'),
|
||||
CarrierNode('cu-tj','unicom','Tianjin','speedtest3.online.tj.cn'),
|
||||
CarrierNode('cm-fz','mobile','Fuzhou','csfw.fj.chinamobile.com'),
|
||||
CarrierNode('cm-hz','mobile','Hangzhou','speedtest.139play.com'),
|
||||
CarrierNode('cm-bj','mobile','Beijing','speedtest.bmcc.com.cn'),
|
||||
CarrierNode('cm-hk','mobile','Hong Kong','speedtestbb.hk.chinamobile.com',mainland=False))
|
||||
|
||||
|
||||
def resolve_public(host,family=4):
|
||||
"""Only called inside a bounded network worker."""
|
||||
af=socket.AF_INET6 if family==6 else socket.AF_INET
|
||||
values={row[4][0] for row in socket.getaddrinfo(host,None,af,type=socket.SOCK_STREAM)}
|
||||
if not values or any(not ipaddress.ip_address(value).is_global for value in values):
|
||||
raise ValueError('Non-public or missing endpoint')
|
||||
return sorted(values)
|
||||
|
||||
|
||||
def probe_node(identifier):
|
||||
from .worker import enabled,invoke
|
||||
if identifier not in {row.identifier for row in NODES}:raise ValueError('Unknown node')
|
||||
if enabled():
|
||||
result=invoke('carrier_probe',{'identifier':identifier},timeout=9)
|
||||
return result if isinstance(result,dict) else {'identifier':identifier,'status':'unknown','error_code':'NODE_TIMEOUT_OR_ERROR','checked_at':utc_now()}
|
||||
node=next(row for row in NODES if row.identifier==identifier)
|
||||
row={**asdict(node),'checked_at':utc_now(),'status':'unknown','scope':'small_protocol_probe_not_bandwidth',
|
||||
'provider_identity':'catalogue_claim','transport':'plaintext_tcp_no_credentials'}
|
||||
try:
|
||||
addresses=resolve_public(node.host)
|
||||
row['addresses']=addresses
|
||||
started=time.monotonic()
|
||||
with socket.create_connection((addresses[0],node.port),timeout=2) as sock:
|
||||
row['tcp_ms']=round((time.monotonic()-started)*1000,3)
|
||||
sock.settimeout(2);sock.sendall(b'HI\n')
|
||||
body=bytearray()
|
||||
while b'\n' not in body and len(body)<256:
|
||||
part=sock.recv(1)
|
||||
if not part:break
|
||||
body.extend(part)
|
||||
if not body.startswith(b'HELLO '):
|
||||
row['error_code']='UNRECOGNIZED_TEST_PROTOCOL';return row
|
||||
sock.sendall(b'DOWNLOAD 16384\n')
|
||||
total=0
|
||||
deadline=time.monotonic()+3
|
||||
while total<16384:
|
||||
if time.monotonic()>=deadline:raise TimeoutError()
|
||||
block=sock.recv(min(4096,16384-total))
|
||||
if not block:break
|
||||
total+=len(block)
|
||||
row['sample_bytes']=total
|
||||
row['status']='sample_available' if total==16384 else 'unknown'
|
||||
if total!=16384:row['error_code']='SHORT_TEST_SAMPLE'
|
||||
except socket.gaierror:row['error_code']='DNS_ERROR'
|
||||
except (OSError,ValueError):row['error_code']='CONNECTION_OR_PROTOCOL_ERROR'
|
||||
return row
|
||||
|
||||
|
||||
def collect_nodes(destination=None,on_result=None,out=None):
|
||||
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||
report=destination if destination is not None else {}
|
||||
report.update(checked_at=utc_now(),nodes={},coverage={},status='unknown',
|
||||
scope='current_vantage_to_fixed_public_test_nodes_not_all_china',
|
||||
bandwidth_measured=False)
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
futures={pool.submit(probe_node,node.identifier):node for node in NODES}
|
||||
for future in as_completed(futures):
|
||||
node=futures[future]
|
||||
try:row=future.result()
|
||||
except Exception:row={'identifier':node.identifier,'status':'unknown','error_code':'NODE_PROBE_ERROR'}
|
||||
report['nodes'][node.identifier]={**asdict(node),**row}
|
||||
if on_result:on_result()
|
||||
if out:
|
||||
label=f'{node.carrier} / {node.city}' + (' [HK]' if not node.mainland else '')
|
||||
out.row(label,row['status']+' · '+str(row.get('error_code',str(row.get('sample_bytes',0))+' bytes')),
|
||||
'ok' if row['status']=='sample_available' else 'unknown')
|
||||
for carrier in ('telecom','unicom','mobile'):
|
||||
report['coverage'][carrier]=any(row['carrier']==carrier and row['mainland'] and row['status']=='sample_available'
|
||||
for row in report['nodes'].values())
|
||||
report['status']='success' if all(report['coverage'].values()) else 'partial'
|
||||
if on_result:on_result()
|
||||
return report
|
||||
|
||||
|
||||
def return_routes(*,method='udp',family=4,hops=30,geo=False,tcp_port=443,destination=None,on_result=None,out=None):
|
||||
"""Beijing/Shanghai/Guangzhou × three carriers, from this host only."""
|
||||
from .worker import invoke
|
||||
from .routes import trace_method,summarize_hops
|
||||
if method not in ('udp','icmp','tcp') or family not in (4,6) or not 1<=hops<=64:
|
||||
raise ValueError('Invalid return-route options')
|
||||
report=destination if destination is not None else {}
|
||||
report.update(direction='this_host_to_china_targets',checked_at=utc_now(),status='partial',routes={},
|
||||
scope='three_cities_three_carriers_not_all_provinces_or_reverse_direction',
|
||||
target_reference='https://github.com/nxtrace/NTrace-core/blob/main/fast_trace/basic.go')
|
||||
for city in ('pek','sha','can'):
|
||||
for carrier,asn in (('telecom',4134),('unicom',4837),('mobile',9808)):
|
||||
key=f'{city}-{carrier}'
|
||||
host=f'ipv{family}.{city}-{asn}.endpoint.nxtrace.org'
|
||||
row={'host':host,'carrier_claim':carrier,'expected_asn':asn,'method':method,'hops':[],'state':'unknown'}
|
||||
report['routes'][key]=row
|
||||
if out:out.line(f'{city} / {carrier} / {method.upper()}','title')
|
||||
addresses=invoke('resolve_public',{'host':host,'family':family},timeout=5)
|
||||
if not isinstance(addresses,list) or not addresses:
|
||||
row['error_code']='TARGET_DNS_UNCONFIRMED'
|
||||
else:
|
||||
row['target']=addresses[0]
|
||||
def progress():
|
||||
if on_result:on_result()
|
||||
def emit(line,**kwargs):
|
||||
if out:out.line(line,'unknown')
|
||||
code=trace_method(row['target'],emit,method=method,tcp_port=tcp_port,hops=hops,lang=out.lang if out else 'en',
|
||||
geo=geo,records=row['hops'],on_result=progress,deadline=time.monotonic()+30)
|
||||
row['state']='reached' if code==0 else 'incomplete'
|
||||
row['statistics']=summarize_hops(row['hops'])
|
||||
if on_result:on_result()
|
||||
report['status']='success' if all(row['state']=='reached' for row in report['routes'].values()) else 'partial'
|
||||
return report
|
||||
+35
-29
@@ -80,6 +80,14 @@ class Host:
|
||||
return os.environ.get("SSH_CONNECTION", "")
|
||||
|
||||
def detect_public_ip(self):
|
||||
from .worker import enabled
|
||||
if enabled():
|
||||
from .net import request_json
|
||||
data,state=request_json('https://api.ipify.org?format=json')
|
||||
try:
|
||||
address=ipaddress.ip_address(data.get('ip')) if state=='ok' else None
|
||||
return str(address) if address and address.is_global else None
|
||||
except (ValueError,TypeError,AttributeError):return None
|
||||
# Explicit opt-in; no environment proxies or redirects.
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
@@ -192,7 +200,7 @@ def route_check(host, t):
|
||||
failed = True
|
||||
evidence.append(f"IPv{family[-1]}: " + t("路由读取失败或格式无法解析"))
|
||||
return Result("default_route", "pass" if valid and not failed else "unknown", t("默认路由"),
|
||||
t("已读取默认路由;不代表互联网或目标地址可达") if valid and not failed else
|
||||
t("已读取默认路由") if valid and not failed else
|
||||
t("默认路由信息不完整或未找到,无法确认网络出口"), "\n".join(evidence),
|
||||
t("仅检查当前网络命名空间的默认路由,不覆盖策略路由。"))
|
||||
|
||||
@@ -220,8 +228,8 @@ def dns_check(host, t):
|
||||
stub = any(addr.is_loopback for addr in addresses)
|
||||
status = "unknown" if invalid or not addresses or stub else "pass"
|
||||
detail = (t("DNS 配置不完整或没有有效的服务器地址") if invalid or not addresses else
|
||||
t("DNS 指向本机解析器;上游配置及解析是否成功尚未验证") if stub else
|
||||
t("已读取 DNS 服务器配置;尚未验证解析是否成功"))
|
||||
t("DNS 指向本机解析器") if stub else
|
||||
t("已读取 DNS 服务器配置"))
|
||||
return Result("dns_config", status, t("DNS 配置"), detail, evidence,
|
||||
t("仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。"))
|
||||
|
||||
@@ -261,10 +269,9 @@ def ssh_port_check(host, ssh, port, t):
|
||||
evidence.append(t("当前 SSH 会话本地端口:{port}", port=session_port))
|
||||
matched = port in configured or port == session_port
|
||||
detail = t("目标端口与 SSH 已知端口冲突;不要用于代理") if matched else t("未能排除与运行中 SSH 端口冲突")
|
||||
advice = t("检查运行中 SSH、socket 激活及自定义实例;证据不足时不要进行端口或防火墙修改。")
|
||||
if malformed:
|
||||
evidence.append(t("SSH 端口输出不完整或无效"))
|
||||
return Result("ssh_port_safety", "warn" if matched else "unknown", t("SSH 端口保护"), detail, "\n".join(evidence), advice)
|
||||
return Result("ssh_port_safety", "warn" if matched else "unknown", t("SSH 端口保护"), detail, "\n".join(evidence), "")
|
||||
|
||||
|
||||
def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=False):
|
||||
@@ -280,12 +287,12 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
os_unknown = system == "Linux" and not os_info.get("ID")
|
||||
version_unknown = is_ubuntu and version is None
|
||||
add(Result("system", "pass" if supported else "unknown" if version_unknown or os_unknown else "warn", t("操作系统"),
|
||||
t("版本符合 Ubuntu 22.04 最低要求;不代表已完成实机兼容验证") if supported else
|
||||
t("版本符合 Ubuntu 22.04 最低要求;实机兼容性按已测试环境确认") if supported else
|
||||
t("无法读取 Linux 发行版标识,不能确认系统要求") if os_unknown else
|
||||
t("无法解析 Ubuntu 版本,不能确认是否符合最低要求") if version_unknown else t("需要 Ubuntu 22.04 或更新版本"),
|
||||
f"{system}; {os_info.get('PRETTY_NAME', t('版本未知'))}; {arch}",
|
||||
t("版本检查不验证官方维护状态;Ubuntu 22.04 及更新版本仍需分别进行实机测试。") if supported else
|
||||
t("其他系统的结果仅供参考;请使用 Ubuntu 22.04 或更新版本验证。")))
|
||||
t("支持范围:Ubuntu 22.04 及更新版本。") if supported else
|
||||
t("其他系统的结果仅供参考。")))
|
||||
add(Result("architecture", "pass" if arch.lower() in {"x86_64", "amd64", "aarch64", "arm64"} else "unknown",
|
||||
t("系统架构"), t("检测架构;本阶段不验证安装包兼容性"), arch))
|
||||
try:
|
||||
@@ -307,14 +314,13 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
try:
|
||||
free = host.disk_free() / 1024**3
|
||||
add(Result("disk", "pass" if free >= 1 else "warn", t("根分区可用空间"),
|
||||
t("可用 {free:.1f} GiB(体检提示阈值 1 GiB,不代表完整安装要求)", free=free)))
|
||||
t("可用 {free:.1f} GiB(体检提示阈值 1 GiB)", free=free)))
|
||||
except OSError:
|
||||
add(Result("disk", "unknown", t("根分区可用空间"), t("无法读取")))
|
||||
try:
|
||||
total, free = host.disk_total() / 1024**3, host.disk_free() / 1024**3
|
||||
add(Result('disk_capacity', 'pass', t('根分区容量'),
|
||||
t('总容量 {total:.1f} GiB,可用 {free:.1f} GiB', total=total, free=free),
|
||||
advice=t('这是根文件系统容量,不一定等于 VPS 购买的磁盘总容量。')))
|
||||
t('总容量 {total:.1f} GiB,可用 {free:.1f} GiB', total=total, free=free)))
|
||||
except (AttributeError, OSError):
|
||||
add(Result('disk_capacity', 'unknown', t('根分区容量'), t('无法读取')))
|
||||
if public_ip and detect_public_ip:
|
||||
@@ -322,19 +328,17 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
if detect_public_ip:
|
||||
address = host.detect_public_ip()
|
||||
add(Result("public_ip", "unknown", t("公网 IP"),
|
||||
t("已通过 ipify 查询出口地址;未验证入站可达性或归属") if address else t("出口 IP 查询失败,无法确定公网地址"),
|
||||
address or "", t("出口地址可能是 NAT 或代理地址;不是入站可达性证明。")))
|
||||
t("已通过 ipify 查询出口地址;入站可达性未确认") if address else t("出口 IP 查询失败,无法确定公网地址"),
|
||||
address or "", t("出口地址可能是 NAT 或代理地址。")))
|
||||
elif public_ip:
|
||||
addr = ipaddress.ip_address(public_ip)
|
||||
add(Result("public_ip", "unknown" if addr.is_global else "warn", t("公网 IP"),
|
||||
t("用户提供的地址具备公网地址属性,但未验证归属或可达性") if addr.is_global else t("提供的地址不是全局可路由公网地址"),
|
||||
t("用户提供的地址具备公网地址属性") if addr.is_global else t("提供的地址不是全局可路由公网地址"),
|
||||
str(addr)))
|
||||
else:
|
||||
add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP"),
|
||||
advice=t("可用 --public-ip 填写云控制台地址;地址归属及入站可达性仍需外部验证。")))
|
||||
add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP")))
|
||||
add(Result("cloud_firewall", "unknown", t("云防火墙与外部可达性"),
|
||||
t("本机读取不能确认云安全组、NAT 或外部网络路径"),
|
||||
advice=t("后续从客户端独立测试;不能仅据此认定端口已开放或被封锁。")))
|
||||
t("本机读取不能确认云安全组、NAT 或外部网络路径")))
|
||||
if system != "Linux":
|
||||
for key, title in (("memory", t("内存")), ("interfaces", t("网络地址")),
|
||||
("default_route", t("默认路由")), ("dns_config", t("DNS 配置")),
|
||||
@@ -348,15 +352,14 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
total_line = next((line for line in (memory or '').splitlines() if line.startswith('MemTotal:')), '')
|
||||
try:
|
||||
total_mib = int(total_line.split()[1]) / 1024
|
||||
add(Result('memory_total', 'pass', t('内存总量'), t('系统可见 {total:.0f} MiB', total=total_mib),
|
||||
advice=t('系统可见内存可能与商家标称或容器限额不同。')))
|
||||
add(Result('memory_total', 'pass', t('内存总量'), t('系统可见 {total:.0f} MiB', total=total_mib)))
|
||||
except (ValueError, IndexError):
|
||||
add(Result('memory_total', 'unknown', t('内存总量'), t('无法读取')))
|
||||
available = next((line for line in (memory or "").splitlines() if line.startswith("MemAvailable:")), "")
|
||||
try:
|
||||
mib = int(available.split()[1]) / 1024
|
||||
add(Result("memory", "pass" if mib >= 256 else "warn", t("可用内存"),
|
||||
t("约 {mib:.0f} MiB(体检提示阈值 256 MiB,不代表完整安装要求)", mib=mib)))
|
||||
t("约 {mib:.0f} MiB(体检提示阈值 256 MiB)", mib=mib)))
|
||||
except (ValueError, IndexError):
|
||||
add(Result("memory", "unknown", t("可用内存"), t("无法读取 MemAvailable")))
|
||||
network = host.run("ip", "-j", "address", "show")
|
||||
@@ -365,7 +368,7 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
addresses = [f"{item['ifname']}: {addr['local']}" for item in interfaces
|
||||
for addr in item.get("addr_info", []) if addr.get("scope") != "host"]
|
||||
add(Result("interfaces", "pass" if addresses else "unknown", t("网络地址"),
|
||||
t("仅表示本机接口地址;不证明互联网可达"), "\n".join(addresses)))
|
||||
t("仅表示本机接口地址"), "\n".join(addresses)))
|
||||
except (TypeError, ValueError, KeyError, AttributeError):
|
||||
add(Result("interfaces", "unknown", t("网络地址"), t(network.reason) or t("无法解析接口信息")))
|
||||
add(route_check(host, t))
|
||||
@@ -375,7 +378,7 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
conflicts = [f"{proto} {endpoint}" for proto, endpoint, number in rows if number == port]
|
||||
add(Result("ports", "warn" if conflicts else "unknown" if ports.code != 0 or malformed else "pass",
|
||||
t("目标端口占用"), t("目标 TCP/UDP 端口 {port} 已有监听", port=port) if conflicts else
|
||||
t("无法完整确认端口占用") if ports.code != 0 or malformed else t("当前未发现 TCP/UDP 端口 {port} 的监听;不保证之后绑定成功", port=port),
|
||||
t("无法完整确认端口占用") if ports.code != 0 or malformed else t("当前未发现 TCP/UDP 端口 {port} 的监听", port=port),
|
||||
"\n".join(conflicts), t("选择未占用端口;不会停止已有服务。") if conflicts else ""))
|
||||
ssh = host.run("sshd", "-T")
|
||||
add(ssh_port_check(host, ssh, port, t))
|
||||
@@ -385,10 +388,10 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
risk = settings.get("permitrootlogin") == "yes" or settings.get("passwordauthentication") == "yes"
|
||||
ssh_complete = all(key in settings for key in ("permitrootlogin", "passwordauthentication"))
|
||||
add(Result("ssh", "unknown" if ssh.code != 0 else "warn" if risk else "unknown", t("SSH 配置"),
|
||||
(t(ssh.reason) or t("读取失败")) if ssh.code != 0 else t("允许 root 登录或密码认证,建议人工审阅") if risk else
|
||||
t("SSH 输出缺少关键字段,无法完成风险判断") if not ssh_complete else t("未发现这两项宽松设置;不能据此认定 SSH 安全"),
|
||||
(t(ssh.reason) or t("读取失败")) if ssh.code != 0 else t("允许 root 登录或密码认证") if risk else
|
||||
t("SSH 输出缺少关键字段,无法完成风险判断") if not ssh_complete else t("未发现这两项宽松设置"),
|
||||
"\n".join(line for line in ssh.output.splitlines() if line.split() and line.split()[0] in selected) if ssh.code == 0 else "",
|
||||
t("sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同;不自动修改 SSH。")))
|
||||
t("sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同。")))
|
||||
firewall = []
|
||||
any_rules = False
|
||||
complete = True
|
||||
@@ -410,19 +413,22 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
|
||||
failed = active.output.strip() == "failed"
|
||||
add(Result("service", "warn" if failed else "pass" if running else "unknown", t("sing-box 服务"),
|
||||
t("sing-box 服务处于 failed 状态,需要检查启动失败原因") if failed else
|
||||
t("服务处于 active;不代表代理连接成功") if running else t("未确认 sing-box 服务运行;新系统尚未安装属于正常情况"),
|
||||
t("服务处于 active") if running else t("未确认 sing-box 服务运行;新系统尚未安装属于正常情况"),
|
||||
f"active: {active.output.strip() or t(active.reason)}\nenabled: {enabled.output.strip() or t(enabled.reason)}"))
|
||||
startup = enabled.output.strip()
|
||||
startup_ok = enabled.code == 0 and startup == "enabled"
|
||||
startup_warn = startup in {"disabled", "masked", "enabled-runtime", "masked-runtime"}
|
||||
add(Result("service_startup", "pass" if startup_ok else "warn" if startup_warn else "unknown",
|
||||
t("sing-box 开机启动"), t("已配置持久开机启动;尚未实际重启验证") if startup_ok else
|
||||
t("sing-box 开机启动"), t("已配置持久开机启动") if startup_ok else
|
||||
t("未配置持久开机启动或服务被屏蔽,重启后可能不可用") if startup_warn else
|
||||
t("无法确认 sing-box 的持久开机启动状态"), startup or t(enabled.reason)))
|
||||
return report(results, port, lang)
|
||||
|
||||
|
||||
def report(results, port, lang="zh"):
|
||||
return {"schema_version": 1, "language": lang, "tool_version": __version__,
|
||||
document={"schema_version": 1, "language": lang, "tool_version": __version__,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(), "mode": "read-only",
|
||||
"target_port": port, "checks": [asdict(result) for result in results]}
|
||||
from .schema import normalize_report
|
||||
normalize_report(document)
|
||||
return document
|
||||
+253
-52
@@ -7,12 +7,12 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from . import __version__, release_label
|
||||
from .checks import inspect
|
||||
from .i18n import translator
|
||||
from .quality import run_quality
|
||||
from .suite import run_suite
|
||||
from .bandwidth import SOURCES
|
||||
from .bandwidth import SOURCES, CHINA_SOURCES
|
||||
|
||||
|
||||
LABELS = {"pass": "通过", "warn": "注意", "unknown": "未知", "skip": "跳过", "fail": "失败"}
|
||||
@@ -54,7 +54,7 @@ class LocalizedParser(argparse.ArgumentParser):
|
||||
self.exit(2, translator(self.lang)("参数错误:{message}", message=message) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
def _main():
|
||||
# Support Windows consoles that cannot encode Chinese.
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
@@ -64,9 +64,17 @@ def main():
|
||||
language_parser.add_argument("--lang", choices=("zh", "en"), default="zh")
|
||||
language_args, remaining = language_parser.parse_known_args()
|
||||
lang = language_args.lang
|
||||
# Auto-degrade to English when the console cannot render CJK glyphs
|
||||
# (e.g. Linux framebuffer tty1) unless the user explicitly requested zh.
|
||||
if lang == 'zh':
|
||||
from .console import can_render_cjk
|
||||
explicit_language = any(arg == '--lang' or arg.startswith('--lang=') for arg in sys.argv[1:])
|
||||
if not can_render_cjk() and not explicit_language:
|
||||
lang = 'en'
|
||||
t = translator(lang)
|
||||
parser = LocalizedParser(lang=lang, description=("ProxyDoctor 只读体检:check 默认离线;suite 会联网" if lang=='zh' else 'ProxyDoctor read-only diagnostics: check defaults offline; suite contacts external services'))
|
||||
parser.add_argument("--version", action="version", version=__version__, help=t("显示版本并退出"))
|
||||
st = lambda zh, en: zh if lang == 'zh' else en
|
||||
parser = LocalizedParser(lang=lang, description=("VPS 检测工具(只读体检):check 默认离线;suite 会联网" if lang=='zh' else 'VPS Check (read-only diagnostics): check defaults offline; suite contacts external services'))
|
||||
parser.add_argument("--version", action="version", version=__version__ + ' · ' + release_label(lang), help=t("显示版本并退出"))
|
||||
parser.add_argument("--lang", choices=("zh", "en"), default=lang, help=t("界面和报告语言:zh 中文,en 英文(默认 zh)"))
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
check = sub.add_parser("check", lang=lang, help=t("读取系统信息并生成体检报告"))
|
||||
@@ -78,17 +86,27 @@ def main():
|
||||
check.add_argument("--json", action="store_true", dest="as_json", help=t("输出 JSON(默认隐藏 IP)"))
|
||||
check.add_argument("--show-ip", action="store_true", help=t("仅在终端显示完整 IP;保存的报告仍脱敏"))
|
||||
check.add_argument("--output", type=Path, help=t("另存为脱敏 JSON;已存在的文件不会覆盖"))
|
||||
check.add_argument("--color", choices=("auto", "always", "never"), default="auto", help=st("彩色输出:auto/always/never", "Colour output: auto/always/never"))
|
||||
check.add_argument("--theme", choices=("default", "iprisk", "framed"), default="default", help=st("终端版式:default/iprisk/framed", "Terminal theme: default/iprisk/framed"))
|
||||
node = sub.add_parser("nodequality", lang=lang, help=t("运行本地 NodeQuality 脚本并实时显示输出"))
|
||||
node.add_argument("--lang", choices=("zh", "en"), default=lang, help=t("界面和报告语言:zh 中文,en 英文(默认 zh)"))
|
||||
node.add_argument("--script", type=Path, required=True, help=t("已下载并检查的 NodeQuality 脚本路径"))
|
||||
quality = sub.add_parser('quality', lang=lang, help=t('联网检测 DNS 和 TCP 连接并实时显示结果'))
|
||||
quality.add_argument('--lang', choices=('zh','en'), default=lang, help=t('界面和报告语言:zh 中文,en 英文(默认 zh)'))
|
||||
st=lambda zh,en: zh if lang=='zh' else en
|
||||
quality.add_argument('--color', choices=('auto','always','never'), default='auto', help=st('彩色输出:auto/always/never','Colour output: auto/always/never'))
|
||||
export = sub.add_parser('report',lang=lang,help=st('将本地 JSON 导出为脱敏 Markdown','Export local JSON as redacted Markdown'))
|
||||
export.add_argument('--input',type=Path,required=True)
|
||||
export.add_argument('--output',type=Path,required=True)
|
||||
recover = sub.add_parser('recover',lang=lang,help=st('从恢复日志导出最后完整结果','Export the last complete recovery snapshot'))
|
||||
recover.add_argument('--journal',type=Path,required=True)
|
||||
recover.add_argument('--output',type=Path,required=True)
|
||||
recover.add_argument('--format',choices=('json','markdown'),default='json')
|
||||
ipq = sub.add_parser('ipquality',lang=lang,help=st('只查询 IP 归属与风险,按来源保留证据','Query IP ownership and provider-specific risk evidence'))
|
||||
ipq.add_argument('--lang',choices=('zh','en'),default=lang)
|
||||
ipq.add_argument('--color',choices=('auto','always','never'),default='auto')
|
||||
ipq.add_argument('--show-ip',action='store_true',help=st('终端显示出口 IP;文件仍脱敏','Show egress IP in terminal; exports stay redacted'))
|
||||
ipq.add_argument('--no-enrichment',action='store_true',help=st('不查询额外免密钥来源','Skip additional keyless sources'))
|
||||
ipq.add_argument('--mail-probes',action='store_true',help=st('额外查询PTR并测试单目标出站TCP/25;不发送邮件','Query PTR and one outbound TCP/25 target; sends no email'))
|
||||
ipq.add_argument('--json-output',type=Path)
|
||||
bandwidth = sub.add_parser('bandwidth',lang=lang,help=st('仅运行多来源多轮测速','Run only repeated multi-source bandwidth tests'))
|
||||
bandwidth.add_argument('--lang',choices=('zh','en'),default=lang)
|
||||
@@ -96,6 +114,8 @@ def main():
|
||||
bandwidth.add_argument('--sample-mib',type=int,choices=range(1,21),default=4,metavar='1..20')
|
||||
bandwidth.add_argument('--rounds','--bandwidth-rounds',type=int,choices=range(3,9),default=3,metavar='3..8')
|
||||
bandwidth.add_argument('--source','--bandwidth-source',choices=tuple(SOURCES),action='append',help=st('可重复指定;默认 Cloudflare + OVHcloud','Repeat to select; default Cloudflare + OVHcloud'))
|
||||
bandwidth.add_argument('--allow-bandwidth',action='store_true',help=st('明确允许消耗流量和带宽;请求载荷预算将在执行前显示','Consent to traffic and bandwidth use; payload budget is printed before execution'))
|
||||
bandwidth.add_argument('--china',action='store_true',help=st('选择大陆三运营商候选;缺失来源如实显示','Select three mainland-carrier candidates; missing sources stay unknown'))
|
||||
bandwidth.add_argument('--json-output',type=Path,help=st('保存脱敏检测依据,不覆盖文件','Save redacted evidence; never overwrite'))
|
||||
media = sub.add_parser('media',lang=lang,help=st('仅检测流媒体,不重复测速','Check media without repeating bandwidth tests'))
|
||||
media.add_argument('--lang',choices=('zh','en'),default=lang)
|
||||
@@ -103,10 +123,12 @@ def main():
|
||||
media.add_argument('--json-output',type=Path,help=st('保存脱敏检测依据,不覆盖文件','Save redacted evidence; never overwrite'))
|
||||
suite = sub.add_parser('suite',lang=lang,help=st('VPS / 网络质量 / 路由 / 流媒体','VPS / network / routes / streaming'))
|
||||
suite.add_argument('--lang',choices=('zh','en'),default=lang)
|
||||
suite.add_argument('--allow-bandwidth',action='store_true',help=st('明确允许带宽测速(默认跳过)','Consent to bandwidth tests (skipped by default)'))
|
||||
suite.add_argument('--offline',action='store_true',help=st('跳过联网检测','Skip outbound requests'))
|
||||
suite.add_argument('--port',type=int,default=8388,help=st('体检目标端口,默认 8388','Target port to inspect (default 8388)'))
|
||||
suite.add_argument('--show-ip',action='store_true',help=st('终端显示出口 IP;文件仍脱敏','Show egress IP in terminal; exports stay redacted'))
|
||||
suite.add_argument('--no-ip-enrichment',action='store_true',help=st('不查询额外 IP 风险来源','Skip additional IP risk sources'))
|
||||
suite.add_argument('--mail-probes',action='store_true',help=st('额外执行PTR与单目标出站TCP/25检查','Include PTR and one outbound TCP/25 check'))
|
||||
suite.add_argument('--sample-mib',type=int,choices=range(1,21),default=4,metavar='1..20',help=st('每轮每方向 MiB,默认 4','MiB per round per direction (default 4)'))
|
||||
suite.add_argument('--bandwidth-rounds',type=int,choices=range(3,9),default=3,metavar='3..8',help=st('每方向测速轮数,默认 3','Measured rounds per direction (default 3)'))
|
||||
suite.add_argument('--bandwidth-source',choices=tuple(SOURCES),action='append',help=st('可重复指定;默认 Cloudflare + OVHcloud','Repeat to select; default Cloudflare + OVHcloud'))
|
||||
@@ -116,52 +138,131 @@ def main():
|
||||
suite.add_argument('--besttrace',type=Path,help=st('本地 BestTrace 可执行文件','Local BestTrace executable'))
|
||||
suite.add_argument('--region-script',type=Path,help=st('已审阅的本地 RegionRestrictionCheck 脚本','Reviewed local RegionRestrictionCheck script'))
|
||||
suite.add_argument('--max-hops',type=int,choices=range(1,65),default=30,metavar='1..64')
|
||||
suite.add_argument('--route-probes',type=int,choices=range(1,4),default=3,help=st('路由重复探测轮数1..3;共享90秒预算','Repeat route probes 1..3 times; shared 90s budget'))
|
||||
suite.add_argument('--no-geo',action='store_true',help=st('不查询路由 IP 位置','Skip route IP geolocation lookups'))
|
||||
suite.add_argument('--color',choices=('auto','always','never'),default='auto')
|
||||
suite.add_argument('--theme', choices=('default','iprisk','framed'), default='default', help=st('终端版式:default/iprisk/framed','Terminal theme: default/iprisk/framed'))
|
||||
suite.add_argument('--route-method',choices=('udp','icmp','tcp'),default='udp')
|
||||
suite.add_argument('--tcp-port',type=int,default=443)
|
||||
suite.add_argument('--china-speed',action='store_true',help=st('启用大陆三运营商候选测速;仍需 --allow-bandwidth','Select mainland carrier speed candidates; requires --allow-bandwidth'))
|
||||
suite.add_argument('--include-return-routes',action='store_true',help=st('额外运行三城三运营商方向追踪','Add three-city × three-carrier outbound traces'))
|
||||
suite.add_argument('--include-bgp',action='store_true',help=st('查询出口 IP 的 RIPE RIS BGP 观测','Query RIPE RIS BGP observations for egress IP'))
|
||||
for name,description in (('trace',st('单目标 UDP/ICMP/TCP 路由','Single-target UDP/ICMP/TCP trace')),
|
||||
('return-route',st('三城三运营商逐跳追踪','Three cities × three carriers traces')),
|
||||
('bgp',st('查询前缀、起源 ASN、邻居及 RIS AS_PATH','Query prefix, origin ASN, neighbours and RIS AS_PATH')),
|
||||
('cn-nodes',st('小流量探测候选测速节点','Probe candidate speed nodes with small samples'))):
|
||||
command=sub.add_parser(name,lang=lang,help=description)
|
||||
command.add_argument('--color',choices=('auto','always','never'),default='auto')
|
||||
command.add_argument('--json-output',type=Path)
|
||||
if name in ('trace','bgp'):command.add_argument('--target',required=True)
|
||||
if name in ('trace','return-route'):
|
||||
command.add_argument('--method',choices=('udp','icmp','tcp'),default='udp')
|
||||
command.add_argument('--tcp-port',type=int,default=443)
|
||||
command.add_argument('--max-hops',type=int,choices=range(1,65),default=30)
|
||||
command.add_argument('--geo',action='store_true',help=st('额外查询逐跳估计位置','Query estimated per-hop location'))
|
||||
if name=='return-route':command.add_argument('--family',type=int,choices=(4,6),default=4)
|
||||
args = parser.parse_args(remaining)
|
||||
if args.command in ('trace','return-route','bgp','cn-nodes'):
|
||||
from .console import Console
|
||||
from .session import ScanSession
|
||||
out=Console(lang,args.color)
|
||||
out.line(release_label(lang),'unknown')
|
||||
if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()):
|
||||
parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing'))
|
||||
if hasattr(args,'tcp_port') and not 1<=args.tcp_port<=65535:parser.error(t('端口必须在 1~65535 之间'))
|
||||
if hasattr(args,'target'):
|
||||
try:target=ipaddress.ip_address(args.target)
|
||||
except ValueError:parser.error(st('目标必须是数字 IP 地址','Target must be a numeric IP address'))
|
||||
if args.command=='bgp' and not target.is_global:parser.error(st('BGP 需要公网 IP','BGP requires a public IP'))
|
||||
key={'trace':'route','return-route':'return_routes','bgp':'bgp','cn-nodes':'carrier_nodes'}[args.command]
|
||||
with ScanSession(key,args.json_output,lang) as scan:
|
||||
data=scan.report[key]
|
||||
if args.command=='trace':
|
||||
from .routes import trace_method,summarize_hops
|
||||
data.update(target=str(target),method=args.method,hops=[])
|
||||
code=trace_method(str(target),lambda line,**kw:out.line(line),method=args.method,tcp_port=args.tcp_port,
|
||||
hops=args.max_hops,geo=args.geo,lang=lang,records=data['hops'],on_result=scan.checkpoint)
|
||||
data.update(state='reached' if code==0 else 'incomplete',statistics=summarize_hops(data['hops']))
|
||||
elif args.command=='return-route':
|
||||
from .carriers import return_routes
|
||||
out.line(st('方向:本机 → 中国目标;不包含中国侧到本机的探测','Direction: this host → China targets; reverse probes not included'),'unknown')
|
||||
return_routes(method=args.method,family=args.family,hops=args.max_hops,geo=args.geo,tcp_port=args.tcp_port,
|
||||
destination=data,on_result=scan.checkpoint,out=out)
|
||||
code=0 if data['status']=='success' else 1
|
||||
elif args.command=='bgp':
|
||||
from .bgp import collect,render
|
||||
collect(str(target),data,scan.checkpoint);render(data,out)
|
||||
code=0 if data['status']=='success' else 1
|
||||
else:
|
||||
from .carriers import collect_nodes
|
||||
collect_nodes(data,scan.checkpoint,out)
|
||||
code=0 if data['status']=='success' else 1
|
||||
out.row(st('大陆三网覆盖','Mainland carrier coverage'),json.dumps(data['coverage']),'ok' if code==0 else 'unknown')
|
||||
if code:scan.report['result']='incomplete'
|
||||
sys.exit(1 if scan.failed else code)
|
||||
if args.command in ('report','recover'):
|
||||
from .reporting import load_report, export_report
|
||||
from .reports import recover_latest
|
||||
try:
|
||||
if args.command == 'report':
|
||||
document = load_report(args.input)
|
||||
format = 'markdown'
|
||||
else:
|
||||
document = recover_latest(args.journal)
|
||||
if document is None:
|
||||
raise ValueError('No complete recovery event')
|
||||
document['recovery'] = {'kind':'last_complete_journal_snapshot','new_measurement':False}
|
||||
if document.get('result') in (None,'running'):
|
||||
document['result'] = 'recovered_partial'
|
||||
format = args.format
|
||||
export_report(document,args.output,format=format,lang=lang)
|
||||
except (OSError,ValueError,TypeError,AttributeError,RecursionError):
|
||||
parser.error(st('导出失败:检查输入格式、读取上限与路径;输出文件必须不存在','Export failed: check input format, read limits and paths; output must not exist'))
|
||||
print(st('已保存脱敏报告;分享前请人工检查:','Redacted report saved; review before sharing: ')+str(args.output))
|
||||
sys.exit(0)
|
||||
if args.command == 'ipquality':
|
||||
from .ipquality import collect,render
|
||||
from .console import Console
|
||||
if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()):
|
||||
parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing'))
|
||||
result=collect(enrich=not args.no_enrichment)
|
||||
render(result,Console(lang,args.color),args.show_ip)
|
||||
print(st('第三方记录可能缺失或不一致;不同评分不可合并;无举报不保证安全。',
|
||||
'Provider records may be missing or disagree; scores are not interchangeable; zero reports do not guarantee safety.'),flush=True)
|
||||
if args.json_output:
|
||||
try:
|
||||
with args.json_output.open('x',encoding='utf-8') as stream:
|
||||
json.dump(redact({'tool_version':__version__,'ip_quality':result},lang),stream,ensure_ascii=False,indent=2)
|
||||
except OSError: parser.error(st('无法保存报告','Unable to save report'))
|
||||
sys.exit(0 if result['coverage']['complete'] else 1)
|
||||
from .session import ScanSession
|
||||
with ScanSession('ip_quality',args.json_output,lang) as scan:
|
||||
result=collect(enrich=not args.no_enrichment,mail=args.mail_probes,
|
||||
destination=scan.report['ip_quality'],on_result=scan.checkpoint)
|
||||
render(result,Console(lang,args.color),args.show_ip)
|
||||
incomplete=not result['coverage']['complete']
|
||||
if incomplete:scan.report['result']='incomplete'
|
||||
sys.exit(1 if incomplete or scan.failed else 0)
|
||||
if args.command == 'bandwidth':
|
||||
if not args.allow_bandwidth:
|
||||
parser.error(st('测速消耗流量和带宽;请添加 --allow-bandwidth 明确允许','Bandwidth tests use traffic and capacity; add --allow-bandwidth to consent'))
|
||||
from .bandwidth import run_console, is_complete
|
||||
if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()):
|
||||
parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing'))
|
||||
result=run_console(lang,args.color,args.rounds,args.sample_mib,args.source)
|
||||
print(st('说明:不同来源分别统计;只支持下载的来源不测上传;HTTPS 吞吐包含建连,不能认定线路容量。',
|
||||
'Notes: sources are summarized separately; download-only sources do not upload. HTTPS throughput includes setup and does not establish line capacity.'),flush=True)
|
||||
if args.json_output:
|
||||
try:
|
||||
with args.json_output.open('x',encoding='utf-8') as stream:
|
||||
json.dump(redact({'tool_version':__version__,'bandwidth':result},lang),stream,ensure_ascii=False,indent=2)
|
||||
except OSError:
|
||||
parser.error(st('无法保存报告','Unable to save report'))
|
||||
sys.exit(0 if is_complete(result) else 1)
|
||||
from .session import ScanSession
|
||||
with ScanSession('bandwidth',args.json_output,lang) as scan:
|
||||
result=run_console(lang,args.color,args.rounds,args.sample_mib,args.source or (CHINA_SOURCES if args.china else None),
|
||||
destination=scan.report['bandwidth'],on_result=scan.checkpoint)
|
||||
incomplete=not is_complete(result)
|
||||
if incomplete:scan.report['result']='incomplete'
|
||||
sys.exit(1 if incomplete or scan.failed else 0)
|
||||
if args.command == 'media':
|
||||
from .media import run_console
|
||||
if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()):
|
||||
parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing'))
|
||||
print(st('流媒体地区检测 · 未登录,不验证账号播放','Media region checks · Anonymous; account playback not tested'),flush=True)
|
||||
results=run_console(lang,args.color)
|
||||
if args.json_output:
|
||||
try:
|
||||
with args.json_output.open('x',encoding='utf-8') as stream:
|
||||
json.dump(redact({'tool_version':__version__,'media':results},lang),stream,ensure_ascii=False,indent=2)
|
||||
except OSError:
|
||||
parser.error(st('无法保存报告','Unable to save report'))
|
||||
sys.exit(1) # Playback entitlement remains untested.
|
||||
print(st('流媒体可达性检测 · 不验证解锁或播放','Media reachability · Unlock and playback not tested'),flush=True)
|
||||
from .session import ScanSession
|
||||
with ScanSession('media',args.json_output,lang) as scan:
|
||||
run_console(lang,args.color,results=scan.report['media'],on_result=scan.checkpoint)
|
||||
incomplete=any(item.get('state')!='reachable' for item in scan.report['media'].values())
|
||||
if incomplete:scan.report['result']='incomplete'
|
||||
sys.exit(1 if incomplete or scan.failed else 0)
|
||||
if args.command == 'suite':
|
||||
if not 1<=args.tcp_port<=65535:parser.error(t('端口必须在 1~65535 之间'))
|
||||
if args.china_speed and not args.allow_bandwidth:
|
||||
parser.error(st('--china-speed 需要 --allow-bandwidth','--china-speed requires --allow-bandwidth'))
|
||||
if args.besttrace or args.region_script:
|
||||
parser.error(st('安全流程暂停第三方程序调用,请使用原生检测','External programs are disabled in the safe workflow; use native checks'))
|
||||
if not 1<=args.port<=65535: parser.error(t('端口必须在 1~65535 之间'))
|
||||
if args.json_output and args.json_output.exists():
|
||||
parser.error(st('输出文件已存在,请换一个文件名','Output already exists; choose another --json-output path'))
|
||||
@@ -176,13 +277,20 @@ def main():
|
||||
route_target=args.route_target,besttrace=args.besttrace,region_script=args.region_script,
|
||||
max_hops=args.max_hops,geo=not args.no_geo,color=args.color,
|
||||
bandwidth_rounds=args.bandwidth_rounds,skip_route=args.skip_route,json_output=args.json_output,
|
||||
bandwidth_sources=args.bandwidth_source,port=args.port,show_ip=args.show_ip,ip_enrichment=not args.no_ip_enrichment)
|
||||
bandwidth_sources=args.bandwidth_source or (CHINA_SOURCES if args.china_speed else None),port=args.port,show_ip=args.show_ip,ip_enrichment=not args.no_ip_enrichment,allow_bandwidth=args.allow_bandwidth,mail_probes=args.mail_probes,route_probes=args.route_probes,theme=args.theme,
|
||||
route_method=args.route_method,tcp_port=args.tcp_port,include_return_routes=args.include_return_routes,include_bgp=args.include_bgp)
|
||||
except OSError:
|
||||
parser.error(st('无法保存报告,请检查输出目录和文件权限','Unable to save report; check the output directory and file permissions'))
|
||||
sys.exit(code)
|
||||
if args.command == 'quality':
|
||||
sys.exit(run_quality(t))
|
||||
from .connectivity import run_console as run_connectivity
|
||||
from .session import ScanSession
|
||||
with ScanSession('network',None,lang) as scan:
|
||||
code=run_connectivity(lang=lang,color=args.color,report=scan.report['network'],on_result=scan.checkpoint)
|
||||
if code:scan.report['result']='incomplete'
|
||||
sys.exit(1 if scan.failed else code)
|
||||
if args.command == 'nodequality':
|
||||
parser.error(st('该第三方流程可能上传报告,当前安全版本已禁用;请使用 suite','This third-party workflow may upload reports and is disabled; use suite'))
|
||||
if not args.script.is_file():
|
||||
parser.error(t("找不到本地脚本文件"))
|
||||
bash = shutil.which('bash')
|
||||
@@ -202,6 +310,18 @@ def main():
|
||||
except ValueError:
|
||||
parser.error(t("--public-ip 必须是有效 IPv4 或 IPv6 地址"))
|
||||
raw_data = inspect(port=args.port, public_ip=args.public_ip, lang=lang, detect_public_ip=args.detect_public_ip)
|
||||
from .resources import collect as collect_resources, render as render_resources
|
||||
try:
|
||||
raw_data['resources'] = collect_resources()
|
||||
except Exception:
|
||||
raw_data['resources'] = {'status': 'unknown', 'error_code': 'RESOURCE_MODULE_ERROR'}
|
||||
from .security import collect as collect_security, render as render_security
|
||||
try:
|
||||
raw_data['security'] = collect_security()
|
||||
except Exception:
|
||||
raw_data['security'] = {'status':'unknown','error_code':'SECURITY_MODULE_ERROR'}
|
||||
from .schema import normalize_report
|
||||
normalize_report(raw_data)
|
||||
safe_data = redact(raw_data, lang)
|
||||
data = raw_data if args.show_ip else safe_data
|
||||
serialized = json.dumps(safe_data, ensure_ascii=False, indent=2)
|
||||
@@ -214,24 +334,105 @@ def main():
|
||||
if args.as_json:
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(f"ProxyDoctor {__version__} · " + t("只读体检 · 已请求 ipify 查询出口 IP" if args.detect_public_ip else "只读体检 · 默认不请求外部网络") + "\n")
|
||||
from .console import Console, can_render_cjk
|
||||
console = Console(lang, getattr(args, 'color', 'auto'))
|
||||
theme = getattr(args, 'theme', 'default')
|
||||
use_theme = theme in ('iprisk', 'framed')
|
||||
separator = ": " if lang == "en" else ":"
|
||||
info_ids = {'system', 'architecture', 'kernel', 'cpu', 'memory_total', 'memory', 'disk_capacity', 'disk', 'interfaces', 'public_ip'}
|
||||
for heading, is_info in ((t('服务器信息'), True), (t('体检结果'), False)):
|
||||
print(f"{heading}:")
|
||||
for item in data["checks"]:
|
||||
if (item['id'] in info_ids) != is_info:
|
||||
continue
|
||||
print(f"[{t(LABELS[item['status']])}] {item['title']}{separator}{item['detail']}")
|
||||
for line in item["evidence"].splitlines():
|
||||
print(" " + t("依据") + separator + line)
|
||||
if item["advice"]:
|
||||
print(" " + t("建议") + separator + item['advice'])
|
||||
print()
|
||||
status_color = {'pass': 'ok', 'warn': 'bad', 'fail': 'bad', 'unknown': 'unknown', 'skip': 'unknown'}
|
||||
if theme == 'framed':
|
||||
console.frame_open(st('VPS 检测报告','VPS check report'), box_w=78, subtitle=release_label(lang) + ' · ' + __version__)
|
||||
# header key/values
|
||||
from datetime import datetime
|
||||
console.frame_keyvalue(st('扫描时间','Scan time'), datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'))
|
||||
console.frame_keyvalue(st('语言','Language'), st('中文','English'))
|
||||
console.frame_keyvalue(st('模式','Mode'), st('只读体检','Read-only'))
|
||||
for number, (title, is_info) in enumerate(((st('服务器信息','Server information'), True), (st('体检结果','Diagnostic results'), False)), start=1):
|
||||
console.frame_section(number, title, title)
|
||||
for item in data["checks"]:
|
||||
if item['id'] in ('memory_total','memory'):
|
||||
continue
|
||||
if (item['id'] in info_ids) != is_info:
|
||||
continue
|
||||
console.frame_keyvalue(item['title'], item['detail'], status_color.get(item['status'], 'unknown'))
|
||||
for line in item["evidence"].splitlines():
|
||||
console.frame_line(st('依据','Evidence') + separator + line, 'muted')
|
||||
elif theme == 'iprisk':
|
||||
console.banner(' VPS CHECK ' + ('v' + __version__))
|
||||
console.line(release_label(lang), 'unknown')
|
||||
console.keyvalue(st('只读体检报告','Read-only diagnostic report'),
|
||||
st('已请求 ipify 查询出口 IP' if args.detect_public_ip else '默认不请求外部网络','ipify egress query' if args.detect_public_ip else 'no external requests by default'), 'title')
|
||||
console.hrule()
|
||||
for number, (title, is_info) in enumerate(((st('服务器信息','Server information'), True), (st('体检结果','Diagnostic results'), False)), start=1):
|
||||
console.theme_section(number, title, title, 'b_cyan')
|
||||
for item in data["checks"]:
|
||||
if item['id'] in ('memory_total','memory'):
|
||||
continue
|
||||
if (item['id'] in info_ids) != is_info:
|
||||
continue
|
||||
console.keyvalue(item['title'], item['detail'], status_color.get(item['status'], 'unknown'))
|
||||
for line in item["evidence"].splitlines():
|
||||
print(' ' + console.paint(st('依据','Evidence'), 'muted') + separator + line, flush=True)
|
||||
else:
|
||||
print(release_label(lang) + f" · {__version__}\n" + t("只读体检 · 已请求 ipify 查询出口 IP" if args.detect_public_ip else "只读体检 · 默认不请求外部网络") + "\n")
|
||||
for heading, is_info in ((t('服务器信息'), True), (t('体检结果'), False)):
|
||||
print(f"{heading}:")
|
||||
for item in data["checks"]:
|
||||
if item['id'] in ('memory_total','memory'):
|
||||
continue
|
||||
if (item['id'] in info_ids) != is_info:
|
||||
continue
|
||||
print(f"[{t(LABELS[item['status']])}] {item['title']}{separator}{item['detail']}")
|
||||
for line in item["evidence"].splitlines():
|
||||
print(" " + t("依据") + separator + line)
|
||||
print()
|
||||
counts = {t(label): sum(item["status"] == status for item in data["checks"])
|
||||
for status, label in LABELS.items()}
|
||||
print("\n" + " / ".join(f"{key} {value}" for key, value in counts.items()))
|
||||
print(t("终端正在显示完整 IP;保存的报告仍脱敏,分享前请检查内容。" if args.show_ip else "报告默认隐藏 IP;分享前请检查内容。通过某一项不代表整体安全或代理可用。"))
|
||||
if data['resources']['status'] in ('partial','unsupported'):
|
||||
if console._framed:
|
||||
console.frame_section(3, '资源限制', 'Resource limits')
|
||||
render_resources(data['resources'], console)
|
||||
else:
|
||||
if console._framed:
|
||||
console.frame_line('RESOURCE_MODULE_ERROR', 'bad')
|
||||
else:
|
||||
print('RESOURCE_MODULE_ERROR')
|
||||
if data['security']['status'] in ('partial','unsupported'):
|
||||
if console._framed:
|
||||
console.frame_section(4, '安全与权限', 'Security & permissions')
|
||||
render_security(data['security'], console)
|
||||
else:
|
||||
if console._framed:
|
||||
console.frame_line('SECURITY_MODULE_ERROR', 'bad')
|
||||
else:
|
||||
print('SECURITY_MODULE_ERROR')
|
||||
if console._framed:
|
||||
summary = " / ".join(f"{key} {value}" for key, value in counts.items())
|
||||
console.frame_section(5, st('汇总','Summary'), 'Summary')
|
||||
summary_status = 'bad' if any(row['status'] in ('warn','fail') for row in data['checks']) or data['security'].get('review_count') else 'unknown' if any(row['status']=='unknown' for row in data['checks']) else 'ok'
|
||||
console.frame_line(summary, summary_status)
|
||||
console.frame_line(st('终端完整 IP;文件脱敏。','Full terminal IP; exports redacted.') if args.show_ip else t('报告默认隐藏 IP。'), 'muted')
|
||||
console.frame_close()
|
||||
else:
|
||||
print("\n" + " / ".join(f"{key} {value}" for key, value in counts.items()))
|
||||
print(st('终端完整 IP;文件脱敏。','Full terminal IP; exports redacted.') if args.show_ip else t('报告默认隐藏 IP。'))
|
||||
# Stable automation contract: 1 = findings, 2 = invalid usage or export error.
|
||||
return_code = 1 if any(item["status"] in {"warn", "fail"} for item in data["checks"]) else 0
|
||||
return_code = 1 if any(item["status"] in {"warn", "fail"} for item in data["checks"]) or data['security'].get('review_count',0) else 0
|
||||
sys.exit(return_code)
|
||||
|
||||
|
||||
def main():
|
||||
import os
|
||||
from .worker import reset,cancel_all
|
||||
previous=os.environ.get('PROXYDOCTOR_NETWORK_WORKERS')
|
||||
os.environ['PROXYDOCTOR_NETWORK_WORKERS']='1'
|
||||
reset()
|
||||
try:
|
||||
return _main()
|
||||
except KeyboardInterrupt:
|
||||
cancel_all()
|
||||
raise
|
||||
finally:
|
||||
if previous is None:os.environ.pop('PROXYDOCTOR_NETWORK_WORKERS',None)
|
||||
else:os.environ['PROXYDOCTOR_NETWORK_WORKERS']=previous
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Family-specific bounded DNS/TCP/TLS observations, without HTTP content."""
|
||||
import errno
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .reports import utc_now
|
||||
|
||||
TARGETS = (
|
||||
('Cloudflare','one.one.one.one','1.1.1.1'),
|
||||
('Cloudflare','one.one.one.one','2606:4700:4700::1111'),
|
||||
('Google DNS','dns.google','8.8.8.8'),
|
||||
('Google DNS','dns.google','2001:4860:4860::8888'),
|
||||
)
|
||||
RESOLVER = '''import json,socket,sys
|
||||
family=int(sys.argv[1])
|
||||
try:
|
||||
answers=socket.getaddrinfo('example.com',443,family=family,type=socket.SOCK_STREAM)
|
||||
print(json.dumps({'addresses':sorted(set(item[4][0] for item in answers))}))
|
||||
except socket.gaierror as error:
|
||||
print(json.dumps({'error':error.errno}))
|
||||
'''
|
||||
|
||||
|
||||
def dns_probe(version, timeout=2):
|
||||
if version not in (4,6) or not 0 < timeout <= 5:
|
||||
raise ValueError('Invalid DNS probe limits')
|
||||
family = socket.AF_INET if version == 4 else socket.AF_INET6
|
||||
row = {'family': version, 'target': 'example.com', 'method': 'system_getaddrinfo',
|
||||
'observed_at': utc_now(), 'status': 'unknown', 'error_code': None}
|
||||
start = time.monotonic()
|
||||
try:
|
||||
process = subprocess.run([sys.executable,'-I','-B','-c',RESOLVER,str(int(family))],
|
||||
capture_output=True, text=True, timeout=timeout, check=False)
|
||||
if process.returncode or len(process.stdout) > 16384:
|
||||
row['error_code'] = 'RESOLVER_PROCESS_ERROR'
|
||||
else:
|
||||
payload = json.loads(process.stdout)
|
||||
code = payload.get('error')
|
||||
if code is not None:
|
||||
row['error_code'] = ('DNS_TEMPORARY_ERROR' if code == socket.EAI_AGAIN else
|
||||
'DNS_NO_ADDRESS' if code == socket.EAI_NONAME else 'DNS_RESOLUTION_ERROR')
|
||||
else:
|
||||
addresses = payload.get('addresses')
|
||||
if not isinstance(addresses,list) or not addresses or any(ipaddress.ip_address(value).version != version for value in addresses):
|
||||
row['error_code'] = 'DNS_INVALID_RESPONSE'
|
||||
else:
|
||||
row.update(status='success',addresses=addresses)
|
||||
except subprocess.TimeoutExpired:
|
||||
row['error_code'] = 'DNS_TIMEOUT'
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
row['error_code'] = 'DNS_PROBE_ERROR'
|
||||
row['elapsed_ms'] = round((time.monotonic()-start)*1000,3)
|
||||
row['timing_scope'] = 'resolver_process_including_startup_and_cache'
|
||||
return row
|
||||
|
||||
|
||||
def transport_probe(label, hostname, address, timeout=4):
|
||||
ip = ipaddress.ip_address(address)
|
||||
if not 0 < timeout <= 10:
|
||||
raise ValueError('Invalid transport timeout')
|
||||
row = {'target': label, 'hostname': hostname, 'address': address, 'family': ip.version,
|
||||
'port': 443, 'observed_at': utc_now(), 'method': 'numeric_tcp_then_verified_tls',
|
||||
'tcp': {'status':'unknown'}, 'tls': {'status':'skipped','error_code':'TCP_NOT_CONNECTED'}}
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
stage = 'tcp'
|
||||
try:
|
||||
# Numeric addresses only: DNS cannot contaminate this TCP observation.
|
||||
with socket.create_connection((address,443),timeout=timeout) as raw:
|
||||
row['tcp'] = {'status':'success','elapsed_ms':round((time.monotonic()-started)*1000,3)}
|
||||
stage = 'tls'
|
||||
context = ssl.create_default_context()
|
||||
remaining = deadline-time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
raw.settimeout(remaining)
|
||||
tls_start = time.monotonic()
|
||||
with context.wrap_socket(raw,server_hostname=hostname) as secure:
|
||||
row['tls'] = {'status':'success','elapsed_ms':round((time.monotonic()-tls_start)*1000,3),
|
||||
'version': secure.version(), 'certificate_verified': True}
|
||||
except ssl.SSLCertVerificationError:
|
||||
row['tls'] = {'status':'unknown','error_code':'TLS_CERTIFICATE_ERROR'}
|
||||
except (TimeoutError, socket.timeout):
|
||||
row[stage] = {'status':'unknown','error_code':stage.upper()+'_TIMEOUT'}
|
||||
except ssl.SSLError:
|
||||
row['tls'] = {'status':'unknown','error_code':'TLS_HANDSHAKE_ERROR'}
|
||||
except OSError as error:
|
||||
reason = {errno.ENETUNREACH:'NETWORK_UNREACHABLE',errno.EHOSTUNREACH:'HOST_UNREACHABLE',
|
||||
errno.ECONNREFUSED:'CONNECTION_REFUSED',errno.EAFNOSUPPORT:'FAMILY_UNSUPPORTED'}.get(error.errno,'SOCKET_ERROR')
|
||||
row[stage] = {'status':'unknown','error_code':reason}
|
||||
row['elapsed_ms'] = round((time.monotonic()-started)*1000,3)
|
||||
return row
|
||||
|
||||
|
||||
def collect(report=None, on_result=None, *, rounds=2, dns=dns_probe, transport=transport_probe):
|
||||
if not 1 <= rounds <= 3:
|
||||
raise ValueError('DNS rounds must be 1..3')
|
||||
report = {} if report is None else report
|
||||
report.update(schema_version=1, observed_at=utc_now(), dns=[], transports=[], dns_summary={},
|
||||
limitations=['Only outbound paths to the named targets were checked.',
|
||||
'DNS timings include process startup and system resolver caching.',
|
||||
'No UDP, PMTU, inbound reachability or media playback was tested.'])
|
||||
def completed():
|
||||
if on_result is not None:
|
||||
on_result()
|
||||
for version in (4,6):
|
||||
rows = []
|
||||
for _ in range(rounds):
|
||||
try:
|
||||
row = dns(version)
|
||||
except Exception:
|
||||
row = {'family':version,'status':'unknown','error_code':'DNS_PROBE_ERROR','observed_at':utc_now()}
|
||||
rows.append(row)
|
||||
report['dns'].append(row)
|
||||
completed()
|
||||
count = sum(row['status'] == 'success' for row in rows)
|
||||
report['dns_summary'][str(version)] = {'attempted':rounds,'succeeded':count,
|
||||
'success_ratio':count/rounds}
|
||||
for label, hostname, address in TARGETS:
|
||||
try:
|
||||
row = transport(label,hostname,address)
|
||||
except Exception:
|
||||
row = {'target':label,'family':ipaddress.ip_address(address).version,
|
||||
'tcp':{'status':'unknown','error_code':'PROBE_ERROR'},'tls':{'status':'skipped'}}
|
||||
report['transports'].append(row)
|
||||
completed()
|
||||
report['diagnostics'] = []
|
||||
for version in (4,6):
|
||||
transports = [row for row in report['transports'] if row['family'] == version]
|
||||
tcp_ok = any(row['tcp']['status'] == 'success' for row in transports)
|
||||
dns_ok = report['dns_summary'][str(version)]['succeeded'] > 0
|
||||
code = ('DNS_FAILED_NUMERIC_TCP_WORKS' if tcp_ok and not dns_ok else
|
||||
'DNS_WORKS_TCP_UNCONFIRMED' if dns_ok and not tcp_ok else
|
||||
'TARGETS_PARTIALLY_VERIFIED' if tcp_ok else 'CONNECTIVITY_UNCONFIRMED')
|
||||
report['diagnostics'].append({'family':version,'code':code})
|
||||
report['status'] = 'success' if all(row['status']=='success' for row in report['dns']) and all(
|
||||
row['tcp']['status']=='success' and row['tls']['status']=='success' for row in report['transports']) else 'partial'
|
||||
completed()
|
||||
return report
|
||||
|
||||
|
||||
def render_row(row, out, kind):
|
||||
tr = out.text
|
||||
labels = {
|
||||
'DNS_TIMEOUT': ('DNS 超时','DNS timed out'),
|
||||
'DNS_NO_ADDRESS': ('未取得该协议族地址','No address for this family'),
|
||||
'DNS_TEMPORARY_ERROR': ('解析器暂时失败','Temporary resolver error'),
|
||||
'TLS_CERTIFICATE_ERROR': ('证书验证失败;检查时间及信任链','Certificate rejected; check clock and trust chain'),
|
||||
'TLS_HANDSHAKE_ERROR': ('TLS 握手未完成','TLS handshake failed'),
|
||||
'TCP_NOT_CONNECTED': ('TCP 未连接,跳过 TLS','TCP unavailable; TLS skipped'),
|
||||
'NETWORK_UNREACHABLE': ('系统报告网络不可达','System reports network unreachable'),
|
||||
'CONNECTION_REFUSED': ('连接被拒绝','Connection refused'),
|
||||
'TCP_TIMEOUT': ('TCP 超时','TCP timed out'),
|
||||
'TLS_TIMEOUT': ('TLS 超时','TLS timed out'),
|
||||
}
|
||||
def error_label(result):
|
||||
code = result.get('error_code','UNKNOWN')
|
||||
return tr(*labels[code])+f' ({code})' if code in labels else code
|
||||
family = 'IPv'+str(row['family'])
|
||||
if kind == 'dns':
|
||||
out.row(f'{family} DNS',f"{row['elapsed_ms']:g} ms" if row['status']=='success' else error_label(row),
|
||||
'ok' if row['status']=='success' else 'unknown')
|
||||
else:
|
||||
for stage in ('tcp','tls'):
|
||||
result = row[stage]
|
||||
out.row(f"{family} {row['target']} {stage.upper()}",
|
||||
f"{result['elapsed_ms']:g} ms" if result['status']=='success' else error_label(result),
|
||||
'ok' if result['status']=='success' else 'unknown')
|
||||
|
||||
|
||||
def run_console(lang='zh', color='auto', report=None, on_result=None, *, out=None):
|
||||
from .console import Console
|
||||
out = out if out is not None else Console(lang,color)
|
||||
report = {} if report is None else report
|
||||
seen_dns = seen_transport = 0
|
||||
def progress():
|
||||
nonlocal seen_dns, seen_transport
|
||||
for row in report['dns'][seen_dns:]:
|
||||
render_row(row,out,'dns')
|
||||
for row in report['transports'][seen_transport:]:
|
||||
render_row(row,out,'transport')
|
||||
seen_dns, seen_transport = len(report['dns']), len(report['transports'])
|
||||
if on_result is not None:
|
||||
on_result()
|
||||
collect(report, progress)
|
||||
for version, summary in report['dns_summary'].items():
|
||||
out.row(f'IPv{version} DNS',f"{summary['succeeded']}/{summary['attempted']} "+out.text('成功(仅本次样本)','successful (current sample only)'))
|
||||
for diagnosis in report['diagnostics']:
|
||||
code = diagnosis['code']
|
||||
note = {
|
||||
'DNS_FAILED_NUMERIC_TCP_WORKS': ('解析未成功但数字 IP 可连接;先排查解析器','DNS failed but numeric TCP worked; inspect resolver'),
|
||||
'DNS_WORKS_TCP_UNCONFIRMED': ('解析有结果,目标 TCP 未确认;检查路由及目标','DNS answered but target TCP is unconfirmed; inspect routes and targets'),
|
||||
'TARGETS_PARTIALLY_VERIFIED': ('部分目标 TCP 已确认;TLS 结果单独看','Some target TCP paths verified; inspect TLS separately'),
|
||||
'CONNECTIVITY_UNCONFIRMED': ('连接尚未确认','Connectivity unconfirmed'),
|
||||
}[code]
|
||||
out.row(f"IPv{diagnosis['family']} "+out.text('提示','Hint'),out.text(*note),'unknown')
|
||||
out.row(out.text('检测范围','Scope'),out.text('出站 DNS/TCP/TLS 检测','Outbound DNS/TCP/TLS checks'),'unknown')
|
||||
return 0 if report['status']=='success' else 1
|
||||
+223
-14
@@ -1,31 +1,240 @@
|
||||
"""Compact terminal output with optional ANSI colour."""
|
||||
"""Compact terminal output with optional ANSI colour and an iprisk-style theme."""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
# ANSI SGR codes used by the themed renderer.
|
||||
_RESET = '\033[0m'
|
||||
_CODES = {
|
||||
'ok': '32', 'bad': '31', 'warn': '33', 'unknown': '33',
|
||||
'title': '36', 'muted': '90',
|
||||
# block / badge colours for the iprisk theme
|
||||
'b_green': '32', 'b_yellow': '33', 'b_red': '31', 'b_cyan': '36',
|
||||
}
|
||||
# Reverse-video badge text is rendered with background colours.
|
||||
_BG = {'b_green': '42', 'b_yellow': '43', 'b_red': '41', 'b_cyan': '46'}
|
||||
|
||||
|
||||
def clean(value):
|
||||
return re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value)).strip()
|
||||
|
||||
|
||||
def _cwidth(ch):
|
||||
"""Display width of one character (2 for wide CJK, 0 for combining)."""
|
||||
if unicodedata.combining(ch):
|
||||
return 0
|
||||
return 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1
|
||||
|
||||
|
||||
def width(value):
|
||||
return sum(_cwidth(ch) for ch in str(value))
|
||||
|
||||
|
||||
def _pad(value, target):
|
||||
return value + ' ' * max(0, target - width(value))
|
||||
|
||||
|
||||
def wrap_columns(value, columns):
|
||||
"""Wrap without losing text or splitting a wide character across columns."""
|
||||
if columns < 2:
|
||||
raise ValueError('At least two columns are required')
|
||||
chunk = ''; used = 0
|
||||
for ch in clean(value):
|
||||
size = _cwidth(ch)
|
||||
if used + size > columns:
|
||||
yield chunk
|
||||
chunk = ''; used = 0
|
||||
chunk += ch; used += size
|
||||
yield chunk
|
||||
|
||||
|
||||
def can_render_cjk():
|
||||
"""Best-effort detection of a console that can draw CJK glyphs.
|
||||
|
||||
Returns False when we are confident the terminal cannot render Chinese
|
||||
(e.g. the Linux kernel framebuffer console tty1..tty63, which only ships
|
||||
ASCII/Latin fonts). Returns True otherwise (we are NOT certain, so this is
|
||||
a conservative "probably fine" answer).
|
||||
"""
|
||||
if sys.platform == 'win32':
|
||||
return True
|
||||
tty = ''
|
||||
try:
|
||||
tty = os.ttyname(0)
|
||||
except OSError:
|
||||
tty = ''
|
||||
if re.match(r'^/dev/(tty|vc/)\d+', tty):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Console:
|
||||
def __init__(self, lang='zh', color='auto'):
|
||||
self.lang=lang
|
||||
self.enabled=color=='always' or (color=='auto' and sys.stdout.isatty() and 'NO_COLOR' not in os.environ)
|
||||
self.lang = lang
|
||||
self.enabled = color == 'always' or (color == 'auto' and sys.stdout.isatty() and 'NO_COLOR' not in os.environ)
|
||||
self._framed = False # when True, row/keyvalue/section render inside a box
|
||||
|
||||
def text(self,zh,en): return zh if self.lang=='zh' else en
|
||||
def text(self, zh, en):
|
||||
return zh if self.lang == 'zh' else en
|
||||
|
||||
def paint(self,value,status='ok',preserve_space=False):
|
||||
value=re.sub(r'[\x00-\x1f\x7f-\x9f]',' ',str(value)) if preserve_space else clean(value)
|
||||
if not self.enabled: return value
|
||||
code={'ok':'32','bad':'31','unknown':'33','title':'36'}[status]
|
||||
# ---- basic paint ----
|
||||
def paint(self, value, status='ok', preserve_space=False):
|
||||
value = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value)) if preserve_space else clean(value)
|
||||
if not self.enabled:
|
||||
return value
|
||||
code = {'ok': '32', 'bad': '31', 'unknown': '33', 'title': '36', 'muted': '90', 'warn': '33'}[status]
|
||||
return f'\033[{code}m{value}\033[0m'
|
||||
|
||||
def row(self,label,value,status='ok'):
|
||||
label=clean(label)
|
||||
width=sum(2 if unicodedata.east_asian_width(ch) in ('W','F') else 1 for ch in label)
|
||||
print(label+' '*max(1,19-width)+self.paint(value,status),flush=True)
|
||||
def row(self, label, value, status='ok'):
|
||||
if self._framed:
|
||||
self.frame_keyvalue(label, value, status)
|
||||
return
|
||||
label = clean(label)
|
||||
w = width(label)
|
||||
print(label + ' ' * max(1, 19 - w) + self.paint(value, status), flush=True)
|
||||
|
||||
def section(self,index,zh,en):
|
||||
print('\n'+self.paint(f'===== {index}. {self.text(zh,en)} =====','title'),flush=True)
|
||||
def line(self, value='', status='ok'):
|
||||
if self._framed:
|
||||
self.frame_line(value, status)
|
||||
else:
|
||||
print(self.paint(value, status, preserve_space=True), flush=True)
|
||||
|
||||
def section(self, index, zh, en):
|
||||
if self._framed:
|
||||
self.frame_section(index, zh, en)
|
||||
return
|
||||
print('\n' + self.paint(f'===== {index}. {self.text(zh, en)} =====', 'title'), flush=True)
|
||||
|
||||
# ---- iprisk-themed primitives ----
|
||||
def block(self, value, kind='b_green', pad=None):
|
||||
"""A solid colour badge like the iprisk report blocks."""
|
||||
value = clean(value)
|
||||
pad = (pad if pad is not None else 14) - width(value)
|
||||
if pad > 0:
|
||||
value = ' ' * (pad // 2) + value + ' ' * (pad - pad // 2)
|
||||
if not self.enabled:
|
||||
return value
|
||||
return f'\033[{_BG[kind]}m\033[30m{value}\033[0m'
|
||||
|
||||
def chip(self, value, status='ok'):
|
||||
"""A small coloured status chip (绿/黄/红)."""
|
||||
value = clean(value)
|
||||
if not self.enabled:
|
||||
return f'[{value}]'
|
||||
return f'\033[{_CODES[status]}m[{value}]\033[0m'
|
||||
|
||||
def banner(self, text):
|
||||
"""A large ASCII-art title line, e.g. PROXYDOCTOR."""
|
||||
if not self.enabled:
|
||||
print(text, flush=True)
|
||||
return
|
||||
print('\033[1;36m' + text + _RESET, flush=True)
|
||||
|
||||
def hrule(self, ch='=', n=64, status='muted'):
|
||||
print(self.paint(ch * n, status), flush=True)
|
||||
|
||||
# ---- the theme's section header: 「一、基础信息」 with a colour block ----
|
||||
CN_NUM = ('一', '二', '三', '四', '五', '六', '七', '八', '九', '十',
|
||||
'十一', '十二', '十三', '十四', '十五', '十六')
|
||||
|
||||
def theme_section(self, number, zh, en, kind='b_cyan'):
|
||||
num = self.CN_NUM[number - 1] if 1 <= number <= len(self.CN_NUM) else str(number)
|
||||
if self.lang == 'zh':
|
||||
title = f'{num}、{zh}'
|
||||
else:
|
||||
title = f'{number}. {en}'
|
||||
print()
|
||||
print(self.block(title, kind, pad=width(title) + 2), flush=True)
|
||||
|
||||
def keyvalue(self, label, value, status='ok'):
|
||||
"""Aligned `label:value` line used by the theme."""
|
||||
label = clean(label)
|
||||
sep = ':' if self.lang == 'zh' else ': '
|
||||
print(' ' + self.paint(label, 'title') + sep + self.paint(value, status), flush=True)
|
||||
|
||||
def panel(self, title, rows, width=76):
|
||||
"""A compact overview; wrap by terminal columns, including CJK text."""
|
||||
if not 40 <= width <= 120:
|
||||
raise ValueError('Panel width must be 40..120')
|
||||
if self._framed:
|
||||
self.frame_line(title, 'title')
|
||||
for label, value, status in rows:
|
||||
self.frame_keyvalue(label, value, status)
|
||||
return
|
||||
|
||||
def pieces(value, limit):
|
||||
chunk = ''; used = 0
|
||||
for ch in clean(value):
|
||||
size = _cwidth(ch)
|
||||
if used + size > limit and chunk:
|
||||
yield chunk; chunk = ''; used = 0
|
||||
chunk += ch; used += size
|
||||
yield chunk
|
||||
|
||||
def line(value, status):
|
||||
value = clean(value)
|
||||
vw = sum(_cwidth(ch) for ch in value)
|
||||
print(self.paint('|', 'title') + ' ' + self.paint(value, status) + ' ' * (width - 4 - vw) + ' ' + self.paint('|', 'title'), flush=True)
|
||||
|
||||
print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True)
|
||||
for value in pieces(title, width - 4):
|
||||
line(value, 'title')
|
||||
print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True)
|
||||
for label, value, status in rows:
|
||||
text = clean(label) + ': ' + clean(value)
|
||||
for piece in pieces(text, width - 4):
|
||||
line(piece, status)
|
||||
print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True)
|
||||
|
||||
# ---- framed theme: the whole report inside one double-line box ----
|
||||
# Box glyphs: ╔ ═ ╗ ║ ╚ ╝ ╠ ╣
|
||||
# NOTE: the module-level `width()` helper is shadowed by parameter names in
|
||||
# some methods, so we keep a local alias `_cw` to the width function.
|
||||
_cw = staticmethod(width)
|
||||
|
||||
def _box(self, left, fill, right, n, status='title'):
|
||||
"""One horizontal box edge, e.g. ╔═══╗ of width n+2."""
|
||||
return self.paint(left + fill * n + right, status)
|
||||
|
||||
def frame_open(self, title, box_w=76, subtitle=None):
|
||||
"""Top edge + centered title + optional subtitle, all inside the box."""
|
||||
if not 40 <= box_w <= 118:
|
||||
raise ValueError('Frame inner width must be 40..118')
|
||||
cw = self._cw
|
||||
self._box_w = box_w
|
||||
self._framed = True
|
||||
print(self._box('╔', '═', '╗', box_w), flush=True)
|
||||
for text, status in ((title, 'title'), (subtitle, 'muted')):
|
||||
if text is None:
|
||||
continue
|
||||
for piece in wrap_columns(text, box_w):
|
||||
left = (box_w - cw(piece)) // 2
|
||||
right = box_w - cw(piece) - left
|
||||
print(self.paint('║', 'title') + ' ' * left + self.paint(piece, status, preserve_space=True) + ' ' * right + self.paint('║', 'title'), flush=True)
|
||||
print(self._box('╠', '═', '╣', box_w), flush=True)
|
||||
|
||||
def frame_line(self, text, status='ok', pad=1):
|
||||
"""A plain line inside the box (left border + text + right border)."""
|
||||
cw = self._cw
|
||||
if not 0 <= pad <= (self._box_w - 2) // 2:
|
||||
raise ValueError('Invalid frame padding')
|
||||
for piece in wrap_columns(text, self._box_w - 2 * pad):
|
||||
print(self.paint('║', 'title') + ' ' * pad + self.paint(piece, status, preserve_space=True) +
|
||||
' ' * (self._box_w - pad - cw(piece)) + self.paint('║', 'title'), flush=True)
|
||||
|
||||
def frame_keyvalue(self, label, value, status='ok'):
|
||||
"""Aligned `label:value` line inside the box."""
|
||||
sep = ':' if self.lang == 'zh' else ': '
|
||||
self.frame_line(clean(label) + sep + clean(value), status, pad=2)
|
||||
|
||||
def frame_section(self, number, zh, en):
|
||||
"""A yellow section heading inside the box, e.g. 一、基础信息."""
|
||||
num = self.CN_NUM[number - 1] if 1 <= number <= len(self.CN_NUM) else str(number)
|
||||
title = f'{num}、{zh}' if self.lang == 'zh' else f'{number}. {en}'
|
||||
self.frame_line(title, 'warn', pad=2)
|
||||
|
||||
def frame_close(self):
|
||||
"""Bottom edge of the box."""
|
||||
print(self._box('╚', '═', '╝', self._box_w), flush=True)
|
||||
self._framed = False
|
||||
@@ -17,6 +17,16 @@ class GeoLocator:
|
||||
if not self.enabled: return unknown
|
||||
if address in self.cache: return self.cache[address]
|
||||
result=unknown
|
||||
from .worker import enabled
|
||||
if enabled():
|
||||
from .net import request_json
|
||||
language='zh-CN' if self.lang=='zh' else 'en'
|
||||
data,state=request_json('https://ipwho.is/'+str(addr)+'?lang='+language)
|
||||
if state=='ok' and data.get('success') is True:
|
||||
values=list(dict.fromkeys(data[k] for k in ('country','region','city') if isinstance(data.get(k),str) and data[k]))
|
||||
if values:result=' / '.join(values)
|
||||
self.cache[address]=result
|
||||
return result
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,*args): return None
|
||||
try:
|
||||
@@ -32,6 +42,7 @@ class GeoLocator:
|
||||
if values: result=' / '.join(values)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code==429: self.enabled=False
|
||||
exc.close()
|
||||
except (OSError,ValueError,urllib.error.URLError): pass
|
||||
self.cache[address]=result
|
||||
return result
|
||||
+25
-21
@@ -1,6 +1,10 @@
|
||||
"""Shared messages; identifiers and raw command evidence remain language neutral."""
|
||||
|
||||
MESSAGES = {
|
||||
"版本符合 Ubuntu 22.04 最低要求;实机兼容性按已测试环境确认": "Version meets the Ubuntu 22.04 minimum; real system compatibility follows tested environments",
|
||||
"支持范围:Ubuntu 22.04 及更新版本。": "Target support: Ubuntu 22.04 and newer.",
|
||||
"已通过 ipify 查询出口地址;入站可达性未确认": "Egress address queried via ipify; inbound reachability unconfirmed",
|
||||
"sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同。": "sshd -T reads defaults; permissions, configuration or a missing runtime directory can prevent inspection. Running-service and Match settings may differ.",
|
||||
'联网检测 DNS 和 TCP 连接并实时显示结果': 'Run outbound DNS and TCP checks with live output',
|
||||
'网络质量检测:会向系统 DNS、Cloudflare 和 Google 发起请求。': 'Network quality checks: requests will be sent to the system DNS resolver, Cloudflare and Google.',
|
||||
'正在查询 DNS:example.com ...': 'Checking DNS: example.com ...',
|
||||
@@ -10,7 +14,7 @@ MESSAGES = {
|
||||
'正在测试 {target} TCP {port} ...': 'Testing {target} TCP {port} ...',
|
||||
'{target} TCP 连接成功:{ms} ms': '{target} TCP connection succeeded: {ms} ms',
|
||||
'{target} TCP 连接未成功;原因尚未确定。': '{target} TCP connection did not succeed; the cause is unknown.',
|
||||
'以上结果只代表本次从此服务器到这些测试目标的情况,不代表代理协议、回程路线或 IP 信誉。': 'These results describe this server’s current connection to these targets only; they do not establish proxy protocol support, return routes or IP reputation.',
|
||||
'以上结果为本次从此服务器到这些测试目标的连接情况。': 'These results describe this server’s current connection to these targets.',
|
||||
'运行本地 NodeQuality 脚本并实时显示输出': 'Run a local NodeQuality script and stream its output',
|
||||
'已下载并检查的 NodeQuality 脚本路径': 'Path to a locally downloaded and reviewed NodeQuality script',
|
||||
'找不到本地脚本文件': 'Local script file not found',
|
||||
@@ -35,20 +39,20 @@ MESSAGES = {
|
||||
"仅在终端显示完整 IP;保存的报告仍脱敏": "Show full IP addresses only in terminal output; saved reports remain redacted",
|
||||
"终端正在显示完整 IP;保存的报告仍脱敏,分享前请检查内容。": "Full IP addresses are visible in terminal output; saved reports remain redacted. Review before sharing.",
|
||||
"向 ipify 发送 HTTPS 请求查询出口 IP(第三方会看到该地址)": "Query egress IP over HTTPS from ipify (the third party will see this address)",
|
||||
"已通过 ipify 查询出口地址;未验证入站可达性或归属": "Egress address queried via ipify; inbound reachability and ownership are not verified",
|
||||
"已通过 ipify 查询出口地址": "Egress address queried via ipify",
|
||||
"出口 IP 查询失败,无法确定公网地址": "Egress IP query failed; the public address is unknown",
|
||||
"出口地址可能是 NAT 或代理地址;不是入站可达性证明。": "The egress address may belong to NAT or a proxy; it does not establish inbound reachability.",
|
||||
"只读体检 · 已请求 ipify 查询出口 IP": "Read-only checks · ipify egress IP lookup requested",
|
||||
"默认路由": "Default route",
|
||||
"路由读取失败或格式无法解析": "Route read failed or output could not be parsed",
|
||||
"已读取默认路由;不代表互联网或目标地址可达": "Default route read; Internet and destination reachability are not established",
|
||||
"已读取默认路由": "Default route read",
|
||||
"默认路由信息不完整或未找到,无法确认网络出口": "Default route information is incomplete or missing; the network exit is unconfirmed",
|
||||
"仅检查当前网络命名空间的默认路由,不覆盖策略路由。": "Only the current network namespace's default routes are checked; policy routing is not covered.",
|
||||
"DNS 配置": "DNS configuration",
|
||||
"无法读取 DNS 配置": "Unable to read DNS configuration",
|
||||
"DNS 配置不完整或没有有效的服务器地址": "DNS configuration is incomplete or has no valid server address",
|
||||
"DNS 指向本机解析器;上游配置及解析是否成功尚未验证": "DNS points to a local resolver; upstream configuration and resolution have not been verified",
|
||||
"已读取 DNS 服务器配置;尚未验证解析是否成功": "DNS server configuration read; successful resolution is not verified",
|
||||
"DNS 指向本机解析器": "DNS points to a local resolver",
|
||||
"已读取 DNS 服务器配置": "DNS server configuration read",
|
||||
"仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。": "Only resolv.conf is inspected; no DNS request is sent, and the actual resolution path may differ.",
|
||||
"SSH 端口保护": "SSH port protection",
|
||||
"sshd 默认配置端口:{ports}": "sshd default configured ports: {ports}",
|
||||
@@ -62,7 +66,7 @@ MESSAGES = {
|
||||
"SSH 输出缺少关键字段,无法完成风险判断": "SSH output is missing required fields; risk assessment is incomplete",
|
||||
"sing-box 服务处于 failed 状态,需要检查启动失败原因": "sing-box service is in the failed state; investigate the startup failure",
|
||||
"sing-box 开机启动": "sing-box startup at boot",
|
||||
"已配置持久开机启动;尚未实际重启验证": "Persistent startup at boot is enabled; an actual reboot has not been tested",
|
||||
"已配置持久开机启动": "Persistent startup at boot is enabled",
|
||||
"未配置持久开机启动或服务被屏蔽,重启后可能不可用": "Persistent startup is not enabled or the service is masked; it may be unavailable after reboot",
|
||||
"无法确认 sing-box 的持久开机启动状态": "Persistent sing-box startup at boot is unconfirmed",
|
||||
"命令不可用": "Command unavailable",
|
||||
@@ -72,19 +76,19 @@ MESSAGES = {
|
||||
"读取超时(8 秒)": "Read timed out (8 seconds)",
|
||||
"无法执行读取命令": "Unable to execute read command",
|
||||
"操作系统": "Operating system",
|
||||
"版本符合 Ubuntu 22.04 最低要求;不代表已完成实机兼容验证": "Version meets the Ubuntu 22.04 minimum; compatibility has not been validated on a real system",
|
||||
"版本符合 Ubuntu 22.04 最低要求": "Version meets the Ubuntu 22.04 minimum",
|
||||
"无法解析 Ubuntu 版本,不能确认是否符合最低要求": "Unable to parse the Ubuntu version; minimum requirement is unconfirmed",
|
||||
"需要 Ubuntu 22.04 或更新版本": "Ubuntu 22.04 or newer is required",
|
||||
"版本未知": "Version unknown",
|
||||
"其他系统的结果仅供参考;请使用 Ubuntu 22.04 或更新版本验证。": "Results on other systems are informational; validate on Ubuntu 22.04 or newer.",
|
||||
"版本检查不验证官方维护状态;Ubuntu 22.04 及更新版本仍需分别进行实机测试。": "Version checking does not verify official maintenance status; Ubuntu 22.04 and newer versions still require individual real-system tests.",
|
||||
"其他系统的结果仅供参考。": "Results on other systems are informational.",
|
||||
"版本检查 Ubuntu 22.04 及更新版本受支持。": "Ubuntu 22.04 and newer versions are supported.",
|
||||
"系统架构": "Architecture",
|
||||
"检测架构;本阶段不验证安装包兼容性": "Architecture detected; package compatibility is not checked at this stage",
|
||||
"根分区可用空间": "Free space on root filesystem",
|
||||
"可用 {free:.1f} GiB(体检提示阈值 1 GiB,不代表完整安装要求)": "{free:.1f} GiB free (advisory threshold: 1 GiB; not a complete installation requirement)",
|
||||
"可用 {free:.1f} GiB(体检提示阈值 1 GiB)": "{free:.1f} GiB free (advisory threshold: 1 GiB)",
|
||||
"无法读取": "Unable to read",
|
||||
"公网 IP": "Public IP",
|
||||
"用户提供的地址具备公网地址属性,但未验证归属或可达性": "The supplied address is globally routable; ownership and reachability are unverified",
|
||||
"用户提供的地址具备公网地址属性": "The supplied address is globally routable",
|
||||
"提供的地址不是全局可路由公网地址": "The supplied address is not a globally routable public address",
|
||||
"默认不向第三方查询出口 IP": "No third-party egress IP lookup is performed by default",
|
||||
"可用 --public-ip 填写云控制台地址;地址归属及入站可达性仍需外部验证。": "Use --public-ip to supply the address from your cloud console; ownership and inbound reachability still need external verification.",
|
||||
@@ -95,18 +99,18 @@ MESSAGES = {
|
||||
"SSH 配置": "SSH configuration", "本机防火墙": "Local firewall", "sing-box 服务": "sing-box service",
|
||||
"此项需要 Linux;当前主机仅验证程序运行": "Requires Linux; this host only validates that the program runs",
|
||||
"可用内存": "Available memory",
|
||||
"约 {mib:.0f} MiB(体检提示阈值 256 MiB,不代表完整安装要求)": "About {mib:.0f} MiB (advisory threshold: 256 MiB; not a complete installation requirement)",
|
||||
"约 {mib:.0f} MiB(体检提示阈值 256 MiB)": "About {mib:.0f} MiB (advisory threshold: 256 MiB)",
|
||||
"无法读取 MemAvailable": "Unable to read MemAvailable",
|
||||
"仅表示本机接口地址;不证明互联网可达": "Local interface addresses only; not proof of Internet reachability",
|
||||
"仅表示本机接口地址": "Local interface addresses only",
|
||||
"无法解析接口信息": "Unable to parse interface information",
|
||||
"目标端口占用": "Target port usage",
|
||||
"目标 TCP/UDP 端口 {port} 已有监听": "A listener already uses target TCP/UDP port {port}",
|
||||
"无法完整确认端口占用": "Unable to fully determine port usage",
|
||||
"当前未发现 TCP/UDP 端口 {port} 的监听;不保证之后绑定成功": "No listener currently found on TCP/UDP port {port}; future binding is not guaranteed",
|
||||
"当前未发现 TCP/UDP 端口 {port} 的监听": "No listener currently found on TCP/UDP port {port}",
|
||||
"选择未占用端口;不会停止已有服务。": "Choose an unused port; existing services will not be stopped.",
|
||||
"允许 root 登录或密码认证,建议人工审阅": "Root login or password authentication is allowed; review manually",
|
||||
"未发现这两项宽松设置;不能据此认定 SSH 安全": "Neither of these permissive settings was found; SSH security is not established",
|
||||
"sshd -T 读取默认配置,不保证与运行中服务或 Match 条件一致;不自动修改 SSH。": "sshd -T reads default configuration, which may differ from the running service or Match conditions; SSH is not modified.",
|
||||
"允许 root 登录或密码认证": "Root login or password authentication is allowed",
|
||||
"未发现这两项宽松设置": "Neither of these permissive settings was found",
|
||||
"sshd -T 读取默认配置,可能与运行中服务或 Match 条件一致。": "sshd -T reads default configuration, which may differ from the running service or Match conditions.",
|
||||
"读取失败": "Read failed", "已读取;": "Read completed; ",
|
||||
"存在状态或规则信息": "state or rule information exists",
|
||||
"未发现活动状态或规则": "no active state or rules found",
|
||||
@@ -114,11 +118,11 @@ MESSAGES = {
|
||||
"没有足够证据确认防火墙保护状态": "Insufficient evidence to establish firewall protection",
|
||||
"部分工具不可用或权限不足。": "Some tools are unavailable or permissions are insufficient.",
|
||||
"规则存在不等于配置安全。": "The presence of rules does not establish a secure configuration.",
|
||||
"服务处于 active;不代表代理连接成功": "Service is active; this does not establish successful proxy connectivity",
|
||||
"服务处于 active": "Service is active",
|
||||
"未确认 sing-box 服务运行;新系统尚未安装属于正常情况": "sing-box service operation is unconfirmed; absence is expected on a fresh system",
|
||||
"通过": "PASS", "注意": "WARN", "未知": "UNKNOWN", "跳过": "SKIP", "失败": "FAIL",
|
||||
"[IP已隐藏]": "[IP REDACTED]",
|
||||
"ProxyDoctor 只读体检:不修改系统;默认不请求外部网络": "ProxyDoctor read-only checks: no system changes; no external requests by default",
|
||||
"VPS 检测工具 只读体检:不修改系统;默认不请求外部网络": "VPS Check read-only checks: no system changes; no external requests by default",
|
||||
"读取系统信息并生成体检报告": "Read system information and generate a diagnostic report",
|
||||
"拟使用的代理端口,默认 8388": "Intended proxy port (default: 8388)",
|
||||
"云控制台提供的 IP;本工具不验证归属": "IP from your cloud console; ownership is not verified",
|
||||
@@ -129,8 +133,8 @@ MESSAGES = {
|
||||
"--public-ip 必须是有效 IPv4 或 IPv6 地址": "--public-ip must be a valid IPv4 or IPv6 address",
|
||||
"无法保存报告:{error}": "Unable to save report: {error}",
|
||||
"只读体检 · 默认不请求外部网络": "Read-only checks · No external requests by default",
|
||||
"依据": "Evidence", "建议": "Advice",
|
||||
"报告默认隐藏 IP;分享前请检查内容。通过某一项不代表整体安全或代理可用。": "IP addresses are redacted by default. Review before sharing. Passing a check does not establish overall security or proxy connectivity.",
|
||||
"依据": "Evidence",
|
||||
"报告默认隐藏 IP。": "IP addresses are redacted by default.",
|
||||
"参数错误:{message}": "Argument error: {message}",
|
||||
"显示帮助并退出": "Show this help message and exit",
|
||||
"显示版本并退出": "Show version and exit",
|
||||
|
||||
+50
-12
@@ -97,7 +97,7 @@ def reconcile(report):
|
||||
'complete':available==len(FIELDS) and report['sources']['AbuseIPDB']['state']=='ok'}
|
||||
|
||||
|
||||
def enrich_report(report):
|
||||
def enrich_report(report,on_result=None):
|
||||
address=report['ip']
|
||||
if not address: return
|
||||
url='https://proxycheck.io/v3/'+urllib.parse.quote(address,safe=':')+'?tag=0&ver=24-June-2026'
|
||||
@@ -120,6 +120,7 @@ def enrich_report(report):
|
||||
history=item.get('attack_history')
|
||||
risk['attack_count']=sum(history.values()) if isinstance(history,dict) and all(type(n) is int and 0<=n<=2**31 for n in history.values()) else None
|
||||
report['provider_risk']['proxycheck.io']=risk
|
||||
if on_result is not None:on_result()
|
||||
data,state=request_json('https://api.ip2location.io/?'+urllib.parse.urlencode({'ip':address,'format':'json'}))
|
||||
if state=='ok' and valid_ip(data.get('ip'))!=address: state='invalid_response'
|
||||
report['sources']['IP2Location']={'state':state,'access':'anonymous'}
|
||||
@@ -128,7 +129,7 @@ def enrich_report(report):
|
||||
report['provider_risk']['IP2Location']={'open_proxy_flag':data.get('is_proxy') if type(data.get('is_proxy')) is bool else None}
|
||||
|
||||
|
||||
def collect(enrich=True):
|
||||
def collect(enrich=True, mail=False, destination=None, on_result=None):
|
||||
"""Keys only via environment; never kept in results, URLs or error messages."""
|
||||
key = os.environ.get('PROXYDOCTOR_IPAPI_KEY', '').strip()
|
||||
abuse_key = os.environ.get('PROXYDOCTOR_ABUSEIPDB_KEY', '').strip()
|
||||
@@ -149,6 +150,10 @@ def collect(enrich=True):
|
||||
'last_reported_at': None, 'window_days': 90}}
|
||||
report['observations']={'ipapi.is':values.copy()}
|
||||
report['provider_risk']={}
|
||||
if destination is not None:
|
||||
destination.update(report)
|
||||
report=destination
|
||||
if on_result is not None:on_result()
|
||||
if not address and data is None:
|
||||
fallback,fallback_state=request_json('https://api.ipify.org?format=json')
|
||||
address=valid_ip(fallback.get('ip')) if fallback else None
|
||||
@@ -176,14 +181,33 @@ def collect(enrich=True):
|
||||
if any(report['reputation'][k] is None for k in ('score', 'total_reports', 'distinct_reporters')):
|
||||
abuse_state = 'partial_response'
|
||||
report['sources']['AbuseIPDB'] = {'state': abuse_state}
|
||||
if enrich: enrich_report(report)
|
||||
if on_result is not None:on_result()
|
||||
if enrich: enrich_report(report,on_result)
|
||||
reconcile(report)
|
||||
if mail:
|
||||
from .mailquality import collect as collect_mail
|
||||
report['mail_quality']=collect_mail(address)
|
||||
if on_result is not None:on_result()
|
||||
return report
|
||||
|
||||
|
||||
def render(report,out,show_ip=False):
|
||||
tr=out.text;values=report['values'];unknown=tr('未知','Unknown')
|
||||
address=report['ip']
|
||||
coverage=report['coverage']
|
||||
concern=[name[3:] for name in ('is_proxy','is_vpn','is_tor','is_abuser') if values.get(name) is True]
|
||||
location=' / '.join(str(values[name]) for name in ('country','region','city') if values.get(name)) or unknown
|
||||
summary=[('IP',address if address and show_ip else tr('已隐藏;--show-ip 显示','Hidden; use --show-ip') if address else unknown,'ok' if address else 'unknown'),
|
||||
('ASN',values.get('asn') or unknown,'ok' if values.get('asn') else 'unknown'),
|
||||
(tr('运营商','ISP'),values.get('company') or unknown,'ok' if values.get('company') else 'unknown'),
|
||||
(tr('估计位置','Estimated location'),location,'unknown'),
|
||||
(tr('网络类型','Network type'),values.get('network_type') or unknown,'unknown'),
|
||||
(tr('风险标签','Risk flags'),', '.join(concern) if concern else tr('未取得肯定风险标签;不代表安全','No positive risk flag; safety unknown'),'bad' if concern else 'unknown'),
|
||||
(tr('来源冲突','Source conflicts'),str(len(report.get('conflicts',{}))),'unknown'),
|
||||
(tr('数据覆盖','Field coverage'),f"{coverage['available']}/{coverage['total']}",'ok' if coverage['complete'] else 'unknown'),
|
||||
(tr('查询时间','Observed at'),report.get('checked_at',unknown),'unknown')]
|
||||
out.panel(tr('IP 质量概览 · 来源证据,不是纯净度评分','IP quality overview · Provider evidence, not a purity score'),summary)
|
||||
out.row(tr('基础归属与多库证据','Ownership and provider evidence'),tr('分来源记录,不合成纯净度分数','Separate sources; no combined purity score'),'unknown')
|
||||
out.row(tr('出口地址','Egress IP'),address if address and show_ip else tr('已隐藏(--show-ip 显示)','Hidden (use --show-ip)') if address else unknown,'ok' if address else 'unknown')
|
||||
for key,zh,en in [('company','运营商','ISP'),('asn','ASN','ASN'),('country','国家','Country'),
|
||||
('region','地区','Region'),('city','城市','City'),('timezone','时区','Timezone'),
|
||||
@@ -201,19 +225,33 @@ def render(report,out,show_ip=False):
|
||||
out.row('proxycheck.io',unknown if risk is None else tr(f'风险分 {risk}/100 · 置信度 {confidence if confidence is not None else "?"}/100',
|
||||
f'Risk {risk}/100 · Confidence {confidence if confidence is not None else "?"}/100'),
|
||||
'unknown' if risk is None else 'bad' if risk>25 else 'ok')
|
||||
reputation=report['reputation'];score=reputation['score']
|
||||
out.row('AbuseIPDB',unknown if score is None else tr(
|
||||
f"评分 {score}/100 · 90 天举报 {reputation['total_reports']} · 举报者 {reputation['distinct_reporters']}",
|
||||
f"Score {score}/100 · 90-day reports {reputation['total_reports']} · Reporters {reputation['distinct_reporters']}"),
|
||||
'unknown' if score is None else 'bad' if score>0 else 'ok')
|
||||
reputation=report.get('reputation',{}); score=reputation.get('score')
|
||||
if score is not None:
|
||||
out.row('AbuseIPDB',tr(f"举报置信分 {score}/100 · 90 天举报 {reputation.get('total_reports')}",
|
||||
f"Abuse confidence {score}/100 · 90-day reports {reputation.get('total_reports')}"),
|
||||
'bad' if score>0 else 'unknown')
|
||||
coverage=report['coverage']
|
||||
for source,observation in report.get('observations',{}).items():
|
||||
classification=observation.get('network_type')
|
||||
# Skip providers that returned no usable type data (e.g. keyless sources).
|
||||
if classification is None:
|
||||
continue
|
||||
flags=' / '.join(key[3:]+'='+('?' if observation.get(key) is None else tr('是','Yes') if observation[key] else tr('否','No'))
|
||||
for key in ('is_datacenter','is_proxy','is_vpn','is_tor'))
|
||||
out.row(source,tr('类型:','Type: ')+classification+' · '+flags,'unknown')
|
||||
if report.get('mail_quality'):
|
||||
from .mailquality import render as render_mail
|
||||
render_mail(report['mail_quality'],out)
|
||||
out.row(tr('IP 数据覆盖','IP data coverage'),f"{coverage['available']}/{coverage['total']} · IPv{report['ip_family'] or '?'}",
|
||||
'ok' if coverage['complete'] else 'unknown')
|
||||
labels={'ok':('成功','OK'),'key_required':('需要密钥','Key required'),'rate_limited':('限流','Rate limited'),
|
||||
'invalid_response':('响应格式异常','Invalid response'),'request_failed':('请求失败','Request failed')}
|
||||
for source,item in report['sources'].items():
|
||||
state=item['state'];name=labels.get(state,(state,state))[0 if out.lang=='zh' else 1]
|
||||
state=item['state']
|
||||
# Skip sources that need a key we don't have, or that returned no usable
|
||||
# data (key_required / invalid_response / request_failed). Only show the
|
||||
# ones that actually produced results.
|
||||
if state == 'key_required':
|
||||
continue
|
||||
name=labels.get(state,(state,state))[0 if out.lang=='zh' else 1]
|
||||
out.row(source+' API',name,'ok' if state=='ok' else 'unknown')
|
||||
if not coverage['complete']:
|
||||
print(tr(' 可选扩展字段:PROXYDOCTOR_IPAPI_KEY;举报数据:PROXYDOCTOR_ABUSEIPDB_KEY。',
|
||||
' Optional extended fields: PROXYDOCTOR_IPAPI_KEY; reports: PROXYDOCTOR_ABUSEIPDB_KEY.'),flush=True)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Opt-in bounded PTR/forward consistency and outbound SMTP TCP probes.
|
||||
|
||||
No email, SMTP command or authentication is sent. DNSBL remains unqueried until
|
||||
each provider's terms, error codes and resolver requirements are configured.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from .reports import utc_now
|
||||
|
||||
PROGRAM = '''import ipaddress,json,socket,sys,time
|
||||
options=json.loads(sys.stdin.readline())
|
||||
if options['mode']=='ptr':
|
||||
try:
|
||||
host,aliases,addresses=socket.gethostbyaddr(options['ip'])
|
||||
forward=socket.getaddrinfo(host,None,type=socket.SOCK_STREAM)
|
||||
matches=any(ipaddress.ip_address(row[4][0])==ipaddress.ip_address(options['ip']) for row in forward)
|
||||
print(json.dumps({'status':'observed','hostname':host[:253],'forward_matches':matches}))
|
||||
except socket.herror:
|
||||
print(json.dumps({'status':'unknown','error_code':'PTR_NO_RECORD_OR_RESOLVER_ERROR'}))
|
||||
except OSError:
|
||||
print(json.dumps({'status':'unknown','error_code':'DNS_ERROR'}))
|
||||
else:
|
||||
try:
|
||||
addresses=socket.getaddrinfo('gmail-smtp-in.l.google.com',25,type=socket.SOCK_STREAM)
|
||||
target=next(row for row in addresses if ipaddress.ip_address(row[4][0]).version==options['family'] and ipaddress.ip_address(row[4][0]).is_global)
|
||||
start=time.monotonic()
|
||||
with socket.create_connection((target[4][0],25),timeout=2):pass
|
||||
print(json.dumps({'status':'reachable','elapsed_ms':round((time.monotonic()-start)*1000,3),'address':target[4][0]}))
|
||||
except (OSError,StopIteration):
|
||||
print(json.dumps({'status':'unknown','error_code':'SMTP_TCP_UNCONFIRMED'}))
|
||||
'''
|
||||
|
||||
|
||||
def probe(mode,address):
|
||||
ip=ipaddress.ip_address(address)
|
||||
if not ip.is_global or mode not in ('ptr','smtp'):
|
||||
raise ValueError('Expected global IP and supported probe')
|
||||
try:
|
||||
process=subprocess.run([sys.executable,'-I','-B','-c',PROGRAM],
|
||||
input=json.dumps({'mode':mode,'ip':str(ip),'family':ip.version}),
|
||||
capture_output=True,text=True,encoding='utf-8',timeout=5,check=False)
|
||||
if process.returncode or len(process.stdout)>8192:
|
||||
return {'status':'unknown','error_code':'PROBE_ERROR'}
|
||||
result=json.loads(process.stdout)
|
||||
return result if isinstance(result,dict) else {'status':'unknown','error_code':'INVALID_RESPONSE'}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'status':'unknown','error_code':'PROBE_TIMEOUT'}
|
||||
except (OSError,ValueError):
|
||||
return {'status':'unknown','error_code':'PROBE_ERROR'}
|
||||
|
||||
|
||||
def collect(address, query=probe):
|
||||
report={'checked_at':utc_now(),'scope':'selected_family_outbound_only','ptr':None,'smtp_tcp':None,
|
||||
'dnsbl':{'status':'skipped','reason':'provider_terms_and_resolver_configuration_required'},
|
||||
'limitations':['PTR consistency does not establish email deliverability or IP reputation.',
|
||||
'SMTP TCP uses the default route; it does not bind the queried IP as source.',
|
||||
'One outbound target cannot prove all port-25 paths or inbound mail availability.',
|
||||
'No DNSBL result or clean-IP score is inferred from missing data.']}
|
||||
if not address:
|
||||
report.update(ptr={'status':'skipped','error_code':'IP_UNAVAILABLE'},smtp_tcp={'status':'skipped','error_code':'IP_UNAVAILABLE'})
|
||||
return report
|
||||
for mode,key in (('ptr','ptr'),('smtp','smtp_tcp')):
|
||||
try:report[key]=query(mode,address)
|
||||
except Exception:report[key]={'status':'unknown','error_code':'PROBE_ERROR'}
|
||||
return report
|
||||
|
||||
|
||||
def render(report,out):
|
||||
tr=out.text
|
||||
ptr=report['ptr'];smtp=report['smtp_tcp']
|
||||
out.row('PTR',ptr.get('hostname') or ptr.get('error_code','Unknown'),'ok' if ptr['status']=='observed' else 'unknown')
|
||||
matches=ptr.get('forward_matches')
|
||||
out.row(tr('PTR 正向一致','PTR forward match'),tr('是','Yes') if matches is True else tr('否','No') if matches is False else tr('未知','Unknown'),
|
||||
'ok' if matches is True else 'unknown')
|
||||
out.row(tr('出站 TCP/25','Outbound TCP/25'),tr('单目标可连接','One target reachable') if smtp['status']=='reachable' else tr('未确认;不能认定全部被封','Unconfirmed; does not prove universal blocking'),
|
||||
'ok' if smtp['status']=='reachable' else 'unknown')
|
||||
out.row('DNSBL',tr('未查询:需核实来源条款及解析器条件','Not queried: provider terms/resolver setup required'),'unknown')
|
||||
+40
-5
@@ -81,6 +81,10 @@ class VisibleText(HTMLParser):
|
||||
|
||||
|
||||
def fetch_page(url):
|
||||
from .worker import enabled,invoke
|
||||
if enabled():
|
||||
result=invoke('page',{'url':url})
|
||||
return Page(**result) if isinstance(result,dict) else Page(None,reason='network_timeout_or_error')
|
||||
expected = urllib.parse.urlparse(url).hostname
|
||||
allowed = next((hosts for hosts in REDIRECT_HOSTS if expected in hosts),frozenset((expected,)))
|
||||
deadline = time.monotonic() + 15
|
||||
@@ -288,13 +292,42 @@ def display(result, lang='zh'):
|
||||
return label + f' · HTTP {http if http is not None else "---"}', 'unknown'
|
||||
|
||||
|
||||
def run_console(lang='zh', color='auto'):
|
||||
def check_reachability(service):
|
||||
"""Public page reachability only; no catalogue, region or playback verdict."""
|
||||
from .reports import utc_now
|
||||
url = SERVICES[service][0]
|
||||
started = time.monotonic()
|
||||
page = fetch_page(url)
|
||||
state = ('reachable' if page.status is not None and 200 <= page.status < 300 and not page.reason else
|
||||
'access_denied' if page.status in (401,403,451) else
|
||||
'rate_limited' if page.status == 429 else
|
||||
'http_error' if page.status is not None else 'unknown')
|
||||
return {'service':service,'scope':'public_page_reachability','state':state,
|
||||
'http':page.status,'evidence':page.reason or 'http_response',
|
||||
'checked_at':utc_now(),'elapsed_ms':round((time.monotonic()-started)*1000,3),
|
||||
'playback':'not_tested','unlock':'not_tested',
|
||||
'probes':[{'url':url,'final_url':page.url,'http':page.status}]}
|
||||
|
||||
|
||||
def display_reachability(result,lang='zh'):
|
||||
zh=lang=='zh'
|
||||
labels={'reachable':('网页可达','Web page reachable'),
|
||||
'access_denied':('访问受限','Access denied'),
|
||||
'rate_limited':('请求限流','Rate limited'),
|
||||
'http_error':('HTTP 异常','HTTP error'),
|
||||
'unknown':('可达性未确认','Reachability unconfirmed')}
|
||||
state=result.get('state','unknown')
|
||||
label=labels.get(state,labels['unknown'])[0 if zh else 1]
|
||||
return label+' · HTTP '+str(result.get('http') or '---'), 'ok' if state=='reachable' else 'unknown'
|
||||
|
||||
|
||||
def run_console(lang='zh', color='auto', results=None, on_result=None, *, out=None):
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from .console import Console
|
||||
out = Console(lang,color)
|
||||
results = {}
|
||||
out = out if out is not None else Console(lang,color)
|
||||
results = {} if results is None else results
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
futures = {pool.submit(check,service):service for service in SERVICES}
|
||||
futures = {pool.submit(check_reachability,service):service for service in SERVICES}
|
||||
for future in as_completed(futures):
|
||||
service = futures[future]
|
||||
try:
|
||||
@@ -303,6 +336,8 @@ def run_console(lang='zh', color='auto'):
|
||||
# Never echo raw provider exceptions (URLs may include sensitive data).
|
||||
result = {'state':'unknown','http':None,'evidence':'network_error','playback':'not_tested'}
|
||||
results[service] = result
|
||||
value, status = display(result,lang)
|
||||
if on_result is not None:
|
||||
on_result()
|
||||
value, status = display_reachability(result,lang)
|
||||
out.row(service,value,status)
|
||||
return results
|
||||
+8
-2
@@ -30,7 +30,13 @@ def read_limited(response, limit, deadline):
|
||||
raise ValueError('response_too_large')
|
||||
|
||||
|
||||
def request_json(url, *, payload=None, headers=None):
|
||||
def request_json(url, *, payload=None, headers=None, max_bytes=65536):
|
||||
if not isinstance(max_bytes,int) or not 1<=max_bytes<=524288:
|
||||
raise ValueError('Invalid JSON response limit')
|
||||
from .worker import enabled,invoke
|
||||
if enabled():
|
||||
result=invoke('json',{'url':url,'payload':payload,'headers':headers,'max_bytes':max_bytes})
|
||||
return tuple(result) if isinstance(result,list) and len(result)==2 else (None,'request_failed')
|
||||
request_headers = {'Accept': 'application/json', 'Accept-Encoding': 'identity',
|
||||
'User-Agent': 'ProxyDoctor/0.4'}
|
||||
request_headers.update(headers or {})
|
||||
@@ -44,7 +50,7 @@ def request_json(url, *, payload=None, headers=None):
|
||||
with opener().open(request, timeout=8) as response:
|
||||
if response.status != 200:
|
||||
return None, 'http_' + str(response.status)
|
||||
result = json.loads(read_limited(response, 65536, deadline))
|
||||
result = json.loads(read_limited(response, max_bytes, deadline))
|
||||
if not isinstance(result, dict):
|
||||
return None, 'invalid_response'
|
||||
return result, 'ok'
|
||||
|
||||
@@ -18,7 +18,7 @@ def dns_check():
|
||||
"print(json.dumps(sorted(set(x[0] for x in a))))")
|
||||
start = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run([sys.executable, '-c', program], capture_output=True,
|
||||
proc = subprocess.run([sys.executable, '-I', '-B', '-c', program], capture_output=True,
|
||||
text=True, timeout=TIMEOUT, check=False)
|
||||
if proc.returncode != 0:
|
||||
return {'status':'unknown', 'reason':'lookup_failed'}
|
||||
@@ -45,13 +45,14 @@ def tcp_check(label, address, port):
|
||||
return {'target':label, 'port':port, 'status':'unknown'}
|
||||
|
||||
|
||||
def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=None):
|
||||
def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=None, on_result=None):
|
||||
"""Run explicit checks, flushing each result as soon as it is available."""
|
||||
if compact:
|
||||
from .console import Console
|
||||
console=Console(lang,color)
|
||||
dns=dns_check()
|
||||
if report is not None: report.update(dns=dns,tcp=[])
|
||||
if on_result is not None: on_result()
|
||||
if dns['status']=='pass':
|
||||
versions='/'.join(v for v,ok in [('IPv4',dns['ipv4']),('IPv6',dns['ipv6'])] if ok)
|
||||
console.row('DNS',f"{versions} / {dns['elapsed_ms']} ms")
|
||||
@@ -60,6 +61,7 @@ def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=No
|
||||
for label,address,port in TARGETS:
|
||||
result=tcp_check(label,address,port)
|
||||
if report is not None: report['tcp'].append(result)
|
||||
if on_result is not None: on_result()
|
||||
ok=result['status']=='pass'
|
||||
console.row(f'{label} TCP/{port}',f"{result['elapsed_ms']} ms" if ok else console.text('未连通','Unreachable'),'ok' if ok else 'bad')
|
||||
complete=complete and ok
|
||||
@@ -70,6 +72,7 @@ def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=No
|
||||
show(t('正在查询 DNS:example.com ...'))
|
||||
dns = dns_check()
|
||||
if report is not None: report.update(dns=dns,tcp=[])
|
||||
if on_result is not None: on_result()
|
||||
complete = dns['status'] == 'pass'
|
||||
if dns['status'] == 'pass':
|
||||
versions = '/'.join(v for v, ok in [('IPv4', dns['ipv4']), ('IPv6', dns['ipv6'])] if ok) or t('其他')
|
||||
@@ -80,10 +83,11 @@ def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=No
|
||||
show(t('正在测试 {target} TCP {port} ...', target=label, port=port))
|
||||
result = tcp_check(label, address, port)
|
||||
if report is not None: report['tcp'].append(result)
|
||||
if on_result is not None: on_result()
|
||||
complete = complete and result['status'] == 'pass'
|
||||
if result['status'] == 'pass':
|
||||
show(t('{target} TCP 连接成功:{ms} ms', target=label, ms=result['elapsed_ms']))
|
||||
else:
|
||||
show(t('{target} TCP 连接未成功;原因尚未确定。', target=label))
|
||||
show(t('以上结果只代表本次从此服务器到这些测试目标的情况,不代表代理协议、回程路线或 IP 信誉。'))
|
||||
show(t('以上结果为本次从此服务器到这些测试目标的连接情况。'))
|
||||
return 0 if complete else 1
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Offline, escaped Markdown exports and conservative sharing redaction."""
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .reports import exclusive_text
|
||||
|
||||
SENSITIVE = re.compile(r'password|passwd|token|secret|api[_-]?key|private[_-]?key|authorization|cookie|subscription',re.I)
|
||||
|
||||
|
||||
def sharing_copy(value, lang='zh'):
|
||||
from .cli import redact
|
||||
hidden = '[已隐藏]' if lang == 'zh' else '[REDACTED]'
|
||||
def walk(item):
|
||||
if isinstance(item,dict):
|
||||
return {key:hidden if SENSITIVE.search(str(key)) else walk(val) for key,val in item.items()}
|
||||
if isinstance(item,list):
|
||||
return [walk(val) for val in item]
|
||||
if not isinstance(item,str):
|
||||
return item
|
||||
def url(match):
|
||||
try:
|
||||
parsed = urlsplit(match[0])
|
||||
# Remove userinfo and all query/fragment values, including signed URLs.
|
||||
netloc = parsed.netloc.rsplit('@',1)[-1]
|
||||
return urlunsplit((parsed.scheme,netloc,parsed.path,'',''))
|
||||
except ValueError:
|
||||
return hidden
|
||||
item = re.sub(r'https?://[^\s<>"\']+',url,item)
|
||||
item = re.sub(r'(?i)\bBearer\s+\S+', 'Bearer '+hidden,item)
|
||||
item = re.sub(r'(?i)\b(password|token|secret|api[_-]?key)\s*[=:]\s*[^\s;,]+',lambda m:m[1]+'='+hidden,item)
|
||||
return item
|
||||
return redact(walk(value),lang)
|
||||
|
||||
|
||||
def load_report(path, max_bytes=8*1024**2):
|
||||
with Path(path).open('rb') as stream:
|
||||
body = stream.read(max_bytes+1)
|
||||
if len(body)>max_bytes:
|
||||
raise ValueError('Report exceeds size limit')
|
||||
data = json.loads(body.decode('utf-8'))
|
||||
if not isinstance(data,dict) or not isinstance(data.get('schema_version'),int):
|
||||
raise ValueError('Expected a versioned report object')
|
||||
if data['schema_version'] not in (1,2,3):
|
||||
raise ValueError('Unsupported report version')
|
||||
return data
|
||||
|
||||
|
||||
def markdown(report, lang='zh'):
|
||||
from . import release_label
|
||||
report = sharing_copy(report,lang)
|
||||
t = lambda zh,en:zh if lang=='zh' else en
|
||||
def cell(value):
|
||||
if isinstance(value,(dict,list)):
|
||||
value=json.dumps(value,ensure_ascii=False)
|
||||
return html.escape(str(value),quote=True).replace('|','|').replace('`','`').replace('\n','<br>').replace('\r','').replace('\x1b','')
|
||||
def table(headers,rows):
|
||||
return ['| '+' | '.join(cell(value) for value in headers)+' |',
|
||||
'| '+' | '.join('---' for _ in headers)+' |']+[
|
||||
'| '+' | '.join(cell(value) for value in row)+' |' for row in rows]
|
||||
rows=[['schema',report.get('schema_version')],[t('版本','Tool version'),report.get('tool_version','unknown')],
|
||||
[t('检测时间','Observation time'),report.get('generated_at',report.get('observed_at','unknown'))],
|
||||
[t('运行结果','Execution result'),report.get('result','unknown')]]
|
||||
lines=['# '+t('VPS 检测工具 · 本地检测报告','VPS Check · local diagnostic report'),'']
|
||||
lines += [release_label(lang),'']
|
||||
lines+=table([t('项目','Item'),t('记录','Observation')],rows)
|
||||
lines+=['',t('本报告来自已有本地记录,没有重新检测。缺失指标保持未知。',
|
||||
'This report exports existing local observations, without rerunning checks. Missing metrics remain unknown.'),'']
|
||||
if report.get('recovery'):
|
||||
lines += ['> '+t('此报告由恢复日志生成,可能仅包含中断前的部分结果。','Recovered from a journal; results may be partial.'),'']
|
||||
modules=report.get('modules',{})
|
||||
if report.get('result_items'):
|
||||
lines += ['## '+t('统一结果记录','Unified evidence records'),'']
|
||||
lines += table([t('检查','Check'),t('状态','Status'),t('方法','Method'),t('来源','Source'),t('证据范围','Confidence scope')],
|
||||
[[row.get('check_id'),row.get('status'),row.get('method'),row.get('source'),row.get('confidence','unknown')]
|
||||
for row in report['result_items'] if isinstance(row,dict)])
|
||||
lines += ['']
|
||||
if isinstance(modules,dict) and modules:
|
||||
lines += ['## '+t('模块执行','Module execution'),'']
|
||||
lines += table([t('模块','Module'),t('状态','Status'),t('原因/错误码','Reason/error')],
|
||||
[[name,row.get('status','unknown'),row.get('error_code') or row.get('reason','')]
|
||||
for name,row in modules.items() if isinstance(row,dict)])
|
||||
lines += ['']
|
||||
checks=report.get('checks',report.get('system',{}).get('checks',[]))
|
||||
if isinstance(checks,list):
|
||||
lines += ['## '+t('本机提示','Local findings'),'']
|
||||
lines += table([t('项目','Check'),t('状态','Status'),t('说明','Explanation')],
|
||||
[[row.get('title',row.get('id','unknown')),row.get('status','unknown'),row.get('detail','')]
|
||||
for row in checks if isinstance(row,dict)])
|
||||
lines += ['']
|
||||
for key,zh,en in [('resources','资源额度与压力','Resource limits and pressure'),
|
||||
('security','权限与更新缓存','Permissions and update cache'),
|
||||
('network','网络检测','Network observations'),('route','路由记录','Route observations'),
|
||||
('ip_quality','IP数据来源与限制','IP providers and limitations'),
|
||||
('bandwidth','带宽样本','Bandwidth samples'),('media','媒体可达性','Media reachability'),
|
||||
('bgp','BGP 观测','BGP observations'),('carrier_nodes','测速节点探测','Speed node probes'),
|
||||
('return_routes','三城三运营商方向追踪','Three-city carrier traces')]:
|
||||
data=report.get(key)
|
||||
lines += ['## '+t(zh,en),'']
|
||||
if data is None:
|
||||
lines += [t('未记录;请查看模块跳过原因。','Not recorded; inspect the module skip reason.'),'']
|
||||
else:
|
||||
# A fence longer than every backtick run in the data prevents a
|
||||
# provider string from closing the block and forging report content.
|
||||
payload=json.dumps(data,ensure_ascii=False,indent=2)
|
||||
runs=re.findall(r'`+',payload)
|
||||
fence='`'*max(3,1+max(map(len,runs),default=0))
|
||||
lines += [fence+'json',payload,fence,'']
|
||||
lines += [t('分享前请人工检查。自动脱敏不是匿名或无敏感信息保证。','Review before sharing. Automated redaction is not an anonymity or secret-free guarantee.'),'']
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def export_report(report, destination, *, format='markdown', lang='zh'):
|
||||
if format not in ('markdown','json'):
|
||||
raise ValueError('Unsupported export format')
|
||||
safe=sharing_copy(report,lang)
|
||||
text=markdown(safe,lang) if format=='markdown' else json.dumps(safe,ensure_ascii=False,indent=2)+'\n'
|
||||
with exclusive_text(destination) as stream:
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
import os
|
||||
os.fsync(stream.fileno())
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Local, exclusive report files and durable per-module recovery snapshots."""
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def exclusive_text(path):
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0)
|
||||
fd = os.open(path, flags, 0o600)
|
||||
return os.fdopen(fd, 'w', encoding='utf-8')
|
||||
|
||||
|
||||
class ReportStore:
|
||||
"""JSONL is authoritative for recovery; a final JSON is created on completion.
|
||||
|
||||
Only complete newline-terminated events are recovery records. No exception
|
||||
strings are stored. Callers supply their redactor, including for checkpoints.
|
||||
"""
|
||||
def __init__(self, destination, redact):
|
||||
if destination is None:
|
||||
directory = Path('reports')
|
||||
directory.mkdir(mode=0o700, exist_ok=True)
|
||||
destination = directory / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||
+ '-' + uuid.uuid4().hex + '.json')
|
||||
self.path = Path(destination)
|
||||
if os.path.lexists(self.path):
|
||||
raise FileExistsError('Report destination exists')
|
||||
self.journal_path = self.path.with_name(self.path.name + '.events.jsonl')
|
||||
self.redact = redact
|
||||
self.stream = exclusive_text(self.journal_path)
|
||||
self.failed = False
|
||||
self.bytes_written = 0
|
||||
|
||||
def checkpoint(self, report):
|
||||
if self.failed:
|
||||
return False
|
||||
try:
|
||||
event = {'event_version': 1, 'saved_at': utc_now(),
|
||||
'snapshot': self.redact(report)}
|
||||
body=json.dumps(event, ensure_ascii=False) + '\n'
|
||||
size=len(body.encode('utf-8'))
|
||||
if size>4*1024**2 or self.bytes_written+size>64*1024**2:
|
||||
self.failed=True
|
||||
return False
|
||||
self.stream.write(body)
|
||||
self.stream.flush()
|
||||
os.fsync(self.stream.fileno())
|
||||
self.bytes_written+=size
|
||||
return True
|
||||
except (OSError, ValueError, TypeError):
|
||||
self.failed = True
|
||||
return False
|
||||
|
||||
def finish(self, report):
|
||||
saved = self.checkpoint(report)
|
||||
try:
|
||||
# Never replace a file created by another process during detection.
|
||||
with exclusive_text(self.path) as output:
|
||||
json.dump(self.redact(report), output, ensure_ascii=False, indent=2)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
except (OSError, ValueError, TypeError):
|
||||
self.failed = True
|
||||
return False
|
||||
return saved
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.stream.close()
|
||||
except OSError:
|
||||
self.failed = True
|
||||
|
||||
|
||||
def recover_latest(path, max_bytes=64*1024**2, max_line_bytes=4*1024**2):
|
||||
"""Read the last complete checkpoint, ignoring an interrupted final write.
|
||||
|
||||
Recovery files are local trusted inputs; this function does not contact any
|
||||
service or execute content. A malformed line is not a successful checkpoint.
|
||||
"""
|
||||
latest = None
|
||||
consumed = 0
|
||||
with Path(path).open('rb') as stream:
|
||||
while True:
|
||||
line = stream.readline(max_line_bytes+1)
|
||||
if not line:
|
||||
break
|
||||
consumed += len(line)
|
||||
if consumed > max_bytes or len(line)>max_line_bytes:
|
||||
raise ValueError('Recovery journal exceeds read budget')
|
||||
if not line.endswith(b'\n'):
|
||||
break
|
||||
try:
|
||||
event = json.loads(line)
|
||||
if event.get('event_version') == 1 and isinstance(event.get('snapshot'), dict):
|
||||
latest = event['snapshot']
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
return latest
|
||||
|
||||
|
||||
class UnavailableStore:
|
||||
"""Continue terminal checks when local storage cannot be opened."""
|
||||
def __init__(self, destination):
|
||||
self.path = Path(destination) if destination else Path('reports')
|
||||
self.journal_path = None
|
||||
self.failed = True
|
||||
|
||||
def checkpoint(self, report):
|
||||
return False
|
||||
|
||||
def finish(self, report):
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Read-only Linux resources; numeric results independent of terminal language."""
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
|
||||
from .reports import utc_now
|
||||
|
||||
|
||||
def read_text(path):
|
||||
try:
|
||||
with Path(path).open(encoding='utf-8') as stream:
|
||||
value = stream.read(262145)
|
||||
return value if len(value) <= 262144 else None
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def number(value):
|
||||
try:
|
||||
parsed = int(value)
|
||||
return parsed if parsed >= 0 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def meminfo(text):
|
||||
result = {}
|
||||
for line in (text or '').splitlines():
|
||||
match = re.fullmatch(r'(MemTotal|MemAvailable|SwapTotal|SwapFree):\s+(\d+) kB', line)
|
||||
if match:
|
||||
result[match[1]] = int(match[2]) * 1024
|
||||
return result
|
||||
|
||||
|
||||
def pressure(text):
|
||||
result = {}
|
||||
for line in (text or '').splitlines():
|
||||
fields = line.split()
|
||||
if not fields or fields[0] not in ('some', 'full'):
|
||||
continue
|
||||
try:
|
||||
values = dict(item.split('=', 1) for item in fields[1:])
|
||||
row = {key: float(values[key]) for key in ('avg10','avg60','avg300')}
|
||||
row['total_us'] = int(values['total'])
|
||||
if all(math.isfinite(row[key]) and 0 <= row[key] <= 100 for key in ('avg10','avg60','avg300')) and row['total_us'] >= 0:
|
||||
result[fields[0]] = row
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
return result or None
|
||||
|
||||
|
||||
def cpu_ticks(text):
|
||||
line = next((line for line in (text or '').splitlines() if line.startswith('cpu ')), '')
|
||||
try:
|
||||
values = [int(value) for value in line.split()[1:]]
|
||||
# Guest and guest_nice are already included in user and nice.
|
||||
return values[:8] if len(values) >= 8 and all(value >= 0 for value in values) else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def steal_delta(before, after):
|
||||
if before is None or after is None:
|
||||
return None
|
||||
deltas = [b-a for a,b in zip(before,after)]
|
||||
total = sum(deltas)
|
||||
return round(100*deltas[7]/total, 3) if total > 0 and all(value >= 0 for value in deltas) else None
|
||||
|
||||
|
||||
def unescape_mount(value):
|
||||
return re.sub(r'\\([0-7]{3})', lambda match: chr(int(match[1], 8)), value)
|
||||
|
||||
|
||||
def cgroup_locations(membership, mounts, controller):
|
||||
"""Map process membership onto visible mount roots, including ancestors.
|
||||
|
||||
Hidden namespace ancestors cannot be inspected; never infer unlimited host
|
||||
resources from the visible root. Limit traversal to 64 visible ancestors.
|
||||
"""
|
||||
memberships = []
|
||||
for line in (membership or '').splitlines():
|
||||
parts = line.split(':', 2)
|
||||
if len(parts) == 3 and (parts[0] == '0' or controller in parts[1].split(',')):
|
||||
memberships.append((parts[0] == '0', parts[2]))
|
||||
for line in (mounts or '').splitlines():
|
||||
left, separator, right = line.partition(' - ')
|
||||
fields, info = left.split(), right.split()
|
||||
if not separator or len(fields) < 6 or len(info) < 3 or info[0] not in ('cgroup','cgroup2'):
|
||||
continue
|
||||
v2 = info[0] == 'cgroup2'
|
||||
if not v2 and controller not in info[2].split(','):
|
||||
continue
|
||||
mount_root, mount = (PurePosixPath(unescape_mount(fields[index])) for index in (3,4))
|
||||
for unified, member in memberships:
|
||||
path = PurePosixPath(member)
|
||||
if unified != v2 or not path.is_absolute() or '..' in path.parts or '..' in mount.parts:
|
||||
continue
|
||||
try:
|
||||
relative = path.relative_to(mount_root)
|
||||
except ValueError:
|
||||
continue
|
||||
current = mount / relative
|
||||
for _ in range(64):
|
||||
yield v2, str(current)
|
||||
if current == mount:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
|
||||
def cgroup_limits(read):
|
||||
membership, mounts = read('/proc/self/cgroup'), read('/proc/self/mountinfo')
|
||||
result = {'cpu_quota_cores': None, 'memory_limit_bytes': None,
|
||||
'observations': [], 'scope': 'visible_cgroup_ancestors_only'}
|
||||
for controller in ('cpu','memory'):
|
||||
for v2, directory in cgroup_locations(membership, mounts, controller):
|
||||
leaf = ('cpu.max' if v2 else 'cpu.cfs_quota_us') if controller == 'cpu' else ('memory.max' if v2 else 'memory.limit_in_bytes')
|
||||
source = directory + '/' + leaf
|
||||
raw = read(source)
|
||||
if raw is None:
|
||||
continue
|
||||
value = None
|
||||
state = 'unknown'
|
||||
if controller == 'cpu':
|
||||
tokens = raw.split()
|
||||
quota = tokens[0] if tokens else ''
|
||||
period = number(tokens[1] if v2 and len(tokens) == 2 else read(directory+'/cpu.cfs_period_us'))
|
||||
if quota == ('max' if v2 else '-1'):
|
||||
state = 'no_visible_limit'
|
||||
elif number(quota) is not None and number(quota) > 0 and period:
|
||||
value = int(quota)/period
|
||||
state = 'measured'
|
||||
key = 'cpu_quota_cores'
|
||||
else:
|
||||
value = number(raw.strip())
|
||||
if raw.strip() == 'max' or (not v2 and value is not None and value >= 2**60):
|
||||
value = None
|
||||
state = 'no_visible_limit'
|
||||
elif value is not None:
|
||||
state = 'measured'
|
||||
key = 'memory_limit_bytes'
|
||||
result['observations'].append({'source': source, 'status': state, 'value': value,
|
||||
'controller': controller, 'version': 2 if v2 else 1})
|
||||
if value is not None:
|
||||
prior = result[key]
|
||||
result[key] = value if prior is None else min(value, prior)
|
||||
return result
|
||||
|
||||
|
||||
def collect(read=read_text, *, interval=0.2, sleep=time.sleep, clock=time.monotonic,
|
||||
system=None, cpu_count=os.cpu_count, affinity=None):
|
||||
if not 0.05 <= interval <= 1:
|
||||
raise ValueError('Resource observation interval must be 0.05..1 seconds')
|
||||
report = {'schema_version': 1, 'observed_at': utc_now(), 'method': 'linux_readonly_snapshot',
|
||||
'status': 'unsupported', 'metrics': {}, 'limitations': [
|
||||
'Visible resources and cgroup limits are not purchased specifications or dedicated capacity.',
|
||||
'Hidden ancestor limits cannot be excluded; steal/PSI describe a short observation, not long-term contention.',
|
||||
'Host-visible PSI/meminfo/CPU counters may differ from container scope.']}
|
||||
if (system or platform.system()) != 'Linux':
|
||||
return report
|
||||
memory = meminfo(read('/proc/meminfo'))
|
||||
limits = cgroup_limits(read)
|
||||
container_markers = [path for path in ('/.dockerenv','/run/.containerenv','/run/systemd/container')
|
||||
if read(path) is not None]
|
||||
try:
|
||||
visible = cpu_count()
|
||||
except OSError:
|
||||
visible = None
|
||||
try:
|
||||
allowed = len(affinity() if affinity else os.sched_getaffinity(0))
|
||||
except (OSError, AttributeError):
|
||||
allowed = None
|
||||
bounds = [v for v in (visible, allowed, limits['cpu_quota_cores']) if v is not None and v > 0]
|
||||
ram = [v for v in (memory.get('MemTotal'), limits['memory_limit_bytes']) if v is not None]
|
||||
before = cpu_ticks(read('/proc/stat'))
|
||||
started = clock()
|
||||
sleep(interval) # Passive observation, no CPU workload or benchmark.
|
||||
after = cpu_ticks(read('/proc/stat'))
|
||||
elapsed = clock()-started
|
||||
swap_total, swap_free = memory.get('SwapTotal'), memory.get('SwapFree')
|
||||
metrics = {'logical_cpus': visible, 'affinity_cpus': allowed,
|
||||
'visible_cpu_upper_bound_cores': min(bounds) if bounds else None,
|
||||
'memory_total_bytes': memory.get('MemTotal'),
|
||||
'memory_available_bytes': memory.get('MemAvailable'),
|
||||
'visible_memory_upper_bound_bytes': min(ram) if ram else None,
|
||||
'swap_total_bytes': swap_total, 'swap_free_bytes': swap_free,
|
||||
'swap_used_bytes': swap_total-swap_free if swap_total is not None and swap_free is not None and swap_free <= swap_total else None,
|
||||
'steal_percent': steal_delta(before, after), 'sample_elapsed_seconds': round(elapsed, 4),
|
||||
'pressure': {kind: pressure(read('/proc/pressure/'+kind)) for kind in ('cpu','memory','io')}}
|
||||
current = next((row for row in limits['observations'] if row['controller'] == 'memory'), None)
|
||||
metrics['cgroup_memory_current_bytes'] = number(read(current['source'].rsplit('/',1)[0] +
|
||||
('/memory.current' if current['version'] == 2 else '/memory.usage_in_bytes'))) if current else None
|
||||
# Keep allocation evidence distinct from the underlying OS view. No local
|
||||
# observation can certify provider-side CPU exclusivity or reservation.
|
||||
report['allocation'] = {
|
||||
'cpu_limit_cores': metrics['visible_cpu_upper_bound_cores'],
|
||||
'cpu_basis': 'affinity_and_visible_quota' if limits['cpu_quota_cores'] is not None else 'visible_cpu_and_affinity_only',
|
||||
'memory_limit_bytes': limits['memory_limit_bytes'] if limits['memory_limit_bytes'] is not None else
|
||||
None if container_markers else memory.get('MemTotal'),
|
||||
'memory_basis': 'visible_cgroup_limit' if limits['memory_limit_bytes'] is not None else
|
||||
'unconfirmed_container_allocation' if container_markers else 'guest_or_os_visible_memory',
|
||||
'dedicated_cpu': 'unknown', 'reserved_memory': 'unknown',
|
||||
'container_evidence': container_markers,
|
||||
}
|
||||
report.update(status='partial', metrics=metrics, cgroup=limits,
|
||||
sources=['/proc/meminfo','/proc/stat','/proc/pressure/{cpu,memory,io}',
|
||||
'/proc/self/cgroup','/proc/self/mountinfo','sched_getaffinity'])
|
||||
return report
|
||||
|
||||
|
||||
def render(report, out):
|
||||
"""Render numeric evidence without parsing translated human descriptions."""
|
||||
tr = out.text
|
||||
if report['status'] == 'unsupported':
|
||||
out.row(tr('资源限制','Resource limits'),tr('当前系统不支持','Unsupported on this system'),'unknown')
|
||||
return
|
||||
metrics, limits = report['metrics'], report['cgroup']
|
||||
def row(zh, en, value, suffix=''):
|
||||
out.row(tr(zh,en), tr('未知','Unknown') if value is None else f'{value:g}{suffix}',
|
||||
'unknown' if value is None else 'ok')
|
||||
allocation = report['allocation']
|
||||
row('本进程 CPU 上限','Process CPU limit',allocation['cpu_limit_cores'],' vCPU')
|
||||
out.row(tr('CPU 额度依据','CPU limit basis'),tr('CPU 范围与可见配额','Affinity and visible quota')
|
||||
if limits['cpu_quota_cores'] is not None else tr('仅系统可见 CPU/进程范围,配额未确认','OS-visible CPUs/affinity only; quota unconfirmed'),'unknown')
|
||||
value = allocation['memory_limit_bytes']
|
||||
row('内存额度/可见量','RAM limit/visible',None if value is None else value/1024**2,' MiB')
|
||||
basis = allocation['memory_basis']
|
||||
out.row(tr('内存额度依据','RAM limit basis'),
|
||||
tr('可见 cgroup 限额(可能与同组进程共享)','Visible cgroup limit (may be shared within the group)') if basis == 'visible_cgroup_limit' else
|
||||
tr('容器额度未确认;不展示宿主机内存作额度','Container allocation unknown; host RAM is not an allocation') if basis == 'unconfirmed_container_allocation' else
|
||||
tr('虚拟机/系统可见量;独享保障未确认','Guest/OS-visible amount; reservation unconfirmed'),'unknown')
|
||||
out.row(tr('CPU/内存独享性','CPU/RAM dedication'),tr('无法从本机确认','Cannot be verified locally'),'unknown')
|
||||
if not allocation['container_evidence']:
|
||||
for zh,en,key in [('Swap 总量','Swap total','swap_total_bytes'),('Swap 已用','Swap used','swap_used_bytes')]:
|
||||
value = metrics[key]
|
||||
row(zh,en,None if value is None else value/1024**2,' MiB')
|
||||
row('CPU steal(短采样)','CPU steal (short)',metrics['steal_percent'],'%')
|
||||
row('实际采样时间','Observation time',metrics['sample_elapsed_seconds'],' s')
|
||||
for kind in ('cpu','memory','io'):
|
||||
psi = metrics['pressure'][kind]
|
||||
row(f'{kind} 压力 some/10s',f'{kind} PSI some/10s',psi.get('some',{}).get('avg10') if psi else None,'%')
|
||||
out.row(tr('范围说明','Scope'),tr('可见配额;独享性未确认;压力为短采样',
|
||||
'Visible quota; exclusivity unconfirmed; short pressure sample'),'unknown')
|
||||
+98
-4
@@ -5,13 +5,78 @@ import struct
|
||||
import sys
|
||||
import time
|
||||
import select
|
||||
import re
|
||||
import shutil
|
||||
from .geo import GeoLocator
|
||||
from .console import clean
|
||||
|
||||
|
||||
def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None):
|
||||
def parse_traceroute_hop(text, ttl, target):
|
||||
"""Parse numeric Linux traceroute -n -q 1 output; retain explicit errors."""
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^\s*(\d+)\s+(.*)', line)
|
||||
if not match or int(match[1]) != ttl:
|
||||
continue
|
||||
rest=match[2]
|
||||
if rest.strip()=='*':
|
||||
return {'hop':ttl,'ip':None,'latency_ms':None,'state':'timeout','probes_sent':1,'responses':0}
|
||||
fields=rest.split()
|
||||
try:
|
||||
address=str(ipaddress.ip_address(fields[0]))
|
||||
rtt=float(fields[1])
|
||||
if not 0<=rtt<=60000 or fields[2]!='ms':continue
|
||||
except (ValueError,IndexError):continue
|
||||
unreachable=any(token.startswith('!') for token in fields[3:])
|
||||
return {'hop':ttl,'ip':address,'latency_ms':rtt,'probes_sent':1,'responses':1,
|
||||
'state':'unreachable' if unreachable else 'reached' if address==str(target) else 'reply'}
|
||||
return None
|
||||
|
||||
|
||||
def trace_method(target, emit=print, *, method='udp', tcp_port=443, hops=30, wait=1.0,
|
||||
lang='zh', geo=True, records=None, on_result=None, deadline=None):
|
||||
"""Native UDP or fixed-system Linux traceroute ICMP/TCP, without elevation."""
|
||||
if method=='udp':
|
||||
return trace(target,emit,hops,wait,lang,geo,records,on_result,deadline)
|
||||
if method not in ('icmp','tcp') or not 1<=hops<=64 or not 1<=tcp_port<=65535 or not 0<wait<=10:
|
||||
raise ValueError('Invalid trace method or limits')
|
||||
address=ipaddress.ip_address(target)
|
||||
executable=shutil.which('traceroute',path='/usr/sbin:/usr/bin:/sbin:/bin') if sys.platform=='linux' else None
|
||||
if not executable:
|
||||
emit('TRACEROUTE_DEPENDENCY_MISSING · ICMP/TCP 需要系统 traceroute' if lang=='zh' else
|
||||
'TRACEROUTE_DEPENDENCY_MISSING · ICMP/TCP require system traceroute',flush=True)
|
||||
return 1
|
||||
from .worker import local_command
|
||||
deadline=deadline if deadline is not None else time.monotonic()+90
|
||||
locator=GeoLocator(lang,enabled=geo)
|
||||
for ttl in range(1,hops+1):
|
||||
remaining=deadline-time.monotonic()
|
||||
if remaining<=0:return 1
|
||||
argv=[executable,'-6' if address.version==6 else '-4','-n','-N','1','-q','1',
|
||||
'-f',str(ttl),'-m',str(ttl),'-w',str(wait),'-I' if method=='icmp' else '-T']
|
||||
if method=='tcp':argv+=['-p',str(tcp_port)]
|
||||
argv.append(str(address))
|
||||
try:code,text,error=local_command(argv,timeout=min(wait+2,remaining))
|
||||
except OSError:
|
||||
code,text,error=None,'','COMMAND_UNAVAILABLE'
|
||||
row=parse_traceroute_hop(text,ttl,address)
|
||||
if row is None:
|
||||
emit(error or 'TRACEROUTE_FAILED_OR_PERMISSION_DENIED',flush=True)
|
||||
return 1
|
||||
row.update(method=method,statistics_scope='one_probe_this_ttl_not_forwarding_loss')
|
||||
row['location']=clean(locator.lookup(row['ip'])) if row['ip'] else None
|
||||
row['response_loss_percent']=0.0 if row['responses'] else 100.0
|
||||
if records is not None:records.append(row)
|
||||
emit(f"{ttl:2d} {row['ip'] or '*':<39} {str(row['latency_ms']) if row['latency_ms'] is not None else '---':>8} ms {row['location'] or '---'}",flush=True)
|
||||
if on_result:on_result()
|
||||
if row['state']=='reached':return 0
|
||||
if code or row['state']=='unreachable':return 1
|
||||
return 1
|
||||
|
||||
|
||||
def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None, on_result=None, deadline=None):
|
||||
if not 1<=hops<=64 or not 0<wait<=10: raise ValueError('Invalid trace limits')
|
||||
address=ipaddress.ip_address(target)
|
||||
deadline=deadline if deadline is not None else time.monotonic()+90
|
||||
locator=GeoLocator(lang,enabled=geo)
|
||||
zh=lang=='zh';v6=address.version==6
|
||||
if sys.platform != 'linux':
|
||||
@@ -25,6 +90,9 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No
|
||||
sock.setsockopt(protocol,recverr,1) # Linux IPV6_RECVERR/IP_RECVERR
|
||||
sock.setblocking(False)
|
||||
for ttl in range(1,hops+1):
|
||||
if time.monotonic()>=deadline:
|
||||
emit('Route observation time budget exhausted (90s)',flush=True)
|
||||
return 1
|
||||
sock.setsockopt(protocol,socket.IPV6_UNICAST_HOPS if v6 else socket.IP_TTL,ttl)
|
||||
start=time.monotonic()
|
||||
endpoint=(str(address),33434+ttl,0,0) if v6 else (str(address),33434+ttl)
|
||||
@@ -48,19 +116,45 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No
|
||||
hop=socket.inet_ntop(family,data[24:40] if v6 else data[20:24])
|
||||
elapsed=(time.monotonic()-start)*1000
|
||||
location=clean(locator.lookup(hop))
|
||||
emit(f'{ttl:2d} {hop:<39} {elapsed:8.1f} ms {location}',flush=True)
|
||||
emit(f'{ttl:2d} {hop:<39} {elapsed:8.1f} ms {location} [reply 1/1; timeout 0/1]',flush=True)
|
||||
reached=(icmp_type==1 and icmp_code==4 if v6 else icmp_type==3 and icmp_code==3) and ipaddress.ip_address(hop)==address
|
||||
if records is not None: records.append({'hop':ttl,'ip':hop,'latency_ms':round(elapsed,3),'location':location,
|
||||
'probes_sent':1,'responses':1,'response_loss_percent':0.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss',
|
||||
'icmp_type':icmp_type,'icmp_code':icmp_code,'state':'reached' if reached else 'reply'})
|
||||
found=True
|
||||
if on_result is not None: on_result()
|
||||
if reached: return 0
|
||||
if icmp_type==(1 if v6 else 3):
|
||||
emit(f' ICMP 不可达(代码 {icmp_code})' if zh else f' ICMP unreachable (code {icmp_code})',flush=True)
|
||||
return 1
|
||||
if found: break
|
||||
if not found:
|
||||
emit(f'{ttl:2d} {"*":<39} {"---":>8} ---',flush=True)
|
||||
if records is not None: records.append({'hop':ttl,'ip':None,'latency_ms':None,'location':None,'state':'timeout'})
|
||||
emit(f'{ttl:2d} {"*":<39} {"---":>8} --- [reply 0/1; timeout 1/1]',flush=True)
|
||||
if records is not None: records.append({'hop':ttl,'ip':None,'latency_ms':None,'location':None,'state':'timeout',
|
||||
'probes_sent':1,'responses':0,'response_loss_percent':100.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss'})
|
||||
if on_result is not None: on_result()
|
||||
except (OSError,AttributeError):
|
||||
emit('原生 UDP 追踪不可用或未收到允许的响应' if zh else 'Native UDP trace unavailable or blocked',flush=True)
|
||||
return 1
|
||||
|
||||
|
||||
def summarize_hops(records):
|
||||
"""Statistics over actually sent TTL probes, never forwarding-loss claims."""
|
||||
import statistics
|
||||
grouped={}
|
||||
for row in records:
|
||||
ttl=row['hop']
|
||||
item=grouped.setdefault(ttl,{'hop':ttl,'probes_sent':0,'responses':0,'ips':set(),'rtts':[]})
|
||||
item['probes_sent']+=row.get('probes_sent',1)
|
||||
item['responses']+=row.get('responses',1 if row.get('ip') else 0)
|
||||
if row.get('ip'):item['ips'].add(row['ip'])
|
||||
if row.get('latency_ms') is not None:item['rtts'].append(row['latency_ms'])
|
||||
result=[]
|
||||
for ttl,item in sorted(grouped.items()):
|
||||
sent=item['probes_sent'];received=item['responses']
|
||||
result.append({'hop':ttl,'probes_sent':sent,'responses':received,'timeouts':sent-received,
|
||||
'response_loss_percent':round(100*(sent-received)/sent,2) if sent else None,
|
||||
'median_rtt_ms':statistics.median(item['rtts']) if item['rtts'] else None,
|
||||
'ips':sorted(item['ips']),
|
||||
'scope':'router_response_loss_only_not_end_to_end_forwarding_loss'})
|
||||
return result
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Small synchronous module boundary; no global process or signal changes."""
|
||||
from .reports import utc_now
|
||||
|
||||
|
||||
class ModuleRunner:
|
||||
def __init__(self, report, checkpoint, notify):
|
||||
self.report = report
|
||||
self.checkpoint = checkpoint
|
||||
self.notify = notify
|
||||
report['modules'] = {}
|
||||
|
||||
def run(self, name, operation=None, skip_reason=None):
|
||||
state = {'status': 'skipped' if skip_reason else 'running',
|
||||
'started_at': utc_now(), 'error_code': None}
|
||||
self.report['modules'][name] = state
|
||||
if skip_reason:
|
||||
state['reason'] = skip_reason
|
||||
state['finished_at'] = utc_now()
|
||||
self.checkpoint()
|
||||
return
|
||||
self.checkpoint()
|
||||
try:
|
||||
operation()
|
||||
state['status'] = 'success'
|
||||
except KeyboardInterrupt:
|
||||
state.update(status='cancelled', error_code='CANCELLED')
|
||||
raise
|
||||
except Exception:
|
||||
# Deliberate isolation boundary. Never echo exception text that may
|
||||
# include URLs, tokens, provider responses or sensitive local paths.
|
||||
state.update(status='failure', error_code='MODULE_ERROR')
|
||||
self.notify(name)
|
||||
finally:
|
||||
state['finished_at'] = utc_now()
|
||||
self.checkpoint()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Uniform evidence envelopes; legacy raw module payloads remain available."""
|
||||
from .reports import utc_now
|
||||
|
||||
STATUSES=frozenset(('success','failure','skipped','unsupported','unknown'))
|
||||
|
||||
|
||||
def normalize_report(report):
|
||||
rows=[]
|
||||
def add(identifier,category,status,method,source,data,error=None,limitations=None):
|
||||
rows.append({'check_id':identifier,'category':category,
|
||||
'status':status if status in STATUSES else 'unknown',
|
||||
'observed_at':data.get('observed_at',data.get('checked_at',report.get('generated_at',utc_now()))),
|
||||
'method':method,'source':source,'metrics':data,'error_code':error,
|
||||
'confidence':'provider_report' if method=='provider_query' else 'execution_only' if category=='execution' else 'direct_observation' if status in ('success','failure') else 'limited_or_unconfirmed',
|
||||
'limitations':limitations or ['Limited observation; not a complete security or availability guarantee.']})
|
||||
for name,item in report.get('modules',{}).items():
|
||||
add('module.'+name,'execution',item.get('status'),'module_execution','local',item,item.get('error_code') or item.get('reason'))
|
||||
for item in (report.get('system') or report).get('checks',[]):
|
||||
add('system.'+item['id'],'system',{'pass':'success','fail':'failure','skip':'skipped'}.get(item.get('status'),'unknown'),
|
||||
'readonly_check','local',item)
|
||||
for name in ('resources','security','bandwidth'):
|
||||
item=report.get(name)
|
||||
if item:
|
||||
add(name,name,'unsupported' if item.get('status')=='unsupported' else 'unknown',
|
||||
item.get('method','readonly_observation'),'local' if name!='bandwidth' else 'selected_https_sources',item,
|
||||
limitations=item.get('limitations'))
|
||||
network=report.get('network') or {}
|
||||
for i,item in enumerate(network.get('dns',[])):
|
||||
add(f'network.dns.{i}','network',item.get('status'),'system_resolver',item.get('target','example.com'),item,item.get('error_code'))
|
||||
for i,item in enumerate(network.get('transports',[])):
|
||||
for stage in ('tcp','tls'):
|
||||
evidence=item.get(stage,{})
|
||||
add(f'network.{stage}.{i}','network',evidence.get('status'),stage,item.get('hostname',item.get('target')),evidence,evidence.get('error_code'))
|
||||
route=report.get('route')
|
||||
if route:
|
||||
add('route.destination','route','success' if route.get('state')=='reached' else 'unknown',
|
||||
route.get('method','udp')+'_trace',route.get('target'),{'state':route.get('state')},
|
||||
limitations=['Router silence is not end-to-end packet loss; location is estimated.'])
|
||||
for i,item in enumerate(route.get('hops',[])):
|
||||
add(f'route.hop.{i}','route','success' if item.get('state') in ('reply','reached') else 'unknown',
|
||||
'single_'+route.get('method','udp')+'_probe',item.get('ip'),item,limitations=['One probe per TTL; response loss is not forwarding loss.'])
|
||||
for name in ('bgp','carrier_nodes','return_routes'):
|
||||
item=report.get(name)
|
||||
if item:
|
||||
add(name,name,'success' if item.get('status')=='success' else 'unknown',
|
||||
'provider_query' if name=='bgp' else 'active_network_observation',
|
||||
item.get('source','fixed_public_targets'),item,item.get('error_code'),item.get('limitations'))
|
||||
for name,item in (report.get('ip_quality') or {}).get('sources',{}).items():
|
||||
add('ip.'+name,'ip_quality','success' if item.get('state')=='ok' else 'skipped' if item.get('state')=='key_required' else 'unknown',
|
||||
'provider_query',name,item,item.get('state') if item.get('state')!='ok' else None)
|
||||
for name,item in report.get('media',{}).items():
|
||||
status = 'success' if item.get('scope')=='public_page_reachability' and item.get('state')=='reachable' else 'failure' if item.get('state')=='restricted' else 'unknown'
|
||||
add('media.'+name,'media',status,'http_reachability' if item.get('scope')=='public_page_reachability' else 'anonymous_service_evidence',name,item,
|
||||
limitations=['HTTP/catalog/offer visibility does not prove account playback.'])
|
||||
report['result_schema_version']=1
|
||||
report['result_items']=rows
|
||||
return rows
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Metadata-only file checks and local Ubuntu update evidence.
|
||||
|
||||
No privilege escalation, package refresh, network calls, private-file reads or
|
||||
automatic repairs. Findings are review prompts, never exploit confirmations.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
|
||||
from .reports import utc_now
|
||||
|
||||
|
||||
POLICIES = (
|
||||
('/etc/passwd',False), ('/etc/group',False),
|
||||
('/etc/shadow',True), ('/etc/gshadow',True),
|
||||
('/etc/sudoers',False), ('/etc/ssh/sshd_config',False),
|
||||
('/etc',False), ('/etc/ssh',False), ('/etc/sudoers.d',False),
|
||||
)
|
||||
|
||||
|
||||
def inspect_path(path, sensitive=False, *, lstat=os.lstat):
|
||||
row = {'source':path,'method':'lstat_metadata_only','status':'unknown',
|
||||
'observed_at':utc_now(),'reasons':[]}
|
||||
try:
|
||||
info = lstat(path)
|
||||
except FileNotFoundError:
|
||||
row.update(status='skipped',error_code='PATH_MISSING')
|
||||
return row
|
||||
except OSError:
|
||||
row['error_code'] = 'METADATA_UNAVAILABLE'
|
||||
return row
|
||||
mode = stat.S_IMODE(info.st_mode)
|
||||
row.update(mode=oct(mode),uid=info.st_uid,gid=info.st_gid)
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
row['error_code'] = 'SYMLINK_REQUIRES_REVIEW'
|
||||
return row
|
||||
if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
|
||||
row['error_code'] = 'UNEXPECTED_FILE_TYPE'
|
||||
return row
|
||||
if mode & stat.S_IWOTH:
|
||||
row['reasons'].append('WORLD_WRITABLE')
|
||||
if mode & stat.S_IWGRP:
|
||||
row['reasons'].append('GROUP_WRITABLE_REVIEW')
|
||||
if info.st_uid != 0:
|
||||
row['reasons'].append('OWNER_NOT_ROOT_REVIEW')
|
||||
if sensitive and mode & stat.S_IROTH:
|
||||
row['reasons'].append('SENSITIVE_WORLD_READABLE')
|
||||
row['status'] = 'review' if row['reasons'] else 'observed'
|
||||
row['limitations'] = ['POSIX mode/owner only; ACLs, capabilities, mount options and runtime use not evaluated.',
|
||||
'Group access or non-root ownership can be intentional; review in deployment context.']
|
||||
return row
|
||||
|
||||
|
||||
def read_small(path):
|
||||
try:
|
||||
with Path(path).open(encoding='utf-8') as stream:
|
||||
value = stream.read(8193)
|
||||
return value if len(value) <= 8192 else None
|
||||
except (OSError,UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def update_evidence(*, lstat=os.lstat, read=read_small, now=time.time):
|
||||
"""A timestamp is freshness evidence, never a vulnerability inventory."""
|
||||
row = {'method':'local_update_cache_only','observed_at':utc_now(),'status':'unknown',
|
||||
'pending_updates':None,'security_updates':None,'sources':[],
|
||||
'limitations':['No repository refresh or package solver was run.',
|
||||
'Missing or stale cache cannot establish whether the system is fully patched.']}
|
||||
for path in ('/var/lib/apt/periodic/update-success-stamp',
|
||||
'/var/lib/update-notifier/updates-available'):
|
||||
evidence = {'source':path,'status':'unknown'}
|
||||
try:
|
||||
info = lstat(path)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
evidence['error_code'] = 'NOT_REGULAR_FILE'
|
||||
else:
|
||||
timestamp = info.st_mtime
|
||||
age = now()-timestamp
|
||||
if age < 0:
|
||||
evidence['error_code'] = 'FUTURE_TIMESTAMP'
|
||||
else:
|
||||
evidence.update(status='observed',age_hours=round(age/3600,2),
|
||||
modified_at=datetime.fromtimestamp(timestamp,timezone.utc).isoformat())
|
||||
if path.endswith('updates-available'):
|
||||
# This notifier summary is optional and may be localized.
|
||||
# Never retain its complete text in logs or reports.
|
||||
text = read(path)
|
||||
match = re.search(r'^\s*(\d+) updates? can be applied immediately\.',text or '',re.M)
|
||||
if match:
|
||||
row['pending_updates'] = int(match[1])
|
||||
row['status'] = 'cached_observation'
|
||||
match = re.search(r'^(\d+) of these updates? (?:is|are) (?:a )?(?:standard )?security updates?\.',text or '',re.M)
|
||||
if match:
|
||||
row['security_updates'] = int(match[1])
|
||||
except FileNotFoundError:
|
||||
evidence['error_code'] = 'CACHE_MISSING'
|
||||
except (OSError,ValueError,OverflowError):
|
||||
evidence['error_code'] = 'CACHE_UNAVAILABLE'
|
||||
row['sources'].append(evidence)
|
||||
return row
|
||||
|
||||
|
||||
def collect(report=None, on_result=None, *, system=None, lstat=os.lstat, read=read_small, now=time.time):
|
||||
report = {} if report is None else report
|
||||
report.update(schema_version=1,observed_at=utc_now(),status='unsupported',files=[],updates=None,
|
||||
limitations=['Read-only hints, not a complete security audit.',
|
||||
'Cloud security groups and external reachability cannot be confirmed locally.'])
|
||||
if (system or platform.system()) != 'Linux':
|
||||
return report
|
||||
for path,sensitive in POLICIES:
|
||||
report['files'].append(inspect_path(path,sensitive,lstat=lstat))
|
||||
if on_result is not None:
|
||||
on_result()
|
||||
report['updates'] = update_evidence(lstat=lstat,read=read,now=now)
|
||||
report['status'] = 'partial'
|
||||
report['review_count'] = sum(item['status']=='review' for item in report['files'])
|
||||
if on_result is not None:
|
||||
on_result()
|
||||
return report
|
||||
|
||||
|
||||
def render(report, out):
|
||||
tr = out.text
|
||||
out.row(tr('权限检查范围','Permission scope'),tr('只读权限元数据','Read-only metadata'),'unknown')
|
||||
reasons = {
|
||||
'WORLD_WRITABLE':('所有用户可写','Writable by everyone'),
|
||||
'GROUP_WRITABLE_REVIEW':('同组可写,需审阅','Group-writable; review'),
|
||||
'OWNER_NOT_ROOT_REVIEW':('所有者非 root,需审阅','Non-root owner; review'),
|
||||
'SENSITIVE_WORLD_READABLE':('敏感文件所有用户可读','Sensitive file readable by everyone'),
|
||||
}
|
||||
for row in report['files']:
|
||||
if row['status'] == 'review':
|
||||
value = '; '.join(tr(*reasons[reason]) for reason in row['reasons'])
|
||||
elif row['status'] == 'observed':
|
||||
value = tr('模式位未见上述宽松项','No listed mode-bit finding')
|
||||
else:
|
||||
value = tr('未知/跳过','Unknown/skipped')+' ('+row.get('error_code','UNKNOWN')+')'
|
||||
out.row(row['source'],row.get('mode','---')+' · '+value,
|
||||
'bad' if row['status']=='review' else 'ok' if row['status']=='observed' else 'unknown')
|
||||
update = report.get('updates')
|
||||
if update:
|
||||
pending = update['pending_updates']
|
||||
out.row(tr('缓存待更新数','Cached pending updates'),tr('未知','Unknown') if pending is None else str(pending),'unknown')
|
||||
for source in update['sources']:
|
||||
if 'age_hours' in source:
|
||||
out.row(tr('更新缓存年龄','Update cache age'),f"{source['age_hours']:g} h · "+source['source'],'unknown')
|
||||
out.row(tr('安全结论','Security conclusion'),tr('本机只读观测;补丁状态未确认','Local read-only observations; patch status unconfirmed'),'unknown')
|
||||
|
||||
|
||||
def run_console(lang='zh', color='auto', report=None, on_result=None):
|
||||
from .console import Console
|
||||
report = collect(report,on_result)
|
||||
render(report,Console(lang,color))
|
||||
return 1 if report.get('review_count') or report['status'] == 'unsupported' else 0
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Consistent persistence lifecycle for individual CLI modules."""
|
||||
from .reports import ReportStore,UnavailableStore,utc_now
|
||||
from .schema import normalize_report
|
||||
from .reporting import sharing_copy
|
||||
from . import __version__
|
||||
|
||||
|
||||
class ScanSession:
|
||||
def __init__(self,key,path,lang):
|
||||
self.key=key
|
||||
self.lang=lang
|
||||
self.report={'schema_version':3,'tool_version':__version__,'generated_at':utc_now(),
|
||||
'mode':'read-only','result':'running',key:{}}
|
||||
try:self.store=ReportStore(path,lambda value:sharing_copy(value,lang))
|
||||
except FileExistsError:raise
|
||||
except OSError:self.store=UnavailableStore(path)
|
||||
self.failed=False
|
||||
|
||||
def checkpoint(self):
|
||||
normalize_report(self.report)
|
||||
if not self.store.checkpoint(self.report):self.failed=True
|
||||
|
||||
def __enter__(self):
|
||||
self.checkpoint()
|
||||
return self
|
||||
|
||||
def __exit__(self,kind,value,traceback):
|
||||
if kind is not None and issubclass(kind,KeyboardInterrupt):
|
||||
from .worker import cancel_all
|
||||
cancel_all()
|
||||
self.report['result']='cancelled'
|
||||
else:self.report['result']='incomplete' if kind else self.report['result'] if self.report['result']!='running' else 'local_execution_complete'
|
||||
self.report['finished_at']=utc_now()
|
||||
normalize_report(self.report)
|
||||
try:
|
||||
self.failed=not self.store.finish(self.report) or self.failed
|
||||
finally:self.store.close()
|
||||
if self.store.journal_path:
|
||||
print(('恢复日志: ' if self.lang=='zh' else 'Recovery journal: ')+str(self.store.journal_path),flush=True)
|
||||
if self.failed:print('REPORT_WRITE_FAILED'+(' / 报告写入未完成' if self.lang=='zh' else ''),flush=True)
|
||||
return False
|
||||
+263
-94
@@ -1,5 +1,4 @@
|
||||
"""Four-part console workflow. Capability and measurement limits are explicit."""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -7,10 +6,10 @@ import subprocess
|
||||
import time
|
||||
|
||||
from .checks import inspect
|
||||
from . import __version__
|
||||
from . import __version__, release_label
|
||||
from .quality import run_quality
|
||||
from .i18n import translator
|
||||
from .routes import trace as native_trace
|
||||
from .routes import trace as native_trace, trace_method as protocol_trace
|
||||
from .media import run_console as run_media
|
||||
from .bandwidth import run_console as measure_bandwidth, is_complete as bandwidth_complete
|
||||
from .ipquality import collect as collect_ipquality, render as render_ipquality
|
||||
@@ -25,107 +24,277 @@ def execute_provider(argv, timeout=180):
|
||||
|
||||
|
||||
def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttrace=None,
|
||||
region_script=None,max_hops=30,geo=True,color='auto',bandwidth_rounds=3,skip_route=False,json_output=None,bandwidth_sources=None,port=8388,show_ip=False,ip_enrichment=True):
|
||||
region_script=None,max_hops=30,geo=True,color='auto',bandwidth_rounds=3,skip_route=False,json_output=None,bandwidth_sources=None,port=8388,show_ip=False,ip_enrichment=True,allow_bandwidth=False,mail_probes=False,route_probes=3,theme='default',
|
||||
route_method='udp',tcp_port=443,include_return_routes=False,include_bgp=False):
|
||||
from .cli import redact
|
||||
from .console import Console
|
||||
import re
|
||||
ipaddress.ip_address(route_target)
|
||||
if not 1<=sample_mib<=20 or not 1<=max_hops<=64 or not 3<=bandwidth_rounds<=8 or not 1<=port<=65535: raise ValueError('Invalid test limits')
|
||||
if besttrace or region_script:
|
||||
raise ValueError('External executables are disabled in the safe workflow; use native checks')
|
||||
if not 1<=route_probes<=3:raise ValueError('Route probe rounds must be 1..3')
|
||||
if route_method not in ('udp','icmp','tcp') or not 1<=tcp_port<=65535 or theme not in ('default','iprisk','framed'):
|
||||
raise ValueError('Invalid route or theme options')
|
||||
out=Console(lang,color); tr=out.text
|
||||
iprisk = theme == 'iprisk'
|
||||
framed = theme == 'framed'
|
||||
from datetime import datetime,timezone
|
||||
report={'schema_version':2,'mode':'read-only','generated_at':datetime.now(timezone.utc).isoformat(),'target_port':port,'network':None,'route':None,'tool_version':__version__,'offline':offline,'bandwidth':None,'ip_quality':None,'media':{}}
|
||||
print(tr(f'ProxyDoctor {__version__} | 原生检测 | 联网模式' if not offline else f'ProxyDoctor {__version__} | 离线模式',
|
||||
f'ProxyDoctor {__version__} | Native checks | Online' if not offline else f'ProxyDoctor {__version__} | Offline'),flush=True)
|
||||
data=redact(inspect(lang=lang,port=port),lang)
|
||||
report['system']=data
|
||||
checks={item['id']:item for item in data['checks']}
|
||||
unknown=tr('未知','Unknown');incomplete=False
|
||||
out.section(1,'VPS 性能与资源','VPS resources and performance')
|
||||
system=checks['system']['evidence'].split(';')
|
||||
out.row(tr('系统','OS'),system[1].strip() if len(system)>1 else unknown)
|
||||
out.row(tr('架构','Architecture'),checks['architecture']['evidence'])
|
||||
out.row(tr('内核','Kernel'),checks['kernel']['detail'], 'ok' if checks['kernel']['status']=='pass' else 'unknown')
|
||||
cpu=checks['cpu']
|
||||
out.row('CPU',re.split('[::]',cpu['evidence'],maxsplit=1)[-1], 'ok' if cpu['status']=='pass' else 'unknown')
|
||||
count=re.search(r'\d+',cpu['detail'])
|
||||
out.row(tr('逻辑核心','Logical CPUs'),count.group() if count else unknown,'ok' if count else 'unknown')
|
||||
for key,zh,en in [('memory_total','总内存','RAM total'),('memory','可用内存','RAM available'),('disk_capacity','磁盘','Root filesystem')]:
|
||||
item=checks.get(key)
|
||||
value=re.split(r'[((]',item['detail'],maxsplit=1)[0] if item else unknown
|
||||
out.row(tr(zh,en),value,'ok' if item and item['status']=='pass' else 'unknown')
|
||||
statuses={'pass':tr('通过','Pass'),'warn':tr('注意','Warning'),'fail':tr('失败','Failed'),
|
||||
'unknown':tr('未知','Unknown'),'skip':tr('跳过','Skipped')}
|
||||
for key,zh,en in [('default_route','默认路由','Default route'),('dns_config','DNS 配置','DNS config'),
|
||||
('ports',f'本机端口 {port}',f'Local port {port}'),('ssh_port_safety','SSH 端口保护','SSH port guard'),
|
||||
('ssh','SSH 配置','SSH config'),('firewall','本机防火墙','Local firewall'),('cloud_firewall','云防火墙','Cloud firewall')]:
|
||||
item=checks[key]
|
||||
value=statuses[item['status']]
|
||||
if key=='ports' and item['status']=='pass': value=tr('未发现占用(仅本机)','No listener found (local only)')
|
||||
if key=='ports' and item['status']=='warn': value=tr('已占用','In use')
|
||||
if key=='ssh_port_safety' and item['status']=='warn': value=tr('目标端口与 SSH 冲突','Target conflicts with SSH')
|
||||
if key=='ssh' and item['status']=='warn': value=tr('允许 root 或密码认证','Root or password auth allowed')
|
||||
out.row(tr(zh,en),value,'bad' if item['status'] in ('warn','fail') else 'ok' if item['status']=='pass' else 'unknown')
|
||||
block=b'\x00'*1024**2;start=time.perf_counter()
|
||||
for _ in range(32): hashlib.sha256(block).digest()
|
||||
out.row('SHA-256',f'{32/max(time.perf_counter()-start,0.000001):.1f} MiB/s')
|
||||
if offline: out.row(tr('带宽','Bandwidth'),tr('跳过','Skipped'),'unknown')
|
||||
report={'schema_version':3,'mode':'read-only','generated_at':datetime.now(timezone.utc).isoformat(),'target_port':port,'network':None,'route':None,'tool_version':__version__,'offline':offline,'bandwidth':None,'bandwidth_consent':allow_bandwidth,'cpu_benchmark':'not_run','ip_quality':None,'media':{}}
|
||||
if framed:
|
||||
out.frame_open(tr('VPS 检测报告','VPS check report'), box_w=78, subtitle=release_label(lang) + ' · ' + __version__)
|
||||
out.frame_keyvalue(tr('模式','Mode'), tr('离线模式' if offline else '联网模式','Offline' if offline else 'Online'))
|
||||
out.frame_keyvalue(tr('目标端口','Target port'), str(port))
|
||||
elif iprisk:
|
||||
out.banner(' VPS CHECK ' + ('v' + __version__))
|
||||
out.line(release_label(lang), 'unknown')
|
||||
out.keyvalue(tr('只读体检报告 · 原生检测','Read-only diagnostic report · native checks'),
|
||||
tr('离线模式' if offline else '联网模式','Offline' if offline else 'Online'),'title')
|
||||
out.hrule()
|
||||
print(flush=True)
|
||||
else:
|
||||
bandwidth=measure_bandwidth(lang=lang,color=color,rounds=bandwidth_rounds,
|
||||
sample_mib=sample_mib,sources=bandwidth_sources)
|
||||
report['bandwidth']=bandwidth
|
||||
incomplete=not bandwidth_complete(bandwidth) or incomplete
|
||||
out.section(2,'节点网络质量','Node network quality')
|
||||
if offline: out.row(tr('网络','Network'),tr('跳过','Skipped'),'unknown')
|
||||
else:
|
||||
network={}
|
||||
incomplete=bool(run_quality(translator(lang),compact=True,lang=lang,color=color,report=network)) or incomplete
|
||||
report['network']=network
|
||||
metadata=collect_ipquality(enrich=ip_enrichment)
|
||||
out.line(release_label(lang), 'unknown')
|
||||
print(tr(f'VPS 检测工具 {__version__} | 原生检测 | 联网模式' if not offline else f'VPS 检测工具 {__version__} | 离线模式',
|
||||
f'VPS Check {__version__} | Native checks | Online' if not offline else f'VPS Check {__version__} | Offline'),flush=True)
|
||||
from .reports import ReportStore, UnavailableStore, utc_now
|
||||
from .runner import ModuleRunner
|
||||
data = {'checks': []}
|
||||
unknown = tr('未知','Unknown')
|
||||
incomplete = False
|
||||
report['result'] = 'running'
|
||||
# Open an exclusive recovery journal before starting any checks.
|
||||
try:
|
||||
store = ReportStore(json_output, lambda value: redact(value, lang))
|
||||
except FileExistsError:
|
||||
raise # A collision must be resolved before running any checks.
|
||||
except OSError:
|
||||
store = UnavailableStore(json_output)
|
||||
persistence_warning = False
|
||||
|
||||
def checkpoint():
|
||||
nonlocal persistence_warning
|
||||
from .schema import normalize_report
|
||||
normalize_report(report)
|
||||
if not store.checkpoint(report) and not persistence_warning:
|
||||
persistence_warning = True
|
||||
out.row(tr('报告保存','Report storage'),tr('写入失败;保留已有日志并继续终端检测','Write failed; retaining prior journal and continuing terminal checks'),'bad')
|
||||
|
||||
def module_error(name):
|
||||
out.row(name, tr('本模块异常,继续其他检测(MODULE_ERROR)','Module failed; continuing other checks (MODULE_ERROR)'),'bad')
|
||||
|
||||
runner = ModuleRunner(report, checkpoint, module_error)
|
||||
|
||||
def heading(number, zh, en, kind='b_cyan'):
|
||||
if iprisk:
|
||||
out.theme_section(number, zh, en, kind)
|
||||
else:
|
||||
out.section(number, zh, en)
|
||||
|
||||
def collect_system():
|
||||
nonlocal data, incomplete
|
||||
data=redact(inspect(lang=lang,port=port),lang)
|
||||
report['system']=data
|
||||
checks={item['id']:item for item in data['checks']}
|
||||
unknown=tr('未知','Unknown')
|
||||
heading(1,'基础信息','Basic information')
|
||||
system=checks['system']['evidence'].split(';')
|
||||
out.row(tr('系统','OS'),system[1].strip() if len(system)>1 else unknown)
|
||||
out.row(tr('架构','Architecture'),checks['architecture']['evidence'])
|
||||
out.row(tr('内核','Kernel'),checks['kernel']['detail'], 'ok' if checks['kernel']['status']=='pass' else 'unknown')
|
||||
cpu=checks['cpu']
|
||||
out.row('CPU',re.split('[::]',cpu['evidence'],maxsplit=1)[-1], 'ok' if cpu['status']=='pass' else 'unknown')
|
||||
# Allocation-focused numbers are rendered by the resources module.
|
||||
# Keep raw OS-visible values in structured evidence, not as VPS limits.
|
||||
for key,zh,en in [('disk_capacity','磁盘','Root filesystem')]:
|
||||
item=checks.get(key)
|
||||
value=re.split(r'[((]',item['detail'],maxsplit=1)[0] if item else unknown
|
||||
out.row(tr(zh,en),value,'ok' if item and item['status']=='pass' else 'unknown')
|
||||
statuses={'pass':tr('通过','Pass'),'warn':tr('注意','Warning'),'fail':tr('失败','Failed'),
|
||||
'unknown':tr('未知','Unknown'),'skip':tr('跳过','Skipped')}
|
||||
for key,zh,en in [('default_route','默认路由','Default route'),('dns_config','DNS 配置','DNS config'),
|
||||
('ports',f'本机端口 {port}',f'Local port {port}'),('ssh_port_safety','SSH 端口保护','SSH port guard'),
|
||||
('ssh','SSH 配置','SSH config'),('firewall','本机防火墙','Local firewall'),('cloud_firewall','云防火墙','Cloud firewall')]:
|
||||
item=checks[key]
|
||||
value=statuses[item['status']]
|
||||
if key=='ports' and item['status']=='pass': value=tr('未发现占用(仅本机)','No listener found (local only)')
|
||||
if key=='ports' and item['status']=='warn': value=tr('已占用','In use')
|
||||
if key=='ssh_port_safety' and item['status']=='warn': value=tr('目标端口与 SSH 冲突','Target conflicts with SSH')
|
||||
if key=='ssh' and item['status']=='warn': value=tr('允许 root 或密码认证','Root or password auth allowed')
|
||||
out.row(tr(zh,en),value,'bad' if item['status'] in ('warn','fail') else 'ok' if item['status']=='pass' else 'unknown')
|
||||
out.row('CPU benchmark',tr('未运行:常规检测不执行性能基准','Not run: routine checks do not benchmark CPU'),'unknown')
|
||||
|
||||
def collect_bandwidth():
|
||||
nonlocal data, incomplete
|
||||
if offline or not allow_bandwidth:
|
||||
out.row(tr('带宽','Bandwidth'),tr('跳过;使用 --allow-bandwidth 明确允许测速','Skipped; use --allow-bandwidth to consent to traffic tests'),'unknown')
|
||||
else:
|
||||
report['bandwidth']={}
|
||||
bandwidth=measure_bandwidth(lang=lang,color=color,rounds=bandwidth_rounds,
|
||||
sample_mib=sample_mib,sources=bandwidth_sources,destination=report['bandwidth'],on_result=checkpoint,out=out)
|
||||
report['bandwidth']=bandwidth
|
||||
incomplete=not bandwidth_complete(bandwidth) or incomplete
|
||||
|
||||
def collect_resources():
|
||||
from .resources import collect, render
|
||||
resource_data = collect()
|
||||
report['resources'] = resource_data
|
||||
render(resource_data, out)
|
||||
|
||||
def collect_security():
|
||||
nonlocal incomplete
|
||||
from .security import collect as collect_security_data, render as render_security
|
||||
security_data = {}
|
||||
report['security'] = security_data
|
||||
if framed:
|
||||
out.frame_line(tr('基础安全(只读)','Basic security (read-only)'), 'title')
|
||||
else:
|
||||
print(out.paint(tr('--- 基础安全(只读) ---','--- Basic security (read-only) ---'),'title'),flush=True)
|
||||
collect_security_data(security_data, on_result=checkpoint)
|
||||
render_security(security_data, out)
|
||||
incomplete = bool(security_data.get('review_count') or security_data['status'] == 'unsupported') or incomplete
|
||||
|
||||
def collect_network():
|
||||
nonlocal data, incomplete
|
||||
heading(2,'节点网络质量','Node network quality')
|
||||
if offline: out.row(tr('网络','Network'),tr('跳过','Skipped'),'unknown')
|
||||
else:
|
||||
from .connectivity import run_console as run_connectivity
|
||||
network={}
|
||||
report['network']=network
|
||||
incomplete=bool(run_connectivity(lang=lang,color=color,report=network,on_result=checkpoint,out=out)) or incomplete
|
||||
|
||||
def collect_ip_quality():
|
||||
nonlocal data, incomplete
|
||||
report['ip_quality']={}
|
||||
metadata=collect_ipquality(enrich=ip_enrichment,mail=mail_probes,destination=report['ip_quality'],on_result=checkpoint)
|
||||
report['ip_quality']=metadata
|
||||
render_ipquality(metadata,out,show_ip)
|
||||
incomplete=not metadata['coverage']['complete'] or incomplete
|
||||
out.section(3,'路由追踪','Route trace')
|
||||
if offline or skip_route: out.row(tr('路由','Route'),tr('跳过','Skipped'),'unknown')
|
||||
else:
|
||||
print(tr(f'目标 {route_target} · 最多 {max_hops} 跳 · 位置为数据库估计',
|
||||
f'Target {route_target} · up to {max_hops} hops · estimated IP location'),flush=True)
|
||||
print(tr('跳 IP 地址 延迟 位置',
|
||||
'Hop IP address Latency Location'),flush=True)
|
||||
route={'target':route_target,'max_hops':max_hops,'geo_enabled':geo,'hops':[]}
|
||||
report['route']=route
|
||||
if besttrace and Path(besttrace).is_file():
|
||||
route['provider']='external_besttrace'
|
||||
route['state']='external_output_only'
|
||||
incomplete=bool(execute_provider([str(Path(besttrace).resolve()),route_target],120)) or incomplete
|
||||
|
||||
def collect_route():
|
||||
nonlocal data, incomplete
|
||||
heading(3,'路由追踪','Route trace')
|
||||
if offline or skip_route: out.row(tr('路由','Route'),tr('跳过','Skipped'),'unknown')
|
||||
else:
|
||||
def route_output(line,**kwargs):
|
||||
print(out.paint(line,'unknown' if any(word in line for word in ('*','unavailable','未知','Unknown')) else 'ok',preserve_space=True),flush=True)
|
||||
route['provider']='native_udp'
|
||||
route_code=native_trace(route_target,emit=route_output,hops=max_hops,lang=lang,geo=geo,records=route['hops'])
|
||||
route['state']='reached' if route_code==0 else 'incomplete'
|
||||
incomplete=bool(route_code) or incomplete
|
||||
out.section(4,'流媒体区域限制','Streaming region restrictions')
|
||||
if offline: out.row(tr('流媒体','Streaming'),tr('跳过','Skipped'),'unknown')
|
||||
elif region_script:
|
||||
path=Path(region_script).resolve();bash=shutil.which('bash')
|
||||
if path.is_file() and bash: incomplete=bool(execute_provider([bash,str(path)],600)) or incomplete
|
||||
else: out.row('RegionRestrictionCheck',unknown,'unknown');incomplete=True
|
||||
out.line(tr(f'目标 {route_target} · 最多 {max_hops} 跳 · 位置为数据库估计',
|
||||
f'Target {route_target} · up to {max_hops} hops · estimated IP location'))
|
||||
out.line(tr('跳 IP 地址 延迟 位置',
|
||||
'Hop IP address Latency Location'))
|
||||
out.line(tr('每跳仅1次探测;响应/超时计数不等于转发丢包率。','One probe per hop; response/timeout counts are not forwarding-loss estimates.'))
|
||||
route={'target':route_target,'max_hops':max_hops,'geo_enabled':geo,'method':route_method,'hops':[]}
|
||||
report['route']=route
|
||||
if besttrace and Path(besttrace).is_file():
|
||||
route['provider']='external_besttrace'
|
||||
route['state']='external_output_only'
|
||||
incomplete=bool(execute_provider([str(Path(besttrace).resolve()),route_target],120)) or incomplete
|
||||
else:
|
||||
def route_output(line,**kwargs):
|
||||
out.line(line,'unknown' if any(word in line for word in ('*','unavailable','未知','Unknown')) else 'ok')
|
||||
route['provider']='native_udp' if route_method=='udp' else 'system_traceroute_'+route_method
|
||||
from .routes import summarize_hops
|
||||
deadline=time.monotonic()+90
|
||||
replies=0;attempts=0
|
||||
route['probe_rounds_requested']=route_probes
|
||||
for number in range(route_probes):
|
||||
if time.monotonic()>=deadline:break
|
||||
out.line(tr(f'路由探测 {number+1}/{route_probes}',f'Route probe {number+1}/{route_probes}'))
|
||||
options=dict(emit=route_output,hops=max_hops,lang=lang,geo=geo and number==0,
|
||||
records=route['hops'],on_result=checkpoint,deadline=deadline)
|
||||
route_code=(native_trace(route_target,**options) if route_method=='udp' else
|
||||
protocol_trace(route_target,method=route_method,tcp_port=tcp_port,**options))
|
||||
attempts+=1;replies+=route_code==0
|
||||
route['statistics']=summarize_hops(route['hops'])
|
||||
checkpoint()
|
||||
route.update(state='reached' if replies else 'incomplete',probe_rounds_run=attempts,destination_responses=replies)
|
||||
for row in route.get('statistics',[]):
|
||||
out.row(tr('跳','Hop')+' '+str(row['hop']),
|
||||
f"reply {row['responses']}/{row['probes_sent']} · timeout {row['timeouts']} · response-loss {row['response_loss_percent']}%",'unknown')
|
||||
out.line(tr('以上是路由器响应缺失统计,不是线路转发丢包率。','These are router-response statistics, not forwarding packet-loss estimates.'))
|
||||
incomplete=replies<route_probes or incomplete
|
||||
|
||||
def collect_media():
|
||||
nonlocal data, incomplete
|
||||
heading(4,'流媒体可达性','Media reachability')
|
||||
if offline: out.row(tr('流媒体','Streaming'),tr('跳过','Skipped'),'unknown')
|
||||
elif region_script:
|
||||
path=Path(region_script).resolve();bash=shutil.which('bash')
|
||||
if path.is_file() and bash: incomplete=bool(execute_provider([bash,str(path)],600)) or incomplete
|
||||
else: out.row('RegionRestrictionCheck',unknown,'unknown');incomplete=True
|
||||
else:
|
||||
run_media(lang,color,results=report['media'],on_result=checkpoint,out=out)
|
||||
incomplete=any(item.get('state')!='reachable' for item in report['media'].values()) or incomplete
|
||||
|
||||
def collect_return_routes():
|
||||
nonlocal incomplete
|
||||
from .carriers import return_routes
|
||||
out.line(tr('三城三运营商 · 方向:本机 → 中国目标','Three cities × three carriers · this host → China targets'),'title')
|
||||
report['return_routes']={}
|
||||
return_routes(method=route_method,hops=max_hops,geo=False,tcp_port=tcp_port,
|
||||
destination=report['return_routes'],on_result=checkpoint,out=out)
|
||||
incomplete=report['return_routes']['status']!='success' or incomplete
|
||||
|
||||
def collect_bgp():
|
||||
nonlocal incomplete
|
||||
from .bgp import collect,render
|
||||
address=(report.get('ip_quality') or {}).get('ip')
|
||||
if not address:
|
||||
report['bgp']={'status':'unknown','error_code':'EGRESS_IP_UNAVAILABLE'}
|
||||
out.row('BGP','EGRESS_IP_UNAVAILABLE','unknown');incomplete=True
|
||||
return
|
||||
report['bgp']={}
|
||||
collect(address,report['bgp'],checkpoint);render(report['bgp'],out)
|
||||
incomplete=report['bgp']['status']!='success' or incomplete
|
||||
|
||||
cancelled = False
|
||||
try:
|
||||
runner.run('system', collect_system)
|
||||
runner.run('resources', collect_resources)
|
||||
runner.run('security', collect_security)
|
||||
runner.run('bandwidth', collect_bandwidth,
|
||||
'offline' if offline else 'consent_required' if not allow_bandwidth else None)
|
||||
if offline:
|
||||
heading(2,'节点网络质量','Node network quality')
|
||||
out.row('DNS / TCP / TLS',tr('离线跳过','Skipped offline'),'unknown')
|
||||
runner.run('network', collect_network, 'offline' if offline else None)
|
||||
runner.run('ip_quality', collect_ip_quality, 'offline' if offline else None)
|
||||
if offline or skip_route:
|
||||
heading(3,'路由追踪','Route trace')
|
||||
out.row(tr('路由','Route'),tr('已跳过','Skipped'),'unknown')
|
||||
runner.run('route', collect_route, 'offline' if offline else 'user_skipped' if skip_route else None)
|
||||
if offline:
|
||||
heading(4,'流媒体可达性','Media reachability')
|
||||
out.row(tr('流媒体','Media'),tr('离线跳过','Skipped offline'),'unknown')
|
||||
runner.run('media', collect_media, 'offline' if offline else None)
|
||||
if include_return_routes:runner.run('return_routes',collect_return_routes,'offline' if offline else None)
|
||||
if include_bgp:runner.run('bgp',collect_bgp,'offline' if offline else None)
|
||||
except KeyboardInterrupt:
|
||||
from .worker import cancel_all
|
||||
cancel_all()
|
||||
cancelled = True
|
||||
finally:
|
||||
report['finished_at'] = utc_now()
|
||||
failed = any(item['status'] == 'failure' for item in report['modules'].values())
|
||||
report['result'] = 'cancelled' if cancelled else 'incomplete' if incomplete or failed else 'local_execution_complete'
|
||||
report['summary'] = {
|
||||
'playback': 'not_tested',
|
||||
'system_findings': sum(item['status'] in ('warn','fail') for item in data['checks']),
|
||||
'failed_modules': sum(item['status'] == 'failure' for item in report['modules'].values()),
|
||||
'permission_reviews': report.get('security',{}).get('review_count',0),
|
||||
'media': {state: sum(isinstance(item, dict) and item.get('state') == state
|
||||
for item in report['media'].values())
|
||||
for state in ('reachable','access_denied','rate_limited','http_error','unknown')},
|
||||
}
|
||||
from .schema import normalize_report
|
||||
normalize_report(report)
|
||||
saved = store.finish(report)
|
||||
store.close()
|
||||
out.row(tr('体检结果','Results'), report['result'], 'unknown' if cancelled or incomplete or failed else 'ok')
|
||||
if saved and not store.failed:
|
||||
out.row(tr('脱敏报告','Redacted report'), str(store.path))
|
||||
else:
|
||||
report['media']=run_media(lang,color)
|
||||
incomplete=True # Account/DRM playback is intentionally not claimed.
|
||||
print(tr('\n说明:带宽按来源独立统计 HTTPS 多轮吞吐(含建连);地区套餐/片库可见不等于账号播放。IP 标记与举报为第三方记录,零举报不保证安全。',
|
||||
'\nNotes: per-source repeated HTTPS throughput, including setup. Offers/catalogs do not prove account playback. Provider flags and reports are evidence; zero reports do not guarantee safety.'),flush=True)
|
||||
report['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
report['result']='incomplete' if incomplete else 'local_execution_complete'
|
||||
counts={state:sum(item.get('state')==state for item in report['media'].values()) for state in ('restricted','offer_available','catalog_visible','metadata_available','unknown')}
|
||||
report['summary']={'media':counts,'playback':'not_tested','system_findings':sum(item['status'] in ('warn','fail') for item in data['checks'])}
|
||||
print(tr(f"体检结果:{report['summary']['system_findings']} 项本机提示 · 媒体限制 {counts['restricted']} · 媒体未知 {counts['unknown']}",
|
||||
f"Results: {report['summary']['system_findings']} local findings · Media restricted {counts['restricted']} · Media unknown {counts['unknown']}"),flush=True)
|
||||
if json_output:
|
||||
import json
|
||||
with Path(json_output).open('x',encoding='utf-8') as stream:
|
||||
json.dump(redact(report,lang),stream,ensure_ascii=False,indent=2)
|
||||
print(tr('脱敏 JSON 已保存:','Redacted JSON saved: ')+str(json_output),flush=True)
|
||||
return 1 if incomplete else 0
|
||||
|
||||
|
||||
out.row(tr('报告保存','Report storage'), tr('保存未完成,请保留已有恢复日志','Save incomplete; retain the existing recovery journal'),'bad')
|
||||
if store.journal_path is not None:
|
||||
out.row(tr('恢复日志','Recovery journal'), str(store.journal_path))
|
||||
if framed:
|
||||
out.frame_close()
|
||||
return 130 if cancelled else 1 if incomplete or failed or store.failed else 0
|
||||
@@ -0,0 +1,117 @@
|
||||
"""CLI-only hard boundaries for network operations, including blocking DNS."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
_active=set()
|
||||
_lock=threading.Lock()
|
||||
_cancelled=threading.Event()
|
||||
|
||||
|
||||
def cancel_all():
|
||||
_cancelled.set()
|
||||
with _lock:
|
||||
for process in tuple(_active):
|
||||
if process.poll() is None:
|
||||
try:process.kill()
|
||||
except OSError:pass
|
||||
|
||||
|
||||
def reset():
|
||||
_cancelled.clear()
|
||||
|
||||
|
||||
def enabled():
|
||||
return os.environ.get('PROXYDOCTOR_NETWORK_WORKERS','1')=='1'
|
||||
|
||||
|
||||
def local_command(argv, timeout=4):
|
||||
"""Bound a caller-validated local probe and register it for cancellation."""
|
||||
if _cancelled.is_set():
|
||||
return None, '', 'CANCELLED'
|
||||
with subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, encoding='utf-8', errors='replace',
|
||||
env={**os.environ, 'LC_ALL':'C'}) as process:
|
||||
with _lock:
|
||||
_active.add(process)
|
||||
if _cancelled.is_set():process.kill()
|
||||
try:
|
||||
output, _ = process.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill();process.communicate()
|
||||
return None, '', 'COMMAND_TIMEOUT'
|
||||
except BaseException:
|
||||
process.kill();process.communicate()
|
||||
raise
|
||||
finally:
|
||||
with _lock:_active.discard(process)
|
||||
if len(output)>16384:
|
||||
return None, '', 'COMMAND_OUTPUT_LIMIT'
|
||||
return process.returncode, output, None
|
||||
|
||||
|
||||
def invoke(action,options,timeout=20):
|
||||
if action not in ('json','page','transfer','carrier_probe','resolve_public'):
|
||||
raise ValueError('Unsupported network action')
|
||||
if _cancelled.is_set():
|
||||
return None
|
||||
try:
|
||||
bootstrap='import sys;sys.path.insert(0,sys.argv.pop(1));from proxydoctor.worker import main;sys.exit(main())'
|
||||
with subprocess.Popen([sys.executable,'-I','-B','-c',bootstrap,str(Path(__file__).resolve().parent.parent),action],
|
||||
stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
|
||||
text=True,encoding='utf-8') as process:
|
||||
with _lock:
|
||||
_active.add(process)
|
||||
if _cancelled.is_set():process.kill()
|
||||
try:
|
||||
output,_=process.communicate(json.dumps(options),timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill();process.communicate()
|
||||
return None
|
||||
except KeyboardInterrupt:
|
||||
cancel_all();process.communicate()
|
||||
raise
|
||||
finally:
|
||||
with _lock:_active.discard(process)
|
||||
if process.returncode or len(output)>2*1024**2:
|
||||
return None
|
||||
return json.loads(output)
|
||||
except (OSError,ValueError,subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
os.environ['PROXYDOCTOR_NETWORK_WORKERS']='0'
|
||||
options=json.loads(sys.stdin.read(65537))
|
||||
action=sys.argv[1]
|
||||
if action=='json':
|
||||
from .net import request_json
|
||||
result=request_json(**options)
|
||||
elif action=='page':
|
||||
from dataclasses import asdict
|
||||
from .media import fetch_page
|
||||
result=asdict(fetch_page(**options))
|
||||
elif action=='transfer':
|
||||
from dataclasses import asdict
|
||||
from .bandwidth import transfer
|
||||
result=asdict(transfer(**options))
|
||||
elif action=='carrier_probe':
|
||||
from .carriers import probe_node
|
||||
result=probe_node(**options)
|
||||
elif action=='resolve_public':
|
||||
from .carriers import resolve_public
|
||||
result=resolve_public(**options)
|
||||
else:
|
||||
return 2
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (Exception,KeyboardInterrupt):
|
||||
sys.exit(1) # Never print provider exceptions/credentials.
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "proxydoctor"
|
||||
version = "0.5.0"
|
||||
version = "1.0.0b1"
|
||||
description = "Read-only Ubuntu preflight and security checks"
|
||||
readme = "README.en.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Unit fixtures exercise direct transports; real CLI enables process guards."""
|
||||
import pytest
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def direct_mockable_transports(monkeypatch):
|
||||
monkeypatch.setenv('PROXYDOCTOR_NETWORK_WORKERS','0')
|
||||
@@ -0,0 +1,105 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from proxydoctor.schema import normalize_report,STATUSES
|
||||
from proxydoctor.reports import ReportStore,recover_latest
|
||||
from proxydoctor import worker
|
||||
|
||||
|
||||
def test_uniform_envelope_preserves_unknown_and_restriction():
|
||||
report={'generated_at':'2026-11-01','modules':{'network':{'status':'running'}},
|
||||
'media':{'Netflix':{'state':'catalog_visible'},'Disney+':{'state':'restricted'}}}
|
||||
rows=normalize_report(report)
|
||||
assert {row['status'] for row in rows}<=STATUSES
|
||||
assert next(row for row in rows if row['check_id']=='media.Netflix')['status']=='unknown'
|
||||
assert next(row for row in rows if row['check_id']=='media.Disney+')['status']=='failure'
|
||||
assert all(set(('check_id','observed_at','method','source','metrics','limitations','error_code'))<=set(row) for row in rows)
|
||||
|
||||
|
||||
def test_route_statistics_count_only_actual_probe_records():
|
||||
from proxydoctor.routes import summarize_hops
|
||||
rows=[{'hop':1,'ip':'192.0.2.1','latency_ms':10,'probes_sent':1,'responses':1},
|
||||
{'hop':1,'ip':None,'latency_ms':None,'probes_sent':1,'responses':0},
|
||||
{'hop':1,'ip':'192.0.2.1','latency_ms':20,'probes_sent':1,'responses':1}]
|
||||
result=summarize_hops(rows)[0]
|
||||
assert result['probes_sent']==3 and result['responses']==2 and result['timeouts']==1
|
||||
assert result['response_loss_percent']==33.33
|
||||
assert result['median_rtt_ms']==15
|
||||
assert 'not_end_to_end' in result['scope']
|
||||
|
||||
|
||||
def test_journal_budget_preserves_previous_complete_record(tmp_path):
|
||||
store=ReportStore(tmp_path/'report.json',lambda x:x)
|
||||
assert store.checkpoint({'small':1})
|
||||
store.bytes_written=64*1024**2
|
||||
assert not store.checkpoint({'small':2})
|
||||
store.close()
|
||||
assert recover_latest(store.journal_path)=={'small':1}
|
||||
|
||||
|
||||
def test_network_worker_timeout_kills_and_reaps(monkeypatch):
|
||||
processes=[]
|
||||
real=worker.subprocess.Popen
|
||||
def child(*args,**kwargs):
|
||||
process=real([sys.executable,'-c','import time;time.sleep(30)'],**kwargs)
|
||||
processes.append(process)
|
||||
return process
|
||||
monkeypatch.setattr(worker.subprocess,'Popen',child)
|
||||
worker.reset()
|
||||
started=time.monotonic()
|
||||
assert worker.invoke('json',{},timeout=0.1) is None
|
||||
assert time.monotonic()-started<5
|
||||
assert processes[0].poll() is not None
|
||||
|
||||
|
||||
def test_worker_cancellation_prevents_new_requests():
|
||||
worker.cancel_all()
|
||||
try:assert worker.invoke('json',{}) is None
|
||||
finally:worker.reset()
|
||||
|
||||
|
||||
def test_installer_integrity_detects_tampering(tmp_path):
|
||||
source=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('alpha_install',source/'install.py')
|
||||
installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer)
|
||||
files=installer.verify(source)
|
||||
assert 'proxydoctor/cli.py' in files
|
||||
# A changed copy must fail before creating any environment or executing code.
|
||||
import shutil
|
||||
copied=tmp_path/'bundle';copied.mkdir()
|
||||
for name in files:
|
||||
target=copied/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copyfile(source/name,target)
|
||||
shutil.copyfile(source/'MANIFEST.sha256.json',copied/'MANIFEST.sha256.json')
|
||||
(copied/'proxydoctor/cli.py').write_text('tampered')
|
||||
with pytest.raises(ValueError,match='integrity'):installer.verify(copied)
|
||||
|
||||
|
||||
def test_installer_manifest_cannot_escape_source(tmp_path):
|
||||
source=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('alpha_install',source/'install.py')
|
||||
installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer)
|
||||
bad={'version':installer.VERSION,'files':{'../outside.py':'x'}}
|
||||
(tmp_path/'MANIFEST.sha256.json').write_text(json.dumps(bad))
|
||||
with pytest.raises(ValueError):installer.verify(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure',[KeyboardInterrupt,subprocess.TimeoutExpired])
|
||||
def test_installer_failure_cleans_reserved_directory_only(tmp_path,monkeypatch,failure):
|
||||
source=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('alpha_install',source/'install.py')
|
||||
installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer)
|
||||
monkeypatch.setattr(installer.sys,'platform','linux')
|
||||
def broken(*args,**kwargs):
|
||||
if failure is KeyboardInterrupt:raise KeyboardInterrupt
|
||||
raise subprocess.TimeoutExpired('venv',45)
|
||||
monkeypatch.setattr(installer.subprocess,'run',broken)
|
||||
neighbor=tmp_path/'keep.txt';neighbor.write_text('keep')
|
||||
with pytest.raises(failure):installer.install(source,tmp_path/'new-prefix')
|
||||
assert not (tmp_path/'new-prefix').exists()
|
||||
assert neighbor.read_text()=='keep'
|
||||
@@ -71,12 +71,12 @@ def test_multiple_sources_stay_separate_with_correct_budget(monkeypatch):
|
||||
calls=[];events=[];samples=[]
|
||||
def transfer(direction,size,source='cloudflare'):
|
||||
calls.append((source,direction,size))
|
||||
speed={'cloudflare':10,'hetzner-fsn':30,'ovh-rbx':50}[source]
|
||||
speed={'cloudflare':10,'hetzner-fsn':30,'ovh-rbx':50}.get(source,60)
|
||||
return bw.Sample(direction,size,2,speed,source=source)
|
||||
monkeypatch.setattr(bw,'transfer',transfer)
|
||||
report=bw.measure_many(sources=tuple(bw.SOURCES),on_source=lambda *args:events.append(args[:2]),on_sample=lambda *args:samples.append(args))
|
||||
assert report['payload_budget_bytes']==int(48.5*1024**2)
|
||||
assert len(calls)==16 and len(samples)==12
|
||||
assert report['payload_budget_bytes']==int(97*1024**2)
|
||||
assert len(calls)==32 and len(samples)==24
|
||||
assert report['sources']['hetzner-fsn']['directions']['download']['median_mbps']==30
|
||||
assert report['sources']['ovh-rbx']['directions']['download']['median_mbps']==50
|
||||
assert 'upload' not in report['sources']['ovh-rbx']['directions']
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Real interpreter regressions for BETA22-001; no dependency on remote APIs."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
|
||||
def copy_package(destination):
|
||||
package=destination/'proxydoctor';package.mkdir(parents=True)
|
||||
for source in (Path(__file__).resolve().parents[1]/'proxydoctor').glob('*.py'):
|
||||
shutil.copyfile(source,package/source.name)
|
||||
return package
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action,options',[
|
||||
('json',{'url':'http://127.0.0.1:1/'}),
|
||||
('page',{'url':'http://127.0.0.1:1/'}),
|
||||
('transfer',{'direction':'invalid','size':0}),
|
||||
('carrier_probe',{'identifier':'invalid'}),
|
||||
('resolve_public',{'host':'127.0.0.1','family':4}),
|
||||
])
|
||||
def test_real_worker_never_creates_bytecode_in_fresh_package(tmp_path,action,options):
|
||||
package=copy_package(tmp_path/'fresh')
|
||||
program='''import json,sys
|
||||
sys.path.insert(0,sys.argv[1])
|
||||
from proxydoctor.worker import invoke,reset
|
||||
reset()
|
||||
result=invoke(sys.argv[2],json.loads(sys.argv[3]),timeout=8)
|
||||
print(json.dumps({'completed':True,'response':result}))
|
||||
'''
|
||||
env={**os.environ,'PYTHONDONTWRITEBYTECODE':'0'}
|
||||
# -I ignores environment-only bytecode settings. The worker must set -B in
|
||||
# its own interpreter argv rather than depend on parent runtime state.
|
||||
result=subprocess.run([sys.executable,'-I','-B','-c',program,str(package.parent),action,json.dumps(options)],
|
||||
env=env,capture_output=True,text=True,timeout=15)
|
||||
assert result.returncode==0,result.stderr
|
||||
assert json.loads(result.stdout)['completed'] is True
|
||||
assert not list(package.rglob('__pycache__'))
|
||||
assert not list(package.rglob('*.pyc'))
|
||||
|
||||
|
||||
def test_fresh_fixture_is_writable_and_plain_python_can_generate_cache(tmp_path):
|
||||
package=copy_package(tmp_path/'control')
|
||||
result=subprocess.run([sys.executable,'-I','-c',
|
||||
'import sys;sys.path.insert(0,sys.argv[1]);import proxydoctor',str(package.parent)],
|
||||
capture_output=True,timeout=10)
|
||||
assert result.returncode==0
|
||||
assert list(package.rglob('*.pyc')), 'Control must be able to reproduce the original cache write'
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Dual-stack fixtures; added without running tests in this batch."""
|
||||
import json
|
||||
import socket
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from proxydoctor import connectivity
|
||||
|
||||
|
||||
def test_dns_negative_answer_and_timeout_remain_unknown():
|
||||
with patch.object(connectivity.subprocess, 'run', return_value=SimpleNamespace(
|
||||
returncode=0, stdout=json.dumps({'error':socket.EAI_NONAME}))):
|
||||
assert connectivity.dns_probe(6)['error_code'] == 'DNS_NO_ADDRESS'
|
||||
with patch.object(connectivity.subprocess, 'run', side_effect=connectivity.subprocess.TimeoutExpired('resolver',2)):
|
||||
assert connectivity.dns_probe(4)['error_code'] == 'DNS_TIMEOUT'
|
||||
|
||||
|
||||
def test_dns_rejects_wrong_family_answers():
|
||||
with patch.object(connectivity.subprocess, 'run', return_value=SimpleNamespace(
|
||||
returncode=0, stdout=json.dumps({'addresses':['1.1.1.1']}))):
|
||||
assert connectivity.dns_probe(6)['error_code'] == 'DNS_INVALID_RESPONSE'
|
||||
|
||||
|
||||
def test_tcp_success_is_preserved_when_tls_certificate_fails():
|
||||
from contextlib import nullcontext
|
||||
raw = SimpleNamespace(settimeout=lambda value: None)
|
||||
context = SimpleNamespace(wrap_socket=lambda *args,**kwargs: (_ for _ in ()).throw(
|
||||
connectivity.ssl.SSLCertVerificationError('sensitive provider text')))
|
||||
with patch.object(connectivity.socket,'create_connection',return_value=nullcontext(raw)), \
|
||||
patch.object(connectivity.ssl,'create_default_context',return_value=context):
|
||||
result = connectivity.transport_probe('test','example.com','1.1.1.1')
|
||||
assert result['tcp']['status'] == 'success'
|
||||
assert result['tls']['error_code'] == 'TLS_CERTIFICATE_ERROR'
|
||||
assert 'sensitive' not in json.dumps(result)
|
||||
|
||||
|
||||
def test_family_results_and_sample_counts_remain_separate():
|
||||
def dns(version):
|
||||
return {'family':version,'status':'success' if version==4 else 'unknown','elapsed_ms':1}
|
||||
def transport(label,hostname,address):
|
||||
version = connectivity.ipaddress.ip_address(address).version
|
||||
return {'family':version,'target':label,'tcp':{'status':'success'},'tls':{'status':'unknown'}}
|
||||
snapshots = []
|
||||
report = {}
|
||||
connectivity.collect(report, lambda: snapshots.append(json.dumps(report)), dns=dns, transport=transport)
|
||||
assert report['dns_summary']['4']['success_ratio'] == 1
|
||||
assert report['dns_summary']['6']['success_ratio'] == 0
|
||||
assert report['diagnostics'][1]['code'] == 'DNS_FAILED_NUMERIC_TCP_WORKS'
|
||||
assert report['status'] == 'partial'
|
||||
assert len(snapshots) == 9
|
||||
|
||||
|
||||
def test_one_probe_exception_does_not_drop_other_targets():
|
||||
def dns(version):
|
||||
raise RuntimeError('token=secret')
|
||||
def transport(label,hostname,address):
|
||||
return {'family':connectivity.ipaddress.ip_address(address).version,
|
||||
'target':label,'tcp':{'status':'success'},'tls':{'status':'success'}}
|
||||
report = connectivity.collect(dns=dns, transport=transport)
|
||||
assert len(report['transports']) == 4
|
||||
assert 'secret' not in json.dumps(report)
|
||||
|
||||
|
||||
def test_ctrl_c_is_not_swallowed_by_probe_isolation():
|
||||
def dns(version):
|
||||
raise KeyboardInterrupt
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
connectivity.collect(dns=dns)
|
||||
@@ -0,0 +1,22 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from proxydoctor.console import Console
|
||||
from proxydoctor import ipquality
|
||||
|
||||
|
||||
def test_panel_wraps_cjk_and_strips_control_characters(capsys):
|
||||
Console('zh','never').panel('概览',[('地区','陕西省'*40+'\x1b[31m','unknown')],width=50)
|
||||
lines=capsys.readouterr().out.splitlines()
|
||||
assert '\x1b' not in '\n'.join(lines)
|
||||
for line in lines:
|
||||
assert sum(2 if unicodedata.east_asian_width(ch) in ('W','F') else 0 if unicodedata.combining(ch) else 1 for ch in line)==50
|
||||
|
||||
|
||||
def test_ip_panel_keeps_missing_risk_unknown_and_hides_ip(monkeypatch,capsys):
|
||||
monkeypatch.setattr(ipquality,'request_json',lambda *args,**kwargs:({'ip':'8.8.8.8'},'ok'))
|
||||
result=ipquality.collect(enrich=False)
|
||||
ipquality.render(result,Console('zh','never'))
|
||||
output=capsys.readouterr().out
|
||||
assert 'IP 质量概览' in output
|
||||
assert '8.8.8.8' not in output
|
||||
assert '不代表安全' in output
|
||||
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from proxydoctor import mailquality
|
||||
|
||||
|
||||
def test_mail_checks_keep_forward_mismatch_and_no_blacklist_claim():
|
||||
report=mailquality.collect('8.8.8.8',query=lambda mode,ip:{'status':'observed','forward_matches':False} if mode=='ptr' else {'status':'unknown'})
|
||||
assert report['ptr']['forward_matches'] is False
|
||||
assert report['dnsbl']['status']=='skipped'
|
||||
assert 'score' not in report
|
||||
|
||||
|
||||
def test_mail_missing_ip_does_not_probe():
|
||||
def unexpected(*args):raise AssertionError('No target')
|
||||
assert mailquality.collect(None,query=unexpected)['ptr']['status']=='skipped'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('address',['127.0.0.1','192.168.1.1','not-ip'])
|
||||
def test_probe_rejects_non_public_target(address):
|
||||
with pytest.raises(ValueError):mailquality.probe('ptr',address)
|
||||
|
||||
|
||||
def test_probe_timeout_stays_unknown_without_exception_details():
|
||||
with patch.object(mailquality.subprocess,'run',side_effect=subprocess.TimeoutExpired('secret',5)):
|
||||
row=mailquality.probe('ptr','8.8.8.8')
|
||||
assert row['error_code']=='PROBE_TIMEOUT'
|
||||
assert 'secret' not in json.dumps(row)
|
||||
|
||||
|
||||
def test_ptr_failure_does_not_drop_smtp_probe():
|
||||
calls=[]
|
||||
def query(mode,ip):
|
||||
calls.append(mode)
|
||||
if mode=='ptr':raise RuntimeError('secret')
|
||||
return {'status':'reachable'}
|
||||
result=mailquality.collect('8.8.8.8',query=query)
|
||||
assert calls==['ptr','smtp']
|
||||
assert result['smtp_tcp']['status']=='reachable'
|
||||
@@ -178,21 +178,23 @@ def test_media_console_failure_does_not_abort_other_services(monkeypatch,capsys)
|
||||
monkeypatch.setattr(media,'SERVICES',{'Netflix':None,'Spotify':None})
|
||||
def check(name):
|
||||
if name=='Netflix': raise RuntimeError('private response data')
|
||||
return {'state':'restricted','http':200,'evidence':'confirmed_explicit_region_message'}
|
||||
monkeypatch.setattr(media,'check',check)
|
||||
return {'state':'access_denied','http':403,'evidence':'http_response'}
|
||||
monkeypatch.setattr(media,'check_reachability',check)
|
||||
results=media.run_console('en','always')
|
||||
output=capsys.readouterr().out
|
||||
assert len(results)==2 and '\x1b[31m' in output
|
||||
assert len(results)==2 and '\x1b[33m' in output
|
||||
assert 'Access denied' in output
|
||||
assert 'private response data' not in output
|
||||
|
||||
|
||||
def test_offline_json_stays_local_and_cannot_overwrite(tmp_path):
|
||||
from proxydoctor.suite import run_suite
|
||||
path=tmp_path/'result.json'
|
||||
assert run_suite(offline=True,json_output=path)==0
|
||||
import platform
|
||||
assert run_suite(offline=True,json_output=path)==(0 if platform.system()=='Linux' else 1)
|
||||
result=json.loads(path.read_text(encoding='utf-8'))
|
||||
assert result['offline'] and result['bandwidth'] is None and result['media']=={}
|
||||
assert result['result']=='local_execution_complete'
|
||||
assert result['result']==('local_execution_complete' if platform.system()=='Linux' else 'incomplete')
|
||||
original=path.read_bytes()
|
||||
with pytest.raises(FileExistsError): run_suite(offline=True,json_output=path)
|
||||
assert path.read_bytes()==original
|
||||
@@ -5,10 +5,10 @@ import sys
|
||||
def test_nodequality_missing_script_fails(tmp_path):
|
||||
result = subprocess.run([sys.executable, '-m', 'proxydoctor', 'nodequality', '--lang', 'en', '--script', str(tmp_path / 'missing')], capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
assert 'Local script file not found' in result.stderr
|
||||
assert 'workflow may upload reports and is disabled' in result.stderr
|
||||
|
||||
|
||||
def test_nodequality_streams_output_and_propagates_exit(tmp_path, monkeypatch, capsys):
|
||||
def test_nodequality_disabled_never_executes_local_script(tmp_path, monkeypatch, capsys):
|
||||
from proxydoctor import cli
|
||||
script = tmp_path / 'script with spaces.sh'
|
||||
script.write_text('# test', encoding='utf-8')
|
||||
@@ -23,8 +23,8 @@ def test_nodequality_streams_output_and_propagates_exit(tmp_path, monkeypatch, c
|
||||
try:
|
||||
cli.main()
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 7
|
||||
assert exc.code == 2
|
||||
else:
|
||||
raise AssertionError('expected exit')
|
||||
assert captured == [(['/bin/bash', str(script.resolve())], {'check': False})]
|
||||
assert 'LIVE OUTPUT' in capsys.readouterr().out
|
||||
assert captured == []
|
||||
assert 'LIVE OUTPUT' not in capsys.readouterr().out
|
||||
@@ -100,9 +100,9 @@ def test_suite_includes_security_network_and_route_evidence(monkeypatch,tmp_path
|
||||
from test_suite import unavailable_bandwidth,unavailable_ip
|
||||
monkeypatch.setattr(suite,'measure_bandwidth',unavailable_bandwidth)
|
||||
monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip)
|
||||
monkeypatch.setattr(suite,'run_media',lambda *args:{'Netflix':{'state':'unknown','playback':'not_tested'}})
|
||||
def network(*args,**kwargs):kwargs['report'].update(dns={'status':'pass'},tcp=[]);return 0
|
||||
monkeypatch.setattr(suite,'run_quality',network)
|
||||
monkeypatch.setattr(suite,'run_media',lambda *args,**kwargs:kwargs['results'].update(Netflix={'state':'unknown','playback':'not_tested'}))
|
||||
def network(*args,**kwargs):kwargs['report'].update(dns=[{'family':4,'status':'success'}],transports=[]);return 0
|
||||
monkeypatch.setattr('proxydoctor.connectivity.run_console',network)
|
||||
def trace(*args,**kwargs):kwargs['records'].append({'hop':1,'ip':'8.8.8.8','latency_ms':2});return 0
|
||||
monkeypatch.setattr(suite,'native_trace',trace)
|
||||
path=tmp_path/'suite.json'
|
||||
@@ -110,8 +110,8 @@ def test_suite_includes_security_network_and_route_evidence(monkeypatch,tmp_path
|
||||
result=json.loads(path.read_text(encoding='utf-8'))
|
||||
output=capsys.readouterr().out
|
||||
assert 'SSH port guard' in output and 'Local port 2222' in output
|
||||
assert result['schema_version']==2 and result['target_port']==2222
|
||||
assert result['network']['dns']['status']=='pass'
|
||||
assert result['schema_version']==3 and result['target_port']==2222
|
||||
assert result['network']['dns'][0]['status']=='success'
|
||||
assert result['route']['state']=='reached'
|
||||
assert result['route']['hops'][0]['ip']!='8.8.8.8'
|
||||
assert result['summary']['playback']=='not_tested'
|
||||
@@ -0,0 +1,133 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from proxydoctor.console import Console,width
|
||||
from proxydoctor import cli,media,bgp,carriers,routes,worker
|
||||
|
||||
|
||||
def test_frames_wrap_wide_text_without_loss_or_border_misalignment(capsys):
|
||||
out=Console('zh','never')
|
||||
title='长标题'*40
|
||||
value='测试位置与来源'*45+'e\u0301'
|
||||
out.frame_open(title,box_w=48,subtitle='1.0 公测版(非最终版本)')
|
||||
out.frame_keyvalue('位置',value)
|
||||
out.frame_section(1,'资源','Resources')
|
||||
out.panel('IP 面板',[('数据源','未知'*60,'unknown')])
|
||||
out.frame_close()
|
||||
lines=capsys.readouterr().out.splitlines()
|
||||
assert all(width(line)==50 for line in lines)
|
||||
body=''.join(line[1:-1].strip() for line in lines if line.startswith('║'))
|
||||
assert title in body and value in body
|
||||
assert '\x1b' not in body
|
||||
|
||||
|
||||
@pytest.mark.parametrize('language',['zh','en'])
|
||||
@pytest.mark.parametrize('theme',['default','iprisk','framed'])
|
||||
def test_actual_check_themes_respect_language_and_beta_identity(language,theme):
|
||||
result=subprocess.run([sys.executable,'-m','proxydoctor','check','--lang='+language,
|
||||
'--theme',theme,'--color','never'],capture_output=True,text=True,encoding='utf-8',timeout=15)
|
||||
assert result.returncode in (0,1)
|
||||
assert ('公测版' if language=='zh' else 'public beta') in result.stdout
|
||||
assert '\x1b' not in result.stdout
|
||||
if language=='en':assert not re.search('[\u4e00-\u9fff]',result.stdout)
|
||||
if theme=='framed':assert all(width(line)==80 for line in result.stdout.splitlines())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('flag',[['--lang','zh'],['--lang=zh']])
|
||||
def test_explicit_chinese_wins_over_console_fallback(monkeypatch,capsys,flag):
|
||||
monkeypatch.setattr('proxydoctor.console.can_render_cjk',lambda:False)
|
||||
monkeypatch.setattr(sys,'argv',['proxydoctor',*flag,'--version'])
|
||||
with pytest.raises(SystemExit) as exc:cli._main()
|
||||
assert exc.value.code==0
|
||||
assert '公测版' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_quality_colour_reaches_renderer(monkeypatch,tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(sys,'argv',['proxydoctor','quality','--color','never','--lang','en'])
|
||||
called=[]
|
||||
def probe(**options):
|
||||
called.append(options['color']);options['report']['status']='success';return 0
|
||||
monkeypatch.setattr('proxydoctor.connectivity.run_console',probe)
|
||||
with pytest.raises(SystemExit) as exc:cli._main()
|
||||
assert exc.value.code==0 and called==['never']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status,state',[(200,'reachable'),(403,'access_denied'),(429,'rate_limited'),(503,'http_error'),(None,'unknown')])
|
||||
def test_media_cli_probe_is_reachability_only(monkeypatch,status,state):
|
||||
calls=[]
|
||||
monkeypatch.setattr(media,'fetch_page',lambda url:calls.append(url) or media.Page(status,'Netflix is not available in your country'))
|
||||
row=media.check_reachability('Netflix')
|
||||
assert row['state']==state and row['scope']=='public_page_reachability'
|
||||
assert row['unlock']==row['playback']=='not_tested'
|
||||
assert len(calls)==1
|
||||
label,_=media.display_reachability(row,'en')
|
||||
assert 'unlocked' not in label.lower() and 'playback successful' not in label.lower()
|
||||
|
||||
|
||||
def test_provider_errors_are_visible_and_supplied_abuse_score_retained(monkeypatch,capsys):
|
||||
from proxydoctor import ipquality
|
||||
monkeypatch.setattr(ipquality,'request_json',lambda *a,**kw:({'ip':'8.8.8.8'},'ok'))
|
||||
row=ipquality.collect(enrich=False)
|
||||
row['sources']['proxycheck.io']={'state':'rate_limited'}
|
||||
row['reputation'].update(score=12,total_reports=2)
|
||||
ipquality.render(row,Console('en','never'))
|
||||
output=capsys.readouterr().out
|
||||
assert 'Rate limited' in output and 'AbuseIPDB' in output and '12/100' in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method',['icmp','tcp'])
|
||||
def test_trace_optional_dependency_missing_is_explicit(monkeypatch,capsys,method):
|
||||
monkeypatch.setattr(routes.shutil,'which',lambda *a,**kw:None)
|
||||
assert routes.trace_method('1.1.1.1',method=method)==1
|
||||
assert 'TRACEROUTE_DEPENDENCY_MISSING' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_tcp_trace_uses_fixed_argv_and_retains_real_rtt(monkeypatch):
|
||||
monkeypatch.setattr(routes.sys,'platform','linux')
|
||||
monkeypatch.setattr(routes.shutil,'which',lambda *a,**kw:'/usr/bin/traceroute')
|
||||
calls=[]
|
||||
monkeypatch.setattr(worker,'local_command',lambda argv,**kw:calls.append(argv) or (0,' 1 1.1.1.1 2.750 ms\n',None))
|
||||
rows=[]
|
||||
assert routes.trace_method('1.1.1.1',lambda *a,**kw:None,method='tcp',tcp_port=8443,geo=False,records=rows)==0
|
||||
assert rows[0]['latency_ms']==2.75 and rows[0]['state']=='reached'
|
||||
assert calls[0][-4:]==['-T','-p','8443','1.1.1.1']
|
||||
assert routes.parse_traceroute_hop(' 1 *',1,'1.1.1.1')['state']=='timeout'
|
||||
assert routes.parse_traceroute_hop(' 1 1.1.1.1 2.0 ms !H',1,'1.1.1.1')['state']=='unreachable'
|
||||
|
||||
|
||||
def test_bgp_validates_prefix_and_keeps_collector_scope(monkeypatch):
|
||||
responses={'network-info':({'prefix':'8.8.8.0/24','asns':[15169]},'ok'),
|
||||
'asn-neighbours':({'neighbours':[{'asn':3356,'type':'left','power':2}]},'ok'),
|
||||
'bgp-state':({'bgp_state':[{'target_prefix':'8.8.8.0/24','path':[3356,15169],'source_id':'00-test'}]},'ok')}
|
||||
monkeypatch.setattr(bgp,'query',lambda endpoint,*a,**kw:responses[endpoint])
|
||||
row=bgp.collect('8.8.8.8')
|
||||
assert row['status']=='success' and row['paths'][0]['as_path']==[3356,15169]
|
||||
assert 'commercial upstream' in ' '.join(row['limitations'])
|
||||
monkeypatch.setattr(bgp,'query',lambda *a,**kw:({'prefix':'1.1.1.0/24','asns':[13335]},'ok'))
|
||||
assert bgp.collect('8.8.8.8')['status']=='unknown'
|
||||
|
||||
|
||||
def test_bgp_provider_failure_keeps_unknown_and_does_not_invent_paths(monkeypatch):
|
||||
monkeypatch.setattr(bgp,'query',lambda *a,**kw:(None,'rate_limited'))
|
||||
row=bgp.collect('8.8.8.8')
|
||||
assert row['status']=='unknown' and row['paths']==[]
|
||||
assert row['sources']['network-info']=='rate_limited'
|
||||
|
||||
|
||||
def test_hong_kong_node_never_counts_as_mainland_mobile(monkeypatch):
|
||||
monkeypatch.setattr(carriers,'probe_node',lambda name:{'identifier':name,'status':'sample_available' if name=='cm-hk' else 'unknown'})
|
||||
row=carriers.collect_nodes()
|
||||
assert row['nodes']['cm-hk']['mainland'] is False
|
||||
assert row['coverage']['mobile'] is False and row['status']=='partial'
|
||||
|
||||
|
||||
def test_new_network_commands_reject_invalid_inputs_without_requests():
|
||||
for args in (['trace','--target',';reboot'],['bgp','--target','127.0.0.1'],
|
||||
['return-route','--tcp-port','0'],['suite','--offline','--china-speed']):
|
||||
result=subprocess.run([sys.executable,'-m','proxydoctor',*args],capture_output=True,timeout=10)
|
||||
assert result.returncode==2
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Fault-injection acceptance cases. Added, not executed in this work batch."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from proxydoctor.reports import ReportStore, recover_latest
|
||||
from proxydoctor.runner import ModuleRunner
|
||||
|
||||
|
||||
def test_module_exception_does_not_abort_next_module_or_expose_secrets():
|
||||
report = {}
|
||||
saved, notices, called = [], [], []
|
||||
runner = ModuleRunner(report, lambda: saved.append(json.dumps(report)), notices.append)
|
||||
|
||||
def broken():
|
||||
raise RuntimeError('token=private-value')
|
||||
|
||||
runner.run('broken', broken)
|
||||
runner.run('healthy', lambda: called.append(True))
|
||||
assert called == [True]
|
||||
assert report['modules']['broken']['error_code'] == 'MODULE_ERROR'
|
||||
assert report['modules']['healthy']['status'] == 'success'
|
||||
assert notices == ['broken']
|
||||
assert 'private-value' not in ''.join(saved)
|
||||
|
||||
|
||||
def test_cancellation_is_checkpointed_and_propagated():
|
||||
report, saved = {}, []
|
||||
runner = ModuleRunner(report, lambda: saved.append(json.dumps(report)), lambda name: None)
|
||||
|
||||
def cancel():
|
||||
raise KeyboardInterrupt
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
runner.run('route', cancel)
|
||||
assert json.loads(saved[-1])['modules']['route']['status'] == 'cancelled'
|
||||
|
||||
|
||||
def test_recovery_retains_last_complete_redacted_event(tmp_path):
|
||||
path = tmp_path/'report.json'
|
||||
store = ReportStore(path, lambda value: {'safe': value['safe']})
|
||||
assert store.checkpoint({'safe': 1, 'secret': 'hidden'})
|
||||
journal = store.journal_path
|
||||
store.close()
|
||||
with journal.open('a', encoding='utf-8') as stream:
|
||||
stream.write('{"interrupted":')
|
||||
assert recover_latest(journal) == {'safe': 1}
|
||||
assert 'hidden' not in journal.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_final_report_race_never_overwrites_existing_file(tmp_path):
|
||||
path = tmp_path/'report.json'
|
||||
store = ReportStore(path, lambda value: value)
|
||||
path.write_text('other process', encoding='utf-8')
|
||||
assert not store.finish({'result': 'partial'})
|
||||
store.close()
|
||||
assert path.read_text(encoding='utf-8') == 'other process'
|
||||
assert recover_latest(store.journal_path)['result'] == 'partial'
|
||||
|
||||
|
||||
def test_existing_destination_is_rejected(tmp_path):
|
||||
path = tmp_path/'report.json'
|
||||
path.write_text('existing', encoding='utf-8')
|
||||
with pytest.raises(FileExistsError):
|
||||
ReportStore(path, lambda value: value)
|
||||
|
||||
|
||||
def test_dangling_symlink_destination_is_rejected(tmp_path):
|
||||
path = tmp_path/'report.json'
|
||||
try:
|
||||
path.symlink_to(tmp_path/'missing.json')
|
||||
except OSError:
|
||||
pytest.skip('Creating symlinks is not permitted on this host')
|
||||
with pytest.raises(FileExistsError):
|
||||
ReportStore(path, lambda value: value)
|
||||
|
||||
|
||||
def test_flush_failure_is_reported_without_discarding_prior_event(tmp_path, monkeypatch):
|
||||
store = ReportStore(tmp_path/'report.json', lambda value: value)
|
||||
assert store.checkpoint({'completed': 'system'})
|
||||
|
||||
def full_disk(fd):
|
||||
raise OSError('disk full')
|
||||
|
||||
monkeypatch.setattr('proxydoctor.reports.os.fsync', full_disk)
|
||||
assert not store.checkpoint({'completed': 'network'})
|
||||
assert store.failed
|
||||
store.close()
|
||||
assert recover_latest(store.journal_path) is not None
|
||||
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from proxydoctor.reporting import export_report, load_report, markdown, sharing_copy
|
||||
from proxydoctor.reports import recover_latest
|
||||
|
||||
|
||||
def test_markdown_escapes_injected_markup_and_preserves_uncertainty():
|
||||
document={'schema_version':3,'modules':{'<script>alert(1)</script>|':{'status':'skipped','reason':'offline'}},
|
||||
'media':{'Netflix':{'state':'unknown','evidence':'</pre><script>x</script>'}}}
|
||||
text=markdown(document,'en')
|
||||
assert '<script>' in text # table cells remain escaped
|
||||
assert '```json\n' in text
|
||||
# The provider string is literal JSON inside a code fence, not raw HTML.
|
||||
assert '"evidence": "</pre><script>x</script>"' in text.split('```json\n')[1].split('\n```')[0]
|
||||
assert 'unknown' in text and 'skipped' in text
|
||||
|
||||
|
||||
def test_markdown_provider_cannot_close_json_fence():
|
||||
payload='```\n# forged section\n``````\n<script>x</script>'
|
||||
text=markdown({'schema_version':3,'media':{'raw':payload}},'en')
|
||||
assert '```````json\n' in text
|
||||
block=text.split('```````json\n')[1].split('\n```````')[0]
|
||||
assert json.loads(block)=={'raw':payload}
|
||||
|
||||
|
||||
def test_sharing_redaction_hides_credentials_queries_and_ip():
|
||||
safe=sharing_copy({'api_key':'my-secret','nested':{'password':'p'},
|
||||
'evidence':'https://user:pass@example.com/test?token=secret#fragment Bearer abc123 8.8.8.8'})
|
||||
text=json.dumps(safe)
|
||||
assert all(value not in text for value in ('my-secret','user:pass','token=secret','abc123','8.8.8.8'))
|
||||
assert 'https://example.com/test' in text
|
||||
|
||||
|
||||
def test_export_does_not_overwrite(tmp_path):
|
||||
destination=tmp_path/'report.md'
|
||||
export_report({'schema_version':3},destination)
|
||||
initial=destination.read_bytes()
|
||||
with pytest.raises(FileExistsError):
|
||||
export_report({'schema_version':3,'result':'different'},destination)
|
||||
assert destination.read_bytes()==initial
|
||||
|
||||
|
||||
def test_loader_rejects_unsupported_schema_and_size(tmp_path):
|
||||
path=tmp_path/'input.json';path.write_text('{"schema_version":999}')
|
||||
with pytest.raises(ValueError):load_report(path)
|
||||
with pytest.raises(ValueError):load_report(path,max_bytes=2)
|
||||
|
||||
|
||||
def test_recovery_read_budget_is_enforced(tmp_path):
|
||||
path=tmp_path/'events';path.write_bytes(b'12345678\n')
|
||||
with pytest.raises(ValueError):recover_latest(path,max_line_bytes=4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('lang',['zh','en'])
|
||||
def test_actual_offline_export_and_partial_recovery_cli(tmp_path,lang):
|
||||
document={'schema_version':3,'result':'running','modules':{'network':{'status':'running'}},'api_key':'private-test-value'}
|
||||
source=tmp_path/'source.json';source.write_text(json.dumps(document),encoding='utf-8')
|
||||
journal=tmp_path/'journal.jsonl'
|
||||
journal.write_text(json.dumps({'event_version':1,'snapshot':document})+'\n'+ '{"partial":',encoding='utf-8')
|
||||
report=tmp_path/'report.md';recovered=tmp_path/'recovered.json'
|
||||
def cli(*args):
|
||||
return subprocess.run([sys.executable,'-m','proxydoctor',*args,'--lang',lang],capture_output=True,text=True,encoding='utf-8',timeout=10)
|
||||
assert cli('report','--input',str(source),'--output',str(report)).returncode==0
|
||||
assert 'private-test-value' not in report.read_text(encoding='utf-8')
|
||||
assert cli('recover','--journal',str(journal),'--output',str(recovered)).returncode==0
|
||||
result=json.loads(recovered.read_text(encoding='utf-8'))
|
||||
assert result['result']=='recovered_partial'
|
||||
assert result['recovery']['new_measurement'] is False
|
||||
assert cli('recover','--journal',str(journal),'--output',str(recovered)).returncode==2
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Resource parser/fixture cases; not executed in the current batch."""
|
||||
from proxydoctor.resources import cgroup_limits, collect, cpu_ticks, pressure, steal_delta
|
||||
|
||||
|
||||
def fixture_reader(files):
|
||||
return lambda path: files.get(path)
|
||||
|
||||
|
||||
def test_nested_v2_uses_strictest_visible_ancestor():
|
||||
read = fixture_reader({
|
||||
'/proc/self/cgroup': '0::/tenant/app\n',
|
||||
'/proc/self/mountinfo': '1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw',
|
||||
'/sys/fs/cgroup/tenant/app/cpu.max': '200000 100000',
|
||||
'/sys/fs/cgroup/tenant/cpu.max': '50000 100000',
|
||||
'/sys/fs/cgroup/cpu.max': 'max 100000',
|
||||
'/sys/fs/cgroup/tenant/app/memory.max': 'max',
|
||||
'/sys/fs/cgroup/tenant/memory.max': '536870912',
|
||||
})
|
||||
result = cgroup_limits(read)
|
||||
assert result['cpu_quota_cores'] == 0.5
|
||||
assert result['memory_limit_bytes'] == 512*1024**2
|
||||
|
||||
|
||||
def test_v1_controller_mount_and_unlimited_sentinel():
|
||||
read = fixture_reader({
|
||||
'/proc/self/cgroup': '2:cpu,cpuacct:/app\n3:memory:/app',
|
||||
'/proc/self/mountinfo': '1 0 0:1 / /cg/cpu rw - cgroup cgroup rw,cpu,cpuacct\n2 0 0:2 / /cg/memory rw - cgroup cgroup rw,memory',
|
||||
'/cg/cpu/app/cpu.cfs_quota_us': '25000',
|
||||
'/cg/cpu/app/cpu.cfs_period_us': '100000',
|
||||
'/cg/memory/app/memory.limit_in_bytes': '9223372036854771712',
|
||||
})
|
||||
result = cgroup_limits(read)
|
||||
assert result['cpu_quota_cores'] == 0.25
|
||||
assert result['memory_limit_bytes'] is None
|
||||
|
||||
|
||||
def test_subtree_mount_maps_membership_without_escaping_visible_root():
|
||||
read = fixture_reader({
|
||||
'/proc/self/cgroup': '0::/tenant/app',
|
||||
'/proc/self/mountinfo': '1 0 0:1 /tenant /sys/fs/cgroup rw - cgroup2 cgroup rw',
|
||||
'/sys/fs/cgroup/app/cpu.max': '100000 100000',
|
||||
'/sys/fs/cgroup/cpu.max': '50000 100000',
|
||||
})
|
||||
result = cgroup_limits(read)
|
||||
assert result['cpu_quota_cores'] == 0.5
|
||||
assert all('/tenant/' not in row['source'] for row in result['observations'])
|
||||
|
||||
|
||||
def test_missing_or_invalid_cgroup_is_unknown_not_unlimited():
|
||||
assert cgroup_limits(fixture_reader({}))['cpu_quota_cores'] is None
|
||||
assert pressure('some avg10=nan avg60=0 avg300=0 total=0') is None
|
||||
assert pressure('some avg10=0.25 avg60=0 avg300=0 total=12')['some']['avg10'] == 0.25
|
||||
|
||||
|
||||
def test_steal_uses_delta_and_does_not_double_count_guest():
|
||||
before = cpu_ticks('cpu 100 0 0 100 0 0 0 0 90 0')
|
||||
after = cpu_ticks('cpu 120 0 0 170 0 0 0 10 100 0')
|
||||
assert steal_delta(before, after) == 10
|
||||
assert steal_delta(after, before) is None
|
||||
assert steal_delta(before, before) is None
|
||||
|
||||
|
||||
def test_collect_keeps_numeric_units_and_passive_sampling():
|
||||
snapshots = iter(['cpu 100 0 0 100 0 0 0 0', 'cpu 120 0 0 170 0 0 0 10'])
|
||||
files = {'/proc/meminfo': 'MemTotal: 1024 kB\nMemAvailable: 512 kB\nSwapTotal: 256 kB\nSwapFree: 128 kB'}
|
||||
times = iter([10.0, 10.2])
|
||||
waits = []
|
||||
result = collect(read=lambda path: next(snapshots) if path == '/proc/stat' else files.get(path),
|
||||
system='Linux', cpu_count=lambda: 8, affinity=lambda: {0,1},
|
||||
sleep=waits.append, clock=lambda: next(times))
|
||||
assert waits == [0.2]
|
||||
assert result['metrics']['visible_cpu_upper_bound_cores'] == 2
|
||||
assert result['metrics']['swap_used_bytes'] == 128*1024
|
||||
assert result['metrics']['steal_percent'] == 10
|
||||
|
||||
|
||||
def test_non_linux_does_not_read_proc_or_wait():
|
||||
def unexpected(*args):
|
||||
raise AssertionError('Unsupported system must not probe Linux files')
|
||||
assert collect(read=unexpected, sleep=unexpected, system='Windows')['status'] == 'unsupported'
|
||||
|
||||
|
||||
def test_container_without_memory_limit_does_not_claim_host_ram():
|
||||
files = {'/.dockerenv': '', '/proc/meminfo': 'MemTotal: 67108864 kB'}
|
||||
result = collect(read=fixture_reader(files), system='Linux', sleep=lambda _: None,
|
||||
cpu_count=lambda: 64, affinity=lambda: {0,1})
|
||||
assert result['allocation']['memory_limit_bytes'] is None
|
||||
assert result['allocation']['dedicated_cpu'] == 'unknown'
|
||||
assert result['metrics']['memory_total_bytes'] == 64*1024**3
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Read-only security fixtures; execution deferred at the user's request."""
|
||||
import json
|
||||
import stat
|
||||
from types import SimpleNamespace
|
||||
|
||||
from proxydoctor.security import collect, inspect_path, update_evidence
|
||||
|
||||
|
||||
def metadata(mode,uid=0,modified=3600):
|
||||
return SimpleNamespace(st_mode=mode,st_uid=uid,st_gid=42,st_mtime=modified)
|
||||
|
||||
|
||||
def test_shadow_group_read_is_not_a_finding():
|
||||
row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFREG|0o640))
|
||||
assert row['status'] == 'observed'
|
||||
|
||||
|
||||
def test_world_readable_shadow_and_writable_configuration_require_review():
|
||||
row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFREG|0o644))
|
||||
assert row['reasons'] == ['SENSITIVE_WORLD_READABLE']
|
||||
row = inspect_path('/etc/sudoers',lstat=lambda _:metadata(stat.S_IFREG|0o666,uid=1000))
|
||||
assert 'WORLD_WRITABLE' in row['reasons']
|
||||
assert 'GROUP_WRITABLE_REVIEW' in row['reasons']
|
||||
assert 'OWNER_NOT_ROOT_REVIEW' in row['reasons']
|
||||
|
||||
|
||||
def test_symlinks_are_not_followed_or_called_safe():
|
||||
row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFLNK|0o777))
|
||||
assert row['status'] == 'unknown'
|
||||
assert row['error_code'] == 'SYMLINK_REQUIRES_REVIEW'
|
||||
|
||||
|
||||
def test_permission_error_does_not_expose_exception_text():
|
||||
def denied(_):
|
||||
raise PermissionError('secret-path-token')
|
||||
row = inspect_path('/etc/shadow',True,lstat=denied)
|
||||
assert row['error_code'] == 'METADATA_UNAVAILABLE'
|
||||
assert 'secret-path-token' not in json.dumps(row)
|
||||
|
||||
|
||||
def test_missing_cache_does_not_mean_zero_updates():
|
||||
def missing(_):
|
||||
raise FileNotFoundError
|
||||
row = update_evidence(lstat=missing)
|
||||
assert row['status'] == 'unknown'
|
||||
assert row['pending_updates'] is None
|
||||
|
||||
|
||||
def test_cache_is_timestamped_observation_not_patch_verdict():
|
||||
row = update_evidence(lstat=lambda _:metadata(stat.S_IFREG|0o644),now=lambda:7200,
|
||||
read=lambda _:'12 updates can be applied immediately.\n3 of these updates are standard security updates.')
|
||||
assert row['pending_updates'] == 12
|
||||
assert row['security_updates'] == 3
|
||||
assert row['sources'][0]['age_hours'] == 1
|
||||
assert row['status'] == 'cached_observation'
|
||||
|
||||
|
||||
def test_localized_summary_stays_unknown_and_raw_text_is_not_retained():
|
||||
row = update_evidence(lstat=lambda _:metadata(stat.S_IFREG|0o644),now=lambda:7200,
|
||||
read=lambda _:'中文缓存 secret-key')
|
||||
assert row['pending_updates'] is None
|
||||
assert 'secret-key' not in json.dumps(row)
|
||||
|
||||
|
||||
def test_collect_does_not_read_password_file_contents():
|
||||
reads = []
|
||||
def read(path):
|
||||
reads.append(path)
|
||||
assert path == '/var/lib/update-notifier/updates-available'
|
||||
return None
|
||||
row = collect(system='Linux',lstat=lambda _:metadata(stat.S_IFREG|0o640),read=read,now=lambda:7200)
|
||||
assert len(row['files']) == 9
|
||||
assert reads == ['/var/lib/update-notifier/updates-available']
|
||||
|
||||
|
||||
def test_non_linux_does_not_inspect_host_files():
|
||||
def unexpected(_):
|
||||
raise AssertionError('Linux paths should not be read')
|
||||
assert collect(system='Windows',lstat=unexpected,read=unexpected)['status'] == 'unsupported'
|
||||
+9
-7
@@ -21,21 +21,22 @@ def unavailable_ip(**kwargs):
|
||||
def test_offline_suite_never_calls_network_or_providers(monkeypatch,capsys):
|
||||
def unexpected(*args,**kwargs): raise AssertionError('unexpected network/provider')
|
||||
monkeypatch.setattr(suite,'measure_bandwidth',unexpected)
|
||||
monkeypatch.setattr(suite,'run_quality',unexpected)
|
||||
monkeypatch.setattr('proxydoctor.connectivity.run_console',unexpected)
|
||||
monkeypatch.setattr(suite,'run_media',unexpected)
|
||||
monkeypatch.setattr(suite,'collect_ipquality',unexpected)
|
||||
monkeypatch.setattr(suite,'execute_provider',unexpected)
|
||||
monkeypatch.setattr(suite,'native_trace',unexpected)
|
||||
assert suite.run_suite(offline=True)==0
|
||||
import platform
|
||||
assert suite.run_suite(offline=True)==(0 if platform.system()=='Linux' else 1)
|
||||
text=capsys.readouterr().out
|
||||
assert all(f'===== {number}.' in text for number in range(1,5))
|
||||
|
||||
|
||||
def test_suite_continues_when_network_and_providers_unavailable(monkeypatch,capsys):
|
||||
monkeypatch.setattr(suite,'measure_bandwidth',unavailable_bandwidth)
|
||||
monkeypatch.setattr(suite,'run_quality',lambda *args,**kwargs: 1)
|
||||
monkeypatch.setattr('proxydoctor.connectivity.run_console',lambda *args,**kwargs: 1)
|
||||
monkeypatch.setattr(suite.shutil,'which',lambda *args,**kwargs: None)
|
||||
monkeypatch.setattr('proxydoctor.media.check',lambda *args: {'state':'unknown','http':403,'evidence':'playback_not_verified'})
|
||||
monkeypatch.setattr('proxydoctor.media.check_reachability',lambda *args: {'state':'access_denied','http':403,'evidence':'http_response'})
|
||||
monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip)
|
||||
monkeypatch.setattr(suite,'native_trace',lambda *args,**kwargs: 1)
|
||||
assert suite.run_suite(lang='en')==1
|
||||
@@ -44,7 +45,7 @@ def test_suite_continues_when_network_and_providers_unavailable(monkeypatch,caps
|
||||
assert 'Access denied' in output
|
||||
|
||||
|
||||
def test_besttrace_argv_is_not_shell_interpolated(tmp_path,monkeypatch):
|
||||
def test_besttrace_is_rejected_before_execution(tmp_path,monkeypatch):
|
||||
executable=tmp_path/'best trace'
|
||||
executable.write_text('example',encoding='utf-8')
|
||||
monkeypatch.setattr(suite,'measure_bandwidth',unavailable_bandwidth)
|
||||
@@ -53,8 +54,9 @@ def test_besttrace_argv_is_not_shell_interpolated(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip)
|
||||
calls=[]
|
||||
monkeypatch.setattr(suite,'execute_provider',lambda argv,timeout: calls.append(argv) or 0)
|
||||
suite.run_suite(besttrace=executable)
|
||||
assert calls==[[str(executable.resolve()),'1.1.1.1']]
|
||||
with pytest.raises(ValueError,match='disabled'):
|
||||
suite.run_suite(besttrace=executable)
|
||||
assert calls==[]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('target',[';reboot','--help','not-a-host'])
|
||||
|
||||
Reference in new issue
Block a user