Checkpoint verified 1.0.0b1 baseline before final hardening

This commit is contained in:
ProxyDoctor Checkpoint committed 2026-11-01 12:36:17 -08:00
1 parent aa7f12d705
commit 9804b73cf0
67 files changed
+5395 -545

No files matched your search

+6
View File
@@ -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
+44
View File
@@ -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。保持大功能关闭/未实现声明。用户批准发布是后续动作,本轮不代为执行。
+28
View File
@@ -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。无公开发布、无主分支合并。
+83
View File
@@ -0,0 +1,83 @@
## v0.1.0-alpha 候选(2026-11-01)
隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。
# 架构与接口演进
2026-11-01。当前范围是独立 Python CLI,未来 FlexMaster 通过 Python API 复用。以下区分现状与方案,方案尚未视为实现。
## 现状
- `checks.py`:Host 只读适配器、Result、inspect;返回 schema_version=1 字典。
- `suite.py`:四模块编排、终端渲染和 schema_version=2 报告,当前相互耦合。
- `routes.py`:Linux UDP 错误队列路由,emit 回调和 records 可供调用;非通用跨平台实现。
- `quality.py`、`media.py`、`ipquality.py`、`bandwidth.py`:可调用函数,但部分收集函数同时输出终端。
- `net.py`:有限响应、请求超时;`console.py`:颜色和控制字符过滤;`cli.py`:参数、脱敏和保存。
- 无运行依赖,Python >=3.10;setuptools>=77 是构建依赖,尚无上限锁定。
## 渐进方案(待实现)
保留文件和现有接口,先增加独立 core/models、core/runner、reports 组件,再逐模块适配。不要一次移动所有代码。
统一 EvidenceResult:schema_version、check_id、status、observed_at、method、source、metrics(数值+单位)、error_code、confidence、limitations。状态为 success/failure/skipped/unsupported/unknown。failure 表示检查失败,不等于确认存在漏洞。媒体业务状态保留独立字段,不把 catalog_visible 映射为播放成功。旧 pass/warn 不可机械映射为网络成功/失败。
Runner 接受显式配置和 provider 注入;每模块独立捕获预期错误,未知异常仅记录固定错误码,不保存异常原文中的凭证。KeyboardInterrupt 传给协调器,落盘已有结果再退出 130。同步阶段先使用明确请求预算;不可通过线程超时假装终止底层网络操作。
Reports 接收结构化对象,不解析 CLI 文本。增量 JSONL 保存完成事件,最终报告独占创建、默认脱敏;禁止覆盖已有文件或通过符号链接写入。持久化失败应保留可用终端结果并返回明确错误。历史比较只比较相同方法、单位、节点及采样参数;用户购买规格单独存为 user_claim,不能当实测。
## 安全配置
常规 suite 不跑 CPU 或磁盘基准;测速需 allow_bandwidth=True / CLI --allow-bandwidth。该标志仅授权当前调用,不是永久授权。当前预算沿用 bandwidth.payload_budget,属于 HTTP 载荷预算,不能宣称为链路计费硬上限。CPU/磁盘基准后续单独命令、确认和上限。常规联网仍会向目标及 API 暴露请求来源;--offline 跳过联网。
第三方可执行文件入口当前暂停;恢复前必须审查版本、许可、网络上传和资源消耗,不自动下载或提权。未来 FlexMaster 默认同样应用安全策略。暂不承诺稳定 API/Schema 兼容性;新格式须先有版本及迁移文档。
## Reliability batch — 2026-11-01 (implemented, untested)
`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs.
Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot.
Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation.
Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet.
## 资源检测第三组(2026-11-01:已编写,未测试)
- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。
- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。
- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。
- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。
- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。
限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。
尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。
实现语义参考 Linux 内核官方文档:
- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max)
- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒)
- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段)
下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。
## 双栈网络第四组(2026-11-01,已编写但未测试)
新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。
分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。
增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。
## 基础安全第五组(2026-11-01:实现未测试)
新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。
更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。
新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。
+110
View File
@@ -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实测。
+13
View File
@@ -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 安装预编译不作无缓存承诺。没有删除历史缓存来掩盖结果,验收使用全新目录。
+411
View File
@@ -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 综合诊断工具。
+20
View File
@@ -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,应在用户明确选择时才打开。未具备的资源统一列待接入,不能伪造地图、数据库结果或风险分数。
+83
View File
@@ -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"
}
}
+17
View File
@@ -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 条款、数据集许可、费用和深层实现仍须逐项研究后才能集成。
+271
View File
@@ -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实测。
+46
View File
@@ -0,0 +1,46 @@
## P0 追加修复 · BETA22-001
本轮修复父解释器 -B 不被新 Python 进程继承的问题。联网、DNS、邮件子解释器均显式传 -B,安装器探测及源码脚本同步。
五种实际 worker 在全新副本中运行,配可写负对照;开发机回归 277 通过、1 跳过。没有删除缓存代替修复。
用户将 22.04 Server 要求调整为原 22.04 Desktop 上全新 HOME/安装目录;24.04.3 Server 另行测试。实际结果见候选包旁本轮验收报告,历史材料不等于当前候选通过。
---
# 1.0 公测候选变更与待测项
版本:1.0.0b1;非最终版。日期:2026-11-01。
基线:用户上传 iprisk-edition,SHA256 3fa8f9c2294774fa0aad98866e8ce3c3ea181bdcf1854515591dd0e7ccf639dc。
## 已完成代码
1. 修复三主题的框宽、中文按显示列换行、长字段截断、分组错位、英文残留、颜色参数未传递、--lang=zh 降级以及风险汇总显示绿色。
2. 联网子模块复用 Console,路由输出进入框内;保留已有普通主题和命令。
3. 可选无密钥来源可省略;限流/异常/请求失败继续显示,恢复有效 AbuseIPDB 结果展示。
4. 流媒体 CLI 仅检查公开页面可达性,一次请求,不输出解锁/套餐/播放结论;旧内部分类 API 保留兼容。
5. 新增 trace ICMP/TCP 的固定系统 traceroute 适配,限时、取消与逐跳解析;缺依赖不安装、不提权。
6. 新增 return-route 三城九方向追踪与 IPv4/IPv6 参数;不冒充全国/双向覆盖。
7. 新增 bgp RIPE RIS 前缀验证、起源、邻居、AS路径和 ASN 标签,不推断商业上游或 GIA 等级。
8. 新增 cn-nodes 目录与小样本探测;覆盖统计排除香港。新增可选择的三网候选 HTTPS 下载,尚未证明完整大陆三网可用,也没有三网上传。
9. 新版标识、安装提示 flush、启动 -B、无 unzip 解压说明;Markdown 安全 JSON 围栏。
10. 更新中英文文档和来源记录。未增加产品 Python 依赖;未修改系统网络/安全配置。
## 证据和限制
- 开发机自动化:271 通过,1 项 Windows 符号链接权限跳过。增加实际主题 CLI、列宽、语言、可达性、颜色、来源异常、BGP 校验、TCP 参数和香港排除回归覆盖。
- 初轮失败 XML 保留;其中包括清单未刷新、边框尾空格和旧接口夹具不适配。修复后全套通过,没有删除失败测试或降低安全要求。
- 上海电信及香港移动的 HTTPS 小 JPEG 可访问,南京电信仅明文成功;大陆联通/移动存在解析失败、拒绝或超时。小样本不等于有效多轮带宽或海外可用。
- 证据位于工作区 outputs/public-beta-tests.xml、carrier-node-discovery-20261101.json、carrier-node-discovery-round2-20261101.json。
- 尚未执行当前版本的 Ubuntu 安装、VM/海外实测。历史 Alpha 结果不能替代新版验收。
## 下一轮测试
- 从最终 ZIP 在 Ubuntu 22.04/24.04 安装、重复安装、失败清理、隔离和界面验收。
- ICMP/TCP 权限、缺依赖、IPv6、超时、中断、逐跳 IP/RTT/地理位置。
- BGP 实网接口、限流、异常响应及路径范围解释。
- 海外 VPS 三城九方向完整追踪及恢复;全国/双向仍是后续能力。
- 大陆三运营商有效多轮下载和节点开放范围;不将香港替代大陆移动。
- 流媒体基础可达性、模块异常、报告保存、Markdown 和中断恢复回归。
- 持续复核节点服务条款和可用性;开源许可证不等于第三方服务承诺。
按用户要求,代码、文档、本地候选包收尾后停止,留待下一阶段测试。
结论:可提交下一轮测试的代码候选;不是已通过最终公测验收。未推送、合并或发布。
+46 -148
View File
@@ -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.
+76 -123
View File
@@ -1,155 +1,108 @@
# ProxyDoctor
# VPS 检测工具 · 1.0 公测版
[中文](README.md) | [English](README.en.md)
面向 Ubuntu VPS 新手的原生 Python 只读诊断工具。支持 Ubuntu 22.04 及更新版本;
当前优先验收 22.04、24.04,其他版本逐步实测。Python 3.10+,没有第三方运行依赖。
本阶段不安装 sing-box,不修改 SSH/防火墙,不自动提权,不下载执行远程脚本。
面向 Ubuntu VPS 的 Python 命令行体检工具。内部项目名及命令仍为 ProxyDoctor / `proxydoctor`。
**这是 1.0 公测版,非最终版本。** 包版本 `1.0.0b1`;当前是本地候选包,尚未公开发布或完成最终验收。
## 快速运行
## 安装
下载源码包并解压,进入项目目录:
需要 Linux、Python 3.10+ 和标准库 venv,支持目标为 Ubuntu 22.04 及更新版本。
没有第三方 Python 运行依赖,不调用 sudo、pip 或系统包管理器。
先从可信来源核对摘要,再解压并阅读 install.py。没有 unzip 也可用 Python 解压:
```bash
bash proxydoctor.sh
bash proxydoctor.sh suite --lang en
bash proxydoctor.sh suite --offline
sha256sum -c ProxyDoctor-v1.0.0-beta.1.zip.sha256
python3 -m zipfile -e ProxyDoctor-v1.0.0-beta.1.zip .
cd ProxyDoctor-v1.0.0-beta.1
python3 install.py --run
```
下载或克隆本身不执行程序。默认入口 `suite` 会联网、逐项回显;
`check` 默认只读取本机信息。普通用户可运行,权限不足显示未知;程序不调用 sudo。
安装到用户目录的隔离环境,首次仅运行离线体检。已有目录不会覆盖;失败只清理本次新建目录。
缺少 Python/venv 时明确退出,不自动安装系统依赖。`--prefix` 可指定不存在的用户目录。
安装后的启动器及各 Python worker 显式使用 `-B`,忽略用户包路径并禁止这些启动路径生成字节码缓存。此承诺覆盖随包安装器启动命令和源码脚本;自行使用 Python 导入或 `python -m` 时应加 `-B`,pip 的安装期预编译及外部调用方不在此范围。
```bash
python3 -m proxydoctor check
python3 -m proxydoctor check --port 8388 --json --output check.json
python3 -m proxydoctor suite --json-output suite.json --color always
PD="$HOME/.local/share/proxydoctor/1.0.0b1/bin/proxydoctor"
"$PD" check --theme framed --lang zh
"$PD" suite --offline --theme framed
"$PD" suite --lang en
"$PD" quality --color always
"$PD" ipquality --show-ip
"$PD" media
"$PD" --help
```
报告文件不会覆盖已有文件,也不会自动创建目录。原生报告仅保存在本地,IP 默认脱敏;
脱敏不是匿名保证,分享前仍应检查。`--show-ip` 可在终端显示出口地址,文件仍脱敏。
路由模块按用户要求在终端显示实际节点 IP,导出文件中隐藏。
`check`、`suite` 支持 default/iprisk/framed 三种主题。结果直接逐项显示在命令行。
中英文可选;纯 Linux 文本控制台默认英文,显式 `--lang zh` 或 `--lang=zh` 保留中文。
## 四模块
## 能力与范围
| 模块 | 直接回显 | 边界 |
| 模块 | 本版能力 | 结果范围 |
| --- | --- | --- |
| VPS 资源与体检 | 系统、架构、CPU、逻辑核心、内核、内存、磁盘、SHA-256 样本;DNS 配置、端口、SSH 保护、SSH 设置和防火墙提示 | 系统可见资源不保证等于商家配额;SHA-256 不是完整 CPU 基准;本机读取不能确认云防火墙放行 |
| 节点网络/IP 质量 | 实际 DNS、TCP 请求,IP 归属/ASN/地区/类型、风险标记、来源状态、覆盖率和分歧 | DNS 返回 IPv6 不代表 IPv6 连通;第三方标记不是客观安全保证,不合并不同评分 |
| 路由 | 原生 Linux IPv4/IPv6 UDP 追踪,默认 30 跳,可至 64;每行 IP、延迟、估计位置 | 中间节点可能不回包,位置是数据库估计,不保证真实物理位置;NAT/过滤可能导致未到达 |
| 流媒体 | Netflix、Disney+、YouTube Premium、Spotify、Prime Video、Max、Apple TV+、Hulu、BBC iPlayer、Paramount+ | 明确地区提示/套餐证据重复确认;片库和元数据不等于账号/DRM 实际播放;规则失效和网络失败显示未知 |
| 系统和资源 | 系统、CPU、内存、Swap、根分区、可见配额、短采样 steal/PSI | 无法证明资源独享;不执行 CPU/磁盘基准 |
| 网络 | 分 IPv4/IPv6 的 DNS、TCP、证书验证 TLS | 本机到指定目标的当前观测 |
| 路由 | 原生 UDP;可选系统 traceroute 的 ICMP/TCP | 默认 30 跳、最多 64 跳;响应缺失不等于转发丢包 |
| IP 信息 | 归属、ASN、运营商及分来源风险;可选 PTR/TCP25 | 缺失保持未知;接口失败显示,未配置的可选密钥来源可省略 |
| 流媒体 | Netflix、Disney+、YouTube Premium 等 10 项公开网页 | 仅可达性,不输出解锁或播放成功结论 |
| 安全 | SSH、端口、防火墙、权限和更新缓存只读检查 | 不修改配置,不是完整漏洞扫描 |
| 带宽 | 多来源多轮下载,Cloudflare 支持上传 | 必须明确允许;短样本不报线路容量 |
| BGP | RIPE RIS 前缀、起源 ASN、邻居、AS_PATH 样本 | 采集器视角,不等于本机选路或商业上游 |
JSON 综合报告 schema_version=2,同时包含本机检查、DNS/TCP、每个测速来源、IP 来源观察、路由逐跳及媒体探测依据。
绿色表示取得结果或局部通过,红色表示已知风险/限制/冲突,黄色表示未知、跳过或证据不足。
支持 `--color auto|always|never` 和 `NO_COLOR`。复杂配置的完整依据可用 `check --json` 查看。
`sshd -T` 读取默认配置,不保证与运行中实例或 Match 条件相同。安装与修复阶段仍须独立保护 SSH。
## 三网、路由和 BGP
## 多来源测速
以下是新增公测能力,尚待本轮 Linux 与海外 VPS 实测验收。
```bash
bash proxydoctor.sh bandwidth
bash proxydoctor.sh bandwidth --source ovh-rbx --rounds 3 --sample-mib 4
bash proxydoctor.sh bandwidth --source hetzner-fsn --sample-mib 1
bash proxydoctor.sh suite --bandwidth-source cloudflare
# 固定候选的小流量探测,不运行带宽测试
"$PD" cn-nodes
# 大陆三运营商候选多轮下载;仍可能不可用,失败如实显示
"$PD" bandwidth --china --allow-bandwidth --rounds 3 --sample-mib 4
"$PD" bandwidth --source cn-telecom-sh --allow-bandwidth
# 香港移动单独标注,不能替代大陆移动
"$PD" bandwidth --source mobile-hk --allow-bandwidth
# ICMP/TCP 需要系统已经安装 traceroute,缺少则明确退出,不提权安装
"$PD" trace --target 1.1.1.1 --method tcp --tcp-port 443 --geo
"$PD" trace --target 1.1.1.1 --method icmp --max-hops 64
# 北京/上海/广州 × 三网,逐条执行,每条约 30 秒探测软预算
"$PD" return-route --method udp
"$PD" return-route --method tcp --family 6
"$PD" bgp --target 1.1.1.1
# 综合入口额外模块需明确开启
"$PD" suite --route-method tcp --include-return-routes --include-bgp
```
| 来源参数 | 节点标签 | 下载 | 上传 | 默认 |
| --- | --- | --- | --- | --- |
| cloudflare | Cloudflare / Anycast | 是 | 是 | 是 |
| ovh-rbx | OVHcloud / 法国 Roubaix | 是 | 不支持 | 是 |
| hetzner-fsn | Hetzner / 德国 Falkenstein | 是 | 不支持 | 否,当前网络较慢 |
三城探测方向为“本机 → 中国目标”,在海外 VPS 上通常称回程;不包含中国侧到 VPS 的去程或全国各省全部线路。
ASN 标签不能证明 CN2 GIA 等套餐等级。测速下载是节点到本机,上传是本机到节点,和逐跳探测方向分别解释。
每方向 128 KiB 预热不计统计;正式 3–8 轮、每轮 1–20 MiB。默认请求测速载荷
最多 36.375 MiB,不含协议开销及其他模块请求。各来源独立给出中位数、范围、成功数;
不做跨来源平均。样本短于 1 秒、轮次不足或波动大显示黄色。吞吐包含建连,不能核验商家标称带宽。
对大文件只接受精确 HTTP 206 分段响应;若服务器忽略 Range,不读取整文件正文,不回退完整下载。
测速超过时限或未完成不记为成功;不支持上传不记为上传失败。带宽子报告 schema_version=2。
节点标签来自公开目录,不等于实测身份认证。当前开发机已验证上海电信及香港移动的小图片可访问;大陆联通、移动尚无本轮成功证据。
香港不计入大陆三网覆盖。HTTPS 测速不自动降级明文或关闭证书验证。三网候选本轮仅下载,未实现三网上传。
`cn-nodes` 使用少量明文测试协议数据,不发送凭据或报告;小样本成功不等于带宽容量。
## IP 数据与密钥
## 报告、安全和中断
报告默认保存在当前目录的 reports/。支持 `--json-output` 的命令可指定路径;已有文件不会覆盖。
保存文件默认脱敏 IP;`--show-ip` 只影响终端。分享前仍须人工复核。
```bash
bash proxydoctor.sh ipquality --show-ip --json-output ip.json
bash proxydoctor.sh ipquality --no-enrichment
bash proxydoctor.sh suite --no-ip-enrichment
"$PD" suite --offline --json-output report.json
"$PD" report --input report.json --output report.md
"$PD" recover --journal report.json.events.jsonl --output recovered.json
"$PD" recover --journal report.json.events.jsonl --output recovered.md --format markdown
```
默认查询 ipapi.is、proxycheck.io 和 IP2Location。若 ipapi.is 连接失败,尝试 ipify 获取出口 IP。
后续查询固定为该地址,拒绝把其他地址的响应混入结果。所有这些服务会看到请求来源,
另外两家会收到查询的出口地址。可用 `--no-ip-enrichment` 关闭额外来源,`--offline` 关闭综合入口联网。
Ctrl+C/SIGTERM 尝试取消活动子进程并保存已有记录。API、DNS、命令、测速有限时;恢复日志有大小上限。
JSON 保留原模块和统一 result_items 的状态、方法、时间、来源及范围。Markdown 使用防提前闭合的 JSON 代码围栏。
断电、SIGKILL、磁盘故障时以最后完整恢复事件为准。
17 项核心字段包括运营商/ASN、地区/时区、网络类型、网段滥用比例,以及数据中心、代理、VPN、
Tor、近期滥用、移动、卫星、Anycast、保留地址标记。按来源记录观察和字段来源;布尔标记冲突显示
“来源不一致”,不会自动选成安全。IP2Location 免费模式的有限代理标记单独保留,不替代通用 VPN 判断。
proxycheck.io 风险/置信度与 AbuseIPDB 评分分别保存,不求平均,不把网段比例当作单 IP 恶意概率。
退出码:0 正常完成,1 有提示/不完整/保存失败,2 参数或安装错误,130 中断。
默认只读,不修改 SSH、防火墙、路由、系统 DNS,不自动上传完整报告或修复系统;联网目标会看到请求来源 IP。
免密钥 API 也有配额:proxycheck.io 当前限 100 次/天,IP2Location 当前限 1000 次/天;以服务官方规则为准。
遇到限流不自动重试。ipapi.is 匿名响应只提供基础数据;扩展字段和 AbuseIPDB 举报数据可通过密钥补充。
不要把密钥发到聊天、提交到仓库或放进命令参数;在 VPS 本地无回显输入:
## 公测状态与开源
```bash
read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY
export PROXYDOCTOR_IPAPI_KEY
printf '\n'
read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY
export PROXYDOCTOR_ABUSEIPDB_KEY
printf '\n'
bash proxydoctor.sh ipquality
```
P0 修复自动化:277 通过、1 项 Windows 符号链接权限跳过。当前构建的实机验收结果以随候选 ZIP 单独交付的 BETA_ACCEPTANCE_REPORT.md 和结构化结果中的 SHA256 为准;不将旧候选报告当作本包已通过。ICMP/TCP 实际权限、完整大陆三网和海外回程需按该报告的覆盖范围判断。
旧 Alpha 结果不是本包验收证据。见 [当前状态](CURRENT_STATUS.md)、[变更和待测项](PUBLIC_BETA_CHANGE_REPORT.md)。
尚无全国完整三网、长期监测、CPU/磁盘基准、DNSBL、Web UI、代理部署、Karing 订阅或自动更新卸载。
更新安装到新版本目录;卸载前退出进程、备份报告,仅移除自己确认过的安装目录。
ipapi.is 密钥放 HTTPS POST 正文,AbuseIPDB 放请求头,不写入 URL 或报告。
缺少字段和密钥明确显示未知;完整覆盖率只针对定义的核心字段,不代表所有数据库或安全认证。
零举报不代表干净 IP。只确认本次接口请求的出口和地址族,不保证每个服务连接都使用同一出口。
## 路由与媒体复测
```bash
bash proxydoctor.sh suite --route-target 1.1.1.1 --max-hops 64
bash proxydoctor.sh suite --route-target 2606:4700:4700::1111 --no-geo
bash proxydoctor.sh media --color always --json-output media.json
```
路由位置来自 ipwho.is,按运行缓存;私网/保留节点不查询。`--no-geo` 关闭位置查询。
媒体重复确认地区提示/带价格的具体套餐;Netflix 检查结构化片库元数据,BBC 额外查询测试频道的公开媒体元数据。
不获取付费节目、不下载流媒体、不绕过登录/DRM,不保留媒体签名地址。BBC 的测试频道结果不代表整个平台。
HTTP 200、403、页面可达、套餐或片库可见均不作为“完全解锁”证据。
## 退出码
- `check`:0=无已知注意/失败项,仍可能含未知;1=有注意/失败项;2=参数/保存错误。
- `suite/bandwidth/ipquality/media`:0=该命令定义的范围完整,离线 0 仅表示本地执行完成;1=有未完成/未知/波动或未验证播放;2=参数/保存错误。
- 流媒体命令仍保留“账号播放未测”,因此返回 1;这不表示程序崩溃。
- Ctrl+C 返回 130,不改系统设置。
## 安装与开发
源码可直接运行。也可把提供的 wheel 安装到自己的虚拟环境:
```bash
python3 -m venv .venv
.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl
.venv/bin/proxydoctor suite --offline
```
Ubuntu 若未安装 venv 支持,需另行安装系统对应组件;程序不会自动安装。
开发测试:`python -m pip install pytest` 后 `python -m pytest -q`。
GitHub Actions 配置已提供,未发布仓库前不宣称远程 CI 已运行。
贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),安全反馈见 [SECURITY.md](SECURITY.md)。
## 阶段一验收与后续
本轮按用户选择完成代码和 22.04/24.04 虚拟机验收;实际记录以交付验收报告为准。
海外 VPS、真实付费账号播放、带真实密钥的扩展数据,以及 Ubuntu 其他版本仍待分别验收;不会把它们标为已通过。
之后阶段才实现 sing-box 安装、Karing 协议连通、修复预览/备份/回滚、Web UI 和安全订阅传输。
不对外开放明文 HTTP 订阅,不根据只读体检自动修改 SSH/防火墙。
## 开源与来源
原创代码使用 MIT 许可证。感谢 ECS、NodeQuality、NetQuality/IPQuality、NextTrace、RegionRestrictionCheck 的功能和展示设计参考;
默认实现没有打包或复制上游脚本/数据库。来源和许可范围见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
本地第三方适配器仅在显式指定时运行,第三方行为/输出不保证只读或脱敏。
数据接口:[Cloudflare](https://github.com/cloudflare/speedtest)、[OVHcloud](https://proof.ovh.net/)、
[Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/)、
[ipapi.is](https://ipapi.is/developers.html)、[proxycheck.io](https://proxycheck.io/api/)、
[IP2Location](https://www.ip2location.io/ip2location-documentation)、[AbuseIPDB](https://docs.abuseipdb.com/)、
[ipwho.is](https://ipwhois.io/documentation)、[ipify](https://www.ipify.org/)。
[Netflix 官方 VPN 说明](https://help.netflix.com/en/node/114701) 也说明了目录限制,不保证页面可达就能播放。
MIT 项目,标准库运行。感谢 [NextTrace](https://github.com/nxtrace/NTrace-core)、[CleanIP sh.cd](https://github.com/CleanIP/sh.cd)、[RIPEstat](https://stat.ripe.net/) 和既有参考项目;详见 [来源和许可](THIRD_PARTY_NOTICES.md)。不打包下载执行第三方检测脚本或测速客户端。
+22
View File
@@ -1,3 +1,16 @@
## v0.1.0-alpha 候选(2026-11-01)
隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。
# Current branch safety changes — 2026-11-01
Routine suites skip CPU and bandwidth benchmarks. Bandwidth requires explicit per-call consent via `--allow-bandwidth` / `allow_bandwidth=True`; payload limits are not a total network billing cap. External executable/script wrappers are disabled pending resource, license and upload review. Online native checks still disclose the request source to their target services; offline mode skips them.
The updated workflow is not yet tested. Fault isolation and incremental report saving are implemented but untested. Current scope excludes proxy deployment and Web UI. No full-report uploads, paid API use, system dependency installation, production VPS tests, main-branch merges or public releases are authorized by this development brief.
---
# Security / 安全反馈
Do not post vulnerabilities containing passwords, subscription URLs, API keys,
@@ -18,3 +31,12 @@ separate review and SSH rollback safeguards.
中文:不要公开提交密码、密钥、订阅地址或完整诊断报告。仓库发布并开启私密漏洞报告后
优先使用该渠道;未配置前先询问私密联系方式,不公开敏感细节。报告脱敏不是匿名保证,
分享前需检查。当前原生检测不修改 SSH/防火墙,不上传报告;联网模式的服务会看到请求来源。
## 基础安全第五组(2026-11-01:实现未测试)
新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。
更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。
新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。
+103
View File
@@ -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实测。
+41
View File
@@ -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.
+102
View File
@@ -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为现有使用/测试/贡献文档能力;一键安装、更新和卸载仍单独未完成。
+14
View File
@@ -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)。
+207
View File
@@ -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.
+200
View File
@@ -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/端口结果视为绝对安全/邮件可投递证明。
+58
View File
@@ -0,0 +1,58 @@
## 报告与IP质量更新(2026-11-01)
- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。
- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。
- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。
- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。
- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。
- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。
- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。
- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。
证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。
## 最新双机验收(2026-11-01)
同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。
## Ubuntu 24.04 实测更新(2026-11-01)
当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。
中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。
DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。
综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。
完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。
# 当前目标与实际进度
更新:2026-11-01。范围:独立 Python CLI,兼容目标 Ubuntu 22.04 及以上;当前不开发代理部署和 Web UI。
**重要:新增分支全部未测试。旧 0.5.0 的 outputs 验收记录仅属于旧版,不能视为新目标完成证据。没有计算虚假的完成百分比。**
| 类别 | 已有/新增源码能力 | 未完成重点 |
|---|---|---|
| 1 本机资源 | OS/内核/CPU/磁盘;新增 cgroup 配额、affinity、Swap、PSI、steal,优先展示额度而非宿主量 | 虚拟化细分、CPU 频率、购买规格对比、CPU/fio 基准;无法从本机证明商家独享承诺 |
| 2 基础网络 | 接口/路由/DNS;本轮新增分 IPv4/IPv6 的多次 DNS、固定数字 IP TCP、验证证书的 TLS、分层错误码与提示 | UDP、PMTU、更完整多网卡/NAT/双栈诊断、全局时限;新实现未验证 |
| 3 路由/BGP | 现有原生 v4/v6 UDP 追踪,最多64跳、IP/RTT/估计位置 | TCP/ICMP、多轮统计、逐跳 ASN、中国运营商特征与 BGP;中国侧探针缺失 |
| 4 IP 质量 | 多来源归属/风险/冲突,保留证据与来源 | DNSBL/PTR/邮件信誉、条款与限额全面审查、真实密钥验证 |
| 5 流媒体 | 多服务保守规则;片库/地区/未知不等于播放 | 新站点规则维护、自定义合法目标、更多实网证据;账号/DRM 未验证 |
| 6 测速与监测 | 多来源 HTTPS 多轮测速;显式许可、载荷预算 | 可信中国运营商节点、iperf3、抖动/丢包统计、24/48h监测与趋势关联 |
| 7 安全体检 | SSH、端口与防火墙只读;新增固定重要路径权限、所有者、更新缓存计数/年龄;接入suite/check | 全目录/ACL/包版本安全审计尚缺;新增代码未测试;不自动修复 |
| 8 报告与历史 | JSON/IP脱敏;新增模块及逐项恢复日志、Ctrl+C保存、独占创建 | Markdown/HTML、历史比较、购买验收报告、完整恢复CLI、所有模块细粒度保存 |
| 9 安装体验 | CLI入口、双语、Python>=3.10、无运行依赖 | 隔离一键安装/更新/卸载/校验、ARM和其他发行版实测 |
| 10 可靠性 | 新增异常隔离、错误码、取消与保存降级、多个故障注入fixture | 所有新增测试和回归尚未运行;系统级全局预算、更多异常场景、双Ubuntu及真实VPS复验 |
| 11 开源/API | 101项登记;架构/测试/研究记录、MIT/第三方声明;新增独立资源/网络API | 统一稳定Schema、所有核心/UI彻底解耦、Issue模板、完整许可/依赖审计;FlexMaster兼容未验收 |
## 已编写的优先工作
1. P0:默认无CPU基准、测速许可、暂停第三方可上传流程。
2. P0:模块隔离、本地增量恢复日志、Ctrl+C保存、写入失败提示。
3. P1:资源配额/压力及面向VPS额度的显示。
4. P1:双栈 DNS/TCP/TLS 证据和针对性诊断(本轮)。
以上为实现进度,均非测试通过结论。先完成重点低风险源码,再按 TEST_PLAN.md 统一运行单元/回归与虚拟机验收。后续优先:统一报告/Markdown→历史与购买规格对比;基础权限/更新缓存已编写但更深安全审计仍待补齐;CPU/磁盘/长期测速等需要明确预算和单独许可。
5. P1:重要文件权限、更新缓存证据与安全提示已编写,未验证。
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Offline, dependency-free installer. Review this file before running it."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import subprocess
import sys
VERSION = '1.0.0b1'
def verify(source):
manifest = json.loads((source/'MANIFEST.sha256.json').read_text(encoding='utf-8'))
if manifest.get('version') != VERSION or not isinstance(manifest.get('files'), dict):
raise ValueError('Invalid release manifest')
files = manifest['files']
required = {'install.py','pyproject.toml','LICENSE','THIRD_PARTY_NOTICES.md',
'proxydoctor/__init__.py','proxydoctor/__main__.py','proxydoctor/cli.py'}
if not required <= set(files):
raise ValueError('Incomplete release manifest')
actual = {p.relative_to(source).as_posix() for p in (source/'proxydoctor').rglob('*.py')}
if actual != {name for name in files if name.startswith('proxydoctor/') and name.endswith('.py')}:
raise ValueError('Unlisted or missing package source')
for name, expected in files.items():
relative = PurePosixPath(name)
if relative.is_absolute() or '..' in relative.parts or '\\' in name:
raise ValueError('Unsafe manifest path')
path = source.joinpath(*relative.parts)
if any(part.is_symlink() for part in (path,*path.parents) if part != source.parent):
raise ValueError('Symlink in source path')
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
raise ValueError('Release integrity check failed')
return files
def install(source, prefix):
if sys.version_info < (3,10) or sys.platform != 'linux':
raise ValueError('Requires Linux and Python 3.10+')
files = verify(source)
prefix = prefix.expanduser().absolute()
if prefix.is_symlink() or os.path.lexists(prefix):
raise FileExistsError('Install destination already exists; choose a new version directory')
prefix.parent.mkdir(parents=True,exist_ok=True)
prefix.mkdir(mode=0o700)
owned = prefix.resolve()
try:
# --without-pip avoids ensurepip/network/package-manager dependencies.
subprocess.run([sys.executable,'-I','-B','-m','venv','--without-pip',str(prefix)],
check=True,timeout=45,capture_output=True)
python = prefix/'bin/python'
probe = subprocess.run([str(python),'-I','-B','-c',
'import json,sys,sysconfig;print(json.dumps([sys.prefix,sys.base_prefix,sysconfig.get_path("purelib")]))'],
check=True,capture_output=True,text=True,timeout=10)
active,base,library = json.loads(probe.stdout)
if active == base or Path(active).resolve() != owned:
raise ValueError('Virtual environment isolation check failed')
library = Path(library)
library.resolve().relative_to(owned)
for name in files:
if name.startswith('proxydoctor/') and name.endswith('.py'):
destination = library/name
destination.parent.mkdir(parents=True,exist_ok=True)
shutil.copyfile(source/name,destination)
notices = prefix/'share/proxydoctor'
notices.mkdir(parents=True)
for name in ('LICENSE','THIRD_PARTY_NOTICES.md','MANIFEST.sha256.json'):
shutil.copyfile(source/name,notices/name)
launcher = prefix/'bin/proxydoctor'
# execv avoids shebang/path whitespace issues without shell interpolation.
launcher.write_text('#!/bin/sh\nexec '+__import__('shlex').quote(str(python))+' -I -B -m proxydoctor "$@"\n',encoding='utf-8')
launcher.chmod(0o700)
subprocess.run([str(launcher),'--version'],check=True,timeout=10,capture_output=True)
(prefix/'proxydoctor-install.json').write_text(json.dumps({'version':VERSION,'isolated':True}),encoding='utf-8')
return launcher
except BaseException:
# Only remove the new directory reserved by this invocation.
if not prefix.is_symlink() and prefix.resolve() == owned:
shutil.rmtree(prefix)
raise
def main():
parser=argparse.ArgumentParser(description='Offline isolated ProxyDoctor installer / 离线隔离安装')
parser.add_argument('--prefix',type=Path,default=Path.home()/'.local/share/proxydoctor'/VERSION)
parser.add_argument('--run',action='store_true',help='Install and run a read-only offline check')
args=parser.parse_args()
try:
launcher=install(Path(__file__).resolve().parent,args.prefix)
except KeyboardInterrupt:
print('Installation cancelled; new environment cleaned / 安装已取消',file=sys.stderr)
return 130
except (OSError,ValueError,subprocess.SubprocessError):
print('Installation failed. Check Python/venv availability, release checksum and destination. No system packages were installed. / 安装失败,请检查环境、校验和与目录。',file=sys.stderr)
return 2
print('Installed / 已安装:'+str(launcher),flush=True)
print('1.0 public beta; not the final release / 1.0 公测版,非最终版本',flush=True)
if args.run:
return subprocess.call([str(launcher),'suite','--offline'])
return 0
if __name__=='__main__':
sys.exit(main())
+2 -2
View File
@@ -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 "$@"
+6 -1
View File
@@ -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)')
+10
View File
@@ -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()
+52 -9
View File
@@ -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
+96
View File
@@ -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')
+141
View File
@@ -0,0 +1,141 @@
"""Fixed public test endpoints; provider labels are claims until corroborated.
Endpoint references: NextTrace and CleanIP sh.cd (see THIRD_PARTY_NOTICES.md).
No remote scripts or automatic registry downloads are executed.
"""
from dataclasses import dataclass,asdict
import ipaddress
import socket
import time
from .reports import utc_now
@dataclass(frozen=True)
class CarrierNode:
identifier:str
carrier:str
city:str
host:str
port:int=8080
mainland:bool=True
NODES=(CarrierNode('ct-sh','telecom','Shanghai','speedtest1.online.sh.cn'),
CarrierNode('ct-nj','telecom','Nanjing','5gnanjing.speedtest.jsinfo.net'),
CarrierNode('ct-cd','telecom','Chengdu','speedtest1.sc.189.cn'),
CarrierNode('cu-sh','unicom','Shanghai','mobile.shunicomtest.com'),
CarrierNode('cu-bj','unicom','Beijing','beijing.unicomtest.com'),
CarrierNode('cu-tj','unicom','Tianjin','speedtest3.online.tj.cn'),
CarrierNode('cm-fz','mobile','Fuzhou','csfw.fj.chinamobile.com'),
CarrierNode('cm-hz','mobile','Hangzhou','speedtest.139play.com'),
CarrierNode('cm-bj','mobile','Beijing','speedtest.bmcc.com.cn'),
CarrierNode('cm-hk','mobile','Hong Kong','speedtestbb.hk.chinamobile.com',mainland=False))
def resolve_public(host,family=4):
"""Only called inside a bounded network worker."""
af=socket.AF_INET6 if family==6 else socket.AF_INET
values={row[4][0] for row in socket.getaddrinfo(host,None,af,type=socket.SOCK_STREAM)}
if not values or any(not ipaddress.ip_address(value).is_global for value in values):
raise ValueError('Non-public or missing endpoint')
return sorted(values)
def probe_node(identifier):
from .worker import enabled,invoke
if identifier not in {row.identifier for row in NODES}:raise ValueError('Unknown node')
if enabled():
result=invoke('carrier_probe',{'identifier':identifier},timeout=9)
return result if isinstance(result,dict) else {'identifier':identifier,'status':'unknown','error_code':'NODE_TIMEOUT_OR_ERROR','checked_at':utc_now()}
node=next(row for row in NODES if row.identifier==identifier)
row={**asdict(node),'checked_at':utc_now(),'status':'unknown','scope':'small_protocol_probe_not_bandwidth',
'provider_identity':'catalogue_claim','transport':'plaintext_tcp_no_credentials'}
try:
addresses=resolve_public(node.host)
row['addresses']=addresses
started=time.monotonic()
with socket.create_connection((addresses[0],node.port),timeout=2) as sock:
row['tcp_ms']=round((time.monotonic()-started)*1000,3)
sock.settimeout(2);sock.sendall(b'HI\n')
body=bytearray()
while b'\n' not in body and len(body)<256:
part=sock.recv(1)
if not part:break
body.extend(part)
if not body.startswith(b'HELLO '):
row['error_code']='UNRECOGNIZED_TEST_PROTOCOL';return row
sock.sendall(b'DOWNLOAD 16384\n')
total=0
deadline=time.monotonic()+3
while total<16384:
if time.monotonic()>=deadline:raise TimeoutError()
block=sock.recv(min(4096,16384-total))
if not block:break
total+=len(block)
row['sample_bytes']=total
row['status']='sample_available' if total==16384 else 'unknown'
if total!=16384:row['error_code']='SHORT_TEST_SAMPLE'
except socket.gaierror:row['error_code']='DNS_ERROR'
except (OSError,ValueError):row['error_code']='CONNECTION_OR_PROTOCOL_ERROR'
return row
def collect_nodes(destination=None,on_result=None,out=None):
from concurrent.futures import ThreadPoolExecutor,as_completed
report=destination if destination is not None else {}
report.update(checked_at=utc_now(),nodes={},coverage={},status='unknown',
scope='current_vantage_to_fixed_public_test_nodes_not_all_china',
bandwidth_measured=False)
with ThreadPoolExecutor(max_workers=3) as pool:
futures={pool.submit(probe_node,node.identifier):node for node in NODES}
for future in as_completed(futures):
node=futures[future]
try:row=future.result()
except Exception:row={'identifier':node.identifier,'status':'unknown','error_code':'NODE_PROBE_ERROR'}
report['nodes'][node.identifier]={**asdict(node),**row}
if on_result:on_result()
if out:
label=f'{node.carrier} / {node.city}' + (' [HK]' if not node.mainland else '')
out.row(label,row['status']+' · '+str(row.get('error_code',str(row.get('sample_bytes',0))+' bytes')),
'ok' if row['status']=='sample_available' else 'unknown')
for carrier in ('telecom','unicom','mobile'):
report['coverage'][carrier]=any(row['carrier']==carrier and row['mainland'] and row['status']=='sample_available'
for row in report['nodes'].values())
report['status']='success' if all(report['coverage'].values()) else 'partial'
if on_result:on_result()
return report
def return_routes(*,method='udp',family=4,hops=30,geo=False,tcp_port=443,destination=None,on_result=None,out=None):
"""Beijing/Shanghai/Guangzhou × three carriers, from this host only."""
from .worker import invoke
from .routes import trace_method,summarize_hops
if method not in ('udp','icmp','tcp') or family not in (4,6) or not 1<=hops<=64:
raise ValueError('Invalid return-route options')
report=destination if destination is not None else {}
report.update(direction='this_host_to_china_targets',checked_at=utc_now(),status='partial',routes={},
scope='three_cities_three_carriers_not_all_provinces_or_reverse_direction',
target_reference='https://github.com/nxtrace/NTrace-core/blob/main/fast_trace/basic.go')
for city in ('pek','sha','can'):
for carrier,asn in (('telecom',4134),('unicom',4837),('mobile',9808)):
key=f'{city}-{carrier}'
host=f'ipv{family}.{city}-{asn}.endpoint.nxtrace.org'
row={'host':host,'carrier_claim':carrier,'expected_asn':asn,'method':method,'hops':[],'state':'unknown'}
report['routes'][key]=row
if out:out.line(f'{city} / {carrier} / {method.upper()}','title')
addresses=invoke('resolve_public',{'host':host,'family':family},timeout=5)
if not isinstance(addresses,list) or not addresses:
row['error_code']='TARGET_DNS_UNCONFIRMED'
else:
row['target']=addresses[0]
def progress():
if on_result:on_result()
def emit(line,**kwargs):
if out:out.line(line,'unknown')
code=trace_method(row['target'],emit,method=method,tcp_port=tcp_port,hops=hops,lang=out.lang if out else 'en',
geo=geo,records=row['hops'],on_result=progress,deadline=time.monotonic()+30)
row['state']='reached' if code==0 else 'incomplete'
row['statistics']=summarize_hops(row['hops'])
if on_result:on_result()
report['status']='success' if all(row['state']=='reached' for row in report['routes'].values()) else 'partial'
return report
+35 -29
View File
@@ -80,6 +80,14 @@ class Host:
return os.environ.get("SSH_CONNECTION", "")
def detect_public_ip(self):
from .worker import enabled
if enabled():
from .net import request_json
data,state=request_json('https://api.ipify.org?format=json')
try:
address=ipaddress.ip_address(data.get('ip')) if state=='ok' else None
return str(address) if address and address.is_global else None
except (ValueError,TypeError,AttributeError):return None
# Explicit opt-in; no environment proxies or redirects.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
@@ -192,7 +200,7 @@ def route_check(host, t):
failed = True
evidence.append(f"IPv{family[-1]}: " + t("路由读取失败或格式无法解析"))
return Result("default_route", "pass" if valid and not failed else "unknown", t("默认路由"),
t("已读取默认路由;不代表互联网或目标地址可达") if valid and not failed else
t("已读取默认路由") if valid and not failed else
t("默认路由信息不完整或未找到,无法确认网络出口"), "\n".join(evidence),
t("仅检查当前网络命名空间的默认路由,不覆盖策略路由。"))
@@ -220,8 +228,8 @@ def dns_check(host, t):
stub = any(addr.is_loopback for addr in addresses)
status = "unknown" if invalid or not addresses or stub else "pass"
detail = (t("DNS 配置不完整或没有有效的服务器地址") if invalid or not addresses else
t("DNS 指向本机解析器;上游配置及解析是否成功尚未验证") if stub else
t("已读取 DNS 服务器配置;尚未验证解析是否成功"))
t("DNS 指向本机解析器") if stub else
t("已读取 DNS 服务器配置"))
return Result("dns_config", status, t("DNS 配置"), detail, evidence,
t("仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。"))
@@ -261,10 +269,9 @@ def ssh_port_check(host, ssh, port, t):
evidence.append(t("当前 SSH 会话本地端口:{port}", port=session_port))
matched = port in configured or port == session_port
detail = t("目标端口与 SSH 已知端口冲突;不要用于代理") if matched else t("未能排除与运行中 SSH 端口冲突")
advice = t("检查运行中 SSH、socket 激活及自定义实例;证据不足时不要进行端口或防火墙修改。")
if malformed:
evidence.append(t("SSH 端口输出不完整或无效"))
return Result("ssh_port_safety", "warn" if matched else "unknown", t("SSH 端口保护"), detail, "\n".join(evidence), advice)
return Result("ssh_port_safety", "warn" if matched else "unknown", t("SSH 端口保护"), detail, "\n".join(evidence), "")
def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=False):
@@ -280,12 +287,12 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
os_unknown = system == "Linux" and not os_info.get("ID")
version_unknown = is_ubuntu and version is None
add(Result("system", "pass" if supported else "unknown" if version_unknown or os_unknown else "warn", t("操作系统"),
t("版本符合 Ubuntu 22.04 最低要求;不代表已完成实机兼容验证") if supported else
t("版本符合 Ubuntu 22.04 最低要求;实机兼容性按已测试环境确认") if supported else
t("无法读取 Linux 发行版标识,不能确认系统要求") if os_unknown else
t("无法解析 Ubuntu 版本,不能确认是否符合最低要求") if version_unknown else t("需要 Ubuntu 22.04 或更新版本"),
f"{system}; {os_info.get('PRETTY_NAME', t('版本未知'))}; {arch}",
t("版本检查不验证官方维护状态;Ubuntu 22.04 及更新版本仍需分别进行实机测试。") if supported else
t("其他系统的结果仅供参考;请使用 Ubuntu 22.04 或更新版本验证。")))
t("支持范围:Ubuntu 22.04 及更新版本。") if supported else
t("其他系统的结果仅供参考。")))
add(Result("architecture", "pass" if arch.lower() in {"x86_64", "amd64", "aarch64", "arm64"} else "unknown",
t("系统架构"), t("检测架构;本阶段不验证安装包兼容性"), arch))
try:
@@ -307,14 +314,13 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
try:
free = host.disk_free() / 1024**3
add(Result("disk", "pass" if free >= 1 else "warn", t("根分区可用空间"),
t("可用 {free:.1f} GiB(体检提示阈值 1 GiB,不代表完整安装要求)", free=free)))
t("可用 {free:.1f} GiB(体检提示阈值 1 GiB)", free=free)))
except OSError:
add(Result("disk", "unknown", t("根分区可用空间"), t("无法读取")))
try:
total, free = host.disk_total() / 1024**3, host.disk_free() / 1024**3
add(Result('disk_capacity', 'pass', t('根分区容量'),
t('总容量 {total:.1f} GiB,可用 {free:.1f} GiB', total=total, free=free),
advice=t('这是根文件系统容量,不一定等于 VPS 购买的磁盘总容量。')))
t('总容量 {total:.1f} GiB,可用 {free:.1f} GiB', total=total, free=free)))
except (AttributeError, OSError):
add(Result('disk_capacity', 'unknown', t('根分区容量'), t('无法读取')))
if public_ip and detect_public_ip:
@@ -322,19 +328,17 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
if detect_public_ip:
address = host.detect_public_ip()
add(Result("public_ip", "unknown", t("公网 IP"),
t("已通过 ipify 查询出口地址;未验证入站可达性或归属") if address else t("出口 IP 查询失败,无法确定公网地址"),
address or "", t("出口地址可能是 NAT 或代理地址;不是入站可达性证明。")))
t("已通过 ipify 查询出口地址;入站可达性未确认") if address else t("出口 IP 查询失败,无法确定公网地址"),
address or "", t("出口地址可能是 NAT 或代理地址。")))
elif public_ip:
addr = ipaddress.ip_address(public_ip)
add(Result("public_ip", "unknown" if addr.is_global else "warn", t("公网 IP"),
t("用户提供的地址具备公网地址属性,但未验证归属或可达性") if addr.is_global else t("提供的地址不是全局可路由公网地址"),
t("用户提供的地址具备公网地址属性") if addr.is_global else t("提供的地址不是全局可路由公网地址"),
str(addr)))
else:
add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP"),
advice=t("可用 --public-ip 填写云控制台地址;地址归属及入站可达性仍需外部验证。")))
add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP")))
add(Result("cloud_firewall", "unknown", t("云防火墙与外部可达性"),
t("本机读取不能确认云安全组、NAT 或外部网络路径"),
advice=t("后续从客户端独立测试;不能仅据此认定端口已开放或被封锁。")))
t("本机读取不能确认云安全组、NAT 或外部网络路径")))
if system != "Linux":
for key, title in (("memory", t("内存")), ("interfaces", t("网络地址")),
("default_route", t("默认路由")), ("dns_config", t("DNS 配置")),
@@ -348,15 +352,14 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
total_line = next((line for line in (memory or '').splitlines() if line.startswith('MemTotal:')), '')
try:
total_mib = int(total_line.split()[1]) / 1024
add(Result('memory_total', 'pass', t('内存总量'), t('系统可见 {total:.0f} MiB', total=total_mib),
advice=t('系统可见内存可能与商家标称或容器限额不同。')))
add(Result('memory_total', 'pass', t('内存总量'), t('系统可见 {total:.0f} MiB', total=total_mib)))
except (ValueError, IndexError):
add(Result('memory_total', 'unknown', t('内存总量'), t('无法读取')))
available = next((line for line in (memory or "").splitlines() if line.startswith("MemAvailable:")), "")
try:
mib = int(available.split()[1]) / 1024
add(Result("memory", "pass" if mib >= 256 else "warn", t("可用内存"),
t("约 {mib:.0f} MiB(体检提示阈值 256 MiB,不代表完整安装要求)", mib=mib)))
t("约 {mib:.0f} MiB(体检提示阈值 256 MiB)", mib=mib)))
except (ValueError, IndexError):
add(Result("memory", "unknown", t("可用内存"), t("无法读取 MemAvailable")))
network = host.run("ip", "-j", "address", "show")
@@ -365,7 +368,7 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
addresses = [f"{item['ifname']}: {addr['local']}" for item in interfaces
for addr in item.get("addr_info", []) if addr.get("scope") != "host"]
add(Result("interfaces", "pass" if addresses else "unknown", t("网络地址"),
t("仅表示本机接口地址;不证明互联网可达"), "\n".join(addresses)))
t("仅表示本机接口地址"), "\n".join(addresses)))
except (TypeError, ValueError, KeyError, AttributeError):
add(Result("interfaces", "unknown", t("网络地址"), t(network.reason) or t("无法解析接口信息")))
add(route_check(host, t))
@@ -375,7 +378,7 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
conflicts = [f"{proto} {endpoint}" for proto, endpoint, number in rows if number == port]
add(Result("ports", "warn" if conflicts else "unknown" if ports.code != 0 or malformed else "pass",
t("目标端口占用"), t("目标 TCP/UDP 端口 {port} 已有监听", port=port) if conflicts else
t("无法完整确认端口占用") if ports.code != 0 or malformed else t("当前未发现 TCP/UDP 端口 {port} 的监听;不保证之后绑定成功", port=port),
t("无法完整确认端口占用") if ports.code != 0 or malformed else t("当前未发现 TCP/UDP 端口 {port} 的监听", port=port),
"\n".join(conflicts), t("选择未占用端口;不会停止已有服务。") if conflicts else ""))
ssh = host.run("sshd", "-T")
add(ssh_port_check(host, ssh, port, t))
@@ -385,10 +388,10 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
risk = settings.get("permitrootlogin") == "yes" or settings.get("passwordauthentication") == "yes"
ssh_complete = all(key in settings for key in ("permitrootlogin", "passwordauthentication"))
add(Result("ssh", "unknown" if ssh.code != 0 else "warn" if risk else "unknown", t("SSH 配置"),
(t(ssh.reason) or t("读取失败")) if ssh.code != 0 else t("允许 root 登录或密码认证,建议人工审阅") if risk else
t("SSH 输出缺少关键字段,无法完成风险判断") if not ssh_complete else t("未发现这两项宽松设置;不能据此认定 SSH 安全"),
(t(ssh.reason) or t("读取失败")) if ssh.code != 0 else t("允许 root 登录或密码认证") if risk else
t("SSH 输出缺少关键字段,无法完成风险判断") if not ssh_complete else t("未发现这两项宽松设置"),
"\n".join(line for line in ssh.output.splitlines() if line.split() and line.split()[0] in selected) if ssh.code == 0 else "",
t("sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同;不自动修改 SSH。")))
t("sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同。")))
firewall = []
any_rules = False
complete = True
@@ -410,19 +413,22 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa
failed = active.output.strip() == "failed"
add(Result("service", "warn" if failed else "pass" if running else "unknown", t("sing-box 服务"),
t("sing-box 服务处于 failed 状态,需要检查启动失败原因") if failed else
t("服务处于 active;不代表代理连接成功") if running else t("未确认 sing-box 服务运行;新系统尚未安装属于正常情况"),
t("服务处于 active") if running else t("未确认 sing-box 服务运行;新系统尚未安装属于正常情况"),
f"active: {active.output.strip() or t(active.reason)}\nenabled: {enabled.output.strip() or t(enabled.reason)}"))
startup = enabled.output.strip()
startup_ok = enabled.code == 0 and startup == "enabled"
startup_warn = startup in {"disabled", "masked", "enabled-runtime", "masked-runtime"}
add(Result("service_startup", "pass" if startup_ok else "warn" if startup_warn else "unknown",
t("sing-box 开机启动"), t("已配置持久开机启动;尚未实际重启验证") if startup_ok else
t("sing-box 开机启动"), t("已配置持久开机启动") if startup_ok else
t("未配置持久开机启动或服务被屏蔽,重启后可能不可用") if startup_warn else
t("无法确认 sing-box 的持久开机启动状态"), startup or t(enabled.reason)))
return report(results, port, lang)
def report(results, port, lang="zh"):
return {"schema_version": 1, "language": lang, "tool_version": __version__,
document={"schema_version": 1, "language": lang, "tool_version": __version__,
"generated_at": datetime.now(timezone.utc).isoformat(), "mode": "read-only",
"target_port": port, "checks": [asdict(result) for result in results]}
from .schema import normalize_report
normalize_report(document)
return document
+253 -52
View File
@@ -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
+203
View File
@@ -0,0 +1,203 @@
"""Family-specific bounded DNS/TCP/TLS observations, without HTTP content."""
import errno
import ipaddress
import json
import socket
import ssl
import subprocess
import sys
import time
from .reports import utc_now
TARGETS = (
('Cloudflare','one.one.one.one','1.1.1.1'),
('Cloudflare','one.one.one.one','2606:4700:4700::1111'),
('Google DNS','dns.google','8.8.8.8'),
('Google DNS','dns.google','2001:4860:4860::8888'),
)
RESOLVER = '''import json,socket,sys
family=int(sys.argv[1])
try:
answers=socket.getaddrinfo('example.com',443,family=family,type=socket.SOCK_STREAM)
print(json.dumps({'addresses':sorted(set(item[4][0] for item in answers))}))
except socket.gaierror as error:
print(json.dumps({'error':error.errno}))
'''
def dns_probe(version, timeout=2):
if version not in (4,6) or not 0 < timeout <= 5:
raise ValueError('Invalid DNS probe limits')
family = socket.AF_INET if version == 4 else socket.AF_INET6
row = {'family': version, 'target': 'example.com', 'method': 'system_getaddrinfo',
'observed_at': utc_now(), 'status': 'unknown', 'error_code': None}
start = time.monotonic()
try:
process = subprocess.run([sys.executable,'-I','-B','-c',RESOLVER,str(int(family))],
capture_output=True, text=True, timeout=timeout, check=False)
if process.returncode or len(process.stdout) > 16384:
row['error_code'] = 'RESOLVER_PROCESS_ERROR'
else:
payload = json.loads(process.stdout)
code = payload.get('error')
if code is not None:
row['error_code'] = ('DNS_TEMPORARY_ERROR' if code == socket.EAI_AGAIN else
'DNS_NO_ADDRESS' if code == socket.EAI_NONAME else 'DNS_RESOLUTION_ERROR')
else:
addresses = payload.get('addresses')
if not isinstance(addresses,list) or not addresses or any(ipaddress.ip_address(value).version != version for value in addresses):
row['error_code'] = 'DNS_INVALID_RESPONSE'
else:
row.update(status='success',addresses=addresses)
except subprocess.TimeoutExpired:
row['error_code'] = 'DNS_TIMEOUT'
except (OSError, ValueError, TypeError, AttributeError):
row['error_code'] = 'DNS_PROBE_ERROR'
row['elapsed_ms'] = round((time.monotonic()-start)*1000,3)
row['timing_scope'] = 'resolver_process_including_startup_and_cache'
return row
def transport_probe(label, hostname, address, timeout=4):
ip = ipaddress.ip_address(address)
if not 0 < timeout <= 10:
raise ValueError('Invalid transport timeout')
row = {'target': label, 'hostname': hostname, 'address': address, 'family': ip.version,
'port': 443, 'observed_at': utc_now(), 'method': 'numeric_tcp_then_verified_tls',
'tcp': {'status':'unknown'}, 'tls': {'status':'skipped','error_code':'TCP_NOT_CONNECTED'}}
started = time.monotonic()
deadline = started + timeout
stage = 'tcp'
try:
# Numeric addresses only: DNS cannot contaminate this TCP observation.
with socket.create_connection((address,443),timeout=timeout) as raw:
row['tcp'] = {'status':'success','elapsed_ms':round((time.monotonic()-started)*1000,3)}
stage = 'tls'
context = ssl.create_default_context()
remaining = deadline-time.monotonic()
if remaining <= 0:
raise TimeoutError
raw.settimeout(remaining)
tls_start = time.monotonic()
with context.wrap_socket(raw,server_hostname=hostname) as secure:
row['tls'] = {'status':'success','elapsed_ms':round((time.monotonic()-tls_start)*1000,3),
'version': secure.version(), 'certificate_verified': True}
except ssl.SSLCertVerificationError:
row['tls'] = {'status':'unknown','error_code':'TLS_CERTIFICATE_ERROR'}
except (TimeoutError, socket.timeout):
row[stage] = {'status':'unknown','error_code':stage.upper()+'_TIMEOUT'}
except ssl.SSLError:
row['tls'] = {'status':'unknown','error_code':'TLS_HANDSHAKE_ERROR'}
except OSError as error:
reason = {errno.ENETUNREACH:'NETWORK_UNREACHABLE',errno.EHOSTUNREACH:'HOST_UNREACHABLE',
errno.ECONNREFUSED:'CONNECTION_REFUSED',errno.EAFNOSUPPORT:'FAMILY_UNSUPPORTED'}.get(error.errno,'SOCKET_ERROR')
row[stage] = {'status':'unknown','error_code':reason}
row['elapsed_ms'] = round((time.monotonic()-started)*1000,3)
return row
def collect(report=None, on_result=None, *, rounds=2, dns=dns_probe, transport=transport_probe):
if not 1 <= rounds <= 3:
raise ValueError('DNS rounds must be 1..3')
report = {} if report is None else report
report.update(schema_version=1, observed_at=utc_now(), dns=[], transports=[], dns_summary={},
limitations=['Only outbound paths to the named targets were checked.',
'DNS timings include process startup and system resolver caching.',
'No UDP, PMTU, inbound reachability or media playback was tested.'])
def completed():
if on_result is not None:
on_result()
for version in (4,6):
rows = []
for _ in range(rounds):
try:
row = dns(version)
except Exception:
row = {'family':version,'status':'unknown','error_code':'DNS_PROBE_ERROR','observed_at':utc_now()}
rows.append(row)
report['dns'].append(row)
completed()
count = sum(row['status'] == 'success' for row in rows)
report['dns_summary'][str(version)] = {'attempted':rounds,'succeeded':count,
'success_ratio':count/rounds}
for label, hostname, address in TARGETS:
try:
row = transport(label,hostname,address)
except Exception:
row = {'target':label,'family':ipaddress.ip_address(address).version,
'tcp':{'status':'unknown','error_code':'PROBE_ERROR'},'tls':{'status':'skipped'}}
report['transports'].append(row)
completed()
report['diagnostics'] = []
for version in (4,6):
transports = [row for row in report['transports'] if row['family'] == version]
tcp_ok = any(row['tcp']['status'] == 'success' for row in transports)
dns_ok = report['dns_summary'][str(version)]['succeeded'] > 0
code = ('DNS_FAILED_NUMERIC_TCP_WORKS' if tcp_ok and not dns_ok else
'DNS_WORKS_TCP_UNCONFIRMED' if dns_ok and not tcp_ok else
'TARGETS_PARTIALLY_VERIFIED' if tcp_ok else 'CONNECTIVITY_UNCONFIRMED')
report['diagnostics'].append({'family':version,'code':code})
report['status'] = 'success' if all(row['status']=='success' for row in report['dns']) and all(
row['tcp']['status']=='success' and row['tls']['status']=='success' for row in report['transports']) else 'partial'
completed()
return report
def render_row(row, out, kind):
tr = out.text
labels = {
'DNS_TIMEOUT': ('DNS 超时','DNS timed out'),
'DNS_NO_ADDRESS': ('未取得该协议族地址','No address for this family'),
'DNS_TEMPORARY_ERROR': ('解析器暂时失败','Temporary resolver error'),
'TLS_CERTIFICATE_ERROR': ('证书验证失败;检查时间及信任链','Certificate rejected; check clock and trust chain'),
'TLS_HANDSHAKE_ERROR': ('TLS 握手未完成','TLS handshake failed'),
'TCP_NOT_CONNECTED': ('TCP 未连接,跳过 TLS','TCP unavailable; TLS skipped'),
'NETWORK_UNREACHABLE': ('系统报告网络不可达','System reports network unreachable'),
'CONNECTION_REFUSED': ('连接被拒绝','Connection refused'),
'TCP_TIMEOUT': ('TCP 超时','TCP timed out'),
'TLS_TIMEOUT': ('TLS 超时','TLS timed out'),
}
def error_label(result):
code = result.get('error_code','UNKNOWN')
return tr(*labels[code])+f' ({code})' if code in labels else code
family = 'IPv'+str(row['family'])
if kind == 'dns':
out.row(f'{family} DNS',f"{row['elapsed_ms']:g} ms" if row['status']=='success' else error_label(row),
'ok' if row['status']=='success' else 'unknown')
else:
for stage in ('tcp','tls'):
result = row[stage]
out.row(f"{family} {row['target']} {stage.upper()}",
f"{result['elapsed_ms']:g} ms" if result['status']=='success' else error_label(result),
'ok' if result['status']=='success' else 'unknown')
def run_console(lang='zh', color='auto', report=None, on_result=None, *, out=None):
from .console import Console
out = out if out is not None else Console(lang,color)
report = {} if report is None else report
seen_dns = seen_transport = 0
def progress():
nonlocal seen_dns, seen_transport
for row in report['dns'][seen_dns:]:
render_row(row,out,'dns')
for row in report['transports'][seen_transport:]:
render_row(row,out,'transport')
seen_dns, seen_transport = len(report['dns']), len(report['transports'])
if on_result is not None:
on_result()
collect(report, progress)
for version, summary in report['dns_summary'].items():
out.row(f'IPv{version} DNS',f"{summary['succeeded']}/{summary['attempted']} "+out.text('成功(仅本次样本)','successful (current sample only)'))
for diagnosis in report['diagnostics']:
code = diagnosis['code']
note = {
'DNS_FAILED_NUMERIC_TCP_WORKS': ('解析未成功但数字 IP 可连接;先排查解析器','DNS failed but numeric TCP worked; inspect resolver'),
'DNS_WORKS_TCP_UNCONFIRMED': ('解析有结果,目标 TCP 未确认;检查路由及目标','DNS answered but target TCP is unconfirmed; inspect routes and targets'),
'TARGETS_PARTIALLY_VERIFIED': ('部分目标 TCP 已确认;TLS 结果单独看','Some target TCP paths verified; inspect TLS separately'),
'CONNECTIVITY_UNCONFIRMED': ('连接尚未确认','Connectivity unconfirmed'),
}[code]
out.row(f"IPv{diagnosis['family']} "+out.text('提示','Hint'),out.text(*note),'unknown')
out.row(out.text('检测范围','Scope'),out.text('出站 DNS/TCP/TLS 检测','Outbound DNS/TCP/TLS checks'),'unknown')
return 0 if report['status']=='success' else 1
+223 -14
View File
@@ -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
+11
View File
@@ -17,6 +17,16 @@ class GeoLocator:
if not self.enabled: return unknown
if address in self.cache: return self.cache[address]
result=unknown
from .worker import enabled
if enabled():
from .net import request_json
language='zh-CN' if self.lang=='zh' else 'en'
data,state=request_json('https://ipwho.is/'+str(addr)+'?lang='+language)
if state=='ok' and data.get('success') is True:
values=list(dict.fromkeys(data[k] for k in ('country','region','city') if isinstance(data.get(k),str) and data[k]))
if values:result=' / '.join(values)
self.cache[address]=result
return result
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self,*args): return None
try:
@@ -32,6 +42,7 @@ class GeoLocator:
if values: result=' / '.join(values)
except urllib.error.HTTPError as exc:
if exc.code==429: self.enabled=False
exc.close()
except (OSError,ValueError,urllib.error.URLError): pass
self.cache[address]=result
return result
+25 -21
View File
@@ -1,6 +1,10 @@
"""Shared messages; identifiers and raw command evidence remain language neutral."""
MESSAGES = {
"版本符合 Ubuntu 22.04 最低要求;实机兼容性按已测试环境确认": "Version meets the Ubuntu 22.04 minimum; real system compatibility follows tested environments",
"支持范围:Ubuntu 22.04 及更新版本。": "Target support: Ubuntu 22.04 and newer.",
"已通过 ipify 查询出口地址;入站可达性未确认": "Egress address queried via ipify; inbound reachability unconfirmed",
"sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同。": "sshd -T reads defaults; permissions, configuration or a missing runtime directory can prevent inspection. Running-service and Match settings may differ.",
'联网检测 DNS 和 TCP 连接并实时显示结果': 'Run outbound DNS and TCP checks with live output',
'网络质量检测:会向系统 DNS、Cloudflare 和 Google 发起请求。': 'Network quality checks: requests will be sent to the system DNS resolver, Cloudflare and Google.',
'正在查询 DNS:example.com ...': 'Checking DNS: example.com ...',
@@ -10,7 +14,7 @@ MESSAGES = {
'正在测试 {target} TCP {port} ...': 'Testing {target} TCP {port} ...',
'{target} TCP 连接成功:{ms} ms': '{target} TCP connection succeeded: {ms} ms',
'{target} TCP 连接未成功;原因尚未确定。': '{target} TCP connection did not succeed; the cause is unknown.',
'以上结果只代表本次从此服务器到这些测试目标的情况,不代表代理协议、回程路线或 IP 信誉。': 'These results describe this server’s current connection to these targets only; they do not establish proxy protocol support, return routes or IP reputation.',
'以上结果为本次从此服务器到这些测试目标的连接情况。': 'These results describe this server’s current connection to these targets.',
'运行本地 NodeQuality 脚本并实时显示输出': 'Run a local NodeQuality script and stream its output',
'已下载并检查的 NodeQuality 脚本路径': 'Path to a locally downloaded and reviewed NodeQuality script',
'找不到本地脚本文件': 'Local script file not found',
@@ -35,20 +39,20 @@ MESSAGES = {
"仅在终端显示完整 IP;保存的报告仍脱敏": "Show full IP addresses only in terminal output; saved reports remain redacted",
"终端正在显示完整 IP;保存的报告仍脱敏,分享前请检查内容。": "Full IP addresses are visible in terminal output; saved reports remain redacted. Review before sharing.",
"向 ipify 发送 HTTPS 请求查询出口 IP(第三方会看到该地址)": "Query egress IP over HTTPS from ipify (the third party will see this address)",
"已通过 ipify 查询出口地址;未验证入站可达性或归属": "Egress address queried via ipify; inbound reachability and ownership are not verified",
"已通过 ipify 查询出口地址": "Egress address queried via ipify",
"出口 IP 查询失败,无法确定公网地址": "Egress IP query failed; the public address is unknown",
"出口地址可能是 NAT 或代理地址;不是入站可达性证明。": "The egress address may belong to NAT or a proxy; it does not establish inbound reachability.",
"只读体检 · 已请求 ipify 查询出口 IP": "Read-only checks · ipify egress IP lookup requested",
"默认路由": "Default route",
"路由读取失败或格式无法解析": "Route read failed or output could not be parsed",
"已读取默认路由;不代表互联网或目标地址可达": "Default route read; Internet and destination reachability are not established",
"已读取默认路由": "Default route read",
"默认路由信息不完整或未找到,无法确认网络出口": "Default route information is incomplete or missing; the network exit is unconfirmed",
"仅检查当前网络命名空间的默认路由,不覆盖策略路由。": "Only the current network namespace's default routes are checked; policy routing is not covered.",
"DNS 配置": "DNS configuration",
"无法读取 DNS 配置": "Unable to read DNS configuration",
"DNS 配置不完整或没有有效的服务器地址": "DNS configuration is incomplete or has no valid server address",
"DNS 指向本机解析器;上游配置及解析是否成功尚未验证": "DNS points to a local resolver; upstream configuration and resolution have not been verified",
"已读取 DNS 服务器配置;尚未验证解析是否成功": "DNS server configuration read; successful resolution is not verified",
"DNS 指向本机解析器": "DNS points to a local resolver",
"已读取 DNS 服务器配置": "DNS server configuration read",
"仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。": "Only resolv.conf is inspected; no DNS request is sent, and the actual resolution path may differ.",
"SSH 端口保护": "SSH port protection",
"sshd 默认配置端口:{ports}": "sshd default configured ports: {ports}",
@@ -62,7 +66,7 @@ MESSAGES = {
"SSH 输出缺少关键字段,无法完成风险判断": "SSH output is missing required fields; risk assessment is incomplete",
"sing-box 服务处于 failed 状态,需要检查启动失败原因": "sing-box service is in the failed state; investigate the startup failure",
"sing-box 开机启动": "sing-box startup at boot",
"已配置持久开机启动;尚未实际重启验证": "Persistent startup at boot is enabled; an actual reboot has not been tested",
"已配置持久开机启动": "Persistent startup at boot is enabled",
"未配置持久开机启动或服务被屏蔽,重启后可能不可用": "Persistent startup is not enabled or the service is masked; it may be unavailable after reboot",
"无法确认 sing-box 的持久开机启动状态": "Persistent sing-box startup at boot is unconfirmed",
"命令不可用": "Command unavailable",
@@ -72,19 +76,19 @@ MESSAGES = {
"读取超时(8 秒)": "Read timed out (8 seconds)",
"无法执行读取命令": "Unable to execute read command",
"操作系统": "Operating system",
"版本符合 Ubuntu 22.04 最低要求;不代表已完成实机兼容验证": "Version meets the Ubuntu 22.04 minimum; compatibility has not been validated on a real system",
"版本符合 Ubuntu 22.04 最低要求": "Version meets the Ubuntu 22.04 minimum",
"无法解析 Ubuntu 版本,不能确认是否符合最低要求": "Unable to parse the Ubuntu version; minimum requirement is unconfirmed",
"需要 Ubuntu 22.04 或更新版本": "Ubuntu 22.04 or newer is required",
"版本未知": "Version unknown",
"其他系统的结果仅供参考;请使用 Ubuntu 22.04 或更新版本验证。": "Results on other systems are informational; validate on Ubuntu 22.04 or newer.",
"版本检查不验证官方维护状态;Ubuntu 22.04 及更新版本仍需分别进行实机测试。": "Version checking does not verify official maintenance status; Ubuntu 22.04 and newer versions still require individual real-system tests.",
"其他系统的结果仅供参考。": "Results on other systems are informational.",
"版本检查 Ubuntu 22.04 及更新版本受支持。": "Ubuntu 22.04 and newer versions are supported.",
"系统架构": "Architecture",
"检测架构;本阶段不验证安装包兼容性": "Architecture detected; package compatibility is not checked at this stage",
"根分区可用空间": "Free space on root filesystem",
"可用 {free:.1f} GiB(体检提示阈值 1 GiB,不代表完整安装要求)": "{free:.1f} GiB free (advisory threshold: 1 GiB; not a complete installation requirement)",
"可用 {free:.1f} GiB(体检提示阈值 1 GiB)": "{free:.1f} GiB free (advisory threshold: 1 GiB)",
"无法读取": "Unable to read",
"公网 IP": "Public IP",
"用户提供的地址具备公网地址属性,但未验证归属或可达性": "The supplied address is globally routable; ownership and reachability are unverified",
"用户提供的地址具备公网地址属性": "The supplied address is globally routable",
"提供的地址不是全局可路由公网地址": "The supplied address is not a globally routable public address",
"默认不向第三方查询出口 IP": "No third-party egress IP lookup is performed by default",
"可用 --public-ip 填写云控制台地址;地址归属及入站可达性仍需外部验证。": "Use --public-ip to supply the address from your cloud console; ownership and inbound reachability still need external verification.",
@@ -95,18 +99,18 @@ MESSAGES = {
"SSH 配置": "SSH configuration", "本机防火墙": "Local firewall", "sing-box 服务": "sing-box service",
"此项需要 Linux;当前主机仅验证程序运行": "Requires Linux; this host only validates that the program runs",
"可用内存": "Available memory",
"约 {mib:.0f} MiB(体检提示阈值 256 MiB,不代表完整安装要求)": "About {mib:.0f} MiB (advisory threshold: 256 MiB; not a complete installation requirement)",
"约 {mib:.0f} MiB(体检提示阈值 256 MiB)": "About {mib:.0f} MiB (advisory threshold: 256 MiB)",
"无法读取 MemAvailable": "Unable to read MemAvailable",
"仅表示本机接口地址;不证明互联网可达": "Local interface addresses only; not proof of Internet reachability",
"仅表示本机接口地址": "Local interface addresses only",
"无法解析接口信息": "Unable to parse interface information",
"目标端口占用": "Target port usage",
"目标 TCP/UDP 端口 {port} 已有监听": "A listener already uses target TCP/UDP port {port}",
"无法完整确认端口占用": "Unable to fully determine port usage",
"当前未发现 TCP/UDP 端口 {port} 的监听;不保证之后绑定成功": "No listener currently found on TCP/UDP port {port}; future binding is not guaranteed",
"当前未发现 TCP/UDP 端口 {port} 的监听": "No listener currently found on TCP/UDP port {port}",
"选择未占用端口;不会停止已有服务。": "Choose an unused port; existing services will not be stopped.",
"允许 root 登录或密码认证,建议人工审阅": "Root login or password authentication is allowed; review manually",
"未发现这两项宽松设置;不能据此认定 SSH 安全": "Neither of these permissive settings was found; SSH security is not established",
"sshd -T 读取默认配置,不保证与运行中服务或 Match 条件一致;不自动修改 SSH。": "sshd -T reads default configuration, which may differ from the running service or Match conditions; SSH is not modified.",
"允许 root 登录或密码认证": "Root login or password authentication is allowed",
"未发现这两项宽松设置": "Neither of these permissive settings was found",
"sshd -T 读取默认配置,可能与运行中服务或 Match 条件一致。": "sshd -T reads default configuration, which may differ from the running service or Match conditions.",
"读取失败": "Read failed", "已读取;": "Read completed; ",
"存在状态或规则信息": "state or rule information exists",
"未发现活动状态或规则": "no active state or rules found",
@@ -114,11 +118,11 @@ MESSAGES = {
"没有足够证据确认防火墙保护状态": "Insufficient evidence to establish firewall protection",
"部分工具不可用或权限不足。": "Some tools are unavailable or permissions are insufficient.",
"规则存在不等于配置安全。": "The presence of rules does not establish a secure configuration.",
"服务处于 active;不代表代理连接成功": "Service is active; this does not establish successful proxy connectivity",
"服务处于 active": "Service is active",
"未确认 sing-box 服务运行;新系统尚未安装属于正常情况": "sing-box service operation is unconfirmed; absence is expected on a fresh system",
"通过": "PASS", "注意": "WARN", "未知": "UNKNOWN", "跳过": "SKIP", "失败": "FAIL",
"[IP已隐藏]": "[IP REDACTED]",
"ProxyDoctor 只读体检:不修改系统;默认不请求外部网络": "ProxyDoctor read-only checks: no system changes; no external requests by default",
"VPS 检测工具 只读体检:不修改系统;默认不请求外部网络": "VPS Check read-only checks: no system changes; no external requests by default",
"读取系统信息并生成体检报告": "Read system information and generate a diagnostic report",
"拟使用的代理端口,默认 8388": "Intended proxy port (default: 8388)",
"云控制台提供的 IP;本工具不验证归属": "IP from your cloud console; ownership is not verified",
@@ -129,8 +133,8 @@ MESSAGES = {
"--public-ip 必须是有效 IPv4 或 IPv6 地址": "--public-ip must be a valid IPv4 or IPv6 address",
"无法保存报告:{error}": "Unable to save report: {error}",
"只读体检 · 默认不请求外部网络": "Read-only checks · No external requests by default",
"依据": "Evidence", "建议": "Advice",
"报告默认隐藏 IP;分享前请检查内容。通过某一项不代表整体安全或代理可用。": "IP addresses are redacted by default. Review before sharing. Passing a check does not establish overall security or proxy connectivity.",
"依据": "Evidence",
"报告默认隐藏 IP。": "IP addresses are redacted by default.",
"参数错误:{message}": "Argument error: {message}",
"显示帮助并退出": "Show this help message and exit",
"显示版本并退出": "Show version and exit",
+50 -12
View File
@@ -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)
+79
View File
@@ -0,0 +1,79 @@
"""Opt-in bounded PTR/forward consistency and outbound SMTP TCP probes.
No email, SMTP command or authentication is sent. DNSBL remains unqueried until
each provider's terms, error codes and resolver requirements are configured.
"""
import ipaddress
import json
import subprocess
import sys
from .reports import utc_now
PROGRAM = '''import ipaddress,json,socket,sys,time
options=json.loads(sys.stdin.readline())
if options['mode']=='ptr':
try:
host,aliases,addresses=socket.gethostbyaddr(options['ip'])
forward=socket.getaddrinfo(host,None,type=socket.SOCK_STREAM)
matches=any(ipaddress.ip_address(row[4][0])==ipaddress.ip_address(options['ip']) for row in forward)
print(json.dumps({'status':'observed','hostname':host[:253],'forward_matches':matches}))
except socket.herror:
print(json.dumps({'status':'unknown','error_code':'PTR_NO_RECORD_OR_RESOLVER_ERROR'}))
except OSError:
print(json.dumps({'status':'unknown','error_code':'DNS_ERROR'}))
else:
try:
addresses=socket.getaddrinfo('gmail-smtp-in.l.google.com',25,type=socket.SOCK_STREAM)
target=next(row for row in addresses if ipaddress.ip_address(row[4][0]).version==options['family'] and ipaddress.ip_address(row[4][0]).is_global)
start=time.monotonic()
with socket.create_connection((target[4][0],25),timeout=2):pass
print(json.dumps({'status':'reachable','elapsed_ms':round((time.monotonic()-start)*1000,3),'address':target[4][0]}))
except (OSError,StopIteration):
print(json.dumps({'status':'unknown','error_code':'SMTP_TCP_UNCONFIRMED'}))
'''
def probe(mode,address):
ip=ipaddress.ip_address(address)
if not ip.is_global or mode not in ('ptr','smtp'):
raise ValueError('Expected global IP and supported probe')
try:
process=subprocess.run([sys.executable,'-I','-B','-c',PROGRAM],
input=json.dumps({'mode':mode,'ip':str(ip),'family':ip.version}),
capture_output=True,text=True,encoding='utf-8',timeout=5,check=False)
if process.returncode or len(process.stdout)>8192:
return {'status':'unknown','error_code':'PROBE_ERROR'}
result=json.loads(process.stdout)
return result if isinstance(result,dict) else {'status':'unknown','error_code':'INVALID_RESPONSE'}
except subprocess.TimeoutExpired:
return {'status':'unknown','error_code':'PROBE_TIMEOUT'}
except (OSError,ValueError):
return {'status':'unknown','error_code':'PROBE_ERROR'}
def collect(address, query=probe):
report={'checked_at':utc_now(),'scope':'selected_family_outbound_only','ptr':None,'smtp_tcp':None,
'dnsbl':{'status':'skipped','reason':'provider_terms_and_resolver_configuration_required'},
'limitations':['PTR consistency does not establish email deliverability or IP reputation.',
'SMTP TCP uses the default route; it does not bind the queried IP as source.',
'One outbound target cannot prove all port-25 paths or inbound mail availability.',
'No DNSBL result or clean-IP score is inferred from missing data.']}
if not address:
report.update(ptr={'status':'skipped','error_code':'IP_UNAVAILABLE'},smtp_tcp={'status':'skipped','error_code':'IP_UNAVAILABLE'})
return report
for mode,key in (('ptr','ptr'),('smtp','smtp_tcp')):
try:report[key]=query(mode,address)
except Exception:report[key]={'status':'unknown','error_code':'PROBE_ERROR'}
return report
def render(report,out):
tr=out.text
ptr=report['ptr'];smtp=report['smtp_tcp']
out.row('PTR',ptr.get('hostname') or ptr.get('error_code','Unknown'),'ok' if ptr['status']=='observed' else 'unknown')
matches=ptr.get('forward_matches')
out.row(tr('PTR 正向一致','PTR forward match'),tr('是','Yes') if matches is True else tr('否','No') if matches is False else tr('未知','Unknown'),
'ok' if matches is True else 'unknown')
out.row(tr('出站 TCP/25','Outbound TCP/25'),tr('单目标可连接','One target reachable') if smtp['status']=='reachable' else tr('未确认;不能认定全部被封','Unconfirmed; does not prove universal blocking'),
'ok' if smtp['status']=='reachable' else 'unknown')
out.row('DNSBL',tr('未查询:需核实来源条款及解析器条件','Not queried: provider terms/resolver setup required'),'unknown')
+40 -5
View File
@@ -81,6 +81,10 @@ class VisibleText(HTMLParser):
def fetch_page(url):
from .worker import enabled,invoke
if enabled():
result=invoke('page',{'url':url})
return Page(**result) if isinstance(result,dict) else Page(None,reason='network_timeout_or_error')
expected = urllib.parse.urlparse(url).hostname
allowed = next((hosts for hosts in REDIRECT_HOSTS if expected in hosts),frozenset((expected,)))
deadline = time.monotonic() + 15
@@ -288,13 +292,42 @@ def display(result, lang='zh'):
return label + f' · HTTP {http if http is not None else "---"}', 'unknown'
def run_console(lang='zh', color='auto'):
def check_reachability(service):
"""Public page reachability only; no catalogue, region or playback verdict."""
from .reports import utc_now
url = SERVICES[service][0]
started = time.monotonic()
page = fetch_page(url)
state = ('reachable' if page.status is not None and 200 <= page.status < 300 and not page.reason else
'access_denied' if page.status in (401,403,451) else
'rate_limited' if page.status == 429 else
'http_error' if page.status is not None else 'unknown')
return {'service':service,'scope':'public_page_reachability','state':state,
'http':page.status,'evidence':page.reason or 'http_response',
'checked_at':utc_now(),'elapsed_ms':round((time.monotonic()-started)*1000,3),
'playback':'not_tested','unlock':'not_tested',
'probes':[{'url':url,'final_url':page.url,'http':page.status}]}
def display_reachability(result,lang='zh'):
zh=lang=='zh'
labels={'reachable':('网页可达','Web page reachable'),
'access_denied':('访问受限','Access denied'),
'rate_limited':('请求限流','Rate limited'),
'http_error':('HTTP 异常','HTTP error'),
'unknown':('可达性未确认','Reachability unconfirmed')}
state=result.get('state','unknown')
label=labels.get(state,labels['unknown'])[0 if zh else 1]
return label+' · HTTP '+str(result.get('http') or '---'), 'ok' if state=='reachable' else 'unknown'
def run_console(lang='zh', color='auto', results=None, on_result=None, *, out=None):
from concurrent.futures import ThreadPoolExecutor, as_completed
from .console import Console
out = Console(lang,color)
results = {}
out = out if out is not None else Console(lang,color)
results = {} if results is None else results
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(check,service):service for service in SERVICES}
futures = {pool.submit(check_reachability,service):service for service in SERVICES}
for future in as_completed(futures):
service = futures[future]
try:
@@ -303,6 +336,8 @@ def run_console(lang='zh', color='auto'):
# Never echo raw provider exceptions (URLs may include sensitive data).
result = {'state':'unknown','http':None,'evidence':'network_error','playback':'not_tested'}
results[service] = result
value, status = display(result,lang)
if on_result is not None:
on_result()
value, status = display_reachability(result,lang)
out.row(service,value,status)
return results
+8 -2
View File
@@ -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'
+7 -3
View File
@@ -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
+124
View File
@@ -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('|','&#124;').replace('`','&#96;').replace('\n','<br>').replace('\r','').replace('\x1b','')
def table(headers,rows):
return ['| '+' | '.join(cell(value) for value in headers)+' |',
'| '+' | '.join('---' for _ in headers)+' |']+[
'| '+' | '.join(cell(value) for value in row)+' |' for row in rows]
rows=[['schema',report.get('schema_version')],[t('版本','Tool version'),report.get('tool_version','unknown')],
[t('检测时间','Observation time'),report.get('generated_at',report.get('observed_at','unknown'))],
[t('运行结果','Execution result'),report.get('result','unknown')]]
lines=['# '+t('VPS 检测工具 · 本地检测报告','VPS Check · local diagnostic report'),'']
lines += [release_label(lang),'']
lines+=table([t('项目','Item'),t('记录','Observation')],rows)
lines+=['',t('本报告来自已有本地记录,没有重新检测。缺失指标保持未知。',
'This report exports existing local observations, without rerunning checks. Missing metrics remain unknown.'),'']
if report.get('recovery'):
lines += ['> '+t('此报告由恢复日志生成,可能仅包含中断前的部分结果。','Recovered from a journal; results may be partial.'),'']
modules=report.get('modules',{})
if report.get('result_items'):
lines += ['## '+t('统一结果记录','Unified evidence records'),'']
lines += table([t('检查','Check'),t('状态','Status'),t('方法','Method'),t('来源','Source'),t('证据范围','Confidence scope')],
[[row.get('check_id'),row.get('status'),row.get('method'),row.get('source'),row.get('confidence','unknown')]
for row in report['result_items'] if isinstance(row,dict)])
lines += ['']
if isinstance(modules,dict) and modules:
lines += ['## '+t('模块执行','Module execution'),'']
lines += table([t('模块','Module'),t('状态','Status'),t('原因/错误码','Reason/error')],
[[name,row.get('status','unknown'),row.get('error_code') or row.get('reason','')]
for name,row in modules.items() if isinstance(row,dict)])
lines += ['']
checks=report.get('checks',report.get('system',{}).get('checks',[]))
if isinstance(checks,list):
lines += ['## '+t('本机提示','Local findings'),'']
lines += table([t('项目','Check'),t('状态','Status'),t('说明','Explanation')],
[[row.get('title',row.get('id','unknown')),row.get('status','unknown'),row.get('detail','')]
for row in checks if isinstance(row,dict)])
lines += ['']
for key,zh,en in [('resources','资源额度与压力','Resource limits and pressure'),
('security','权限与更新缓存','Permissions and update cache'),
('network','网络检测','Network observations'),('route','路由记录','Route observations'),
('ip_quality','IP数据来源与限制','IP providers and limitations'),
('bandwidth','带宽样本','Bandwidth samples'),('media','媒体可达性','Media reachability'),
('bgp','BGP 观测','BGP observations'),('carrier_nodes','测速节点探测','Speed node probes'),
('return_routes','三城三运营商方向追踪','Three-city carrier traces')]:
data=report.get(key)
lines += ['## '+t(zh,en),'']
if data is None:
lines += [t('未记录;请查看模块跳过原因。','Not recorded; inspect the module skip reason.'),'']
else:
# A fence longer than every backtick run in the data prevents a
# provider string from closing the block and forging report content.
payload=json.dumps(data,ensure_ascii=False,indent=2)
runs=re.findall(r'`+',payload)
fence='`'*max(3,1+max(map(len,runs),default=0))
lines += [fence+'json',payload,fence,'']
lines += [t('分享前请人工检查。自动脱敏不是匿名或无敏感信息保证。','Review before sharing. Automated redaction is not an anonymity or secret-free guarantee.'),'']
return '\n'.join(lines)
def export_report(report, destination, *, format='markdown', lang='zh'):
if format not in ('markdown','json'):
raise ValueError('Unsupported export format')
safe=sharing_copy(report,lang)
text=markdown(safe,lang) if format=='markdown' else json.dumps(safe,ensure_ascii=False,indent=2)+'\n'
with exclusive_text(destination) as stream:
stream.write(text)
stream.flush()
import os
os.fsync(stream.fileno())
+121
View File
@@ -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
+245
View File
@@ -0,0 +1,245 @@
"""Read-only Linux resources; numeric results independent of terminal language."""
import math
import os
from pathlib import Path, PurePosixPath
import platform
import re
import time
from .reports import utc_now
def read_text(path):
try:
with Path(path).open(encoding='utf-8') as stream:
value = stream.read(262145)
return value if len(value) <= 262144 else None
except (OSError, UnicodeError):
return None
def number(value):
try:
parsed = int(value)
return parsed if parsed >= 0 else None
except (ValueError, TypeError):
return None
def meminfo(text):
result = {}
for line in (text or '').splitlines():
match = re.fullmatch(r'(MemTotal|MemAvailable|SwapTotal|SwapFree):\s+(\d+) kB', line)
if match:
result[match[1]] = int(match[2]) * 1024
return result
def pressure(text):
result = {}
for line in (text or '').splitlines():
fields = line.split()
if not fields or fields[0] not in ('some', 'full'):
continue
try:
values = dict(item.split('=', 1) for item in fields[1:])
row = {key: float(values[key]) for key in ('avg10','avg60','avg300')}
row['total_us'] = int(values['total'])
if all(math.isfinite(row[key]) and 0 <= row[key] <= 100 for key in ('avg10','avg60','avg300')) and row['total_us'] >= 0:
result[fields[0]] = row
except (KeyError, ValueError):
continue
return result or None
def cpu_ticks(text):
line = next((line for line in (text or '').splitlines() if line.startswith('cpu ')), '')
try:
values = [int(value) for value in line.split()[1:]]
# Guest and guest_nice are already included in user and nice.
return values[:8] if len(values) >= 8 and all(value >= 0 for value in values) else None
except ValueError:
return None
def steal_delta(before, after):
if before is None or after is None:
return None
deltas = [b-a for a,b in zip(before,after)]
total = sum(deltas)
return round(100*deltas[7]/total, 3) if total > 0 and all(value >= 0 for value in deltas) else None
def unescape_mount(value):
return re.sub(r'\\([0-7]{3})', lambda match: chr(int(match[1], 8)), value)
def cgroup_locations(membership, mounts, controller):
"""Map process membership onto visible mount roots, including ancestors.
Hidden namespace ancestors cannot be inspected; never infer unlimited host
resources from the visible root. Limit traversal to 64 visible ancestors.
"""
memberships = []
for line in (membership or '').splitlines():
parts = line.split(':', 2)
if len(parts) == 3 and (parts[0] == '0' or controller in parts[1].split(',')):
memberships.append((parts[0] == '0', parts[2]))
for line in (mounts or '').splitlines():
left, separator, right = line.partition(' - ')
fields, info = left.split(), right.split()
if not separator or len(fields) < 6 or len(info) < 3 or info[0] not in ('cgroup','cgroup2'):
continue
v2 = info[0] == 'cgroup2'
if not v2 and controller not in info[2].split(','):
continue
mount_root, mount = (PurePosixPath(unescape_mount(fields[index])) for index in (3,4))
for unified, member in memberships:
path = PurePosixPath(member)
if unified != v2 or not path.is_absolute() or '..' in path.parts or '..' in mount.parts:
continue
try:
relative = path.relative_to(mount_root)
except ValueError:
continue
current = mount / relative
for _ in range(64):
yield v2, str(current)
if current == mount:
break
current = current.parent
def cgroup_limits(read):
membership, mounts = read('/proc/self/cgroup'), read('/proc/self/mountinfo')
result = {'cpu_quota_cores': None, 'memory_limit_bytes': None,
'observations': [], 'scope': 'visible_cgroup_ancestors_only'}
for controller in ('cpu','memory'):
for v2, directory in cgroup_locations(membership, mounts, controller):
leaf = ('cpu.max' if v2 else 'cpu.cfs_quota_us') if controller == 'cpu' else ('memory.max' if v2 else 'memory.limit_in_bytes')
source = directory + '/' + leaf
raw = read(source)
if raw is None:
continue
value = None
state = 'unknown'
if controller == 'cpu':
tokens = raw.split()
quota = tokens[0] if tokens else ''
period = number(tokens[1] if v2 and len(tokens) == 2 else read(directory+'/cpu.cfs_period_us'))
if quota == ('max' if v2 else '-1'):
state = 'no_visible_limit'
elif number(quota) is not None and number(quota) > 0 and period:
value = int(quota)/period
state = 'measured'
key = 'cpu_quota_cores'
else:
value = number(raw.strip())
if raw.strip() == 'max' or (not v2 and value is not None and value >= 2**60):
value = None
state = 'no_visible_limit'
elif value is not None:
state = 'measured'
key = 'memory_limit_bytes'
result['observations'].append({'source': source, 'status': state, 'value': value,
'controller': controller, 'version': 2 if v2 else 1})
if value is not None:
prior = result[key]
result[key] = value if prior is None else min(value, prior)
return result
def collect(read=read_text, *, interval=0.2, sleep=time.sleep, clock=time.monotonic,
system=None, cpu_count=os.cpu_count, affinity=None):
if not 0.05 <= interval <= 1:
raise ValueError('Resource observation interval must be 0.05..1 seconds')
report = {'schema_version': 1, 'observed_at': utc_now(), 'method': 'linux_readonly_snapshot',
'status': 'unsupported', 'metrics': {}, 'limitations': [
'Visible resources and cgroup limits are not purchased specifications or dedicated capacity.',
'Hidden ancestor limits cannot be excluded; steal/PSI describe a short observation, not long-term contention.',
'Host-visible PSI/meminfo/CPU counters may differ from container scope.']}
if (system or platform.system()) != 'Linux':
return report
memory = meminfo(read('/proc/meminfo'))
limits = cgroup_limits(read)
container_markers = [path for path in ('/.dockerenv','/run/.containerenv','/run/systemd/container')
if read(path) is not None]
try:
visible = cpu_count()
except OSError:
visible = None
try:
allowed = len(affinity() if affinity else os.sched_getaffinity(0))
except (OSError, AttributeError):
allowed = None
bounds = [v for v in (visible, allowed, limits['cpu_quota_cores']) if v is not None and v > 0]
ram = [v for v in (memory.get('MemTotal'), limits['memory_limit_bytes']) if v is not None]
before = cpu_ticks(read('/proc/stat'))
started = clock()
sleep(interval) # Passive observation, no CPU workload or benchmark.
after = cpu_ticks(read('/proc/stat'))
elapsed = clock()-started
swap_total, swap_free = memory.get('SwapTotal'), memory.get('SwapFree')
metrics = {'logical_cpus': visible, 'affinity_cpus': allowed,
'visible_cpu_upper_bound_cores': min(bounds) if bounds else None,
'memory_total_bytes': memory.get('MemTotal'),
'memory_available_bytes': memory.get('MemAvailable'),
'visible_memory_upper_bound_bytes': min(ram) if ram else None,
'swap_total_bytes': swap_total, 'swap_free_bytes': swap_free,
'swap_used_bytes': swap_total-swap_free if swap_total is not None and swap_free is not None and swap_free <= swap_total else None,
'steal_percent': steal_delta(before, after), 'sample_elapsed_seconds': round(elapsed, 4),
'pressure': {kind: pressure(read('/proc/pressure/'+kind)) for kind in ('cpu','memory','io')}}
current = next((row for row in limits['observations'] if row['controller'] == 'memory'), None)
metrics['cgroup_memory_current_bytes'] = number(read(current['source'].rsplit('/',1)[0] +
('/memory.current' if current['version'] == 2 else '/memory.usage_in_bytes'))) if current else None
# Keep allocation evidence distinct from the underlying OS view. No local
# observation can certify provider-side CPU exclusivity or reservation.
report['allocation'] = {
'cpu_limit_cores': metrics['visible_cpu_upper_bound_cores'],
'cpu_basis': 'affinity_and_visible_quota' if limits['cpu_quota_cores'] is not None else 'visible_cpu_and_affinity_only',
'memory_limit_bytes': limits['memory_limit_bytes'] if limits['memory_limit_bytes'] is not None else
None if container_markers else memory.get('MemTotal'),
'memory_basis': 'visible_cgroup_limit' if limits['memory_limit_bytes'] is not None else
'unconfirmed_container_allocation' if container_markers else 'guest_or_os_visible_memory',
'dedicated_cpu': 'unknown', 'reserved_memory': 'unknown',
'container_evidence': container_markers,
}
report.update(status='partial', metrics=metrics, cgroup=limits,
sources=['/proc/meminfo','/proc/stat','/proc/pressure/{cpu,memory,io}',
'/proc/self/cgroup','/proc/self/mountinfo','sched_getaffinity'])
return report
def render(report, out):
"""Render numeric evidence without parsing translated human descriptions."""
tr = out.text
if report['status'] == 'unsupported':
out.row(tr('资源限制','Resource limits'),tr('当前系统不支持','Unsupported on this system'),'unknown')
return
metrics, limits = report['metrics'], report['cgroup']
def row(zh, en, value, suffix=''):
out.row(tr(zh,en), tr('未知','Unknown') if value is None else f'{value:g}{suffix}',
'unknown' if value is None else 'ok')
allocation = report['allocation']
row('本进程 CPU 上限','Process CPU limit',allocation['cpu_limit_cores'],' vCPU')
out.row(tr('CPU 额度依据','CPU limit basis'),tr('CPU 范围与可见配额','Affinity and visible quota')
if limits['cpu_quota_cores'] is not None else tr('仅系统可见 CPU/进程范围,配额未确认','OS-visible CPUs/affinity only; quota unconfirmed'),'unknown')
value = allocation['memory_limit_bytes']
row('内存额度/可见量','RAM limit/visible',None if value is None else value/1024**2,' MiB')
basis = allocation['memory_basis']
out.row(tr('内存额度依据','RAM limit basis'),
tr('可见 cgroup 限额(可能与同组进程共享)','Visible cgroup limit (may be shared within the group)') if basis == 'visible_cgroup_limit' else
tr('容器额度未确认;不展示宿主机内存作额度','Container allocation unknown; host RAM is not an allocation') if basis == 'unconfirmed_container_allocation' else
tr('虚拟机/系统可见量;独享保障未确认','Guest/OS-visible amount; reservation unconfirmed'),'unknown')
out.row(tr('CPU/内存独享性','CPU/RAM dedication'),tr('无法从本机确认','Cannot be verified locally'),'unknown')
if not allocation['container_evidence']:
for zh,en,key in [('Swap 总量','Swap total','swap_total_bytes'),('Swap 已用','Swap used','swap_used_bytes')]:
value = metrics[key]
row(zh,en,None if value is None else value/1024**2,' MiB')
row('CPU steal(短采样)','CPU steal (short)',metrics['steal_percent'],'%')
row('实际采样时间','Observation time',metrics['sample_elapsed_seconds'],' s')
for kind in ('cpu','memory','io'):
psi = metrics['pressure'][kind]
row(f'{kind} 压力 some/10s',f'{kind} PSI some/10s',psi.get('some',{}).get('avg10') if psi else None,'%')
out.row(tr('范围说明','Scope'),tr('可见配额;独享性未确认;压力为短采样',
'Visible quota; exclusivity unconfirmed; short pressure sample'),'unknown')
+98 -4
View File
@@ -5,13 +5,78 @@ import struct
import sys
import time
import select
import re
import shutil
from .geo import GeoLocator
from .console import clean
def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None):
def parse_traceroute_hop(text, ttl, target):
"""Parse numeric Linux traceroute -n -q 1 output; retain explicit errors."""
for line in text.splitlines():
match = re.match(r'^\s*(\d+)\s+(.*)', line)
if not match or int(match[1]) != ttl:
continue
rest=match[2]
if rest.strip()=='*':
return {'hop':ttl,'ip':None,'latency_ms':None,'state':'timeout','probes_sent':1,'responses':0}
fields=rest.split()
try:
address=str(ipaddress.ip_address(fields[0]))
rtt=float(fields[1])
if not 0<=rtt<=60000 or fields[2]!='ms':continue
except (ValueError,IndexError):continue
unreachable=any(token.startswith('!') for token in fields[3:])
return {'hop':ttl,'ip':address,'latency_ms':rtt,'probes_sent':1,'responses':1,
'state':'unreachable' if unreachable else 'reached' if address==str(target) else 'reply'}
return None
def trace_method(target, emit=print, *, method='udp', tcp_port=443, hops=30, wait=1.0,
lang='zh', geo=True, records=None, on_result=None, deadline=None):
"""Native UDP or fixed-system Linux traceroute ICMP/TCP, without elevation."""
if method=='udp':
return trace(target,emit,hops,wait,lang,geo,records,on_result,deadline)
if method not in ('icmp','tcp') or not 1<=hops<=64 or not 1<=tcp_port<=65535 or not 0<wait<=10:
raise ValueError('Invalid trace method or limits')
address=ipaddress.ip_address(target)
executable=shutil.which('traceroute',path='/usr/sbin:/usr/bin:/sbin:/bin') if sys.platform=='linux' else None
if not executable:
emit('TRACEROUTE_DEPENDENCY_MISSING · ICMP/TCP 需要系统 traceroute' if lang=='zh' else
'TRACEROUTE_DEPENDENCY_MISSING · ICMP/TCP require system traceroute',flush=True)
return 1
from .worker import local_command
deadline=deadline if deadline is not None else time.monotonic()+90
locator=GeoLocator(lang,enabled=geo)
for ttl in range(1,hops+1):
remaining=deadline-time.monotonic()
if remaining<=0:return 1
argv=[executable,'-6' if address.version==6 else '-4','-n','-N','1','-q','1',
'-f',str(ttl),'-m',str(ttl),'-w',str(wait),'-I' if method=='icmp' else '-T']
if method=='tcp':argv+=['-p',str(tcp_port)]
argv.append(str(address))
try:code,text,error=local_command(argv,timeout=min(wait+2,remaining))
except OSError:
code,text,error=None,'','COMMAND_UNAVAILABLE'
row=parse_traceroute_hop(text,ttl,address)
if row is None:
emit(error or 'TRACEROUTE_FAILED_OR_PERMISSION_DENIED',flush=True)
return 1
row.update(method=method,statistics_scope='one_probe_this_ttl_not_forwarding_loss')
row['location']=clean(locator.lookup(row['ip'])) if row['ip'] else None
row['response_loss_percent']=0.0 if row['responses'] else 100.0
if records is not None:records.append(row)
emit(f"{ttl:2d} {row['ip'] or '*':<39} {str(row['latency_ms']) if row['latency_ms'] is not None else '---':>8} ms {row['location'] or '---'}",flush=True)
if on_result:on_result()
if row['state']=='reached':return 0
if code or row['state']=='unreachable':return 1
return 1
def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None, on_result=None, deadline=None):
if not 1<=hops<=64 or not 0<wait<=10: raise ValueError('Invalid trace limits')
address=ipaddress.ip_address(target)
deadline=deadline if deadline is not None else time.monotonic()+90
locator=GeoLocator(lang,enabled=geo)
zh=lang=='zh';v6=address.version==6
if sys.platform != 'linux':
@@ -25,6 +90,9 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No
sock.setsockopt(protocol,recverr,1) # Linux IPV6_RECVERR/IP_RECVERR
sock.setblocking(False)
for ttl in range(1,hops+1):
if time.monotonic()>=deadline:
emit('Route observation time budget exhausted (90s)',flush=True)
return 1
sock.setsockopt(protocol,socket.IPV6_UNICAST_HOPS if v6 else socket.IP_TTL,ttl)
start=time.monotonic()
endpoint=(str(address),33434+ttl,0,0) if v6 else (str(address),33434+ttl)
@@ -48,19 +116,45 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No
hop=socket.inet_ntop(family,data[24:40] if v6 else data[20:24])
elapsed=(time.monotonic()-start)*1000
location=clean(locator.lookup(hop))
emit(f'{ttl:2d} {hop:<39} {elapsed:8.1f} ms {location}',flush=True)
emit(f'{ttl:2d} {hop:<39} {elapsed:8.1f} ms {location} [reply 1/1; timeout 0/1]',flush=True)
reached=(icmp_type==1 and icmp_code==4 if v6 else icmp_type==3 and icmp_code==3) and ipaddress.ip_address(hop)==address
if records is not None: records.append({'hop':ttl,'ip':hop,'latency_ms':round(elapsed,3),'location':location,
'probes_sent':1,'responses':1,'response_loss_percent':0.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss',
'icmp_type':icmp_type,'icmp_code':icmp_code,'state':'reached' if reached else 'reply'})
found=True
if on_result is not None: on_result()
if reached: return 0
if icmp_type==(1 if v6 else 3):
emit(f' ICMP 不可达(代码 {icmp_code})' if zh else f' ICMP unreachable (code {icmp_code})',flush=True)
return 1
if found: break
if not found:
emit(f'{ttl:2d} {"*":<39} {"---":>8} ---',flush=True)
if records is not None: records.append({'hop':ttl,'ip':None,'latency_ms':None,'location':None,'state':'timeout'})
emit(f'{ttl:2d} {"*":<39} {"---":>8} --- [reply 0/1; timeout 1/1]',flush=True)
if records is not None: records.append({'hop':ttl,'ip':None,'latency_ms':None,'location':None,'state':'timeout',
'probes_sent':1,'responses':0,'response_loss_percent':100.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss'})
if on_result is not None: on_result()
except (OSError,AttributeError):
emit('原生 UDP 追踪不可用或未收到允许的响应' if zh else 'Native UDP trace unavailable or blocked',flush=True)
return 1
def summarize_hops(records):
"""Statistics over actually sent TTL probes, never forwarding-loss claims."""
import statistics
grouped={}
for row in records:
ttl=row['hop']
item=grouped.setdefault(ttl,{'hop':ttl,'probes_sent':0,'responses':0,'ips':set(),'rtts':[]})
item['probes_sent']+=row.get('probes_sent',1)
item['responses']+=row.get('responses',1 if row.get('ip') else 0)
if row.get('ip'):item['ips'].add(row['ip'])
if row.get('latency_ms') is not None:item['rtts'].append(row['latency_ms'])
result=[]
for ttl,item in sorted(grouped.items()):
sent=item['probes_sent'];received=item['responses']
result.append({'hop':ttl,'probes_sent':sent,'responses':received,'timeouts':sent-received,
'response_loss_percent':round(100*(sent-received)/sent,2) if sent else None,
'median_rtt_ms':statistics.median(item['rtts']) if item['rtts'] else None,
'ips':sorted(item['ips']),
'scope':'router_response_loss_only_not_end_to_end_forwarding_loss'})
return result
+35
View File
@@ -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()
+57
View File
@@ -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
+158
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
"""Consistent persistence lifecycle for individual CLI modules."""
from .reports import ReportStore,UnavailableStore,utc_now
from .schema import normalize_report
from .reporting import sharing_copy
from . import __version__
class ScanSession:
def __init__(self,key,path,lang):
self.key=key
self.lang=lang
self.report={'schema_version':3,'tool_version':__version__,'generated_at':utc_now(),
'mode':'read-only','result':'running',key:{}}
try:self.store=ReportStore(path,lambda value:sharing_copy(value,lang))
except FileExistsError:raise
except OSError:self.store=UnavailableStore(path)
self.failed=False
def checkpoint(self):
normalize_report(self.report)
if not self.store.checkpoint(self.report):self.failed=True
def __enter__(self):
self.checkpoint()
return self
def __exit__(self,kind,value,traceback):
if kind is not None and issubclass(kind,KeyboardInterrupt):
from .worker import cancel_all
cancel_all()
self.report['result']='cancelled'
else:self.report['result']='incomplete' if kind else self.report['result'] if self.report['result']!='running' else 'local_execution_complete'
self.report['finished_at']=utc_now()
normalize_report(self.report)
try:
self.failed=not self.store.finish(self.report) or self.failed
finally:self.store.close()
if self.store.journal_path:
print(('恢复日志: ' if self.lang=='zh' else 'Recovery journal: ')+str(self.store.journal_path),flush=True)
if self.failed:print('REPORT_WRITE_FAILED'+(' / 报告写入未完成' if self.lang=='zh' else ''),flush=True)
return False
+263 -94
View File
@@ -1,5 +1,4 @@
"""Four-part console workflow. Capability and measurement limits are explicit."""
import hashlib
import ipaddress
from pathlib import Path
import shutil
@@ -7,10 +6,10 @@ import subprocess
import time
from .checks import inspect
from . import __version__
from . import __version__, release_label
from .quality import run_quality
from .i18n import translator
from .routes import trace as native_trace
from .routes import trace as native_trace, trace_method as protocol_trace
from .media import run_console as run_media
from .bandwidth import run_console as measure_bandwidth, is_complete as bandwidth_complete
from .ipquality import collect as collect_ipquality, render as render_ipquality
@@ -25,107 +24,277 @@ def execute_provider(argv, timeout=180):
def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttrace=None,
region_script=None,max_hops=30,geo=True,color='auto',bandwidth_rounds=3,skip_route=False,json_output=None,bandwidth_sources=None,port=8388,show_ip=False,ip_enrichment=True):
region_script=None,max_hops=30,geo=True,color='auto',bandwidth_rounds=3,skip_route=False,json_output=None,bandwidth_sources=None,port=8388,show_ip=False,ip_enrichment=True,allow_bandwidth=False,mail_probes=False,route_probes=3,theme='default',
route_method='udp',tcp_port=443,include_return_routes=False,include_bgp=False):
from .cli import redact
from .console import Console
import re
ipaddress.ip_address(route_target)
if not 1<=sample_mib<=20 or not 1<=max_hops<=64 or not 3<=bandwidth_rounds<=8 or not 1<=port<=65535: raise ValueError('Invalid test limits')
if besttrace or region_script:
raise ValueError('External executables are disabled in the safe workflow; use native checks')
if not 1<=route_probes<=3:raise ValueError('Route probe rounds must be 1..3')
if route_method not in ('udp','icmp','tcp') or not 1<=tcp_port<=65535 or theme not in ('default','iprisk','framed'):
raise ValueError('Invalid route or theme options')
out=Console(lang,color); tr=out.text
iprisk = theme == 'iprisk'
framed = theme == 'framed'
from datetime import datetime,timezone
report={'schema_version':2,'mode':'read-only','generated_at':datetime.now(timezone.utc).isoformat(),'target_port':port,'network':None,'route':None,'tool_version':__version__,'offline':offline,'bandwidth':None,'ip_quality':None,'media':{}}
print(tr(f'ProxyDoctor {__version__} | 原生检测 | 联网模式' if not offline else f'ProxyDoctor {__version__} | 离线模式',
f'ProxyDoctor {__version__} | Native checks | Online' if not offline else f'ProxyDoctor {__version__} | Offline'),flush=True)
data=redact(inspect(lang=lang,port=port),lang)
report['system']=data
checks={item['id']:item for item in data['checks']}
unknown=tr('未知','Unknown');incomplete=False
out.section(1,'VPS 性能与资源','VPS resources and performance')
system=checks['system']['evidence'].split(';')
out.row(tr('系统','OS'),system[1].strip() if len(system)>1 else unknown)
out.row(tr('架构','Architecture'),checks['architecture']['evidence'])
out.row(tr('内核','Kernel'),checks['kernel']['detail'], 'ok' if checks['kernel']['status']=='pass' else 'unknown')
cpu=checks['cpu']
out.row('CPU',re.split('[::]',cpu['evidence'],maxsplit=1)[-1], 'ok' if cpu['status']=='pass' else 'unknown')
count=re.search(r'\d+',cpu['detail'])
out.row(tr('逻辑核心','Logical CPUs'),count.group() if count else unknown,'ok' if count else 'unknown')
for key,zh,en in [('memory_total','总内存','RAM total'),('memory','可用内存','RAM available'),('disk_capacity','磁盘','Root filesystem')]:
item=checks.get(key)
value=re.split(r'[((]',item['detail'],maxsplit=1)[0] if item else unknown
out.row(tr(zh,en),value,'ok' if item and item['status']=='pass' else 'unknown')
statuses={'pass':tr('通过','Pass'),'warn':tr('注意','Warning'),'fail':tr('失败','Failed'),
'unknown':tr('未知','Unknown'),'skip':tr('跳过','Skipped')}
for key,zh,en in [('default_route','默认路由','Default route'),('dns_config','DNS 配置','DNS config'),
('ports',f'本机端口 {port}',f'Local port {port}'),('ssh_port_safety','SSH 端口保护','SSH port guard'),
('ssh','SSH 配置','SSH config'),('firewall','本机防火墙','Local firewall'),('cloud_firewall','云防火墙','Cloud firewall')]:
item=checks[key]
value=statuses[item['status']]
if key=='ports' and item['status']=='pass': value=tr('未发现占用(仅本机)','No listener found (local only)')
if key=='ports' and item['status']=='warn': value=tr('已占用','In use')
if key=='ssh_port_safety' and item['status']=='warn': value=tr('目标端口与 SSH 冲突','Target conflicts with SSH')
if key=='ssh' and item['status']=='warn': value=tr('允许 root 或密码认证','Root or password auth allowed')
out.row(tr(zh,en),value,'bad' if item['status'] in ('warn','fail') else 'ok' if item['status']=='pass' else 'unknown')
block=b'\x00'*1024**2;start=time.perf_counter()
for _ in range(32): hashlib.sha256(block).digest()
out.row('SHA-256',f'{32/max(time.perf_counter()-start,0.000001):.1f} MiB/s')
if offline: out.row(tr('带宽','Bandwidth'),tr('跳过','Skipped'),'unknown')
report={'schema_version':3,'mode':'read-only','generated_at':datetime.now(timezone.utc).isoformat(),'target_port':port,'network':None,'route':None,'tool_version':__version__,'offline':offline,'bandwidth':None,'bandwidth_consent':allow_bandwidth,'cpu_benchmark':'not_run','ip_quality':None,'media':{}}
if framed:
out.frame_open(tr('VPS 检测报告','VPS check report'), box_w=78, subtitle=release_label(lang) + ' · ' + __version__)
out.frame_keyvalue(tr('模式','Mode'), tr('离线模式' if offline else '联网模式','Offline' if offline else 'Online'))
out.frame_keyvalue(tr('目标端口','Target port'), str(port))
elif iprisk:
out.banner(' VPS CHECK ' + ('v' + __version__))
out.line(release_label(lang), 'unknown')
out.keyvalue(tr('只读体检报告 · 原生检测','Read-only diagnostic report · native checks'),
tr('离线模式' if offline else '联网模式','Offline' if offline else 'Online'),'title')
out.hrule()
print(flush=True)
else:
bandwidth=measure_bandwidth(lang=lang,color=color,rounds=bandwidth_rounds,
sample_mib=sample_mib,sources=bandwidth_sources)
report['bandwidth']=bandwidth
incomplete=not bandwidth_complete(bandwidth) or incomplete
out.section(2,'节点网络质量','Node network quality')
if offline: out.row(tr('网络','Network'),tr('跳过','Skipped'),'unknown')
else:
network={}
incomplete=bool(run_quality(translator(lang),compact=True,lang=lang,color=color,report=network)) or incomplete
report['network']=network
metadata=collect_ipquality(enrich=ip_enrichment)
out.line(release_label(lang), 'unknown')
print(tr(f'VPS 检测工具 {__version__} | 原生检测 | 联网模式' if not offline else f'VPS 检测工具 {__version__} | 离线模式',
f'VPS Check {__version__} | Native checks | Online' if not offline else f'VPS Check {__version__} | Offline'),flush=True)
from .reports import ReportStore, UnavailableStore, utc_now
from .runner import ModuleRunner
data = {'checks': []}
unknown = tr('未知','Unknown')
incomplete = False
report['result'] = 'running'
# Open an exclusive recovery journal before starting any checks.
try:
store = ReportStore(json_output, lambda value: redact(value, lang))
except FileExistsError:
raise # A collision must be resolved before running any checks.
except OSError:
store = UnavailableStore(json_output)
persistence_warning = False
def checkpoint():
nonlocal persistence_warning
from .schema import normalize_report
normalize_report(report)
if not store.checkpoint(report) and not persistence_warning:
persistence_warning = True
out.row(tr('报告保存','Report storage'),tr('写入失败;保留已有日志并继续终端检测','Write failed; retaining prior journal and continuing terminal checks'),'bad')
def module_error(name):
out.row(name, tr('本模块异常,继续其他检测(MODULE_ERROR)','Module failed; continuing other checks (MODULE_ERROR)'),'bad')
runner = ModuleRunner(report, checkpoint, module_error)
def heading(number, zh, en, kind='b_cyan'):
if iprisk:
out.theme_section(number, zh, en, kind)
else:
out.section(number, zh, en)
def collect_system():
nonlocal data, incomplete
data=redact(inspect(lang=lang,port=port),lang)
report['system']=data
checks={item['id']:item for item in data['checks']}
unknown=tr('未知','Unknown')
heading(1,'基础信息','Basic information')
system=checks['system']['evidence'].split(';')
out.row(tr('系统','OS'),system[1].strip() if len(system)>1 else unknown)
out.row(tr('架构','Architecture'),checks['architecture']['evidence'])
out.row(tr('内核','Kernel'),checks['kernel']['detail'], 'ok' if checks['kernel']['status']=='pass' else 'unknown')
cpu=checks['cpu']
out.row('CPU',re.split('[::]',cpu['evidence'],maxsplit=1)[-1], 'ok' if cpu['status']=='pass' else 'unknown')
# Allocation-focused numbers are rendered by the resources module.
# Keep raw OS-visible values in structured evidence, not as VPS limits.
for key,zh,en in [('disk_capacity','磁盘','Root filesystem')]:
item=checks.get(key)
value=re.split(r'[((]',item['detail'],maxsplit=1)[0] if item else unknown
out.row(tr(zh,en),value,'ok' if item and item['status']=='pass' else 'unknown')
statuses={'pass':tr('通过','Pass'),'warn':tr('注意','Warning'),'fail':tr('失败','Failed'),
'unknown':tr('未知','Unknown'),'skip':tr('跳过','Skipped')}
for key,zh,en in [('default_route','默认路由','Default route'),('dns_config','DNS 配置','DNS config'),
('ports',f'本机端口 {port}',f'Local port {port}'),('ssh_port_safety','SSH 端口保护','SSH port guard'),
('ssh','SSH 配置','SSH config'),('firewall','本机防火墙','Local firewall'),('cloud_firewall','云防火墙','Cloud firewall')]:
item=checks[key]
value=statuses[item['status']]
if key=='ports' and item['status']=='pass': value=tr('未发现占用(仅本机)','No listener found (local only)')
if key=='ports' and item['status']=='warn': value=tr('已占用','In use')
if key=='ssh_port_safety' and item['status']=='warn': value=tr('目标端口与 SSH 冲突','Target conflicts with SSH')
if key=='ssh' and item['status']=='warn': value=tr('允许 root 或密码认证','Root or password auth allowed')
out.row(tr(zh,en),value,'bad' if item['status'] in ('warn','fail') else 'ok' if item['status']=='pass' else 'unknown')
out.row('CPU benchmark',tr('未运行:常规检测不执行性能基准','Not run: routine checks do not benchmark CPU'),'unknown')
def collect_bandwidth():
nonlocal data, incomplete
if offline or not allow_bandwidth:
out.row(tr('带宽','Bandwidth'),tr('跳过;使用 --allow-bandwidth 明确允许测速','Skipped; use --allow-bandwidth to consent to traffic tests'),'unknown')
else:
report['bandwidth']={}
bandwidth=measure_bandwidth(lang=lang,color=color,rounds=bandwidth_rounds,
sample_mib=sample_mib,sources=bandwidth_sources,destination=report['bandwidth'],on_result=checkpoint,out=out)
report['bandwidth']=bandwidth
incomplete=not bandwidth_complete(bandwidth) or incomplete
def collect_resources():
from .resources import collect, render
resource_data = collect()
report['resources'] = resource_data
render(resource_data, out)
def collect_security():
nonlocal incomplete
from .security import collect as collect_security_data, render as render_security
security_data = {}
report['security'] = security_data
if framed:
out.frame_line(tr('基础安全(只读)','Basic security (read-only)'), 'title')
else:
print(out.paint(tr('--- 基础安全(只读) ---','--- Basic security (read-only) ---'),'title'),flush=True)
collect_security_data(security_data, on_result=checkpoint)
render_security(security_data, out)
incomplete = bool(security_data.get('review_count') or security_data['status'] == 'unsupported') or incomplete
def collect_network():
nonlocal data, incomplete
heading(2,'节点网络质量','Node network quality')
if offline: out.row(tr('网络','Network'),tr('跳过','Skipped'),'unknown')
else:
from .connectivity import run_console as run_connectivity
network={}
report['network']=network
incomplete=bool(run_connectivity(lang=lang,color=color,report=network,on_result=checkpoint,out=out)) or incomplete
def collect_ip_quality():
nonlocal data, incomplete
report['ip_quality']={}
metadata=collect_ipquality(enrich=ip_enrichment,mail=mail_probes,destination=report['ip_quality'],on_result=checkpoint)
report['ip_quality']=metadata
render_ipquality(metadata,out,show_ip)
incomplete=not metadata['coverage']['complete'] or incomplete
out.section(3,'路由追踪','Route trace')
if offline or skip_route: out.row(tr('路由','Route'),tr('跳过','Skipped'),'unknown')
else:
print(tr(f'目标 {route_target} · 最多 {max_hops} 跳 · 位置为数据库估计',
f'Target {route_target} · up to {max_hops} hops · estimated IP location'),flush=True)
print(tr('跳 IP 地址 延迟 位置',
'Hop IP address Latency Location'),flush=True)
route={'target':route_target,'max_hops':max_hops,'geo_enabled':geo,'hops':[]}
report['route']=route
if besttrace and Path(besttrace).is_file():
route['provider']='external_besttrace'
route['state']='external_output_only'
incomplete=bool(execute_provider([str(Path(besttrace).resolve()),route_target],120)) or incomplete
def collect_route():
nonlocal data, incomplete
heading(3,'路由追踪','Route trace')
if offline or skip_route: out.row(tr('路由','Route'),tr('跳过','Skipped'),'unknown')
else:
def route_output(line,**kwargs):
print(out.paint(line,'unknown' if any(word in line for word in ('*','unavailable','未知','Unknown')) else 'ok',preserve_space=True),flush=True)
route['provider']='native_udp'
route_code=native_trace(route_target,emit=route_output,hops=max_hops,lang=lang,geo=geo,records=route['hops'])
route['state']='reached' if route_code==0 else 'incomplete'
incomplete=bool(route_code) or incomplete
out.section(4,'流媒体区域限制','Streaming region restrictions')
if offline: out.row(tr('流媒体','Streaming'),tr('跳过','Skipped'),'unknown')
elif region_script:
path=Path(region_script).resolve();bash=shutil.which('bash')
if path.is_file() and bash: incomplete=bool(execute_provider([bash,str(path)],600)) or incomplete
else: out.row('RegionRestrictionCheck',unknown,'unknown');incomplete=True
out.line(tr(f'目标 {route_target} · 最多 {max_hops} 跳 · 位置为数据库估计',
f'Target {route_target} · up to {max_hops} hops · estimated IP location'))
out.line(tr('跳 IP 地址 延迟 位置',
'Hop IP address Latency Location'))
out.line(tr('每跳仅1次探测;响应/超时计数不等于转发丢包率。','One probe per hop; response/timeout counts are not forwarding-loss estimates.'))
route={'target':route_target,'max_hops':max_hops,'geo_enabled':geo,'method':route_method,'hops':[]}
report['route']=route
if besttrace and Path(besttrace).is_file():
route['provider']='external_besttrace'
route['state']='external_output_only'
incomplete=bool(execute_provider([str(Path(besttrace).resolve()),route_target],120)) or incomplete
else:
def route_output(line,**kwargs):
out.line(line,'unknown' if any(word in line for word in ('*','unavailable','未知','Unknown')) else 'ok')
route['provider']='native_udp' if route_method=='udp' else 'system_traceroute_'+route_method
from .routes import summarize_hops
deadline=time.monotonic()+90
replies=0;attempts=0
route['probe_rounds_requested']=route_probes
for number in range(route_probes):
if time.monotonic()>=deadline:break
out.line(tr(f'路由探测 {number+1}/{route_probes}',f'Route probe {number+1}/{route_probes}'))
options=dict(emit=route_output,hops=max_hops,lang=lang,geo=geo and number==0,
records=route['hops'],on_result=checkpoint,deadline=deadline)
route_code=(native_trace(route_target,**options) if route_method=='udp' else
protocol_trace(route_target,method=route_method,tcp_port=tcp_port,**options))
attempts+=1;replies+=route_code==0
route['statistics']=summarize_hops(route['hops'])
checkpoint()
route.update(state='reached' if replies else 'incomplete',probe_rounds_run=attempts,destination_responses=replies)
for row in route.get('statistics',[]):
out.row(tr('跳','Hop')+' '+str(row['hop']),
f"reply {row['responses']}/{row['probes_sent']} · timeout {row['timeouts']} · response-loss {row['response_loss_percent']}%",'unknown')
out.line(tr('以上是路由器响应缺失统计,不是线路转发丢包率。','These are router-response statistics, not forwarding packet-loss estimates.'))
incomplete=replies<route_probes or incomplete
def collect_media():
nonlocal data, incomplete
heading(4,'流媒体可达性','Media reachability')
if offline: out.row(tr('流媒体','Streaming'),tr('跳过','Skipped'),'unknown')
elif region_script:
path=Path(region_script).resolve();bash=shutil.which('bash')
if path.is_file() and bash: incomplete=bool(execute_provider([bash,str(path)],600)) or incomplete
else: out.row('RegionRestrictionCheck',unknown,'unknown');incomplete=True
else:
run_media(lang,color,results=report['media'],on_result=checkpoint,out=out)
incomplete=any(item.get('state')!='reachable' for item in report['media'].values()) or incomplete
def collect_return_routes():
nonlocal incomplete
from .carriers import return_routes
out.line(tr('三城三运营商 · 方向:本机 → 中国目标','Three cities × three carriers · this host → China targets'),'title')
report['return_routes']={}
return_routes(method=route_method,hops=max_hops,geo=False,tcp_port=tcp_port,
destination=report['return_routes'],on_result=checkpoint,out=out)
incomplete=report['return_routes']['status']!='success' or incomplete
def collect_bgp():
nonlocal incomplete
from .bgp import collect,render
address=(report.get('ip_quality') or {}).get('ip')
if not address:
report['bgp']={'status':'unknown','error_code':'EGRESS_IP_UNAVAILABLE'}
out.row('BGP','EGRESS_IP_UNAVAILABLE','unknown');incomplete=True
return
report['bgp']={}
collect(address,report['bgp'],checkpoint);render(report['bgp'],out)
incomplete=report['bgp']['status']!='success' or incomplete
cancelled = False
try:
runner.run('system', collect_system)
runner.run('resources', collect_resources)
runner.run('security', collect_security)
runner.run('bandwidth', collect_bandwidth,
'offline' if offline else 'consent_required' if not allow_bandwidth else None)
if offline:
heading(2,'节点网络质量','Node network quality')
out.row('DNS / TCP / TLS',tr('离线跳过','Skipped offline'),'unknown')
runner.run('network', collect_network, 'offline' if offline else None)
runner.run('ip_quality', collect_ip_quality, 'offline' if offline else None)
if offline or skip_route:
heading(3,'路由追踪','Route trace')
out.row(tr('路由','Route'),tr('已跳过','Skipped'),'unknown')
runner.run('route', collect_route, 'offline' if offline else 'user_skipped' if skip_route else None)
if offline:
heading(4,'流媒体可达性','Media reachability')
out.row(tr('流媒体','Media'),tr('离线跳过','Skipped offline'),'unknown')
runner.run('media', collect_media, 'offline' if offline else None)
if include_return_routes:runner.run('return_routes',collect_return_routes,'offline' if offline else None)
if include_bgp:runner.run('bgp',collect_bgp,'offline' if offline else None)
except KeyboardInterrupt:
from .worker import cancel_all
cancel_all()
cancelled = True
finally:
report['finished_at'] = utc_now()
failed = any(item['status'] == 'failure' for item in report['modules'].values())
report['result'] = 'cancelled' if cancelled else 'incomplete' if incomplete or failed else 'local_execution_complete'
report['summary'] = {
'playback': 'not_tested',
'system_findings': sum(item['status'] in ('warn','fail') for item in data['checks']),
'failed_modules': sum(item['status'] == 'failure' for item in report['modules'].values()),
'permission_reviews': report.get('security',{}).get('review_count',0),
'media': {state: sum(isinstance(item, dict) and item.get('state') == state
for item in report['media'].values())
for state in ('reachable','access_denied','rate_limited','http_error','unknown')},
}
from .schema import normalize_report
normalize_report(report)
saved = store.finish(report)
store.close()
out.row(tr('体检结果','Results'), report['result'], 'unknown' if cancelled or incomplete or failed else 'ok')
if saved and not store.failed:
out.row(tr('脱敏报告','Redacted report'), str(store.path))
else:
report['media']=run_media(lang,color)
incomplete=True # Account/DRM playback is intentionally not claimed.
print(tr('\n说明:带宽按来源独立统计 HTTPS 多轮吞吐(含建连);地区套餐/片库可见不等于账号播放。IP 标记与举报为第三方记录,零举报不保证安全。',
'\nNotes: per-source repeated HTTPS throughput, including setup. Offers/catalogs do not prove account playback. Provider flags and reports are evidence; zero reports do not guarantee safety.'),flush=True)
report['finished_at']=datetime.now(timezone.utc).isoformat()
report['result']='incomplete' if incomplete else 'local_execution_complete'
counts={state:sum(item.get('state')==state for item in report['media'].values()) for state in ('restricted','offer_available','catalog_visible','metadata_available','unknown')}
report['summary']={'media':counts,'playback':'not_tested','system_findings':sum(item['status'] in ('warn','fail') for item in data['checks'])}
print(tr(f"体检结果:{report['summary']['system_findings']} 项本机提示 · 媒体限制 {counts['restricted']} · 媒体未知 {counts['unknown']}",
f"Results: {report['summary']['system_findings']} local findings · Media restricted {counts['restricted']} · Media unknown {counts['unknown']}"),flush=True)
if json_output:
import json
with Path(json_output).open('x',encoding='utf-8') as stream:
json.dump(redact(report,lang),stream,ensure_ascii=False,indent=2)
print(tr('脱敏 JSON 已保存:','Redacted JSON saved: ')+str(json_output),flush=True)
return 1 if incomplete else 0
out.row(tr('报告保存','Report storage'), tr('保存未完成,请保留已有恢复日志','Save incomplete; retain the existing recovery journal'),'bad')
if store.journal_path is not None:
out.row(tr('恢复日志','Recovery journal'), str(store.journal_path))
if framed:
out.frame_close()
return 130 if cancelled else 1 if incomplete or failed or store.failed else 0
+117
View File
@@ -0,0 +1,117 @@
"""CLI-only hard boundaries for network operations, including blocking DNS."""
import json
import os
import subprocess
import sys
from pathlib import Path
import threading
_active=set()
_lock=threading.Lock()
_cancelled=threading.Event()
def cancel_all():
_cancelled.set()
with _lock:
for process in tuple(_active):
if process.poll() is None:
try:process.kill()
except OSError:pass
def reset():
_cancelled.clear()
def enabled():
return os.environ.get('PROXYDOCTOR_NETWORK_WORKERS','1')=='1'
def local_command(argv, timeout=4):
"""Bound a caller-validated local probe and register it for cancellation."""
if _cancelled.is_set():
return None, '', 'CANCELLED'
with subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding='utf-8', errors='replace',
env={**os.environ, 'LC_ALL':'C'}) as process:
with _lock:
_active.add(process)
if _cancelled.is_set():process.kill()
try:
output, _ = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
process.kill();process.communicate()
return None, '', 'COMMAND_TIMEOUT'
except BaseException:
process.kill();process.communicate()
raise
finally:
with _lock:_active.discard(process)
if len(output)>16384:
return None, '', 'COMMAND_OUTPUT_LIMIT'
return process.returncode, output, None
def invoke(action,options,timeout=20):
if action not in ('json','page','transfer','carrier_probe','resolve_public'):
raise ValueError('Unsupported network action')
if _cancelled.is_set():
return None
try:
bootstrap='import sys;sys.path.insert(0,sys.argv.pop(1));from proxydoctor.worker import main;sys.exit(main())'
with subprocess.Popen([sys.executable,'-I','-B','-c',bootstrap,str(Path(__file__).resolve().parent.parent),action],
stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
text=True,encoding='utf-8') as process:
with _lock:
_active.add(process)
if _cancelled.is_set():process.kill()
try:
output,_=process.communicate(json.dumps(options),timeout=timeout)
except subprocess.TimeoutExpired:
process.kill();process.communicate()
return None
except KeyboardInterrupt:
cancel_all();process.communicate()
raise
finally:
with _lock:_active.discard(process)
if process.returncode or len(output)>2*1024**2:
return None
return json.loads(output)
except (OSError,ValueError,subprocess.TimeoutExpired):
return None
def main():
os.environ['PROXYDOCTOR_NETWORK_WORKERS']='0'
options=json.loads(sys.stdin.read(65537))
action=sys.argv[1]
if action=='json':
from .net import request_json
result=request_json(**options)
elif action=='page':
from dataclasses import asdict
from .media import fetch_page
result=asdict(fetch_page(**options))
elif action=='transfer':
from dataclasses import asdict
from .bandwidth import transfer
result=asdict(transfer(**options))
elif action=='carrier_probe':
from .carriers import probe_node
result=probe_node(**options)
elif action=='resolve_public':
from .carriers import resolve_public
result=resolve_public(**options)
else:
return 2
print(json.dumps(result))
return 0
if __name__=='__main__':
try:
sys.exit(main())
except (Exception,KeyboardInterrupt):
sys.exit(1) # Never print provider exceptions/credentials.
+1 -1
View File
@@ -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"
+6
View File
@@ -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')
+105
View File
@@ -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'
+3 -3
View File
@@ -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']
+51
View File
@@ -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'
+70
View File
@@ -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)
+22
View File
@@ -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
+41
View File
@@ -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'
+7 -5
View File
@@ -178,21 +178,23 @@ def test_media_console_failure_does_not_abort_other_services(monkeypatch,capsys)
monkeypatch.setattr(media,'SERVICES',{'Netflix':None,'Spotify':None})
def check(name):
if name=='Netflix': raise RuntimeError('private response data')
return {'state':'restricted','http':200,'evidence':'confirmed_explicit_region_message'}
monkeypatch.setattr(media,'check',check)
return {'state':'access_denied','http':403,'evidence':'http_response'}
monkeypatch.setattr(media,'check_reachability',check)
results=media.run_console('en','always')
output=capsys.readouterr().out
assert len(results)==2 and '\x1b[31m' in output
assert len(results)==2 and '\x1b[33m' in output
assert 'Access denied' in output
assert 'private response data' not in output
def test_offline_json_stays_local_and_cannot_overwrite(tmp_path):
from proxydoctor.suite import run_suite
path=tmp_path/'result.json'
assert run_suite(offline=True,json_output=path)==0
import platform
assert run_suite(offline=True,json_output=path)==(0 if platform.system()=='Linux' else 1)
result=json.loads(path.read_text(encoding='utf-8'))
assert result['offline'] and result['bandwidth'] is None and result['media']=={}
assert result['result']=='local_execution_complete'
assert result['result']==('local_execution_complete' if platform.system()=='Linux' else 'incomplete')
original=path.read_bytes()
with pytest.raises(FileExistsError): run_suite(offline=True,json_output=path)
assert path.read_bytes()==original
+5 -5
View File
@@ -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
+5 -5
View File
@@ -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'
+133
View File
@@ -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
+89
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
import json
import subprocess
import sys
import pytest
from proxydoctor.reporting import export_report, load_report, markdown, sharing_copy
from proxydoctor.reports import recover_latest
def test_markdown_escapes_injected_markup_and_preserves_uncertainty():
document={'schema_version':3,'modules':{'<script>alert(1)</script>|':{'status':'skipped','reason':'offline'}},
'media':{'Netflix':{'state':'unknown','evidence':'</pre><script>x</script>'}}}
text=markdown(document,'en')
assert '&lt;script&gt;' in text # table cells remain escaped
assert '```json\n' in text
# The provider string is literal JSON inside a code fence, not raw HTML.
assert '"evidence": "</pre><script>x</script>"' in text.split('```json\n')[1].split('\n```')[0]
assert 'unknown' in text and 'skipped' in text
def test_markdown_provider_cannot_close_json_fence():
payload='```\n# forged section\n``````\n<script>x</script>'
text=markdown({'schema_version':3,'media':{'raw':payload}},'en')
assert '```````json\n' in text
block=text.split('```````json\n')[1].split('\n```````')[0]
assert json.loads(block)=={'raw':payload}
def test_sharing_redaction_hides_credentials_queries_and_ip():
safe=sharing_copy({'api_key':'my-secret','nested':{'password':'p'},
'evidence':'https://user:pass@example.com/test?token=secret#fragment Bearer abc123 8.8.8.8'})
text=json.dumps(safe)
assert all(value not in text for value in ('my-secret','user:pass','token=secret','abc123','8.8.8.8'))
assert 'https://example.com/test' in text
def test_export_does_not_overwrite(tmp_path):
destination=tmp_path/'report.md'
export_report({'schema_version':3},destination)
initial=destination.read_bytes()
with pytest.raises(FileExistsError):
export_report({'schema_version':3,'result':'different'},destination)
assert destination.read_bytes()==initial
def test_loader_rejects_unsupported_schema_and_size(tmp_path):
path=tmp_path/'input.json';path.write_text('{"schema_version":999}')
with pytest.raises(ValueError):load_report(path)
with pytest.raises(ValueError):load_report(path,max_bytes=2)
def test_recovery_read_budget_is_enforced(tmp_path):
path=tmp_path/'events';path.write_bytes(b'12345678\n')
with pytest.raises(ValueError):recover_latest(path,max_line_bytes=4)
@pytest.mark.parametrize('lang',['zh','en'])
def test_actual_offline_export_and_partial_recovery_cli(tmp_path,lang):
document={'schema_version':3,'result':'running','modules':{'network':{'status':'running'}},'api_key':'private-test-value'}
source=tmp_path/'source.json';source.write_text(json.dumps(document),encoding='utf-8')
journal=tmp_path/'journal.jsonl'
journal.write_text(json.dumps({'event_version':1,'snapshot':document})+'\n'+ '{"partial":',encoding='utf-8')
report=tmp_path/'report.md';recovered=tmp_path/'recovered.json'
def cli(*args):
return subprocess.run([sys.executable,'-m','proxydoctor',*args,'--lang',lang],capture_output=True,text=True,encoding='utf-8',timeout=10)
assert cli('report','--input',str(source),'--output',str(report)).returncode==0
assert 'private-test-value' not in report.read_text(encoding='utf-8')
assert cli('recover','--journal',str(journal),'--output',str(recovered)).returncode==0
result=json.loads(recovered.read_text(encoding='utf-8'))
assert result['result']=='recovered_partial'
assert result['recovery']['new_measurement'] is False
assert cli('recover','--journal',str(journal),'--output',str(recovered)).returncode==2
+89
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
"""Read-only security fixtures; execution deferred at the user's request."""
import json
import stat
from types import SimpleNamespace
from proxydoctor.security import collect, inspect_path, update_evidence
def metadata(mode,uid=0,modified=3600):
return SimpleNamespace(st_mode=mode,st_uid=uid,st_gid=42,st_mtime=modified)
def test_shadow_group_read_is_not_a_finding():
row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFREG|0o640))
assert row['status'] == 'observed'
def test_world_readable_shadow_and_writable_configuration_require_review():
row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFREG|0o644))
assert row['reasons'] == ['SENSITIVE_WORLD_READABLE']
row = inspect_path('/etc/sudoers',lstat=lambda _:metadata(stat.S_IFREG|0o666,uid=1000))
assert 'WORLD_WRITABLE' in row['reasons']
assert 'GROUP_WRITABLE_REVIEW' in row['reasons']
assert 'OWNER_NOT_ROOT_REVIEW' in row['reasons']
def test_symlinks_are_not_followed_or_called_safe():
row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFLNK|0o777))
assert row['status'] == 'unknown'
assert row['error_code'] == 'SYMLINK_REQUIRES_REVIEW'
def test_permission_error_does_not_expose_exception_text():
def denied(_):
raise PermissionError('secret-path-token')
row = inspect_path('/etc/shadow',True,lstat=denied)
assert row['error_code'] == 'METADATA_UNAVAILABLE'
assert 'secret-path-token' not in json.dumps(row)
def test_missing_cache_does_not_mean_zero_updates():
def missing(_):
raise FileNotFoundError
row = update_evidence(lstat=missing)
assert row['status'] == 'unknown'
assert row['pending_updates'] is None
def test_cache_is_timestamped_observation_not_patch_verdict():
row = update_evidence(lstat=lambda _:metadata(stat.S_IFREG|0o644),now=lambda:7200,
read=lambda _:'12 updates can be applied immediately.\n3 of these updates are standard security updates.')
assert row['pending_updates'] == 12
assert row['security_updates'] == 3
assert row['sources'][0]['age_hours'] == 1
assert row['status'] == 'cached_observation'
def test_localized_summary_stays_unknown_and_raw_text_is_not_retained():
row = update_evidence(lstat=lambda _:metadata(stat.S_IFREG|0o644),now=lambda:7200,
read=lambda _:'中文缓存 secret-key')
assert row['pending_updates'] is None
assert 'secret-key' not in json.dumps(row)
def test_collect_does_not_read_password_file_contents():
reads = []
def read(path):
reads.append(path)
assert path == '/var/lib/update-notifier/updates-available'
return None
row = collect(system='Linux',lstat=lambda _:metadata(stat.S_IFREG|0o640),read=read,now=lambda:7200)
assert len(row['files']) == 9
assert reads == ['/var/lib/update-notifier/updates-available']
def test_non_linux_does_not_inspect_host_files():
def unexpected(_):
raise AssertionError('Linux paths should not be read')
assert collect(system='Windows',lstat=unexpected,read=unexpected)['status'] == 'unsupported'
+9 -7
View File
@@ -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'])