diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..7fd53bc
--- /dev/null
+++ b/.gitattributes
@@ -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
diff --git a/ALPHA_RELEASE_REPORT.md b/ALPHA_RELEASE_REPORT.md
new file mode 100644
index 0000000..9807f81
--- /dev/null
+++ b/ALPHA_RELEASE_REPORT.md
@@ -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。保持大功能关闭/未实现声明。用户批准发布是后续动作,本轮不代为执行。
diff --git a/ALPHA_TASK_STATUS.md b/ALPHA_TASK_STATUS.md
new file mode 100644
index 0000000..c36efd1
--- /dev/null
+++ b/ALPHA_TASK_STATUS.md
@@ -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。无公开发布、无主分支合并。
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..75001f0
--- /dev/null
+++ b/ARCHITECTURE.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实测。
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..076b87f
--- /dev/null
+++ b/CHANGELOG.md
@@ -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实测。
diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md
new file mode 100644
index 0000000..4e9247a
--- /dev/null
+++ b/CURRENT_STATUS.md
@@ -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 安装预编译不作无缓存承诺。没有删除历史缓存来掩盖结果,验收使用全新目录。
diff --git a/DEVELOPMENT_BRIEF.zh.md b/DEVELOPMENT_BRIEF.zh.md
new file mode 100644
index 0000000..e5e1189
--- /dev/null
+++ b/DEVELOPMENT_BRIEF.zh.md
@@ -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 综合诊断工具。
\ No newline at end of file
diff --git a/IPQUALITY_COVERAGE.md b/IPQUALITY_COVERAGE.md
new file mode 100644
index 0000000..5d07552
--- /dev/null
+++ b/IPQUALITY_COVERAGE.md
@@ -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,应在用户明确选择时才打开。未具备的资源统一列待接入,不能伪造地图、数据库结果或风险分数。
diff --git a/MANIFEST.sha256.json b/MANIFEST.sha256.json
new file mode 100644
index 0000000..808cc10
--- /dev/null
+++ b/MANIFEST.sha256.json
@@ -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"
+ }
+}
diff --git a/OPEN_SOURCE_RESEARCH.md b/OPEN_SOURCE_RESEARCH.md
new file mode 100644
index 0000000..f8e66ec
--- /dev/null
+++ b/OPEN_SOURCE_RESEARCH.md
@@ -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 条款、数据集许可、费用和深层实现仍须逐项研究后才能集成。
diff --git a/PROBLEM_BACKLOG.md b/PROBLEM_BACKLOG.md
new file mode 100644
index 0000000..06d0c14
--- /dev/null
+++ b/PROBLEM_BACKLOG.md
@@ -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实测。
diff --git a/PUBLIC_BETA_CHANGE_REPORT.md b/PUBLIC_BETA_CHANGE_REPORT.md
new file mode 100644
index 0000000..5720c57
--- /dev/null
+++ b/PUBLIC_BETA_CHANGE_REPORT.md
@@ -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 和中断恢复回归。
+- 持续复核节点服务条款和可用性;开源许可证不等于第三方服务承诺。
+
+按用户要求,代码、文档、本地候选包收尾后停止,留待下一阶段测试。
+结论:可提交下一轮测试的代码候选;不是已通过最终公测验收。未推送、合并或发布。
diff --git a/README.en.md b/README.en.md
index 639db88..0f6b573 100644
--- a/README.en.md
+++ b/README.en.md
@@ -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.
diff --git a/README.md b/README.md
index 60cbdd8..2434733 100644
--- a/README.md
+++ b/README.md
@@ -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)。不打包下载执行第三方检测脚本或测速客户端。
diff --git a/SECURITY.md b/SECURITY.md
index 0eb8000..4cbc0ad 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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实测。
diff --git a/TEST_PLAN.md b/TEST_PLAN.md
new file mode 100644
index 0000000..52ec719
--- /dev/null
+++ b/TEST_PLAN.md
@@ -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实测。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index a1a91f0..136d38f 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -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.
diff --git a/USER_CHECKLIST_STATUS.md b/USER_CHECKLIST_STATUS.md
new file mode 100644
index 0000000..6df3e24
--- /dev/null
+++ b/USER_CHECKLIST_STATUS.md
@@ -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为现有使用/测试/贡献文档能力;一键安装、更新和卸载仍单独未完成。
diff --git a/docs/history-beta-before-p0-20261101.md b/docs/history-beta-before-p0-20261101.md
new file mode 100644
index 0000000..901f10a
--- /dev/null
+++ b/docs/history-beta-before-p0-20261101.md
@@ -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)。
diff --git a/docs/pre-alpha-README.en.md b/docs/pre-alpha-README.en.md
new file mode 100644
index 0000000..d33c41a
--- /dev/null
+++ b/docs/pre-alpha-README.en.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.
diff --git a/docs/pre-alpha-README.md b/docs/pre-alpha-README.md
new file mode 100644
index 0000000..12bf0e9
--- /dev/null
+++ b/docs/pre-alpha-README.md
@@ -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/端口结果视为绝对安全/邮件可投递证明。
diff --git a/docs/status-history-before-2026-11-01.md b/docs/status-history-before-2026-11-01.md
new file mode 100644
index 0000000..daf64cd
--- /dev/null
+++ b/docs/status-history-before-2026-11-01.md
@@ -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:重要文件权限、更新缓存证据与安全提示已编写,未验证。
diff --git a/install.py b/install.py
new file mode 100644
index 0000000..a65082b
--- /dev/null
+++ b/install.py
@@ -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())
diff --git a/proxydoctor.sh b/proxydoctor.sh
index 5b2c31b..d25ea45 100644
--- a/proxydoctor.sh
+++ b/proxydoctor.sh
@@ -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 "$@"
diff --git a/proxydoctor/__init__.py b/proxydoctor/__init__.py
index 442ad3e..2f856be 100644
--- a/proxydoctor/__init__.py
+++ b/proxydoctor/__init__.py
@@ -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)')
diff --git a/proxydoctor/__main__.py b/proxydoctor/__main__.py
index e950ada..175c97b 100644
--- a/proxydoctor/__main__.py
+++ b/proxydoctor/__main__.py
@@ -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()
diff --git a/proxydoctor/bandwidth.py b/proxydoctor/bandwidth.py
index 95f0549..7f3c5dc 100644
--- a/proxydoctor/bandwidth.py
+++ b/proxydoctor/bandwidth.py
@@ -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
diff --git a/proxydoctor/bgp.py b/proxydoctor/bgp.py
new file mode 100644
index 0000000..634add3
--- /dev/null
+++ b/proxydoctor/bgp.py
@@ -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')
diff --git a/proxydoctor/carriers.py b/proxydoctor/carriers.py
new file mode 100644
index 0000000..162399e
--- /dev/null
+++ b/proxydoctor/carriers.py
@@ -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
diff --git a/proxydoctor/checks.py b/proxydoctor/checks.py
index 292bc25..871bb85 100644
--- a/proxydoctor/checks.py
+++ b/proxydoctor/checks.py
@@ -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
diff --git a/proxydoctor/cli.py b/proxydoctor/cli.py
index a22f327..1468cc0 100644
--- a/proxydoctor/cli.py
+++ b/proxydoctor/cli.py
@@ -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
diff --git a/proxydoctor/connectivity.py b/proxydoctor/connectivity.py
new file mode 100644
index 0000000..3b981c6
--- /dev/null
+++ b/proxydoctor/connectivity.py
@@ -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
diff --git a/proxydoctor/console.py b/proxydoctor/console.py
index 66cc8e0..7af4f07 100644
--- a/proxydoctor/console.py
+++ b/proxydoctor/console.py
@@ -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
diff --git a/proxydoctor/geo.py b/proxydoctor/geo.py
index 2d4fa43..9867c1b 100644
--- a/proxydoctor/geo.py
+++ b/proxydoctor/geo.py
@@ -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
diff --git a/proxydoctor/i18n.py b/proxydoctor/i18n.py
index ccc85e0..73fb5e9 100644
--- a/proxydoctor/i18n.py
+++ b/proxydoctor/i18n.py
@@ -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",
diff --git a/proxydoctor/ipquality.py b/proxydoctor/ipquality.py
index 2213871..9809f4b 100644
--- a/proxydoctor/ipquality.py
+++ b/proxydoctor/ipquality.py
@@ -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)
diff --git a/proxydoctor/mailquality.py b/proxydoctor/mailquality.py
new file mode 100644
index 0000000..4ac4f5c
--- /dev/null
+++ b/proxydoctor/mailquality.py
@@ -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')
diff --git a/proxydoctor/media.py b/proxydoctor/media.py
index 34b99d8..fbfcac4 100644
--- a/proxydoctor/media.py
+++ b/proxydoctor/media.py
@@ -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
diff --git a/proxydoctor/net.py b/proxydoctor/net.py
index 3d78b49..e53dcee 100644
--- a/proxydoctor/net.py
+++ b/proxydoctor/net.py
@@ -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'
diff --git a/proxydoctor/quality.py b/proxydoctor/quality.py
index 3fb969a..45f34bb 100644
--- a/proxydoctor/quality.py
+++ b/proxydoctor/quality.py
@@ -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
diff --git a/proxydoctor/reporting.py b/proxydoctor/reporting.py
new file mode 100644
index 0000000..2f9a7d0
--- /dev/null
+++ b/proxydoctor/reporting.py
@@ -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','
').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())
diff --git a/proxydoctor/reports.py b/proxydoctor/reports.py
new file mode 100644
index 0000000..648217f
--- /dev/null
+++ b/proxydoctor/reports.py
@@ -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
diff --git a/proxydoctor/resources.py b/proxydoctor/resources.py
new file mode 100644
index 0000000..33e9932
--- /dev/null
+++ b/proxydoctor/resources.py
@@ -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')
diff --git a/proxydoctor/routes.py b/proxydoctor/routes.py
index 80043fe..287d6b5 100644
--- a/proxydoctor/routes.py
+++ b/proxydoctor/routes.py
@@ -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 08} 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=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
diff --git a/proxydoctor/runner.py b/proxydoctor/runner.py
new file mode 100644
index 0000000..5d433db
--- /dev/null
+++ b/proxydoctor/runner.py
@@ -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()
diff --git a/proxydoctor/schema.py b/proxydoctor/schema.py
new file mode 100644
index 0000000..22285fa
--- /dev/null
+++ b/proxydoctor/schema.py
@@ -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
diff --git a/proxydoctor/security.py b/proxydoctor/security.py
new file mode 100644
index 0000000..a6a1e5f
--- /dev/null
+++ b/proxydoctor/security.py
@@ -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
diff --git a/proxydoctor/session.py b/proxydoctor/session.py
new file mode 100644
index 0000000..f66f328
--- /dev/null
+++ b/proxydoctor/session.py
@@ -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
diff --git a/proxydoctor/suite.py b/proxydoctor/suite.py
index 3dc44ff..4e1ab31 100644
--- a/proxydoctor/suite.py
+++ b/proxydoctor/suite.py
@@ -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=replies16384:
+ 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.
diff --git a/pyproject.toml b/pyproject.toml
index aa11142..6e10b09 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..d04944a
--- /dev/null
+++ b/tests/conftest.py
@@ -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')
diff --git a/tests/test_alpha_quality.py b/tests/test_alpha_quality.py
new file mode 100644
index 0000000..9d7481b
--- /dev/null
+++ b/tests/test_alpha_quality.py
@@ -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'
diff --git a/tests/test_bandwidth_sources.py b/tests/test_bandwidth_sources.py
index 0b1da11..fc7696d 100644
--- a/tests/test_bandwidth_sources.py
+++ b/tests/test_bandwidth_sources.py
@@ -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']
diff --git a/tests/test_bytecode_policy.py b/tests/test_bytecode_policy.py
new file mode 100644
index 0000000..c2caf74
--- /dev/null
+++ b/tests/test_bytecode_policy.py
@@ -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'
diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py
new file mode 100644
index 0000000..9415239
--- /dev/null
+++ b/tests/test_connectivity.py
@@ -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)
diff --git a/tests/test_ip_panel.py b/tests/test_ip_panel.py
new file mode 100644
index 0000000..b488842
--- /dev/null
+++ b/tests/test_ip_panel.py
@@ -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
diff --git a/tests/test_mailquality.py b/tests/test_mailquality.py
new file mode 100644
index 0000000..a477984
--- /dev/null
+++ b/tests/test_mailquality.py
@@ -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'
diff --git a/tests/test_measurements.py b/tests/test_measurements.py
index 4a5ee58..dd8b281 100644
--- a/tests/test_measurements.py
+++ b/tests/test_measurements.py
@@ -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
diff --git a/tests/test_nodequality.py b/tests/test_nodequality.py
index 96a99a0..b863f38 100644
--- a/tests/test_nodequality.py
+++ b/tests/test_nodequality.py
@@ -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
diff --git a/tests/test_phase1_delivery.py b/tests/test_phase1_delivery.py
index c7d2df9..d89865d 100644
--- a/tests/test_phase1_delivery.py
+++ b/tests/test_phase1_delivery.py
@@ -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'
diff --git a/tests/test_public_beta.py b/tests/test_public_beta.py
new file mode 100644
index 0000000..4eecbc3
--- /dev/null
+++ b/tests/test_public_beta.py
@@ -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
diff --git a/tests/test_recovery.py b/tests/test_recovery.py
new file mode 100644
index 0000000..4e5c169
--- /dev/null
+++ b/tests/test_recovery.py
@@ -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
diff --git a/tests/test_reporting.py b/tests/test_reporting.py
new file mode 100644
index 0000000..bca8492
--- /dev/null
+++ b/tests/test_reporting.py
@@ -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':{'|':{'status':'skipped','reason':'offline'}},
+ 'media':{'Netflix':{'state':'unknown','evidence':''}}}
+ 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": ""' 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'
+ 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
diff --git a/tests/test_resources.py b/tests/test_resources.py
new file mode 100644
index 0000000..5ec6507
--- /dev/null
+++ b/tests/test_resources.py
@@ -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
diff --git a/tests/test_security.py b/tests/test_security.py
new file mode 100644
index 0000000..428a8ea
--- /dev/null
+++ b/tests/test_security.py
@@ -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'
diff --git a/tests/test_suite.py b/tests/test_suite.py
index ee59342..172b5fb 100644
--- a/tests/test_suite.py
+++ b/tests/test_suite.py
@@ -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'])