From 9804b73cf0d57a602f47b6aa1466881d38943954 Mon Sep 17 00:00:00 2001 From: ProxyDoctor Checkpoint Date: Sun, 1 Nov 2026 12:36:17 -0800 Subject: [PATCH] Checkpoint verified 1.0.0b1 baseline before final hardening --- .gitattributes | 6 + ALPHA_RELEASE_REPORT.md | 44 +++ ALPHA_TASK_STATUS.md | 28 ++ ARCHITECTURE.md | 83 +++++ CHANGELOG.md | 110 ++++++ CURRENT_STATUS.md | 13 + DEVELOPMENT_BRIEF.zh.md | 411 +++++++++++++++++++++++ IPQUALITY_COVERAGE.md | 20 ++ MANIFEST.sha256.json | 83 +++++ OPEN_SOURCE_RESEARCH.md | 17 + PROBLEM_BACKLOG.md | 271 +++++++++++++++ PUBLIC_BETA_CHANGE_REPORT.md | 46 +++ README.en.md | 194 +++-------- README.md | 199 +++++------ SECURITY.md | 22 ++ TEST_PLAN.md | 103 ++++++ THIRD_PARTY_NOTICES.md | 41 +++ USER_CHECKLIST_STATUS.md | 102 ++++++ docs/history-beta-before-p0-20261101.md | 14 + docs/pre-alpha-README.en.md | 207 ++++++++++++ docs/pre-alpha-README.md | 200 +++++++++++ docs/status-history-before-2026-11-01.md | 58 ++++ install.py | 106 ++++++ proxydoctor.sh | 4 +- proxydoctor/__init__.py | 7 +- proxydoctor/__main__.py | 10 + proxydoctor/bandwidth.py | 61 +++- proxydoctor/bgp.py | 96 ++++++ proxydoctor/carriers.py | 141 ++++++++ proxydoctor/checks.py | 64 ++-- proxydoctor/cli.py | 305 ++++++++++++++--- proxydoctor/connectivity.py | 203 +++++++++++ proxydoctor/console.py | 237 ++++++++++++- proxydoctor/geo.py | 11 + proxydoctor/i18n.py | 46 +-- proxydoctor/ipquality.py | 62 +++- proxydoctor/mailquality.py | 79 +++++ proxydoctor/media.py | 45 ++- proxydoctor/net.py | 10 +- proxydoctor/quality.py | 10 +- proxydoctor/reporting.py | 124 +++++++ proxydoctor/reports.py | 121 +++++++ proxydoctor/resources.py | 245 ++++++++++++++ proxydoctor/routes.py | 102 +++++- proxydoctor/runner.py | 35 ++ proxydoctor/schema.py | 57 ++++ proxydoctor/security.py | 158 +++++++++ proxydoctor/session.py | 41 +++ proxydoctor/suite.py | 357 ++++++++++++++------ proxydoctor/worker.py | 117 +++++++ pyproject.toml | 2 +- tests/conftest.py | 6 + tests/test_alpha_quality.py | 105 ++++++ tests/test_bandwidth_sources.py | 6 +- tests/test_bytecode_policy.py | 51 +++ tests/test_connectivity.py | 70 ++++ tests/test_ip_panel.py | 22 ++ tests/test_mailquality.py | 41 +++ tests/test_measurements.py | 12 +- tests/test_nodequality.py | 10 +- tests/test_phase1_delivery.py | 10 +- tests/test_public_beta.py | 133 ++++++++ tests/test_recovery.py | 89 +++++ tests/test_reporting.py | 73 ++++ tests/test_resources.py | 89 +++++ tests/test_security.py | 79 +++++ tests/test_suite.py | 16 +- 67 files changed, 5395 insertions(+), 545 deletions(-) create mode 100644 .gitattributes create mode 100644 ALPHA_RELEASE_REPORT.md create mode 100644 ALPHA_TASK_STATUS.md create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 CURRENT_STATUS.md create mode 100644 DEVELOPMENT_BRIEF.zh.md create mode 100644 IPQUALITY_COVERAGE.md create mode 100644 MANIFEST.sha256.json create mode 100644 OPEN_SOURCE_RESEARCH.md create mode 100644 PROBLEM_BACKLOG.md create mode 100644 PUBLIC_BETA_CHANGE_REPORT.md create mode 100644 TEST_PLAN.md create mode 100644 USER_CHECKLIST_STATUS.md create mode 100644 docs/history-beta-before-p0-20261101.md create mode 100644 docs/pre-alpha-README.en.md create mode 100644 docs/pre-alpha-README.md create mode 100644 docs/status-history-before-2026-11-01.md create mode 100644 install.py create mode 100644 proxydoctor/bgp.py create mode 100644 proxydoctor/carriers.py create mode 100644 proxydoctor/connectivity.py create mode 100644 proxydoctor/mailquality.py create mode 100644 proxydoctor/reporting.py create mode 100644 proxydoctor/reports.py create mode 100644 proxydoctor/resources.py create mode 100644 proxydoctor/runner.py create mode 100644 proxydoctor/schema.py create mode 100644 proxydoctor/security.py create mode 100644 proxydoctor/session.py create mode 100644 proxydoctor/worker.py create mode 100644 tests/conftest.py create mode 100644 tests/test_alpha_quality.py create mode 100644 tests/test_bytecode_policy.py create mode 100644 tests/test_connectivity.py create mode 100644 tests/test_ip_panel.py create mode 100644 tests/test_mailquality.py create mode 100644 tests/test_public_beta.py create mode 100644 tests/test_recovery.py create mode 100644 tests/test_reporting.py create mode 100644 tests/test_resources.py create mode 100644 tests/test_security.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7fd53bc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf +*.py text eol=lf +*.sh text eol=lf +*.toml text eol=lf +*.json text eol=lf +*.md text eol=lf diff --git a/ALPHA_RELEASE_REPORT.md b/ALPHA_RELEASE_REPORT.md new file mode 100644 index 0000000..9807f81 --- /dev/null +++ b/ALPHA_RELEASE_REPORT.md @@ -0,0 +1,44 @@ +> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。 + +# v0.1.0-alpha 发布前报告 + +日期:2026-11-01。版本元数据0.1.0a0,候选ZIP名v0.1.0-alpha。结论:**可内部Alpha试用;不建议现在公开发布,尚缺全新Ubuntu镜像安装验证及发布摘要可信分发确认。** 本轮未公开发布或合并主分支。 + +## 修复 + +1. 新增标准库离线安装器,venv --without-pip,无sudo/pip/包下载;不覆盖已有目录,失败和取消清理自建目录。包括安装路径含空格验收。 +2. 增加逐文件SHA256清单、ZIP摘要、路径/符号链接/篡改拒绝,并统一LF,避免Git换行导致校验失效。摘要须来自可信渠道,不能替代发布签名。 +3. HTTP/API/定位/媒体/吞吐CLI请求采用可终止子进程,覆盖阻塞DNS;网络通常20s、吞吐25s,本机命令8s,DNS探测2–5s。SIGINT/SIGTERM取消活动worker,保留已完成结果。 +4. 增加统一result_schema_version=1/result_items,五状态及来源、时间、方法、指标、错误、置信范围、限制;保留旧报告字段以避免无关迁移。 +5. suite及独立quality/ipquality/media/bandwidth命令增量保存;IP逐来源、测速逐样本回调。报告独占创建,日志单条4MiB、总64MiB;失败提示并返回非零,可恢复最后完整事件。 +6. UDP路由最多3轮、共享90s软预算,显示每跳实际发送、响应、超时与响应缺失统计;明确不等于转发丢包率。真实回环三次回应+一次无回应得到4发3收1超时/25%响应缺失。 + +## 测试 + +| 环境 | 自动测试 | 实际Alpha验收 | +|---|---|---| +| Ubuntu22.04.5 / Python3.10.12 | 248通过,5.00s | 23/23 | +| Ubuntu24.04.3 / Python3.12.3 | 248通过,5.32s | 23/23 | +| Windows开发环境 | 247通过、1跳过 | 非目标安装平台;跳过符号链接创建权限 | + +实际验收覆盖:空HOME/全新prefix离线安装及启动、无pip/user-site、版本及包加载位置、拒绝已有安装、中英文suite/check统一格式、JSON恢复/Markdown导出、源码篡改拒绝、真实HTTP429保留限流、阻塞媒体请求的SIGINT/SIGTERM退出130且保存已完成结果、模块异常不泄密且继续、真实UDP响应/超时统计、指定可读配置前后哈希不变。 + +自动测试还覆盖:worker超时杀死/回收、取消不再发起新请求、安装失败与取消清理、清单逃逸、日志预算、原有所有回归。没有将Mock结果当作真实公网测量。两台远端源文件哈希相同并与上传源核对。 + +证据位于工作区outputs:Alpha22/Alpha24-pytest.txt、Alpha22/Alpha24-alpha-acceptance.json、Alpha22/Alpha24-alpha-acceptance.txt、Alpha-dual-vm-summary.json。版本测试记录与历史0.5.0/239测试记录区分。 + +## 未闭合门槛与剩余风险 + +- **全新操作系统镜像安装尚未验证**:本轮是已有Ubuntu上的空HOME及全新环境,不证明系统没有先前安装过Python/venv组件。安装器检测失败会退出,不自动更改系统。09-01全量验收不能因此勾完成。 +- 公开发布须从可信来源独立提供ZIP摘要;本轮无签名密钥、无远程发布、无远程CI结果。所有构建/测试包仍在本地工作区。 +- CLI默认网络worker有上限;路由90s为软预算,正在进行的定位请求可能额外耗时最多一个worker上限。不是CPU/内存/所有系统调用的硬隔离沙箱。 +- 第三方真实网络/地区/数据库字段受出口和目标限制。Alpha本轮主要实测本机回环错误与取消,不宣称海外网络、付费数据库、账号播放或专业吞吐容量已验收。 +- SIGKILL、断电、磁盘/文件系统损坏以及未完成的探测无法保证结果保存;先前完整日志记录为恢复依据。路径应在可信用户目录,未承诺抵御共享目录所有竞态攻击。 +- 配置哈希仅覆盖指定可读路径,不是完整系统写入审计。仅系统/数据读取和本地输出写入,不修改SSH/防火墙/路由/DNS,无完整报告上传。 +- ARM、容器全场景、全新Server/Desktop、真实VPS、所有信号时序、可选带宽worker真实容量未验证。兼容和支持范围必须按README披露。 + +## P2与发布建议 + +P0/P1已完成主要代码修复和现有VM实测,但干净系统镜像门槛未闭合,故不扩展P2代码。04-09/05-03/08-05保留已有证据与保守边界;09-04仍缺全新安装形态验收。 + +建议把当前包作为内部Alpha候选供审阅;补全新镜像验证、确认摘要分发后再决定公开Alpha。保持大功能关闭/未实现声明。用户批准发布是后续动作,本轮不代为执行。 diff --git a/ALPHA_TASK_STATUS.md b/ALPHA_TASK_STATUS.md new file mode 100644 index 0000000..c36efd1 --- /dev/null +++ b/ALPHA_TASK_STATUS.md @@ -0,0 +1,28 @@ +> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。 + +# v0.1.0-alpha 发布优先项状态 + +本轮日期:2026-11-01。与原96项条目编号对应。历史27/53/16统计是本轮前基准,不再作为最新Alpha状态;本轮不以局部改动把全部复合需求勾为完成。 + +| 优先级/编号 | 实现与验证 | 本轮状态/剩余限制 | +|---|---|---| +| P0 09-03 | venv --without-pip、包只复制到隔离site-packages,空HOME、禁用user-site、无pip实测 | Alpha范围通过;需系统Python/venv | +| P0 09-01 | 一条python3 install.py --run离线安装并运行;两VM空HOME/新prefix通过 | 实现通过;全新Ubuntu镜像未验证,公开发布前仍需补验 | +| P0 09-10 | 可审查安装器、ZIP SHA256、逐文件清单、拒绝路径逃逸/源符号链接/篡改/已有目录 | 机制通过;摘要不是数字签名,发布时必须提供可信摘要渠道 | +| P0 10-04 | CLI HTTP/API/定位/媒体/吞吐网络worker时限、DNS子进程、固定本机命令、错误码及隔离 | 回归及实际429/异常通过;带宽未做新的实网容量验收 | +| P0 10-06 | SIGINT/SIGTERM取消网络worker、保留完成结果,安装失败/取消只清理新目录 | 两VM真实阻塞请求信号验收及清理故障注入通过;SIGKILL/断电不保证未落盘数据 | +| P1 03-02 | 最多3轮重复UDP追踪,逐跳实际发出/响应/超时/RTT/响应缺失比例 | 实际3次响应+1次超时统计通过;不宣称转发丢包率,不含ICMP/TCP | +| P1 08-01 | 统一result_items schema1;旧payload和schema保持兼容,check/suite/独立命令可消费 | Alpha报告规范通过;不宣称未来FlexMaster API稳定承诺 | +| P1 08-02 | 统一五状态、来源/方法/时间/错误/置信范围;执行和业务证据分开 | 回归及CLI验证通过;cancelled原状态保留、统一项为unknown+CANCELLED | +| P1 08-08 | suite及独立网络/IP/媒体/测速增量保存,JSONL恢复、文件独占、写入上限 | 信号/模块错误/恢复/写失败注入通过;正在进行的未完成探测、硬件故障不保证全部保留 | + +## P2 门槛评估 + +因全新操作系统镜像安装验证尚缺,不宣称全部发布门槛已闭合。本轮不新增P2实现: + +- 04-09:已有查询时间、来源、部分置信范围;第三方数据库真实更新时间不一定提供,不能伪造。 +- 05-03:保留地区/片库/未知与账号播放边界;未补账号登录或播放能力,不宣称完整验收。 +- 08-05:保留基于实际结果的保守排查提示,不推断唯一故障原因。 +- 09-04:现有两版UbuntuVM已测;全新Server/Desktop安装形态仍需独立验证。 + +其余96项需求范围与未实现的大型能力沿用CURRENT_STATUS.md。无公开发布、无主分支合并。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..75001f0 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,83 @@ + +## v0.1.0-alpha 候选(2026-11-01) + +隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。 + +# 架构与接口演进 + +2026-11-01。当前范围是独立 Python CLI,未来 FlexMaster 通过 Python API 复用。以下区分现状与方案,方案尚未视为实现。 + +## 现状 + +- `checks.py`:Host 只读适配器、Result、inspect;返回 schema_version=1 字典。 +- `suite.py`:四模块编排、终端渲染和 schema_version=2 报告,当前相互耦合。 +- `routes.py`:Linux UDP 错误队列路由,emit 回调和 records 可供调用;非通用跨平台实现。 +- `quality.py`、`media.py`、`ipquality.py`、`bandwidth.py`:可调用函数,但部分收集函数同时输出终端。 +- `net.py`:有限响应、请求超时;`console.py`:颜色和控制字符过滤;`cli.py`:参数、脱敏和保存。 +- 无运行依赖,Python >=3.10;setuptools>=77 是构建依赖,尚无上限锁定。 + +## 渐进方案(待实现) + +保留文件和现有接口,先增加独立 core/models、core/runner、reports 组件,再逐模块适配。不要一次移动所有代码。 + +统一 EvidenceResult:schema_version、check_id、status、observed_at、method、source、metrics(数值+单位)、error_code、confidence、limitations。状态为 success/failure/skipped/unsupported/unknown。failure 表示检查失败,不等于确认存在漏洞。媒体业务状态保留独立字段,不把 catalog_visible 映射为播放成功。旧 pass/warn 不可机械映射为网络成功/失败。 + +Runner 接受显式配置和 provider 注入;每模块独立捕获预期错误,未知异常仅记录固定错误码,不保存异常原文中的凭证。KeyboardInterrupt 传给协调器,落盘已有结果再退出 130。同步阶段先使用明确请求预算;不可通过线程超时假装终止底层网络操作。 + +Reports 接收结构化对象,不解析 CLI 文本。增量 JSONL 保存完成事件,最终报告独占创建、默认脱敏;禁止覆盖已有文件或通过符号链接写入。持久化失败应保留可用终端结果并返回明确错误。历史比较只比较相同方法、单位、节点及采样参数;用户购买规格单独存为 user_claim,不能当实测。 + +## 安全配置 + +常规 suite 不跑 CPU 或磁盘基准;测速需 allow_bandwidth=True / CLI --allow-bandwidth。该标志仅授权当前调用,不是永久授权。当前预算沿用 bandwidth.payload_budget,属于 HTTP 载荷预算,不能宣称为链路计费硬上限。CPU/磁盘基准后续单独命令、确认和上限。常规联网仍会向目标及 API 暴露请求来源;--offline 跳过联网。 + +第三方可执行文件入口当前暂停;恢复前必须审查版本、许可、网络上传和资源消耗,不自动下载或提权。未来 FlexMaster 默认同样应用安全策略。暂不承诺稳定 API/Schema 兼容性;新格式须先有版本及迁移文档。 + + +## Reliability batch — 2026-11-01 (implemented, untested) + +`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs. + +Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot. + +Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation. + +Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet. + + +## 资源检测第三组(2026-11-01:已编写,未测试) + +- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。 +- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。 +- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。 +- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。 +- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。 + +限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。 + +尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。 + +实现语义参考 Linux 内核官方文档: + +- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max) +- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒) +- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段) + +下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。 + + +## 双栈网络第四组(2026-11-01,已编写但未测试) + +新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。 + +分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。 + +增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。 + + +## 基础安全第五组(2026-11-01:实现未测试) + +新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。 + +更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。 + +新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..076b87f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,110 @@ +## 1.0.0b1 — 2026-11-01 公测候选,非最终版本 + +终端、语言和参数修复;流媒体改为可达性;新增可选 ICMP/TCP、三城三运营商追踪、BGP 和候选节点探测。完整大陆三网未验证。详见 PUBLIC_BETA_CHANGE_REPORT.md。 + + +## v0.1.0-alpha 候选(2026-11-01) + +隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。 + + +## 报告与IP质量更新(2026-11-01) + +- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。 +- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。 +- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。 +- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。 +- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。 +- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。 +- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。 +- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。 + +证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。 + + +## 最新双机验收(2026-11-01) + +同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。 + + +## Ubuntu 24.04 实测更新(2026-11-01) + +当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。 +中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。 +DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。 +综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。 +完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。 + +# Changelog + +## Resource display correction — 2026-11-01 (untested) + +Resource output now prioritizes process CPU bounds and visible cgroup memory +limits. Raw OS CPU/RAM observations remain detailed JSON evidence rather than +being presented as dedicated VPS capacity. Recognized containers without a +visible memory limit show unknown allocation instead of host MemTotal. Allocation +basis and unknown CPU/memory dedication are explicit. Container markers are +best-effort evidence, not complete virtualization detection. No local metric +certifies provider-side exclusive CPU or reserved memory. Added a container +fixture case; no tests executed. + +## Unreleased — 2026-11-01 + +- 范围更新为独立 Python VPS CLI,不开发代理部署和 Web UI。 +- 按任务书逐条登记 101 项需求,记录源码、验证方法及限制。 +- 新增架构演进、五个参考项目入口源码/许可证研究和测试计划。 +- 综合流程移除自动 SHA-256 基准,默认跳过带宽测试;CLI --allow-bandwidth / Python allow_bandwidth=True 显式许可才启用测速。 +- 暂停 NodeQuality/BestTrace/RegionRestrictionCheck 包装入口,避免常规流程执行未经资源/上传审查的第三方程序。 +- 保留现有 0.5.0 代码历史,在 engineering/safe-cli-core 分支修改。 + +验证状态:用户要求本轮可先不测试;未运行自动测试、虚拟机或实网测量。模块故障隔离、增量保存、统一结构化接口和其他 backlog 项尚未完成。版本号保持 0.5.0,未发布。 + + +## Reliability batch — 2026-11-01 (implemented, untested) + +`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs. + +Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot. + +Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation. + +Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet. + + +## 资源检测第三组(2026-11-01:已编写,未测试) + +- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。 +- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。 +- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。 +- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。 +- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。 + +限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。 + +尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。 + +实现语义参考 Linux 内核官方文档: + +- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max) +- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒) +- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段) + +下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。 + + +## 双栈网络第四组(2026-11-01,已编写但未测试) + +新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。 + +分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。 + +增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。 + + +## 基础安全第五组(2026-11-01:实现未测试) + +新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。 + +更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。 + +新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。 diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md new file mode 100644 index 0000000..4e9247a --- /dev/null +++ b/CURRENT_STATUS.md @@ -0,0 +1,13 @@ +# 当前状态:1.0.0b1 · P0 修复候选 + +2026-11-01;非最终版本,未推送、合并或公开发布。 + +- 已读取并核实上轮 22.04 Desktop 的原始报告:22 项中 21 通过,字节码缓存检查失败。 +- BETA22-001 已修复:安装器、提供的 shell 启动器、联网 worker、DNS 和邮件子解释器均显式带 -B,不依赖父解释器或被 -I 忽略的环境变量。 +- 增加五种实际 worker 的全新源码副本回归,以及可写负对照。开发机 277 通过、1 项 Windows 权限跳过。 +- 用户已调整本轮 22.04 方案:使用原 22.04 Desktop 虚拟机的新 HOME/全新安装目录;这不等于新装 Server 镜像。另一验收目标已确认是 24.04.3 Server。 +- 本轮要求从本轮 ZIP 校验/解压/安装,测试主要命令、缓存、异常、中断和配置不变。当前构建的完成情况以候选包旁 BETA_ACCEPTANCE_REPORT.md / BETA_ACCEPTANCE_RESULTS.json 中的 SHA256 与实际日志为准。 +- 旧 Alpha、初版 Beta 和第三方模型报告均为历史证据,不自动赋予当前构建通过状态。 +- 可选 ICMP/TCP 依赖、完整大陆三网、海外 VPS 等能力必须按实机报告的实际覆盖解释,不因入口已存在而标记完成。 + +缓存承诺覆盖提供的安装启动器、源码脚本及其 Python 子进程;外部不带 -B 的自行导入、pip 安装预编译不作无缓存承诺。没有删除历史缓存来掩盖结果,验收使用全新目录。 diff --git a/DEVELOPMENT_BRIEF.zh.md b/DEVELOPMENT_BRIEF.zh.md new file mode 100644 index 0000000..e5e1189 --- /dev/null +++ b/DEVELOPMENT_BRIEF.zh.md @@ -0,0 +1,411 @@ +# VPS 综合检测工具——完整开发与工程优化任务书 + +## 0. 项目背景与最终目标 + +我正在独立开发一个基于 Python 的开源 VPS 综合检测工具,主要面向中文 VPS 社区及 Linux 初学者。 + +当前已有初步完成的功能: + +- Linux 发行版、内核、CPU、内存、磁盘等基础信息检测。 +- Traceroute 路由跟踪及终端显示。 +- 现有代码已在 Ubuntu 22.04 Server 和 Ubuntu 24.04 Desktop 虚拟机环境中进行过测试。 + +以上完成情况是我的阶段性描述,请通过实际代码和测试重新核实,不能仅凭我的描述认定全部测试通过。 + +本项目的目标不是简单复制其他 VPS 检测脚本,而是开发一个: + +**自主实现为主、安全透明、功能完整、结果准确、适合小白、可长期维护,并能被未来 FlexMaster 项目调用的 VPS 综合诊断工具。** + +项目现阶段只开发 Python CLI 检测工具,不开发代理部署功能和 Web UI。 + +未来 FlexMaster 将复用本项目的检测核心,但二者作为独立项目维护。 + +--- + +## 1. 开发原则(最高优先级) + +1. **自主实现优先。** 能够通过 Python 标准库或成熟 Python 依赖完成的功能,优先自主编写。 +2. **保留已有成果。** 不推倒重来,不重复开发已经稳定实现的本机检测与 Traceroute。 +3. **避免重复造轮子。** 对磁盘基准、专业网络吞吐和高级路由诊断,可调用 fio、iperf3、mtr 等成熟工具。 +4. **合法利用外部资源。** 对地理位置、ASN、IP 风险、信誉信息等本机无法独立获取的数据,可使用合法的第三方 API 或数据库。 +5. **源码借鉴必须合法。** 先检查 LICENSE、版权声明及第三方依赖许可,不未经确认直接复制 GPL/AGPL 项目的实现代码。 +6. **检测结果必须真实。** 不伪造延迟、丢包、IP 纯净度、流媒体解锁状态或测速结果。 +7. **默认安全。** 普通检测只读;性能、磁盘写入、持续测速及较大流量测试必须明确提示并取得用户确认。 +8. **单模块故障隔离。** 外部 API、网络、权限或依赖出错时,不能导致整个程序崩溃。 +9. **结构化优先。** 检测逻辑、数据模型、CLI 输出、报告导出相互解耦。 +10. **面向小白。** 最终实现一条命令启动,中文输出清楚,提供英文选项,并解释结果含义。 +11. **以证据判定完成。** 每个任务必须有对应实现、测试记录及验收说明。 +12. **不得擅自执行高风险动作。** 不自动修改 SSH、防火墙、路由、DNS、系统网络配置,不部署代理,不向外上传完整报告。 + +--- + +## 2. 需要解决的全部功能问题 + +请对以下十一类功能进行逐项审查、设计、实现及测试。 + +要求建立完整的问题追踪表,记录每项的状态: + +- 已实现且通过测试 +- 已实现但有缺陷 +- 部分实现 +- 尚未实现 +- 外部条件不足,待实机验证 +- 不适用,并说明原因 + +### 第一类:VPS 本机与硬件检测 + +- 准确识别 Linux 发行版、内核、CPU 架构及虚拟化类型。 +- 识别 CPU 型号、核心数、频率及实际资源配额。 +- 检测内存、Swap、磁盘容量与资源限制。 +- 检测 CPU steal、内存压力、资源争用。 +- 实现可重复、可比较的 CPU 性能基准。 +- 检测磁盘吞吐、IOPS、读写延迟,严格限制磁盘写入和占用。 +- 支持用户输入购买规格,并对比实际可见配置;无法核实时明确说明。 + +### 第二类:基础网络与 DNS + +- 检测公网 IPv4、IPv6、默认路由和网络接口。 +- 分别验证 IPv4/IPv6 外网连通性。 +- 测量 DNS 解析时间、成功率和错误类型。 +- 区分 DNS、网络路径及目标服务故障。 +- 测试 TCP 连接、TLS 握手与 UDP 连通性。 +- 检查 MTU/PMTU、双栈异常与 IPv6 连接问题。 +- 正确处理 NAT、容器、多网卡环境,避免误判公网出口。 + +### 第三类:Traceroute、BGP 与线路分析 + +- 保留现有 Traceroute 核心代码并完善。 +- 支持可行的 ICMP、UDP、TCP 探测方式。 +- 显示逐跳 IP、RTT、超时及具有明确统计依据的丢包数据。 +- 查询逐跳 ASN、运营商、推测地区。 +- 避免将不响应的路由器错误判断为线路故障。 +- 避免将单个中间节点的高延迟直接认定为拥堵。 +- 检测中国电信、联通、移动相关线路特征。 +- 支持 BGP、上游网络与 ASN 信息分析。 +- 明确区分 VPS → 中国与中国 → VPS 的测试方向。 +- 地理位置只显示推测结果,不声称是真实物理线路。 +- 无中国侧测试节点时,不伪造中国到 VPS 的去程检测。 + +### 第四类:IP 地理位置、纯净度与信誉 + +- 查询国家、地区、城市、ASN、运营商。 +- 识别住宅、机房、移动网络等 IP 分类。 +- 查询代理、VPN、Tor 及相关风险标签。 +- 支持多个数据源交叉验证与冲突提示。 +- 查询公开黑名单、DNSBL、邮件信誉。 +- 检测 PTR 等 DNS 邮件相关信息。 +- 不使用缺乏依据的绝对“纯净度分数”。 +- 处理 API 限速、失效、付费要求和服务条款。 +- 每条结果记录来源、查询时间、可信度及限制。 + +### 第五类:流媒体与在线服务可用性 + +- 检测 Netflix、Disney+、YouTube 等服务。 +- 检测 ChatGPT 等在线服务的网络与地区可用性。 +- 区分网络可达、地区识别、受限、需要登录、无法确认及实际播放成功。 +- 检测 DNS、TLS、HTTP 错误及服务端限制。 +- 禁止将 HTTP 200 直接判定为成功解锁。 +- 处理网站检测规则变化、反爬限制和误报。 +- 明确声明账号权限、订阅资格和实际播放能力可能无法自动验证。 +- 支持用户自定义合法检测目标。 +- 不尝试绕过账号验证、反爬或访问限制。 + +### 第六类:多地区测速与长期稳定性 + +- 研究可信的全球及中国不同运营商测试节点。 +- 测量延迟、抖动、丢包、下载和上传速率。 +- 严格区分测试方向、节点地理位置与节点运营商。 +- 限制带宽、并发、执行时间及流量消耗。 +- 识别测试节点瓶颈,避免假性低速。 +- 支持可中断的 24/48 小时低负载监测。 +- 分析晚高峰性能劣化、间歇性断线、突发丢包。 +- 关联 CPU steal、内存压力及网络质量的时间变化。 +- 中断监测时安全退出并保留已有数据。 + +### 第七类:基础安全体检 + +- 只读检查 SSH 配置和潜在高风险认证方式。 +- 检查监听端口及可能暴露的服务。 +- 识别系统防火墙状态,说明云安全组无法从本机完整确认。 +- 检查系统更新状态及基础安全设置。 +- 检测重要文件权限风险。 +- 避免把正常业务配置直接定义为漏洞。 +- 提供有依据的修复建议,但不自动执行。 +- 避免泄露密码、Token、私钥及其他敏感信息。 + +### 第八类:报告、诊断与历史记录 + +- 设计统一的结构化检测结果 Schema。 +- 区分成功、失败、跳过、不支持和无法判断。 +- 保存检测时间、方法、来源、错误与置信度说明。 +- 将技术指标转换成普通用户能理解的中文解释。 +- 提供基于规则和真实测量结果的诊断建议。 +- 支持本地 JSON、Markdown 报告,必要时提供 HTML。 +- 对分享用报告进行脱敏。 +- 上传失败、断网或意外退出时不丢失已有报告。 +- 支持历史记录及不同时间、不同服务器的对比。 +- 生成 VPS 购买验收报告,区分实际测量与用户提供的购买规格。 +- 不生成未经校准的绝对性能评分。 + +### 第九类:安装、兼容性与用户体验 + +- 实现面向小白的一键安装与运行。 +- 检查 Python 版本、依赖及权限。 +- 使用虚拟环境隔离依赖,不污染系统 Python。 +- 优先兼容 Ubuntu 22.04 Server、24.04 Desktop。 +- 评估其他发行版、容器、ARM/x86 架构兼容性。 +- 提供中文和英文输出,处理终端编码问题。 +- 尽可能支持非 root 检测。 +- 缺少依赖时友好提示、降级或跳过。 +- 提供帮助、参数说明、更新、卸载和离线说明。 +- 安装脚本可审查,采用可信来源及版本固定/完整性验证。 +- 不要求用户盲目执行来源不明的 curl | bash。 + +### 第十类:可靠性、隐私与自动化测试 + +- 验证默认检测确实不修改系统配置。 +- 区分普通检测与高负载、有写入的基准测试。 +- 对运行时间、CPU、磁盘、流量设置安全上限。 +- 处理超时、网络失败、权限错误和依赖缺失。 +- 保证模块故障隔离。 +- 处理 Ctrl+C、安全取消与临时文件清理。 +- 防止命令注入、路径问题、终端控制字符污染和敏感日志泄露。 +- 使用 Mock、单元测试、集成测试和异常场景测试。 +- 在两种 Ubuntu 虚拟机及真实 VPS 上验证。 +- 建立安全的社区错误反馈方式。 +- 检查第三方许可证、依赖安全和 API 使用条款。 + +### 第十一类:开源维护与未来 FlexMaster 集成 + +- 每个检测模块既能由 CLI 使用,也能通过 Python API 调用。 +- 检测核心与终端 UI 解耦。 +- 为未来 FlexMaster 设计稳定、可复用的接口。 +- 完善 README、安装说明、贡献指南及测试文档。 +- 提供标准 GitHub Issue 模板和脱敏日志机制。 +- 明确支持系统、已知问题和未验证环境。 +- 建立外部贡献代码审查与自动化测试规范。 +- 为开源发布准备合适的 LICENSE、依赖声明及第三方版权通知。 + +--- + +## 3. 参考成熟项目,但不能直接拼接它们 + +请实际阅读以下 GitHub 仓库的相关源码、目录结构、检测逻辑和许可证文件: + +1. YABS + https://github.com/masonr/yet-another-bench-script + +2. Backtrace + https://github.com/oneclickvirt/backtrace + +3. 融合怪 ECS + https://github.com/oneclickvirt/ecs + +4. NodeQuality + https://github.com/LloydAsp/NodeQuality + +5. OneClickVirt + https://github.com/oneclickvirt/oneclickvirt + +重点研究: + +- YABS:fio、iperf3、基准流程及结构化结果。 +- Backtrace:三网回程、ASN、线路识别。 +- 融合怪:多模块综合检测与功能覆盖。 +- NodeQuality:环境隔离、报告组织、异常恢复。 +- OneClickVirt:自动化工程组织及兼容性测试。 + +必须实际检查相关源码,不仅阅读 README。 + +每项借鉴方案要说明: + +- 项目与源码链接 +- 具体文件、函数或调用入口 +- 采用何种实现方式 +- 采用原因 +- 许可证及版权义务 +- 可能涉及的 API 条款或费用 +- 安全风险及兼容性问题 + +如果可以自主开发,请优先自主实现。确需借助已有项目时,优先选择通过公开接口、系统命令或兼容许可证的独立依赖实现。 + +不要未经授权复制 GPL/AGPL 代码;不要认为调用外部程序就自动豁免全部许可证义务。必要时明确标记为待确认。 + +--- + +## 4. 与现有竞品的竞争策略 + +请参考融合怪和 NodeQuality 的功能覆盖,但不要只追求更多测试项目。 + +我们的重点竞争优势为: + +**A. Safe by Default** +- 只读优先 +- 低负载模式 +- 资源限制 +- 错误隔离 +- 数据本地优先 +- 不修改 SSH、防火墙及网络配置 + +**B. Explain, Not Just Measure** +- 用中文说明检测结果 +- 提供数据来源和可信度 +- 解释风险与异常 +- 给出明确的下一步排查建议 +- 不编造检测结论 + +**C. Compare Over Time** +- 本地检测历史 +- 24/48 小时稳定性监测 +- 晚高峰异常分析 +- 多 VPS 比较 +- 购买验收报告 + +请在竞品研究中区分: + +1. 竞争项目已经实现的能力。 +2. 竞争项目部分实现或存在局限的能力。 +3. 我们能够通过实际工程实现证明的优势。 + +不允许仅凭宣传描述声称优于竞争项目。 + +--- + +## 5. 技术架构与实现要求 + +保持 Python 为核心技术栈。 + +推荐采用以下逻辑分层,可以根据现有代码合理调整: + +- Core:模块调度、配置、数据模型、异常处理。 +- Checks:系统、网络、路由、IP、流媒体、安全等检测。 +- Benchmarks:CPU、磁盘和吞吐性能基准。 +- Providers:外部 API、数据库、测速节点接口。 +- Diagnostics:解释规则和异常诊断。 +- Reports:JSON、Markdown、HTML、脱敏和历史比较。 +- CLI:终端交互、语言切换和进度展示。 +- Tests:Mock、单元、集成与回归测试。 + +要求: + +- 模块可以独立运行。 +- 外部依赖允许替换。 +- 接口返回结构化对象,不依赖解析 CLI 文字。 +- 所有网络请求具有明确超时。 +- 默认测试不要求 root。 +- 高风险或高负载操作使用显式确认机制。 +- 使用固定版本或受控版本范围管理依赖。 +- 提供稳定的数据格式与错误编码。 +- 保持未来与 FlexMaster 的接口兼容性。 + +--- + +## 6. Work 的执行流程 + +### 第一步:代码审查与现状确认 + +先查看我现有项目代码、Git 状态、测试、依赖和目录。 + +识别哪些功能已经存在、哪些功能不稳定、哪些功能尚未开发。 + +不得覆盖已有成果。 + +### 第二步:建立问题追踪表 + +把上面十一类需求全部登记。 + +为每一项标注: + +- 编号及问题描述 +- 当前代码位置 +- 实际完成状态 +- 建议实现方法 +- 所需依赖/API +- 测试方法 +- 风险及限制 +- 完成证据 + +不要单纯因为某个功能名称出现在代码里就标记完成。 + +### 第三步:技术方案与开源研究 + +针对未实现或不可靠的功能,先比较自主实现、Python 依赖、系统工具及第三方 API 方案。 + +涉及 GPL/AGPL 代码复制、付费 API、上传用户数据或改变原有架构的重大决定时,先征求我确认。 + +### 第四步:逐模块开发 + +完成代码实现、测试、必要文档和错误处理。 + +优先复用现有组件,避免一次性进行无关的大规模重构。 + +### 第五步:测试与验证 + +在有权限的隔离测试环境中执行安全测试。 + +对于 Ubuntu 22.04 Server、24.04 Desktop,以及真实 VPS 的测试,分别记录结果。 + +没有真实 VPS 环境时,通过 Mock 和有限的本地测试推进,但必须注明尚未实机验证。 + +禁止擅自在生产 VPS 上运行高负载测试。 + +### 第六步:汇总与交付 + +输出真实完成情况和未解决问题。 + +不要为了交付而将模拟、跳过或不确定的检测伪装为通过。 + +--- + +## 7. 需要创建和维护的项目文件 + +建议在仓库中维护: + +- `PROBLEM_BACKLOG.md`:全部问题及实际状态 +- `ARCHITECTURE.md`:模块架构与数据接口 +- `OPEN_SOURCE_RESEARCH.md`:开源项目源码研究与许可证分析 +- `TEST_PLAN.md`:测试场景及验收标准 +- `SECURITY.md`:安全边界、隐私和漏洞报告 +- `CHANGELOG.md`:已经完成的实际更新 +- `README.md`:面向用户的使用说明 + +如果文件已存在,应审查并更新,不要无故覆盖。 + +--- + +## 8. 最终验收标准 + +我希望最终获得的不是只有功能菜单的半成品,而是实际可运行、可信的工具。 + +验收时需要证明: + +1. 十一类问题都有明确状态及依据。 +2. 声称完成的功能具有相应测试证据。 +3. 常规扫描不会擅自更改服务器网络和安全配置。 +4. 基准和大流量测试有明确授权及资源限制。 +5. 一个模块失败不影响其他模块运行。 +6. 结果可在终端查看、导出并安全保存。 +7. 第三方数据具有来源和不确定性说明。 +8. 用户能够理解检测结果及异常原因。 +9. 项目能够通过合理的安装流程运行。 +10. 代码模块可被未来 FlexMaster 独立调用。 +11. 开源借鉴内容具备合法的版权和许可证处理方案。 +12. 未验证的功能不得宣传为已经完成。 + +## 9. 本次工作授权边界 + +请先完成现有代码审查、问题登记、开源源码研究及架构分析。 + +完成这些后,在隔离分支中逐模块推进低风险开发和自动化测试,保留原有代码及 Git 历史。 + +**未经我进一步确认,不要执行生产 VPS 测试、安装系统级依赖、修改防火墙/SSH、使用付费 API、上传检测数据、合并主分支或公开发布。** + +每完成一组实质性工作,请向我汇报: + +- 实际完成了什么 +- 修改了哪些文件 +- 测试通过了哪些项目 +- 哪些仍然存在问题 +- 下一步建议 + +最终目标:建立一个具有独立代码价值、可信检测结果、安全工程设计,并能持续维护的 Python VPS 综合诊断工具。 \ No newline at end of file diff --git a/IPQUALITY_COVERAGE.md b/IPQUALITY_COVERAGE.md new file mode 100644 index 0000000..5d07552 --- /dev/null +++ b/IPQUALITY_COVERAGE.md @@ -0,0 +1,20 @@ +# IPQuality 风格功能覆盖与外部资源 + +2026-11-01。目标是借鉴清楚的分区展示,优先自主实现,不直接运行或拼接第三方脚本。 + +| 分区 | 当前代码 | 限制/后续 | +|---|---|---| +| 顶部概览 | IP、ASN、运营商、估计位置、网络类型、风险标签、来源冲突数、覆盖率、时间 | 默认隐藏IP;没有取得风险标签不等于安全;不是综合纯净度评分 | +| 多数据库依据 | ipapi.is、proxycheck.io、IP2Location、可选AbuseIPDB;来源类型/代理/VPN/Tor对照 | 缺字段/密钥保留未知;并未覆盖上游所有数据库 | +| 流媒体/在线服务 | suite下独立模块,保留限制、片库、未知等证据 | 账号、DRM和实际播放未验证;IPQuality本身不作为我们的数据API | +| 邮件网络 | `ipquality --mail-probes` / `suite --mail-probes`;PTR、正向一致性、单目标出站TCP/25 | 默认跳过额外探测;不发邮件;不证明入站25、全部邮件服务或投递信誉 | +| DNSBL | 明确显示未查询 | 每个来源的使用条款、resolver限制、查询控制地址、NXDOMAIN/限流/错误码需要先实现,不能把空响应当干净 | +| IPMap/定位地图 | 尚未接入 | 待选择具体来源并核对公开API/许可/费用/查询隐私;不会自动抓取需要登录或限制访问的网页 | + +## 实际源码研究及许可 + +阅读 [xykt/IPQuality LICENSE](https://github.com/xykt/IPQuality/blob/main/LICENSE) 和 [ip.sh](https://github.com/xykt/IPQuality/blob/main/ip.sh):包括邮件端口与 `check_dnsbl_parallel` / `check_dnsbl` 入口及结构化邮件结果组织。根许可证为GNU AGPL v3文本。我们没有复制其实现代码、数据库清单或服务网页抓取逻辑;报告布局和检测需求作为设计参考,检测代码自主编写。 + +该入口的DNSBL分类依赖查询返回内容。我们不会照搬“空结果即干净”之类假设,必须区分查询失败、服务拒绝、未列出及命中,再考虑显示统计。也不会认为调用外部程序就自动免除许可责任。若以后确需引入AGPL实现或分发其组件,先单独评估并征求用户确认。 + +外部资源可通过受控API适配器、本地合法数据库或明确可选的查询链接接入。每条来源需记录查询时间、来源版本/地址、字段、错误、限制及费用。网页查询链接若携带公网IP,应在用户明确选择时才打开。未具备的资源统一列待接入,不能伪造地图、数据库结果或风险分数。 diff --git a/MANIFEST.sha256.json b/MANIFEST.sha256.json new file mode 100644 index 0000000..808cc10 --- /dev/null +++ b/MANIFEST.sha256.json @@ -0,0 +1,83 @@ +{ + "version": "1.0.0b1", + "files": { + "install.py": "c0f1f65b129c92beddadb3697b8bec4cadc99a1f982bd7a1b1aa5b5cc992c762", + "pyproject.toml": "ebca11d7c070a9ab2f565178cd78da96fe303b9eeee4a14eccc8beb99e0a1922", + "proxydoctor.sh": "7b5ccc2c96e36b4f6d53dd54915bc90416aba9ace06738e0c1913cf887b92972", + "LICENSE": "4538e62067151c3795abf12838a4799f54ef6546a2a8a1697abd1ce21b03336d", + ".gitattributes": "1cbf58d18ccb72113d6d1bb3996400d557ccc83cda00a11cd63ef5ca38bbf199", + "ALPHA_RELEASE_REPORT.md": "f4efc245bcec8f78d1bc7a925dc922271d7b3705f43be6b7611f8d6547af0300", + "ALPHA_TASK_STATUS.md": "faec6e515e8941b70b65774e36fb98b10c7b3f1284096bf186fe3345f3b7db7f", + "ARCHITECTURE.md": "2d0167efe06df1eb10f0386b3a48c743fcd555a57e7a5247f1af24e6d2524701", + "CHANGELOG.md": "98fc7cb3d1ba9f39680f5cbda1511340af05af35f8d3b12eaf2d213bce573e88", + "CONTRIBUTING.md": "45fa883a73cd267a732bfd99e542f736c975a1e11ac9075c127262a4500de902", + "CURRENT_STATUS.md": "735e4bdef596fe4f25780225b30e975998b4cd1061aa091da673d07d8c85c5e0", + "DEVELOPMENT_BRIEF.zh.md": "c80df4f88d60decdacb29864d48760a3517f1016bdb0ba784749365116affc6e", + "IPQUALITY_COVERAGE.md": "342c4919b0ba7f05699f16526e8d0153b91e7e1c38876451019cd9ee5f1613c7", + "OPEN_SOURCE_RESEARCH.md": "5ae3b119e93a0f7164a50bfd0e65f3c811285c04d626c25d02e950bd9b75d432", + "PROBLEM_BACKLOG.md": "a65378b9572aa8e703bbf94df26bd0b8e80b36b1d0249962ed5b1ccfbc309419", + "PUBLIC_BETA_CHANGE_REPORT.md": "abe72da4c576e02089e4e564c5f88fd81e5c3034347ca48c2bcb981d0f0dcca4", + "README.en.md": "5cd2e7d51221b8290e4c81ca4bbd0d6eb5ff9d38ee9b5e50e8ed43693ca73d64", + "README.md": "be116592cf5814c5a3ec9521f7b2fb691ca2c4bde62f4bfd649722086d4e1452", + "SECURITY.md": "5b55903153973d4413d53b4715d71df677df20cde4506bac5bc866778b654838", + "TEST_PLAN.md": "6036d77b05958e9c088aa9605d3b78986ab3b7ded42abcf65f53118099d9e490", + "THIRD_PARTY_NOTICES.md": "b1f24279e2870c4917b85b220a42d1ae31b6e39452cd1aa8dcb55425b0d6a6d1", + "USER_CHECKLIST_STATUS.md": "fb983deeca9a1a4b2e219b95163a9e8f0ba02bca7cea778f96455603f2b29df2", + "proxydoctor/__init__.py": "e86da25d9f8a2028fe7ece956847b17b4cb82f127ba356aa6ac28b5cdbe89855", + "proxydoctor/__main__.py": "f21b83731acbaa16519e17fa795601ccd3543400cd5c59bb40098a488c225d84", + "proxydoctor/bandwidth.py": "199a350483c53467cc341b521951e7a533ab04d5655dd8ceb2da94ad85653d34", + "proxydoctor/bgp.py": "4c9fea00e616058700f73335fe1995764b85d610a89b22936b4e5dd9f563c0c6", + "proxydoctor/carriers.py": "3838b06354e68f57aa514ba4570be087489582821e530a4f2948a15598712295", + "proxydoctor/checks.py": "f010c09c834af2f4ac48128113bb5aebef9d1fb0c796891623d02232ccf09e31", + "proxydoctor/cli.py": "0a53081310bdc76cc57fc7ef75c645a0f8efee04fa03c4a463ca75b7ddd7e143", + "proxydoctor/connectivity.py": "2b6d4cfe69271ad582c3b0eb81bc69d38a9395c80cc12df2804bd01854f22385", + "proxydoctor/console.py": "c3de3fd95509a701df296c5d9c7bc167fe39e7b557b505efe477180283508948", + "proxydoctor/geo.py": "9547a5e7da6d92d62df0fd5977d39384fcb5c745fac7efed93d283ae88074643", + "proxydoctor/i18n.py": "d9f619c0b0e85ef7295aec39bf1a94b1c43d7f19d5fd0fe86f438e84d05f89b1", + "proxydoctor/ipquality.py": "4dd1fc1421d11d03d8c803626393ca02d7be81527d4984da57c9ef2140c395bb", + "proxydoctor/mailquality.py": "ae1ad2e071bd7038b6ea7fbcbd63b7128eac9e5477f79f43c22172d6a5bb5071", + "proxydoctor/media.py": "b0c32211ed5a1b1ba37aac6cd31725be40ec0c2dbe1a5acbaf54a4e1d961229d", + "proxydoctor/net.py": "ed53a91e7e048c3e4850b6f72d0d90c74dc8229dc2a36cb2deadf0071df7b68c", + "proxydoctor/quality.py": "2e06f0d43cb95909fe4f14f08b39567b94cc1ee4ee03338fb8885fe1ec9fc878", + "proxydoctor/reporting.py": "2689e39fe1c1f4f07ac43e3b7b564de34cb36d20e56669ab30c20923f27c727d", + "proxydoctor/reports.py": "09bd0952c89397ef601a3421e563bdad1e74b17d07976dfcb4066a0353cea4ba", + "proxydoctor/resources.py": "b2b7355ef8f09a16d28956e292dfb848c2ddc927c3773730c437cf53de4a09a4", + "proxydoctor/routes.py": "9bec6db8ce78ffa34c41931f7d388e9ac6d59ce52fed5d442371efe844d426ae", + "proxydoctor/runner.py": "84462cb56bf335140a3aec08ace9c80dd745a9aeea99be618ac4483cb3430c8e", + "proxydoctor/schema.py": "f80098d2dbf0334d460e47754a429a7449532fdd6aa69420ed275edebd5f8830", + "proxydoctor/security.py": "363327e43ab13dc8ebb9bb88f49fd14cea4aedca0952e9733159f99a195bb019", + "proxydoctor/session.py": "e72383becc82015b4a2ea5a8a6aa535a6b6e6bba90b94f29117346d9a9b0acf7", + "proxydoctor/suite.py": "e0f608e71669c7dc92a659ee0a8f083569a640cd83aeaa15077308da68b73c49", + "proxydoctor/worker.py": "8dcabf06c2604f846cc8fe5b09a392ff782c30fc7145f6befeef733133ac4eb3", + "tests/conftest.py": "9ba6f678d6297f2dc85f42121790246cd86b120dd52e537ee09d4c19f2cdf1b9", + "tests/test_alpha_quality.py": "a833be4ec7f0600383495b838ea1e95f50112003d47b2cc2cebbec50d0eba706", + "tests/test_bandwidth_sources.py": "c9e41f7f3d25078feef335a549fb8a3229662d552b552f0579369ae61cfc659b", + "tests/test_bytecode_policy.py": "b66e5358d5e7adf36d5413b776e494d8c66d1dda368d259a79a02f0ea622d657", + "tests/test_checks.py": "4ff2ff8562a13635d24cf6a29dd7063578bcc1a202840aeb76c7619c06cacfb3", + "tests/test_connectivity.py": "e4cea940bf3783e312f7acf28b80c7fc709579e3809a415a5822b05431d6fde1", + "tests/test_console_geo.py": "24d91b4abf292f666fde27244099bab7d52afff99b835c2fda26bd73a86aba03", + "tests/test_i18n.py": "48e1615334c1c1b19a872bf11067e7e38f78ece60eb777d3cc4ad7661cef1c12", + "tests/test_ip_panel.py": "817b27b6eb96e91b370418fe1b7e4625253d77091e87e8bf55e8077781044570", + "tests/test_mailquality.py": "87907932a22a423ec8134bb60ebf29523019b4c91ae774ab2c2fb65dd5113c44", + "tests/test_measurements.py": "d2dfa5d3273a662c6d343b163e449d0baae85ebb3e81d94e75e6882109cd5a04", + "tests/test_media.py": "cd97e7d0ac600847ecd91ea0be5a2b1eb1dca573c9f25e697e0529d676351139", + "tests/test_nodequality.py": "444eeb659e3bd70e67bb11b89df9de9c856609a102a25036c59b5947a2bd94f1", + "tests/test_phase1_delivery.py": "4d83d7464dffbf7eedde97a2750eb93140ebb4c344306c7bcdc76332a2966da9", + "tests/test_public_beta.py": "9a8f62bb4c199e2c3bf9a7c639c7180d1367825e76eb96f33e58af10925d394a", + "tests/test_quality.py": "a7afc7a3794032f4a9bc53ab8385188168f1fe96615ae2cac1b2d0eaf0b72561", + "tests/test_recovery.py": "05803bfbc5de22c9a974335d0d67554af98e082568b91ac542ff5035a47434c2", + "tests/test_reporting.py": "9d22378138cecf3a3ec2db52862002d0c0ccd0905785c6a922cc1b5e87bda762", + "tests/test_resources.py": "0276cace908748f7acf76bc2beba527c37c564e090fe18ac8c2cd9371615de88", + "tests/test_review.py": "9a41c686dd280d041929ca2b465f4b738e05765cfc4289ca719a16865d7c1700", + "tests/test_routes.py": "68074a666bc29ac92b8b1ba1f985a12b6949e64cf3c22b20789fa4dc98eab5ed", + "tests/test_security.py": "49e5b4a02b6d9d08a57265d5fa7311331b3041e1403d6883a42c1728dc1564fa", + "tests/test_server_info.py": "52bd0f409cc01ffaabb3fde25a0781dab286065d475f2c639ed586b89b5608c2", + "tests/test_stage1.py": "9413983d55805a663d5b831b64146f191846192788ff014f4db256c5f68d3b9a", + "tests/test_suite.py": "61c83fe1bdb54deea884120ae8f9cc17373cdbe2f27bc84b600f86e614b1b5cb", + "docs/history-beta-before-p0-20261101.md": "e5dcc486181d3590905aa4ec7e181f0c3a7b7cb253c47578fae475b36094bb6c", + "docs/pre-alpha-README.en.md": "82922b373bd7d0ede200368a6a3db68339678ca27bea8554cd41324d65d4a3ea", + "docs/pre-alpha-README.md": "0d7819c12bc688f52e765c8bc6827198772548663dcb9f7adb26db59c79ed73d", + "docs/status-history-before-2026-11-01.md": "f50bd7cb60fdaac5e2e5c14105fa08e5b19b1f354ae9414732b161d31b8665fd", + ".github/workflows/tests.yml": "ba27ec460fd71a2f9bbac20be0aa5b7043a14a04ae7c3ae0eaf5237cd72b4b5c" + } +} diff --git a/OPEN_SOURCE_RESEARCH.md b/OPEN_SOURCE_RESEARCH.md new file mode 100644 index 0000000..f8e66ec --- /dev/null +++ b/OPEN_SOURCE_RESEARCH.md @@ -0,0 +1,17 @@ +# 开源源码研究(首轮) + +阅读日期:2026-11-01。通过 GitHub 连接器实际获取根目录、下列源码及 LICENSE;未运行参考脚本、未复制实现。链接为阅读时默认分支,可变化,实施任何复用前还需固定 commit 和复核依赖许可证。本轮是入口级研究,不宣称完成整个仓库审计。 + +| 项目 | 实际阅读文件/入口 | 源码观察与采用方案 | 根许可证及处理 | 风险、费用和后续工作 | +|---|---|---|---|---| +| [YABS](https://github.com/masonr/yet-another-bench-script) | [yabs.sh](https://github.com/masonr/yet-another-bench-script/blob/master/yabs.sh),本地 fio/iperf3 探测、FIO_BIN_TAG/IPERF_BIN_TAG、catch_abort、JSON 参数路径;LICENSE | 现有实现已有本地工具优先、基准跳过、JSON 和中断处理;我们计划独立编写本地工具适配器,消费其 JSON,不复制 shell。分别统计方法/节点便于比较 | 根 LICENSE 为 WTFPL v2;未来 fio/iperf3 的二进制、库及分发许可须单独核查,根许可不覆盖一切 | 脚本含下载和可选结果发送;本项目不采用自动下载/上传。测速节点条款和流量费用未逐项确认,不能默认集成 | +| [Backtrace](https://github.com/oneclickvirt/backtrace) | [cmd/main.go](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go):newBacktraceFlagSet、validateStructuredOptions、RunRouteReport 调用及 ResolveOriginASNWithConfig | 已有结构化路由、尝试次数、超时和可选 WHOIS/geofeed;计划自主建立 ASN 特征证据,保留现有 UDP 核心。中国目标只标为 VPS→中国方向 | Apache-2.0;如未来复用须保留许可、版权和适用 NOTICE,并注明修改;本轮无复制 | 入口包含外部访问;bk/bgptools 深层算法与数据库许可尚待审查。ASN 不代表具体城市或必经专线;外部服务限额/费用待核 | +| [ECS](https://github.com/oneclickvirt/ecs) | [goecs.go](https://github.com/oneclickvirt/ecs/blob/master/goecs.go):runStructuredCLI、legacyDeadlineWindows、sanitizeECSLog、main | 已有显式结构化 CLI 和编排/日志处理入口,不能声称这些是本项目独有。借鉴分阶段调度理念,自主实现 Python 故障隔离和预算 | GPL v3 文本;不复制其实现;如果未来引入须先评估衍生/分发义务并征求用户确认 | 多模块和外部组件需逐个审核,内部实现尚未逐项研究。数据服务条款及费用待核实;不能以根仓库许可替代依赖审查 | +| [NodeQuality](https://github.com/LloydAsp/NodeQuality) | [NodeQuality.sh](https://github.com/LloydAsp/NodeQuality/blob/main/NodeQuality.sh):load_bench_os、chroot_run、clear_mount、sig_cleanup、upload_result、main | 有临时系统、分模块选择、信号清理和报告组织;main 调用 upload_result。只借鉴模块组织和清理思路,采用本地报告、非 root 检测;现有包装入口暂停 | AGPL v3 文本;不复制代码。不因采用外部进程就认定豁免许可责任 | chroot/mount/swap 与远程脚本执行不符合当前默认边界;上传报告需另行明确授权。不能仅依据“临时系统”认定对宿主无影响 | +| [OneClickVirt](https://github.com/oneclickvirt/oneclickvirt) | 根目录、[agent-regressions.yml](https://github.com/oneclickvirt/oneclickvirt/blob/main/.github/workflows/agent-regressions.yml):Go race 检查、OS matrix、Cargo locked 与 musl 目标 | 工程已有多环境回归和依赖锁定;借鉴 CI 组织方法,独立编写 Python Mock/Ubuntu 矩阵。该项目是控制面/Agent/Web 工程,不是检测核心的直接替代 | GPL v3 文本;不复制工作流或产品代码。后续引入任何组件单独审核 | 看到 CI 配置不证明运行通过;此工作流不证明所有 Ubuntu、ARM 或本项目兼容。无需接入其部署功能,外部服务费用不适用当前研究 | + +## 竞争判断 + +已观察到:YABS 的结构化输出、Backtrace 的结构化路由入口、ECS 的编排和日志入口、NodeQuality 的中断清理、OneClickVirt 的多环境 CI。因此不能以这些名称本身宣称优于竞品。上述源码审查也不足以证明竞品缺少某能力。 + +本项目可验证的候选优势:默认跳过基准、明确许可测速、保守地区证据、本地报告和历史比较。前两项本轮已修改但未测试;后两项部分存在或待开发,暂不作优越性宣传。API 条款、数据集许可、费用和深层实现仍须逐项研究后才能集成。 diff --git a/PROBLEM_BACKLOG.md b/PROBLEM_BACKLOG.md new file mode 100644 index 0000000..06d0c14 --- /dev/null +++ b/PROBLEM_BACKLOG.md @@ -0,0 +1,271 @@ +> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。 + +> 最新Alpha优先项状态见 [ALPHA_TASK_STATUS.md](ALPHA_TASK_STATUS.md)。以下包含本轮前历史统计,不能作为最新发布验收结论。 + + +## 报告与IP质量更新(2026-11-01) + +- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。 +- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。 +- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。 +- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。 +- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。 +- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。 +- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。 +- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。 + +证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。 + + +## 最新双机验收(2026-11-01) + +同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。 + + +## Ubuntu 24.04 实测更新(2026-11-01) + +当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。 +中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。 +DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。 +综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。 +完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。 + +# 全部需求追踪表 + + +更新:2026-11-01。范围:独立 Python CLI;不开发代理部署或 Web UI。 + +本轮按用户要求不执行测试。历史 outputs/ 验收报告属于 0.5.0,不能证明本分支修改通过。状态中“部分实现”只表示源码证据;未将存在函数名等同于验收。 + +优先级:P0 默认安全与保存/隔离 → P1 结构化本机、双栈网络和证据 → P2 路由/信誉/媒体与报告 → P3 基准、长期监测、安装与集成。 + +P0 本轮:综合流程移除自动 CPU 基准;带宽要求显式许可;暂停可上传数据的第三方脚本入口。实现未测试。保存/隔离已编写,待验证;最新汇总见 CURRENT_STATUS.md。 + + +## 第一类:VPS 本机与硬件检测 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 01-01 | 准确识别 Linux 发行版、内核、CPU 架构及虚拟化类型。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 | +| 01-02 | 识别 CPU 型号、核心数、频率及实际资源配额。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 | +| 01-03 | 检测内存、Swap、磁盘容量与资源限制。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 | +| 01-04 | 检测 CPU steal、内存压力、资源争用。 | 部分实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 | +| 01-05 | 实现可重复、可比较的 CPU 性能基准。 | 尚未实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 | +| 01-06 | 检测磁盘吞吐、IOPS、读写延迟,严格限制磁盘写入和占用。 | 尚未实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 | +| 01-07 | 支持用户输入购买规格,并对比实际可见配置;无法核实时明确说明。 | 尚未实现 | P1 | proxydoctor/checks.py; suite.py(源码审查;非通过证据) | 只读 /proc、cgroup v1/v2、PSI;CPU/fio 基准独立授权;标准库;可选 fio | tests/test_server_info.py; test_checks.py,逐项补缺并运行;本轮未执行 | 配额不等于物理资源;VM 不代表商家配置 | + + +## 第二类:基础网络与 DNS + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 02-01 | 检测公网 IPv4、IPv6、默认路由和网络接口。 | 部分实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 | +| 02-02 | 分别验证 IPv4/IPv6 外网连通性。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 | +| 02-03 | 测量 DNS 解析时间、成功率和错误类型。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 | +| 02-04 | 区分 DNS、网络路径及目标服务故障。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 | +| 02-05 | 测试 TCP 连接、TLS 握手与 UDP 连通性。 | 部分实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 | +| 02-06 | 检查 MTU/PMTU、双栈异常与 IPv6 连接问题。 | 尚未实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 | +| 02-07 | 正确处理 NAT、容器、多网卡环境,避免误判公网出口。 | 部分实现 | P1 | checks.py; quality.py; net.py(源码审查;非通过证据) | 分协议族 DNS/TCP/TLS/UDP 探针;PMTU 需注明权限与路径限制;socket/ssl;可选系统工具 | tests/test_quality.py; test_review.py,逐项补缺并运行;本轮未执行 | NAT 出口不代表入站;DNS 解析预算待核查 | + + +## 第三类:Traceroute、BGP 与线路分析 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 03-01 | 保留现有 Traceroute 核心代码并完善。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-02 | 支持可行的 ICMP、UDP、TCP 探测方式。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-03 | 显示逐跳 IP、RTT、超时及具有明确统计依据的丢包数据。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-04 | 查询逐跳 ASN、运营商、推测地区。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-05 | 避免将不响应的路由器错误判断为线路故障。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-06 | 避免将单个中间节点的高延迟直接认定为拥堵。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-07 | 检测中国电信、联通、移动相关线路特征。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-08 | 支持 BGP、上游网络与 ASN 信息分析。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-09 | 明确区分 VPS → 中国与中国 → VPS 的测试方向。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-10 | 地理位置只显示推测结果,不声称是真实物理线路。 | 部分实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | +| 03-11 | 无中国侧测试节点时,不伪造中国到 VPS 的去程检测。 | 尚未实现 | P2 | routes.py; geo.py(源码审查;非通过证据) | 保留 UDP;扩展多轮与 TCP/ICMP,ASN 来源独立记录;标准库;可选 mtr;ASN API | tests/test_routes.py; test_console_geo.py,逐项补缺并运行;本轮未执行 | 中间跳不响应不等于丢包;无中国探针不生成去程 | + + +## 第四类:IP 地理位置、纯净度与信誉 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 04-01 | 查询国家、地区、城市、ASN、运营商。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-02 | 识别住宅、机房、移动网络等 IP 分类。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-03 | 查询代理、VPN、Tor 及相关风险标签。 | 部分实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-04 | 支持多个数据源交叉验证与冲突提示。 | 部分实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-05 | 查询公开黑名单、DNSBL、邮件信誉。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-06 | 检测 PTR 等 DNS 邮件相关信息。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-07 | 不使用缺乏依据的绝对“纯净度分数”。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-08 | 处理 API 限速、失效、付费要求和服务条款。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | +| 04-09 | 每条结果记录来源、查询时间、可信度及限制。 | 尚未实现 | P2 | ipquality.py; geo.py(源码审查;非通过证据) | 保留分源风险;补 PTR 与允许使用的 DNSBL;现有三来源;可选密钥 API | tests/test_measurements.py; test_phase1_delivery.py,逐项补缺并运行;本轮未执行 | 来源条款/限额需逐项审核;风险分不可合并 | + + +## 第五类:流媒体与在线服务可用性 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 05-01 | 检测 Netflix、Disney+、YouTube 等服务。 | 部分实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-02 | 检测 ChatGPT 等在线服务的网络与地区可用性。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-03 | 区分网络可达、地区识别、受限、需要登录、无法确认及实际播放成功。 | 尚未实现 | P0 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-04 | 检测 DNS、TLS、HTTP 错误及服务端限制。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-05 | 禁止将 HTTP 200 直接判定为成功解锁。 | 部分实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-06 | 处理网站检测规则变化、反爬限制和误报。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-07 | 明确声明账号权限、订阅资格和实际播放能力可能无法自动验证。 | 部分实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-08 | 支持用户自定义合法检测目标。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | +| 05-09 | 不尝试绕过账号验证、反爬或访问限制。 | 尚未实现 | P2 | media.py(源码审查;非通过证据) | 保守证据状态机;自定义目标仅合法 HTTPS 且限响应;urllib/ssl | tests/test_media.py,逐项补缺并运行;本轮未执行 | HTTP 200 不等于播放;反爬/订阅保持未知 | + + +## 第六类:多地区测速与长期稳定性 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 06-01 | 研究可信的全球及中国不同运营商测试节点。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-02 | 测量延迟、抖动、丢包、下载和上传速率。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-03 | 严格区分测试方向、节点地理位置与节点运营商。 | 部分实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-04 | 限制带宽、并发、执行时间及流量消耗。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-05 | 识别测试节点瓶颈,避免假性低速。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-06 | 支持可中断的 24/48 小时低负载监测。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-07 | 分析晚高峰性能劣化、间歇性断线、突发丢包。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-08 | 关联 CPU steal、内存压力及网络质量的时间变化。 | 部分实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | +| 06-09 | 中断监测时安全退出并保留已有数据。 | 尚未实现 | P3 | bandwidth.py; quality.py(源码审查;非通过证据) | 显式流量许可;增加受控节点、可取消 JSONL 监测;HTTPS;可选 iperf3 | tests/test_bandwidth_sources.py,逐项补缺并运行;本轮未执行 | 载荷预算不含协议开销;节点瓶颈不代表线路 | + + +## 第七类:基础安全体检 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 07-01 | 只读检查 SSH 配置和潜在高风险认证方式。 | 部分实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | +| 07-02 | 检查监听端口及可能暴露的服务。 | 部分实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | +| 07-03 | 识别系统防火墙状态,说明云安全组无法从本机完整确认。 | 部分实现 | P0 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | +| 07-04 | 检查系统更新状态及基础安全设置。 | 部分实现 | P1 | security.py; checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_security.py; checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | +| 07-05 | 检测重要文件权限风险。 | 部分实现 | P1 | security.py; checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_security.py; checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | +| 07-06 | 避免把正常业务配置直接定义为漏洞。 | 尚未实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | +| 07-07 | 提供有依据的修复建议,但不自动执行。 | 部分实现 | P0 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | +| 07-08 | 避免泄露密码、Token、私钥及其他敏感信息。 | 尚未实现 | P1 | checks.py(源码审查;非通过证据) | 保留 SSH/监听/防火墙只读;补更新缓存和权限检查;标准库;系统只读命令 | tests/test_checks.py; test_review.py,逐项补缺并运行;本轮未执行 | Match 条件和云安全组无法完整推断 | + + +## 第八类:报告、诊断与历史记录 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 08-01 | 设计统一的结构化检测结果 Schema。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-02 | 区分成功、失败、跳过、不支持和无法判断。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-03 | 保存检测时间、方法、来源、错误与置信度说明。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-04 | 将技术指标转换成普通用户能理解的中文解释。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-05 | 提供基于规则和真实测量结果的诊断建议。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-06 | 支持本地 JSON、Markdown 报告,必要时提供 HTML。 | 部分实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-07 | 对分享用报告进行脱敏。 | 部分实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-08 | 上传失败、断网或意外退出时不丢失已有报告。 | 尚未实现 | P0 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-09 | 支持历史记录及不同时间、不同服务器的对比。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-10 | 生成 VPS 购买验收报告,区分实际测量与用户提供的购买规格。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | +| 08-11 | 不生成未经校准的绝对性能评分。 | 尚未实现 | P2 | checks.Result; suite.run_suite; cli.redact(源码审查;非通过证据) | 兼容旧 Schema;增证据模型、增量记录、Markdown、历史差异;标准库 JSON/SQLite | tests/test_stage1.py; test_suite.py,逐项补缺并运行;本轮未执行 | 目前结束才保存;脱敏不是匿名保证 | + + +## 第九类:安装、兼容性与用户体验 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 09-01 | 实现面向小白的一键安装与运行。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-02 | 检查 Python 版本、依赖及权限。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-03 | 使用虚拟环境隔离依赖,不污染系统 Python。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-04 | 优先兼容 Ubuntu 22.04 Server、24.04 Desktop。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-05 | 评估其他发行版、容器、ARM/x86 架构兼容性。 | 外部条件不足,待实机验证 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-06 | 提供中文和英文输出,处理终端编码问题。 | 部分实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-07 | 尽可能支持非 root 检测。 | 部分实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-08 | 缺少依赖时友好提示、降级或跳过。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-09 | 提供帮助、参数说明、更新、卸载和离线说明。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-10 | 安装脚本可审查,采用可信来源及版本固定/完整性验证。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | +| 09-11 | 不要求用户盲目执行来源不明的 curl | bash。 | 尚未实现 | P3 | proxydoctor.sh; pyproject.toml; README.md(源码审查;非通过证据) | 虚拟环境安装、版本校验、受控更新卸载;Python >=3.10;setuptools | tests/test_phase1_delivery.py; test_i18n.py,逐项补缺并运行;本轮未执行 | 现有脚本只启动,不隔离安装;ARM 未实测 | + + +## 第十类:可靠性、隐私与自动化测试 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 10-01 | 验证默认检测确实不修改系统配置。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-02 | 区分普通检测与高负载、有写入的基准测试。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-03 | 对运行时间、CPU、磁盘、流量设置安全上限。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-04 | 处理超时、网络失败、权限错误和依赖缺失。 | 部分实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-05 | 保证模块故障隔离。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-06 | 处理 Ctrl+C、安全取消与临时文件清理。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-07 | 防止命令注入、路径问题、终端控制字符污染和敏感日志泄露。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-08 | 使用 Mock、单元测试、集成测试和异常场景测试。 | 部分实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-09 | 在两种 Ubuntu 虚拟机及真实 VPS 上验证。 | 外部条件不足,待实机验证 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-10 | 建立安全的社区错误反馈方式。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | +| 10-11 | 检查第三方许可证、依赖安全和 API 使用条款。 | 尚未实现 | P0 | net.py; console.py; checks.Host; __main__.py(源码审查;非通过证据) | 模块隔离、全局预算、取消保存、终端与日志脱敏;Mock/pytest(开发依赖) | tests/,逐项补缺并运行;本轮未执行 | 修改后未测试;真实 VPS 尚缺 | + + +## 第十一类:开源维护与未来 FlexMaster 集成 + + +| 编号 | 需求 | 状态 | 优先级 | 代码/证据 | 实现/依赖 | 验证方案 | 限制 | +|---|---|---|---|---|---|---|---| +| 11-01 | 每个检测模块既能由 CLI 使用,也能通过 Python API 调用。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | +| 11-02 | 检测核心与终端 UI 解耦。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | +| 11-03 | 为未来 FlexMaster 设计稳定、可复用的接口。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | +| 11-04 | 完善 README、安装说明、贡献指南及测试文档。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | +| 11-05 | 提供标准 GitHub Issue 模板和脱敏日志机制。 | 部分实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | +| 11-06 | 明确支持系统、已知问题和未验证环境。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | +| 11-07 | 建立外部贡献代码审查与自动化测试规范。 | 尚未实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | +| 11-08 | 为开源发布准备合适的 LICENSE、依赖声明及第三方版权通知。 | 部分实现 | P1 | 现有模块函数; CONTRIBUTING.md; LICENSE(源码审查;非通过证据) | 稳定 Python API、文档与 Issue 模板、许可登记;标准库;无新运行依赖 | tests/,逐项补缺并运行;本轮未执行 | 核心仍与 UI 混合;不承诺已兼容 FlexMaster | + +共登记 101 项。每行验收前须补测量记录与精确证据;不得批量升级完成状态。 + +## P0 第二组实现记录(2026-11-01,未测试) + +- `runner.py`:六模块独立执行边界、固定错误码、取消透传、执行状态与时间。 +- `reports.py`:独占创建、脱敏增量 JSONL、flush/fsync、最终 JSON、恢复读取 API、存储失败降级。 +- `suite.py`:接入默认本地保存、Ctrl+C 保存/130、失败模块继续;`quality.py`/`routes.py`/`media.py` 增加结果保存回调。 +- `tests/test_recovery.py`:新增模块错误、取消、尾部损坏、写入冲突、符号链接及刷盘失败场景,未执行。 + +关联第八类保存/中断、第十类故障隔离/取消需求由“尚未实现”推进为“部分实现(未测试)”;尚不能标记已实现且通过测试。完整限制见架构和测试计划的 Reliability batch。 + + +## 资源检测第三组(2026-11-01:已编写,未测试) + +- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。 +- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。 +- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。 +- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。 +- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。 + +限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。 + +尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。 + +实现语义参考 Linux 内核官方文档: + +- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max) +- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒) +- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段) + +下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。 + + +## 双栈网络第四组(2026-11-01,已编写但未测试) + +新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。 + +分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。 + +增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。 + +逐项状态仍为部分实现;02类 DNS/TCP/TLS 代码证据新增 connectivity.py。整体进度见 CURRENT_STATUS.md。 + +## 基础安全第五组(2026-11-01:实现未测试) + +新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。 + +更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。 + +新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。 diff --git a/PUBLIC_BETA_CHANGE_REPORT.md b/PUBLIC_BETA_CHANGE_REPORT.md new file mode 100644 index 0000000..5720c57 --- /dev/null +++ b/PUBLIC_BETA_CHANGE_REPORT.md @@ -0,0 +1,46 @@ +## P0 追加修复 · BETA22-001 + +本轮修复父解释器 -B 不被新 Python 进程继承的问题。联网、DNS、邮件子解释器均显式传 -B,安装器探测及源码脚本同步。 +五种实际 worker 在全新副本中运行,配可写负对照;开发机回归 277 通过、1 跳过。没有删除缓存代替修复。 +用户将 22.04 Server 要求调整为原 22.04 Desktop 上全新 HOME/安装目录;24.04.3 Server 另行测试。实际结果见候选包旁本轮验收报告,历史材料不等于当前候选通过。 + +--- + +# 1.0 公测候选变更与待测项 + +版本:1.0.0b1;非最终版。日期:2026-11-01。 +基线:用户上传 iprisk-edition,SHA256 3fa8f9c2294774fa0aad98866e8ce3c3ea181bdcf1854515591dd0e7ccf639dc。 + +## 已完成代码 + +1. 修复三主题的框宽、中文按显示列换行、长字段截断、分组错位、英文残留、颜色参数未传递、--lang=zh 降级以及风险汇总显示绿色。 +2. 联网子模块复用 Console,路由输出进入框内;保留已有普通主题和命令。 +3. 可选无密钥来源可省略;限流/异常/请求失败继续显示,恢复有效 AbuseIPDB 结果展示。 +4. 流媒体 CLI 仅检查公开页面可达性,一次请求,不输出解锁/套餐/播放结论;旧内部分类 API 保留兼容。 +5. 新增 trace ICMP/TCP 的固定系统 traceroute 适配,限时、取消与逐跳解析;缺依赖不安装、不提权。 +6. 新增 return-route 三城九方向追踪与 IPv4/IPv6 参数;不冒充全国/双向覆盖。 +7. 新增 bgp RIPE RIS 前缀验证、起源、邻居、AS路径和 ASN 标签,不推断商业上游或 GIA 等级。 +8. 新增 cn-nodes 目录与小样本探测;覆盖统计排除香港。新增可选择的三网候选 HTTPS 下载,尚未证明完整大陆三网可用,也没有三网上传。 +9. 新版标识、安装提示 flush、启动 -B、无 unzip 解压说明;Markdown 安全 JSON 围栏。 +10. 更新中英文文档和来源记录。未增加产品 Python 依赖;未修改系统网络/安全配置。 + +## 证据和限制 + +- 开发机自动化:271 通过,1 项 Windows 符号链接权限跳过。增加实际主题 CLI、列宽、语言、可达性、颜色、来源异常、BGP 校验、TCP 参数和香港排除回归覆盖。 +- 初轮失败 XML 保留;其中包括清单未刷新、边框尾空格和旧接口夹具不适配。修复后全套通过,没有删除失败测试或降低安全要求。 +- 上海电信及香港移动的 HTTPS 小 JPEG 可访问,南京电信仅明文成功;大陆联通/移动存在解析失败、拒绝或超时。小样本不等于有效多轮带宽或海外可用。 +- 证据位于工作区 outputs/public-beta-tests.xml、carrier-node-discovery-20261101.json、carrier-node-discovery-round2-20261101.json。 +- 尚未执行当前版本的 Ubuntu 安装、VM/海外实测。历史 Alpha 结果不能替代新版验收。 + +## 下一轮测试 + +- 从最终 ZIP 在 Ubuntu 22.04/24.04 安装、重复安装、失败清理、隔离和界面验收。 +- ICMP/TCP 权限、缺依赖、IPv6、超时、中断、逐跳 IP/RTT/地理位置。 +- BGP 实网接口、限流、异常响应及路径范围解释。 +- 海外 VPS 三城九方向完整追踪及恢复;全国/双向仍是后续能力。 +- 大陆三运营商有效多轮下载和节点开放范围;不将香港替代大陆移动。 +- 流媒体基础可达性、模块异常、报告保存、Markdown 和中断恢复回归。 +- 持续复核节点服务条款和可用性;开源许可证不等于第三方服务承诺。 + +按用户要求,代码、文档、本地候选包收尾后停止,留待下一阶段测试。 +结论:可提交下一轮测试的代码候选;不是已通过最终公测验收。未推送、合并或发布。 diff --git a/README.en.md b/README.en.md index 639db88..0f6b573 100644 --- a/README.en.md +++ b/README.en.md @@ -1,172 +1,70 @@ -# ProxyDoctor +# VPS Check · 1.0 public beta [中文](README.md) | [English](README.en.md) -Native Python read-only VPS diagnostics for Ubuntu 22.04 and newer. Python 3.10+; -no third-party runtime dependencies. Ubuntu 22.04/24.04 are the current acceptance -targets; other versions need separate testing. No automatic sudo, installation, -SSH/firewall changes or fetching/executing remote scripts. +Read-only Python VPS diagnostics; internal package/command: proxydoctor. **Public beta, not the final release.** Version: `1.0.0b1`. Local candidate only; not publicly published or finally accepted. -## Run +## Install -Extract the source package and run from its directory: +Requires Linux, Python 3.10+ and standard-library venv. Target: Ubuntu 22.04 and newer. No Python runtime dependencies, sudo, pip or automatic system package installation. Check the ZIP against a trusted SHA256 digest and review install.py first; Python extraction works without unzip. ```bash -bash proxydoctor.sh -bash proxydoctor.sh suite --lang en -bash proxydoctor.sh suite --offline --lang en -python3 -m proxydoctor check --lang en --port 8388 --json --output check.json +sha256sum -c ProxyDoctor-v1.0.0-beta.1.zip.sha256 +python3 -m zipfile -e ProxyDoctor-v1.0.0-beta.1.zip . +cd ProxyDoctor-v1.0.0-beta.1 +python3 install.py --run +PD="$HOME/.local/share/proxydoctor/1.0.0b1/bin/proxydoctor" +"$PD" check --theme framed --lang en +"$PD" suite --offline --lang en ``` -Downloading/cloning does not execute code. The default `suite` contacts external -services and streams compact colored rows. `check` defaults to local reads. -Ordinary users can run it; missing privileges remain unknown. Report files stay -local, do not overwrite existing files and redact IPs. Review before sharing; -redaction is not an anonymity guarantee. `--show-ip` displays egress IP in the -terminal only. Routes display actual hop IPs in the terminal; exports mask them. +An isolated user environment is created. Existing destinations are refused; failures clean only the new directory. Missing prerequisites fail clearly. The supplied launcher and Python workers explicitly use -B. This covers supplied launch paths, not arbitrary external imports or pip precompilation; add -B when invoking Python directly. -## Four modules - -| Module | Terminal output | Limits | -| --- | --- | --- | -| Resources/preflight | OS, architecture, CPU/model/count, kernel, memory, disk, SHA-256 sample; route/DNS config, target port, SSH guard/config, local/cloud firewall status | Visible resources may differ from quotas; SHA-256 is not a general CPU benchmark; local reads cannot establish cloud ingress | -| Network/IP | Real DNS/TCP probes; ownership/ASN/location/type, provider flags/scores, coverage and disagreements | AAAA answers do not prove IPv6 connectivity; provider scores are separate observations, not safety certification | -| Route | Linux IPv4/IPv6 UDP error queue, 30 hops by default, up to 64; IP, latency, estimated location | NAT/filtering or silent routers can prevent completion; geolocation is approximate | -| Media | Ten independent service adapters, repeat confirmation, catalog/metadata evidence and explicit unknown reasons | No paid account/DRM playback verification; public offers/catalogs do not prove full unlock | - -Suite JSON schema_version=2 includes local checks, DNS/TCP, per-source bandwidth, -IP observations/provenance/conflicts, route hops and media probe evidence. -Green indicates acquired data/local success, red known warnings/restrictions, -yellow unknown/skipped/insufficient evidence. `--color auto|always|never` and -`NO_COLOR` are supported. Use `check --json` for detailed local evidence. -`sshd -T` defaults may differ from running instances/Match rules. Future repair -features still require separate SSH safeguards. - -## Bandwidth +## Commands ```bash -bash proxydoctor.sh bandwidth --lang en -bash proxydoctor.sh bandwidth --lang en --source ovh-rbx --rounds 3 --sample-mib 4 -bash proxydoctor.sh bandwidth --lang en --source hetzner-fsn --sample-mib 1 -bash proxydoctor.sh suite --lang en --bandwidth-source cloudflare +"$PD" suite --lang en +"$PD" quality --color always --lang en +"$PD" ipquality --show-ip --lang en +"$PD" media --lang en +"$PD" cn-nodes --lang en +"$PD" bandwidth --china --allow-bandwidth --rounds 3 --sample-mib 4 --lang en +"$PD" bandwidth --source mobile-hk --allow-bandwidth --lang en +"$PD" trace --target 1.1.1.1 --method icmp --geo --lang en +"$PD" trace --target 1.1.1.1 --method tcp --tcp-port 443 --lang en +"$PD" return-route --method udp --lang en +"$PD" bgp --target 1.1.1.1 --lang en +"$PD" suite --route-method tcp --include-return-routes --include-bgp --lang en ``` -| ID | Source/node label | Download | Upload | Default | -| --- | --- | --- | --- | --- | -| cloudflare | Cloudflare / Anycast | Yes | Yes | Yes | -| ovh-rbx | OVHcloud / FR Roubaix | Yes | No | Yes | -| hetzner-fsn | Hetzner / DE Falkenstein | Yes | No | No; slow on current test network | +- check/suite support default, iprisk and framed themes, with live results. Explicit language choices override the Linux-console fallback. +- System, visible resource quota, Swap, short steal/PSI, SSH/ports/firewall, file permissions and update-cache observations. No benchmarks or exclusive-resource certification. +- IPv4/IPv6 DNS/TCP/certificate-verified TLS and native UDP trace. ICMP/TCP require existing system traceroute; no elevation or automatic installation. +- IP provider evidence; failed providers remain visible. Optional PTR/outbound TCP25 sends no email. +- Ten media services: public webpage reachability only. No unlock or playback verdict in the CLI. +- Repeated bounded bandwidth requires --allow-bandwidth. Download means node to this host; upload means this host to node. China candidates currently provide download only; Cloudflare supports upload. +- cn-nodes uses small plaintext protocol samples, without credentials or report uploads. Success is not a capacity measurement. Hong Kong Mobile never counts as mainland Mobile coverage. +- Return routes: Beijing/Shanghai/Guangzhou × three carriers, this host to China targets. Not nationwide or reverse-direction coverage. Per-route soft probe budget: 30s; optional geo requests have separate limits. +- BGP: RIPE RIS prefix, origin ASNs, sampled neighbours and AS paths. Collector observations do not prove local selected packet paths, commercial upstreams or premium tiers. -Excluded 128 KiB warmup per supported direction; 3–8 measured rounds of 1–20 MiB. -Default requested benchmark payload is at most 36.375 MiB, excluding protocol -and other module traffic. Medians/ranges/success counts stay separate per source. -Short samples (<1s), missing rounds and high variation stay yellow. Wall-time -HTTPS throughput includes connection setup and cannot verify advertised capacity. -Static files require exact HTTP 206 ranges; ignored Range responses are closed -without reading the full body, never falling back to a full-file download. -Unsupported upload is skipped, not counted as failure. Bandwidth JSON schema=2. +Development-host small JPEG samples succeeded for Shanghai Telecom and Hong Kong Mobile. Mainland Unicom/Mobile do not yet have successful evidence this round. HTTPS throughput never downgrades to HTTP or disables certificate verification. New Linux/overseas network capabilities remain pending real acceptance. -## IP sources and keys +## Reports and safety + +Reports/journals are local (reports/ by default), with no overwrites. --show-ip affects only terminal display. Review redacted exports before sharing. ```bash -bash proxydoctor.sh ipquality --lang en --show-ip --json-output ip.json -bash proxydoctor.sh ipquality --lang en --no-enrichment -bash proxydoctor.sh suite --lang en --no-ip-enrichment +"$PD" suite --offline --json-output report.json --lang en +"$PD" report --input report.json --output report.md --lang en +"$PD" recover --journal report.json.events.jsonl --output recovered.json --lang en ``` -Defaults: ipapi.is, proxycheck.io and IP2Location. On an ipapi.is transport failure, -ipify can provide an egress address. Subsequent sources query that exact address; -mismatched responses are rejected. These services see the source address and the -additional sources receive the selected egress IP. Disable extra sources with -`--no-ip-enrichment` or all suite networking with `--offline`. +Ctrl+C/SIGTERM cancels workers and attempts to save observations. Operations have timeouts; journals have size limits. Power/storage failure and SIGKILL cannot guarantee unfinished data survives. JSON keeps legacy fields and uniform result items; Markdown uses collision-safe JSON fences. -Seventeen core fields cover ownership/ASN, location/timezone, type/network abuse -share and datacenter/proxy/VPN/Tor/recent-abuse/mobile/satellite/anycast/bogon flags. -Per-source observations and field provenance are retained. Conflicting booleans -become unknown with disagreement evidence. IP2Location's limited free-plan proxy -flag is kept separate, not converted into a broad VPN guarantee. proxycheck.io -risk/confidence and AbuseIPDB scores are not averaged. Network abuse share is not -a single-IP maliciousness probability. +Exit codes: 0 completed, 1 findings/incomplete/storage failure, 2 usage/install error, 130 cancelled. Defaults do not modify SSH/firewall/routes/system DNS, benchmark, repair or upload complete reports. Online targets see the request source. -Keyless limits currently include 100 queries/day for proxycheck.io and 1000/day -for IP2Location, subject to provider rules. No automatic quota retries. Anonymous -ipapi.is has limited fields; optional keys provide extended fields/AbuseIPDB reports. -Enter locally without echoing; never send keys to chat or commit them: +P0 automation: 277 passed, one Windows symlink-permission skip. Current VM acceptance is recorded separately in BETA_ACCEPTANCE_REPORT.md and its JSON beside the candidate ZIP, identified by SHA256. Historical Alpha and earlier Beta evidence is not current-build acceptance. Full carrier throughput, ICMP/TCP permission variants and overseas paths require explicit coverage evidence. -```bash -read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY -export PROXYDOCTOR_IPAPI_KEY -printf '\n' -read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY -export PROXYDOCTOR_ABUSEIPDB_KEY -printf '\n' -bash proxydoctor.sh ipquality --lang en -``` +No nationwide carrier coverage, long monitoring, DNSBL, Web UI, proxy deployment, Karing subscriptions or automatic updates/uninstall. Install updates into a new directory; back up reports before removing a confirmed old installation. -ipapi.is keys travel in HTTPS POST JSON; AbuseIPDB keys use headers, not URLs or -reports. Missing fields remain unknown. Complete coverage means defined fields, -not every database or a security certification. Zero reports do not guarantee -safety. Observed API egress/family may differ from media connections. - -## Route and media - -```bash -bash proxydoctor.sh suite --lang en --max-hops 64 --route-target 1.1.1.1 -bash proxydoctor.sh suite --lang en --route-target 2606:4700:4700::1111 --no-geo -bash proxydoctor.sh media --lang en --json-output media.json -``` - -ipwho.is geolocation is cached per run; private/reserved hops are never queried. -Media covers Netflix, Disney+, YouTube Premium, Spotify, Prime Video, Max, Apple -TV+, Hulu, BBC iPlayer and Paramount+. Clear regional messages and visible priced -offers require repeat agreement. Netflix uses structured catalog metadata; BBC -also checks public test-channel metadata. No media segments/paid content are -fetched and signed media URLs are not retained. BBC's channel result is scoped -to that channel. HTTP 200/403 or catalog/offer visibility never proves playback. - -## Exit codes - -`check`: 0=no known warning/failure (unknowns may remain), 1=findings, 2=usage/export -error. Online suite/bandwidth/ipquality/media: 1 also covers incomplete/unknown/ -variable measurements or unverified playback. Offline 0 means local execution -completed, not online success. Media intentionally leaves playback untested and -returns 1. Ctrl+C returns 130 without changing system settings. - -## Install and contribute - -Source runs directly. Install the supplied wheel into a local virtual environment: - -```bash -python3 -m venv .venv -.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl -.venv/bin/proxydoctor suite --offline --lang en -``` - -If Ubuntu lacks venv support, install its matching OS component separately; -ProxyDoctor never installs it automatically. Development: install pytest and run -`python -m pytest -q`. An Actions workflow is supplied; remote CI is not claimed -before repository publication. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). - -## Stage 1 and scope - -This iteration targets code plus Ubuntu 22.04/24.04 VM acceptance as selected by -the user; consult the delivered acceptance records for actual results. Overseas -VPS tests, real-key extended data, paid-account playback and other Ubuntu releases -remain separately unverified. Later stages cover sing-box installation, Karing -protocol validation, repair previews/backups/rollback, Web UI and secure subscription -transport. No public plaintext subscription server or automatic SSH/firewall changes. - -## License and acknowledgements - -Original code is MIT. Thanks to ECS, NodeQuality, NetQuality/IPQuality, NextTrace -and RegionRestrictionCheck for feature/presentation references. No upstream script -source/databases are bundled by the default implementation. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). -Explicit local-script adapters are opt-in; external behavior/output may not be -read-only or redacted. - -Official data references: [Cloudflare](https://github.com/cloudflare/speedtest), -[OVHcloud](https://proof.ovh.net/), [Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/), -[ipapi.is](https://ipapi.is/developers.html), [proxycheck.io](https://proxycheck.io/api/), -[IP2Location](https://www.ip2location.io/ip2location-documentation), [AbuseIPDB](https://docs.abuseipdb.com/), -[ipwho.is](https://ipwhois.io/documentation), [ipify](https://www.ipify.org/). -[Netflix VPN guidance](https://help.netflix.com/en/node/114701) explains catalog restrictions. +See [current status](CURRENT_STATUS.md), [changes and pending acceptance](PUBLIC_BETA_CHANGE_REPORT.md), [security](SECURITY.md) and [third-party notices](THIRD_PARTY_NOTICES.md). MIT project, standard-library runtime, no bundled tracing/speed-test binaries. diff --git a/README.md b/README.md index 60cbdd8..2434733 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,108 @@ -# ProxyDoctor +# VPS 检测工具 · 1.0 公测版 [中文](README.md) | [English](README.en.md) -面向 Ubuntu VPS 新手的原生 Python 只读诊断工具。支持 Ubuntu 22.04 及更新版本; -当前优先验收 22.04、24.04,其他版本逐步实测。Python 3.10+,没有第三方运行依赖。 -本阶段不安装 sing-box,不修改 SSH/防火墙,不自动提权,不下载执行远程脚本。 +面向 Ubuntu VPS 的 Python 命令行体检工具。内部项目名及命令仍为 ProxyDoctor / `proxydoctor`。 +**这是 1.0 公测版,非最终版本。** 包版本 `1.0.0b1`;当前是本地候选包,尚未公开发布或完成最终验收。 -## 快速运行 +## 安装 -下载源码包并解压,进入项目目录: +需要 Linux、Python 3.10+ 和标准库 venv,支持目标为 Ubuntu 22.04 及更新版本。 +没有第三方 Python 运行依赖,不调用 sudo、pip 或系统包管理器。 +先从可信来源核对摘要,再解压并阅读 install.py。没有 unzip 也可用 Python 解压: ```bash -bash proxydoctor.sh -bash proxydoctor.sh suite --lang en -bash proxydoctor.sh suite --offline +sha256sum -c ProxyDoctor-v1.0.0-beta.1.zip.sha256 +python3 -m zipfile -e ProxyDoctor-v1.0.0-beta.1.zip . +cd ProxyDoctor-v1.0.0-beta.1 +python3 install.py --run ``` -下载或克隆本身不执行程序。默认入口 `suite` 会联网、逐项回显; -`check` 默认只读取本机信息。普通用户可运行,权限不足显示未知;程序不调用 sudo。 +安装到用户目录的隔离环境,首次仅运行离线体检。已有目录不会覆盖;失败只清理本次新建目录。 +缺少 Python/venv 时明确退出,不自动安装系统依赖。`--prefix` 可指定不存在的用户目录。 +安装后的启动器及各 Python worker 显式使用 `-B`,忽略用户包路径并禁止这些启动路径生成字节码缓存。此承诺覆盖随包安装器启动命令和源码脚本;自行使用 Python 导入或 `python -m` 时应加 `-B`,pip 的安装期预编译及外部调用方不在此范围。 ```bash -python3 -m proxydoctor check -python3 -m proxydoctor check --port 8388 --json --output check.json -python3 -m proxydoctor suite --json-output suite.json --color always +PD="$HOME/.local/share/proxydoctor/1.0.0b1/bin/proxydoctor" +"$PD" check --theme framed --lang zh +"$PD" suite --offline --theme framed +"$PD" suite --lang en +"$PD" quality --color always +"$PD" ipquality --show-ip +"$PD" media +"$PD" --help ``` -报告文件不会覆盖已有文件,也不会自动创建目录。原生报告仅保存在本地,IP 默认脱敏; -脱敏不是匿名保证,分享前仍应检查。`--show-ip` 可在终端显示出口地址,文件仍脱敏。 -路由模块按用户要求在终端显示实际节点 IP,导出文件中隐藏。 +`check`、`suite` 支持 default/iprisk/framed 三种主题。结果直接逐项显示在命令行。 +中英文可选;纯 Linux 文本控制台默认英文,显式 `--lang zh` 或 `--lang=zh` 保留中文。 -## 四模块 +## 能力与范围 -| 模块 | 直接回显 | 边界 | +| 模块 | 本版能力 | 结果范围 | | --- | --- | --- | -| VPS 资源与体检 | 系统、架构、CPU、逻辑核心、内核、内存、磁盘、SHA-256 样本;DNS 配置、端口、SSH 保护、SSH 设置和防火墙提示 | 系统可见资源不保证等于商家配额;SHA-256 不是完整 CPU 基准;本机读取不能确认云防火墙放行 | -| 节点网络/IP 质量 | 实际 DNS、TCP 请求,IP 归属/ASN/地区/类型、风险标记、来源状态、覆盖率和分歧 | DNS 返回 IPv6 不代表 IPv6 连通;第三方标记不是客观安全保证,不合并不同评分 | -| 路由 | 原生 Linux IPv4/IPv6 UDP 追踪,默认 30 跳,可至 64;每行 IP、延迟、估计位置 | 中间节点可能不回包,位置是数据库估计,不保证真实物理位置;NAT/过滤可能导致未到达 | -| 流媒体 | Netflix、Disney+、YouTube Premium、Spotify、Prime Video、Max、Apple TV+、Hulu、BBC iPlayer、Paramount+ | 明确地区提示/套餐证据重复确认;片库和元数据不等于账号/DRM 实际播放;规则失效和网络失败显示未知 | +| 系统和资源 | 系统、CPU、内存、Swap、根分区、可见配额、短采样 steal/PSI | 无法证明资源独享;不执行 CPU/磁盘基准 | +| 网络 | 分 IPv4/IPv6 的 DNS、TCP、证书验证 TLS | 本机到指定目标的当前观测 | +| 路由 | 原生 UDP;可选系统 traceroute 的 ICMP/TCP | 默认 30 跳、最多 64 跳;响应缺失不等于转发丢包 | +| IP 信息 | 归属、ASN、运营商及分来源风险;可选 PTR/TCP25 | 缺失保持未知;接口失败显示,未配置的可选密钥来源可省略 | +| 流媒体 | Netflix、Disney+、YouTube Premium 等 10 项公开网页 | 仅可达性,不输出解锁或播放成功结论 | +| 安全 | SSH、端口、防火墙、权限和更新缓存只读检查 | 不修改配置,不是完整漏洞扫描 | +| 带宽 | 多来源多轮下载,Cloudflare 支持上传 | 必须明确允许;短样本不报线路容量 | +| BGP | RIPE RIS 前缀、起源 ASN、邻居、AS_PATH 样本 | 采集器视角,不等于本机选路或商业上游 | -JSON 综合报告 schema_version=2,同时包含本机检查、DNS/TCP、每个测速来源、IP 来源观察、路由逐跳及媒体探测依据。 -绿色表示取得结果或局部通过,红色表示已知风险/限制/冲突,黄色表示未知、跳过或证据不足。 -支持 `--color auto|always|never` 和 `NO_COLOR`。复杂配置的完整依据可用 `check --json` 查看。 -`sshd -T` 读取默认配置,不保证与运行中实例或 Match 条件相同。安装与修复阶段仍须独立保护 SSH。 +## 三网、路由和 BGP -## 多来源测速 +以下是新增公测能力,尚待本轮 Linux 与海外 VPS 实测验收。 ```bash -bash proxydoctor.sh bandwidth -bash proxydoctor.sh bandwidth --source ovh-rbx --rounds 3 --sample-mib 4 -bash proxydoctor.sh bandwidth --source hetzner-fsn --sample-mib 1 -bash proxydoctor.sh suite --bandwidth-source cloudflare +# 固定候选的小流量探测,不运行带宽测试 +"$PD" cn-nodes +# 大陆三运营商候选多轮下载;仍可能不可用,失败如实显示 +"$PD" bandwidth --china --allow-bandwidth --rounds 3 --sample-mib 4 +"$PD" bandwidth --source cn-telecom-sh --allow-bandwidth +# 香港移动单独标注,不能替代大陆移动 +"$PD" bandwidth --source mobile-hk --allow-bandwidth +# ICMP/TCP 需要系统已经安装 traceroute,缺少则明确退出,不提权安装 +"$PD" trace --target 1.1.1.1 --method tcp --tcp-port 443 --geo +"$PD" trace --target 1.1.1.1 --method icmp --max-hops 64 +# 北京/上海/广州 × 三网,逐条执行,每条约 30 秒探测软预算 +"$PD" return-route --method udp +"$PD" return-route --method tcp --family 6 +"$PD" bgp --target 1.1.1.1 +# 综合入口额外模块需明确开启 +"$PD" suite --route-method tcp --include-return-routes --include-bgp ``` -| 来源参数 | 节点标签 | 下载 | 上传 | 默认 | -| --- | --- | --- | --- | --- | -| cloudflare | Cloudflare / Anycast | 是 | 是 | 是 | -| ovh-rbx | OVHcloud / 法国 Roubaix | 是 | 不支持 | 是 | -| hetzner-fsn | Hetzner / 德国 Falkenstein | 是 | 不支持 | 否,当前网络较慢 | +三城探测方向为“本机 → 中国目标”,在海外 VPS 上通常称回程;不包含中国侧到 VPS 的去程或全国各省全部线路。 +ASN 标签不能证明 CN2 GIA 等套餐等级。测速下载是节点到本机,上传是本机到节点,和逐跳探测方向分别解释。 -每方向 128 KiB 预热不计统计;正式 3–8 轮、每轮 1–20 MiB。默认请求测速载荷 -最多 36.375 MiB,不含协议开销及其他模块请求。各来源独立给出中位数、范围、成功数; -不做跨来源平均。样本短于 1 秒、轮次不足或波动大显示黄色。吞吐包含建连,不能核验商家标称带宽。 -对大文件只接受精确 HTTP 206 分段响应;若服务器忽略 Range,不读取整文件正文,不回退完整下载。 -测速超过时限或未完成不记为成功;不支持上传不记为上传失败。带宽子报告 schema_version=2。 +节点标签来自公开目录,不等于实测身份认证。当前开发机已验证上海电信及香港移动的小图片可访问;大陆联通、移动尚无本轮成功证据。 +香港不计入大陆三网覆盖。HTTPS 测速不自动降级明文或关闭证书验证。三网候选本轮仅下载,未实现三网上传。 +`cn-nodes` 使用少量明文测试协议数据,不发送凭据或报告;小样本成功不等于带宽容量。 -## IP 数据与密钥 +## 报告、安全和中断 + +报告默认保存在当前目录的 reports/。支持 `--json-output` 的命令可指定路径;已有文件不会覆盖。 +保存文件默认脱敏 IP;`--show-ip` 只影响终端。分享前仍须人工复核。 ```bash -bash proxydoctor.sh ipquality --show-ip --json-output ip.json -bash proxydoctor.sh ipquality --no-enrichment -bash proxydoctor.sh suite --no-ip-enrichment +"$PD" suite --offline --json-output report.json +"$PD" report --input report.json --output report.md +"$PD" recover --journal report.json.events.jsonl --output recovered.json +"$PD" recover --journal report.json.events.jsonl --output recovered.md --format markdown ``` -默认查询 ipapi.is、proxycheck.io 和 IP2Location。若 ipapi.is 连接失败,尝试 ipify 获取出口 IP。 -后续查询固定为该地址,拒绝把其他地址的响应混入结果。所有这些服务会看到请求来源, -另外两家会收到查询的出口地址。可用 `--no-ip-enrichment` 关闭额外来源,`--offline` 关闭综合入口联网。 +Ctrl+C/SIGTERM 尝试取消活动子进程并保存已有记录。API、DNS、命令、测速有限时;恢复日志有大小上限。 +JSON 保留原模块和统一 result_items 的状态、方法、时间、来源及范围。Markdown 使用防提前闭合的 JSON 代码围栏。 +断电、SIGKILL、磁盘故障时以最后完整恢复事件为准。 -17 项核心字段包括运营商/ASN、地区/时区、网络类型、网段滥用比例,以及数据中心、代理、VPN、 -Tor、近期滥用、移动、卫星、Anycast、保留地址标记。按来源记录观察和字段来源;布尔标记冲突显示 -“来源不一致”,不会自动选成安全。IP2Location 免费模式的有限代理标记单独保留,不替代通用 VPN 判断。 -proxycheck.io 风险/置信度与 AbuseIPDB 评分分别保存,不求平均,不把网段比例当作单 IP 恶意概率。 +退出码:0 正常完成,1 有提示/不完整/保存失败,2 参数或安装错误,130 中断。 +默认只读,不修改 SSH、防火墙、路由、系统 DNS,不自动上传完整报告或修复系统;联网目标会看到请求来源 IP。 -免密钥 API 也有配额:proxycheck.io 当前限 100 次/天,IP2Location 当前限 1000 次/天;以服务官方规则为准。 -遇到限流不自动重试。ipapi.is 匿名响应只提供基础数据;扩展字段和 AbuseIPDB 举报数据可通过密钥补充。 -不要把密钥发到聊天、提交到仓库或放进命令参数;在 VPS 本地无回显输入: +## 公测状态与开源 -```bash -read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY -export PROXYDOCTOR_IPAPI_KEY -printf '\n' -read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY -export PROXYDOCTOR_ABUSEIPDB_KEY -printf '\n' -bash proxydoctor.sh ipquality -``` +P0 修复自动化:277 通过、1 项 Windows 符号链接权限跳过。当前构建的实机验收结果以随候选 ZIP 单独交付的 BETA_ACCEPTANCE_REPORT.md 和结构化结果中的 SHA256 为准;不将旧候选报告当作本包已通过。ICMP/TCP 实际权限、完整大陆三网和海外回程需按该报告的覆盖范围判断。 +旧 Alpha 结果不是本包验收证据。见 [当前状态](CURRENT_STATUS.md)、[变更和待测项](PUBLIC_BETA_CHANGE_REPORT.md)。 +尚无全国完整三网、长期监测、CPU/磁盘基准、DNSBL、Web UI、代理部署、Karing 订阅或自动更新卸载。 +更新安装到新版本目录;卸载前退出进程、备份报告,仅移除自己确认过的安装目录。 -ipapi.is 密钥放 HTTPS POST 正文,AbuseIPDB 放请求头,不写入 URL 或报告。 -缺少字段和密钥明确显示未知;完整覆盖率只针对定义的核心字段,不代表所有数据库或安全认证。 -零举报不代表干净 IP。只确认本次接口请求的出口和地址族,不保证每个服务连接都使用同一出口。 - -## 路由与媒体复测 - -```bash -bash proxydoctor.sh suite --route-target 1.1.1.1 --max-hops 64 -bash proxydoctor.sh suite --route-target 2606:4700:4700::1111 --no-geo -bash proxydoctor.sh media --color always --json-output media.json -``` - -路由位置来自 ipwho.is,按运行缓存;私网/保留节点不查询。`--no-geo` 关闭位置查询。 -媒体重复确认地区提示/带价格的具体套餐;Netflix 检查结构化片库元数据,BBC 额外查询测试频道的公开媒体元数据。 -不获取付费节目、不下载流媒体、不绕过登录/DRM,不保留媒体签名地址。BBC 的测试频道结果不代表整个平台。 -HTTP 200、403、页面可达、套餐或片库可见均不作为“完全解锁”证据。 - -## 退出码 - -- `check`:0=无已知注意/失败项,仍可能含未知;1=有注意/失败项;2=参数/保存错误。 -- `suite/bandwidth/ipquality/media`:0=该命令定义的范围完整,离线 0 仅表示本地执行完成;1=有未完成/未知/波动或未验证播放;2=参数/保存错误。 -- 流媒体命令仍保留“账号播放未测”,因此返回 1;这不表示程序崩溃。 -- Ctrl+C 返回 130,不改系统设置。 - -## 安装与开发 - -源码可直接运行。也可把提供的 wheel 安装到自己的虚拟环境: - -```bash -python3 -m venv .venv -.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl -.venv/bin/proxydoctor suite --offline -``` - -Ubuntu 若未安装 venv 支持,需另行安装系统对应组件;程序不会自动安装。 -开发测试:`python -m pip install pytest` 后 `python -m pytest -q`。 -GitHub Actions 配置已提供,未发布仓库前不宣称远程 CI 已运行。 -贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),安全反馈见 [SECURITY.md](SECURITY.md)。 - -## 阶段一验收与后续 - -本轮按用户选择完成代码和 22.04/24.04 虚拟机验收;实际记录以交付验收报告为准。 -海外 VPS、真实付费账号播放、带真实密钥的扩展数据,以及 Ubuntu 其他版本仍待分别验收;不会把它们标为已通过。 -之后阶段才实现 sing-box 安装、Karing 协议连通、修复预览/备份/回滚、Web UI 和安全订阅传输。 -不对外开放明文 HTTP 订阅,不根据只读体检自动修改 SSH/防火墙。 - -## 开源与来源 - -原创代码使用 MIT 许可证。感谢 ECS、NodeQuality、NetQuality/IPQuality、NextTrace、RegionRestrictionCheck 的功能和展示设计参考; -默认实现没有打包或复制上游脚本/数据库。来源和许可范围见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 -本地第三方适配器仅在显式指定时运行,第三方行为/输出不保证只读或脱敏。 - -数据接口:[Cloudflare](https://github.com/cloudflare/speedtest)、[OVHcloud](https://proof.ovh.net/)、 -[Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/)、 -[ipapi.is](https://ipapi.is/developers.html)、[proxycheck.io](https://proxycheck.io/api/)、 -[IP2Location](https://www.ip2location.io/ip2location-documentation)、[AbuseIPDB](https://docs.abuseipdb.com/)、 -[ipwho.is](https://ipwhois.io/documentation)、[ipify](https://www.ipify.org/)。 -[Netflix 官方 VPN 说明](https://help.netflix.com/en/node/114701) 也说明了目录限制,不保证页面可达就能播放。 +MIT 项目,标准库运行。感谢 [NextTrace](https://github.com/nxtrace/NTrace-core)、[CleanIP sh.cd](https://github.com/CleanIP/sh.cd)、[RIPEstat](https://stat.ripe.net/) 和既有参考项目;详见 [来源和许可](THIRD_PARTY_NOTICES.md)。不打包下载执行第三方检测脚本或测速客户端。 diff --git a/SECURITY.md b/SECURITY.md index 0eb8000..4cbc0ad 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,16 @@ + +## v0.1.0-alpha 候选(2026-11-01) + +隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。 + +# Current branch safety changes — 2026-11-01 + +Routine suites skip CPU and bandwidth benchmarks. Bandwidth requires explicit per-call consent via `--allow-bandwidth` / `allow_bandwidth=True`; payload limits are not a total network billing cap. External executable/script wrappers are disabled pending resource, license and upload review. Online native checks still disclose the request source to their target services; offline mode skips them. + +The updated workflow is not yet tested. Fault isolation and incremental report saving are implemented but untested. Current scope excludes proxy deployment and Web UI. No full-report uploads, paid API use, system dependency installation, production VPS tests, main-branch merges or public releases are authorized by this development brief. + +--- + # Security / 安全反馈 Do not post vulnerabilities containing passwords, subscription URLs, API keys, @@ -18,3 +31,12 @@ separate review and SSH rollback safeguards. 中文:不要公开提交密码、密钥、订阅地址或完整诊断报告。仓库发布并开启私密漏洞报告后 优先使用该渠道;未配置前先询问私密联系方式,不公开敏感细节。报告脱敏不是匿名保证, 分享前需检查。当前原生检测不修改 SSH/防火墙,不上传报告;联网模式的服务会看到请求来源。 + + +## 基础安全第五组(2026-11-01:实现未测试) + +新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。 + +更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。 + +新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。 diff --git a/TEST_PLAN.md b/TEST_PLAN.md new file mode 100644 index 0000000..52ec719 --- /dev/null +++ b/TEST_PLAN.md @@ -0,0 +1,103 @@ +## 1.0 公测待验收 + +当前包的 Ubuntu 安装、ICMP/TCP、三网有效下载、BGP 实网及海外三城回程均留待下一轮;本轮开发机 271 通过、1 跳过。旧 Alpha 验证仅为历史。具体步骤范围见 PUBLIC_BETA_CHANGE_REPORT.md。 + + +## v0.1.0-alpha 候选(2026-11-01) + +隔离离线安装、完整性拒绝、网络worker超时/取消、统一结果schema、独立模块增量保存、路由重复探测统计已实现;两版Ubuntu各248自动测试/23实际验收。全新OS镜像尚缺,建议内部试用、暂缓公开。详情见ALPHA_RELEASE_REPORT.md。无主分支合并。 + + +## 报告与IP质量更新(2026-11-01) + +- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。 +- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。 +- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。 +- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。 +- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。 +- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。 +- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。 +- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。 + +证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。 + + +## 最新双机验收(2026-11-01) + +同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。 + + +## Ubuntu 24.04 实测更新(2026-11-01) + +当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。 +中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。 +DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。 +综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。 +完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。 + +# 测试与验收计划 + +本轮按用户指示暂不运行测试,所有新修改标记“未验证”。历史 0.5.0 记录位于工作区 outputs,不能直接用于本分支验收。 + +| 批次 | 必须验证的行为 | 方法和环境 | 当前证据 | +|---|---|---|---| +| P0 默认安全 | suite 默认不调用 hashlib 基准/measure_bandwidth;离线零外网;bandwidth 无许可拒绝;许可后预算显示;所有第三方包装入口拒绝 | Mock 调用计数、CLI 子进程、中文/英文;普通用户 | 新源码,未运行 | +| P0 安全持久化 | 每模块异常继续下一模块;Ctrl+C 保留事件;权限不足/磁盘满/中断/符号链接/已有文件均不覆盖 | 临时目录与故障注入;已有 suite 回归适配 | 尚未实现 | +| P1 资源 | cgroup v1/v2 嵌套配额/无限/缺文件;Swap/PSI/steal 采样;购买规格与实测分离 | 固定 fixture + Ubuntu 22.04 Server/24.04 Desktop | 待开发和验证 | +| P1 网络 | 分 IPv4/IPv6,DNS 超时/拒绝/无记录,TCP/TLS/UDP 限时,NAT/多网卡,不将失败归因唯一 | Mock + 回环服务 + VM | 部分旧测试待复核 | +| P2 路由 | 1..64 跳、v4/v6、目标不回应、中间超时不算端到端丢包、ASN 冲突、方向标记 | 固定报文 fixture + VM + 获授权公网节点 | UDP 旧用例存在,新验收待补 | +| P2 IP/媒体 | 403/429/200登录/地区禁止/有效元数据/证据冲突;时间和来源;无密钥/错误密钥;不下载受保护内容 | 离线 fixture,必要时有限实网 | 旧用例存在;新规则不宣称验证 | +| P3 基准监测 | 磁盘限额与清理、无裸盘写、进程超时、24/48h 中断恢复和时区、流量预算 | 先 Mock 后专用 VM,真实 VPS 须另授权 | 尚未实现 | +| P3 发布集成 | 干净 venv 安装/更新/卸载/离线、Schema 兼容、Python API 不依赖 CLI 文本、许可清单 | Ubuntu 双版本和 CI;ARM/真实 VPS 独立记录 | 待验证 | + +每次执行记录 commit、日期、Python/OS/架构、命令、退出码、通过/失败/跳过数、脱敏日志和限制。未知不能算通过;缺海外 VPS 或 API 条件记为待实机验证。无生产测试、无付费 API、无完整报告上传、无系统依赖安装。旧测试如果断言自动测速,应改为显式传 allow_bandwidth=True,再新增默认不测速的独立断言。 + + +## Reliability batch — 2026-11-01 (implemented, untested) + +`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs. + +Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot. + +Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation. + +Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet. + + +## 资源检测第三组(2026-11-01:已编写,未测试) + +- 新增 `proxydoctor/resources.py`,提供独立 `collect()` Python API 和数值渲染。接入综合检测的独立 resources 模块及 `check` 的 JSON/终端输出。 +- 读取 `/proc/self/cgroup` 和 mountinfo,映射 cgroup v1/v2 挂载与成员路径,取最多 64 级可见父级中的最严格 CPU/内存配额;结合 sched_getaffinity 和逻辑 CPU 数计算可见 CPU 上限。 +- 输出内存可见上限、Swap 总量/使用量、cgroup 当前内存使用及 CPU/内存/IO PSI。保留数字和单位,不解析中文描述;缺失或格式不合法保持未知。 +- 对 `/proc/stat` 进行默认 0.2 秒被动采样,计算 steal 的计数差值比例。排除重复包含在 user/nice 中的 guest 字段;计数回退、缺失或无增量保持未知。 +- 新增 `tests/test_resources.py` 的七个 fixture 场景:v2 父级限制、v1、子树挂载、缺失/非法数据、steal 差值、被动采样和非 Linux 降级。均未执行。 + +限制:namespace 隐藏的父级无法确认;混合层级/特殊挂载仍待实机验证。cgroup 当前内存用量与最严格父级上限可能属于不同层级,不可相减充当可用内存。meminfo、PSI、steal 可能反映宿主视图;不代表容器专属指标或长期超售证据。配额为可见上限,不证明独享、保证容量或购买规格。短采样不是 CPU 性能基准,颜色表示数据是否可读,暂不判断压力高低。 + +尚未实现:虚拟化类型细化、CPU 频率与购买规格对比、可授权 CPU/磁盘基准。没有运行测试或 VM 实测,不更新为“已实现且通过测试”。 + +实现语义参考 Linux 内核官方文档: + +- [cgroup v2](https://docs.kernel.org/admin-guide/cgroup-v2.html)(层级限制、cpu.max、memory.max) +- [PSI](https://docs.kernel.org/accounting/psi.html)(avg10/60/300 百分比和 total 微秒) +- [/proc](https://docs.kernel.org/filesystems/proc.html)(CPU 计数与内存字段) + +下次验收需先运行新增测试和现有回归,再分别在 Ubuntu 22.04 Server、24.04 Desktop 验证宿主/cgroup/权限视图;真实 VPS 未具备,不以 VM 代替。 + + +## 双栈网络第四组(2026-11-01,已编写但未测试) + +新增 `connectivity.py`,接入 `suite` 和 `quality`;旧 quality.py API 保留但不再是这两个 CLI 入口的主检测。每协议族默认2次独立系统解析(每次2秒子进程时限),4个固定数字IP端点执行 TCP→证书验证TLS(每目标4秒网络预算)。DNS时间包含进程启动、系统缓存与解析,不能作为纯DNS服务器延迟;成功率仅为本次小样本。 + +分离DNS/TCP/TLS结果,TCP成功且TLS失败时保留两者;错误不含原始异常、URL凭证。固定端点不代表全互联网或中国运营商,TLS不验证HTTP业务可用性。仅观察出站;未测UDP/PMTU/入站/云安全组。没有真实网络执行;网络时限不等于进程所有本地调度/证书加载时间的绝对总上限。 + +增加 `tests/test_connectivity.py` 六个场景:DNS无地址/超时、协议族错误、证书失败保留TCP成功、分族样本统计、探针错误隔离、Ctrl+C透传。尚未执行。 + + +## 基础安全第五组(2026-11-01:实现未测试) + +新增 `proxydoctor/security.py`:独立 Python collect/render API,接入 suite 独立安全模块和 check 的结构化/双语终端输出。检查固定9个文件/目录的 lstat 权限、所有者和类型,不读取 shadow/gshadow 内容、不提权、不刷新包缓存、不执行任何修复。检测世界可写、同组可写、非root所有者、敏感文件世界可读,均为人工审阅提示而非漏洞确认;shadow 的同组只读本身不报风险。符号链接不跟随判断,结果未知。 + +更新状态只读取 apt 成功时间戳和可选 update-notifier 缓存的明确英文计数。记录缓存年龄;无缓存/无权限/本地化或不识别格式不解释为零更新。未调用 apt update/upgrade,不联网、不查询付费API。即使缓存为0也不能证明无漏洞或已完整修补。仅固定入口/父目录,不递归审计 sudoers.d/sshd_config.d;ACL、capabilities、运行态、ESM/订阅资格和完整包版本安全比对未实现。 + +新增 `tests/test_security.py` 九个离线场景(含权限、符号链接、错误不泄密、缓存缺失/年龄/本地化、禁止读取私密内容和非Linux降级),本轮没有执行测试。此前与本轮全部新增实现仍待回归和双Ubuntu实测。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a1a91f0..136d38f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -37,3 +37,44 @@ API availability, accuracy and terms are controlled by each provider. IP locatio is an estimate, not verified physical hardware location. Last reviewed / 最后核对:2026-10-31. +# Additional design reference — 2026-11-01 + +xykt/IPQuality: https://github.com/xykt/IPQuality . Its root LICENSE contains +GNU AGPL v3. We reviewed ip.sh and its mail/DNSBL entry points for functional +coverage and report organization. No implementation, DNSBL list or scraped +provider data from that project is bundled. Our PTR/SMTP probes and overview +renderer are independently written. Provider APIs and any future datasets or +maps require their own terms and licensing review. + +## 1.0 公测新增参考与数据服务(2026-11-01) + +- NextTrace / NTrace-core:https://github.com/nxtrace/NTrace-core ,GPL-3.0;仅参考公开三城运营商测试目标域名的事实和用途,没有复制其 Go 实现、打包二进制或调用其定位鉴权服务。目标参考:https://github.com/nxtrace/NTrace-core/blob/main/fast_trace/basic.go 。 +- CleanIP sh.cd:https://github.com/CleanIP/sh.cd ,MIT,Copyright (c) 2026 CleanIP;参考少量公开测速端点信息和节点失败需诚实展示的设计,没有复制其 shell 执行/上传流程。目录标签不能证明当前运营商、物理位置或服务授权;代码开源不等于测速服务永远开放。 +- VPSDance/vkit:https://github.com/VPSDance/vkit/blob/main/speedtest.sh ,仅作为寻找公开候选端点的研究线索;未复制脚本、打包或执行。未确认许可的代码不作为本项目实现依赖。 +- RIPEstat Data API:https://stat.ripe.net/docs/data-api/ripestat-data-api ,查询 network-info、asn-neighbours、bgp-state。使用 HTTPS,逐次查询、有限响应/超时,不下载整个 BGP 表。结果记录 RIPE RIS 采集器视角,按服务条款和限额使用。 +- 系统 traceroute 是用户系统已有的可选工具。本包不分发、不自动安装,不使用 shell、不自动提权。UDP 模式保持自主标准库实现。 +- 固定测速端点只发送测试载荷,没有订阅、账号或 API 密钥。HTTPS 下载不禁用证书校验、不自动降级 HTTP;小流量明文协议探测单独标明范围。 + +CleanIP 参考数据的许可证声明: + +MIT License + +Copyright (c) 2026 CleanIP + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/USER_CHECKLIST_STATUS.md b/USER_CHECKLIST_STATUS.md new file mode 100644 index 0000000..6df3e24 --- /dev/null +++ b/USER_CHECKLIST_STATUS.md @@ -0,0 +1,102 @@ +> 历史 Alpha/问题状态记录:当前 1.0 公测候选请以 CURRENT_STATUS.md 和 PUBLIC_BETA_CHANGE_REPORT.md 为准;新增代码不等于已完成实测。 + +> 最新Alpha优先项状态见 [ALPHA_TASK_STATUS.md](ALPHA_TASK_STATUS.md)。以下包含本轮前历史统计,不能作为最新发布验收结论。 + +# 用户96项清单逐项状态统计 + +更新:2026-11-01。对应本次用户重新贴出的96项清单,编号为“类别-该类条目序号”;不同于原开发任务书101项登记。 +已完成表示当前明确支持范围内已有实现及对应验证,不表示生产环境或所有场景保证。复合需求仍缺关键部分时列部分完成。 +证据:两版Ubuntu最终各239项自动回归;前版各17项实际CLI验收;新报告导出/恢复实际执行,以及IP/PTR/TCP25实网记录。具体限制见CURRENT_STATUS.md和IPQUALITY_COVERAGE.md。 + +| 类别 | 总数 | 已完成 | 部分完成 | 未实现 | +|---|---:|---:|---:|---:| +| 1. VPS本机与硬件检测 | 7 | 1 | 3 | 3 | +| 2. 网络基础与DNS | 7 | 1 | 5 | 1 | +| 3. Traceroute与线路分析 | 9 | 1 | 6 | 2 | +| 4. IP地图、纯净度与信誉 | 9 | 5 | 3 | 1 | +| 5. 流媒体与在线服务 | 8 | 3 | 4 | 1 | +| 6. 多地区测速与长期稳定性 | 9 | 0 | 5 | 4 | +| 7. 基础安全体检 | 8 | 4 | 4 | 0 | +| 8. 检测结果、诊断与报告 | 10 | 3 | 5 | 2 | +| 9. 安装、兼容性与用户体验 | 10 | 3 | 6 | 1 | +| 10. 可靠性、隐私与测试 | 12 | 4 | 8 | 0 | +| 11. 开源项目与未来复用 | 7 | 2 | 4 | 1 | +| 合计 | 96 | 27 | 53 | 16 | + +当前完成27/96(约28%),部分完成53项,未实现16项。该比例只按条目计数,不按工作量;不等于项目剩余时间或生产就绪度。 + +## 对应条目编号 + +### 1. VPS本机与硬件检测 + +- 已完成:01-03 +- 部分完成:01-01、01-02、01-04 +- 未实现:01-05、01-06、01-07 + +### 2. 网络基础与DNS + +- 已完成:02-02 +- 部分完成:02-01、02-03、02-04、02-05、02-07 +- 未实现:02-06 + +### 3. Traceroute与线路分析 + +- 已完成:03-09 +- 部分完成:03-01、03-02、03-03、03-04、03-05、03-08 +- 未实现:03-06、03-07 + +### 4. IP地图、纯净度与信誉 + +- 已完成:04-01、04-02、04-03、04-04、04-07 +- 部分完成:04-06、04-08、04-09 +- 未实现:04-05 + +### 5. 流媒体与在线服务 + +- 已完成:05-01、05-05、05-07 +- 部分完成:05-02、05-03、05-04、05-06 +- 未实现:05-08 + +### 6. 多地区测速与长期稳定性 + +- 已完成:无 +- 部分完成:06-01、06-02、06-03、06-04、06-05 +- 未实现:06-06、06-07、06-08、06-09 + +### 7. 基础安全体检 + +- 已完成:07-01、07-03、07-05、07-06 +- 部分完成:07-02、07-04、07-07、07-08 +- 未实现:无 + +### 8. 检测结果、诊断与报告 + +- 已完成:08-04、08-06、08-07 +- 部分完成:08-01、08-02、08-03、08-05、08-08 +- 未实现:08-09、08-10 + +### 9. 安装、兼容性与用户体验 + +- 已完成:09-06、09-07、09-08 +- 部分完成:09-01、09-02、09-04、09-05、09-09、09-10 +- 未实现:09-03 + +### 10. 可靠性、隐私与测试 + +- 已完成:10-01、10-02、10-05、10-09 +- 部分完成:10-03、10-04、10-06、10-07、10-08、10-10、10-11、10-12 +- 未实现:无 + +### 11. 开源项目与未来复用 + +- 已完成:11-04、11-06 +- 部分完成:11-01、11-02、11-05、11-07 +- 未实现:11-03 + +## 勾选范围说明 + +01-03:系统可见内存/Swap/根文件系统和可见配额;不是独享资源证明。02-02:到固定目标的IPv4/IPv6连接测试已具备,测试返回失败/未知也是有效检测结果,不表示网络可达验收通过。 +04-01~04:查询并保留各来源提供的字段;字段缺失/冲突不伪装成确定结果。05-01:基础请求/可达性检测,不含账号播放;05-07为明确处理不可自动验证的边界。 +07-01:SSH默认配置中的选定设置;07-05:固定重要路径模式/所有者,不包含ACL/完整目录递归。08-06/07:本地JSON/Markdown与分享导出脱敏功能,仍须人工复核,不保证任意文本匿名。 +09-04仍为部分完成:两台现有Ubuntu虚拟机已验证,但未分别证明每种安装形态和运行环境。10-01仅在当前只读流程和已测可读配置哈希范围内;不是整个系统写入审计。10-09是已有Mock/单元/实际CLI测试机制,不表示所有异常路径已覆盖。 +11-04为现有使用/测试/贡献文档能力;一键安装、更新和卸载仍单独未完成。 diff --git a/docs/history-beta-before-p0-20261101.md b/docs/history-beta-before-p0-20261101.md new file mode 100644 index 0000000..901f10a --- /dev/null +++ b/docs/history-beta-before-p0-20261101.md @@ -0,0 +1,14 @@ +# 当前状态:1.0 公测候选,非最终版本 + +2026-11-01;版本 1.0.0b1。未公开发布、推送或合并主分支。 + +- 基于用户 iprisk-edition 完善界面、语言、颜色、框线和错误来源展示,保留只读、安全安装和恢复机制。 +- CLI 流媒体改为仅网页可达性;内部旧分类函数保留兼容,默认不调用。 +- ICMP/TCP 系统 traceroute 适配、RIPE RIS BGP、三城九方向追踪和三网候选目录已接入代码,Linux/实网验收待执行。 +- 中国测速候选已接入 HTTPS 下载;上海电信、香港移动小图片可访问。大陆联通/移动未测通;香港不计大陆覆盖,三网候选未提供上传。 +- 自动化:Windows/Python 3.12.14,271 通过、1 项符号链接权限跳过。证据:工作区 outputs/public-beta-tests.xml。 +- 用户要求代码完成后停下:本轮未开始 VM 安装、全量联网或大流量测速。旧 Alpha、其他模型测试不能作为本包的新验收证据。 + +96 项表和 Alpha 文档保留历史;本轮没有根据代码接入或测试数量批量打勾。 +03-01/03-06/03-07/06-01 等仍不能标为全部完成。没有全国完整三网、双向探针、长期监测或 Web UI。 +详见 [README](README.md) 和 [本轮变更与待测项](PUBLIC_BETA_CHANGE_REPORT.md)。 diff --git a/docs/pre-alpha-README.en.md b/docs/pre-alpha-README.en.md new file mode 100644 index 0000000..d33c41a --- /dev/null +++ b/docs/pre-alpha-README.en.md @@ -0,0 +1,207 @@ +See [current status](CURRENT_STATUS.md) for implemented and outstanding work. Family-specific DNS/TCP/TLS checks now back suite/quality; untested. + +# Current development scope (2026-11-01) + +This branch develops an independent Python VPS diagnostics CLI. See [backlog](PROBLEM_BACKLOG.md), [architecture](ARCHITECTURE.md), [source research](OPEN_SOURCE_RESEARCH.md), [test plan](TEST_PLAN.md), and [changelog](CHANGELOG.md). The previous 0.5.0 documentation below is historical context. + +Routine `proxydoctor suite` skips CPU and bandwidth benchmarks. Consent to traffic tests with `proxydoctor bandwidth --allow-bandwidth` or `proxydoctor suite --allow-bandwidth`. The displayed payload budget excludes protocol overhead and other checks. Use `suite --offline` to skip network checks. Third-party executable/script wrappers are temporarily disabled. + +These changes have not been tested or released. Earlier test results do not validate this branch. + +--- + +# ProxyDoctor + +[中文](README.md) | [English](README.en.md) + +Native Python read-only VPS diagnostics for Ubuntu 22.04 and newer. Python 3.10+; +no third-party runtime dependencies. Ubuntu 22.04/24.04 are the current acceptance +targets; other versions need separate testing. No automatic sudo, installation, +SSH/firewall changes or fetching/executing remote scripts. + +## Run + +Extract the source package and run from its directory: + +```bash +bash proxydoctor.sh +bash proxydoctor.sh suite --lang en +bash proxydoctor.sh suite --offline --lang en +python3 -m proxydoctor check --lang en --port 8388 --json --output check.json +``` + +Downloading/cloning does not execute code. The default `suite` contacts external +services and streams compact colored rows. `check` defaults to local reads. +Ordinary users can run it; missing privileges remain unknown. Report files stay +local, do not overwrite existing files and redact IPs. Review before sharing; +redaction is not an anonymity guarantee. `--show-ip` displays egress IP in the +terminal only. Routes display actual hop IPs in the terminal; exports mask them. + +## Four modules + +| Module | Terminal output | Limits | +| --- | --- | --- | +| Resources/preflight | OS, architecture, CPU/model/count, kernel, memory, disk, SHA-256 sample; route/DNS config, target port, SSH guard/config, local/cloud firewall status | Visible resources may differ from quotas; SHA-256 is not a general CPU benchmark; local reads cannot establish cloud ingress | +| Network/IP | Real DNS/TCP probes; ownership/ASN/location/type, provider flags/scores, coverage and disagreements | AAAA answers do not prove IPv6 connectivity; provider scores are separate observations, not safety certification | +| Route | Linux IPv4/IPv6 UDP error queue, 30 hops by default, up to 64; IP, latency, estimated location | NAT/filtering or silent routers can prevent completion; geolocation is approximate | +| Media | Ten independent service adapters, repeat confirmation, catalog/metadata evidence and explicit unknown reasons | No paid account/DRM playback verification; public offers/catalogs do not prove full unlock | + +Suite JSON schema_version=2 includes local checks, DNS/TCP, per-source bandwidth, +IP observations/provenance/conflicts, route hops and media probe evidence. +Green indicates acquired data/local success, red known warnings/restrictions, +yellow unknown/skipped/insufficient evidence. `--color auto|always|never` and +`NO_COLOR` are supported. Use `check --json` for detailed local evidence. +`sshd -T` defaults may differ from running instances/Match rules. Future repair +features still require separate SSH safeguards. + +## Bandwidth + +```bash +bash proxydoctor.sh bandwidth --lang en +bash proxydoctor.sh bandwidth --lang en --source ovh-rbx --rounds 3 --sample-mib 4 +bash proxydoctor.sh bandwidth --lang en --source hetzner-fsn --sample-mib 1 +bash proxydoctor.sh suite --lang en --bandwidth-source cloudflare +``` + +| ID | Source/node label | Download | Upload | Default | +| --- | --- | --- | --- | --- | +| cloudflare | Cloudflare / Anycast | Yes | Yes | Yes | +| ovh-rbx | OVHcloud / FR Roubaix | Yes | No | Yes | +| hetzner-fsn | Hetzner / DE Falkenstein | Yes | No | No; slow on current test network | + +Excluded 128 KiB warmup per supported direction; 3–8 measured rounds of 1–20 MiB. +Default requested benchmark payload is at most 36.375 MiB, excluding protocol +and other module traffic. Medians/ranges/success counts stay separate per source. +Short samples (<1s), missing rounds and high variation stay yellow. Wall-time +HTTPS throughput includes connection setup and cannot verify advertised capacity. +Static files require exact HTTP 206 ranges; ignored Range responses are closed +without reading the full body, never falling back to a full-file download. +Unsupported upload is skipped, not counted as failure. Bandwidth JSON schema=2. + +## IP sources and keys + +```bash +bash proxydoctor.sh ipquality --lang en --show-ip --json-output ip.json +bash proxydoctor.sh ipquality --lang en --no-enrichment +bash proxydoctor.sh suite --lang en --no-ip-enrichment +``` + +Defaults: ipapi.is, proxycheck.io and IP2Location. On an ipapi.is transport failure, +ipify can provide an egress address. Subsequent sources query that exact address; +mismatched responses are rejected. These services see the source address and the +additional sources receive the selected egress IP. Disable extra sources with +`--no-ip-enrichment` or all suite networking with `--offline`. + +Seventeen core fields cover ownership/ASN, location/timezone, type/network abuse +share and datacenter/proxy/VPN/Tor/recent-abuse/mobile/satellite/anycast/bogon flags. +Per-source observations and field provenance are retained. Conflicting booleans +become unknown with disagreement evidence. IP2Location's limited free-plan proxy +flag is kept separate, not converted into a broad VPN guarantee. proxycheck.io +risk/confidence and AbuseIPDB scores are not averaged. Network abuse share is not +a single-IP maliciousness probability. + +Keyless limits currently include 100 queries/day for proxycheck.io and 1000/day +for IP2Location, subject to provider rules. No automatic quota retries. Anonymous +ipapi.is has limited fields; optional keys provide extended fields/AbuseIPDB reports. +Enter locally without echoing; never send keys to chat or commit them: + +```bash +read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY +export PROXYDOCTOR_IPAPI_KEY +printf '\n' +read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY +export PROXYDOCTOR_ABUSEIPDB_KEY +printf '\n' +bash proxydoctor.sh ipquality --lang en +``` + +ipapi.is keys travel in HTTPS POST JSON; AbuseIPDB keys use headers, not URLs or +reports. Missing fields remain unknown. Complete coverage means defined fields, +not every database or a security certification. Zero reports do not guarantee +safety. Observed API egress/family may differ from media connections. + +## Route and media + +```bash +bash proxydoctor.sh suite --lang en --max-hops 64 --route-target 1.1.1.1 +bash proxydoctor.sh suite --lang en --route-target 2606:4700:4700::1111 --no-geo +bash proxydoctor.sh media --lang en --json-output media.json +``` + +ipwho.is geolocation is cached per run; private/reserved hops are never queried. +Media covers Netflix, Disney+, YouTube Premium, Spotify, Prime Video, Max, Apple +TV+, Hulu, BBC iPlayer and Paramount+. Clear regional messages and visible priced +offers require repeat agreement. Netflix uses structured catalog metadata; BBC +also checks public test-channel metadata. No media segments/paid content are +fetched and signed media URLs are not retained. BBC's channel result is scoped +to that channel. HTTP 200/403 or catalog/offer visibility never proves playback. + +## Exit codes + +`check`: 0=no known warning/failure (unknowns may remain), 1=findings, 2=usage/export +error. Online suite/bandwidth/ipquality/media: 1 also covers incomplete/unknown/ +variable measurements or unverified playback. Offline 0 means local execution +completed, not online success. Media intentionally leaves playback untested and +returns 1. Ctrl+C returns 130 without changing system settings. + +## Install and contribute + +Source runs directly. Install the supplied wheel into a local virtual environment: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl +.venv/bin/proxydoctor suite --offline --lang en +``` + +If Ubuntu lacks venv support, install its matching OS component separately; +ProxyDoctor never installs it automatically. Development: install pytest and run +`python -m pytest -q`. An Actions workflow is supplied; remote CI is not claimed +before repository publication. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). + +## Stage 1 and scope + +This iteration targets code plus Ubuntu 22.04/24.04 VM acceptance as selected by +the user; consult the delivered acceptance records for actual results. Overseas +VPS tests, real-key extended data, paid-account playback and other Ubuntu releases +remain separately unverified. Later stages cover sing-box installation, Karing +protocol validation, repair previews/backups/rollback, Web UI and secure subscription +transport. No public plaintext subscription server or automatic SSH/firewall changes. + +## License and acknowledgements + +Original code is MIT. Thanks to ECS, NodeQuality, NetQuality/IPQuality, NextTrace +and RegionRestrictionCheck for feature/presentation references. No upstream script +source/databases are bundled by the default implementation. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +Explicit local-script adapters are opt-in; external behavior/output may not be +read-only or redacted. + +Official data references: [Cloudflare](https://github.com/cloudflare/speedtest), +[OVHcloud](https://proof.ovh.net/), [Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/), +[ipapi.is](https://ipapi.is/developers.html), [proxycheck.io](https://proxycheck.io/api/), +[IP2Location](https://www.ip2location.io/ip2location-documentation), [AbuseIPDB](https://docs.abuseipdb.com/), +[ipwho.is](https://ipwhois.io/documentation), [ipify](https://www.ipify.org/). +[Netflix VPN guidance](https://help.netflix.com/en/node/114701) explains catalog restrictions. + + +## Reliability batch — 2026-11-01 (implemented, untested) + +`suite` now creates a local redacted JSON report and sibling `.events.jsonl` recovery journal. Without `--json-output`, files are placed under `./reports/` with unique names. Detection does not modify system configuration, but does write these local output files. Linux file creation requests mode 0600; Windows uses inherited ACLs. + +Each module start/completion and completed DNS/TCP result, route hop, and media service writes a flushed/fsynced snapshot. Module exceptions use `MODULE_ERROR` without storing raw exception text; other modules continue. `success` in `modules` means the module executed, not that a service is unlocked or a server is safe. Overall partial measurement remains incomplete. Ctrl+C records cancellation and returns 130. Previously completed journal records remain useful after a crash; use the Python `proxydoctor.reports.recover_latest(path)` API to read the latest complete snapshot. + +Existing final/journal files and dangling final symlinks are rejected. Storage errors during checks produce a terminal warning, keep prior records, and return a nonzero result. The final JSON is exclusively created; a failed final write can leave a partial final file, so recover from the journal. No absolute durability guarantee applies to disk/controller failure. Storage paths must be in user-controlled directories; parent-directory symlink attacks are outside this first implementation. + +Limits: module-level isolation does not isolate every individual local system check. Bandwidth and IP collection currently checkpoint on module completion; interruption can lose the current module's unfinished results. Media cancellation may wait for in-flight bounded network calls when the thread pool exits. SIGKILL/power loss cannot trigger cleanup; the journal contains only the last complete checkpoint. Standalone commands retain their existing save behavior. These changes and the new fault-injection tests have not been run yet. + +## Resource observations (unverified) + +`suite` and `check` now include visible CPU quotas, process affinity, visible memory bounds, swap, PSI and short-sample CPU steal. Numeric results are preserved separately from terminal language. The default 0.2-second passive observation does not benchmark the CPU or change cgroup settings. Missing data remains unknown. Visible limits do not establish dedicated capacity; namespace/host scope and long-term contention require separate verification. New tests have not been run. + +Basic security now inspects fixed-path ownership/mode metadata and optional local update-cache age/counts. Password-file contents are not read. Review findings manually; cached counts do not establish patch status. Untested. + +## Local report export and IP overview + +Use `proxydoctor report --input report.json --output report.md` to export a local snapshot; `proxydoctor recover --journal report.json.events.jsonl --output recovered.json` recovers the last complete record. Add `--format markdown --lang en` for English Markdown recovery. Files are never overwritten. Inspect exports before sharing. + +`proxydoctor ipquality --mail-probes` opts into bounded PTR/forward consistency and one outbound TCP/25 target without sending mail. The overview panel precedes provider evidence. DNSBL and IP maps are not yet integrated; see IPQUALITY_COVERAGE.md. Final code passed 239 tests on each Ubuntu VM. diff --git a/docs/pre-alpha-README.md b/docs/pre-alpha-README.md new file mode 100644 index 0000000..12bf0e9 --- /dev/null +++ b/docs/pre-alpha-README.md @@ -0,0 +1,200 @@ +最新逐类完成情况和未完成重点见 [当前进度](CURRENT_STATUS.md)。新增双栈 DNS/TCP/TLS 已接入 suite/quality,尚未验证。 + +# 当前开发范围(2026-11-01) + +资源首页优先显示本进程 CPU 上限、可见内存限额及其依据。已识别的容器如果没有可读限额,不以宿主机总内存充当自己的额度。系统原始值保留在 JSON 详细依据中;CPU/内存是否独享统一标注“无法从本机确认”。容器识别仍有遗漏可能,KVM 中的系统可见内存也不等于已验证商家独享保障。此轮显示调整未测试。 + +本分支按新的 [开发任务书](DEVELOPMENT_BRIEF.zh.md) 推进独立 Python VPS 综合诊断 CLI。代理部署、Web UI 不在当前范围。下面原 0.5.0 说明保留作背景;最新实际状态以 [问题追踪](PROBLEM_BACKLOG.md)、[架构](ARCHITECTURE.md)、[源码研究](OPEN_SOURCE_RESEARCH.md)、[测试计划](TEST_PLAN.md) 和 [更新记录](CHANGELOG.md) 为准。 + +常规 `bash proxydoctor.sh` / `proxydoctor suite` 默认跳过 CPU 和带宽基准。需要测速时,使用 `proxydoctor bandwidth --allow-bandwidth` 或 `proxydoctor suite --allow-bandwidth`;执行前显示请求载荷预算,协议开销与其他模块流量另计。`proxydoctor suite --offline` 跳过联网。第三方脚本/可执行文件包装入口暂时禁用。 + +本分支修改尚未运行测试,不继承旧版测试通过结论,也尚未发布。 + +--- + +# ProxyDoctor + +[中文](README.md) | [English](README.en.md) + +面向 Ubuntu VPS 新手的原生 Python 只读诊断工具。支持 Ubuntu 22.04 及更新版本; +当前优先验收 22.04、24.04,其他版本逐步实测。Python 3.10+,没有第三方运行依赖。 +本阶段不安装 sing-box,不修改 SSH/防火墙,不自动提权,不下载执行远程脚本。 + +## 快速运行 + +下载源码包并解压,进入项目目录: + +```bash +bash proxydoctor.sh +bash proxydoctor.sh suite --lang en +bash proxydoctor.sh suite --offline +``` + +下载或克隆本身不执行程序。默认入口 `suite` 会联网、逐项回显; +`check` 默认只读取本机信息。普通用户可运行,权限不足显示未知;程序不调用 sudo。 + +```bash +python3 -m proxydoctor check +python3 -m proxydoctor check --port 8388 --json --output check.json +python3 -m proxydoctor suite --json-output suite.json --color always +``` + +报告文件不会覆盖已有文件,也不会自动创建目录。原生报告仅保存在本地,IP 默认脱敏; +脱敏不是匿名保证,分享前仍应检查。`--show-ip` 可在终端显示出口地址,文件仍脱敏。 +路由模块按用户要求在终端显示实际节点 IP,导出文件中隐藏。 + +## 四模块 + +| 模块 | 直接回显 | 边界 | +| --- | --- | --- | +| VPS 资源与体检 | 系统、架构、CPU、逻辑核心、内核、内存、磁盘、SHA-256 样本;DNS 配置、端口、SSH 保护、SSH 设置和防火墙提示 | 系统可见资源不保证等于商家配额;SHA-256 不是完整 CPU 基准;本机读取不能确认云防火墙放行 | +| 节点网络/IP 质量 | 实际 DNS、TCP 请求,IP 归属/ASN/地区/类型、风险标记、来源状态、覆盖率和分歧 | DNS 返回 IPv6 不代表 IPv6 连通;第三方标记不是客观安全保证,不合并不同评分 | +| 路由 | 原生 Linux IPv4/IPv6 UDP 追踪,默认 30 跳,可至 64;每行 IP、延迟、估计位置 | 中间节点可能不回包,位置是数据库估计,不保证真实物理位置;NAT/过滤可能导致未到达 | +| 流媒体 | Netflix、Disney+、YouTube Premium、Spotify、Prime Video、Max、Apple TV+、Hulu、BBC iPlayer、Paramount+ | 明确地区提示/套餐证据重复确认;片库和元数据不等于账号/DRM 实际播放;规则失效和网络失败显示未知 | + +JSON 综合报告 schema_version=2,同时包含本机检查、DNS/TCP、每个测速来源、IP 来源观察、路由逐跳及媒体探测依据。 +绿色表示取得结果或局部通过,红色表示已知风险/限制/冲突,黄色表示未知、跳过或证据不足。 +支持 `--color auto|always|never` 和 `NO_COLOR`。复杂配置的完整依据可用 `check --json` 查看。 +`sshd -T` 读取默认配置,不保证与运行中实例或 Match 条件相同。安装与修复阶段仍须独立保护 SSH。 + +## 多来源测速 + +```bash +bash proxydoctor.sh bandwidth +bash proxydoctor.sh bandwidth --source ovh-rbx --rounds 3 --sample-mib 4 +bash proxydoctor.sh bandwidth --source hetzner-fsn --sample-mib 1 +bash proxydoctor.sh suite --bandwidth-source cloudflare +``` + +| 来源参数 | 节点标签 | 下载 | 上传 | 默认 | +| --- | --- | --- | --- | --- | +| cloudflare | Cloudflare / Anycast | 是 | 是 | 是 | +| ovh-rbx | OVHcloud / 法国 Roubaix | 是 | 不支持 | 是 | +| hetzner-fsn | Hetzner / 德国 Falkenstein | 是 | 不支持 | 否,当前网络较慢 | + +每方向 128 KiB 预热不计统计;正式 3–8 轮、每轮 1–20 MiB。默认请求测速载荷 +最多 36.375 MiB,不含协议开销及其他模块请求。各来源独立给出中位数、范围、成功数; +不做跨来源平均。样本短于 1 秒、轮次不足或波动大显示黄色。吞吐包含建连,不能核验商家标称带宽。 +对大文件只接受精确 HTTP 206 分段响应;若服务器忽略 Range,不读取整文件正文,不回退完整下载。 +测速超过时限或未完成不记为成功;不支持上传不记为上传失败。带宽子报告 schema_version=2。 + +## IP 数据与密钥 + +```bash +bash proxydoctor.sh ipquality --show-ip --json-output ip.json +bash proxydoctor.sh ipquality --no-enrichment +bash proxydoctor.sh suite --no-ip-enrichment +``` + +默认查询 ipapi.is、proxycheck.io 和 IP2Location。若 ipapi.is 连接失败,尝试 ipify 获取出口 IP。 +后续查询固定为该地址,拒绝把其他地址的响应混入结果。所有这些服务会看到请求来源, +另外两家会收到查询的出口地址。可用 `--no-ip-enrichment` 关闭额外来源,`--offline` 关闭综合入口联网。 + +17 项核心字段包括运营商/ASN、地区/时区、网络类型、网段滥用比例,以及数据中心、代理、VPN、 +Tor、近期滥用、移动、卫星、Anycast、保留地址标记。按来源记录观察和字段来源;布尔标记冲突显示 +“来源不一致”,不会自动选成安全。IP2Location 免费模式的有限代理标记单独保留,不替代通用 VPN 判断。 +proxycheck.io 风险/置信度与 AbuseIPDB 评分分别保存,不求平均,不把网段比例当作单 IP 恶意概率。 + +免密钥 API 也有配额:proxycheck.io 当前限 100 次/天,IP2Location 当前限 1000 次/天;以服务官方规则为准。 +遇到限流不自动重试。ipapi.is 匿名响应只提供基础数据;扩展字段和 AbuseIPDB 举报数据可通过密钥补充。 +不要把密钥发到聊天、提交到仓库或放进命令参数;在 VPS 本地无回显输入: + +```bash +read -rsp 'ipapi.is API Key: ' PROXYDOCTOR_IPAPI_KEY +export PROXYDOCTOR_IPAPI_KEY +printf '\n' +read -rsp 'AbuseIPDB API Key: ' PROXYDOCTOR_ABUSEIPDB_KEY +export PROXYDOCTOR_ABUSEIPDB_KEY +printf '\n' +bash proxydoctor.sh ipquality +``` + +ipapi.is 密钥放 HTTPS POST 正文,AbuseIPDB 放请求头,不写入 URL 或报告。 +缺少字段和密钥明确显示未知;完整覆盖率只针对定义的核心字段,不代表所有数据库或安全认证。 +零举报不代表干净 IP。只确认本次接口请求的出口和地址族,不保证每个服务连接都使用同一出口。 + +## 路由与媒体复测 + +```bash +bash proxydoctor.sh suite --route-target 1.1.1.1 --max-hops 64 +bash proxydoctor.sh suite --route-target 2606:4700:4700::1111 --no-geo +bash proxydoctor.sh media --color always --json-output media.json +``` + +路由位置来自 ipwho.is,按运行缓存;私网/保留节点不查询。`--no-geo` 关闭位置查询。 +媒体重复确认地区提示/带价格的具体套餐;Netflix 检查结构化片库元数据,BBC 额外查询测试频道的公开媒体元数据。 +不获取付费节目、不下载流媒体、不绕过登录/DRM,不保留媒体签名地址。BBC 的测试频道结果不代表整个平台。 +HTTP 200、403、页面可达、套餐或片库可见均不作为“完全解锁”证据。 + +## 退出码 + +- `check`:0=无已知注意/失败项,仍可能含未知;1=有注意/失败项;2=参数/保存错误。 +- `suite/bandwidth/ipquality/media`:0=该命令定义的范围完整,离线 0 仅表示本地执行完成;1=有未完成/未知/波动或未验证播放;2=参数/保存错误。 +- 流媒体命令仍保留“账号播放未测”,因此返回 1;这不表示程序崩溃。 +- Ctrl+C 返回 130,不改系统设置。 + +## 安装与开发 + +源码可直接运行。也可把提供的 wheel 安装到自己的虚拟环境: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install /path/to/proxydoctor-0.5.0-py3-none-any.whl +.venv/bin/proxydoctor suite --offline +``` + +Ubuntu 若未安装 venv 支持,需另行安装系统对应组件;程序不会自动安装。 +开发测试:`python -m pip install pytest` 后 `python -m pytest -q`。 +GitHub Actions 配置已提供,未发布仓库前不宣称远程 CI 已运行。 +贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),安全反馈见 [SECURITY.md](SECURITY.md)。 + +## 阶段一验收与后续 + +本轮按用户选择完成代码和 22.04/24.04 虚拟机验收;实际记录以交付验收报告为准。 +海外 VPS、真实付费账号播放、带真实密钥的扩展数据,以及 Ubuntu 其他版本仍待分别验收;不会把它们标为已通过。 +之后阶段才实现 sing-box 安装、Karing 协议连通、修复预览/备份/回滚、Web UI 和安全订阅传输。 +不对外开放明文 HTTP 订阅,不根据只读体检自动修改 SSH/防火墙。 + +## 开源与来源 + +原创代码使用 MIT 许可证。感谢 ECS、NodeQuality、NetQuality/IPQuality、NextTrace、RegionRestrictionCheck 的功能和展示设计参考; +默认实现没有打包或复制上游脚本/数据库。来源和许可范围见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 +本地第三方适配器仅在显式指定时运行,第三方行为/输出不保证只读或脱敏。 + +数据接口:[Cloudflare](https://github.com/cloudflare/speedtest)、[OVHcloud](https://proof.ovh.net/)、 +[Hetzner](https://docs.hetzner.com/storage/object-storage/troubleshooting/bandwidth-latency-issues/)、 +[ipapi.is](https://ipapi.is/developers.html)、[proxycheck.io](https://proxycheck.io/api/)、 +[IP2Location](https://www.ip2location.io/ip2location-documentation)、[AbuseIPDB](https://docs.abuseipdb.com/)、 +[ipwho.is](https://ipwhois.io/documentation)、[ipify](https://www.ipify.org/)。 +[Netflix 官方 VPN 说明](https://help.netflix.com/en/node/114701) 也说明了目录限制,不保证页面可达就能播放。 + +## 增量保存与故障隔离(实现未测试) + +综合检测默认在当前目录的 `reports/` 保存脱敏 JSON 和 `.events.jsonl` 恢复日志,也可用 `--json-output` 指定文件。常规体检不修改系统配置,但会写入这些本地报告。已存在的报告和日志不会覆盖。 + +各模块独立记录异常后继续;DNS/TCP、路由跳、媒体结果完成后及时保存。Ctrl+C 会尝试保存并返回 130。磁盘写入失败会提示并继续终端检测;恢复时以日志中最后完整的记录为准。模块 success 只表示执行完成,不代表服务解锁或服务器安全。 + +本轮新增故障注入测试但未执行。测速/IP 查询尚不能保存模块内部所有未完成样本;媒体取消可能等待正在进行的请求结束;强制终止只能恢复最后一次完整记录。详见 TEST_PLAN.md。 + +## 资源检测新功能(未验证) + +`suite` 和 `check` 增加 CPU 可见配额、进程 CPU 范围、内存可见上限、Swap、PSI 压力和短采样 CPU steal。结果直接显示在终端,并保留结构化数值。默认被动观察约 0.2 秒,不跑基准、不写 cgroup 设置。 + +缺失数据标为未知;可见配额不等于独享资源,短采样不代表长期状况。完整限制和后续验收见 TEST_PLAN.md。 + +基础安全新增重要路径权限/所有者与更新缓存年龄/计数,只读且不读取密码文件内容。风险提示需人工审阅,缓存不证明完整修补;未测试。 + + +## IP概览、Markdown和恢复命令 + +```bash +# 终端集中显示IP信息;额外邮件探测为可选,不发送邮件 +bash proxydoctor.sh ipquality --mail-probes --show-ip +# 从已有JSON导出,不重新检测 +bash proxydoctor.sh report --input reports/example.json --output report.md +# 恢复最后完整记录,输出文件须不存在 +bash proxydoctor.sh recover --journal reports/example.json.events.jsonl --output recovered.json +bash proxydoctor.sh recover --journal reports/example.json.events.jsonl --output recovered.md --format markdown --lang en +``` + +`--show-ip`只改变终端IP显示,文件仍脱敏。顶部是概览,详细数据源在下面。DNSBL与IPMap尚未接入,覆盖和限制见 [IP质量功能表](IPQUALITY_COVERAGE.md)。不要将来源评分或PTR/端口结果视为绝对安全/邮件可投递证明。 diff --git a/docs/status-history-before-2026-11-01.md b/docs/status-history-before-2026-11-01.md new file mode 100644 index 0000000..daf64cd --- /dev/null +++ b/docs/status-history-before-2026-11-01.md @@ -0,0 +1,58 @@ + +## 报告与IP质量更新(2026-11-01) + +- 新增离线 `report` 命令:JSON→中英文Markdown,保留指标、来源、未知和跳过原因;输出独占创建,HTML/Markdown注入内容转义。 +- 新增 `recover` 命令:恢复日志最后完整快照→JSON/Markdown;未完成运行标为recovered_partial,明确不是新检测。输入有读取预算。 +- 分享副本增加敏感键、Bearer凭证、URL用户信息/查询参数脱敏;仍需人工复核,不保证任意文本完全匿名。 +- 新增IP概览面板,CJK对齐与长行换行;多来源类型/代理/VPN/Tor证据放在下面,不合成纯净度评分。 +- 新增可选 `--mail-probes`:PTR/正向一致性、单目标出站TCP25,默认不启用,不发送邮件。DNSBL明确未查询;IPMap尚未接入。 +- 实际阅读xykt/IPQuality源码与AGPL许可,只参考需求与组织,未复制实现,详见IPQUALITY_COVERAGE.md。 +- 报告/恢复/PTR实网版本两版Ubuntu均237测试通过;最终面板加入后,两版完整离线回归均239测试通过(22:4.04秒;24:4.49秒)。报告导出、恢复JSON/Markdown、中英文离线真实CLI均退出0。IP数据不完整时退出1符合预期。 +- 实网:22的单目标出站TCP25可连接,24未确认;两台PTR未取得有效结果,不能据此判断邮件信誉。无付费API、系统依赖安装或完整报告上传。 + +证据:工作区outputs/ReportIP22-*、ReportIP24-*(真实导出/探测),PanelFinal22-pytest.txt / PanelFinal24-pytest.txt(最终239测试)。此前223测试记录属于前版,不覆盖所有新增功能。 + + +## 最新双机验收(2026-11-01) + +同版代码已在Ubuntu22.04.5/Python3.10.12和Ubuntu24.04.3/Python3.12.3分别完成223项测试、17/17实际CLI验收。中文/英文离线、Ctrl+C保存、恢复和防覆盖通过;两台实网检测均完整执行但存在目标TCP/IPv6/媒体未确认项,不能说网络全部通过。22.04初次验收脚本内存预期错误,按VirtualBox独立配置修正后复测通过。完整范围及原始证据见工作区outputs/双虚拟机-当前版本验收报告.md。此前未测试段落属于编写时状态,最新以本节为准。测试通过不表示全部101项需求完成。 + + +## Ubuntu 24.04 实测更新(2026-11-01) + +当前分支在Ubuntu24.04.3/Python3.12.3完成223项测试、17/17实际CLI验收。 +中英文离线、默认测速拦截、Ctrl+C保存和防覆盖通过;网络实测正常执行但结果incomplete。 +DNS有结果,固定IPv4 TCP超时、IPv6不可达、TLS跳过;30跳路由未到达目标,媒体多数未知。 +综合报告schema版本升为3。此前“尚未测试”的段落是编写时记录;最新验证以本节为准。 +完整日志/范围见工作区outputs/Current24-验收报告.md。Ubuntu22新版、真实VPS及未实现项目仍待验收。 + +# 当前目标与实际进度 + +更新:2026-11-01。范围:独立 Python CLI,兼容目标 Ubuntu 22.04 及以上;当前不开发代理部署和 Web UI。 + +**重要:新增分支全部未测试。旧 0.5.0 的 outputs 验收记录仅属于旧版,不能视为新目标完成证据。没有计算虚假的完成百分比。** + +| 类别 | 已有/新增源码能力 | 未完成重点 | +|---|---|---| +| 1 本机资源 | OS/内核/CPU/磁盘;新增 cgroup 配额、affinity、Swap、PSI、steal,优先展示额度而非宿主量 | 虚拟化细分、CPU 频率、购买规格对比、CPU/fio 基准;无法从本机证明商家独享承诺 | +| 2 基础网络 | 接口/路由/DNS;本轮新增分 IPv4/IPv6 的多次 DNS、固定数字 IP TCP、验证证书的 TLS、分层错误码与提示 | UDP、PMTU、更完整多网卡/NAT/双栈诊断、全局时限;新实现未验证 | +| 3 路由/BGP | 现有原生 v4/v6 UDP 追踪,最多64跳、IP/RTT/估计位置 | TCP/ICMP、多轮统计、逐跳 ASN、中国运营商特征与 BGP;中国侧探针缺失 | +| 4 IP 质量 | 多来源归属/风险/冲突,保留证据与来源 | DNSBL/PTR/邮件信誉、条款与限额全面审查、真实密钥验证 | +| 5 流媒体 | 多服务保守规则;片库/地区/未知不等于播放 | 新站点规则维护、自定义合法目标、更多实网证据;账号/DRM 未验证 | +| 6 测速与监测 | 多来源 HTTPS 多轮测速;显式许可、载荷预算 | 可信中国运营商节点、iperf3、抖动/丢包统计、24/48h监测与趋势关联 | +| 7 安全体检 | SSH、端口与防火墙只读;新增固定重要路径权限、所有者、更新缓存计数/年龄;接入suite/check | 全目录/ACL/包版本安全审计尚缺;新增代码未测试;不自动修复 | +| 8 报告与历史 | JSON/IP脱敏;新增模块及逐项恢复日志、Ctrl+C保存、独占创建 | Markdown/HTML、历史比较、购买验收报告、完整恢复CLI、所有模块细粒度保存 | +| 9 安装体验 | CLI入口、双语、Python>=3.10、无运行依赖 | 隔离一键安装/更新/卸载/校验、ARM和其他发行版实测 | +| 10 可靠性 | 新增异常隔离、错误码、取消与保存降级、多个故障注入fixture | 所有新增测试和回归尚未运行;系统级全局预算、更多异常场景、双Ubuntu及真实VPS复验 | +| 11 开源/API | 101项登记;架构/测试/研究记录、MIT/第三方声明;新增独立资源/网络API | 统一稳定Schema、所有核心/UI彻底解耦、Issue模板、完整许可/依赖审计;FlexMaster兼容未验收 | + +## 已编写的优先工作 + +1. P0:默认无CPU基准、测速许可、暂停第三方可上传流程。 +2. P0:模块隔离、本地增量恢复日志、Ctrl+C保存、写入失败提示。 +3. P1:资源配额/压力及面向VPS额度的显示。 +4. P1:双栈 DNS/TCP/TLS 证据和针对性诊断(本轮)。 + +以上为实现进度,均非测试通过结论。先完成重点低风险源码,再按 TEST_PLAN.md 统一运行单元/回归与虚拟机验收。后续优先:统一报告/Markdown→历史与购买规格对比;基础权限/更新缓存已编写但更深安全审计仍待补齐;CPU/磁盘/长期测速等需要明确预算和单独许可。 + +5. P1:重要文件权限、更新缓存证据与安全提示已编写,未验证。 diff --git a/install.py b/install.py new file mode 100644 index 0000000..a65082b --- /dev/null +++ b/install.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Offline, dependency-free installer. Review this file before running it.""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import sys + +VERSION = '1.0.0b1' + + +def verify(source): + manifest = json.loads((source/'MANIFEST.sha256.json').read_text(encoding='utf-8')) + if manifest.get('version') != VERSION or not isinstance(manifest.get('files'), dict): + raise ValueError('Invalid release manifest') + files = manifest['files'] + required = {'install.py','pyproject.toml','LICENSE','THIRD_PARTY_NOTICES.md', + 'proxydoctor/__init__.py','proxydoctor/__main__.py','proxydoctor/cli.py'} + if not required <= set(files): + raise ValueError('Incomplete release manifest') + actual = {p.relative_to(source).as_posix() for p in (source/'proxydoctor').rglob('*.py')} + if actual != {name for name in files if name.startswith('proxydoctor/') and name.endswith('.py')}: + raise ValueError('Unlisted or missing package source') + for name, expected in files.items(): + relative = PurePosixPath(name) + if relative.is_absolute() or '..' in relative.parts or '\\' in name: + raise ValueError('Unsafe manifest path') + path = source.joinpath(*relative.parts) + if any(part.is_symlink() for part in (path,*path.parents) if part != source.parent): + raise ValueError('Symlink in source path') + if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected: + raise ValueError('Release integrity check failed') + return files + + +def install(source, prefix): + if sys.version_info < (3,10) or sys.platform != 'linux': + raise ValueError('Requires Linux and Python 3.10+') + files = verify(source) + prefix = prefix.expanduser().absolute() + if prefix.is_symlink() or os.path.lexists(prefix): + raise FileExistsError('Install destination already exists; choose a new version directory') + prefix.parent.mkdir(parents=True,exist_ok=True) + prefix.mkdir(mode=0o700) + owned = prefix.resolve() + try: + # --without-pip avoids ensurepip/network/package-manager dependencies. + subprocess.run([sys.executable,'-I','-B','-m','venv','--without-pip',str(prefix)], + check=True,timeout=45,capture_output=True) + python = prefix/'bin/python' + probe = subprocess.run([str(python),'-I','-B','-c', + 'import json,sys,sysconfig;print(json.dumps([sys.prefix,sys.base_prefix,sysconfig.get_path("purelib")]))'], + check=True,capture_output=True,text=True,timeout=10) + active,base,library = json.loads(probe.stdout) + if active == base or Path(active).resolve() != owned: + raise ValueError('Virtual environment isolation check failed') + library = Path(library) + library.resolve().relative_to(owned) + for name in files: + if name.startswith('proxydoctor/') and name.endswith('.py'): + destination = library/name + destination.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(source/name,destination) + notices = prefix/'share/proxydoctor' + notices.mkdir(parents=True) + for name in ('LICENSE','THIRD_PARTY_NOTICES.md','MANIFEST.sha256.json'): + shutil.copyfile(source/name,notices/name) + launcher = prefix/'bin/proxydoctor' + # execv avoids shebang/path whitespace issues without shell interpolation. + launcher.write_text('#!/bin/sh\nexec '+__import__('shlex').quote(str(python))+' -I -B -m proxydoctor "$@"\n',encoding='utf-8') + launcher.chmod(0o700) + subprocess.run([str(launcher),'--version'],check=True,timeout=10,capture_output=True) + (prefix/'proxydoctor-install.json').write_text(json.dumps({'version':VERSION,'isolated':True}),encoding='utf-8') + return launcher + except BaseException: + # Only remove the new directory reserved by this invocation. + if not prefix.is_symlink() and prefix.resolve() == owned: + shutil.rmtree(prefix) + raise + + +def main(): + parser=argparse.ArgumentParser(description='Offline isolated ProxyDoctor installer / 离线隔离安装') + parser.add_argument('--prefix',type=Path,default=Path.home()/'.local/share/proxydoctor'/VERSION) + parser.add_argument('--run',action='store_true',help='Install and run a read-only offline check') + args=parser.parse_args() + try: + launcher=install(Path(__file__).resolve().parent,args.prefix) + except KeyboardInterrupt: + print('Installation cancelled; new environment cleaned / 安装已取消',file=sys.stderr) + return 130 + except (OSError,ValueError,subprocess.SubprocessError): + print('Installation failed. Check Python/venv availability, release checksum and destination. No system packages were installed. / 安装失败,请检查环境、校验和与目录。',file=sys.stderr) + return 2 + print('Installed / 已安装:'+str(launcher),flush=True) + print('1.0 public beta; not the final release / 1.0 公测版,非最终版本',flush=True) + if args.run: + return subprocess.call([str(launcher),'suite','--offline']) + return 0 + + +if __name__=='__main__': + sys.exit(main()) diff --git a/proxydoctor.sh b/proxydoctor.sh index 5b2c31b..d25ea45 100644 --- a/proxydoctor.sh +++ b/proxydoctor.sh @@ -6,6 +6,6 @@ if ! command -v python3 >/dev/null 2>&1; then exit 2 fi if [ "$#" -eq 0 ]; then - exec python3 -u -m proxydoctor suite + exec python3 -B -u -m proxydoctor suite fi -exec python3 -u -m proxydoctor "$@" +exec python3 -B -u -m proxydoctor "$@" diff --git a/proxydoctor/__init__.py b/proxydoctor/__init__.py index 442ad3e..2f856be 100644 --- a/proxydoctor/__init__.py +++ b/proxydoctor/__init__.py @@ -1,3 +1,8 @@ """ProxyDoctor: collect evidence before proposing changes.""" -__version__ = "0.5.0" +__version__ = "1.0.0b1" + + +def release_label(lang='zh'): + return ('VPS 检测工具 · 1.0 公测版(非最终版本)' if lang == 'zh' else + 'VPS Check · 1.0 public beta (not the final release)') diff --git a/proxydoctor/__main__.py b/proxydoctor/__main__.py index e950ada..175c97b 100644 --- a/proxydoctor/__main__.py +++ b/proxydoctor/__main__.py @@ -1,4 +1,14 @@ from .cli import main +import signal + +def cancelled(signum, frame): + from .worker import cancel_all + cancel_all() + raise KeyboardInterrupt + +if hasattr(signal,'SIGTERM'): + signal.signal(signal.SIGTERM,cancelled) +signal.signal(signal.SIGINT,cancelled) try: main() diff --git a/proxydoctor/bandwidth.py b/proxydoctor/bandwidth.py index 95f0549..7f3c5dc 100644 --- a/proxydoctor/bandwidth.py +++ b/proxydoctor/bandwidth.py @@ -18,6 +18,9 @@ class Source: download_url: str upload_url: str | None = None use_range: bool = False + capped_stream: bool = False + carrier: str | None = None + mainland: bool | None = None # Fixed official test endpoints only. No arbitrary URLs, account keys or binaries. @@ -26,7 +29,16 @@ SOURCES = { 'https://speed.cloudflare.com/__up'), 'hetzner-fsn': Source('Hetzner','DE / Falkenstein','https://fsn1-speed.hetzner.com/100MB.bin',use_range=True), 'ovh-rbx': Source('OVHcloud','FR / Roubaix','https://lil1-rbx.speedtest.network.ovh.net/files/100Mb.dat',use_range=True), + 'cn-telecom-sh': Source('China Telecom','CN / Shanghai','https://speedtest1.online.sh.cn:8080/speedtest/random4000x4000.jpg', + capped_stream=True,carrier='telecom',mainland=True), + 'cn-unicom-sh': Source('China Unicom','CN / Shanghai','https://mobile.shunicomtest.com:8080/speedtest/random4000x4000.jpg', + capped_stream=True,carrier='unicom',mainland=True), + 'cn-mobile-fz': Source('China Mobile','CN / Fuzhou','https://csfw.fj.chinamobile.com:8080/speedtest/random4000x4000.jpg', + capped_stream=True,carrier='mobile',mainland=True), + 'mobile-hk': Source('China Mobile Hong Kong','HK / not mainland China','https://speedtestbb.hk.chinamobile.com:8080/speedtest/random4000x4000.jpg', + capped_stream=True,carrier='mobile',mainland=False), } +CHINA_SOURCES=('cn-telecom-sh','cn-unicom-sh','cn-mobile-fz') # Keep the slower, environment-sensitive Hetzner endpoint explicitly selectable. DEFAULT_SOURCES = ('cloudflare','ovh-rbx') @@ -55,6 +67,10 @@ class Sample: def transfer(direction, size, source='cloudflare'): + from .worker import enabled,invoke + if enabled(): + result=invoke('transfer',{'direction':direction,'size':size,'source':source},timeout=25) + return Sample(**result) if isinstance(result,dict) else Sample(direction,size,error='request_failed',source=source) if direction not in ('download', 'upload') or not 1 <= size <= 20 * 1024**2: raise ValueError('Invalid transfer limits') if source not in SOURCES: @@ -67,9 +83,9 @@ def transfer(direction, size, source='cloudflare'): headers = {'User-Agent': 'ProxyDoctor/0.4.1', 'Accept-Encoding': 'identity', 'Cache-Control': 'no-cache'} url = provider.download_url - if not provider.use_range: + if not provider.use_range and not provider.capped_stream: url += '?bytes=' + str(size) - else: + elif provider.use_range: headers['Range'] = f'bytes=0-{size-1}' data = None if direction == 'upload': @@ -104,7 +120,23 @@ def transfer(direction, size, source='cloudflare'): pop = ray.rsplit('-', 1)[-1] if len(pop) == 3 and pop.isalpha(): result.pop = pop.upper() - body = read_limited(response, size if direction == 'download' else 65536, start + 30) + if direction=='download' and provider.capped_stream: + # Fixed public test images often ignore Range. Read exactly the + # consented payload, then close; never consume the full object. + if not response.headers.get('Content-Type','').lower().startswith('image/jpeg'): + result.error='unexpected_test_content';return result + chunks=[];remaining=size + while remaining: + if time.monotonic()-start>20:raise TimeoutError() + read=getattr(response,'read1',response.read) + block=read(min(65536,remaining)) + if not block:break + chunks.append(block);remaining-=len(block) + body=b''.join(chunks) + if not body.startswith(b'\xff\xd8'): + result.error='unexpected_test_content';return result + else: + body = read_limited(response, size if direction == 'download' else 65536, start + 30) if direction == 'download' and len(body) != size: result.error = 'short_response' return result @@ -147,6 +179,9 @@ def measure(rounds=3, sample_mib=4, on_sample=None, source='cloudflare'): source_ids((source,)) provider = SOURCES[source] report = {'source':source,'provider':provider.name,'region':provider.region, + 'carrier_claim':provider.carrier,'mainland_china':provider.mainland, + 'provider_identity':'catalogue_claim' if provider.carrier else 'official_test_endpoint', + 'directions_scope':'download: node_to_this_host; upload: this_host_to_node', 'download_url':provider.download_url,'upload_supported':provider.upload_url is not None, 'method': 'sequential_https_wall_time', 'payload_budget_bytes': payload_budget(rounds,sample_mib,(source,)), @@ -196,17 +231,18 @@ def is_complete(report): for result in source['directions'].values()) -def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None): +def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None,destination=None,on_result=None,*,out=None): from .console import Console - out=Console(lang,color);tr=out.text + out=out if out is not None else Console(lang,color);tr=out.text selected=source_ids(sources) budget=payload_budget(rounds,sample_mib,selected)/1024**2 - print(tr(f' {len(selected)} 个来源 · 每方向 {rounds} 轮 × {sample_mib} MiB · 请求载荷最多 {budget:g} MiB', - f' {len(selected)} sources · {rounds} rounds × {sample_mib} MiB/direction · requested payload ≤ {budget:g} MiB'),flush=True) + out.line(tr(f'{len(selected)} 个来源 · 每方向 {rounds} 轮 × {sample_mib} MiB · 请求载荷最多 {budget:g} MiB', + f'{len(selected)} sources · {rounds} rounds × {sample_mib} MiB/direction · requested payload ≤ {budget:g} MiB')) def source_output(identifier,event,result): provider=SOURCES[identifier] if event=='start': - print('\n'+out.paint(f'{provider.name} · {provider.region}','title'),flush=True) + out.line(f'{provider.name} · {provider.region}','title') + out.row(tr('测速方向','Direction'),tr('下载:节点 → 本机;上传:本机 → 节点','Download: node → this host; upload: this host → node'),'unknown') if not provider.upload_url: out.row(tr('上传','Upload'),tr('不支持(该来源仅提供下载)','Not supported (download-only source)'),'unknown') return for direction,summary in result['directions'].items(): @@ -221,8 +257,15 @@ def run_console(lang='zh',color='auto',rounds=3,sample_mib=4,sources=None): 'insufficient':tr('有效轮次不足','Insufficient successful rounds')}.get(summary['quality']) if note: out.row('',note,'unknown') def sample_output(identifier,direction,number,total,sample): + if destination is not None: + destination.setdefault('sample_events',[]).append(asdict(sample)) + if on_result is not None:on_result() label=tr('下载','Download') if direction=='download' else tr('上传','Upload') value=f'{sample.mbps:.2f} Mbps · {sample.elapsed_s:.2f}s' if sample.mbps is not None else tr('未成功','Failed')+' · '+str(sample.error) if sample.pop: value+=' · '+sample.pop out.row(f'{label} {number}/{total}',value,'ok' if sample.mbps is not None else 'unknown') - return measure_many(rounds,sample_mib,selected,source_output,sample_output) + result=measure_many(rounds,sample_mib,selected,source_output,sample_output) + if destination is not None: + destination.update(result) + return destination + return result diff --git a/proxydoctor/bgp.py b/proxydoctor/bgp.py new file mode 100644 index 0000000..634add3 --- /dev/null +++ b/proxydoctor/bgp.py @@ -0,0 +1,96 @@ +"""Bounded RIPE RIS observations, not the VPS's selected routing table.""" +import ipaddress +from urllib.parse import urlencode +from .net import request_json +from .reports import utc_now + +SOURCE='https://stat.ripe.net' +ASN_HINTS={4134:'China Telecom 163',4809:'China Telecom CN2',4837:'China Unicom', + 9929:'China Unicom 9929',9808:'China Mobile',58453:'CMI',58807:'CMIN2'} + + +def as_number(value): + if isinstance(value,bool):return None + try: + number=int(value) + return number if str(number)==str(value) and 1<=number<=4294967295 else None + except (TypeError,ValueError):return None + + +def query(endpoint,resource,**params): + url=SOURCE+'/data/'+endpoint+'/data.json?'+urlencode({'resource':resource,**params}) + payload,state=request_json(url,max_bytes=524288) + if state!='ok':return None,state + if payload.get('status')!='ok' or not isinstance(payload.get('data'),dict): + return None,'invalid_response' + return payload['data'],'ok' + + +def collect(address, destination=None, on_result=None): + ip=ipaddress.ip_address(address) + if not ip.is_global:raise ValueError('BGP lookup requires a public IP') + report=destination if destination is not None else {} + report.update(target=str(ip),checked_at=utc_now(),source=SOURCE,status='unknown', + prefix=None,origin_asns=[],neighbours=[],paths=[],sources={}, + limitations=['RIS collector observations are not the local selected route.', + 'ASN adjacency does not prove a commercial upstream relationship.', + 'Sampled AS paths do not prove packet paths or CN2 GIA service.']) + def save(): + if on_result:on_result() + data,state=query('network-info',str(ip)) + report['sources']['network-info']=state + if state!='ok':save();return report + try: + prefix=ipaddress.ip_network(data['prefix']) + origins=data['asns'] + if ip not in prefix or not isinstance(origins,list) or not origins or any(as_number(n) is None for n in origins): + raise ValueError() + report.update(prefix=str(prefix),origin_asns=[int(n) for n in origins],status='partial') + except (ValueError,TypeError,KeyError): + report['sources']['network-info']='invalid_response';save();return report + save() + neighbours,state=query('asn-neighbours','AS'+str(report['origin_asns'][0])) + report['sources']['asn-neighbours']=state + if state=='ok': + rows=neighbours.get('neighbours',[]) + if isinstance(rows,list): + report['neighbours']=[{'asn':as_number(row.get('asn')),'type':row.get('type'), + 'power':row.get('power')} for row in rows[:256] + if isinstance(row,dict) and as_number(row.get('asn'))] + report['neighbours_query_time']=neighbours.get('query_starttime') + save() + state_data,state=query('bgp-state',report['prefix'],rrcs='0') + report['sources']['bgp-state']=state + if state=='ok': + rows=state_data.get('bgp_state',[]) + seen=set() + if isinstance(rows,list): + for row in rows[:512]: + if not isinstance(row,dict) or not isinstance(row.get('path'),list):continue + path=row['path'] + try:prefix=ipaddress.ip_network(row.get('target_prefix')) + except (ValueError,TypeError):continue + if ip not in prefix or not path or len(path)>128 or any(as_number(n) is None for n in path):continue + path=tuple(map(int,path)) + if path in seen:continue + seen.add(path) + report['paths'].append({'as_path':list(path),'collector_peer':row.get('source_id'), + 'prefix':str(prefix),'network_hints':[ASN_HINTS[n] for n in dict.fromkeys(path) if n in ASN_HINTS]}) + if len(report['paths'])>=20:break + report['data_time']=state_data.get('timestamp',state_data.get('query_time')) + report['status']='success' if report['paths'] and all(v=='ok' for v in report['sources'].values()) else 'partial' + save() + return report + + +def render(report,out): + tr=out.text + out.row('BGP',report.get('status','unknown'),'ok' if report.get('status')=='success' else 'unknown') + out.row(tr('公告前缀','Announced prefix'),report.get('prefix') or tr('未知','Unknown'),'unknown') + out.row(tr('起源 ASN','Origin ASNs'),', '.join('AS'+str(n) for n in report.get('origin_asns',[])) or '---') + out.row(tr('观测邻居','Observed neighbours'),len(report.get('neighbours',[])),'unknown') + for row in report.get('paths',[]): + out.row('RIS AS_PATH',' → '.join(map(str,row['as_path'])),'unknown') + if row['network_hints']:out.row(tr('ASN 标签','ASN hints'),' / '.join(row['network_hints']),'unknown') + for name,state in report.get('sources',{}).items():out.row(name,state,'ok' if state=='ok' else 'unknown') + out.row(tr('范围','Scope'),tr('RIS 采集器视角;不等于本机实际选路或商业上游','RIS collector view; local path and commercial upstream unconfirmed'),'unknown') diff --git a/proxydoctor/carriers.py b/proxydoctor/carriers.py new file mode 100644 index 0000000..162399e --- /dev/null +++ b/proxydoctor/carriers.py @@ -0,0 +1,141 @@ +"""Fixed public test endpoints; provider labels are claims until corroborated. + +Endpoint references: NextTrace and CleanIP sh.cd (see THIRD_PARTY_NOTICES.md). +No remote scripts or automatic registry downloads are executed. +""" +from dataclasses import dataclass,asdict +import ipaddress +import socket +import time +from .reports import utc_now + + +@dataclass(frozen=True) +class CarrierNode: + identifier:str + carrier:str + city:str + host:str + port:int=8080 + mainland:bool=True + + +NODES=(CarrierNode('ct-sh','telecom','Shanghai','speedtest1.online.sh.cn'), + CarrierNode('ct-nj','telecom','Nanjing','5gnanjing.speedtest.jsinfo.net'), + CarrierNode('ct-cd','telecom','Chengdu','speedtest1.sc.189.cn'), + CarrierNode('cu-sh','unicom','Shanghai','mobile.shunicomtest.com'), + CarrierNode('cu-bj','unicom','Beijing','beijing.unicomtest.com'), + CarrierNode('cu-tj','unicom','Tianjin','speedtest3.online.tj.cn'), + CarrierNode('cm-fz','mobile','Fuzhou','csfw.fj.chinamobile.com'), + CarrierNode('cm-hz','mobile','Hangzhou','speedtest.139play.com'), + CarrierNode('cm-bj','mobile','Beijing','speedtest.bmcc.com.cn'), + CarrierNode('cm-hk','mobile','Hong Kong','speedtestbb.hk.chinamobile.com',mainland=False)) + + +def resolve_public(host,family=4): + """Only called inside a bounded network worker.""" + af=socket.AF_INET6 if family==6 else socket.AF_INET + values={row[4][0] for row in socket.getaddrinfo(host,None,af,type=socket.SOCK_STREAM)} + if not values or any(not ipaddress.ip_address(value).is_global for value in values): + raise ValueError('Non-public or missing endpoint') + return sorted(values) + + +def probe_node(identifier): + from .worker import enabled,invoke + if identifier not in {row.identifier for row in NODES}:raise ValueError('Unknown node') + if enabled(): + result=invoke('carrier_probe',{'identifier':identifier},timeout=9) + return result if isinstance(result,dict) else {'identifier':identifier,'status':'unknown','error_code':'NODE_TIMEOUT_OR_ERROR','checked_at':utc_now()} + node=next(row for row in NODES if row.identifier==identifier) + row={**asdict(node),'checked_at':utc_now(),'status':'unknown','scope':'small_protocol_probe_not_bandwidth', + 'provider_identity':'catalogue_claim','transport':'plaintext_tcp_no_credentials'} + try: + addresses=resolve_public(node.host) + row['addresses']=addresses + started=time.monotonic() + with socket.create_connection((addresses[0],node.port),timeout=2) as sock: + row['tcp_ms']=round((time.monotonic()-started)*1000,3) + sock.settimeout(2);sock.sendall(b'HI\n') + body=bytearray() + while b'\n' not in body and len(body)<256: + part=sock.recv(1) + if not part:break + body.extend(part) + if not body.startswith(b'HELLO '): + row['error_code']='UNRECOGNIZED_TEST_PROTOCOL';return row + sock.sendall(b'DOWNLOAD 16384\n') + total=0 + deadline=time.monotonic()+3 + while total<16384: + if time.monotonic()>=deadline:raise TimeoutError() + block=sock.recv(min(4096,16384-total)) + if not block:break + total+=len(block) + row['sample_bytes']=total + row['status']='sample_available' if total==16384 else 'unknown' + if total!=16384:row['error_code']='SHORT_TEST_SAMPLE' + except socket.gaierror:row['error_code']='DNS_ERROR' + except (OSError,ValueError):row['error_code']='CONNECTION_OR_PROTOCOL_ERROR' + return row + + +def collect_nodes(destination=None,on_result=None,out=None): + from concurrent.futures import ThreadPoolExecutor,as_completed + report=destination if destination is not None else {} + report.update(checked_at=utc_now(),nodes={},coverage={},status='unknown', + scope='current_vantage_to_fixed_public_test_nodes_not_all_china', + bandwidth_measured=False) + with ThreadPoolExecutor(max_workers=3) as pool: + futures={pool.submit(probe_node,node.identifier):node for node in NODES} + for future in as_completed(futures): + node=futures[future] + try:row=future.result() + except Exception:row={'identifier':node.identifier,'status':'unknown','error_code':'NODE_PROBE_ERROR'} + report['nodes'][node.identifier]={**asdict(node),**row} + if on_result:on_result() + if out: + label=f'{node.carrier} / {node.city}' + (' [HK]' if not node.mainland else '') + out.row(label,row['status']+' · '+str(row.get('error_code',str(row.get('sample_bytes',0))+' bytes')), + 'ok' if row['status']=='sample_available' else 'unknown') + for carrier in ('telecom','unicom','mobile'): + report['coverage'][carrier]=any(row['carrier']==carrier and row['mainland'] and row['status']=='sample_available' + for row in report['nodes'].values()) + report['status']='success' if all(report['coverage'].values()) else 'partial' + if on_result:on_result() + return report + + +def return_routes(*,method='udp',family=4,hops=30,geo=False,tcp_port=443,destination=None,on_result=None,out=None): + """Beijing/Shanghai/Guangzhou × three carriers, from this host only.""" + from .worker import invoke + from .routes import trace_method,summarize_hops + if method not in ('udp','icmp','tcp') or family not in (4,6) or not 1<=hops<=64: + raise ValueError('Invalid return-route options') + report=destination if destination is not None else {} + report.update(direction='this_host_to_china_targets',checked_at=utc_now(),status='partial',routes={}, + scope='three_cities_three_carriers_not_all_provinces_or_reverse_direction', + target_reference='https://github.com/nxtrace/NTrace-core/blob/main/fast_trace/basic.go') + for city in ('pek','sha','can'): + for carrier,asn in (('telecom',4134),('unicom',4837),('mobile',9808)): + key=f'{city}-{carrier}' + host=f'ipv{family}.{city}-{asn}.endpoint.nxtrace.org' + row={'host':host,'carrier_claim':carrier,'expected_asn':asn,'method':method,'hops':[],'state':'unknown'} + report['routes'][key]=row + if out:out.line(f'{city} / {carrier} / {method.upper()}','title') + addresses=invoke('resolve_public',{'host':host,'family':family},timeout=5) + if not isinstance(addresses,list) or not addresses: + row['error_code']='TARGET_DNS_UNCONFIRMED' + else: + row['target']=addresses[0] + def progress(): + if on_result:on_result() + def emit(line,**kwargs): + if out:out.line(line,'unknown') + code=trace_method(row['target'],emit,method=method,tcp_port=tcp_port,hops=hops,lang=out.lang if out else 'en', + geo=geo,records=row['hops'],on_result=progress,deadline=time.monotonic()+30) + row['state']='reached' if code==0 else 'incomplete' + row['statistics']=summarize_hops(row['hops']) + if on_result:on_result() + report['status']='success' if all(row['state']=='reached' for row in report['routes'].values()) else 'partial' + return report diff --git a/proxydoctor/checks.py b/proxydoctor/checks.py index 292bc25..871bb85 100644 --- a/proxydoctor/checks.py +++ b/proxydoctor/checks.py @@ -80,6 +80,14 @@ class Host: return os.environ.get("SSH_CONNECTION", "") def detect_public_ip(self): + from .worker import enabled + if enabled(): + from .net import request_json + data,state=request_json('https://api.ipify.org?format=json') + try: + address=ipaddress.ip_address(data.get('ip')) if state=='ok' else None + return str(address) if address and address.is_global else None + except (ValueError,TypeError,AttributeError):return None # Explicit opt-in; no environment proxies or redirects. class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): @@ -192,7 +200,7 @@ def route_check(host, t): failed = True evidence.append(f"IPv{family[-1]}: " + t("路由读取失败或格式无法解析")) return Result("default_route", "pass" if valid and not failed else "unknown", t("默认路由"), - t("已读取默认路由;不代表互联网或目标地址可达") if valid and not failed else + t("已读取默认路由") if valid and not failed else t("默认路由信息不完整或未找到,无法确认网络出口"), "\n".join(evidence), t("仅检查当前网络命名空间的默认路由,不覆盖策略路由。")) @@ -220,8 +228,8 @@ def dns_check(host, t): stub = any(addr.is_loopback for addr in addresses) status = "unknown" if invalid or not addresses or stub else "pass" detail = (t("DNS 配置不完整或没有有效的服务器地址") if invalid or not addresses else - t("DNS 指向本机解析器;上游配置及解析是否成功尚未验证") if stub else - t("已读取 DNS 服务器配置;尚未验证解析是否成功")) + t("DNS 指向本机解析器") if stub else + t("已读取 DNS 服务器配置")) return Result("dns_config", status, t("DNS 配置"), detail, evidence, t("仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。")) @@ -261,10 +269,9 @@ def ssh_port_check(host, ssh, port, t): evidence.append(t("当前 SSH 会话本地端口:{port}", port=session_port)) matched = port in configured or port == session_port detail = t("目标端口与 SSH 已知端口冲突;不要用于代理") if matched else t("未能排除与运行中 SSH 端口冲突") - advice = t("检查运行中 SSH、socket 激活及自定义实例;证据不足时不要进行端口或防火墙修改。") if malformed: evidence.append(t("SSH 端口输出不完整或无效")) - return Result("ssh_port_safety", "warn" if matched else "unknown", t("SSH 端口保护"), detail, "\n".join(evidence), advice) + return Result("ssh_port_safety", "warn" if matched else "unknown", t("SSH 端口保护"), detail, "\n".join(evidence), "") def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=False): @@ -280,12 +287,12 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa os_unknown = system == "Linux" and not os_info.get("ID") version_unknown = is_ubuntu and version is None add(Result("system", "pass" if supported else "unknown" if version_unknown or os_unknown else "warn", t("操作系统"), - t("版本符合 Ubuntu 22.04 最低要求;不代表已完成实机兼容验证") if supported else + t("版本符合 Ubuntu 22.04 最低要求;实机兼容性按已测试环境确认") if supported else t("无法读取 Linux 发行版标识,不能确认系统要求") if os_unknown else t("无法解析 Ubuntu 版本,不能确认是否符合最低要求") if version_unknown else t("需要 Ubuntu 22.04 或更新版本"), f"{system}; {os_info.get('PRETTY_NAME', t('版本未知'))}; {arch}", - t("版本检查不验证官方维护状态;Ubuntu 22.04 及更新版本仍需分别进行实机测试。") if supported else - t("其他系统的结果仅供参考;请使用 Ubuntu 22.04 或更新版本验证。"))) + t("支持范围:Ubuntu 22.04 及更新版本。") if supported else + t("其他系统的结果仅供参考。"))) add(Result("architecture", "pass" if arch.lower() in {"x86_64", "amd64", "aarch64", "arm64"} else "unknown", t("系统架构"), t("检测架构;本阶段不验证安装包兼容性"), arch)) try: @@ -307,14 +314,13 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa try: free = host.disk_free() / 1024**3 add(Result("disk", "pass" if free >= 1 else "warn", t("根分区可用空间"), - t("可用 {free:.1f} GiB(体检提示阈值 1 GiB,不代表完整安装要求)", free=free))) + t("可用 {free:.1f} GiB(体检提示阈值 1 GiB)", free=free))) except OSError: add(Result("disk", "unknown", t("根分区可用空间"), t("无法读取"))) try: total, free = host.disk_total() / 1024**3, host.disk_free() / 1024**3 add(Result('disk_capacity', 'pass', t('根分区容量'), - t('总容量 {total:.1f} GiB,可用 {free:.1f} GiB', total=total, free=free), - advice=t('这是根文件系统容量,不一定等于 VPS 购买的磁盘总容量。'))) + t('总容量 {total:.1f} GiB,可用 {free:.1f} GiB', total=total, free=free))) except (AttributeError, OSError): add(Result('disk_capacity', 'unknown', t('根分区容量'), t('无法读取'))) if public_ip and detect_public_ip: @@ -322,19 +328,17 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa if detect_public_ip: address = host.detect_public_ip() add(Result("public_ip", "unknown", t("公网 IP"), - t("已通过 ipify 查询出口地址;未验证入站可达性或归属") if address else t("出口 IP 查询失败,无法确定公网地址"), - address or "", t("出口地址可能是 NAT 或代理地址;不是入站可达性证明。"))) + t("已通过 ipify 查询出口地址;入站可达性未确认") if address else t("出口 IP 查询失败,无法确定公网地址"), + address or "", t("出口地址可能是 NAT 或代理地址。"))) elif public_ip: addr = ipaddress.ip_address(public_ip) add(Result("public_ip", "unknown" if addr.is_global else "warn", t("公网 IP"), - t("用户提供的地址具备公网地址属性,但未验证归属或可达性") if addr.is_global else t("提供的地址不是全局可路由公网地址"), + t("用户提供的地址具备公网地址属性") if addr.is_global else t("提供的地址不是全局可路由公网地址"), str(addr))) else: - add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP"), - advice=t("可用 --public-ip 填写云控制台地址;地址归属及入站可达性仍需外部验证。"))) + add(Result("public_ip", "unknown", t("公网 IP"), t("默认不向第三方查询出口 IP"))) add(Result("cloud_firewall", "unknown", t("云防火墙与外部可达性"), - t("本机读取不能确认云安全组、NAT 或外部网络路径"), - advice=t("后续从客户端独立测试;不能仅据此认定端口已开放或被封锁。"))) + t("本机读取不能确认云安全组、NAT 或外部网络路径"))) if system != "Linux": for key, title in (("memory", t("内存")), ("interfaces", t("网络地址")), ("default_route", t("默认路由")), ("dns_config", t("DNS 配置")), @@ -348,15 +352,14 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa total_line = next((line for line in (memory or '').splitlines() if line.startswith('MemTotal:')), '') try: total_mib = int(total_line.split()[1]) / 1024 - add(Result('memory_total', 'pass', t('内存总量'), t('系统可见 {total:.0f} MiB', total=total_mib), - advice=t('系统可见内存可能与商家标称或容器限额不同。'))) + add(Result('memory_total', 'pass', t('内存总量'), t('系统可见 {total:.0f} MiB', total=total_mib))) except (ValueError, IndexError): add(Result('memory_total', 'unknown', t('内存总量'), t('无法读取'))) available = next((line for line in (memory or "").splitlines() if line.startswith("MemAvailable:")), "") try: mib = int(available.split()[1]) / 1024 add(Result("memory", "pass" if mib >= 256 else "warn", t("可用内存"), - t("约 {mib:.0f} MiB(体检提示阈值 256 MiB,不代表完整安装要求)", mib=mib))) + t("约 {mib:.0f} MiB(体检提示阈值 256 MiB)", mib=mib))) except (ValueError, IndexError): add(Result("memory", "unknown", t("可用内存"), t("无法读取 MemAvailable"))) network = host.run("ip", "-j", "address", "show") @@ -365,7 +368,7 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa addresses = [f"{item['ifname']}: {addr['local']}" for item in interfaces for addr in item.get("addr_info", []) if addr.get("scope") != "host"] add(Result("interfaces", "pass" if addresses else "unknown", t("网络地址"), - t("仅表示本机接口地址;不证明互联网可达"), "\n".join(addresses))) + t("仅表示本机接口地址"), "\n".join(addresses))) except (TypeError, ValueError, KeyError, AttributeError): add(Result("interfaces", "unknown", t("网络地址"), t(network.reason) or t("无法解析接口信息"))) add(route_check(host, t)) @@ -375,7 +378,7 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa conflicts = [f"{proto} {endpoint}" for proto, endpoint, number in rows if number == port] add(Result("ports", "warn" if conflicts else "unknown" if ports.code != 0 or malformed else "pass", t("目标端口占用"), t("目标 TCP/UDP 端口 {port} 已有监听", port=port) if conflicts else - t("无法完整确认端口占用") if ports.code != 0 or malformed else t("当前未发现 TCP/UDP 端口 {port} 的监听;不保证之后绑定成功", port=port), + t("无法完整确认端口占用") if ports.code != 0 or malformed else t("当前未发现 TCP/UDP 端口 {port} 的监听", port=port), "\n".join(conflicts), t("选择未占用端口;不会停止已有服务。") if conflicts else "")) ssh = host.run("sshd", "-T") add(ssh_port_check(host, ssh, port, t)) @@ -385,10 +388,10 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa risk = settings.get("permitrootlogin") == "yes" or settings.get("passwordauthentication") == "yes" ssh_complete = all(key in settings for key in ("permitrootlogin", "passwordauthentication")) add(Result("ssh", "unknown" if ssh.code != 0 else "warn" if risk else "unknown", t("SSH 配置"), - (t(ssh.reason) or t("读取失败")) if ssh.code != 0 else t("允许 root 登录或密码认证,建议人工审阅") if risk else - t("SSH 输出缺少关键字段,无法完成风险判断") if not ssh_complete else t("未发现这两项宽松设置;不能据此认定 SSH 安全"), + (t(ssh.reason) or t("读取失败")) if ssh.code != 0 else t("允许 root 登录或密码认证") if risk else + t("SSH 输出缺少关键字段,无法完成风险判断") if not ssh_complete else t("未发现这两项宽松设置"), "\n".join(line for line in ssh.output.splitlines() if line.split() and line.split()[0] in selected) if ssh.code == 0 else "", - t("sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同;不自动修改 SSH。"))) + t("sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同。"))) firewall = [] any_rules = False complete = True @@ -410,19 +413,22 @@ def inspect(host=None, port=8388, public_ip=None, lang="zh", detect_public_ip=Fa failed = active.output.strip() == "failed" add(Result("service", "warn" if failed else "pass" if running else "unknown", t("sing-box 服务"), t("sing-box 服务处于 failed 状态,需要检查启动失败原因") if failed else - t("服务处于 active;不代表代理连接成功") if running else t("未确认 sing-box 服务运行;新系统尚未安装属于正常情况"), + t("服务处于 active") if running else t("未确认 sing-box 服务运行;新系统尚未安装属于正常情况"), f"active: {active.output.strip() or t(active.reason)}\nenabled: {enabled.output.strip() or t(enabled.reason)}")) startup = enabled.output.strip() startup_ok = enabled.code == 0 and startup == "enabled" startup_warn = startup in {"disabled", "masked", "enabled-runtime", "masked-runtime"} add(Result("service_startup", "pass" if startup_ok else "warn" if startup_warn else "unknown", - t("sing-box 开机启动"), t("已配置持久开机启动;尚未实际重启验证") if startup_ok else + t("sing-box 开机启动"), t("已配置持久开机启动") if startup_ok else t("未配置持久开机启动或服务被屏蔽,重启后可能不可用") if startup_warn else t("无法确认 sing-box 的持久开机启动状态"), startup or t(enabled.reason))) return report(results, port, lang) def report(results, port, lang="zh"): - return {"schema_version": 1, "language": lang, "tool_version": __version__, + document={"schema_version": 1, "language": lang, "tool_version": __version__, "generated_at": datetime.now(timezone.utc).isoformat(), "mode": "read-only", "target_port": port, "checks": [asdict(result) for result in results]} + from .schema import normalize_report + normalize_report(document) + return document diff --git a/proxydoctor/cli.py b/proxydoctor/cli.py index a22f327..1468cc0 100644 --- a/proxydoctor/cli.py +++ b/proxydoctor/cli.py @@ -7,12 +7,12 @@ import shutil import subprocess import sys -from . import __version__ +from . import __version__, release_label from .checks import inspect from .i18n import translator from .quality import run_quality from .suite import run_suite -from .bandwidth import SOURCES +from .bandwidth import SOURCES, CHINA_SOURCES LABELS = {"pass": "通过", "warn": "注意", "unknown": "未知", "skip": "跳过", "fail": "失败"} @@ -54,7 +54,7 @@ class LocalizedParser(argparse.ArgumentParser): self.exit(2, translator(self.lang)("参数错误:{message}", message=message) + "\n") -def main(): +def _main(): # Support Windows consoles that cannot encode Chinese. for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): @@ -64,9 +64,17 @@ def main(): language_parser.add_argument("--lang", choices=("zh", "en"), default="zh") language_args, remaining = language_parser.parse_known_args() lang = language_args.lang + # Auto-degrade to English when the console cannot render CJK glyphs + # (e.g. Linux framebuffer tty1) unless the user explicitly requested zh. + if lang == 'zh': + from .console import can_render_cjk + explicit_language = any(arg == '--lang' or arg.startswith('--lang=') for arg in sys.argv[1:]) + if not can_render_cjk() and not explicit_language: + lang = 'en' t = translator(lang) - parser = LocalizedParser(lang=lang, description=("ProxyDoctor 只读体检:check 默认离线;suite 会联网" if lang=='zh' else 'ProxyDoctor read-only diagnostics: check defaults offline; suite contacts external services')) - parser.add_argument("--version", action="version", version=__version__, help=t("显示版本并退出")) + st = lambda zh, en: zh if lang == 'zh' else en + parser = LocalizedParser(lang=lang, description=("VPS 检测工具(只读体检):check 默认离线;suite 会联网" if lang=='zh' else 'VPS Check (read-only diagnostics): check defaults offline; suite contacts external services')) + parser.add_argument("--version", action="version", version=__version__ + ' · ' + release_label(lang), help=t("显示版本并退出")) parser.add_argument("--lang", choices=("zh", "en"), default=lang, help=t("界面和报告语言:zh 中文,en 英文(默认 zh)")) sub = parser.add_subparsers(dest="command", required=True) check = sub.add_parser("check", lang=lang, help=t("读取系统信息并生成体检报告")) @@ -78,17 +86,27 @@ def main(): check.add_argument("--json", action="store_true", dest="as_json", help=t("输出 JSON(默认隐藏 IP)")) check.add_argument("--show-ip", action="store_true", help=t("仅在终端显示完整 IP;保存的报告仍脱敏")) check.add_argument("--output", type=Path, help=t("另存为脱敏 JSON;已存在的文件不会覆盖")) + check.add_argument("--color", choices=("auto", "always", "never"), default="auto", help=st("彩色输出:auto/always/never", "Colour output: auto/always/never")) + check.add_argument("--theme", choices=("default", "iprisk", "framed"), default="default", help=st("终端版式:default/iprisk/framed", "Terminal theme: default/iprisk/framed")) node = sub.add_parser("nodequality", lang=lang, help=t("运行本地 NodeQuality 脚本并实时显示输出")) node.add_argument("--lang", choices=("zh", "en"), default=lang, help=t("界面和报告语言:zh 中文,en 英文(默认 zh)")) node.add_argument("--script", type=Path, required=True, help=t("已下载并检查的 NodeQuality 脚本路径")) quality = sub.add_parser('quality', lang=lang, help=t('联网检测 DNS 和 TCP 连接并实时显示结果')) quality.add_argument('--lang', choices=('zh','en'), default=lang, help=t('界面和报告语言:zh 中文,en 英文(默认 zh)')) - st=lambda zh,en: zh if lang=='zh' else en + quality.add_argument('--color', choices=('auto','always','never'), default='auto', help=st('彩色输出:auto/always/never','Colour output: auto/always/never')) + export = sub.add_parser('report',lang=lang,help=st('将本地 JSON 导出为脱敏 Markdown','Export local JSON as redacted Markdown')) + export.add_argument('--input',type=Path,required=True) + export.add_argument('--output',type=Path,required=True) + recover = sub.add_parser('recover',lang=lang,help=st('从恢复日志导出最后完整结果','Export the last complete recovery snapshot')) + recover.add_argument('--journal',type=Path,required=True) + recover.add_argument('--output',type=Path,required=True) + recover.add_argument('--format',choices=('json','markdown'),default='json') ipq = sub.add_parser('ipquality',lang=lang,help=st('只查询 IP 归属与风险,按来源保留证据','Query IP ownership and provider-specific risk evidence')) ipq.add_argument('--lang',choices=('zh','en'),default=lang) ipq.add_argument('--color',choices=('auto','always','never'),default='auto') ipq.add_argument('--show-ip',action='store_true',help=st('终端显示出口 IP;文件仍脱敏','Show egress IP in terminal; exports stay redacted')) ipq.add_argument('--no-enrichment',action='store_true',help=st('不查询额外免密钥来源','Skip additional keyless sources')) + ipq.add_argument('--mail-probes',action='store_true',help=st('额外查询PTR并测试单目标出站TCP/25;不发送邮件','Query PTR and one outbound TCP/25 target; sends no email')) ipq.add_argument('--json-output',type=Path) bandwidth = sub.add_parser('bandwidth',lang=lang,help=st('仅运行多来源多轮测速','Run only repeated multi-source bandwidth tests')) bandwidth.add_argument('--lang',choices=('zh','en'),default=lang) @@ -96,6 +114,8 @@ def main(): bandwidth.add_argument('--sample-mib',type=int,choices=range(1,21),default=4,metavar='1..20') bandwidth.add_argument('--rounds','--bandwidth-rounds',type=int,choices=range(3,9),default=3,metavar='3..8') bandwidth.add_argument('--source','--bandwidth-source',choices=tuple(SOURCES),action='append',help=st('可重复指定;默认 Cloudflare + OVHcloud','Repeat to select; default Cloudflare + OVHcloud')) + bandwidth.add_argument('--allow-bandwidth',action='store_true',help=st('明确允许消耗流量和带宽;请求载荷预算将在执行前显示','Consent to traffic and bandwidth use; payload budget is printed before execution')) + bandwidth.add_argument('--china',action='store_true',help=st('选择大陆三运营商候选;缺失来源如实显示','Select three mainland-carrier candidates; missing sources stay unknown')) bandwidth.add_argument('--json-output',type=Path,help=st('保存脱敏检测依据,不覆盖文件','Save redacted evidence; never overwrite')) media = sub.add_parser('media',lang=lang,help=st('仅检测流媒体,不重复测速','Check media without repeating bandwidth tests')) media.add_argument('--lang',choices=('zh','en'),default=lang) @@ -103,10 +123,12 @@ def main(): media.add_argument('--json-output',type=Path,help=st('保存脱敏检测依据,不覆盖文件','Save redacted evidence; never overwrite')) suite = sub.add_parser('suite',lang=lang,help=st('VPS / 网络质量 / 路由 / 流媒体','VPS / network / routes / streaming')) suite.add_argument('--lang',choices=('zh','en'),default=lang) + suite.add_argument('--allow-bandwidth',action='store_true',help=st('明确允许带宽测速(默认跳过)','Consent to bandwidth tests (skipped by default)')) suite.add_argument('--offline',action='store_true',help=st('跳过联网检测','Skip outbound requests')) suite.add_argument('--port',type=int,default=8388,help=st('体检目标端口,默认 8388','Target port to inspect (default 8388)')) suite.add_argument('--show-ip',action='store_true',help=st('终端显示出口 IP;文件仍脱敏','Show egress IP in terminal; exports stay redacted')) suite.add_argument('--no-ip-enrichment',action='store_true',help=st('不查询额外 IP 风险来源','Skip additional IP risk sources')) + suite.add_argument('--mail-probes',action='store_true',help=st('额外执行PTR与单目标出站TCP/25检查','Include PTR and one outbound TCP/25 check')) suite.add_argument('--sample-mib',type=int,choices=range(1,21),default=4,metavar='1..20',help=st('每轮每方向 MiB,默认 4','MiB per round per direction (default 4)')) suite.add_argument('--bandwidth-rounds',type=int,choices=range(3,9),default=3,metavar='3..8',help=st('每方向测速轮数,默认 3','Measured rounds per direction (default 3)')) suite.add_argument('--bandwidth-source',choices=tuple(SOURCES),action='append',help=st('可重复指定;默认 Cloudflare + OVHcloud','Repeat to select; default Cloudflare + OVHcloud')) @@ -116,52 +138,131 @@ def main(): suite.add_argument('--besttrace',type=Path,help=st('本地 BestTrace 可执行文件','Local BestTrace executable')) suite.add_argument('--region-script',type=Path,help=st('已审阅的本地 RegionRestrictionCheck 脚本','Reviewed local RegionRestrictionCheck script')) suite.add_argument('--max-hops',type=int,choices=range(1,65),default=30,metavar='1..64') + suite.add_argument('--route-probes',type=int,choices=range(1,4),default=3,help=st('路由重复探测轮数1..3;共享90秒预算','Repeat route probes 1..3 times; shared 90s budget')) suite.add_argument('--no-geo',action='store_true',help=st('不查询路由 IP 位置','Skip route IP geolocation lookups')) suite.add_argument('--color',choices=('auto','always','never'),default='auto') + suite.add_argument('--theme', choices=('default','iprisk','framed'), default='default', help=st('终端版式:default/iprisk/framed','Terminal theme: default/iprisk/framed')) + suite.add_argument('--route-method',choices=('udp','icmp','tcp'),default='udp') + suite.add_argument('--tcp-port',type=int,default=443) + suite.add_argument('--china-speed',action='store_true',help=st('启用大陆三运营商候选测速;仍需 --allow-bandwidth','Select mainland carrier speed candidates; requires --allow-bandwidth')) + suite.add_argument('--include-return-routes',action='store_true',help=st('额外运行三城三运营商方向追踪','Add three-city × three-carrier outbound traces')) + suite.add_argument('--include-bgp',action='store_true',help=st('查询出口 IP 的 RIPE RIS BGP 观测','Query RIPE RIS BGP observations for egress IP')) + for name,description in (('trace',st('单目标 UDP/ICMP/TCP 路由','Single-target UDP/ICMP/TCP trace')), + ('return-route',st('三城三运营商逐跳追踪','Three cities × three carriers traces')), + ('bgp',st('查询前缀、起源 ASN、邻居及 RIS AS_PATH','Query prefix, origin ASN, neighbours and RIS AS_PATH')), + ('cn-nodes',st('小流量探测候选测速节点','Probe candidate speed nodes with small samples'))): + command=sub.add_parser(name,lang=lang,help=description) + command.add_argument('--color',choices=('auto','always','never'),default='auto') + command.add_argument('--json-output',type=Path) + if name in ('trace','bgp'):command.add_argument('--target',required=True) + if name in ('trace','return-route'): + command.add_argument('--method',choices=('udp','icmp','tcp'),default='udp') + command.add_argument('--tcp-port',type=int,default=443) + command.add_argument('--max-hops',type=int,choices=range(1,65),default=30) + command.add_argument('--geo',action='store_true',help=st('额外查询逐跳估计位置','Query estimated per-hop location')) + if name=='return-route':command.add_argument('--family',type=int,choices=(4,6),default=4) args = parser.parse_args(remaining) + if args.command in ('trace','return-route','bgp','cn-nodes'): + from .console import Console + from .session import ScanSession + out=Console(lang,args.color) + out.line(release_label(lang),'unknown') + if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()): + parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing')) + if hasattr(args,'tcp_port') and not 1<=args.tcp_port<=65535:parser.error(t('端口必须在 1~65535 之间')) + if hasattr(args,'target'): + try:target=ipaddress.ip_address(args.target) + except ValueError:parser.error(st('目标必须是数字 IP 地址','Target must be a numeric IP address')) + if args.command=='bgp' and not target.is_global:parser.error(st('BGP 需要公网 IP','BGP requires a public IP')) + key={'trace':'route','return-route':'return_routes','bgp':'bgp','cn-nodes':'carrier_nodes'}[args.command] + with ScanSession(key,args.json_output,lang) as scan: + data=scan.report[key] + if args.command=='trace': + from .routes import trace_method,summarize_hops + data.update(target=str(target),method=args.method,hops=[]) + code=trace_method(str(target),lambda line,**kw:out.line(line),method=args.method,tcp_port=args.tcp_port, + hops=args.max_hops,geo=args.geo,lang=lang,records=data['hops'],on_result=scan.checkpoint) + data.update(state='reached' if code==0 else 'incomplete',statistics=summarize_hops(data['hops'])) + elif args.command=='return-route': + from .carriers import return_routes + out.line(st('方向:本机 → 中国目标;不包含中国侧到本机的探测','Direction: this host → China targets; reverse probes not included'),'unknown') + return_routes(method=args.method,family=args.family,hops=args.max_hops,geo=args.geo,tcp_port=args.tcp_port, + destination=data,on_result=scan.checkpoint,out=out) + code=0 if data['status']=='success' else 1 + elif args.command=='bgp': + from .bgp import collect,render + collect(str(target),data,scan.checkpoint);render(data,out) + code=0 if data['status']=='success' else 1 + else: + from .carriers import collect_nodes + collect_nodes(data,scan.checkpoint,out) + code=0 if data['status']=='success' else 1 + out.row(st('大陆三网覆盖','Mainland carrier coverage'),json.dumps(data['coverage']),'ok' if code==0 else 'unknown') + if code:scan.report['result']='incomplete' + sys.exit(1 if scan.failed else code) + if args.command in ('report','recover'): + from .reporting import load_report, export_report + from .reports import recover_latest + try: + if args.command == 'report': + document = load_report(args.input) + format = 'markdown' + else: + document = recover_latest(args.journal) + if document is None: + raise ValueError('No complete recovery event') + document['recovery'] = {'kind':'last_complete_journal_snapshot','new_measurement':False} + if document.get('result') in (None,'running'): + document['result'] = 'recovered_partial' + format = args.format + export_report(document,args.output,format=format,lang=lang) + except (OSError,ValueError,TypeError,AttributeError,RecursionError): + parser.error(st('导出失败:检查输入格式、读取上限与路径;输出文件必须不存在','Export failed: check input format, read limits and paths; output must not exist')) + print(st('已保存脱敏报告;分享前请人工检查:','Redacted report saved; review before sharing: ')+str(args.output)) + sys.exit(0) if args.command == 'ipquality': from .ipquality import collect,render from .console import Console if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()): parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing')) - result=collect(enrich=not args.no_enrichment) - render(result,Console(lang,args.color),args.show_ip) - print(st('第三方记录可能缺失或不一致;不同评分不可合并;无举报不保证安全。', - 'Provider records may be missing or disagree; scores are not interchangeable; zero reports do not guarantee safety.'),flush=True) - if args.json_output: - try: - with args.json_output.open('x',encoding='utf-8') as stream: - json.dump(redact({'tool_version':__version__,'ip_quality':result},lang),stream,ensure_ascii=False,indent=2) - except OSError: parser.error(st('无法保存报告','Unable to save report')) - sys.exit(0 if result['coverage']['complete'] else 1) + from .session import ScanSession + with ScanSession('ip_quality',args.json_output,lang) as scan: + result=collect(enrich=not args.no_enrichment,mail=args.mail_probes, + destination=scan.report['ip_quality'],on_result=scan.checkpoint) + render(result,Console(lang,args.color),args.show_ip) + incomplete=not result['coverage']['complete'] + if incomplete:scan.report['result']='incomplete' + sys.exit(1 if incomplete or scan.failed else 0) if args.command == 'bandwidth': + if not args.allow_bandwidth: + parser.error(st('测速消耗流量和带宽;请添加 --allow-bandwidth 明确允许','Bandwidth tests use traffic and capacity; add --allow-bandwidth to consent')) from .bandwidth import run_console, is_complete if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()): parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing')) - result=run_console(lang,args.color,args.rounds,args.sample_mib,args.source) - print(st('说明:不同来源分别统计;只支持下载的来源不测上传;HTTPS 吞吐包含建连,不能认定线路容量。', - 'Notes: sources are summarized separately; download-only sources do not upload. HTTPS throughput includes setup and does not establish line capacity.'),flush=True) - if args.json_output: - try: - with args.json_output.open('x',encoding='utf-8') as stream: - json.dump(redact({'tool_version':__version__,'bandwidth':result},lang),stream,ensure_ascii=False,indent=2) - except OSError: - parser.error(st('无法保存报告','Unable to save report')) - sys.exit(0 if is_complete(result) else 1) + from .session import ScanSession + with ScanSession('bandwidth',args.json_output,lang) as scan: + result=run_console(lang,args.color,args.rounds,args.sample_mib,args.source or (CHINA_SOURCES if args.china else None), + destination=scan.report['bandwidth'],on_result=scan.checkpoint) + incomplete=not is_complete(result) + if incomplete:scan.report['result']='incomplete' + sys.exit(1 if incomplete or scan.failed else 0) if args.command == 'media': from .media import run_console if args.json_output and (args.json_output.exists() or not args.json_output.parent.is_dir()): parser.error(st('输出文件已存在或目录不存在','Output already exists or directory is missing')) - print(st('流媒体地区检测 · 未登录,不验证账号播放','Media region checks · Anonymous; account playback not tested'),flush=True) - results=run_console(lang,args.color) - if args.json_output: - try: - with args.json_output.open('x',encoding='utf-8') as stream: - json.dump(redact({'tool_version':__version__,'media':results},lang),stream,ensure_ascii=False,indent=2) - except OSError: - parser.error(st('无法保存报告','Unable to save report')) - sys.exit(1) # Playback entitlement remains untested. + print(st('流媒体可达性检测 · 不验证解锁或播放','Media reachability · Unlock and playback not tested'),flush=True) + from .session import ScanSession + with ScanSession('media',args.json_output,lang) as scan: + run_console(lang,args.color,results=scan.report['media'],on_result=scan.checkpoint) + incomplete=any(item.get('state')!='reachable' for item in scan.report['media'].values()) + if incomplete:scan.report['result']='incomplete' + sys.exit(1 if incomplete or scan.failed else 0) if args.command == 'suite': + if not 1<=args.tcp_port<=65535:parser.error(t('端口必须在 1~65535 之间')) + if args.china_speed and not args.allow_bandwidth: + parser.error(st('--china-speed 需要 --allow-bandwidth','--china-speed requires --allow-bandwidth')) + if args.besttrace or args.region_script: + parser.error(st('安全流程暂停第三方程序调用,请使用原生检测','External programs are disabled in the safe workflow; use native checks')) if not 1<=args.port<=65535: parser.error(t('端口必须在 1~65535 之间')) if args.json_output and args.json_output.exists(): parser.error(st('输出文件已存在,请换一个文件名','Output already exists; choose another --json-output path')) @@ -176,13 +277,20 @@ def main(): route_target=args.route_target,besttrace=args.besttrace,region_script=args.region_script, max_hops=args.max_hops,geo=not args.no_geo,color=args.color, bandwidth_rounds=args.bandwidth_rounds,skip_route=args.skip_route,json_output=args.json_output, - bandwidth_sources=args.bandwidth_source,port=args.port,show_ip=args.show_ip,ip_enrichment=not args.no_ip_enrichment) + bandwidth_sources=args.bandwidth_source or (CHINA_SOURCES if args.china_speed else None),port=args.port,show_ip=args.show_ip,ip_enrichment=not args.no_ip_enrichment,allow_bandwidth=args.allow_bandwidth,mail_probes=args.mail_probes,route_probes=args.route_probes,theme=args.theme, + route_method=args.route_method,tcp_port=args.tcp_port,include_return_routes=args.include_return_routes,include_bgp=args.include_bgp) except OSError: parser.error(st('无法保存报告,请检查输出目录和文件权限','Unable to save report; check the output directory and file permissions')) sys.exit(code) if args.command == 'quality': - sys.exit(run_quality(t)) + from .connectivity import run_console as run_connectivity + from .session import ScanSession + with ScanSession('network',None,lang) as scan: + code=run_connectivity(lang=lang,color=args.color,report=scan.report['network'],on_result=scan.checkpoint) + if code:scan.report['result']='incomplete' + sys.exit(1 if scan.failed else code) if args.command == 'nodequality': + parser.error(st('该第三方流程可能上传报告,当前安全版本已禁用;请使用 suite','This third-party workflow may upload reports and is disabled; use suite')) if not args.script.is_file(): parser.error(t("找不到本地脚本文件")) bash = shutil.which('bash') @@ -202,6 +310,18 @@ def main(): except ValueError: parser.error(t("--public-ip 必须是有效 IPv4 或 IPv6 地址")) raw_data = inspect(port=args.port, public_ip=args.public_ip, lang=lang, detect_public_ip=args.detect_public_ip) + from .resources import collect as collect_resources, render as render_resources + try: + raw_data['resources'] = collect_resources() + except Exception: + raw_data['resources'] = {'status': 'unknown', 'error_code': 'RESOURCE_MODULE_ERROR'} + from .security import collect as collect_security, render as render_security + try: + raw_data['security'] = collect_security() + except Exception: + raw_data['security'] = {'status':'unknown','error_code':'SECURITY_MODULE_ERROR'} + from .schema import normalize_report + normalize_report(raw_data) safe_data = redact(raw_data, lang) data = raw_data if args.show_ip else safe_data serialized = json.dumps(safe_data, ensure_ascii=False, indent=2) @@ -214,24 +334,105 @@ def main(): if args.as_json: print(json.dumps(data, ensure_ascii=False, indent=2)) else: - print(f"ProxyDoctor {__version__} · " + t("只读体检 · 已请求 ipify 查询出口 IP" if args.detect_public_ip else "只读体检 · 默认不请求外部网络") + "\n") + from .console import Console, can_render_cjk + console = Console(lang, getattr(args, 'color', 'auto')) + theme = getattr(args, 'theme', 'default') + use_theme = theme in ('iprisk', 'framed') separator = ": " if lang == "en" else ":" info_ids = {'system', 'architecture', 'kernel', 'cpu', 'memory_total', 'memory', 'disk_capacity', 'disk', 'interfaces', 'public_ip'} - for heading, is_info in ((t('服务器信息'), True), (t('体检结果'), False)): - print(f"{heading}:") - for item in data["checks"]: - if (item['id'] in info_ids) != is_info: - continue - print(f"[{t(LABELS[item['status']])}] {item['title']}{separator}{item['detail']}") - for line in item["evidence"].splitlines(): - print(" " + t("依据") + separator + line) - if item["advice"]: - print(" " + t("建议") + separator + item['advice']) - print() + status_color = {'pass': 'ok', 'warn': 'bad', 'fail': 'bad', 'unknown': 'unknown', 'skip': 'unknown'} + if theme == 'framed': + console.frame_open(st('VPS 检测报告','VPS check report'), box_w=78, subtitle=release_label(lang) + ' · ' + __version__) + # header key/values + from datetime import datetime + console.frame_keyvalue(st('扫描时间','Scan time'), datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')) + console.frame_keyvalue(st('语言','Language'), st('中文','English')) + console.frame_keyvalue(st('模式','Mode'), st('只读体检','Read-only')) + for number, (title, is_info) in enumerate(((st('服务器信息','Server information'), True), (st('体检结果','Diagnostic results'), False)), start=1): + console.frame_section(number, title, title) + for item in data["checks"]: + if item['id'] in ('memory_total','memory'): + continue + if (item['id'] in info_ids) != is_info: + continue + console.frame_keyvalue(item['title'], item['detail'], status_color.get(item['status'], 'unknown')) + for line in item["evidence"].splitlines(): + console.frame_line(st('依据','Evidence') + separator + line, 'muted') + elif theme == 'iprisk': + console.banner(' VPS CHECK ' + ('v' + __version__)) + console.line(release_label(lang), 'unknown') + console.keyvalue(st('只读体检报告','Read-only diagnostic report'), + st('已请求 ipify 查询出口 IP' if args.detect_public_ip else '默认不请求外部网络','ipify egress query' if args.detect_public_ip else 'no external requests by default'), 'title') + console.hrule() + for number, (title, is_info) in enumerate(((st('服务器信息','Server information'), True), (st('体检结果','Diagnostic results'), False)), start=1): + console.theme_section(number, title, title, 'b_cyan') + for item in data["checks"]: + if item['id'] in ('memory_total','memory'): + continue + if (item['id'] in info_ids) != is_info: + continue + console.keyvalue(item['title'], item['detail'], status_color.get(item['status'], 'unknown')) + for line in item["evidence"].splitlines(): + print(' ' + console.paint(st('依据','Evidence'), 'muted') + separator + line, flush=True) + else: + print(release_label(lang) + f" · {__version__}\n" + t("只读体检 · 已请求 ipify 查询出口 IP" if args.detect_public_ip else "只读体检 · 默认不请求外部网络") + "\n") + for heading, is_info in ((t('服务器信息'), True), (t('体检结果'), False)): + print(f"{heading}:") + for item in data["checks"]: + if item['id'] in ('memory_total','memory'): + continue + if (item['id'] in info_ids) != is_info: + continue + print(f"[{t(LABELS[item['status']])}] {item['title']}{separator}{item['detail']}") + for line in item["evidence"].splitlines(): + print(" " + t("依据") + separator + line) + print() counts = {t(label): sum(item["status"] == status for item in data["checks"]) for status, label in LABELS.items()} - print("\n" + " / ".join(f"{key} {value}" for key, value in counts.items())) - print(t("终端正在显示完整 IP;保存的报告仍脱敏,分享前请检查内容。" if args.show_ip else "报告默认隐藏 IP;分享前请检查内容。通过某一项不代表整体安全或代理可用。")) + if data['resources']['status'] in ('partial','unsupported'): + if console._framed: + console.frame_section(3, '资源限制', 'Resource limits') + render_resources(data['resources'], console) + else: + if console._framed: + console.frame_line('RESOURCE_MODULE_ERROR', 'bad') + else: + print('RESOURCE_MODULE_ERROR') + if data['security']['status'] in ('partial','unsupported'): + if console._framed: + console.frame_section(4, '安全与权限', 'Security & permissions') + render_security(data['security'], console) + else: + if console._framed: + console.frame_line('SECURITY_MODULE_ERROR', 'bad') + else: + print('SECURITY_MODULE_ERROR') + if console._framed: + summary = " / ".join(f"{key} {value}" for key, value in counts.items()) + console.frame_section(5, st('汇总','Summary'), 'Summary') + summary_status = 'bad' if any(row['status'] in ('warn','fail') for row in data['checks']) or data['security'].get('review_count') else 'unknown' if any(row['status']=='unknown' for row in data['checks']) else 'ok' + console.frame_line(summary, summary_status) + console.frame_line(st('终端完整 IP;文件脱敏。','Full terminal IP; exports redacted.') if args.show_ip else t('报告默认隐藏 IP。'), 'muted') + console.frame_close() + else: + print("\n" + " / ".join(f"{key} {value}" for key, value in counts.items())) + print(st('终端完整 IP;文件脱敏。','Full terminal IP; exports redacted.') if args.show_ip else t('报告默认隐藏 IP。')) # Stable automation contract: 1 = findings, 2 = invalid usage or export error. - return_code = 1 if any(item["status"] in {"warn", "fail"} for item in data["checks"]) else 0 + return_code = 1 if any(item["status"] in {"warn", "fail"} for item in data["checks"]) or data['security'].get('review_count',0) else 0 sys.exit(return_code) + + +def main(): + import os + from .worker import reset,cancel_all + previous=os.environ.get('PROXYDOCTOR_NETWORK_WORKERS') + os.environ['PROXYDOCTOR_NETWORK_WORKERS']='1' + reset() + try: + return _main() + except KeyboardInterrupt: + cancel_all() + raise + finally: + if previous is None:os.environ.pop('PROXYDOCTOR_NETWORK_WORKERS',None) + else:os.environ['PROXYDOCTOR_NETWORK_WORKERS']=previous diff --git a/proxydoctor/connectivity.py b/proxydoctor/connectivity.py new file mode 100644 index 0000000..3b981c6 --- /dev/null +++ b/proxydoctor/connectivity.py @@ -0,0 +1,203 @@ +"""Family-specific bounded DNS/TCP/TLS observations, without HTTP content.""" +import errno +import ipaddress +import json +import socket +import ssl +import subprocess +import sys +import time + +from .reports import utc_now + +TARGETS = ( + ('Cloudflare','one.one.one.one','1.1.1.1'), + ('Cloudflare','one.one.one.one','2606:4700:4700::1111'), + ('Google DNS','dns.google','8.8.8.8'), + ('Google DNS','dns.google','2001:4860:4860::8888'), +) +RESOLVER = '''import json,socket,sys +family=int(sys.argv[1]) +try: + answers=socket.getaddrinfo('example.com',443,family=family,type=socket.SOCK_STREAM) + print(json.dumps({'addresses':sorted(set(item[4][0] for item in answers))})) +except socket.gaierror as error: + print(json.dumps({'error':error.errno})) +''' + + +def dns_probe(version, timeout=2): + if version not in (4,6) or not 0 < timeout <= 5: + raise ValueError('Invalid DNS probe limits') + family = socket.AF_INET if version == 4 else socket.AF_INET6 + row = {'family': version, 'target': 'example.com', 'method': 'system_getaddrinfo', + 'observed_at': utc_now(), 'status': 'unknown', 'error_code': None} + start = time.monotonic() + try: + process = subprocess.run([sys.executable,'-I','-B','-c',RESOLVER,str(int(family))], + capture_output=True, text=True, timeout=timeout, check=False) + if process.returncode or len(process.stdout) > 16384: + row['error_code'] = 'RESOLVER_PROCESS_ERROR' + else: + payload = json.loads(process.stdout) + code = payload.get('error') + if code is not None: + row['error_code'] = ('DNS_TEMPORARY_ERROR' if code == socket.EAI_AGAIN else + 'DNS_NO_ADDRESS' if code == socket.EAI_NONAME else 'DNS_RESOLUTION_ERROR') + else: + addresses = payload.get('addresses') + if not isinstance(addresses,list) or not addresses or any(ipaddress.ip_address(value).version != version for value in addresses): + row['error_code'] = 'DNS_INVALID_RESPONSE' + else: + row.update(status='success',addresses=addresses) + except subprocess.TimeoutExpired: + row['error_code'] = 'DNS_TIMEOUT' + except (OSError, ValueError, TypeError, AttributeError): + row['error_code'] = 'DNS_PROBE_ERROR' + row['elapsed_ms'] = round((time.monotonic()-start)*1000,3) + row['timing_scope'] = 'resolver_process_including_startup_and_cache' + return row + + +def transport_probe(label, hostname, address, timeout=4): + ip = ipaddress.ip_address(address) + if not 0 < timeout <= 10: + raise ValueError('Invalid transport timeout') + row = {'target': label, 'hostname': hostname, 'address': address, 'family': ip.version, + 'port': 443, 'observed_at': utc_now(), 'method': 'numeric_tcp_then_verified_tls', + 'tcp': {'status':'unknown'}, 'tls': {'status':'skipped','error_code':'TCP_NOT_CONNECTED'}} + started = time.monotonic() + deadline = started + timeout + stage = 'tcp' + try: + # Numeric addresses only: DNS cannot contaminate this TCP observation. + with socket.create_connection((address,443),timeout=timeout) as raw: + row['tcp'] = {'status':'success','elapsed_ms':round((time.monotonic()-started)*1000,3)} + stage = 'tls' + context = ssl.create_default_context() + remaining = deadline-time.monotonic() + if remaining <= 0: + raise TimeoutError + raw.settimeout(remaining) + tls_start = time.monotonic() + with context.wrap_socket(raw,server_hostname=hostname) as secure: + row['tls'] = {'status':'success','elapsed_ms':round((time.monotonic()-tls_start)*1000,3), + 'version': secure.version(), 'certificate_verified': True} + except ssl.SSLCertVerificationError: + row['tls'] = {'status':'unknown','error_code':'TLS_CERTIFICATE_ERROR'} + except (TimeoutError, socket.timeout): + row[stage] = {'status':'unknown','error_code':stage.upper()+'_TIMEOUT'} + except ssl.SSLError: + row['tls'] = {'status':'unknown','error_code':'TLS_HANDSHAKE_ERROR'} + except OSError as error: + reason = {errno.ENETUNREACH:'NETWORK_UNREACHABLE',errno.EHOSTUNREACH:'HOST_UNREACHABLE', + errno.ECONNREFUSED:'CONNECTION_REFUSED',errno.EAFNOSUPPORT:'FAMILY_UNSUPPORTED'}.get(error.errno,'SOCKET_ERROR') + row[stage] = {'status':'unknown','error_code':reason} + row['elapsed_ms'] = round((time.monotonic()-started)*1000,3) + return row + + +def collect(report=None, on_result=None, *, rounds=2, dns=dns_probe, transport=transport_probe): + if not 1 <= rounds <= 3: + raise ValueError('DNS rounds must be 1..3') + report = {} if report is None else report + report.update(schema_version=1, observed_at=utc_now(), dns=[], transports=[], dns_summary={}, + limitations=['Only outbound paths to the named targets were checked.', + 'DNS timings include process startup and system resolver caching.', + 'No UDP, PMTU, inbound reachability or media playback was tested.']) + def completed(): + if on_result is not None: + on_result() + for version in (4,6): + rows = [] + for _ in range(rounds): + try: + row = dns(version) + except Exception: + row = {'family':version,'status':'unknown','error_code':'DNS_PROBE_ERROR','observed_at':utc_now()} + rows.append(row) + report['dns'].append(row) + completed() + count = sum(row['status'] == 'success' for row in rows) + report['dns_summary'][str(version)] = {'attempted':rounds,'succeeded':count, + 'success_ratio':count/rounds} + for label, hostname, address in TARGETS: + try: + row = transport(label,hostname,address) + except Exception: + row = {'target':label,'family':ipaddress.ip_address(address).version, + 'tcp':{'status':'unknown','error_code':'PROBE_ERROR'},'tls':{'status':'skipped'}} + report['transports'].append(row) + completed() + report['diagnostics'] = [] + for version in (4,6): + transports = [row for row in report['transports'] if row['family'] == version] + tcp_ok = any(row['tcp']['status'] == 'success' for row in transports) + dns_ok = report['dns_summary'][str(version)]['succeeded'] > 0 + code = ('DNS_FAILED_NUMERIC_TCP_WORKS' if tcp_ok and not dns_ok else + 'DNS_WORKS_TCP_UNCONFIRMED' if dns_ok and not tcp_ok else + 'TARGETS_PARTIALLY_VERIFIED' if tcp_ok else 'CONNECTIVITY_UNCONFIRMED') + report['diagnostics'].append({'family':version,'code':code}) + report['status'] = 'success' if all(row['status']=='success' for row in report['dns']) and all( + row['tcp']['status']=='success' and row['tls']['status']=='success' for row in report['transports']) else 'partial' + completed() + return report + + +def render_row(row, out, kind): + tr = out.text + labels = { + 'DNS_TIMEOUT': ('DNS 超时','DNS timed out'), + 'DNS_NO_ADDRESS': ('未取得该协议族地址','No address for this family'), + 'DNS_TEMPORARY_ERROR': ('解析器暂时失败','Temporary resolver error'), + 'TLS_CERTIFICATE_ERROR': ('证书验证失败;检查时间及信任链','Certificate rejected; check clock and trust chain'), + 'TLS_HANDSHAKE_ERROR': ('TLS 握手未完成','TLS handshake failed'), + 'TCP_NOT_CONNECTED': ('TCP 未连接,跳过 TLS','TCP unavailable; TLS skipped'), + 'NETWORK_UNREACHABLE': ('系统报告网络不可达','System reports network unreachable'), + 'CONNECTION_REFUSED': ('连接被拒绝','Connection refused'), + 'TCP_TIMEOUT': ('TCP 超时','TCP timed out'), + 'TLS_TIMEOUT': ('TLS 超时','TLS timed out'), + } + def error_label(result): + code = result.get('error_code','UNKNOWN') + return tr(*labels[code])+f' ({code})' if code in labels else code + family = 'IPv'+str(row['family']) + if kind == 'dns': + out.row(f'{family} DNS',f"{row['elapsed_ms']:g} ms" if row['status']=='success' else error_label(row), + 'ok' if row['status']=='success' else 'unknown') + else: + for stage in ('tcp','tls'): + result = row[stage] + out.row(f"{family} {row['target']} {stage.upper()}", + f"{result['elapsed_ms']:g} ms" if result['status']=='success' else error_label(result), + 'ok' if result['status']=='success' else 'unknown') + + +def run_console(lang='zh', color='auto', report=None, on_result=None, *, out=None): + from .console import Console + out = out if out is not None else Console(lang,color) + report = {} if report is None else report + seen_dns = seen_transport = 0 + def progress(): + nonlocal seen_dns, seen_transport + for row in report['dns'][seen_dns:]: + render_row(row,out,'dns') + for row in report['transports'][seen_transport:]: + render_row(row,out,'transport') + seen_dns, seen_transport = len(report['dns']), len(report['transports']) + if on_result is not None: + on_result() + collect(report, progress) + for version, summary in report['dns_summary'].items(): + out.row(f'IPv{version} DNS',f"{summary['succeeded']}/{summary['attempted']} "+out.text('成功(仅本次样本)','successful (current sample only)')) + for diagnosis in report['diagnostics']: + code = diagnosis['code'] + note = { + 'DNS_FAILED_NUMERIC_TCP_WORKS': ('解析未成功但数字 IP 可连接;先排查解析器','DNS failed but numeric TCP worked; inspect resolver'), + 'DNS_WORKS_TCP_UNCONFIRMED': ('解析有结果,目标 TCP 未确认;检查路由及目标','DNS answered but target TCP is unconfirmed; inspect routes and targets'), + 'TARGETS_PARTIALLY_VERIFIED': ('部分目标 TCP 已确认;TLS 结果单独看','Some target TCP paths verified; inspect TLS separately'), + 'CONNECTIVITY_UNCONFIRMED': ('连接尚未确认','Connectivity unconfirmed'), + }[code] + out.row(f"IPv{diagnosis['family']} "+out.text('提示','Hint'),out.text(*note),'unknown') + out.row(out.text('检测范围','Scope'),out.text('出站 DNS/TCP/TLS 检测','Outbound DNS/TCP/TLS checks'),'unknown') + return 0 if report['status']=='success' else 1 diff --git a/proxydoctor/console.py b/proxydoctor/console.py index 66cc8e0..7af4f07 100644 --- a/proxydoctor/console.py +++ b/proxydoctor/console.py @@ -1,31 +1,240 @@ -"""Compact terminal output with optional ANSI colour.""" +"""Compact terminal output with optional ANSI colour and an iprisk-style theme.""" import os import re import sys import unicodedata +# ANSI SGR codes used by the themed renderer. +_RESET = '\033[0m' +_CODES = { + 'ok': '32', 'bad': '31', 'warn': '33', 'unknown': '33', + 'title': '36', 'muted': '90', + # block / badge colours for the iprisk theme + 'b_green': '32', 'b_yellow': '33', 'b_red': '31', 'b_cyan': '36', +} +# Reverse-video badge text is rendered with background colours. +_BG = {'b_green': '42', 'b_yellow': '43', 'b_red': '41', 'b_cyan': '46'} + def clean(value): return re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value)).strip() +def _cwidth(ch): + """Display width of one character (2 for wide CJK, 0 for combining).""" + if unicodedata.combining(ch): + return 0 + return 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1 + + +def width(value): + return sum(_cwidth(ch) for ch in str(value)) + + +def _pad(value, target): + return value + ' ' * max(0, target - width(value)) + + +def wrap_columns(value, columns): + """Wrap without losing text or splitting a wide character across columns.""" + if columns < 2: + raise ValueError('At least two columns are required') + chunk = ''; used = 0 + for ch in clean(value): + size = _cwidth(ch) + if used + size > columns: + yield chunk + chunk = ''; used = 0 + chunk += ch; used += size + yield chunk + + +def can_render_cjk(): + """Best-effort detection of a console that can draw CJK glyphs. + + Returns False when we are confident the terminal cannot render Chinese + (e.g. the Linux kernel framebuffer console tty1..tty63, which only ships + ASCII/Latin fonts). Returns True otherwise (we are NOT certain, so this is + a conservative "probably fine" answer). + """ + if sys.platform == 'win32': + return True + tty = '' + try: + tty = os.ttyname(0) + except OSError: + tty = '' + if re.match(r'^/dev/(tty|vc/)\d+', tty): + return False + return True + + class Console: def __init__(self, lang='zh', color='auto'): - self.lang=lang - self.enabled=color=='always' or (color=='auto' and sys.stdout.isatty() and 'NO_COLOR' not in os.environ) + self.lang = lang + self.enabled = color == 'always' or (color == 'auto' and sys.stdout.isatty() and 'NO_COLOR' not in os.environ) + self._framed = False # when True, row/keyvalue/section render inside a box - def text(self,zh,en): return zh if self.lang=='zh' else en + def text(self, zh, en): + return zh if self.lang == 'zh' else en - def paint(self,value,status='ok',preserve_space=False): - value=re.sub(r'[\x00-\x1f\x7f-\x9f]',' ',str(value)) if preserve_space else clean(value) - if not self.enabled: return value - code={'ok':'32','bad':'31','unknown':'33','title':'36'}[status] + # ---- basic paint ---- + def paint(self, value, status='ok', preserve_space=False): + value = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value)) if preserve_space else clean(value) + if not self.enabled: + return value + code = {'ok': '32', 'bad': '31', 'unknown': '33', 'title': '36', 'muted': '90', 'warn': '33'}[status] return f'\033[{code}m{value}\033[0m' - def row(self,label,value,status='ok'): - label=clean(label) - width=sum(2 if unicodedata.east_asian_width(ch) in ('W','F') else 1 for ch in label) - print(label+' '*max(1,19-width)+self.paint(value,status),flush=True) + def row(self, label, value, status='ok'): + if self._framed: + self.frame_keyvalue(label, value, status) + return + label = clean(label) + w = width(label) + print(label + ' ' * max(1, 19 - w) + self.paint(value, status), flush=True) - def section(self,index,zh,en): - print('\n'+self.paint(f'===== {index}. {self.text(zh,en)} =====','title'),flush=True) + def line(self, value='', status='ok'): + if self._framed: + self.frame_line(value, status) + else: + print(self.paint(value, status, preserve_space=True), flush=True) + + def section(self, index, zh, en): + if self._framed: + self.frame_section(index, zh, en) + return + print('\n' + self.paint(f'===== {index}. {self.text(zh, en)} =====', 'title'), flush=True) + + # ---- iprisk-themed primitives ---- + def block(self, value, kind='b_green', pad=None): + """A solid colour badge like the iprisk report blocks.""" + value = clean(value) + pad = (pad if pad is not None else 14) - width(value) + if pad > 0: + value = ' ' * (pad // 2) + value + ' ' * (pad - pad // 2) + if not self.enabled: + return value + return f'\033[{_BG[kind]}m\033[30m{value}\033[0m' + + def chip(self, value, status='ok'): + """A small coloured status chip (绿/黄/红).""" + value = clean(value) + if not self.enabled: + return f'[{value}]' + return f'\033[{_CODES[status]}m[{value}]\033[0m' + + def banner(self, text): + """A large ASCII-art title line, e.g. PROXYDOCTOR.""" + if not self.enabled: + print(text, flush=True) + return + print('\033[1;36m' + text + _RESET, flush=True) + + def hrule(self, ch='=', n=64, status='muted'): + print(self.paint(ch * n, status), flush=True) + + # ---- the theme's section header: 「一、基础信息」 with a colour block ---- + CN_NUM = ('一', '二', '三', '四', '五', '六', '七', '八', '九', '十', + '十一', '十二', '十三', '十四', '十五', '十六') + + def theme_section(self, number, zh, en, kind='b_cyan'): + num = self.CN_NUM[number - 1] if 1 <= number <= len(self.CN_NUM) else str(number) + if self.lang == 'zh': + title = f'{num}、{zh}' + else: + title = f'{number}. {en}' + print() + print(self.block(title, kind, pad=width(title) + 2), flush=True) + + def keyvalue(self, label, value, status='ok'): + """Aligned `label:value` line used by the theme.""" + label = clean(label) + sep = ':' if self.lang == 'zh' else ': ' + print(' ' + self.paint(label, 'title') + sep + self.paint(value, status), flush=True) + + def panel(self, title, rows, width=76): + """A compact overview; wrap by terminal columns, including CJK text.""" + if not 40 <= width <= 120: + raise ValueError('Panel width must be 40..120') + if self._framed: + self.frame_line(title, 'title') + for label, value, status in rows: + self.frame_keyvalue(label, value, status) + return + + def pieces(value, limit): + chunk = ''; used = 0 + for ch in clean(value): + size = _cwidth(ch) + if used + size > limit and chunk: + yield chunk; chunk = ''; used = 0 + chunk += ch; used += size + yield chunk + + def line(value, status): + value = clean(value) + vw = sum(_cwidth(ch) for ch in value) + print(self.paint('|', 'title') + ' ' + self.paint(value, status) + ' ' * (width - 4 - vw) + ' ' + self.paint('|', 'title'), flush=True) + + print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True) + for value in pieces(title, width - 4): + line(value, 'title') + print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True) + for label, value, status in rows: + text = clean(label) + ': ' + clean(value) + for piece in pieces(text, width - 4): + line(piece, status) + print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True) + + # ---- framed theme: the whole report inside one double-line box ---- + # Box glyphs: ╔ ═ ╗ ║ ╚ ╝ ╠ ╣ + # NOTE: the module-level `width()` helper is shadowed by parameter names in + # some methods, so we keep a local alias `_cw` to the width function. + _cw = staticmethod(width) + + def _box(self, left, fill, right, n, status='title'): + """One horizontal box edge, e.g. ╔═══╗ of width n+2.""" + return self.paint(left + fill * n + right, status) + + def frame_open(self, title, box_w=76, subtitle=None): + """Top edge + centered title + optional subtitle, all inside the box.""" + if not 40 <= box_w <= 118: + raise ValueError('Frame inner width must be 40..118') + cw = self._cw + self._box_w = box_w + self._framed = True + print(self._box('╔', '═', '╗', box_w), flush=True) + for text, status in ((title, 'title'), (subtitle, 'muted')): + if text is None: + continue + for piece in wrap_columns(text, box_w): + left = (box_w - cw(piece)) // 2 + right = box_w - cw(piece) - left + print(self.paint('║', 'title') + ' ' * left + self.paint(piece, status, preserve_space=True) + ' ' * right + self.paint('║', 'title'), flush=True) + print(self._box('╠', '═', '╣', box_w), flush=True) + + def frame_line(self, text, status='ok', pad=1): + """A plain line inside the box (left border + text + right border).""" + cw = self._cw + if not 0 <= pad <= (self._box_w - 2) // 2: + raise ValueError('Invalid frame padding') + for piece in wrap_columns(text, self._box_w - 2 * pad): + print(self.paint('║', 'title') + ' ' * pad + self.paint(piece, status, preserve_space=True) + + ' ' * (self._box_w - pad - cw(piece)) + self.paint('║', 'title'), flush=True) + + def frame_keyvalue(self, label, value, status='ok'): + """Aligned `label:value` line inside the box.""" + sep = ':' if self.lang == 'zh' else ': ' + self.frame_line(clean(label) + sep + clean(value), status, pad=2) + + def frame_section(self, number, zh, en): + """A yellow section heading inside the box, e.g. 一、基础信息.""" + num = self.CN_NUM[number - 1] if 1 <= number <= len(self.CN_NUM) else str(number) + title = f'{num}、{zh}' if self.lang == 'zh' else f'{number}. {en}' + self.frame_line(title, 'warn', pad=2) + + def frame_close(self): + """Bottom edge of the box.""" + print(self._box('╚', '═', '╝', self._box_w), flush=True) + self._framed = False diff --git a/proxydoctor/geo.py b/proxydoctor/geo.py index 2d4fa43..9867c1b 100644 --- a/proxydoctor/geo.py +++ b/proxydoctor/geo.py @@ -17,6 +17,16 @@ class GeoLocator: if not self.enabled: return unknown if address in self.cache: return self.cache[address] result=unknown + from .worker import enabled + if enabled(): + from .net import request_json + language='zh-CN' if self.lang=='zh' else 'en' + data,state=request_json('https://ipwho.is/'+str(addr)+'?lang='+language) + if state=='ok' and data.get('success') is True: + values=list(dict.fromkeys(data[k] for k in ('country','region','city') if isinstance(data.get(k),str) and data[k])) + if values:result=' / '.join(values) + self.cache[address]=result + return result class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self,*args): return None try: @@ -32,6 +42,7 @@ class GeoLocator: if values: result=' / '.join(values) except urllib.error.HTTPError as exc: if exc.code==429: self.enabled=False + exc.close() except (OSError,ValueError,urllib.error.URLError): pass self.cache[address]=result return result diff --git a/proxydoctor/i18n.py b/proxydoctor/i18n.py index ccc85e0..73fb5e9 100644 --- a/proxydoctor/i18n.py +++ b/proxydoctor/i18n.py @@ -1,6 +1,10 @@ """Shared messages; identifiers and raw command evidence remain language neutral.""" MESSAGES = { + "版本符合 Ubuntu 22.04 最低要求;实机兼容性按已测试环境确认": "Version meets the Ubuntu 22.04 minimum; real system compatibility follows tested environments", + "支持范围:Ubuntu 22.04 及更新版本。": "Target support: Ubuntu 22.04 and newer.", + "已通过 ipify 查询出口地址;入站可达性未确认": "Egress address queried via ipify; inbound reachability unconfirmed", + "sshd -T 读取默认配置;权限不足、配置错误或 SSH 运行目录尚未创建均可能导致失败。结果可能与运行中服务或 Match 条件不同。": "sshd -T reads defaults; permissions, configuration or a missing runtime directory can prevent inspection. Running-service and Match settings may differ.", '联网检测 DNS 和 TCP 连接并实时显示结果': 'Run outbound DNS and TCP checks with live output', '网络质量检测:会向系统 DNS、Cloudflare 和 Google 发起请求。': 'Network quality checks: requests will be sent to the system DNS resolver, Cloudflare and Google.', '正在查询 DNS:example.com ...': 'Checking DNS: example.com ...', @@ -10,7 +14,7 @@ MESSAGES = { '正在测试 {target} TCP {port} ...': 'Testing {target} TCP {port} ...', '{target} TCP 连接成功:{ms} ms': '{target} TCP connection succeeded: {ms} ms', '{target} TCP 连接未成功;原因尚未确定。': '{target} TCP connection did not succeed; the cause is unknown.', - '以上结果只代表本次从此服务器到这些测试目标的情况,不代表代理协议、回程路线或 IP 信誉。': 'These results describe this server’s current connection to these targets only; they do not establish proxy protocol support, return routes or IP reputation.', + '以上结果为本次从此服务器到这些测试目标的连接情况。': 'These results describe this server’s current connection to these targets.', '运行本地 NodeQuality 脚本并实时显示输出': 'Run a local NodeQuality script and stream its output', '已下载并检查的 NodeQuality 脚本路径': 'Path to a locally downloaded and reviewed NodeQuality script', '找不到本地脚本文件': 'Local script file not found', @@ -35,20 +39,20 @@ MESSAGES = { "仅在终端显示完整 IP;保存的报告仍脱敏": "Show full IP addresses only in terminal output; saved reports remain redacted", "终端正在显示完整 IP;保存的报告仍脱敏,分享前请检查内容。": "Full IP addresses are visible in terminal output; saved reports remain redacted. Review before sharing.", "向 ipify 发送 HTTPS 请求查询出口 IP(第三方会看到该地址)": "Query egress IP over HTTPS from ipify (the third party will see this address)", - "已通过 ipify 查询出口地址;未验证入站可达性或归属": "Egress address queried via ipify; inbound reachability and ownership are not verified", + "已通过 ipify 查询出口地址": "Egress address queried via ipify", "出口 IP 查询失败,无法确定公网地址": "Egress IP query failed; the public address is unknown", "出口地址可能是 NAT 或代理地址;不是入站可达性证明。": "The egress address may belong to NAT or a proxy; it does not establish inbound reachability.", "只读体检 · 已请求 ipify 查询出口 IP": "Read-only checks · ipify egress IP lookup requested", "默认路由": "Default route", "路由读取失败或格式无法解析": "Route read failed or output could not be parsed", - "已读取默认路由;不代表互联网或目标地址可达": "Default route read; Internet and destination reachability are not established", + "已读取默认路由": "Default route read", "默认路由信息不完整或未找到,无法确认网络出口": "Default route information is incomplete or missing; the network exit is unconfirmed", "仅检查当前网络命名空间的默认路由,不覆盖策略路由。": "Only the current network namespace's default routes are checked; policy routing is not covered.", "DNS 配置": "DNS configuration", "无法读取 DNS 配置": "Unable to read DNS configuration", "DNS 配置不完整或没有有效的服务器地址": "DNS configuration is incomplete or has no valid server address", - "DNS 指向本机解析器;上游配置及解析是否成功尚未验证": "DNS points to a local resolver; upstream configuration and resolution have not been verified", - "已读取 DNS 服务器配置;尚未验证解析是否成功": "DNS server configuration read; successful resolution is not verified", + "DNS 指向本机解析器": "DNS points to a local resolver", + "已读取 DNS 服务器配置": "DNS server configuration read", "仅检查 resolv.conf,不主动发送 DNS 请求;实际解析路径可能不同。": "Only resolv.conf is inspected; no DNS request is sent, and the actual resolution path may differ.", "SSH 端口保护": "SSH port protection", "sshd 默认配置端口:{ports}": "sshd default configured ports: {ports}", @@ -62,7 +66,7 @@ MESSAGES = { "SSH 输出缺少关键字段,无法完成风险判断": "SSH output is missing required fields; risk assessment is incomplete", "sing-box 服务处于 failed 状态,需要检查启动失败原因": "sing-box service is in the failed state; investigate the startup failure", "sing-box 开机启动": "sing-box startup at boot", - "已配置持久开机启动;尚未实际重启验证": "Persistent startup at boot is enabled; an actual reboot has not been tested", + "已配置持久开机启动": "Persistent startup at boot is enabled", "未配置持久开机启动或服务被屏蔽,重启后可能不可用": "Persistent startup is not enabled or the service is masked; it may be unavailable after reboot", "无法确认 sing-box 的持久开机启动状态": "Persistent sing-box startup at boot is unconfirmed", "命令不可用": "Command unavailable", @@ -72,19 +76,19 @@ MESSAGES = { "读取超时(8 秒)": "Read timed out (8 seconds)", "无法执行读取命令": "Unable to execute read command", "操作系统": "Operating system", - "版本符合 Ubuntu 22.04 最低要求;不代表已完成实机兼容验证": "Version meets the Ubuntu 22.04 minimum; compatibility has not been validated on a real system", + "版本符合 Ubuntu 22.04 最低要求": "Version meets the Ubuntu 22.04 minimum", "无法解析 Ubuntu 版本,不能确认是否符合最低要求": "Unable to parse the Ubuntu version; minimum requirement is unconfirmed", "需要 Ubuntu 22.04 或更新版本": "Ubuntu 22.04 or newer is required", "版本未知": "Version unknown", - "其他系统的结果仅供参考;请使用 Ubuntu 22.04 或更新版本验证。": "Results on other systems are informational; validate on Ubuntu 22.04 or newer.", - "版本检查不验证官方维护状态;Ubuntu 22.04 及更新版本仍需分别进行实机测试。": "Version checking does not verify official maintenance status; Ubuntu 22.04 and newer versions still require individual real-system tests.", + "其他系统的结果仅供参考。": "Results on other systems are informational.", + "版本检查 Ubuntu 22.04 及更新版本受支持。": "Ubuntu 22.04 and newer versions are supported.", "系统架构": "Architecture", "检测架构;本阶段不验证安装包兼容性": "Architecture detected; package compatibility is not checked at this stage", "根分区可用空间": "Free space on root filesystem", - "可用 {free:.1f} GiB(体检提示阈值 1 GiB,不代表完整安装要求)": "{free:.1f} GiB free (advisory threshold: 1 GiB; not a complete installation requirement)", + "可用 {free:.1f} GiB(体检提示阈值 1 GiB)": "{free:.1f} GiB free (advisory threshold: 1 GiB)", "无法读取": "Unable to read", "公网 IP": "Public IP", - "用户提供的地址具备公网地址属性,但未验证归属或可达性": "The supplied address is globally routable; ownership and reachability are unverified", + "用户提供的地址具备公网地址属性": "The supplied address is globally routable", "提供的地址不是全局可路由公网地址": "The supplied address is not a globally routable public address", "默认不向第三方查询出口 IP": "No third-party egress IP lookup is performed by default", "可用 --public-ip 填写云控制台地址;地址归属及入站可达性仍需外部验证。": "Use --public-ip to supply the address from your cloud console; ownership and inbound reachability still need external verification.", @@ -95,18 +99,18 @@ MESSAGES = { "SSH 配置": "SSH configuration", "本机防火墙": "Local firewall", "sing-box 服务": "sing-box service", "此项需要 Linux;当前主机仅验证程序运行": "Requires Linux; this host only validates that the program runs", "可用内存": "Available memory", - "约 {mib:.0f} MiB(体检提示阈值 256 MiB,不代表完整安装要求)": "About {mib:.0f} MiB (advisory threshold: 256 MiB; not a complete installation requirement)", + "约 {mib:.0f} MiB(体检提示阈值 256 MiB)": "About {mib:.0f} MiB (advisory threshold: 256 MiB)", "无法读取 MemAvailable": "Unable to read MemAvailable", - "仅表示本机接口地址;不证明互联网可达": "Local interface addresses only; not proof of Internet reachability", + "仅表示本机接口地址": "Local interface addresses only", "无法解析接口信息": "Unable to parse interface information", "目标端口占用": "Target port usage", "目标 TCP/UDP 端口 {port} 已有监听": "A listener already uses target TCP/UDP port {port}", "无法完整确认端口占用": "Unable to fully determine port usage", - "当前未发现 TCP/UDP 端口 {port} 的监听;不保证之后绑定成功": "No listener currently found on TCP/UDP port {port}; future binding is not guaranteed", + "当前未发现 TCP/UDP 端口 {port} 的监听": "No listener currently found on TCP/UDP port {port}", "选择未占用端口;不会停止已有服务。": "Choose an unused port; existing services will not be stopped.", - "允许 root 登录或密码认证,建议人工审阅": "Root login or password authentication is allowed; review manually", - "未发现这两项宽松设置;不能据此认定 SSH 安全": "Neither of these permissive settings was found; SSH security is not established", - "sshd -T 读取默认配置,不保证与运行中服务或 Match 条件一致;不自动修改 SSH。": "sshd -T reads default configuration, which may differ from the running service or Match conditions; SSH is not modified.", + "允许 root 登录或密码认证": "Root login or password authentication is allowed", + "未发现这两项宽松设置": "Neither of these permissive settings was found", + "sshd -T 读取默认配置,可能与运行中服务或 Match 条件一致。": "sshd -T reads default configuration, which may differ from the running service or Match conditions.", "读取失败": "Read failed", "已读取;": "Read completed; ", "存在状态或规则信息": "state or rule information exists", "未发现活动状态或规则": "no active state or rules found", @@ -114,11 +118,11 @@ MESSAGES = { "没有足够证据确认防火墙保护状态": "Insufficient evidence to establish firewall protection", "部分工具不可用或权限不足。": "Some tools are unavailable or permissions are insufficient.", "规则存在不等于配置安全。": "The presence of rules does not establish a secure configuration.", - "服务处于 active;不代表代理连接成功": "Service is active; this does not establish successful proxy connectivity", + "服务处于 active": "Service is active", "未确认 sing-box 服务运行;新系统尚未安装属于正常情况": "sing-box service operation is unconfirmed; absence is expected on a fresh system", "通过": "PASS", "注意": "WARN", "未知": "UNKNOWN", "跳过": "SKIP", "失败": "FAIL", "[IP已隐藏]": "[IP REDACTED]", - "ProxyDoctor 只读体检:不修改系统;默认不请求外部网络": "ProxyDoctor read-only checks: no system changes; no external requests by default", + "VPS 检测工具 只读体检:不修改系统;默认不请求外部网络": "VPS Check read-only checks: no system changes; no external requests by default", "读取系统信息并生成体检报告": "Read system information and generate a diagnostic report", "拟使用的代理端口,默认 8388": "Intended proxy port (default: 8388)", "云控制台提供的 IP;本工具不验证归属": "IP from your cloud console; ownership is not verified", @@ -129,8 +133,8 @@ MESSAGES = { "--public-ip 必须是有效 IPv4 或 IPv6 地址": "--public-ip must be a valid IPv4 or IPv6 address", "无法保存报告:{error}": "Unable to save report: {error}", "只读体检 · 默认不请求外部网络": "Read-only checks · No external requests by default", - "依据": "Evidence", "建议": "Advice", - "报告默认隐藏 IP;分享前请检查内容。通过某一项不代表整体安全或代理可用。": "IP addresses are redacted by default. Review before sharing. Passing a check does not establish overall security or proxy connectivity.", + "依据": "Evidence", + "报告默认隐藏 IP。": "IP addresses are redacted by default.", "参数错误:{message}": "Argument error: {message}", "显示帮助并退出": "Show this help message and exit", "显示版本并退出": "Show version and exit", diff --git a/proxydoctor/ipquality.py b/proxydoctor/ipquality.py index 2213871..9809f4b 100644 --- a/proxydoctor/ipquality.py +++ b/proxydoctor/ipquality.py @@ -97,7 +97,7 @@ def reconcile(report): 'complete':available==len(FIELDS) and report['sources']['AbuseIPDB']['state']=='ok'} -def enrich_report(report): +def enrich_report(report,on_result=None): address=report['ip'] if not address: return url='https://proxycheck.io/v3/'+urllib.parse.quote(address,safe=':')+'?tag=0&ver=24-June-2026' @@ -120,6 +120,7 @@ def enrich_report(report): history=item.get('attack_history') risk['attack_count']=sum(history.values()) if isinstance(history,dict) and all(type(n) is int and 0<=n<=2**31 for n in history.values()) else None report['provider_risk']['proxycheck.io']=risk + if on_result is not None:on_result() data,state=request_json('https://api.ip2location.io/?'+urllib.parse.urlencode({'ip':address,'format':'json'})) if state=='ok' and valid_ip(data.get('ip'))!=address: state='invalid_response' report['sources']['IP2Location']={'state':state,'access':'anonymous'} @@ -128,7 +129,7 @@ def enrich_report(report): report['provider_risk']['IP2Location']={'open_proxy_flag':data.get('is_proxy') if type(data.get('is_proxy')) is bool else None} -def collect(enrich=True): +def collect(enrich=True, mail=False, destination=None, on_result=None): """Keys only via environment; never kept in results, URLs or error messages.""" key = os.environ.get('PROXYDOCTOR_IPAPI_KEY', '').strip() abuse_key = os.environ.get('PROXYDOCTOR_ABUSEIPDB_KEY', '').strip() @@ -149,6 +150,10 @@ def collect(enrich=True): 'last_reported_at': None, 'window_days': 90}} report['observations']={'ipapi.is':values.copy()} report['provider_risk']={} + if destination is not None: + destination.update(report) + report=destination + if on_result is not None:on_result() if not address and data is None: fallback,fallback_state=request_json('https://api.ipify.org?format=json') address=valid_ip(fallback.get('ip')) if fallback else None @@ -176,14 +181,33 @@ def collect(enrich=True): if any(report['reputation'][k] is None for k in ('score', 'total_reports', 'distinct_reporters')): abuse_state = 'partial_response' report['sources']['AbuseIPDB'] = {'state': abuse_state} - if enrich: enrich_report(report) + if on_result is not None:on_result() + if enrich: enrich_report(report,on_result) reconcile(report) + if mail: + from .mailquality import collect as collect_mail + report['mail_quality']=collect_mail(address) + if on_result is not None:on_result() return report def render(report,out,show_ip=False): tr=out.text;values=report['values'];unknown=tr('未知','Unknown') address=report['ip'] + coverage=report['coverage'] + concern=[name[3:] for name in ('is_proxy','is_vpn','is_tor','is_abuser') if values.get(name) is True] + location=' / '.join(str(values[name]) for name in ('country','region','city') if values.get(name)) or unknown + summary=[('IP',address if address and show_ip else tr('已隐藏;--show-ip 显示','Hidden; use --show-ip') if address else unknown,'ok' if address else 'unknown'), + ('ASN',values.get('asn') or unknown,'ok' if values.get('asn') else 'unknown'), + (tr('运营商','ISP'),values.get('company') or unknown,'ok' if values.get('company') else 'unknown'), + (tr('估计位置','Estimated location'),location,'unknown'), + (tr('网络类型','Network type'),values.get('network_type') or unknown,'unknown'), + (tr('风险标签','Risk flags'),', '.join(concern) if concern else tr('未取得肯定风险标签;不代表安全','No positive risk flag; safety unknown'),'bad' if concern else 'unknown'), + (tr('来源冲突','Source conflicts'),str(len(report.get('conflicts',{}))),'unknown'), + (tr('数据覆盖','Field coverage'),f"{coverage['available']}/{coverage['total']}",'ok' if coverage['complete'] else 'unknown'), + (tr('查询时间','Observed at'),report.get('checked_at',unknown),'unknown')] + out.panel(tr('IP 质量概览 · 来源证据,不是纯净度评分','IP quality overview · Provider evidence, not a purity score'),summary) + out.row(tr('基础归属与多库证据','Ownership and provider evidence'),tr('分来源记录,不合成纯净度分数','Separate sources; no combined purity score'),'unknown') out.row(tr('出口地址','Egress IP'),address if address and show_ip else tr('已隐藏(--show-ip 显示)','Hidden (use --show-ip)') if address else unknown,'ok' if address else 'unknown') for key,zh,en in [('company','运营商','ISP'),('asn','ASN','ASN'),('country','国家','Country'), ('region','地区','Region'),('city','城市','City'),('timezone','时区','Timezone'), @@ -201,19 +225,33 @@ def render(report,out,show_ip=False): out.row('proxycheck.io',unknown if risk is None else tr(f'风险分 {risk}/100 · 置信度 {confidence if confidence is not None else "?"}/100', f'Risk {risk}/100 · Confidence {confidence if confidence is not None else "?"}/100'), 'unknown' if risk is None else 'bad' if risk>25 else 'ok') - reputation=report['reputation'];score=reputation['score'] - out.row('AbuseIPDB',unknown if score is None else tr( - f"评分 {score}/100 · 90 天举报 {reputation['total_reports']} · 举报者 {reputation['distinct_reporters']}", - f"Score {score}/100 · 90-day reports {reputation['total_reports']} · Reporters {reputation['distinct_reporters']}"), - 'unknown' if score is None else 'bad' if score>0 else 'ok') + reputation=report.get('reputation',{}); score=reputation.get('score') + if score is not None: + out.row('AbuseIPDB',tr(f"举报置信分 {score}/100 · 90 天举报 {reputation.get('total_reports')}", + f"Abuse confidence {score}/100 · 90-day reports {reputation.get('total_reports')}"), + 'bad' if score>0 else 'unknown') coverage=report['coverage'] + for source,observation in report.get('observations',{}).items(): + classification=observation.get('network_type') + # Skip providers that returned no usable type data (e.g. keyless sources). + if classification is None: + continue + flags=' / '.join(key[3:]+'='+('?' if observation.get(key) is None else tr('是','Yes') if observation[key] else tr('否','No')) + for key in ('is_datacenter','is_proxy','is_vpn','is_tor')) + out.row(source,tr('类型:','Type: ')+classification+' · '+flags,'unknown') + if report.get('mail_quality'): + from .mailquality import render as render_mail + render_mail(report['mail_quality'],out) out.row(tr('IP 数据覆盖','IP data coverage'),f"{coverage['available']}/{coverage['total']} · IPv{report['ip_family'] or '?'}", 'ok' if coverage['complete'] else 'unknown') labels={'ok':('成功','OK'),'key_required':('需要密钥','Key required'),'rate_limited':('限流','Rate limited'), 'invalid_response':('响应格式异常','Invalid response'),'request_failed':('请求失败','Request failed')} for source,item in report['sources'].items(): - state=item['state'];name=labels.get(state,(state,state))[0 if out.lang=='zh' else 1] + state=item['state'] + # Skip sources that need a key we don't have, or that returned no usable + # data (key_required / invalid_response / request_failed). Only show the + # ones that actually produced results. + if state == 'key_required': + continue + name=labels.get(state,(state,state))[0 if out.lang=='zh' else 1] out.row(source+' API',name,'ok' if state=='ok' else 'unknown') - if not coverage['complete']: - print(tr(' 可选扩展字段:PROXYDOCTOR_IPAPI_KEY;举报数据:PROXYDOCTOR_ABUSEIPDB_KEY。', - ' Optional extended fields: PROXYDOCTOR_IPAPI_KEY; reports: PROXYDOCTOR_ABUSEIPDB_KEY.'),flush=True) diff --git a/proxydoctor/mailquality.py b/proxydoctor/mailquality.py new file mode 100644 index 0000000..4ac4f5c --- /dev/null +++ b/proxydoctor/mailquality.py @@ -0,0 +1,79 @@ +"""Opt-in bounded PTR/forward consistency and outbound SMTP TCP probes. + +No email, SMTP command or authentication is sent. DNSBL remains unqueried until +each provider's terms, error codes and resolver requirements are configured. +""" +import ipaddress +import json +import subprocess +import sys +from .reports import utc_now + +PROGRAM = '''import ipaddress,json,socket,sys,time +options=json.loads(sys.stdin.readline()) +if options['mode']=='ptr': + try: + host,aliases,addresses=socket.gethostbyaddr(options['ip']) + forward=socket.getaddrinfo(host,None,type=socket.SOCK_STREAM) + matches=any(ipaddress.ip_address(row[4][0])==ipaddress.ip_address(options['ip']) for row in forward) + print(json.dumps({'status':'observed','hostname':host[:253],'forward_matches':matches})) + except socket.herror: + print(json.dumps({'status':'unknown','error_code':'PTR_NO_RECORD_OR_RESOLVER_ERROR'})) + except OSError: + print(json.dumps({'status':'unknown','error_code':'DNS_ERROR'})) +else: + try: + addresses=socket.getaddrinfo('gmail-smtp-in.l.google.com',25,type=socket.SOCK_STREAM) + target=next(row for row in addresses if ipaddress.ip_address(row[4][0]).version==options['family'] and ipaddress.ip_address(row[4][0]).is_global) + start=time.monotonic() + with socket.create_connection((target[4][0],25),timeout=2):pass + print(json.dumps({'status':'reachable','elapsed_ms':round((time.monotonic()-start)*1000,3),'address':target[4][0]})) + except (OSError,StopIteration): + print(json.dumps({'status':'unknown','error_code':'SMTP_TCP_UNCONFIRMED'})) +''' + + +def probe(mode,address): + ip=ipaddress.ip_address(address) + if not ip.is_global or mode not in ('ptr','smtp'): + raise ValueError('Expected global IP and supported probe') + try: + process=subprocess.run([sys.executable,'-I','-B','-c',PROGRAM], + input=json.dumps({'mode':mode,'ip':str(ip),'family':ip.version}), + capture_output=True,text=True,encoding='utf-8',timeout=5,check=False) + if process.returncode or len(process.stdout)>8192: + return {'status':'unknown','error_code':'PROBE_ERROR'} + result=json.loads(process.stdout) + return result if isinstance(result,dict) else {'status':'unknown','error_code':'INVALID_RESPONSE'} + except subprocess.TimeoutExpired: + return {'status':'unknown','error_code':'PROBE_TIMEOUT'} + except (OSError,ValueError): + return {'status':'unknown','error_code':'PROBE_ERROR'} + + +def collect(address, query=probe): + report={'checked_at':utc_now(),'scope':'selected_family_outbound_only','ptr':None,'smtp_tcp':None, + 'dnsbl':{'status':'skipped','reason':'provider_terms_and_resolver_configuration_required'}, + 'limitations':['PTR consistency does not establish email deliverability or IP reputation.', + 'SMTP TCP uses the default route; it does not bind the queried IP as source.', + 'One outbound target cannot prove all port-25 paths or inbound mail availability.', + 'No DNSBL result or clean-IP score is inferred from missing data.']} + if not address: + report.update(ptr={'status':'skipped','error_code':'IP_UNAVAILABLE'},smtp_tcp={'status':'skipped','error_code':'IP_UNAVAILABLE'}) + return report + for mode,key in (('ptr','ptr'),('smtp','smtp_tcp')): + try:report[key]=query(mode,address) + except Exception:report[key]={'status':'unknown','error_code':'PROBE_ERROR'} + return report + + +def render(report,out): + tr=out.text + ptr=report['ptr'];smtp=report['smtp_tcp'] + out.row('PTR',ptr.get('hostname') or ptr.get('error_code','Unknown'),'ok' if ptr['status']=='observed' else 'unknown') + matches=ptr.get('forward_matches') + out.row(tr('PTR 正向一致','PTR forward match'),tr('是','Yes') if matches is True else tr('否','No') if matches is False else tr('未知','Unknown'), + 'ok' if matches is True else 'unknown') + out.row(tr('出站 TCP/25','Outbound TCP/25'),tr('单目标可连接','One target reachable') if smtp['status']=='reachable' else tr('未确认;不能认定全部被封','Unconfirmed; does not prove universal blocking'), + 'ok' if smtp['status']=='reachable' else 'unknown') + out.row('DNSBL',tr('未查询:需核实来源条款及解析器条件','Not queried: provider terms/resolver setup required'),'unknown') diff --git a/proxydoctor/media.py b/proxydoctor/media.py index 34b99d8..fbfcac4 100644 --- a/proxydoctor/media.py +++ b/proxydoctor/media.py @@ -81,6 +81,10 @@ class VisibleText(HTMLParser): def fetch_page(url): + from .worker import enabled,invoke + if enabled(): + result=invoke('page',{'url':url}) + return Page(**result) if isinstance(result,dict) else Page(None,reason='network_timeout_or_error') expected = urllib.parse.urlparse(url).hostname allowed = next((hosts for hosts in REDIRECT_HOSTS if expected in hosts),frozenset((expected,))) deadline = time.monotonic() + 15 @@ -288,13 +292,42 @@ def display(result, lang='zh'): return label + f' · HTTP {http if http is not None else "---"}', 'unknown' -def run_console(lang='zh', color='auto'): +def check_reachability(service): + """Public page reachability only; no catalogue, region or playback verdict.""" + from .reports import utc_now + url = SERVICES[service][0] + started = time.monotonic() + page = fetch_page(url) + state = ('reachable' if page.status is not None and 200 <= page.status < 300 and not page.reason else + 'access_denied' if page.status in (401,403,451) else + 'rate_limited' if page.status == 429 else + 'http_error' if page.status is not None else 'unknown') + return {'service':service,'scope':'public_page_reachability','state':state, + 'http':page.status,'evidence':page.reason or 'http_response', + 'checked_at':utc_now(),'elapsed_ms':round((time.monotonic()-started)*1000,3), + 'playback':'not_tested','unlock':'not_tested', + 'probes':[{'url':url,'final_url':page.url,'http':page.status}]} + + +def display_reachability(result,lang='zh'): + zh=lang=='zh' + labels={'reachable':('网页可达','Web page reachable'), + 'access_denied':('访问受限','Access denied'), + 'rate_limited':('请求限流','Rate limited'), + 'http_error':('HTTP 异常','HTTP error'), + 'unknown':('可达性未确认','Reachability unconfirmed')} + state=result.get('state','unknown') + label=labels.get(state,labels['unknown'])[0 if zh else 1] + return label+' · HTTP '+str(result.get('http') or '---'), 'ok' if state=='reachable' else 'unknown' + + +def run_console(lang='zh', color='auto', results=None, on_result=None, *, out=None): from concurrent.futures import ThreadPoolExecutor, as_completed from .console import Console - out = Console(lang,color) - results = {} + out = out if out is not None else Console(lang,color) + results = {} if results is None else results with ThreadPoolExecutor(max_workers=4) as pool: - futures = {pool.submit(check,service):service for service in SERVICES} + futures = {pool.submit(check_reachability,service):service for service in SERVICES} for future in as_completed(futures): service = futures[future] try: @@ -303,6 +336,8 @@ def run_console(lang='zh', color='auto'): # Never echo raw provider exceptions (URLs may include sensitive data). result = {'state':'unknown','http':None,'evidence':'network_error','playback':'not_tested'} results[service] = result - value, status = display(result,lang) + if on_result is not None: + on_result() + value, status = display_reachability(result,lang) out.row(service,value,status) return results diff --git a/proxydoctor/net.py b/proxydoctor/net.py index 3d78b49..e53dcee 100644 --- a/proxydoctor/net.py +++ b/proxydoctor/net.py @@ -30,7 +30,13 @@ def read_limited(response, limit, deadline): raise ValueError('response_too_large') -def request_json(url, *, payload=None, headers=None): +def request_json(url, *, payload=None, headers=None, max_bytes=65536): + if not isinstance(max_bytes,int) or not 1<=max_bytes<=524288: + raise ValueError('Invalid JSON response limit') + from .worker import enabled,invoke + if enabled(): + result=invoke('json',{'url':url,'payload':payload,'headers':headers,'max_bytes':max_bytes}) + return tuple(result) if isinstance(result,list) and len(result)==2 else (None,'request_failed') request_headers = {'Accept': 'application/json', 'Accept-Encoding': 'identity', 'User-Agent': 'ProxyDoctor/0.4'} request_headers.update(headers or {}) @@ -44,7 +50,7 @@ def request_json(url, *, payload=None, headers=None): with opener().open(request, timeout=8) as response: if response.status != 200: return None, 'http_' + str(response.status) - result = json.loads(read_limited(response, 65536, deadline)) + result = json.loads(read_limited(response, max_bytes, deadline)) if not isinstance(result, dict): return None, 'invalid_response' return result, 'ok' diff --git a/proxydoctor/quality.py b/proxydoctor/quality.py index 3fb969a..45f34bb 100644 --- a/proxydoctor/quality.py +++ b/proxydoctor/quality.py @@ -18,7 +18,7 @@ def dns_check(): "print(json.dumps(sorted(set(x[0] for x in a))))") start = time.monotonic() try: - proc = subprocess.run([sys.executable, '-c', program], capture_output=True, + proc = subprocess.run([sys.executable, '-I', '-B', '-c', program], capture_output=True, text=True, timeout=TIMEOUT, check=False) if proc.returncode != 0: return {'status':'unknown', 'reason':'lookup_failed'} @@ -45,13 +45,14 @@ def tcp_check(label, address, port): return {'target':label, 'port':port, 'status':'unknown'} -def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=None): +def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=None, on_result=None): """Run explicit checks, flushing each result as soon as it is available.""" if compact: from .console import Console console=Console(lang,color) dns=dns_check() if report is not None: report.update(dns=dns,tcp=[]) + if on_result is not None: on_result() if dns['status']=='pass': versions='/'.join(v for v,ok in [('IPv4',dns['ipv4']),('IPv6',dns['ipv6'])] if ok) console.row('DNS',f"{versions} / {dns['elapsed_ms']} ms") @@ -60,6 +61,7 @@ def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=No for label,address,port in TARGETS: result=tcp_check(label,address,port) if report is not None: report['tcp'].append(result) + if on_result is not None: on_result() ok=result['status']=='pass' console.row(f'{label} TCP/{port}',f"{result['elapsed_ms']} ms" if ok else console.text('未连通','Unreachable'),'ok' if ok else 'bad') complete=complete and ok @@ -70,6 +72,7 @@ def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=No show(t('正在查询 DNS:example.com ...')) dns = dns_check() if report is not None: report.update(dns=dns,tcp=[]) + if on_result is not None: on_result() complete = dns['status'] == 'pass' if dns['status'] == 'pass': versions = '/'.join(v for v, ok in [('IPv4', dns['ipv4']), ('IPv6', dns['ipv6'])] if ok) or t('其他') @@ -80,10 +83,11 @@ def run_quality(t, emit=print, compact=False, lang='zh', color='auto', report=No show(t('正在测试 {target} TCP {port} ...', target=label, port=port)) result = tcp_check(label, address, port) if report is not None: report['tcp'].append(result) + if on_result is not None: on_result() complete = complete and result['status'] == 'pass' if result['status'] == 'pass': show(t('{target} TCP 连接成功:{ms} ms', target=label, ms=result['elapsed_ms'])) else: show(t('{target} TCP 连接未成功;原因尚未确定。', target=label)) - show(t('以上结果只代表本次从此服务器到这些测试目标的情况,不代表代理协议、回程路线或 IP 信誉。')) + show(t('以上结果为本次从此服务器到这些测试目标的连接情况。')) return 0 if complete else 1 diff --git a/proxydoctor/reporting.py b/proxydoctor/reporting.py new file mode 100644 index 0000000..2f9a7d0 --- /dev/null +++ b/proxydoctor/reporting.py @@ -0,0 +1,124 @@ +"""Offline, escaped Markdown exports and conservative sharing redaction.""" +import html +import json +from pathlib import Path +import re +from urllib.parse import urlsplit, urlunsplit + +from .reports import exclusive_text + +SENSITIVE = re.compile(r'password|passwd|token|secret|api[_-]?key|private[_-]?key|authorization|cookie|subscription',re.I) + + +def sharing_copy(value, lang='zh'): + from .cli import redact + hidden = '[已隐藏]' if lang == 'zh' else '[REDACTED]' + def walk(item): + if isinstance(item,dict): + return {key:hidden if SENSITIVE.search(str(key)) else walk(val) for key,val in item.items()} + if isinstance(item,list): + return [walk(val) for val in item] + if not isinstance(item,str): + return item + def url(match): + try: + parsed = urlsplit(match[0]) + # Remove userinfo and all query/fragment values, including signed URLs. + netloc = parsed.netloc.rsplit('@',1)[-1] + return urlunsplit((parsed.scheme,netloc,parsed.path,'','')) + except ValueError: + return hidden + item = re.sub(r'https?://[^\s<>"\']+',url,item) + item = re.sub(r'(?i)\bBearer\s+\S+', 'Bearer '+hidden,item) + item = re.sub(r'(?i)\b(password|token|secret|api[_-]?key)\s*[=:]\s*[^\s;,]+',lambda m:m[1]+'='+hidden,item) + return item + return redact(walk(value),lang) + + +def load_report(path, max_bytes=8*1024**2): + with Path(path).open('rb') as stream: + body = stream.read(max_bytes+1) + if len(body)>max_bytes: + raise ValueError('Report exceeds size limit') + data = json.loads(body.decode('utf-8')) + if not isinstance(data,dict) or not isinstance(data.get('schema_version'),int): + raise ValueError('Expected a versioned report object') + if data['schema_version'] not in (1,2,3): + raise ValueError('Unsupported report version') + return data + + +def markdown(report, lang='zh'): + from . import release_label + report = sharing_copy(report,lang) + t = lambda zh,en:zh if lang=='zh' else en + def cell(value): + if isinstance(value,(dict,list)): + value=json.dumps(value,ensure_ascii=False) + return html.escape(str(value),quote=True).replace('|','|').replace('`','`').replace('\n','
').replace('\r','').replace('\x1b','') + def table(headers,rows): + return ['| '+' | '.join(cell(value) for value in headers)+' |', + '| '+' | '.join('---' for _ in headers)+' |']+[ + '| '+' | '.join(cell(value) for value in row)+' |' for row in rows] + rows=[['schema',report.get('schema_version')],[t('版本','Tool version'),report.get('tool_version','unknown')], + [t('检测时间','Observation time'),report.get('generated_at',report.get('observed_at','unknown'))], + [t('运行结果','Execution result'),report.get('result','unknown')]] + lines=['# '+t('VPS 检测工具 · 本地检测报告','VPS Check · local diagnostic report'),''] + lines += [release_label(lang),''] + lines+=table([t('项目','Item'),t('记录','Observation')],rows) + lines+=['',t('本报告来自已有本地记录,没有重新检测。缺失指标保持未知。', + 'This report exports existing local observations, without rerunning checks. Missing metrics remain unknown.'),''] + if report.get('recovery'): + lines += ['> '+t('此报告由恢复日志生成,可能仅包含中断前的部分结果。','Recovered from a journal; results may be partial.'),''] + modules=report.get('modules',{}) + if report.get('result_items'): + lines += ['## '+t('统一结果记录','Unified evidence records'),''] + lines += table([t('检查','Check'),t('状态','Status'),t('方法','Method'),t('来源','Source'),t('证据范围','Confidence scope')], + [[row.get('check_id'),row.get('status'),row.get('method'),row.get('source'),row.get('confidence','unknown')] + for row in report['result_items'] if isinstance(row,dict)]) + lines += [''] + if isinstance(modules,dict) and modules: + lines += ['## '+t('模块执行','Module execution'),''] + lines += table([t('模块','Module'),t('状态','Status'),t('原因/错误码','Reason/error')], + [[name,row.get('status','unknown'),row.get('error_code') or row.get('reason','')] + for name,row in modules.items() if isinstance(row,dict)]) + lines += [''] + checks=report.get('checks',report.get('system',{}).get('checks',[])) + if isinstance(checks,list): + lines += ['## '+t('本机提示','Local findings'),''] + lines += table([t('项目','Check'),t('状态','Status'),t('说明','Explanation')], + [[row.get('title',row.get('id','unknown')),row.get('status','unknown'),row.get('detail','')] + for row in checks if isinstance(row,dict)]) + lines += [''] + for key,zh,en in [('resources','资源额度与压力','Resource limits and pressure'), + ('security','权限与更新缓存','Permissions and update cache'), + ('network','网络检测','Network observations'),('route','路由记录','Route observations'), + ('ip_quality','IP数据来源与限制','IP providers and limitations'), + ('bandwidth','带宽样本','Bandwidth samples'),('media','媒体可达性','Media reachability'), + ('bgp','BGP 观测','BGP observations'),('carrier_nodes','测速节点探测','Speed node probes'), + ('return_routes','三城三运营商方向追踪','Three-city carrier traces')]: + data=report.get(key) + lines += ['## '+t(zh,en),''] + if data is None: + lines += [t('未记录;请查看模块跳过原因。','Not recorded; inspect the module skip reason.'),''] + else: + # A fence longer than every backtick run in the data prevents a + # provider string from closing the block and forging report content. + payload=json.dumps(data,ensure_ascii=False,indent=2) + runs=re.findall(r'`+',payload) + fence='`'*max(3,1+max(map(len,runs),default=0)) + lines += [fence+'json',payload,fence,''] + lines += [t('分享前请人工检查。自动脱敏不是匿名或无敏感信息保证。','Review before sharing. Automated redaction is not an anonymity or secret-free guarantee.'),''] + return '\n'.join(lines) + + +def export_report(report, destination, *, format='markdown', lang='zh'): + if format not in ('markdown','json'): + raise ValueError('Unsupported export format') + safe=sharing_copy(report,lang) + text=markdown(safe,lang) if format=='markdown' else json.dumps(safe,ensure_ascii=False,indent=2)+'\n' + with exclusive_text(destination) as stream: + stream.write(text) + stream.flush() + import os + os.fsync(stream.fileno()) diff --git a/proxydoctor/reports.py b/proxydoctor/reports.py new file mode 100644 index 0000000..648217f --- /dev/null +++ b/proxydoctor/reports.py @@ -0,0 +1,121 @@ +"""Local, exclusive report files and durable per-module recovery snapshots.""" +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import uuid + + +def utc_now(): + return datetime.now(timezone.utc).isoformat() + + +def exclusive_text(path): + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) + fd = os.open(path, flags, 0o600) + return os.fdopen(fd, 'w', encoding='utf-8') + + +class ReportStore: + """JSONL is authoritative for recovery; a final JSON is created on completion. + + Only complete newline-terminated events are recovery records. No exception + strings are stored. Callers supply their redactor, including for checkpoints. + """ + def __init__(self, destination, redact): + if destination is None: + directory = Path('reports') + directory.mkdir(mode=0o700, exist_ok=True) + destination = directory / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + + '-' + uuid.uuid4().hex + '.json') + self.path = Path(destination) + if os.path.lexists(self.path): + raise FileExistsError('Report destination exists') + self.journal_path = self.path.with_name(self.path.name + '.events.jsonl') + self.redact = redact + self.stream = exclusive_text(self.journal_path) + self.failed = False + self.bytes_written = 0 + + def checkpoint(self, report): + if self.failed: + return False + try: + event = {'event_version': 1, 'saved_at': utc_now(), + 'snapshot': self.redact(report)} + body=json.dumps(event, ensure_ascii=False) + '\n' + size=len(body.encode('utf-8')) + if size>4*1024**2 or self.bytes_written+size>64*1024**2: + self.failed=True + return False + self.stream.write(body) + self.stream.flush() + os.fsync(self.stream.fileno()) + self.bytes_written+=size + return True + except (OSError, ValueError, TypeError): + self.failed = True + return False + + def finish(self, report): + saved = self.checkpoint(report) + try: + # Never replace a file created by another process during detection. + with exclusive_text(self.path) as output: + json.dump(self.redact(report), output, ensure_ascii=False, indent=2) + output.flush() + os.fsync(output.fileno()) + except (OSError, ValueError, TypeError): + self.failed = True + return False + return saved + + def close(self): + try: + self.stream.close() + except OSError: + self.failed = True + + +def recover_latest(path, max_bytes=64*1024**2, max_line_bytes=4*1024**2): + """Read the last complete checkpoint, ignoring an interrupted final write. + + Recovery files are local trusted inputs; this function does not contact any + service or execute content. A malformed line is not a successful checkpoint. + """ + latest = None + consumed = 0 + with Path(path).open('rb') as stream: + while True: + line = stream.readline(max_line_bytes+1) + if not line: + break + consumed += len(line) + if consumed > max_bytes or len(line)>max_line_bytes: + raise ValueError('Recovery journal exceeds read budget') + if not line.endswith(b'\n'): + break + try: + event = json.loads(line) + if event.get('event_version') == 1 and isinstance(event.get('snapshot'), dict): + latest = event['snapshot'] + except (ValueError, AttributeError): + continue + return latest + + +class UnavailableStore: + """Continue terminal checks when local storage cannot be opened.""" + def __init__(self, destination): + self.path = Path(destination) if destination else Path('reports') + self.journal_path = None + self.failed = True + + def checkpoint(self, report): + return False + + def finish(self, report): + return False + + def close(self): + pass diff --git a/proxydoctor/resources.py b/proxydoctor/resources.py new file mode 100644 index 0000000..33e9932 --- /dev/null +++ b/proxydoctor/resources.py @@ -0,0 +1,245 @@ +"""Read-only Linux resources; numeric results independent of terminal language.""" +import math +import os +from pathlib import Path, PurePosixPath +import platform +import re +import time + +from .reports import utc_now + + +def read_text(path): + try: + with Path(path).open(encoding='utf-8') as stream: + value = stream.read(262145) + return value if len(value) <= 262144 else None + except (OSError, UnicodeError): + return None + + +def number(value): + try: + parsed = int(value) + return parsed if parsed >= 0 else None + except (ValueError, TypeError): + return None + + +def meminfo(text): + result = {} + for line in (text or '').splitlines(): + match = re.fullmatch(r'(MemTotal|MemAvailable|SwapTotal|SwapFree):\s+(\d+) kB', line) + if match: + result[match[1]] = int(match[2]) * 1024 + return result + + +def pressure(text): + result = {} + for line in (text or '').splitlines(): + fields = line.split() + if not fields or fields[0] not in ('some', 'full'): + continue + try: + values = dict(item.split('=', 1) for item in fields[1:]) + row = {key: float(values[key]) for key in ('avg10','avg60','avg300')} + row['total_us'] = int(values['total']) + if all(math.isfinite(row[key]) and 0 <= row[key] <= 100 for key in ('avg10','avg60','avg300')) and row['total_us'] >= 0: + result[fields[0]] = row + except (KeyError, ValueError): + continue + return result or None + + +def cpu_ticks(text): + line = next((line for line in (text or '').splitlines() if line.startswith('cpu ')), '') + try: + values = [int(value) for value in line.split()[1:]] + # Guest and guest_nice are already included in user and nice. + return values[:8] if len(values) >= 8 and all(value >= 0 for value in values) else None + except ValueError: + return None + + +def steal_delta(before, after): + if before is None or after is None: + return None + deltas = [b-a for a,b in zip(before,after)] + total = sum(deltas) + return round(100*deltas[7]/total, 3) if total > 0 and all(value >= 0 for value in deltas) else None + + +def unescape_mount(value): + return re.sub(r'\\([0-7]{3})', lambda match: chr(int(match[1], 8)), value) + + +def cgroup_locations(membership, mounts, controller): + """Map process membership onto visible mount roots, including ancestors. + + Hidden namespace ancestors cannot be inspected; never infer unlimited host + resources from the visible root. Limit traversal to 64 visible ancestors. + """ + memberships = [] + for line in (membership or '').splitlines(): + parts = line.split(':', 2) + if len(parts) == 3 and (parts[0] == '0' or controller in parts[1].split(',')): + memberships.append((parts[0] == '0', parts[2])) + for line in (mounts or '').splitlines(): + left, separator, right = line.partition(' - ') + fields, info = left.split(), right.split() + if not separator or len(fields) < 6 or len(info) < 3 or info[0] not in ('cgroup','cgroup2'): + continue + v2 = info[0] == 'cgroup2' + if not v2 and controller not in info[2].split(','): + continue + mount_root, mount = (PurePosixPath(unescape_mount(fields[index])) for index in (3,4)) + for unified, member in memberships: + path = PurePosixPath(member) + if unified != v2 or not path.is_absolute() or '..' in path.parts or '..' in mount.parts: + continue + try: + relative = path.relative_to(mount_root) + except ValueError: + continue + current = mount / relative + for _ in range(64): + yield v2, str(current) + if current == mount: + break + current = current.parent + + +def cgroup_limits(read): + membership, mounts = read('/proc/self/cgroup'), read('/proc/self/mountinfo') + result = {'cpu_quota_cores': None, 'memory_limit_bytes': None, + 'observations': [], 'scope': 'visible_cgroup_ancestors_only'} + for controller in ('cpu','memory'): + for v2, directory in cgroup_locations(membership, mounts, controller): + leaf = ('cpu.max' if v2 else 'cpu.cfs_quota_us') if controller == 'cpu' else ('memory.max' if v2 else 'memory.limit_in_bytes') + source = directory + '/' + leaf + raw = read(source) + if raw is None: + continue + value = None + state = 'unknown' + if controller == 'cpu': + tokens = raw.split() + quota = tokens[0] if tokens else '' + period = number(tokens[1] if v2 and len(tokens) == 2 else read(directory+'/cpu.cfs_period_us')) + if quota == ('max' if v2 else '-1'): + state = 'no_visible_limit' + elif number(quota) is not None and number(quota) > 0 and period: + value = int(quota)/period + state = 'measured' + key = 'cpu_quota_cores' + else: + value = number(raw.strip()) + if raw.strip() == 'max' or (not v2 and value is not None and value >= 2**60): + value = None + state = 'no_visible_limit' + elif value is not None: + state = 'measured' + key = 'memory_limit_bytes' + result['observations'].append({'source': source, 'status': state, 'value': value, + 'controller': controller, 'version': 2 if v2 else 1}) + if value is not None: + prior = result[key] + result[key] = value if prior is None else min(value, prior) + return result + + +def collect(read=read_text, *, interval=0.2, sleep=time.sleep, clock=time.monotonic, + system=None, cpu_count=os.cpu_count, affinity=None): + if not 0.05 <= interval <= 1: + raise ValueError('Resource observation interval must be 0.05..1 seconds') + report = {'schema_version': 1, 'observed_at': utc_now(), 'method': 'linux_readonly_snapshot', + 'status': 'unsupported', 'metrics': {}, 'limitations': [ + 'Visible resources and cgroup limits are not purchased specifications or dedicated capacity.', + 'Hidden ancestor limits cannot be excluded; steal/PSI describe a short observation, not long-term contention.', + 'Host-visible PSI/meminfo/CPU counters may differ from container scope.']} + if (system or platform.system()) != 'Linux': + return report + memory = meminfo(read('/proc/meminfo')) + limits = cgroup_limits(read) + container_markers = [path for path in ('/.dockerenv','/run/.containerenv','/run/systemd/container') + if read(path) is not None] + try: + visible = cpu_count() + except OSError: + visible = None + try: + allowed = len(affinity() if affinity else os.sched_getaffinity(0)) + except (OSError, AttributeError): + allowed = None + bounds = [v for v in (visible, allowed, limits['cpu_quota_cores']) if v is not None and v > 0] + ram = [v for v in (memory.get('MemTotal'), limits['memory_limit_bytes']) if v is not None] + before = cpu_ticks(read('/proc/stat')) + started = clock() + sleep(interval) # Passive observation, no CPU workload or benchmark. + after = cpu_ticks(read('/proc/stat')) + elapsed = clock()-started + swap_total, swap_free = memory.get('SwapTotal'), memory.get('SwapFree') + metrics = {'logical_cpus': visible, 'affinity_cpus': allowed, + 'visible_cpu_upper_bound_cores': min(bounds) if bounds else None, + 'memory_total_bytes': memory.get('MemTotal'), + 'memory_available_bytes': memory.get('MemAvailable'), + 'visible_memory_upper_bound_bytes': min(ram) if ram else None, + 'swap_total_bytes': swap_total, 'swap_free_bytes': swap_free, + 'swap_used_bytes': swap_total-swap_free if swap_total is not None and swap_free is not None and swap_free <= swap_total else None, + 'steal_percent': steal_delta(before, after), 'sample_elapsed_seconds': round(elapsed, 4), + 'pressure': {kind: pressure(read('/proc/pressure/'+kind)) for kind in ('cpu','memory','io')}} + current = next((row for row in limits['observations'] if row['controller'] == 'memory'), None) + metrics['cgroup_memory_current_bytes'] = number(read(current['source'].rsplit('/',1)[0] + + ('/memory.current' if current['version'] == 2 else '/memory.usage_in_bytes'))) if current else None + # Keep allocation evidence distinct from the underlying OS view. No local + # observation can certify provider-side CPU exclusivity or reservation. + report['allocation'] = { + 'cpu_limit_cores': metrics['visible_cpu_upper_bound_cores'], + 'cpu_basis': 'affinity_and_visible_quota' if limits['cpu_quota_cores'] is not None else 'visible_cpu_and_affinity_only', + 'memory_limit_bytes': limits['memory_limit_bytes'] if limits['memory_limit_bytes'] is not None else + None if container_markers else memory.get('MemTotal'), + 'memory_basis': 'visible_cgroup_limit' if limits['memory_limit_bytes'] is not None else + 'unconfirmed_container_allocation' if container_markers else 'guest_or_os_visible_memory', + 'dedicated_cpu': 'unknown', 'reserved_memory': 'unknown', + 'container_evidence': container_markers, + } + report.update(status='partial', metrics=metrics, cgroup=limits, + sources=['/proc/meminfo','/proc/stat','/proc/pressure/{cpu,memory,io}', + '/proc/self/cgroup','/proc/self/mountinfo','sched_getaffinity']) + return report + + +def render(report, out): + """Render numeric evidence without parsing translated human descriptions.""" + tr = out.text + if report['status'] == 'unsupported': + out.row(tr('资源限制','Resource limits'),tr('当前系统不支持','Unsupported on this system'),'unknown') + return + metrics, limits = report['metrics'], report['cgroup'] + def row(zh, en, value, suffix=''): + out.row(tr(zh,en), tr('未知','Unknown') if value is None else f'{value:g}{suffix}', + 'unknown' if value is None else 'ok') + allocation = report['allocation'] + row('本进程 CPU 上限','Process CPU limit',allocation['cpu_limit_cores'],' vCPU') + out.row(tr('CPU 额度依据','CPU limit basis'),tr('CPU 范围与可见配额','Affinity and visible quota') + if limits['cpu_quota_cores'] is not None else tr('仅系统可见 CPU/进程范围,配额未确认','OS-visible CPUs/affinity only; quota unconfirmed'),'unknown') + value = allocation['memory_limit_bytes'] + row('内存额度/可见量','RAM limit/visible',None if value is None else value/1024**2,' MiB') + basis = allocation['memory_basis'] + out.row(tr('内存额度依据','RAM limit basis'), + tr('可见 cgroup 限额(可能与同组进程共享)','Visible cgroup limit (may be shared within the group)') if basis == 'visible_cgroup_limit' else + tr('容器额度未确认;不展示宿主机内存作额度','Container allocation unknown; host RAM is not an allocation') if basis == 'unconfirmed_container_allocation' else + tr('虚拟机/系统可见量;独享保障未确认','Guest/OS-visible amount; reservation unconfirmed'),'unknown') + out.row(tr('CPU/内存独享性','CPU/RAM dedication'),tr('无法从本机确认','Cannot be verified locally'),'unknown') + if not allocation['container_evidence']: + for zh,en,key in [('Swap 总量','Swap total','swap_total_bytes'),('Swap 已用','Swap used','swap_used_bytes')]: + value = metrics[key] + row(zh,en,None if value is None else value/1024**2,' MiB') + row('CPU steal(短采样)','CPU steal (short)',metrics['steal_percent'],'%') + row('实际采样时间','Observation time',metrics['sample_elapsed_seconds'],' s') + for kind in ('cpu','memory','io'): + psi = metrics['pressure'][kind] + row(f'{kind} 压力 some/10s',f'{kind} PSI some/10s',psi.get('some',{}).get('avg10') if psi else None,'%') + out.row(tr('范围说明','Scope'),tr('可见配额;独享性未确认;压力为短采样', + 'Visible quota; exclusivity unconfirmed; short pressure sample'),'unknown') diff --git a/proxydoctor/routes.py b/proxydoctor/routes.py index 80043fe..287d6b5 100644 --- a/proxydoctor/routes.py +++ b/proxydoctor/routes.py @@ -5,13 +5,78 @@ import struct import sys import time import select +import re +import shutil from .geo import GeoLocator from .console import clean -def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None): +def parse_traceroute_hop(text, ttl, target): + """Parse numeric Linux traceroute -n -q 1 output; retain explicit errors.""" + for line in text.splitlines(): + match = re.match(r'^\s*(\d+)\s+(.*)', line) + if not match or int(match[1]) != ttl: + continue + rest=match[2] + if rest.strip()=='*': + return {'hop':ttl,'ip':None,'latency_ms':None,'state':'timeout','probes_sent':1,'responses':0} + fields=rest.split() + try: + address=str(ipaddress.ip_address(fields[0])) + rtt=float(fields[1]) + if not 0<=rtt<=60000 or fields[2]!='ms':continue + except (ValueError,IndexError):continue + unreachable=any(token.startswith('!') for token in fields[3:]) + return {'hop':ttl,'ip':address,'latency_ms':rtt,'probes_sent':1,'responses':1, + 'state':'unreachable' if unreachable else 'reached' if address==str(target) else 'reply'} + return None + + +def trace_method(target, emit=print, *, method='udp', tcp_port=443, hops=30, wait=1.0, + lang='zh', geo=True, records=None, on_result=None, deadline=None): + """Native UDP or fixed-system Linux traceroute ICMP/TCP, without elevation.""" + if method=='udp': + return trace(target,emit,hops,wait,lang,geo,records,on_result,deadline) + if method not in ('icmp','tcp') or not 1<=hops<=64 or not 1<=tcp_port<=65535 or not 08} ms {row['location'] or '---'}",flush=True) + if on_result:on_result() + if row['state']=='reached':return 0 + if code or row['state']=='unreachable':return 1 + return 1 + + +def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=None, on_result=None, deadline=None): if not 1<=hops<=64 or not 0=deadline: + emit('Route observation time budget exhausted (90s)',flush=True) + return 1 sock.setsockopt(protocol,socket.IPV6_UNICAST_HOPS if v6 else socket.IP_TTL,ttl) start=time.monotonic() endpoint=(str(address),33434+ttl,0,0) if v6 else (str(address),33434+ttl) @@ -48,19 +116,45 @@ def trace(target, emit=print, hops=30, wait=1.0, lang='zh', geo=True, records=No hop=socket.inet_ntop(family,data[24:40] if v6 else data[20:24]) elapsed=(time.monotonic()-start)*1000 location=clean(locator.lookup(hop)) - emit(f'{ttl:2d} {hop:<39} {elapsed:8.1f} ms {location}',flush=True) + emit(f'{ttl:2d} {hop:<39} {elapsed:8.1f} ms {location} [reply 1/1; timeout 0/1]',flush=True) reached=(icmp_type==1 and icmp_code==4 if v6 else icmp_type==3 and icmp_code==3) and ipaddress.ip_address(hop)==address if records is not None: records.append({'hop':ttl,'ip':hop,'latency_ms':round(elapsed,3),'location':location, + 'probes_sent':1,'responses':1,'response_loss_percent':0.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss', 'icmp_type':icmp_type,'icmp_code':icmp_code,'state':'reached' if reached else 'reply'}) found=True + if on_result is not None: on_result() if reached: return 0 if icmp_type==(1 if v6 else 3): emit(f' ICMP 不可达(代码 {icmp_code})' if zh else f' ICMP unreachable (code {icmp_code})',flush=True) return 1 if found: break if not found: - emit(f'{ttl:2d} {"*":<39} {"---":>8} ---',flush=True) - if records is not None: records.append({'hop':ttl,'ip':None,'latency_ms':None,'location':None,'state':'timeout'}) + emit(f'{ttl:2d} {"*":<39} {"---":>8} --- [reply 0/1; timeout 1/1]',flush=True) + if records is not None: records.append({'hop':ttl,'ip':None,'latency_ms':None,'location':None,'state':'timeout', + 'probes_sent':1,'responses':0,'response_loss_percent':100.0,'statistics_scope':'one_probe_this_ttl_not_forwarding_loss'}) + if on_result is not None: on_result() except (OSError,AttributeError): emit('原生 UDP 追踪不可用或未收到允许的响应' if zh else 'Native UDP trace unavailable or blocked',flush=True) return 1 + + +def summarize_hops(records): + """Statistics over actually sent TTL probes, never forwarding-loss claims.""" + import statistics + grouped={} + for row in records: + ttl=row['hop'] + item=grouped.setdefault(ttl,{'hop':ttl,'probes_sent':0,'responses':0,'ips':set(),'rtts':[]}) + item['probes_sent']+=row.get('probes_sent',1) + item['responses']+=row.get('responses',1 if row.get('ip') else 0) + if row.get('ip'):item['ips'].add(row['ip']) + if row.get('latency_ms') is not None:item['rtts'].append(row['latency_ms']) + result=[] + for ttl,item in sorted(grouped.items()): + sent=item['probes_sent'];received=item['responses'] + result.append({'hop':ttl,'probes_sent':sent,'responses':received,'timeouts':sent-received, + 'response_loss_percent':round(100*(sent-received)/sent,2) if sent else None, + 'median_rtt_ms':statistics.median(item['rtts']) if item['rtts'] else None, + 'ips':sorted(item['ips']), + 'scope':'router_response_loss_only_not_end_to_end_forwarding_loss'}) + return result diff --git a/proxydoctor/runner.py b/proxydoctor/runner.py new file mode 100644 index 0000000..5d433db --- /dev/null +++ b/proxydoctor/runner.py @@ -0,0 +1,35 @@ +"""Small synchronous module boundary; no global process or signal changes.""" +from .reports import utc_now + + +class ModuleRunner: + def __init__(self, report, checkpoint, notify): + self.report = report + self.checkpoint = checkpoint + self.notify = notify + report['modules'] = {} + + def run(self, name, operation=None, skip_reason=None): + state = {'status': 'skipped' if skip_reason else 'running', + 'started_at': utc_now(), 'error_code': None} + self.report['modules'][name] = state + if skip_reason: + state['reason'] = skip_reason + state['finished_at'] = utc_now() + self.checkpoint() + return + self.checkpoint() + try: + operation() + state['status'] = 'success' + except KeyboardInterrupt: + state.update(status='cancelled', error_code='CANCELLED') + raise + except Exception: + # Deliberate isolation boundary. Never echo exception text that may + # include URLs, tokens, provider responses or sensitive local paths. + state.update(status='failure', error_code='MODULE_ERROR') + self.notify(name) + finally: + state['finished_at'] = utc_now() + self.checkpoint() diff --git a/proxydoctor/schema.py b/proxydoctor/schema.py new file mode 100644 index 0000000..22285fa --- /dev/null +++ b/proxydoctor/schema.py @@ -0,0 +1,57 @@ +"""Uniform evidence envelopes; legacy raw module payloads remain available.""" +from .reports import utc_now + +STATUSES=frozenset(('success','failure','skipped','unsupported','unknown')) + + +def normalize_report(report): + rows=[] + def add(identifier,category,status,method,source,data,error=None,limitations=None): + rows.append({'check_id':identifier,'category':category, + 'status':status if status in STATUSES else 'unknown', + 'observed_at':data.get('observed_at',data.get('checked_at',report.get('generated_at',utc_now()))), + 'method':method,'source':source,'metrics':data,'error_code':error, + 'confidence':'provider_report' if method=='provider_query' else 'execution_only' if category=='execution' else 'direct_observation' if status in ('success','failure') else 'limited_or_unconfirmed', + 'limitations':limitations or ['Limited observation; not a complete security or availability guarantee.']}) + for name,item in report.get('modules',{}).items(): + add('module.'+name,'execution',item.get('status'),'module_execution','local',item,item.get('error_code') or item.get('reason')) + for item in (report.get('system') or report).get('checks',[]): + add('system.'+item['id'],'system',{'pass':'success','fail':'failure','skip':'skipped'}.get(item.get('status'),'unknown'), + 'readonly_check','local',item) + for name in ('resources','security','bandwidth'): + item=report.get(name) + if item: + add(name,name,'unsupported' if item.get('status')=='unsupported' else 'unknown', + item.get('method','readonly_observation'),'local' if name!='bandwidth' else 'selected_https_sources',item, + limitations=item.get('limitations')) + network=report.get('network') or {} + for i,item in enumerate(network.get('dns',[])): + add(f'network.dns.{i}','network',item.get('status'),'system_resolver',item.get('target','example.com'),item,item.get('error_code')) + for i,item in enumerate(network.get('transports',[])): + for stage in ('tcp','tls'): + evidence=item.get(stage,{}) + add(f'network.{stage}.{i}','network',evidence.get('status'),stage,item.get('hostname',item.get('target')),evidence,evidence.get('error_code')) + route=report.get('route') + if route: + add('route.destination','route','success' if route.get('state')=='reached' else 'unknown', + route.get('method','udp')+'_trace',route.get('target'),{'state':route.get('state')}, + limitations=['Router silence is not end-to-end packet loss; location is estimated.']) + for i,item in enumerate(route.get('hops',[])): + add(f'route.hop.{i}','route','success' if item.get('state') in ('reply','reached') else 'unknown', + 'single_'+route.get('method','udp')+'_probe',item.get('ip'),item,limitations=['One probe per TTL; response loss is not forwarding loss.']) + for name in ('bgp','carrier_nodes','return_routes'): + item=report.get(name) + if item: + add(name,name,'success' if item.get('status')=='success' else 'unknown', + 'provider_query' if name=='bgp' else 'active_network_observation', + item.get('source','fixed_public_targets'),item,item.get('error_code'),item.get('limitations')) + for name,item in (report.get('ip_quality') or {}).get('sources',{}).items(): + add('ip.'+name,'ip_quality','success' if item.get('state')=='ok' else 'skipped' if item.get('state')=='key_required' else 'unknown', + 'provider_query',name,item,item.get('state') if item.get('state')!='ok' else None) + for name,item in report.get('media',{}).items(): + status = 'success' if item.get('scope')=='public_page_reachability' and item.get('state')=='reachable' else 'failure' if item.get('state')=='restricted' else 'unknown' + add('media.'+name,'media',status,'http_reachability' if item.get('scope')=='public_page_reachability' else 'anonymous_service_evidence',name,item, + limitations=['HTTP/catalog/offer visibility does not prove account playback.']) + report['result_schema_version']=1 + report['result_items']=rows + return rows diff --git a/proxydoctor/security.py b/proxydoctor/security.py new file mode 100644 index 0000000..a6a1e5f --- /dev/null +++ b/proxydoctor/security.py @@ -0,0 +1,158 @@ +"""Metadata-only file checks and local Ubuntu update evidence. + +No privilege escalation, package refresh, network calls, private-file reads or +automatic repairs. Findings are review prompts, never exploit confirmations. +""" +from datetime import datetime, timezone +import os +from pathlib import Path +import platform +import re +import stat +import time + +from .reports import utc_now + + +POLICIES = ( + ('/etc/passwd',False), ('/etc/group',False), + ('/etc/shadow',True), ('/etc/gshadow',True), + ('/etc/sudoers',False), ('/etc/ssh/sshd_config',False), + ('/etc',False), ('/etc/ssh',False), ('/etc/sudoers.d',False), +) + + +def inspect_path(path, sensitive=False, *, lstat=os.lstat): + row = {'source':path,'method':'lstat_metadata_only','status':'unknown', + 'observed_at':utc_now(),'reasons':[]} + try: + info = lstat(path) + except FileNotFoundError: + row.update(status='skipped',error_code='PATH_MISSING') + return row + except OSError: + row['error_code'] = 'METADATA_UNAVAILABLE' + return row + mode = stat.S_IMODE(info.st_mode) + row.update(mode=oct(mode),uid=info.st_uid,gid=info.st_gid) + if stat.S_ISLNK(info.st_mode): + row['error_code'] = 'SYMLINK_REQUIRES_REVIEW' + return row + if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)): + row['error_code'] = 'UNEXPECTED_FILE_TYPE' + return row + if mode & stat.S_IWOTH: + row['reasons'].append('WORLD_WRITABLE') + if mode & stat.S_IWGRP: + row['reasons'].append('GROUP_WRITABLE_REVIEW') + if info.st_uid != 0: + row['reasons'].append('OWNER_NOT_ROOT_REVIEW') + if sensitive and mode & stat.S_IROTH: + row['reasons'].append('SENSITIVE_WORLD_READABLE') + row['status'] = 'review' if row['reasons'] else 'observed' + row['limitations'] = ['POSIX mode/owner only; ACLs, capabilities, mount options and runtime use not evaluated.', + 'Group access or non-root ownership can be intentional; review in deployment context.'] + return row + + +def read_small(path): + try: + with Path(path).open(encoding='utf-8') as stream: + value = stream.read(8193) + return value if len(value) <= 8192 else None + except (OSError,UnicodeError): + return None + + +def update_evidence(*, lstat=os.lstat, read=read_small, now=time.time): + """A timestamp is freshness evidence, never a vulnerability inventory.""" + row = {'method':'local_update_cache_only','observed_at':utc_now(),'status':'unknown', + 'pending_updates':None,'security_updates':None,'sources':[], + 'limitations':['No repository refresh or package solver was run.', + 'Missing or stale cache cannot establish whether the system is fully patched.']} + for path in ('/var/lib/apt/periodic/update-success-stamp', + '/var/lib/update-notifier/updates-available'): + evidence = {'source':path,'status':'unknown'} + try: + info = lstat(path) + if not stat.S_ISREG(info.st_mode): + evidence['error_code'] = 'NOT_REGULAR_FILE' + else: + timestamp = info.st_mtime + age = now()-timestamp + if age < 0: + evidence['error_code'] = 'FUTURE_TIMESTAMP' + else: + evidence.update(status='observed',age_hours=round(age/3600,2), + modified_at=datetime.fromtimestamp(timestamp,timezone.utc).isoformat()) + if path.endswith('updates-available'): + # This notifier summary is optional and may be localized. + # Never retain its complete text in logs or reports. + text = read(path) + match = re.search(r'^\s*(\d+) updates? can be applied immediately\.',text or '',re.M) + if match: + row['pending_updates'] = int(match[1]) + row['status'] = 'cached_observation' + match = re.search(r'^(\d+) of these updates? (?:is|are) (?:a )?(?:standard )?security updates?\.',text or '',re.M) + if match: + row['security_updates'] = int(match[1]) + except FileNotFoundError: + evidence['error_code'] = 'CACHE_MISSING' + except (OSError,ValueError,OverflowError): + evidence['error_code'] = 'CACHE_UNAVAILABLE' + row['sources'].append(evidence) + return row + + +def collect(report=None, on_result=None, *, system=None, lstat=os.lstat, read=read_small, now=time.time): + report = {} if report is None else report + report.update(schema_version=1,observed_at=utc_now(),status='unsupported',files=[],updates=None, + limitations=['Read-only hints, not a complete security audit.', + 'Cloud security groups and external reachability cannot be confirmed locally.']) + if (system or platform.system()) != 'Linux': + return report + for path,sensitive in POLICIES: + report['files'].append(inspect_path(path,sensitive,lstat=lstat)) + if on_result is not None: + on_result() + report['updates'] = update_evidence(lstat=lstat,read=read,now=now) + report['status'] = 'partial' + report['review_count'] = sum(item['status']=='review' for item in report['files']) + if on_result is not None: + on_result() + return report + + +def render(report, out): + tr = out.text + out.row(tr('权限检查范围','Permission scope'),tr('只读权限元数据','Read-only metadata'),'unknown') + reasons = { + 'WORLD_WRITABLE':('所有用户可写','Writable by everyone'), + 'GROUP_WRITABLE_REVIEW':('同组可写,需审阅','Group-writable; review'), + 'OWNER_NOT_ROOT_REVIEW':('所有者非 root,需审阅','Non-root owner; review'), + 'SENSITIVE_WORLD_READABLE':('敏感文件所有用户可读','Sensitive file readable by everyone'), + } + for row in report['files']: + if row['status'] == 'review': + value = '; '.join(tr(*reasons[reason]) for reason in row['reasons']) + elif row['status'] == 'observed': + value = tr('模式位未见上述宽松项','No listed mode-bit finding') + else: + value = tr('未知/跳过','Unknown/skipped')+' ('+row.get('error_code','UNKNOWN')+')' + out.row(row['source'],row.get('mode','---')+' · '+value, + 'bad' if row['status']=='review' else 'ok' if row['status']=='observed' else 'unknown') + update = report.get('updates') + if update: + pending = update['pending_updates'] + out.row(tr('缓存待更新数','Cached pending updates'),tr('未知','Unknown') if pending is None else str(pending),'unknown') + for source in update['sources']: + if 'age_hours' in source: + out.row(tr('更新缓存年龄','Update cache age'),f"{source['age_hours']:g} h · "+source['source'],'unknown') + out.row(tr('安全结论','Security conclusion'),tr('本机只读观测;补丁状态未确认','Local read-only observations; patch status unconfirmed'),'unknown') + + +def run_console(lang='zh', color='auto', report=None, on_result=None): + from .console import Console + report = collect(report,on_result) + render(report,Console(lang,color)) + return 1 if report.get('review_count') or report['status'] == 'unsupported' else 0 diff --git a/proxydoctor/session.py b/proxydoctor/session.py new file mode 100644 index 0000000..f66f328 --- /dev/null +++ b/proxydoctor/session.py @@ -0,0 +1,41 @@ +"""Consistent persistence lifecycle for individual CLI modules.""" +from .reports import ReportStore,UnavailableStore,utc_now +from .schema import normalize_report +from .reporting import sharing_copy +from . import __version__ + + +class ScanSession: + def __init__(self,key,path,lang): + self.key=key + self.lang=lang + self.report={'schema_version':3,'tool_version':__version__,'generated_at':utc_now(), + 'mode':'read-only','result':'running',key:{}} + try:self.store=ReportStore(path,lambda value:sharing_copy(value,lang)) + except FileExistsError:raise + except OSError:self.store=UnavailableStore(path) + self.failed=False + + def checkpoint(self): + normalize_report(self.report) + if not self.store.checkpoint(self.report):self.failed=True + + def __enter__(self): + self.checkpoint() + return self + + def __exit__(self,kind,value,traceback): + if kind is not None and issubclass(kind,KeyboardInterrupt): + from .worker import cancel_all + cancel_all() + self.report['result']='cancelled' + else:self.report['result']='incomplete' if kind else self.report['result'] if self.report['result']!='running' else 'local_execution_complete' + self.report['finished_at']=utc_now() + normalize_report(self.report) + try: + self.failed=not self.store.finish(self.report) or self.failed + finally:self.store.close() + if self.store.journal_path: + print(('恢复日志: ' if self.lang=='zh' else 'Recovery journal: ')+str(self.store.journal_path),flush=True) + if self.failed:print('REPORT_WRITE_FAILED'+(' / 报告写入未完成' if self.lang=='zh' else ''),flush=True) + return False diff --git a/proxydoctor/suite.py b/proxydoctor/suite.py index 3dc44ff..4e1ab31 100644 --- a/proxydoctor/suite.py +++ b/proxydoctor/suite.py @@ -1,5 +1,4 @@ """Four-part console workflow. Capability and measurement limits are explicit.""" -import hashlib import ipaddress from pathlib import Path import shutil @@ -7,10 +6,10 @@ import subprocess import time from .checks import inspect -from . import __version__ +from . import __version__, release_label from .quality import run_quality from .i18n import translator -from .routes import trace as native_trace +from .routes import trace as native_trace, trace_method as protocol_trace from .media import run_console as run_media from .bandwidth import run_console as measure_bandwidth, is_complete as bandwidth_complete from .ipquality import collect as collect_ipquality, render as render_ipquality @@ -25,107 +24,277 @@ def execute_provider(argv, timeout=180): def run_suite(lang='zh',offline=False,sample_mib=4,route_target='1.1.1.1',besttrace=None, - region_script=None,max_hops=30,geo=True,color='auto',bandwidth_rounds=3,skip_route=False,json_output=None,bandwidth_sources=None,port=8388,show_ip=False,ip_enrichment=True): + region_script=None,max_hops=30,geo=True,color='auto',bandwidth_rounds=3,skip_route=False,json_output=None,bandwidth_sources=None,port=8388,show_ip=False,ip_enrichment=True,allow_bandwidth=False,mail_probes=False,route_probes=3,theme='default', + route_method='udp',tcp_port=443,include_return_routes=False,include_bgp=False): from .cli import redact from .console import Console import re ipaddress.ip_address(route_target) if not 1<=sample_mib<=20 or not 1<=max_hops<=64 or not 3<=bandwidth_rounds<=8 or not 1<=port<=65535: raise ValueError('Invalid test limits') + if besttrace or region_script: + raise ValueError('External executables are disabled in the safe workflow; use native checks') + if not 1<=route_probes<=3:raise ValueError('Route probe rounds must be 1..3') + if route_method not in ('udp','icmp','tcp') or not 1<=tcp_port<=65535 or theme not in ('default','iprisk','framed'): + raise ValueError('Invalid route or theme options') out=Console(lang,color); tr=out.text + iprisk = theme == 'iprisk' + framed = theme == 'framed' from datetime import datetime,timezone - report={'schema_version':2,'mode':'read-only','generated_at':datetime.now(timezone.utc).isoformat(),'target_port':port,'network':None,'route':None,'tool_version':__version__,'offline':offline,'bandwidth':None,'ip_quality':None,'media':{}} - print(tr(f'ProxyDoctor {__version__} | 原生检测 | 联网模式' if not offline else f'ProxyDoctor {__version__} | 离线模式', - f'ProxyDoctor {__version__} | Native checks | Online' if not offline else f'ProxyDoctor {__version__} | Offline'),flush=True) - data=redact(inspect(lang=lang,port=port),lang) - report['system']=data - checks={item['id']:item for item in data['checks']} - unknown=tr('未知','Unknown');incomplete=False - out.section(1,'VPS 性能与资源','VPS resources and performance') - system=checks['system']['evidence'].split(';') - out.row(tr('系统','OS'),system[1].strip() if len(system)>1 else unknown) - out.row(tr('架构','Architecture'),checks['architecture']['evidence']) - out.row(tr('内核','Kernel'),checks['kernel']['detail'], 'ok' if checks['kernel']['status']=='pass' else 'unknown') - cpu=checks['cpu'] - out.row('CPU',re.split('[::]',cpu['evidence'],maxsplit=1)[-1], 'ok' if cpu['status']=='pass' else 'unknown') - count=re.search(r'\d+',cpu['detail']) - out.row(tr('逻辑核心','Logical CPUs'),count.group() if count else unknown,'ok' if count else 'unknown') - for key,zh,en in [('memory_total','总内存','RAM total'),('memory','可用内存','RAM available'),('disk_capacity','磁盘','Root filesystem')]: - item=checks.get(key) - value=re.split(r'[((]',item['detail'],maxsplit=1)[0] if item else unknown - out.row(tr(zh,en),value,'ok' if item and item['status']=='pass' else 'unknown') - statuses={'pass':tr('通过','Pass'),'warn':tr('注意','Warning'),'fail':tr('失败','Failed'), - 'unknown':tr('未知','Unknown'),'skip':tr('跳过','Skipped')} - for key,zh,en in [('default_route','默认路由','Default route'),('dns_config','DNS 配置','DNS config'), - ('ports',f'本机端口 {port}',f'Local port {port}'),('ssh_port_safety','SSH 端口保护','SSH port guard'), - ('ssh','SSH 配置','SSH config'),('firewall','本机防火墙','Local firewall'),('cloud_firewall','云防火墙','Cloud firewall')]: - item=checks[key] - value=statuses[item['status']] - if key=='ports' and item['status']=='pass': value=tr('未发现占用(仅本机)','No listener found (local only)') - if key=='ports' and item['status']=='warn': value=tr('已占用','In use') - if key=='ssh_port_safety' and item['status']=='warn': value=tr('目标端口与 SSH 冲突','Target conflicts with SSH') - if key=='ssh' and item['status']=='warn': value=tr('允许 root 或密码认证','Root or password auth allowed') - out.row(tr(zh,en),value,'bad' if item['status'] in ('warn','fail') else 'ok' if item['status']=='pass' else 'unknown') - block=b'\x00'*1024**2;start=time.perf_counter() - for _ in range(32): hashlib.sha256(block).digest() - out.row('SHA-256',f'{32/max(time.perf_counter()-start,0.000001):.1f} MiB/s') - if offline: out.row(tr('带宽','Bandwidth'),tr('跳过','Skipped'),'unknown') + report={'schema_version':3,'mode':'read-only','generated_at':datetime.now(timezone.utc).isoformat(),'target_port':port,'network':None,'route':None,'tool_version':__version__,'offline':offline,'bandwidth':None,'bandwidth_consent':allow_bandwidth,'cpu_benchmark':'not_run','ip_quality':None,'media':{}} + if framed: + out.frame_open(tr('VPS 检测报告','VPS check report'), box_w=78, subtitle=release_label(lang) + ' · ' + __version__) + out.frame_keyvalue(tr('模式','Mode'), tr('离线模式' if offline else '联网模式','Offline' if offline else 'Online')) + out.frame_keyvalue(tr('目标端口','Target port'), str(port)) + elif iprisk: + out.banner(' VPS CHECK ' + ('v' + __version__)) + out.line(release_label(lang), 'unknown') + out.keyvalue(tr('只读体检报告 · 原生检测','Read-only diagnostic report · native checks'), + tr('离线模式' if offline else '联网模式','Offline' if offline else 'Online'),'title') + out.hrule() + print(flush=True) else: - bandwidth=measure_bandwidth(lang=lang,color=color,rounds=bandwidth_rounds, - sample_mib=sample_mib,sources=bandwidth_sources) - report['bandwidth']=bandwidth - incomplete=not bandwidth_complete(bandwidth) or incomplete - out.section(2,'节点网络质量','Node network quality') - if offline: out.row(tr('网络','Network'),tr('跳过','Skipped'),'unknown') - else: - network={} - incomplete=bool(run_quality(translator(lang),compact=True,lang=lang,color=color,report=network)) or incomplete - report['network']=network - metadata=collect_ipquality(enrich=ip_enrichment) + out.line(release_label(lang), 'unknown') + print(tr(f'VPS 检测工具 {__version__} | 原生检测 | 联网模式' if not offline else f'VPS 检测工具 {__version__} | 离线模式', + f'VPS Check {__version__} | Native checks | Online' if not offline else f'VPS Check {__version__} | Offline'),flush=True) + from .reports import ReportStore, UnavailableStore, utc_now + from .runner import ModuleRunner + data = {'checks': []} + unknown = tr('未知','Unknown') + incomplete = False + report['result'] = 'running' + # Open an exclusive recovery journal before starting any checks. + try: + store = ReportStore(json_output, lambda value: redact(value, lang)) + except FileExistsError: + raise # A collision must be resolved before running any checks. + except OSError: + store = UnavailableStore(json_output) + persistence_warning = False + + def checkpoint(): + nonlocal persistence_warning + from .schema import normalize_report + normalize_report(report) + if not store.checkpoint(report) and not persistence_warning: + persistence_warning = True + out.row(tr('报告保存','Report storage'),tr('写入失败;保留已有日志并继续终端检测','Write failed; retaining prior journal and continuing terminal checks'),'bad') + + def module_error(name): + out.row(name, tr('本模块异常,继续其他检测(MODULE_ERROR)','Module failed; continuing other checks (MODULE_ERROR)'),'bad') + + runner = ModuleRunner(report, checkpoint, module_error) + + def heading(number, zh, en, kind='b_cyan'): + if iprisk: + out.theme_section(number, zh, en, kind) + else: + out.section(number, zh, en) + + def collect_system(): + nonlocal data, incomplete + data=redact(inspect(lang=lang,port=port),lang) + report['system']=data + checks={item['id']:item for item in data['checks']} + unknown=tr('未知','Unknown') + heading(1,'基础信息','Basic information') + system=checks['system']['evidence'].split(';') + out.row(tr('系统','OS'),system[1].strip() if len(system)>1 else unknown) + out.row(tr('架构','Architecture'),checks['architecture']['evidence']) + out.row(tr('内核','Kernel'),checks['kernel']['detail'], 'ok' if checks['kernel']['status']=='pass' else 'unknown') + cpu=checks['cpu'] + out.row('CPU',re.split('[::]',cpu['evidence'],maxsplit=1)[-1], 'ok' if cpu['status']=='pass' else 'unknown') + # Allocation-focused numbers are rendered by the resources module. + # Keep raw OS-visible values in structured evidence, not as VPS limits. + for key,zh,en in [('disk_capacity','磁盘','Root filesystem')]: + item=checks.get(key) + value=re.split(r'[((]',item['detail'],maxsplit=1)[0] if item else unknown + out.row(tr(zh,en),value,'ok' if item and item['status']=='pass' else 'unknown') + statuses={'pass':tr('通过','Pass'),'warn':tr('注意','Warning'),'fail':tr('失败','Failed'), + 'unknown':tr('未知','Unknown'),'skip':tr('跳过','Skipped')} + for key,zh,en in [('default_route','默认路由','Default route'),('dns_config','DNS 配置','DNS config'), + ('ports',f'本机端口 {port}',f'Local port {port}'),('ssh_port_safety','SSH 端口保护','SSH port guard'), + ('ssh','SSH 配置','SSH config'),('firewall','本机防火墙','Local firewall'),('cloud_firewall','云防火墙','Cloud firewall')]: + item=checks[key] + value=statuses[item['status']] + if key=='ports' and item['status']=='pass': value=tr('未发现占用(仅本机)','No listener found (local only)') + if key=='ports' and item['status']=='warn': value=tr('已占用','In use') + if key=='ssh_port_safety' and item['status']=='warn': value=tr('目标端口与 SSH 冲突','Target conflicts with SSH') + if key=='ssh' and item['status']=='warn': value=tr('允许 root 或密码认证','Root or password auth allowed') + out.row(tr(zh,en),value,'bad' if item['status'] in ('warn','fail') else 'ok' if item['status']=='pass' else 'unknown') + out.row('CPU benchmark',tr('未运行:常规检测不执行性能基准','Not run: routine checks do not benchmark CPU'),'unknown') + + def collect_bandwidth(): + nonlocal data, incomplete + if offline or not allow_bandwidth: + out.row(tr('带宽','Bandwidth'),tr('跳过;使用 --allow-bandwidth 明确允许测速','Skipped; use --allow-bandwidth to consent to traffic tests'),'unknown') + else: + report['bandwidth']={} + bandwidth=measure_bandwidth(lang=lang,color=color,rounds=bandwidth_rounds, + sample_mib=sample_mib,sources=bandwidth_sources,destination=report['bandwidth'],on_result=checkpoint,out=out) + report['bandwidth']=bandwidth + incomplete=not bandwidth_complete(bandwidth) or incomplete + + def collect_resources(): + from .resources import collect, render + resource_data = collect() + report['resources'] = resource_data + render(resource_data, out) + + def collect_security(): + nonlocal incomplete + from .security import collect as collect_security_data, render as render_security + security_data = {} + report['security'] = security_data + if framed: + out.frame_line(tr('基础安全(只读)','Basic security (read-only)'), 'title') + else: + print(out.paint(tr('--- 基础安全(只读) ---','--- Basic security (read-only) ---'),'title'),flush=True) + collect_security_data(security_data, on_result=checkpoint) + render_security(security_data, out) + incomplete = bool(security_data.get('review_count') or security_data['status'] == 'unsupported') or incomplete + + def collect_network(): + nonlocal data, incomplete + heading(2,'节点网络质量','Node network quality') + if offline: out.row(tr('网络','Network'),tr('跳过','Skipped'),'unknown') + else: + from .connectivity import run_console as run_connectivity + network={} + report['network']=network + incomplete=bool(run_connectivity(lang=lang,color=color,report=network,on_result=checkpoint,out=out)) or incomplete + + def collect_ip_quality(): + nonlocal data, incomplete + report['ip_quality']={} + metadata=collect_ipquality(enrich=ip_enrichment,mail=mail_probes,destination=report['ip_quality'],on_result=checkpoint) report['ip_quality']=metadata render_ipquality(metadata,out,show_ip) incomplete=not metadata['coverage']['complete'] or incomplete - out.section(3,'路由追踪','Route trace') - if offline or skip_route: out.row(tr('路由','Route'),tr('跳过','Skipped'),'unknown') - else: - print(tr(f'目标 {route_target} · 最多 {max_hops} 跳 · 位置为数据库估计', - f'Target {route_target} · up to {max_hops} hops · estimated IP location'),flush=True) - print(tr('跳 IP 地址 延迟 位置', - 'Hop IP address Latency Location'),flush=True) - route={'target':route_target,'max_hops':max_hops,'geo_enabled':geo,'hops':[]} - report['route']=route - if besttrace and Path(besttrace).is_file(): - route['provider']='external_besttrace' - route['state']='external_output_only' - incomplete=bool(execute_provider([str(Path(besttrace).resolve()),route_target],120)) or incomplete + + def collect_route(): + nonlocal data, incomplete + heading(3,'路由追踪','Route trace') + if offline or skip_route: out.row(tr('路由','Route'),tr('跳过','Skipped'),'unknown') else: - def route_output(line,**kwargs): - print(out.paint(line,'unknown' if any(word in line for word in ('*','unavailable','未知','Unknown')) else 'ok',preserve_space=True),flush=True) - route['provider']='native_udp' - route_code=native_trace(route_target,emit=route_output,hops=max_hops,lang=lang,geo=geo,records=route['hops']) - route['state']='reached' if route_code==0 else 'incomplete' - incomplete=bool(route_code) or incomplete - out.section(4,'流媒体区域限制','Streaming region restrictions') - if offline: out.row(tr('流媒体','Streaming'),tr('跳过','Skipped'),'unknown') - elif region_script: - path=Path(region_script).resolve();bash=shutil.which('bash') - if path.is_file() and bash: incomplete=bool(execute_provider([bash,str(path)],600)) or incomplete - else: out.row('RegionRestrictionCheck',unknown,'unknown');incomplete=True + out.line(tr(f'目标 {route_target} · 最多 {max_hops} 跳 · 位置为数据库估计', + f'Target {route_target} · up to {max_hops} hops · estimated IP location')) + out.line(tr('跳 IP 地址 延迟 位置', + 'Hop IP address Latency Location')) + out.line(tr('每跳仅1次探测;响应/超时计数不等于转发丢包率。','One probe per hop; response/timeout counts are not forwarding-loss estimates.')) + route={'target':route_target,'max_hops':max_hops,'geo_enabled':geo,'method':route_method,'hops':[]} + report['route']=route + if besttrace and Path(besttrace).is_file(): + route['provider']='external_besttrace' + route['state']='external_output_only' + incomplete=bool(execute_provider([str(Path(besttrace).resolve()),route_target],120)) or incomplete + else: + def route_output(line,**kwargs): + out.line(line,'unknown' if any(word in line for word in ('*','unavailable','未知','Unknown')) else 'ok') + route['provider']='native_udp' if route_method=='udp' else 'system_traceroute_'+route_method + from .routes import summarize_hops + deadline=time.monotonic()+90 + replies=0;attempts=0 + route['probe_rounds_requested']=route_probes + for number in range(route_probes): + if time.monotonic()>=deadline:break + out.line(tr(f'路由探测 {number+1}/{route_probes}',f'Route probe {number+1}/{route_probes}')) + options=dict(emit=route_output,hops=max_hops,lang=lang,geo=geo and number==0, + records=route['hops'],on_result=checkpoint,deadline=deadline) + route_code=(native_trace(route_target,**options) if route_method=='udp' else + protocol_trace(route_target,method=route_method,tcp_port=tcp_port,**options)) + attempts+=1;replies+=route_code==0 + route['statistics']=summarize_hops(route['hops']) + checkpoint() + route.update(state='reached' if replies else 'incomplete',probe_rounds_run=attempts,destination_responses=replies) + for row in route.get('statistics',[]): + out.row(tr('跳','Hop')+' '+str(row['hop']), + f"reply {row['responses']}/{row['probes_sent']} · timeout {row['timeouts']} · response-loss {row['response_loss_percent']}%",'unknown') + out.line(tr('以上是路由器响应缺失统计,不是线路转发丢包率。','These are router-response statistics, not forwarding packet-loss estimates.')) + incomplete=replies16384: + return None, '', 'COMMAND_OUTPUT_LIMIT' + return process.returncode, output, None + + +def invoke(action,options,timeout=20): + if action not in ('json','page','transfer','carrier_probe','resolve_public'): + raise ValueError('Unsupported network action') + if _cancelled.is_set(): + return None + try: + bootstrap='import sys;sys.path.insert(0,sys.argv.pop(1));from proxydoctor.worker import main;sys.exit(main())' + with subprocess.Popen([sys.executable,'-I','-B','-c',bootstrap,str(Path(__file__).resolve().parent.parent),action], + stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL, + text=True,encoding='utf-8') as process: + with _lock: + _active.add(process) + if _cancelled.is_set():process.kill() + try: + output,_=process.communicate(json.dumps(options),timeout=timeout) + except subprocess.TimeoutExpired: + process.kill();process.communicate() + return None + except KeyboardInterrupt: + cancel_all();process.communicate() + raise + finally: + with _lock:_active.discard(process) + if process.returncode or len(output)>2*1024**2: + return None + return json.loads(output) + except (OSError,ValueError,subprocess.TimeoutExpired): + return None + + +def main(): + os.environ['PROXYDOCTOR_NETWORK_WORKERS']='0' + options=json.loads(sys.stdin.read(65537)) + action=sys.argv[1] + if action=='json': + from .net import request_json + result=request_json(**options) + elif action=='page': + from dataclasses import asdict + from .media import fetch_page + result=asdict(fetch_page(**options)) + elif action=='transfer': + from dataclasses import asdict + from .bandwidth import transfer + result=asdict(transfer(**options)) + elif action=='carrier_probe': + from .carriers import probe_node + result=probe_node(**options) + elif action=='resolve_public': + from .carriers import resolve_public + result=resolve_public(**options) + else: + return 2 + print(json.dumps(result)) + return 0 + + +if __name__=='__main__': + try: + sys.exit(main()) + except (Exception,KeyboardInterrupt): + sys.exit(1) # Never print provider exceptions/credentials. diff --git a/pyproject.toml b/pyproject.toml index aa11142..6e10b09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "proxydoctor" -version = "0.5.0" +version = "1.0.0b1" description = "Read-only Ubuntu preflight and security checks" readme = "README.en.md" license = "MIT" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d04944a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +"""Unit fixtures exercise direct transports; real CLI enables process guards.""" +import pytest + +@pytest.fixture(autouse=True) +def direct_mockable_transports(monkeypatch): + monkeypatch.setenv('PROXYDOCTOR_NETWORK_WORKERS','0') diff --git a/tests/test_alpha_quality.py b/tests/test_alpha_quality.py new file mode 100644 index 0000000..9d7481b --- /dev/null +++ b/tests/test_alpha_quality.py @@ -0,0 +1,105 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import time + +import pytest + +from proxydoctor.schema import normalize_report,STATUSES +from proxydoctor.reports import ReportStore,recover_latest +from proxydoctor import worker + + +def test_uniform_envelope_preserves_unknown_and_restriction(): + report={'generated_at':'2026-11-01','modules':{'network':{'status':'running'}}, + 'media':{'Netflix':{'state':'catalog_visible'},'Disney+':{'state':'restricted'}}} + rows=normalize_report(report) + assert {row['status'] for row in rows}<=STATUSES + assert next(row for row in rows if row['check_id']=='media.Netflix')['status']=='unknown' + assert next(row for row in rows if row['check_id']=='media.Disney+')['status']=='failure' + assert all(set(('check_id','observed_at','method','source','metrics','limitations','error_code'))<=set(row) for row in rows) + + +def test_route_statistics_count_only_actual_probe_records(): + from proxydoctor.routes import summarize_hops + rows=[{'hop':1,'ip':'192.0.2.1','latency_ms':10,'probes_sent':1,'responses':1}, + {'hop':1,'ip':None,'latency_ms':None,'probes_sent':1,'responses':0}, + {'hop':1,'ip':'192.0.2.1','latency_ms':20,'probes_sent':1,'responses':1}] + result=summarize_hops(rows)[0] + assert result['probes_sent']==3 and result['responses']==2 and result['timeouts']==1 + assert result['response_loss_percent']==33.33 + assert result['median_rtt_ms']==15 + assert 'not_end_to_end' in result['scope'] + + +def test_journal_budget_preserves_previous_complete_record(tmp_path): + store=ReportStore(tmp_path/'report.json',lambda x:x) + assert store.checkpoint({'small':1}) + store.bytes_written=64*1024**2 + assert not store.checkpoint({'small':2}) + store.close() + assert recover_latest(store.journal_path)=={'small':1} + + +def test_network_worker_timeout_kills_and_reaps(monkeypatch): + processes=[] + real=worker.subprocess.Popen + def child(*args,**kwargs): + process=real([sys.executable,'-c','import time;time.sleep(30)'],**kwargs) + processes.append(process) + return process + monkeypatch.setattr(worker.subprocess,'Popen',child) + worker.reset() + started=time.monotonic() + assert worker.invoke('json',{},timeout=0.1) is None + assert time.monotonic()-started<5 + assert processes[0].poll() is not None + + +def test_worker_cancellation_prevents_new_requests(): + worker.cancel_all() + try:assert worker.invoke('json',{}) is None + finally:worker.reset() + + +def test_installer_integrity_detects_tampering(tmp_path): + source=Path(__file__).resolve().parents[1] + spec=importlib.util.spec_from_file_location('alpha_install',source/'install.py') + installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer) + files=installer.verify(source) + assert 'proxydoctor/cli.py' in files + # A changed copy must fail before creating any environment or executing code. + import shutil + copied=tmp_path/'bundle';copied.mkdir() + for name in files: + target=copied/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copyfile(source/name,target) + shutil.copyfile(source/'MANIFEST.sha256.json',copied/'MANIFEST.sha256.json') + (copied/'proxydoctor/cli.py').write_text('tampered') + with pytest.raises(ValueError,match='integrity'):installer.verify(copied) + + +def test_installer_manifest_cannot_escape_source(tmp_path): + source=Path(__file__).resolve().parents[1] + spec=importlib.util.spec_from_file_location('alpha_install',source/'install.py') + installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer) + bad={'version':installer.VERSION,'files':{'../outside.py':'x'}} + (tmp_path/'MANIFEST.sha256.json').write_text(json.dumps(bad)) + with pytest.raises(ValueError):installer.verify(tmp_path) + + +@pytest.mark.parametrize('failure',[KeyboardInterrupt,subprocess.TimeoutExpired]) +def test_installer_failure_cleans_reserved_directory_only(tmp_path,monkeypatch,failure): + source=Path(__file__).resolve().parents[1] + spec=importlib.util.spec_from_file_location('alpha_install',source/'install.py') + installer=importlib.util.module_from_spec(spec);spec.loader.exec_module(installer) + monkeypatch.setattr(installer.sys,'platform','linux') + def broken(*args,**kwargs): + if failure is KeyboardInterrupt:raise KeyboardInterrupt + raise subprocess.TimeoutExpired('venv',45) + monkeypatch.setattr(installer.subprocess,'run',broken) + neighbor=tmp_path/'keep.txt';neighbor.write_text('keep') + with pytest.raises(failure):installer.install(source,tmp_path/'new-prefix') + assert not (tmp_path/'new-prefix').exists() + assert neighbor.read_text()=='keep' diff --git a/tests/test_bandwidth_sources.py b/tests/test_bandwidth_sources.py index 0b1da11..fc7696d 100644 --- a/tests/test_bandwidth_sources.py +++ b/tests/test_bandwidth_sources.py @@ -71,12 +71,12 @@ def test_multiple_sources_stay_separate_with_correct_budget(monkeypatch): calls=[];events=[];samples=[] def transfer(direction,size,source='cloudflare'): calls.append((source,direction,size)) - speed={'cloudflare':10,'hetzner-fsn':30,'ovh-rbx':50}[source] + speed={'cloudflare':10,'hetzner-fsn':30,'ovh-rbx':50}.get(source,60) return bw.Sample(direction,size,2,speed,source=source) monkeypatch.setattr(bw,'transfer',transfer) report=bw.measure_many(sources=tuple(bw.SOURCES),on_source=lambda *args:events.append(args[:2]),on_sample=lambda *args:samples.append(args)) - assert report['payload_budget_bytes']==int(48.5*1024**2) - assert len(calls)==16 and len(samples)==12 + assert report['payload_budget_bytes']==int(97*1024**2) + assert len(calls)==32 and len(samples)==24 assert report['sources']['hetzner-fsn']['directions']['download']['median_mbps']==30 assert report['sources']['ovh-rbx']['directions']['download']['median_mbps']==50 assert 'upload' not in report['sources']['ovh-rbx']['directions'] diff --git a/tests/test_bytecode_policy.py b/tests/test_bytecode_policy.py new file mode 100644 index 0000000..c2caf74 --- /dev/null +++ b/tests/test_bytecode_policy.py @@ -0,0 +1,51 @@ +"""Real interpreter regressions for BETA22-001; no dependency on remote APIs.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import pytest + + +def copy_package(destination): + package=destination/'proxydoctor';package.mkdir(parents=True) + for source in (Path(__file__).resolve().parents[1]/'proxydoctor').glob('*.py'): + shutil.copyfile(source,package/source.name) + return package + + +@pytest.mark.parametrize('action,options',[ + ('json',{'url':'http://127.0.0.1:1/'}), + ('page',{'url':'http://127.0.0.1:1/'}), + ('transfer',{'direction':'invalid','size':0}), + ('carrier_probe',{'identifier':'invalid'}), + ('resolve_public',{'host':'127.0.0.1','family':4}), +]) +def test_real_worker_never_creates_bytecode_in_fresh_package(tmp_path,action,options): + package=copy_package(tmp_path/'fresh') + program='''import json,sys +sys.path.insert(0,sys.argv[1]) +from proxydoctor.worker import invoke,reset +reset() +result=invoke(sys.argv[2],json.loads(sys.argv[3]),timeout=8) +print(json.dumps({'completed':True,'response':result})) +''' + env={**os.environ,'PYTHONDONTWRITEBYTECODE':'0'} + # -I ignores environment-only bytecode settings. The worker must set -B in + # its own interpreter argv rather than depend on parent runtime state. + result=subprocess.run([sys.executable,'-I','-B','-c',program,str(package.parent),action,json.dumps(options)], + env=env,capture_output=True,text=True,timeout=15) + assert result.returncode==0,result.stderr + assert json.loads(result.stdout)['completed'] is True + assert not list(package.rglob('__pycache__')) + assert not list(package.rglob('*.pyc')) + + +def test_fresh_fixture_is_writable_and_plain_python_can_generate_cache(tmp_path): + package=copy_package(tmp_path/'control') + result=subprocess.run([sys.executable,'-I','-c', + 'import sys;sys.path.insert(0,sys.argv[1]);import proxydoctor',str(package.parent)], + capture_output=True,timeout=10) + assert result.returncode==0 + assert list(package.rglob('*.pyc')), 'Control must be able to reproduce the original cache write' diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py new file mode 100644 index 0000000..9415239 --- /dev/null +++ b/tests/test_connectivity.py @@ -0,0 +1,70 @@ +"""Dual-stack fixtures; added without running tests in this batch.""" +import json +import socket +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from proxydoctor import connectivity + + +def test_dns_negative_answer_and_timeout_remain_unknown(): + with patch.object(connectivity.subprocess, 'run', return_value=SimpleNamespace( + returncode=0, stdout=json.dumps({'error':socket.EAI_NONAME}))): + assert connectivity.dns_probe(6)['error_code'] == 'DNS_NO_ADDRESS' + with patch.object(connectivity.subprocess, 'run', side_effect=connectivity.subprocess.TimeoutExpired('resolver',2)): + assert connectivity.dns_probe(4)['error_code'] == 'DNS_TIMEOUT' + + +def test_dns_rejects_wrong_family_answers(): + with patch.object(connectivity.subprocess, 'run', return_value=SimpleNamespace( + returncode=0, stdout=json.dumps({'addresses':['1.1.1.1']}))): + assert connectivity.dns_probe(6)['error_code'] == 'DNS_INVALID_RESPONSE' + + +def test_tcp_success_is_preserved_when_tls_certificate_fails(): + from contextlib import nullcontext + raw = SimpleNamespace(settimeout=lambda value: None) + context = SimpleNamespace(wrap_socket=lambda *args,**kwargs: (_ for _ in ()).throw( + connectivity.ssl.SSLCertVerificationError('sensitive provider text'))) + with patch.object(connectivity.socket,'create_connection',return_value=nullcontext(raw)), \ + patch.object(connectivity.ssl,'create_default_context',return_value=context): + result = connectivity.transport_probe('test','example.com','1.1.1.1') + assert result['tcp']['status'] == 'success' + assert result['tls']['error_code'] == 'TLS_CERTIFICATE_ERROR' + assert 'sensitive' not in json.dumps(result) + + +def test_family_results_and_sample_counts_remain_separate(): + def dns(version): + return {'family':version,'status':'success' if version==4 else 'unknown','elapsed_ms':1} + def transport(label,hostname,address): + version = connectivity.ipaddress.ip_address(address).version + return {'family':version,'target':label,'tcp':{'status':'success'},'tls':{'status':'unknown'}} + snapshots = [] + report = {} + connectivity.collect(report, lambda: snapshots.append(json.dumps(report)), dns=dns, transport=transport) + assert report['dns_summary']['4']['success_ratio'] == 1 + assert report['dns_summary']['6']['success_ratio'] == 0 + assert report['diagnostics'][1]['code'] == 'DNS_FAILED_NUMERIC_TCP_WORKS' + assert report['status'] == 'partial' + assert len(snapshots) == 9 + + +def test_one_probe_exception_does_not_drop_other_targets(): + def dns(version): + raise RuntimeError('token=secret') + def transport(label,hostname,address): + return {'family':connectivity.ipaddress.ip_address(address).version, + 'target':label,'tcp':{'status':'success'},'tls':{'status':'success'}} + report = connectivity.collect(dns=dns, transport=transport) + assert len(report['transports']) == 4 + assert 'secret' not in json.dumps(report) + + +def test_ctrl_c_is_not_swallowed_by_probe_isolation(): + def dns(version): + raise KeyboardInterrupt + with pytest.raises(KeyboardInterrupt): + connectivity.collect(dns=dns) diff --git a/tests/test_ip_panel.py b/tests/test_ip_panel.py new file mode 100644 index 0000000..b488842 --- /dev/null +++ b/tests/test_ip_panel.py @@ -0,0 +1,22 @@ +import re +import unicodedata +from proxydoctor.console import Console +from proxydoctor import ipquality + + +def test_panel_wraps_cjk_and_strips_control_characters(capsys): + Console('zh','never').panel('概览',[('地区','陕西省'*40+'\x1b[31m','unknown')],width=50) + lines=capsys.readouterr().out.splitlines() + assert '\x1b' not in '\n'.join(lines) + for line in lines: + assert sum(2 if unicodedata.east_asian_width(ch) in ('W','F') else 0 if unicodedata.combining(ch) else 1 for ch in line)==50 + + +def test_ip_panel_keeps_missing_risk_unknown_and_hides_ip(monkeypatch,capsys): + monkeypatch.setattr(ipquality,'request_json',lambda *args,**kwargs:({'ip':'8.8.8.8'},'ok')) + result=ipquality.collect(enrich=False) + ipquality.render(result,Console('zh','never')) + output=capsys.readouterr().out + assert 'IP 质量概览' in output + assert '8.8.8.8' not in output + assert '不代表安全' in output diff --git a/tests/test_mailquality.py b/tests/test_mailquality.py new file mode 100644 index 0000000..a477984 --- /dev/null +++ b/tests/test_mailquality.py @@ -0,0 +1,41 @@ +import json +import subprocess +from types import SimpleNamespace +from unittest.mock import patch +import pytest +from proxydoctor import mailquality + + +def test_mail_checks_keep_forward_mismatch_and_no_blacklist_claim(): + report=mailquality.collect('8.8.8.8',query=lambda mode,ip:{'status':'observed','forward_matches':False} if mode=='ptr' else {'status':'unknown'}) + assert report['ptr']['forward_matches'] is False + assert report['dnsbl']['status']=='skipped' + assert 'score' not in report + + +def test_mail_missing_ip_does_not_probe(): + def unexpected(*args):raise AssertionError('No target') + assert mailquality.collect(None,query=unexpected)['ptr']['status']=='skipped' + + +@pytest.mark.parametrize('address',['127.0.0.1','192.168.1.1','not-ip']) +def test_probe_rejects_non_public_target(address): + with pytest.raises(ValueError):mailquality.probe('ptr',address) + + +def test_probe_timeout_stays_unknown_without_exception_details(): + with patch.object(mailquality.subprocess,'run',side_effect=subprocess.TimeoutExpired('secret',5)): + row=mailquality.probe('ptr','8.8.8.8') + assert row['error_code']=='PROBE_TIMEOUT' + assert 'secret' not in json.dumps(row) + + +def test_ptr_failure_does_not_drop_smtp_probe(): + calls=[] + def query(mode,ip): + calls.append(mode) + if mode=='ptr':raise RuntimeError('secret') + return {'status':'reachable'} + result=mailquality.collect('8.8.8.8',query=query) + assert calls==['ptr','smtp'] + assert result['smtp_tcp']['status']=='reachable' diff --git a/tests/test_measurements.py b/tests/test_measurements.py index 4a5ee58..dd8b281 100644 --- a/tests/test_measurements.py +++ b/tests/test_measurements.py @@ -178,21 +178,23 @@ def test_media_console_failure_does_not_abort_other_services(monkeypatch,capsys) monkeypatch.setattr(media,'SERVICES',{'Netflix':None,'Spotify':None}) def check(name): if name=='Netflix': raise RuntimeError('private response data') - return {'state':'restricted','http':200,'evidence':'confirmed_explicit_region_message'} - monkeypatch.setattr(media,'check',check) + return {'state':'access_denied','http':403,'evidence':'http_response'} + monkeypatch.setattr(media,'check_reachability',check) results=media.run_console('en','always') output=capsys.readouterr().out - assert len(results)==2 and '\x1b[31m' in output + assert len(results)==2 and '\x1b[33m' in output + assert 'Access denied' in output assert 'private response data' not in output def test_offline_json_stays_local_and_cannot_overwrite(tmp_path): from proxydoctor.suite import run_suite path=tmp_path/'result.json' - assert run_suite(offline=True,json_output=path)==0 + import platform + assert run_suite(offline=True,json_output=path)==(0 if platform.system()=='Linux' else 1) result=json.loads(path.read_text(encoding='utf-8')) assert result['offline'] and result['bandwidth'] is None and result['media']=={} - assert result['result']=='local_execution_complete' + assert result['result']==('local_execution_complete' if platform.system()=='Linux' else 'incomplete') original=path.read_bytes() with pytest.raises(FileExistsError): run_suite(offline=True,json_output=path) assert path.read_bytes()==original diff --git a/tests/test_nodequality.py b/tests/test_nodequality.py index 96a99a0..b863f38 100644 --- a/tests/test_nodequality.py +++ b/tests/test_nodequality.py @@ -5,10 +5,10 @@ import sys def test_nodequality_missing_script_fails(tmp_path): result = subprocess.run([sys.executable, '-m', 'proxydoctor', 'nodequality', '--lang', 'en', '--script', str(tmp_path / 'missing')], capture_output=True, text=True) assert result.returncode == 2 - assert 'Local script file not found' in result.stderr + assert 'workflow may upload reports and is disabled' in result.stderr -def test_nodequality_streams_output_and_propagates_exit(tmp_path, monkeypatch, capsys): +def test_nodequality_disabled_never_executes_local_script(tmp_path, monkeypatch, capsys): from proxydoctor import cli script = tmp_path / 'script with spaces.sh' script.write_text('# test', encoding='utf-8') @@ -23,8 +23,8 @@ def test_nodequality_streams_output_and_propagates_exit(tmp_path, monkeypatch, c try: cli.main() except SystemExit as exc: - assert exc.code == 7 + assert exc.code == 2 else: raise AssertionError('expected exit') - assert captured == [(['/bin/bash', str(script.resolve())], {'check': False})] - assert 'LIVE OUTPUT' in capsys.readouterr().out + assert captured == [] + assert 'LIVE OUTPUT' not in capsys.readouterr().out diff --git a/tests/test_phase1_delivery.py b/tests/test_phase1_delivery.py index c7d2df9..d89865d 100644 --- a/tests/test_phase1_delivery.py +++ b/tests/test_phase1_delivery.py @@ -100,9 +100,9 @@ def test_suite_includes_security_network_and_route_evidence(monkeypatch,tmp_path from test_suite import unavailable_bandwidth,unavailable_ip monkeypatch.setattr(suite,'measure_bandwidth',unavailable_bandwidth) monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip) - monkeypatch.setattr(suite,'run_media',lambda *args:{'Netflix':{'state':'unknown','playback':'not_tested'}}) - def network(*args,**kwargs):kwargs['report'].update(dns={'status':'pass'},tcp=[]);return 0 - monkeypatch.setattr(suite,'run_quality',network) + monkeypatch.setattr(suite,'run_media',lambda *args,**kwargs:kwargs['results'].update(Netflix={'state':'unknown','playback':'not_tested'})) + def network(*args,**kwargs):kwargs['report'].update(dns=[{'family':4,'status':'success'}],transports=[]);return 0 + monkeypatch.setattr('proxydoctor.connectivity.run_console',network) def trace(*args,**kwargs):kwargs['records'].append({'hop':1,'ip':'8.8.8.8','latency_ms':2});return 0 monkeypatch.setattr(suite,'native_trace',trace) path=tmp_path/'suite.json' @@ -110,8 +110,8 @@ def test_suite_includes_security_network_and_route_evidence(monkeypatch,tmp_path result=json.loads(path.read_text(encoding='utf-8')) output=capsys.readouterr().out assert 'SSH port guard' in output and 'Local port 2222' in output - assert result['schema_version']==2 and result['target_port']==2222 - assert result['network']['dns']['status']=='pass' + assert result['schema_version']==3 and result['target_port']==2222 + assert result['network']['dns'][0]['status']=='success' assert result['route']['state']=='reached' assert result['route']['hops'][0]['ip']!='8.8.8.8' assert result['summary']['playback']=='not_tested' diff --git a/tests/test_public_beta.py b/tests/test_public_beta.py new file mode 100644 index 0000000..4eecbc3 --- /dev/null +++ b/tests/test_public_beta.py @@ -0,0 +1,133 @@ +import json +import re +import subprocess +import sys +import time +from pathlib import Path +import pytest +from proxydoctor.console import Console,width +from proxydoctor import cli,media,bgp,carriers,routes,worker + + +def test_frames_wrap_wide_text_without_loss_or_border_misalignment(capsys): + out=Console('zh','never') + title='长标题'*40 + value='测试位置与来源'*45+'e\u0301' + out.frame_open(title,box_w=48,subtitle='1.0 公测版(非最终版本)') + out.frame_keyvalue('位置',value) + out.frame_section(1,'资源','Resources') + out.panel('IP 面板',[('数据源','未知'*60,'unknown')]) + out.frame_close() + lines=capsys.readouterr().out.splitlines() + assert all(width(line)==50 for line in lines) + body=''.join(line[1:-1].strip() for line in lines if line.startswith('║')) + assert title in body and value in body + assert '\x1b' not in body + + +@pytest.mark.parametrize('language',['zh','en']) +@pytest.mark.parametrize('theme',['default','iprisk','framed']) +def test_actual_check_themes_respect_language_and_beta_identity(language,theme): + result=subprocess.run([sys.executable,'-m','proxydoctor','check','--lang='+language, + '--theme',theme,'--color','never'],capture_output=True,text=True,encoding='utf-8',timeout=15) + assert result.returncode in (0,1) + assert ('公测版' if language=='zh' else 'public beta') in result.stdout + assert '\x1b' not in result.stdout + if language=='en':assert not re.search('[\u4e00-\u9fff]',result.stdout) + if theme=='framed':assert all(width(line)==80 for line in result.stdout.splitlines()) + + +@pytest.mark.parametrize('flag',[['--lang','zh'],['--lang=zh']]) +def test_explicit_chinese_wins_over_console_fallback(monkeypatch,capsys,flag): + monkeypatch.setattr('proxydoctor.console.can_render_cjk',lambda:False) + monkeypatch.setattr(sys,'argv',['proxydoctor',*flag,'--version']) + with pytest.raises(SystemExit) as exc:cli._main() + assert exc.value.code==0 + assert '公测版' in capsys.readouterr().out + + +def test_quality_colour_reaches_renderer(monkeypatch,tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys,'argv',['proxydoctor','quality','--color','never','--lang','en']) + called=[] + def probe(**options): + called.append(options['color']);options['report']['status']='success';return 0 + monkeypatch.setattr('proxydoctor.connectivity.run_console',probe) + with pytest.raises(SystemExit) as exc:cli._main() + assert exc.value.code==0 and called==['never'] + + +@pytest.mark.parametrize('status,state',[(200,'reachable'),(403,'access_denied'),(429,'rate_limited'),(503,'http_error'),(None,'unknown')]) +def test_media_cli_probe_is_reachability_only(monkeypatch,status,state): + calls=[] + monkeypatch.setattr(media,'fetch_page',lambda url:calls.append(url) or media.Page(status,'Netflix is not available in your country')) + row=media.check_reachability('Netflix') + assert row['state']==state and row['scope']=='public_page_reachability' + assert row['unlock']==row['playback']=='not_tested' + assert len(calls)==1 + label,_=media.display_reachability(row,'en') + assert 'unlocked' not in label.lower() and 'playback successful' not in label.lower() + + +def test_provider_errors_are_visible_and_supplied_abuse_score_retained(monkeypatch,capsys): + from proxydoctor import ipquality + monkeypatch.setattr(ipquality,'request_json',lambda *a,**kw:({'ip':'8.8.8.8'},'ok')) + row=ipquality.collect(enrich=False) + row['sources']['proxycheck.io']={'state':'rate_limited'} + row['reputation'].update(score=12,total_reports=2) + ipquality.render(row,Console('en','never')) + output=capsys.readouterr().out + assert 'Rate limited' in output and 'AbuseIPDB' in output and '12/100' in output + + +@pytest.mark.parametrize('method',['icmp','tcp']) +def test_trace_optional_dependency_missing_is_explicit(monkeypatch,capsys,method): + monkeypatch.setattr(routes.shutil,'which',lambda *a,**kw:None) + assert routes.trace_method('1.1.1.1',method=method)==1 + assert 'TRACEROUTE_DEPENDENCY_MISSING' in capsys.readouterr().out + + +def test_tcp_trace_uses_fixed_argv_and_retains_real_rtt(monkeypatch): + monkeypatch.setattr(routes.sys,'platform','linux') + monkeypatch.setattr(routes.shutil,'which',lambda *a,**kw:'/usr/bin/traceroute') + calls=[] + monkeypatch.setattr(worker,'local_command',lambda argv,**kw:calls.append(argv) or (0,' 1 1.1.1.1 2.750 ms\n',None)) + rows=[] + assert routes.trace_method('1.1.1.1',lambda *a,**kw:None,method='tcp',tcp_port=8443,geo=False,records=rows)==0 + assert rows[0]['latency_ms']==2.75 and rows[0]['state']=='reached' + assert calls[0][-4:]==['-T','-p','8443','1.1.1.1'] + assert routes.parse_traceroute_hop(' 1 *',1,'1.1.1.1')['state']=='timeout' + assert routes.parse_traceroute_hop(' 1 1.1.1.1 2.0 ms !H',1,'1.1.1.1')['state']=='unreachable' + + +def test_bgp_validates_prefix_and_keeps_collector_scope(monkeypatch): + responses={'network-info':({'prefix':'8.8.8.0/24','asns':[15169]},'ok'), + 'asn-neighbours':({'neighbours':[{'asn':3356,'type':'left','power':2}]},'ok'), + 'bgp-state':({'bgp_state':[{'target_prefix':'8.8.8.0/24','path':[3356,15169],'source_id':'00-test'}]},'ok')} + monkeypatch.setattr(bgp,'query',lambda endpoint,*a,**kw:responses[endpoint]) + row=bgp.collect('8.8.8.8') + assert row['status']=='success' and row['paths'][0]['as_path']==[3356,15169] + assert 'commercial upstream' in ' '.join(row['limitations']) + monkeypatch.setattr(bgp,'query',lambda *a,**kw:({'prefix':'1.1.1.0/24','asns':[13335]},'ok')) + assert bgp.collect('8.8.8.8')['status']=='unknown' + + +def test_bgp_provider_failure_keeps_unknown_and_does_not_invent_paths(monkeypatch): + monkeypatch.setattr(bgp,'query',lambda *a,**kw:(None,'rate_limited')) + row=bgp.collect('8.8.8.8') + assert row['status']=='unknown' and row['paths']==[] + assert row['sources']['network-info']=='rate_limited' + + +def test_hong_kong_node_never_counts_as_mainland_mobile(monkeypatch): + monkeypatch.setattr(carriers,'probe_node',lambda name:{'identifier':name,'status':'sample_available' if name=='cm-hk' else 'unknown'}) + row=carriers.collect_nodes() + assert row['nodes']['cm-hk']['mainland'] is False + assert row['coverage']['mobile'] is False and row['status']=='partial' + + +def test_new_network_commands_reject_invalid_inputs_without_requests(): + for args in (['trace','--target',';reboot'],['bgp','--target','127.0.0.1'], + ['return-route','--tcp-port','0'],['suite','--offline','--china-speed']): + result=subprocess.run([sys.executable,'-m','proxydoctor',*args],capture_output=True,timeout=10) + assert result.returncode==2 diff --git a/tests/test_recovery.py b/tests/test_recovery.py new file mode 100644 index 0000000..4e5c169 --- /dev/null +++ b/tests/test_recovery.py @@ -0,0 +1,89 @@ +"""Fault-injection acceptance cases. Added, not executed in this work batch.""" +import json + +import pytest + +from proxydoctor.reports import ReportStore, recover_latest +from proxydoctor.runner import ModuleRunner + + +def test_module_exception_does_not_abort_next_module_or_expose_secrets(): + report = {} + saved, notices, called = [], [], [] + runner = ModuleRunner(report, lambda: saved.append(json.dumps(report)), notices.append) + + def broken(): + raise RuntimeError('token=private-value') + + runner.run('broken', broken) + runner.run('healthy', lambda: called.append(True)) + assert called == [True] + assert report['modules']['broken']['error_code'] == 'MODULE_ERROR' + assert report['modules']['healthy']['status'] == 'success' + assert notices == ['broken'] + assert 'private-value' not in ''.join(saved) + + +def test_cancellation_is_checkpointed_and_propagated(): + report, saved = {}, [] + runner = ModuleRunner(report, lambda: saved.append(json.dumps(report)), lambda name: None) + + def cancel(): + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + runner.run('route', cancel) + assert json.loads(saved[-1])['modules']['route']['status'] == 'cancelled' + + +def test_recovery_retains_last_complete_redacted_event(tmp_path): + path = tmp_path/'report.json' + store = ReportStore(path, lambda value: {'safe': value['safe']}) + assert store.checkpoint({'safe': 1, 'secret': 'hidden'}) + journal = store.journal_path + store.close() + with journal.open('a', encoding='utf-8') as stream: + stream.write('{"interrupted":') + assert recover_latest(journal) == {'safe': 1} + assert 'hidden' not in journal.read_text(encoding='utf-8') + + +def test_final_report_race_never_overwrites_existing_file(tmp_path): + path = tmp_path/'report.json' + store = ReportStore(path, lambda value: value) + path.write_text('other process', encoding='utf-8') + assert not store.finish({'result': 'partial'}) + store.close() + assert path.read_text(encoding='utf-8') == 'other process' + assert recover_latest(store.journal_path)['result'] == 'partial' + + +def test_existing_destination_is_rejected(tmp_path): + path = tmp_path/'report.json' + path.write_text('existing', encoding='utf-8') + with pytest.raises(FileExistsError): + ReportStore(path, lambda value: value) + + +def test_dangling_symlink_destination_is_rejected(tmp_path): + path = tmp_path/'report.json' + try: + path.symlink_to(tmp_path/'missing.json') + except OSError: + pytest.skip('Creating symlinks is not permitted on this host') + with pytest.raises(FileExistsError): + ReportStore(path, lambda value: value) + + +def test_flush_failure_is_reported_without_discarding_prior_event(tmp_path, monkeypatch): + store = ReportStore(tmp_path/'report.json', lambda value: value) + assert store.checkpoint({'completed': 'system'}) + + def full_disk(fd): + raise OSError('disk full') + + monkeypatch.setattr('proxydoctor.reports.os.fsync', full_disk) + assert not store.checkpoint({'completed': 'network'}) + assert store.failed + store.close() + assert recover_latest(store.journal_path) is not None diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 0000000..bca8492 --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,73 @@ +import json +import subprocess +import sys + +import pytest + +from proxydoctor.reporting import export_report, load_report, markdown, sharing_copy +from proxydoctor.reports import recover_latest + + +def test_markdown_escapes_injected_markup_and_preserves_uncertainty(): + document={'schema_version':3,'modules':{'|':{'status':'skipped','reason':'offline'}}, + 'media':{'Netflix':{'state':'unknown','evidence':''}}} + text=markdown(document,'en') + assert '<script>' in text # table cells remain escaped + assert '```json\n' in text + # The provider string is literal JSON inside a code fence, not raw HTML. + assert '"evidence": ""' in text.split('```json\n')[1].split('\n```')[0] + assert 'unknown' in text and 'skipped' in text + + +def test_markdown_provider_cannot_close_json_fence(): + payload='```\n# forged section\n``````\n' + text=markdown({'schema_version':3,'media':{'raw':payload}},'en') + assert '```````json\n' in text + block=text.split('```````json\n')[1].split('\n```````')[0] + assert json.loads(block)=={'raw':payload} + + +def test_sharing_redaction_hides_credentials_queries_and_ip(): + safe=sharing_copy({'api_key':'my-secret','nested':{'password':'p'}, + 'evidence':'https://user:pass@example.com/test?token=secret#fragment Bearer abc123 8.8.8.8'}) + text=json.dumps(safe) + assert all(value not in text for value in ('my-secret','user:pass','token=secret','abc123','8.8.8.8')) + assert 'https://example.com/test' in text + + +def test_export_does_not_overwrite(tmp_path): + destination=tmp_path/'report.md' + export_report({'schema_version':3},destination) + initial=destination.read_bytes() + with pytest.raises(FileExistsError): + export_report({'schema_version':3,'result':'different'},destination) + assert destination.read_bytes()==initial + + +def test_loader_rejects_unsupported_schema_and_size(tmp_path): + path=tmp_path/'input.json';path.write_text('{"schema_version":999}') + with pytest.raises(ValueError):load_report(path) + with pytest.raises(ValueError):load_report(path,max_bytes=2) + + +def test_recovery_read_budget_is_enforced(tmp_path): + path=tmp_path/'events';path.write_bytes(b'12345678\n') + with pytest.raises(ValueError):recover_latest(path,max_line_bytes=4) + + +@pytest.mark.parametrize('lang',['zh','en']) +def test_actual_offline_export_and_partial_recovery_cli(tmp_path,lang): + document={'schema_version':3,'result':'running','modules':{'network':{'status':'running'}},'api_key':'private-test-value'} + source=tmp_path/'source.json';source.write_text(json.dumps(document),encoding='utf-8') + journal=tmp_path/'journal.jsonl' + journal.write_text(json.dumps({'event_version':1,'snapshot':document})+'\n'+ '{"partial":',encoding='utf-8') + report=tmp_path/'report.md';recovered=tmp_path/'recovered.json' + def cli(*args): + return subprocess.run([sys.executable,'-m','proxydoctor',*args,'--lang',lang],capture_output=True,text=True,encoding='utf-8',timeout=10) + assert cli('report','--input',str(source),'--output',str(report)).returncode==0 + assert 'private-test-value' not in report.read_text(encoding='utf-8') + assert cli('recover','--journal',str(journal),'--output',str(recovered)).returncode==0 + result=json.loads(recovered.read_text(encoding='utf-8')) + assert result['result']=='recovered_partial' + assert result['recovery']['new_measurement'] is False + assert cli('recover','--journal',str(journal),'--output',str(recovered)).returncode==2 diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..5ec6507 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,89 @@ +"""Resource parser/fixture cases; not executed in the current batch.""" +from proxydoctor.resources import cgroup_limits, collect, cpu_ticks, pressure, steal_delta + + +def fixture_reader(files): + return lambda path: files.get(path) + + +def test_nested_v2_uses_strictest_visible_ancestor(): + read = fixture_reader({ + '/proc/self/cgroup': '0::/tenant/app\n', + '/proc/self/mountinfo': '1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup rw', + '/sys/fs/cgroup/tenant/app/cpu.max': '200000 100000', + '/sys/fs/cgroup/tenant/cpu.max': '50000 100000', + '/sys/fs/cgroup/cpu.max': 'max 100000', + '/sys/fs/cgroup/tenant/app/memory.max': 'max', + '/sys/fs/cgroup/tenant/memory.max': '536870912', + }) + result = cgroup_limits(read) + assert result['cpu_quota_cores'] == 0.5 + assert result['memory_limit_bytes'] == 512*1024**2 + + +def test_v1_controller_mount_and_unlimited_sentinel(): + read = fixture_reader({ + '/proc/self/cgroup': '2:cpu,cpuacct:/app\n3:memory:/app', + '/proc/self/mountinfo': '1 0 0:1 / /cg/cpu rw - cgroup cgroup rw,cpu,cpuacct\n2 0 0:2 / /cg/memory rw - cgroup cgroup rw,memory', + '/cg/cpu/app/cpu.cfs_quota_us': '25000', + '/cg/cpu/app/cpu.cfs_period_us': '100000', + '/cg/memory/app/memory.limit_in_bytes': '9223372036854771712', + }) + result = cgroup_limits(read) + assert result['cpu_quota_cores'] == 0.25 + assert result['memory_limit_bytes'] is None + + +def test_subtree_mount_maps_membership_without_escaping_visible_root(): + read = fixture_reader({ + '/proc/self/cgroup': '0::/tenant/app', + '/proc/self/mountinfo': '1 0 0:1 /tenant /sys/fs/cgroup rw - cgroup2 cgroup rw', + '/sys/fs/cgroup/app/cpu.max': '100000 100000', + '/sys/fs/cgroup/cpu.max': '50000 100000', + }) + result = cgroup_limits(read) + assert result['cpu_quota_cores'] == 0.5 + assert all('/tenant/' not in row['source'] for row in result['observations']) + + +def test_missing_or_invalid_cgroup_is_unknown_not_unlimited(): + assert cgroup_limits(fixture_reader({}))['cpu_quota_cores'] is None + assert pressure('some avg10=nan avg60=0 avg300=0 total=0') is None + assert pressure('some avg10=0.25 avg60=0 avg300=0 total=12')['some']['avg10'] == 0.25 + + +def test_steal_uses_delta_and_does_not_double_count_guest(): + before = cpu_ticks('cpu 100 0 0 100 0 0 0 0 90 0') + after = cpu_ticks('cpu 120 0 0 170 0 0 0 10 100 0') + assert steal_delta(before, after) == 10 + assert steal_delta(after, before) is None + assert steal_delta(before, before) is None + + +def test_collect_keeps_numeric_units_and_passive_sampling(): + snapshots = iter(['cpu 100 0 0 100 0 0 0 0', 'cpu 120 0 0 170 0 0 0 10']) + files = {'/proc/meminfo': 'MemTotal: 1024 kB\nMemAvailable: 512 kB\nSwapTotal: 256 kB\nSwapFree: 128 kB'} + times = iter([10.0, 10.2]) + waits = [] + result = collect(read=lambda path: next(snapshots) if path == '/proc/stat' else files.get(path), + system='Linux', cpu_count=lambda: 8, affinity=lambda: {0,1}, + sleep=waits.append, clock=lambda: next(times)) + assert waits == [0.2] + assert result['metrics']['visible_cpu_upper_bound_cores'] == 2 + assert result['metrics']['swap_used_bytes'] == 128*1024 + assert result['metrics']['steal_percent'] == 10 + + +def test_non_linux_does_not_read_proc_or_wait(): + def unexpected(*args): + raise AssertionError('Unsupported system must not probe Linux files') + assert collect(read=unexpected, sleep=unexpected, system='Windows')['status'] == 'unsupported' + + +def test_container_without_memory_limit_does_not_claim_host_ram(): + files = {'/.dockerenv': '', '/proc/meminfo': 'MemTotal: 67108864 kB'} + result = collect(read=fixture_reader(files), system='Linux', sleep=lambda _: None, + cpu_count=lambda: 64, affinity=lambda: {0,1}) + assert result['allocation']['memory_limit_bytes'] is None + assert result['allocation']['dedicated_cpu'] == 'unknown' + assert result['metrics']['memory_total_bytes'] == 64*1024**3 diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..428a8ea --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,79 @@ +"""Read-only security fixtures; execution deferred at the user's request.""" +import json +import stat +from types import SimpleNamespace + +from proxydoctor.security import collect, inspect_path, update_evidence + + +def metadata(mode,uid=0,modified=3600): + return SimpleNamespace(st_mode=mode,st_uid=uid,st_gid=42,st_mtime=modified) + + +def test_shadow_group_read_is_not_a_finding(): + row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFREG|0o640)) + assert row['status'] == 'observed' + + +def test_world_readable_shadow_and_writable_configuration_require_review(): + row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFREG|0o644)) + assert row['reasons'] == ['SENSITIVE_WORLD_READABLE'] + row = inspect_path('/etc/sudoers',lstat=lambda _:metadata(stat.S_IFREG|0o666,uid=1000)) + assert 'WORLD_WRITABLE' in row['reasons'] + assert 'GROUP_WRITABLE_REVIEW' in row['reasons'] + assert 'OWNER_NOT_ROOT_REVIEW' in row['reasons'] + + +def test_symlinks_are_not_followed_or_called_safe(): + row = inspect_path('/etc/shadow',True,lstat=lambda _:metadata(stat.S_IFLNK|0o777)) + assert row['status'] == 'unknown' + assert row['error_code'] == 'SYMLINK_REQUIRES_REVIEW' + + +def test_permission_error_does_not_expose_exception_text(): + def denied(_): + raise PermissionError('secret-path-token') + row = inspect_path('/etc/shadow',True,lstat=denied) + assert row['error_code'] == 'METADATA_UNAVAILABLE' + assert 'secret-path-token' not in json.dumps(row) + + +def test_missing_cache_does_not_mean_zero_updates(): + def missing(_): + raise FileNotFoundError + row = update_evidence(lstat=missing) + assert row['status'] == 'unknown' + assert row['pending_updates'] is None + + +def test_cache_is_timestamped_observation_not_patch_verdict(): + row = update_evidence(lstat=lambda _:metadata(stat.S_IFREG|0o644),now=lambda:7200, + read=lambda _:'12 updates can be applied immediately.\n3 of these updates are standard security updates.') + assert row['pending_updates'] == 12 + assert row['security_updates'] == 3 + assert row['sources'][0]['age_hours'] == 1 + assert row['status'] == 'cached_observation' + + +def test_localized_summary_stays_unknown_and_raw_text_is_not_retained(): + row = update_evidence(lstat=lambda _:metadata(stat.S_IFREG|0o644),now=lambda:7200, + read=lambda _:'中文缓存 secret-key') + assert row['pending_updates'] is None + assert 'secret-key' not in json.dumps(row) + + +def test_collect_does_not_read_password_file_contents(): + reads = [] + def read(path): + reads.append(path) + assert path == '/var/lib/update-notifier/updates-available' + return None + row = collect(system='Linux',lstat=lambda _:metadata(stat.S_IFREG|0o640),read=read,now=lambda:7200) + assert len(row['files']) == 9 + assert reads == ['/var/lib/update-notifier/updates-available'] + + +def test_non_linux_does_not_inspect_host_files(): + def unexpected(_): + raise AssertionError('Linux paths should not be read') + assert collect(system='Windows',lstat=unexpected,read=unexpected)['status'] == 'unsupported' diff --git a/tests/test_suite.py b/tests/test_suite.py index ee59342..172b5fb 100644 --- a/tests/test_suite.py +++ b/tests/test_suite.py @@ -21,21 +21,22 @@ def unavailable_ip(**kwargs): def test_offline_suite_never_calls_network_or_providers(monkeypatch,capsys): def unexpected(*args,**kwargs): raise AssertionError('unexpected network/provider') monkeypatch.setattr(suite,'measure_bandwidth',unexpected) - monkeypatch.setattr(suite,'run_quality',unexpected) + monkeypatch.setattr('proxydoctor.connectivity.run_console',unexpected) monkeypatch.setattr(suite,'run_media',unexpected) monkeypatch.setattr(suite,'collect_ipquality',unexpected) monkeypatch.setattr(suite,'execute_provider',unexpected) monkeypatch.setattr(suite,'native_trace',unexpected) - assert suite.run_suite(offline=True)==0 + import platform + assert suite.run_suite(offline=True)==(0 if platform.system()=='Linux' else 1) text=capsys.readouterr().out assert all(f'===== {number}.' in text for number in range(1,5)) def test_suite_continues_when_network_and_providers_unavailable(monkeypatch,capsys): monkeypatch.setattr(suite,'measure_bandwidth',unavailable_bandwidth) - monkeypatch.setattr(suite,'run_quality',lambda *args,**kwargs: 1) + monkeypatch.setattr('proxydoctor.connectivity.run_console',lambda *args,**kwargs: 1) monkeypatch.setattr(suite.shutil,'which',lambda *args,**kwargs: None) - monkeypatch.setattr('proxydoctor.media.check',lambda *args: {'state':'unknown','http':403,'evidence':'playback_not_verified'}) + monkeypatch.setattr('proxydoctor.media.check_reachability',lambda *args: {'state':'access_denied','http':403,'evidence':'http_response'}) monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip) monkeypatch.setattr(suite,'native_trace',lambda *args,**kwargs: 1) assert suite.run_suite(lang='en')==1 @@ -44,7 +45,7 @@ def test_suite_continues_when_network_and_providers_unavailable(monkeypatch,caps assert 'Access denied' in output -def test_besttrace_argv_is_not_shell_interpolated(tmp_path,monkeypatch): +def test_besttrace_is_rejected_before_execution(tmp_path,monkeypatch): executable=tmp_path/'best trace' executable.write_text('example',encoding='utf-8') monkeypatch.setattr(suite,'measure_bandwidth',unavailable_bandwidth) @@ -53,8 +54,9 @@ def test_besttrace_argv_is_not_shell_interpolated(tmp_path,monkeypatch): monkeypatch.setattr(suite,'collect_ipquality',unavailable_ip) calls=[] monkeypatch.setattr(suite,'execute_provider',lambda argv,timeout: calls.append(argv) or 0) - suite.run_suite(besttrace=executable) - assert calls==[[str(executable.resolve()),'1.1.1.1']] + with pytest.raises(ValueError,match='disabled'): + suite.run_suite(besttrace=executable) + assert calls==[] @pytest.mark.parametrize('target',[';reboot','--help','not-a-host'])